From bc09479dfbfcbf104bf41b4f376c9dc078dd98a9 Mon Sep 17 00:00:00 2001 From: peterino2 Date: Sun, 31 May 2026 18:36:14 -0700 Subject: [PATCH] scanning --- android/MusicRoom | 1 + android/dev-setup.bat | 143 ++++++++++++++++++++++++++ android/run.bat | 43 ++++++++ channel.ts | 2 + config.ts | 14 +++ db.ts | 2 + init.ts | 24 ++++- library.ts | 233 ++++++++++++++++++++++++++++++++++++++++-- routes/tracks.ts | 2 + 9 files changed, 448 insertions(+), 16 deletions(-) create mode 160000 android/MusicRoom create mode 100644 android/dev-setup.bat create mode 100644 android/run.bat diff --git a/android/MusicRoom b/android/MusicRoom new file mode 160000 index 0000000..fd6fc4d --- /dev/null +++ b/android/MusicRoom @@ -0,0 +1 @@ +Subproject commit fd6fc4d757b2f8ad4ff5e6715d6a4a9a560a35a3 diff --git a/android/dev-setup.bat b/android/dev-setup.bat new file mode 100644 index 0000000..7e1db3b --- /dev/null +++ b/android/dev-setup.bat @@ -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 diff --git a/android/run.bat b/android/run.bat new file mode 100644 index 0000000..4c8995f --- /dev/null +++ b/android/run.bat @@ -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 diff --git a/channel.ts b/channel.ts index 5de40a9..04493f6 100644 --- a/channel.ts +++ b/channel.ts @@ -5,6 +5,8 @@ export interface Track { filename: string; // Original filename title: string; // Display title duration: number; + replayGainDb?: number | null; + replayPeak?: number | null; } export type PlaybackMode = "once" | "repeat-all" | "repeat-one" | "shuffle"; diff --git a/config.ts b/config.ts index 793e91c..9868e37 100644 --- a/config.ts +++ b/config.ts @@ -13,12 +13,20 @@ export interface YtdlpConfig { updateCheckInterval: number; } +export interface ReplayGainConfig { + enabled: boolean; + command: string; + truePeak: boolean; + timeoutMs: number; +} + export interface Config { port: number; musicDir: string; allowGuests: boolean; defaultPermissions: string[]; ytdlp?: YtdlpConfig; + replayGain?: ReplayGainConfig; } const CONFIG_PATH = join(import.meta.dir, "config.json"); @@ -38,6 +46,12 @@ export const DEFAULT_CONFIG: Config = { allowPlaylists: true, autoUpdate: true, updateCheckInterval: 86400 + }, + replayGain: { + enabled: true, + command: "rsgain", + truePeak: false, + timeoutMs: 120000 } }; diff --git a/db.ts b/db.ts index 3bb51d6..2634d55 100644 --- a/db.ts +++ b/db.ts @@ -89,6 +89,8 @@ export interface Track { album: string | null; duration: number; size: number; + replayGainDb: number | null; + replayPeak: number | null; created_at: number; } diff --git a/init.ts b/init.ts index 0e5fe72..81c0f66 100644 --- a/init.ts +++ b/init.ts @@ -63,6 +63,8 @@ export function buildTracksFromIds(trackIds: string[], lib: Library): Track[] { filename: libTrack.filename, title: libTrack.title || libTrack.filename.replace(/\.[^.]+$/, ""), duration: libTrack.duration, + replayGainDb: libTrack.replayGainDb, + replayPeak: libTrack.replayPeak, }); } } @@ -78,6 +80,8 @@ export function getAllLibraryTracks(lib: Library): Track[] { filename: t.filename, title: t.title || t.filename.replace(/\.[^.]+$/, ""), duration: t.duration, + replayGainDb: t.replayGainDb, + replayPeak: t.replayPeak, })); } @@ -110,7 +114,7 @@ export async function init(): Promise { }); // Initialize library - const library = new Library(MUSIC_DIR); + const library = new Library(MUSIC_DIR, "./library_cache.db", config.replayGain ?? DEFAULT_CONFIG.replayGain); setLibrary(library); // Track pending playlist additions (title -> {playlistId, playlistName, userId}) @@ -155,7 +159,7 @@ export async function init(): Promise { } // 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; const trackTitle = normalizeForMatch(track.title || ""); @@ -351,11 +355,19 @@ export async function init(): Promise { const allTracks = library.getAllTracks().map(t => ({ id: t.id, title: t.title, - duration: t.duration + duration: t.duration, + replayGainDb: t.replayGainDb, + replayPeak: t.replayPeak, })); broadcastToAll({ 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 }); }); @@ -366,7 +378,9 @@ export async function init(): Promise { const allTracks = library.getAllTracks().map(t => ({ id: t.id, title: t.title, - duration: t.duration + duration: t.duration, + replayGainDb: t.replayGainDb, + replayPeak: t.replayPeak, })); broadcastToAll({ type: "track_removed", diff --git a/library.ts b/library.ts index 8be8e32..f3a0618 100644 --- a/library.ts +++ b/library.ts @@ -1,4 +1,5 @@ import { Database } from "bun:sqlite"; +import { spawn } from "child_process"; import { createHash } from "crypto"; import { watch, type FSWatcher } from "fs"; import { readdir, stat } from "fs/promises"; @@ -8,6 +9,24 @@ import { type Track } from "./db"; const HASH_CHUNK_SIZE = 64 * 1024; // 64KB 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 { filename: string; @@ -33,7 +52,9 @@ export class Library { private trackInfo = new Map(); // trackId -> full info private watcher: FSWatcher | null = null; private eventListeners = new Map>(); - private pendingFiles = new Map(); // filepath -> debounce timer + private pendingFiles = new Map>(); // filepath -> debounce timer + private replayGainConfig: Required; + private replayGainAvailable: boolean | null = null; // Scan progress tracking private _scanProgress = { scanning: false, processed: 0, total: 0 }; @@ -43,9 +64,19 @@ export class Library { 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.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.initCacheDb(); } @@ -71,9 +102,13 @@ export class Library { album TEXT, duration REAL NOT NULL, size INTEGER NOT NULL, + replay_gain_db REAL, + replay_peak REAL, created_at INTEGER DEFAULT (unixepoch()) ) `); + this.addColumnIfMissing("tracks", "replay_gain_db", "REAL"); + this.addColumnIfMissing("tracks", "replay_peak", "REAL"); // Library activity log 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)`); } + + 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 { 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; } - 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(` - INSERT INTO tracks (id, title, artist, album, duration, size) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO tracks (id, title, artist, album, duration, size, replay_gain_db, replay_peak) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET title = COALESCE(excluded.title, title), artist = COALESCE(excluded.artist, artist), - album = COALESCE(excluded.album, album) - `).run(track.id, track.title, track.artist, track.album, track.duration, track.size); + album = COALESCE(excluded.album, album), + 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 { @@ -157,6 +218,124 @@ export class Library { 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 { + 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 { + 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; + + 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 { const relativePath = relative(this.musicDir, filePath); const filename = filePath.substring(filePath.lastIndexOf("/") + 1).replace(/\\/g, "/").split("/").pop() || filePath; @@ -194,6 +373,13 @@ export class Library { // 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 = { id: trackId, filename, @@ -203,6 +389,8 @@ export class Library { album, duration, size, + replayGainDb, + replayPeak, available: true, created_at: Math.floor(Date.now() / 1000), }; @@ -215,6 +403,8 @@ export class Library { album, duration, size, + replayGainDb, + replayPeak, }); return track; @@ -229,7 +419,17 @@ export class Library { // Single query joining file_cache and tracks - all in cacheDb now 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 LEFT JOIN tracks t ON fc.track_id = t.id `).all() as Array<{ @@ -239,6 +439,10 @@ export class Library { artist: string | null; album: string | 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) { @@ -253,7 +457,11 @@ export class Library { artist: row.artist || null, album: row.album || null, duration: row.duration || 0, + size: row.size || 0, + replayGainDb: row.replay_gain_db, + replayPeak: row.replay_peak, available: true, + created_at: row.created_at || 0, }; this.trackMap.set(track.id, fullPath); @@ -312,9 +520,12 @@ export class Library { try { const fileStat = await stat(fullPath); if (cacheEntry.size === fileStat.size && cacheEntry.mtime_ms === Math.floor(fileStat.mtimeMs)) { - skipped++; - this._scanProgress.processed++; - continue; + const cachedTrack = this.trackInfo.get(cacheEntry.track_id); + if (!this.shouldAttemptReplayGain() || !this.needsReplayGain(cachedTrack)) { + skipped++; + this._scanProgress.processed++; + continue; + } } } catch {} } diff --git a/routes/tracks.ts b/routes/tracks.ts index 6e3e878..1431fe0 100644 --- a/routes/tracks.ts +++ b/routes/tracks.ts @@ -17,6 +17,8 @@ export function handleGetLibrary(req: Request, server: any): Response { artist: t.artist, album: t.album, duration: t.duration, + replayGainDb: t.replayGainDb, + replayPeak: t.replayPeak, available: t.available, })); return Response.json(tracks, { headers });