'use strict'; const createDebug = require('debug'); const fs = require('fs-extra'); const fg = require('fast-glob'); const path = require('pathe'); const defu = require('defu'); const yaml = require('yaml'); const zod = require('zod'); const node = require('breadfs/node'); const breadfs = require('breadfs'); const webdav = require('breadfs/webdav'); const dateFns = require('date-fns'); const simptrad = require('simptrad'); const anitomy = require('anitomy'); const color = require('@breadc/color'); const consola = require('consola'); const death = require('@breadc/death'); const undici = require('undici'); function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e.default : e; } const createDebug__default = /*#__PURE__*/_interopDefaultCompat(createDebug); const fs__default = /*#__PURE__*/_interopDefaultCompat(fs); const fg__default = /*#__PURE__*/_interopDefaultCompat(fg); const path__default = /*#__PURE__*/_interopDefaultCompat(path); const defu__default = /*#__PURE__*/_interopDefaultCompat(defu); 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; }; const debug = createDebug__default("animespace"); class AnimeSystemError extends Error { constructor(detail) { super(detail); __publicField$1(this, "detail"); this.detail = detail; } } function isSubDir(parent, dir) { const relative = path__default.relative(parent, dir); return relative && !relative.startsWith("..") && !path__default.isAbsolute(relative); } async function listIncludeFiles(extension, directory) { try { const exts = new Set(extension.include); return (await directory.list()).filter((f) => exts.has(f.extname.slice(1))).map((f) => ({ filename: f.basename, path: f, metadata: {} })); } catch { return []; } } const StringArray = zod.z.union([ zod.z.string().transform((s) => [s]), zod.z.array(zod.z.string()), zod.z.null().transform(() => []) ]); function resolveStringArray(arr) { const parsed = StringArray.safeParse(arr); if (parsed.success) { return parsed.data; } else { return []; } } function formatTitle(template, data) { for (const [key, value] of Object.entries(data)) { template = template.replace(new RegExp(`{${key}}`, "g"), value); } return template; } function formatEpisode(ep) { if (0 <= ep && ep < 10) { return "0" + ep; } else { return "" + ep; } } function onUncaughtException(fn) { process.on("uncaughtException", fn); return () => { process.removeListener("uncaughtException", fn); }; } function onUnhandledRejection(fn) { process.on("unhandledRejection", fn); return () => { process.removeListener("unhandledRejection", fn); }; } const ufetch = async (url, init) => { const proxy2 = getProxy(); if (!!proxy2) { const { ProxyAgent } = await import('undici'); return undici.fetch(url, { ...init, dispatcher: new ProxyAgent(proxy2) }); } else { return undici.fetch(url, init); } }; const proxy = { enable: false, url: void 0 }; function getProxy() { if (!proxy.enable) { return void 0; } if (proxy.url) { return proxy.url; } const env = process?.env ?? {}; const list = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"]; for (const l of list) { const t = env[l]; if (!!t) { return t; } } return void 0; } function uniqBy(arr, map) { const set = /* @__PURE__ */ new Set(); const list = []; for (const item of arr) { const key = map(item); if (!set.has(key)) { set.add(key); list.push(item); } } return list; } const DefaultConfigFilename = `./anime.yaml`; const DefaultStorageDirectory = `./animes`; const DefaultCacheDirectory = `./.cache`; const DefaultTrashDirectory = `./.trash`; const DefaultEpisodeFormat = "[{fansub}] {title} S{season}E{ep}.{extension}"; const DefaultFilmFormat = "[{fansub}] {title}.{extension}"; const PluginEntry = zod.z.object({ name: zod.z.string() }).passthrough(); const FormatPreference = zod.z.object({ episode: zod.z.string().default(DefaultEpisodeFormat), film: zod.z.string().default(DefaultFilmFormat), ova: zod.z.string().default(DefaultFilmFormat) }); const ExtensionPreference = zod.z.object({ include: zod.z.array(zod.z.string()).default(["mp4", "mkv"]), exclude: zod.z.array(zod.z.string()).default([]) }); const KeywordPreference = zod.z.object({ order: zod.z.record(zod.z.string(), zod.z.array(zod.z.string())).default({}), exclude: zod.z.array(zod.z.string()).default([]) }); const Preference = zod.z.object({ format: FormatPreference.default({}), extension: ExtensionPreference.default({}), keyword: KeywordPreference.default({}) }).passthrough().default({}); const StorageDef = zod.z.object({ provider: zod.z.enum(["local", "webdav"]), directory: zod.z.string(), url: zod.z.string().optional(), username: zod.z.string().optional(), password: zod.z.string().optional() }).transform((storage) => { if (storage.provider === "local") { return { provider: "local", directory: storage.directory }; } else if (storage.provider === "webdav") { if (storage.url) { return { provider: "webdav", url: storage.url, directory: storage.directory ?? "/", username: storage.username, password: storage.password }; } } return zod.z.NEVER; }); const StorageStr = zod.z.string().transform((directory) => ({ provider: "local", directory })); const StorageRef = zod.z.object({ refer: zod.z.string() }).transform((refer) => ({ provider: "refer", refer: refer.refer })); const Storage = zod.z.record(zod.z.union([StorageDef, StorageStr, StorageRef])).default({}).transform((storage) => { if (!("anime" in storage)) { storage["anime"] = { provider: "local", directory: DefaultStorageDirectory }; } if (!("library" in storage)) { storage["library"] = { provider: "refer", refer: "anime" }; } if (!("cache" in storage)) { storage["cache"] = { provider: "local", directory: DefaultCacheDirectory }; } if (!("trash" in storage)) { storage["trash"] = { provider: "local", directory: DefaultTrashDirectory }; } return storage; }); const RawAnimeSpaceSchema = zod.z.object({ storage: Storage, preference: Preference, plans: StringArray, plugins: zod.z.array(PluginEntry).default([]) }); const AnimePlanSchema = zod.z.object({ title: zod.z.string(), alias: zod.z.array(zod.z.string()).nullish().transform((v) => v ?? []), translations: zod.z.union([ zod.z.string().transform((s) => ({ unknown: [s] })), zod.z.array(zod.z.string()).transform((arr) => ({ unknown: arr })), zod.z.record(zod.z.string(), StringArray) ]).default({}), directory: zod.z.string().optional(), storage: zod.z.string().default("anime"), type: zod.z.enum(["\u756A\u5267", "\u7535\u5F71", "OVA"]).default("\u756A\u5267"), status: zod.z.enum(["onair", "finish"]).optional(), season: zod.z.coerce.number().default(1), date: zod.z.coerce.date().optional(), rewrite: zod.z.object({ title: zod.z.string().optional(), episode: zod.z.union([ zod.z.coerce.number().transform((n) => ({ offset: n, fansub: void 0 })), zod.z.object({ offset: zod.z.coerce.number(), fansub: StringArray.optional() }) ]).optional(), season: zod.z.number().optional() }).passthrough().optional(), fansub: StringArray.default([]), preference: zod.z.object({ format: FormatPreference.optional(), extension: ExtensionPreference.optional(), keyword: KeywordPreference.optional() }).passthrough().optional(), keywords: zod.z.any() }).passthrough(); const PlanSchema = zod.z.object({ name: zod.z.string().default("unknown"), date: zod.z.coerce.date(), status: zod.z.enum(["onair", "finish"]).default("onair"), preference: zod.z.object({ format: FormatPreference.optional(), extension: ExtensionPreference.optional(), keyword: KeywordPreference.optional() }).passthrough().optional(), onair: zod.z.array(AnimePlanSchema).default([]) }); async function loadPlans(space) { const plans = await loadPlan(space); for (const plugin of space.plugins) { await plugin.prepare?.plans?.(space, plans); } return plans; } async function loadPlan(space) { const plugins = space.plugins; const animePlanSchema = plugins.reduce( (acc, plugin) => plugin?.schema?.plan ? acc.merge(plugin?.schema?.plan) : acc, AnimePlanSchema ); const Schema = PlanSchema.extend({ onair: zod.z.array(animePlanSchema).default([]) }); const files = await fg__default(space.plans, { cwd: space.root.path, dot: true }); const plans = await Promise.all( files.map(async (file) => { try { const content = await fs__default.readFile(path__default.resolve(space.root.path, file), "utf-8"); const yaml$1 = yaml.parse(content); const parsed = Schema.safeParse(yaml$1); if (parsed.success) { const preference = defu__default(parsed.data.preference, space.preference); const plan = { ...parsed.data, preference, onair: parsed.data.onair.map((o) => { if (!space.storage[o.storage]) { throw new AnimeSystemError(`Storage ${o.storage} \u4E0D\u5B58\u5728`); } return { ...o, storage: { name: o.storage, root: space.storage[o.storage] }, // Inherit plan status status: o.status ? o.status : parsed.data.status, // Inherit plan date date: o.date ? o.date : parsed.data.date, // Manually resolve keywords keywords: resolveKeywordsArray(o.title, o.alias, o.translations, o.keywords), // Inherit preference, preference: defu__default(o.preference, preference) }; }) }; return plan; } else { debug(parsed.error.issues); throw new AnimeSystemError(`\u89E3\u6790 ${path__default.relative(space.root.path, file)} \u5931\u8D25`); } } catch (error) { if (error instanceof AnimeSystemError) { console.error(error); } else { debug(error); console.error(`\u89E3\u6790 ${path__default.relative(space.root.path, file)} \u5931\u8D25`); } return void 0; } }) ); return plans.filter(Boolean); } function resolveKeywordsArray(title, alias, translations, keywords) { const titles = [ title, ...alias, ...Object.entries(translations).flatMap(([_key, value]) => value) ]; if (keywords !== void 0 && keywords !== null) { if (typeof keywords === "string") { if (!keywords.startsWith("!")) { return { include: [...titles, keywords], exclude: [] }; } else { return { include: titles, exclude: [keywords.slice(1)] }; } } else if (Array.isArray(keywords)) { const include = []; const exclude = []; for (const keyword of keywords) { if (typeof keyword === "string") { if (!keyword.startsWith("!")) { include.push(keyword); } else { exclude.push(keyword.slice(1)); } } } return { include, exclude }; } } return { include: titles, exclude: [] }; } function makeBreadFS(root, storage) { const resolved = {}; for (const [name, store] of Object.entries(storage)) { if (store.provider !== "refer") { resolved[name] = makeConcreteStorage(name, store); } } for (const [name, store] of Object.entries(storage)) { if (store.provider === "refer") { if (resolved[store.refer]) { resolved[name] = resolved[store.refer]; } else { throw new Error(`Can not find storage ${store.refer}`); } } } if (!resolved.anime) { throw new Error(`Can not find anime storage`); } if (!resolved.library) { throw new Error(`Can not find storage storage`); } if (!resolved.cache) { throw new Error(`Can not find cache storage`); } if (!resolved.trash) { throw new Error(`Can not find trash storage`); } return resolved; function makeConcreteStorage(name, storage2) { if (storage2.provider === "local") { return node.fs.path(root).resolve(storage2.directory); } else if (storage2.provider === "webdav") { const fs = breadfs.BreadFS.of( new webdav.WebDAVProvider(storage2.url, { username: storage2.username, password: storage2.password }) ); return fs.path(storage2.directory); } else { throw new Error(`Unexpected storage provider of ${name}`); } } } async function makeNewSpace(root) { const space = { storage: { anime: { provider: "local", directory: path__default.join(root, DefaultStorageDirectory) } }, preference: { format: { episode: DefaultEpisodeFormat, film: DefaultFilmFormat, ova: DefaultFilmFormat }, extension: { include: ["mp4", "mkv"], exclude: [] }, keyword: { order: { format: ["mp4", "mkv"], language: ["\u7B80", "\u7E41"], resolution: ["1080", "720"] }, exclude: [] } }, plans: ["./plans/*.yaml"], plugins: [ { name: "animegarden", provider: "aria2", directory: "./download" }, { name: "local", directory: "./local" }, { name: "bangumi", username: "" } ] }; await fs__default.mkdir(root, { recursive: true }).catch(() => { }); await Promise.all([ fs__default.mkdir(space.storage.anime.directory, { recursive: true }).catch(() => { }), fs__default.mkdir(path__default.join(root, "./plans"), { recursive: true }).catch(() => { }), fs__default.mkdir(path__default.join(root, "./download"), { recursive: true }).catch(() => { }), fs__default.mkdir(path__default.join(root, "./local"), { recursive: true }).catch(() => { }), fs__default.writeFile( path__default.join(root, DefaultConfigFilename), yaml.stringify({ ...space, root: void 0, storage: { ...space.storage, anime: { ...space.storage.anime, directory: DefaultStorageDirectory } } }), "utf-8" ), fs__default.writeFile( path__default.join(root, ".gitignore"), ["*.mp4", "*.mkv", "*.aria2", ".cache", ".trash"].join("\n"), "utf-8" ), fs__default.writeFile(path__default.join(root, "README.md"), `# AnimeSpace `, "utf-8") ]); return space; } async function loadSpace(_root, importPlugin) { const root = node.fs.path(path__default.resolve(_root)); const config = await loadRawSpace(root); const plugins = await loadPlugins(config.plugins); const schema = plugins.reduce( (acc, plugin) => plugin?.schema?.space ? acc.merge(plugin?.schema?.space) : acc, RawAnimeSpaceSchema ); const parsed = schema.safeParse(config); if (parsed.success) { const space = parsed.data; const storage = makeBreadFS(root, space.storage); const resolved = { root, preference: space.preference, storage, plans: space.plans, plugins }; await initSpace(resolved); await validateSpace(resolved); return resolved; } else { debug(parsed.error.issues); throw new AnimeSystemError(`\u89E3\u6790 ${root.join(DefaultConfigFilename).path} \u5931\u8D25`); } async function loadPlugins(entries) { if (importPlugin) { const parsed2 = zod.z.array(PluginEntry).safeParse(entries); if (parsed2.success) { const entries2 = parsed2.data; return (await Promise.all(entries2.map((p) => resolvePlugin(p)))).filter( Boolean ); } else { throw new AnimeSystemError(`Failed to parse anime space plugin config`); } } return []; async function resolvePlugin(p) { if (!!importPlugin) { if (typeof importPlugin === "function") { return importPlugin(p); } else if (typeof importPlugin === "object") { return importPlugin[p.name]?.(p); } } return void 0; } } } async function loadRawSpace(root) { const configPath = root.join(DefaultConfigFilename); if (await configPath.exists()) { const configContent = await configPath.readText(); return yaml.parse(configContent); } else { return await makeNewSpace(root.path); } } async function initSpace(space) { for (const plugin of space.plugins) { await plugin.prepare?.space?.(space); } } async function validateSpace(space) { const root = space.root.path; const validateAnime = async () => { try { await fs__default.access(root, fs__default.constants.R_OK | fs__default.constants.W_OK); } catch { throw new AnimeSystemError(`Can not access anime space directory ${root}`); } if (space.storage.anime.fs === node.fs) { try { await fs__default.access(space.storage.anime.path, fs__default.constants.R_OK | fs__default.constants.W_OK); } catch { try { await fs__default.mkdir(space.storage.anime.path, { recursive: true }); } catch { throw new AnimeSystemError( `Can not access local anime storage directory ${space.storage.anime.path}` ); } } } }; const validateLibrary = async () => { if (space.storage.library.fs === node.fs && space.storage.library.path !== space.storage.anime.path) { try { await fs__default.access(space.storage.library.path, fs__default.constants.R_OK | fs__default.constants.W_OK); } catch { try { await fs__default.mkdir(space.storage.library.path, { recursive: true }); } catch { throw new AnimeSystemError( `Can not access local anime external library directory ${space.storage.library.path}` ); } } } }; const validateCache = async () => { if (space.storage.cache.fs === node.fs) { try { await fs__default.access(space.storage.cache.path, fs__default.constants.R_OK | fs__default.constants.W_OK); } catch { try { await fs__default.mkdir(space.storage.cache.path, { recursive: true }); } catch { throw new AnimeSystemError( `Can not access local cache storage directory ${space.storage.cache.path}` ); } } } }; const validateTrash = async () => { if (space.storage.trash.fs === node.fs) { try { await fs__default.access(space.storage.trash.path, fs__default.constants.R_OK | fs__default.constants.W_OK); } catch { try { await fs__default.mkdir(space.storage.trash.path, { recursive: true }); } catch { throw new AnimeSystemError( `Can not access local trash storage directory ${space.storage.trash.path}` ); } } } }; await Promise.all([validateAnime(), validateLibrary(), validateCache(), validateTrash()]); return true; } function stringifyLocalLibrary(lib, rawLib) { const copied = JSON.parse(JSON.stringify(lib)); if (rawLib?.title === void 0) { copied.title = void 0; } if (copied.videos === void 0) { copied.videos = []; } for (const v of copied.videos) { if (v.naming === "auto") { v.naming = void 0; } } const doc = new yaml.Document(copied); yaml.visit(doc, { Scalar(key, node) { if (key === "key") { node.spaceBefore = true; } }, Seq(key, node) { let first = true; for (const child of node.items) { if (first) { first = false; continue; } child.spaceBefore = true; } return yaml.visit.SKIP; } }); return `# Generated at ${dateFns.format( new Date(), "yyyy-MM-dd hh:mm")} ` + doc.toString({ lineWidth: 0 }); } 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; }; const LibraryFilename = "library.yaml"; class Anime { constructor(system, plan, file) { __publicField(this, "directory"); __publicField(this, "relativeDirectory"); __publicField(this, "libraryDirectory"); __publicField(this, "trashDirectory"); __publicField(this, "plan"); __publicField(this, "planFile"); __publicField(this, "system"); __publicField(this, "_lib"); __publicField(this, "_raw_lib"); __publicField(this, "_files"); /** * Store all the changes made to this anime */ __publicField(this, "_delta", []); /** * Mark whether there is any changes made to this anime that are not writen back */ __publicField(this, "_dirty", false); this.system = system; this.plan = plan; this.planFile = file; const space = system.space; const dirname = plan.title; this.directory = plan.directory ? plan.storage.root.resolve(plan.directory) : plan.storage.root.join(dirname); this.libraryDirectory = plan.directory ? space.storage.library.resolve(plan.directory) : space.storage.library.join(dirname); this.trashDirectory = plan.directory ? space.storage.trash.resolve(plan.directory) : space.storage.trash.join(dirname); this.relativeDirectory = plan.directory ? plan.directory : dirname; } get space() { return this.system.space; } get delta() { return this._delta; } get dirty() { return this._dirty; } matchKeywords(text) { for (const ban of this.plan.keywords.exclude) { if (text.includes(ban)) { return false; } } for (const key of this.plan.keywords.include) { if (text.includes(key)) { return true; } } return true; } get libraryPath() { return this.libraryDirectory.join(LibraryFilename); } async library(force = false) { if (this._lib === void 0 || force) { await this.libraryDirectory.ensureDir(); const libPath = this.libraryPath; if (await libPath.exists()) { this._dirty = false; const libContent = await libPath.readText().catch(() => libPath.readText()); const lib = yaml.parse(libContent) ?? {}; this._raw_lib = lib; const schema = zod.z.object({ title: zod.z.string().default(this.plan.title).catch(this.plan.title), season: this.plan.season !== void 0 ? zod.z.coerce.number().default(this.plan.season).catch(this.plan.season) : zod.z.coerce.number().optional(), date: zod.z.coerce.date().default(this.plan.date).catch(this.plan.date), // Hack: for old data, keep this default storage: zod.z.string().default("anime"), videos: zod.z.array( zod.z.object({ filename: zod.z.string(), naming: zod.z.enum(["auto", "manual"]).default("auto").catch("auto"), date: zod.z.coerce.date().optional(), season: zod.z.coerce.number().optional(), episode: zod.z.coerce.number().optional() }).passthrough() ).catch([]) }).passthrough(); const parsed = schema.safeParse(lib); if (parsed.success) { debug(parsed.data); const videos = (lib?.videos ?? []).filter(Boolean); for (const video of videos) { if (!video.naming) { video.naming = "auto"; } } if (this.plan.storage.name !== parsed.data.storage) { videos.splice(0, videos.length); this._dirty = true; parsed.data.storage = this.plan.storage.name; } this._lib = { ...parsed.data, videos }; return this._lib; } else { debug(parsed.error.issues); throw new AnimeSystemError(`\u89E3\u6790 ${this.plan.title} \u7684 ${LibraryFilename} \u5931\u8D25`); } } else { const defaultLib = { title: this.plan.title, storage: this.plan.storage.name, videos: [] }; await libPath.writeText(stringifyLocalLibrary(defaultLib, { videos: [] })); this._raw_lib = {}; this._lib = defaultLib; return defaultLib; } } else { return this._lib; } } async list(force = false) { if (this._files === void 0 || force) { const files = await listIncludeFiles(this.plan.preference.extension, this.directory); return this._files = files; } else { return this._files; } } // --- format --- format(type) { type ?? (type = this.plan.type); switch (type) { case "\u7535\u5F71": return this.plan.preference.format.film; case "OVA": case "\u7279\u522B\u7BC7": return this.plan.preference.format.ova; case "\u756A\u5267": default: return this.plan.preference.format.episode; } } reformatVideoFilename(video) { if (video.naming === "auto") { const title = this._raw_lib?.title ?? this.plan.rewrite?.title ?? this.plan.title; const date = video.date ?? this.plan.date; const season = this.resolveSeason(video.type, video.season); const episode = this.resolveEpisode(video.episode, video.fansub); return formatTitle(this.format(), { title, yyyy: dateFns.format(date, "yyyy"), MM: dateFns.format(date, "MM"), season: season !== void 0 ? formatEpisode(season) : "01", ep: episode !== void 0 ? formatEpisode(episode) : "{ep}", extension: path__default.extname(video.filename).slice(1) ?? "mp4", fansub: video.fansub ?? "fansub" }); } else { return video.filename; } } formatFilename(meta) { const title = this._raw_lib?.title ?? this.plan.rewrite?.title ?? this.plan.title; const date = this.plan.date; const season = this.resolveSeason(meta.type, meta.season); const episode = this.resolveEpisode(meta.episode, meta.fansub); return formatTitle(this.format(meta.type), { title, yyyy: dateFns.format(date, "yyyy"), mm: dateFns.format(date, "MM"), season: season !== void 0 ? formatEpisode(season) : "01", ep: episode !== void 0 ? formatEpisode(episode) : "{ep}", extension: meta.extension?.toLowerCase() ?? "mp4", fansub: meta.fansub ?? "fansub" }); } resolveEpisode(episode, fansub) { if (episode !== void 0) { const overwrite = this.plan.rewrite?.episode; if (overwrite !== void 0) { if (overwrite.fansub === void 0 || fansub && overwrite.fansub.includes(fansub)) { const overwriten = episode + overwrite.offset; return overwriten > 0 && !Number.isNaN(overwriten) ? overwriten : episode; } } return episode; } else { return void 0; } } resolveSeason(type, season) { if (type === "OVA" || type === "\u7279\u522B\u7BC7" || type === "\u7279\u5225\u7BC7") return 0; return season ?? this.plan.season; } // --- mutation --- async addVideo(localSrc, newVideo, { copy = false, onProgress } = {}) { const lib = await this.library(); let delta = void 0; try { const src = node.fs.path(localSrc); const dst = this.directory.join(newVideo.filename); if (src.path !== dst.path) { if (copy) { await src.copyTo(dst, { overwrite: true, fallback: { stream: { contentLength: true, onProgress }, file: { write: { onProgress } } } }); delta = { operation: "copy", video: newVideo }; } else { await src.moveTo(dst, { overwrite: true, fallback: { stream: { contentLength: true, onProgress }, file: { write: { onProgress } } } }); delta = { operation: "move", video: newVideo }; } this._delta.push(delta); const oldVideoId = lib.videos.findIndex((v) => v.filename === newVideo.filename); if (oldVideoId !== -1) { const oldVideo = lib.videos[oldVideoId]; this._delta.push({ operation: "remove", video: oldVideo }); this._lib.videos.splice(oldVideoId, 1); } } this._dirty = true; lib.videos.push(newVideo); } catch (error) { console.error(error); } finally { return delta; } } /** * Copy a video outside into this library * * @param src The absolute path of src video * @param video The stored video data * @returns */ async addVideoByCopy(src, video, options = {}) { return this.addVideo(src, video, { copy: true }); } /** * Move a video outside into this library * * @param src The absolute path of src video * @param video The stored video data * @returns */ async addVideoByMove(src, video, options = {}) { return this.addVideo(src, video, { copy: false }); } /** * Move the video to the target path * * @param src The video to be moved * @param dst Target path */ async moveVideo(src, dst) { await this.library(); if (!this._lib.videos.find((v) => v === src)) return; const oldFilename = src.filename; const newFilename = dst; try { if (oldFilename !== newFilename) { this._dirty = true; await this.directory.join(oldFilename).moveTo(this.directory.join(newFilename)); src.filename = newFilename; const delta = { operation: "move", video: src }; this._delta.push(delta); return delta; } } catch (error) { src.filename = oldFilename; console.error(error); } } async removeVideo(target) { const remove = () => { const idx = lib.videos.findIndex((v) => v === target); if (idx !== -1) { const oldVideo = lib.videos[idx]; lib.videos.splice(idx, 1); this._dirty = true; const delta = { operation: "remove", video: oldVideo }; this._delta.push(delta); return delta; } }; const lib = await this.library(); const video = this.directory.join(target.filename); if (!lib.videos.find((v) => v === target)) return; if (await video.exists()) { try { await video.remove(); return remove(); } catch (error) { console.error(error); } } else { remove(); } } async sortVideos() { const lib = await this.library(); const src = lib.videos.map((v) => v.filename); lib.videos.sort((lhs, rhs) => { const sl = lhs.season ?? 1; const sr = rhs.season ?? 1; if (sl !== sr) return sl - sr; const el = lhs.episode ?? -1; const er = rhs.episode ?? -1; return el - er; }); const dst = lib.videos.map((v) => v.filename); this._dirty || (this._dirty = lib.videos.some((_el, idx) => src[idx] !== dst[idx])); } async writeLibrary() { for (const plugin of this.space.plugins) { await plugin.writeLibrary?.pre?.(this.system, this); } await this.sortVideos(); if (this._lib && this._dirty) { debug(`Start writing anime library of ${this._lib.title}`); try { await this.libraryPath.writeText(stringifyLocalLibrary(this._lib, this._raw_lib)); this._dirty = false; for (const plugin of this.space.plugins) { await plugin.writeLibrary?.post?.(this.system, this); } debug(`Write anime library of ${this._lib.title} OK`); } catch (error) { console.error(error); } } else { debug(`Keep anime library of ${this.plan.title}`); } } } const parser = new anitomy.Parser(); function parseEpisode(anime, title, options = {}) { const info = parser.parse(title); if (!info) return void 0; const metadata = options?.metadata instanceof Function ? options.metadata(info) : options.metadata ?? void 0; if (anime.plan.type === "\u756A\u5267") { const resolvedEpisode = anime.resolveEpisode(info.episode.number, metadata?.fansub); if (!!info.type && info.type.toLocaleLowerCase() !== "tv") { const resolvedTitle = anime.formatFilename({ ...metadata, season: 0, episode: info.episode.number }); return { anime, type: simptrad.tradToSimple(info.type), title, resolvedTitle, metadata, parsed: info, episode: info.episode.number, resolvedEpisode, // 范围集数 episodeAlt: info.episode.numberAlt, resolvedEpisodeAlt: anime.resolveEpisode(info.episode.numberAlt, metadata?.fansub) }; } else if (info.episode.number !== void 0 && resolvedEpisode !== void 0) { const resolvedTitle = anime.formatFilename({ ...metadata, episode: info.episode.number }); return { anime, type: "TV", title, resolvedTitle, metadata, parsed: info, episode: info.episode.number, resolvedEpisode, // 范围集数, e.g. 01-12 episodeAlt: info.episode.numberAlt, resolvedEpisodeAlt: anime.resolveEpisode(info.episode.numberAlt, metadata?.fansub) }; } } else if (anime.plan.type === "\u7535\u5F71") { const resolvedTitle = anime.formatFilename({ ...metadata, episode: info.episode.number }); return { anime, type: "\u7535\u5F71", title, resolvedTitle, metadata, parsed: info }; } else if (anime.plan.type === "OVA") { const resolvedEpisode = anime.resolveEpisode(info.episode.number, metadata?.fansub); const resolvedTitle = anime.formatFilename({ ...metadata, episode: info.episode.number }); return { anime, type: info.type ? simptrad.tradToSimple(info.type) : "OVA", title, resolvedTitle, metadata, parsed: info, episode: info.episode.number, resolvedEpisode, // 范围集数 episodeAlt: info.episode.numberAlt, resolvedEpisodeAlt: anime.resolveEpisode(info.episode.numberAlt, metadata?.fansub) }; } return { anime, title, metadata, parsed: info }; } function isValidEpisode(episode) { if (episode && "type" in episode && episode.type) { return true; } else { return false; } } function hasEpisodeNumber(episode) { return "episode" in episode && episode.episode !== void 0 && "resolvedEpisode" in episode && episode.resolvedEpisode !== void 0; } function hasEpisodeNumberAlt(episode) { return "episodeAlt" in episode && episode.episodeAlt !== void 0 && "resolvedEpisodeAlt" in episode && episode.resolvedEpisodeAlt !== void 0; } function getEpisodeType(episode) { if (episode.type === "TV" || episode.type === "\u7535\u5F71") { return episode.type; } else { return "OVA"; } } function getEpisodeKey(episode) { const episodeAlt = "resolvedEpisodeAlt" in episode ? episode.resolvedEpisodeAlt !== void 0 ? `-${episode.resolvedEpisodeAlt}` : "" : ""; if (episode.type === "TV") { const season = "season" in episode.metadata ? episode.metadata.season : 1; return `${episode.type}/S${season}/${episode.resolvedEpisode ?? "null"}${episodeAlt}`; } if (episode.type === "\u7535\u5F71") { return `${episode.type}/`; } if (hasEpisodeNumber(episode)) { return `${episode.type}/${episode.resolvedEpisode ?? "null"}${episodeAlt}`; } else { return `${episode.type}/`; } } function sameEpisode(lhs, rhs) { return getEpisodeKey(lhs) === getEpisodeKey(rhs); } async function refresh(system, options) { const logger = system.logger.withTag("refresh"); logger.log(color.lightBlue(`Refresh Anime Space`)); const animes = await system.animes(options); for (const plugin of system.space.plugins) { await plugin.refresh?.pre?.(system, options); } for (const anime of animes) { for (const plugin of system.space.plugins) { await plugin.refresh?.refresh?.(system, anime, options); } } for (const plugin of system.space.plugins) { await plugin.refresh?.post?.(system, options); } logger.log(""); if (options.logDelta) { system.printDelta(); } logger.log(color.lightGreen(`Refresh Anime Space OK`)); return animes; } async function introspect(system, options) { const logger = system.logger.withTag("introspect"); logger.log(color.lightBlue(`Introspect Anime Space`)); const animes = await system.animes(options); for (const plugin of system.space.plugins) { await plugin.introspect?.pre?.(system, options); } for (const anime of animes) { await introspectAnime(system, anime, options); } for (const plugin of system.space.plugins) { await plugin.introspect?.post?.(system, options); } logger.log(color.lightGreen(`Introspect Anime Space OK`)); logger.log(""); return animes; } async function introspectAnime(system, anime, options) { const lib = await anime.library(); const videos = lib.videos; const files = await anime.list(); const unknownFiles = []; const unknownVideos = []; { const set = new Set(videos.map((v) => v.filename)); for (const file of files) { if (!set.has(file.filename)) { unknownFiles.push(file); } } } { const set = new Set(files.map((v) => v.filename)); for (const video of videos) { if (!set.has(video.filename)) { unknownVideos.push(video); } } } const logger = system.logger.withTag("introspect"); for (const video of unknownVideos) { let found = false; for (const plugin of system.space.plugins) { const handleUnknownVideo = plugin.introspect?.handleUnknownVideo; if (handleUnknownVideo) { const res = await handleUnknownVideo(system, anime, video, options); if (res) { found = true; break; } } } if (!found) { logger.log(`${color.lightRed("Removing")} "${color.bold(video.filename)}"`); await anime.removeVideo(video); } } for (const file of unknownFiles) { for (const plugin of system.space.plugins) { const handleUnknownFile = plugin.introspect?.handleUnknownFile; if (handleUnknownFile) { const video = await handleUnknownFile(system, anime, file, options); if (video) { break; } } } } for (const video of videos) { const filename = anime.reformatVideoFilename(video); if (filename !== video.filename) { logger.log(`${color.lightBlue(`Moving`)} "${color.bold(video.filename)}" to "${color.bold(filename)}"`); await anime.moveVideo(video, filename); } } } async function loadAnime(system, filter = (p) => p.plan.status === "onair") { const plans = await system.plans(); const animes = plans.flatMap((p) => p.onair.map((ap) => new Anime(system, ap, p))); { const set = /* @__PURE__ */ new Set(); for (const anime of animes) { if (!set.has(anime.directory)) { set.add(anime.directory); } else { throw new AnimeSystemError( `\u53D1\u73B0\u6587\u4EF6\u5939\u91CD\u540D\u7684\u52A8\u753B ${anime.plan.title} (Season ${anime.plan.season})` ); } } } const filtered = animes.filter(filter); animes.splice(0, animes.length, ...filtered); const successed = await Promise.all( animes.map(async (a) => { try { await a.library(); return a; } catch (error) { if (error instanceof AnimeSystemError) { console.error(error.message); } else { debug(error); throw error; } return void 0; } }) ); return successed.filter(Boolean); } async function createSystem(space) { const logger = consola.createConsola({ formatOptions: { columns: process.stdout.getWindowSize?.()[0] } }); let animes = void 0; const system = { space, logger, printSpace() { printSpace(logger, space); logger.log(""); }, printDelta() { if (!animes) return; const delta = animes.flatMap((anime) => anime.delta); if (delta.length > 0) { logger.log( `${color.dim("There are")} ${color.lightCyan(delta.length + " changes")} ${color.dim( "applied to the space" )}` ); printDelta(logger, delta); logger.log(""); } else { logger.log(color.lightGreen(`Every anime is latest`)); } }, async plans() { return await loadPlans(space); }, async animes(options = {}) { if (!options.force && animes !== void 0) { return animes; } else if (!options?.filter) { return animes = await loadAnime(system); } else { const filter = options.filter; const normalize = (t) => t.toLowerCase(); if (typeof filter === "string") { const keyword = normalize(filter); return animes = await loadAnime( system, (a) => normalize(a.plan.title).includes(keyword) ); } else if (typeof filter === "function") { return animes = await loadAnime(system, filter); } else { const keyword = normalize(filter.keyword); const status = filter.status; return animes = await loadAnime( system, (a) => a.plan.status === status && normalize(a.plan.title).includes(keyword) ); } } }, async refresh(options = {}) { logger.wrapConsole(); const animes2 = await refresh(system, { force: false, logDelta: true, ...options }); logger.restoreConsole(); return animes2; }, async introspect(options = {}) { logger.wrapConsole(); const animes2 = await introspect(system, options); logger.restoreConsole(); return animes2; }, async writeBack() { logger.wrapConsole(); const animes2 = await system.animes(); await Promise.all(animes2.map((a) => a.writeLibrary())); logger.restoreConsole(); return animes2; }, isChanged() { if (animes) { return animes.some((a) => a.dirty); } else { return false; } } }; return system; } function printSpace(logger, space) { logger.log(`${color.dim("Space")} ${space.root}`); if (space.storage.anime.fs.name === "node") { logger.log(`${color.dim("Storage")} ${space.storage.anime.path}`); } else if (space.storage.anime.fs.name === "webdav") { const join = (a, b) => { return a.replace(/\/$/, "") + "/" + b.replace(/^\//, ""); }; const webdav = space.storage.anime.fs; logger.log(`${color.dim("Storage")} ${join(webdav.provider.url, space.storage.anime.path)}`); } if (space.storage.library.fs.name === "node") { logger.log(`${color.dim("Library")} ${space.storage.library.path}`); } } function printDelta(logger, delta) { const DOT = color.dim("\u2022"); for (const d of delta) { const format = { copy: color.lightGreen("Copy"), move: color.lightGreen("Move"), remove: color.lightRed("Remove") }; const op = format[d.operation]; logger.log(` ${DOT} ${op} ${d.log ?? d.video.filename}`); } } exports.onDeath = death.onDeath; exports.Anime = Anime; exports.AnimeSystemError = AnimeSystemError; exports.StringArray = StringArray; exports.createSystem = createSystem; exports.debug = debug; exports.formatEpisode = formatEpisode; exports.formatTitle = formatTitle; exports.getEpisodeKey = getEpisodeKey; exports.getEpisodeType = getEpisodeType; exports.getProxy = getProxy; exports.hasEpisodeNumber = hasEpisodeNumber; exports.hasEpisodeNumberAlt = hasEpisodeNumberAlt; exports.initSpace = initSpace; exports.isSubDir = isSubDir; exports.isValidEpisode = isValidEpisode; exports.listIncludeFiles = listIncludeFiles; exports.loadAnime = loadAnime; exports.loadPlans = loadPlans; exports.loadSpace = loadSpace; exports.onUncaughtException = onUncaughtException; exports.onUnhandledRejection = onUnhandledRejection; exports.parseEpisode = parseEpisode; exports.printDelta = printDelta; exports.printSpace = printSpace; exports.proxy = proxy; exports.resolveStringArray = resolveStringArray; exports.sameEpisode = sameEpisode; exports.ufetch = ufetch; exports.uniqBy = uniqBy; exports.validateSpace = validateSpace;