UNPKG

yun-playlist-downloader

Version:
609 lines (595 loc) 16.8 kB
// 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 };