yun-playlist-downloader
Version:
NetEase CloudMusic downloader
609 lines (595 loc) • 16.8 kB
JavaScript
// node_modules/.pnpm/tsup@8.4.0_postcss@8.4.33_typescript@5.8.2_yaml@2.7.1/node_modules/tsup/assets/esm_shims.js
import { fileURLToPath } from "url";
var getFilename = () => fileURLToPath(import.meta.url);
var __filename = /* @__PURE__ */ getFilename();
// src/index.ts
import { dl as dl2 } from "dl-vampire";
import filenamify from "filenamify";
import LogSymbols from "log-symbols";
import path from "path";
// src/auth/cookie.ts
import { existsSync, readFileSync } from "fs";
import logSymbols from "log-symbols";
import { resolve } from "path";
var DEFAULT_COOKIE_FILE = "yun.cookie.txt";
var COOKIE_CONTENT = void 0;
function readCookie(cookieFile) {
if (!cookieFile) return;
const file = resolve(cookieFile);
if (!existsSync(file)) {
console.log(`${logSymbols.warning} [cookie] cookie 文件不存在: %s`, file);
return;
} else {
console.log(`${logSymbols.info} [cookie] 使用 cookie 文件: %s`, file);
}
let str = readFileSync(file, "utf-8");
str = str.split("\n").filter((line) => {
if (!line.trim()) return false;
if (line.startsWith("//")) return false;
return true;
}).join("");
COOKIE_CONTENT = str;
}
// src/common.ts
import debug from "debug";
var baseDebug = debug("yun");
// src/api/index.ts
import Api from "NeteaseCloudMusicApi";
import delay from "delay";
import { chunk } from "es-toolkit";
import pmap from "promise.map";
var debug2 = baseDebug.extend("api:index");
var BATCH_ID_SIZE = 200;
var BATCH_ID_CONCURRENCY = 4;
var getApiBaseConfig = () => {
return { cookie: COOKIE_CONTENT };
};
function handleRequestLimit(fn, label) {
return async function(...args) {
let ret;
const limitReached = (err) => {
var _a, _b;
return err && typeof err === "object" && err.status === 406 && ((_a = err.body) == null ? void 0 : _a.code) === 406 && /操作频繁/.test(((_b = err.body) == null ? void 0 : _b.message) || "");
};
let wait = 1;
while (true) {
let err;
try {
ret = await fn(...args);
} catch (e) {
err = e;
}
if (limitReached(err)) {
debug2("handleRequestLimit: 406-操作频繁 for %s, staring wait %s seconds", label, wait);
await delay(1e3 * wait++);
continue;
}
if (err) {
throw err;
} else {
debug2("handleRequestLimit: success for %s", label);
return ret;
}
}
};
}
async function playlistDetail(id) {
const res = await Api.playlist_detail({ ...getApiBaseConfig(), id });
const playlist = res.body.playlist;
return playlist;
}
async function songDetail(ids) {
const singleRequest = async (idsStr) => {
const res = await Api.song_detail({ ...getApiBaseConfig(), ids: idsStr });
const songDatas = res.body.songs;
return songDatas;
};
const chunks = chunk(ids, BATCH_ID_SIZE);
const songDatasArray = await pmap(
chunks,
(chunk2) => {
return singleRequest(chunk2.join(","));
},
BATCH_ID_CONCURRENCY
);
return songDatasArray.flat();
}
async function songUrl(ids, quality) {
const singleRequest = async (id) => {
const res = await Api.song_url({ ...getApiBaseConfig(), id, br: quality });
const infos = res.body.data;
return infos;
};
const chunks = chunk(ids, BATCH_ID_SIZE);
const infosArr = await pmap(
chunks,
(chunk2) => singleRequest(chunk2.join(",")),
BATCH_ID_CONCURRENCY
);
return infosArr.flat();
}
async function album(id) {
const res = await Api.album({ ...getApiBaseConfig(), id });
const album2 = res.body.album;
const songs = res.body.songs;
return { album: album2, songs };
}
async function djradioPrograms(id) {
let hasMore = true;
let pagesize = 100;
let pagenum = 1;
let allPrograms = [];
do {
const res = await handleRequestLimit(
Api.dj_program,
`dj_program(${pagenum})`
)({
...getApiBaseConfig(),
rid: id,
limit: pagesize,
offset: (pagenum - 1) * pagesize,
asc: "false"
// lastest published first
});
const programs = res.body.programs;
allPrograms = allPrograms.concat(programs);
hasMore = res.body.more;
pagenum++;
} while (hasMore);
return allPrograms;
}
// src/singleton.ts
import got from "got";
var CHROME_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36";
var headers = {
"referer": "http://music.163.com/",
"user-agent": CHROME_UA
};
var request = got.extend({
headers,
prefixUrl: "https://music.163.com",
searchParams: {
/* eslint camelcase: off */
csrf_token: "6817f1ae5c9664c9076e301c537afc29"
}
});
// src/util.ts
function normalizeUrl(url) {
return url.replace(/(https?:.*?\/)(#\/)/, "$1");
}
var getId = function(url) {
url = normalizeUrl(url);
const u = new URL(url);
const id = u.searchParams.get("id");
return id;
};
// src/adapter/base.ts
import { invariant } from "es-toolkit";
import { get } from "es-toolkit/compat";
import { extname } from "path";
var NOT_IMPLEMENTED = "not NOT_IMPLEMENTED";
var BaseAdapter = class {
constructor(url) {
this.url = url;
const id = getId(url);
invariant(id, "id cannot be empty");
this.id = id;
}
/**
* get title for a page
*/
async getTitle() {
throw new Error(NOT_IMPLEMENTED);
}
/**
* get cover
*/
async getCover() {
return "";
}
/**
* get songs
*/
getSongs(quality) {
throw new Error(NOT_IMPLEMENTED);
}
/**
* get songs detail
*/
getSongsFromData(songDatas) {
const len = String(songDatas.length).length;
return songDatas.map(function(songData, index) {
var _a;
const url = (_a = songData.playUrlInfo) == null ? void 0 : _a.url;
let ext;
if (url) {
const pathname = new URL(url).pathname;
ext = extname(pathname);
if (ext.startsWith(".")) ext = ext.slice(1);
}
return {
// 歌手
singer: get(songData, "ar.0.name") || get(songData, "artists.0.name") || "",
// 歌曲名
songName: songData.name,
// 专辑名
albumName: get(songData, "al.name") || "",
// url for download
url,
// free trial
isFreeTrial: songData.playUrlInfo ? Boolean(songData.playUrlInfo.freeTrialInfo) : void 0,
// extension
ext,
// index, first as 01
index: String(index + 1).padStart(len, "0"),
// rawIndex: 0,1 ...
rawIndex: index,
// raw
raw: songData
};
});
}
async filterSongs(songDatas, quality) {
const ids = songDatas.map((s) => s.id);
const playUrlInfos = await songUrl(ids, quality);
const ret = { songs: [], removed: [], all: [] };
for (let songData of songDatas) {
const { id } = songData;
const info = playUrlInfos.find((x) => String(x.id) === String(id));
const songDataFull = songData;
songDataFull.playUrlInfo = info;
ret.all.push(songDataFull);
if (!(info == null ? void 0 : info.url)) {
ret.removed.push(songDataFull);
} else {
ret.songs.push(songDataFull);
}
}
return ret;
}
};
// src/adapter/album.ts
var AlbumAdapter = class extends BaseAdapter {
#detail;
async fetchDetail() {
if (this.#detail) return;
this.#detail = await album(this.id);
}
async getTitle() {
await this.fetchDetail();
return this.#detail.album.name;
}
async getCover() {
await this.fetchDetail();
return this.#detail.album.picUrl;
}
async getSongs(quality) {
await this.fetchDetail();
const { all: songDatas } = await this.filterSongs(this.#detail.songs, quality);
return this.getSongsFromData(songDatas);
}
};
// src/adapter/djradio.ts
import moment from "moment";
var debug3 = baseDebug.extend("adapter:djradio");
var DjradioAdapter = class extends BaseAdapter {
#programs;
async fetchAllPrograms() {
if (this.#programs) return;
const allPrograms = await djradioPrograms(this.id);
this.#programs = allPrograms;
}
get radio() {
var _a, _b;
return (_b = (_a = this.#programs) == null ? void 0 : _a[0]) == null ? void 0 : _b.radio;
}
async getTitle() {
var _a;
await this.fetchAllPrograms();
return (_a = this.radio) == null ? void 0 : _a.name;
}
async getCover() {
var _a;
await this.fetchAllPrograms();
return (_a = this.radio) == null ? void 0 : _a.picUrl;
}
async getSongs(quality) {
await this.fetchAllPrograms();
const allPrograms = this.#programs;
const mainSongs = allPrograms.map((x) => x.mainSong);
const { all } = await this.filterSongs(mainSongs, quality);
const songs = this.getSongsFromData(all);
const programsSongs = songs.map((i, index) => {
const { createTime } = allPrograms[index];
const programDate = moment(createTime).format("YYYY-MM-DD");
const programOrder = allPrograms.length - index;
return {
...i,
programDate,
programOrder
};
});
return programsSongs;
}
};
// src/adapter/playlist.ts
var PlaylistAdapter = class extends BaseAdapter {
async fetchPlaylist() {
if (this.playlist) return;
this.playlist = await playlistDetail(this.id);
}
async getTitle() {
await this.fetchPlaylist();
return this.playlist.name;
}
async getCover() {
await this.fetchPlaylist();
return this.playlist.coverImgUrl;
}
async getSongDatas() {
await this.fetchPlaylist();
const trackIds = this.playlist.trackIds.map((x) => x.id);
const songDatas = await songDetail(trackIds);
return songDatas;
}
async getSongs(quality) {
const songDatas = await this.getSongDatas();
const { all } = await this.filterSongs(songDatas, quality);
const songs = this.getSongsFromData(all);
return songs;
}
};
// src/download/progress/ink.tsx
import { dl } from "dl-vampire";
import { Static, Text, render } from "ink";
import Spinner from "ink-spinner";
import { once } from "es-toolkit";
import logSymbols2 from "log-symbols";
import { useEffect, useState } from "react";
import { proxy, useSnapshot } from "valtio";
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
var inkState = proxy({
completed: [],
running: []
});
async function downloadSongWithInk(options) {
renderApp();
const { url, file, song, totalLength, retryTimeout, retryTimes, skipExists } = options;
const index = song.rawIndex;
inkState.running.push({ ...options, index, progress: 0, started: Date.now() });
function updateRunningItem(payload) {
const runningItem = inkState.running.find((x) => x.index === index);
if (!runningItem) return;
Object.assign(runningItem, payload);
}
const moveToComplete = once((payload) => {
const idx = inkState.running.findIndex((x) => x.index === index);
if (idx !== -1) inkState.running.splice(idx, 1);
inkState.completed.push({ ...options, index, ...payload });
});
const success = once(() => {
moveToComplete({ success: true, skip });
});
const fail = once(() => {
moveToComplete({ success: false });
});
const downloading = (percent) => {
updateRunningItem({ progress: percent });
};
const retry = (i) => {
updateRunningItem({
retry: i,
progress: 0,
started: Date.now()
});
};
let skip = false;
try {
;
({ skip } = await dl({
url,
file,
skipExists,
onprogress(p) {
const { percent } = p;
if (percent === 1) {
success();
} else {
downloading(percent);
}
},
retry: {
timeout: retryTimeout,
times: retryTimes,
onerror: function(e, i) {
retry(i);
}
}
}));
} catch (e) {
fail();
return;
}
success();
}
var inkInstance;
var renderApp = once(() => {
inkInstance = render(/* @__PURE__ */ jsx(App, {}));
});
function useNow(updateInterval = 100) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => {
setNow(Date.now());
}, updateInterval);
return () => {
clearInterval(id);
};
}, [updateInterval]);
return now;
}
function App() {
const { completed, running } = useSnapshot(inkState);
const now = useNow();
return /* @__PURE__ */ jsxs(Fragment, { children: [
/* @__PURE__ */ jsx(Static, { items: completed, children: (item) => {
return /* @__PURE__ */ jsxs(Text, { children: [
item.success ? logSymbols2.success : logSymbols2.error,
" ",
item.song.index,
"/",
item.totalLength,
" ",
item.success ? item.skip ? "下载跳过" : "下载成功" : "下载失败",
" ",
item.file
] }, item.index);
} }),
running.map((item) => {
return /* @__PURE__ */ jsxs(Text, { children: [
now - item.started < 100 ? logSymbols2.info : /* @__PURE__ */ jsx(Text, { color: "green", children: /* @__PURE__ */ jsx(Spinner, { type: "dots" }) }),
" ",
item.song.index,
"/",
item.totalLength,
" 下载中",
" ",
/* @__PURE__ */ jsx(ProgressBar, { progress: item.progress }),
" ",
`(${Math.round(item.progress * 100)}%)`.padStart(5, " "),
" ",
item.retry ? `第${item.retry}次重试中 ` : null,
item.file
] }, item.index);
})
] });
}
function ProgressBar({ progress }) {
if (progress < 0) progress = 0;
if (progress > 1) progress = 1;
const width = 10;
const filledChar = "=";
const filledLen = Math.round(width * progress);
const restChar = " ";
const restLen = width - filledLen;
return /* @__PURE__ */ jsxs(Text, { children: [
"[",
/* @__PURE__ */ jsxs(Text, { color: "green", children: [
filledChar.repeat(filledLen),
restChar.repeat(restLen)
] }),
"]"
] });
}
// src/index.ts
var typeItems = [
{
type: "playlist",
typeText: "列表",
adapter: PlaylistAdapter
},
{
type: "album",
typeText: "专辑",
adapter: AlbumAdapter
},
{
type: "djradio",
typeText: "电台",
adapter: DjradioAdapter
}
];
async function downloadSong(options) {
const { progress } = options;
if (progress) {
return downloadSongWithInk(options);
} else {
return downloadSongPlain(options);
}
}
async function downloadSongPlain(options) {
const { url, file, song, totalLength, retryTimeout, retryTimes, skipExists } = options;
let skip = false;
try {
;
({ skip } = await dl2({
url,
file,
skipExists,
retry: {
timeout: retryTimeout,
times: retryTimes,
onerror: function(e, i) {
console.log(`${LogSymbols.warning} ${song.index}/${totalLength} ${i + 1}次失败 ${file}`);
}
}
}));
} catch (e) {
console.log(`${LogSymbols.error} ${song.index}/${totalLength} 下载失败 ${file}`);
console.error(e.stack || e);
return;
}
console.log(
`${LogSymbols.success} ${song.index}/${totalLength} ${skip ? "下载跳过" : "下载成功"} ${file}`
);
}
function getType(url) {
const item = typeItems.find((item2) => url.includes(item2.type));
if (item) return item;
if (/#\/radio/.exec(url)) {
return typeItems.find((item2) => item2.type === "djradio");
}
const msg = "unsupported type";
throw new Error(msg);
}
function getAdapter(url) {
const { adapter } = getType(url);
return new adapter(url);
}
function getFileName({
format,
song,
url,
// 专辑 or playlist 名称
name
}) {
const typesItem = getType(url);
["typeText", "type"].forEach((t) => {
const val = filenamify(String(typesItem[t]));
format = format.replace(new RegExp(":" + t, "ig"), val);
});
const keys = ["songName", "singer", "albumName", "rawIndex", "index", "ext"];
keys.forEach((token) => {
const val = filenamify(String(song[token]));
format = format.replace(new RegExp(":" + token, "ig"), val);
});
format = format.replace(new RegExp(":name", "ig"), filenamify(name));
if (typesItem.type === "djradio") {
const { programDate, programOrder } = song;
if (programDate) {
format = format.replace(new RegExp(":programDate"), filenamify(programDate));
}
if (programOrder) {
format = format.replace(new RegExp(":programOrder"), filenamify(programOrder.toString()));
}
}
if (song.isFreeTrial) {
const dir = path.dirname(format);
const ext = path.extname(format);
const base = path.basename(format, ext);
format = path.join(dir, `${base} [试听]${ext}`);
}
return format;
}
export {
__filename,
DEFAULT_COOKIE_FILE,
readCookie,
baseDebug,
typeItems,
downloadSong,
downloadSongPlain,
getType,
getAdapter,
getFileName
};