178 lines
6.2 KiB
JavaScript
Executable File
178 lines
6.2 KiB
JavaScript
Executable File
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;
|
|
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 {
|
|
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: albumArtists[0] || null,
|
|
artists: albumArtists,
|
|
albumArtist,
|
|
isCompilation,
|
|
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,
|
|
};
|