This commit is contained in:
吴璨
2026-08-04 09:40:15 +08:00
parent 536685e327
commit 839ec8eb2e
8 changed files with 68 additions and 31 deletions
+9 -9
View File
@@ -121,7 +121,7 @@ async function main() {
const track = album.tracks[i]; const track = album.tracks[i];
const target = getAlbumDownloadTarget(album, track, i); const target = getAlbumDownloadTarget(album, track, i);
await waitIfPaused(); await waitIfPaused();
const artists = track.ar?.map(a => a.name).join('/') || album.artist?.name || '未知'; const artists = track.ar?.map(a => a.name).join(' / ') || album.artist?.name || '未知';
console.log(`[${i + 1}/${album.tracks.length}] ${track.name} - ${artists}`); console.log(`[${i + 1}/${album.tracks.length}] ${track.name} - ${artists}`);
try { try {
await downloadSong(track.id, level, { await downloadSong(track.id, level, {
@@ -144,7 +144,7 @@ async function main() {
for (let i = 0; i < pl.tracks.length; i++) { for (let i = 0; i < pl.tracks.length; i++) {
const t = pl.tracks[i]; const t = pl.tracks[i];
await waitIfPaused(); await waitIfPaused();
console.log(`[${i + 1}/${pl.tracks.length}] ${t.name} - ${t.ar?.map(a => a.name).join('/')}`); console.log(`[${i + 1}/${pl.tracks.length}] ${t.name} - ${t.ar?.map(a => a.name).join(' / ')}`);
try { await downloadSong(t.id, level, { downloadLyric: batchLyric, embedCoverArt: batchCover, maxRetries }); } try { await downloadSong(t.id, level, { downloadLyric: batchLyric, embedCoverArt: batchCover, maxRetries }); }
catch (e) { console.error(` ❌ 出错: ${e.message}`); } catch (e) { console.error(` ❌ 出错: ${e.message}`); }
if (i < pl.tracks.length - 1) await sleep(500); if (i < pl.tracks.length - 1) await sleep(500);
@@ -242,7 +242,7 @@ async function main() {
for (let i = 0; i < pl.tracks.length; i++) { for (let i = 0; i < pl.tracks.length; i++) {
const t = pl.tracks[i]; const t = pl.tracks[i];
await waitIfPaused(); await waitIfPaused();
console.log(`\n[${i + 1}/${pl.tracks.length}] ${t.name} - ${t.ar?.map(a => a.name).join('/')}`); console.log(`\n[${i + 1}/${pl.tracks.length}] ${t.name} - ${t.ar?.map(a => a.name).join(' / ')}`);
try { await downloadSong(t.id, level); } try { await downloadSong(t.id, level); }
catch (e) { console.error(` ❌ 出错: ${e.message}`); } catch (e) { console.error(` ❌ 出错: ${e.message}`); }
if (i < pl.tracks.length - 1) await sleep(500); if (i < pl.tracks.length - 1) await sleep(500);
@@ -268,7 +268,7 @@ async function main() {
pl.tracks.forEach((t, i) => { pl.tracks.forEach((t, i) => {
const num = String(i + 1).padStart(3); const num = String(i + 1).padStart(3);
const name = (t.name || '').slice(0, 22).padEnd(24); const name = (t.name || '').slice(0, 22).padEnd(24);
const artist = (t.ar?.map(a => a.name).join('/') || '').slice(0, 18).padEnd(20); const artist = (t.ar?.map(a => a.name).join(' / ') || '').slice(0, 18).padEnd(20);
const album = (t.al?.name || '').slice(0, 18); const album = (t.al?.name || '').slice(0, 18);
console.log(` ${num} ${name} ${artist} ${album}`); console.log(` ${num} ${name} ${artist} ${album}`);
}); });
@@ -309,7 +309,7 @@ async function main() {
console.log(`\n📥 开始下载 ${tracksToDownload.length} 首歌曲...`); console.log(`\n📥 开始下载 ${tracksToDownload.length} 首歌曲...`);
for (let i = 0; i < tracksToDownload.length; i++) { for (let i = 0; i < tracksToDownload.length; i++) {
const t = tracksToDownload[i]; const t = tracksToDownload[i];
console.log(`\n[${i + 1}/${tracksToDownload.length}] ${t.name} - ${t.ar?.map(a => a.name).join('/')}`); console.log(`\n[${i + 1}/${tracksToDownload.length}] ${t.name} - ${t.ar?.map(a => a.name).join(' / ')}`);
try { await downloadSong(t.id, level); } try { await downloadSong(t.id, level); }
catch (e) { console.error(` ❌ 出错: ${e.message}`); } catch (e) { console.error(` ❌ 出错: ${e.message}`); }
if (i < tracksToDownload.length - 1) await sleep(500); if (i < tracksToDownload.length - 1) await sleep(500);
@@ -958,7 +958,7 @@ async function main() {
console.log(`\n💿 ${album.name} - ${album.artist?.name || '未知歌手'}`); console.log(`\n💿 ${album.name} - ${album.artist?.name || '未知歌手'}`);
console.log(`${album.tracks.length} 首歌曲`); console.log(`${album.tracks.length} 首歌曲`);
album.tracks.forEach((track, i) => { album.tracks.forEach((track, i) => {
const artists = track.ar?.map(a => a.name).join('/') || album.artist?.name || '未知'; const artists = track.ar?.map(a => a.name).join(' / ') || album.artist?.name || '未知';
console.log(` ${String(i + 1).padStart(2)}. ${track.name} - ${artists}`); console.log(` ${String(i + 1).padStart(2)}. ${track.name} - ${artists}`);
}); });
@@ -971,7 +971,7 @@ async function main() {
for (let i = 0; i < album.tracks.length; i++) { for (let i = 0; i < album.tracks.length; i++) {
const track = album.tracks[i]; const track = album.tracks[i];
const artists = track.ar?.map(a => a.name).join('/') || album.artist?.name || '未知'; const artists = track.ar?.map(a => a.name).join(' / ') || album.artist?.name || '未知';
const target = getAlbumDownloadTarget(album, track, i); const target = getAlbumDownloadTarget(album, track, i);
await waitIfPaused(); await waitIfPaused();
console.log(`\n[${i + 1}/${album.tracks.length}] ${track.name} - ${artists}`); console.log(`\n[${i + 1}/${album.tracks.length}] ${track.name} - ${artists}`);
@@ -1459,7 +1459,7 @@ async function main() {
// 生成 M3U 内容 // 生成 M3U 内容
const m3uLines = ['#EXTM3U', '']; const m3uLines = ['#EXTM3U', ''];
for (const t of pl.tracks) { for (const t of pl.tracks) {
const artist = t.ar?.map(a => a.name).join('/') || '未知'; const artist = t.ar?.map(a => a.name).join(' / ') || '未知';
const title = t.name || '未知'; const title = t.name || '未知';
const duration = Math.round((t.dt || 0) / 1000) || -1; const duration = Math.round((t.dt || 0) / 1000) || -1;
m3uLines.push(`#EXTINF:${duration},${artist} - ${title}`); m3uLines.push(`#EXTINF:${duration},${artist} - ${title}`);
@@ -1648,7 +1648,7 @@ async function main() {
for (let i = 0; i < pl.tracks.length; i++) { for (let i = 0; i < pl.tracks.length; i++) {
const t = pl.tracks[i]; const t = pl.tracks[i];
const artist = t.ar?.map(a => a.name).join('/') || '未知'; const artist = t.ar?.map(a => a.name).join(' / ') || '未知';
const title = t.name || '未知'; const title = t.name || '未知';
console.log(`[${i + 1}/${pl.tracks.length}] ${title} - ${artist}`); console.log(`[${i + 1}/${pl.tracks.length}] ${title} - ${artist}`);
+11
View File
@@ -1,4 +1,15 @@
[ [
{
"id": 1431373779,
"name": "想要你在身边",
"artist": "音阙诗听/王梓钰/锦零",
"album": "听",
"level": "lossless",
"bitrate": 1411000,
"size": 27191185,
"file": "音阙诗听_王梓钰_锦零 - 想要你在身边.flac",
"timestamp": "2026-08-03T03:19:47.520Z"
},
{ {
"id": "3342981041", "id": "3342981041",
"name": "铁花飞", "name": "铁花飞",
+5 -1
View File
@@ -1,5 +1,6 @@
const { API } = require('./config'); const { API } = require('./config');
const { fetchJSON } = require('./network'); const { fetchJSON } = require('./network');
const { formatPersonNames } = require('./utils');
async function searchSongs(keyword, limit = 10) { async function searchSongs(keyword, limit = 10) {
const res = await fetchJSON(`${API.search}?keyword=${encodeURIComponent(keyword)}&limit=${limit}`); const res = await fetchJSON(`${API.search}?keyword=${encodeURIComponent(keyword)}&limit=${limit}`);
@@ -9,7 +10,10 @@ async function searchSongs(keyword, limit = 10) {
} }
const songs = Array.isArray(res.data) ? res.data : (res.data?.songs || []); const songs = Array.isArray(res.data) ? res.data : (res.data?.songs || []);
if (!songs.length) console.log('❌ 搜索无结果'); if (!songs.length) console.log('❌ 搜索无结果');
return songs; return songs.map(song => ({
...song,
artists: formatPersonNames(song.artists),
}));
} }
async function interactiveSearch(rl, ask) { async function interactiveSearch(rl, ask) {
+9 -4
View File
@@ -6,7 +6,7 @@ const { fetchLyric, mergeLrc, parseCredits } = require('./lyrics');
const { fetchNeteaseAlbumDetail, fetchNeteaseSongDetail } = require('./metadata'); const { fetchNeteaseAlbumDetail, fetchNeteaseSongDetail } = require('./metadata');
const { downloadFile, fetchJSON } = require('./network'); const { downloadFile, fetchJSON } = require('./network');
const { embedCover } = require('./tagging'); const { embedCover } = require('./tagging');
const { sanitize, sleep } = require('./utils'); const { sanitize, formatPersonNames, sleep } = require('./utils');
async function downloadSong(songId, level, { async function downloadSong(songId, level, {
downloadLyric = true, downloadLyric = true,
@@ -42,7 +42,8 @@ async function downloadSongOnce(songId, level, {
const result = await fetchJSON(`${API.music}?id=${songId}&level=${level}`); const result = await fetchJSON(`${API.music}?id=${songId}&level=${level}`);
if (result.code !== 200 || !result.data?.url) throw new Error(result.msg || 'API 返回错误'); if (result.code !== 200 || !result.data?.url) throw new Error(result.msg || 'API 返回错误');
const { name, artist, album, url: audioUrl, br, size, level: actualLevel, picUrl } = result.data; const { name, artist: rawArtist, album, url: audioUrl, br, size, level: actualLevel, picUrl } = result.data;
const artist = formatPersonNames(rawArtist);
const ext = audioUrl.match(/\.(mp3|flac|m4a|wav|aac)(\?|$)/i)?.[1] || 'mp3'; const ext = audioUrl.match(/\.(mp3|flac|m4a|wav|aac)(\?|$)/i)?.[1] || 'mp3';
const baseName = sanitize(outputBaseName || `${artist} - ${name}`); const baseName = sanitize(outputBaseName || `${artist} - ${name}`);
const fileName = `${baseName}.${ext}`; const fileName = `${baseName}.${ext}`;
@@ -105,10 +106,10 @@ async function downloadSongOnce(songId, level, {
if (detail) { if (detail) {
if (detail.trackNo) tagMeta.trackNo = detail.trackNo; if (detail.trackNo) tagMeta.trackNo = detail.trackNo;
if (detail.disc) tagMeta.disc = detail.disc; if (detail.disc) tagMeta.disc = detail.disc;
const albumArtist = detail.artists?.map(item => item.name).join(' / ');
if (albumArtist) tagMeta.albumArtist = albumArtist;
if (detail.album?.id) { if (detail.album?.id) {
const albumDetail = await fetchNeteaseAlbumDetail(detail.album.id); const albumDetail = await fetchNeteaseAlbumDetail(detail.album.id);
if (albumDetail?.albumArtist) tagMeta.albumArtist = albumDetail.albumArtist;
if (albumDetail?.isCompilation) tagMeta.compilation = true;
if (albumDetail?.publishTime) { if (albumDetail?.publishTime) {
const date = new Date(albumDetail.publishTime); const date = new Date(albumDetail.publishTime);
tagMeta.year = date.getFullYear(); tagMeta.year = date.getFullYear();
@@ -118,6 +119,10 @@ async function downloadSongOnce(songId, level, {
if (albumDetail?.company) tagMeta.publisher = albumDetail.company; if (albumDetail?.company) tagMeta.publisher = albumDetail.company;
if (tagMeta.trackNo && albumDetail?.size) tagMeta.trackNo = `${tagMeta.trackNo}/${albumDetail.size}`; if (tagMeta.trackNo && albumDetail?.size) tagMeta.trackNo = `${tagMeta.trackNo}/${albumDetail.size}`;
} }
if (!tagMeta.albumArtist) {
const fallbackAlbumArtist = detail.artists?.map(item => item.name).join(' / ');
if (fallbackAlbumArtist) tagMeta.albumArtist = fallbackAlbumArtist;
}
} }
} catch {} } catch {}
await embedCover(dest, picUrl, tagMeta); await embedCover(dest, picUrl, tagMeta);
+2 -1
View File
@@ -2,6 +2,7 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const { API, DOWNLOAD_DIR } = require('./config'); const { API, DOWNLOAD_DIR } = require('./config');
const { fetchJSON } = require('./network'); const { fetchJSON } = require('./network');
const { formatPersonNames } = require('./utils');
async function fetchLyric(songId) { async function fetchLyric(songId) {
try { try {
@@ -47,7 +48,7 @@ function parseCredits(lrcText) {
const match = line.match(/^([^:]{1,12})\s*[:]\s*(.+)$/); const match = line.match(/^([^:]{1,12})\s*[:]\s*(.+)$/);
if (!match) continue; if (!match) continue;
const label = match[1].trim(); const label = match[1].trim();
const value = match[2].trim().replace(/\s*\/\s*/g, '/'); const value = formatPersonNames(match[2].trim());
if (!value) continue; if (!value) continue;
for (const [key, pattern] of Object.entries(labels)) { for (const [key, pattern] of Object.entries(labels)) {
if (pattern.test(label) && !credits[key]) { if (pattern.test(label) && !credits[key]) {
+10 -1
View File
@@ -61,6 +61,12 @@ async function fetchNeteaseAlbumDetail(albumId) {
const data = await requestNetease(`/api/v1/album/${albumId}`); const data = await requestNetease(`/api/v1/album/${albumId}`);
if (!data) return null; if (!data) return null;
const album = data.album || data; const album = data.album || data;
const albumArtists = (album.artists?.length ? album.artists : [album.artist])
.filter(artist => artist?.name)
.map(artist => ({ id: artist.id, name: artist.name, trans: artist.trans }));
const albumArtist = albumArtists.map(artist => artist.name).join(' / ');
const normalizedAlbumArtist = albumArtist.toLowerCase().replace(/[.\s]/g, '');
const isCompilation = ['群星', 'variousartists', 'variousartist', 'va'].includes(normalizedAlbumArtist);
return { return {
id: album.id, id: album.id,
name: album.name, name: album.name,
@@ -70,7 +76,10 @@ async function fetchNeteaseAlbumDetail(albumId) {
tags: album.tags || [], tags: album.tags || [],
type: album.type || '', type: album.type || '',
size: album.size || 0, size: album.size || 0,
artist: album.artist ? { id: album.artist.id, name: album.artist.name, trans: album.artist.trans } : null, artist: albumArtists[0] || null,
artists: albumArtists,
albumArtist,
isCompilation,
picUrl: album.picUrl || '', picUrl: album.picUrl || '',
tracks: (data.songs || album.songs || []).map(song => ({ tracks: (data.songs || album.songs || []).map(song => ({
id: song.id, id: song.id,
+17 -14
View File
@@ -2,6 +2,7 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const { execSync, execFileSync } = require('child_process'); const { execSync, execFileSync } = require('child_process');
const { downloadBuffer } = require('./network'); const { downloadBuffer } = require('./network');
const { formatPersonNames } = require('./utils');
function hasFFmpeg() { function hasFFmpeg() {
try { execSync('ffmpeg -version', { stdio: 'ignore' }); return true; } catch { return false; } try { execSync('ffmpeg -version', { stdio: 'ignore' }); return true; } catch { return false; }
@@ -23,24 +24,25 @@ async function embedCover(audioPath, coverUrl, meta) {
const NodeID3 = require('node-id3'); const NodeID3 = require('node-id3');
const tags = { const tags = {
title: meta.name, title: meta.name,
artist: meta.artist, artist: formatPersonNames(meta.artist),
album: meta.album, album: meta.album,
image: coverPath, image: coverPath,
}; };
if (meta.albumArtist) tags.performerInfo = meta.albumArtist; if (meta.albumArtist) tags.performerInfo = formatPersonNames(meta.albumArtist);
if (meta.compilation) tags.TCMP = '1';
if (meta.year) tags.year = String(meta.year); if (meta.year) tags.year = String(meta.year);
if (meta.date) tags.date = String(meta.date); if (meta.date) tags.date = String(meta.date);
if (meta.trackNo) tags.trackNumber = String(meta.trackNo); if (meta.trackNo) tags.trackNumber = String(meta.trackNo);
if (meta.disc) tags.partOfSet = String(meta.disc); if (meta.disc) tags.partOfSet = String(meta.disc);
if (meta.genre) tags.genre = meta.genre; if (meta.genre) tags.genre = meta.genre;
if (meta.composer) tags.composer = meta.composer; if (meta.composer) tags.composer = formatPersonNames(meta.composer);
if (meta.lyricist) tags.textWriter = meta.lyricist; if (meta.lyricist) tags.textWriter = formatPersonNames(meta.lyricist);
if (meta.publisher) tags.publisher = meta.publisher; if (meta.publisher) tags.publisher = meta.publisher;
const userTexts = []; const userTexts = [];
if (meta.arranger) userTexts.push({ description: 'ARRANGER', value: meta.arranger }); if (meta.arranger) userTexts.push({ description: 'ARRANGER', value: formatPersonNames(meta.arranger) });
if (meta.producer) userTexts.push({ description: 'PRODUCER', value: meta.producer }); if (meta.producer) userTexts.push({ description: 'PRODUCER', value: formatPersonNames(meta.producer) });
if (meta.mixer) userTexts.push({ description: 'MIXER', value: meta.mixer }); if (meta.mixer) userTexts.push({ description: 'MIXER', value: formatPersonNames(meta.mixer) });
if (userTexts.length) tags.userDefinedText = userTexts; if (userTexts.length) tags.userDefinedText = userTexts;
if (meta.lyrics) tags.unsynchronisedLyrics = { language: 'chi', text: meta.lyrics }; if (meta.lyrics) tags.unsynchronisedLyrics = { language: 'chi', text: meta.lyrics };
const ok = NodeID3.write(tags, audioPath); const ok = NodeID3.write(tags, audioPath);
@@ -63,19 +65,20 @@ async function embedCover(audioPath, coverUrl, meta) {
} }
}; };
addMeta('title', meta.name); addMeta('title', meta.name);
addMeta('artist', meta.artist); addMeta('artist', formatPersonNames(meta.artist));
addMeta('album', meta.album); addMeta('album', meta.album);
addMeta('album_artist', meta.albumArtist || meta.artist); addMeta('album_artist', formatPersonNames(meta.albumArtist || meta.artist));
addMeta('compilation', meta.compilation ? '1' : undefined);
addMeta('date', meta.date || meta.year); addMeta('date', meta.date || meta.year);
addMeta('track', meta.trackNo); addMeta('track', meta.trackNo);
addMeta('disc', meta.disc); addMeta('disc', meta.disc);
addMeta('genre', meta.genre); addMeta('genre', meta.genre);
addMeta('composer', meta.composer); addMeta('composer', formatPersonNames(meta.composer));
addMeta('publisher', meta.publisher); addMeta('publisher', meta.publisher);
addMeta('lyricist', meta.lyricist); addMeta('lyricist', formatPersonNames(meta.lyricist));
addMeta('arranger', meta.arranger); addMeta('arranger', formatPersonNames(meta.arranger));
addMeta('producer', meta.producer); addMeta('producer', formatPersonNames(meta.producer));
addMeta('mixer', meta.mixer); addMeta('mixer', formatPersonNames(meta.mixer));
if (meta.lyrics && meta.lyrics.length <= 8000) addMeta('lyrics', meta.lyrics); if (meta.lyrics && meta.lyrics.length <= 8000) addMeta('lyrics', meta.lyrics);
args.push('-metadata:s:v', 'title=Album cover', '-metadata:s:v', 'comment=Cover (front)'); args.push('-metadata:s:v', 'title=Album cover', '-metadata:s:v', 'comment=Cover (front)');
if (ext === '.mp3') args.push('-id3v2_version', '3'); if (ext === '.mp3') args.push('-id3v2_version', '3');
+5 -1
View File
@@ -10,6 +10,10 @@ function sanitizePathSegment(name, fallback) {
return value && value !== '.' && value !== '..' ? value : fallback; return value && value !== '.' && value !== '..' ? value : fallback;
} }
function formatPersonNames(value) {
return typeof value === 'string' ? value.replace(/\s*\/\s*/g, ' / ') : value;
}
function sleep(ms) { function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise(resolve => setTimeout(resolve, ms));
} }
@@ -26,4 +30,4 @@ function getDirSize(dirPath) {
return size; return size;
} }
module.exports = { sanitize, sanitizePathSegment, sleep, getDirSize }; module.exports = { sanitize, sanitizePathSegment, formatPersonNames, sleep, getDirSize };