diff --git a/sync-server/Dockerfile b/sync-server/Dockerfile index 1f6dfc8..ea9198b 100644 --- a/sync-server/Dockerfile +++ b/sync-server/Dockerfile @@ -1,5 +1,8 @@ FROM node:18-alpine +# Install ffmpeg +RUN apk add --no-cache ffmpeg + WORKDIR /app COPY server.js . diff --git a/sync-server/server.js b/sync-server/server.js index 606c0f5..7a3dc11 100644 --- a/sync-server/server.js +++ b/sync-server/server.js @@ -3,6 +3,7 @@ const https = require('https'); const fs = require('fs'); const path = require('path'); const url = require('url'); +const { spawn } = require('child_process'); const DATA_DIR = process.env.DATA_DIR || './data'; const MUSIC_DIR = process.env.MUSIC_DIR || './music'; @@ -136,19 +137,24 @@ function processSong(song) { // Check if file exists (fuzzy match for extension) try { const files = fs.readdirSync(MUSIC_DIR); - // Check for standard format OR manual rename (Song Name.ext) const exists = files.some(f => { - // 1. Standard format: Artist - Name [source_id].ext + // 1. Exact Match: Artist - Name [source_id].ext if (f.startsWith(baseName)) return true; - // 2. Manual rename: Name.ext (ignoring extension) - // We check if the file starts with the song name followed by a dot + // 2. Simple Format: Artist - Name.ext + if (f.startsWith(`${safeArtist} - ${safeName}.`)) return true; + + // 3. Simple Format w/o Artist: Name.ext if (f.startsWith(`${safeName}.`)) return true; + // 4. Match prefix ignoring ID: Artist - Name [ + if (f.startsWith(`${safeArtist} - ${safeName} [`)) return true; + return false; }); if (exists) { + // console.log(`[Sync] Skipped (Exists): ${song.name}`); resolve(); return; } @@ -167,14 +173,47 @@ function processSong(song) { else if (url.includes('.ogg')) ext = 'ogg'; else if (url.includes('.wav')) ext = 'wav'; - const dest = path.join(MUSIC_DIR, `${baseName}.${ext}`); - downloadFile(url, dest) - .then(() => { - console.log(`[Sync] Downloaded: ${baseName}.${ext}`); + const tempFile = path.join(MUSIC_DIR, `temp_${Date.now()}_${song.id}.${ext}`); + const finalFile = path.join(MUSIC_DIR, `${baseName}.${ext}`); + + downloadFile(url, tempFile) + .then(async () => { + try { + // Try to download cover image + let coverPath = null; + try { + const coverUrl = `https://music-dl.sayqz.com/api/?source=${source}&id=${song.id}&type=pic`; + coverPath = path.join(MUSIC_DIR, `temp_cover_${Date.now()}_${song.id}.jpg`); + await downloadFile(coverUrl, coverPath); + } catch (e) { + console.warn(`[Sync] Failed to download cover for ${song.name}:`, e.message); + } + + // Write Metadata (Title, Artist, Album, Cover) + await writeMetadata(tempFile, finalFile, { + title: song.name, + artist: song.artist, + album: song.album || song.name, // Fallback to song name if album is missing + cover: coverPath + }); + + console.log(`[Sync] Downloaded & Tagged: ${baseName}.${ext}`); + + // Cleanup temp files + if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile); + if (coverPath && fs.existsSync(coverPath)) fs.unlinkSync(coverPath); + } catch (err) { + console.error(`[Sync] Metadata Error for ${song.name}:`, err.message); + // Fallback: Just rename temp to final + if (!fs.existsSync(finalFile)) { + fs.renameSync(tempFile, finalFile); + } + } resolve(); }) .catch(err => { console.error(`[Sync] Download failed for ${song.name}:`, err.message); + if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile); resolve(); }); }; @@ -246,4 +285,65 @@ function downloadFile(url, dest) { reject(err); }); }); +} + +function writeMetadata(inputPath, outputPath, metadata) { + return new Promise((resolve, reject) => { + const args = ['-i', inputPath]; + + // Add cover input if available + if (metadata.cover) { + args.push('-i', metadata.cover); + args.push('-map', '0:0'); // Map audio from first input + args.push('-map', '1:0'); // Map image from second input + args.push('-c:v', 'mjpeg'); // Convert cover to jpeg + + // ID3v2 metadata for MP3 (cover art) + if (outputPath.endsWith('.mp3')) { + args.push('-id3v2_version', '3'); + args.push('-metadata:s:v', 'title="Album cover"'); + args.push('-metadata:s:v', 'comment="Cover (front)"'); + } else if (outputPath.endsWith('.flac')) { + args.push('-disposition:v', 'attached_pic'); + } + } else { + args.push('-c', 'copy'); + } + + // Add metadata tags + args.push( + '-metadata', `title=${metadata.title}`, + '-metadata', `artist=${metadata.artist}`, + '-metadata', `album=${metadata.album}` + ); + + // Required for FLAC + Cover to work properly without re-encoding audio + if (outputPath.endsWith('.flac') && metadata.cover) { + args.push('-c:a', 'copy'); + } else if (outputPath.endsWith('.mp3') && metadata.cover) { + args.push('-c:a', 'copy'); + } + + args.push('-y', outputPath); + + const ffmpeg = spawn('ffmpeg', args); + + // Capture stderr for debugging + let stderr = ''; + ffmpeg.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + ffmpeg.on('close', (code) => { + if (code === 0) resolve(); + else { + console.error(`FFmpeg Error Output: ${stderr}`); + reject(new Error(`FFmpeg exited with code ${code}`)); + } + }); + + ffmpeg.on('error', (err) => { + reject(err); + }); + }); } \ No newline at end of file