Compare commits
3 Commits
cbf541d81e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 480233f01b | |||
| 60b2fc0578 | |||
| 839ec8eb2e |
@@ -18,3 +18,4 @@
|
|||||||
*.tgz
|
*.tgz
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
download_history.json
|
download_history.json
|
||||||
|
-e "download_history.json\nMusic/"
|
||||||
|
|||||||
+59
-14
@@ -38,6 +38,27 @@ function createRL() {
|
|||||||
return { rl, ask };
|
return { rl, ask };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseNumberSelection(input, total, { emptyMeansAll = false } = {}) {
|
||||||
|
const value = input.trim().toLowerCase();
|
||||||
|
if (value === 'a' || (!value && emptyMeansAll)) {
|
||||||
|
return Array.from({ length: total }, (_, index) => index + 1);
|
||||||
|
}
|
||||||
|
if (!value || value === '0') return [];
|
||||||
|
|
||||||
|
const indices = new Set();
|
||||||
|
for (const part of value.split(/[,,\s]+/)) {
|
||||||
|
const rangeMatch = part.match(/^(\d+)-(\d+)$/);
|
||||||
|
if (rangeMatch) {
|
||||||
|
const start = parseInt(rangeMatch[1], 10);
|
||||||
|
const end = parseInt(rangeMatch[2], 10);
|
||||||
|
for (let number = start; number <= end; number++) indices.add(number);
|
||||||
|
} else if (/^\d+$/.test(part)) {
|
||||||
|
indices.add(parseInt(part, 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...indices].filter(number => number >= 1 && number <= total).sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
function printBanner() {
|
function printBanner() {
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('╔══════════════════════════════════════════╗');
|
console.log('╔══════════════════════════════════════════╗');
|
||||||
@@ -62,7 +83,7 @@ function printMenu() {
|
|||||||
console.log('│ 9. 下载历史 │');
|
console.log('│ 9. 下载历史 │');
|
||||||
console.log('│ 0. 设置音质等级 │');
|
console.log('│ 0. 设置音质等级 │');
|
||||||
console.log('│ s. 按歌名搜索 │');
|
console.log('│ s. 按歌名搜索 │');
|
||||||
console.log('│ a. 下载完整专辑 │');
|
console.log('│ a. 下载专辑/指定曲目 │');
|
||||||
console.log('│ b. 批量ID下载 │');
|
console.log('│ b. 批量ID下载 │');
|
||||||
console.log('│ f. 从文件批量下载 │');
|
console.log('│ f. 从文件批量下载 │');
|
||||||
console.log('│ n. 从文件名识别并补全标签 │');
|
console.log('│ n. 从文件名识别并补全标签 │');
|
||||||
@@ -962,22 +983,34 @@ async function main() {
|
|||||||
console.log(` ${String(i + 1).padStart(2)}. ${track.name} - ${artists}`);
|
console.log(` ${String(i + 1).padStart(2)}. ${track.name} - ${artists}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const trackPick = await ask('\n选择歌曲序号 (如 1,3,5 或 2-6,a/回车=全部,0=取消): ');
|
||||||
|
const selectedIndices = parseNumberSelection(trackPick, album.tracks.length, { emptyMeansAll: true });
|
||||||
|
if (!selectedIndices.length) {
|
||||||
|
if (trackPick.trim() !== '0') console.log('⚠️ 没有有效的歌曲序号');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const selectedTracks = selectedIndices.map(number => ({
|
||||||
|
track: album.tracks[number - 1],
|
||||||
|
trackIndex: number - 1,
|
||||||
|
}));
|
||||||
|
|
||||||
level = await askLevel(ask, level);
|
level = await askLevel(ask, level);
|
||||||
const confirm = await ask(`确认下载整张专辑? (y/n): `);
|
const confirm = await ask(`确认下载选中的 ${selectedTracks.length} 首歌曲? (y/n): `);
|
||||||
if (confirm.trim().toLowerCase() !== 'y') break;
|
if (confirm.trim().toLowerCase() !== 'y') break;
|
||||||
|
|
||||||
const albumTarget = getAlbumDownloadTarget(album, album.tracks[0], 0);
|
const firstItem = selectedTracks[0];
|
||||||
|
const albumTarget = getAlbumDownloadTarget(album, firstItem.track, firstItem.trackIndex);
|
||||||
await saveAlbumCover(album.picUrl, albumTarget.outputDir);
|
await saveAlbumCover(album.picUrl, albumTarget.outputDir);
|
||||||
|
|
||||||
for (let i = 0; i < album.tracks.length; i++) {
|
for (let i = 0; i < selectedTracks.length; i++) {
|
||||||
const track = album.tracks[i];
|
const { track, trackIndex } = selectedTracks[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, trackIndex);
|
||||||
await waitIfPaused();
|
await waitIfPaused();
|
||||||
console.log(`\n[${i + 1}/${album.tracks.length}] ${track.name} - ${artists}`);
|
console.log(`\n[${i + 1}/${selectedTracks.length}] ${track.name} - ${artists}`);
|
||||||
try { await downloadSong(track.id, level, target); }
|
try { await downloadSong(track.id, level, target); }
|
||||||
catch (e) { console.error(` ❌ 出错: ${e.message}`); }
|
catch (e) { console.error(` ❌ 出错: ${e.message}`); }
|
||||||
if (i < album.tracks.length - 1) await sleep(500);
|
if (i < selectedTracks.length - 1) await sleep(500);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1050,13 +1083,25 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`\n💿 共 ${albumsToDownload.length} 张专辑,${allSongs.length} 首歌曲`);
|
console.log(`\n💿 共 ${albumsToDownload.length} 张专辑,${allSongs.length} 首歌曲`);
|
||||||
|
allSongs.forEach((item, index) => {
|
||||||
|
const albumSuffix = albumsToDownload.length > 1 ? ` [${item.albumName}]` : '';
|
||||||
|
console.log(` ${String(index + 1).padStart(2)}. ${item.song.name} - ${item.song.artists}${albumSuffix}`);
|
||||||
|
});
|
||||||
|
const songPick = await ask('\n选择歌曲序号 (如 1,3,5 或 2-6,a/回车=全部,0=取消): ');
|
||||||
|
const selectedIndices = parseNumberSelection(songPick, allSongs.length, { emptyMeansAll: true });
|
||||||
|
if (!selectedIndices.length) {
|
||||||
|
if (songPick.trim() !== '0') console.log('⚠️ 没有有效的歌曲序号');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const songsToDownload = selectedIndices.map(number => allSongs[number - 1]);
|
||||||
|
|
||||||
level = await askLevel(ask, level);
|
level = await askLevel(ask, level);
|
||||||
const confirm = await ask(`确认下载? (y/n): `);
|
const confirm = await ask(`确认下载选中的 ${songsToDownload.length} 首歌曲? (y/n): `);
|
||||||
if (confirm.trim().toLowerCase() !== 'y') break;
|
if (confirm.trim().toLowerCase() !== 'y') break;
|
||||||
|
|
||||||
const savedCoverDirs = new Set();
|
const savedCoverDirs = new Set();
|
||||||
for (let i = 0; i < allSongs.length; i++) {
|
for (let i = 0; i < songsToDownload.length; i++) {
|
||||||
const item = allSongs[i];
|
const item = songsToDownload[i];
|
||||||
const s = item.song;
|
const s = item.song;
|
||||||
const target = getAlbumDownloadTarget({
|
const target = getAlbumDownloadTarget({
|
||||||
name: item.albumName,
|
name: item.albumName,
|
||||||
@@ -1068,10 +1113,10 @@ async function main() {
|
|||||||
savedCoverDirs.add(target.outputDir);
|
savedCoverDirs.add(target.outputDir);
|
||||||
}
|
}
|
||||||
await waitIfPaused();
|
await waitIfPaused();
|
||||||
console.log(`\n[${i + 1}/${allSongs.length}] ${s.name} - ${s.artists}`);
|
console.log(`\n[${i + 1}/${songsToDownload.length}] ${s.name} - ${s.artists}`);
|
||||||
try { await downloadSong(s.id, level, target); }
|
try { await downloadSong(s.id, level, target); }
|
||||||
catch (e) { console.error(` ❌ 出错: ${e.message}`); }
|
catch (e) { console.error(` ❌ 出错: ${e.message}`); }
|
||||||
if (i < allSongs.length - 1) await sleep(500);
|
if (i < songsToDownload.length - 1) await sleep(500);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -2023,7 +2068,7 @@ async function main() {
|
|||||||
console.log(` ║ 3 输入歌单 ID 下载全部 - 输入歌单ID批量下载 ║`);
|
console.log(` ║ 3 输入歌单 ID 下载全部 - 输入歌单ID批量下载 ║`);
|
||||||
console.log(` ║ s 按歌名搜索 - 按歌名精确搜索,支持试听/详情/下载 ║`);
|
console.log(` ║ s 按歌名搜索 - 按歌名精确搜索,支持试听/详情/下载 ║`);
|
||||||
console.log(` ║ 6 按歌手批量下载 - 搜索歌手并选择下载其歌曲 ║`);
|
console.log(` ║ 6 按歌手批量下载 - 搜索歌手并选择下载其歌曲 ║`);
|
||||||
console.log(` ║ a 下载完整专辑 - 输入专辑ID下载全部曲目,也支持名称搜索 ║`);
|
console.log(` ║ a 下载专辑 - 输入专辑ID或名称,可按序号选择曲目 ║`);
|
||||||
console.log(` ║ ║`);
|
console.log(` ║ ║`);
|
||||||
console.log(` ║ 【查看与管理】 ║`);
|
console.log(` ║ 【查看与管理】 ║`);
|
||||||
console.log(` ║ 4 查看歌单歌曲列表 - 浏览歌单内容,可选择性下载 ║`);
|
console.log(` ║ 4 查看歌单歌曲列表 - 浏览歌单内容,可选择性下载 ║`);
|
||||||
|
|||||||
+5
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const util = require('util');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
const LOG_FILE = path.join(__dirname, 'log.txt');
|
||||||
|
let activeLogPath = null;
|
||||||
|
let logFinished = false;
|
||||||
|
let originalConsoleLog = null;
|
||||||
|
let originalConsoleError = null;
|
||||||
|
|
||||||
|
const AUDIO_EXTENSIONS = new Set(['.mp3', '.flac', '.m4a', '.aac', '.ogg', '.opus', '.wav']);
|
||||||
|
const PERSON_TAGS = new Set([
|
||||||
|
'artist',
|
||||||
|
'artists',
|
||||||
|
'album_artist',
|
||||||
|
'albumartist',
|
||||||
|
'album_artists',
|
||||||
|
'albumartists',
|
||||||
|
'composer',
|
||||||
|
'lyricist',
|
||||||
|
'text_writer',
|
||||||
|
'textwriter',
|
||||||
|
'writer',
|
||||||
|
'arranger',
|
||||||
|
'producer',
|
||||||
|
'mixer',
|
||||||
|
'performer',
|
||||||
|
'conductor',
|
||||||
|
'sort_artist',
|
||||||
|
'artist_sort',
|
||||||
|
'artistsort',
|
||||||
|
'sort_album_artist',
|
||||||
|
'album_artist_sort',
|
||||||
|
'albumartistsort',
|
||||||
|
'sort_composer',
|
||||||
|
'composer_sort',
|
||||||
|
'composersort',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function printHelp() {
|
||||||
|
console.log(`批量规范音频人物标签中的分隔符
|
||||||
|
|
||||||
|
用法:
|
||||||
|
node normalize_person_separators.js [目录] [--apply] [--backup]
|
||||||
|
|
||||||
|
参数:
|
||||||
|
目录 要递归扫描的目录,默认为 Music
|
||||||
|
--apply 实际修改文件;不传时只预览
|
||||||
|
--backup 修改前创建同名 .bak 备份,仅与 --apply 一起生效
|
||||||
|
-h, --help 显示帮助
|
||||||
|
|
||||||
|
示例:
|
||||||
|
node normalize_person_separators.js
|
||||||
|
node normalize_person_separators.js "D:\\Music"
|
||||||
|
node normalize_person_separators.js Music --apply --backup
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendLog(text) {
|
||||||
|
if (activeLogPath) fs.appendFileSync(activeLogPath, text, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function startApplyLog(options) {
|
||||||
|
activeLogPath = LOG_FILE;
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
fs.appendFileSync(
|
||||||
|
activeLogPath,
|
||||||
|
`\n===== apply 开始 ${startedAt} =====\n目标目录: ${options.targetDir}\n创建备份: ${options.backup ? '是' : '否'}\n命令: ${process.argv.map(value => JSON.stringify(value)).join(' ')}\n`,
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
originalConsoleLog = console.log;
|
||||||
|
originalConsoleError = console.error;
|
||||||
|
console.log = (...args) => {
|
||||||
|
originalConsoleLog(...args);
|
||||||
|
appendLog(`${util.format(...args)}\n`);
|
||||||
|
};
|
||||||
|
console.error = (...args) => {
|
||||||
|
originalConsoleError(...args);
|
||||||
|
appendLog(`${util.format(...args)}\n`);
|
||||||
|
};
|
||||||
|
process.once('exit', finishApplyLog);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishApplyLog() {
|
||||||
|
if (!activeLogPath || logFinished) return;
|
||||||
|
logFinished = true;
|
||||||
|
appendLog(`===== apply 结束 ${new Date().toISOString()} =====\n`);
|
||||||
|
console.log = originalConsoleLog;
|
||||||
|
console.error = originalConsoleError;
|
||||||
|
activeLogPath = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mirrorChildOutput(output, isError = false) {
|
||||||
|
if (!output) return;
|
||||||
|
const text = String(output);
|
||||||
|
(isError ? process.stderr : process.stdout).write(text);
|
||||||
|
appendLog(text.endsWith('\n') ? text : `${text}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
let targetDir = null;
|
||||||
|
let apply = false;
|
||||||
|
let backup = false;
|
||||||
|
|
||||||
|
for (const arg of argv) {
|
||||||
|
if (arg === '-h' || arg === '--help') return { help: true };
|
||||||
|
if (arg === '--apply') apply = true;
|
||||||
|
else if (arg === '--backup') backup = true;
|
||||||
|
else if (arg.startsWith('-')) throw new Error(`未知参数: ${arg}`);
|
||||||
|
else if (targetDir === null) targetDir = arg;
|
||||||
|
else throw new Error(`只能指定一个目录: ${arg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
help: false,
|
||||||
|
apply,
|
||||||
|
backup,
|
||||||
|
targetDir: path.resolve(targetDir || path.join(__dirname, 'Music')),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTagKey(key) {
|
||||||
|
return key.toLowerCase().replace(/[\s-]+/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePersonValue(value) {
|
||||||
|
if (typeof value !== 'string') return value;
|
||||||
|
return value
|
||||||
|
.replace(/\s+&\s+/g, ' / ')
|
||||||
|
.replace(/,\s+/g, ' / ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function findAudioFiles(dirPath) {
|
||||||
|
const files = [];
|
||||||
|
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
|
||||||
|
const entryPath = path.join(dirPath, entry.name);
|
||||||
|
if (entry.isDirectory()) files.push(...findAudioFiles(entryPath));
|
||||||
|
else if (entry.isFile() && AUDIO_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(entryPath);
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTags(filePath) {
|
||||||
|
const result = spawnSync('ffprobe', [
|
||||||
|
'-v', 'error',
|
||||||
|
'-show_entries', 'format_tags',
|
||||||
|
'-of', 'json',
|
||||||
|
filePath,
|
||||||
|
], { encoding: 'utf8', windowsHide: true });
|
||||||
|
mirrorChildOutput(result.stderr, true);
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (result.status !== 0) throw new Error(`ffprobe 退出码: ${result.status}`);
|
||||||
|
return JSON.parse(result.stdout).format?.tags || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectChanges(tags) {
|
||||||
|
const changes = [];
|
||||||
|
for (const [key, oldValue] of Object.entries(tags)) {
|
||||||
|
if (!PERSON_TAGS.has(normalizeTagKey(key))) continue;
|
||||||
|
const newValue = normalizePersonValue(oldValue);
|
||||||
|
if (newValue !== oldValue) changes.push({ key, oldValue, newValue });
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceFileAtomically(sourcePath, replacementPath, sourceStat) {
|
||||||
|
const displacedPath = `${sourcePath}.normalize-original-${process.pid}`;
|
||||||
|
if (fs.existsSync(displacedPath)) throw new Error(`保护文件已存在: ${displacedPath}`);
|
||||||
|
|
||||||
|
fs.renameSync(sourcePath, displacedPath);
|
||||||
|
try {
|
||||||
|
fs.renameSync(replacementPath, sourcePath);
|
||||||
|
fs.chmodSync(sourcePath, sourceStat.mode);
|
||||||
|
fs.utimesSync(sourcePath, sourceStat.atime, sourceStat.mtime);
|
||||||
|
fs.unlinkSync(displacedPath);
|
||||||
|
} catch (error) {
|
||||||
|
if (fs.existsSync(sourcePath)) fs.unlinkSync(sourcePath);
|
||||||
|
fs.renameSync(displacedPath, sourcePath);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyChanges(filePath, changes, createBackup) {
|
||||||
|
const extension = path.extname(filePath);
|
||||||
|
const tempPath = path.join(
|
||||||
|
path.dirname(filePath),
|
||||||
|
`.${path.basename(filePath, extension)}.normalize-${process.pid}-${Date.now()}${extension}`,
|
||||||
|
);
|
||||||
|
const backupPath = `${filePath}.bak`;
|
||||||
|
const sourceStat = fs.statSync(filePath);
|
||||||
|
|
||||||
|
if (fs.existsSync(tempPath)) throw new Error(`临时文件已存在: ${tempPath}`);
|
||||||
|
if (createBackup) fs.copyFileSync(filePath, backupPath, fs.constants.COPYFILE_EXCL);
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'-v', 'error',
|
||||||
|
'-i', filePath,
|
||||||
|
'-map', '0',
|
||||||
|
'-map_metadata', '0',
|
||||||
|
'-c', 'copy',
|
||||||
|
];
|
||||||
|
for (const change of changes) args.push('-metadata', `${change.key}=${change.newValue}`);
|
||||||
|
args.push(tempPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync('ffmpeg', args, { encoding: 'utf8', windowsHide: true });
|
||||||
|
mirrorChildOutput(result.stdout);
|
||||||
|
mirrorChildOutput(result.stderr, true);
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (result.status !== 0) throw new Error(`ffmpeg 退出码: ${result.status}`);
|
||||||
|
replaceFileAtomically(filePath, tempPath, sourceStat);
|
||||||
|
} catch (error) {
|
||||||
|
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
let options;
|
||||||
|
try {
|
||||||
|
options = parseArgs(process.argv.slice(2));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`参数错误: ${error.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.help) {
|
||||||
|
printHelp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(options.targetDir) || !fs.statSync(options.targetDir).isDirectory()) {
|
||||||
|
console.error(`目录不存在: ${options.targetDir}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (options.apply) {
|
||||||
|
try {
|
||||||
|
startApplyLog(options);
|
||||||
|
console.log(`完整日志将追加写入: ${LOG_FILE}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`无法创建日志文件: ${error.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (options.backup && !options.apply) {
|
||||||
|
console.log('提示: 当前是预览模式,--backup 不会创建备份。');
|
||||||
|
}
|
||||||
|
|
||||||
|
let files;
|
||||||
|
try {
|
||||||
|
files = findAudioFiles(options.targetDir);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`扫描目录失败: ${error.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${options.apply ? '修改' : '预览'}模式,扫描目录: ${options.targetDir}`);
|
||||||
|
console.log(`找到 ${files.length} 个音频文件。\n`);
|
||||||
|
|
||||||
|
let matchedFiles = 0;
|
||||||
|
let changedFiles = 0;
|
||||||
|
let failedFiles = 0;
|
||||||
|
|
||||||
|
for (const filePath of files) {
|
||||||
|
try {
|
||||||
|
const changes = collectChanges(readTags(filePath));
|
||||||
|
if (!changes.length) continue;
|
||||||
|
matchedFiles++;
|
||||||
|
console.log(path.relative(options.targetDir, filePath) || path.basename(filePath));
|
||||||
|
for (const change of changes) {
|
||||||
|
console.log(` ${change.key}: ${change.oldValue} -> ${change.newValue}`);
|
||||||
|
}
|
||||||
|
if (options.apply) {
|
||||||
|
applyChanges(filePath, changes, options.backup);
|
||||||
|
changedFiles++;
|
||||||
|
console.log(' 已修改');
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
} catch (error) {
|
||||||
|
failedFiles++;
|
||||||
|
console.error(`${filePath}\n 处理失败: ${error.message}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.apply) {
|
||||||
|
console.log(`完成:匹配 ${matchedFiles} 个,修改 ${changedFiles} 个,失败 ${failedFiles} 个。`);
|
||||||
|
} else {
|
||||||
|
console.log(`预览完成:${matchedFiles} 个文件需要修改,失败 ${failedFiles} 个。`);
|
||||||
|
if (matchedFiles) console.log('确认无误后加 --apply 执行;如需保留原文件,再加 --backup。');
|
||||||
|
}
|
||||||
|
if (failedFiles) process.exitCode = 1;
|
||||||
|
finishApplyLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
Reference in New Issue
Block a user