├── .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 | [![version](https://img.shields.io/npm/v/animespace?label=AnimeSpace)](https://www.npmjs.com/package/animespace) 4 | [![CI](https://github.com/yjl9903/AnimeSpace/actions/workflows/ci.yml/badge.svg)](https://github.com/yjl9903/AnimeSpace/actions/workflows/ci.yml) 5 | [![Docs](https://img.shields.io/badge/AnimeSpace-Demo-brightgreen)](https://animespace.onekuma.cn/) 6 | [![License](https://img.shields.io/github/license/yjl9903/AnimeSpace)](./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 | ![Jellyfin](./docs/public/Jellyfin.jpeg) 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 | ![home](https://cdn.jsdelivr.net/gh/yjl9903/animegarden/assets/home.png) 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 | ![模糊检索](/search-1.png) 45 | 46 | 然后, 你注意到了 "桜都字幕组" 字幕组, 你想要进一步筛选它的结果,你可以直接**点击右边任何一个 "桜都字幕组" 按钮**。 47 | 48 | ![字幕组筛选](/search-2.png) 49 | 50 | ![字幕组筛选结果](/search-3.png) 51 | 52 | 最后,你发现返回的结果中包含简体和繁体两种,你现在可以使用标题匹配的关键词来进一步筛选,你可以在**搜索框输入** "葬送的芙莉莲 字幕组:桜都字幕组 **+简体内嵌**"。 53 | 54 | ![最终结果](/search-4.png) 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 | ![Jellyfin](/Jellyfin.jpeg) 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 | [![version](https://img.shields.io/npm/v/animespace?label=AnimeSpace)](https://www.npmjs.com/package/animespace) 6 | [![CI](https://github.com/yjl9903/AnimeSpace/actions/workflows/ci.yml/badge.svg)](https://github.com/yjl9903/AnimeSpace/actions/workflows/ci.yml) 7 | [![Docs](https://img.shields.io/badge/AnimeSpace-Demo-brightgreen)](https://animespace.onekuma.cn/) 8 | [![License](https://img.shields.io/github/license/yjl9903/AnimeSpace)](./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 | ![Jellyfin](./docs/public/Jellyfin.jpeg) 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