Files
163-music-dl/lib/metadata.js
T
2026-07-22 17:52:55 +08:00

169 lines
5.8 KiB
JavaScript

const fs = require('fs');
const http = require('http');
const https = require('https');
const path = require('path');
const { API, DOWNLOAD_DIR, NETEASE_BASE_URL } = require('./config');
const { downloadBuffer, fetchJSON } = require('./network');
const { sanitizePathSegment } = require('./utils');
function requestNetease(pathname) {
return new Promise((resolve) => {
const requestUrl = new URL(pathname, `${NETEASE_BASE_URL}/`);
const options = {
method: 'GET',
headers: {
Referer: NETEASE_BASE_URL,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
Accept: 'application/json',
Cookie: 'os=pc',
},
};
const protocol = requestUrl.protocol === 'https:' ? https : http;
const req = protocol.request(requestUrl, options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try { resolve(JSON.parse(data)); }
catch { resolve(null); }
});
});
req.on('error', () => resolve(null));
req.setTimeout(5000, () => { req.destroy(); resolve(null); });
req.end();
});
}
async function fetchNeteaseSongDetail(songId) {
const data = await requestNetease(`/api/v1/song/detail?ids=[${songId}]`);
const song = data?.songs?.[0];
if (!song) return null;
return {
id: song.id,
name: song.name,
artists: song.ar?.map(artist => ({ id: artist.id, name: artist.name })) || [],
album: song.al ? { id: song.al.id, name: song.al.name, cover: song.al.picUrl || song.al.cover } : null,
duration: song.dt || 0,
disc: song.cd || '',
trackNo: song.no || 0,
aliases: song.alia || [],
transNames: song.tns || [],
popularity: song.pop || 0,
fee: song.fee,
quality: {
h: song.h ? { br: song.h.br, size: song.h.size, sr: song.h.sr } : null,
m: song.m ? { br: song.m.br, size: song.m.size, sr: song.m.sr } : null,
l: song.l ? { br: song.l.br, size: song.l.size, sr: song.l.sr } : null,
},
};
}
async function fetchNeteaseAlbumDetail(albumId) {
const data = await requestNetease(`/api/v1/album/${albumId}`);
if (!data) return null;
const album = data.album || data;
return {
id: album.id,
name: album.name,
publishTime: album.publishTime || null,
company: album.company || '',
description: album.description || '',
tags: album.tags || [],
type: album.type || '',
size: album.size || 0,
artist: album.artist ? { id: album.artist.id, name: album.artist.name, trans: album.artist.trans } : null,
picUrl: album.picUrl || '',
tracks: (data.songs || album.songs || []).map(song => ({
id: song.id,
name: song.name,
ar: song.ar || song.artists || [],
al: song.al || song.album || null,
dt: song.dt || song.duration || 0,
disc: song.cd || '',
trackNo: song.no || 0,
})),
};
}
function getAlbumDownloadTarget(album, track, index, trackCount = album.tracks?.length || 0) {
const trackArtists = track.ar?.map(artist => artist.name).filter(Boolean).join(' / ');
const artistDir = sanitizePathSegment(album.artist?.name || trackArtists, '未知歌手');
const albumDir = sanitizePathSegment(album.name, '未知专辑');
const numberWidth = Math.max(2, String(trackCount).length);
const trackNumber = String(index + 1).padStart(numberWidth, '0');
return {
outputDir: path.join(DOWNLOAD_DIR, artistDir, albumDir),
outputBaseName: `${trackNumber}. ${track.name || '未知歌曲'}`,
};
}
async function saveAlbumCover(coverUrl, outputDir) {
const coverPath = path.join(outputDir, 'cover.jpg');
if (fs.existsSync(coverPath) && fs.statSync(coverPath).size > 0) {
console.log(' ⏭️ 专辑封面已存在: cover.jpg');
return;
}
if (!coverUrl) {
console.log(' ⚠️ 未获取到专辑封面');
return;
}
try {
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(coverPath, await downloadBuffer(coverUrl));
console.log(' 🖼️ 专辑封面已保存: cover.jpg');
} catch (e) {
console.error(` ⚠️ 专辑封面保存失败: ${e.message}`);
}
}
async function fetchFullMetadata(songId) {
const [songDetail, chkszData] = await Promise.all([
fetchNeteaseSongDetail(songId),
fetchJSON(`${API.music}?id=${songId}&level=standard`).catch(() => null),
]);
if (!songDetail) return null;
const albumDetail = songDetail.album?.id
? await fetchNeteaseAlbumDetail(songDetail.album.id)
: null;
const durationSec = Math.round(songDetail.duration / 1000);
const publishDate = albumDetail?.publishTime
? new Date(albumDetail.publishTime).toISOString().slice(0, 10)
: null;
return {
id: songId,
name: songDetail.name,
artists: songDetail.artists,
artistStr: songDetail.artists.map(artist => artist.name).join(' / '),
album: songDetail.album,
albumStr: songDetail.album?.name || '',
duration: songDetail.duration,
durationStr: `${Math.floor(durationSec / 60)}:${String(durationSec % 60).padStart(2, '0')}`,
disc: songDetail.disc,
trackNo: songDetail.trackNo,
aliases: songDetail.aliases,
transNames: songDetail.transNames,
popularity: songDetail.popularity,
fee: songDetail.fee,
publishTime: albumDetail?.publishTime,
publishYear: albumDetail?.publishTime ? new Date(albumDetail.publishTime).getFullYear() : null,
publishDate,
company: albumDetail?.company || '',
description: albumDetail?.description || '',
tags: albumDetail?.tags || [],
albumType: albumDetail?.type || '',
albumSize: albumDetail?.size || 0,
albumArtist: albumDetail?.artist,
quality: songDetail.quality,
chksz: chkszData?.code === 200 ? chkszData.data : null,
};
}
module.exports = {
fetchNeteaseSongDetail,
fetchNeteaseAlbumDetail,
getAlbumDownloadTarget,
saveAlbumCover,
fetchFullMetadata,
};