import fs from 'fs/promises'; import path from 'path'; import { parseFile } from 'music-metadata'; import crypto from 'crypto'; import { run, all } from '../database/db.js'; const AUDIO_EXTENSIONS = ['.mp3', '.m4a', '.flac', '.wav', '.aac', '.ogg']; export class AudioScanner { constructor(libraryPath) { this.libraryPath = libraryPath; } async scanLibrary() { console.log(`🔍 Scanning library: ${this.libraryPath}`); const files = await this.findAudioFiles(this.libraryPath); console.log(`📁 Found ${files.length} audio files`); let processed = 0; for (const filePath of files) { try { await this.processAudioFile(filePath); processed++; if (processed % 10 === 0) { console.log(`⏳ Processed ${processed}/${files.length} files`); } } catch (error) { console.error(`❌ Error processing ${filePath}:`, error.message); } } await this.updateAlbums(); console.log(`✅ Scan complete! Processed ${processed} files`); } async findAudioFiles(dir, fileList = []) { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { await this.findAudioFiles(fullPath, fileList); } else if (entry.isFile()) { const ext = path.extname(entry.name).toLowerCase(); if (AUDIO_EXTENSIONS.includes(ext)) { fileList.push(fullPath); } } } return fileList; } async processAudioFile(filePath) { const metadata = await parseFile(filePath); const stats = await fs.stat(filePath); const relativePath = path.relative(this.libraryPath, filePath); const folderPath = path.dirname(relativePath); const id = this.generateId(filePath); const isSingle = this.detectSingle(metadata, folderPath); const track = { id, title: metadata.common.title || path.basename(filePath, path.extname(filePath)), artist: metadata.common.artist || 'Unknown Artist', album: metadata.common.album || (isSingle ? 'Singles' : 'Unknown Album'), album_artist: metadata.common.albumartist || metadata.common.artist, track_number: metadata.common.track?.no || null, disc_number: metadata.common.disk?.no || null, year: metadata.common.year || null, genre: metadata.common.genre?.[0] || null, duration: metadata.format.duration || 0, file_path: relativePath, file_size: stats.size, format: metadata.format.container || path.extname(filePath).slice(1), bitrate: metadata.format.bitrate || null, sample_rate: metadata.format.sampleRate || null, is_single: isSingle ? 1 : 0, folder_path: folderPath, artwork_path: null }; await this.saveTrack(track); // Extract and save artwork if available if (metadata.common.picture && metadata.common.picture.length > 0) { await this.saveArtwork(id, metadata.common.picture[0]); } } detectSingle(metadata, folderPath) { // Check if folder name contains "singles" or "single" if (/singles?/i.test(folderPath)) { return true; } // If no album tag, likely a single if (!metadata.common.album) { return true; } // Could add more heuristics here return false; } async saveTrack(track) { await run(` INSERT OR REPLACE INTO tracks ( id, title, artist, album, album_artist, track_number, disc_number, year, genre, duration, file_path, file_size, format, bitrate, sample_rate, is_single, folder_path, artwork_path, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `, [ track.id, track.title, track.artist, track.album, track.album_artist, track.track_number, track.disc_number, track.year, track.genre, track.duration, track.file_path, track.file_size, track.format, track.bitrate, track.sample_rate, track.is_single, track.folder_path, track.artwork_path ]); } async saveArtwork(trackId, picture) { const artworkDir = path.join(process.cwd(), 'artwork'); await fs.mkdir(artworkDir, { recursive: true }); const ext = picture.format.split('/')[1] || 'jpg'; const filename = `${trackId}.${ext}`; const artworkPath = path.join(artworkDir, filename); await fs.writeFile(artworkPath, picture.data); await run('UPDATE tracks SET artwork_path = ? WHERE id = ?', [filename, trackId]); } async updateAlbums() { // Group tracks by album and create album entries const albums = await all(` SELECT album, album_artist, year, genre, COUNT(*) as track_count, MAX(artwork_path) as artwork_path FROM tracks WHERE is_single = 0 GROUP BY album, album_artist `); for (const album of albums) { const albumId = this.generateId(`${album.album}-${album.album_artist}`); await run(` INSERT OR REPLACE INTO albums ( id, name, artist, year, genre, track_count, artwork_path ) VALUES (?, ?, ?, ?, ?, ?, ?) `, [ albumId, album.album, album.album_artist, album.year, album.genre, album.track_count, album.artwork_path ]); } } generateId(input) { return crypto.createHash('md5').update(input).digest('hex'); } }