From 13ef60b7bd434ad1a163e82021abf16c498e5dd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B2=E6=82=A6?= Date: Wed, 7 Jan 2026 12:26:30 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=E6=94=B9=E8=BF=9B=EF=BC=88?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=EF=BC=89=EF=BC=9A=E6=96=B0=E5=A2=9E=E5=85=83?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=B5=8C=E5=85=A5=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=8C=B9=E9=85=8D=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在Docker容器中安装ffmpeg以支持媒体处理 - 为下载文件嵌入标题、艺术家、专辑及封面图等元数据 - 重构文件存在性检测机制,兼容多种命名格式 - 下载过程中采用临时文件确保数据完整性 (注:根据中文技术文档惯例进行了以下优化: 1. 使用"功能改进"替代直译"feat",更符合国内开发文档表述 2. "metadata embedding"译为"元数据嵌入"是行业标准译法 3. "refactor"译为"重构"准确传达代码改造含义 4. 采用四字短语"确保数据完整性"保持技术文档的简洁性 5. 使用中文括号和冒号格式,符合国内技术文档排版规范) --- sync-server/Dockerfile | 3 ++ sync-server/server.js | 116 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 111 insertions(+), 8 deletions(-) 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