├── .github
├── renovate.json
└── workflows
│ ├── bundle.yml
│ ├── ci.yml
│ ├── docs.yml
│ └── release.yml
├── .gitignore
├── .node-version
├── .npmrc
├── .prettierrc
├── .tazerc.json
├── LICENSE
├── README.md
├── docs
├── .vitepress
│ ├── .gitignore
│ └── config.ts
├── animegarden
│ ├── collection.md
│ ├── index.md
│ ├── rss.md
│ └── search.md
├── animespace
│ ├── cli
│ │ └── index.md
│ ├── config
│ │ ├── index.md
│ │ ├── jellyfin.md
│ │ └── plan.md
│ ├── index.md
│ └── installation
│ │ └── index.md
├── anitomy
│ └── index.md
├── bgmc
│ └── index.md
├── bgmd
│ └── index.md
├── index.md
├── nfo.js
│ └── index.md
├── package.json
├── public
│ ├── BingSiteAuth.xml
│ ├── Jellyfin.jpeg
│ ├── animepaste.png
│ ├── baidu_verify_codeva-LdRV3NCcLP.html
│ ├── cloudflare.png
│ ├── favicon.svg
│ ├── search-1.png
│ ├── search-2.png
│ ├── search-3.png
│ └── search-4.png
├── tmdbc
│ └── index.md
└── vite.config.ts
├── package.json
├── packages
├── animegarden
│ ├── README.md
│ ├── build.config.ts
│ ├── package.json
│ ├── scripts
│ │ └── client.ts
│ ├── src
│ │ ├── cli.ts
│ │ ├── constant.ts
│ │ ├── download
│ │ │ ├── aria2.ts
│ │ │ ├── base.ts
│ │ │ ├── index.ts
│ │ │ ├── trackers.ts
│ │ │ └── webtorrent.ts
│ │ ├── format.ts
│ │ ├── index.ts
│ │ ├── logger.ts
│ │ ├── plan.d.ts
│ │ ├── resources
│ │ │ ├── cache.ts
│ │ │ └── index.ts
│ │ └── task
│ │ │ ├── extract.ts
│ │ │ ├── index.ts
│ │ │ ├── run.ts
│ │ │ └── types.ts
│ └── tsconfig.json
├── bangumi
│ ├── README.md
│ ├── build.config.ts
│ ├── package.json
│ ├── src
│ │ ├── generate.ts
│ │ └── index.ts
│ └── tsconfig.json
├── cli
│ ├── .gitignore
│ ├── README.md
│ ├── build.config.ts
│ ├── cli.mjs
│ ├── package.json
│ ├── scripts
│ │ └── sea.mjs
│ ├── src
│ │ ├── cli.ts
│ │ ├── constant.ts
│ │ ├── index.ts
│ │ ├── logger
│ │ │ ├── constant.ts
│ │ │ └── index.ts
│ │ ├── system
│ │ │ ├── cli.ts
│ │ │ ├── index.ts
│ │ │ ├── system.ts
│ │ │ └── utils.ts
│ │ ├── types.ts
│ │ └── utils.ts
│ ├── test
│ │ ├── __snapshots__
│ │ │ └── space.test.ts.snap
│ │ ├── introspect.test.ts
│ │ └── space.test.ts
│ ├── tsconfig.json
│ └── vitest.config.ts
├── core
│ ├── README.md
│ ├── build.config.ts
│ ├── package.json
│ ├── src
│ │ ├── anime
│ │ │ ├── anime.ts
│ │ │ ├── episode.ts
│ │ │ ├── index.ts
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ ├── error.ts
│ │ ├── index.ts
│ │ ├── plan
│ │ │ ├── index.ts
│ │ │ ├── plan.ts
│ │ │ ├── schema.ts
│ │ │ └── types.ts
│ │ ├── plugin.ts
│ │ ├── space
│ │ │ ├── constant.ts
│ │ │ ├── index.ts
│ │ │ ├── new.ts
│ │ │ ├── schema.ts
│ │ │ ├── space.ts
│ │ │ ├── storage.ts
│ │ │ └── types.ts
│ │ ├── system
│ │ │ ├── index.ts
│ │ │ ├── introspect.ts
│ │ │ ├── refresh.ts
│ │ │ ├── system.ts
│ │ │ └── types.ts
│ │ └── utils
│ │ │ ├── array.ts
│ │ │ ├── format.ts
│ │ │ ├── fs.ts
│ │ │ ├── index.ts
│ │ │ ├── signal.ts
│ │ │ ├── types.ts
│ │ │ └── ufetch.ts
│ ├── test
│ │ ├── __snapshots__
│ │ │ ├── introspect.test.ts.snap
│ │ │ └── space.test.ts.snap
│ │ ├── breadfs.ts
│ │ ├── fixtures
│ │ │ └── space
│ │ │ │ ├── .gitignore
│ │ │ │ ├── anime.yaml
│ │ │ │ ├── anime
│ │ │ │ ├── 偶像大师 灰姑娘女孩 U149
│ │ │ │ │ └── library.yaml
│ │ │ │ ├── 天国大魔境
│ │ │ │ │ └── library.yaml
│ │ │ │ └── 熊熊勇闯异世界 Punch!
│ │ │ │ │ └── library.yaml
│ │ │ │ ├── download
│ │ │ │ └── .gitkeep
│ │ │ │ └── plans
│ │ │ │ └── 2023.yaml
│ │ ├── introspect.test.ts
│ │ ├── library.test.ts
│ │ └── space.test.ts
│ ├── tsconfig.json
│ └── vitest.config.ts
└── local
│ ├── README.md
│ ├── build.config.ts
│ ├── package.json
│ ├── src
│ └── index.ts
│ └── tsconfig.json
├── patches
└── consola@3.1.0.patch
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── tsconfig.json
├── turbo.json
└── vitest.config.ts
/.github/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["github>yjl9903/renovate-config"]
3 | }
4 |
--------------------------------------------------------------------------------
/.github/workflows/bundle.yml:
--------------------------------------------------------------------------------
1 | name: Bundle
2 |
3 | on:
4 | workflow_dispatch:
5 |
6 | jobs:
7 | bundle:
8 | name: Bundle on ${{ matrix.target }}
9 |
10 | runs-on: ${{ matrix.os }}
11 |
12 | strategy:
13 | fail-fast: false
14 | matrix:
15 | include:
16 | - target: linux
17 | os: ubuntu-latest
18 | binary: ./packages/cli/bin/anime
19 | - target: macos
20 | os: macos-latest
21 | binary: ./packages/cli/bin/anime
22 | - target: windows
23 | os: windows-latest
24 | binary: .\packages\cli\bin\anime.exe
25 |
26 | steps:
27 | - uses: actions/checkout@v4
28 |
29 | - name: Setup pnpm
30 | uses: pnpm/action-setup@v4.1.0
31 |
32 | - name: Setup node
33 | uses: actions/setup-node@v4
34 | with:
35 | node-version: 22.16.0
36 | cache: pnpm
37 |
38 | - name: Install
39 | run: pnpm install
40 |
41 | - name: Build
42 | run: pnpm build
43 |
44 | - name: Test Binary
45 | run: |
46 | ${{ matrix.binary }} --help
47 | ${{ matrix.binary }} space
48 |
49 | - uses: actions/upload-artifact@v4
50 | with:
51 | name: anime-${{ matrix.target }}
52 | path: ${{ matrix.binary }}
53 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | branches:
9 | - main
10 | - feat/*
11 |
12 | jobs:
13 | build:
14 | runs-on: ubuntu-latest
15 | steps:
16 | - name: Set Timezone
17 | run: sudo timedatectl set-timezone "Asia/Shanghai"
18 |
19 | - uses: actions/checkout@v4
20 |
21 | - name: Setup pnpm
22 | uses: pnpm/action-setup@v2
23 |
24 | - name: Setup node
25 | uses: actions/setup-node@v4
26 | with:
27 | node-version: 22.16.0
28 | cache: pnpm
29 |
30 | - name: Install
31 | run: pnpm install
32 |
33 | - name: Build
34 | run: pnpm build:all
35 |
36 | - name: Test
37 | run: pnpm test:ci
38 |
--------------------------------------------------------------------------------
/.github/workflows/docs.yml:
--------------------------------------------------------------------------------
1 | name: Deploy docs
2 |
3 | on:
4 | workflow_dispatch:
5 | push:
6 | branches:
7 | - main
8 | paths:
9 | - docs/**
10 |
11 | jobs:
12 | deploy:
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: Set Timezone
16 | run: sudo timedatectl set-timezone "Asia/Shanghai"
17 |
18 | - uses: actions/checkout@v4
19 |
20 | - name: Setup pnpm
21 | uses: pnpm/action-setup@v2
22 |
23 | - name: Setup node
24 | uses: actions/setup-node@v4
25 | with:
26 | node-version: 22.16.0
27 | cache: pnpm
28 |
29 | - name: Install
30 | run: pnpm install
31 |
32 | - name: Build
33 | run: pnpm build:docs
34 |
35 | - name: Setup ossutil
36 | uses: manyuanrong/setup-ossutil@v3.0
37 | with:
38 | endpoint: ${{ secrets.ACCESS_ENDPOINT }}
39 | access-key-id: ${{ secrets.ACCESS_KEY_ID }}
40 | access-key-secret: ${{ secrets.ACCESS_KEY_SECRET }}
41 |
42 | - name: Deploy To OSS
43 | run: |
44 | ossutil rm oss://${{ secrets.BUCKET_NAME }}/ -rf
45 | ossutil cp ./docs/.vitepress/dist oss://${{ secrets.BUCKET_NAME }}/ -rf
46 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'v*'
7 |
8 | jobs:
9 | release:
10 | runs-on: ubuntu-latest
11 |
12 | permissions:
13 | contents: write
14 |
15 | steps:
16 | - name: Set Timezone
17 | run: sudo timedatectl set-timezone "Asia/Shanghai"
18 |
19 | - uses: actions/checkout@v4
20 | with:
21 | fetch-depth: 0
22 |
23 | - uses: actions/setup-node@v4
24 | with:
25 | node-version: 22.16.0
26 |
27 | - run: npx changelogithub
28 | env:
29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
30 |
31 | # binary:
32 | # name: Bundle binary for ${{ matrix.target }}
33 |
34 | # runs-on: ${{ matrix.os }}
35 |
36 | # env:
37 | # binary: anime
38 | # archive: anime-${{ matrix.target }}.${{ matrix.archive }}
39 | # checksum: anime-${{ matrix.target }}-sha256sum.txt
40 |
41 | # strategy:
42 | # fail-fast: false
43 |
44 | # matrix:
45 | # include:
46 | # - target: linux
47 | # os: ubuntu-latest
48 | # dist: ./packages/cli/bin/anime
49 | # archive: tar.gz
50 |
51 | # - target: macos
52 | # os: macos-latest
53 | # dist: ./packages/cli/bin/anime
54 | # archive: zip
55 |
56 | # - target: windows
57 | # os: windows-latest
58 | # dist: .\packages\cli\bin\anime.exe
59 | # archive: zip
60 |
61 | # steps:
62 | # - uses: szenius/set-timezone@v1.2
63 | # with:
64 | # timezoneLinux: "Asia/Shanghai"
65 | # timezoneMacos: "Asia/Shanghai"
66 | # timezoneWindows: "China Standard Time"
67 |
68 | # - uses: actions/checkout@v4
69 |
70 | # - name: Setup pnpm
71 | # uses: pnpm/action-setup@v2.4.0
72 |
73 | # - name: Setup node
74 | # uses: actions/setup-node@v4
75 | # with:
76 | # node-version: 20.x
77 | # cache: pnpm
78 |
79 | # - name: Install
80 | # run: pnpm install
81 |
82 | # - name: Build
83 | # run: pnpm build
84 |
85 | # - name: Test
86 | # run: |
87 | # pnpm test:ci
88 | # ${{ matrix.dist }} --version
89 | # ${{ matrix.dist }} --help
90 |
91 | # - name: Create archive (linux)
92 | # if: ${{ matrix.os == 'ubuntu-latest' }}
93 | # run: |
94 | # tar -czvf ${{ env.archive }} -C $(dirname ${{ matrix.dist }}) $(basename ${{ matrix.dist }})
95 | # sha256sum ${{ env.archive }} > ${{ env.checksum }}
96 |
97 | # - name: Create archive (macos)
98 | # if: ${{ matrix.os == 'macos-latest' }}
99 | # run: |
100 | # zip -j ${{ env.archive }} ${{ matrix.dist }}
101 | # shasum -a 256 ${{ env.archive }} > ${{ env.checksum }}
102 |
103 | # - name: Create archive (windows)
104 | # if: ${{ matrix.os == 'windows-latest' }}
105 | # run: |
106 | # Compress-Archive -DestinationPath ${{ env.archive }} -Path ${{ matrix.dist }}
107 | # Get-FileHash ${{ env.archive }} -Algorithm SHA256 | Out-File ${{ env.checksum }}
108 |
109 | # - name: Upload artifacts archive
110 | # uses: actions/upload-artifact@v4
111 | # with:
112 | # name: ${{ env.archive }}
113 | # path: ${{ env.archive }}
114 |
115 | # - name: Upload artifacts checksum
116 | # uses: actions/upload-artifact@v4
117 | # with:
118 | # name: ${{ env.checksum }}
119 | # path: ${{ env.checksum }}
120 |
121 | # - name: Upload binary to release
122 | # if: ${{ startsWith(github.ref, 'refs/tags/v') }}
123 | # uses: svenstaro/upload-release-action@v2
124 | # with:
125 | # overwrite: true
126 | # tag: ${{ github.ref }}
127 | # repo_token: ${{ secrets.GITHUB_TOKEN }}
128 | # file: ${{ env.archive }}
129 | # asset_name: ${{ env.archive }}
130 |
131 | # - name: Upload checksum to release
132 | # if: ${{ startsWith(github.ref, 'refs/tags/v') }}
133 | # uses: svenstaro/upload-release-action@v2
134 | # with:
135 | # overwrite: true
136 | # tag: ${{ github.ref }}
137 | # repo_token: ${{ secrets.GITHUB_TOKEN }}
138 | # file: ${{ env.checksum }}
139 | # asset_name: ${{ env.checksum }}
140 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | # Build output
11 | node_modules
12 | dist
13 | dist-ssr
14 | *.local
15 | .vite-ssg-temp
16 | .cloudflare
17 | .mf
18 | .wrangler
19 | .turbo
20 | /functions
21 |
22 | # Editor directories and files
23 | .vscode/*
24 | !.vscode/extensions.json
25 | .idea
26 | .DS_Store
27 | *.suo
28 | *.ntvs*
29 | *.njsproj
30 | *.sln
31 | *.sw?
32 |
--------------------------------------------------------------------------------
/.node-version:
--------------------------------------------------------------------------------
1 | 20.10.0
2 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | ignore-workspace-root-check=true
2 | shamefully-hoist=true
3 | strict-peer-dependencies=false
4 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "singleQuote": true,
4 | "printWidth": 100,
5 | "trailingComma": "none"
6 | }
--------------------------------------------------------------------------------
/.tazerc.json:
--------------------------------------------------------------------------------
1 | {
2 | "mode": "major",
3 | "write": true,
4 | "recursive": true,
5 | "exclude": [
6 | "mediainfo.js",
7 | "vitepress"
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # :tv: AnimeSpace
2 |
3 | [](https://www.npmjs.com/package/animespace)
4 | [](https://github.com/yjl9903/AnimeSpace/actions/workflows/ci.yml)
5 | [](https://animespace.onekuma.cn/)
6 | [](./LICENSE)
7 |
8 |
「 你所热爱的就是你的动画 」
9 |
10 | Paste your favourite anime online.
11 |
12 | AnimeSpace is yet another complete **solution** for **automatically following bangumis**.
13 |
14 | All the bangumi resources are automatically collected and downloaded from [動漫花園](https://share.dmhy.org/). **Sincere thanks to [動漫花園](https://share.dmhy.org/) and all the fansubs.**
15 |
16 | + 📖 [中文文档](https://animespace.onekuma.cn/)
17 | + 📚 [部署博客](https://blog.onekuma.cn/alidriver-alist-rclone-animepaste)
18 |
19 | > **Notice**:
20 | >
21 | > 👷♂️ Still work in progress towards v0.1.0.
22 | >
23 | > More docs and out-of-the-box usage will be available in v0.1.0.
24 |
25 | ## Features
26 |
27 | + :gear: **Automatically** collect, download and organize anime resources
28 | + :construction_worker_man: **Scrape anime metadata** from [Bangumi 番组计划](https://bangumi.tv/) and generate NFO file (WIP)
29 | + :film_strip: **Support any media server** including [Infuse](https://firecore.com/infuse), [Plex](https://www.plex.tv/), [Jellyfin](https://github.com/jellyfin/jellyfin), [Kodi](https://kodi.tv/) and so on...
30 |
31 | 
32 |
33 | ## Installation
34 |
35 | > **Prerequisite**
36 | >
37 | > Install latest [Node.js](https://nodejs.org/) and [pnpm](https://pnpm.io/) globally.
38 |
39 | See [部署 | AnimeSpace](https://animespace.onekuma.cn/deploy/) and [安装 CLI | AnimeSpace](https://animespace.onekuma.cn/admin/).
40 |
41 | ## Usage
42 |
43 | ### Prepare anime plan
44 |
45 | It supports to scrape the following list from [Bangumi 番组计划](https://bangumi.tv/).
46 |
47 | First, ensure that you can config the Bangumi ID in your `anime.yaml`.
48 |
49 | ```yaml
50 | plugins:
51 | # ...
52 | - name: bangumi
53 | username: '603937' # <- You Bangumi ID
54 | ```
55 |
56 | Second, just the following simple command.
57 |
58 | ```bash
59 | anime bangumi generate --fansub --create ".yaml"
60 | ```
61 |
62 | See [放映计划 | AnimeSpace](https://animespace.onekuma.cn/admin/plan.html) to get more details.
63 |
64 | Alternatively, if you share similiar interests in animation with me, you can just clone my [.animespace](https://github.com/yjl9903/.animespace) config space directory.
65 |
66 | ### Download anime resources
67 |
68 | Just run the following simple command.
69 |
70 | ```bash
71 | anime refresh
72 | ```
73 |
74 | ## Related Projects
75 |
76 | + [AnimeGarden](https://github.com/yjl9903/AnimeGarden): 動漫花園 3-rd party [mirror site](https://animes.garden/) and API endpoint
77 | + [bgmc](https://github.com/yjl9903/bgmc): Bangumi Data / API Clients
78 | + [nfo.js](https://github.com/yjl9903/nfo.js): Parse and stringify nfo files
79 | + [naria2](https://github.com/yjl9903/naria2): Convenient BitTorrent Client based on the aria2 JSON-RPC
80 | + [BreadFS](https://github.com/yjl9903/BreadFS): Unified File System Abstraction
81 | + [Breadc](https://github.com/yjl9903/Breadc): Yet another Command Line Application Framework with fully TypeScript support
82 | + [memofunc](https://github.com/yjl9903/memofunc): Memorize your function call automatically
83 |
84 | ## Credits
85 |
86 | + **[動漫花園](https://share.dmhy.org/) and all the fansubs**
87 | + [Bangumi 番组计划](https://bangumi.tv/) provides a platform for sharing anything about ACG
88 | + [Bangumi Data](https://github.com/bangumi-data/bangumi-data) collects the infomation of animes
89 | + [aria2](能干猫今天也忧郁) and [WebTorrent](https://webtorrent.io/) provide the ability to download magnet links
90 | + [Anime Tracker List](https://github.com/DeSireFire/animeTrackerList) collects trackers for downloading bangumi resources
91 |
92 | ## License
93 |
94 | AGPL-3.0 License © 2023 [XLor](https://github.com/yjl9903)
95 |
--------------------------------------------------------------------------------
/docs/.vitepress/.gitignore:
--------------------------------------------------------------------------------
1 | cache
--------------------------------------------------------------------------------
/docs/.vitepress/config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitepress';
2 |
3 | import { injectScriptTags } from 'unplugin-analytics/vitepress';
4 |
5 | const APP_HOST = `https://animespace.onekuma.cn/`;
6 |
7 | export default defineConfig({
8 | lang: 'zh-CN',
9 | title: 'AnimeSpace',
10 | description: '你所热爱的就是你的动画',
11 | head: [
12 | ['meta', { name: 'theme-color', content: '#ffffff' }],
13 | ['link', { rel: 'icon', href: '/favicon.svg', type: 'image/svg+xml' }]
14 | ],
15 | lastUpdated: true,
16 | sitemap: {
17 | hostname: APP_HOST
18 | },
19 | async transformHead(context) {
20 | injectScriptTags({
21 | umami: {
22 | src: `umami.onekuma.cn`,
23 | id: `49015651-4aac-417b-9cc8-9bbf1a88b1f3`
24 | }
25 | })(context);
26 | },
27 | themeConfig: {
28 | logo: '/favicon.svg',
29 | editLink: {
30 | pattern: 'https://github.com/yjl9903/AnimeSpace/tree/main/docs/:path',
31 | text: '反馈修改建议'
32 | },
33 | footer: {
34 | message: 'Released under the AGPL-3.0 License.',
35 | copyright: 'Copyright © 2023-PRESENT XLor'
36 | },
37 | socialLinks: [{ icon: 'github', link: 'https://github.com/yjl9903/AnimeSpace' }],
38 | search: {
39 | provider: 'local'
40 | },
41 | // algolia: {
42 | // appId: 'FGCMJD7ZM9',
43 | // apiKey: 'dad73f46ec1ba55810109fb2fa7a472b',
44 | // indexName: 'docs'
45 | // },
46 | nav: [
47 | { text: 'AnimeSpace', link: '/animespace/' },
48 | { text: 'AnimeGarden', link: '/animegarden/' },
49 | {
50 | text: '生态系统',
51 | items: [
52 | { text: 'AnimeSpace', link: '/animespace/' },
53 | { text: 'AnimeGarden', link: '/animegarden/' },
54 | { text: 'Anitomy', link: '/anitomy/' },
55 | { text: 'bgmd', link: '/bgmd/' },
56 | { text: 'bgmc', link: '/bgmc/' },
57 | { text: 'tmdbc', link: '/tmdbc/' },
58 | { text: 'nfo.js', link: '/nfo.js/' }
59 | ]
60 | }
61 | ],
62 | sidebar: {
63 | '/': [
64 | {
65 | text: '开始',
66 | items: [
67 | {
68 | text: 'AnimeSpace',
69 | link: '/animespace/'
70 | },
71 | {
72 | text: '安装 CLI',
73 | link: '/animespace/installation/'
74 | }
75 | ]
76 | },
77 | {
78 | text: '配置',
79 | items: [
80 | {
81 | text: '配置根目录',
82 | link: '/animespace/config/'
83 | },
84 | {
85 | text: '放映计划',
86 | link: '/animespace/config/plan'
87 | },
88 | {
89 | text: '集成媒体库软件',
90 | link: '/animespace/config/jellyfin'
91 | }
92 | ]
93 | },
94 | {
95 | text: '命令行程序',
96 | items: [
97 | {
98 | text: '使用 CLI',
99 | link: '/animespace/cli/'
100 | }
101 | ]
102 | },
103 | {
104 | text: '生态系统',
105 | items: [
106 | { text: 'AnimeGarden', link: '/animegarden/' },
107 | { text: 'Anitomy', link: '/anitomy/' },
108 | { text: 'bgmd', link: '/bgmd/' },
109 | { text: 'bgmc', link: '/bgmc/' },
110 | { text: 'tmdbc', link: '/tmdbc/' },
111 | { text: 'nfo.js', link: '/nfo.js/' }
112 | ]
113 | }
114 | ],
115 | '/animegarden/': [
116 | { text: 'AnimeGarden', link: '/animegarden/' },
117 | { text: '高级搜索', link: '/animegarden/search' },
118 | { text: '收藏夹管理', link: '/animegarden/collection' },
119 | { text: 'RSS 订阅', link: '/animegarden/rss' }
120 | ]
121 | }
122 | }
123 | });
124 |
--------------------------------------------------------------------------------
/docs/animegarden/collection.md:
--------------------------------------------------------------------------------
1 | # 收藏夹
2 |
3 | > 👷♂️ 文档内容仍在建设中。
4 |
--------------------------------------------------------------------------------
/docs/animegarden/index.md:
--------------------------------------------------------------------------------
1 | # 🌸 动画花园 AnimeGarden
2 |
3 | [動漫花園](https://share.dmhy.org/) 第三方 [镜像站](https://animes.garden) 以及 [动画 BT 资源聚合站](https://animes.garden).
4 |
5 | + ☁️ 为开发者准备的开放 [API 接口](https://animes.garden/docs/api)
6 | + 📺 查看 [动画放送时间表](https://animes.garden/anime) 来找到你喜欢的动画
7 | + 🔖 支持丰富的高级搜索, 例如: `葬送的芙莉莲 +简体内嵌 字幕组:桜都字幕组 类型:动画`
8 | + 📙 自定义 RSS 订阅链接, 例如: [葬送的芙莉莲](animes.garden/feed.xml?filter=%5B%7B%22fansubId%22:%5B%22619%22%5D,%22type%22:%22%E5%8B%95%E7%95%AB%22,%22include%22:%5B%22%E8%91%AC%E9%80%81%E7%9A%84%E8%8A%99%E8%8E%89%E8%8E%B2%22%5D,%22keywords%22:%5B%22%E7%AE%80%E4%BD%93%E5%86%85%E5%B5%8C%22%5D%7D%5D)
9 | + ⭐ 搜索条件收藏夹和生成聚合的 RSS 订阅链接
10 | + 👷♂️ 支持与 [AutoBangumi](https://www.autobangumi.org/) 和 [AnimeSpace](https://github.com/yjl9903/AnimeSpace) 集成
11 |
12 | 
13 |
14 | ## 高级搜索
15 |
16 | 在搜索框,支持以下几种高级指令:
17 |
18 | + **标题匹配**:
19 | + `标题:xxx`:返回的每一条结果的标题必须**匹配其指定的某一个关键词**
20 | + `包含:xxx` 或者 `+xxx`:返回的每一条结果的标题必须**匹配其指定的所有关键词**
21 | + `排除:xxx` 或者 `-xxx`:返回的每一条结果的标题不能包含**其指定的所有关键词**
22 | + `fansub:xxx`:返回某一字幕组的所有结果
23 | + `after:xxx`:返回结果的创建时间在 `xxx` **之后**
24 | + `before:xxx`:返回结果的创建时间在 `xxx` **之前**
25 |
26 | 详细见 [高级搜索](/animegarden/search)。
27 |
28 | ## 收藏夹
29 |
30 | 详细见 [收藏夹](/animegarden/collection)。
31 |
32 | ## RSS 订阅
33 |
34 | 从 AnimeGarden 获得的所有 RSS 订阅链接均可以在 AutoBangumi 中使用。
35 |
36 | 详细见 [RSS 订阅](/animegarden/rss)。
37 |
--------------------------------------------------------------------------------
/docs/animegarden/rss.md:
--------------------------------------------------------------------------------
1 | # RSS 订阅
2 |
3 | > 👷♂️ 文档内容仍在建设中。
4 |
5 | ## 与 AutoBangumi 集成
6 |
7 | 从 AnimeGarden 获得的所有 RSS 订阅链接均可以在 AutoBangumi 中使用。
8 |
9 | 你也可以使用收藏夹功能,将多个搜索条件聚合成一个订阅链接,就像使用[蜜柑计划](https://mikanani.me/)。
10 |
--------------------------------------------------------------------------------
/docs/animegarden/search.md:
--------------------------------------------------------------------------------
1 | # 高级搜索
2 |
3 | > 👷♂️ 文档内容仍在建设中。
4 |
5 | 在搜索框,支持以下几种高级指令:
6 |
7 | + **标题匹配**:
8 | + `标题:xxx`:返回的每一条结果的标题必须**匹配其指定的某一个关键词**
9 | + `包含:xxx` 或者 `+xxx`:返回的每一条结果的标题必须**匹配其指定的所有关键词**
10 | + `排除:xxx` 或者 `-xxx`:返回的每一条结果的标题不能包含**其指定的所有关键词**
11 | + `fansub:xxx`:返回某一字幕组的所有结果
12 | + `after:xxx`:返回结果的创建时间在 `xxx` **之后**
13 | + `before:xxx`:返回结果的创建时间在 `xxx` **之前**
14 |
15 | ## 标题匹配
16 |
17 | AnimeGarden 使用了**两套搜索策略**:
18 |
19 | + 如果你不使用**标题匹配指令**,AnimeGarden 将会根据你的输入,使用**搜索引擎**进行**模糊匹配**
20 | + 使用**标题匹配指令**,AnimeGarden 将会依照对应的逻辑,筛选结果
21 |
22 | 举例来说:
23 |
24 | `标题:葬送的芙莉莲`,将会匹配所有**标题包含** _"葬送的芙莉莲"_ 的条目。
25 |
26 | `标题:葬送的芙莉莲 标题:"Sousou no Frieren"`,将会匹配所有**标题包含** _"葬送的芙莉莲"_ **或者** _"Sousou no Frieren"_ 的条目。
27 |
28 | `标题:葬送的芙莉莲 标题:"Sousou no Frieren" 包含:简体内嵌 包含:桜都字幕组`,将会匹配所有**标题包含** _"葬送的芙莉莲"_ **或者** _"Sousou no Frieren"_,并且**标题包含** _"简体内嵌"_ **和** _"桜都字幕组"_ 的条目。
29 |
30 | `标题:葬送的芙莉莲 标题:"Sousou no Frieren" 包含:简体内嵌 包含:桜都字幕组 排除:繁`,将会匹配所有**标题包含** _"葬送的芙莉莲"_ **或者** _"Sousou no Frieren"_,并且**标题包含** _"简体内嵌"_ **和** _"桜都字幕组"_,并且**标题不包含** _"繁"_ 的条目。
31 |
32 | 如果你对数理逻辑比较熟悉, 你可以理解为(省略了字符串包含):
33 |
34 | ```text
35 | (标题1 OR 标题2 OR ...) AND (包含1 AND 包含2 AND ...) AND (not 排除1 AND not 排除2 AND ...)
36 | ```
37 |
38 | ## 结合字幕组和类型筛选器
39 |
40 | 下面以 "葬送的芙莉莲" 为例, 介绍如何一步步地筛选出你想要的资源。
41 |
42 | 首先,**输入一个标题关键词 "葬送的芙莉莲"**,进行初步地模糊检索。
43 |
44 | 
45 |
46 | 然后, 你注意到了 "桜都字幕组" 字幕组, 你想要进一步筛选它的结果,你可以直接**点击右边任何一个 "桜都字幕组" 按钮**。
47 |
48 | 
49 |
50 | 
51 |
52 | 最后,你发现返回的结果中包含简体和繁体两种,你现在可以使用标题匹配的关键词来进一步筛选,你可以在**搜索框输入** "葬送的芙莉莲 字幕组:桜都字幕组 **+简体内嵌**"。
53 |
54 | 
55 |
56 | 在检索过程中,你可以结合使用**搜索框的标题搜索或者匹配**,也可以**点击字幕组或者类型**,来进一步筛选结果。
57 |
--------------------------------------------------------------------------------
/docs/animespace/cli/index.md:
--------------------------------------------------------------------------------
1 | # 使用 CLI
2 |
3 | > 👷♂️ 文档内容尚未完全更新到新版。
4 |
5 | ```text
6 | anime/0.1.0-beta.18
7 |
8 | Create your own Anime Space
9 |
10 | Usage: anime [OPTIONS]
11 |
12 | Commands:
13 | anime space Display the space directory
14 | anime run [...args] Run command in the space directory
15 | anime watch Watch anime system update
16 | anime refresh Refresh the local anime system
17 | anime introspect Introspect the local anime system
18 | anime garden list [keyword] List videos of anime from AnimeGarden
19 | anime garden clean Clean downloaded and animegarden cache
20 | anime bangumi search Search anime from bangumi and generate plan
21 | anime bangumi generate Generate Plan from your bangumi collections
22 |
23 | Options:
24 | -h, --help Print help
25 | -v, --version Print version
26 | ```
27 |
28 | ## anime space
29 |
30 | 打开配置目录。
31 |
32 | ## anime refresh
33 |
34 | 刷新动画资源。
35 |
--------------------------------------------------------------------------------
/docs/animespace/config/index.md:
--------------------------------------------------------------------------------
1 | # 配置
2 |
3 | AnimeSpace 默认使用 `~/.animespace/` (或者 `ANIMESPACE_ROOT` 环境变量)作为工作目录,储存所有配置文件,动画数据库和视频资源。
4 |
5 | 安装完成后,你必须运行初始化工作目录。
6 |
7 | ```bash
8 | anime space
9 | ```
10 |
11 | ## 直接使用现成的配置目录
12 |
13 | 如果我们共享相似喜好,你可以直接使用我的[配置目录](https://github.com/yjl9903/.animespace)。
14 |
15 | ```bash
16 | gh repo clone yjl9903/.animespace ~/.animespace
17 | ```
18 |
19 | **注意**: 克隆仓库后,记得修改动画的存储位置。
20 |
21 | ## 全局配置目录
22 |
23 | ```txt
24 | ~/.animespace/
25 | ├── plans/ # Plans folder
26 | │ ├─ 2022-04.yml
27 | │ └─ 2022-07.yml
28 | ├── anime/ # Anime store
29 | │ └─ 相合之物
30 | │ ├─ 相合之物 - S01E01.mp4
31 | │ ├─ 相合之物 - S01E02.mp4
32 | │ └─ 相合之物 - S01E03.mp4
33 | └── anime.yaml # AnimeSpace config file
34 | ```
35 |
36 | ## 根配置文件
37 |
38 | 该文件位于:`~/.animespace/anime.yaml`。
39 |
40 | ```yaml
41 | # ~/.animespace/anime.yaml
42 |
43 | storage: ./anime
44 |
45 | preference:
46 | format:
47 | anime: '{title}'
48 | episode: '[{fansub}] {title} - E{ep}.{extension}'
49 | film: '[{fansub}] {title}.{extension}'
50 | ova: '[{fansub}] {title}.{extension}'
51 | extension:
52 | include: [mp4, mkv]
53 | exclude: []
54 | keyword:
55 | order:
56 | format: [mp4, mkv]
57 | resolution: ['1080', '720']
58 | language: ['简', '繁']
59 | exclude: []
60 | fansub:
61 | order: []
62 | exclude: []
63 |
64 | plans:
65 | - ./plans/*.yaml
66 |
67 | plugins:
68 | - name: animegarden
69 | provider: aria2
70 |
71 | - name: local
72 | introspect: true
73 | refresh: true
74 |
75 | - name: bangumi
76 | username: '603937'
77 | ```
78 |
79 | ## 放映计划配置
80 |
81 | 在根配置文件的 `plans` 字段下,你可以指定一个放映计划的配置文件路径列表(相对于工作目录)。
82 |
83 | 推荐在工作目录下创建一个 `plans` 文件夹,用于储存所有的放映计划配置文件。
84 |
85 | 详细配置见 [放映计划](/animespace/config/plan)。
86 |
--------------------------------------------------------------------------------
/docs/animespace/config/jellyfin.md:
--------------------------------------------------------------------------------
1 | # 集成媒体库软件
2 |
3 | > 👷♂️ 文档内容尚未完全更新到新版。
4 |
5 | 所有下载的动画资源默认位于 `~/.animespace/anime/` 目录,你只需要配置使用的媒体库软件扫描该目录即可。AnimeSpace 自动下载的动画资源,大部分都可以直接被媒体库软件自动识别,无须二次刮削;AnimeSpace 同样还支持配置动画名称,季度和文件命名格式,方便软件识别。
6 |
7 | ## Jellyfin
8 |
9 | 
10 |
11 | ### Bangumi 元数据插件
12 |
13 | 见 [jellyfin-plugin-bangumi](https://github.com/kookxiang/jellyfin-plugin-bangumi)。
14 |
15 | ## Infuse
16 |
17 | 👷♂️ WIP
18 |
19 | ## Plex
20 |
21 | 👷♂️ WIP
22 |
23 | ## Kodi
24 |
25 | 👷♂️ WIP
26 |
--------------------------------------------------------------------------------
/docs/animespace/config/plan.md:
--------------------------------------------------------------------------------
1 | # 放映计划
2 |
3 | > 👷♂️ 文档内容尚未完全更新到新版。
4 |
5 | ```yaml
6 | # ~/.animespace/plans/2022-4.yaml
7 |
8 | name: '2022 年 4 月新番'
9 |
10 | date: '2022-04-01 00:00'
11 |
12 | status: onair
13 |
14 | onair:
15 | - title: 相合之物
16 | bgm: '333664'
17 | fansub:
18 | - Lilith-Raws
19 | ```
20 |
--------------------------------------------------------------------------------
/docs/animespace/index.md:
--------------------------------------------------------------------------------
1 | # 开始
2 |
3 | > 👷♂️ 文档内容尚未完全更新到新版。
4 |
5 | AnimeSpace 是另一个全自动追番工具。
6 |
7 | 所有动画资源目前都是自动从[動漫花園](https://share.dmhy.org/)上抓取。非常感谢[動漫花園](https://share.dmhy.org/)平台和所有字幕组的分享!
8 |
9 | ## 指南
10 |
11 | ### 下载 AnimeSpace
12 |
13 | 首先,[下载 AnimeSpace](/animespace/installation/)
14 |
15 | ### 配置 AnimeSpace
16 |
17 | 接下来, 你需要参考 [配置](/animespace/config/) 来配置你的 AnimeSpace。
18 |
19 | ### 刷新动画资源
20 |
21 | 最后,你可以运行以下命令:
22 |
23 | ```bash
24 | anime refresh
25 | ```
26 |
27 | 此时,AnimeSpace 会读取你的配置文件,开始运行:
28 |
29 | 1. 根据放送配置, 从 [AnimeGarden](https://animes.garden) 上获取最新的动画资源;
30 | 2. 将缺失的动画下载到本地的临时目录;
31 | 3. 将动画重命名为恰当的形式,复制到配置的动画目录中。
32 |
--------------------------------------------------------------------------------
/docs/animespace/installation/index.md:
--------------------------------------------------------------------------------
1 | # 安装管理后台 CLI
2 |
3 | 为了给 AnimeSpace 添加动画资源,你需要使用配套的[管理后台命令行程序](https://github.com/yjl9903/AnimeSpace/tree/main/packages/cli)。
4 |
5 | > **环境准备**
6 | >
7 | > 全局安装最新的 [Node.js](https://nodejs.org/) 和 [pnpm](https://pnpm.io/)。
8 |
9 | ## 从 npm 上全局安装
10 |
11 | ```bash
12 | npm i -g animespace
13 | # or
14 | pnpm i -g animespace
15 | ```
16 |
17 | ## 手动安装和链接
18 |
19 | 首先,克隆本仓库(或者你部署时 fork 的仓库)。
20 |
21 | ```bash
22 | git clone https://github.com/yjl9903/AnimeSpace.git
23 | ```
24 |
25 | 然后,安装依赖,并构建 CLI。
26 |
27 | ```bash
28 | pnpm install
29 | pnpm build:cli
30 | ```
31 |
32 | 最后,将 CLI 链接为全局可执行程序。
33 |
34 | ```bash
35 | cd packages/cli
36 | pnpm link -g
37 | ```
38 |
39 | ## 检验安装成功
40 |
41 | 如果下载和安装成功, `anime` 命令将被注册到全局,你可以运行以下命令,确认下载的 AnimeSpace CLI 的版本号。
42 |
43 | ```bash
44 | anime --version
45 | ```
46 |
--------------------------------------------------------------------------------
/docs/anitomy/index.md:
--------------------------------------------------------------------------------
1 | # Anitomy
2 |
3 | A TypeScript port of [Anitomy](https://github.com/erengy/anitomy) inspired by [AnitomySharp](https://github.com/tabratton/AnitomySharp). All credits to [erengy](https://github.com/erengy) for the actual library.
4 |
5 | More features:
6 |
7 | + Implemented without any dependencies, which supports run in Node.js, Deno, Bun, and Browser
8 | + Optimized for parsing Chinese torrent name from [動漫花園](https://share.dmhy.org/)
9 |
10 | > 👷♂️ Still work in progress.
11 |
12 | ## Installation
13 |
14 | ```bash
15 | npm i anitomy
16 | ```
17 |
18 | ## Usage
19 |
20 | ```ts
21 | import { parse } from 'anitomy'
22 |
23 | const info = parse(`[Lilith-Raws] 熊熊勇闖異世界 PUNCH! / Kuma Kuma Kuma Bear S02 - 02 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4]`)
24 | ```
25 |
26 | ```js
27 | {
28 | "audio": {
29 | "term": "AAC",
30 | },
31 | "episode": {
32 | "number": 2,
33 | "numberAlt": undefined,
34 | "title": undefined,
35 | },
36 | "file": {
37 | "checksum": undefined,
38 | "extension": "MP4",
39 | "name": "[Lilith-Raws] 熊熊勇闖異世界 PUNCH! / Kuma Kuma Kuma Bear S02 - 02 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4]",
40 | },
41 | "language": "CHT",
42 | "month": undefined,
43 | "release": {
44 | "group": "Lilith-Raws",
45 | "version": undefined,
46 | },
47 | "season": "2",
48 | "source": "WEB-DL",
49 | "subtitles": undefined,
50 | "title": "熊熊勇闖異世界 PUNCH! / Kuma Kuma Kuma Bear S02",
51 | "type": undefined,
52 | "video": {
53 | "resolution": "1080p",
54 | "term": "AVC",
55 | },
56 | "volume": {
57 | "number": undefined,
58 | },
59 | "year": undefined,
60 | }
61 | ```
62 |
--------------------------------------------------------------------------------
/docs/bgmc/index.md:
--------------------------------------------------------------------------------
1 | # Bangumi Client
2 |
3 | ## Installation
4 |
5 | ```bash
6 | npm i bgmc
7 | ```
8 |
9 | ## Usage
10 |
11 | Create the bangumi API client, and fetch something.
12 |
13 | ```ts
14 | import { BgmClient } from 'bgmc';
15 |
16 | const client = new BgmClient(fetch);
17 | const calendar = await client.calendar();
18 |
19 | console.log(calendar);
20 | ```
21 |
22 | Get the lastest bangumi data from the cdn of [bgmd](https://unpkg.com/bgmd@0/data/index.json).
23 |
24 | ```ts
25 | import { getCalendar } from 'bgmc/data';
26 |
27 | const calendar = await getCalendar();
28 | console.log(calendar);
29 | ```
30 |
--------------------------------------------------------------------------------
/docs/bgmd/index.md:
--------------------------------------------------------------------------------
1 | # Bangumi Data
2 |
3 | ## Installation
4 |
5 | ```bash
6 | npm i bgmd
7 | ```
8 |
9 | You can also just use the following cdn to get the latest data.
10 |
11 | - `https://unpkg.com/bgmd@0/data/index.json`
12 | - `https://unpkg.com/bgmd@0/data/calendar.json`
13 | - `https://unpkg.com/bgmd@0/data/full.json`
14 |
15 | Or you can just use the following APIs in `bgmc/data` to fetch the latest data from cdn.
16 |
17 | ```ts
18 | import { getCalendar } from 'bgmc/data';
19 |
20 | const calendar = await getCalendar();
21 | console.log(calendar);
22 | ```
23 |
24 | ## Usage
25 |
26 | ```ts
27 | import { bangumis } from 'bgmd/full'
28 | import { calendar } from 'bgmd/calendar'
29 |
30 | // ...
31 | ```
32 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: home
3 | sidebar: false
4 |
5 | title: AnimeSpace
6 | titleTemplate: AnimeSpace
7 |
8 | hero:
9 | name: AnimeSpace
10 | text: Keep following your favourite anime
11 | tagline: 你所热爱的就是你的动画
12 | image:
13 | src: /favicon.svg
14 | alt: AnimeSpace Favicon
15 | actions:
16 | - theme: brand
17 | text: 开始
18 | link: /animespace/
19 | - theme: alt
20 | text: Anime Garden
21 | link: /animegarden/
22 | - theme: alt
23 | text: GitHub
24 | link: https://github.com/yjl9903/AnimeSpace
25 |
26 | features:
27 | - title: 自动化
28 | details: 自动抓取 / 下载 / 整理动画资源
29 | - title: 动画花园 Anime Garden
30 | details: 動漫花園第三方镜像站
31 | - title: 集成媒体库
32 | details: 本地资源可被 Jellyfin, Infuse 等软件自动识别
33 | ---
--------------------------------------------------------------------------------
/docs/nfo.js/index.md:
--------------------------------------------------------------------------------
1 | # nfo.js
2 |
3 | Parse and stringify [nfo files](https://kodi.wiki/view/NFO_files).
4 |
5 | > NFO files contain information about the release, such as the digital media title, authorship, year, or license information. This information is delivered for publishing through digital media to make it searchable on the web as well as within local catalogues and libraries.
6 | >
7 | > From [.nfo - Wikipeida](https://en.wikipedia.org/wiki/.nfo).
8 |
9 | 👷♂️ Still work in progress.
10 |
11 | ## Installation
12 |
13 | ```bash
14 | npm i nfojs
15 | ```
16 |
17 | ## Usage
18 |
19 | ```ts
20 | import { stringifyTVShow } from 'nfojs'
21 |
22 | const text = stringifyTVShow({
23 | title: '【我推的孩子】',
24 | ratings: [{ name: 'bangumi', max: '10', value: '7.8' }],
25 | uniqueId: [{ type: 'bangumi', value: '386809' }],
26 | userrating: '7',
27 | plot: `“在演艺圈里,谎言就是武器。 ”
28 | 在小城市工作的妇产科医生・五郎,有一天他所推的偶像“B小町”出现在了他的面前。“B小町”有着一个禁忌的秘密。
29 | 如此这般的两人实现了最糟糕的相遇,从此命运的齿轮开始转动——`,
30 | season: '1',
31 | premiered: '2023-04-12',
32 | actor: [
33 | {
34 | name: '平牧大辅',
35 | role: '导演',
36 | thumb: 'https://lain.bgm.tv/pic/crt/l/85/d2/13069_prsn_9C181.jpg'
37 | },
38 | {
39 | name: '田中仁',
40 | role: '脚本'
41 | }
42 | ]
43 | })
44 | ```
45 |
--------------------------------------------------------------------------------
/docs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@animespace/docs",
3 | "version": "0.1.0-beta.24",
4 | "private": true,
5 | "type": "module",
6 | "scripts": {
7 | "build": "vitepress build",
8 | "dev": "vitepress --open",
9 | "serve": "vitepress serve"
10 | },
11 | "dependencies": {
12 | "@vueuse/core": "^13.3.0",
13 | "vue": "^3.5.15"
14 | },
15 | "devDependencies": {
16 | "@algolia/client-search": "^5.25.0",
17 | "@iconify-json/carbon": "^1.2.8",
18 | "@types/node": "^22.15.29",
19 | "@unocss/reset": "^66.1.3",
20 | "@vitejs/plugin-vue": "^5.2.4",
21 | "fast-glob": "^3.3.3",
22 | "https-localhost": "^4.7.1",
23 | "unocss": "^66.1.3",
24 | "unplugin-analytics": "^0.0.12",
25 | "unplugin-vue-components": "^28.7.0",
26 | "vite": "^6.3.5",
27 | "vite-plugin-pwa": "^1.0.0",
28 | "vitepress": "1.6.3",
29 | "workbox-window": "^7.3.0"
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/docs/public/BingSiteAuth.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | CD7F2B2E8843152DAFC93C13891922CF
4 |
--------------------------------------------------------------------------------
/docs/public/Jellyfin.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yjl9903/AnimeSpace/c6e0d03a2b0edcc83dd94c223108e4b8a7ed9e9d/docs/public/Jellyfin.jpeg
--------------------------------------------------------------------------------
/docs/public/animepaste.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yjl9903/AnimeSpace/c6e0d03a2b0edcc83dd94c223108e4b8a7ed9e9d/docs/public/animepaste.png
--------------------------------------------------------------------------------
/docs/public/baidu_verify_codeva-LdRV3NCcLP.html:
--------------------------------------------------------------------------------
1 | 21c7e27f81b30fcb7807021b5119167a
--------------------------------------------------------------------------------
/docs/public/cloudflare.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yjl9903/AnimeSpace/c6e0d03a2b0edcc83dd94c223108e4b8a7ed9e9d/docs/public/cloudflare.png
--------------------------------------------------------------------------------
/docs/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/public/search-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yjl9903/AnimeSpace/c6e0d03a2b0edcc83dd94c223108e4b8a7ed9e9d/docs/public/search-1.png
--------------------------------------------------------------------------------
/docs/public/search-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yjl9903/AnimeSpace/c6e0d03a2b0edcc83dd94c223108e4b8a7ed9e9d/docs/public/search-2.png
--------------------------------------------------------------------------------
/docs/public/search-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yjl9903/AnimeSpace/c6e0d03a2b0edcc83dd94c223108e4b8a7ed9e9d/docs/public/search-3.png
--------------------------------------------------------------------------------
/docs/public/search-4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yjl9903/AnimeSpace/c6e0d03a2b0edcc83dd94c223108e4b8a7ed9e9d/docs/public/search-4.png
--------------------------------------------------------------------------------
/docs/tmdbc/index.md:
--------------------------------------------------------------------------------
1 | # TMDB Client
2 |
3 | ## Installation
4 |
5 | ```bash
6 | npm i tmdbc
7 | ```
8 |
9 | ## Usage
10 |
11 | ```ts
12 | import { TMDBClient } from 'tmdbc';
13 |
14 | const client = new TMDBClient({ token: 'Your token' });
15 | ```
16 |
--------------------------------------------------------------------------------
/docs/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 |
3 | export default defineConfig({
4 | server: {
5 | fs: {
6 | // Allow serving files from one level up to the project root
7 | allow: ['..']
8 | }
9 | }
10 | });
11 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@animespace/monorepo",
3 | "version": "0.1.0-beta.24",
4 | "private": true,
5 | "scripts": {
6 | "anime": "tsx packages/cli/src/cli.ts",
7 | "build": "turbo run build --filter !@animespace/docs",
8 | "build:all": "turbo run build",
9 | "build:cli": "turbo run build --filter animespace...",
10 | "build:docs": "turbo run build --filter @animespace/docs",
11 | "dev:cli": "turbo run dev --filter animespace...",
12 | "dev:docs": "pnpm -C docs dev",
13 | "format": "turbo run format --parallel",
14 | "release": "bumpp package.json docs/package.json packages/*/package.json --commit --push --tag && pnpm -r publish --access public",
15 | "test:ci": "turbo run test:ci",
16 | "typecheck": "turbo run typecheck",
17 | "preversion": "pnpm test:ci",
18 | "postversion": "pnpm build:cli"
19 | },
20 | "devDependencies": {
21 | "@types/node": "^22.15.29",
22 | "bumpp": "latest",
23 | "optc": "^0.6.4",
24 | "presea": "^0.0.10",
25 | "prettier": "^3.5.3",
26 | "rimraf": "^6.0.1",
27 | "turbo": "^2.5.4",
28 | "typescript": "^5.8.3",
29 | "unbuild": "^3.5.0",
30 | "vite": "^6.3.5",
31 | "vitepress": "1.6.3",
32 | "vitest": "^3.1.4"
33 | },
34 | "packageManager": "pnpm@10.11.0",
35 | "engines": {
36 | "node": ">=v20.8.0"
37 | },
38 | "pnpm": {
39 | "patchedDependencies": {
40 | "consola@3.1.0": "patches/consola@3.1.0.patch"
41 | },
42 | "onlyBuiltDependencies": [
43 | "@naria2/node",
44 | "bufferutil",
45 | "esbuild",
46 | "node-datachannel",
47 | "utf-8-validate",
48 | "utp-native"
49 | ]
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/packages/animegarden/README.md:
--------------------------------------------------------------------------------
1 | # @animespace/animegarden
2 |
3 | ## License
4 |
5 | AGPL-3.0 License © 2023 [XLor](https://github.com/yjl9903)
6 |
--------------------------------------------------------------------------------
/packages/animegarden/build.config.ts:
--------------------------------------------------------------------------------
1 | import { defineBuildConfig } from 'unbuild';
2 |
3 | export default defineBuildConfig({
4 | entries: ['src/index'],
5 | declaration: true,
6 | clean: true,
7 | rollup: {
8 | inlineDependencies: true,
9 | emitCJS: true
10 | },
11 | dependencies: ['libaria2-ts'],
12 | externals: ['breadc']
13 | });
14 |
--------------------------------------------------------------------------------
/packages/animegarden/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@animespace/animegarden",
3 | "version": "0.1.0-beta.24",
4 | "description": "Create your own Anime Space",
5 | "keywords": [
6 | "anime",
7 | "animegarden",
8 | "dmhy",
9 | "animespace",
10 | "cli"
11 | ],
12 | "homepage": "https://animespace.onekuma.cn/",
13 | "bugs": {
14 | "url": "https://github.com/yjl9903/AnimeSpace/issues"
15 | },
16 | "repository": {
17 | "type": "git",
18 | "url": "git+https://github.com/yjl9903/AnimeSpace.git",
19 | "directory": "packages/animegarden"
20 | },
21 | "license": "AGPL-3.0",
22 | "author": "XLor",
23 | "sideEffects": false,
24 | "type": "module",
25 | "exports": {
26 | ".": {
27 | "types": "./dist/index.d.ts",
28 | "import": "./dist/index.mjs",
29 | "require": "./dist/index.cjs"
30 | }
31 | },
32 | "main": "dist/index.cjs",
33 | "module": "dist/index.mjs",
34 | "types": "dist/index.d.ts",
35 | "files": [
36 | "dist",
37 | "*.mjs"
38 | ],
39 | "scripts": {
40 | "build": "unbuild",
41 | "dev": "unbuild --stub",
42 | "format": "prettier --write src/**/*.ts",
43 | "test": "vitest",
44 | "typecheck": "tsc --noEmit"
45 | },
46 | "dependencies": {
47 | "@animegarden/client": "^0.5.2",
48 | "@animespace/core": "workspace:*",
49 | "@breadc/color": "^0.9.7",
50 | "@naria2/node": "^0.1.2",
51 | "@onekuma/map": "^0.1.10",
52 | "anitomy": "^0.0.35",
53 | "breadfs": "^0.1.8",
54 | "cli-progress": "^3.12.0",
55 | "date-fns": "^4.1.0",
56 | "debug": "^4.4.1",
57 | "defu": "^6.1.4",
58 | "fs-extra": "^11.3.0",
59 | "get-port-please": "^3.1.2",
60 | "libaria2": "^1.0.95",
61 | "memofunc": "^0.1.6",
62 | "naria2": "^0.1.2",
63 | "pathe": "^2.0.3",
64 | "string-width": "^7.2.0",
65 | "webtorrent": "^2.6.8",
66 | "zod": "^3.25.31"
67 | },
68 | "devDependencies": {
69 | "breadc": "^0.9.7"
70 | },
71 | "engines": {
72 | "node": ">=v20.7.0"
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/packages/animegarden/scripts/client.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 | import { fileURLToPath } from 'node:url';
3 |
4 | import { createSystem, loadSpace } from '@animespace/core';
5 |
6 | import { Aria2Client } from '../src/download/aria2';
7 |
8 | const root = path.join(fileURLToPath(import.meta.url), '../../../core/test/fixtures/space');
9 | const space = await loadSpace(root);
10 | const system = await createSystem(space);
11 |
12 | const client = new Aria2Client(system, {
13 | debug: { log: './aria2.log', pipe: false }
14 | });
15 |
16 | await client.start();
17 |
18 | await client.download(
19 | '[喵萌Production&LoliHouse] 偶像大师 灰姑娘女孩 U149 / THE IDOLM@STER CINDERELLA GIRLS U149 - 05 [WebRip 1080p HEVC-10bit AAC][简繁日内封字幕]',
20 | 'magnet:?xt=urn:btih:TYQHELYZVAJ5RHVER5VXO36V6XOSPBUO&dn=&tr=http%3A%2F%2F104.143.10.186%3A8000%2Fannounce&tr=udp%3A%2F%2F104.143.10.186%3A8000%2Fannounce&tr=http%3A%2F%2Ftracker.openbittorrent.com%3A80%2Fannounce&tr=http%3A%2F%2Ftracker3.itzmx.com%3A6961%2Fannounce&tr=http%3A%2F%2Ftracker4.itzmx.com%3A2710%2Fannounce&tr=http%3A%2F%2Ftracker.publicbt.com%3A80%2Fannounce&tr=http%3A%2F%2Ftracker.prq.to%2Fannounce&tr=http%3A%2F%2Fopen.acgtracker.com%3A1096%2Fannounce&tr=https%3A%2F%2Ft-115.rhcloud.com%2Fonly_for_ylbud&tr=http%3A%2F%2Ftracker1.itzmx.com%3A8080%2Fannounce&tr=http%3A%2F%2Ftracker2.itzmx.com%3A6961%2Fannounce&tr=udp%3A%2F%2Ftracker1.itzmx.com%3A8080%2Fannounce&tr=udp%3A%2F%2Ftracker2.itzmx.com%3A6961%2Fannounce&tr=udp%3A%2F%2Ftracker3.itzmx.com%3A6961%2Fannounce&tr=udp%3A%2F%2Ftracker4.itzmx.com%3A2710%2Fannounce&tr=http%3A%2F%2Ftr.bangumi.moe%3A6969%2Fannounce&tr=http%3A%2F%2Ft.nyaatracker.com%2Fannounce&tr=http%3A%2F%2Fopen.nyaatorrents.info%3A6544%2Fannounce&tr=http%3A%2F%2Ft2.popgo.org%3A7456%2Fannonce&tr=http%3A%2F%2Fshare.camoe.cn%3A8080%2Fannounce&tr=http%3A%2F%2Fopentracker.acgnx.se%2Fannounce&tr=http%3A%2F%2Ftracker.acgnx.se%2Fannounce&tr=http%3A%2F%2Fnyaa.tracker.wf%3A7777%2Fannounce&tr=http%3A%2F%2Ft.nyaatracker.com%3A80%2Fannounce&tr=https%3A%2F%2Ftr.bangumi.moe%3A9696%2Fannounce&tr=http%3A%2F%2Ft.acg.rip%3A6699%2Fannounce&tr=http%3A%2F%2Fopen.acgnxtracker.com%2Fannounce&tr=https%3A%2F%2Ftracker.nanoha.org%2Fannounce',
21 | {
22 | onStart() {
23 | system.logger.log('Start downloading');
24 | },
25 | onMetadataProgress(payload) {
26 | system.logger.log(
27 | `Metadata: ${payload.completed} / ${payload.total} (Connections: ${payload.connections}, Speed: ${payload.speed})`
28 | );
29 | },
30 | onMetadataComplete() {
31 | system.logger.log(`Metadata OK`);
32 | },
33 | onProgress(payload) {
34 | system.logger.log(
35 | `Downloading: ${payload.completed} / ${payload.total} (Connections: ${payload.connections}, Speed: ${payload.speed})`
36 | );
37 | },
38 | onComplete() {
39 | system.logger.log('Download OK');
40 | },
41 | onError(error) {
42 | system.logger.error(error.message);
43 | }
44 | }
45 | );
46 |
47 | await client.close();
48 |
--------------------------------------------------------------------------------
/packages/animegarden/src/cli.ts:
--------------------------------------------------------------------------------
1 | import type { Breadc } from 'breadc';
2 |
3 | import { bold, lightYellow, link } from '@breadc/color';
4 | import { type AnimeSystem, loadAnime } from '@animespace/core';
5 |
6 | import './plan.d';
7 |
8 | import { ANIMEGARDEN, DOT } from './constant';
9 | import { generateDownloadTask } from './task';
10 | import { formatAnimeGardenSearchURL, printFansubs, printKeywords } from './format';
11 | import { DownloadClient } from './download';
12 | import { fetchAnimeResources } from './resources';
13 |
14 | export function registerCli(
15 | system: AnimeSystem,
16 | cli: Breadc<{}>,
17 | getClient: (system: AnimeSystem) => DownloadClient
18 | ) {
19 | const logger = system.logger.withTag('animegarden');
20 |
21 | cli
22 | .command('garden list [keyword]', 'List videos of anime from AnimeGarden')
23 | .option('--onair', 'Only display onair animes')
24 | .action(async (keyword, options) => {
25 | const animes = await filterAnimes(keyword, options);
26 |
27 | for (const anime of animes) {
28 | const animegardenURL = formatAnimeGardenSearchURL(anime);
29 | logger.log(
30 | `${bold(anime.plan.title)} (${link(
31 | `Bangumi: ${anime.plan.bgm}`,
32 | `https://bangumi.tv/subject/${anime.plan.bgm}`
33 | )}, ${link('AnimeGarden', animegardenURL)})`
34 | );
35 | printKeywords(anime, logger);
36 | printFansubs(anime, logger);
37 |
38 | const resources = await fetchAnimeResources(system, anime);
39 | const videos = await generateDownloadTask(system, anime, resources, true);
40 | const lib = await anime.library();
41 |
42 | for (const { video, resource } of videos) {
43 | const detailURL = `https://animes.garden/detail/${resource.provider}/${resource.providerId}`;
44 |
45 | let extra = '';
46 | if (!lib.videos.find((v) => v.source.magnet === video.source.magnet!)) {
47 | const aliasVideo = lib.videos.find(
48 | (v) => v.source.type !== ANIMEGARDEN && v.episode === video.episode
49 | );
50 | if (aliasVideo) {
51 | extra = `overwritten by ${bold(aliasVideo.filename)}`;
52 | } else {
53 | extra = lightYellow('Not yet downloaded');
54 | }
55 | }
56 |
57 | logger.log(` ${DOT} ${link(video.filename, detailURL)} ${extra ? `(${extra})` : ''}`);
58 | }
59 | logger.log('');
60 | }
61 | });
62 |
63 | cli
64 | .command('garden clean', 'Clean downloaded and animegarden cache')
65 | .option('-y, --yes')
66 | .option('-e, --ext ', {
67 | description: 'Clean downloaded files with extensions (splitted by ",")',
68 | default: 'mp4,mkv,aria2'
69 | })
70 | .action(async (options) => {
71 | const client = getClient(system);
72 | const extensions = options.ext.split(',');
73 | const exts = extensions.map((e) => (e.startsWith('.') ? e : '.' + e));
74 |
75 | await client.clean(exts);
76 | });
77 |
78 | // --- Util functions ---
79 | async function filterAnimes(keyword: string | undefined, options: { onair: boolean }) {
80 | return (
81 | await loadAnime(system, (a) => (options.onair ? a.plan.status === 'onair' : true))
82 | ).filter(
83 | (a) =>
84 | !keyword ||
85 | a.plan.title.includes(keyword) ||
86 | Object.values(a.plan.translations)
87 | .flat()
88 | .some((t) => t.includes(keyword))
89 | );
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/packages/animegarden/src/constant.ts:
--------------------------------------------------------------------------------
1 | import createDebug from 'debug';
2 | import { dim } from '@breadc/color';
3 |
4 | export const DOT = dim('•');
5 |
6 | export const ANIMEGARDEN = 'AnimeGarden';
7 |
8 | export const debug = createDebug('animegarden');
9 |
--------------------------------------------------------------------------------
/packages/animegarden/src/download/base.ts:
--------------------------------------------------------------------------------
1 | import type { AnimeSystem } from '@animespace/core';
2 |
3 | interface DownloadLogger {
4 | info: (message: string) => void;
5 | warn: (message: string) => void;
6 | error: (message: string) => void;
7 | }
8 |
9 | export abstract class DownloadClient {
10 | private _system: AnimeSystem;
11 |
12 | protected logger?: DownloadLogger;
13 |
14 | public constructor(system: AnimeSystem) {
15 | this._system = system;
16 | }
17 |
18 | public get system() {
19 | return this._system;
20 | }
21 |
22 | public set system(system: AnimeSystem) {
23 | this._system = system;
24 | this.initialize(system);
25 | }
26 |
27 | public setLogger(logger: DownloadLogger) {
28 | this.logger = logger;
29 | }
30 |
31 | public abstract download(
32 | key: string,
33 | magnet: string,
34 | options?: DownloadOptions
35 | ): Promise<{ files: string[] }>;
36 |
37 | public abstract initialize(system: AnimeSystem): void;
38 |
39 | public abstract start(): Promise;
40 |
41 | public abstract close(): Promise;
42 |
43 | public async clean(extensions: string[] = ['.mp4', '.mkv']) {}
44 | }
45 |
46 | type MayPromise = T | Promise;
47 |
48 | export type DownloadState = 'waiting' | 'metadata' | 'downloading' | 'complete' | 'error';
49 |
50 | export interface DownloadProgress {
51 | total: bigint;
52 |
53 | completed: bigint;
54 |
55 | connections: number;
56 |
57 | speed: number;
58 | }
59 |
60 | export interface DownloadOptions {
61 | onStart?: () => MayPromise;
62 |
63 | onMetadataProgress?: (payload: DownloadProgress) => MayPromise;
64 |
65 | onMetadataComplete?: (payload: DownloadProgress) => MayPromise;
66 |
67 | onProgress?: (payload: DownloadProgress) => MayPromise;
68 |
69 | onComplete?: (payload: DownloadProgress) => MayPromise;
70 |
71 | onError?: (error: { message?: string; code?: number }) => MayPromise;
72 | }
73 |
--------------------------------------------------------------------------------
/packages/animegarden/src/download/index.ts:
--------------------------------------------------------------------------------
1 | import type { AnimeSystem } from '@animespace/core';
2 |
3 | import { Aria2Client } from './aria2';
4 | import { DownloadClient } from './base';
5 | import { WebtorrentClient } from './webtorrent';
6 |
7 | export { DownloadClient } from './base';
8 |
9 | export type DownloadProviders = 'webtorrent' | 'aria2' | 'qbittorrent';
10 |
11 | export function makeClient(
12 | provider: DownloadProviders,
13 | system: AnimeSystem,
14 | options: any
15 | ): DownloadClient {
16 | switch (provider) {
17 | case 'aria2':
18 | return new Aria2Client(system, options);
19 | case 'qbittorrent':
20 | case 'webtorrent':
21 | default:
22 | return new WebtorrentClient(system);
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/packages/animegarden/src/download/trackers.ts:
--------------------------------------------------------------------------------
1 | export const DefaultTrackers = [
2 | 'http://tracker.gbitt.info/announce',
3 | 'https://tracker.lilithraws.cf/announce',
4 | 'https://tracker1.520.jp/announce',
5 | 'http://www.wareztorrent.com/announce',
6 | 'https://tr.burnabyhighstar.com/announce',
7 | 'http://tk.greedland.net/announce',
8 | 'http://trackme.theom.nz:80/announce',
9 | 'https://tracker.foreverpirates.co:443/announce',
10 | 'http://tracker3.ctix.cn:8080/announce',
11 | 'https://tracker.m-team.cc/announce.php',
12 | 'https://tracker.gbitt.info:443/announce',
13 | 'https://tracker.loligirl.cn/announce',
14 | 'https://tp.m-team.cc:443/announce.php',
15 | 'https://tr.abir.ga/announce',
16 | 'http://tracker.electro-torrent.pl/announce',
17 | 'http://1337.abcvg.info/announce',
18 | 'https://trackme.theom.nz:443/announce',
19 | 'https://tracker.tamersunion.org:443/announce',
20 | 'https://tr.abiir.top/announce',
21 | 'wss://tracker.openwebtorrent.com:443/announce',
22 | 'http://www.all4nothin.net:80/announce.php',
23 | 'https://tracker.kuroy.me:443/announce',
24 | 'https://1337.abcvg.info:443/announce',
25 | 'http://torrentsmd.com:8080/announce',
26 | 'https://tracker.gbitt.info/announce',
27 | 'udp://tracker.sylphix.com:6969/announce'
28 | ];
29 |
--------------------------------------------------------------------------------
/packages/animegarden/src/download/webtorrent.ts:
--------------------------------------------------------------------------------
1 | import { AnimeSystem } from '@animespace/core';
2 |
3 | import { DownloadClient, DownloadOptions } from './base';
4 |
5 | export class WebtorrentClient extends DownloadClient {
6 | public async download(
7 | magnet: string,
8 | outDir: string,
9 | options?: DownloadOptions | undefined
10 | ): Promise<{ files: string[] }> {
11 | throw new Error('Method not implemented.');
12 | }
13 |
14 | public initialize(system: AnimeSystem) {}
15 |
16 | public async start() {}
17 |
18 | public async close() {
19 | return true;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/packages/animegarden/src/format.ts:
--------------------------------------------------------------------------------
1 | import type { ConsolaInstance } from 'consola';
2 |
3 | import width from 'string-width';
4 |
5 | import { Anime } from '@animespace/core';
6 | import { dim, link, underline } from '@breadc/color';
7 |
8 | export function formatAnimeGardenSearchURL(anime: Anime) {
9 | const include = anime.plan.keywords.include.map((v) => 'include=' + v);
10 | const exclude = anime.plan.keywords.exclude.map((v) => 'exclude=' + v);
11 |
12 | return `https://animes.garden/resources/1?${include.join('&')}&${exclude.join('&')}&after=${encodeURIComponent(anime.plan.date.toISOString())}`;
13 | }
14 |
15 | export function printKeywords(anime: Anime, logger: ConsolaInstance) {
16 | const include = anime.plan.keywords.include;
17 | const sum = include.reduce((acc, t) => acc + width(t), 0);
18 | if (sum > 50) {
19 | logger.log(dim('Include keywords | ') + underline(overflowText(include[0], 50)));
20 | for (const t of include.slice(1)) {
21 | logger.log(` ${dim('|')} ${underline(overflowText(t, 50))}`);
22 | }
23 | } else {
24 | logger.log(
25 | `${dim('Include keywords')} ${include
26 | .map((t) => underline(overflowText(t, 50)))
27 | .join(dim(' | '))}`
28 | );
29 | }
30 |
31 | if (anime.plan.keywords.exclude.length > 0) {
32 | logger.log(
33 | `${dim(`Exclude keywords`)} [ ${anime.plan.keywords.exclude
34 | .map((t) => underline(t))
35 | .join(' , ')} ]`
36 | );
37 | }
38 | }
39 |
40 | export function printFansubs(anime: Anime, logger: ConsolaInstance) {
41 | const fansubs = anime.plan.fansub;
42 | logger.log(
43 | `${dim('Prefer fansubs')} ${
44 | fansubs.length === 0
45 | ? `See ${link('AnimeGarden', formatAnimeGardenSearchURL(anime))} to select some fansubs`
46 | : fansubs.join(dim(' > '))
47 | }`
48 | );
49 | }
50 |
51 | function overflowText(text: string, length: number, rest = '...') {
52 | if (width(text) <= length) {
53 | return text;
54 | } else {
55 | return text.slice(0, length - rest.length) + rest;
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/packages/animegarden/src/index.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod';
2 | import { memo } from 'memofunc';
3 | import { fetchResourceDetail } from '@animegarden/client';
4 | import { bold, dim, lightBlue, lightCyan, lightRed, link } from '@breadc/color';
5 |
6 | import { type AnimeSystem, type Plugin, type PluginEntry, onDeath, ufetch } from '@animespace/core';
7 |
8 | import './plan.d';
9 |
10 | import { registerCli } from './cli';
11 | import { ANIMEGARDEN, DOT } from './constant';
12 | import { DownloadProviders, makeClient } from './download';
13 | import { generateDownloadTask, runDownloadTask } from './task';
14 | import { formatAnimeGardenSearchURL, printFansubs, printKeywords } from './format';
15 | import { clearAnimeResourcesCache, fetchAnimeResources, useResourcesCache } from './resources';
16 |
17 | export interface AnimeGardenOptions extends PluginEntry {
18 | api?: string;
19 |
20 | provider?: DownloadProviders;
21 | }
22 |
23 | const memoClient = memo(
24 | (provider: DownloadProviders, system: AnimeSystem, options: any) => {
25 | const client = makeClient(provider, system, options);
26 | onDeath(async () => {
27 | await client.close();
28 | });
29 | return client;
30 | },
31 | {
32 | serialize() {
33 | return [];
34 | }
35 | }
36 | );
37 |
38 | export function AnimeGarden(options: AnimeGardenOptions): Plugin {
39 | const provider = options.provider ?? 'webtorrent';
40 | const config = { baseURL: options.api };
41 | const getClient = (sys: AnimeSystem) => memoClient(provider, sys, options);
42 |
43 | let shouldClearCache = false;
44 |
45 | return {
46 | name: 'animegarden',
47 | options,
48 | schema: {
49 | plan: z.object({
50 | bgm: z.coerce.string()
51 | })
52 | },
53 | command(system, cli) {
54 | registerCli(system, cli, getClient);
55 | },
56 | writeLibrary: {
57 | async post(system, anime) {
58 | if (shouldClearCache) {
59 | clearAnimeResourcesCache(system, anime);
60 | }
61 | }
62 | },
63 | introspect: {
64 | async pre(system) {
65 | shouldClearCache = true;
66 | },
67 | async handleUnknownVideo(system, anime, video) {
68 | if (video.source.type === ANIMEGARDEN && video.source.magnet) {
69 | const logger = system.logger.withTag('animegarden');
70 | const client = getClient(system);
71 |
72 | const resource = await fetchResourceDetail(
73 | 'dmhy',
74 | video.source.magnet.split('/').at(-1)!,
75 | { fetch: ufetch, baseURL: options.api }
76 | );
77 |
78 | try {
79 | if (
80 | resource &&
81 | resource.resource &&
82 | resource.detail &&
83 | resource.detail.magnets.length > 0
84 | ) {
85 | await client.start();
86 |
87 | logger.log(
88 | `${lightBlue('Downloading')} ${bold(video.filename)} ${dim('from')} ${link(
89 | `AnimeGarden`,
90 | video.source.magnet
91 | )}`
92 | );
93 |
94 | await anime.removeVideo(video);
95 | await runDownloadTask(
96 | system,
97 | anime,
98 | [
99 | {
100 | video,
101 | resource: {
102 | ...resource.resource,
103 | // This should have tracker
104 | magnet: resource.detail?.magnets[0].url,
105 | tracker: ''
106 | }
107 | }
108 | ],
109 | client
110 | );
111 | }
112 | } catch (error) {
113 | logger.error(error);
114 | // should not clear video library
115 | return video;
116 | } finally {
117 | return video;
118 | }
119 | }
120 |
121 | return undefined;
122 | }
123 | },
124 | refresh: {
125 | async pre(system, options) {
126 | useResourcesCache.clear();
127 | const cache = await useResourcesCache(system, config);
128 | if (options.filter !== undefined) {
129 | cache.disable();
130 | }
131 | },
132 | async post(system) {
133 | const cache = await useResourcesCache(system, config);
134 | cache.finalize();
135 | useResourcesCache.clear();
136 | },
137 | async refresh(system, anime) {
138 | const logger = system.logger.withTag('animegarden');
139 | logger.log('');
140 |
141 | logger.log(
142 | `${lightBlue('Fetching resources')} ${bold(anime.plan.title)} (${link(
143 | `Bangumi: ${anime.plan.bgm}`,
144 | `https://bangumi.tv/subject/${anime.plan.bgm}`
145 | )})`
146 | );
147 | printKeywords(anime, logger);
148 | printFansubs(anime, logger);
149 |
150 | const animegardenURL = formatAnimeGardenSearchURL(anime);
151 | const resources = await fetchAnimeResources(system, anime, config).catch(() => undefined);
152 | if (resources === undefined) {
153 | logger.log(
154 | `${lightRed('Found resources')} ${dim('from')} ${link(
155 | 'AnimeGarden',
156 | animegardenURL
157 | )} ${lightRed('failed')}`
158 | );
159 | return;
160 | }
161 |
162 | const newVideos = await generateDownloadTask(system, anime, resources);
163 |
164 | const oldVideos = (await anime.library()).videos.filter(
165 | (v) => v.source.type === ANIMEGARDEN
166 | );
167 | logger.log(
168 | `${dim('There are')} ${lightCyan(oldVideos.length + ' resources')} ${dim(
169 | 'downloaded from'
170 | )} ${link('AnimeGarden', animegardenURL)}`
171 | );
172 | if (newVideos.length === 0) {
173 | return;
174 | }
175 |
176 | logger.log(
177 | `${lightBlue(`Downloading ${newVideos.length} resources`)} ${dim('from')} ${link(
178 | 'AnimeGarden',
179 | animegardenURL
180 | )}`
181 | );
182 | for (const { video, resource } of newVideos) {
183 | const detailURL = `https://animes.garden/detail/${resource.provider}/${resource.providerId}`;
184 | logger.log(` ${DOT} ${link(video.filename, detailURL)}`);
185 | }
186 |
187 | try {
188 | const client = getClient(system);
189 | client.system = system; // Ensure the system is correct
190 |
191 | await runDownloadTask(system, anime, newVideos, client);
192 | } catch (error) {
193 | logger.error(error);
194 | }
195 | }
196 | }
197 | };
198 | }
199 |
--------------------------------------------------------------------------------
/packages/animegarden/src/logger.ts:
--------------------------------------------------------------------------------
1 | import Progress from 'cli-progress';
2 | import { bold } from '@breadc/color';
3 |
4 | const { Format, MultiBar, Presets, SingleBar } = Progress;
5 |
6 | export function createSingleProgress() {
7 | return new SingleBar(
8 | {
9 | format: ' {bar} {percentage}% | ETA: {eta}s',
10 | clearOnComplete: true
11 | },
12 | Presets.shades_grey
13 | );
14 | }
15 |
16 | export interface ProgressBarOption {
17 | suffix?: (value: number, total: number, payload: T) => string;
18 | }
19 |
20 | export function createProgressBar>(
21 | option: ProgressBarOption = {}
22 | ) {
23 | const multibar = new MultiBar(
24 | {
25 | format(_options, params, payload: T & { title: string }) {
26 | // const formatTime = Format.TimeFormat;
27 | const formatValue = Format.ValueFormat;
28 | const formatBar = Format.BarFormat;
29 | const percentage = Math.floor(params.progress * 100);
30 | // const stopTime = Date.now();
31 | // const elapsedTime = Math.round((stopTime - params.startTime) / 1000);
32 |
33 | const context = {
34 | bar: formatBar(params.progress, _options),
35 |
36 | percentage: formatValue(percentage, _options, 'percentage'),
37 | total: params.total,
38 | value: params.value
39 | // eta: formatValue(params.eta, _options, 'eta'),
40 | // duration: formatValue(elapsedTime, _options, 'duration'),
41 | };
42 |
43 | const suffix: string = option.suffix
44 | ? ' | ' + option.suffix(params.value, params.total, payload)
45 | : '';
46 |
47 | return payload.title !== undefined && typeof payload.title === 'string'
48 | ? `${payload.title}`
49 | : `${context.bar} ${context.percentage}%` + suffix;
50 | },
51 | stopOnComplete: false,
52 | clearOnComplete: true,
53 | hideCursor: true,
54 | forceRedraw: true
55 | },
56 | Presets.shades_grey
57 | );
58 |
59 | multibar.on('stop', () => {
60 | // @ts-ignore
61 | for (const line of multibar.loggingBuffer) {
62 | // Remove the last end of line symbol
63 | console.log(line.substring(0, line.length - 1));
64 | }
65 | });
66 |
67 | return {
68 | finish() {
69 | multibar.stop();
70 | },
71 | println(text: string) {
72 | multibar.log(text + '\n');
73 | },
74 | create(name: string, length: number) {
75 | const empty = multibar.create(length, 0, {}, { title: name } as any);
76 | const title = multibar.create(length, 0, {}, { title: name } as any);
77 | const progress = multibar.create(length, 0);
78 | title.update(0, { title: name });
79 | empty.update(0, { title: '' });
80 |
81 | return {
82 | stop() {
83 | empty.stop();
84 | title.stop();
85 | progress.stop();
86 | },
87 | remove() {
88 | this.stop();
89 | multibar.remove(empty);
90 | multibar.remove(title);
91 | multibar.remove(progress);
92 | },
93 | rename(newName: string) {
94 | name = newName;
95 | },
96 | update(value: number, payload?: T) {
97 | empty.update(value, { title: '' });
98 | title.update(value, { title: name });
99 | progress.update(value, payload);
100 | },
101 | increment(value: number, payload?: T) {
102 | empty.increment(value, { title: '' });
103 | title.increment(value, { title: name });
104 | progress.increment(value, payload);
105 | }
106 | };
107 | }
108 | };
109 | }
110 |
--------------------------------------------------------------------------------
/packages/animegarden/src/plan.d.ts:
--------------------------------------------------------------------------------
1 | import '@animespace/core';
2 |
3 | declare module '@animespace/core' {
4 | interface AnimePlan {
5 | bgm: string;
6 | }
7 |
8 | interface LocalVideoSource {
9 | magnet?: string;
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/packages/animegarden/src/resources/cache.ts:
--------------------------------------------------------------------------------
1 | import { Path } from 'breadfs';
2 | import { memoAsync } from 'memofunc';
3 | import {
4 | type FetchResourcesOptions,
5 | type Resource,
6 | fetchResources,
7 | makeResourcesFilter
8 | } from '@animegarden/client';
9 |
10 | import { Anime, AnimeSystem, ufetch } from '@animespace/core';
11 |
12 | import { debug } from '../constant';
13 |
14 | type ResourcesCacheSchema = Required<
15 | Omit>, 'ok' | 'complete'>
16 | >;
17 |
18 | type AnimeCacheSchema = Required<
19 | Omit>, 'ok' | 'complete'> & {
20 | prefer: { fansub: string[] };
21 | }
22 | >;
23 |
24 | export class ResourcesCache {
25 | private readonly system: AnimeSystem;
26 |
27 | private readonly options: FetchResourcesOptions;
28 |
29 | private readonly root: Path;
30 |
31 | private readonly animeRoot: Path;
32 |
33 | private readonly resourcesRoot: Path;
34 |
35 | private valid: boolean = false;
36 |
37 | private recentResources: Resource[] = [];
38 |
39 | private errors: unknown[] = [];
40 |
41 | private recentResponse: Awaited> | undefined = undefined;
42 |
43 | constructor(system: AnimeSystem, options: Pick = {}) {
44 | this.system = system;
45 | this.options = options;
46 | this.root = system.space.storage.cache.join('animegarden');
47 | this.animeRoot = this.root.join('anime');
48 | this.resourcesRoot = this.root.join('resources');
49 | }
50 |
51 | private reset() {
52 | this.valid = false;
53 | this.recentResources = [];
54 | this.recentResponse = undefined;
55 | this.errors = [];
56 | }
57 |
58 | public disable() {
59 | this.reset();
60 | }
61 |
62 | private async loadLatestResources(): Promise {
63 | try {
64 | const content = await this.resourcesRoot.join('latest.json').readText();
65 | return JSON.parse(content);
66 | } catch {
67 | return undefined;
68 | }
69 | }
70 |
71 | private async updateLatestResources(
72 | resp: Awaited>
73 | ): Promise {
74 | try {
75 | const copied = { ...resp };
76 | Reflect.deleteProperty(copied, 'ok');
77 | Reflect.deleteProperty(copied, 'complete');
78 |
79 | await this.resourcesRoot.join('latest.json').writeText(JSON.stringify(copied, null, 2));
80 | } catch {}
81 | }
82 |
83 | public async initialize() {
84 | await Promise.all([this.animeRoot.ensureDir(), this.resourcesRoot.ensureDir()]);
85 |
86 | const latest = await this.loadLatestResources();
87 | const timestamp = latest?.resources[0]?.fetchedAt
88 | ? new Date(latest.resources[0].fetchedAt)
89 | : undefined;
90 |
91 | // There is no cache found or the cache is old
92 | const invalid =
93 | timestamp === undefined ||
94 | new Date().getTime() - timestamp.getTime() > 7 * 24 * 60 * 60 * 1000;
95 |
96 | const ac = new AbortController();
97 |
98 | const resp = await fetchResources({
99 | fetch: ufetch,
100 | baseURL: this.options.baseURL,
101 | type: '动画',
102 | retry: 10,
103 | count: -1,
104 | signal: ac.signal,
105 | timeout: 60 * 1000,
106 | tracker: true,
107 | headers: {
108 | 'Cache-Control': 'no-store'
109 | },
110 | progress(delta) {
111 | if (invalid) {
112 | ac.abort();
113 | return;
114 | }
115 |
116 | const newItems = delta.filter(
117 | (item) => new Date(item.fetchedAt).getTime() > timestamp.getTime()
118 | );
119 | if (newItems.length === 0) {
120 | ac.abort();
121 | }
122 | }
123 | });
124 |
125 | this.valid =
126 | resp.resources.length > 0 || !resp.ok || !invalid || !resp.filter || !resp.timestamp;
127 |
128 | const oldIds = new Set(latest?.resources.map((r) => r.id) ?? []);
129 | this.recentResources = resp.resources.filter((r) => !oldIds.has(r.id));
130 | this.recentResponse = resp;
131 | }
132 |
133 | public async finalize() {
134 | // When there is no fetch error, store the latest response
135 | if (this.errors.length === 0 && this.recentResponse) {
136 | await this.updateLatestResources(this.recentResponse);
137 | }
138 | this.reset();
139 | }
140 |
141 | private async loadAnimeResources(anime: Anime): Promise {
142 | try {
143 | const root = this.animeRoot.join(anime.relativeDirectory);
144 | await root.ensureDir();
145 | return JSON.parse(await root.join('resources.json').readText());
146 | } catch {
147 | return undefined;
148 | }
149 | }
150 |
151 | private async updateAnimeResources(
152 | anime: Anime,
153 | resp: Awaited & { magnet: string }>
154 | ): Promise {
155 | try {
156 | const root = this.animeRoot.join(anime.relativeDirectory);
157 |
158 | const copied = { ...resp, prefer: { fansub: anime.plan.fansub } };
159 | Reflect.deleteProperty(copied, 'ok');
160 | Reflect.deleteProperty(copied, 'complete');
161 |
162 | await root.join('resources.json').writeText(JSON.stringify(copied, null, 2));
163 | } catch {}
164 | }
165 |
166 | public async clearAnimeResources(anime: Anime) {
167 | try {
168 | const root = this.animeRoot.join(anime.relativeDirectory);
169 | await root.join('resources.json').remove();
170 | } catch {}
171 | }
172 |
173 | public async load(anime: Anime) {
174 | const cache = await this.loadAnimeResources(anime);
175 | if (this.valid && cache?.filter) {
176 | // Check whether there is any changes to the filter
177 | const validateFilter = (cache: AnimeCacheSchema) => {
178 | if (
179 | !cache.filter?.after ||
180 | new Date(cache.filter.after).getTime() !== anime.plan.date.getTime()
181 | ) {
182 | return false;
183 | }
184 |
185 | const stringify = (keys?: string[]) => (keys ?? []).join(',');
186 |
187 | if (
188 | !cache.filter?.include ||
189 | stringify(cache.filter.include) !== stringify(anime.plan.keywords.include)
190 | ) {
191 | return false;
192 | }
193 |
194 | if (stringify(cache.filter.exclude) !== stringify(anime.plan.keywords.exclude)) {
195 | return false;
196 | }
197 |
198 | if (stringify(cache.prefer.fansub) !== stringify(anime.plan.fansub)) {
199 | return false;
200 | }
201 |
202 | return true;
203 | };
204 |
205 | const filter = makeResourcesFilter({
206 | types: ['动画'],
207 | after: anime.plan.date,
208 | include: anime.plan.keywords.include,
209 | exclude: anime.plan.keywords.exclude
210 | });
211 | const relatedRes = this.recentResources.filter(filter);
212 | if (validateFilter(cache) && relatedRes.length === 0) {
213 | // There is no change
214 | return cache.resources;
215 | }
216 | }
217 |
218 | try {
219 | const ac = new AbortController();
220 |
221 | const resp = await fetchResources({
222 | fetch: ufetch,
223 | baseURL: this.options.baseURL,
224 | type: '动画',
225 | after: anime.plan.date,
226 | include: anime.plan.keywords.include,
227 | exclude: anime.plan.keywords.exclude,
228 | tracker: true,
229 | retry: 10,
230 | count: -1,
231 | signal: ac.signal,
232 | progress(delta, props) {
233 | // for (const item of delta) {
234 | // }
235 | }
236 | });
237 |
238 | await this.updateAnimeResources(anime, resp);
239 |
240 | return resp.resources;
241 | } catch (error) {
242 | debug(error);
243 | // Record fetch error happened
244 | this.errors.push(error);
245 | throw error;
246 | }
247 | }
248 | }
249 |
250 | export async function clearAnimeResourcesCache(system: AnimeSystem, anime: Anime) {
251 | const cache = new ResourcesCache(system);
252 | await cache.clearAnimeResources(anime);
253 | }
254 |
255 | export const useResourcesCache = memoAsync(
256 | async (system: AnimeSystem, options?: Pick) => {
257 | const cache = new ResourcesCache(system, options);
258 | await cache.initialize();
259 | return cache;
260 | }
261 | );
262 |
--------------------------------------------------------------------------------
/packages/animegarden/src/resources/index.ts:
--------------------------------------------------------------------------------
1 | import type { FetchResourcesOptions } from '@animegarden/client';
2 |
3 | import { Anime, AnimeSystem } from '@animespace/core';
4 |
5 | import { useResourcesCache } from './cache';
6 |
7 | export { clearAnimeResourcesCache, useResourcesCache } from './cache';
8 |
9 | export async function fetchAnimeResources(
10 | system: AnimeSystem,
11 | anime: Anime,
12 | options?: Pick
13 | ) {
14 | const cache = await useResourcesCache(system);
15 | try {
16 | return await cache.load(anime);
17 | } catch (error) {
18 | console.error(error);
19 | throw error;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/packages/animegarden/src/task/extract.ts:
--------------------------------------------------------------------------------
1 | import type { Resource } from '@animegarden/client';
2 |
3 | import {
4 | Anime,
5 | AnimePlanType,
6 | AnimeSystem,
7 | getEpisodeKey,
8 | hasEpisodeNumberAlt,
9 | isValidEpisode,
10 | parseEpisode
11 | } from '@animespace/core';
12 | import { MutableMap } from '@onekuma/map';
13 |
14 | import { lightYellow } from '@breadc/color';
15 |
16 | import type { Task } from './types';
17 |
18 | export async function generateDownloadTask(
19 | system: AnimeSystem,
20 | anime: Anime,
21 | resources: Resource<{ tracker: true }>[],
22 | force = false
23 | ) {
24 | const library = await anime.library();
25 | const ordered = groupResources(system, anime, resources);
26 | const videos: Task[] = [];
27 |
28 | for (const [_ep, { fansub, resources }] of ordered) {
29 | resources.sort((lhs, rhs) => {
30 | const tl = lhs.title;
31 | const tr = rhs.title;
32 |
33 | for (const [_, order] of Object.entries(anime.plan.preference.keyword.order)) {
34 | for (const k of order) {
35 | const key = k.toLowerCase();
36 | const hl = tl.toLowerCase().indexOf(key) !== -1;
37 | const hr = tr.toLowerCase().indexOf(key) !== -1;
38 | if (hl !== hr) {
39 | if (hl) {
40 | return -1;
41 | } else {
42 | return 1;
43 | }
44 | }
45 | }
46 | }
47 |
48 | if (rhs.provider !== lhs.provider) {
49 | return rhs.provider.localeCompare(lhs.provider);
50 | }
51 |
52 | const createdAt = new Date(rhs.createdAt).getTime() - new Date(lhs.createdAt).getTime();
53 | if (createdAt) {
54 | return createdAt;
55 | }
56 |
57 | return new Date(rhs.fetchedAt).getTime() - new Date(lhs.fetchedAt).getTime();
58 | });
59 |
60 | const res = resources[0];
61 | if (
62 | force ||
63 | !library.videos.find((r) => r.source.magnet?.split('/').at(-1) === res.providerId)
64 | ) {
65 | const info = parseEpisode(anime, res.title, {
66 | metadata: (info) => ({
67 | fansub: res.fansub?.name ?? res.publisher.name ?? info.release.group ?? 'fansub'
68 | })
69 | });
70 |
71 | if (isValidEpisode(info)) {
72 | videos.push({
73 | video: {
74 | filename: anime.formatFilename({
75 | type: info.type,
76 | fansub,
77 | episode: info.parsed.episode.number, // Raw episode number
78 | extension: info.parsed.file.extension
79 | }),
80 | naming: 'auto',
81 | fansub: fansub,
82 | type: unifyType(info.type),
83 | season: info.parsed.season ? +info.parsed.season : undefined,
84 | episode: info.parsed.episode.number, // Raw episode number
85 | source: {
86 | type: 'AnimeGarden',
87 | magnet: `https://animes.garden/detail/${res.provider}/${res.providerId}`
88 | }
89 | },
90 | resource: res
91 | });
92 | }
93 | }
94 | }
95 |
96 | videos.sort((lhs, rhs) => {
97 | const ds = (lhs.video.season ?? 1) - (rhs.video.season ?? 1);
98 | if (ds !== 0) return ds;
99 | return lhs.video.episode! - rhs.video.episode!;
100 | });
101 |
102 | return videos;
103 | }
104 |
105 | function groupResources(
106 | system: AnimeSystem,
107 | anime: Anime,
108 | resources: Resource<{ tracker: true }>[]
109 | ) {
110 | const logger = system.logger.withTag('animegarden');
111 | const map = new MutableMap[]>>([]);
112 |
113 | for (const r of resources) {
114 | // Resource title should not have exclude keywords
115 | if (anime.plan.preference.keyword.exclude.some((k) => r.title.indexOf(k) !== -1)) {
116 | continue;
117 | }
118 |
119 | const episode = parseEpisode(anime, r.title, {
120 | metadata: (info) => ({
121 | fansub: r.fansub?.name ?? r.publisher.name ?? info.release.group ?? 'fansub'
122 | })
123 | });
124 |
125 | if (episode && isValidEpisode(episode)) {
126 | // Disable multiple files like 01-12
127 | if (episode.type === 'TV') {
128 | if (!hasEpisodeNumberAlt(episode)) {
129 | const fansub = episode.metadata.fansub;
130 | if (fansub === 'fansub' || anime.plan.fansub.includes(fansub)) {
131 | map
132 | .getOrPut(
133 | getEpisodeKey(episode),
134 | () => new MutableMap[]>([])
135 | )
136 | .getOrPut(fansub, () => [])
137 | .push(r);
138 | }
139 | }
140 | } else if (['电影', '特别篇'].includes(episode.type)) {
141 | const fansub = episode.metadata.fansub;
142 | if (fansub === 'fansub' || anime.plan.fansub.includes(fansub)) {
143 | map
144 | .getOrPut(
145 | getEpisodeKey(episode),
146 | () => new MutableMap[]>([])
147 | )
148 | .getOrPut(fansub, () => [])
149 | .push(r);
150 | }
151 | }
152 | } else {
153 | logger.log(`${lightYellow('Parse Error')} ${r.title}`);
154 | }
155 | }
156 |
157 | const fansubIds = new MutableMap(anime.plan.fansub.map((f, idx) => [f, idx]));
158 | const ordered = new MutableMap(
159 | map
160 | .entries()
161 | .filter(([_ep, map]) => map.size > 0)
162 | .map(([ep, map]) => {
163 | const fansubs = map.entries().toArray();
164 | fansubs.sort((lhs, rhs) => {
165 | const fl = fansubIds.getOrDefault(lhs[0], 9999);
166 | const fr = fansubIds.getOrDefault(rhs[0], 9999);
167 | return fl - fr;
168 | });
169 |
170 | return [ep, { fansub: fansubs[0][0], resources: fansubs[0][1] }] as const;
171 | })
172 | .toArray()
173 | );
174 |
175 | return ordered;
176 | }
177 |
178 | function unifyType(type: string): AnimePlanType {
179 | switch (type) {
180 | case '番剧':
181 | case 'TV':
182 | return '番剧';
183 | case '电影':
184 | return '电影';
185 | case '特别篇':
186 | return 'OVA';
187 | default:
188 | return '番剧';
189 | }
190 | }
191 |
--------------------------------------------------------------------------------
/packages/animegarden/src/task/index.ts:
--------------------------------------------------------------------------------
1 | export * from './extract';
2 |
3 | export * from './run';
4 |
5 | export * from './types';
6 |
--------------------------------------------------------------------------------
/packages/animegarden/src/task/types.ts:
--------------------------------------------------------------------------------
1 | import type { Resource } from '@animegarden/client';
2 |
3 | import type { LocalVideo } from '@animespace/core';
4 |
5 | export type Task = {
6 | video: LocalVideo;
7 | resource: Pick<
8 | Resource<{ tracker: true }>,
9 | 'title' | 'magnet' | 'tracker' | 'href' | 'provider' | 'providerId'
10 | >;
11 | };
12 |
--------------------------------------------------------------------------------
/packages/animegarden/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "include": [
4 | "src/**/*.ts"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/packages/bangumi/README.md:
--------------------------------------------------------------------------------
1 | # @animespace/bangumi
2 |
3 | ## License
4 |
5 | AGPL-3.0 License © 2023 [XLor](https://github.com/yjl9903)
6 |
--------------------------------------------------------------------------------
/packages/bangumi/build.config.ts:
--------------------------------------------------------------------------------
1 | import { defineBuildConfig } from 'unbuild';
2 |
3 | export default defineBuildConfig({
4 | entries: ['src/index'],
5 | declaration: true,
6 | clean: true,
7 | rollup: {
8 | emitCJS: true
9 | },
10 | externals: ['breadc']
11 | });
12 |
--------------------------------------------------------------------------------
/packages/bangumi/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@animespace/bangumi",
3 | "version": "0.1.0-beta.24",
4 | "description": "Create your own Anime Space",
5 | "keywords": [
6 | "anime",
7 | "animegarden",
8 | "animespace",
9 | "cli"
10 | ],
11 | "homepage": "https://animespace.onekuma.cn/",
12 | "bugs": {
13 | "url": "https://github.com/yjl9903/AnimeSpace/issues"
14 | },
15 | "repository": {
16 | "type": "git",
17 | "url": "git+https://github.com/yjl9903/AnimeSpace.git",
18 | "directory": "packages/bangumi"
19 | },
20 | "license": "AGPL-3.0",
21 | "author": "XLor",
22 | "sideEffects": false,
23 | "type": "module",
24 | "exports": {
25 | ".": {
26 | "types": "./dist/index.d.ts",
27 | "import": "./dist/index.mjs",
28 | "require": "./dist/index.cjs"
29 | }
30 | },
31 | "main": "dist/index.cjs",
32 | "module": "dist/index.mjs",
33 | "types": "dist/index.d.ts",
34 | "files": [
35 | "dist",
36 | "*.mjs"
37 | ],
38 | "scripts": {
39 | "build": "unbuild",
40 | "dev": "unbuild --stub",
41 | "format": "prettier --write src/**/*.ts",
42 | "test": "vitest",
43 | "typecheck": "tsc --noEmit"
44 | },
45 | "dependencies": {
46 | "@animegarden/client": "^0.5.2",
47 | "@animespace/core": "workspace:*",
48 | "@breadc/color": "^0.9.7",
49 | "bgmc": "^0.0.11",
50 | "date-fns": "^4.1.0",
51 | "fs-extra": "^11.3.0",
52 | "prompts": "^2.4.2"
53 | },
54 | "engines": {
55 | "node": ">=v20.7.0"
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/packages/bangumi/src/generate.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import fs from 'fs-extra';
3 |
4 | import { AnimeSystem, ufetch, uniqBy } from '@animespace/core';
5 |
6 | import { fetchResources } from '@animegarden/client';
7 | import { bold, lightBlue, lightRed } from '@breadc/color';
8 | import { format, getYear, subMonths } from 'date-fns';
9 | import { BgmClient, type CollectionInformation } from 'bgmc';
10 |
11 | import { version } from '../package.json';
12 |
13 | type Item = T extends Array ? R : never;
14 |
15 | type CollectionItem = Item>;
16 |
17 | const client = new BgmClient(ufetch, {
18 | maxRetry: 1,
19 | userAgent: `animespace/${version} (https://github.com/yjl9903/AnimeSpace)`
20 | });
21 |
22 | export async function generatePlan(
23 | system: AnimeSystem,
24 | collections: CollectionItem[] | number[],
25 | options: {
26 | create: string | undefined;
27 | fansub: boolean;
28 | date: string | undefined;
29 | }
30 | ) {
31 | const output: string[] = [];
32 | const writeln = (text: string) => {
33 | if (options.create) {
34 | output.push(text);
35 | } else {
36 | console.log(text);
37 | }
38 | };
39 |
40 | const now = new Date();
41 | const date = inferDate(options.date);
42 | writeln(`title: 创建于 ${format(now, 'yyyy-MM-dd hh:mm')}`);
43 | writeln(``);
44 | writeln(`date: ${format(date, 'yyyy-MM-dd hh:mm')}`);
45 | writeln(``);
46 | writeln(`status: onair`);
47 | writeln(``);
48 | writeln(`onair:`);
49 | for (const anime of collections) {
50 | if (typeof anime === 'object') {
51 | const begin = anime.subject?.date ? new Date(anime.subject.date) : undefined;
52 | if (begin && begin.getTime() < date.getTime()) {
53 | continue;
54 | }
55 |
56 | if (options.create) {
57 | system.logger.log(
58 | `${lightBlue('Searching')} ${bold(
59 | anime.subject?.name_cn || anime.subject?.name || `Bangumi ${anime.subject_id}`
60 | )}`
61 | );
62 | }
63 | }
64 |
65 | try {
66 | const item = await client.subject(typeof anime === 'object' ? anime.subject_id : anime);
67 |
68 | const title = item.name_cn || item.name;
69 | const aliasBox = item.infobox?.find((box) => box.key === '别名');
70 | const translations = Array.isArray(aliasBox?.value)
71 | ? ((aliasBox?.value.map((v) => v?.v).filter(Boolean) as string[]) ?? [])
72 | : typeof aliasBox?.value === 'string'
73 | ? [aliasBox.value]
74 | : [];
75 |
76 | if (item.name && item.name !== title) {
77 | translations.unshift(item.name);
78 | }
79 |
80 | const plan = {
81 | title,
82 | bgm: '' + item.id,
83 | season: inferSeason(title, ...translations),
84 | type: inferType(item),
85 | translations
86 | };
87 |
88 | const escapeString = (t: string) => t.replace(`'`, `''`);
89 |
90 | writeln(` - title: ${escapeString(plan.title)}`);
91 | writeln(` alias:`);
92 | for (const t of plan.translations ?? []) {
93 | writeln(` - '${escapeString(t)}'`);
94 | }
95 | if (plan.season !== 1) {
96 | writeln(` season: ${plan.season}`);
97 | }
98 | writeln(` bgm: '${plan.bgm}'`);
99 | if (plan.type) {
100 | writeln(` type: '${plan.type}'`);
101 | }
102 |
103 | if (options.fansub) {
104 | const fansub = await getFansub([plan.title, ...plan.translations]);
105 | writeln(` fansub:`);
106 | if (fansub.length === 0) {
107 | writeln(` # No fansub found, please check the translations or search keywords`);
108 | }
109 | for (const f of fansub) {
110 | writeln(` - ${f}`);
111 | }
112 | if (fansub.length === 0 && options.create) {
113 | system.logger.warn(`No fansub found for ${title}`);
114 | }
115 | }
116 |
117 | const includeURL = [title, ...translations]
118 | .map((t) =>
119 | t
120 | .replace(/\[/g, '%5B')
121 | .replace(/\]/g, '%5D')
122 | .replace(/,/g, '%2C')
123 | .replace(/"/g, '%22')
124 | .replace(/ /g, '%20')
125 | )
126 | .map((v) => 'include=' + v);
127 |
128 | writeln(
129 | ` # https://animes.garden/resources/1?${includeURL.join('&')}&after=${encodeURIComponent(
130 | date.toISOString()
131 | )}`
132 | );
133 | writeln(``);
134 | } catch (error) {
135 | if (typeof anime === 'object') {
136 | system.logger.error(
137 | `${lightRed('Failed to search')} ${bold(
138 | anime.subject?.name_cn || anime.subject?.name || `Bangumi ${anime.subject_id}`
139 | )}`
140 | );
141 | } else {
142 | system.logger.error(error);
143 | }
144 | }
145 | }
146 |
147 | if (options.create) {
148 | const p = path.join(system.space.root.resolve(options.create).path);
149 | await fs.writeFile(p, output.join('\n'), 'utf-8');
150 | }
151 | }
152 |
153 | export async function searchBgm(input: string) {
154 | return (await client.search(input, { type: 2 })).list ?? [];
155 | }
156 |
157 | export async function getCollections(username: string) {
158 | const list: CollectionItem[] = [];
159 | while (true) {
160 | const { data } = await client.getCollections(username, {
161 | subject_type: 2,
162 | type: 3,
163 | limit: 50,
164 | offset: list.length
165 | });
166 | if (data && data.length > 0) {
167 | list.push(...data);
168 | } else {
169 | break;
170 | }
171 | }
172 | return uniqBy(list, (c) => '' + c.subject_id);
173 | }
174 |
175 | async function getFansub(titles: string[]) {
176 | const { resources } = await fetchResources({
177 | fetch: ufetch,
178 | include: titles,
179 | count: -1,
180 | retry: 5
181 | });
182 | return uniqBy(
183 | resources.filter((r) => !!r.fansub),
184 | (r) => r.fansub!.name
185 | ).map((r) => r.fansub!.name);
186 | }
187 |
188 | function inferType(subject: Awaited>) {
189 | const FILM = ['电影', '剧场版'];
190 | const titles = [subject.name, subject.name_cn];
191 |
192 | {
193 | for (const title of titles) {
194 | for (const f of FILM) {
195 | if (title && title.includes(f)) {
196 | return '电影';
197 | }
198 | }
199 | }
200 | }
201 | {
202 | for (const tag of subject.tags) {
203 | if (FILM.includes(tag.name)) {
204 | return '电影';
205 | }
206 | }
207 | }
208 |
209 | return undefined;
210 | }
211 |
212 | function inferSeason(...titles: string[]) {
213 | for (const title of titles) {
214 | {
215 | const match = /Season\s*(\d+)/.exec(title);
216 | if (match) {
217 | return +match[1];
218 | }
219 | }
220 | {
221 | const match = /第\s*(\d+)\s*(季|期)/.exec(title);
222 | if (match) {
223 | return +match[1];
224 | }
225 | }
226 | if (title.includes('第二季')) return 2;
227 | if (title.includes('第三季')) return 3;
228 | if (title.includes('第四季')) return 4;
229 | if (title.includes('第五季')) return 5;
230 | if (title.includes('第六季')) return 6;
231 | if (title.includes('第七季')) return 7;
232 | if (title.includes('第八季')) return 8;
233 | if (title.includes('第九季')) return 9;
234 | if (title.includes('第十季')) return 10;
235 | }
236 | return 1;
237 | }
238 |
239 | function inferDate(now: string | undefined) {
240 | const date = !!now ? new Date(now) : new Date();
241 | const d1 = new Date(getYear(date), 1, 1, 0, 0, 0);
242 | const d2 = new Date(getYear(date), 4, 1, 0, 0, 0);
243 | const d3 = new Date(getYear(date), 7, 1, 0, 0, 0);
244 | const d4 = new Date(getYear(date), 10, 1, 0, 0, 0);
245 | const d5 = new Date(getYear(date) + 1, 1, 1, 0, 0, 0);
246 | if (d1.getTime() > date.getTime()) {
247 | return subMonths(d1, 1);
248 | } else if (d2.getTime() > date.getTime()) {
249 | return subMonths(d2, 1);
250 | } else if (d3.getTime() > date.getTime()) {
251 | return subMonths(d3, 1);
252 | } else if (d4.getTime() > date.getTime()) {
253 | return subMonths(d4, 1);
254 | } else {
255 | return subMonths(d5, 1);
256 | }
257 | }
258 |
--------------------------------------------------------------------------------
/packages/bangumi/src/index.ts:
--------------------------------------------------------------------------------
1 | import { type Plugin, type PluginEntry } from '@animespace/core';
2 |
3 | import prompts from 'prompts';
4 |
5 | import { generatePlan, getCollections, searchBgm } from './generate';
6 |
7 | export interface BangumiOptions extends PluginEntry {
8 | username?: string;
9 | }
10 |
11 | export function Bangumi(options: BangumiOptions): Plugin {
12 | const defaultUsername = options.username;
13 |
14 | return {
15 | name: 'bangumi',
16 | options,
17 | command(system, cli) {
18 | const logger = system.logger.withTag('bangumi');
19 |
20 | cli
21 | .command('bangumi search ', 'Search anime from bangumi and generate plan')
22 | .alias('bgm search')
23 | .option('--date ', 'Specify the onair begin date')
24 | .option('--fansub', 'Generate fansub list')
25 | .action(async (input, options) => {
26 | const bgms = await searchBgm(input);
27 | if (bgms.length === 0) {
28 | logger.warn('未找到任何动画');
29 | return;
30 | }
31 |
32 | const selected =
33 | bgms.length === 1
34 | ? { bangumi: bgms }
35 | : await prompts({
36 | type: 'multiselect',
37 | name: 'bangumi',
38 | message: '选择将要生成计划的动画',
39 | choices: bgms.map((bgm) => ({
40 | title: (bgm.name_cn || bgm.name) ?? String(bgm.id!),
41 | value: bgm
42 | })),
43 | hint: '- 上下移动, 空格选择, 回车确认',
44 | // @ts-ignore
45 | instructions: false
46 | });
47 |
48 | if (!selected.bangumi) {
49 | return;
50 | }
51 |
52 | if (bgms.length > 1) {
53 | logger.log('');
54 | }
55 |
56 | await generatePlan(
57 | system,
58 | selected.bangumi.map((bgm: any) => bgm.id!),
59 | { create: undefined, fansub: options.fansub, date: options.date }
60 | );
61 | });
62 |
63 | cli
64 | .command('bangumi generate', 'Generate Plan from your bangumi collections')
65 | .alias('bgm gen')
66 | .alias('bgm generate')
67 | .option('--username ', 'Bangumi username')
68 | .option('--create ', 'Create plan file in the space directory')
69 | .option('--fansub', 'Generate fansub list')
70 | .option('--date ', 'Specify the onair begin date')
71 | .action(async (options) => {
72 | const username = options.username ?? defaultUsername ?? '';
73 |
74 | if (!username) {
75 | logger.error('You should provide your bangumi username with --username ');
76 | }
77 |
78 | const collections = await getCollections(username);
79 | return await generatePlan(system, collections, options);
80 | });
81 | }
82 | };
83 | }
84 |
--------------------------------------------------------------------------------
/packages/bangumi/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "include": [
4 | "src/**/*.ts"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/packages/cli/.gitignore:
--------------------------------------------------------------------------------
1 | bin
2 |
--------------------------------------------------------------------------------
/packages/cli/README.md:
--------------------------------------------------------------------------------
1 | # :tv: AnimeSpace
2 |
3 | 「 你所热爱的就是你的动画 」
4 |
5 | [](https://www.npmjs.com/package/animespace)
6 | [](https://github.com/yjl9903/AnimeSpace/actions/workflows/ci.yml)
7 | [](https://animespace.onekuma.cn/)
8 | [](./LICENSE)
9 |
10 | Paste your favourite anime online.
11 |
12 | AnimeSpace is yet another complete **solution** for **automatically following bangumis**.
13 |
14 | All the bangumi resources are automatically collected and downloaded from [動漫花園](https://share.dmhy.org/). **Sincere thanks to [動漫花園](https://share.dmhy.org/) and all the fansubs.**
15 |
16 | + 📖 [中文文档](https://animespace.onekuma.cn/)
17 | + 📚 [部署博客](https://blog.onekuma.cn/alidriver-alist-rclone-animepaste)
18 |
19 | ## Features
20 |
21 | + :gear: **Automatically** collect, download and organize anime resources
22 | + :construction_worker_man: **Scrape anime metadata** from [Bangumi 番组计划](https://bangumi.tv/) and generate NFO file (WIP)
23 | + :film_strip: **Support any media server** including [Infuse](https://firecore.com/infuse), [Plex](https://www.plex.tv/), [Jellyfin](https://github.com/jellyfin/jellyfin), [Kodi](https://kodi.tv/) and so on...
24 |
25 | 
26 |
27 | ## Installation and Deploy
28 |
29 | > **Prerequisite**
30 | >
31 | > Install latest [Node.js](https://nodejs.org/) and [pnpm](https://pnpm.io/) globally.
32 |
33 | See [部署 | AnimeSpace](https://animespace.onekuma.cn/deploy/) and [安装 CLI | AnimeSpace](https://animespace.onekuma.cn/admin/).
34 |
35 | ## Usage
36 |
37 | ### Prepare anime plan
38 |
39 | It supports to scrape the following list from [Bangumi 番组计划](https://bangumi.tv/).
40 |
41 | First, ensure that you can config the Bangumi ID in your `anime.yaml`.
42 |
43 | ```yaml
44 | plugins:
45 | # ...
46 | - name: bangumi
47 | username: '603937' # <- You Bangumi ID
48 | ```
49 |
50 | Second, just the following simple command.
51 |
52 | ```bash
53 | anime bangumi generate --fansub --create ".yaml"
54 | ```
55 |
56 | See [放映计划 | AnimeSpace](https://animespace.onekuma.cn/admin/plan.html) to get more details.
57 |
58 | ### Download anime resources
59 |
60 | Just run the following simple command.
61 |
62 | ```bash
63 | anime refresh
64 | ```
65 |
66 | ## Related Projects
67 |
68 | + [AnimeGarden](https://github.com/yjl9903/AnimeGarden): 動漫花園 3-rd party [mirror site](https://animes.garden/) and API endpoint
69 | + [bgmc](https://github.com/yjl9903/bgmc): Bangumi Data / API Clients
70 | + [nfo.js](https://github.com/yjl9903/nfo.js): Parse and stringify nfo files
71 | + [naria2](https://github.com/yjl9903/naria2): Convenient BitTorrent Client based on the aria2 JSON-RPC
72 | + [BreadFS](https://github.com/yjl9903/BreadFS): Unified File System Abstraction
73 | + [Breadc](https://github.com/yjl9903/Breadc): Yet another Command Line Application Framework with fully TypeScript support
74 | + [memofunc](https://github.com/yjl9903/memofunc): Memorize your function call automatically
75 |
76 | ## Credits
77 |
78 | + **[動漫花園](https://share.dmhy.org/) and all the fansubs**
79 | + [Bangumi 番组计划](https://bangumi.tv/) provides a platform for sharing anything about ACG
80 | + [Bangumi Data](https://github.com/bangumi-data/bangumi-data) collects the infomation of animes
81 | + [aria2](能干猫今天也忧郁) and [WebTorrent](https://webtorrent.io/) provide the ability to download magnet links
82 | + [Anime Tracker List](https://github.com/DeSireFire/animeTrackerList) collects trackers for downloading bangumi resources
83 |
84 | ## License
85 |
86 | AGPL-3.0 License © 2023 [XLor](https://github.com/yjl9903)
87 |
--------------------------------------------------------------------------------
/packages/cli/build.config.ts:
--------------------------------------------------------------------------------
1 | import { defineBuildConfig } from 'unbuild';
2 |
3 | export default defineBuildConfig({
4 | entries: ['src/index', 'src/cli'],
5 | declaration: true,
6 | clean: true,
7 | rollup: {
8 | emitCJS: true
9 | }
10 | });
11 |
--------------------------------------------------------------------------------
/packages/cli/cli.mjs:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | import('./dist/cli.mjs');
4 |
--------------------------------------------------------------------------------
/packages/cli/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "animespace",
3 | "version": "0.1.0-beta.24",
4 | "description": "Create your own Anime Space",
5 | "keywords": [
6 | "anime",
7 | "animespace",
8 | "cli"
9 | ],
10 | "homepage": "https://animespace.onekuma.cn/",
11 | "bugs": {
12 | "url": "https://github.com/yjl9903/AnimeSpace/issues"
13 | },
14 | "repository": {
15 | "type": "git",
16 | "url": "git+https://github.com/yjl9903/AnimeSpace.git",
17 | "directory": "packages/cli"
18 | },
19 | "license": "AGPL-3.0",
20 | "author": "XLor",
21 | "sideEffects": false,
22 | "exports": {
23 | ".": {
24 | "types": "./dist/index.d.ts",
25 | "import": "./dist/index.mjs",
26 | "require": "./dist/index.cjs"
27 | }
28 | },
29 | "main": "dist/index.cjs",
30 | "module": "dist/index.mjs",
31 | "types": "dist/index.d.ts",
32 | "bin": {
33 | "anime": "./cli.mjs"
34 | },
35 | "files": [
36 | "dist",
37 | "*.mjs"
38 | ],
39 | "scripts": {
40 | "build": "unbuild && pnpm bundle",
41 | "bundle": "rimraf bin && ncc build src/cli.ts -m -o bin",
42 | "dev": "unbuild --stub",
43 | "format": "prettier --write src/**/*.ts test/**/*.ts",
44 | "test": "vitest",
45 | "test:ci": "vitest --run",
46 | "typecheck": "tsc --noEmit --skipLibCheck",
47 | "preversion": "pnpm build"
48 | },
49 | "dependencies": {
50 | "@animespace/animegarden": "workspace:*",
51 | "@animespace/bangumi": "workspace:*",
52 | "@animespace/core": "workspace:*",
53 | "@animespace/local": "workspace:*",
54 | "@breadc/color": "^0.9.7",
55 | "@onekuma/map": "^0.1.10",
56 | "breadc": "^0.9.7",
57 | "date-fns": "^4.1.0",
58 | "debug": "^4.4.1",
59 | "fs-extra": "^11.3.0",
60 | "open-editor": "^5.1.0",
61 | "pathe": "^2.0.3",
62 | "prompts": "^2.4.2",
63 | "undici": "^7.10.0"
64 | },
65 | "devDependencies": {
66 | "@types/cli-progress": "^3.11.6",
67 | "@types/debug": "^4.1.12",
68 | "@types/fs-extra": "^11.0.4",
69 | "@types/prompts": "^2.4.9",
70 | "@vercel/ncc": "^0.38.3",
71 | "bangumi-data": "^0.3.173"
72 | },
73 | "engines": {
74 | "node": ">=v20.7.0"
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/packages/cli/scripts/sea.mjs:
--------------------------------------------------------------------------------
1 | import fs from 'fs-extra';
2 | import { bundle } from 'presea';
3 |
4 | const main = './bin/index.js';
5 |
6 | // Replace require("node:os") -> require("os")
7 | const content = await fs.readFile(main, 'utf-8');
8 | const patched = content.replace(/require\("node:(\w+)"\)/g, `require("$1")`);
9 | await fs.writeFile(main, patched, 'utf-8');
10 |
11 | await bundle(process.cwd(), {
12 | main,
13 | outDir: './bin',
14 | sign: true,
15 | useSnapshot: false,
16 | useCodeCache: true
17 | });
18 |
--------------------------------------------------------------------------------
/packages/cli/src/cli.ts:
--------------------------------------------------------------------------------
1 | import createDebug from 'debug';
2 | import { lightRed } from '@breadc/color';
3 | import { BreadcError, ParseError } from 'breadc';
4 |
5 | import { AnimeSystemError, onUncaughtException, onUnhandledRejection } from '@animespace/core';
6 |
7 | import { makeCliApp, makeSystem } from './system';
8 |
9 | const debug = createDebug('animespace');
10 |
11 | export async function bootstrap() {
12 | const handle = (error: unknown) => {
13 | if (error instanceof AnimeSystemError) {
14 | console.error(lightRed('Anime System ') + error.detail);
15 | } else if (error instanceof ParseError || error instanceof BreadcError) {
16 | console.error(lightRed('CLI ') + error.message);
17 | } else if (error instanceof Error) {
18 | console.error(lightRed('Unknown ') + error.message);
19 | } else {
20 | console.error(error);
21 | }
22 | debug(error);
23 | };
24 |
25 | process.setMaxListeners(256);
26 | onUncaughtException(handle);
27 | onUnhandledRejection(handle);
28 |
29 | try {
30 | const system = await makeSystem();
31 | const app = await makeCliApp(system);
32 | await app.run(process.argv.slice(2));
33 | } catch (error: unknown) {
34 | handle(error);
35 | process.exit(1);
36 | }
37 | }
38 |
39 | bootstrap();
40 |
--------------------------------------------------------------------------------
/packages/cli/src/constant.ts:
--------------------------------------------------------------------------------
1 | export const MAX_RETRY = 5;
2 |
--------------------------------------------------------------------------------
/packages/cli/src/index.ts:
--------------------------------------------------------------------------------
1 | export { Anime, type AnimeSpace, type AnimeSystem } from '@animespace/core';
2 |
3 | export * from './types';
4 |
5 | export * from './system';
6 |
--------------------------------------------------------------------------------
/packages/cli/src/logger/constant.ts:
--------------------------------------------------------------------------------
1 | import { bold, dim, lightCyan, lightGreen } from '@breadc/color';
2 |
3 | export const DOT = dim('•');
4 | export const titleColor = bold;
5 | export const startColor = lightCyan;
6 | export const okColor = lightGreen;
7 |
--------------------------------------------------------------------------------
/packages/cli/src/logger/index.ts:
--------------------------------------------------------------------------------
1 | export * from './constant';
2 |
3 | // https://gist.github.com/shingchi/64c04e0dd2cbbfbc1350
4 | export function calcLength(text: string) {
5 | const RE = /[\u4e00-\u9fa5\uff00-\uffff\u3000\u3000-\u303f]/;
6 | let sum = 0;
7 | for (const c of text) {
8 | sum += RE.test(c) ? 2 : 1;
9 | }
10 | return sum;
11 | }
12 |
13 | export function padRight(texts: string[], fill = ' '): string[] {
14 | const length = texts.map((t) => calcLength(t)).reduce((max, l) => Math.max(max, l), 0);
15 | return texts.map((t) => t + fill.repeat(length - calcLength(t)));
16 | }
17 |
--------------------------------------------------------------------------------
/packages/cli/src/system/cli.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs-extra';
2 | import path from 'pathe';
3 | import { execSync } from 'node:child_process';
4 |
5 | import openEditor from 'open-editor';
6 | import { type Breadc, breadc } from 'breadc';
7 | import { dim, lightBlue, lightCyan, lightGreen, lightRed } from '@breadc/color';
8 | import { AnimeSystem, LocalVideoDelta, onDeath, printDelta, proxy } from '@animespace/core';
9 |
10 | import { description, version } from '../../package.json';
11 |
12 | import { loop } from './utils';
13 | import { makeSystem } from './system';
14 |
15 | export async function makeCliApp(system: AnimeSystem) {
16 | const app = breadc('anime', {
17 | version,
18 | description,
19 | plugins: [
20 | {
21 | onPreCommand(parsed) {
22 | if (parsed.options?.proxy) {
23 | proxy.enable = true;
24 | }
25 | }
26 | }
27 | ]
28 | }).option('--proxy', {
29 | description: 'Enable HTTP/HTTPS proxy'
30 | });
31 |
32 | registerApp(system, app);
33 | for (const plugin of system.space.plugins) {
34 | await plugin.command?.(system, app);
35 | }
36 | return app;
37 | }
38 |
39 | function registerApp(system: AnimeSystem, app: Breadc<{}>) {
40 | const isTTY = !!process?.stdout?.isTTY;
41 |
42 | app
43 | .command('space', 'Display the space directory')
44 | .option('--open', 'Open space in your editor')
45 | .action(async (options) => {
46 | const root = system.space.root;
47 | const cmds = options['--'];
48 | if (cmds.length > 0) {
49 | if (isTTY) {
50 | system.printSpace();
51 | }
52 | execSync(cmds.join(' '), { cwd: root.path, stdio: 'inherit' });
53 | } else if (options.open) {
54 | try {
55 | openEditor([root.path]);
56 | } catch (error) {
57 | console.log(root);
58 | }
59 | } else {
60 | console.log(root);
61 | }
62 | return root;
63 | });
64 |
65 | app
66 | .command('run [...args]', 'Run command in the space directory')
67 | .action(async (command, args) => {
68 | const pkgJson = await fs
69 | .readJSON(system.space.root.resolve('package.json').path)
70 | .catch(() => undefined);
71 |
72 | const env = { ...process.env };
73 | env.PATH = [
74 | ...(process.env.PATH ?? '').split(path.delimiter),
75 | system.space.root.resolve('node_modules/.bin').path
76 | ].join(path.delimiter);
77 |
78 | if (pkgJson.scripts && command in pkgJson.scripts) {
79 | const cmd = pkgJson.scripts[command] as string;
80 |
81 | if (isTTY) {
82 | system.printSpace();
83 | }
84 |
85 | execSync(cmd + ' ' + args.join(' '), {
86 | cwd: system.space.root.path,
87 | stdio: 'inherit',
88 | env
89 | });
90 | } else {
91 | if (isTTY) {
92 | system.printSpace();
93 | }
94 |
95 | execSync(command + ' ' + args.join(' '), {
96 | cwd: system.space.root.path,
97 | stdio: 'inherit',
98 | env
99 | });
100 | }
101 | });
102 |
103 | app
104 | .command('watch', 'Watch anime system update')
105 | .option('-d, --duration