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, };