功能改进(同步):新增元数据嵌入并优化匹配逻辑
- 在Docker容器中安装ffmpeg以支持媒体处理 - 为下载文件嵌入标题、艺术家、专辑及封面图等元数据 - 重构文件存在性检测机制,兼容多种命名格式 - 下载过程中采用临时文件确保数据完整性 (注:根据中文技术文档惯例进行了以下优化: 1. 使用"功能改进"替代直译"feat",更符合国内开发文档表述 2. "metadata embedding"译为"元数据嵌入"是行业标准译法 3. "refactor"译为"重构"准确传达代码改造含义 4. 采用四字短语"确保数据完整性"保持技术文档的简洁性 5. 使用中文括号和冒号格式,符合国内技术文档排版规范)
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
# Install ffmpeg
|
||||
RUN apk add --no-cache ffmpeg
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user