175 lines
5.4 KiB
JavaScript
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');
|
|
}
|
|
}
|