Files
AudioApp/src/services/audioScanner.js

175 lines
5.4 KiB
JavaScript

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');
}
}