/** * File Hash Utility * Documentation: documentation/fixes/file-hash-matching.md * * Generates deterministic hashes of audio file collections for accurate library matching. * Used to match RMAB-organized audiobooks with Audiobookshelf library items. */ import crypto from 'crypto'; import path from 'path'; import { AUDIO_EXTENSIONS } from '../constants/audio-formats'; /** * Generates a SHA256 hash of audio filenames for library matching. * * Process: * 1. Extract basenames from file paths * 2. Filter to supported audio extensions * 3. Normalize to lowercase * 4. Sort alphabetically * 5. Generate SHA256 hash * * @param filePaths - Array of absolute or relative file paths * @returns 64-character hex string (SHA256 hash) or empty string if no audio files * * @example * ```typescript * const hash = generateFilesHash([ * '/path/to/Chapter 01.mp3', * '/path/to/Chapter 02.mp3', * '/path/to/cover.jpg' // Filtered out (not audio) * ]); * // Returns: "abc123def456..." (64 chars) * ``` */ export function generateFilesHash(filePaths: string[]): string { if (!filePaths || filePaths.length === 0) { return ''; } // Extract basenames and filter to audio files only const audioBasenames = filePaths .map((filePath) => { // Normalize path separators to forward slashes for cross-platform consistency const normalizedPath = filePath.replace(/\\/g, '/'); return path.posix.basename(normalizedPath); }) .filter((basename) => { const ext = path.extname(basename).toLowerCase(); return (AUDIO_EXTENSIONS as readonly string[]).includes(ext); }) .map((basename) => basename.toLowerCase()) // Normalize case .sort(); // Sort alphabetically for deterministic hash // No audio files found if (audioBasenames.length === 0) { return ''; } // Generate SHA256 hash const hash = crypto .createHash('sha256') .update(JSON.stringify(audioBasenames)) .digest('hex'); return hash; } /** * Validates if a hash string is a valid SHA256 hash */ export function isValidHash(hash: string): boolean { return /^[a-f0-9]{64}$/i.test(hash); }