@animespace/animegarden
Version:
Create your own Anime Space
1,375 lines (1,359 loc) • 45.4 kB
JavaScript
import { z } from 'zod';
import { memoAsync, memo } from 'memofunc';
import { fetchResources, makeResourcesFilter, fetchResourceDetail } from '@animegarden/client';
import { dim, lightYellow, bold, lightGreen, link, lightRed, cyan, underline, lightBlue, lightCyan } from '@breadc/color';
import { parseEpisode, isValidEpisode, hasEpisodeNumberAlt, getEpisodeKey, onDeath, onUnhandledRejection, ufetch, loadAnime, getProxy, resolveStringArray } from '@animespace/core';
import createDebug from 'debug';
import width from 'string-width';
import { MutableMap } from '@onekuma/map';
import fs from 'fs-extra';
import path from 'pathe';
import { spawn } from 'node:child_process';
import { defu } from 'defu';
import { WebSocket } from 'libaria2';
import { launchWebUI } from '@naria2/node/ui';
import Progress from 'cli-progress';
const DOT = dim("\u2022");
const ANIMEGARDEN = "AnimeGarden";
const debug = createDebug("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;
}
}
}
}
const createdAt = new Date(rhs.createdAt).getTime() - new Date(lhs.createdAt).getTime();
if (createdAt) {
return createdAt;
}
return new Date(rhs.fetchedAt).getTime() - new Date(lhs.fetchedAt).getTime();
});
const res = resources2[0];
if (force || !library.videos.find((r) => r.source.magnet?.split("/").at(-1) === res.href.split("/").at(-1))) {
const info = parseEpisode(anime, res.title, {
metadata: (info2) => ({
fansub: res.fansub?.name ?? res.publisher.name ?? info2.release.group ?? "fansub"
})
});
if (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://animes.garden/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 = new MutableMap([]);
for (const r of resources) {
if (anime.plan.preference.keyword.exclude.some((k) => r.title.indexOf(k) !== -1)) {
continue;
}
const episode = parseEpisode(anime, r.title, {
metadata: (info) => ({
fansub: r.fansub?.name ?? r.publisher.name ?? info.release.group ?? "fansub"
})
});
if (episode && isValidEpisode(episode)) {
if (episode.type === "TV") {
if (!hasEpisodeNumberAlt(episode)) {
const fansub = episode.metadata.fansub;
if (fansub === "fansub" || anime.plan.fansub.includes(fansub)) {
map.getOrPut(
getEpisodeKey(episode),
() => new 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.getOrPut(
getEpisodeKey(episode),
() => new MutableMap([])
).getOrPut(fansub, () => []).push(r);
}
}
} else {
logger.log(`${lightYellow("Parse Error")} ${r.title}`);
}
}
const fansubIds = new MutableMap(anime.plan.fansub.map((f, idx) => [f, idx]));
const ordered = new MutableMap(
map.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;
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(`${cyan("Info")} ${message}`);
},
warn(message) {
multibar.println(`${lightYellow("Warn")} ${message}`);
},
error(message) {
multibar.println(`${lightRed("Error")} ${message}`);
}
};
client.setLogger(multibarLogger);
const cancelDeath = onDeath(async () => {
multibar.finish();
});
const cancelUnhandledRej = onUnhandledRejection(() => {
multibar.finish();
});
const cancelRefresh = loop(
async () => {
if (anime.dirty) {
await anime.writeLibrary();
systemLogger.log(
lightGreen(`Write`) + bold(` ${anime.plan.title} `) + lightGreen(`library file OK`)
);
}
},
10 * 60 * 1e3
);
const tasks = videos.map(async (task) => {
const bar = multibar.create(`${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(
`${lightGreen("Download")} ${bold(task.video.filename)} ${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.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(`${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://animes.garden/detail/${task.resource.provider}/${task.resource.providerId}`;
copyDelta.log = link(task.video.filename, detailURL);
if (oldVideo) {
multibarLogger.log(`${lightRed("Removing")} ${bold(oldVideo.filename)}`);
await anime.removeVideo(oldVideo);
}
multibarLogger.log(
`${lightGreen("Copy")} ${bold(task.video.filename)} ${lightGreen("OK")}`
);
}
} finally {
bar2.stop();
bar2.remove();
}
} else {
multibar.println(
`${lightYellow(`Warn`)} Resource ${link(
task.resource.title,
task.resource.href
)} has multiple files`
);
}
} catch (error) {
const defaultMessage = `Download ${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(lightRed(`Failed to downloading resources of ${bold(anime.plan.title)}`));
systemLogger.error(error);
} finally {
if (anime.dirty) {
await anime.writeLibrary();
systemLogger.log(
lightGreen(`Write`) + bold(` ${anime.plan.title} `) + 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 = onDeath(() => {
clearTimeout(timestamp);
});
return () => {
clearTimeout(timestamp);
cancel();
};
}
function formatAnimeGardenSearchURL(anime) {
const include = anime.plan.keywords.include.map((v) => "include=" + v);
const exclude = anime.plan.keywords.exclude.map((v) => "exclude=" + v);
return `https://animes.garden/resources/1?${include.join("&")}&${exclude.join("&")}&after=${encodeURIComponent(anime.plan.date.toISOString())}`;
}
function printKeywords(anime, logger) {
const include = anime.plan.keywords.include;
const sum = include.reduce((acc, t) => acc + width(t), 0);
if (sum > 50) {
logger.log(dim("Include keywords | ") + underline(overflowText(include[0], 50)));
for (const t of include.slice(1)) {
logger.log(` ${dim("|")} ${underline(overflowText(t, 50))}`);
}
} else {
logger.log(
`${dim("Include keywords")} ${include.map((t) => underline(overflowText(t, 50))).join(dim(" | "))}`
);
}
if (anime.plan.keywords.exclude.length > 0) {
logger.log(
`${dim(`Exclude keywords`)} [ ${anime.plan.keywords.exclude.map((t) => underline(t)).join(" , ")} ]`
);
}
}
function printFansubs(anime, logger) {
const fansubs = anime.plan.fansub;
logger.log(
`${dim("Prefer fansubs")} ${fansubs.length === 0 ? `See ${link("AnimeGarden", formatAnimeGardenSearchURL(anime))} to select some fansubs` : fansubs.join(dim(" > "))}`
);
}
function overflowText(text, length, rest = "...") {
if (width(text) <= length) {
return text;
} else {
return text.slice(0, length - rest.length) + rest;
}
}
class ResourcesCache {
system;
options;
root;
animeRoot;
resourcesRoot;
valid = false;
recentResources = [];
errors = [];
recentResponse = void 0;
constructor(system, options = {}) {
this.system = system;
this.options = options;
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 fetchResources({
fetch: ufetch,
baseURL: this.options.baseURL,
type: "\u52A8\u753B",
retry: 10,
count: -1,
signal: ac.signal,
timeout: 60 * 1e3,
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 = resp.resources.length > 0 || !resp.ok || !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 = makeResourcesFilter({
types: ["\u52A8\u753B"],
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 fetchResources({
fetch: ufetch,
baseURL: this.options.baseURL,
type: "\u52A8\u753B",
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, props) {
}
});
await this.updateAnimeResources(anime, resp);
return resp.resources;
} catch (error) {
debug(error);
this.errors.push(error);
throw error;
}
}
}
async function clearAnimeResourcesCache(system, anime) {
const cache = new ResourcesCache(system);
await cache.clearAnimeResources(anime);
}
const useResourcesCache = memoAsync(
async (system, options) => {
const cache = new ResourcesCache(system, options);
await cache.initialize();
return cache;
}
);
async function fetchAnimeResources(system, anime, options) {
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(
`${bold(anime.plan.title)} (${link(
`Bangumi: ${anime.plan.bgm}`,
`https://bangumi.tv/subject/${anime.plan.bgm}`
)}, ${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://animes.garden/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 ${bold(aliasVideo.filename)}`;
} else {
extra = lightYellow("Not yet downloaded");
}
}
logger.log(` ${DOT} ${link(video.filename, detailURL)} ${extra ? `(${extra})` : ""}`);
}
logger.log("");
}
});
cli.command("garden clean", "Clean downloaded and animegarden cache").option("-y, --yes").option("-e, --ext <string>", {
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 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"
];
class DownloadClient {
_system;
logger;
constructor(system) {
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"]) {
}
}
class Aria2Client extends DownloadClient {
options;
consola;
started = false;
client;
webUI;
version;
heartbeat;
gids = /* @__PURE__ */ new Map();
constructor(system, options = {}) {
super(system);
this.consola = system.logger.withTag("aria2");
this.options = 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 : 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: 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\) 不存在/
];
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 = new MutableMap();
for (const task of this.gids.values()) {
map.set(task.key, task);
}
for (const task of map.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 = 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.ensureDir(path.dirname(this.options.debug.log));
if (await fs.exists(this.options.debug.log)) {
await fs.rm(this.options.debug.log);
}
this.consola.log(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 = 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 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 launchWebUI({
rpc: { port: rpcPort, secret: this.options.secret }
});
this.consola.log(
dim(
`aria2 v${this.version} is running on the port ${link(rpcPort + "", this.webUI.url)}`
)
);
res();
} catch (error) {
rej(error);
}
});
child.addListener("error", async (error) => {
this.consola.error(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(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.readdir(this.options.directory).catch(() => []);
await Promise.all(
files.map(async (file) => {
if (extensions.includes(path.extname(file).toLowerCase())) {
const p = path.join(this.options.directory, file);
try {
await fs.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 = memo(
(provider, system, options) => {
const client = makeClient(provider, system, options);
onDeath(async () => {
await client.close();
});
return client;
},
{
serialize() {
return [];
}
}
);
function AnimeGarden(options) {
const provider = options.provider ?? "webtorrent";
const config = { baseURL: options.api };
const getClient = (sys) => memoClient(provider, sys, options);
let shouldClearCache = false;
return {
name: "animegarden",
options,
schema: {
plan: z.object({
bgm: 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 fetchResourceDetail(
"dmhy",
video.source.magnet.split("/").at(-1),
{ fetch: ufetch, baseURL: options.api }
);
try {
if (resource && resource.resource && resource.detail && resource.detail.magnets.length > 0) {
await client.start();
logger.log(
`${lightBlue("Downloading")} ${bold(video.filename)} ${dim("from")} ${link(
`AnimeGarden`,
video.source.magnet
)}`
);
await anime.removeVideo(video);
await runDownloadTask(
system,
anime,
[
{
video,
resource: {
...resource.resource,
// This should have tracker
magnet: resource.detail?.magnets[0].url,
tracker: ""
}
}
],
client
);
}
} catch (error) {
logger.error(error);
return video;
} finally {
return video;
}
}
return void 0;
}
},
refresh: {
async pre(system, options2) {
useResourcesCache.clear();
const cache = await useResourcesCache(system, config);
if (options2.filter !== void 0) {
cache.disable();
}
},
async post(system) {
const cache = await useResourcesCache(system, config);
cache.finalize();
useResourcesCache.clear();
},
async refresh(system, anime) {
const logger = system.logger.withTag("animegarden");
logger.log("");
logger.log(
`${lightBlue("Fetching resources")} ${bold(anime.plan.title)} (${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(
`${lightRed("Found resources")} ${dim("from")} ${link(
"AnimeGarden",
animegardenURL
)} ${lightRed("failed")}`
);
return;
}
const newVideos = await generateDownloadTask(system, anime, resources);
const oldVideos = (await anime.library()).videos.filter(
(v) => v.source.type === ANIMEGARDEN
);
logger.log(
`${dim("There are")} ${lightCyan(oldVideos.length + " resources")} ${dim(
"downloaded from"
)} ${link("AnimeGarden", animegardenURL)}`
);
if (newVideos.length === 0) {
return;
}
logger.log(
`${lightBlue(`Downloading ${newVideos.length} resources`)} ${dim("from")} ${link(
"AnimeGarden",
animegardenURL
)}`
);
for (const { video, resource } of newVideos) {
const detailURL = `https://animes.garden/detail/${resource.provider}/${resource.providerId}`;
logger.log(` ${DOT} ${link(video.filename, detailURL)}`);
}
try {
const client = getClient(system);
client.system = system;
await runDownloadTask(system, anime, newVideos, client);
} catch (error) {
logger.error(error);
}
}
}
};
}
export { AnimeGarden };