scanning
This commit is contained in:
parent
c58e30b30d
commit
bc09479dfb
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit fd6fc4d757b2f8ad4ff5e6715d6a4a9a560a35a3
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
14
config.ts
14
config.ts
|
|
@ -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
2
db.ts
|
|
@ -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
24
init.ts
|
|
@ -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",
|
||||||
|
|
|
||||||
233
library.ts
233
library.ts
|
|
@ -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(`
|
||||||
|
|
@ -91,6 +126,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(`
|
||||||
INSERT INTO library_log (action, track_id, filename, title, user_id, username)
|
INSERT INTO library_log (action, track_id, filename, title, user_id, username)
|
||||||
|
|
@ -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 {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue