'use strict'; const zod = require('zod'); const memofunc = require('memofunc'); const animegarden = require('animegarden'); const color = require('@breadc/color'); const core = require('@animespace/core'); const createDebug = require('debug'); const width = require('string-width'); const map = require('@onekuma/map'); const fs = require('fs-extra'); const path = require('pathe'); const node_child_process = require('node:child_process'); const defu = require('defu'); const libaria2 = require('libaria2'); const ui = require('@naria2/node/ui'); const Progress = require('cli-progress'); function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e.default : e; } const createDebug__default = /*#__PURE__*/_interopDefaultCompat(createDebug); const width__default = /*#__PURE__*/_interopDefaultCompat(width); const fs__default = /*#__PURE__*/_interopDefaultCompat(fs); const path__default = /*#__PURE__*/_interopDefaultCompat(path); const Progress__default = /*#__PURE__*/_interopDefaultCompat(Progress); const DOT = color.dim("\u2022"); const ANIMEGARDEN = "AnimeGarden"; createDebug__default("animegarden"); async function generateDownloadTask(system, anime, resources, force = false) { const library = await anime.library(); const ordered = groupResources(system, anime, resources); const videos = []; for (const [_ep, { fansub, resources: resources2 }] of ordered) { resources2.sort((lhs, rhs) => { const tl = lhs.title; const tr = rhs.title; for (const [_, order] of Object.entries(anime.plan.preference.keyword.order)) { for (const k of order) { const key = k.toLowerCase(); const hl = tl.toLowerCase().indexOf(key) !== -1; const hr = tr.toLowerCase().indexOf(key) !== -1; if (hl !== hr) { if (hl) { return -1; } else { return 1; } } } } return new Date(rhs.createdAt).getTime() - new Date(lhs.createdAt).getTime(); }); const res = resources2[0]; if (force || !library.videos.find((r) => r.source.magnet?.split("/").at(-1) === res.href.split("/").at(-1))) { const info = core.parseEpisode(anime, res.title, { metadata: (info2) => ({ fansub: res.fansub?.name ?? res.publisher.name ?? info2.release.group ?? "fansub" }) }); if (core.isValidEpisode(info)) { videos.push({ video: { filename: anime.formatFilename({ type: info.type, fansub, episode: info.parsed.episode.number, // Raw episode number extension: info.parsed.file.extension }), naming: "auto", fansub, type: unifyType(info.type), season: info.parsed.season ? +info.parsed.season : void 0, episode: info.parsed.episode.number, // Raw episode number source: { type: "AnimeGarden", magnet: `https://garden.breadio.wiki/detail/${res.provider}/${res.href.split("/").at(-1)}` } }, resource: res }); } } } videos.sort((lhs, rhs) => { const ds = (lhs.video.season ?? 1) - (rhs.video.season ?? 1); if (ds !== 0) return ds; return lhs.video.episode - rhs.video.episode; }); return videos; } function groupResources(system, anime, resources) { const logger = system.logger.withTag("animegarden"); const map$1 = new map.MutableMap([]); for (const r of resources) { if (anime.plan.preference.keyword.exclude.some((k) => r.title.indexOf(k) !== -1)) { continue; } const episode = core.parseEpisode(anime, r.title, { metadata: (info) => ({ fansub: r.fansub?.name ?? r.publisher.name ?? info.release.group ?? "fansub" }) }); if (episode && core.isValidEpisode(episode)) { if (episode.type === "TV") { if (!core.hasEpisodeNumberAlt(episode)) { const fansub = episode.metadata.fansub; if (fansub === "fansub" || anime.plan.fansub.includes(fansub)) { map$1.getOrPut( core.getEpisodeKey(episode), () => new map.MutableMap([]) ).getOrPut(fansub, () => []).push(r); } } } else if (["\u7535\u5F71", "\u7279\u522B\u7BC7"].includes(episode.type)) { const fansub = episode.metadata.fansub; if (fansub === "fansub" || anime.plan.fansub.includes(fansub)) { map$1.getOrPut( core.getEpisodeKey(episode), () => new map.MutableMap([]) ).getOrPut(fansub, () => []).push(r); } } } else { logger.log(`${color.lightYellow("Parse Error")} ${r.title}`); } } const fansubIds = new map.MutableMap(anime.plan.fansub.map((f, idx) => [f, idx])); const ordered = new map.MutableMap( map$1.entries().filter(([_ep, map2]) => map2.size > 0).map(([ep, map2]) => { const fansubs = map2.entries().toArray(); fansubs.sort((lhs, rhs) => { const fl = fansubIds.getOrDefault(lhs[0], 9999); const fr = fansubIds.getOrDefault(rhs[0], 9999); return fl - fr; }); return [ep, { fansub: fansubs[0][0], resources: fansubs[0][1] }]; }).toArray() ); return ordered; } function unifyType(type) { switch (type) { case "\u756A\u5267": case "TV": return "\u756A\u5267"; case "\u7535\u5F71": return "\u7535\u5F71"; case "\u7279\u522B\u7BC7": return "OVA"; default: return "\u756A\u5267"; } } const { Format, MultiBar, Presets, SingleBar } = Progress__default; function createProgressBar(option = {}) { const multibar = new MultiBar( { format(_options, params, payload) { const formatValue = Format.ValueFormat; const formatBar = Format.BarFormat; const percentage = Math.floor(params.progress * 100); const context = { bar: formatBar(params.progress, _options), percentage: formatValue(percentage, _options, "percentage"), total: params.total, value: params.value // eta: formatValue(params.eta, _options, 'eta'), // duration: formatValue(elapsedTime, _options, 'duration'), }; const suffix = option.suffix ? " | " + option.suffix(params.value, params.total, payload) : ""; return payload.title !== void 0 && typeof payload.title === "string" ? `${payload.title}` : `${context.bar} ${context.percentage}%` + suffix; }, stopOnComplete: false, clearOnComplete: true, hideCursor: true, forceRedraw: true }, Presets.shades_grey ); multibar.on("stop", () => { for (const line of multibar.loggingBuffer) { console.log(line.substring(0, line.length - 1)); } }); return { finish() { multibar.stop(); }, println(text) { multibar.log(text + "\n"); }, create(name, length) { const empty = multibar.create(length, 0, {}, { title: name }); const title = multibar.create(length, 0, {}, { title: name }); const progress = multibar.create(length, 0); title.update(0, { title: name }); empty.update(0, { title: "" }); return { stop() { empty.stop(); title.stop(); progress.stop(); }, remove() { this.stop(); multibar.remove(empty); multibar.remove(title); multibar.remove(progress); }, rename(newName) { name = newName; }, update(value, payload) { empty.update(value, { title: "" }); title.update(value, { title: name }); progress.update(value, payload); }, increment(value, payload) { empty.increment(value, { title: "" }); title.increment(value, { title: name }); progress.increment(value, payload); } }; } }; } async function runDownloadTask(system, anime, videos, client) { if (videos.length === 0) return; await client.start(); const multibar = createProgressBar({ suffix(value, total, payload) { if (value >= 100) { return "OK"; } const formatSize = (size) => size < 1024 * 1024 ? (size / 1024).toFixed(1) + " KB" : (size / 1024 / 1024).toFixed(1) + " MB"; let text = ""; if (payload.state) { text += payload.state; text += ` | ${Number(payload.completed)} B / ${Number(payload.total)} B`; } else { text += `${formatSize(Number(payload.completed))} / ${formatSize(Number(payload.total))}`; if (payload.speed) { text += ` | Speed: ${formatSize(payload.speed)}/s`; } } if (payload.connections) { text += ` | Connections: ${payload.connections}`; } return text; } }); const systemLogger = system.logger.withTag("animegarden"); const multibarLogger = { log(message) { multibar.println(`${message}`); }, info(message) { multibar.println(`${color.cyan("Info")} ${message}`); }, warn(message) { multibar.println(`${color.lightYellow("Warn")} ${message}`); }, error(message) { multibar.println(`${color.lightRed("Error")} ${message}`); } }; client.setLogger(multibarLogger); const cancelDeath = core.onDeath(async () => { multibar.finish(); }); const cancelUnhandledRej = core.onUnhandledRejection(() => { multibar.finish(); }); const cancelRefresh = loop( async () => { if (anime.dirty) { await anime.writeLibrary(); systemLogger.log( color.lightGreen(`Write`) + color.bold(` ${anime.plan.title} `) + color.lightGreen(`library file OK`) ); } }, 10 * 60 * 1e3 ); const tasks = videos.map(async (task) => { const bar = multibar.create(`${color.bold(task.video.filename)}`, 100); try { const { files } = await client.download( task.video.filename, task.resource.magnet + task.resource.tracker, { onStart() { bar.update(0, { speed: 0, connections: 0, completed: BigInt(0), total: BigInt(0), state: "Downloading metadata" }); }, onMetadataProgress(progress) { bar.update(0, { ...progress, state: "Downloading metadata" }); }, onProgress(payload) { const completed = Number(payload.completed); const total = Number(payload.total); const value = payload.total > 0 ? +(Math.ceil(1e3 * completed / total) / 10).toFixed(1) : 0; bar.update(value, { ...payload, state: "" }); }, onComplete() { bar.update(100); } } ); bar.update(100); bar.remove(); multibarLogger.log( `${color.lightGreen("Download")} ${color.bold(task.video.filename)} ${color.lightGreen("OK")}` ); if (files.length === 1) { const file = files[0]; if (task.video.naming === "auto") { task.video.filename = anime.formatFilename({ type: task.video.type, fansub: task.video.fansub, episode: task.video.episode, extension: path__default.extname(file).slice(1) || "mp4" }); } const resolvedEpisode = anime.resolveEpisode(task.video.episode, task.video.fansub); const resolvedSeason = anime.resolveSeason(task.video.type, task.video.season); const library = (await anime.library()).videos; const oldVideo = library.find( (v) => v.source.type === ANIMEGARDEN && anime.resolveEpisode(v.episode, v.fansub) === resolvedEpisode && (anime.resolveSeason(v.type, v.season) ?? 1) === (resolvedSeason ?? 1) // Find same episode after being resolved ); const bar2 = multibar.create(`${color.bold(task.video.filename)}`, 100); bar2.update(0, { speed: 0, connections: 0, completed: BigInt(0), total: BigInt(0), state: "Copying" }); try { const copyDelta = await anime.addVideoByCopy(file, task.video, { onProgress({ current, total: _total }) { const total = _total ?? current; const value = total > 0 ? +(Math.ceil(1e3 * current / total) / 10).toFixed(1) : 0; bar2.update(value, { speed: 0, connections: 0, completed: BigInt(current), total: BigInt(total), state: "Copying" }); } }); if (copyDelta) { const detailURL = `https://garden.breadio.wiki/detail/${task.resource.provider}/${task.resource.providerId}`; copyDelta.log = color.link(task.video.filename, detailURL); if (oldVideo) { multibarLogger.log(`${color.lightRed("Removing")} ${color.bold(oldVideo.filename)}`); await anime.removeVideo(oldVideo); } multibarLogger.log( `${color.lightGreen("Copy")} ${color.bold(task.video.filename)} ${color.lightGreen("OK")}` ); } } finally { bar2.stop(); bar2.remove(); } } else { multibar.println( `${color.lightYellow(`Warn`)} Resource ${color.link( task.resource.title, task.resource.href )} has multiple files` ); } } catch (error) { const defaultMessage = `Download ${color.link(task.resource.title, task.resource.href)} failed`; if (error instanceof Error && error?.message) { multibarLogger.error(error.message ?? defaultMessage); systemLogger.error(error); } else { multibarLogger.error(defaultMessage); } } finally { bar.stop(); } }); try { await Promise.all(tasks); multibar.finish(); } catch (error) { multibar.finish(); systemLogger.log(color.lightRed(`Failed to downloading resources of ${color.bold(anime.plan.title)}`)); systemLogger.error(error); } finally { if (anime.dirty) { await anime.writeLibrary(); systemLogger.log( color.lightGreen(`Write`) + color.bold(` ${anime.plan.title} `) + color.lightGreen(`library file OK`) ); } } cancelRefresh(); cancelUnhandledRej(); cancelDeath(); } function loop(fn, interval) { let timestamp; const wrapper = async () => { await fn(); timestamp = setTimeout(wrapper, interval); }; timestamp = setTimeout(wrapper, interval); const cancel = core.onDeath(() => { clearTimeout(timestamp); }); return () => { clearTimeout(timestamp); cancel(); }; } function formatAnimeGardenSearchURL(anime) { return `https://garden.breadio.wiki/resources/1?include=${encodeURIComponent( JSON.stringify(anime.plan.keywords.include) )}&exclude=${encodeURIComponent( JSON.stringify(anime.plan.keywords.exclude) )}&after=${encodeURIComponent(anime.plan.date.toISOString())}`; } function printKeywords(anime, logger) { const include = anime.plan.keywords.include; const sum = include.reduce((acc, t) => acc + width__default(t), 0); if (sum > 50) { logger.log(color.dim("Include keywords | ") + color.underline(overflowText(include[0], 50))); for (const t of include.slice(1)) { logger.log(` ${color.dim("|")} ${color.underline(overflowText(t, 50))}`); } } else { logger.log( `${color.dim("Include keywords")} ${include.map((t) => color.underline(overflowText(t, 50))).join(color.dim(" | "))}` ); } if (anime.plan.keywords.exclude.length > 0) { logger.log( `${color.dim(`Exclude keywords`)} [ ${anime.plan.keywords.exclude.map((t) => color.underline(t)).join(" , ")} ]` ); } } function printFansubs(anime, logger) { const fansubs = anime.plan.fansub; logger.log( `${color.dim("Prefer fansubs")} ${fansubs.length === 0 ? `See ${color.link("AnimeGarden", formatAnimeGardenSearchURL(anime))} to select some fansubs` : fansubs.join(color.dim(" > "))}` ); } function overflowText(text, length, rest = "...") { if (width__default(text) <= length) { return text; } else { return text.slice(0, length - rest.length) + rest; } } var __defProp$2 = Object.defineProperty; var __defNormalProp$2 = (obj, key, value) => key in obj ? __defProp$2(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField$2 = (obj, key, value) => { __defNormalProp$2(obj, typeof key !== "symbol" ? key + "" : key, value); return value; }; class ResourcesCache { constructor(system) { __publicField$2(this, "system"); __publicField$2(this, "root"); __publicField$2(this, "animeRoot"); __publicField$2(this, "resourcesRoot"); __publicField$2(this, "valid", false); __publicField$2(this, "recentResources", []); __publicField$2(this, "errors", []); __publicField$2(this, "recentResponse"); this.system = system; this.root = system.space.storage.cache.join("animegarden"); this.animeRoot = this.root.join("anime"); this.resourcesRoot = this.root.join("resources"); } reset() { this.valid = false; this.recentResources = []; this.recentResponse = void 0; this.errors = []; } disable() { this.reset(); } async loadLatestResources() { try { const content = await this.resourcesRoot.join("latest.json").readText(); return JSON.parse(content); } catch { return void 0; } } async updateLatestResources(resp) { try { const copied = { ...resp }; Reflect.deleteProperty(copied, "ok"); Reflect.deleteProperty(copied, "complete"); await this.resourcesRoot.join("latest.json").writeText(JSON.stringify(copied, null, 2)); } catch { } } async initialize() { await Promise.all([this.animeRoot.ensureDir(), this.resourcesRoot.ensureDir()]); const latest = await this.loadLatestResources(); const timestamp = latest?.resources[0]?.fetchedAt ? new Date(latest.resources[0].fetchedAt) : void 0; const invalid = timestamp === void 0 || (/* @__PURE__ */ new Date()).getTime() - timestamp.getTime() > 7 * 24 * 60 * 60 * 1e3; const ac = new AbortController(); const resp = await animegarden.fetchResources(core.ufetch, { type: "\u52D5\u756B", retry: 10, count: -1, signal: ac.signal, tracker: true, headers: { "Cache-Control": "no-store" }, progress(delta) { if (invalid) { ac.abort(); return; } const newItems = delta.filter( (item) => new Date(item.fetchedAt).getTime() > timestamp.getTime() ); if (newItems.length === 0) { ac.abort(); } } }); this.valid = !invalid || !resp.filter || !resp.timestamp; const oldIds = new Set(latest?.resources.map((r) => r.id) ?? []); this.recentResources = resp.resources.filter((r) => !oldIds.has(r.id)); this.recentResponse = resp; } async finalize() { if (this.errors.length === 0 && this.recentResponse) { await this.updateLatestResources(this.recentResponse); } this.reset(); } async loadAnimeResources(anime) { try { const root = this.animeRoot.join(anime.relativeDirectory); await root.ensureDir(); return JSON.parse(await root.join("resources.json").readText()); } catch { return void 0; } } async updateAnimeResources(anime, resp) { try { const root = this.animeRoot.join(anime.relativeDirectory); const copied = { ...resp, prefer: { fansub: anime.plan.fansub } }; Reflect.deleteProperty(copied, "ok"); Reflect.deleteProperty(copied, "complete"); await root.join("resources.json").writeText(JSON.stringify(copied, null, 2)); } catch { } } async clearAnimeResources(anime) { try { const root = this.animeRoot.join(anime.relativeDirectory); await root.join("resources.json").remove(); } catch { } } async load(anime) { const cache = await this.loadAnimeResources(anime); if (this.valid && cache?.filter) { const validateFilter = (cache2) => { if (!cache2.filter.after || new Date(cache2.filter.after).getTime() !== anime.plan.date.getTime()) { return false; } const stringify = (keys) => (keys ?? []).join(","); if (!cache2.filter.include || stringify(cache2.filter.include) !== stringify(anime.plan.keywords.include)) { return false; } if (stringify(cache2.filter.exclude) !== stringify(anime.plan.keywords.exclude)) { return false; } if (stringify(cache2.prefer.fansub) !== stringify(anime.plan.fansub)) { return false; } return true; }; const filter = animegarden.makeResourcesFilter({ type: "\u52D5\u756B", after: anime.plan.date, include: anime.plan.keywords.include, exclude: anime.plan.keywords.exclude }); const relatedRes = this.recentResources.filter(filter); if (validateFilter(cache) && relatedRes.length === 0) { return cache.resources; } } try { const ac = new AbortController(); const resp = await animegarden.fetchResources(core.ufetch, { type: "\u52D5\u756B", after: anime.plan.date, include: anime.plan.keywords.include, exclude: anime.plan.keywords.exclude, tracker: true, retry: 10, count: -1, signal: ac.signal, progress(delta) { for (const item of delta) { } } }); await this.updateAnimeResources(anime, resp); return resp.resources; } catch (error) { this.errors.push(error); throw error; } } } async function clearAnimeResourcesCache(system, anime) { const cache = new ResourcesCache(system); await cache.clearAnimeResources(anime); } const useResourcesCache = memofunc.memoAsync(async (system) => { const cache = new ResourcesCache(system); await cache.initialize(); return cache; }); async function fetchAnimeResources(system, anime) { const cache = await useResourcesCache(system); try { return await cache.load(anime); } catch (error) { console.error(error); throw error; } } function registerCli(system, cli, getClient) { const logger = system.logger.withTag("animegarden"); cli.command("garden list [keyword]", "List videos of anime from AnimeGarden").option("--onair", "Only display onair animes").action(async (keyword, options) => { const animes = await filterAnimes(keyword, options); for (const anime of animes) { const animegardenURL = formatAnimeGardenSearchURL(anime); logger.log( `${color.bold(anime.plan.title)} (${color.link( `Bangumi: ${anime.plan.bgm}`, `https://bangumi.tv/subject/${anime.plan.bgm}` )}, ${color.link("AnimeGarden", animegardenURL)})` ); printKeywords(anime, logger); printFansubs(anime, logger); const resources = await fetchAnimeResources(system, anime); const videos = await generateDownloadTask(system, anime, resources, true); const lib = await anime.library(); for (const { video, resource } of videos) { const detailURL = `https://garden.breadio.wiki/detail/${resource.provider}/${resource.providerId}`; let extra = ""; if (!lib.videos.find((v) => v.source.magnet === video.source.magnet)) { const aliasVideo = lib.videos.find( (v) => v.source.type !== ANIMEGARDEN && v.episode === video.episode ); if (aliasVideo) { extra = `overwritten by ${color.bold(aliasVideo.filename)}`; } else { extra = color.lightYellow("Not yet downloaded"); } } logger.log(` ${DOT} ${color.link(video.filename, detailURL)} ${extra ? `(${extra})` : ""}`); } logger.log(""); } }); cli.command("garden clean", "Clean downloaded and animegarden cache").option("-y, --yes").option("-e, --ext ", { description: 'Clean downloaded files with extensions (splitted by ",")', default: "mp4,mkv,aria2" }).action(async (options) => { const client = getClient(system); const extensions = options.ext.split(","); const exts = extensions.map((e) => e.startsWith(".") ? e : "." + e); await client.clean(exts); }); async function filterAnimes(keyword, options) { return (await core.loadAnime(system, (a) => options.onair ? a.plan.status === "onair" : true)).filter( (a) => !keyword || a.plan.title.includes(keyword) || Object.values(a.plan.translations).flat().some((t) => t.includes(keyword)) ); } } const DefaultTrackers = [ "http://tracker.gbitt.info/announce", "https://tracker.lilithraws.cf/announce", "https://tracker1.520.jp/announce", "http://www.wareztorrent.com/announce", "https://tr.burnabyhighstar.com/announce", "http://tk.greedland.net/announce", "http://trackme.theom.nz:80/announce", "https://tracker.foreverpirates.co:443/announce", "http://tracker3.ctix.cn:8080/announce", "https://tracker.m-team.cc/announce.php", "https://tracker.gbitt.info:443/announce", "https://tracker.loligirl.cn/announce", "https://tp.m-team.cc:443/announce.php", "https://tr.abir.ga/announce", "http://tracker.electro-torrent.pl/announce", "http://1337.abcvg.info/announce", "https://trackme.theom.nz:443/announce", "https://tracker.tamersunion.org:443/announce", "https://tr.abiir.top/announce", "wss://tracker.openwebtorrent.com:443/announce", "http://www.all4nothin.net:80/announce.php", "https://tracker.kuroy.me:443/announce", "https://1337.abcvg.info:443/announce", "http://torrentsmd.com:8080/announce", "https://tracker.gbitt.info/announce", "udp://tracker.sylphix.com:6969/announce" ]; var __defProp$1 = Object.defineProperty; var __defNormalProp$1 = (obj, key, value) => key in obj ? __defProp$1(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField$1 = (obj, key, value) => { __defNormalProp$1(obj, typeof key !== "symbol" ? key + "" : key, value); return value; }; class DownloadClient { constructor(system) { __publicField$1(this, "_system"); __publicField$1(this, "logger"); this._system = system; } get system() { return this._system; } set system(system) { this._system = system; this.initialize(system); } setLogger(logger) { this.logger = logger; } async clean(extensions = [".mp4", ".mkv"]) { } } var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => { __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); return value; }; class Aria2Client extends DownloadClient { constructor(system, options = {}) { super(system); __publicField(this, "options"); __publicField(this, "consola"); __publicField(this, "started", false); __publicField(this, "client"); __publicField(this, "webUI"); __publicField(this, "version"); __publicField(this, "heartbeat"); __publicField(this, "gids", /* @__PURE__ */ new Map()); this.consola = system.logger.withTag("aria2"); this.options = defu.defu(options, { binary: "aria2c", directory: system.space.root.resolve("./download").path, secret: "animespace", args: [], proxy: false, trackers: [.../* @__PURE__ */ new Set([...options.trackers ?? [], ...DefaultTrackers])], debug: { pipe: false, log: void 0 } }); } initialize(system) { this.consola = system.logger.withTag("aria2"); this.options.directory = system.space.root.resolve(this.options.directory).path; if (this.options.debug.log) { this.options.debug.log = system.space.root.resolve(this.options.debug.log).path; } } async download(key, magnet, options = {}) { await this.start(); if (!this.started || !this.client) { throw new Error("aria2 has not started"); } const directory = this.options.directory; const proxy = typeof this.options.proxy === "string" ? this.options.proxy : core.getProxy(); const gid = await this.client.addUri([magnet], { dir: directory, "bt-save-metadata": true, "bt-tracker": this.options.trackers.join(","), "no-proxy": this.options.proxy === false ? true : false, "all-proxy": this.options.proxy !== false ? proxy : void 0 }).catch((error) => { this.consola.error(error); return void 0; }); if (!gid) { throw new Error("Start downloading task failed"); } const that = this; const client = this.client; return new Promise((res, rej) => { const task = { key, state: "waiting", magnet, gids: { metadata: gid, files: /* @__PURE__ */ new Set() }, progress: map.MutableMap.empty(), options, async onDownloadStart(gid2) { const status = await client.tellStatus(gid2); await that.updateStatus(task, status); }, async onError() { rej(new Error("aria2c is stopped")); }, async onDownloadError(gid2) { that.gids.delete(gid2); const status = await client.tellStatus(gid2); await that.updateStatus(task, status); if (task.state === "error") { if (status.errorMessage && status.errorMessage.indexOf("[METADATA]") === -1) { const REs = [ /File (.*) exists, but a control file\(\*.aria2\) does not exist/, /文件 (.*) 已存在,但是控制文件 \(\*.aria2\) 不存在/, /InfoHash (\w+) is already registered/ ]; for (const RE of REs) { if (RE.test(status.errorMessage)) { const files = status.files.map((f) => f.path); res({ files }); return; } } } rej(new Error(status.errorMessage)); } }, async onBtDownloadComplete(gid2) { that.gids.delete(gid2); const status = await client.tellStatus(gid2); await that.updateStatus(task, status, "complete"); if (task.state === "complete") { const statuses = await Promise.all( [...task.gids.files].map((gid3) => client.tellStatus(gid3)) ); const files = []; for (const status2 of statuses) { for (const f of status2.files) { files.push(f.path); } } res({ files }); } } }; this.gids.set(gid, task); }); } registerCallback() { this.client.addListener("aria2.onDownloadStart", async (event) => { const { gid } = event; if (this.gids.has(gid)) { await this.gids.get(gid).onDownloadStart(gid); } }); this.client.addListener("aria2.onDownloadError", async ({ gid }) => { if (this.gids.has(gid)) { await this.gids.get(gid).onDownloadError(gid); } }); this.client.addListener("aria2.onBtDownloadComplete", async ({ gid }) => { if (this.gids.has(gid)) { await this.gids.get(gid).onBtDownloadComplete(gid); } }); this.heartbeat = setInterval(async () => { if (this.client && await this.client.getVersion().catch(() => false)) { await Promise.all( [...this.gids].map(async ([gid, task]) => { const status = await this.client.tellStatus(gid); await this.updateStatus(task, status); if (task.state === "complete") { await task.onBtDownloadComplete(gid); } else if (task.state === "error") { await task.onDownloadError(gid); } }) ); } else { const map$1 = new map.MutableMap(); for (const task of this.gids.values()) { map$1.set(task.key, task); } for (const task of map$1.values()) { await task.onError(); } await this.close(); } }, 500); } async updateStatus(task, status, nextState) { const oldState = task.state; const gid = status.gid; const connections = Number(status.connections); const speed = Number(status.downloadSpeed); if (oldState === "error" || oldState === "complete") { return; } const force = !task.progress.has(gid); const progress = task.progress.getOrPut(gid, () => ({ state: "active", completed: status.completedLength, total: status.totalLength, connections, speed })); const oldProgress = { ...progress }; const updateProgress = () => { progress.completed = status.completedLength; progress.total = status.totalLength; progress.connections = connections; progress.speed = speed; }; if (task.gids.metadata === gid) { switch (status.status) { case "waiting": if (oldProgress.state === "active") { updateProgress(); } break; case "active": if (oldProgress.state === "active") { updateProgress(); } if (task.state === "waiting") { task.state = "metadata"; } break; case "error": task.state = "error"; progress.state = "error"; updateProgress(); break; case "complete": this.gids.delete(gid); progress.state = "complete"; updateProgress(); const followed = core.resolveStringArray(status.followedBy); for (const f of followed) { task.gids.files.add(f); this.gids.set(f, task); } if (task.state === "metadata" || task.state === "waiting") { task.state = "downloading"; } else { (this.logger ?? this.consola).error(`Unexpected previous task state ${task.state}`); } break; case "paused": (this.logger ?? this.consola).warn(`Download task ${task.key} was unexpectedly paused`); break; } const payload = { completed: progress.completed, total: progress.total, connections, speed }; const dirty = force || oldState !== task.state || oldProgress.state !== progress.state || oldProgress.completed !== progress.completed || oldProgress.total !== progress.total || oldProgress.connections !== progress.connections || oldProgress.speed !== progress.speed; if (task.state === "waiting" || task.state === "metadata") { if (dirty) { await task.options.onMetadataProgress?.(payload); } } else if (task.state === "downloading") { await task.options.onMetadataComplete?.(payload); } else if (task.state === "error") { await task.options.onError?.({ message: status.errorMessage, code: status.errorCode }); } else { (this.logger ?? this.consola).error( `Download task ${task.key} entered unexpectedly state ${task.state}` ); } } else { switch (status.status) { case "waiting": case "active": if (oldProgress.state === "active") { updateProgress(); } break; case "error": task.state = "error"; progress.state = "error"; updateProgress(); break; case "complete": progress.state = "complete"; updateProgress(); break; case "paused": (this.logger ?? this.consola).warn(`Download task ${task.key} was unexpectedly paused`); break; } if (nextState) { progress.state = nextState; } let active = false; let completed = BigInt(0), total = BigInt(0); for (const p of task.progress.values()) { completed += p.completed; total += p.total; if (p.state === "active") { active = true; } } const payload = { completed, total, connections, speed }; const dirty = force || oldState !== task.state || oldProgress.state !== progress.state || oldProgress.completed !== progress.completed || oldProgress.total !== progress.total || oldProgress.connections !== progress.connections || oldProgress.speed !== progress.speed; if (progress.state === "active") { if (dirty) { await task.options.onProgress?.(payload); } } else if (progress.state === "complete") { if (active) { if (dirty) { await task.options.onProgress?.(payload); } } else { task.state = "complete"; await task.options.onComplete?.(payload); } } else if (progress.state === "error") { await task.options.onError?.({ message: status.errorMessage, code: status.errorCode }); } } } async start(force = false) { if (!force) { if (this.started || this.client || this.version) return; } this.started = true; if (this.options.debug.log) { try { await fs__default.ensureDir(path__default.dirname(this.options.debug.log)); if (await fs__default.exists(this.options.debug.log)) { await fs__default.rm(this.options.debug.log); } this.consola.log(color.dim(`aria2 debug log will be written to ${this.options.debug.log}`)); } catch { } } const rpcPort = 16800 + Math.round(Math.random() * 1e4); const listenPort = 26800 + Math.round(Math.random() * 1e4); const env = { ...process.env }; delete env["all_proxy"]; delete env["ALL_PROXY"]; delete env["http_proxy"]; delete env["https_proxy"]; delete env["HTTP_PROXY"]; delete env["HTTPS_PROXY"]; const child = node_child_process.spawn( this.options.binary, [ // Bittorent // https://aria2.github.io/manual/en/html/aria2c.html#cmdoption-bt-detach-seed-only `--bt-detach-seed-only`, `--dht-listen-port=${listenPort + 101}-${listenPort + 200}`, `--listen-port=${listenPort}-${listenPort + 100}`, // RPC related "--enable-rpc", "--rpc-listen-all", "--rpc-allow-origin-all", `--rpc-listen-port=${rpcPort}`, `--rpc-secret=${this.options.secret}`, // Debug log ...this.options.debug.log ? [`--log=${this.options.debug.log}`] : [], // Rest arguments ...this.options.args ], { cwd: this.system.space.root.path, env } ); return new Promise((res, rej) => { if (this.options.debug.pipe) { child.stdout.on("data", (chunk) => { console.log(chunk.toString()); }); child.stderr.on("data", (chunk) => { console.log(chunk.toString()); }); } child.stdout.once("data", async (_chunk) => { try { this.client = new libaria2.WebSocket.Client({ protocol: "ws", host: "localhost", port: rpcPort, auth: { secret: this.options.secret } }); this.gids.clear(); this.registerCallback(); const version = await this.client.getVersion(); this.version = version.version; this.webUI = await ui.launchWebUI({ rpc: { port: rpcPort, secret: this.options.secret } }); this.consola.log( color.dim( `aria2 v${this.version} is running on the port ${color.link(rpcPort + "", this.webUI.url)}` ) ); res(); } catch (error) { rej(error); } }); child.addListener("error", async (error) => { this.consola.error(color.dim(`Some error happened in aria2`)); await this.close().catch(() => { }); }); child.addListener("exit", async () => { await this.close().catch(() => { }); }); }); } async close() { clearInterval(this.heartbeat); if (this.client) { const version = this.version; const res = await this.client.shutdown().catch(() => "OK"); await Promise.all([ this.client.close().catch(() => { }), new Promise((res2) => { if (this.webUI.server) { this.webUI.server.close(() => res2()); } else { res2(); } }) ]); this.client = void 0; this.webUI = void 0; this.version = void 0; this.started = false; if (res === "OK") { this.consola.log(color.dim(`aria2 v${version} has been closed`)); return true; } else { return false; } } else { this.client = void 0; this.version = void 0; this.started = false; return false; } } async clean(extensions = []) { const files = await fs__default.readdir(this.options.directory).catch(() => []); await Promise.all( files.map(async (file) => { if (extensions.includes(path__default.extname(file).toLowerCase())) { const p = path__default.join(this.options.directory, file); try { await fs__default.remove(p); } catch (error) { this.consola.error(error); } } }) ); } } class WebtorrentClient extends DownloadClient { async download(magnet, outDir, options) { throw new Error("Method not implemented."); } initialize(system) { } async start() { } async close() { return true; } } function makeClient(provider, system, options) { switch (provider) { case "aria2": return new Aria2Client(system, options); case "qbittorrent": case "webtorrent": default: return new WebtorrentClient(system); } } const memoClient = memofunc.memo( (provider, system, options) => { const client = makeClient(provider, system, options); core.onDeath(async () => { await client.close(); }); return client; }, { serialize() { return []; } } ); function AnimeGarden(options) { const provider = options.provider ?? "webtorrent"; const getClient = (sys) => memoClient(provider, sys, options); let shouldClearCache = false; return { name: "animegarden", options, schema: { plan: zod.z.object({ bgm: zod.z.coerce.string() }) }, command(system, cli) { registerCli(system, cli, getClient); }, writeLibrary: { async post(system, anime) { if (shouldClearCache) { clearAnimeResourcesCache(system, anime); } } }, introspect: { async pre(system) { shouldClearCache = true; }, async handleUnknownVideo(system, anime, video) { if (video.source.type === ANIMEGARDEN && video.source.magnet) { const logger = system.logger.withTag("animegarden"); const client = getClient(system); const resource = await animegarden.fetchResourceDetail( core.ufetch, "dmhy", video.source.magnet.split("/").at(-1) ); try { if (resource) { await client.start(); logger.log( `${color.lightBlue("Downloading")} ${color.bold(video.filename)} ${color.dim("from")} ${color.link( `AnimeGarden`, video.source.magnet )}` ); await anime.removeVideo(video); await runDownloadTask( system, anime, [ { video, resource: { ...resource, // This should have tracker magnet: resource.magnet.href, tracker: "" } } ], client ); } } catch (error) { logger.error(error); return video; } finally { return video; } } return void 0; } }, refresh: { async pre(system, options2) { const cache = await useResourcesCache(system); if (options2.filter !== void 0) { cache.disable(); } }, async post(system) { const cache = await useResourcesCache(system); cache.finalize(); useResourcesCache.clear(); }, async refresh(system, anime) { const logger = system.logger.withTag("animegarden"); logger.log(""); logger.log( `${color.lightBlue("Fetching resources")} ${color.bold(anime.plan.title)} (${color.link( `Bangumi: ${anime.plan.bgm}`, `https://bangumi.tv/subject/${anime.plan.bgm}` )})` ); printKeywords(anime, logger); printFansubs(anime, logger); const animegardenURL = formatAnimeGardenSearchURL(anime); const resources = await fetchAnimeResources(system, anime).catch(() => void 0); if (resources === void 0) { logger.log( `${color.lightRed("Found resources")} ${color.dim("from")} ${color.link( "AnimeGarden", animegardenURL )} ${color.lightRed("failed")}` ); return; } const newVideos = await generateDownloadTask(system, anime, resources); const oldVideos = (await anime.library()).videos.filter( (v) => v.source.type === ANIMEGARDEN ); logger.log( `${color.dim("There are")} ${color.lightCyan(oldVideos.length + " resources")} ${color.dim( "downloaded from" )} ${color.link("AnimeGarden", animegardenURL)}` ); if (newVideos.length === 0) { return; } logger.log( `${color.lightBlue(`Downloading ${newVideos.length} resources`)} ${color.dim("from")} ${color.link( "AnimeGarden", animegardenURL )}` ); for (const { video, resource } of newVideos) { const detailURL = `https://garden.breadio.wiki/detail/${resource.provider}/${resource.providerId}`; logger.log(` ${DOT} ${color.link(video.filename, detailURL)}`); } try { const client = getClient(system); client.system = system; await runDownloadTask(system, anime, newVideos, client); } catch (error) { logger.error(error); } } } }; } exports.AnimeGarden = AnimeGarden;