Showing preview only (1,891K chars total). Download the full file or copy to clipboard to get everything.
Repository: EstrellaXD/Auto_Bangumi
Branch: main
Commit: 717ad11f7fad
Files: 472
Total size: 1.7 MB
Directory structure:
gitextract_e3m8bq15/
├── .dockerignore
├── .gitattributes
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.yml
│ │ ├── config.yml
│ │ ├── discussion.yml
│ │ ├── feature_request.yml
│ │ ├── parser_bug.yml
│ │ ├── rename_bug.yml
│ │ └── rfc.yml
│ ├── PULL_REQUEST_TEMPLATE/
│ │ └── pull_request_template.md
│ └── workflows/
│ └── build.yml
├── .gitignore
├── .vscode/
│ ├── extensions.json
│ ├── launch.json
│ └── settings.json
├── CHANGELOG.md
├── CLAUDE.md
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── README.md
├── SECURITY.md
├── backend/
│ ├── .pre-commit-config.yaml
│ ├── .vscode/
│ │ └── settings.json
│ ├── dev.sh
│ ├── pyproject.toml
│ ├── scripts/
│ │ └── pip-lock-version.sh
│ └── src/
│ ├── dev_server.py
│ ├── main.py
│ ├── module/
│ │ ├── __init__.py
│ │ ├── ab_decorator/
│ │ │ ├── __init__.py
│ │ │ └── timeout.py
│ │ ├── api/
│ │ │ ├── __init__.py
│ │ │ ├── auth.py
│ │ │ ├── bangumi.py
│ │ │ ├── config.py
│ │ │ ├── downloader.py
│ │ │ ├── log.py
│ │ │ ├── notification.py
│ │ │ ├── passkey.py
│ │ │ ├── program.py
│ │ │ ├── response.py
│ │ │ ├── rss.py
│ │ │ ├── search.py
│ │ │ └── setup.py
│ │ ├── checker/
│ │ │ ├── __init__.py
│ │ │ └── checker.py
│ │ ├── conf/
│ │ │ ├── __init__.py
│ │ │ ├── config.py
│ │ │ ├── const.py
│ │ │ ├── log.py
│ │ │ ├── parse.py
│ │ │ ├── search_provider.py
│ │ │ └── uvicorn_logging.py
│ │ ├── core/
│ │ │ ├── __init__.py
│ │ │ ├── offset_scanner.py
│ │ │ ├── program.py
│ │ │ ├── status.py
│ │ │ └── sub_thread.py
│ │ ├── database/
│ │ │ ├── __init__.py
│ │ │ ├── bangumi.py
│ │ │ ├── combine.py
│ │ │ ├── engine.py
│ │ │ ├── passkey.py
│ │ │ ├── rss.py
│ │ │ ├── torrent.py
│ │ │ └── user.py
│ │ ├── downloader/
│ │ │ ├── __init__.py
│ │ │ ├── client/
│ │ │ │ ├── __init__.py
│ │ │ │ ├── aria2_downloader.py
│ │ │ │ ├── mock_downloader.py
│ │ │ │ ├── qb_downloader.py
│ │ │ │ └── tr_downloader.py
│ │ │ ├── download_client.py
│ │ │ ├── exceptions.py
│ │ │ └── path.py
│ │ ├── manager/
│ │ │ ├── __init__.py
│ │ │ ├── collector.py
│ │ │ ├── renamer.py
│ │ │ └── torrent.py
│ │ ├── mcp/
│ │ │ ├── __init__.py
│ │ │ ├── resources.py
│ │ │ ├── security.py
│ │ │ ├── server.py
│ │ │ └── tools.py
│ │ ├── models/
│ │ │ ├── __init__.py
│ │ │ ├── api.py
│ │ │ ├── bangumi.py
│ │ │ ├── config.py
│ │ │ ├── passkey.py
│ │ │ ├── response.py
│ │ │ ├── rss.py
│ │ │ ├── torrent.py
│ │ │ └── user.py
│ │ ├── network/
│ │ │ ├── __init__.py
│ │ │ ├── request_contents.py
│ │ │ ├── request_url.py
│ │ │ └── site/
│ │ │ ├── __init__.py
│ │ │ └── mikan.py
│ │ ├── notification/
│ │ │ ├── __init__.py
│ │ │ ├── base.py
│ │ │ ├── manager.py
│ │ │ ├── notification.py
│ │ │ ├── plugin/
│ │ │ │ ├── __init__.py
│ │ │ │ ├── bark.py
│ │ │ │ ├── server_chan.py
│ │ │ │ ├── slack.py
│ │ │ │ ├── telegram.py
│ │ │ │ └── wecom.py
│ │ │ └── providers/
│ │ │ ├── __init__.py
│ │ │ ├── bark.py
│ │ │ ├── discord.py
│ │ │ ├── gotify.py
│ │ │ ├── pushover.py
│ │ │ ├── server_chan.py
│ │ │ ├── telegram.py
│ │ │ ├── webhook.py
│ │ │ └── wecom.py
│ │ ├── parser/
│ │ │ ├── __init__.py
│ │ │ ├── analyser/
│ │ │ │ ├── __init__.py
│ │ │ │ ├── bgm_calendar.py
│ │ │ │ ├── bgm_parser.py
│ │ │ │ ├── mikan_parser.py
│ │ │ │ ├── offset_detector.py
│ │ │ │ ├── openai.py
│ │ │ │ ├── raw_parser.py
│ │ │ │ ├── tmdb_parser.py
│ │ │ │ └── torrent_parser.py
│ │ │ └── title_parser.py
│ │ ├── rss/
│ │ │ ├── __init__.py
│ │ │ ├── analyser.py
│ │ │ └── engine.py
│ │ ├── searcher/
│ │ │ ├── __init__.py
│ │ │ ├── provider.py
│ │ │ └── searcher.py
│ │ ├── security/
│ │ │ ├── __init__.py
│ │ │ ├── api.py
│ │ │ ├── auth_strategy.py
│ │ │ ├── jwt.py
│ │ │ └── webauthn.py
│ │ ├── update/
│ │ │ ├── __init__.py
│ │ │ ├── cross_version.py
│ │ │ ├── data_migration.py
│ │ │ ├── rss.py
│ │ │ ├── startup.py
│ │ │ └── version_check.py
│ │ └── utils/
│ │ ├── __init__.py
│ │ ├── bangumi_data.py
│ │ ├── cache_image.py
│ │ └── json_config.py
│ ├── test/
│ │ ├── __init__.py
│ │ ├── conftest.py
│ │ ├── e2e/
│ │ │ ├── Dockerfile.mock-rss
│ │ │ ├── __init__.py
│ │ │ ├── conftest.py
│ │ │ ├── docker-compose.test.yml
│ │ │ ├── fixtures/
│ │ │ │ └── mikan.xml
│ │ │ ├── mock_rss_server.py
│ │ │ └── test_e2e_workflow.py
│ │ ├── factories.py
│ │ ├── test_api_auth.py
│ │ ├── test_api_bangumi.py
│ │ ├── test_api_bangumi_extended.py
│ │ ├── test_api_config.py
│ │ ├── test_api_downloader.py
│ │ ├── test_api_log.py
│ │ ├── test_api_passkey.py
│ │ ├── test_api_program.py
│ │ ├── test_api_rss.py
│ │ ├── test_api_search.py
│ │ ├── test_auth.py
│ │ ├── test_config.py
│ │ ├── test_database.py
│ │ ├── test_download_client.py
│ │ ├── test_integration.py
│ │ ├── test_issue_bugs.py
│ │ ├── test_mcp_resources.py
│ │ ├── test_mcp_security.py
│ │ ├── test_mcp_tools.py
│ │ ├── test_migration.py
│ │ ├── test_mock_downloader.py
│ │ ├── test_notification.py
│ │ ├── test_openai.py
│ │ ├── test_path.py
│ │ ├── test_path_parser.py
│ │ ├── test_qb_downloader.py
│ │ ├── test_raw_parser.py
│ │ ├── test_renamer.py
│ │ ├── test_rss_engine.py
│ │ ├── test_rss_engine_new.py
│ │ ├── test_searcher.py
│ │ ├── test_setup.py
│ │ ├── test_title_parser.py
│ │ ├── test_tmdb.py
│ │ └── test_torrent_parser.py
│ └── test_passkey_server.py
├── docs/
│ ├── .vitepress/
│ │ ├── config.ts
│ │ └── theme/
│ │ ├── components/
│ │ │ └── HomePreviewWebUI.vue
│ │ ├── index.ts
│ │ └── style.css
│ ├── api/
│ │ └── index.md
│ ├── changelog/
│ │ ├── 2.6.md
│ │ ├── 3.0.md
│ │ ├── 3.1.md
│ │ ├── 3.2-zh.md
│ │ └── 3.2.md
│ ├── config/
│ │ ├── downloader.md
│ │ ├── experimental.md
│ │ ├── manager.md
│ │ ├── notifier.md
│ │ ├── parser.md
│ │ ├── program.md
│ │ ├── proxy.md
│ │ └── rss.md
│ ├── deploy/
│ │ ├── docker-cli.md
│ │ ├── docker-compose.md
│ │ ├── dsm.md
│ │ ├── local.md
│ │ └── quick-start.md
│ ├── dev/
│ │ ├── database.md
│ │ ├── e2e-test-guide.md
│ │ └── index.md
│ ├── en/
│ │ ├── api/
│ │ │ └── index.md
│ │ ├── changelog/
│ │ │ ├── 2.6.md
│ │ │ ├── 3.0.md
│ │ │ ├── 3.1.md
│ │ │ └── 3.2.md
│ │ ├── config/
│ │ │ ├── downloader.md
│ │ │ ├── experimental.md
│ │ │ ├── manager.md
│ │ │ ├── notifier.md
│ │ │ ├── parser.md
│ │ │ ├── program.md
│ │ │ ├── proxy.md
│ │ │ └── rss.md
│ │ ├── deploy/
│ │ │ ├── docker-cli.md
│ │ │ ├── docker-compose.md
│ │ │ ├── dsm.md
│ │ │ ├── local.md
│ │ │ └── quick-start.md
│ │ ├── dev/
│ │ │ ├── database.md
│ │ │ └── index.md
│ │ ├── faq/
│ │ │ ├── index.md
│ │ │ ├── network.md
│ │ │ └── troubleshooting.md
│ │ ├── feature/
│ │ │ ├── bangumi.md
│ │ │ ├── calendar.md
│ │ │ ├── rename.md
│ │ │ ├── rss.md
│ │ │ └── search.md
│ │ ├── home/
│ │ │ ├── index.md
│ │ │ └── pipline.md
│ │ └── index.md
│ ├── faq/
│ │ ├── index.md
│ │ ├── network.md
│ │ └── troubleshooting.md
│ ├── feature/
│ │ ├── bangumi.md
│ │ ├── calendar.md
│ │ ├── rename.md
│ │ ├── rss.md
│ │ └── search.md
│ ├── home/
│ │ ├── index.md
│ │ └── pipline.md
│ ├── index.md
│ ├── ja/
│ │ ├── api/
│ │ │ └── index.md
│ │ ├── changelog/
│ │ │ ├── 2.6.md
│ │ │ ├── 3.0.md
│ │ │ ├── 3.1.md
│ │ │ └── 3.2.md
│ │ ├── config/
│ │ │ ├── downloader.md
│ │ │ ├── experimental.md
│ │ │ ├── manager.md
│ │ │ ├── notifier.md
│ │ │ ├── parser.md
│ │ │ ├── program.md
│ │ │ ├── proxy.md
│ │ │ └── rss.md
│ │ ├── deploy/
│ │ │ ├── docker-cli.md
│ │ │ ├── docker-compose.md
│ │ │ ├── dsm.md
│ │ │ ├── local.md
│ │ │ └── quick-start.md
│ │ ├── dev/
│ │ │ ├── database.md
│ │ │ └── index.md
│ │ ├── faq/
│ │ │ ├── index.md
│ │ │ ├── network.md
│ │ │ └── troubleshooting.md
│ │ ├── feature/
│ │ │ ├── bangumi.md
│ │ │ ├── calendar.md
│ │ │ ├── rename.md
│ │ │ ├── rss.md
│ │ │ └── search.md
│ │ ├── home/
│ │ │ ├── index.md
│ │ │ └── pipline.md
│ │ └── index.md
│ ├── package.json
│ ├── plans/
│ │ ├── 2026-01-25-search-panel-redesign.md
│ │ └── 2026-02-23-calendar-drag-organize-design.md
│ ├── resource/
│ │ ├── docker-compose/
│ │ │ ├── AutoBangumi/
│ │ │ │ └── docker-compose.yml
│ │ │ └── qBittorrent+AutoBangumi/
│ │ │ └── docker-compose.yml
│ │ └── unraid.xml
│ ├── tsconfig.json
│ └── vercel.json
├── entrypoint.sh
├── scripts/
│ └── generate-beta-notes.sh
└── webui/
├── .eslintignore
├── .eslintrc.json
├── .husky/
│ └── pre-commit
├── .neoconf.json
├── .npmrc
├── .prettierignore
├── .prettierrc.json
├── .storybook/
│ ├── main.ts
│ └── preview.ts
├── .vscode/
│ ├── extensions.json
│ └── settings.json
├── LICENSE
├── README.md
├── index.html
├── package.json
├── public/
│ └── robots.txt
├── src/
│ ├── App.vue
│ ├── api/
│ │ ├── __tests__/
│ │ │ ├── auth.test.ts
│ │ │ ├── bangumi.test.ts
│ │ │ └── rss.test.ts
│ │ ├── auth.ts
│ │ ├── bangumi.ts
│ │ ├── check.ts
│ │ ├── config.ts
│ │ ├── download.ts
│ │ ├── downloader.ts
│ │ ├── log.ts
│ │ ├── notification.ts
│ │ ├── passkey.ts
│ │ ├── program.ts
│ │ ├── rss.ts
│ │ ├── search.ts
│ │ └── setup.ts
│ ├── components/
│ │ ├── ab-add-rss.vue
│ │ ├── ab-bangumi-card.vue
│ │ ├── ab-change-account.vue
│ │ ├── ab-container.vue
│ │ ├── ab-edit-rule.vue
│ │ ├── ab-fold-panel.vue
│ │ ├── ab-image.vue
│ │ ├── ab-label.vue
│ │ ├── ab-popup.vue
│ │ ├── ab-rule.vue
│ │ ├── ab-search-bar.vue
│ │ ├── ab-setting.vue
│ │ ├── ab-status-bar.vue
│ │ ├── basic/
│ │ │ ├── __tests__/
│ │ │ │ ├── ab-button.test.ts
│ │ │ │ └── ab-switch.test.ts
│ │ │ ├── ab-adaptive-modal.vue
│ │ │ ├── ab-add.stories.ts
│ │ │ ├── ab-add.vue
│ │ │ ├── ab-bottom-sheet.vue
│ │ │ ├── ab-button-multi.stories.ts
│ │ │ ├── ab-button-multi.vue
│ │ │ ├── ab-button.stories.ts
│ │ │ ├── ab-button.vue
│ │ │ ├── ab-checkbox.stories.ts
│ │ │ ├── ab-checkbox.vue
│ │ │ ├── ab-data-list.vue
│ │ │ ├── ab-offset-mismatch-dialog.vue
│ │ │ ├── ab-page-title.stories.ts
│ │ │ ├── ab-page-title.vue
│ │ │ ├── ab-pull-refresh.vue
│ │ │ ├── ab-search.stories.ts
│ │ │ ├── ab-search.vue
│ │ │ ├── ab-select.stories.ts
│ │ │ ├── ab-select.vue
│ │ │ ├── ab-status.stories.ts
│ │ │ ├── ab-status.vue
│ │ │ ├── ab-swipe-container.vue
│ │ │ ├── ab-switch.stories.ts
│ │ │ ├── ab-switch.vue
│ │ │ ├── ab-tag.stories.ts
│ │ │ └── ab-tag.vue
│ │ ├── layout/
│ │ │ ├── ab-mobile-nav.vue
│ │ │ ├── ab-sidebar.vue
│ │ │ └── ab-topbar.vue
│ │ ├── media-query.vue
│ │ ├── search/
│ │ │ ├── ab-search-card.vue
│ │ │ ├── ab-search-confirm.vue
│ │ │ ├── ab-search-filters.vue
│ │ │ └── ab-search-modal.vue
│ │ ├── setting/
│ │ │ ├── config-download.vue
│ │ │ ├── config-manage.vue
│ │ │ ├── config-normal.vue
│ │ │ ├── config-notification.vue
│ │ │ ├── config-openai.vue
│ │ │ ├── config-parser.vue
│ │ │ ├── config-passkey.vue
│ │ │ ├── config-player.vue
│ │ │ ├── config-proxy.vue
│ │ │ ├── config-search-provider.vue
│ │ │ └── config-security.vue
│ │ └── setup/
│ │ ├── wizard-container.vue
│ │ ├── wizard-step-account.vue
│ │ ├── wizard-step-downloader.vue
│ │ ├── wizard-step-notification.vue
│ │ ├── wizard-step-review.vue
│ │ ├── wizard-step-rss.vue
│ │ └── wizard-step-welcome.vue
│ ├── hooks/
│ │ ├── __tests__/
│ │ │ ├── useApi.test.ts
│ │ │ └── useAuth.test.ts
│ │ ├── useAddRss.ts
│ │ ├── useApi.ts
│ │ ├── useAppInfo.ts
│ │ ├── useAuth.ts
│ │ ├── useBreakpointQuery.ts
│ │ ├── useDarkMode.ts
│ │ ├── useMessage.ts
│ │ ├── useMyI18n.ts
│ │ ├── usePasskey.ts
│ │ └── useSafeArea.ts
│ ├── i18n/
│ │ ├── en.json
│ │ └── zh-CN.json
│ ├── main.ts
│ ├── pages/
│ │ ├── index/
│ │ │ ├── bangumi.vue
│ │ │ ├── calendar.vue
│ │ │ ├── config.vue
│ │ │ ├── downloader.vue
│ │ │ ├── log.vue
│ │ │ ├── player.vue
│ │ │ └── rss.vue
│ │ ├── index.vue
│ │ ├── login.vue
│ │ └── setup.vue
│ ├── router/
│ │ └── index.ts
│ ├── services/
│ │ └── webauthn.ts
│ ├── store/
│ │ ├── __tests__/
│ │ │ ├── bangumi.test.ts
│ │ │ └── rss.test.ts
│ │ ├── bangumi.ts
│ │ ├── config.ts
│ │ ├── downloader.ts
│ │ ├── log.ts
│ │ ├── player.ts
│ │ ├── program.ts
│ │ ├── rss.ts
│ │ ├── search.ts
│ │ └── setup.ts
│ ├── style/
│ │ ├── global.scss
│ │ ├── mixin.scss
│ │ ├── transition.scss
│ │ └── var.scss
│ ├── test/
│ │ ├── mocks/
│ │ │ └── api.ts
│ │ └── setup.ts
│ └── utils/
│ ├── axios.ts
│ └── poster.ts
├── tsconfig.json
├── tsconfig.node.json
├── types/
│ ├── api.ts
│ ├── auth.ts
│ ├── bangumi.ts
│ ├── components.ts
│ ├── config.ts
│ ├── downloader.ts
│ ├── dts/
│ │ ├── auto-imports.d.ts
│ │ ├── components.d.ts
│ │ ├── html.d.ts
│ │ ├── router-type.d.ts
│ │ └── vite-env.d.ts
│ ├── passkey.ts
│ ├── rss.ts
│ ├── setup.ts
│ ├── torrent.ts
│ └── utils.ts
├── unocss.config.ts
├── vite.config.ts
└── vitest.config.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .dockerignore
================================================
__pycache__
*.pyc
*.pyo
*.pyd
.Python
env
pip-log.txt
pip-delete-this-directory.txt
.tox
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.log
.git
.mypy_cache
../.pytest_cache
.hypothesis
backend/src/module/tests
backend/src/module/conf/const_dev.py
config/bangumi.json/config/bangumi.json
/docs
/.github
/.config
/.data
/.cache
/LICENSE
/README.md
/setup.py
dist.zip
data
config
/backend/src/config
/backend/src/data
.pytest_cache
test
.env
test.py
================================================
FILE: .gitattributes
================================================
# Don't allow people to merge changes to these generated files, because the result
# may be invalid. You need to run "rush update" again.
pnpm-lock.yaml merge=binary
shrinkwrap.yaml merge=binary
npm-shrinkwrap.json merge=binary
yarn.lock merge=binary
# Rush's JSON config files use JavaScript-style code comments. The rule below prevents pedantic
# syntax highlighters such as GitHub's from highlighting these comments as errors. Your text editor
# may also require a special configuration to allow comments in JSON.
#
# For more information, see this issue: https://github.com/microsoft/rushstack/issues/1088
#
*.json linguist-language=JSON-with-Comments
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.yml
================================================
name: 问题反馈
description: File a bug report
title: "[错误报告]请在此处简单描述你的问题"
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
描述问题前,请先更新到最新版本。
请确认以下信息,如果你没有完成以下检查,那么你的 issue 将会被直接关闭。
解析器问题请转到[专用模板](https://github.com/EstrellaXD/Auto_Bangumi/issues/new?assignees=&labels=bug&template=parser_bug.yml&title=%5B解析器错误%5D),
重命名问题请到[专用模板](https://github.com/EstrellaXD/Auto_Bangumi/issues/new?assignees=&labels=bug&template=rename_bug.yml&title=%5B重命名错误%5D)
- type: checkboxes
id: ensure
attributes:
label: 确认
description: 在提交 issue 之前,请确认你已经阅读并确认以下内容
options:
- label: 我的版本是最新版本,我的版本号与 [version](https://github.com/EstrellaXD/Auto_Bangumi/releases/latest) 相同。
required: true
- label: 我已经查阅了[已知问题](https://autobangumi.org/faq/),并确认我的问题不在其中。
required: true
- label: 我已经 [issue](https://github.com/EstrellaXD/Auto_Bangumi/issues) 中搜索过,确认我的问题没有被提出过。
required: true
- label: 我已经修改标题,将标题中的 **描述** 替换为我遇到的问题。
required: true
- type: input
id: version
attributes:
label: 当前程序版本
description: 遇到问题时程序所在的版本号
validations:
required: true
- type: dropdown
id: type
attributes:
label: 问题类型
description: 你在以下哪个部分碰到了问题
options:
- WebUI
- 程序运行问题
- 其他问题
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: 问题描述
description: 请详细描述你碰到的问题
placeholder: "问题描述"
validations:
required: true
- type: textarea
id: logs
attributes:
label: 发生问题时系统日志
description: 问题出现时,程序运行日志请复制到这里。
render: bash
================================================
FILE: .github/ISSUE_TEMPLATE/config.yml
================================================
blank_issues_enabled: false
contact_links:
- name: 使用说明
url: https://github.com/EstrellaXD/Auto_Bangumi/wiki/Home
about: 使用说明
- name: Twitter
url: https://twitter.com/Estrella_Pan
about: 推特联系我
================================================
FILE: .github/ISSUE_TEMPLATE/discussion.yml
================================================
name: 项目讨论
description: discussion
title: "[Discussion] "
labels: ["discussion"]
body:
- type: markdown
attributes:
value: |
[BUG](https://github.com/EstrellaXD/Auto_Bangumi/issues/new?assignees=&labels=bug&template=bug_report.yml&title=%5BBUG%5D%3A) 与 [Feature Request](https://github.com/EstrellaXD/Auto_Bangumi/issues/new?assignees=&labels=feature+request&template=feature_request.yml&title=%5BFeature+Request%5D%3A+) 请转到对应位置提交。
- type: textarea
id: discussion
attributes:
label: 项目讨论
description: 请详细描述需要讨论的内容。
placeholder: "项目讨论"
validations:
required: true
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.yml
================================================
name: 功能改进
description: Feature Request
title: "[Feature Request]"
labels: ["feature request"]
body:
- type: markdown
attributes:
value: |
请说明你希望添加的功能。
- type: textarea
id: feature-request
attributes:
label: 功能改进
description: 请详细描述需要改进或者添加的功能。
placeholder: "功能改进"
validations:
required: true
================================================
FILE: .github/ISSUE_TEMPLATE/parser_bug.yml
================================================
name: 解析器错误
description: Report parser bug
title: "[解析器错误]"
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
描述问题前,请先更新到最新版本。
最新版本: [version](https://img.shields.io/docker/v/estrellaxd/auto_bangumi)
本模板仅限于解析器匹配错误。目前 AB 并不能解析类似 `[1-12]` 这样的合集
- type: input
id: version
attributes:
label: 当前程序版本
description: 遇到问题时程序所在的版本号
validations:
required: true
- type: dropdown
id: language
attributes:
label: 解析器语言设置
description: 你是用那种语言碰到了问题
options:
- 默认:zh
- en
- jp
validations:
required: true
- type: dropdown
id: TMDB
attributes:
label: TMDB 解析
description: 是否开启 TMDB 解析
options:
- 是
- 否
validations:
required: true
- type: input
id: RawName
attributes:
label: 字幕组提供的名称
validations:
required: true
- type: input
id: ErrorName
attributes:
label: 错误识别名称
description: 解析错误的名称,如果出现 `Not Matched` 确实非合集之类的无法解析的名称后再提交 issue
validations:
required: true
================================================
FILE: .github/ISSUE_TEMPLATE/rename_bug.yml
================================================
name: 重命名错误
description: Report parser bug
title: "[重命名错误]"
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
描述问题前,请先更新到最新版本。
最新版本: [version](https://img.shields.io/docker/v/estrellaxd/auto_bangumi)
本模板仅限于重命名错误。目前 AB 并不能重命名合集,或者以文件夹形式下载的番剧,如果出现类似错误请等待版本更新!
- type: input
id: version
attributes:
label: 当前程序版本
description: 遇到问题时程序所在的版本号
validations:
required: true
- type: dropdown
id: language
attributes:
label: 重命名设置
description: 你是用那重命名设置出现问题
options:
- 默认:pn
- normal
- advance
validations:
required: true
- type: input
id: RawName
attributes:
label: 种子名称
description: 原本种子的名称
validations:
required: true
- type: input
id: path
attributes:
label: 文件路径
description: 种子所在的目录,请以 AB 创建的文件夹为例子,如:`/Lycoris Recoil/Season 1/`,如果没有创建类似的文件夹请参考 [FAQ]() 中的排错指南。
validations:
required: true
- type: input
id: ErrorName
attributes:
label: 错误命名
description: 重命名错误的名称
validations:
required: true
- type: textarea
id: logs
attributes:
label: 发生问题时系统日志
description: 如果有条件,请打开 Debug 模式复制针对错误命名的日志。
render: shell
================================================
FILE: .github/ISSUE_TEMPLATE/rfc.yml
================================================
# https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/syntax-for-issue-forms
name: 功能提案
description: Request for Comments
title: "[RFC]"
labels: ["RFC"]
body:
- type: markdown
attributes:
value: |
一份提案(RFC)定位为 **「在某功能/重构的具体开发前,用于开发者间 review 技术设计/方案的文档」**,
目的是让协作的开发者间清晰的知道「要做什么」和「具体会怎么做」,以及所有的开发者都能公开透明的参与讨论;
以便评估和讨论产生的影响 (遗漏的考虑、向后兼容性、与现有功能的冲突),
因此提案侧重在对解决问题的 **方案、设计、步骤** 的描述上。
如果仅希望讨论是否添加或改进某功能本身,请使用 -> [Issue: 功能改进](https://github.com/EstrellaXD/Auto_Bangumi/issues/new?labels=feature+request&template=feature_request.yml&title=%5BFeature+Request%5D+)
- type: textarea
id: background
attributes:
label: 背景 or 问题
description: 简单描述遇到的什么问题或需要改动什么。可以引用其他 issue、讨论、文档等。
validations:
required: true
- type: textarea
id: goal
attributes:
label: "目标 & 方案简述"
description: 简单描述提案此提案实现后,**预期的目标效果**,以及简单大致描述会采取的方案/步骤,可能会/不会产生什么影响。
validations:
required: true
- type: textarea
id: design
attributes:
label: "方案设计 & 实现步骤"
description: |
详细描述你设计的具体方案,可以考虑拆分列表或要点,一步步描述具体打算如何实现的步骤和相关细节。
这部份不需要一次性写完整,即使在创建完此提案 issue 后,依旧可以再次编辑修改。
validations:
required: false
- type: textarea
id: alternative
attributes:
label: "替代方案 & 对比"
description: |
[可选] 为来实现目标效果,还考虑过什么其他方案,有什么对比?
validations:
required: false
================================================
FILE: .github/PULL_REQUEST_TEMPLATE/pull_request_template.md
================================================
## New
## Change
## Fix
================================================
FILE: .github/workflows/build.yml
================================================
name: Build Docker
on:
pull_request:
types:
- opened
- synchronize
- closed
branches:
- main
push:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
python-version: "3.13"
- uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: Install dependencies
run: cd backend && uv sync --group dev
- name: Test
run: |
mkdir -p backend/config
cd backend && uv run pytest src/test -v
webui-test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 9
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v4
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: cd webui && pnpm install
- name: build test
run: |
cd webui && pnpm test:build
version-info:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: If release
id: release
run: |
if [[ '${{ github.event_name }}' == 'pull_request' && '${{ github.event.pull_request.head.ref }}' == *'dev'* ]]; then
if [ ${{ github.event.pull_request.merged }} == true ]; then
echo "release=1" >> $GITHUB_OUTPUT
else
echo "release=0" >> $GITHUB_OUTPUT
fi
elif [[ '${{ github.event_name }}' == 'push' && (${{ github.ref }} == *'alpha'* || ${{ github.ref }} == *'beta'*) ]]; then
echo "release=1" >> $GITHUB_OUTPUT
else
echo "release=0" >> $GITHUB_OUTPUT
fi
- name: If dev
id: dev
run: |
if [[ '${{ github.event_name }}' == 'push' && (${{ github.ref }} == *'alpha'* || ${{ github.ref }} == *'beta'*) ]]; then
echo "dev=1" >> $GITHUB_OUTPUT
else
echo "dev=0" >> $GITHUB_OUTPUT
fi
- name: Check version
id: version
run: |
if [ '${{ github.event_name }}' == 'pull_request' ]; then
if [ ${{ github.event.pull_request.merged }} == true ]; then
# Extract version from PR title (handles "Release X.Y.Z", "vX.Y.Z", or "X.Y.Z")
PR_TITLE="${{ github.event.pull_request.title }}"
VERSION=$(echo "$PR_TITLE" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?' | head -1)
if [ -n "$VERSION" ]; then
echo "version=$VERSION" >> $GITHUB_OUTPUT
else
echo "version=$PR_TITLE" >> $GITHUB_OUTPUT
fi
fi
elif [[ ${{ github.event_name }} == 'push' && (${{ github.ref }} == *'alpha'* || ${{ github.ref }} == *'beta'*) ]]; then
echo "version=${{ github.ref_name }}" >> $GITHUB_OUTPUT
else
echo "version=Test" >> $GITHUB_OUTPUT
fi
- name: If build test
id: build_test
run: |
if [[ '${{ github.event_name }}' == 'pull_request' && '${{ github.event.pull_request.merged }}' != 'true' && '${{ github.event.pull_request.head.ref }}' == *'dev'* ]]; then
echo "build_test=1" >> $GITHUB_OUTPUT
else
echo "build_test=0" >> $GITHUB_OUTPUT
fi
- name: Check result
run: |
echo "release: ${{ steps.release.outputs.release }}"
echo "dev: ${{ steps.dev.outputs.dev }}"
echo "build_test: ${{ steps.build_test.outputs.build_test }}"
echo "version: ${{ steps.version.outputs.version }}"
outputs:
release: ${{ steps.release.outputs.release }}
dev: ${{ steps.dev.outputs.dev }}
build_test: ${{ steps.build_test.outputs.build_test }}
version: ${{ steps.version.outputs.version }}
build-webui:
runs-on: ubuntu-latest
needs: [test, webui-test, version-info]
if: ${{ needs.version-info.outputs.release == 1 || needs.version-info.outputs.dev == 1 || needs.version-info.outputs.build_test == 1 }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 9
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v4
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: cd webui && pnpm install
- name: Build
run: |
cd webui && pnpm build
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: dist
path: webui/dist
build-docker:
runs-on: ubuntu-latest
needs: [build-webui, version-info]
if: ${{ needs.version-info.outputs.release == 1 || needs.version-info.outputs.dev == 1 || needs.version-info.outputs.build_test == 1 }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Create Version info via tag
working-directory: ./backend/src
run: |
echo ${{ needs.version-info.outputs.version }}
echo "VERSION='${{ needs.version-info.outputs.version }}'" >> module/__version__.py
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
- name: Docker metadata main
if: ${{ needs.version-info.outputs.release == 1 && needs.version-info.outputs.dev != 1 }}
id: meta
uses: docker/metadata-action@v4
with:
images: |
estrellaxd/auto_bangumi
ghcr.io/${{ github.repository }}
tags: |
type=raw,value=${{ needs.version-info.outputs.version }}
type=raw,value=latest
- name: Docker metadata dev
if: ${{ needs.version-info.outputs.dev == 1 }}
id: meta-dev
uses: docker/metadata-action@v4
with:
images: |
estrellaxd/auto_bangumi
ghcr.io/${{ github.repository }}
tags: |
type=raw,value=${{ needs.version-info.outputs.version }}
type=raw,value=dev-latest
- name: Login to DockerHub
if: ${{ needs.version-info.outputs.release == 1 }}
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Login to ghcr.io
if: ${{ needs.version-info.outputs.release == 1 }}
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.ACCESS_TOKEN }}
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: dist
path: backend/src/dist
- name: Build and push
if: ${{ needs.version-info.outputs.release == 1 && needs.version-info.outputs.dev != 1 }}
uses: docker/build-push-action@v4
with:
context: .
builder: ${{ steps.buildx.output.name }}
platforms: linux/amd64,linux/arm64
push: True
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha, scope=${{ github.workflow }}
cache-to: type=gha, scope=${{ github.workflow }}
- name: Build and push dev
if: ${{ needs.version-info.outputs.dev == 1 }}
uses: docker/build-push-action@v4
with:
context: .
builder: ${{ steps.buildx.output.name }}
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta-dev.outputs.tags }}
labels: ${{ steps.meta-dev.outputs.labels }}
cache-from: type=gha, scope=${{ github.workflow }}
cache-to: type=gha, scope=${{ github.workflow }}
- name: Build test
if: ${{ needs.version-info.outputs.release == 0 }}
uses: docker/build-push-action@v4
with:
context: .
builder: ${{ steps.buildx.output.name }}
platforms: linux/amd64,linux/arm64
push: false
tags: estrellaxd/auto_bangumi:test
cache-from: type=gha, scope=${{ github.workflow }}
cache-to: type=gha, scope=${{ github.workflow }}
release:
runs-on: ubuntu-latest
needs: [build-docker, version-info]
if: ${{ needs.version-info.outputs.release == 1 }}
outputs:
url: ${{ steps.release.outputs.url }}
version: ${{ needs.version-info.outputs.version }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download artifact webui
uses: actions/download-artifact@v4
with:
name: dist
path: webui/dist
- name: Zip webui
run: |
cd webui && ls -al && tree && zip -r dist.zip dist
- name: Download artifact app
uses: actions/download-artifact@v4
with:
name: dist
path: backend/src/dist
- name: Create Version info via tag
working-directory: ./backend/src
run: |
echo ${{ needs.version-info.outputs.version }}
echo "VERSION='${{ needs.version-info.outputs.version }}'" >> module/__version__.py
- name: Zip app
run: |
cd backend && zip -r app-v${{ needs.version-info.outputs.version }}.zip src
- name: Generate Release info
id: release-info
run: |
if ${{ needs.version-info.outputs.dev == 1 }}; then
echo "version=🌙${{ needs.version-info.outputs.version }}" >> $GITHUB_OUTPUT
echo "pre_release=true" >> $GITHUB_OUTPUT
else
echo "version=🌟${{ needs.version-info.outputs.version }}" >> $GITHUB_OUTPUT
echo "pre_release=false" >> $GITHUB_OUTPUT
fi
- name: Read changelog
id: changelog
run: |
if [ -f docs/changelog/3.2.md ]; then
echo "body<<EOF" >> $GITHUB_OUTPUT
cat docs/changelog/3.2.md >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
fi
- name: Release
id: release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ needs.version-info.outputs.version }}
name: ${{ steps.release-info.outputs.version }}
body: ${{ github.event.pull_request.body || steps.changelog.outputs.body }}
draft: false
prerelease: ${{ steps.release-info.outputs.pre_release == 'true' }}
files: |
webui/dist.zip
backend/app-v${{ needs.version-info.outputs.version }}.zip
env:
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
telegram:
runs-on: ubuntu-latest
needs: [release]
steps:
- name: send telegram message on push
uses: appleboy/telegram-action@master
with:
to: ${{ secrets.TELEGRAM_TO }}
token: ${{ secrets.TELEGRAM_TOKEN }}
message: |
New release: ${{ needs.release.outputs.version }}
Link: ${{ needs.release.outputs.url }}
================================================
FILE: .gitignore
================================================
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.idea
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Custom
#
# backend
/backend/src/test.py
/backend/src/module/run_debug.sh
/backend/src/module/debug_run.sh
/backend/src/module/__version__.py
/backend/src/data/
/src/module/conf/config_dev.ini
.run
/backend/src/templates/
/backend/src/config/
/src/debuger.py
/backend/src/dist.zip
/pyrightconfig.json
# webui
logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
webui/.vite/deps/*
dist.zip
dist-ssr
*.local
dev-dist
# Editor directories and files
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# vitepress
/docs/.vitepress/cache/
# test file
test.*
# local config
/backend/config/
.claude/settings.local.json
================================================
FILE: .vscode/extensions.json
================================================
{
"recommendations": [
// https://marketplace.visualstudio.com/items?itemName=antfu.unocss
"antfu.unocss",
// https://marketplace.visualstudio.com/items?itemName=formulahendry.auto-rename-tag
"formulahendry.auto-rename-tag",
// https://marketplace.visualstudio.com/items?itemName=streetsidesoftware.code-spell-checker
"streetsidesoftware.code-spell-checker",
// https://marketplace.visualstudio.com/items?itemName=naumovs.color-highlight
"naumovs.color-highlight",
// https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance
"ms-python.vscode-pylance",
// https://marketplace.visualstudio.com/items?itemName=ms-python.python
"ms-python.python",
// https://marketplace.visualstudio.com/items?itemName=vue.volar
"vue.volar"
]
}
================================================
FILE: .vscode/launch.json
================================================
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Dev Backend",
"type": "python",
"request": "launch",
"cwd": "${workspaceFolder}/backend/src",
"program": "main.py",
"env": {
"HOST": "127.0.0.1",
},
"console": "integratedTerminal",
"justMyCode": true
}
]
}
================================================
FILE: .vscode/settings.json
================================================
{
"files.associations": {
"settings.json": "json5",
"launch.json": "json5",
"extensions.json": "json5",
"tsconfig.json": "json5",
"tsconfig.*.json": "json5",
},
"[markdown]": {
"editor.wordWrap": "off",
},
"python.venvPath": "./backend/venv",
"cSpell.words": [
"Bangumi",
"fastapi",
"mikan",
"starlette"
],
}
================================================
FILE: CHANGELOG.md
================================================
# [Unreleased]
## Backend
### Added
- 新增 `Security` 配置模型,支持登录 IP 白名单、MCP IP 白名单和 Bearer Token 认证
- 新增登录端点 IP 白名单检查中间件 (`check_login_ip`)
- MCP 安全中间件升级为可配置模式:支持 CIDR 白名单 + Bearer Token 双重认证
- 认证端点支持 `Authorization: Bearer` 令牌绕过 Cookie 登录
- 配置 API `_sanitize_dict` 修复:仅对字符串值进行脱敏,避免误处理非字符串字段
- 新增番剧放送日手动设置 API (`PATCH /api/v1/bangumi/{id}/weekday`),支持锁定放送日防止日历刷新覆盖
- 数据库迁移 v9:`bangumi` 表新增 `weekday_locked` 列
### Fixed
- 修复 qBittorrent 下载器 SSL 连接问题:解耦 HTTPS 协议选择与证书验证,自签名证书不再导致连接失败 (#923)
- 修复 `torrents_rename_file` 重命名验证循环中 `continue` 应为 `break` 的逻辑错误
### Changed
- 重构认证模块:提取 `_issue_token` 公共方法,消除 3 处重复的 JWT 签发逻辑
- `get_current_user` 简化为三级认证(DEV 绕过 → Bearer Token → Cookie JWT)
- `LocalNetworkMiddleware` 重命名为 `McpAccessMiddleware`,从硬编码 RFC 1918 改为读取配置
### Tests
- 新增 101 个单元测试覆盖安全、认证、配置、下载器和 MockDownloader 模块
## Frontend
### Added
- 新增日历拖拽排列功能:可将「未知」番剧拖入星期列,自动设置放送日并锁定
- 拖入后显示紫色图钉图标,鼠标悬停显示取消按钮
- 锁定的番剧在日历刷新时不会被覆盖
- 使用 vuedraggable 实现流畅拖拽动画
- 新增安全设置组件 (`config-security.vue`),支持在 WebUI 中配置 IP 白名单和 Token
- 前端 `Security` 类型定义和初始化配置
---
# [3.2.3] - 2026-02-23
## Backend
### Added
- 新增 MCP (Model Context Protocol) 服务器,支持通过 Claude Desktop 等 LLM 工具管理番剧订阅
- SSE 传输层挂载在 `/mcp/sse`,支持 MCP 客户端连接
- 10 个工具:list_anime、get_anime、search_anime、subscribe_anime、unsubscribe_anime、list_downloads、list_rss_feeds、get_program_status、refresh_feeds、update_anime
- 4 个资源:anime/list、anime/{id}、status、rss/feeds
- 本地网络 IP 白名单安全中间件(RFC 1918 + 回环地址),无需 JWT 认证
- 新增通知系统重构,支持多通知渠道同时启用
- 支持 Telegram、Bark、Server 酱、企业微信、Discord、Gotify、Pushover、Webhook 八种渠道
- 新增通知管理 API:`GET/PUT /api/notification/providers`
- 新增 E2E 集成测试套件,覆盖 RSS→下载→重命名全流程
### Fixes
- 修复第 0 集(SP/OVA)被错误重命名为第 1 集的问题 (#977)
- Episode 0 现在免受集数偏移影响,不再覆盖正常集数文件
- 修复 RSS 过滤器包含特殊字符(如 `[字幕组`)时导致程序崩溃的问题 (#974)
- 无效正则表达式自动降级为字面量匹配
- 修复聚合 RSS 解析时 `title_raw` 为空导致 `TypeError` 崩溃的问题 (#976)
- 修复解析器处理无括号种子名称时 `IndexError` 崩溃的问题 (#973)
- 修复删除番剧时未清理关联种子记录的问题
- 修复认证路由、JWT 刷新和 WebAuthn 注册流程的多个安全问题
- 修复程序生命周期管理和后台任务取消逻辑
- 修复数据库迁移在部分场景下未正确执行的问题
### Performance
- 优化日志系统:`RotatingFileHandler` 轮转(5 MB × 3)、`QueueHandler` 异步写入、`GET /api/log` 限读 512 KB
- 优化重命名器:批量数据库查询,并发获取种子文件列表
- 所有 `logger.debug(f"...")` 转为惰性 `%s` 格式化(~80 处)
### Tests
- 新增 26 个回归测试覆盖 #974、#976、#977、#986
- 扩展 raw_parser、torrent_parser、path_parser 测试覆盖率
## Frontend
### Fixes
- 修复认证路由守卫和 i18n 初始化顺序问题
- 修复通知设置组件与项目设计系统的对齐问题
- 修复组件生命周期管理问题
## Docs
- README 移除未实现的 Aria2 和 Transmission 下载器 (#987)
---
# [3.2.0-beta.13] - 2026-01-26
## Frontend
### Features
- 重新设计搜索面板
- 新增筛选区域,支持按字幕组、分辨率、字幕类型、季度分类筛选
- 多选筛选器,智能禁用不兼容的选项(灰色显示)
- 结果项标签改为非点击式彩色药丸样式
- 统一标签样式(药丸形状、12px 字体)
- 标签值标准化(分辨率:FHD/HD/4K,字幕:简/繁/双语)
- 筛选分类和结果变体支持展开/收起
- 海报高度自动匹配 4 行变体项(168px)
- 点击弹窗外部自动关闭
---
# [3.2.0-beta.12] - 2026-01-26
## Backend
### Features
- 偏移检查面板新增建议值显示(解析的季度/集数和建议的偏移量)
### Fixes
- 修复季度偏移未应用到下载文件夹路径的问题
- 设置季度偏移后,qBittorrent 保存路径会自动更新(如 `Season 2` → `Season 1`)
- RSS 规则的保存路径也会同步更新
- 优化集数偏移建议逻辑
- 简单季度不匹配时不再建议集数偏移(仅虚拟季度需要)
- 改进提示信息,明确说明是否需要调整集数
---
# [3.2.0-beta.11] - 2026-01-25
## Backend
### Features
- 新增季度/集数偏移自动检测功能
- 通过分析 TMDB 剧集播出日期检测「虚拟季度」(如芙莉莲第一季分两部分播出)
- 当播出间隔超过6个月时自动识别为不同部分
- 自动计算集数偏移量(如 RSS 显示 S2E1 → TMDB S1E29)
- 新增后台扫描线程,自动检测已有订阅的偏移问题
- 新增搜索源配置 API 端点:
- `GET /search/provider/config` - 获取搜索源配置
- `PUT /search/provider/config` - 更新搜索源配置
- 新增 API 端点:
- `POST /bangumi/detect-offset` - 检测季度/集数偏移
- `PATCH /bangumi/dismiss-review/{id}` - 忽略偏移检查提醒
- 数据库新增 `needs_review` 和 `needs_review_reason` 字段
## Frontend
### Features
- 新增搜索源设置面板
- 支持查看、添加、编辑、删除搜索源
- 默认搜索源(mikan、nyaa、dmhy)不可删除
- URL 模板验证,确保包含 `%s` 占位符
- 新增 iOS 风格通知角标系统
- 黄色角标 + 紫色边框显示需要检查的订阅
- 支持组合显示(如 `! | 2` 表示有警告且有多个规则)
- 卡片黄色发光动画提示需要注意
- 编辑弹窗新增警告横幅,支持一键自动检测和忽略
- 规则选择弹窗高亮显示有警告的规则
- 首页空状态新增「添加 RSS 订阅」按钮,引导新用户快速上手
- 日历页面海报图片添加懒加载,提升性能
- 日历页面「未知播出日」独立为单独区块,优化视觉节奏
### Fixes
- 修复移动端设置页面水平溢出问题
- 输入框添加 `max-width: 100%` 防止超出容器
- 折叠面板添加宽度约束和溢出隐藏
- 设置栅格添加 `min-width: 0` 允许收缩
- 修复移动端顶栏布局
- 搜索按钮改为弹性布局,填充 Logo 和图标之间的空间
- 减小图标按钮尺寸和间距,优化紧凑型布局
- 添加「点击搜索」文字提示
- 修复移动端搜索弹窗关闭按钮被截断问题
- 减小弹窗头部内边距和元素尺寸
- 搜索源选择按钮缩小至适配移动端
- 修复设置页面保存/取消按钮缺少加载状态
- 修复侧边栏展开动画抖动(rotateY → rotate)
- 移动端底部导航标签字号从 10px 增至 11px,提升可读性
- 登录页背景动画添加 `will-change: transform` 优化 GPU 性能
---
# [3.2.0-beta.8] - 2026-01-25
## Backend
### Features
- Passkey 登录支持无用户名模式(可发现凭证)
### Fixes
- 修复搜索和订阅流程中的多个问题
- 改进种子获取可靠性和错误处理
## Frontend
### Features
- Passkey 登录支持无用户名模式(可发现凭证)
---
# [3.2.0-beta.7] - 2026-01-25
## Backend
### Features
- 数据库迁移自动填充 NULL 值为模型默认值
### Fixes
- 修复下载器连接检查添加最大重试次数
- 修复添加种子时的网络瞬态错误,添加重试逻辑
## Frontend
### Features
- 重新设计搜索面板,新增模态框和过滤系统
- 重新设计登录面板,采用现代毛玻璃风格
- 日志页面新增日志级别过滤功能
### Fixes
- 修复日历页面未知列宽度问题
- 统一下载器页面操作栏按钮尺寸
---
# [3.2.0-beta.6] - 2026-01-25
## Backend
### Features
- 新增番剧归档功能:支持手动归档/取消归档,已完结番剧自动归档
### Fixes
- 修复 `add_all()` 方法缺少去重检查导致重复添加番剧规则的问题
- 去重逻辑基于 `(title_raw, group_name)` 组合,同时支持批量内部去重
- 新增剧集偏移自动检测:根据 TMDB 季度集数自动计算偏移量(如 S02E18 → S02E05)
- TMDB 解析器新增 `series_status` 和 `season_episode_counts` 字段提取
- 新增数据库迁移 v4:为 `bangumi` 表添加 `archived` 字段
- 新增 API 端点:
- `PATCH /bangumi/archive/{id}` - 归档番剧
- `PATCH /bangumi/unarchive/{id}` - 取消归档
- `GET /bangumi/refresh/metadata` - 刷新元数据并自动归档已完结番剧
- `GET /bangumi/suggest-offset/{id}` - 获取建议的剧集偏移量
- 重命名模块支持从数据库查询偏移量并应用到文件名
## Frontend
### Features
- 番剧列表页新增可折叠的「已归档」分区
- 日历页新增番剧分组功能:相同番剧的多个规则合并显示,点击可选择具体规则
- 番剧列表页新增骨架屏加载动画
### Fixes
- 修复弹窗 z-index 层级问题,新增 CSS 变量管理层级系统
- 改善无障碍体验:按钮最小触摸区域 44px、焦点状态可见、添加 aria-label
- 规则编辑弹窗新增归档/取消归档按钮
- 规则编辑器新增剧集偏移字段和「自动检测」按钮
- 新增 i18n 翻译(中文/英文)
- 优化规则编辑弹窗布局:统一表单字段对齐、统一按钮高度、修复移动端底部弹窗 z-index 层级问题
- 修复下载器页面仅显示季度文件夹名的问题,现在会显示「番剧名 / Season 1」格式
---
# [3.2.0-beta.5] - 2026-01-24
## Backend
### Features
- RSS 订阅源新增连接状态追踪:每次刷新后记录 `connection_status`(healthy/error)、`last_checked_at` 和 `last_error`
- 新增数据库迁移 v2:为 `rssitem` 表添加连接状态相关字段
### Performance
- 新增共享 HTTP 客户端连接池,复用 TCP/SSL 连接,减少每次请求的握手开销
- RSS 刷新改为并发拉取所有订阅源(`asyncio.gather`),多源场景下速度提升约 10 倍
- 种子文件下载改为并发获取,下载多个种子时速度提升约 5 倍
- 重命名模块并发获取所有种子文件列表,速度提升约 20 倍
- 通知发送改为并发执行,移除 2 秒硬编码延迟
- 新增 TMDB 和 Mikan 解析结果的内存缓存,避免重复 API 调用
- 为 `Torrent.url`、`Torrent.rss_id`、`Bangumi.title_raw`、`Bangumi.deleted`、`RSSItem.url` 添加数据库索引
- RSS 批量启用/禁用改为单次事务操作,替代逐条提交
- 预编译正则表达式(种子名解析规则、过滤器匹配),避免运行时重复编译
- `SeasonCollector` 在循环外创建,复用单次认证
- `check_first_run` 缓存默认配置字典,避免每次创建新对象
- 通知模块中的同步数据库调用改为 `asyncio.to_thread`,避免阻塞事件循环
- RSS 解析去重从 O(n²) 列表查找改为 O(1) 集合查找
- 文件后缀判断使用 `frozenset` 替代列表,提升查找效率
- `Episode`/`SeasonInfo` 数据类添加 `__slots__`,减少内存占用
- RSS XML 解析返回元组列表,替代三个独立列表再 zip 的模式
- qBittorrent 规则设置改为并发执行
## Frontend
### Features
- RSS 管理页面新增连接状态标签:健康时显示绿色「已连接」,错误时显示红色「错误」并通过 tooltip 显示错误详情
### Performance
- 下载器 store 使用 `shallowRef` 替代 `ref`,避免大数组的深层响应式代理
- 表格列定义改为 `computed`,避免每次渲染重建
- RSS 表格列与数据分离,数据变化时不重建列配置
- 日历页移除重复的 `getAll()` 调用
- `ab-select` 的 `watchEffect` 改为 `watch`,消除挂载时的无效 emit
- `useClipboard` 提升到 store 顶层,避免每次 `copy()` 创建新实例
- `setInterval` 替换为 `useIntervalFn`,自动生命周期管理,防止内存泄漏
- 共享 `ruleTemplate` 对象改为浅拷贝,避免意外的引用共变
- `ab-add-rss` 移除不必要的 `setTimeout` 延迟
### Fixes
- 修复 `ab-image.vue` 中 `<style scope>` 的拼写错误(应为 `scoped`)
- 修复 `ab-edit-rule.vue` 中 `String` 类型应为 `string`
- `bangumi` ref 初始化为 `[]` 而非 `undefined`,减少下游空值检查
- `ab-bangumi-card` 模板类型安全:动态属性访问改为显式枚举
- 启用 `noImplicitAny: true` 提升类型安全
### Types
- `ab-button`、`ab-search` 的 `defineEmits` 改为类型化声明
- `ab-data-list` 使用明确的 `DataItem` 类型替代 `any`
---
# [3.2.0-beta.4] - 2026-01-24
## Backend
### Bugfixes
- 修复从 3.1.x 升级后数据库缺少 `air_weekday` 列导致服务器错误的问题 (#956)
- 修复重命名模块中 `'dict' object has no attribute 'files'` 的错误
- 新增 `schema_version` 表追踪数据库版本,确保迁移可靠执行
- 修复 qBittorrent 下载器中缺少 `torrents_files` API 调用的问题
### Changes
- 数据库迁移机制重构:使用 `schema_version` 表替代仅依赖应用版本号的迁移策略
- 启动时始终检查并执行未完成的迁移,防止迁移中断后无法恢复
### Tests
- 新增全面的测试套件,覆盖核心业务逻辑:
- RSS 引擎测试:pull_rss、match_torrent、refresh_rss、add_rss 全流程
- 下载客户端测试:init_downloader、set_rule、add_torrent(磁力/文件)、rename
- 路径工具测试:save_path 生成、文件分类、is_ep 深度检查
- 重命名器测试:gen_path 命名方法(pn/advance/none/subtitle)、单文件/集合重命名
- 认证测试:JWT 创建/解码/验证、密码哈希、get_current_user
- 通知测试:getClient 工厂、send_msg 成功/失败、poster 查询
- 搜索测试:URL 构建、关键词清洗、special_url
- 配置测试:默认值、序列化、迁移、环境变量覆盖
- Bangumi API 测试:CRUD 端点 + 认证要求
- RSS API 测试:CRUD/批量端点 + 刷新
- 集成测试:RSS→下载全流程、重命名全流程、数据库一致性
- 新增 `pytest-mock` 开发依赖
- 新增共享测试 fixtures(`conftest.py`)和数据工厂(`factories.py`)
---
# [3.1] - 2023-08
- 合并了后端和前端仓库,优化了项目目录
- 优化了版本发布流程。
- Wiki 迁移至 Vitepress,地址:https://autobangumi.org
## Backend
### Features
- 新增 `RSS Engine` 模块,从现在起,AB 可以自主对 RSS 进行更新支持 `RSS` 订阅并且发送种子给下载器。
- 现在支持多个聚合 RSS 订阅源,可以通过 `RSS Engine` 模块进行管理。
- 支持下载去重功能,重复的订阅的种子不会被下载。
- 增加手动刷新 API,可以手动刷新 RSS 订阅。
- 新增 RSS 订阅管理 API。
- 新增 `Search Engine`模块,可以通过关键词搜索种子并解析成收集或者订阅任务。
- 插件化的搜索引擎,可以通过插件的方式添加新的搜索目标,目前支持 `mikan`、`dmhy` 和 `nyaa`
- 新增对字幕组的特异性规则,可以针对不同的字幕组进行单独设置。
- 新增 IPv6 监听支持,需要在环境变量中设置 `IPV6=1`。
- API 新增批量操作,可以批量管理规则和 RSS 订阅。
### Changes
- 数据库结构变更,更换为 `sqlmodel` 管理数据库。
- 新增版本管理,可以无缝更新软件数据。
- 调整 API 格式,更加统一。
- 增加 API 返回语言选项。
- 增加数据库 mock test。
- 优化代码。
### Bugfixes
- 修复了一些小问题。
- 增加了一些大问题。
## Frontend
### Features
- 增加 `i18n` 支持,目前支持 `zh-CN` 和 `en-US`。
- 增加 pwa 支持。
- 增加 RSS 管理页面。
- 增加搜索顶栏。
### Changes
- 调整一些 UI 细节。
================================================
FILE: CLAUDE.md
================================================
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
AutoBangumi is an RSS-based automatic anime downloading and organization tool. It monitors RSS feeds from anime torrent sites (Mikan, DMHY, Nyaa), downloads episodes via qBittorrent, and organizes files into a Plex/Jellyfin-compatible directory structure with automatic renaming.
## Development Commands
### Backend (Python)
```bash
# Install dependencies
cd backend && uv sync
# Install with dev tools
cd backend && uv sync --group dev
# Run development server (port 7892, API docs at /docs)
cd backend/src && uv run python main.py
# Run tests
cd backend && uv run pytest
cd backend && uv run pytest src/test/test_xxx.py -v # run specific test
# Linting and formatting
cd backend && uv run ruff check src
cd backend && uv run black src
# Add a dependency
cd backend && uv add <package>
# Add a dev dependency
cd backend && uv add --group dev <package>
```
### Frontend (Vue 3 + TypeScript)
```bash
cd webui
# Install dependencies (uses pnpm, not npm)
pnpm install
# Development server (port 5173)
pnpm dev
# Build for production
pnpm build
# Type checking
pnpm test:build
# Linting and formatting
pnpm lint
pnpm lint:fix
pnpm format
```
### Docker
```bash
docker build -t auto_bangumi:latest .
docker run -p 7892:7892 -v /path/to/config:/app/config -v /path/to/data:/app/data auto_bangumi:latest
```
## Architecture
```
backend/src/
├── main.py # FastAPI entry point, mounts API at /api
├── module/
│ ├── api/ # REST API routes (v1 prefix)
│ │ ├── auth.py # Authentication endpoints
│ │ ├── bangumi.py # Anime series CRUD
│ │ ├── rss.py # RSS feed management
│ │ ├── config.py # Configuration endpoints
│ │ ├── program.py # Program status/control
│ │ └── search.py # Torrent search
│ ├── core/ # Application logic
│ │ ├── program.py # Main controller, orchestrates all operations
│ │ ├── sub_thread.py # Background task execution
│ │ └── status.py # Application state tracking
│ ├── models/ # SQLModel ORM models (Pydantic + SQLAlchemy)
│ ├── database/ # Database operations (SQLite at data/data.db)
│ ├── rss/ # RSS parsing and analysis
│ ├── downloader/ # qBittorrent integration
│ │ └── client/ # Download client implementations (qb, aria2, tr)
│ ├── searcher/ # Torrent search providers (Mikan, DMHY, Nyaa)
│ ├── parser/ # Torrent name parsing, metadata extraction
│ │ └── analyser/ # TMDB, Mikan, OpenAI parsers
│ ├── manager/ # File organization and renaming
│ ├── notification/ # Notification plugins (Telegram, Bark, etc.)
│ ├── conf/ # Configuration management, settings
│ ├── network/ # HTTP client utilities
│ └── security/ # JWT authentication
webui/src/
├── api/ # Axios API client functions
├── components/ # Vue components (basic/, layout/, setting/)
├── pages/ # Router-based page components
├── router/ # Vue Router configuration
├── store/ # Pinia state management
├── i18n/ # Internationalization (zh-CN, en-US)
└── hooks/ # Custom Vue composables
```
## Key Data Flow
1. RSS feeds are parsed by `module/rss/` to extract torrent information
2. Torrent names are analyzed by `module/parser/analyser/` to extract anime metadata
3. Downloads are managed via `module/downloader/` (qBittorrent API)
4. Files are organized by `module/manager/` into standard directory structure
5. Background tasks run in `module/core/sub_thread.py` to avoid blocking
## Code Style
- Python: Black (88 char lines), Ruff linter (E, F, I rules), target Python 3.10+
- TypeScript: ESLint + Prettier
- Run formatters before committing
## Git Branching
- `main`: Stable releases only
- `X.Y-dev` branches: Active development (e.g., `3.2-dev`)
- Bug fixes → PR to current released version's `-dev` branch
- New features → PR to next version's `-dev` branch
## Releasing a Beta Version
1. Update version in `backend/pyproject.toml`
2. Update `CHANGELOG.md` with the new version heading
3. Commit and push to the dev branch
4. Create and push a tag with the version name (e.g., `3.2.0-beta.4`):
```bash
git tag 3.2.0-beta.4
git push origin 3.2.0-beta.4
```
5. The CI/CD workflow (`.github/workflows/build.yml`) detects the tag contains "beta", uses the tag name as the VERSION string, generates `module/__version__.py`, and builds the Docker image
The VERSION is injected at build time via CI — `module/__version__.py` does not exist in the repo. At runtime, `module/conf/config.py` imports it or falls back to `"DEV_VERSION"`.
## Database Migrations
Schema migrations are tracked via a `schema_version` table in SQLite. To add a new migration:
1. Increment `CURRENT_SCHEMA_VERSION` in `backend/src/module/database/combine.py`
2. Append a new entry to the `MIGRATIONS` list: `(version, "description", ["SQL statements"])`
3. Migrations run automatically on startup via `run_migrations()`
## Notes
- Documentation and comments are in Chinese
- Uses SQLModel (hybrid Pydantic + SQLAlchemy ORM)
- External integrations: qBittorrent API, TMDB API, OpenAI API
- Version tracked in `/config/version.info` (for cross-version upgrade detection)
================================================
FILE: CONTRIBUTING.md
================================================
# 贡献指南 Contributing
我们欢迎各位 Contributors 参与贡献帮助 AutoBangumi 更好的解决大家遇到的问题,
这篇指南会指导你如何为 AutoBangumi 贡献功能修复代码,可以在你要提出 Pull Request 之前花几分钟来阅读一遍这篇指南。
这篇文章包含什么?
- [项目规划 Roadmap](#项目规划-roadmap)
- [提案寻求共识 Request for Comments](#提案寻求共识-request-for-comments)
- [分支管理 Git Branch](#分支管理-git-branch)
- [版本号](#版本号)
- [分支开发,主干发布](#分支开发主干发布)
- [Branch 生命周期](#branch-生命周期)
- [Git Workflow 一览](#git-workflow-一览)
- [Pull Request](#pull-request)
- [版本发布介绍](#版本发布介绍)
## 项目规划 Roadmap
AutoBangumi 开发组使用 [GitHub Project](https://github.com/EstrellaXD/Auto_Bangumi/projects?query=is%3Aopen) 看板来管理预计开发的规划、在修复中的问题,以及它们处理的进度;
这将帮助你更好的了解
- 开发团队在做什么?
- 有什么和你想贡献的方向一致的,可以直接参与实现与优化
- 有什么已经在进行中的,避免自己重复不必要的工作
在 [Project](https://github.com/EstrellaXD/Auto_Bangumi/projects?query=is%3Aopen) 中你可以看到除通常的 `[Feature Request]`, `[BUG]`, 一些小优化项以外,还有一类 **`[RFC]`**;
### 提案寻求共识 Request for Comments
> 在 issue 中通过 `RFC` label 能找到到现有的 [AutoBangumi RFCs](https://github.com/EstrellaXD/Auto_Bangumi/issues?q=is%3Aissue+label%3ARFC)
对于一些小的优化项或者 bug 修复,你大可以直接帮忙调整代码然后提出 Pull Request,只需要简单阅读下 [分支管理](#分支管理-Git-Branch) 章节以基于正确的版本分支修复、以及通过 [Pull Request](#Pull-Request) 章节了解 PR 将如何被合并。
<br/>
而如果你打算做的是一项**较大的**功能重构,改动范围大而涉及的方面比较多,那么希望你能通过 [Issue: 功能提案](https://github.com/EstrellaXD/Auto_Bangumi/issues/new?assignees=&labels=RFC&projects=&template=rfc.yml&title=%5BRFC%5D%3A+) 先写一份 RFC 提案来简单阐述「你打算怎么做」的简短方案,来寻求开发者的讨论和共识。
因为有些方案可能是开发团队原本讨论并且认为不要做的事,而上一步可以避免你浪费大量精力。
> 如果仅希望讨论是否添加或改进某功能本身,而非「要如何实现」,请使用 -> [Issue: 功能改进](https://github.com/EstrellaXD/Auto_Bangumi/issues/new?labels=feature+request&template=feature_request.yml&title=%5BFeature+Request%5D+)
<br/>
一份 [提案(RFC)](https://github.com/EstrellaXD/Auto_Bangumi/issues?q=is%3Aissue+is%3Aopen+label%3ARFC) 定位为 **「在某功能/重构的具体开发前,用于开发者间 review 技术设计/方案的文档」**,
目的是让协作的开发者间清晰的知道「要做什么」和「具体会怎么做」,以及所有的开发者都能公开透明的参与讨论;
以便评估和讨论产生的影响 (遗漏的考虑、向后兼容性、与现有功能的冲突),
因此提案侧重在对解决问题的 **方案、设计、步骤** 的描述上。
## 分支管理 Git Branch
### 版本号
AutoBangumi 项目中的 Git 分支使用与发布版本规则密切相关,因此先介绍版本规范;
AutoBangumi 发布的版本号遵循 [「语义化版本 SemVer」](https://semver.org/lang/zh-CN/) 的规范,
使用 `<Major>.<Minor>.<Patch>` 三位版本的格式,每一位版本上的数字更新含义如下:
- **Major**: 大版本更新,很可能有不兼容的 配置/API 修改
- **Minor**: 向下兼容的功能性新增
- **Patch**: 向下兼容的 Bug 修复 / 小优化修正
### 分支开发,主干发布
AutoBangumi 项目使用「分支开发,主干发布」的模式,
[**`main`**](https://github.com/EstrellaXD/Auto_Bangumi/commits/main) 分支是稳定版本的 **「主干分支」**,只用于发布版本,不用于直接开发新功能或修复。
每一个 Minor 版本都有一个对应的 **「开发分支」** 用于开发新功能、与发布后维护修复问题,
开发分支的名字为 `<Major>.<Minor>-dev`,如 `3.1-dev`, `3.0-dev`, `2.6-dev`, 你可以在仓库的 [All Branches 中搜索到它们](https://github.com/EstrellaXD/Auto_Bangumi/branches/all?query=-dev)。
### Branch 生命周期
当一个 Minor 开发分支(以 `3.1-dev` 为例) 完成新功能开发,**首次**合入 main 分支后,
- 发布 Minor 版本 (如 `3.1.0`)
- 同时拉出**下一个** Minor 开发分支(`3.2-dev`),用于下一个版本新功能开发
- 而**上一个**版本开发分支(`3.0-dev`)进入归档不再维护
- 且这个 Minor 分支(`3.1-dev`)进入维护阶段,不再增加新功能/重构,只维护 Bugs 修复
- Bug 修复到维护阶段的 Minor 分支(`3.1-dev`)后,会再往 main 分支合并,并发布 `Patch` 版本
根据这个流程,对于各位 Contributors 在开发贡献时选择 Git Branch 来说,则是:
- 若「修复 Bug」,则基于**当前发布版本**的 Minor 分支开发修复,并 PR 到这个分支
- 若「添加新功能/重构」,则基于**还未发布的下一个版本** Minor 分支开发,并 PR 到这个分支
> 「当前发布版本」为 [[Releases 页面]](https://github.com/EstrellaXD/Auto_Bangumi/releases) 最新版本,这也与 [[GitHub Container Registry]](https://github.com/EstrellaXD/Auto_Bangumi/pkgs/container/auto_bangumi) 中最新版本相同
### Git Workflow 一览
> 图中 commit timeline 从左到右 --->
```mermaid
%%{init: {'theme': 'base', 'gitGraph': {'showCommitLabel': true}}}%%
gitGraph:
checkout main
commit id: "."
branch 3.0-dev
commit id: "feat 1"
commit id: "feat 2"
commit id: "feat 3"
checkout main
merge 3.0-dev tag: "3.0.9"
commit id: ".."
branch 3.1-dev
commit id: "feat 4"
checkout 3.0-dev
commit id: "PR merge (fix)"
checkout main
merge 3.0-dev tag: "3.0.10"
checkout 3.1-dev
commit id: "feat 5"
commit id: "feat 6"
checkout main
merge 3.1-dev tag: "3.1.0"
commit id: "..."
branch 3.2-dev
commit id: "feat 7"
commit id: "feat 8"
checkout 3.1-dev
commit id: "PR merge (fix) "
checkout main
merge 3.1-dev tag: "3.1.1"
checkout 3.2-dev
commit id: "PR merge (feat)"
```
## Pull Request
请确保你根据上文的 Git 分支管理 章节选择了正确的 PR 目标分支,
> - 若「修复 Bug」,则 PR 到**当前发布版本**的 Minor 维护分支
> - 若「添加新功能/重构」,则 PR **下一个版本** Minor 开发分支
<br/>
- 一个 PR 应该只对应一件事,而不应引入不相关的更改;
对于不同的事情可以拆分提多个 PR,这能帮助开发组每次 review 只专注一个问题。
- 在提 PR 的标题与描述中,最好对修改内容做简短的说明,包括原因和意图,
如果有相关的 issue 或 RFC,应该把它们链接到 PR 描述中,
这将帮助开发组 code review 时能最快了解上下文。
- 确保勾选了「允许维护者编辑」(`Allow edits from maintainers`) 选项。这使我们可以直接进行较小的编辑/重构并节省大量时间。
- 请确保本地通过了「单元测试」和「代码风格 Lint」,这也会在 PR 的 GitHub CI 上检查
- 对于 bug fix 和新功能,通常开发组也会请求你添加对应改动的单元测试覆盖
开发组会在有时间的最快阶段 Review 贡献者提的 PR 并讨论或批准合并(Approve Merge)。
## 版本发布介绍
版本发布目前由开发组通过手动合并「特定发版 PR」后自动触发打包与发布。
通常 Bug 修复的 PR 合并后会很快发版,通常不到一周;
而新功能的发版时间则会更长而且不定,你可以在我们的 [GitHub Project](https://github.com/EstrellaXD/Auto_Bangumi/projects?query=is%3Aopen) 看板中看到开发进度,一个版本规划的新功能都开发完备后就会发版。
## 贡献文档
如果要为文档做贡献,请注意以下几点:
- 更新分支为 `docs-update`,并基于它做修改.
- 请确保你的 PR 标题和描述中包含了你的修改的目的和意图。
撰写文档请尽量使用规范的书面化用语,遵照 Markdown 语法,以及 [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines) 中的规范。
================================================
FILE: Dockerfile
================================================
# syntax=docker/dockerfile:1
FROM ghcr.io/astral-sh/uv:0.5-python3.13-alpine AS builder
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
# Install dependencies (cached layer)
COPY backend/pyproject.toml backend/uv.lock ./
RUN uv sync --frozen --no-dev
# Copy application source
COPY backend/src ./src
FROM python:3.13-alpine AS runtime
RUN apk add --no-cache \
bash \
su-exec \
shadow \
tini \
tzdata
ENV LANG="C.UTF-8" \
TZ=Asia/Shanghai \
PUID=1000 \
PGID=1000 \
UMASK=022
WORKDIR /app
# Copy venv and source from builder
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/src .
COPY --chmod=755 entrypoint.sh /entrypoint.sh
# Add user
RUN mkdir -p /home/ab && \
addgroup -S ab -g 911 && \
adduser -S ab -G ab -h /home/ab -s /sbin/nologin -u 911
ENV PATH="/app/.venv/bin:$PATH"
EXPOSE 7892
VOLUME ["/app/config", "/app/data"]
ENTRYPOINT ["tini", "-g", "--", "/entrypoint.sh"]
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2022 Estrella Pan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
<p align="center">
<img src="docs/public/image/icons/light-icon.svg#gh-light-mode-only" width=50%/ alt="">
<img src="docs/public/image/icons/dark-icon.svg#gh-dark-mode-only" width=50%/ alt="">
</p>
<p align="center">
<img title="docker build version" src="https://img.shields.io/docker/v/estrellaxd/auto_bangumi" alt="">
<img title="release date" src="https://img.shields.io/github/release-date/estrellaxd/auto_bangumi" alt="">
<img title="docker pull" src="https://img.shields.io/docker/pulls/estrellaxd/auto_bangumi" alt="">
<img title="python version" src="https://img.shields.io/badge/python-3.11-blue" alt="">
</p>
<p align="center">
<a href="https://www.autobangumi.org">官方网站</a> | <a href="https://www.autobangumi.org/deploy/quick-start.html">快速开始</a> | <a href="https://www.autobangumi.org/changelog/3.2.html">更新日志</a> | <a href="https://t.me/autobangumi_update">更新推送</a> | <a href="https://t.me/autobangumi">TG 群组</a>
</p>
# 项目说明
<p align="center">
<img title="AutoBangumi" src="docs/public/image/feature/bangumi-list.png" alt="" width=75%>
</p>
本项目是基于 RSS 的全自动追番整理下载工具。只需要在 [Mikan Project][mikan] 等网站上订阅番剧,就可以全自动追番。
并且整理完成的名称和目录可以直接被 [Plex][plex]、[Jellyfin][plex] 等媒体库软件识别,无需二次刮削。
## AutoBangumi 功能说明
### 核心功能
- 简易单次配置就能持续使用
- 无需介入的 `RSS` 解析器,解析番组信息并且自动生成下载规则
- 首次运行设置向导,7 步引导完成配置
- 番剧文件整理:
```
Bangumi
├── bangumi_A_title
│ ├── Season 1
│ │ ├── A S01E01.mp4
│ │ ├── A S01E02.mp4
│ │ ├── A S01E03.mp4
│ │ └── A S01E04.mp4
│ └── Season 2
│ ├── A S02E01.mp4
│ ├── A S02E02.mp4
│ ├── A S02E03.mp4
│ └── A S02E04.mp4
├── bangumi_B_title
│ └─── Season 1
```
- 全自动重命名,重命名后 99% 以上的番剧可以直接被媒体库软件直接刮削
```
[Lilith-Raws] Kakkou no Iinazuke - 07 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4].mp4
>>
Kakkou no Iinazuke S01E07.mp4
```
- 自定义重命名,可以根据上级文件夹对所有子文件重命名。
- 季中追番可以补全当季遗漏的所有剧集
- 高度可自定义的功能选项,可以针对不同媒体库软件微调
- 支持多种 RSS 站点,支持聚合 RSS 的解析
- 无需维护完全无感使用
- 内置 TMDB 解析器,可以直接生成完整的 TMDB 格式的文件以及番剧信息
### 3.2 新功能
- **日历视图**:按播出日期查看订阅番剧,集成 Bangumi.tv 放送时间表
- **Passkey 无密码登录**:支持 WebAuthn 指纹/面容登录,支持无用户名登录
- **季度/集数偏移自动检测**:自动识别「虚拟季度」并计算正确的集数偏移
- **番剧归档**:手动或自动归档已完结番剧,保持列表整洁
- **搜索源设置面板**:在 UI 中直接管理搜索源,无需编辑配置文件
- **RSS 连接状态**:实时显示订阅源健康状态,快速定位问题
- **iOS 风格通知徽章**:直观显示需要关注的订阅
- **全新 UI 设计**:深色/浅色主题、移动端适配、毛玻璃登录页
- **性能优化**:并发 RSS 刷新提速 10 倍、并发下载提速 5 倍
## [Roadmap](https://github.com/users/EstrellaXD/projects/2)
***已支持的下载器:***
- qBittorrent
## Star History
[](https://star-history.com/#EstrellaXD/Auto_Bangumi)
## 贡献
欢迎提供 ISSUE 或者 PR, 贡献代码前建议阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。
贡献者名单请见:
<a href="https://github.com/EstrellaXD/Auto_Bangumi/graphs/contributors"><img src="https://contrib.rocks/image?repo=EstrellaXD/Auto_Bangumi"></a>
## Licence
[MIT licence](https://github.com/EstrellaXD/Auto_Bangumi/blob/main/LICENSE)
[mikan]: https://mikanani.me
[plex]: https://plex.tv
[jellyfin]: https://jellyfin.org
================================================
FILE: SECURITY.md
================================================
# Security Policy / 安全政策
## Supported Versions / 支持的版本
| Version | Supported |
| ------- | ------------------ |
| 3.x | :white_check_mark: |
| < 3.0 | :x: |
## Reporting a Vulnerability / 报告漏洞
### English
If you discover a security vulnerability in AutoBangumi, please report it responsibly:
1. **GitHub Private Vulnerability Reporting** (Recommended): Use [GitHub's private vulnerability reporting feature](https://github.com/EstrellaXD/Auto_Bangumi/security/advisories/new) to submit your report securely.
2. **Email**: Contact the maintainer directly at the email associated with the GitHub account [@EstrellaXD](https://github.com/EstrellaXD).
**Please do NOT:**
- Open a public GitHub issue for security vulnerabilities
- Disclose the vulnerability publicly before it has been addressed
**What to include in your report:**
- Description of the vulnerability
- Steps to reproduce the issue
- Potential impact
- Any suggested fixes (optional)
We will acknowledge receipt of your report within 48 hours and work to address the issue promptly.
---
### 中文
如果您在 AutoBangumi 中发现安全漏洞,请通过以下方式负责任地报告:
1. **GitHub 私密漏洞报告**(推荐):使用 [GitHub 的私密漏洞报告功能](https://github.com/EstrellaXD/Auto_Bangumi/security/advisories/new) 安全地提交您的报告。
2. **邮件**:直接联系维护者,使用 GitHub 账户 [@EstrellaXD](https://github.com/EstrellaXD) 关联的邮箱。
**请勿:**
- 在公开的 GitHub Issue 中报告安全漏洞
- 在漏洞被修复之前公开披露
**报告中请包含:**
- 漏洞描述
- 复现步骤
- 潜在影响
- 修复建议(可选)
我们将在 48 小时内确认收到您的报告,并尽快处理该问题。
================================================
FILE: backend/.pre-commit-config.yaml
================================================
repos:
- repo: https://github.com/psf/black
rev: 22.10.0
hooks:
- id: black
language: python
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.0.291
hooks:
- id: ruff
================================================
FILE: backend/.vscode/settings.json
================================================
{
"python.formatting.provider": "none",
"python.formatting.blackPath": "black",
"editor.formatOnSave": true,
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter"
}
}
================================================
FILE: backend/dev.sh
================================================
#!/usr/bin/env bash
# This script is used to run the development environment.
python3 -m venv venv
./venv/bin/python3 -m pip install -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements-dev.txt
# install git-hooks for pre-commit before committing.
./venv/bin/pre-commit install
cd src || exit
CONFIG_DIR="config"
if [ ! -d "$CONFIG_DIR" ]; then
echo "The directory '$CONFIG_DIR' is missing."
mkdir config
fi
VERSION_FILE="module/__version__.py"
if [ ! -f "$VERSION_FILE" ]; then
echo "The file '$VERSION_FILE' is missing."
echo "VERSION='DEV_VERSION'" >>"$VERSION_FILE"
fi
../venv/bin/uvicorn main:app --reload --port 7892
================================================
FILE: backend/pyproject.toml
================================================
[project]
name = "auto-bangumi"
version = "3.2.4"
description = "AutoBangumi - Automated anime download manager"
requires-python = ">=3.13"
dependencies = [
"fastapi>=0.109.0",
"uvicorn>=0.27.0",
"httpx[socks]>=0.25.0",
"httpx-socks>=0.9.0",
"beautifulsoup4>=4.12.0",
"sqlmodel>=0.0.14",
"sqlalchemy[asyncio]>=2.0.0",
"aiosqlite>=0.19.0",
"pydantic>=2.0.0",
"python-jose>=3.3.0",
"passlib>=1.7.4",
"bcrypt>=4.0.1,<4.1",
"python-multipart>=0.0.6",
"python-dotenv>=1.0.0",
"Jinja2>=3.1.2",
"openai>=1.54.3",
"semver>=3.0.1",
"sse-starlette>=1.6.5",
"webauthn>=2.0.0",
"urllib3>=2.0.3",
"mcp[cli]>=1.8.0",
]
[dependency-groups]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"pytest-mock>=3.12.0",
"ruff>=0.1.0",
"black>=24.0.0",
"pre-commit>=3.0.0",
]
[tool.pytest.ini_options]
testpaths = ["src/test"]
pythonpath = ["src"]
asyncio_mode = "auto"
markers = [
"e2e: End-to-end integration tests (require Docker)",
]
[tool.ruff]
line-length = 88
target-version = "py313"
exclude = [".venv", "venv", "build", "dist"]
[tool.ruff.lint]
select = ["E", "F", "I"]
ignore = ["E501", "F401"]
fixable = ["ALL"]
unfixable = []
[tool.ruff.lint.mccabe]
max-complexity = 10
[tool.uv]
package = false
[tool.black]
line-length = 88
target-version = ['py313']
================================================
FILE: backend/scripts/pip-lock-version.sh
================================================
#!/usr/bin/env bash
#
# Usage:
# `bash scripts/pip-lock-version.sh`
#
# ```prompt
# Lock the library versions in `requirements.txt` to the current ones from `pip freeze` using shell script,
# but don't change any order in `requirements.txt`
# ```
#
# Create a temporary requirements file using pip freeze
pip freeze > pip_freeze.log
# Read the existing requirements.txt line by line
while IFS= read -r line
do
# Extract the library name without version
lib_name=$(echo $line | cut -d'=' -f1)
# Find the corresponding library in the temporary requirements file
lib_line=$(grep "^$lib_name==" pip_freeze.log)
# If the library is found, update the line
if [[ $lib_line ]]
then
echo $lib_line
else
echo $line
fi
# Redirect the output to a new requirements file
done < requirements.txt > new_requirements.log
# Remove the temporary requirements file
rm pip_freeze.log
# Replace the old requirements file with the new one
mv new_requirements.log requirements.txt
================================================
FILE: backend/src/dev_server.py
================================================
"""Minimal dev server that skips downloader check for UI testing."""
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi import APIRouter
from module.database.combine import Database
from module.database.engine import engine
# Initialize DB + migrations + default user
with Database(engine) as db:
db.create_table()
db.user.add_default_user()
# Build v1 router without program router (which blocks on downloader check)
from module.api.auth import router as auth_router
from module.api.bangumi import router as bangumi_router
from module.api.config import router as config_router
from module.api.log import router as log_router
from module.api.rss import router as rss_router
from module.api.search import router as search_router
v1 = APIRouter(prefix="/v1")
v1.include_router(auth_router)
v1.include_router(bangumi_router)
v1.include_router(config_router)
v1.include_router(log_router)
v1.include_router(rss_router)
v1.include_router(search_router)
# Stub status endpoint (real one lives in program router which blocks on downloader)
@v1.get("/status")
async def stub_status():
return {"status": True, "version": "dev"}
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(v1, prefix="/api")
if __name__ == "__main__":
uvicorn.run(app, host="127.0.0.1", port=7892)
================================================
FILE: backend/src/main.py
================================================
import logging
import os
from contextlib import asynccontextmanager
from pathlib import Path
import uvicorn
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from module.api import v1
from module.api.program import program
from module.conf import VERSION, settings, setup_logger
from module.mcp import create_mcp_app
setup_logger(reset=True)
logger = logging.getLogger(__name__)
uvicorn_logging_config = {
"version": 1,
"disable_existing_loggers": False,
"handlers": logger.handlers,
"loggers": {
"uvicorn": {
"level": logger.level,
},
"uvicorn.access": {
"level": "WARNING",
},
},
}
@asynccontextmanager
async def lifespan(app: FastAPI):
import asyncio
# Startup
asyncio.create_task(program.startup())
yield
# Shutdown
await program.stop()
def create_app() -> FastAPI:
app = FastAPI(lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=[],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["*"],
)
# mount routers
app.include_router(v1, prefix="/api")
# mount MCP server (SSE transport for LLM tool integration)
app.mount("/mcp", create_mcp_app())
return app
app = create_app()
_POSTERS_BASE = Path("data/posters").resolve()
@app.get("/posters/{path:path}", tags=["posters"])
def posters(path: str):
resolved = (_POSTERS_BASE / path).resolve()
if not str(resolved).startswith(str(_POSTERS_BASE)):
return HTMLResponse(status_code=403)
return FileResponse(str(resolved))
if VERSION != "DEV_VERSION":
app.mount("/assets", StaticFiles(directory="dist/assets"), name="assets")
app.mount("/images", StaticFiles(directory="dist/images"), name="images")
# app.mount("/icons", StaticFiles(directory="dist/icons"), name="icons")
templates = Jinja2Templates(directory="dist")
@app.get("/{path:path}")
def html(request: Request, path: str):
files = os.listdir("dist")
if path in files:
return FileResponse(f"dist/{path}")
else:
context = {"request": request}
return templates.TemplateResponse("index.html", context)
else:
@app.get("/", status_code=302, tags=["html"])
def index():
return RedirectResponse("/docs")
if __name__ == "__main__":
if os.getenv("IPV6"):
host = "::"
else:
host = os.getenv("HOST", "0.0.0.0")
uvicorn.run(
app,
host=host,
port=settings.program.webui_port,
log_config=uvicorn_logging_config,
)
================================================
FILE: backend/src/module/__init__.py
================================================
================================================
FILE: backend/src/module/ab_decorator/__init__.py
================================================
import asyncio
import functools
import logging
import httpx
from .timeout import timeout
logger = logging.getLogger(__name__)
_lock = asyncio.Lock()
_RETRY_DELAYS = [5, 15, 45, 120, 300]
def qb_connect_failed_wait(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
times = 0
while times < 5:
try:
return await func(*args, **kwargs)
except (
ConnectionError,
TimeoutError,
OSError,
httpx.ConnectError,
httpx.TimeoutException,
httpx.RequestError,
) as e:
delay = _RETRY_DELAYS[times]
logger.debug("URL: %s", args[0])
logger.warning(e)
logger.warning(
"Cannot connect to qBittorrent. Wait %ds and retry...", delay
)
await asyncio.sleep(delay)
times += 1
return wrapper
def api_failed(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
try:
return await func(*args, **kwargs)
except Exception as e:
logger.debug("URL: %s", args[0])
logger.warning("Wrong API response.")
logger.debug(e)
return wrapper
def locked(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
async with _lock:
return await func(*args, **kwargs)
return wrapper
================================================
FILE: backend/src/module/ab_decorator/timeout.py
================================================
import signal
def timeout(seconds):
def decorator(func):
def handler(signum, frame):
raise TimeoutError("Function timed out.")
def wrapper(*args, **kwargs):
# 设置信号处理程序,当超时时触发TimeoutError异常
signal.signal(signal.SIGALRM, handler)
signal.alarm(seconds) # 设置alarm定时器
try:
result = func(*args, **kwargs)
finally:
signal.alarm(0) # 取消alarm定时器
return result
return wrapper
return decorator
================================================
FILE: backend/src/module/api/__init__.py
================================================
from fastapi import APIRouter
from .auth import router as auth_router
from .bangumi import router as bangumi_router
from .config import router as config_router
from .downloader import router as downloader_router
from .log import router as log_router
from .passkey import router as passkey_router
from .program import router as program_router
from .rss import router as rss_router
from .search import router as search_router
from .setup import router as setup_router
from .notification import router as notification_router
__all__ = "v1"
# API 1.0
v1 = APIRouter(prefix="/v1")
v1.include_router(auth_router)
v1.include_router(passkey_router)
v1.include_router(log_router)
v1.include_router(program_router)
v1.include_router(bangumi_router)
v1.include_router(config_router)
v1.include_router(downloader_router)
v1.include_router(rss_router)
v1.include_router(search_router)
v1.include_router(setup_router)
v1.include_router(notification_router)
================================================
FILE: backend/src/module/api/auth.py
================================================
from datetime import datetime, timedelta
from fastapi import APIRouter, Cookie, Depends, HTTPException, status
from fastapi.responses import JSONResponse, Response
from fastapi.security import OAuth2PasswordRequestForm
from module.models import APIResponse
from module.models.user import User, UserUpdate
from module.security.api import (
active_user,
auth_user,
check_login_ip,
get_current_user,
update_user_info,
)
from module.security.jwt import create_access_token, decode_token
from .response import u_response
router = APIRouter(prefix="/auth", tags=["auth"])
_TOKEN_EXPIRY_DAYS = 1
_TOKEN_MAX_AGE = 86400
def _issue_token(username: str, response: Response) -> dict:
"""Create a JWT, set it as an HttpOnly cookie, and return the bearer payload."""
token = create_access_token(
data={"sub": username}, expires_delta=timedelta(days=_TOKEN_EXPIRY_DAYS)
)
response.set_cookie(key="token", value=token, httponly=True, max_age=_TOKEN_MAX_AGE)
return {"access_token": token, "token_type": "bearer"}
@router.post("/login", response_model=dict, dependencies=[Depends(check_login_ip)])
async def login(response: Response, form_data=Depends(OAuth2PasswordRequestForm)):
"""Authenticate with username/password and issue a session token."""
user = User(username=form_data.username, password=form_data.password)
resp = auth_user(user)
if resp.status:
return _issue_token(user.username, response)
return u_response(resp)
@router.get(
"/refresh_token", response_model=dict, dependencies=[Depends(get_current_user)]
)
async def refresh(response: Response, token: str = Cookie(None)):
"""Refresh the current session token and update the active-user timestamp."""
payload = decode_token(token)
username = payload.get("sub") if payload else None
if not username:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized"
)
active_user[username] = datetime.now()
return _issue_token(username, response)
@router.get(
"/logout", response_model=APIResponse, dependencies=[Depends(get_current_user)]
)
async def logout(response: Response, token: str = Cookie(None)):
"""Invalidate the session and clear the token cookie."""
payload = decode_token(token)
username = payload.get("sub") if payload else None
if username:
active_user.pop(username, None)
response.delete_cookie(key="token")
return JSONResponse(
status_code=200,
content={"msg_en": "Logout successfully.", "msg_zh": "登出成功。"},
)
@router.post("/update", response_model=dict, dependencies=[Depends(get_current_user)])
async def update_user(
user_data: UserUpdate, response: Response, token: str = Cookie(None)
):
"""Update credentials for the current user and re-issue a fresh token."""
payload = decode_token(token)
old_user = payload.get("sub") if payload else None
if not old_user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized"
)
if update_user_info(user_data, old_user):
return {**_issue_token(old_user, response), "message": "update success"}
================================================
FILE: backend/src/module/api/bangumi.py
================================================
from typing import Literal, Optional
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from module.conf import settings
from module.database import Database
from module.manager import TorrentManager
from module.models import APIResponse, Bangumi, BangumiUpdate
from module.parser.analyser.offset_detector import (
OffsetSuggestion as DetectorSuggestion,
)
from module.parser.analyser.offset_detector import detect_offset_mismatch
from module.parser.analyser.tmdb_parser import tmdb_parser
from module.security.api import UNAUTHORIZED, get_current_user
from .response import u_response
class OffsetSuggestion(BaseModel):
"""Legacy offset suggestion model."""
suggested_offset: int
reason: str
class TMDBSummary(BaseModel):
"""Summary of TMDB data for display."""
title: str
total_seasons: int
season_episode_counts: dict[int, int]
status: Optional[str]
virtual_season_starts: Optional[dict[int, list[int]]] = None # {1: [1, 29], ...}
class OffsetSuggestionDetail(BaseModel):
"""Detailed offset suggestion from detector."""
season_offset: int
episode_offset: int
reason: str
confidence: Literal["high", "medium", "low"]
class SetWeekdayRequest(BaseModel):
weekday: Optional[int] = None # 0-6 for Mon-Sun, None to reset
class DetectOffsetRequest(BaseModel):
"""Request body for detect-offset endpoint."""
title: str
parsed_season: int
parsed_episode: int
class DetectOffsetResponse(BaseModel):
"""Response for detect-offset endpoint."""
has_mismatch: bool
suggestion: Optional[OffsetSuggestionDetail]
tmdb_info: Optional[TMDBSummary]
router = APIRouter(prefix="/bangumi", tags=["bangumi"])
def str_to_list(data: Bangumi):
data.filter = data.filter.split(",")
data.rss_link = data.rss_link.split(",")
return data
@router.get(
"/get/all", response_model=list[Bangumi], dependencies=[Depends(get_current_user)]
)
async def get_all_data():
with TorrentManager() as manager:
return manager.bangumi.search_all()
@router.get(
"/get/{bangumi_id}",
response_model=Bangumi,
dependencies=[Depends(get_current_user)],
)
async def get_data(bangumi_id: str):
with TorrentManager() as manager:
resp = manager.search_one(bangumi_id)
return resp
@router.patch(
"/update/{bangumi_id}",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def update_rule(
bangumi_id: int,
data: BangumiUpdate,
):
with TorrentManager() as manager:
resp = await manager.update_rule(bangumi_id, data)
return u_response(resp)
@router.delete(
path="/delete/{bangumi_id}",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def delete_rule(bangumi_id: str, file: bool = False):
with TorrentManager() as manager:
resp = await manager.delete_rule(bangumi_id, file)
return u_response(resp)
@router.delete(
path="/delete/many/",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def delete_many_rule(bangumi_id: list, file: bool = False):
with TorrentManager() as manager:
for i in bangumi_id:
resp = await manager.delete_rule(i, file)
return u_response(resp)
@router.delete(
path="/disable/{bangumi_id}",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def disable_rule(bangumi_id: str, file: bool = False):
with TorrentManager() as manager:
resp = await manager.disable_rule(bangumi_id, file)
return u_response(resp)
@router.delete(
path="/disable/many/",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def disable_many_rule(bangumi_id: list, file: bool = False):
with TorrentManager() as manager:
for i in bangumi_id:
resp = await manager.disable_rule(i, file)
return u_response(resp)
@router.get(
path="/enable/{bangumi_id}",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def enable_rule(bangumi_id: str):
with TorrentManager() as manager:
resp = manager.enable_rule(bangumi_id)
return u_response(resp)
@router.get(
path="/refresh/poster/all",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def refresh_poster_all():
with TorrentManager() as manager:
resp = await manager.refresh_poster()
return u_response(resp)
@router.get(
path="/refresh/poster/{bangumi_id}",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def refresh_poster_one(bangumi_id: int):
with TorrentManager() as manager:
resp = await manager.refind_poster(bangumi_id)
return u_response(resp)
@router.get(
path="/refresh/calendar",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def refresh_calendar():
with TorrentManager() as manager:
resp = await manager.refresh_calendar()
return u_response(resp)
@router.get(
"/reset/all", response_model=APIResponse, dependencies=[Depends(get_current_user)]
)
async def reset_all():
with TorrentManager() as manager:
manager.bangumi.delete_all()
return JSONResponse(
status_code=200,
content={"msg_en": "Reset all rules successfully.", "msg_zh": "重置所有规则成功。"},
)
@router.patch(
path="/archive/{bangumi_id}",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def archive_rule(bangumi_id: int):
"""Archive a bangumi."""
with TorrentManager() as manager:
resp = manager.archive_rule(bangumi_id)
return u_response(resp)
@router.patch(
path="/unarchive/{bangumi_id}",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def unarchive_rule(bangumi_id: int):
"""Unarchive a bangumi."""
with TorrentManager() as manager:
resp = manager.unarchive_rule(bangumi_id)
return u_response(resp)
@router.get(
path="/refresh/metadata",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def refresh_metadata():
"""Refresh TMDB metadata and auto-archive ended series."""
with TorrentManager() as manager:
resp = await manager.refresh_metadata()
return u_response(resp)
@router.get(
path="/suggest-offset/{bangumi_id}",
response_model=OffsetSuggestion,
dependencies=[Depends(get_current_user)],
)
async def suggest_offset(bangumi_id: int):
"""Suggest offset based on TMDB episode counts."""
with TorrentManager() as manager:
resp = await manager.suggest_offset(bangumi_id)
return resp
@router.post(
path="/detect-offset",
response_model=DetectOffsetResponse,
dependencies=[Depends(get_current_user)],
)
async def detect_offset(request: DetectOffsetRequest):
"""Detect season/episode mismatch with TMDB data.
Called by frontend before adding/subscribing to check if offsets are needed.
"""
language = settings.rss_parser.language
tmdb_info = await tmdb_parser(request.title, language)
if not tmdb_info:
return DetectOffsetResponse(
has_mismatch=False,
suggestion=None,
tmdb_info=None,
)
# Detect mismatch
suggestion = detect_offset_mismatch(
parsed_season=request.parsed_season,
parsed_episode=request.parsed_episode,
tmdb_info=tmdb_info,
)
# Build TMDB summary
tmdb_summary = TMDBSummary(
title=tmdb_info.title,
total_seasons=tmdb_info.last_season,
season_episode_counts=tmdb_info.season_episode_counts or {},
status=tmdb_info.series_status,
virtual_season_starts=tmdb_info.virtual_season_starts,
)
if suggestion:
return DetectOffsetResponse(
has_mismatch=True,
suggestion=OffsetSuggestionDetail(
season_offset=suggestion.season_offset,
episode_offset=suggestion.episode_offset,
reason=suggestion.reason,
confidence=suggestion.confidence,
),
tmdb_info=tmdb_summary,
)
return DetectOffsetResponse(
has_mismatch=False,
suggestion=None,
tmdb_info=tmdb_summary,
)
@router.post(
path="/dismiss-review/{bangumi_id}",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def dismiss_review(bangumi_id: int):
"""Clear the needs_review flag for a bangumi after user reviews."""
with Database() as db:
success = db.bangumi.clear_needs_review(bangumi_id)
if success:
return JSONResponse(
status_code=200,
content={
"status": True,
"msg_en": "Review dismissed.",
"msg_zh": "已取消检查标记。",
},
)
else:
return JSONResponse(
status_code=404,
content={
"status": False,
"msg_en": f"Bangumi {bangumi_id} not found.",
"msg_zh": f"未找到番剧 {bangumi_id}。",
},
)
@router.get(
path="/needs-review",
response_model=list[Bangumi],
dependencies=[Depends(get_current_user)],
)
async def get_needs_review():
"""Get all bangumi that need review for offset mismatch."""
with Database() as db:
return db.bangumi.get_needs_review()
@router.patch(
path="/{bangumi_id}/weekday",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def set_weekday(bangumi_id: int, request: SetWeekdayRequest):
"""Manually set the broadcast weekday for a bangumi."""
if request.weekday is not None and not (0 <= request.weekday <= 6):
return JSONResponse(
status_code=400,
content={
"status": False,
"msg_en": "Weekday must be 0-6 (Mon-Sun) or null.",
"msg_zh": "星期必须是 0-6(周一至周日)或空。",
},
)
with Database() as db:
success = db.bangumi.set_weekday(bangumi_id, request.weekday)
if success:
action = f"weekday {request.weekday}" if request.weekday is not None else "unknown"
return JSONResponse(
status_code=200,
content={
"status": True,
"msg_en": f"Set bangumi to {action}.",
"msg_zh": f"已设置放送日为 {action}。",
},
)
return JSONResponse(
status_code=404,
content={
"status": False,
"msg_en": f"Bangumi {bangumi_id} not found.",
"msg_zh": f"未找到番剧 {bangumi_id}。",
},
)
================================================
FILE: backend/src/module/api/config.py
================================================
import logging
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from module.conf import settings
from module.models import APIResponse, Config
from module.security.api import UNAUTHORIZED, get_current_user
router = APIRouter(prefix="/config", tags=["config"])
logger = logging.getLogger(__name__)
_SENSITIVE_KEYS = ("password", "api_key", "token", "secret")
_MASK = "********"
def _is_sensitive(key: str) -> bool:
return any(s in key.lower() for s in _SENSITIVE_KEYS)
def _sanitize_dict(d: dict) -> dict:
"""Recursively mask string values whose keys contain sensitive keywords."""
result = {}
for k, v in d.items():
if isinstance(v, dict):
result[k] = _sanitize_dict(v)
elif isinstance(v, list):
result[k] = [
_sanitize_dict(item) if isinstance(item, dict) else item for item in v
]
elif isinstance(v, str) and _is_sensitive(k):
result[k] = _MASK
else:
result[k] = v
return result
def _restore_masked(incoming: dict, current: dict) -> dict:
"""Replace masked sentinel values with real values from current config."""
for k, v in incoming.items():
if isinstance(v, dict) and isinstance(current.get(k), dict):
_restore_masked(v, current[k])
elif isinstance(v, list) and isinstance(current.get(k), list):
cur_list = current[k]
for i, item in enumerate(v):
if (
isinstance(item, dict)
and i < len(cur_list)
and isinstance(cur_list[i], dict)
):
_restore_masked(item, cur_list[i])
elif v == _MASK and _is_sensitive(k):
incoming[k] = current.get(k, v)
return incoming
@router.get("/get", dependencies=[Depends(get_current_user)])
async def get_config():
"""Return the current configuration with sensitive fields masked."""
return _sanitize_dict(settings.dict())
@router.patch(
"/update", response_model=APIResponse, dependencies=[Depends(get_current_user)]
)
async def update_config(config: Config):
"""Persist and reload configuration from the supplied payload."""
try:
config_dict = _restore_masked(config.dict(), settings.dict())
settings.save(config_dict=config_dict)
settings.load()
# update_rss()
logger.info("Config updated")
return JSONResponse(
status_code=200,
content={
"msg_en": "Update config successfully.",
"msg_zh": "更新配置成功。",
},
)
except Exception as e:
logger.warning(e)
return JSONResponse(
status_code=406,
content={"msg_en": "Update config failed.", "msg_zh": "更新配置失败。"},
)
================================================
FILE: backend/src/module/api/downloader.py
================================================
import logging
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from module.database import Database
from module.downloader import DownloadClient
from module.security.api import get_current_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/downloader", tags=["downloader"])
class TorrentHashesRequest(BaseModel):
hashes: list[str]
class TorrentDeleteRequest(BaseModel):
hashes: list[str]
delete_files: bool = False
class TorrentTagRequest(BaseModel):
"""Request to tag a torrent with a bangumi ID."""
hash: str
bangumi_id: int
@router.get("/torrents", dependencies=[Depends(get_current_user)])
async def get_torrents():
async with DownloadClient() as client:
return await client.get_torrent_info(category="Bangumi", status_filter=None)
@router.post("/torrents/pause", dependencies=[Depends(get_current_user)])
async def pause_torrents(req: TorrentHashesRequest):
hashes = "|".join(req.hashes)
async with DownloadClient() as client:
await client.pause_torrent(hashes)
return {"msg_en": "Torrents paused", "msg_zh": "种子已暂停"}
@router.post("/torrents/resume", dependencies=[Depends(get_current_user)])
async def resume_torrents(req: TorrentHashesRequest):
hashes = "|".join(req.hashes)
async with DownloadClient() as client:
await client.resume_torrent(hashes)
return {"msg_en": "Torrents resumed", "msg_zh": "种子已恢复"}
@router.post("/torrents/delete", dependencies=[Depends(get_current_user)])
async def delete_torrents(req: TorrentDeleteRequest):
hashes = "|".join(req.hashes)
async with DownloadClient() as client:
await client.delete_torrent(hashes, delete_files=req.delete_files)
return {"msg_en": "Torrents deleted", "msg_zh": "种子已删除"}
@router.post("/torrents/tag", dependencies=[Depends(get_current_user)])
async def tag_torrent(req: TorrentTagRequest):
"""Tag a torrent with a bangumi ID for accurate offset lookup.
This adds the 'ab:ID' tag to the torrent in qBittorrent, which allows
the renamer to look up the correct episode/season offset.
"""
# Verify bangumi exists
with Database() as db:
bangumi = db.bangumi.search_id(req.bangumi_id)
if not bangumi:
return {
"status": False,
"msg_en": f"Bangumi {req.bangumi_id} not found",
"msg_zh": f"未找到番剧 {req.bangumi_id}",
}
tag = f"ab:{req.bangumi_id}"
async with DownloadClient() as client:
await client.add_tag(req.hash, tag)
return {
"status": True,
"msg_en": f"Tagged torrent with {tag}",
"msg_zh": f"已为种子添加标签 {tag}",
}
@router.post("/torrents/tag/auto", dependencies=[Depends(get_current_user)])
async def auto_tag_torrents():
"""Auto-tag all untagged Bangumi torrents based on name/path matching.
This helps fix torrents that were added before tagging was implemented.
Returns the number of torrents tagged and any that couldn't be matched.
"""
tagged_count = 0
unmatched = []
async with DownloadClient() as client:
# Get all Bangumi torrents
torrents = await client.get_torrent_info(category="Bangumi", status_filter=None)
with Database() as db:
for torrent in torrents:
torrent_hash = torrent["hash"]
torrent_name = torrent["name"]
save_path = torrent["save_path"]
tags = torrent.get("tags", "")
# Skip if already has ab: tag
if "ab:" in tags:
continue
# Try to match bangumi
bangumi = None
# First try by torrent name
bangumi = db.bangumi.match_torrent(torrent_name)
# Then try by save_path
if not bangumi:
bangumi = db.bangumi.match_by_save_path(save_path)
if bangumi and not bangumi.deleted:
tag = f"ab:{bangumi.id}"
await client.add_tag(torrent_hash, tag)
tagged_count += 1
logger.info(
f"[AutoTag] Tagged '{torrent_name[:50]}...' with {tag} "
f"(matched: {bangumi.official_title})"
)
else:
unmatched.append({
"hash": torrent_hash,
"name": torrent_name,
"save_path": save_path,
})
return {
"status": True,
"tagged_count": tagged_count,
"unmatched_count": len(unmatched),
"unmatched": unmatched[:10], # Return first 10 unmatched for debugging
"msg_en": f"Tagged {tagged_count} torrents, {len(unmatched)} could not be matched",
"msg_zh": f"已标记 {tagged_count} 个种子,{len(unmatched)} 个无法匹配",
}
================================================
FILE: backend/src/module/api/log.py
================================================
from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi.responses import JSONResponse
from module.conf import LOG_PATH
from module.models import APIResponse
from module.security.api import UNAUTHORIZED, get_current_user
router = APIRouter(prefix="/log", tags=["log"])
_TAIL_BYTES = 512 * 1024 # 512 KB
@router.get("", response_model=str, dependencies=[Depends(get_current_user)])
async def get_log():
if LOG_PATH.exists():
with open(LOG_PATH, "rb") as f:
f.seek(0, 2)
size = f.tell()
if size > _TAIL_BYTES:
f.seek(-_TAIL_BYTES, 2)
data = f.read()
# Drop first partial line
idx = data.find(b"\n")
if idx != -1:
data = data[idx + 1 :]
else:
f.seek(0)
data = f.read()
return Response(data, media_type="text/plain")
else:
return Response("Log file not found", status_code=404)
@router.get(
"/clear", response_model=APIResponse, dependencies=[Depends(get_current_user)]
)
async def clear_log():
if LOG_PATH.exists():
LOG_PATH.write_text("")
return JSONResponse(
status_code=200,
content={"msg_en": "Log cleared successfully.", "msg_zh": "日志清除成功。"},
)
else:
return JSONResponse(
status_code=406,
content={"msg_en": "Log file not found.", "msg_zh": "日志文件未找到。"},
)
================================================
FILE: backend/src/module/api/notification.py
================================================
"""Notification API endpoints."""
import logging
from typing import Optional
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from module.models.config import NotificationProvider as ProviderConfig
from module.notification import NotificationManager
from module.security.api import get_current_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/notification", tags=["notification"])
class TestProviderRequest(BaseModel):
"""Request body for testing a saved provider by index."""
provider_index: int = Field(..., description="Index of the provider to test")
class TestProviderConfigRequest(BaseModel):
"""Request body for testing an unsaved provider configuration."""
type: str = Field(..., description="Provider type")
enabled: bool = Field(True, description="Whether provider is enabled")
token: Optional[str] = Field(None, description="Auth token")
chat_id: Optional[str] = Field(None, description="Chat/channel ID")
webhook_url: Optional[str] = Field(None, description="Webhook URL")
server_url: Optional[str] = Field(None, description="Server URL")
device_key: Optional[str] = Field(None, description="Device key")
user_key: Optional[str] = Field(None, description="User key")
api_token: Optional[str] = Field(None, description="API token")
template: Optional[str] = Field(None, description="Custom template")
url: Optional[str] = Field(None, description="URL for generic webhook")
class TestResponse(BaseModel):
"""Response for test notification endpoints."""
success: bool
message: str
message_zh: str = ""
message_en: str = ""
@router.post(
"/test", response_model=TestResponse, dependencies=[Depends(get_current_user)]
)
async def test_provider(request: TestProviderRequest):
"""Test a configured notification provider by its index.
Sends a test notification using the provider at the specified index
in the current configuration.
"""
try:
manager = NotificationManager()
if request.provider_index >= len(manager):
return TestResponse(
success=False,
message=f"Invalid provider index: {request.provider_index}",
message_zh=f"无效的提供者索引: {request.provider_index}",
message_en=f"Invalid provider index: {request.provider_index}",
)
success, message = await manager.test_provider(request.provider_index)
return TestResponse(
success=success,
message=message,
message_zh="测试成功" if success else f"测试失败: {message}",
message_en="Test successful" if success else f"Test failed: {message}",
)
except Exception as e:
logger.error(f"Failed to test provider: {e}")
return TestResponse(
success=False,
message=str(e),
message_zh=f"测试失败: {e}",
message_en=f"Test failed: {e}",
)
@router.post(
"/test-config",
response_model=TestResponse,
dependencies=[Depends(get_current_user)],
)
async def test_provider_config(request: TestProviderConfigRequest):
"""Test an unsaved notification provider configuration.
Useful for testing a provider before saving it to the configuration.
"""
try:
# Convert request to ProviderConfig
config = ProviderConfig(
type=request.type,
enabled=request.enabled,
token=request.token or "",
chat_id=request.chat_id or "",
webhook_url=request.webhook_url or "",
server_url=request.server_url or "",
device_key=request.device_key or "",
user_key=request.user_key or "",
api_token=request.api_token or "",
template=request.template,
url=request.url or "",
)
success, message = await NotificationManager.test_provider_config(config)
return TestResponse(
success=success,
message=message,
message_zh="测试成功" if success else f"测试失败: {message}",
message_en="Test successful" if success else f"Test failed: {message}",
)
except Exception as e:
logger.error(f"Failed to test provider config: {e}")
return TestResponse(
success=False,
message=str(e),
message_zh=f"测试失败: {e}",
message_en=f"Test failed: {e}",
)
================================================
FILE: backend/src/module/api/passkey.py
================================================
"""
Passkey 管理 API
用于注册、列表、删除 Passkey 凭证
"""
import logging
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, Response
from sqlmodel import select
from module.database.engine import async_session_factory
from module.database.passkey import PasskeyDatabase
from module.models import APIResponse
from module.models.passkey import (
PasskeyAuthFinish,
PasskeyAuthStart,
PasskeyCreate,
PasskeyDelete,
PasskeyList,
)
from module.models.user import User
from module.security.api import active_user, get_current_user
from module.security.auth_strategy import PasskeyAuthStrategy
from module.security.jwt import create_access_token
from module.security.webauthn import get_webauthn_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/passkey", tags=["passkey"])
def _get_webauthn_from_request(request: Request):
"""
从请求中构造 WebAuthnService
优先使用浏览器的 Origin header(与 clientDataJSON 中的 origin 一致)
"""
from urllib.parse import urlparse
origin = request.headers.get("origin")
if not origin:
# Fallback: 从 Referer 或 Host 推断
referer = request.headers.get("referer", "")
if referer:
parsed = urlparse(referer)
origin = f"{parsed.scheme}://{parsed.netloc}"
else:
host = request.headers.get("host", "localhost:7892")
forwarded_proto = request.headers.get("x-forwarded-proto")
scheme = forwarded_proto if forwarded_proto else request.url.scheme
origin = f"{scheme}://{host}"
parsed_origin = urlparse(origin)
rp_id = parsed_origin.hostname or "localhost"
return get_webauthn_service(rp_id, "AutoBangumi", origin)
# ============ 注册流程 ============
@router.post("/register/options", response_model=dict)
async def get_registration_options(
request: Request,
username: str = Depends(get_current_user),
):
"""
生成 Passkey 注册选项
前端调用 navigator.credentials.create() 时使用
"""
webauthn = _get_webauthn_from_request(request)
async with async_session_factory() as session:
try:
# Get user
result = await session.execute(
select(User).where(User.username == username)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Get existing passkeys
passkey_db = PasskeyDatabase(session)
existing_passkeys = await passkey_db.get_passkeys_by_user_id(user.id)
options = webauthn.generate_registration_options(
username=username,
user_id=user.id,
existing_passkeys=existing_passkeys,
)
return options
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to generate registration options: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/register/verify", response_model=APIResponse)
async def verify_registration(
passkey_data: PasskeyCreate,
request: Request,
username: str = Depends(get_current_user),
):
"""
验证 Passkey 注册响应并保存
"""
webauthn = _get_webauthn_from_request(request)
async with async_session_factory() as session:
try:
# Get user
result = await session.execute(
select(User).where(User.username == username)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# 验证 WebAuthn 响应
passkey = webauthn.verify_registration(
username=username,
credential=passkey_data.attestation_response,
device_name=passkey_data.name,
)
# 设置 user_id 并保存
passkey.user_id = user.id
passkey_db = PasskeyDatabase(session)
await passkey_db.create_passkey(passkey)
return JSONResponse(
status_code=200,
content={
"msg_en": f"Passkey '{passkey_data.name}' registered successfully",
"msg_zh": f"Passkey '{passkey_data.name}' 注册成功",
},
)
except ValueError as e:
logger.warning(f"Registration verification failed for {username}: {e}")
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to register passkey: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============ 认证流程 ============
@router.post("/auth/options", response_model=dict)
async def get_passkey_login_options(
auth_data: PasskeyAuthStart,
request: Request,
):
"""
生成 Passkey 登录选项(challenge)
前端先调用此接口,再调用 navigator.credentials.get()
如果提供 username,返回该用户的 passkey 列表(allowCredentials)
如果不提供 username,返回可发现凭证选项(浏览器显示所有可用 passkey)
"""
webauthn = _get_webauthn_from_request(request)
# Discoverable credentials mode (no username)
if not auth_data.username:
try:
options = webauthn.generate_discoverable_authentication_options()
return options
except Exception as e:
logger.error(f"Failed to generate discoverable login options: {e}")
raise HTTPException(status_code=500, detail=str(e))
# Username-based mode
async with async_session_factory() as session:
try:
# Get user
result = await session.execute(
select(User).where(User.username == auth_data.username)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
passkey_db = PasskeyDatabase(session)
passkeys = await passkey_db.get_passkeys_by_user_id(user.id)
if not passkeys:
raise HTTPException(
status_code=400, detail="No passkeys registered for this user"
)
options = webauthn.generate_authentication_options(
auth_data.username, passkeys
)
return options
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to generate login options: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/auth/verify", response_model=dict)
async def login_with_passkey(
auth_data: PasskeyAuthFinish,
response: Response,
request: Request,
):
"""
使用 Passkey 登录(替代密码登录)
如果提供 username,验证 passkey 属于该用户
如果不提供 username(可发现凭证模式),从 credential 中提取用户信息
"""
webauthn = _get_webauthn_from_request(request)
strategy = PasskeyAuthStrategy(webauthn)
resp = await strategy.authenticate(auth_data.username, auth_data.credential)
if resp.status:
# Get username from response (may be discovered from credential)
username = resp.data.get("username") if resp.data else auth_data.username
if not username:
raise HTTPException(status_code=500, detail="Failed to determine username")
token = create_access_token(
data={"sub": username}, expires_delta=timedelta(days=1)
)
response.set_cookie(key="token", value=token, httponly=True, max_age=86400)
active_user[username] = datetime.now()
return {"access_token": token, "token_type": "bearer"}
raise HTTPException(status_code=resp.status_code, detail=resp.msg_en)
# ============ Passkey 管理 ============
@router.get("/list", response_model=list[PasskeyList])
async def list_passkeys(username: str = Depends(get_current_user)):
"""获取用户的所有 Passkey"""
async with async_session_factory() as session:
try:
# Get user
result = await session.execute(
select(User).where(User.username == username)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
passkey_db = PasskeyDatabase(session)
passkeys = await passkey_db.get_passkeys_by_user_id(user.id)
return [passkey_db.to_list_model(pk) for pk in passkeys]
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to list passkeys: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/delete", response_model=APIResponse)
async def delete_passkey(
delete_data: PasskeyDelete,
username: str = Depends(get_current_user),
):
"""删除 Passkey"""
async with async_session_factory() as session:
try:
# Get user
result = await session.execute(
select(User).where(User.username == username)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
passkey_db = PasskeyDatabase(session)
await passkey_db.delete_passkey(delete_data.passkey_id, user.id)
return JSONResponse(
status_code=200,
content={
"msg_en": "Passkey deleted successfully",
"msg_zh": "Passkey 删除成功",
},
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete passkey: {e}")
raise HTTPException(status_code=500, detail=str(e))
================================================
FILE: backend/src/module/api/program.py
================================================
import logging
import os
import signal
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
from module.conf import VERSION
from module.core import Program
from module.models import APIResponse
from module.security.api import UNAUTHORIZED, get_current_user
from .response import u_response
logger = logging.getLogger(__name__)
program = Program()
router = APIRouter(tags=["program"])
# Note: Lifespan events (startup/shutdown) are now handled in main.py via lifespan context manager
@router.get(
"/restart", response_model=APIResponse, dependencies=[Depends(get_current_user)]
)
async def restart():
try:
resp = await program.restart()
return u_response(resp)
except Exception as e:
logger.debug(e)
logger.warning("Failed to restart program")
raise HTTPException(
status_code=500,
detail={
"msg_en": "Failed to restart program.",
"msg_zh": "重启程序失败。",
},
)
@router.get(
"/start", response_model=APIResponse, dependencies=[Depends(get_current_user)]
)
async def start():
try:
resp = await program.start()
return u_response(resp)
except Exception as e:
logger.debug(e)
logger.warning("Failed to start program")
raise HTTPException(
status_code=500,
detail={
"msg_en": "Failed to start program.",
"msg_zh": "启动程序失败。",
},
)
@router.get(
"/stop", response_model=APIResponse, dependencies=[Depends(get_current_user)]
)
async def stop():
resp = await program.stop()
return u_response(resp)
@router.get("/status", response_model=dict, dependencies=[Depends(get_current_user)])
async def program_status():
if not program.is_running:
return {
"status": False,
"version": VERSION,
"first_run": program.first_run,
}
else:
return {
"status": True,
"version": VERSION,
"first_run": program.first_run,
}
@router.get(
"/shutdown", response_model=APIResponse, dependencies=[Depends(get_current_user)]
)
async def shutdown_program():
await program.stop()
logger.info("Shutting down program...")
os.kill(os.getpid(), signal.SIGINT)
return JSONResponse(
status_code=200,
content={
"msg_en": "Shutdown program successfully.",
"msg_zh": "关闭程序成功。",
},
)
# Check status
@router.get(
"/check/downloader",
tags=["check"],
response_model=bool,
dependencies=[Depends(get_current_user)],
)
async def check_downloader_status():
return await program.check_downloader()
================================================
FILE: backend/src/module/api/response.py
================================================
from fastapi.exceptions import HTTPException
from fastapi.responses import JSONResponse
from module.models.response import ResponseModel
def u_response(response_model: ResponseModel):
return JSONResponse(
status_code=response_model.status_code,
content={
"msg_en": response_model.msg_en,
"msg_zh": response_model.msg_zh,
},
)
================================================
FILE: backend/src/module/api/rss.py
================================================
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from module.downloader import DownloadClient
from module.manager import SeasonCollector
from module.models import APIResponse, Bangumi, RSSItem, RSSUpdate, Torrent
from module.rss import RSSAnalyser, RSSEngine
from module.security.api import UNAUTHORIZED, get_current_user
from .response import u_response
router = APIRouter(prefix="/rss", tags=["rss"])
@router.get(
path="", response_model=list[RSSItem], dependencies=[Depends(get_current_user)]
)
async def get_rss():
with RSSEngine() as engine:
return engine.rss.search_all()
@router.post(
path="/add", response_model=APIResponse, dependencies=[Depends(get_current_user)]
)
async def add_rss(rss: RSSItem):
with RSSEngine() as engine:
result = await engine.add_rss(rss.url, rss.name, rss.aggregate, rss.parser)
return u_response(result)
@router.post(
path="/enable/many",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def enable_many_rss(
rss_ids: list[int],
):
with RSSEngine() as engine:
result = engine.enable_list(rss_ids)
return u_response(result)
@router.delete(
path="/delete/{rss_id}",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def delete_rss(rss_id: int):
with RSSEngine() as engine:
if engine.rss.delete(rss_id):
return JSONResponse(
status_code=200,
content={"msg_en": "Delete RSS successfully.", "msg_zh": "删除 RSS 成功。"},
)
else:
return JSONResponse(
status_code=406,
content={"msg_en": "Delete RSS failed.", "msg_zh": "删除 RSS 失败。"},
)
@router.post(
path="/delete/many",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def delete_many_rss(
rss_ids: list[int],
):
with RSSEngine() as engine:
result = engine.delete_list(rss_ids)
return u_response(result)
@router.patch(
path="/disable/{rss_id}",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def disable_rss(rss_id: int):
with RSSEngine() as engine:
if engine.rss.disable(rss_id):
return JSONResponse(
status_code=200,
content={"msg_en": "Disable RSS successfully.", "msg_zh": "禁用 RSS 成功。"},
)
else:
return JSONResponse(
status_code=406,
content={"msg_en": "Disable RSS failed.", "msg_zh": "禁用 RSS 失败。"},
)
@router.post(
path="/disable/many",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def disable_many_rss(rss_ids: list[int]):
with RSSEngine() as engine:
result = engine.disable_list(rss_ids)
return u_response(result)
@router.patch(
path="/update/{rss_id}",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def update_rss(
rss_id: int, data: RSSUpdate, current_user=Depends(get_current_user)
):
if not current_user:
raise UNAUTHORIZED
with RSSEngine() as engine:
if engine.rss.update(rss_id, data):
return JSONResponse(
status_code=200,
content={"msg_en": "Update RSS successfully.", "msg_zh": "更新 RSS 成功。"},
)
else:
return JSONResponse(
status_code=406,
content={"msg_en": "Update RSS failed.", "msg_zh": "更新 RSS 失败。"},
)
@router.get(
path="/refresh/all",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def refresh_all():
async with DownloadClient() as client:
with RSSEngine() as engine:
await engine.refresh_rss(client)
return JSONResponse(
status_code=200,
content={"msg_en": "Refresh all RSS successfully.", "msg_zh": "刷新 RSS 成功。"},
)
@router.get(
path="/refresh/{rss_id}",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def refresh_rss(rss_id: int):
async with DownloadClient() as client:
with RSSEngine() as engine:
await engine.refresh_rss(client, rss_id)
return JSONResponse(
status_code=200,
content={"msg_en": "Refresh RSS successfully.", "msg_zh": "刷新 RSS 成功。"},
)
@router.get(
path="/torrent/{rss_id}",
response_model=list[Torrent],
dependencies=[Depends(get_current_user)],
)
async def get_torrent(
rss_id: int,
):
with RSSEngine() as engine:
return engine.get_rss_torrents(rss_id)
# Old API
analyser = RSSAnalyser()
@router.post(
"/analysis", response_model=Bangumi, dependencies=[Depends(get_current_user)]
)
async def analysis(rss: RSSItem):
data = await analyser.link_to_data(rss)
if isinstance(data, Bangumi):
return data
else:
return u_response(data)
@router.post(
"/collect", response_model=APIResponse, dependencies=[Depends(get_current_user)]
)
async def download_collection(data: Bangumi):
async with SeasonCollector() as collector:
resp = await collector.collect_season(data, data.rss_link)
return u_response(resp)
@router.post(
"/subscribe", response_model=APIResponse, dependencies=[Depends(get_current_user)]
)
async def subscribe(data: Bangumi, rss: RSSItem):
resp = await SeasonCollector.subscribe_season(data, parser=rss.parser)
return u_response(resp)
================================================
FILE: backend/src/module/api/search.py
================================================
from fastapi import APIRouter, Depends, Query
from sse_starlette.sse import EventSourceResponse
from module.conf.search_provider import get_provider, save_provider
from module.models import Bangumi
from module.searcher import SEARCH_CONFIG, SearchTorrent
from module.security.api import UNAUTHORIZED, get_current_user
router = APIRouter(prefix="/search", tags=["search"])
@router.get(
"/bangumi", response_model=list[Bangumi], dependencies=[Depends(get_current_user)]
)
async def search_torrents(site: str = "mikan", keywords: str = Query(None)):
"""
Server Send Event for per Bangumi item
"""
if not keywords:
return []
keywords = keywords.split(" ")
async def event_generator():
async with SearchTorrent() as st:
async for item in st.analyse_keyword(keywords=keywords, site=site):
yield item
return EventSourceResponse(content=event_generator())
@router.get(
"/provider", response_model=list[str], dependencies=[Depends(get_current_user)]
)
async def search_provider():
return list(SEARCH_CONFIG.keys())
@router.get(
"/provider/config",
response_model=dict[str, str],
dependencies=[Depends(get_current_user)],
)
async def get_search_provider_config():
"""Get all search providers with their URL templates."""
return get_provider()
@router.put(
"/provider/config",
response_model=dict[str, str],
dependencies=[Depends(get_current_user)],
)
async def update_search_provider_config(providers: dict[str, str]):
"""Update search providers configuration."""
save_provider(providers)
return get_provider()
================================================
FILE: backend/src/module/api/setup.py
================================================
import ipaddress
import logging
import socket
from pathlib import Path
from urllib.parse import urlparse
import httpx
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from module.conf import VERSION, settings
from module.models import Config, ResponseModel
from module.models.config import NotificationProvider as ProviderConfig
from module.network import RequestContent
from module.notification import PROVIDER_REGISTRY
from module.security.jwt import get_password_hash
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/setup", tags=["setup"])
SENTINEL_PATH = Path("config/.setup_complete")
def _require_setup_needed():
"""Guard: raise 403 if setup is already completed."""
if SENTINEL_PATH.exists():
raise HTTPException(status_code=403, detail="Setup already completed.")
# Allow setup in dev mode even if settings differ
if VERSION != "DEV_VERSION" and settings.dict() != Config().dict():
raise HTTPException(status_code=403, detail="Setup already completed.")
def _validate_url(url: str) -> None:
"""Reject non-HTTP schemes and private/reserved/loopback IPs."""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise HTTPException(status_code=400, detail="Only http/https URLs are allowed.")
hostname = parsed.hostname
if not hostname:
raise HTTPException(status_code=400, detail="Invalid URL: no hostname.")
try:
addrs = socket.getaddrinfo(hostname, None)
except socket.gaierror:
raise HTTPException(status_code=400, detail="Cannot resolve hostname.")
for family, _, _, _, sockaddr in addrs:
ip = ipaddress.ip_address(sockaddr[0])
if ip.is_private or ip.is_reserved or ip.is_loopback:
raise HTTPException(
status_code=400,
detail="URLs pointing to private/reserved IPs are not allowed.",
)
# --- Request/Response Models ---
class SetupStatusResponse(BaseModel):
need_setup: bool
version: str
class TestDownloaderRequest(BaseModel):
type: str = Field("qbittorrent")
host: str
username: str
password: str
ssl: bool = False
class TestRSSRequest(BaseModel):
url: str
class TestNotificationRequest(BaseModel):
type: str
token: str
chat_id: str = ""
class TestResultResponse(BaseModel):
success: bool
message_en: str
message_zh: str
title: str | None = None
item_count: int | None = None
class SetupCompleteRequest(BaseModel):
username: str = Field(..., min_length=4, max_length=20)
password: str = Field(..., min_length=8)
downloader_type: str = Field("qbittorrent")
downloader_host: str
downloader_username: str
downloader_password: str
downloader_path: str = Field("/downloads/Bangumi")
downloader_ssl: bool = False
rss_url: str = ""
rss_name: str = ""
notification_enable: bool = False
notification_type: str = "telegram"
notification_token: str = ""
notification_chat_id: str = ""
# --- Endpoints ---
@router.get("/status", response_model=SetupStatusResponse)
async def get_setup_status():
"""Check whether the setup wizard is needed."""
# In dev mode, always allow setup wizard for testing
if VERSION == "DEV_VERSION":
need_setup = not SENTINEL_PATH.exists()
else:
need_setup = not SENTINEL_PATH.exists() and settings.dict() == Config().dict()
return SetupStatusResponse(need_setup=need_setup, version=VERSION)
@router.post("/test-downloader", response_model=TestResultResponse)
async def test_downloader(req: TestDownloaderRequest):
"""Test connection to the download client."""
_require_setup_needed()
# Support mock mode for development
if req.type == "mock":
return TestResultResponse(
success=True,
message_en="Mock downloader enabled.",
message_zh="已启用模拟下载器。",
)
scheme = "https" if req.ssl else "http"
host = req.host if "://" in req.host else f"{scheme}://{req.host}"
try:
async with httpx.AsyncClient(timeout=5.0) as client:
# Check if host is reachable and is qBittorrent
resp = await client.get(host)
if (
"qbittorrent" not in resp.text.lower()
and "vuetorrent" not in resp.text.lower()
):
return TestResultResponse(
success=False,
message_en="Host is reachable but does not appear to be qBittorrent.",
message_zh="主机可达但似乎不是 qBittorrent。",
)
# Try to authenticate
login_url = f"{host}/api/v2/auth/login"
login_resp = await client.post(
login_url,
data={"username": req.username, "password": req.password},
)
if login_resp.status_code == 200 and "ok" in login_resp.text.lower():
return TestResultResponse(
success=True,
message_en="Connection successful.",
message_zh="连接成功。",
)
elif login_resp.status_code == 403:
return TestResultResponse(
success=False,
message_en="Authentication failed: IP is banned by qBittorrent.",
message_zh="认证失败:IP 被 qBittorrent 封禁。",
)
else:
return TestResultResponse(
success=False,
message_en="Authentication failed: incorrect username or password.",
message_zh="认证失败:用户名或密码错误。",
)
except httpx.TimeoutException:
return TestResultResponse(
success=False,
message_en="Connection timed out.",
message_zh="连接超时。",
)
except httpx.ConnectError:
return TestResultResponse(
success=False,
message_en="Cannot connect to the host.",
message_zh="无法连接到主机。",
)
except Exception as e:
logger.error(f"[Setup] Downloader test failed: {e}")
return TestResultResponse(
success=False,
message_en=f"Connection failed: {e}",
message_zh=f"连接失败:{e}",
)
@router.post("/test-rss", response_model=TestResultResponse)
async def test_rss(req: TestRSSRequest):
"""Test an RSS feed URL."""
_require_setup_needed()
_validate_url(req.url)
try:
async with RequestContent() as request:
soup = await request.get_xml(req.url)
if soup is None:
return TestResultResponse(
success=False,
message_en="Failed to fetch or parse the RSS feed.",
message_zh="无法获取或解析 RSS 源。",
)
title = soup.find("./channel/title")
title_text = title.text if title is not None else None
items = soup.findall("./channel/item")
return TestResultResponse(
success=True,
message_en="RSS feed is valid.",
message_zh="RSS 源有效。",
title=title_text,
item_count=len(items),
)
except Exception as e:
logger.error(f"[Setup] RSS test failed: {e}")
return TestResultResponse(
success=False,
message_en=f"Failed to fetch RSS feed: {e}",
message_zh=f"获取 RSS 源失败:{e}",
)
@router.post("/test-notification", response_model=TestResultResponse)
async def test_notification(req: TestNotificationRequest):
"""Send a test notification."""
_require_setup_needed()
provider_cls = PROVIDER_REGISTRY.get(req.type.lower())
if provider_cls is None:
return TestResultResponse(
success=False,
message_en=f"Unknown notification type: {req.type}",
message_zh=f"未知的通知类型:{req.type}",
)
try:
# Create provider config
config = ProviderConfig(
type=req.type,
enabled=True,
token=req.token,
chat_id=req.chat_id,
)
provider = provider_cls(config)
async with provider:
success, message = await provider.test()
if success:
return TestResultResponse(
success=True,
message_en="Test notification sent successfully.",
message_zh="测试通知发送成功。",
)
else:
return TestResultResponse(
success=False,
message_en=f"Failed to send test notification: {message}",
message_zh=f"测试通知发送失败:{message}",
)
except Exception as e:
logger.error(f"[Setup] Notification test failed: {e}")
return TestResultResponse(
success=False,
message_en=f"Notification test failed: {e}",
message_zh=f"通知测试失败:{e}",
)
@router.post("/complete", response_model=ResponseModel)
async def complete_setup(req: SetupCompleteRequest):
"""Save all wizard configuration and mark setup as complete."""
_require_setup_needed()
try:
# 1. Update user credentials
from module.database import Database
with Database() as db:
from module.models.user import UserUpdate
db.user.update_user(
"admin",
UserUpdate(username=req.username, password=req.password),
)
# 2. Update configuration
config_dict = settings.dict()
config_dict["downloader"] = {
"type": req.downloader_type,
"host": req.downloader_host,
"username": req.downloader_username,
"password": req.downloader_password,
"path": req.downloader_path,
"ssl": req.downloader_ssl,
}
if req.notification_enable:
config_dict["notification"] = {
"enable": True,
"providers": [
{
"type": req.notification_type,
"enabled": True,
"token": req.notification_token,
"chat_id": req.notification_chat_id,
}
],
}
settings.save(config_dict)
# Reload settings in-place
config_obj = Config.parse_obj(config_dict)
settings.__dict__.update(config_obj.__dict__)
# 3. Add RSS feed if provided
if req.rss_url:
from module.rss import RSSEngine
with RSSEngine() as rss_engine:
await rss_engine.add_rss(req.rss_url, name=req.rss_name or None)
# 4. Create sentinel file
SENTINEL_PATH.parent.mkdir(parents=True, exist_ok=True)
SENTINEL_PATH.touch()
return ResponseModel(
status=True,
status_code=200,
msg_en="Setup completed successfully.",
msg_zh="设置完成。",
)
except Exception as e:
logger.error(f"[Setup] Complete failed: {e}")
return ResponseModel(
status=False,
status_code=500,
msg_en=f"Setup failed: {e}",
msg_zh=f"设置失败:{e}",
)
================================================
FILE: backend/src/module/checker/__init__.py
================================================
from .checker import Checker
================================================
FILE: backend/src/module/checker/checker.py
================================================
import logging
from pathlib import Path
import httpx
from module.conf import VERSION, settings
from module.models import Config
from module.update import version_check
logger = logging.getLogger(__name__)
_default_config_dict: dict | None = None
def _get_default_config_dict() -> dict:
global _default_config_dict
if _default_config_dict is None:
_default_config_dict = Config().dict()
return _default_config_dict
class Checker:
def __init__(self):
pass
@staticmethod
def check_renamer() -> bool:
if settings.bangumi_manage.enable:
return True
else:
return False
@staticmethod
def check_analyser() -> bool:
if settings.rss_parser.enable:
return True
else:
return False
@staticmethod
def check_first_run() -> bool:
if Path("config/.setup_complete").exists():
return False
return settings.dict() == _get_default_config_dict()
@staticmethod
def check_version() -> tuple[bool, int | None]:
return version_check()
@staticmethod
def check_database() -> bool:
db_path = Path("data/data.db")
if not db_path.exists():
return False
else:
return True
@staticmethod
async def check_downloader() -> bool:
from module.downloader import DownloadClient
# Mock downloader always succeeds
if settings.downloader.type == "mock":
logger.info("[Checker] Using MockDownloader - skipping connection check")
return True
try:
url = (
f"http://{settings.downloader.host}"
if "://" not in settings.downloader.host
else f"{settings.downloader.host}"
)
async with httpx.AsyncClient(timeout=2.0) as client:
response = await client.get(url)
if "qbittorrent" in response.text.lower() or "vuetorrent" in response.text.lower():
async with DownloadClient() as dl_client:
if dl_client.authed:
return True
else:
return False
else:
return False
except httpx.TimeoutException:
logger.error("[Checker] Downloader connect timeout.")
return False
except httpx.ConnectError:
logger.error("[Checker] Downloader connect failed.")
return False
except Exception as e:
logger.error(f"[Checker] Downloader connect failed: {e}")
return False
@staticmethod
def check_img_cache() -> bool:
img_path = Path("data/posters")
if img_path.exists():
return True
else:
img_path.mkdir()
return False
================================================
FILE: backend/src/module/conf/__init__.py
================================================
import sys
from pathlib import Path
from .config import VERSION, settings
from .log import LOG_PATH, setup_logger
from .search_provider import SEARCH_CONFIG
TMDB_API = "32b19d6a05b512190a056fa4e747cbbc"
DATA_PATH = "sqlite:///data/data.db"
LEGACY_DATA_PATH = Path("data/data.json")
VERSION_PATH = Path("config/version.info")
POSTERS_PATH = Path("data/posters")
PLATFORM = "Windows" if sys.platform == "win32" else "Unix"
================================================
FILE: backend/src/module/conf/config.py
================================================
import json
import logging
import os
from pathlib import Path
from dotenv import load_dotenv
from module.models.config import Config
from .const import DEFAULT_SETTINGS, ENV_TO_ATTR
logger = logging.getLogger(__name__)
CONFIG_ROOT = Path("config")
try:
from module.__version__ import VERSION
except ImportError:
logger.info("Can't find version info, use DEV_VERSION instead")
VERSION = "DEV_VERSION"
CONFIG_PATH = (
CONFIG_ROOT / "config_dev.json"
if VERSION == "DEV_VERSION"
else CONFIG_ROOT / "config.json"
).resolve()
class Settings(Config):
"""Runtime configuration singleton.
On construction, loads from ``CONFIG_PATH`` if the file exists (and
immediately re-saves to apply any migrations), otherwise bootstraps
defaults from environment variables via ``init()``.
Use ``settings`` module-level instance rather than instantiating directly.
"""
def __init__(self):
super().__init__()
if CONFIG_PATH.exists():
self.load()
self.save()
else:
self.init()
def load(self):
"""Load and validate configuration from ``CONFIG_PATH``, applying migrations."""
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
config = json.load(f)
config = self._migrate_old_config(config)
config_obj = Config.model_validate(config)
self.__dict__.update(config_obj.__dict__)
logger.info("Config loaded")
@staticmethod
def _migrate_old_config(config: dict) -> dict:
"""Migrate old config field names (3.1.x) to current format (3.2.x)."""
program = config.get("program", {})
# Rename sleep_time -> rss_time
if "sleep_time" in program and "rss_time" not in program:
program["rss_time"] = program.pop("sleep_time")
elif "sleep_time" in program:
program.pop("sleep_time")
# Rename times -> rename_time
if "times" in program and "rename_time" not in program:
program["rename_time"] = program.pop("times")
elif "times" in program:
program.pop("times")
# Remove deprecated data_version field
program.pop("data_version", None)
# Remove deprecated rss_parser fields
rss_parser = config.get("rss_parser", {})
for key in ("type", "custom_url", "token", "enable_tmdb"):
rss_parser.pop(key, None)
# Add security section if missing (preserves local-network MCP default)
if "security" not in config:
config["security"] = DEFAULT_SETTINGS["security"]
return config
def save(self, config_dict: dict | None = None):
"""Write configuration to ``CONFIG_PATH``. Uses current state when no dict supplied."""
if not config_dict:
config_dict = self.model_dump()
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(config_dict, f, indent=4, ensure_ascii=False)
def init(self):
"""Bootstrap a new config file from ``.env`` and environment variables."""
load_dotenv(".env")
self.__load_from_env()
self.save()
def __load_from_env(self):
"""Apply ``ENV_TO_ATTR`` mappings from the process environment to the config dict."""
config_dict = self.model_dump()
for key, section in ENV_TO_ATTR.items():
for env, attr in section.items():
if env in os.environ:
if isinstance(attr, list):
for _attr in attr:
attr_name = _attr[0] if isinstance(_attr, tuple) else _attr
config_dict[key][attr_name] = self.__val_from_env(
env, _attr
)
else:
attr_name = attr[0] if isinstance(attr, tuple) else attr
config_dict[key][attr_name] = self.__val_from_env(env, attr)
config_obj = Config.model_validate(config_dict)
self.__dict__.update(config_obj.__dict__)
logger.info("Config loaded from env")
@staticmethod
def __val_from_env(env: str, attr: tuple | str):
"""Return the environment variable value, applying the converter when attr is a tuple."""
if isinstance(attr, tuple):
return attr[1](os.environ[env])
return os.environ[env]
@property
def group_rules(self):
return self.__dict__["group_rules"]
settings = Settings()
================================================
FILE: backend/src/module/conf/const.py
================================================
# -*- encoding: utf-8 -*-
# DEFAULT_SETTINGS: factory defaults written to config.json on first run.
# ENV_TO_ATTR: maps AB_* environment variables to Config model attribute paths.
# Values are either a string attr name, a (attr_name, converter) tuple, or a
# list of such tuples when a single env var sets multiple attributes.
DEFAULT_SETTINGS = {
"program": {
"rss_time": 900,
"rename_time": 60,
"webui_port": 7892,
},
"downloader": {
"type": "qbittorrent",
"host": "172.17.0.1:8080",
"username": "admin",
"password": "adminadmin",
"path": "/downloads/Bangumi",
"ssl": False,
},
"rss_parser": {
"enable": True,
"filter": ["720", "\\d+-\\d+"],
"language": "zh",
},
"bangumi_manage": {
"enable": True,
"eps_complete": False,
"rename_method": "pn",
"group_tag": False,
"remove_bad_torrent": False,
},
"log": {
"debug_enable": False,
},
"proxy": {
"enable": False,
"type": "http",
"host": "",
"port": 0,
"username": "",
"password": "",
},
"notification": {"enable": False, "providers": []},
"experimental_openai": {
"enable": False,
"api_key": "",
"api_base": "https://api.openai.com/v1",
"api_type": "openai",
"api_version": "2023-05-15",
"model": "gpt-3.5-turbo",
"deployment_id": "",
},
"security": {
"login_whitelist": [],
"login_tokens": [],
"mcp_whitelist": [
"127.0.0.0/8",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"::1/128",
"fe80::/10",
"fc00::/7",
],
"mcp_tokens": [],
},
}
ENV_TO_ATTR = {
"program": {
"AB_INTERVAL_TIME": ("rss_time", lambda e: int(e)),
"AB_RENAME_FREQ": ("rename_time", lambda e: int(e)),
"AB_WEBUI_PORT": ("webui_port", lambda e: int(e)),
},
"downloader": {
"AB_DOWNLOADER_HOST": "host",
"AB_DOWNLOADER_USERNAME": "username",
"AB_DOWNLOADER_PASSWORD": "password",
"AB_DOWNLOAD_PATH": "path",
},
"rss_parser": {
"AB_RSS_COLLECTOR": ("enable", lambda e: e.lower() in ("true", "1", "t")),
"AB_NOT_CONTAIN": ("filter", lambda e: e.split("|")),
"AB_LANGUAGE": "language",
},
"bangumi_manage": {
"AB_RENAME": ("enable", lambda e: e.lower() in ("true", "1", "t")),
"AB_METHOD": ("rename_method", lambda e: e.lower()),
"AB_GROUP_TAG": ("group_tag", lambda e: e.lower() in ("true", "1", "t")),
"AB_EP_COMPLETE": ("eps_complete", lambda e: e.lower() in ("true", "1", "t")),
"AB_REMOVE_BAD_BT": (
"remove_bad_torrent",
lambda e: e.lower() in ("true", "1", "t"),
),
},
"log": {
"AB_DEBUG_MODE": ("debug_enable", lambda e: e.lower() in ("true", "1", "t")),
},
"proxy": {
"AB_HTTP_PROXY": [
("enable", lambda e: True),
("type", lambda e: "http"),
("host", lambda e: e.split(":")[0]),
("port", lambda e: int(e.split(":")[1])),
],
"AB_SOCKS": [
("enable", lambda e: True),
("type", lambda e: "socks"),
("host", lambda e: e.split(",")[0]),
("port", lambda e: int(e.split(",")[1])),
("username", lambda e: e.split(",")[2]),
("password", lambda e: e.split(",")[3]),
],
},
}
class BCOLORS:
"""ANSI colour helpers for terminal output."""
@staticmethod
def _(color: str, *args: str) -> str:
"""Wrap *args* in the given ANSI colour code and reset at the end."""
strings = [str(s) for s in args]
return f"{color}{', '.join(strings)}{BCOLORS.ENDC}"
HEADER = "\033[95m"
OKBLUE = "\033[94m"
OKCYAN = "\033[96m"
OKGREEN = "\033[92m"
WARNING = "\033[93m"
FAIL = "\033[91m"
ENDC = "\033[0m"
BOLD = "\033[1m"
UNDERLINE = "\033[4m"
================================================
FILE: backend/src/module/conf/log.py
================================================
import atexit
import logging
from logging.handlers import QueueHandler, QueueListener, RotatingFileHandler
from pathlib import Path
from queue import SimpleQueue
from .config import settings
LOG_ROOT = Path("data")
LOG_PATH = LOG_ROOT / "log.txt"
_listener: QueueListener | None = None
def setup_logger(level: int = logging.INFO, reset: bool = False):
global _listener
level = logging.DEBUG if settings.log.debug_enable else level
LOG_ROOT.mkdir(exist_ok=True)
if reset and LOG_PATH.exists():
LOG_PATH.unlink(missing_ok=True)
logging.addLevelName(logging.DEBUG, "DEBUG:")
logging.addLevelName(logging.INFO, "INFO:")
logging.addLevelName(logging.WARNING, "WARNING:")
LOGGING_FORMAT = "[%(asctime)s] %(levelname)-8s %(message)s"
TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
formatter = logging.Formatter(LOGGING_FORMAT, datefmt=TIME_FORMAT)
file_handler = RotatingFileHandler(
LOG_PATH, maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8"
)
file_handler.setFormatter(formatter)
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(formatter)
log_queue: SimpleQueue = SimpleQueue()
queue_handler = QueueHandler(log_queue)
_listener = QueueListener(log_queue, file_handler, stream_handler, respect_handler_level=True)
_listener.start()
atexit.register(_listener.stop)
logging.basicConfig(
level=level,
handlers=[queue_handler],
)
# Suppress verbose HTTP request logs from httpx
logging.getLogger("httpx").setLevel(logging.WARNING)
================================================
FILE: backend/src/module/conf/parse.py
================================================
import argparse
def parse():
parser = argparse.ArgumentParser(
prog="Auto Bangumi",
description="""
本项目是基于 Mikan Project、qBittorrent 的全自动追番整理下载工具。
只需要在 Mikan Project 上订阅番剧,就可以全自动追番。
并且整理完成的名称和目录可以直接被 Plex、Jellyfin 等媒体库软件识别,
无需二次刮削。""",
)
parser.add_argument("-d", "--debug", action="store_true", help="debug mode")
return parser.parse_args()
================================================
FILE: backend/src/module/conf/search_provider.py
================================================
from pathlib import Path
from module.utils import json_config
DEFAULT_PROVIDER = {
"mikan": "https://mikanani.me/RSS/Search?searchstr=%s",
"nyaa": "https://nyaa.si/?page=rss&q=%s&c=0_0&f=0",
"dmhy": "http://dmhy.org/topics/rss/rss.xml?keyword=%s",
}
PROVIDER_PATH = Path("config/search_provider.json")
def load_provider():
if PROVIDER_PATH.exists():
return json_config.load(PROVIDER_PATH)
else:
json_config.save(PROVIDER_PATH, DEFAULT_PROVIDER)
return DEFAULT_PROVIDER
def save_provider(providers: dict[str, str]):
"""Save search providers to config file and update SEARCH_CONFIG."""
global SEARCH_CONFIG
json_config.save(PROVIDER_PATH, providers)
SEARCH_CONFIG = providers
def get_provider():
"""Get current search providers config."""
return SEARCH_CONFIG
SEARCH_CONFIG = load_provider()
================================================
FILE: backend/src/module/conf/uvicorn_logging.py
================================================
from .log import LOG_PATH
logging_config = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"format": "[%(asctime)s] %(levelname)-8s %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
}
},
"handlers": {
"file": {
"class": "logging.handlers.RotatingFileHandler",
"filename": LOG_PATH,
"formatter": "default",
"encoding": "utf-8",
"maxBytes": 5242880,
"backupCount": 3,
},
"console": {
"class": "logging.StreamHandler",
"formatter": "default",
},
},
"loggers": {
"uvicorn": {
"handlers": ["console"],
"level": "INFO",
},
"uvicorn.error": {
"level": "WARNING",
},
"uvicorn.access": {
"handlers": ["console"],
"level": "DEBUG",
"propagate": False,
},
},
}
================================================
FILE: backend/src/module/core/__init__.py
================================================
from .program import Program
================================================
FILE: backend/src/module/core/offset_scanner.py
================================================
"""Background scanner for detecting season/episode offset mismatches."""
import logging
from module.conf import settings
from module.database import Database
from module.models import Bangumi
from module.parser.analyser.offset_detector import detect_offset_mismatch
from module.parser.analyser.tmdb_parser import tmdb_parser
logger = logging.getLogger(__name__)
class OffsetScanner:
"""Periodically scan bangumi for season/episode mismatches with TMDB."""
async def scan_all(self) -> int:
"""Scan all active bangumi for offset mismatches.
Returns:
Number of bangumi flagged for review.
"""
logger.info("[OffsetScanner] Starting offset scan...")
with Database() as db:
bangumi_list = db.bangumi.get_active_for_scan()
if not bangumi_list:
logger.debug("[OffsetScanner] No active bangumi to scan.")
return 0
flagged_count = 0
for bangumi in bangumi_list:
try:
if await self._check_bangumi(bangumi):
flagged_count += 1
except Exception as e:
logger.warning(
f"[OffsetScanner] Error checking {bangumi.official_title}: {e}"
)
logger.info(
f"[OffsetScanner] Scan complete. Flagged {flagged_count} bangumi for review."
)
return flagged_count
async def _check_bangumi(self, bangumi: Bangumi) -> bool:
"""Check a single bangumi for offset mismatch.
Args:
bangumi: The bangumi to check.
Returns:
True if flagged for review, False otherwise.
"""
# Skip if already needs review
if bangumi.needs_review:
logger.debug(
f"[OffsetScanner] Skipping {bangumi.official_title}: already needs review"
)
return False
# Skip if user has already configured offsets
if bangumi.season_offset != 0 or bangumi.episode_offset != 0:
logger.debug(
f"[OffsetScanner] Skipping {bangumi.official_title}: has configured offsets"
)
return False
# Get TMDB info
language = settings.rss_parser.language
tmdb_info = await tmdb_parser(bangumi.official_title, language)
if not tmdb_info:
logger.debug(
f"[OffsetScanner] Skipping {bangumi.official_title}: no TMDB info"
)
return False
# Get latest episode for this bangumi (use season as proxy since we don't track episodes)
# For now, we'll check based on the bangumi's season
parsed_episode = 1 # Default to episode 1 for season-based detection
# Detect mismatch
suggestion = detect_offset_mismatch(
parsed_season=bangumi.season,
parsed_episode=parsed_episode,
tmdb_info=tmdb_info,
)
if suggestion and suggestion.confidence in ("high", "medium"):
with Database() as db:
db.bangumi.set_needs_review(
bangumi.id,
suggestion.reason,
suggested_season_offset=suggestion.season_offset,
suggested_episode_offset=suggestion.episode_offset,
)
logger.info(
f"[OffsetScanner] Flagged {bangumi.official_title} for review: {suggestion.reason} "
f"(suggested: season={suggestion.season_offset}, episode={suggestion.episode_offset})"
)
return True
return False
async def check_single(self, bangumi_id: int) -> bool:
"""Check a single bangumi by ID.
Args:
bangumi_id: The ID of the bangumi to check.
Returns:
True if flagged for review, False otherwise.
"""
with Database() as db:
bangumi = db.bangumi.search_id(bangumi_id)
if not bangumi:
logger.warning(f"[OffsetScanner] Bangumi {bangumi_id} not found")
return False
return await self._check_bangumi(bangumi)
================================================
FILE: backend/src/module/core/program.py
================================================
import asyncio
import logging
from module.conf import VERSION, settings
from module.models import ResponseModel
from module.update import (
cache_image,
data_migration,
first_run,
from_30_to_31,
from_31_to_32,
run_migrations,
start_up,
)
from .sub_thread import CalendarRefreshThread, OffsetScanThread, RenameThread, RSSThread
logger = logging.getLogger(__name__)
figlet = r"""
_ ____ _
/\ | | | _ \ (_)
/ \ _ _| |_ ___ | |_) | __ _ _ __ __ _ _ _ _ __ ___ _
/ /\ \| | | | __/ _ \| _ < / _` | '_ \ / _` | | | | '_ ` _ \| |
/ ____ \ |_| | || (_) | |_) | (_| | | | | (_| | |_| | | | | | | |
/_/ \_\__,_|\__\___/|____/ \__,_|_| |_|\__, |\__,_|_| |_| |_|_|
__/ |
|___/
"""
class Program(RenameThread, RSSThread, OffsetScanThread, CalendarRefreshThread):
def __init__(self):
super().__init__()
self._startup_done = False
@staticmethod
def __start_info():
for line in figlet.splitlines():
logger.info(line.strip("\n"))
logger.info(
f"Version {VERSION} Author: EstrellaXD Twitter: https://twitter.com/Estrella_Pan"
)
logger.info("GitHub: https://github.com/EstrellaXD/Auto_Bangumi/")
logger.info("Starting AutoBangumi...")
async def startup(self):
# Prevent duplicate startup due to nested router lifespan events
if self._startup_done:
return
self.__start_info()
if not self.database:
first_run()
logger.info("[Core] No db file exists, create database file.")
return {"status": "First run detected."}
if self.legacy_data:
logger.info(
"[Core] Legacy data detected, starting data migration, please wait patiently."
)
data_migration()
else:
need_update, last_minor = self.version_update
if need_update:
if last_minor is not None and last_minor == 0:
await from_30_to_31()
logger.info("[Core] Database migrated from 3.0 to 3.1.")
await from_31_to_32()
logger.info("[Core] Database updated.")
else:
# Always check schema version and run pending migrations,
# in case a previous migration was interrupted or failed.
run_migrations()
if not self.img_cache:
logger.info("[Core] No image cache exists, create image cache.")
await cache_image()
await self.start()
self._startup_done = True
async def start(self):
settings.load()
max_retries = 10
retry_count = 0
while not await self.check_downloader_status():
retry_count += 1
logger.warning(
f"Downloader is not running. (attempt {retry_count}/{max_retries})"
)
if retry_count >= max_retries:
logger.error(
"Failed to connect to downloader after maximum retries. "
"Please check downloader settings and network/proxy configuration. "
"Program will continue but download functions will not work."
)
break
logger.info("Waiting for downloader to start...")
await asyncio.sleep(30)
if self.enable_renamer:
self.rename_start()
if self.enable_rss:
self.rss_start()
# Start offset scanner for background mismatch detection
self.scan_start()
# Start calendar refresh (every 24 hours)
self.calendar_start()
self._tasks_started = True
logger.info("Program running.")
return ResponseModel(
status=True,
status_code=200,
msg_en="Program started.",
msg_zh="程序启动成功。",
)
async def stop(self):
if self.is_running:
await self.rename_stop()
await self.rss_stop()
await self.scan_stop()
await self.calendar_stop()
self._tasks_started = False
return ResponseModel(
status=True,
status_code=200,
msg_en="Program stopped.",
msg_zh="程序停止成功。",
)
else:
return ResponseModel(
status=False,
status_code=406,
msg_en="Program is not running.",
msg_zh="程序未运行。",
)
async def restart(self):
stop_ok = True
try:
await self.stop()
except Exception as e:
logger.warning(f"[Core] Error during stop in restart: {e}")
stop_ok = False
start_ok = True
try:
await self.start()
except Exception as e:
logger.error(f"[Core] Error during start in restart: {e}")
start_ok = False
if start_ok and stop_ok:
return ResponseModel(
status=True,
status_code=200,
msg_en="Program restarted.",
msg_zh="程序重启成功。",
)
elif start_ok:
return ResponseModel(
status=True,
status_code=200,
msg_en="Program restarted (stop had warnings).",
msg_zh="程序重启成功(停止时有警告)。",
)
else:
return ResponseModel(
status=False,
status_code=500,
msg_en="Program failed to restart.",
msg_zh="程序重启失败。",
)
def update_database(self):
need_update, _ = self.version_update
if not need_update:
return {"status": "No update found."}
else:
start_up()
return {"status": "Database updated."}
================================================
FILE: backend/src/module/core/status.py
================================================
import asyncio
import time
from module.checker import Checker
from module.conf import LEGACY_DATA_PATH
DOWNLOADER_STATUS_TTL = 60
class ProgramStatus(Checker):
def __init__(self):
super().__init__()
self.stop_event = asyncio.Event()
self.lock = asyncio.Lock()
self._downloader_status = False
self._downloader_last_check: float = 0
self._torrents_status = False
self._tasks_started = False
self.event = asyncio.Event()
@property
def is_running(self):
if not self._tasks_started or self.check_first_run():
return False
else:
return True
@property
def is_stopped(self):
return not self._tasks_started
@property
def downloader_status(self):
return self._downloader_status
async def check_downloader_status(self) -> bool:
now = time.monotonic()
if (
not self._downloader_status
or (now - self._downloader_last_check) >= DOWNLOADER_STATUS_TTL
):
self._downloader_status = await self.check_downloader()
self._downloader_last_check = now
return self._downloader_status
@property
def enable_rss(self):
return self.check_analyser()
@property
def enable_renamer(self):
return self.check_renamer()
@property
def first_run(self):
return self.check_first_run()
@property
def legacy_data(self):
return LEGACY_DATA_PATH.exists()
@property
def version_update(self) -> tuple[bool, int | None]:
is_same, last_minor = self.check_version()
return not is_same, last_minor
@property
def database(self):
return self.check_database()
@property
def img_cache(self):
return self.check_img_cache()
================================================
FILE: backend/src/module/core/sub_thread.py
================================================
import asyncio
import logging
from module.conf import settings
from module.downloader import DownloadClient
from module.manager import Renamer, TorrentManager, eps_complete
from module.notification import NotificationManager
from module.rss import RSSAnalyser, RSSEngine
from .offset_scanner import OffsetScanner
from .status import ProgramStatus
logger = logging.getLogger(__name__)
# Calendar refresh interval in seconds (24 hours)
CALENDAR_REFRESH_INTERVAL = 24 * 60 * 60
class RSSThread(ProgramStatus):
def __init__(self):
super().__init__()
self._rss_task: asyncio.Task | None = None
self._rss_stop_event = asyncio.Event()
self.analyser = RSSAnalyser()
async def rss_loop(self):
while not self._rss_stop_event.is_set():
try:
async with DownloadClient() as client:
with RSSEngine() as engine:
# Analyse RSS
rss_list = engine.rss.search_aggregate()
for rss in rss_list:
await self.analyser.rss_to_data(rss, engine)
# Run RSS Engine
await engine.refresh_rss(client)
if settings.bangumi_manage.eps_complete:
await eps_complete()
except Exception as e:
logger.error(f"[RSSThread] Error during RSS loop: {e}")
try:
await asyncio.wait_for(
self._rss_stop_event.wait(),
timeout=settings.program.rss_time,
)
except asyncio.TimeoutError:
pass
def rss_start(self):
self._rss_stop_event.clear()
self._rss_task = asyncio.create_task(self.rss_loop())
async def rss_stop(self):
if self._rss_task and not self._rss_task.done():
self._rss_stop_event.set()
self._rss_task.cancel()
try:
await self._rss_task
except asyncio.CancelledError:
pass
self._rss_task = None
class RenameThread(ProgramStatus):
def __init__(self):
super().__init__()
self._rename_task: asyncio.Task | None = None
self._rename_stop_event = asyncio.Event()
async def rename_loop(self):
while not self._rename_stop_event.is_set():
try:
async with Renamer() as renamer:
renamed_info = await renamer.rename()
if settings.notification.enable and renamed_info:
manager = NotificationManager()
for info in renamed_info:
await manager.send_all(info)
except Exception as e:
logger.error(f"[RenameThread] Error during rename loop: {e}")
try:
await asyncio.wait_for(
self._rename_stop_event.wait(),
timeout=settings.program.rename_time,
)
except asyncio.TimeoutError:
pass
def rename_start(self):
self._rename_stop_event.clear()
self._rename_task = asyncio.create_task(self.rename_loop())
async def rename_stop(self):
if self._rename_task and not self._rename_task.done():
self._rename_stop_event.set()
self._rename_task.cancel()
try:
await self._rename_task
except asyncio.CancelledError:
pass
self._rename_task = None
# Offset scan interval in seconds (6 hours)
OFFSET_SCAN_INTERVAL = 6 * 60 * 60
class OffsetScanThread(ProgramStatus):
"""Background thread for scanning bangumi offset mismatches."""
def __init__(self):
super().__init__()
self._scan_task: asyncio.Task | None = None
self._scan_stop_event = asyncio.Event()
self._scanner = OffsetScanner()
async def scan_loop(self):
# Initial delay to let the system stabilize
await asyncio.sleep(60)
while not self._scan_stop_event.is_set():
try:
flagged = await self._scanner.scan_all()
logger.info(
f"[OffsetScanThread] Scan complete, flagged {flagged} bangumi"
)
except Exception as e:
logger.error(f"[OffsetScanThread] Error during scan: {e}")
try:
await asyncio.wait_for(
self._scan_stop_event.wait(),
timeout=OFFSET_SCAN_INTERVAL,
)
except asyncio.TimeoutError:
pass
def scan_start(self):
self._scan_stop_event.clear()
self._scan_task = asyncio.create_task(self.scan_loop())
logger.info("[OffsetScanThread] Started offset scanner")
async def scan_stop(self):
if self._scan_task and not self._scan_task.done():
self._scan_stop_event.set()
self._scan_task.cancel()
try:
await self._scan_task
except asyncio.CancelledError:
pass
self._scan_task = None
logger.info("[OffsetScanThread] Stopped offset scanner")
class CalendarRefreshThread(ProgramStatus):
"""Background thread for refreshing bangumi calendar data every 24 hours."""
def __init__(self):
super().__init__()
self._calendar_task: asyncio.Task | None = None
self._calendar_stop_event = asyncio.Event()
async def calendar_loop(self):
# Initial delay to let the system stabilize
await asyncio.sleep(120)
while not self._calendar_stop_event.is_set():
try:
with TorrentManager() as manager:
resp = await manager.refresh_calendar()
if resp.status:
logger.info(
"[CalendarRefreshThread] Calendar refresh completed"
)
else:
logger.warning(
f"[CalendarRefreshThread] Calendar refresh failed: {resp.msg_en}"
)
except Exception as e:
logger.error(f"[CalendarRefreshThread] Error during refresh: {e}")
try:
await asyncio.wait_for(
self._calendar_stop_event.wait(),
timeout=CALENDAR_REFRESH_INTERVAL,
)
except asyncio.TimeoutError:
pass
def calendar_start(self):
self._calendar_stop_event.clear()
self._calendar_task = asyncio.create_task(self.calendar_loop())
logger.info("[CalendarRefreshThread] Started calendar refresh (every 24h)")
async def calendar_stop(self):
if self._calendar_task and not self._calendar_task.done():
self._calendar_stop_event.set()
self._calendar_task.cancel()
try:
await self._calendar_task
except asyncio.CancelledError:
pass
self._calendar_task = None
logger.info("[CalendarRefreshThread] Stopped calendar refresh")
================================================
FILE: backend/src/module/database/__init__.py
================================================
from .combine import Database
from .engine import engine
================================================
FILE: backend/src/module/database/bangumi.py
================================================
import json
import logging
import re
import time
from typing import Optional
from sqlalchemy.sql import func
from sqlmodel import Session, and_, delete, false, or_, select
from module.models import Bangumi, BangumiUpdate
logger = logging.getLogger(__name__)
def _normalize_group_name(group: str | None) -> str:
"""Normalize group name for comparison by removing common separators."""
if not group:
return ""
# Remove common separators (&, ×, _, -) and normalize to lowercase
return re.sub(r"[&×_\-]", "", group).lower().strip()
def _groups_are_similar(group1: str | None, group2: str | None) -> bool:
"""
Check if two group names are similar enough to be considered the same group.
Handles cases like:
- "LoliHouse" vs "LoliHouse&动漫国字幕组"
- "字幕组A" vs "字幕组A×字幕组B"
"""
if not group1 or not group2:
return False
# Exact match or substring match (one contains the other)
if group1 == group2 or group1 in group2 or group2 in group1:
return True
# Normalized comparison - check if core group names overlap
norm1 = _normalize_group_name(group1)
norm2 = _normalize_group_name(group2)
return norm1 in norm2 or norm2 in norm1
def _get_aliases_list(bangumi: Bangumi) -> list[str]:
"""Get the list of title aliases from a bangumi's title_aliases JSON field."""
if not bangumi.title_aliases:
return []
try:
aliases = json.loads(bangumi.title_aliases)
if not isinstance(aliases, list):
return []
return [a for a in aliases if a]
except (json.JSONDecodeError, TypeError):
return []
def _set_aliases_list(bangumi: Bangumi, aliases: list[str]) -> None:
"""Set the title aliases JSON field from a list."""
if not aliases:
bangumi.title_aliases = None
else:
# Remove duplicates while preserving order
unique_aliases = list(dict.fromkeys(aliases))
bangumi.title_aliases = json.dumps(unique_aliases, ensure_ascii=False)
# Module-level TTL cache for search_all results
_bangumi_cache: list[Bangumi] | None = None
_bangumi_cache_time: float = 0
_BANGUMI_CACHE_TTL: float = 300.0 # 5 minutes - extended from 60s to reduce DB queries
def _invalidate_bangumi_cache():
global _bangumi_cache, _bangumi_cache_time
_bangumi_cache = None
_bangumi_cache_time = 0
class BangumiDatabase:
def __init__(self, session: Session):
self.session = session
def find_semantic_duplicate(self, data: Bangumi) -> Optional[Bangumi]:
"""
Find existing bangumi that semantically matches the new one.
This handles cases where subtitle groups change naming mid-season.
A semantic match requires:
- Same official_title
- Same dpi (resolution)
- Same subtitle type
- Same source
- Similar group_name (one contains the other)
Returns the matching Bangumi if found, None otherwise.
"""
statement = select(Bangumi).where(
and_(
Bangumi.official_title == data.official_title,
Bangumi.deleted == false(),
)
)
candidates = self.session.execute(statement).scalars().all()
for candidate in candidates:
is_exact_duplicate = (
candidate.title_raw == data.title_raw
and candidate.group_name == data.group_name
)
if is_exact_duplicate:
continue
is_semantic_match = (
candidate.dpi == data.dpi
and candidate.subtitle == data.subtitle
and candidate.source == data.source
and _groups_are_similar(candidate.group_name, data.group_name)
)
if is_semantic_match:
logger.debug(
"[Database] Found semantic duplicate: '%s' matches "
"existing '%s' (official: %s)",
data.title_raw,
candidate.title_raw,
data.official_title,
)
return candidate
return None
def add_title_alias(
self, bangumi_id: int, new_title_raw: str, auto_commit: bool = True
) -> bool:
"""
Add a new title_raw alias to an existing bangumi.
This allows a single bangumi entry to match multiple naming patterns.
"""
bangumi = self.session.get(Bangumi, bangumi_id)
if not bangumi:
logger.warning(
f"[Database] Cannot add alias: bangumi id {bangumi_id} not found"
)
return False
# Don't add None or empty aliases
if not new_title_raw:
return False
# Don't add if it's the same as the main title_raw
if bangumi.title_raw == new_title_raw:
return False
# Get existing aliases and add the new one
aliases = _get_aliases_list(bangumi)
if new_title_raw in aliases:
return False # Already exists
aliases.append(new_title_raw)
_set_aliases_list(bangumi, aliases)
self.session.add(bangumi)
if auto_commit:
self.session.commit()
_invalidate_bangumi_cache()
logger.info(
f"[Database] Added alias '{new_title_raw}' to bangumi '{bangumi.official_title}' "
f"(id: {bangumi_id})"
)
return True
def get_all_title_patterns(self, bangumi: Bangumi) -> list[str]:
"""Get all title patterns for matching (title_raw + all aliases)."""
patterns = []
if bangumi.title_raw:
patterns.append(bangumi.title_raw)
patterns.extend(_get_aliases_list(bangumi))
return patterns
def _is_duplicate(self, data: Bangumi) -> bool:
"""Check if a bangumi rule already exists based on title_raw and group_name."""
statement = select(Bangumi).where(
and_(
Bangumi.title_raw == data.title_raw,
Bangumi.group_name == data.group_name,
)
)
result = self.session.execute(statement)
return result.scalar_one_or_none() is not None
def add(self, data: Bangumi) -> bool:
if self._is_duplicate(data):
logger.debug(
"[Database] Skipping duplicate: %s (%s)",
data.official_title,
data.group_name,
)
return False
# Check for semantic duplicate (same anime, different naming pattern)
semantic_match = self.find_semantic_duplicate(data)
if semantic_match:
# Add as alias instead of creating new entry
self.add_title_alias(semantic_match.id, data.title_raw)
logger.info(
f"[Database] Merged '{data.title_raw}' as alias to existing "
f"'{semantic_match.title_raw}' (official: {data.official_title})"
)
return False # Return False since we didn't add a new entry
self.session.add(data)
self.session.commit()
_invalidate_bangumi_cache()
logger.debug("[Database] Insert %s into database.", data.official_title)
return True
def add_all(self, datas: list[Bangumi]) -> int:
"""Add multiple bangumi, skipping duplicates. Returns count of added items."""
if not datas:
return 0
# Batch query: get all existing (title_raw, group_name) combinations in one query
# This replaces N individual _is_duplicate() calls with a single SELECT
keys_to_check = [(d.title_raw, d.group_name) for d in datas]
conditions = [
and_(Bangumi.title_raw == tr, Bangumi.group_name == gn)
for tr, gn in keys_to_check
]
if conditions:
statement = select(Bangumi.title_raw, Bangumi.group_name).where(
or_(*conditions)
)
result = self.session.execute(statement)
existing = set(result.all())
else:
existing = set()
# Filter out exact duplicates
to_add = [d for d in datas if (d.title_raw, d.group_name) not in existing]
# Check for semantic duplicates and add as aliases
semantic_merged = 0
really_to_add = []
for d in to_add:
semantic_match = self.find_semantic_duplicate(d)
if semantic_match:
# Add as alias instead of creating new entry (defer commit)
self.add_title_alias(semantic_match.id, d.title_raw, auto_commit=False)
semantic_merged += 1
logger.info(
f"[Database] Merged '{d.title_raw}' as alias to existing "
f"'{semantic_match.title_raw}' (official: {d.official_title})"
)
else:
really_to_add.append(d)
# Also deduplicate within the batch itself
seen = set()
unique_to_add = []
for d in really_to_add:
key = (d.title_raw, d.group_name)
if key not in seen:
seen.add(key)
unique_to_add.append(d)
if not unique_to_add:
if semantic_merged > 0:
self.session.commit()
_invalidate_bangumi_cache()
logger.debug(
"[Database] %s bangumi merged as aliases, " "rest were duplicates.",
semantic_merged,
)
else:
logger.debug(
"[Database] All %s bangumi already exist, skipping.",
len(datas),
)
return 0
self.session.add_all(unique_to_add)
self.session.commit()
_invalidate_bangumi_cache()
skipped = len(datas) - len(unique_to_add) - semantic_merged
if skipped > 0 or semantic_merged > 0:
logger.debug(
"[Database] Insert %s bangumi, "
"skipped %s duplicates, merged %s as aliases.",
len(unique_to_add),
skipped,
semantic_merged,
)
else:
logger.debug(
"[Database] Insert %s bangumi into database.",
len(unique_to_add),
)
return len(unique_to_add)
def update(self, data: Bangumi | BangumiUpdate, _id: int = None) -> bool:
if _id and isinstance(data, BangumiUpdate):
db_data = self.session.get(Bangumi, _id)
elif isinstance(data, Bangumi):
db_data = self.session.get(Bangumi, data.id)
else:
return False
if not db_data:
return False
bangumi_data = data.model_dump(exclude_unset=True)
for key, value in bangumi_data.items():
setattr(db_data, key, value)
self.session.add(db_data)
self.session.commit()
_invalidate_bangumi_cache()
logger.debug("[Database] Update %s", data.official_title)
return True
def update_all(self, datas: list[Bangumi]):
self.session.add_all(datas)
self.session.commit()
_invalidate_bangumi_cache()
logger.debug("[Database] Update %s bangumi.", len(datas))
def update_rss(self, title_raw: str, rss_set: str):
statement = select(Bangumi).where(Bangumi.title_raw == title_raw)
result = self.session.execute(statement)
bangumi = result.scalar_one_or_none()
if bangumi:
bangumi.rss_link = rss_set
bangumi.added = False
self.session.add(bangumi)
self.session.commit()
_invalidate_bangumi_cache()
logger.debug("[Database] Update %s rss_link to %s.", title_raw, rss_set)
def update_poster(self, title_raw: str, poster_link: str):
statement = select(Bangumi).where(Bangumi.title_raw == title_raw)
result = self.session.execute(statement)
bangumi = result.scalar_one_or_none()
if bangumi:
bangumi.poster_link = poster_link
self.session.add(bangumi)
self.session.commit()
_invalidate_bangumi_cache()
logger.debug(
"[Database] Update %s poster_link to %s.", title_raw, poster_link
)
def delete_one(self, _id: int):
statement = select(Bangumi).where(Bangumi.id == _id)
result = self.session.execute(statement)
bangumi = result.scalar_one_or_none()
if bangumi:
self.session.delete(bangumi)
self.session.commit()
_invalidate_bangumi_cache()
logger.debug("[Database] Delete bangumi id: %s.", _id)
def delete_all(self):
statement = delete(Bangumi)
self.session.execute(statement)
self.session.commit()
_invalidate_bangumi_cache()
def search_all(self) -> list[Bangumi]:
global _bangumi_cache, _bangumi_cache_time
now = time.time()
if (
_bangumi_cache is not None
and (now - _bangumi_cache_time) < _BANGUMI_CACHE_TTL
):
return _bangumi_cache
statement = select(Bangumi)
result = self.session.execute(statement)
bangumis = list(result.scalars().all())
# Expunge objects from session to prevent DetachedInstanceError when
# cached objects are accessed from a different session/request context
for b in bangumis:
self.session.expunge(b)
_bangumi_cache = bangumis
_bangumi_cache_time = now
return _bangumi_cache
def search_id(self, _id: int) -> Optional[Bangumi]:
statement = select(Bangumi).where(Bangumi.id == _id)
bangumi = self.session.execute(statement).scalar_one_or_none()
if bangumi is None:
logger.warning(f"[Database] Cannot find bangumi id: {_id}.")
return None
logger.debug("[Database] Find bangumi id: %s.", _id)
return bangumi
def search_official_title(self, official_title: str) -> Optional[Bangumi]:
statement = select(Bangumi).where(Bangumi.official_title == official_title)
return self.session.execute(statement).scalar_one_or_none()
def search_ids(self, ids: list[int]) -> list[Bangumi]:
"""Batch lookup multiple bangumi by their IDs."""
if not ids:
return []
statement = select(Bangumi).where(Bangumi.id.in_(ids))
result = self.session.execute(statement)
return list(result.scalars().all())
def match_poster(self, bangumi_name: str) -> str:
statement = select(Bangumi).where(
func.instr(bangumi_name, Bangumi.official_title) > 0
)
data = self.session.execute(statement).scalar_one_or_none()
return data.poster_link if data else ""
def match_list(self, torrent_list: list, rss_link: str) -> list:
match_datas = self.search_all()
if not match_datas:
return torrent_list
# Build index for O(1) lookup after regex match
# Include both title_raw and all aliases
title_index: dict[str, Bangumi] = {}
for m in match_datas:
# Add main title_raw (skip if None to avoid TypeError in sorted())
if m.title_raw:
title_index[m.title_raw] = m
# Add all aliases
for alias in _get_aliases_list(m):
if alias:
title_index[alias] = m
# Build compiled regex pattern for fast substring matching
# Sort by length descending so longer (more specific) matches are found first
sorted_titles = sorted(title_index.keys(), key=len, reverse=True)
# Escape special regex characters and join with alternation
pattern = "|".join(re.escape(title) for title in sorted_titles)
title_regex = re.compile(pattern)
unmatched = []
rss_updated = set()
for torrent in torrent_list:
match = title_regex.search(torrent.name)
if match:
matched_title = match.group(0)
match_data = title_index[matched_title]
# Use the bangumi's main title_raw for rss_updated tracking
if (
rss_link not in match_data.rss_link
and match_data.title_raw not in rss_updated
):
match_data = self.session.merge(match_data)
match_data.rss_link += f",{rss_link}"
match_data.added = False
rss_updated.add(match_data.title_raw)
else:
unmatched.append(torrent)
# Batch commit all rss_link updates
if rss_updated:
self.session.commit()
_invalidate_bangumi_cache()
logger.debug(
"[Database] Batch updated rss_link for %s bangumi.",
len(rss_updated),
)
return unmatched
def match_torrent(self, torrent_name: str) -> Optional[Bangumi]:
"""
Match torrent name to a bangumi, checking both title_raw and title_aliases.
Returns the bangumi with the longest matching pattern for specificity.
"""
match_datas = self.search_all()
if not match_datas:
return None
best_match: Optional[Bangumi] = None
best_match_len = 0
for bangumi in match_datas:
if bangumi.deleted:
continue
# Check all patterns (title_raw + aliases)
patterns = self.get_all_title_patterns(bangumi)
for pattern in patterns:
if pattern in torrent_name:
# Prefer longer matches (more specific)
if len(pattern) > best_match_len:
best_match = bangumi
best_match_len = len(pattern)
return best_match
def not_complete(self) -> list[Bangumi]:
condition = select(Bangumi).where(
and_(Bangumi.eps_collect == false(), Bangumi.deleted == false())
)
result = self.session.execute(condition)
return list(result.scalars().all())
def not_added(self) -> list[Bangumi]:
conditions = select(Bangumi).where(
or_(
Bangumi.added == 0,
Bangumi.rule_name.is_(None),
Bangumi.save_path.is_(None),
)
)
result = self.session.execute(conditions)
return list(result.scalars().all())
def disable_rule(self, _id: int):
statement = select(Bangumi).where(Bangumi.id == _id)
result = self.session.execute(statement)
bangumi = result.scalar_one_or_none()
if bangumi:
bangumi.deleted = True
self.session.add(bangumi)
self.session.commit()
_invalidate_bangumi_cache()
logger.debug("[Database] Disable rule %s.", bangumi.title_raw)
def search_rss(self, rss_link: str) -> list[Bangumi]:
statement = select(Bangumi).where(func.instr(rss_link, Bangumi.rss_link) > 0)
result = self.session.execute(statement)
return list(result.scalars().all())
def archive_one(self, _id: int) -> bool:
"""Set archived=True for the given bangumi."""
bangumi = self.session.get(Bangumi, _id)
if not bangumi:
logger.warning(f"[Database] Cannot archive bangumi id: {_id}, not found.")
return False
bangumi.archived = True
self.session.add(bangumi)
self.session.commit()
_invalidate_bangumi_cache()
logger.debug("[Database] Archived bangumi id: %s.", _id)
return True
def unarchive_one(self, _id: int) -> bool:
"""Set archived=False for the given bangumi."""
bangumi = self.session.get(Bangumi, _id)
if not bangumi:
logger.warning(f"[Database] Cannot unarchive bangumi id: {_id}, not found.")
return False
bangumi.archived = False
self.session.add(bangumi)
self.session.commit()
_invalidate_bangumi_cache()
logger.debug("[Database] Unarchived bangumi id: %s.", _id)
return True
def match_by_save_path(self, save_path: str) -> Optional[Bangumi]:
"""Find bangumi by save_path to get offset.
Tries exact match first, then falls back to matching with/without trailing slashes
and different path separators.
Note: When multiple subscriptions share the same save_path (e.g., different RSS
sources for the same anime), this returns the first match. Use match_torrent()
for more accurate matching when torrent_name is available.
"""
if not save_path:
return None
# Try exact match first
statement = select(Bangumi).where(
and_(Bangumi.save_path == save_path, Bangumi.deleted == false())
)
result = self.session.execute(statement)
bangumi = result.scalars().first()
if bangumi:
return bangumi
# Normalize the input path and try variations
normalized = save_path.replace("\\", "/").rstrip("/")
variations = [
normalized,
normalized + "/",
save_path.rstrip("/"),
save_path.rstrip("\\"),
]
# Remove duplicates while preserving order
seen = {save_path}
unique_variations = []
for v in variations:
if v not in seen:
seen.add(v)
unique_variations.append(v)
for variant in unique_variations:
statement = select(Bangumi).where(
and_(Bangumi.save_path == variant, Bangumi.deleted == false())
)
result = self.session.execute(statement)
bangumi = result.scalars().first()
if bangumi:
return bangumi
return None
def get_needs_review(self) -> list[Bangumi]:
"""Get all bangumi that need review for offset mismatch."""
statement = select(Bangumi).where(
and_(
Bangumi.needs_review == True, # noqa: E712
Bangumi.deleted == false(),
)
)
result = self.session.execute(statement)
return list(result.scalars().all())
def get_active_for_scan(self) -> list[Bangumi]:
"""Get all active (non-deleted, non-archived) bangumi for offset scanning."""
statement = select(Bangumi).where(
and_(
Bangumi.deleted == false(),
Bangumi.archived == false(),
)
)
result = self.session.execute(statement)
return list(result.scalars().all())
def set_needs_review(
self,
_id: int,
reason: str,
suggested_season_offset: int | None = None,
suggested_episode_offset: int | None = None,
) -> bool:
"""Mark a bangumi as needing review with suggested offsets.
Args:
_id: The bangumi ID
reason: Human-readable reason for the review
suggested_season_offset: Suggested season offset value
suggested_episode_offset: Suggested episode offset value
"""
bangumi = self.session.get(Bangumi, _id)
if not bangumi:
return False
bangumi.needs_review = True
bangumi.needs_review_reason = reason
bangumi.suggested_season_offset = suggested_season_offset
bangumi.suggested_episode_offset = suggested_episode_offset
self.session.add(bangumi)
self.session.commit()
_invalidate_bangumi_cache()
logger.debug(
"[Database] Marked bangumi id %s as needs_review: %s "
"(suggested: season=%s, episode=%s)",
_id,
reason,
suggested_season_offset,
suggested_episode_offset,
)
return True
def clear_needs_review(self, _id: int) -> bool:
"""Clear the needs_review flag and suggested offsets for a bangumi."""
bangumi = self.session.get(Bangumi, _id)
if not bangumi:
return False
bangumi.needs_review = False
bangumi.needs_review_reason = None
bangumi.suggested_season_offset = None
bangumi.suggested_episode_offset = None
self.session.add(bangumi)
self.session.commit()
_invalidate_bangumi_cache()
logger.debug("[Database] Cleared needs_review for bangumi id %s", _id)
return True
def set_weekday(self, _id: int, weekday: int | None) -> bool:
"""Set air_weekday and weekday_locked for manual calendar assignment."""
bangumi = self.session.get(Bangumi, _id)
if not bangumi:
return False
if weekday is not None:
bangumi.air_weekday = weekday
bangumi.weekday_locked = True
else:
bangumi.air_weekday = None
bangumi.weekday_locked = False
self.session.add(bangumi)
self.session.commit()
_invalidate_bangumi_cache()
logger.debug(
"[Database] Set weekday=%s, locked=%s for bangumi id %s",
weekday,
bangumi.weekday_locked,
_id,
)
return True
================================================
FILE: backend/src/module/database/combine.py
================================================
import logging
from typing import Any, get_args, get_origin
from pydantic.fields import FieldInfo
from pydantic_core import PydanticUndefined
from sqlalchemy import inspect, text
from sqlmodel import Session, SQLModel
from module.models import Bangumi, User
from module.models.passkey import Passkey
from module.models.rss import RSSItem
from module.models.torrent import Torrent
from .bangumi import BangumiDatabase
from .engine import engine as e
from .rss import RSSDatabase
from .torrent import TorrentDatabase
from .user import UserDatabase
logger = logging.getLogger(__name__)
# 所有需要进行空值填充的表模型
TABLE_MODELS: list[type[SQLModel]] = [Bangumi, RSSItem, Torrent, User, Passkey]
# Increment this when adding new migrations to MIGRATIONS list.
CURRENT_SCHEMA_VERSION = 9
# Each migration is a tuple of (version, description, list of SQL statements).
# Migrations are applied in order. A migration at index i brings the schema
# from version i to version i+1.
MIGRATIONS = [
(
1,
"add air_weekday column to bangumi",
["ALTER TABLE bangumi ADD COLUMN air_weekday INTEGER"],
),
(
2,
"add connection status columns to rssitem",
[
"ALTER TABLE rssitem ADD COLUMN connection_status TEXT",
"ALTER TABLE rssitem ADD COLUMN last_checked_at TEXT",
"ALTER TABLE rssitem ADD COLUMN last_error TEXT",
],
),
(
3,
"create passkey table for WebAuthn support",
[
"""CREATE TABLE IF NOT EXISTS passkey (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES user(id),
name VARCHAR(64) NOT NULL,
credential_id VARCHAR NOT NULL UNIQUE,
public_key VARCHAR NOT NULL,
sign_count INTEGER DEFAULT 0,
aaguid VARCHAR,
transports VARCHAR,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP,
backup_eligible BOOLEAN DEFAULT 0,
backup_state BOOLEAN DEFAULT 0
)""",
"CREATE INDEX IF NOT EXISTS ix_passkey_user_id ON passkey(user_id)",
"CREATE UNIQUE INDEX IF NOT EXISTS ix_passkey_credential_id ON passkey(credential_id)",
],
),
(
4,
"add archived column to bangumi",
["ALTER TABLE bangumi ADD COLUMN archived BOOLEAN DEFAULT 0"],
),
(
5,
"rename offset to episode_offset, add season_offset and review fields",
[
"ALTER TABLE bangumi RENAME COLUMN offset TO episode_offset",
"ALTER TABLE bangumi ADD COLUMN season_offset INTEGER DEFAULT 0",
"ALTER TABLE bangumi ADD COLUMN needs_review INTEGER DEFAULT 0",
"ALTER TABLE bangumi ADD COLUMN needs_review_reason TEXT DEFAULT NULL",
],
),
(
6,
"add qb_hash column to torrent for downloader tracking",
[
"ALTER TABLE torrent ADD COLUMN qb_hash TEXT",
"CREATE INDEX IF NOT EXISTS ix_torrent_qb_hash ON torrent(qb_hash)",
],
),
(
7,
"add suggested offset columns for offset review",
[
"ALTER TABLE bangumi ADD COLUMN suggested_season_offset INTEGER DEFAULT NULL",
"ALTER TABLE bangumi ADD COLUMN suggested_episode_offset INTEGER DEFAULT NULL",
],
),
(
8,
"add title_aliases for mid-season naming changes",
[
"ALTER TABLE bangumi ADD COLUMN title_aliases TEXT DEFAULT NULL",
],
),
(
9,
"add weekday_locked column to bangumi",
[
"ALTER TABLE bangumi ADD COLUMN weekday_locked BOOLEAN DEFAULT 0",
],
),
]
class Database(Session):
def __init__(self, engine=e):
self.engine = engine
super().__init__(engine)
self.rss = RSSDatabase(self)
self.torrent = TorrentDatabase(self)
self.bangumi = BangumiDatabase(self)
self.user = UserDatabase(self)
def create_table(self):
SQLModel.metadata.create_all(self.engine)
self._ensure_schema_version_table()
def _ensure_schema_version_table(self):
"""Create the schema_version table if it doesn't exist."""
with self.engine.connect() as conn:
conn.execute(
text(
"CREATE TABLE IF NOT EXISTS schema_version ("
" id INTEGER PRIMARY KEY,"
" version INTEGER NOT NULL"
")"
)
)
conn.commit()
def _get_schema_version(self) -> int:
"""Get the current schema version from the database."""
inspector = inspect(self.engine)
if "schema_version" not in inspector.get_table_names():
return 0
with self.engine.connect() as conn:
result = conn.execute(
text("SELECT version FROM schema_version WHERE id = 1")
)
row = result.fetchone()
return row[0] if row else 0
def _set_schema_version(self, version: int):
"""Update the schema version in the database."""
with self.engine.connect() as conn:
conn.execute(
text(
"INSERT OR REPLACE INTO schema_version (id, version) VALUES (1, :version)"
),
{"version": version},
)
conn.commit()
def run_migrations(self):
"""Run pending schema migrations based on the stored schema version."""
self._ensure_schema_version_table()
current = self._get_schema_version()
if current >= CURRENT_SCHEMA_VERSION:
return
inspector = inspect(self.engine)
tables = inspector.get_table_names()
for version, description, statements in MIGRATIONS:
if version <= current:
continue
# Check if migration is actually needed (column may already exist)
needs_run = True
if "bangumi" in tables and version == 1:
columns = [col["name"] for col in inspector.get_columns("bangumi")]
if "air_weekday" in columns:
needs_run = False
if "rssitem" in tables and version == 2:
columns = [col["name"] for col in inspector.get_columns("rssitem")]
if "connection_status" in columns:
needs_run = False
if version == 3 and "passkey" in tables:
needs_run = False
if "bangumi" in tables and version == 4:
columns = [col["name"] for col in inspector.get_columns("bangumi")]
if "archived" in columns:
needs_run = False
if "bangumi" in tables and version == 5:
columns = [col["name"] for col in inspector.get_columns("bangumi")]
if "episode_offset" in columns:
needs_run = False
if "torrent" in tables and version == 6:
columns = [col["name"] for col in inspector.get_columns("torrent")]
if "qb_hash" in columns:
needs_run = False
if "bangumi" in tables and version == 7:
columns = [col["name"] for col in inspector.get_columns("bangumi")]
if "suggested_season_offset" in columns:
needs_run = False
if "bangumi" in tables and version == 8:
columns = [col["name"] for col in inspector.get_columns("bangumi")]
if "title_aliases" in columns:
needs_run = False
if "bangumi" in tables and version == 9:
columns = [col["name"] for col in inspector.get_columns("bangumi")]
if "weekday_locked" in columns:
needs_run = False
if needs_run:
try:
with self.engine.connect() as conn:
for stmt in statements:
conn.execute(text(stmt))
conn.commit()
logger.info(f"[Database] Migration v{version}: {description}")
except Exception as e:
logger.error(f"[Database] Migration v{version} failed: {e}")
break
else:
logger.debug(
f"[Database] Migration v{version} skipped (already applied): {description}"
)
self._set_schema_version(version)
logger.info(f"[Database] Schema version is now {self._get_schema_version()}.")
self._fill_null_with_defaults()
def _get_field_default(self, field_info: FieldInfo) -> tuple[bool, Any]:
"""
获取字段的默认值。
返回:
gitextract_e3m8bq15/
├── .dockerignore
├── .gitattributes
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.yml
│ │ ├── config.yml
│ │ ├── discussion.yml
│ │ ├── feature_request.yml
│ │ ├── parser_bug.yml
│ │ ├── rename_bug.yml
│ │ └── rfc.yml
│ ├── PULL_REQUEST_TEMPLATE/
│ │ └── pull_request_template.md
│ └── workflows/
│ └── build.yml
├── .gitignore
├── .vscode/
│ ├── extensions.json
│ ├── launch.json
│ └── settings.json
├── CHANGELOG.md
├── CLAUDE.md
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── README.md
├── SECURITY.md
├── backend/
│ ├── .pre-commit-config.yaml
│ ├── .vscode/
│ │ └── settings.json
│ ├── dev.sh
│ ├── pyproject.toml
│ ├── scripts/
│ │ └── pip-lock-version.sh
│ └── src/
│ ├── dev_server.py
│ ├── main.py
│ ├── module/
│ │ ├── __init__.py
│ │ ├── ab_decorator/
│ │ │ ├── __init__.py
│ │ │ └── timeout.py
│ │ ├── api/
│ │ │ ├── __init__.py
│ │ │ ├── auth.py
│ │ │ ├── bangumi.py
│ │ │ ├── config.py
│ │ │ ├── downloader.py
│ │ │ ├── log.py
│ │ │ ├── notification.py
│ │ │ ├── passkey.py
│ │ │ ├── program.py
│ │ │ ├── response.py
│ │ │ ├── rss.py
│ │ │ ├── search.py
│ │ │ └── setup.py
│ │ ├── checker/
│ │ │ ├── __init__.py
│ │ │ └── checker.py
│ │ ├── conf/
│ │ │ ├── __init__.py
│ │ │ ├── config.py
│ │ │ ├── const.py
│ │ │ ├── log.py
│ │ │ ├── parse.py
│ │ │ ├── search_provider.py
│ │ │ └── uvicorn_logging.py
│ │ ├── core/
│ │ │ ├── __init__.py
│ │ │ ├── offset_scanner.py
│ │ │ ├── program.py
│ │ │ ├── status.py
│ │ │ └── sub_thread.py
│ │ ├── database/
│ │ │ ├── __init__.py
│ │ │ ├── bangumi.py
│ │ │ ├── combine.py
│ │ │ ├── engine.py
│ │ │ ├── passkey.py
│ │ │ ├── rss.py
│ │ │ ├── torrent.py
│ │ │ └── user.py
│ │ ├── downloader/
│ │ │ ├── __init__.py
│ │ │ ├── client/
│ │ │ │ ├── __init__.py
│ │ │ │ ├── aria2_downloader.py
│ │ │ │ ├── mock_downloader.py
│ │ │ │ ├── qb_downloader.py
│ │ │ │ └── tr_downloader.py
│ │ │ ├── download_client.py
│ │ │ ├── exceptions.py
│ │ │ └── path.py
│ │ ├── manager/
│ │ │ ├── __init__.py
│ │ │ ├── collector.py
│ │ │ ├── renamer.py
│ │ │ └── torrent.py
│ │ ├── mcp/
│ │ │ ├── __init__.py
│ │ │ ├── resources.py
│ │ │ ├── security.py
│ │ │ ├── server.py
│ │ │ └── tools.py
│ │ ├── models/
│ │ │ ├── __init__.py
│ │ │ ├── api.py
│ │ │ ├── bangumi.py
│ │ │ ├── config.py
│ │ │ ├── passkey.py
│ │ │ ├── response.py
│ │ │ ├── rss.py
│ │ │ ├── torrent.py
│ │ │ └── user.py
│ │ ├── network/
│ │ │ ├── __init__.py
│ │ │ ├── request_contents.py
│ │ │ ├── request_url.py
│ │ │ └── site/
│ │ │ ├── __init__.py
│ │ │ └── mikan.py
│ │ ├── notification/
│ │ │ ├── __init__.py
│ │ │ ├── base.py
│ │ │ ├── manager.py
│ │ │ ├── notification.py
│ │ │ ├── plugin/
│ │ │ │ ├── __init__.py
│ │ │ │ ├── bark.py
│ │ │ │ ├── server_chan.py
│ │ │ │ ├── slack.py
│ │ │ │ ├── telegram.py
│ │ │ │ └── wecom.py
│ │ │ └── providers/
│ │ │ ├── __init__.py
│ │ │ ├── bark.py
│ │ │ ├── discord.py
│ │ │ ├── gotify.py
│ │ │ ├── pushover.py
│ │ │ ├── server_chan.py
│ │ │ ├── telegram.py
│ │ │ ├── webhook.py
│ │ │ └── wecom.py
│ │ ├── parser/
│ │ │ ├── __init__.py
│ │ │ ├── analyser/
│ │ │ │ ├── __init__.py
│ │ │ │ ├── bgm_calendar.py
│ │ │ │ ├── bgm_parser.py
│ │ │ │ ├── mikan_parser.py
│ │ │ │ ├── offset_detector.py
│ │ │ │ ├── openai.py
│ │ │ │ ├── raw_parser.py
│ │ │ │ ├── tmdb_parser.py
│ │ │ │ └── torrent_parser.py
│ │ │ └── title_parser.py
│ │ ├── rss/
│ │ │ ├── __init__.py
│ │ │ ├── analyser.py
│ │ │ └── engine.py
│ │ ├── searcher/
│ │ │ ├── __init__.py
│ │ │ ├── provider.py
│ │ │ └── searcher.py
│ │ ├── security/
│ │ │ ├── __init__.py
│ │ │ ├── api.py
│ │ │ ├── auth_strategy.py
│ │ │ ├── jwt.py
│ │ │ └── webauthn.py
│ │ ├── update/
│ │ │ ├── __init__.py
│ │ │ ├── cross_version.py
│ │ │ ├── data_migration.py
│ │ │ ├── rss.py
│ │ │ ├── startup.py
│ │ │ └── version_check.py
│ │ └── utils/
│ │ ├── __init__.py
│ │ ├── bangumi_data.py
│ │ ├── cache_image.py
│ │ └── json_config.py
│ ├── test/
│ │ ├── __init__.py
│ │ ├── conftest.py
│ │ ├── e2e/
│ │ │ ├── Dockerfile.mock-rss
│ │ │ ├── __init__.py
│ │ │ ├── conftest.py
│ │ │ ├── docker-compose.test.yml
│ │ │ ├── fixtures/
│ │ │ │ └── mikan.xml
│ │ │ ├── mock_rss_server.py
│ │ │ └── test_e2e_workflow.py
│ │ ├── factories.py
│ │ ├── test_api_auth.py
│ │ ├── test_api_bangumi.py
│ │ ├── test_api_bangumi_extended.py
│ │ ├── test_api_config.py
│ │ ├── test_api_downloader.py
│ │ ├── test_api_log.py
│ │ ├── test_api_passkey.py
│ │ ├── test_api_program.py
│ │ ├── test_api_rss.py
│ │ ├── test_api_search.py
│ │ ├── test_auth.py
│ │ ├── test_config.py
│ │ ├── test_database.py
│ │ ├── test_download_client.py
│ │ ├── test_integration.py
│ │ ├── test_issue_bugs.py
│ │ ├── test_mcp_resources.py
│ │ ├── test_mcp_security.py
│ │ ├── test_mcp_tools.py
│ │ ├── test_migration.py
│ │ ├── test_mock_downloader.py
│ │ ├── test_notification.py
│ │ ├── test_openai.py
│ │ ├── test_path.py
│ │ ├── test_path_parser.py
│ │ ├── test_qb_downloader.py
│ │ ├── test_raw_parser.py
│ │ ├── test_renamer.py
│ │ ├── test_rss_engine.py
│ │ ├── test_rss_engine_new.py
│ │ ├── test_searcher.py
│ │ ├── test_setup.py
│ │ ├── test_title_parser.py
│ │ ├── test_tmdb.py
│ │ └── test_torrent_parser.py
│ └── test_passkey_server.py
├── docs/
│ ├── .vitepress/
│ │ ├── config.ts
│ │ └── theme/
│ │ ├── components/
│ │ │ └── HomePreviewWebUI.vue
│ │ ├── index.ts
│ │ └── style.css
│ ├── api/
│ │ └── index.md
│ ├── changelog/
│ │ ├── 2.6.md
│ │ ├── 3.0.md
│ │ ├── 3.1.md
│ │ ├── 3.2-zh.md
│ │ └── 3.2.md
│ ├── config/
│ │ ├── downloader.md
│ │ ├── experimental.md
│ │ ├── manager.md
│ │ ├── notifier.md
│ │ ├── parser.md
│ │ ├── program.md
│ │ ├── proxy.md
│ │ └── rss.md
│ ├── deploy/
│ │ ├── docker-cli.md
│ │ ├── docker-compose.md
│ │ ├── dsm.md
│ │ ├── local.md
│ │ └── quick-start.md
│ ├── dev/
│ │ ├── database.md
│ │ ├── e2e-test-guide.md
│ │ └── index.md
│ ├── en/
│ │ ├── api/
│ │ │ └── index.md
│ │ ├── changelog/
│ │ │ ├── 2.6.md
│ │ │ ├── 3.0.md
│ │ │ ├── 3.1.md
│ │ │ └── 3.2.md
│ │ ├── config/
│ │ │ ├── downloader.md
│ │ │ ├── experimental.md
│ │ │ ├── manager.md
│ │ │ ├── notifier.md
│ │ │ ├── parser.md
│ │ │ ├── program.md
│ │ │ ├── proxy.md
│ │ │ └── rss.md
│ │ ├── deploy/
│ │ │ ├── docker-cli.md
│ │ │ ├── docker-compose.md
│ │ │ ├── dsm.md
│ │ │ ├── local.md
│ │ │ └── quick-start.md
│ │ ├── dev/
│ │ │ ├── database.md
│ │ │ └── index.md
│ │ ├── faq/
│ │ │ ├── index.md
│ │ │ ├── network.md
│ │ │ └── troubleshooting.md
│ │ ├── feature/
│ │ │ ├── bangumi.md
│ │ │ ├── calendar.md
│ │ │ ├── rename.md
│ │ │ ├── rss.md
│ │ │ └── search.md
│ │ ├── home/
│ │ │ ├── index.md
│ │ │ └── pipline.md
│ │ └── index.md
│ ├── faq/
│ │ ├── index.md
│ │ ├── network.md
│ │ └── troubleshooting.md
│ ├── feature/
│ │ ├── bangumi.md
│ │ ├── calendar.md
│ │ ├── rename.md
│ │ ├── rss.md
│ │ └── search.md
│ ├── home/
│ │ ├── index.md
│ │ └── pipline.md
│ ├── index.md
│ ├── ja/
│ │ ├── api/
│ │ │ └── index.md
│ │ ├── changelog/
│ │ │ ├── 2.6.md
│ │ │ ├── 3.0.md
│ │ │ ├── 3.1.md
│ │ │ └── 3.2.md
│ │ ├── config/
│ │ │ ├── downloader.md
│ │ │ ├── experimental.md
│ │ │ ├── manager.md
│ │ │ ├── notifier.md
│ │ │ ├── parser.md
│ │ │ ├── program.md
│ │ │ ├── proxy.md
│ │ │ └── rss.md
│ │ ├── deploy/
│ │ │ ├── docker-cli.md
│ │ │ ├── docker-compose.md
│ │ │ ├── dsm.md
│ │ │ ├── local.md
│ │ │ └── quick-start.md
│ │ ├── dev/
│ │ │ ├── database.md
│ │ │ └── index.md
│ │ ├── faq/
│ │ │ ├── index.md
│ │ │ ├── network.md
│ │ │ └── troubleshooting.md
│ │ ├── feature/
│ │ │ ├── bangumi.md
│ │ │ ├── calendar.md
│ │ │ ├── rename.md
│ │ │ ├── rss.md
│ │ │ └── search.md
│ │ ├── home/
│ │ │ ├── index.md
│ │ │ └── pipline.md
│ │ └── index.md
│ ├── package.json
│ ├── plans/
│ │ ├── 2026-01-25-search-panel-redesign.md
│ │ └── 2026-02-23-calendar-drag-organize-design.md
│ ├── resource/
│ │ ├── docker-compose/
│ │ │ ├── AutoBangumi/
│ │ │ │ └── docker-compose.yml
│ │ │ └── qBittorrent+AutoBangumi/
│ │ │ └── docker-compose.yml
│ │ └── unraid.xml
│ ├── tsconfig.json
│ └── vercel.json
├── entrypoint.sh
├── scripts/
│ └── generate-beta-notes.sh
└── webui/
├── .eslintignore
├── .eslintrc.json
├── .husky/
│ └── pre-commit
├── .neoconf.json
├── .npmrc
├── .prettierignore
├── .prettierrc.json
├── .storybook/
│ ├── main.ts
│ └── preview.ts
├── .vscode/
│ ├── extensions.json
│ └── settings.json
├── LICENSE
├── README.md
├── index.html
├── package.json
├── public/
│ └── robots.txt
├── src/
│ ├── App.vue
│ ├── api/
│ │ ├── __tests__/
│ │ │ ├── auth.test.ts
│ │ │ ├── bangumi.test.ts
│ │ │ └── rss.test.ts
│ │ ├── auth.ts
│ │ ├── bangumi.ts
│ │ ├── check.ts
│ │ ├── config.ts
│ │ ├── download.ts
│ │ ├── downloader.ts
│ │ ├── log.ts
│ │ ├── notification.ts
│ │ ├── passkey.ts
│ │ ├── program.ts
│ │ ├── rss.ts
│ │ ├── search.ts
│ │ └── setup.ts
│ ├── components/
│ │ ├── ab-add-rss.vue
│ │ ├── ab-bangumi-card.vue
│ │ ├── ab-change-account.vue
│ │ ├── ab-container.vue
│ │ ├── ab-edit-rule.vue
│ │ ├── ab-fold-panel.vue
│ │ ├── ab-image.vue
│ │ ├── ab-label.vue
│ │ ├── ab-popup.vue
│ │ ├── ab-rule.vue
│ │ ├── ab-search-bar.vue
│ │ ├── ab-setting.vue
│ │ ├── ab-status-bar.vue
│ │ ├── basic/
│ │ │ ├── __tests__/
│ │ │ │ ├── ab-button.test.ts
│ │ │ │ └── ab-switch.test.ts
│ │ │ ├── ab-adaptive-modal.vue
│ │ │ ├── ab-add.stories.ts
│ │ │ ├── ab-add.vue
│ │ │ ├── ab-bottom-sheet.vue
│ │ │ ├── ab-button-multi.stories.ts
│ │ │ ├── ab-button-multi.vue
│ │ │ ├── ab-button.stories.ts
│ │ │ ├── ab-button.vue
│ │ │ ├── ab-checkbox.stories.ts
│ │ │ ├── ab-checkbox.vue
│ │ │ ├── ab-data-list.vue
│ │ │ ├── ab-offset-mismatch-dialog.vue
│ │ │ ├── ab-page-title.stories.ts
│ │ │ ├── ab-page-title.vue
│ │ │ ├── ab-pull-refresh.vue
│ │ │ ├── ab-search.stories.ts
│ │ │ ├── ab-search.vue
│ │ │ ├── ab-select.stories.ts
│ │ │ ├── ab-select.vue
│ │ │ ├── ab-status.stories.ts
│ │ │ ├── ab-status.vue
│ │ │ ├── ab-swipe-container.vue
│ │ │ ├── ab-switch.stories.ts
│ │ │ ├── ab-switch.vue
│ │ │ ├── ab-tag.stories.ts
│ │ │ └── ab-tag.vue
│ │ ├── layout/
│ │ │ ├── ab-mobile-nav.vue
│ │ │ ├── ab-sidebar.vue
│ │ │ └── ab-topbar.vue
│ │ ├── media-query.vue
│ │ ├── search/
│ │ │ ├── ab-search-card.vue
│ │ │ ├── ab-search-confirm.vue
│ │ │ ├── ab-search-filters.vue
│ │ │ └── ab-search-modal.vue
│ │ ├── setting/
│ │ │ ├── config-download.vue
│ │ │ ├── config-manage.vue
│ │ │ ├── config-normal.vue
│ │ │ ├── config-notification.vue
│ │ │ ├── config-openai.vue
│ │ │ ├── config-parser.vue
│ │ │ ├── config-passkey.vue
│ │ │ ├── config-player.vue
│ │ │ ├── config-proxy.vue
│ │ │ ├── config-search-provider.vue
│ │ │ └── config-security.vue
│ │ └── setup/
│ │ ├── wizard-container.vue
│ │ ├── wizard-step-account.vue
│ │ ├── wizard-step-downloader.vue
│ │ ├── wizard-step-notification.vue
│ │ ├── wizard-step-review.vue
│ │ ├── wizard-step-rss.vue
│ │ └── wizard-step-welcome.vue
│ ├── hooks/
│ │ ├── __tests__/
│ │ │ ├── useApi.test.ts
│ │ │ └── useAuth.test.ts
│ │ ├── useAddRss.ts
│ │ ├── useApi.ts
│ │ ├── useAppInfo.ts
│ │ ├── useAuth.ts
│ │ ├── useBreakpointQuery.ts
│ │ ├── useDarkMode.ts
│ │ ├── useMessage.ts
│ │ ├── useMyI18n.ts
│ │ ├── usePasskey.ts
│ │ └── useSafeArea.ts
│ ├── i18n/
│ │ ├── en.json
│ │ └── zh-CN.json
│ ├── main.ts
│ ├── pages/
│ │ ├── index/
│ │ │ ├── bangumi.vue
│ │ │ ├── calendar.vue
│ │ │ ├── config.vue
│ │ │ ├── downloader.vue
│ │ │ ├── log.vue
│ │ │ ├── player.vue
│ │ │ └── rss.vue
│ │ ├── index.vue
│ │ ├── login.vue
│ │ └── setup.vue
│ ├── router/
│ │ └── index.ts
│ ├── services/
│ │ └── webauthn.ts
│ ├── store/
│ │ ├── __tests__/
│ │ │ ├── bangumi.test.ts
│ │ │ └── rss.test.ts
│ │ ├── bangumi.ts
│ │ ├── config.ts
│ │ ├── downloader.ts
│ │ ├── log.ts
│ │ ├── player.ts
│ │ ├── program.ts
│ │ ├── rss.ts
│ │ ├── search.ts
│ │ └── setup.ts
│ ├── style/
│ │ ├── global.scss
│ │ ├── mixin.scss
│ │ ├── transition.scss
│ │ └── var.scss
│ ├── test/
│ │ ├── mocks/
│ │ │ └── api.ts
│ │ └── setup.ts
│ └── utils/
│ ├── axios.ts
│ └── poster.ts
├── tsconfig.json
├── tsconfig.node.json
├── types/
│ ├── api.ts
│ ├── auth.ts
│ ├── bangumi.ts
│ ├── components.ts
│ ├── config.ts
│ ├── downloader.ts
│ ├── dts/
│ │ ├── auto-imports.d.ts
│ │ ├── components.d.ts
│ │ ├── html.d.ts
│ │ ├── router-type.d.ts
│ │ └── vite-env.d.ts
│ ├── passkey.ts
│ ├── rss.ts
│ ├── setup.ts
│ ├── torrent.ts
│ └── utils.ts
├── unocss.config.ts
├── vite.config.ts
└── vitest.config.ts
SYMBOL INDEX (2027 symbols across 200 files)
FILE: backend/src/dev_server.py
function stub_status (line 33) | async def stub_status():
FILE: backend/src/main.py
function lifespan (line 36) | async def lifespan(app: FastAPI):
function create_app (line 46) | def create_app() -> FastAPI:
function posters (line 73) | def posters(path: str):
function html (line 87) | def html(request: Request, path: str):
function index (line 98) | def index():
FILE: backend/src/module/ab_decorator/__init__.py
function qb_connect_failed_wait (line 15) | def qb_connect_failed_wait(func):
function api_failed (line 42) | def api_failed(func):
function locked (line 55) | def locked(func):
FILE: backend/src/module/ab_decorator/timeout.py
function timeout (line 4) | def timeout(seconds):
FILE: backend/src/module/api/auth.py
function _issue_token (line 26) | def _issue_token(username: str, response: Response) -> dict:
function login (line 36) | async def login(response: Response, form_data=Depends(OAuth2PasswordRequ...
function refresh (line 48) | async def refresh(response: Response, token: str = Cookie(None)):
function logout (line 63) | async def logout(response: Response, token: str = Cookie(None)):
function update_user (line 77) | async def update_user(
FILE: backend/src/module/api/bangumi.py
class OffsetSuggestion (line 21) | class OffsetSuggestion(BaseModel):
class TMDBSummary (line 27) | class TMDBSummary(BaseModel):
class OffsetSuggestionDetail (line 36) | class OffsetSuggestionDetail(BaseModel):
class SetWeekdayRequest (line 44) | class SetWeekdayRequest(BaseModel):
class DetectOffsetRequest (line 48) | class DetectOffsetRequest(BaseModel):
class DetectOffsetResponse (line 55) | class DetectOffsetResponse(BaseModel):
function str_to_list (line 64) | def str_to_list(data: Bangumi):
function get_all_data (line 73) | async def get_all_data():
function get_data (line 83) | async def get_data(bangumi_id: str):
function update_rule (line 94) | async def update_rule(
function delete_rule (line 108) | async def delete_rule(bangumi_id: str, file: bool = False):
function delete_many_rule (line 119) | async def delete_many_rule(bangumi_id: list, file: bool = False):
function disable_rule (line 131) | async def disable_rule(bangumi_id: str, file: bool = False):
function disable_many_rule (line 142) | async def disable_many_rule(bangumi_id: list, file: bool = False):
function enable_rule (line 154) | async def enable_rule(bangumi_id: str):
function refresh_poster_all (line 165) | async def refresh_poster_all():
function refresh_poster_one (line 175) | async def refresh_poster_one(bangumi_id: int):
function refresh_calendar (line 186) | async def refresh_calendar():
function reset_all (line 195) | async def reset_all():
function archive_rule (line 209) | async def archive_rule(bangumi_id: int):
function unarchive_rule (line 221) | async def unarchive_rule(bangumi_id: int):
function refresh_metadata (line 233) | async def refresh_metadata():
function suggest_offset (line 245) | async def suggest_offset(bangumi_id: int):
function detect_offset (line 257) | async def detect_offset(request: DetectOffsetRequest):
function dismiss_review (line 312) | async def dismiss_review(bangumi_id: int):
function get_needs_review (line 342) | async def get_needs_review():
function set_weekday (line 353) | async def set_weekday(bangumi_id: int, request: SetWeekdayRequest):
FILE: backend/src/module/api/config.py
function _is_sensitive (line 17) | def _is_sensitive(key: str) -> bool:
function _sanitize_dict (line 21) | def _sanitize_dict(d: dict) -> dict:
function _restore_masked (line 38) | def _restore_masked(incoming: dict, current: dict) -> dict:
function get_config (line 58) | async def get_config():
function update_config (line 66) | async def update_config(config: Config):
FILE: backend/src/module/api/downloader.py
class TorrentHashesRequest (line 15) | class TorrentHashesRequest(BaseModel):
class TorrentDeleteRequest (line 19) | class TorrentDeleteRequest(BaseModel):
class TorrentTagRequest (line 24) | class TorrentTagRequest(BaseModel):
function get_torrents (line 31) | async def get_torrents():
function pause_torrents (line 37) | async def pause_torrents(req: TorrentHashesRequest):
function resume_torrents (line 45) | async def resume_torrents(req: TorrentHashesRequest):
function delete_torrents (line 53) | async def delete_torrents(req: TorrentDeleteRequest):
function tag_torrent (line 61) | async def tag_torrent(req: TorrentTagRequest):
function auto_tag_torrents (line 89) | async def auto_tag_torrents():
FILE: backend/src/module/api/log.py
function get_log (line 15) | async def get_log():
function clear_log (line 38) | async def clear_log():
FILE: backend/src/module/api/notification.py
class TestProviderRequest (line 17) | class TestProviderRequest(BaseModel):
class TestProviderConfigRequest (line 23) | class TestProviderConfigRequest(BaseModel):
class TestResponse (line 39) | class TestResponse(BaseModel):
function test_provider (line 51) | async def test_provider(request: TestProviderRequest):
function test_provider_config (line 89) | async def test_provider_config(request: TestProviderConfigRequest):
FILE: backend/src/module/api/passkey.py
function _get_webauthn_from_request (line 33) | def _get_webauthn_from_request(request: Request):
function get_registration_options (line 63) | async def get_registration_options(
function verify_registration (line 103) | async def verify_registration(
function get_passkey_login_options (line 157) | async def get_passkey_login_options(
function login_with_passkey (line 211) | async def login_with_passkey(
function list_passkeys (line 247) | async def list_passkeys(username: str = Depends(get_current_user)):
function delete_passkey (line 272) | async def delete_passkey(
FILE: backend/src/module/api/program.py
function restart (line 26) | async def restart():
function start (line 45) | async def start():
function stop (line 64) | async def stop():
function program_status (line 70) | async def program_status():
function shutdown_program (line 88) | async def shutdown_program():
function check_downloader_status (line 108) | async def check_downloader_status():
FILE: backend/src/module/api/response.py
function u_response (line 7) | def u_response(response_model: ResponseModel):
FILE: backend/src/module/api/rss.py
function get_rss (line 18) | async def get_rss():
function add_rss (line 26) | async def add_rss(rss: RSSItem):
function enable_many_rss (line 37) | async def enable_many_rss(
function delete_rss (line 50) | async def delete_rss(rss_id: int):
function delete_many_rss (line 69) | async def delete_many_rss(
function disable_rss (line 82) | async def disable_rss(rss_id: int):
function disable_many_rss (line 101) | async def disable_many_rss(rss_ids: list[int]):
function update_rss (line 112) | async def update_rss(
function refresh_all (line 135) | async def refresh_all():
function refresh_rss (line 150) | async def refresh_rss(rss_id: int):
function get_torrent (line 165) | async def get_torrent(
function analysis (line 179) | async def analysis(rss: RSSItem):
function download_collection (line 190) | async def download_collection(data: Bangumi):
function subscribe (line 199) | async def subscribe(data: Bangumi, rss: RSSItem):
FILE: backend/src/module/api/search.py
function search_torrents (line 15) | async def search_torrents(site: str = "mikan", keywords: str = Query(Non...
function search_provider (line 34) | async def search_provider():
function get_search_provider_config (line 43) | async def get_search_provider_config():
function update_search_provider_config (line 53) | async def update_search_provider_config(providers: dict[str, str]):
FILE: backend/src/module/api/setup.py
function _require_setup_needed (line 25) | def _require_setup_needed():
function _validate_url (line 34) | def _validate_url(url: str) -> None:
class SetupStatusResponse (line 58) | class SetupStatusResponse(BaseModel):
class TestDownloaderRequest (line 63) | class TestDownloaderRequest(BaseModel):
class TestRSSRequest (line 71) | class TestRSSRequest(BaseModel):
class TestNotificationRequest (line 75) | class TestNotificationRequest(BaseModel):
class TestResultResponse (line 81) | class TestResultResponse(BaseModel):
class SetupCompleteRequest (line 89) | class SetupCompleteRequest(BaseModel):
function get_setup_status (line 110) | async def get_setup_status():
function test_downloader (line 121) | async def test_downloader(req: TestDownloaderRequest):
function test_rss (line 196) | async def test_rss(req: TestRSSRequest):
function test_notification (line 230) | async def test_notification(req: TestNotificationRequest):
function complete_setup (line 275) | async def complete_setup(req: SetupCompleteRequest):
FILE: backend/src/module/checker/checker.py
function _get_default_config_dict (line 16) | def _get_default_config_dict() -> dict:
class Checker (line 23) | class Checker:
method __init__ (line 24) | def __init__(self):
method check_renamer (line 28) | def check_renamer() -> bool:
method check_analyser (line 35) | def check_analyser() -> bool:
method check_first_run (line 42) | def check_first_run() -> bool:
method check_version (line 48) | def check_version() -> tuple[bool, int | None]:
method check_database (line 52) | def check_database() -> bool:
method check_downloader (line 60) | async def check_downloader() -> bool:
method check_img_cache (line 95) | def check_img_cache() -> bool:
FILE: backend/src/module/conf/config.py
class Settings (line 29) | class Settings(Config):
method __init__ (line 39) | def __init__(self):
method load (line 47) | def load(self):
method _migrate_old_config (line 57) | def _migrate_old_config(config: dict) -> dict:
method save (line 84) | def save(self, config_dict: dict | None = None):
method init (line 91) | def init(self):
method __load_from_env (line 97) | def __load_from_env(self):
method __val_from_env (line 117) | def __val_from_env(env: str, attr: tuple | str):
method group_rules (line 124) | def group_rules(self):
FILE: backend/src/module/conf/const.py
class BCOLORS (line 119) | class BCOLORS:
method _ (line 123) | def _(color: str, *args: str) -> str:
FILE: backend/src/module/conf/log.py
function setup_logger (line 15) | def setup_logger(level: int = logging.INFO, reset: bool = False):
FILE: backend/src/module/conf/parse.py
function parse (line 4) | def parse():
FILE: backend/src/module/conf/search_provider.py
function load_provider (line 14) | def load_provider():
function save_provider (line 22) | def save_provider(providers: dict[str, str]):
function get_provider (line 29) | def get_provider():
FILE: backend/src/module/core/offset_scanner.py
class OffsetScanner (line 14) | class OffsetScanner:
method scan_all (line 17) | async def scan_all(self) -> int:
method _check_bangumi (line 47) | async def _check_bangumi(self, bangumi: Bangumi) -> bool:
method check_single (line 107) | async def check_single(self, bangumi_id: int) -> bool:
FILE: backend/src/module/core/program.py
class Program (line 32) | class Program(RenameThread, RSSThread, OffsetScanThread, CalendarRefresh...
method __init__ (line 33) | def __init__(self):
method __start_info (line 38) | def __start_info():
method startup (line 47) | async def startup(self):
method start (line 79) | async def start(self):
method stop (line 114) | async def stop(self):
method restart (line 135) | async def restart(self):
method update_database (line 170) | def update_database(self):
FILE: backend/src/module/core/status.py
class ProgramStatus (line 10) | class ProgramStatus(Checker):
method __init__ (line 11) | def __init__(self):
method is_running (line 22) | def is_running(self):
method is_stopped (line 29) | def is_stopped(self):
method downloader_status (line 33) | def downloader_status(self):
method check_downloader_status (line 36) | async def check_downloader_status(self) -> bool:
method enable_rss (line 47) | def enable_rss(self):
method enable_renamer (line 51) | def enable_renamer(self):
method first_run (line 55) | def first_run(self):
method legacy_data (line 59) | def legacy_data(self):
method version_update (line 63) | def version_update(self) -> tuple[bool, int | None]:
method database (line 68) | def database(self):
method img_cache (line 72) | def img_cache(self):
FILE: backend/src/module/core/sub_thread.py
class RSSThread (line 19) | class RSSThread(ProgramStatus):
method __init__ (line 20) | def __init__(self):
method rss_loop (line 26) | async def rss_loop(self):
method rss_start (line 49) | def rss_start(self):
method rss_stop (line 53) | async def rss_stop(self):
class RenameThread (line 64) | class RenameThread(ProgramStatus):
method __init__ (line 65) | def __init__(self):
method rename_loop (line 70) | async def rename_loop(self):
method rename_start (line 89) | def rename_start(self):
method rename_stop (line 93) | async def rename_stop(self):
class OffsetScanThread (line 108) | class OffsetScanThread(ProgramStatus):
method __init__ (line 111) | def __init__(self):
method scan_loop (line 117) | async def scan_loop(self):
method scan_start (line 138) | def scan_start(self):
method scan_stop (line 143) | async def scan_stop(self):
class CalendarRefreshThread (line 155) | class CalendarRefreshThread(ProgramStatus):
method __init__ (line 158) | def __init__(self):
method calendar_loop (line 163) | async def calendar_loop(self):
method calendar_start (line 190) | def calendar_start(self):
method calendar_stop (line 195) | async def calendar_stop(self):
FILE: backend/src/module/database/bangumi.py
function _normalize_group_name (line 15) | def _normalize_group_name(group: str | None) -> str:
function _groups_are_similar (line 23) | def _groups_are_similar(group1: str | None, group2: str | None) -> bool:
function _get_aliases_list (line 44) | def _get_aliases_list(bangumi: Bangumi) -> list[str]:
function _set_aliases_list (line 57) | def _set_aliases_list(bangumi: Bangumi, aliases: list[str]) -> None:
function _invalidate_bangumi_cache (line 73) | def _invalidate_bangumi_cache():
class BangumiDatabase (line 79) | class BangumiDatabase:
method __init__ (line 80) | def __init__(self, session: Session):
method find_semantic_duplicate (line 83) | def find_semantic_duplicate(self, data: Bangumi) -> Optional[Bangumi]:
method add_title_alias (line 131) | def add_title_alias(
method get_all_title_patterns (line 172) | def get_all_title_patterns(self, bangumi: Bangumi) -> list[str]:
method _is_duplicate (line 180) | def _is_duplicate(self, data: Bangumi) -> bool:
method add (line 191) | def add(self, data: Bangumi) -> bool:
method add_all (line 217) | def add_all(self, datas: list[Bangumi]) -> int:
method update (line 300) | def update(self, data: Bangumi | BangumiUpdate, _id: int = None) -> bool:
method update_all (line 318) | def update_all(self, datas: list[Bangumi]):
method update_rss (line 324) | def update_rss(self, title_raw: str, rss_set: str):
method update_poster (line 336) | def update_poster(self, title_raw: str, poster_link: str):
method delete_one (line 349) | def delete_one(self, _id: int):
method delete_all (line 359) | def delete_all(self):
method search_all (line 365) | def search_all(self) -> list[Bangumi]:
method search_id (line 384) | def search_id(self, _id: int) -> Optional[Bangumi]:
method search_official_title (line 393) | def search_official_title(self, official_title: str) -> Optional[Bangu...
method search_ids (line 397) | def search_ids(self, ids: list[int]) -> list[Bangumi]:
method match_poster (line 405) | def match_poster(self, bangumi_name: str) -> str:
method match_list (line 412) | def match_list(self, torrent_list: list, rss_link: str) -> list:
method match_torrent (line 464) | def match_torrent(self, torrent_name: str) -> Optional[Bangumi]:
method not_complete (line 492) | def not_complete(self) -> list[Bangumi]:
method not_added (line 499) | def not_added(self) -> list[Bangumi]:
method disable_rule (line 510) | def disable_rule(self, _id: int):
method search_rss (line 521) | def search_rss(self, rss_link: str) -> list[Bangumi]:
method archive_one (line 526) | def archive_one(self, _id: int) -> bool:
method unarchive_one (line 539) | def unarchive_one(self, _id: int) -> bool:
method match_by_save_path (line 552) | def match_by_save_path(self, save_path: str) -> Optional[Bangumi]:
method get_needs_review (line 601) | def get_needs_review(self) -> list[Bangumi]:
method get_active_for_scan (line 612) | def get_active_for_scan(self) -> list[Bangumi]:
method set_needs_review (line 623) | def set_needs_review(
method clear_needs_review (line 658) | def clear_needs_review(self, _id: int) -> bool:
method set_weekday (line 673) | def set_weekday(self, _id: int, weekday: int | None) -> bool:
FILE: backend/src/module/database/combine.py
class Database (line 116) | class Database(Session):
method __init__ (line 117) | def __init__(self, engine=e):
method create_table (line 125) | def create_table(self):
method _ensure_schema_version_table (line 129) | def _ensure_schema_version_table(self):
method _get_schema_version (line 142) | def _get_schema_version(self) -> int:
method _set_schema_version (line 154) | def _set_schema_version(self, version: int):
method run_migrations (line 165) | def run_migrations(self):
method _get_field_default (line 230) | def _get_field_default(self, field_info: FieldInfo) -> tuple[bool, Any]:
method _is_optional_field (line 245) | def _is_optional_field(self, model: type[SQLModel], field_name: str) -...
method _fill_null_with_defaults (line 257) | def _fill_null_with_defaults(self):
method drop_table (line 320) | def drop_table(self):
method migrate (line 323) | def migrate(self):
FILE: backend/src/module/database/engine.py
function _set_sqlite_fk_sync (line 20) | def _set_sqlite_fk_sync(dbapi_conn, connection_record):
function _set_sqlite_fk_async (line 27) | def _set_sqlite_fk_async(dbapi_conn, connection_record):
FILE: backend/src/module/database/passkey.py
class PasskeyDatabase (line 18) | class PasskeyDatabase:
method __init__ (line 19) | def __init__(self, session: AsyncSession):
method create_passkey (line 22) | async def create_passkey(self, passkey: Passkey) -> Passkey:
method get_passkey_by_credential_id (line 30) | async def get_passkey_by_credential_id(
method get_passkeys_by_user_id (line 38) | async def get_passkeys_by_user_id(self, user_id: int) -> List[Passkey]:
method get_passkey_by_id (line 44) | async def get_passkey_by_id(self, passkey_id: int, user_id: int) -> Pa...
method update_passkey_usage (line 55) | async def update_passkey_usage(self, passkey: Passkey, new_sign_count:...
method delete_passkey (line 62) | async def delete_passkey(self, passkey_id: int, user_id: int) -> bool:
method to_list_model (line 70) | def to_list_model(self, passkey: Passkey) -> PasskeyList:
FILE: backend/src/module/database/rss.py
class RSSDatabase (line 10) | class RSSDatabase:
method __init__ (line 11) | def __init__(self, session: Session):
method add (line 14) | def add(self, data: RSSItem) -> bool:
method add_all (line 28) | def add_all(self, data: list[RSSItem]):
method update (line 41) | def update(self, _id: int, data: RSSUpdate) -> bool:
method enable (line 54) | def enable(self, _id: int) -> bool:
method enable_batch (line 65) | def enable_batch(self, ids: list[int]):
method disable (line 72) | def disable(self, _id: int) -> bool:
method disable_batch (line 83) | def disable_batch(self, ids: list[int]):
method search_id (line 90) | def search_id(self, _id: int) -> RSSItem | None:
method search_all (line 93) | def search_all(self) -> list[RSSItem]:
method search_active (line 97) | def search_active(self) -> list[RSSItem]:
method search_aggregate (line 103) | def search_aggregate(self) -> list[RSSItem]:
method delete (line 109) | def delete(self, _id: int) -> bool:
method delete_all (line 119) | def delete_all(self):
FILE: backend/src/module/database/torrent.py
class TorrentDatabase (line 10) | class TorrentDatabase:
method __init__ (line 11) | def __init__(self, session: Session):
method add (line 14) | def add(self, data: Torrent):
method add_all (line 19) | def add_all(self, datas: list[Torrent]):
method update (line 24) | def update(self, data: Torrent):
method update_all (line 29) | def update_all(self, datas: list[Torrent]):
method update_one_user (line 33) | def update_one_user(self, data: Torrent):
method search (line 38) | def search(self, _id: int) -> Torrent | None:
method search_all (line 42) | def search_all(self) -> list[Torrent]:
method search_rss (line 46) | def search_rss(self, rss_id: int) -> list[Torrent]:
method check_new (line 50) | def check_new(self, torrents_list: list[Torrent]) -> list[Torrent]:
method search_by_qb_hash (line 59) | def search_by_qb_hash(self, qb_hash: str) -> Torrent | None:
method search_by_qb_hashes (line 64) | def search_by_qb_hashes(self, qb_hashes: list[str]) -> list[Torrent]:
method delete_by_bangumi_id (line 73) | def delete_by_bangumi_id(self, bangumi_id: int) -> int:
method search_by_url (line 89) | def search_by_url(self, url: str) -> Torrent | None:
method update_qb_hash (line 94) | def update_qb_hash(self, torrent_id: int, qb_hash: str) -> bool:
FILE: backend/src/module/database/user.py
class UserDatabase (line 13) | class UserDatabase:
method __init__ (line 14) | def __init__(self, session: Session):
method get_user (line 17) | def get_user(self, username: str) -> User:
method auth_user (line 25) | def auth_user(self, user: User) -> ResponseModel:
method update_user (line 57) | def update_user(self, username: str, update_user: UserUpdate) -> User:
method add_default_user (line 71) | def add_default_user(self):
FILE: backend/src/module/downloader/client/aria2_downloader.py
class Aria2Downloader (line 11) | class Aria2Downloader:
method __init__ (line 12) | def __init__(self, host: str, username: str, password: str):
method _call (line 19) | async def _call(self, method: str, params: list = None):
method auth (line 37) | async def auth(self, retry=3):
method logout (line 54) | async def logout(self):
method torrents_files (line 59) | async def torrents_files(self, torrent_hash: str):
method add_torrents (line 62) | async def add_torrents(
method check_host (line 81) | async def check_host(self):
method prefs_init (line 84) | async def prefs_init(self, prefs):
method get_app_prefs (line 87) | async def get_app_prefs(self):
method add_category (line 90) | async def add_category(self, category):
method torrents_info (line 93) | async def torrents_info(self, status_filter, category, tag=None):
method get_torrents_by_tag (line 96) | async def get_torrents_by_tag(self, tag: str) -> list[dict]:
method torrents_delete (line 99) | async def torrents_delete(self, hash, delete_files: bool = True):
method torrents_pause (line 102) | async def torrents_pause(self, hashes: str):
method torrents_resume (line 105) | async def torrents_resume(self, hashes: str):
method torrents_rename_file (line 108) | async def torrents_rename_file(
method rss_add_feed (line 113) | async def rss_add_feed(self, url, item_path):
method rss_remove_item (line 116) | async def rss_remove_item(self, item_path):
method rss_get_feeds (line 119) | async def rss_get_feeds(self):
method rss_set_rule (line 122) | async def rss_set_rule(self, rule_name, rule_def):
method move_torrent (line 125) | async def move_torrent(self, hashes, new_location):
method get_download_rule (line 128) | async def get_download_rule(self):
method get_torrent_path (line 131) | async def get_torrent_path(self, _hash):
method set_category (line 134) | async def set_category(self, _hash, category):
method check_connection (line 137) | async def check_connection(self):
method remove_rule (line 140) | async def remove_rule(self, rule_name):
method add_tag (line 143) | async def add_tag(self, _hash, tag):
FILE: backend/src/module/downloader/client/mock_downloader.py
class MockDownloader (line 14) | class MockDownloader:
method __init__ (line 20) | def __init__(self):
method auth (line 34) | async def auth(self, retry=3) -> bool:
method logout (line 38) | async def logout(self):
method check_host (line 41) | async def check_host(self) -> bool:
method prefs_init (line 45) | async def prefs_init(self, prefs: dict):
method get_app_prefs (line 49) | async def get_app_prefs(self) -> dict:
method add_category (line 53) | async def add_category(self, category: str):
method torrents_info (line 57) | async def torrents_info(
method torrents_files (line 76) | async def torrents_files(self, torrent_hash: str) -> list[dict]:
method add_torrents (line 82) | async def add_torrents(
method torrents_delete (line 113) | async def torrents_delete(self, hash: str, delete_files: bool = True):
method torrents_pause (line 121) | async def torrents_pause(self, hashes: str):
method torrents_resume (line 127) | async def torrents_resume(self, hashes: str):
method torrents_rename_file (line 133) | async def torrents_rename_file(
method rss_add_feed (line 139) | async def rss_add_feed(self, url: str, item_path: str):
method rss_remove_item (line 143) | async def rss_remove_item(self, item_path: str):
method rss_get_feeds (line 147) | async def rss_get_feeds(self) -> dict:
method rss_set_rule (line 151) | async def rss_set_rule(self, rule_name: str, rule_def: dict):
method move_torrent (line 155) | async def move_torrent(self, hashes: str, new_location: str):
method get_download_rule (line 161) | async def get_download_rule(self) -> dict:
method get_torrent_path (line 165) | async def get_torrent_path(self, _hash: str) -> str:
method set_category (line 171) | async def set_category(self, _hash: str, category: str):
method remove_rule (line 176) | async def remove_rule(self, rule_name: str):
method add_tag (line 180) | async def add_tag(self, _hash: str, tag: str):
method check_connection (line 187) | async def check_connection(self) -> str:
method add_mock_torrent (line 192) | def add_mock_torrent(
method get_state (line 220) | def get_state(self) -> dict[str, Any]:
FILE: backend/src/module/downloader/client/qb_downloader.py
class QbDownloader (line 12) | class QbDownloader:
method __init__ (line 13) | def __init__(self, host: str, username: str, password: str, ssl: bool):
method _url (line 24) | def _url(self, endpoint: str) -> str:
method auth (line 27) | async def auth(self, retry=3):
method logout (line 75) | async def logout(self):
method check_host (line 88) | async def check_host(self):
method check_rss (line 95) | def check_rss(self, rss_link: str):
method prefs_init (line 99) | async def prefs_init(self, prefs):
method get_app_prefs (line 107) | async def get_app_prefs(self):
method add_category (line 111) | async def add_category(self, category):
method torrents_info (line 118) | async def torrents_info(self, status_filter, category, tag=None):
method torrents_files (line 130) | async def torrents_files(self, torrent_hash: str):
method add_torrents (line 136) | async def add_torrents(
method get_torrents_by_tag (line 190) | async def get_torrents_by_tag(self, tag: str) -> list[dict]:
method torrents_delete (line 194) | async def torrents_delete(self, hash, delete_files: bool = True):
method torrents_pause (line 200) | async def torrents_pause(self, hashes: str):
method torrents_resume (line 206) | async def torrents_resume(self, hashes: str):
method torrents_rename_file (line 212) | async def torrents_rename_file(
method rss_add_feed (line 255) | async def rss_add_feed(self, url, item_path):
method rss_remove_item (line 263) | async def rss_remove_item(self, item_path):
method rss_get_feeds (line 271) | async def rss_get_feeds(self):
method rss_set_rule (line 275) | async def rss_set_rule(self, rule_name, rule_def):
method move_torrent (line 281) | async def move_torrent(self, hashes, new_location):
method get_download_rule (line 287) | async def get_download_rule(self):
method get_torrent_path (line 291) | async def get_torrent_path(self, _hash):
method set_category (line 300) | async def set_category(self, _hash, category):
method check_connection (line 313) | async def check_connection(self):
method remove_rule (line 317) | async def remove_rule(self, rule_name):
method add_tag (line 323) | async def add_tag(self, _hash, tag):
FILE: backend/src/module/downloader/download_client.py
class DownloadClient (line 13) | class DownloadClient(TorrentPath):
method __init__ (line 21) | def __init__(self):
method __getClient (line 27) | def __getClient():
method __aenter__ (line 51) | async def __aenter__(self):
method __aexit__ (line 60) | async def __aexit__(self, exc_type, exc_val, exc_tb):
method auth (line 65) | async def auth(self):
method check_host (line 72) | async def check_host(self):
method init_downloader (line 75) | async def init_downloader(self):
method set_rule (line 95) | async def set_rule(self, data: Bangumi):
method set_rules (line 120) | async def set_rules(self, bangumi_info: list[Bangumi]):
method get_torrent_info (line 125) | async def get_torrent_info(
method get_torrent_files (line 132) | async def get_torrent_files(self, torrent_hash: str):
method rename_torrent_file (line 135) | async def rename_torrent_file(
method delete_torrent (line 147) | async def delete_torrent(self, hashes, delete_files: bool = True):
method pause_torrent (line 151) | async def pause_torrent(self, hashes: str):
method resume_torrent (line 154) | async def resume_torrent(self, hashes: str):
method add_torrent (line 157) | async def add_torrent(self, torrent: Torrent | list, bangumi: Bangumi)...
method move_torrent (line 223) | async def move_torrent(self, hashes, location):
method add_rss_feed (line 227) | async def add_rss_feed(self, rss_link, item_path="Mikan_RSS"):
method remove_rss_feed (line 230) | async def remove_rss_feed(self, item_path):
method get_rss_feed (line 233) | async def get_rss_feed(self):
method get_download_rules (line 236) | async def get_download_rules(self):
method get_torrent_path (line 239) | async def get_torrent_path(self, hashes):
method set_category (line 242) | async def set_category(self, hashes, category):
method remove_rule (line 245) | async def remove_rule(self, rule_name):
method get_torrents_by_tag (line 249) | async def get_torrents_by_tag(self, tag: str) -> list[dict]:
method add_tag (line 255) | async def add_tag(self, torrent_hash: str, tag: str):
FILE: backend/src/module/downloader/exceptions.py
class ConflictError (line 1) | class ConflictError(Exception):
FILE: backend/src/module/downloader/path.py
class TorrentPath (line 20) | class TorrentPath:
method __init__ (line 21) | def __init__(self):
method check_files (line 25) | def check_files(files: list[dict]):
method _path_to_bangumi (line 38) | def _path_to_bangumi(save_path: PathLike[str] | str, torrent_name: str...
method _file_depth (line 55) | def _file_depth(file_path: PathLike[str] | str):
method is_ep (line 58) | def is_ep(self, file_path: PathLike[str] | str):
method _gen_save_path (line 62) | def _gen_save_path(data: Bangumi | BangumiUpdate):
method _rule_name (line 84) | def _rule_name(data: Bangumi):
method _join_path (line 93) | def _join_path(*args):
FILE: backend/src/module/manager/collector.py
class SeasonCollector (line 11) | class SeasonCollector(DownloadClient):
method collect_season (line 12) | async def collect_season(self, bangumi: Bangumi, link: str = None):
method subscribe_season (line 50) | async def subscribe_season(data: Bangumi, parser: str = "mikan"):
function eps_complete (line 65) | async def eps_complete():
FILE: backend/src/module/manager/renamer.py
class Renamer (line 22) | class Renamer(DownloadClient):
method __init__ (line 23) | def __init__(self):
method _cleanup_pending_cache (line 29) | def _cleanup_pending_cache():
method print_result (line 45) | def print_result(torrent_count, rename_count):
method gen_path (line 53) | def gen_path(
method rename_file (line 98) | async def rename_file(
method rename_collection (line 170) | async def rename_collection(
method rename_subtitles (line 206) | async def rename_subtitles(
method _parse_bangumi_id_from_tags (line 246) | def _parse_bangumi_id_from_tags(tags: str) -> int | None:
method _normalize_path (line 263) | def _normalize_path(path: str) -> str:
method _batch_lookup_offsets (line 272) | def _batch_lookup_offsets(
method _lookup_offsets (line 365) | def _lookup_offsets(
method rename (line 442) | async def rename(self) -> list[Notification]:
FILE: backend/src/module/manager/torrent.py
class TorrentManager (line 14) | class TorrentManager(Database):
method __match_torrents_list (line 16) | async def __match_torrents_list(data: Bangumi | BangumiUpdate) -> list:
method delete_torrents (line 25) | async def delete_torrents(self, data: Bangumi, client: DownloadClient):
method delete_rule (line 44) | async def delete_rule(self, _id: int | str, file: bool = False):
method disable_rule (line 70) | async def disable_rule(self, _id: str | int, file: bool = False):
method enable_rule (line 94) | def enable_rule(self, _id: str | int):
method update_rule (line 114) | async def update_rule(self, bangumi_id, data: BangumiUpdate):
method refresh_poster (line 176) | async def refresh_poster(self):
method refind_poster (line 189) | async def refind_poster(self, bangumi_id: int):
method refresh_calendar (line 200) | async def refresh_calendar(self):
method search_all_bangumi (line 231) | def search_all_bangumi(self):
method search_one (line 237) | def search_one(self, _id: int | str):
method archive_rule (line 250) | def archive_rule(self, _id: int):
method unarchive_rule (line 275) | def unarchive_rule(self, _id: int):
method refresh_metadata (line 300) | async def refresh_metadata(self):
method suggest_offset (line 337) | async def suggest_offset(self, bangumi_id: int) -> dict:
FILE: backend/src/module/mcp/resources.py
function handle_resource (line 53) | def handle_resource(uri: str) -> str:
FILE: backend/src/module/mcp/security.py
function _parse_network (line 17) | def _parse_network(cidr: str) -> ipaddress.IPv4Network | ipaddress.IPv6N...
function _is_allowed (line 25) | def _is_allowed(host: str, whitelist: list[str]) -> bool:
function clear_network_cache (line 38) | def clear_network_cache():
class McpAccessMiddleware (line 43) | class McpAccessMiddleware(BaseHTTPMiddleware):
method dispatch (line 51) | async def dispatch(self, request: Request, call_next):
FILE: backend/src/module/mcp/server.py
function list_tools (line 32) | async def list_tools() -> list[types.Tool]:
function call_tool (line 37) | async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
function list_resources (line 43) | async def list_resources() -> list[types.Resource]:
function list_resource_templates (line 48) | async def list_resource_templates() -> list[types.ResourceTemplate]:
function read_resource (line 53) | async def read_resource(uri: str) -> str:
function handle_sse (line 58) | async def handle_sse(request: Request):
function create_mcp_starlette_app (line 71) | def create_mcp_starlette_app() -> Starlette:
FILE: backend/src/module/mcp/tools.py
function _bangumi_to_dict (line 175) | def _bangumi_to_dict(b: Bangumi) -> dict:
function handle_tool (line 198) | async def handle_tool(name: str, arguments: dict) -> list[types.TextCont...
function _dispatch (line 213) | async def _dispatch(name: str, args: dict) -> dict | list:
function _list_anime (line 238) | def _list_anime(active_only: bool) -> list[dict]:
function _get_anime (line 247) | def _get_anime(bangumi_id: int) -> dict:
function _search_anime (line 255) | async def _search_anime(keywords: str, site: str) -> list[dict]:
function _subscribe_anime (line 266) | async def _subscribe_anime(rss_link: str, parser: str) -> dict:
function _unsubscribe_anime (line 276) | async def _unsubscribe_anime(bangumi_id: int, delete: bool) -> dict:
function _list_downloads (line 285) | async def _list_downloads(status: str) -> list[dict]:
function _list_rss_feeds (line 305) | def _list_rss_feeds() -> list[dict]:
function _get_program_status (line 324) | def _get_program_status() -> dict:
function _refresh_feeds (line 334) | async def _refresh_feeds() -> dict:
function _update_anime (line 341) | async def _update_anime(args: dict) -> dict:
FILE: backend/src/module/models/api.py
class RssLink (line 4) | class RssLink(BaseModel):
class AddRule (line 8) | class AddRule(BaseModel):
class ChangeConfig (line 13) | class ChangeConfig(BaseModel):
class ChangeRule (line 17) | class ChangeRule(BaseModel):
FILE: backend/src/module/models/bangumi.py
class Bangumi (line 8) | class Bangumi(SQLModel, table=True):
class BangumiUpdate (line 57) | class BangumiUpdate(SQLModel):
class Notification (line 95) | class Notification(BaseModel):
class Episode (line 103) | class Episode:
class SeasonInfo (line 117) | class SeasonInfo:
FILE: backend/src/module/models/config.py
function _expand (line 7) | def _expand(value: str | None) -> str:
class Program (line 12) | class Program(BaseModel):
class Downloader (line 20) | class Downloader(BaseModel):
method host (line 38) | def host(self):
method username (line 42) | def username(self):
method password (line 46) | def password(self):
class RSSParser (line 50) | class RSSParser(BaseModel):
class BangumiManage (line 58) | class BangumiManage(BaseModel):
class Log (line 68) | class Log(BaseModel):
class Proxy (line 74) | class Proxy(BaseModel):
method username (line 85) | def username(self):
method password (line 89) | def password(self):
class NotificationProvider (line 93) | class NotificationProvider(BaseModel):
method token (line 127) | def token(self) -> str:
method chat_id (line 131) | def chat_id(self) -> str:
method webhook_url (line 135) | def webhook_url(self) -> str:
method server_url (line 139) | def server_url(self) -> str:
method device_key (line 143) | def device_key(self) -> str:
method user_key (line 147) | def user_key(self) -> str:
method api_token (line 151) | def api_token(self) -> str:
method url (line 155) | def url(self) -> str:
class Notification (line 159) | class Notification(BaseModel):
method token (line 173) | def token(self) -> str:
method chat_id (line 177) | def chat_id(self) -> str:
method migrate_legacy_config (line 181) | def migrate_legacy_config(self) -> "Notification":
class ExperimentalOpenAI (line 195) | class ExperimentalOpenAI(BaseModel):
method validate_api_base (line 216) | def validate_api_base(cls, value: str) -> str:
class Security (line 222) | class Security(BaseModel):
class Config (line 248) | class Config(BaseModel):
method model_dump (line 261) | def model_dump(self, *args, by_alias=True, **kwargs):
method dict (line 265) | def dict(self, *args, by_alias=True, **kwargs):
FILE: backend/src/module/models/passkey.py
class Passkey (line 12) | class Passkey(SQLModel, table=True):
class PasskeyCreate (line 41) | class PasskeyCreate(BaseModel):
class PasskeyList (line 49) | class PasskeyList(BaseModel):
class PasskeyDelete (line 60) | class PasskeyDelete(BaseModel):
class PasskeyAuthStart (line 66) | class PasskeyAuthStart(BaseModel):
class PasskeyAuthFinish (line 72) | class PasskeyAuthFinish(BaseModel):
FILE: backend/src/module/models/response.py
class ResponseModel (line 4) | class ResponseModel(BaseModel):
class APIResponse (line 12) | class APIResponse(BaseModel):
FILE: backend/src/module/models/rss.py
class RSSItem (line 6) | class RSSItem(SQLModel, table=True):
class RSSUpdate (line 18) | class RSSUpdate(SQLModel):
FILE: backend/src/module/models/torrent.py
class Torrent (line 7) | class Torrent(SQLModel, table=True):
class TorrentUpdate (line 18) | class TorrentUpdate(SQLModel):
class EpisodeFile (line 22) | class EpisodeFile(BaseModel):
class SubtitleFile (line 31) | class SubtitleFile(BaseModel):
FILE: backend/src/module/models/user.py
class User (line 7) | class User(SQLModel, table=True):
class UserUpdate (line 15) | class UserUpdate(SQLModel):
class UserLogin (line 22) | class UserLogin(SQLModel):
class Token (line 27) | class Token(BaseModel):
class TokenData (line 32) | class TokenData(BaseModel):
FILE: backend/src/module/network/request_contents.py
class RequestContent (line 14) | class RequestContent(RequestURL):
method get_torrents (line 15) | async def get_torrents(
method get_xml (line 41) | async def get_xml(self, _url, retry: int = 3) -> xml.etree.ElementTree...
method get_json (line 51) | async def get_json(self, _url) -> dict:
method post_json (line 56) | async def post_json(self, _url, data: dict) -> dict:
method post_data (line 60) | async def post_data(self, _url, data: dict):
method post_files (line 63) | async def post_files(self, _url, data: dict, files: dict):
method get_html (line 66) | async def get_html(self, _url):
method get_content (line 70) | async def get_content(self, _url):
method check_connection (line 77) | async def check_connection(self, _url):
method get_rss_title (line 80) | async def get_rss_title(self, _url):
FILE: backend/src/module/network/request_url.py
function _proxy_config_key (line 16) | def _proxy_config_key() -> str:
function get_shared_client (line 22) | async def get_shared_client() -> httpx.AsyncClient:
class RequestURL (line 52) | class RequestURL:
method __init__ (line 56) | def __init__(self):
method _get_headers (line 60) | def _get_headers(self, url: str) -> dict:
method get_url (line 75) | async def get_url(self, url, retry=3):
method post_url (line 101) | async def post_url(self, url: str, data: dict, retry=3):
method check_url (line 125) | async def check_url(self, url: str):
method post_form (line 136) | async def post_form(self, url: str, data: dict, files):
method __aenter__ (line 147) | async def __aenter__(self):
method __aexit__ (line 151) | async def __aexit__(self, exc_type, exc_val, exc_tb):
FILE: backend/src/module/network/site/mikan.py
function rss_parser (line 6) | def rss_parser(soup):
function mikan_title (line 25) | def mikan_title(soup):
FILE: backend/src/module/notification/base.py
class NotificationProvider (line 9) | class NotificationProvider(RequestContent, ABC):
method send (line 17) | async def send(self, notification: Notification) -> bool:
method test (line 29) | async def test(self) -> tuple[bool, str]:
method _format_message (line 38) | def _format_message(self, notify: Notification) -> str:
FILE: backend/src/module/notification/manager.py
class NotificationManager (line 18) | class NotificationManager:
method __init__ (line 21) | def __init__(self):
method _load_providers (line 25) | def _load_providers(self):
method _get_poster (line 44) | async def _get_poster(self, notification: Notification):
method send_all (line 57) | async def send_all(self, notification: Notification):
method test_provider (line 90) | async def test_provider(self, index: int) -> tuple[bool, str]:
method test_provider_config (line 110) | async def test_provider_config(config: "ProviderConfig") -> tuple[bool...
method __len__ (line 132) | def __len__(self) -> int:
FILE: backend/src/module/notification/notification.py
function getClient (line 18) | def getClient(type: str):
class PostNotification (line 31) | class PostNotification:
method __init__ (line 32) | def __init__(self):
method _get_poster_sync (line 39) | def _get_poster_sync(notify: Notification):
method send_msg (line 44) | async def send_msg(self, notify: Notification) -> bool:
method __aenter__ (line 53) | async def __aenter__(self):
method __aexit__ (line 57) | async def __aexit__(self, exc_type, exc_val, exc_tb):
FILE: backend/src/module/notification/plugin/bark.py
class BarkNotification (line 9) | class BarkNotification(RequestContent):
method __init__ (line 10) | def __init__(self, token, **kwargs):
method gen_message (line 16) | def gen_message(notify: Notification) -> str:
method post_msg (line 22) | async def post_msg(self, notify: Notification) -> bool:
FILE: backend/src/module/notification/plugin/server_chan.py
class ServerChanNotification (line 9) | class ServerChanNotification(RequestContent):
method __init__ (line 12) | def __init__(self, token, **kwargs):
method gen_message (line 17) | def gen_message(notify: Notification) -> str:
method post_msg (line 23) | async def post_msg(self, notify: Notification) -> bool:
FILE: backend/src/module/notification/plugin/slack.py
class SlackNotification (line 9) | class SlackNotification(RequestContent):
method __init__ (line 10) | def __init__(self, token, **kwargs):
method gen_message (line 16) | def gen_message(notify: Notification) -> str:
method post_msg (line 22) | async def post_msg(self, notify: Notification) -> bool:
FILE: backend/src/module/notification/plugin/telegram.py
class TelegramNotification (line 10) | class TelegramNotification(RequestContent):
method __init__ (line 11) | def __init__(self, token, chat_id):
method gen_message (line 18) | def gen_message(notify: Notification) -> str:
method post_msg (line 24) | async def post_msg(self, notify: Notification) -> bool:
FILE: backend/src/module/notification/plugin/wecom.py
class WecomNotification (line 9) | class WecomNotification(RequestContent):
method __init__ (line 12) | def __init__(self, token, chat_id, **kwargs):
method gen_message (line 19) | def gen_message(notify: Notification) -> str:
method post_msg (line 25) | async def post_msg(self, notify: Notification) -> bool:
FILE: backend/src/module/notification/providers/bark.py
class BarkProvider (line 15) | class BarkProvider(NotificationProvider):
method __init__ (line 20) | def __init__(self, config: "ProviderConfig"):
method send (line 27) | async def send(self, notification: Notification) -> bool:
method test (line 41) | async def test(self) -> tuple[bool, str]:
FILE: backend/src/module/notification/providers/discord.py
class DiscordProvider (line 15) | class DiscordProvider(NotificationProvider):
method __init__ (line 18) | def __init__(self, config: "ProviderConfig"):
method send (line 22) | async def send(self, notification: Notification) -> bool:
method test (line 45) | async def test(self) -> tuple[bool, str]:
FILE: backend/src/module/notification/providers/gotify.py
class GotifyProvider (line 15) | class GotifyProvider(NotificationProvider):
method __init__ (line 18) | def __init__(self, config: "ProviderConfig"):
method send (line 24) | async def send(self, notification: Notification) -> bool:
method test (line 49) | async def test(self) -> tuple[bool, str]:
FILE: backend/src/module/notification/providers/pushover.py
class PushoverProvider (line 15) | class PushoverProvider(NotificationProvider):
method __init__ (line 20) | def __init__(self, config: "ProviderConfig"):
method send (line 25) | async def send(self, notification: Notification) -> bool:
method test (line 46) | async def test(self) -> tuple[bool, str]:
FILE: backend/src/module/notification/providers/server_chan.py
class ServerChanProvider (line 15) | class ServerChanProvider(NotificationProvider):
method __init__ (line 18) | def __init__(self, config: "ProviderConfig"):
method send (line 23) | async def send(self, notification: Notification) -> bool:
method test (line 35) | async def test(self) -> tuple[bool, str]:
FILE: backend/src/module/notification/providers/telegram.py
class TelegramProvider (line 16) | class TelegramProvider(NotificationProvider):
method __init__ (line 19) | def __init__(self, config: "ProviderConfig"):
method send (line 26) | async def send(self, notification: Notification) -> bool:
method test (line 45) | async def test(self) -> tuple[bool, str]:
FILE: backend/src/module/notification/providers/webhook.py
class WebhookProvider (line 28) | class WebhookProvider(NotificationProvider):
method __init__ (line 31) | def __init__(self, config: "ProviderConfig"):
method _render_template (line 36) | def _render_template(self, notification: Notification) -> dict:
method send (line 72) | async def send(self, notification: Notification) -> bool:
method test (line 81) | async def test(self) -> tuple[bool, str]:
FILE: backend/src/module/notification/providers/wecom.py
class WecomProvider (line 21) | class WecomProvider(NotificationProvider):
method __init__ (line 24) | def __init__(self, config: "ProviderConfig"):
method send (line 30) | async def send(self, notification: Notification) -> bool:
method test (line 52) | async def test(self) -> tuple[bool, str]:
FILE: backend/src/module/parser/analyser/bgm_calendar.py
function fetch_bgm_calendar (line 10) | async def fetch_bgm_calendar() -> list[dict]:
function match_weekday (line 43) | def match_weekday(official_title: str, title_raw: str, calendar_items: l...
FILE: backend/src/module/parser/analyser/bgm_parser.py
function search_url (line 4) | def search_url(e):
function bgm_parser (line 8) | async def bgm_parser(title):
FILE: backend/src/module/parser/analyser/mikan_parser.py
function mikan_parser (line 16) | async def mikan_parser(homepage: str):
FILE: backend/src/module/parser/analyser/offset_detector.py
class OffsetSuggestion (line 13) | class OffsetSuggestion:
function detect_offset_mismatch (line 22) | def detect_offset_mismatch(
FILE: backend/src/module/parser/analyser/openai.py
class Episode (line 14) | class Episode(BaseModel):
class OpenAIParser (line 35) | class OpenAIParser:
method __init__ (line 36) | def __init__(
method parse (line 77) | def parse(
method _prepare_params (line 120) | def _prepare_params(self, text: str, prompt: str) -> dict[str, Any]:
FILE: backend/src/module/parser/analyser/raw_parser.py
function _fallback_parse (line 24) | def _fallback_parse(content_title: str) -> tuple | None:
function get_group (line 50) | def get_group(name: str) -> str:
function pre_process (line 57) | def pre_process(raw_name: str) -> str:
function prefix_process (line 61) | def prefix_process(raw: str, group: str) -> str:
function season_process (line 77) | def season_process(season_info: str):
function name_process (line 104) | def name_process(name: str):
function find_tags (line 138) | def find_tags(other):
function clean_sub (line 152) | def clean_sub(sub: str | None) -> str | None:
function process (line 158) | def process(raw_title: str):
function raw_parser (line 202) | def raw_parser(raw: str) -> Episode | None:
FILE: backend/src/module/parser/analyser/tmdb_parser.py
class TMDBInfo (line 22) | class TMDBInfo:
method get_offset_for_season (line 36) | def get_offset_for_season(self, season: int) -> int:
function search_url (line 50) | def search_url(e):
function info_url (line 54) | def info_url(e, key):
function season_url (line 58) | def season_url(tv_id, season_number, key):
function is_animation (line 62) | async def is_animation(tv_id, language, req: RequestContent) -> bool:
function get_season_episode_air_dates (line 72) | async def get_season_episode_air_dates(
function detect_virtual_seasons (line 101) | def detect_virtual_seasons(episodes: list[dict], gap_months: int = 6) ->...
function get_aired_episode_count (line 140) | async def get_aired_episode_count(
function get_season (line 185) | def get_season(seasons: list) -> tuple[int, str]:
function tmdb_parser (line 202) | async def tmdb_parser(title, language, test: bool = False) -> TMDBInfo |...
FILE: backend/src/module/parser/analyser/torrent_parser.py
function get_path_basename (line 32) | def get_path_basename(torrent_path: str) -> str:
function get_group (line 47) | def get_group(group_and_title) -> tuple[str | None, str]:
function get_season_and_title (line 57) | def get_season_and_title(season_and_title) -> tuple[str, int]:
function get_subtitle_lang (line 66) | def get_subtitle_lang(subtitle_name: str) -> str:
function torrent_parser (line 73) | def torrent_parser(
function _torrent_parser_impl (line 96) | def _torrent_parser_impl(
FILE: backend/src/module/parser/title_parser.py
class TitleParser (line 17) | class TitleParser:
method __init__ (line 18) | def __init__(self):
method torrent_parser (line 22) | def torrent_parser(
method tmdb_parser (line 34) | async def tmdb_parser(title: str, season: int, language: str):
method tmdb_poster_parser (line 46) | async def tmdb_poster_parser(bangumi: Bangumi):
method raw_parser (line 60) | def raw_parser(raw: str) -> Bangumi | None:
method mikan_parser (line 113) | async def mikan_parser(homepage: str) -> tuple[str, str]:
FILE: backend/src/module/rss/analyser.py
class RSSAnalyser (line 14) | class RSSAnalyser(TitleParser):
method official_title_parser (line 15) | async def official_title_parser(self, bangumi: Bangumi, rss: RSSItem, ...
method get_rss_torrents (line 38) | async def get_rss_torrents(rss_link: str, full_parse: bool = True) -> ...
method torrents_to_data (line 46) | async def torrents_to_data(
method torrent_to_data (line 62) | async def torrent_to_data(self, torrent: Torrent, rss: RSSItem) -> Ban...
method rss_to_data (line 69) | async def rss_to_data(
method link_to_data (line 86) | async def link_to_data(self, rss: RSSItem) -> Bangumi | ResponseModel:
FILE: backend/src/module/rss/engine.py
class RSSEngine (line 15) | class RSSEngine(Database):
method __init__ (line 16) | def __init__(self, _engine=engine):
method _get_torrents (line 22) | async def _get_torrents(rss: RSSItem) -> list[Torrent]:
method get_rss_torrents (line 30) | def get_rss_torrents(self, rss_id: int) -> list[Torrent]:
method add_rss (line 37) | async def add_rss(
method disable_list (line 70) | def disable_list(self, rss_id_list: list[int]):
method enable_list (line 79) | def enable_list(self, rss_id_list: list[int]):
method delete_list (line 88) | def delete_list(self, rss_id_list: list[int]):
method pull_rss (line 98) | async def pull_rss(self, rss_item: RSSItem) -> list[Torrent]:
method _pull_rss_with_status (line 103) | async def _pull_rss_with_status(
method _get_filter_pattern (line 113) | def _get_filter_pattern(self, filter_str: str) -> re.Pattern:
method match_torrent (line 134) | def match_torrent(self, torrent: Torrent) -> Optional[Bangumi]:
method refresh_rss (line 145) | async def refresh_rss(self, client: DownloadClient, rss_id: Optional[i...
method download_bangumi (line 175) | async def download_bangumi(self, bangumi: Bangumi):
FILE: backend/src/module/searcher/provider.py
function search_url (line 7) | def search_url(site: str, keywords: list[str]) -> RSSItem:
FILE: backend/src/module/searcher/searcher.py
class SearchTorrent (line 29) | class SearchTorrent(RequestContent, RSSAnalyser):
method search_torrents (line 30) | async def search_torrents(self, rss_item: RSSItem) -> list[Torrent]:
method _fetch_tmdb_poster (line 33) | async def _fetch_tmdb_poster(self, title: str) -> str | None:
method analyse_keyword (line 49) | async def analyse_keyword(
method special_url (line 73) | def special_url(data: Bangumi, site: str) -> RSSItem:
method search_season (line 78) | async def search_season(self, data: Bangumi, site: str = "mikan") -> l...
FILE: backend/src/module/security/api.py
function check_login_ip (line 25) | def check_login_ip(request: Request):
function get_current_user (line 41) | async def get_current_user(request: Request, token: str = Cookie(None)):
function get_token_data (line 66) | async def get_token_data(token: str = Depends(oauth2_scheme)):
function update_user_info (line 76) | def update_user_info(user_data: UserUpdate, current_user):
function auth_user (line 86) | def auth_user(user: User):
FILE: backend/src/module/security/auth_strategy.py
class AuthStrategy (line 15) | class AuthStrategy(ABC):
method authenticate (line 19) | async def authenticate(
class PasskeyAuthStrategy (line 35) | class PasskeyAuthStrategy(AuthStrategy):
method __init__ (line 38) | def __init__(self, webauthn_service):
method authenticate (line 41) | async def authenticate(
FILE: backend/src/module/security/jwt.py
function _load_or_create_secret (line 11) | def _load_or_create_secret() -> str:
function create_access_token (line 28) | def create_access_token(data: dict, expires_delta: timedelta | None = No...
function decode_token (line 40) | def decode_token(token: str):
function verify_token (line 51) | def verify_token(token: str):
function verify_password (line 62) | def verify_password(plain_password, hashed_password):
function get_password_hash (line 66) | def get_password_hash(password):
FILE: backend/src/module/security/webauthn.py
class WebAuthnService (line 35) | class WebAuthnService:
method __init__ (line 38) | def __init__(self, rp_id: str, rp_name: str, origin: str):
method _cleanup_expired (line 54) | def _cleanup_expired(self) -> None:
method _store_challenge (line 64) | def _store_challenge(self, logical_key: str, challenge: bytes) -> None:
method _pop_challenge_by_key (line 72) | def _pop_challenge_by_key(self, logical_key: str) -> bytes | None:
method _pop_challenge_by_value (line 80) | def _pop_challenge_by_value(self, challenge: bytes) -> bytes | None:
method generate_registration_options (line 90) | def generate_registration_options(
method verify_registration (line 136) | def verify_registration(
method generate_authentication_options (line 191) | def generate_authentication_options(
method generate_discoverable_authentication_options (line 224) | def generate_discoverable_authentication_options(self) -> dict:
method verify_authentication (line 245) | def verify_authentication(
method verify_discoverable_authentication (line 285) | def verify_discoverable_authentication(
method _parse_transports (line 333) | def _parse_transports(
method base64url_encode (line 345) | def base64url_encode(self, data: bytes) -> str:
method base64url_decode (line 349) | def base64url_decode(self, data: str) -> bytes:
function get_webauthn_service (line 361) | def get_webauthn_service(rp_id: str, rp_name: str, origin: str) -> WebAu...
FILE: backend/src/module/update/cross_version.py
function from_30_to_31 (line 13) | async def from_30_to_31():
function from_31_to_32 (line 38) | async def from_31_to_32():
function run_migrations (line 46) | def run_migrations():
function cache_image (line 52) | async def cache_image():
FILE: backend/src/module/update/data_migration.py
function data_migration (line 7) | def data_migration():
function database_migration (line 22) | def database_migration():
FILE: backend/src/module/update/rss.py
function update_main_rss (line 4) | def update_main_rss(rss_link: str):
FILE: backend/src/module/update/startup.py
function start_up (line 9) | def start_up():
function first_run (line 16) | def first_run():
FILE: backend/src/module/update/version_check.py
function version_check (line 6) | def version_check() -> tuple[bool, int | None]:
FILE: backend/src/module/utils/cache_image.py
function save_image (line 4) | def save_image(img, suffix):
function load_image (line 12) | def load_image(img_path):
FILE: backend/src/module/utils/json_config.py
function load (line 6) | def load(filename):
function save (line 11) | def save(filename, obj):
function get (line 16) | async def get(url):
FILE: backend/src/test/conftest.py
function _clear_bangumi_cache (line 23) | def _clear_bangumi_cache():
function db_engine (line 36) | def db_engine():
function db_session (line 45) | def db_session(db_engine):
function test_settings (line 57) | def test_settings():
function mock_settings (line 63) | def mock_settings(test_settings):
function mock_qb_client (line 76) | def mock_qb_client():
function app (line 110) | def app():
function authed_client (line 118) | def authed_client(app):
function unauthed_client (line 131) | def unauthed_client(app):
function mock_program (line 142) | def mock_program():
function mock_webauthn (line 173) | def mock_webauthn():
function mock_download_client (line 212) | def mock_download_client():
FILE: backend/src/test/e2e/conftest.py
function pytest_collection_modifyitems (line 24) | def pytest_collection_modifyitems(config, items):
function e2e_tmpdir (line 48) | def e2e_tmpdir(tmp_path_factory):
function docker_services (line 54) | def docker_services():
function qb_password (line 83) | def qb_password(docker_services):
function ab_process (line 99) | def ab_process(e2e_tmpdir, docker_services):
function api_client (line 167) | def api_client(ab_process):
function e2e_state (line 178) | def e2e_state():
FILE: backend/src/test/e2e/mock_rss_server.py
function handle_rss (line 11) | async def handle_rss(request: web.Request) -> web.Response:
function handle_health (line 22) | async def handle_health(request: web.Request) -> web.Response:
function create_app (line 26) | def create_app() -> web.Application:
FILE: backend/src/test/e2e/test_e2e_workflow.py
class TestE2EWorkflow (line 25) | class TestE2EWorkflow:
method test_01_setup_status_needs_setup (line 32) | def test_01_setup_status_needs_setup(self, api_client):
method test_02_verify_infrastructure (line 40) | def test_02_verify_infrastructure(self, api_client, qb_password):
method test_03_mock_rss_nonexistent_feed (line 59) | def test_03_mock_rss_nonexistent_feed(self):
method test_04_test_mock_downloader (line 64) | def test_04_test_mock_downloader(self, api_client):
method test_05_setup_validation_username_too_short (line 78) | def test_05_setup_validation_username_too_short(self, api_client):
method test_06_setup_validation_password_too_short (line 94) | def test_06_setup_validation_password_too_short(self, api_client):
method test_07_complete_setup (line 110) | def test_07_complete_setup(self, api_client, e2e_state):
method test_08_setup_status_complete (line 132) | def test_08_setup_status_complete(self, api_client):
method test_09_setup_complete_blocked (line 138) | def test_09_setup_complete_blocked(self, api_client):
method test_09b_test_downloader_blocked (line 154) | def test_09b_test_downloader_blocked(self, api_client):
method test_09c_test_rss_blocked (line 162) | def test_09c_test_rss_blocked(self, api_client):
method test_10_login (line 174) | def test_10_login(self, api_client, e2e_state):
method test_11_login_cookie_set (line 186) | def test_11_login_cookie_set(self, api_client):
method test_12_access_protected_endpoint (line 190) | def test_12_access_protected_endpoint(self, api_client):
method test_13_refresh_token (line 199) | def test_13_refresh_token(self, api_client, e2e_state):
method test_14_login_wrong_password (line 210) | def test_14_login_wrong_password(self, api_client):
method test_15_login_nonexistent_user (line 218) | def test_15_login_nonexistent_user(self, api_client):
method test_16_unauthenticated_client (line 226) | def test_16_unauthenticated_client(self):
method test_20_get_config (line 240) | def test_20_get_config(self, api_client):
method test_21_config_passwords_masked (line 258) | def test_21_config_passwords_masked(self, api_client):
method test_22_update_config (line 267) | def test_22_update_config(self, api_client):
method test_23_config_update_persisted (line 281) | def test_23_config_update_persisted(self, api_client):
method test_30_list_rss_initial (line 290) | def test_30_list_rss_initial(self, api_client, e2e_state):
method test_31_add_rss_feed (line 300) | def test_31_add_rss_feed(self, api_client, e2e_state):
method test_32_add_rss_duplicate_url (line 313) | def test_32_add_rss_duplicate_url(self, api_client):
method test_33_list_rss_after_add (line 327) | def test_33_list_rss_after_add(self, api_client, e2e_state):
method test_34_disable_rss (line 340) | def test_34_disable_rss(self, api_client, e2e_state):
method test_35_verify_rss_disabled (line 346) | def test_35_verify_rss_disabled(self, api_client, e2e_state):
method test_36_enable_rss (line 356) | def test_36_enable_rss(self, api_client, e2e_state):
method test_37_verify_rss_enabled (line 362) | def test_37_verify_rss_enabled(self, api_client, e2e_state):
method test_38_update_rss (line 370) | def test_38_update_rss(self, api_client, e2e_state):
method test_39_verify_rss_updated (line 379) | def test_39_verify_rss_updated(self, api_client, e2e_state):
method test_39b_delete_nonexistent_rss (line 387) | def test_39b_delete_nonexistent_rss(self, api_client):
method test_39c_disable_nonexistent_rss (line 396) | def test_39c_disable_nonexistent_rss(self, api_client):
method test_39d_delete_rss (line 401) | def test_39d_delete_rss(self, api_client, e2e_state):
method test_39e_verify_rss_deleted (line 407) | def test_39e_verify_rss_deleted(self, api_client, e2e_state):
method test_40_bangumi_get_all_empty (line 418) | def test_40_bangumi_get_all_empty(self, api_client):
method test_41_bangumi_needs_review_empty (line 424) | def test_41_bangumi_needs_review_empty(self, api_client):
method test_42_bangumi_dismiss_review_nonexistent (line 430) | def test_42_bangumi_dismiss_review_nonexistent(self, api_client):
method test_43_bangumi_reset_all (line 435) | def test_43_bangumi_reset_all(self, api_client):
method test_50_downloader_check (line 444) | def test_50_downloader_check(self, api_client):
method test_51_downloader_torrents_empty (line 449) | def test_51_downloader_torrents_empty(self, api_client):
method test_52_downloader_pause_empty (line 455) | def test_52_downloader_pause_empty(self, api_client):
method test_53_downloader_resume_empty (line 462) | def test_53_downloader_resume_empty(self, api_client):
method test_54_downloader_delete_empty (line 469) | def test_54_downloader_delete_empty(self, api_client):
method test_55_downloader_tag_nonexistent_bangumi (line 477) | def test_55_downloader_tag_nonexistent_bangumi(self, api_client):
method test_56_downloader_auto_tag (line 486) | def test_56_downloader_auto_tag(self, api_client):
method test_57_qbittorrent_direct_connectivity (line 494) | def test_57_qbittorrent_direct_connectivity(self, qb_password):
method test_60_program_status_not_running (line 508) | def test_60_program_status_not_running(self, api_client):
method test_61_program_stop_when_not_running (line 520) | def test_61_program_stop_when_not_running(self, api_client):
method test_62_program_start (line 525) | def test_62_program_start(self, api_client):
method test_63_program_stop (line 530) | def test_63_program_stop(self, api_client):
method test_64_program_stop_already_stopped (line 535) | def test_64_program_stop_already_stopped(self, api_client):
method test_65_program_restart (line 540) | def test_65_program_restart(self, api_client):
method test_70_get_log (line 549) | def test_70_get_log(self, api_client):
method test_71_clear_log (line 557) | def test_71_clear_log(self, api_client):
method test_72_get_log_after_clear (line 563) | def test_72_get_log_after_clear(self, api_client):
method test_80_search_providers (line 574) | def test_80_search_providers(self, api_client):
method test_81_search_provider_config (line 582) | def test_81_search_provider_config(self, api_client):
method test_82_search_empty_keywords (line 589) | def test_82_search_empty_keywords(self, api_client):
method test_85_notification_test_invalid_index (line 599) | def test_85_notification_test_invalid_index(self, api_client):
method test_86_notification_test_config_unknown_type (line 607) | def test_86_notification_test_config_unknown_type(self, api_client):
method test_90_update_credentials (line 620) | def test_90_update_credentials(self, api_client, e2e_state):
method test_91_login_with_new_password (line 632) | def test_91_login_with_new_password(self, api_client, e2e_state):
method test_92_login_old_password_fails (line 644) | def test_92_login_old_password_fails(self, api_client):
method test_93_logout (line 652) | def test_93_logout(self, api_client):
method test_94_verify_logged_out (line 657) | def test_94_verify_logged_out(self, api_client):
FILE: backend/src/test/factories.py
function make_bangumi (line 10) | def make_bangumi(**overrides) -> Bangumi:
function make_torrent (line 36) | def make_torrent(**overrides) -> Torrent:
function make_rss_item (line 48) | def make_rss_item(**overrides) -> RSSItem:
function make_config (line 61) | def make_config(**overrides) -> Config:
function make_passkey (line 70) | def make_passkey(**overrides) -> Passkey:
FILE: backend/src/test/test_api_auth.py
function app (line 21) | def app():
function authed_client (line 29) | def authed_client(app):
function unauthed_client (line 42) | def unauthed_client(app):
class TestAuthRequired (line 52) | class TestAuthRequired:
method test_refresh_token_unauthorized (line 54) | def test_refresh_token_unauthorized(self, unauthed_client):
method test_logout_unauthorized (line 60) | def test_logout_unauthorized(self, unauthed_client):
method test_update_unauthorized (line 66) | def test_update_unauthorized(self, unauthed_client):
class TestLogin (line 80) | class TestLogin:
method test_login_success (line 81) | def test_login_success(self, unauthed_client):
method test_login_failure (line 97) | def test_login_failure(self, unauthed_client):
class TestRefreshToken (line 116) | class TestRefreshToken:
method test_refresh_token_success (line 117) | def test_refresh_token_success(self, authed_client):
class TestLogout (line 135) | class TestLogout:
method test_logout_success (line 136) | def test_logout_success(self, authed_client):
class TestUpdateCredentials (line 153) | class TestUpdateCredentials:
method test_update_success (line 154) | def test_update_success(self, authed_client):
method test_update_failure (line 170) | def test_update_failure(self, authed_client):
class TestRefreshTokenCookieBehavior (line 195) | class TestRefreshTokenCookieBehavior:
method test_refresh_with_no_cookie_raises_401 (line 196) | def test_refresh_with_no_cookie_raises_401(self, authed_client):
method test_refresh_with_valid_cookie_updates_active_user (line 203) | def test_refresh_with_valid_cookie_updates_active_user(self, authed_cl...
method test_refresh_returns_new_token (line 213) | def test_refresh_returns_new_token(self, authed_client):
class TestLogoutCookieBehavior (line 231) | class TestLogoutCookieBehavior:
method test_logout_removes_only_current_user (line 232) | def test_logout_removes_only_current_user(self, authed_client):
method test_logout_with_no_cookie_still_succeeds (line 246) | def test_logout_with_no_cookie_still_succeeds(self, authed_client):
class TestUpdateCookieBehavior (line 259) | class TestUpdateCookieBehavior:
method test_update_with_no_cookie_raises_401 (line 260) | def test_update_with_no_cookie_raises_401(self, authed_client):
method test_update_with_valid_cookie_succeeds (line 269) | def test_update_with_valid_cookie_succeeds(self, authed_client):
FILE: backend/src/test/test_api_bangumi.py
function app (line 24) | def app():
function authed_client (line 32) | def authed_client(app):
function unauthed_client (line 44) | def unauthed_client(app):
class TestAuthRequired (line 54) | class TestAuthRequired:
method test_get_all_unauthorized (line 56) | def test_get_all_unauthorized(self, unauthed_client):
method test_get_by_id_unauthorized (line 62) | def test_get_by_id_unauthorized(self, unauthed_client):
method test_delete_unauthorized (line 68) | def test_delete_unauthorized(self, unauthed_client):
class TestGetBangumi (line 79) | class TestGetBangumi:
method test_get_all (line 80) | def test_get_all(self, authed_client):
method test_get_by_id (line 95) | def test_get_by_id(self, authed_client):
class TestUpdateBangumi (line 114) | class TestUpdateBangumi:
method test_update_success (line 115) | def test_update_success(self, authed_client):
class TestDeleteBangumi (line 160) | class TestDeleteBangumi:
method test_delete_success (line 161) | def test_delete_success(self, authed_client):
method test_disable_rule (line 176) | def test_disable_rule(self, authed_client):
method test_enable_rule (line 191) | def test_enable_rule(self, authed_client):
class TestResetBangumi (line 212) | class TestResetBangumi:
method test_reset_all (line 213) | def test_reset_all(self, authed_client):
FILE: backend/src/test/test_api_bangumi_extended.py
function app (line 22) | def app():
function authed_client (line 30) | def authed_client(app):
function unauthed_client (line 43) | def unauthed_client(app):
class TestArchiveBangumi (line 53) | class TestArchiveBangumi:
method test_archive_success (line 54) | def test_archive_success(self, authed_client):
method test_unarchive_success (line 69) | def test_unarchive_success(self, authed_client):
class TestRefreshBangumi (line 90) | class TestRefreshBangumi:
method test_refresh_poster_all (line 91) | def test_refresh_poster_all(self, authed_client):
method test_refresh_poster_one (line 106) | def test_refresh_poster_one(self, authed_client):
method test_refresh_calendar (line 121) | def test_refresh_calendar(self, authed_client):
method test_refresh_metadata (line 136) | def test_refresh_metadata(self, authed_client):
class TestOffsetDetection (line 157) | class TestOffsetDetection:
method test_suggest_offset (line 158) | def test_suggest_offset(self, authed_client):
method test_detect_offset_no_mismatch (line 173) | def test_detect_offset_no_mismatch(self, authed_client):
method test_detect_offset_with_mismatch (line 198) | def test_detect_offset_with_mismatch(self, authed_client):
method test_detect_offset_no_tmdb_data (line 232) | def test_detect_offset_no_tmdb_data(self, authed_client):
class TestNeedsReview (line 255) | class TestNeedsReview:
method test_get_needs_review (line 256) | def test_get_needs_review(self, authed_client):
method test_dismiss_review_success (line 274) | def test_dismiss_review_success(self, authed_client):
method test_dismiss_review_not_found (line 288) | def test_dismiss_review_not_found(self, authed_client):
class TestBatchOperations (line 306) | class TestBatchOperations:
method test_delete_many_auth_required (line 307) | def test_delete_many_auth_required(self, unauthed_client):
method test_disable_many_auth_required (line 319) | def test_disable_many_auth_required(self, unauthed_client):
FILE: backend/src/test/test_api_config.py
function app (line 21) | def app():
function authed_client (line 29) | def authed_client(app):
function unauthed_client (line 42) | def unauthed_client(app):
function mock_settings (line 48) | def mock_settings():
class TestAuthRequired (line 90) | class TestAuthRequired:
method test_get_config_unauthorized (line 92) | def test_get_config_unauthorized(self, unauthed_client):
method test_update_config_unauthorized (line 98) | def test_update_config_unauthorized(self, unauthed_client):
class TestGetConfig (line 109) | class TestGetConfig:
method test_get_config_success (line 110) | def test_get_config_success(self, authed_client):
class TestUpdateConfig (line 130) | class TestUpdateConfig:
method test_update_config_success (line 131) | def test_update_config_success(self, authed_client, mock_settings):
method test_update_config_failure (line 193) | def test_update_config_failure(self, authed_client, mock_settings):
method test_update_config_partial_validation_error (line 254) | def test_update_config_partial_validation_error(self, authed_client):
class TestSanitizeDict (line 274) | class TestSanitizeDict:
method test_masks_password_key (line 275) | def test_masks_password_key(self):
method test_masks_api_key (line 280) | def test_masks_api_key(self):
method test_masks_token_key (line 285) | def test_masks_token_key(self):
method test_masks_secret_key (line 290) | def test_masks_secret_key(self):
method test_case_insensitive_key_matching (line 295) | def test_case_insensitive_key_matching(self):
method test_non_sensitive_keys_pass_through (line 300) | def test_non_sensitive_keys_pass_through(self):
method test_nested_dict_recursed (line 307) | def test_nested_dict_recursed(self):
method test_deeply_nested_dict (line 320) | def test_deeply_nested_dict(self):
method test_non_string_value_not_masked (line 325) | def test_non_string_value_not_masked(self):
method test_empty_dict (line 331) | def test_empty_dict(self):
method test_mixed_sensitive_and_plain (line 335) | def test_mixed_sensitive_and_plain(self):
method test_sanitize_list_of_dicts (line 350) | def test_sanitize_list_of_dicts(self):
method test_get_config_masks_sensitive_fields (line 364) | def test_get_config_masks_sensitive_fields(self, authed_client):
class TestRestoreMasked (line 382) | class TestRestoreMasked:
method test_masked_password_restored (line 385) | def test_masked_password_restored(self):
method test_new_password_preserved (line 392) | def test_new_password_preserved(self):
method test_nested_masked_password_restored (line 399) | def test_nested_masked_password_restored(self):
method test_nested_new_password_preserved (line 406) | def test_nested_new_password_preserved(self):
method test_multiple_sensitive_fields (line 413) | def test_multiple_sensitive_fields(self):
method test_non_sensitive_mask_value_untouched (line 430) | def test_non_sensitive_mask_value_untouched(self):
method test_list_of_dicts_restored (line 437) | def test_list_of_dicts_restored(self):
method test_empty_dicts (line 455) | def test_empty_dicts(self):
method test_round_trip_preserves_credentials (line 459) | def test_round_trip_preserves_credentials(self):
method test_update_config_preserves_password_when_masked (line 475) | def test_update_config_preserves_password_when_masked(
FILE: backend/src/test/test_api_downloader.py
function app (line 19) | def app():
function authed_client (line 27) | def authed_client(app):
function unauthed_client (line 40) | def unauthed_client(app):
function mock_download_client (line 46) | def mock_download_client():
class TestAuthRequired (line 74) | class TestAuthRequired:
method test_get_torrents_unauthorized (line 76) | def test_get_torrents_unauthorized(self, unauthed_client):
method test_pause_torrents_unauthorized (line 82) | def test_pause_torrents_unauthorized(self, unauthed_client):
method test_resume_torrents_unauthorized (line 90) | def test_resume_torrents_unauthorized(self, unauthed_client):
method test_delete_torrents_unauthorized (line 98) | def test_delete_torrents_unauthorized(self, unauthed_client):
class TestGetTorrents (line 112) | class TestGetTorrents:
method test_get_torrents_success (line 113) | def test_get_torrents_success(self, authed_client, mock_download_client):
method test_get_torrents_empty (line 128) | def test_get_torrents_empty(self, authed_client, mock_download_client):
class TestPauseTorrents (line 148) | class TestPauseTorrents:
method test_pause_single_torrent (line 149) | def test_pause_single_torrent(self, authed_client, mock_download_client):
method test_pause_multiple_torrents (line 166) | def test_pause_multiple_torrents(self, authed_client, mock_download_cl...
class TestResumeTorrents (line 189) | class TestResumeTorrents:
method test_resume_single_torrent (line 190) | def test_resume_single_torrent(self, authed_client, mock_download_clie...
method test_resume_multiple_torrents (line 207) | def test_resume_multiple_torrents(self, authed_client, mock_download_c...
class TestDeleteTorrents (line 229) | class TestDeleteTorrents:
method test_delete_single_torrent_keep_files (line 230) | def test_delete_single_torrent_keep_files(
method test_delete_torrent_with_files (line 252) | def test_delete_torrent_with_files(self, authed_client, mock_download_...
method test_delete_multiple_torrents (line 270) | def test_delete_multiple_torrents(self, authed_client, mock_download_c...
class TestTagTorrent (line 294) | class TestTagTorrent:
method test_tag_torrent_success (line 295) | def test_tag_torrent_success(self, authed_client, mock_download_client):
method test_tag_torrent_bangumi_not_found (line 331) | def test_tag_torrent_bangumi_not_found(self, authed_client, mock_downl...
class TestAutoTagTorrents (line 353) | class TestAutoTagTorrents:
method test_auto_tag_success (line 354) | def test_auto_tag_success(self, authed_client, mock_download_client):
method test_auto_tag_no_matches (line 405) | def test_auto_tag_no_matches(self, authed_client, mock_download_client):
FILE: backend/src/test/test_api_log.py
function app (line 21) | def app():
function authed_client (line 29) | def authed_client(app):
function unauthed_client (line 42) | def unauthed_client(app):
function temp_log_file (line 48) | def temp_log_file():
class TestAuthRequired (line 61) | class TestAuthRequired:
method test_get_log_unauthorized (line 63) | def test_get_log_unauthorized(self, unauthed_client):
method test_clear_log_unauthorized (line 69) | def test_clear_log_unauthorized(self, unauthed_client):
class TestGetLog (line 80) | class TestGetLog:
method test_get_log_success (line 81) | def test_get_log_success(self, authed_client, temp_log_file):
method test_get_log_not_found (line 89) | def test_get_log_not_found(self, authed_client):
method test_get_log_multiline (line 97) | def test_get_log_multiline(self, authed_client, temp_log_file):
class TestClearLog (line 118) | class TestClearLog:
method test_clear_log_success (line 119) | def test_clear_log_success(self, authed_client, temp_log_file):
method test_clear_log_not_found (line 133) | def test_clear_log_not_found(self, authed_client):
FILE: backend/src/test/test_api_passkey.py
function app (line 22) | def app():
function authed_client (line 30) | def authed_client(app):
function unauthed_client (line 43) | def unauthed_client(app):
function mock_webauthn (line 49) | def mock_webauthn():
function mock_user_model (line 83) | def mock_user_model():
class TestAuthRequired (line 96) | class TestAuthRequired:
method test_register_options_unauthorized (line 98) | def test_register_options_unauthorized(self, unauthed_client):
method test_register_verify_unauthorized (line 104) | def test_register_verify_unauthorized(self, unauthed_client):
method test_list_passkeys_unauthorized (line 113) | def test_list_passkeys_unauthorized(self, unauthed_client):
method test_delete_passkey_unauthorized (line 119) | def test_delete_passkey_unauthorized(self, unauthed_client):
class TestRegisterOptions (line 132) | class TestRegisterOptions:
method test_get_registration_options_success (line 133) | def test_get_registration_options_success(
method test_get_registration_options_user_not_found (line 165) | def test_get_registration_options_user_not_found(
class TestRegisterVerify (line 193) | class TestRegisterVerify:
method test_verify_registration_success (line 194) | def test_verify_registration_success(
class TestAuthOptions (line 245) | class TestAuthOptions:
method test_get_auth_options_with_username (line 246) | def test_get_auth_options_with_username(self, unauthed_client, mock_we...
method test_get_auth_options_discoverable (line 283) | def test_get_auth_options_discoverable(self, unauthed_client, mock_web...
method test_get_auth_options_user_not_found (line 296) | def test_get_auth_options_user_not_found(self, unauthed_client, mock_w...
class TestAuthVerify (line 324) | class TestAuthVerify:
method test_login_with_passkey_success (line 325) | def test_login_with_passkey_success(self, unauthed_client, mock_webaut...
method test_login_with_passkey_failure (line 365) | def test_login_with_passkey_failure(self, unauthed_client, mock_webaut...
class TestListPasskeys (line 404) | class TestListPasskeys:
method test_list_passkeys_success (line 405) | def test_list_passkeys_success(self, authed_client, mock_user_model):
method test_list_passkeys_empty (line 443) | def test_list_passkeys_empty(self, authed_client, mock_user_model):
class TestDeletePasskey (line 471) | class TestDeletePasskey:
method test_delete_passkey_success (line 472) | def test_delete_passkey_success(self, authed_client, mock_user_model):
FILE: backend/src/test/test_api_program.py
function app (line 20) | def app():
function authed_client (line 28) | def authed_client(app):
function unauthed_client (line 41) | def unauthed_client(app):
function mock_program (line 47) | def mock_program():
class TestAuthRequired (line 76) | class TestAuthRequired:
method test_restart_unauthorized (line 78) | def test_restart_unauthorized(self, unauthed_client):
method test_start_unauthorized (line 84) | def test_start_unauthorized(self, unauthed_client):
method test_stop_unauthorized (line 90) | def test_stop_unauthorized(self, unauthed_client):
method test_status_unauthorized (line 96) | def test_status_unauthorized(self, unauthed_client):
class TestStartProgram (line 107) | class TestStartProgram:
method test_start_success (line 108) | def test_start_success(self, authed_client, mock_program):
method test_start_failure (line 115) | def test_start_failure(self, authed_client, mock_program):
class TestStopProgram (line 129) | class TestStopProgram:
method test_stop_success (line 130) | def test_stop_success(self, authed_client, mock_program):
class TestRestartProgram (line 143) | class TestRestartProgram:
method test_restart_success (line 144) | def test_restart_success(self, authed_client, mock_program):
method test_restart_failure (line 151) | def test_restart_failure(self, authed_client, mock_program):
class TestProgramStatus (line 165) | class TestProgramStatus:
method test_status_running (line 166) | def test_status_running(self, authed_client, mock_program):
method test_status_stopped (line 180) | def test_status_stopped(self, authed_client, mock_program):
class TestCheckDownloader (line 199) | class TestCheckDownloader:
method test_check_downloader_connected (line 200) | def test_check_downloader_connected(self, authed_client, mock_program):
method test_check_downloader_disconnected (line 209) | def test_check_downloader_disconnected(self, authed_client, mock_progr...
FILE: backend/src/test/test_api_rss.py
function app (line 22) | def app():
function authed_client (line 29) | def authed_client(app):
function unauthed_client (line 40) | def unauthed_client(app):
class TestAuthRequired (line 49) | class TestAuthRequired:
method test_get_rss_unauthorized (line 51) | def test_get_rss_unauthorized(self, unauthed_client):
method test_add_rss_unauthorized (line 57) | def test_add_rss_unauthorized(self, unauthed_client):
class TestGetRss (line 70) | class TestGetRss:
method test_get_all (line 71) | def test_get_all(self, authed_client):
class TestAddRss (line 95) | class TestAddRss:
method test_add_success (line 96) | def test_add_success(self, authed_client):
class TestDeleteRss (line 125) | class TestDeleteRss:
method test_delete_success (line 126) | def test_delete_success(self, authed_client):
method test_delete_failure (line 138) | def test_delete_failure(self, authed_client):
class TestDisableRss (line 156) | class TestDisableRss:
method test_disable_success (line 157) | def test_disable_success(self, authed_client):
method test_disable_failure (line 169) | def test_disable_failure(self, authed_client):
class TestBatchOperations (line 187) | class TestBatchOperations:
method test_enable_many (line 188) | def test_enable_many(self, authed_client):
method test_disable_many (line 203) | def test_disable_many(self, authed_client):
method test_delete_many (line 218) | def test_delete_many(self, authed_client):
class TestUpdateRss (line 239) | class TestUpdateRss:
method test_update_success (line 240) | def test_update_success(self, authed_client):
class TestRefreshRss (line 261) | class TestRefreshRss:
method test_refresh_all (line 262) | def test_refresh_all(self, authed_client):
method test_refresh_single (line 278) | def test_refresh_single(self, authed_client):
class TestGetRssTorrents (line 300) | class TestGetRssTorrents:
method test_get_torrents (line 301) | def test_get_torrents(self, authed_client):
FILE: backend/src/test/test_api_search.py
function app (line 19) | def app():
function authed_client (line 27) | def authed_client(app):
function unauthed_client (line 40) | def unauthed_client(app):
class TestAuthRequired (line 50) | class TestAuthRequired:
method test_search_bangumi_unauthorized (line 52) | def test_search_bangumi_unauthorized(self, unauthed_client):
method test_search_provider_unauthorized (line 60) | def test_search_provider_unauthorized(self, unauthed_client):
method test_get_provider_config_unauthorized (line 66) | def test_get_provider_config_unauthorized(self, unauthed_client):
class TestSearchBangumi (line 77) | class TestSearchBangumi:
method test_search_no_keywords (line 78) | def test_search_no_keywords(self, authed_client):
method test_search_with_keywords_auth_required (line 85) | def test_search_with_keywords_auth_required(self, unauthed_client):
class TestSearchProvider (line 99) | class TestSearchProvider:
method test_get_provider_list (line 100) | def test_get_provider_list(self, authed_client):
class TestSearchProviderConfig (line 118) | class TestSearchProviderConfig:
method test_get_provider_config (line 119) | def test_get_provider_config(self, authed_client):
class TestUpdateProviderConfig (line 139) | class TestUpdateProviderConfig:
method test_update_provider_config_success (line 140) | def test_update_provider_config_success(self, authed_client):
method test_update_provider_config_empty (line 158) | def test_update_provider_config_empty(self, authed_client):
FILE: backend/src/test/test_auth.py
class TestCreateAccessToken (line 22) | class TestCreateAccessToken:
method test_creates_valid_token (line 23) | def test_creates_valid_token(self):
method test_token_contains_sub_claim (line 30) | def test_token_contains_sub_claim(self):
method test_token_contains_exp_claim (line 37) | def test_token_contains_exp_claim(self):
method test_custom_expiry (line 43) | def test_custom_expiry(self):
class TestDecodeToken (line 57) | class TestDecodeToken:
method test_valid_token (line 58) | def test_valid_token(self):
method test_invalid_token (line 65) | def test_invalid_token(self):
method test_empty_token (line 70) | def test_empty_token(self):
method test_missing_sub_claim (line 75) | def test_missing_sub_claim(self):
class TestVerifyToken (line 88) | class TestVerifyToken:
method test_valid_fresh_token (line 89) | def test_valid_fresh_token(self):
method test_expired_token_returns_none (line 98) | def test_expired_token_returns_none(self):
method test_invalid_token_returns_none (line 108) | def test_invalid_token_returns_none(self):
class TestPasswordHashing (line 119) | class TestPasswordHashing:
method test_hash_and_verify_roundtrip (line 120) | def test_hash_and_verify_roundtrip(self):
method test_wrong_password (line 126) | def test_wrong_password(self):
method test_hash_is_not_plaintext (line 131) | def test_hash_is_not_plaintext(self):
method test_different_hashes_for_same_password (line 137) | def test_different_hashes_for_same_password(self):
class TestGetCurrentUser (line 153) | class TestGetCurrentUser:
method _mock_request (line 155) | def _mock_request(authorization=""):
method test_no_cookie_raises_401 (line 164) | async def test_no_cookie_raises_401(self):
method test_invalid_token_raises_401 (line 175) | async def test_invalid_token_raises_401(self):
method test_valid_token_user_not_active (line 186) | async def test_valid_token_user_not_active(self):
method test_valid_token_active_user_succeeds (line 202) | async def test_valid_token_active_user_succeeds(self):
method test_dev_bypass_skips_auth (line 221) | async def test_dev_bypass_skips_auth(self):
method test_bearer_token_bypass_valid (line 229) | async def test_bearer_token_bypass_valid(self):
method test_bearer_token_bypass_invalid (line 242) | async def test_bearer_token_bypass_invalid(self):
class TestCheckLoginIp (line 263) | class TestCheckLoginIp:
method _make_request (line 265) | def _make_request(host: str | None):
method test_empty_whitelist_allows_all (line 276) | def test_empty_whitelist_allows_all(self):
method test_allowed_ip_passes (line 287) | def test_allowed_ip_passes(self):
method test_blocked_ip_raises_403 (line 297) | def test_blocked_ip_raises_403(self):
method test_no_client_raises_403_when_whitelist_set (line 311) | def test_no_client_raises_403_when_whitelist_set(self):
FILE: backend/src/test/test_config.py
class TestConfigDefaults (line 28) | class TestConfigDefaults:
method test_program_defaults (line 29) | def test_program_defaults(self):
method test_downloader_defaults (line 36) | def test_downloader_defaults(self):
method test_rss_parser_defaults (line 43) | def test_rss_parser_defaults(self):
method test_bangumi_manage_defaults (line 50) | def test_bangumi_manage_defaults(self):
method test_proxy_defaults (line 59) | def test_proxy_defaults(self):
method test_notification_defaults (line 65) | def test_notification_defaults(self):
class TestConfigSerialization (line 77) | class TestConfigSerialization:
method test_dict_uses_alias (line 78) | def test_dict_uses_alias(self):
method test_roundtrip_json (line 86) | def test_roundtrip_json(self, tmp_path):
class TestMigrateOldConfig (line 107) | class TestMigrateOldConfig:
method test_sleep_time_to_rss_time (line 108) | def test_sleep_time_to_rss_time(self):
method test_times_to_rename_time (line 118) | def test_times_to_rename_time(self):
method test_removes_data_version (line 128) | def test_removes_data_version(self):
method test_removes_deprecated_rss_parser_fields (line 137) | def test_removes_deprecated_rss_parser_fields(self):
method test_no_migration_needed (line 156) | def test_no_migration_needed(self):
method test_both_old_and_new_fields (line 166) | def test_both_old_and_new_fields(self):
class TestSettingsLoad (line 182) | class TestSettingsLoad:
method test_load_from_json_file (line 183) | def test_load_from_json_file(self, tmp_path):
method test_save_writes_json (line 199) | def test_save_writes_json(self, tmp_path):
class TestEnvOverrides (line 220) | class TestEnvOverrides:
method test_downloader_host_from_env (line 221) | def test_downloader_host_from_env(self, tmp_path):
class TestSecurityModel (line 240) | class TestSecurityModel:
method test_security_defaults (line 241) | def test_security_defaults(self):
method test_security_in_config (line 249) | def test_security_in_config(self):
method test_security_populated (line 256) | def test_security_populated(self):
method test_security_roundtrip_serialization (line 269) | def test_security_roundtrip_serialization(self):
class TestNotificationProvider (line 286) | class TestNotificationProvider:
method test_minimal_provider (line 287) | def test_minimal_provider(self):
method test_telegram_provider_fields (line 293) | def test_telegram_provider_fields(self):
method test_discord_provider_fields (line 299) | def test_discord_provider_fields(self):
method test_bark_provider_fields (line 306) | def test_bark_provider_fields(self):
method test_pushover_provider_fields (line 314) | def test_pushover_provider_fields(self):
method test_url_field_property (line 320) | def test_url_field_property(self):
method test_optional_fields_default_empty_string (line 325) | def test_optional_fields_default_empty_string(self):
method test_provider_can_be_disabled (line 332) | def test_provider_can_be_disabled(self):
method test_env_var_expansion_in_token (line 337) | def test_env_var_expansion_in_token(self, monkeypatch):
class TestNotificationLegacyMigration (line 349) | class TestNotificationLegacyMigration:
method test_new_format_no_migration (line 350) | def test_new_format_no_migration(self):
method test_old_format_migrates_to_provider (line 359) | def test_old_format_migrates_to_provider(self):
method test_old_format_no_migration_when_providers_already_set (line 372) | def test_old_format_no_migration_when_providers_already_set(self):
method test_notification_empty_providers_by_default (line 383) | def test_notification_empty_providers_by_default(self):
class TestDownloaderEnvExpansion (line 395) | class TestDownloaderEnvExpansion:
method test_host_expands_env_var (line 396) | def test_host_expands_env_var(self, monkeypatch):
method test_username_expands_env_var (line 402) | def test_username_expands_env_var(self, monkeypatch):
method test_password_expands_env_var (line 408) | def test_password_expands_env_var(self, monkeypatch):
method test_literal_host_not_expanded (line 414) | def test_literal_host_not_expanded(self):
class TestDefaultSettings (line 425) | class TestDefaultSettings:
method test_security_section_present (line 426) | def test_security_section_present(self):
method test_security_default_mcp_whitelist (line 430) | def test_security_default_mcp_whitelist(self):
method test_security_default_tokens_empty (line 437) | def test_security_default_tokens_empty(self):
method test_notification_uses_providers_format (line 442) | def test_notification_uses_providers_format(self):
class TestBCOLORS (line 455) | class TestBCOLORS:
method test_wrap_single_string (line 456) | def test_wrap_single_string(self):
method test_wrap_multiple_strings (line 463) | def test_wrap_multiple_strings(self):
method test_wrap_non_string_arg (line 469) | def test_wrap_non_string_arg(self):
method test_all_color_constants_are_strings (line 474) | def test_all_color_constants_are_strings(self):
class TestMigrateSecuritySection (line 485) | class TestMigrateSecuritySection:
method test_adds_security_when_missing (line 486) | def test_adds_security_when_missing(self):
method test_preserves_existing_security_section (line 496) | def test_preserves_existing_security_section(self):
FILE: backend/src/test/test_database.py
function db_session (line 16) | def db_session():
function test_bangumi_database (line 23) | def test_bangumi_database(db_session):
function test_torrent_database (line 73) | def test_torrent_database(db_session):
function test_rss_database (line 92) | def test_rss_database(db_session):
function test_torrent_search_by_qb_hash (line 106) | def test_torrent_search_by_qb_hash(db_session):
function test_torrent_search_by_qb_hash_not_found (line 125) | def test_torrent_search_by_qb_hash_not_found(db_session):
function test_torrent_search_by_url (line 133) | def test_torrent_search_by_url(db_session):
function test_torrent_search_by_url_not_found (line 151) | def test_torrent_search_by_url_not_found(db_session):
function test_torrent_update_qb_hash (line 159) | def test_torrent_update_qb_hash(db_session):
function test_torrent_update_qb_hash_nonexistent (line 180) | def test_torrent_update_qb_hash_nonexistent(db_session):
function test_torrent_with_bangumi_id (line 188) | def test_torrent_with_bangumi_id(db_session):
function test_torrent_qb_hash_index_efficient (line 207) | def test_torrent_qb_hash_index_efficient(db_session):
function test_add_title_alias (line 239) | def test_add_title_alias(db_session):
function test_add_title_alias_duplicate (line 266) | def test_add_title_alias_duplicate(db_session):
function test_add_title_alias_same_as_title_raw (line 288) | def test_add_title_alias_same_as_title_raw(db_session):
function test_match_torrent_with_alias (line 308) | def test_match_torrent_with_alias(db_session):
function test_find_semantic_duplicate_same_official_title (line 339) | def test_find_semantic_duplicate_same_official_title(db_session):
function test_find_semantic_duplicate_no_match_different_resolution (line 372) | def test_find_semantic_duplicate_no_match_different_resolution(db_session):
function test_add_with_semantic_duplicate_creates_alias (line 402) | def test_add_with_semantic_duplicate_creates_alias(db_session):
class TestDeleteByBangumiId (line 443) | class TestDeleteByBangumiId:
method test_deletes_matching_torrents (line 446) | def test_deletes_matching_torrents(self, db_session):
method test_leaves_other_bangumi_torrents (line 456) | def test_leaves_other_bangumi_torrents(self, db_session):
method test_no_match_returns_zero (line 467) | def test_no_match_returns_zero(self, db_session):
method test_skips_null_bangumi_id (line 475) | def test_skips_null_bangumi_id(self, db_session):
method test_check_new_finds_urls_after_cleanup (line 486) | def test_check_new_finds_urls_after_cleanup(self, db_session):
function test_groups_are_similar (line 503) | def test_groups_are_similar():
function test_get_all_title_patterns (line 524) | def test_get_all_title_patterns(db_session):
function test_match_list_with_aliases (line 554) | def test_match_list_with_aliases(db_session):
FILE: backend/src/test/test_download_client.py
function download_client (line 14) | def download_client(mock_qb_client):
class TestAuth (line 38) | class TestAuth:
method test_auth_success (line 39) | async def test_auth_success(self, download_client, mock_qb_client):
method test_auth_failure (line 45) | async def test_auth_failure(self, download_client, mock_qb_client):
class TestInitDownloader (line 57) | class TestInitDownloader:
method test_sets_prefs_and_category (line 58) | async def test_sets_prefs_and_category(self, download_client, mock_qb_...
method test_detects_path_when_empty (line 70) | async def test_detects_path_when_empty(self, download_client, mock_qb_...
method test_category_already_exists_no_error (line 80) | async def test_category_already_exists_no_error(self, download_client,...
class TestSetRule (line 94) | class TestSetRule:
method test_generates_correct_rule (line 95) | async def test_generates_correct_rule(self, download_client, mock_qb_c...
method test_marks_bangumi_added (line 118) | async def test_marks_bangumi_added(self, download_client, mock_qb_clie...
method test_rule_name_set (line 128) | async def test_rule_name_set(self, download_client, mock_qb_client):
method test_rule_name_with_group_tag (line 146) | async def test_rule_name_with_group_tag(self, download_client, mock_qb...
class TestAddTorrent (line 167) | class TestAddTorrent:
method test_magnet_url (line 168) | async def test_magnet_url(self, download_client, mock_qb_client):
method test_file_url_downloads_content (line 185) | async def test_file_url_downloads_content(self, download_client, mock_...
method test_list_magnet_urls (line 203) | async def test_list_magnet_urls(self, download_client, mock_qb_client):
method test_empty_list_returns_false (line 223) | async def test_empty_list_returns_false(self, download_client, mock_qb...
method test_client_rejects_returns_false (line 235) | async def test_client_rejects_returns_false(self, download_client, moc...
method test_generates_save_path_if_missing (line 250) | async def test_generates_save_path_if_missing(self, download_client, m...
class TestClientDelegation (line 272) | class TestClientDelegation:
method test_get_torrent_info (line 273) | async def test_get_torrent_info(self, download_client, mock_qb_client):
method test_rename_torrent_file_success (line 284) | async def test_rename_torrent_file_success(self, download_client, mock...
method test_rename_torrent_file_failure (line 290) | async def test_rename_torrent_file_failure(self, download_client, mock...
method test_rename_torrent_file_passes_verify_flag (line 296) | async def test_rename_torrent_file_passes_verify_flag(
method test_delete_torrent (line 307) | async def test_delete_torrent(self, download_client, mock_qb_client):
class TestAddTag (line 318) | class TestAddTag:
method test_add_tag_delegates_to_client (line 319) | async def test_add_tag_delegates_to_client(self, download_client, mock...
method test_add_tag_short_hash_no_error (line 326) | async def test_add_tag_short_hash_no_error(self, download_client, mock...
class TestContextManagerAuth (line 339) | class TestContextManagerAuth:
method test_aenter_raises_on_auth_failure (line 340) | async def test_aenter_raises_on_auth_failure(self, download_client, mo...
method test_aenter_succeeds_when_auth_passes (line 347) | async def test_aenter_succeeds_when_auth_passes(self, download_client,...
method test_aexit_calls_logout_when_authed (line 355) | async def test_aexit_calls_logout_when_authed(self, download_client, m...
FILE: backend/src/test/test_integration.py
function clear_cache (line 18) | def clear_cache():
class TestRssToDownloadFlow (line 29) | class TestRssToDownloadFlow:
method test_full_flow (line 32) | async def test_full_flow(self, db_engine):
method test_filtered_torrents_not_downloaded (line 93) | async def test_filtered_torrents_not_downloaded(self, db_engine):
method test_duplicate_torrents_not_reprocessed (line 126) | async def test_duplicate_torrents_not_reprocessed(self, db_engine):
class TestRenameFlow (line 172) | class TestRenameFlow:
method test_single_file_rename (line 175) | async def test_single_file_rename(self, mock_qb_client):
method test_collection_rename (line 234) | async def test_collection_rename(self, mock_qb_client):
class TestDatabaseConsistency (line 298) | class TestDatabaseConsistency:
method test_bangumi_uniqueness_by_title_raw (line 301) | def test_bangumi_uniqueness_by_title_raw(self, db_engine):
method test_rss_uniqueness_by_url (line 315) | def test_rss_uniqueness_by_url(self, db_engine):
method test_torrent_check_new_filters_duplicates (line 325) | def test_torrent_check_new_filters_duplicates(self, db_engine):
method test_match_torrent_respects_deleted_flag (line 341) | def test_match_torrent_respects_deleted_flag(self, db_engine):
method test_bangumi_disable_and_enable (line 355) | def test_bangumi_disable_and_enable(self, db_engine):
FILE: backend/src/test/test_issue_bugs.py
class TestIssue986AtlasSubGroupFormat (line 30) | class TestIssue986AtlasSubGroupFormat:
method test_get_group_extracts_atlas_group (line 39) | def test_get_group_extracts_atlas_group(self):
method test_process_returns_none_for_atlas_format (line 45) | def test_process_returns_none_for_atlas_format(self):
method test_raw_parser_returns_none_for_atlas_format (line 55) | def test_raw_parser_returns_none_for_atlas_format(self):
method test_atlas_titles_all_fail_to_parse (line 63) | def test_atlas_titles_all_fail_to_parse(self, title):
method test_get_group_returns_empty_for_no_brackets (line 68) | def test_get_group_returns_empty_for_no_brackets(self):
method test_get_group_does_not_crash_on_empty_string (line 73) | def test_get_group_does_not_crash_on_empty_string(self):
class TestIssue977EpisodeZeroOffset (line 89) | class TestIssue977EpisodeZeroOffset:
method test_episode_zero_preserved_with_no_offset (line 92) | def test_episode_zero_preserved_with_no_offset(self):
method test_episode_zero_immune_to_positive_offset (line 106) | def test_episode_zero_immune_to_positive_offset(self):
method test_episode_zero_immune_to_negative_offset (line 120) | def test_episode_zero_immune_to_negative_offset(self):
method test_regular_episode_offset_still_works (line 134) | def test_regular_episode_offset_still_works(self):
method test_episode_zero_advance_method (line 146) | def test_episode_zero_advance_method(self):
class TestIssue976NoneInMatchList (line 171) | class TestIssue976NoneInMatchList:
method test_match_list_filters_none_title_raw (line 174) | def test_match_list_filters_none_title_raw(self, db_session):
method test_sorted_with_none_key_raises_typeerror (line 202) | def test_sorted_with_none_key_raises_typeerror(self):
method test_empty_title_index_produces_empty_pattern (line 208) | def test_empty_title_index_produces_empty_pattern(self):
method test_get_group_no_brackets_returns_empty (line 215) | def test_get_group_no_brackets_returns_empty(self):
method test_get_group_single_bracket_pair (line 222) | def test_get_group_single_bracket_pair(self):
method test_get_group_empty_brackets (line 227) | def test_get_group_empty_brackets(self):
class TestIssue974FilterPatternError (line 243) | class TestIssue974FilterPatternError:
method test_normal_filter_compiles (line 246) | def test_normal_filter_compiles(self):
method test_raw_unterminated_bracket_is_invalid_regex (line 255) | def test_raw_unterminated_bracket_is_invalid_regex(self):
method test_engine_handles_unterminated_bracket (line 262) | def test_engine_handles_unterminated_bracket(self):
method test_engine_handles_unmatched_parenthesis (line 275) | def test_engine_handles_unmatched_parenthesis(self):
method test_engine_handles_trailing_backslash (line 285) | def test_engine_handles_trailing_backslash(self):
method test_engine_default_filter_still_uses_regex (line 294) | def test_engine_default_filter_still_uses_regex(self):
method test_engine_caches_filter_pattern (line 305) | def test_engine_caches_filter_pattern(self):
class TestIssue990NumberPrefixTitle (line 327) | class TestIssue990NumberPrefixTitle:
method test_raw_parser_correctly_parses_leading_number_title (line 334) | def test_raw_parser_correctly_parses_leading_number_title(self):
method test_title_parser_returns_bangumi_for_number_prefix_title (line 343) | def test_title_parser_returns_bangumi_for_number_prefix_title(self):
method test_add_title_alias_rejects_none (line 352) | def test_add_title_alias_rejects_none(self, db_session):
method test_add_title_alias_rejects_empty_string (line 371) | def test_add_title_alias_rejects_empty_string(self, db_session):
method test_get_aliases_list_filters_null_values (line 388) | def test_get_aliases_list_filters_null_values(self):
method test_get_all_title_patterns_skips_none_title_raw (line 402) | def test_get_all_title_patterns_skips_none_title_raw(self, db_session):
method test_match_torrent_no_crash_on_none_title_raw (line 415) | def test_match_torrent_no_crash_on_none_title_raw(self, db_session):
method test_match_torrent_no_crash_on_null_aliases (line 436) | def test_match_torrent_no_crash_on_null_aliases(self, db_session):
method test_match_list_no_crash_on_corrupted_data (line 458) | def test_match_list_no_crash_on_corrupted_data(self, db_session):
class TestIssue992NonEpisodicAttributeError (line 497) | class TestIssue992NonEpisodicAttributeError:
method test_title_parser_returns_none_for_non_episodic (line 507) | def test_title_parser_returns_none_for_non_episodic(self, title):
method test_raw_parser_returns_none_for_unparseable (line 514) | def test_raw_parser_returns_none_for_unparseable(self):
class TestIssue1005SearchOfficialTitle (line 526) | class TestIssue1005SearchOfficialTitle:
method test_method_exists (line 529) | def test_method_exists(self):
method test_search_official_title_finds_match (line 535) | def test_search_official_title_finds_match(self, db_session):
method test_search_official_title_returns_none_when_not_found (line 553) | def test_search_official_title_returns_none_when_not_found(self, db_se...
FILE: backend/src/test/test_mcp_resources.py
function _mock_sync_manager (line 22) | def _mock_sync_manager(bangumi_list=None, single=None):
function _mock_rss_engine (line 36) | def _mock_rss_engine(feeds):
function _parse (line 47) | def _parse(raw: str) -> dict | list:
class TestResourceMetadata (line 56) | class TestResourceMetadata:
method test_resources_is_list (line 59) | def test_resources_is_list(self):
method test_resources_not_empty (line 62) | def test_resources_not_empty(self):
method test_resource_uris_present (line 65) | def test_resource_uris_present(self):
method test_resource_templates_is_list (line 71) | def test_resource_templates_is_list(self):
method test_anime_id_template_present (line 74) | def test_anime_id_template_present(self):
class TestBangumiToDictResources (line 84) | class TestBangumiToDictResources:
method sample (line 91) | def sample(self) -> Bangumi:
method test_returns_dict (line 109) | def test_returns_dict(self, sample):
method test_required_keys_present (line 112) | def test_required_keys_present(self, sample):
method test_id_value (line 132) | def test_id_value(self, sample):
method test_official_title_value (line 135) | def test_official_title_value(self, sample):
method test_eps_collect_true (line 138) | def test_eps_collect_true(self, sample):
method test_none_optional_poster (line 141) | def test_none_optional_poster(self):
class TestHandleResourceAnimeList (line 151) | class TestHandleResourceAnimeList:
method test_returns_json_string (line 154) | def test_returns_json_string(self):
method test_empty_database_returns_empty_list (line 162) | def test_empty_database_returns_empty_list(self):
method test_multiple_bangumi_serialised (line 169) | def test_multiple_bangumi_serialised(self):
method test_ids_are_in_output (line 177) | def test_ids_are_in_output(self):
method test_non_ascii_titles_preserved (line 186) | def test_non_ascii_titles_preserved(self):
class TestHandleResourceStatus (line 196) | class TestHandleResourceStatus:
method mock_program (line 200) | def mock_program(self):
method test_returns_json_string (line 206) | def test_returns_json_string(self, mock_program):
method test_version_in_output (line 215) | def test_version_in_output(self, mock_program):
method test_running_true (line 223) | def test_running_true(self, mock_program):
method test_first_run_false (line 232) | def test_first_run_false(self, mock_program):
method test_all_keys_present (line 241) | def test_all_keys_present(self, mock_program):
class TestHandleResourceRssFeeds (line 250) | class TestHandleResourceRssFeeds:
method _make_feed (line 253) | def _make_feed(self, feed_id=1, name="TestFeed", url="https://example....
method test_returns_json_string (line 263) | def test_returns_json_string(self):
method test_empty_feeds_returns_empty_list (line 270) | def test_empty_feeds_returns_empty_list(self):
method test_feed_fields_present (line 276) | def test_feed_fields_present(self):
method test_multiple_feeds (line 289) | def test_multiple_feeds(self):
class TestHandleResourceAnimeById (line 305) | class TestHandleResourceAnimeById:
method test_valid_id_returns_bangumi_dict (line 308) | def test_valid_id_returns_bangumi_dict(self):
method test_not_found_id_returns_error (line 317) | def test_not_found_id_returns_error(self):
method test_non_numeric_id_returns_error (line 327) | def test_non_numeric_id_returns_error(self):
method test_negative_id_is_passed_to_manager (line 333) | def test_negative_id_is_passed_to_manager(self):
method test_result_has_required_fields (line 343) | def test_result_has_required_fields(self):
class TestHandleResourceUnknown (line 358) | class TestHandleResourceUnknown:
method test_unknown_uri_returns_json_error (line 361) | def test_unknown_uri_returns_json_error(self):
method test_unknown_uri_mentions_uri_in_error (line 366) | def test_unknown_uri_mentions_uri_in_error(self):
method test_empty_uri_returns_error (line 372) | def test_empty_uri_returns_error(self):
method test_completely_different_scheme_returns_error (line 377) | def test_completely_different_scheme_returns_error(self):
FILE: backend/src/test/test_mcp_security.py
class TestIsAllowed (line 19) | class TestIsAllowed:
method setup_method (line 22) | def setup_method(self):
method test_ipv4_loopback_allowed (line 37) | def test_ipv4_loopback_allowed(self):
method test_ipv4_loopback_range (line 40) | def test_ipv4_loopback_range(self):
method test_ipv4_10_network (line 43) | def test_ipv4_10_network(self):
method test_ipv4_172_16_network (line 46) | def test_ipv4_172_16_network(self):
method test_ipv4_192_168_network (line 49) | def test_ipv4_192_168_network(self):
method test_ipv6_loopback (line 52) | def test_ipv6_loopback(self):
method test_ipv6_link_local (line 55) | def test_ipv6_link_local(self):
method test_ipv6_ula (line 58) | def test_ipv6_ula(self):
method test_public_ipv4_denied (line 63) | def test_public_ipv4_denied(self):
method test_public_ipv6_denied (line 66) | def test_public_ipv6_denied(self):
method test_172_outside_range (line 69) | def test_172_outside_range(self):
method test_empty_whitelist_denies_all (line 74) | def test_empty_whitelist_denies_all(self):
method test_invalid_hostname (line 79) | def test_invalid_hostname(self):
method test_empty_string (line 82) | def test_empty_string(self):
method test_malformed_ipv4 (line 85) | def test_malformed_ipv4(self):
method test_single_ip_whitelist (line 90) | def test_single_ip_whitelist(self):
function _make_mcp_settings (line 100) | def _make_mcp_settings(mcp_whitelist=None, mcp_tokens=None):
function _make_app (line 115) | def _make_app() -> Starlette:
function _patch_client_ip (line 126) | def _patch_client_ip(app, ip):
class TestMcpAccessMiddleware (line 139) | class TestMcpAccessMiddleware:
method setup_method (line 142) | def setup_method(self):
method test_allowed_ip_passes (line 145) | def test_allowed_ip_passes(self):
method test_denied_ip_blocked (line 154) | def test_denied_ip_blocked(self):
method test_empty_whitelist_denies_all (line 163) | def test_empty_whitelist_denies_all(self):
method test_missing_client_blocked (line 171) | def test_missing_client_blocked(self):
method test_bearer_token_bypasses_ip (line 179) | def test_bearer_token_bypasses_ip(self):
method test_invalid_bearer_token_denied (line 191) | def test_invalid_bearer_token_denied(self):
method test_private_network_with_default_whitelist (line 203) | def test_private_network_with_default_whitelist(self):
method test_blocked_response_is_json (line 220) | def test_blocked_response_is_json(self):
FILE: backend/src/test/test_mcp_tools.py
function _make_response (line 22) | def _make_response(status: bool = True, msg: str = "OK") -> ResponseModel:
function _mock_sync_manager (line 26) | def _mock_sync_manager(bangumi_list=None, single=None):
class TestBangumiToDict (line 47) | class TestBangumiToDict:
method sample_bangumi (line 51) | def sample_bangumi(self) -> Bangumi:
method test_returns_dict (line 73) | def test_returns_dict(self, sample_bangumi):
method test_id_field (line 78) | def test_id_field(self, sample_bangumi):
method test_official_title_field (line 82) | def test_official_title_field(self, sample_bangumi):
method test_title_raw_field (line 86) | def test_title_raw_field(self, sample_bangumi):
method test_season_field (line 90) | def test_season_field(self, sample_bangumi):
method test_episode_offset_field (line 94) | def test_episode_offset_field(self, sample_bangumi):
method test_season_offset_field (line 98) | def test_season_offset_field(self, sample_bangumi):
method test_rss_link_field (line 102) | def test_rss_link_field(self, sample_bangumi):
method test_deleted_field (line 109) | def test_deleted_field(self, sample_bangumi):
method test_archived_field (line 113) | def test_archived_field(self, sample_bangumi):
method test_eps_collect_field (line 117) | def test_eps_collect_field(self, sample_bangumi):
method test_all_expected_keys_present (line 121) | def test_all_expected_keys_present(self, sample_bangumi):
method test_none_optional_fields (line 146) | def test_none_optional_fields(self):
class TestToolsDefinitions (line 160) | class TestToolsDefinitions:
method test_tools_is_list (line 163) | def test_tools_is_list(self):
method test_tools_not_empty (line 166) | def test_tools_not_empty(self):
method test_all_tools_have_names (line 169) | def test_all_tools_have_names(self):
method test_expected_tool_names_present (line 173) | def test_expected_tool_names_present(self):
class TestDispatch (line 195) | class TestDispatch:
method test_dispatch_list_anime_all (line 200) | async def test_dispatch_list_anime_all(self):
method test_dispatch_list_anime_active_only (line 211) | async def test_dispatch_list_anime_active_only(self):
method test_dispatch_get_anime_found (line 224) | async def test_dispatch_get_anime_found(self):
method test_dispatch_get_anime_not_found (line 235) | async def test_dispatch_get_anime_not_found(self):
method test_dispatch_search_anime (line 253) | async def test_dispatch_search_anime(self):
method test_dispatch_search_anime_default_site (line 276) | async def test_dispatch_search_anime_default_site(self):
method test_dispatch_subscribe_anime_success (line 297) | async def test_dispatch_subscribe_anime_success(self):
method test_dispatch_subscribe_anime_failure (line 320) | async def test_dispatch_subscribe_anime_failure(self):
method test_dispatch_unsubscribe_disable (line 339) | async def test_dispatch_unsubscribe_disable(self):
method test_dispatch_unsubscribe_delete (line 350) | async def test_dispatch_unsubscribe_delete(self):
method test_dispatch_list_downloads_all (line 363) | async def test_dispatch_list_downloads_all(self):
method test_dispatch_list_downloads_filter_downloading (line 390) | async def test_dispatch_list_downloads_filter_downloading(self):
method test_dispatch_list_downloads_keys (line 404) | async def test_dispatch_list_downloads_keys(self):
method test_dispatch_list_rss_feeds (line 439) | async def test_dispatch_list_rss_feeds(self):
method test_dispatch_get_program_status (line 467) | async def test_dispatch_get_program_status(self):
method test_dispatch_refresh_feeds (line 490) | async def test_dispatch_refresh_feeds(self):
method test_dispatch_update_anime_success (line 513) | async def test_dispatch_update_anime_success(self):
method test_dispatch_update_anime_not_found (line 531) | async def test_dispatch_update_anime_not_found(self):
method test_dispatch_unknown_tool (line 544) | async def test_dispatch_unknown_tool(self):
class TestHandleTool (line 556) | class TestHandleTool:
method test_handle_tool_returns_text_content_list (line 559) | async def test_handle_tool_returns_text_content_list(self):
method test_handle_tool_result_is_valid_json (line 572) | async def test_handle_tool_result_is_valid_json(self):
method test_handle_tool_exception_returns_error_json (line 583) | async def test_handle_tool_exception_returns_error_json(self):
method test_handle_tool_unknown_name_returns_error_json (line 596) | async def test_handle_tool_unknown_name_returns_error_json(self):
FILE: backend/src/test/test_migration.py
class TestConfigMigration (line 67) | class TestConfigMigration:
method test_migrate_old_config_renames_program_fields (line 70) | def test_migrate_old_config_renames_program_fields(self):
method test_migrate_old_config_removes_data_version (line 80) | def test_migrate_old_config_removes_data_version(self):
method test_migrate_old_config_removes_deprecated_rss_fields (line 85) | def test_migrate_old_config_removes_deprecated_rss_fields(self):
method test_migrate_old_config_preserves_valid_fields (line 93) | def test_migrate_old_config_preserves_valid_fields(self):
method test_migrate_new_config_no_change (line 106) | def test_migrate_new_config_no_change(self):
method test_migrate_does_not_overwrite_new_fields_with_old (line 124) | def test_migrate_does_not_overwrite_new_fields_with_old(self):
method test_load_old_config_file (line 142) | def test_load_old_config_file(self):
method test_load_old_config_saves_migrated_format (line 172) | def test_load_old_config_saves_migrated_format(self):
class TestDatabaseMigration (line 199) | class TestDatabaseMigration:
method _create_old_31x_database (line 202) | def _create_old_31x_database(self, engine):
method _insert_old_data (line 262) | def _insert_old_data(self, engine):
method test_migrate_adds_air_weekday_column (line 301) | def test_migrate_adds_air_weekday_column(self):
method test_migrate_preserves_existing_data (line 324) | def test_migrate_preserves_existing_data(self):
method test_migrate_preserves_user_data (line 350) | def test_migrate_preserves_user_data(self):
method test_migrate_preserves_rss_data (line 366) | def test_migrate_preserves_rss_data(self):
method test_migrate_preserves_torrent_data (line 383) | def test_migrate_preserves_torrent_data(self):
method test_migrate_idempotent (line 400) | def test_migrate_idempotent(self):
method test_new_bangumi_with_air_weekday (line 417) | def test_new_bangumi_with_air_weekday(self):
method test_passkey_table_created (line 448) | def test_passkey_table_created(self):
method test_schema_version_tracked (line 464) | def test_schema_version_tracked(self):
method test_schema_version_skips_applied_migrations (line 481) | def test_schema_version_skips_applied_migrations(self):
method test_schema_version_zero_for_old_db (line 499) | def test_schema_version_zero_for_old_db(self):
FILE: backend/src/test/test_mock_downloader.py
function mock_dl (line 9) | def mock_dl() -> MockDownloader:
class TestMockDownloaderInit (line 19) | class TestMockDownloaderInit:
method test_initial_state_is_empty (line 20) | def test_initial_state_is_empty(self, mock_dl):
method test_initial_categories (line 27) | def test_initial_categories(self, mock_dl):
method test_initial_prefs (line 33) | def test_initial_prefs(self, mock_dl):
class TestMockDownloaderAuth (line 45) | class TestMockDownloaderAuth:
method test_auth_returns_true (line 46) | async def test_auth_returns_true(self, mock_dl):
method test_auth_retry_param_accepted (line 50) | async def test_auth_retry_param_accepted(self, mock_dl):
method test_logout_does_not_raise (line 54) | async def test_logout_does_not_raise(self, mock_dl):
method test_check_host_returns_true (line 57) | async def test_check_host_returns_true(self, mock_dl):
method test_check_connection_returns_version_string (line 61) | async def test_check_connection_returns_version_string(self, mock_dl):
class TestMockDownloaderPrefs (line 71) | class TestMockDownloaderPrefs:
method test_prefs_init_updates_prefs (line 72) | async def test_prefs_init_updates_prefs(self, mock_dl):
method test_get_app_prefs_returns_dict (line 78) | async def test_get_app_prefs_returns_dict(self, mock_dl):
class TestMockDownloaderCategories (line 89) | class TestMockDownloaderCategories:
method test_add_category_persists (line 90) | async def test_add_category_persists(self, mock_dl):
method test_add_duplicate_category_no_error (line 95) | async def test_add_duplicate_category_no_error(self, mock_dl):
class TestMockDownloaderAddTorrents (line 107) | class TestMockDownloaderAddTorrents:
method test_add_torrent_url_returns_true (line 108) | async def test_add_torrent_url_returns_true(self, mock_dl):
method test_add_torrent_stores_in_state (line 117) | async def test_add_torrent_stores_in_state(self, mock_dl):
method test_add_torrent_with_tag_stored (line 127) | async def test_add_torrent_with_tag_stored(self, mock_dl):
method test_add_torrent_with_file_bytes (line 139) | async def test_add_torrent_with_file_bytes(self, mock_dl):
method test_two_different_torrents_stored_separately (line 148) | async def test_two_different_torrents_stored_separately(self, mock_dl):
class TestMockDownloaderTorrentsInfo (line 165) | class TestMockDownloaderTorrentsInfo:
method test_returns_all_when_no_filter (line 166) | async def test_returns_all_when_no_filter(self, mock_dl):
method test_filters_by_category (line 172) | async def test_filters_by_category(self, mock_dl):
method test_filters_by_tag (line 179) | async def test_filters_by_tag(self, mock_dl):
method test_empty_store_returns_empty_list (line 190) | async def test_empty_store_returns_empty_list(self, mock_dl):
class TestMockDownloaderTorrentsFiles (line 195) | class TestMockDownloaderTorrentsFiles:
method test_returns_files_for_known_hash (line 196) | async def test_returns_files_for_known_hash(self, mock_dl):
method test_returns_empty_list_for_unknown_hash (line 203) | async def test_returns_empty_list_for_unknown_hash(self, mock_dl):
class TestMockDownloaderDelete (line 208) | class TestMockDownloaderDelete:
method test_delete_single_torrent (line 209) | async def test_delete_single_torrent(self, mock_dl):
method test_delete_multiple_torrents_pipe_separated (line 214) | async def test_delete_multiple_torrents_pipe_separated(self, mock_dl):
method test_delete_nonexistent_hash_no_error (line 221) | async def test_delete_nonexistent_hash_no_error(self, mock_dl):
class TestMockDownloaderPauseResume (line 225) | class TestMockDownloaderPauseResume:
method test_pause_sets_state (line 226) | async def test_pause_sets_state(self, mock_dl):
method test_resume_sets_state (line 231) | async def test_resume_sets_state(self, mock_dl):
method test_pause_multiple_pipe_separated (line 236) | async def test_pause_multiple_pipe_separated(self, mock_dl):
method test_pause_unknown_hash_no_error (line 243) | async def test_pause_unknown_hash_no_error(self, mock_dl):
class TestMockDownloaderRename (line 252) | class TestMockDownloaderRename:
method test_rename_returns_true (line 253) | async def test_rename_returns_true(self, mock_dl):
method test_rename_with_verify_flag (line 261) | async def test_rename_with_verify_flag(self, mock_dl):
class TestMockDownloaderRssFeeds (line 276) | class TestMockDownloaderRssFeeds:
method test_add_feed_stored (line 277) | async def test_add_feed_stored(self, mock_dl):
method test_remove_feed (line 283) | async def test_remove_feed(self, mock_dl):
method test_remove_nonexistent_feed_no_error (line 289) | async def test_remove_nonexistent_feed_no_error(self, mock_dl):
method test_get_feeds_initially_empty (line 292) | async def test_get_feeds_initially_empty(self, mock_dl):
class TestMockDownloaderRules (line 302) | class TestMockDownloaderRules:
method test_set_rule_stored (line 303) | async def test_set_rule_stored(self, mock_dl):
method test_remove_rule (line 310) | async def test_remove_rule(self, mock_dl):
method test_remove_nonexistent_rule_no_error (line 316) | async def test_remove_nonexistent_rule_no_error(self, mock_dl):
method test_get_download_rule_initially_empty (line 319) | async def test_get_download_rule_initially_empty(self, mock_dl):
class TestMockDownloaderMovePath (line 329) | class TestMockDownloaderMovePath:
method test_move_torrent_updates_save_path (line 330) | async def test_move_torrent_updates_save_path(self, mock_dl):
method test_move_multiple_pipe_separated (line 335) | async def test_move_multiple_pipe_separated(self, mock_dl):
method test_get_torrent_path_known_hash (line 342) | async def test_get_torrent_path_known_hash(self, mock_dl):
method test_get_torrent_path_unknown_hash_returns_default (line 347) | async def test_get_torrent_path_unknown_hash_returns_default(self, moc...
class TestMockDownloaderSetCategory (line 357) | class TestMockDownloaderSetCategory:
method test_set_category_updates_torrent (line 358) | async def test_set_category_updates_torrent(self, mock_dl):
method test_set_category_unknown_hash_no_error (line 363) | async def test_set_category_unknown_hash_no_error(self, mock_dl):
class TestMockDownloaderTags (line 372) | class TestMockDownloaderTags:
method test_add_tag_appends (line 373) | async def test_add_tag_appends(self, mock_dl):
method test_add_tag_no_duplicates (line 378) | async def test_add_tag_no_duplicates(self, mock_dl):
method test_add_tag_unknown_hash_no_error (line 384) | async def test_add_tag_unknown_hash_no_error(self, mock_dl):
method test_multiple_tags_on_same_torrent (line 387) | async def test_multiple_tags_on_same_torrent(self, mock_dl):
class TestAddMockTorrentHelper (line 400) | class TestAddMockTorrentHelper:
method test_generates_hash_from_name (line 401) | def test_generates_hash_from_name(self, mock_dl):
method test_explicit_hash_used (line 406) | def test_explicit_hash_used(self, mock_dl):
method test_torrent_state_is_completed_by_default (line 410) | def test_torrent_state_is_completed_by_default(self, mock_dl):
method test_torrent_state_custom (line 415) | def test_torrent_state_custom(self, mock_dl):
method test_default_file_is_mkv (line 420) | def test_default_file_is_mkv(self, mock_dl):
method test_custom_files_stored (line 426) | def test_custom_files_stored(self, mock_dl):
FILE: backend/src/test/test_notification.py
class TestProviderRegistry (line 26) | class TestProviderRegistry:
method test_telegram (line 27) | def test_telegram(self):
method test_discord (line 31) | def test_discord(self):
method test_bark (line 35) | def test_bark(self):
method test_server_chan (line 39) | def test_server_chan(self):
method test_wecom (line 44) | def test_wecom(self):
method test_gotify (line 48) | def test_gotify(self):
method test_pushover (line 52) | def test_pushover(self):
method test_webhook (line 56) | def test_webhook(self):
method test_unknown_type (line 60) | def test_unknown_type(self):
class TestNotificationManager (line 71) | class TestNotificationManager:
method mock_settings (line 73) | def mock_settings(self):
method test_empty_providers (line 79) | def test_empty_providers(self, mock_settings):
method test_load_single_provider (line 84) | def test_load_single_provider(self, mock_settings):
method test_skip_disabled_provider (line 93) | def test_skip_disabled_provider(self, mock_settings):
method test_load_multiple_providers (line 101) | def test_load_multiple_providers(self, mock_settings):
method test_skip_unknown_provider (line 116) | def test_skip_unknown_provider(self, mock_settings):
method test_send_all (line 127) | async def test_send_all(self, mock_settings):
method test_test_provider (line 151) | async def test_test_provider(self, mock_settings):
method test_test_provider_invalid_index (line 167) | async def test_test_provider_invalid_index(self, mock_settings):
class TestTelegramProvider (line 182) | class TestTelegramProvider:
method provider (line 184) | def provider(self):
method test_send_with_photo (line 188) | async def test_send_with_photo(self, provider):
method test_send_without_photo (line 203) | async def test_send_without_photo(self, provider):
method test_test_success (line 216) | async def test_test_success(self, provider):
class TestDiscordProvider (line 226) | class TestDiscordProvider:
method provider (line 228) | def provider(self):
method test_send (line 234) | async def test_send(self, provider):
class TestBarkProvider (line 249) | class TestBarkProvider:
method provider (line 251) | def provider(self):
method test_send (line 255) | async def test_send(self, provider):
class TestWebhookProvider (line 268) | class TestWebhookProvider:
method provider (line 270) | def provider(self):
method test_render_template (line 279) | def test_render_template(self, provider):
method test_send (line 287) | async def test_send(self, provider):
class TestConfigMigration (line 303) | class TestConfigMigration:
method test_legacy_config_migration (line 304) | def test_legacy_config_migration(self):
method test_new_config_no_migration (line 321) | def test_new_config_no_migration(self):
FILE: backend/src/test/test_openai.py
class TestOpenAIParser (line 8) | class TestOpenAIParser:
method setup_class (line 10) | def setup_class(cls):
method test__prepare_params_with_openai (line 15) | def test__prepare_params_with_openai(self):
method test__prepare_params_with_azure (line 30) | def test__prepare_params_with_azure(self):
method test_parse (line 54) | def test_parse(self):
FILE: backend/src/test/test_path.py
function torrent_path (line 13) | def torrent_path():
class TestGenSavePath (line 22) | class TestGenSavePath:
method test_with_year (line 23) | def test_with_year(self):
method test_without_year (line 33) | def test_without_year(self):
method test_season_formatting (line 44) | def test_season_formatting(self):
method test_with_different_base_path (line 53) | def test_with_different_base_path(self):
class TestRuleName (line 70) | class TestRuleName:
method test_without_group_tag (line 71) | def test_without_group_tag(self):
method test_with_group_tag (line 80) | def test_with_group_tag(self):
class TestCheckFiles (line 95) | class TestCheckFiles:
method test_separates_media_and_subtitles (line 96) | def test_separates_media_and_subtitles(self):
method test_ignores_other_extensions (line 113) | def test_ignores_other_extensions(self):
method test_case_insensitive_extensions (line 126) | def test_case_insensitive_extensions(self):
method test_empty_file_list (line 139) | def test_empty_file_list(self):
method test_nested_paths (line 145) | def test_nested_paths(self):
class TestPathToBangumi (line 162) | class TestPathToBangumi:
method test_extracts_name_and_season (line 163) | def test_extracts_name_and_season(self):
method test_season_1_default (line 175) | def test_season_1_default(self):
method test_s_prefix_pattern (line 185) | def test_s_prefix_pattern(self):
class TestIsEp (line 200) | class TestIsEp:
method test_shallow_file (line 201) | def test_shallow_file(self):
method test_one_folder_deep (line 206) | def test_one_folder_deep(self):
method test_too_deep (line 211) | def test_too_deep(self):
method test_file_depth (line 216) | def test_file_depth(self):
FILE: backend/src/test/test_path_parser.py
function test_path_to_bangumi (line 6) | def test_path_to_bangumi():
class TestGenSavePath (line 16) | class TestGenSavePath:
method test_gen_save_path_no_offset (line 19) | def test_gen_save_path_no_offset(self):
method test_gen_save_path_with_positive_offset (line 38) | def test_gen_save_path_with_positive_offset(self):
method test_gen_save_path_with_negative_offset (line 57) | def test_gen_save_path_with_negative_offset(self):
method test_gen_save_path_offset_below_one_ignored (line 75) | def test_gen_save_path_offset_below_one_ignored(self):
method test_gen_save_path_season_two_no_offset (line 93) | def test_gen_save_path_season_two_no_offset(self):
method test_gen_save_path_large_positive_offset (line 111) | def test_gen_save_path_large_positive_offset(self):
method test_gen_save_path_offset_yields_exactly_season_one (line 129) | def test_gen_save_path_offset_yields_exactly_season_one(self):
FILE: backend/src/test/test_qb_downloader.py
class TestQbDownloaderConstructor (line 22) | class TestQbDownloaderConstructor:
method test_ssl_true_no_scheme_uses_https (line 25) | def test_ssl_true_no_scheme_uses_https(self):
method test_ssl_false_no_scheme_uses_http (line 30) | def test_ssl_false_no_scheme_uses_http(self):
method test_explicit_http_scheme_preserved_when_ssl_true (line 35) | def test_explicit_http_scheme_preserved_when_ssl_true(self):
method test_explicit_https_scheme_preserved_when_ssl_false (line 42) | def test_explicit_https_scheme_preserved_when_ssl_false(self):
method test_explicit_http_scheme_preserved_ssl_false (line 49) | def test_explicit_http_scheme_preserved_ssl_false(self):
method test_explicit_https_scheme_preserved_ssl_true (line 54) | def test_explicit_https_scheme_preserved_ssl_true(self):
method test_credentials_stored (line 59) | def test_credentials_stored(self):
method test_client_initially_none (line 68) | def test_client_initially_none(self):
function test_scheme_selection_matrix (line 95) | def test_scheme_selection_matrix(host: str, ssl: bool, expected_prefix: ...
class TestAuthClientCreation (line 108) | class TestAuthClientCreation:
method test_auth_creates_client_with_verify_false_when_ssl_true (line 111) | async def test_auth_creates_client_with_verify_false_when_ssl_true(self):
method test_auth_creates_client_with_verify_false_when_ssl_false (line 137) | async def test_auth_creates_client_with_verify_false_when_ssl_false(se...
method test_auth_uses_5_second_connect_timeout (line 162) | async def test_auth_uses_5_second_connect_timeout(self):
class TestAuthSuccessFailure (line 193) | class TestAuthSuccessFailure:
method test_auth_returns_true_on_ok_response (line 196) | async def test_auth_returns_true_on_ok_response(self):
method test_auth_returns_false_on_403 (line 214) | async def test_auth_returns_false_on_403(self):
method test_auth_retries_up_to_limit_on_server_error (line 234) | async def test_auth_retries_up_to_limit_on_server_error(self):
class TestAuthConnectErrorLogging (line 263) | class TestAuthConnectErrorLogging:
method test_https_url_logs_https_specific_guidance (line 266) | async def test_https_url_logs_https_specific_guidance(self, caplog):
method test_https_url_derived_from_ssl_flag_logs_https_guidance (line 296) | async def test_https_url_derived_from_ssl_flag_logs_https_guidance(sel...
method test_http_url_logs_generic_message_without_ssl_hint (line 321) | async def test_http_url_logs_generic_message_without_ssl_hint(self, ca...
method test_http_url_derived_from_ssl_flag_false_no_ssl_hint (line 349) | async def test_http_url_derived_from_ssl_flag_false_no_ssl_hint(self, ...
method test_connect_error_logs_check_ip_port_info (line 374) | async def test_connect_error_logs_check_ip_port_info(self, caplog):
method test_explicit_http_with_ssl_true_still_uses_generic_message (line 399) | async def test_explicit_http_with_ssl_true_still_uses_generic_message(...
class TestUrlHelper (line 433) | class TestUrlHelper:
method test_url_format_with_http (line 436) | def test_url_format_with_http(self):
method test_url_format_with_https (line 441) | def test_url_format_with_https(self):
method test_url_with_explicit_http_scheme_overriding_ssl_true (line 446) | def test_url_with_explicit_http_scheme_overriding_ssl_true(self):
FILE: backend/src/test/test_raw_parser.py
function test_raw_parser (line 6) | def test_raw_parser():
class TestIssue924SpecialPunctuation (line 177) | class TestIssue924SpecialPunctuation:
method test_parse_title_with_fullwidth_parens (line 180) | def test_parse_title_with_fullwidth_parens(self):
class TestIssue910NeoQswFormat (line 192) | class TestIssue910NeoQswFormat:
method test_parse_neo_qsw_format (line 197) | def test_parse_neo_qsw_format(self):
class TestIssue876NoSeparator (line 204) | class TestIssue876NoSeparator:
method test_parse_without_dash (line 213) | def test_parse_without_dash(self):
class TestIssue819ChineseEpisodeMarker (line 221) | class TestIssue819ChineseEpisodeMarker:
method test_parse_chinese_episode_marker (line 224) | def test_parse_chinese_episode_marker(self):
class TestIssue811ColonInTitle (line 235) | class TestIssue811ColonInTitle:
method test_parse_colon_in_english_title (line 238) | def test_parse_colon_in_english_title(self):
class TestIssue798VTuberTitle (line 249) | class TestIssue798VTuberTitle:
method test_parse_vtuber_title (line 252) | def test_parse_vtuber_title(self):
class TestIssue794PreEpisodeFormat (line 265) | class TestIssue794PreEpisodeFormat:
method test_parse_pre_episode (line 274) | def test_parse_pre_episode(self):
method test_returns_none (line 281) | def test_returns_none(self, title):
class TestIssue766Lv2InTitle (line 286) | class TestIssue766Lv2InTitle:
method test_parse_lv2_title (line 289) | def test_parse_lv2_title(self):
class TestIssue764WesternFormat (line 301) | class TestIssue764WesternFormat:
method test_parse_western_format (line 304) | def test_parse_western_format(self):
class TestIssue986AtlasFormat (line 318) | class TestIssue986AtlasFormat:
method test_parse_atlas_format (line 327) | def test_parse_atlas_format(self):
method test_returns_none (line 334) | def test_returns_none(self, title):
class TestIssue773CompoundEpisode (line 339) | class TestIssue773CompoundEpisode:
method test_parse_compound_episode (line 344) | def test_parse_compound_episode(self):
class TestIssue805TitleWithCht (line 351) | class TestIssue805TitleWithCht:
method test_parse_cht_title (line 354) | def test_parse_cht_title(self):
FILE: backend/src/test/test_renamer.py
class TestGenPath (line 15) | class TestGenPath:
method test_pn_method (line 16) | def test_pn_method(self):
method test_advance_method (line 24) | def test_advance_method(self):
method test_none_method (line 32) | def test_none_method(self):
method test_subtitle_none_method (line 44) | def test_subtitle_none_method(self):
method test_subtitle_pn_method (line 57) | def test_subtitle_pn_method(self):
method test_subtitle_advance_method (line 70) | def test_subtitle_advance_method(self):
method test_zero_padding_single_digit (line 83) | def test_zero_padding_single_digit(self):
method test_no_padding_double_digit (line 91) | def test_no_padding_double_digit(self):
method test_unknown_method_returns_original (line 99) | def test_unknown_method_returns_original(self):
method test_mp4_suffix (line 107) | def test_mp4_suffix(self):
class TestRenameFile (line 121) | class TestRenameFile:
method renamer (line 123) | def renamer(self, mock_qb_client):
method test_successful_rename (line 143) | async def test_successful_rename(self, renamer):
method test_parse_fails_no_remove (line 165) | async def test_parse_fails_no_remove(self, renamer):
method test_parse_fails_remove_bad (line 182) | async def test_parse_fails_remove_bad(self, renamer):
method test_same_path_skipped (line 200) | async def test_same_path_skipped(self, renamer):
class TestRenameCollection (line 228) | class TestRenameCollection:
method renamer (line 230) | def renamer(self, mock_qb_client):
method test_renames_each_file (line 248) | async def test_renames_each_file(self, renamer):
method test_skips_deep_files (line 274) | async def test_skips_deep_files(self, renamer):
class TestRenameSubtitles (line 304) | class TestRenameSubtitles:
method renamer (line 306) | def renamer(self, mock_qb_client):
method test_renames_subtitles_with_language (line 323) | async def test_renames_subtitles_with_language(self, renamer):
class TestRenameFlow (line 359) | class TestRenameFlow:
method renamer (line 361) | def renamer(self, mock_qb_client):
method test_single_file_rename (line 379) | async def test_single_file_rename(self, renamer):
method test_collection_sets_category (line 409) | async def test_collection_sets_category(self, renamer):
method test_no_media_files_no_crash (line 445) | async def test_no_media_files_no_crash(self, renamer):
class TestParseBangumiIdFromTags (line 473) | class TestParseBangumiIdFromTags:
method test_single_ab_tag (line 476) | def test_single_ab_tag(self):
method test_ab_tag_with_other_tags (line 481) | def test_ab_tag_with_other_tags(self):
method test_ab_tag_with_spaces (line 486) | def test_ab_tag_with_spaces(self):
method test_empty_string (line 491) | def test_empty_string(self):
method test_none_input (line 496) | def test_none_input(self):
method test_no_ab_tag (line 501) | def test_no_ab_tag(self):
method test_invalid_ab_tag_non_numeric (line 506) | def test_invalid_ab_tag_non_numeric(self):
method test_ab_tag_first_match (line 511) | def test_ab_tag_first_match(self):
method test_ab_tag_zero (line 516) | def test_ab_tag_zero(self):
method test_ab_tag_large_number (line 521) | def test_ab_tag_large_number(self):
class TestGenPathWithOffsets (line 532) | class TestGenPathWithOffsets:
method test_episode_offset_positive (line 535) | def test_episode_offset_positive(self):
method test_episode_offset_negative (line 543) | def test_episode_offset_negative(self):
method test_episode_offset_negative_below_zero_ignored (line 551) | def test_episode_offset_negative_below_zero_ignored(self):
method test_episode_offset_producing_zero_ignored (line 559) | def test_episode_offset_producing_zero_ignored(self):
method test_episode_zero_preserved_without_offset (line 567) | def test_episode_zero_preserved_without_offset(self):
method test_season_offset_positive (line 575) | def test_season_offset_positive(self):
method test_season_offset_negative (line 591) | def test_season_offset_negative(self):
method test_season_offset_negative_below_one_ignored (line 602) | def test_season_offset_negative_below_one_ignored(self):
method test_both_offsets_combined (line 610) | def test_both_offsets_combined(self):
method test_offset_with_advance_method (line 625) | def test_offset_with_advance_method(self):
method test_offset_with_subtitle_method (line 635) | def test_offset_with_subtitle_method(self):
method test_offset_none_method_unchanged (line 650) | def test_offset_none_method_unchanged(self):
class TestLookupOffsets (line 668) | class TestLookupOffsets:
method renamer (line 672) | def renamer(self, mock_qb_client):
method test_lookup_by_qb_hash (line 690) | def test_lookup_by_qb_hash(self, renamer, db_session):
method test_lookup_by_tag_when_hash_not_found (line 736) | def test_lookup_by_tag_when_hash_not_found(self, renamer, db_session):
method test_lookup_by_torrent_name (line 772) | def test_lookup_by_torrent_name(self, renamer, db_session):
method test_lookup_by_save_path_fallback (line 808) | def test_lookup_by_save_path_fallback(self, renamer, db_session):
method test_lookup_returns_zero_when_not_found (line 845) | def test_lookup_returns_zero_when_not_found(self, renamer, db_session):
method test_lookup_skips_deleted_bangumi (line 868) | def test_lookup_skips_deleted_bangumi(self, renamer, db_session):
method test_lookup_handles_database_exception (line 906) | def test_lookup_handles_database_exception(self, renamer):
method test_lookup_by_save_path_with_trailing_slash (line 921) | def test_lookup_by_save_path_with_trailing_slash(self, renamer, db_ses...
method test_lookup_by_save_path_with_backslashes (line 959) | def test_lookup_by_save_path_with_backslashes(self, renamer, db_session):
class TestNormalizePath (line 998) | class TestNormalizePath:
method test_empty_path (line 1001) | def test_empty_path(self):
method test_removes_trailing_slash (line 1006) | def test_removes_trailing_slash(self):
method test_removes_trailing_backslash (line 1011) | def test_removes_trailing_backslash(self):
method test_converts_backslashes (line 1016) | def test_converts_backslashes(self):
method test_preserves_forward_slashes (line 1021) | def test_preserves_forward_slashes(self):
FILE: backend/src/test/test_rss_engine.py
function test_rss_engine (line 8) | async def test_rss_engine():
FILE: backend/src/test/test_rss_engine_new.py
function rss_engine (line 18) | def rss_engine(db_engine):
function clear_bangumi_cache (line 25) | def clear_bangumi_cache():
class TestPullRss (line 37) | class TestPullRss:
method test_returns_only_new_torrents (line 38) | async def test_returns_only_new_torrents(self, rss_engine):
method test_all_existing_returns_empty (line 61) | async def test_all_existing_returns_empty(self, rss_engine):
method test_empty_feed_returns_empty (line 78) | async def test_empty_feed_returns_empty(self, rss_engine):
class TestMatchTorrent (line 96) | class TestMatchTorrent:
method test_matches_by_title_raw_substring (line 97) | def test_matches_by_title_raw_substring(self, rss_engine):
method test_no_match_returns_none (line 110) | def test_no_match_returns_none(self, rss_engine):
method test_filter_excludes_matching_torrent (line 120) | def test_filter_excludes_matching_torrent(self, rss_engine):
method test_empty_filter_allows_match (line 132) | def test_empty_filter_allows_match(self, rss_engine):
method test_filter_case_insensitive (line 144) | def test_filter_case_insensitive(self, rss_engine):
method test_deleted_bangumi_not_matched (line 157) | def test_deleted_bangumi_not_matched(self, rss_engine):
method test_comma_separated_filters (line 167) | def test_comma_separated_filters(self, rss_engine):
class TestRefreshRss (line 190) | class TestRefreshRss:
method test_downloads_matched_torrents (line 191) | async def test_downloads_matched_torrents(self, rss_engine, mock_qb_cl...
method test_unmatched_torrents_stored_not_downloaded (line 220) | async def test_unmatched_torrents_stored_not_downloaded(self, rss_engi...
method test_refresh_specific_rss_id (line 240) | async def test_refresh_specific_rss_id(self, rss_engine):
method test_refresh_nonexistent_rss_id (line 255) | async def test_refresh_nonexistent_rss_id(self, rss_engine):
class TestAddRss (line 269) | class TestAddRss:
method test_add_with_name (line 270) | async def test_add_with_name(self, rss_engine):
method test_add_without_name_fetches_title (line 285) | async def test_add_without_name_fetches_title(self, rss_engine):
method test_add_without_name_fetch_fails (line 304) | async def test_add_without_name_fetch_fails(self, rss_engine):
method test_add_duplicate_url_fails (line 322) | async def test_add_duplicate_url_fails(self, rss_engine):
FILE: backend/src/test/test_searcher.py
class TestSearchUrl (line 15) | class TestSearchUrl:
method mock_search_config (line 17) | def mock_search_config(self):
method test_mikan_url (line 27) | def test_mikan_url(self):
method test_nyaa_url (line 36) | def test_nyaa_url(self):
method test_dmhy_url (line 42) | def test_dmhy_url(self):
method test_unsupported_site_raises (line 48) | def test_unsupported_site_raises(self):
method test_keyword_sanitization (line 53) | def test_keyword_sanitization(self):
method test_multiple_keywords_joined (line 60) | def test_multiple_keywords_joined(self):
method test_aggregate_is_false (line 69) | def test_aggregate_is_false(self):
class TestSpecialUrl (line 80) | class TestSpecialUrl:
method test_uses_bangumi_fields (line 81) | def test_uses_bangumi_fields(self):
method test_skips_none_fields (line 105) | def test_skips_none_fields(self):
FILE: backend/src/test/test_setup.py
function client (line 11) | def client():
function mock_first_run (line 21) | def mock_first_run():
function mock_setup_complete (line 35) | def mock_setup_complete():
class TestSetupStatus (line 42) | class TestSetupStatus:
method test_status_first_run (line 43) | def test_status_first_run(self, client, mock_first_run):
method test_status_setup_complete (line 50) | def test_status_setup_complete(self, client, mock_setup_complete):
method test_status_config_changed (line 56) | def test_status_config_changed(self, client):
class TestSetupGuard (line 75) | class TestSetupGuard:
method test_test_downloader_blocked_after_setup (line 76) | def test_test_downloader_blocked_after_setup(self, client, mock_setup_...
method test_test_rss_blocked_after_setup (line 89) | def test_test_rss_blocked_after_setup(self, client, mock_setup_complete):
method test_test_notification_blocked_after_setup (line 96) | def test_test_notification_blocked_after_setup(self, client, mock_setu...
method test_complete_blocked_after_setup (line 103) | def test_complete_blocked_after_setup(self, client, mock_setup_complete):
class TestTestDownloader (line 126) | class TestTestDownloader:
method test_private_ip_accepted (line 127) | def test_private_ip_accepted(self, client, mock_first_run):
method test_loopback_ip_accepted (line 155) | def test_loopback_ip_accepted(self, client, mock_first_run):
method test_connection_timeout (line 181) | def test_connection_timeout(self, client, mock_first_run):
method test_connection_refused (line 206) | def test_connection_refused(self, client, mock_first_run):
class TestTestRSS (line 232) | class TestTestRSS:
method test_invalid_url (line 233) | def test_invalid_url(self, client, mock_first_run):
class TestRequestValidation (line 250) | class TestRequestValidation:
method test_username_too_short (line 251) | def test_username_too_short(self, client, mock_first_run):
method test_password_too_short (line 273) | def test_password_too_short(self, client, mock_first_run):
class TestSentinelPath (line 296) | class TestSentinelPath:
method test_sentinel_path_is_in_config_dir (line 297) | def test_sentinel_path_is_in_config_dir(self):
FILE: backend/src/test/test_title_parser.py
class TestTitleParser (line 6) | class TestTitleParser:
method test_parse_without_openai (line 7) | def test_parse_without_openai(self):
method test_parse_with_openai (line 20) | def test_parse_with_openai(self):
FILE: backend/src/test/test_tmdb.py
function test_tmdb_parser (line 4) | async def test_tmdb_parser():
FILE: backend/src/test/test_torrent_parser.py
function test_torrent_parser (line 8) | def test_torrent_parser():
class TestGetPathBasename (line 131) | class TestGetPathBasename:
method test_regular_path (line 132) | def test_regular_path(self):
method test_empty_path (line 135) | def test_empty_path(self):
method test_path_with_trailing_slash (line 138) | def test_path_with_trailing_slash(self):
method test_windows_path (line 142) | def test_windows_path(self):
FILE: backend/src/test_passkey_server.py
function startup (line 22) | async def startup():
function index (line 30) | def index():
FILE: docs/.vitepress/theme/index.ts
method setup (line 16) | setup() {
FILE: webui/.storybook/main.ts
method viteFinal (line 18) | viteFinal(config) {
FILE: webui/src/api/auth.ts
method login (line 5) | async login(username: string, password: string) {
method refresh (line 24) | async refresh() {
method logout (line 29) | async logout() {
method update (line 34) | async update(username: string, password: string) {
FILE: webui/src/api/bangumi.ts
method getAll (line 16) | async getAll() {
method getRule (line 32) | async getRule(bangumiId: number) {
method updateRule (line 51) | async updateRule(bangumiId: number, bangumiRule: BangumiRule) {
method deleteRule (line 71) | async deleteRule(bangumiId: number | number[], file: boolean) {
method disableRule (line 97) | async disableRule(bangumiId: number | number[], file: boolean) {
method enableRule (line 121) | async enableRule(bangumiId: number) {
method resetAll (line 131) | async resetAll() {
method refreshPoster (line 139) | async refreshPoster() {
method refreshCalendar (line 149) | async refreshCalendar() {
method archiveRule (line 160) | async archiveRule(bangumiId: number) {
method unarchiveRule (line 171) | async unarchiveRule(bangumiId: number) {
method refreshMetadata (line 181) | async refreshMetadata() {
method suggestOffset (line 192) | async suggestOffset(bangumiId: number) {
method detectOffset (line 203) | async detectOffset(request: DetectOffsetRequest) {
method dismissReview (line 215) | async dismissReview(bangumiId: number) {
method setWeekday (line 227) | async setWeekday(bangumiId: number, weekday: number | null) {
method getNeedsReview (line 238) | async getNeedsReview() {
FILE: webui/src/api/check.ts
method downloader (line 5) | async downloader() {
FILE: webui/src/api/config.ts
method getConfig (line 8) | async getConfig() {
method updateConfig (line 17) | async updateConfig(newConfig: Config) {
FILE: webui/src/api/download.ts
method analysis (line 10) | async analysis(rss_item: RSS) {
method collection (line 28) | async collection(bangumiData: BangumiRule) {
method subscribe (line 45) | async subscribe(bangumiData: BangumiRule, rss: RSS) {
FILE: webui/src/api/downloader.ts
method getTorrents (line 5) | async getTorrents() {
method pause (line 12) | async pause(hashes: string[]) {
method resume (line 20) | async resume(hashes: string[]) {
method deleteTorrents (line 28) | async deleteTorrents(hashes: string[], deleteFiles = false) {
FILE: webui/src/api/log.ts
method getLog (line 4) | async getLog() {
method clearLog (line 9) | async clearLog() {
FILE: webui/src/api/notification.ts
type TestProviderRequest (line 4) | interface TestProviderRequest {
type TestProviderConfigRequest (line 8) | interface TestProviderConfigRequest {
type TestResponse (line 22) | interface TestResponse {
method testProvider (line 33) | async testProvider(request: TestProviderRequest) {
method testProviderConfig (line 44) | async testProviderConfig(request: TestProviderConfigRequest) {
FILE: webui/src/api/passkey.ts
method getRegistrationOptions (line 22) | async getRegistrationOptions(): Promise<RegistrationOptions> {
method verifyRegistration (line 32) | async verifyRegistration(request: PasskeyCreateRequest): Promise<ApiSucc...
method getLoginOptions (line 45) | async getLoginOptions(
method loginWithPasskey (line 58) | async loginWithPasskey(
method list (line 73) | async list(): Promise<PasskeyItem[]> {
method delete (line 81) | async delete(request: PasskeyDeleteRequest): Promise<ApiSuccess> {
FILE: webui/src/api/program.ts
method restart (line 7) | async restart() {
method start (line 15) | async start() {
method stop (line 23) | async stop() {
method status (line 31) | async status() {
method shutdown (line 42) | async shutdown() {
FILE: webui/src/api/rss.ts
method get (line 6) | async get() {
method add (line 11) | async add(rss: RSS) {
method delete (line 16) | async delete(rss_id: number) {
method deleteMany (line 23) | async deleteMany(rss_list: number[]) {
method disable (line 31) | async disable(rss_id: number) {
method disableMany (line 38) | async disableMany(rss_list: number[]) {
method update (line 46) | async update(rss_id: number, rss: RSS) {
method enableMany (line 54) | async enableMany(rss_list: number[]) {
method refreshAll (line 62) | async refreshAll() {
method refresh (line 67) | async refresh(rss_id: number) {
method getTorrent (line 74) | async getTorrent(rss_id: number) {
FILE: webui/src/api/search.ts
type EventSourceStatus (line 5) | type EventSourceStatus = 'OPEN' | 'CONNECTING' | 'CLOSED';
method get (line 8) | get() {
method getProvider (line 66) | async getProvider() {
FILE: webui/src/api/setup.ts
method getStatus (line 11) | async getStatus() {
method testDownloader (line 16) | async testDownloader(config: TestDownloaderRequest) {
method testRSS (line 24) | async testRSS(url: string) {
method testNotification (line 31) | async testNotification(config: TestNotificationRequest) {
method complete (line 39) | async complete(config: SetupCompleteRequest) {
FILE: webui/src/components/basic/__tests__/ab-button.test.ts
method setup (line 14) | setup(props, { slots }) {
FILE: webui/src/components/basic/__tests__/ab-switch.test.ts
method setup (line 15) | setup(props, { emit, slots }) {
FILE: webui/src/components/basic/ab-add.stories.ts
type Story (line 12) | type Story = StoryObj<typeof AbAdd>;
method setup (line 17) | setup() {
FILE: webui/src/components/basic/ab-button-multi.stories.ts
type Story (line 22) | type Story = StoryObj<typeof AbButtonMulti>;
method setup (line 27) | setup() {
FILE: webui/src/components/basic/ab-button.stories.ts
type Story (line 22) | type Story = StoryObj<typeof AbButton>;
method setup (line 27) | setup() {
FILE: webui/src/components/basic/ab-checkbox.stories.ts
type Story (line 12) | type Story = StoryObj<typeof AbCheckbox>;
method setup (line 17) | setup() {
FILE: webui/src/components/basic/ab-page-title.stories.ts
type Story (line 12) | type Story = StoryObj<typeof AbPageTitle>;
method setup (line 17) | setup() {
FILE: webui/src/components/basic/ab-search.stories.ts
type Story (line 12) | type Story = StoryObj<typeof AbSearch>;
method setup (line 17) | setup() {
FILE: webui/src/components/basic/ab-select.stories.ts
type Story (line 12) | type Story = StoryObj<typeof AbSelect>;
method setup (line 17) | setup() {
FILE: webui/src/components/basic/ab-status.stories.ts
type Story (line 12) | type Story = StoryObj<typeof AbStatus>;
method setup (line 17) | setup() {
FILE: webui/src/components/basic/ab-switch.stories.ts
type Story (line 12) | type Story = StoryObj<typeof AbSwitch>;
method setup (line 17) | setup() {
FILE: webui/src/components/basic/ab-tag.stories.ts
type Story (line 12) | type Story = StoryObj<typeof AbTag>;
method setup (line 17) | setup() {
FILE: webui/src/hooks/__tests__/useApi.test.ts
type Options (line 9) | interface Options<T = unknown> {
type AnyAsyncFunction (line 18) | type AnyAsyncFunction = (...args: any[]) => Promise<any>;
function createUseApi (line 20) | function createUseApi<TApi extends AnyAsyncFunction>(
FILE: webui/src/hooks/__tests__/useAuth.test.ts
type User (line 68) | interface User {
FILE: webui/src/hooks/useAddRss.ts
function useAddRss (line 9) | function useAddRss() {
FILE: webui/src/hooks/useApi.ts
type AnyAsyncFuntion (line 1) | type AnyAsyncFuntion<TData = any> = (...args: any[]) => Promise<TData>;
type Options (line 3) | interface Options<T = any> {
function useApi (line 11) | function useApi<
FILE: webui/src/hooks/useAppInfo.ts
function getStatus (line 8) | function getStatus() {
FILE: webui/src/hooks/useAuth.ts
function clearUser (line 17) | function clearUser() {
function formVerify (line 22) | function formVerify() {
function login (line 37) | function login() {
method onSuccess (line 57) | onSuccess() {
method onSuccess (line 66) | onSuccess() {
function update (line 71) | function update() {
FILE: webui/src/hooks/useDarkMode.ts
type ThemeMode (line 4) | type ThemeMode = 'light' | 'dark' | 'system';
function applyTheme (line 16) | function applyTheme() {
function setMode (line 25) | function setMode(newMode: ThemeMode) {
function toggle (line 34) | function toggle() {
FILE: webui/src/hooks/useMyI18n.ts
type Languages (line 12) | type Languages = keyof typeof messages;
function normalizeLocale (line 14) | function normalizeLocale(locale: string): Languages {
function changeLocale (line 38) | function changeLocale() {
function returnUserLangText (line 46) | function returnUserLangText(texts: {
function returnUserLangMsg (line 52) | function returnUserLangMsg(res: ApiSuccess) {
FILE: webui/src/hooks/usePasskey.ts
function loadPasskeys (line 21) | async function loadPasskeys() {
function addPasskey (line 35) | async function addPasskey(deviceName: string): Promise<boolean> {
function loginWithPasskey (line 54) | async function loginWithPasskey(username?: string): Promise<boolean> {
function deletePasskey (line 72) | async function deletePasskey(passkeyId: number): Promise<boolean> {
FILE: webui/src/hooks/useSafeArea.ts
function useSafeArea (line 3) | function useSafeArea() {
FILE: webui/src/services/webauthn.ts
function base64UrlToBuffer (line 10) | function base64UrlToBuffer(base64url: string): ArrayBuffer {
function bufferToBase64Url (line 24) | function bufferToBase64Url(buffer: ArrayBuffer): string {
function registerPasskey (line 39) | async function registerPasskey(deviceName: string): Promise<void> {
function loginWithPasskey (line 113) | async function loginWithPasskey(username?: string): Promise<void> {
function isWebAuthnSupported (line 167) | function isWebAuthnSupported(): boolean {
function isPlatformAuthenticatorAvailable (line 175) | async function isPlatformAuthenticatorAvailable(): Promise<boolean> {
FILE: webui/src/store/bangumi.ts
function getAll (line 27) | async function getAll() {
function refreshData (line 43) | function refreshData() {
method onSuccess (line 50) | onSuccess() {
function openEditPopup (line 64) | function openEditPopup(data: BangumiRule) {
function ruleManage (line 69) | function ruleManage(
function setWeekday (line 85) | async function setWeekday(bangumiId: number, weekday: number | null) {
FILE: webui/src/store/config.ts
function getConfig (line 6) | async function getConfig() {
method onSuccess (line 13) | onSuccess() {
function getSettingGroup (line 22) | function getSettingGroup<Tkey extends keyof Config>(key: Tkey) {
FILE: webui/src/store/downloader.ts
function getAll (line 47) | async function getAll() {
method onSuccess (line 60) | onSuccess() {
function toggleHash (line 80) | function toggleHash(hash: string) {
function toggleGroup (line 89) | function toggleGroup(group: TorrentGroup) {
function clearSelection (line 106) | function clearSelection() {
FILE: webui/src/store/log.ts
function getLog (line 10) | function getLog() {
method onSuccess (line 20) | onSuccess() {
function copy (line 40) | function copy() {
FILE: webui/src/store/player.ts
type MediaPlayerType (line 3) | type MediaPlayerType = 'jump' | 'iframe';
function normalizeUrl (line 5) | function normalizeUrl(url: string): string {
function setUrl (line 24) | function setUrl(value: string) {
FILE: webui/src/store/rss.ts
function getAll (line 7) | async function getAll() {
method onSuccess (line 22) | onSuccess() {
FILE: webui/src/store/search.ts
type SearchFilters (line 4) | interface SearchFilters {
type FilterOptions (line 11) | interface FilterOptions {
type GroupedBangumi (line 19) | interface GroupedBangumi {
function parseResolution (line 28) | function parseResolution(bangumi: BangumiRule): string {
function parseSubtitleType (line 39) | function parseSubtitleType(bangumi: BangumiRule): string {
function parseSeason (line 57) | function parseSeason(bangumi: BangumiRule): string {
function getProviders (line 218) | async function getProviders() {
function openModal (line 223) | function openModal() {
function closeModal (line 227) | function closeModal() {
function toggleModal (line 233) | function toggleModal() {
function clearSearch (line 241) | function clearSearch() {
function toggleVariantFilter (line 249) | function toggleVariantFilter(category: keyof SearchFilters, value: strin...
function clearVariantFilters (line 259) | function clearVariantFilters() {
function selectGroup (line 263) | function selectGroup(group: GroupedBangumi) {
function clearSelectedGroup (line 268) | function clearSelectedGroup() {
function selectResult (line 273) | function selectResult(bangumi: BangumiRule) {
function clearSelectedResult (line 277) | function clearSelectedResult() {
function onSearch (line 281) | function onSearch() {
FILE: webui/src/store/setup.ts
function nextStep (line 55) | function nextStep() {
function prevStep (line 61) | function prevStep() {
function goToStep (line 67) | function goToStep(index: number) {
function buildCompleteRequest (line 74) | function buildCompleteRequest(): SetupCompleteRequest {
function $reset (line 93) | function $reset() {
FILE: webui/src/test/mocks/api.ts
function createMockResponse (line 207) | function createMockResponse<T>(data: T, status = 200) {
function createMockError (line 220) | function createMockError(status: number, message: string) {
FILE: webui/src/utils/poster.ts
function resolvePosterUrl (line 1) | function resolvePosterUrl(link: string | null | undefined): string {
FILE: webui/types/api.ts
type AuthError (line 1) | type AuthError = 'Not authenticated';
type LoginError (line 3) | type LoginError = 'Password error' | 'User not found';
type ApiErrorMessage (line 5) | type ApiErrorMessage = AuthError | LoginError;
type StatusCode (line 13) | type StatusCode = 401 | 404 | 406 | 500;
type ApiError (line 15) | interface ApiError {
type ApiSuccess (line 21) | interface ApiSuccess {
FILE: webui/types/auth.ts
type LoginSuccess (line 1) | interface LoginSuccess {
type Update (line 7) | interface Update extends LoginSuccess {
type User (line 11) | interface User {
FILE: webui/types/bangumi.ts
type BangumiRule (line 4) | interface BangumiRule {
type BangumiAPI (line 32) | interface BangumiAPI extends Omit<BangumiRule, 'filter' | 'rss_link'> {
type SearchResult (line 37) | interface SearchResult {
type BangumiUpdate (line 42) | type BangumiUpdate = Omit<BangumiAPI, 'id'>;
type OffsetSuggestion (line 73) | interface OffsetSuggestion {
type TMDBSummary (line 79) | interface TMDBSummary {
type OffsetSuggestionDetail (line 87) | interface OffsetSuggestionDetail {
type DetectOffsetRequest (line 95) | interface DetectOffsetRequest {
type DetectOffsetResponse (line 102) | interface DetectOffsetResponse {
FILE: webui/types/components.ts
type SelectItem (line 1) | interface SelectItem {
type AbSettingProps (line 8) | interface AbSettingProps {
type SettingItem (line 16) | type SettingItem<T> = AbSettingProps & {
FILE: webui/types/config.ts
type DownloaderType (line 4) | type DownloaderType = ['qbittorrent'];
type RssParserLang (line 6) | type RssParserLang = ['zh', 'en', 'jp'];
type RenameMethod (line 8) | type RenameMethod = ['normal', 'pn', 'advance', 'none'];
type ProxyType (line 10) | type ProxyType = ['http', 'https', 'socks5'];
type NotificationType (line 12) | type NotificationType = [
type OpenAIModel (line 23) | type OpenAIModel = ['gpt-4o', 'gpt-4o-mini', 'gpt-3.5-turbo'];
type OpenAIType (line 25) | type OpenAIType = ['openai', 'azure'];
type Program (line 27) | interface Program {
type Downloader (line 33) | interface Downloader {
type RssParser (line 41) | interface RssParser {
type BangumiManage (line 46) | interface BangumiManage {
type Log (line 53) | interface Log {
type Proxy (line 56) | interface Proxy {
type NotificationProviderConfig (line 65) | interface NotificationProviderConfig {
type Notification (line 81) | interface Notification {
type ExperimentalOpenAI (line 89) | interface ExperimentalOpenAI {
type Security (line 104) | interface Security {
type Config (line 111) | interface Config {
FILE: webui/types/downloader.ts
type QbTorrentState (line 1) | type QbTorrentState =
type QbTorrentInfo (line 22) | interface QbTorrentInfo {
type TorrentGroup (line 38) | interface TorrentGroup {
FILE: webui/types/dts/components.d.ts
type GlobalComponents (line 11) | interface GlobalComponents {
FILE: webui/types/dts/html.d.ts
type HTMLAttributes (line 8) | interface HTMLAttributes extends AttributifyAttributes {}
FILE: webui/types/dts/router-type.d.ts
type RouteNamedMap (line 41) | interface RouteNamedMap {
type RouterTyped (line 58) | type RouterTyped = _RouterTyped<RouteNamedMap>
type RouteLocationNormalized (line 64) | type RouteLocationNormalized<Name extends keyof RouteNamedMap = keyof Ro...
type RouteLocationNormalizedLoaded (line 70) | type RouteLocationNormalizedLoaded<Name extends keyof RouteNamedMap = ke...
type RouteLocationResolved (line 76) | type RouteLocationResolved<Name extends keyof RouteNamedMap = keyof Rout...
type RouteLocation (line 81) | type RouteLocation<Name extends keyof RouteNamedMap = keyof RouteNamedMa...
type RouteLocationRaw (line 86) | type RouteLocationRaw<Name extends keyof RouteNamedMap = keyof RouteName...
type RouteParams (line 94) | type RouteParams<Name extends keyof RouteNamedMap> = RouteNamedMap[Name]...
type RouteParamsRaw (line 98) | type RouteParamsRaw<Name extends keyof RouteNamedMap> = RouteNamedMap[Na...
type TypesConfig (line 141) | interface TypesConfig {
FILE: webui/types/passkey.ts
type PasskeyItem (line 6) | interface PasskeyItem {
type RegistrationOptions (line 16) | interface RegistrationOptions {
type AuthenticationOptions (line 38) | interface AuthenticationOptions {
type PasskeyCreateRequest (line 51) | interface PasskeyCreateRequest {
type PasskeyDeleteRequest (line 57) | interface PasskeyDeleteRequest {
type PasskeyAuthStartRequest (line 62) | interface PasskeyAuthStartRequest {
type PasskeyAuthFinishRequest (line 67) | interface PasskeyAuthFinishRequest {
FILE: webui/types/rss.ts
type RSS (line 1) | interface RSS {
FILE: webui/types/setup.ts
type SetupStatus (line 1) | interface SetupStatus {
type TestDownloaderRequest (line 6) | interface TestDownloaderRequest {
type TestRSSRequest (line 14) | interface TestRSSRequest {
type TestNotificationRequest (line 18) | interface TestNotificationRequest {
type TestResult (line 24) | interface TestResult {
type SetupCompleteRequest (line 32) | interface SetupCompleteRequest {
type WizardStep (line 49) | type WizardStep =
FILE: webui/types/torrent.ts
type Torrent (line 1) | interface Torrent {
FILE: webui/types/utils.ts
type TupleToUnion (line 1) | type TupleToUnion<T extends any[]> = T[number];
Condensed preview — 472 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (1,931K chars).
[
{
"path": ".dockerignore",
"chars": 467,
"preview": "__pycache__\n*.pyc\n*.pyo\n*.pyd\n.Python\nenv\npip-log.txt\npip-delete-this-directory.txt\n.tox\n.coverage\n.coverage.*\n.cache\nno"
},
{
"path": ".gitattributes",
"chars": 751,
"preview": "# Don't allow people to merge changes to these generated files, because the result\r\n# may be invalid. You need to run \""
},
{
"path": ".github/ISSUE_TEMPLATE/bug_report.yml",
"chars": 1696,
"preview": "name: 问题反馈\ndescription: File a bug report\ntitle: \"[错误报告]请在此处简单描述你的问题\"\nlabels: [\"bug\"]\nbody:\n - type: markdown\n attri"
},
{
"path": ".github/ISSUE_TEMPLATE/config.yml",
"chars": 213,
"preview": "blank_issues_enabled: false\ncontact_links:\n - name: 使用说明\n url: https://github.com/EstrellaXD/Auto_Bangumi/wiki/Home\n"
},
{
"path": ".github/ISSUE_TEMPLATE/discussion.yml",
"chars": 620,
"preview": "name: 项目讨论\ndescription: discussion\ntitle: \"[Discussion] \"\nlabels: [\"discussion\"]\nbody:\n - type: markdown\n attributes"
},
{
"path": ".github/ISSUE_TEMPLATE/feature_request.yml",
"chars": 349,
"preview": "name: 功能改进\ndescription: Feature Request\ntitle: \"[Feature Request]\"\nlabels: [\"feature request\"]\nbody:\n - type: markdown\n"
},
{
"path": ".github/ISSUE_TEMPLATE/parser_bug.yml",
"chars": 1093,
"preview": "name: 解析器错误\ndescription: Report parser bug\ntitle: \"[解析器错误]\"\nlabels: [\"bug\"]\nbody:\n - type: markdown\n attributes:\n "
},
{
"path": ".github/ISSUE_TEMPLATE/rename_bug.yml",
"chars": 1262,
"preview": "name: 重命名错误\ndescription: Report parser bug\ntitle: \"[重命名错误]\"\nlabels: [\"bug\"]\nbody:\n - type: markdown\n attributes:\n "
},
{
"path": ".github/ISSUE_TEMPLATE/rfc.yml",
"chars": 1458,
"preview": "# https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/syntax-for-issue-f"
},
{
"path": ".github/PULL_REQUEST_TEMPLATE/pull_request_template.md",
"chars": 25,
"preview": "## New\n\n## Change\n\n## Fix"
},
{
"path": ".github/workflows/build.yml",
"chars": 12148,
"preview": "name: Build Docker\n\non:\n pull_request:\n types:\n - opened\n - synchronize\n - closed\n branches:\n "
},
{
"path": ".gitignore",
"chars": 3855,
"preview": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packagi"
},
{
"path": ".vscode/extensions.json",
"chars": 805,
"preview": "{\n \"recommendations\": [\n // https://marketplace.visualstudio.com/items?itemName=antfu.unocss\n \"antfu.unocss\",\n "
},
{
"path": ".vscode/launch.json",
"chars": 530,
"preview": "{\n // Use IntelliSense to learn about possible attributes.\n // Hover to view descriptions of existing attributes.\n //"
},
{
"path": ".vscode/settings.json",
"chars": 363,
"preview": "{\n \"files.associations\": {\n \"settings.json\": \"json5\",\n \"launch.json\": \"json5\",\n \"extensions.json\": \"json5\",\n "
},
{
"path": "CHANGELOG.md",
"chars": 9139,
"preview": "# [Unreleased]\n\n## Backend\n\n### Added\n\n- 新增 `Security` 配置模型,支持登录 IP 白名单、MCP IP 白名单和 Bearer Token 认证\n- 新增登录端点 IP 白名单检查中间件"
},
{
"path": "CLAUDE.md",
"chars": 5497,
"preview": "# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## "
},
{
"path": "CONTRIBUTING.md",
"chars": 5090,
"preview": "# 贡献指南 Contributing\n\n我们欢迎各位 Contributors 参与贡献帮助 AutoBangumi 更好的解决大家遇到的问题,\n\n这篇指南会指导你如何为 AutoBangumi 贡献功能修复代码,可以在你要提出 Pull"
},
{
"path": "Dockerfile",
"chars": 964,
"preview": "# syntax=docker/dockerfile:1\n\nFROM ghcr.io/astral-sh/uv:0.5-python3.13-alpine AS builder\n\nWORKDIR /app\nENV UV_COMPILE_BY"
},
{
"path": "LICENSE",
"chars": 1069,
"preview": "MIT License\n\nCopyright (c) 2022 Estrella Pan\n\nPermission is hereby granted, free of charge, to any person obtaining a co"
},
{
"path": "README.md",
"chars": 3065,
"preview": "<p align=\"center\">\n <img src=\"docs/public/image/icons/light-icon.svg#gh-light-mode-only\" width=50%/ alt=\"\">\n <img "
},
{
"path": "SECURITY.md",
"chars": 1478,
"preview": "# Security Policy / 安全政策\n\n## Supported Versions / 支持的版本\n\n| Version | Supported |\n| ------- | ------------------"
},
{
"path": "backend/.pre-commit-config.yaml",
"chars": 218,
"preview": "repos:\n- repo: https://github.com/psf/black\n rev: 22.10.0\n hooks:\n - id: black\n language: python\n- repo: https"
},
{
"path": "backend/.vscode/settings.json",
"chars": 196,
"preview": "{\n \"python.formatting.provider\": \"none\",\n \"python.formatting.blackPath\": \"black\",\n \"editor.formatOnSave\": true,\n \"[p"
},
{
"path": "backend/dev.sh",
"chars": 644,
"preview": "#!/usr/bin/env bash\n\n# This script is used to run the development environment.\n\npython3 -m venv venv\n\n./venv/bin/python3"
},
{
"path": "backend/pyproject.toml",
"chars": 1362,
"preview": "[project]\nname = \"auto-bangumi\"\nversion = \"3.2.4\"\ndescription = \"AutoBangumi - Automated anime download manager\"\nrequire"
},
{
"path": "backend/scripts/pip-lock-version.sh",
"chars": 1021,
"preview": "#!/usr/bin/env bash\n\n#\n# Usage:\n# `bash scripts/pip-lock-version.sh`\n#\n# ```prompt\n# Lock the library versions in `req"
},
{
"path": "backend/src/dev_server.py",
"chars": 1470,
"preview": "\"\"\"Minimal dev server that skips downloader check for UI testing.\"\"\"\nimport uvicorn\nfrom fastapi import FastAPI\nfrom fas"
},
{
"path": "backend/src/main.py",
"chars": 2853,
"preview": "import logging\nimport os\nfrom contextlib import asynccontextmanager\nfrom pathlib import Path\n\nimport uvicorn\nfrom fastap"
},
{
"path": "backend/src/module/__init__.py",
"chars": 1,
"preview": "\n"
},
{
"path": "backend/src/module/ab_decorator/__init__.py",
"chars": 1501,
"preview": "import asyncio\nimport functools\nimport logging\n\nimport httpx\n\nfrom .timeout import timeout\n\nlogger = logging.getLogger(_"
},
{
"path": "backend/src/module/ab_decorator/timeout.py",
"chars": 540,
"preview": "import signal\n\n\ndef timeout(seconds):\n def decorator(func):\n def handler(signum, frame):\n raise Tim"
},
{
"path": "backend/src/module/api/__init__.py",
"chars": 946,
"preview": "from fastapi import APIRouter\n\nfrom .auth import router as auth_router\nfrom .bangumi import router as bangumi_router\nfro"
},
{
"path": "backend/src/module/api/auth.py",
"chars": 3216,
"preview": "from datetime import datetime, timedelta\n\nfrom fastapi import APIRouter, Cookie, Depends, HTTPException, status\nfrom fas"
},
{
"path": "backend/src/module/api/bangumi.py",
"chars": 10803,
"preview": "from typing import Literal, Optional\n\nfrom fastapi import APIRouter, Depends\nfrom fastapi.responses import JSONResponse\n"
},
{
"path": "backend/src/module/api/config.py",
"chars": 2854,
"preview": "import logging\n\nfrom fastapi import APIRouter, Depends\nfrom fastapi.responses import JSONResponse\n\nfrom module.conf impo"
},
{
"path": "backend/src/module/api/downloader.py",
"chars": 4926,
"preview": "import logging\n\nfrom fastapi import APIRouter, Depends\nfrom pydantic import BaseModel\n\nfrom module.database import Datab"
},
{
"path": "backend/src/module/api/log.py",
"chars": 1511,
"preview": "from fastapi import APIRouter, Depends, HTTPException, Response, status\nfrom fastapi.responses import JSONResponse\n\nfrom"
},
{
"path": "backend/src/module/api/notification.py",
"chars": 4467,
"preview": "\"\"\"Notification API endpoints.\"\"\"\n\nimport logging\nfrom typing import Optional\n\nfrom fastapi import APIRouter, Depends\nfr"
},
{
"path": "backend/src/module/api/passkey.py",
"chars": 9858,
"preview": "\"\"\"\nPasskey 管理 API\n用于注册、列表、删除 Passkey 凭证\n\"\"\"\n\nimport logging\nfrom datetime import datetime, timedelta\n\nfrom fastapi impo"
},
{
"path": "backend/src/module/api/program.py",
"chars": 2771,
"preview": "import logging\nimport os\nimport signal\n\nfrom fastapi import APIRouter, Depends, HTTPException\nfrom fastapi.responses imp"
},
{
"path": "backend/src/module/api/response.py",
"chars": 385,
"preview": "from fastapi.exceptions import HTTPException\nfrom fastapi.responses import JSONResponse\n\nfrom module.models.response imp"
},
{
"path": "backend/src/module/api/rss.py",
"chars": 5562,
"preview": "from fastapi import APIRouter, Depends\nfrom fastapi.responses import JSONResponse\n\nfrom module.downloader import Downloa"
},
{
"path": "backend/src/module/api/search.py",
"chars": 1641,
"preview": "from fastapi import APIRouter, Depends, Query\nfrom sse_starlette.sse import EventSourceResponse\n\nfrom module.conf.search"
},
{
"path": "backend/src/module/api/setup.py",
"chars": 11375,
"preview": "import ipaddress\nimport logging\nimport socket\nfrom pathlib import Path\nfrom urllib.parse import urlparse\n\nimport httpx\nf"
},
{
"path": "backend/src/module/checker/__init__.py",
"chars": 29,
"preview": "from .checker import Checker\n"
},
{
"path": "backend/src/module/checker/checker.py",
"chars": 2865,
"preview": "import logging\nfrom pathlib import Path\n\nimport httpx\n\nfrom module.conf import VERSION, settings\nfrom module.models impo"
},
{
"path": "backend/src/module/conf/__init__.py",
"chars": 424,
"preview": "import sys\nfrom pathlib import Path\n\nfrom .config import VERSION, settings\nfrom .log import LOG_PATH, setup_logger\nfrom "
},
{
"path": "backend/src/module/conf/config.py",
"chars": 4523,
"preview": "import json\nimport logging\nimport os\nfrom pathlib import Path\n\nfrom dotenv import load_dotenv\n\nfrom module.models.config"
},
{
"path": "backend/src/module/conf/const.py",
"chars": 4117,
"preview": "# -*- encoding: utf-8 -*-\n# DEFAULT_SETTINGS: factory defaults written to config.json on first run.\n# ENV_TO_ATTR: maps "
},
{
"path": "backend/src/module/conf/log.py",
"chars": 1581,
"preview": "import atexit\nimport logging\nfrom logging.handlers import QueueHandler, QueueListener, RotatingFileHandler\nfrom pathlib "
},
{
"path": "backend/src/module/conf/parse.py",
"chars": 409,
"preview": "import argparse\n\n\ndef parse():\n parser = argparse.ArgumentParser(\n prog=\"Auto Bangumi\",\n description=\"\""
},
{
"path": "backend/src/module/conf/search_provider.py",
"chars": 869,
"preview": "from pathlib import Path\n\nfrom module.utils import json_config\n\nDEFAULT_PROVIDER = {\n \"mikan\": \"https://mikanani.me/R"
},
{
"path": "backend/src/module/conf/uvicorn_logging.py",
"chars": 1000,
"preview": "from .log import LOG_PATH\n\nlogging_config = {\n \"version\": 1,\n \"disable_existing_loggers\": False,\n \"formatters\":"
},
{
"path": "backend/src/module/core/__init__.py",
"chars": 29,
"preview": "from .program import Program\n"
},
{
"path": "backend/src/module/core/offset_scanner.py",
"chars": 4141,
"preview": "\"\"\"Background scanner for detecting season/episode offset mismatches.\"\"\"\n\nimport logging\n\nfrom module.conf import settin"
},
{
"path": "backend/src/module/core/program.py",
"chars": 6065,
"preview": "import asyncio\nimport logging\n\nfrom module.conf import VERSION, settings\nfrom module.models import ResponseModel\nfrom mo"
},
{
"path": "backend/src/module/core/status.py",
"chars": 1837,
"preview": "import asyncio\nimport time\n\nfrom module.checker import Checker\nfrom module.conf import LEGACY_DATA_PATH\n\nDOWNLOADER_STAT"
},
{
"path": "backend/src/module/core/sub_thread.py",
"chars": 7219,
"preview": "import asyncio\nimport logging\n\nfrom module.conf import settings\nfrom module.downloader import DownloadClient\nfrom module"
},
{
"path": "backend/src/module/database/__init__.py",
"chars": 57,
"preview": "from .combine import Database\nfrom .engine import engine\n"
},
{
"path": "backend/src/module/database/bangumi.py",
"chars": 25416,
"preview": "import json\nimport logging\nimport re\nimport time\nfrom typing import Optional\n\nfrom sqlalchemy.sql import func\nfrom sqlmo"
},
{
"path": "backend/src/module/database/combine.py",
"chars": 12820,
"preview": "import logging\nfrom typing import Any, get_args, get_origin\n\nfrom pydantic.fields import FieldInfo\nfrom pydantic_core im"
},
{
"path": "backend/src/module/database/engine.py",
"chars": 972,
"preview": "from sqlalchemy import event\nfrom sqlalchemy.ext.asyncio import AsyncSession, create_async_engine\nfrom sqlalchemy.orm im"
},
{
"path": "backend/src/module/database/passkey.py",
"chars": 2820,
"preview": "\"\"\"\nPasskey 数据库操作层\n\"\"\"\n\nimport logging\nfrom datetime import datetime, timezone\nfrom typing import List, Optional\n\nfrom f"
},
{
"path": "backend/src/module/database/rss.py",
"chars": 4187,
"preview": "import logging\n\nfrom sqlmodel import Session, and_, delete, select\n\nfrom module.models import RSSItem, RSSUpdate\n\nlogger"
},
{
"path": "backend/src/module/database/torrent.py",
"chars": 3773,
"preview": "import logging\n\nfrom sqlmodel import Session, select\n\nfrom module.models import Torrent\n\nlogger = logging.getLogger(__na"
},
{
"path": "backend/src/module/database/user.py",
"chars": 2983,
"preview": "import logging\n\nfrom fastapi import HTTPException\nfrom sqlmodel import Session, select\n\nfrom module.models import Respon"
},
{
"path": "backend/src/module/downloader/__init__.py",
"chars": 44,
"preview": "from .download_client import DownloadClient\n"
},
{
"path": "backend/src/module/downloader/client/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "backend/src/module/downloader/client/aria2_downloader.py",
"chars": 5116,
"preview": "import asyncio\nimport logging\n\nimport httpx\n\nfrom module.conf import settings\n\nlogger = logging.getLogger(__name__)\n\n\ncl"
},
{
"path": "backend/src/module/downloader/client/mock_downloader.py",
"chars": 8034,
"preview": "\"\"\"\nMock Downloader for local development and testing.\n\nThis downloader simulates qBittorrent behavior without requiring"
},
{
"path": "backend/src/module/downloader/client/qb_downloader.py",
"chars": 12030,
"preview": "import asyncio\nimport json\nimport logging\n\nimport httpx\n\nfrom module.ab_decorator import qb_connect_failed_wait\n\nlogger "
},
{
"path": "backend/src/module/downloader/client/tr_downloader.py",
"chars": 0,
"preview": ""
},
{
"path": "backend/src/module/downloader/download_client.py",
"chars": 10201,
"preview": "import asyncio\nimport logging\n\nfrom module.conf import settings\nfrom module.models import Bangumi, Torrent\nfrom module.n"
},
{
"path": "backend/src/module/downloader/exceptions.py",
"chars": 41,
"preview": "class ConflictError(Exception):\n pass\n"
},
{
"path": "backend/src/module/downloader/path.py",
"chars": 3090,
"preview": "import logging\nimport re\nfrom os import PathLike\n\nfrom module.conf import PLATFORM, settings\nfrom module.models import B"
},
{
"path": "backend/src/module/manager/__init__.py",
"chars": 118,
"preview": "from .collector import SeasonCollector, eps_complete\nfrom .renamer import Renamer\nfrom .torrent import TorrentManager\n"
},
{
"path": "backend/src/module/manager/collector.py",
"chars": 2968,
"preview": "import logging\n\nfrom module.downloader import DownloadClient\nfrom module.models import Bangumi, ResponseModel\nfrom modul"
},
{
"path": "backend/src/module/manager/renamer.py",
"chars": 21123,
"preview": "import asyncio\nimport logging\nimport time\n\nfrom module.conf import settings\nfrom module.database import Database\nfrom mo"
},
{
"path": "backend/src/module/manager/torrent.py",
"chars": 14629,
"preview": "import logging\n\nfrom module.conf import settings\nfrom module.database import Database\nfrom module.downloader import Down"
},
{
"path": "backend/src/module/mcp/__init__.py",
"chars": 395,
"preview": "\"\"\"MCP (Model Context Protocol) server for AutoBangumi.\n\nExposes anime subscriptions, RSS feeds, and download status to "
},
{
"path": "backend/src/module/mcp/resources.py",
"chars": 3384,
"preview": "\"\"\"MCP resource definitions and handlers for AutoBangumi.\n\n``RESOURCES`` lists static resources; ``RESOURCE_TEMPLATES`` "
},
{
"path": "backend/src/module/mcp/security.py",
"chars": 2272,
"preview": "\"\"\"MCP access control: configurable IP whitelist and bearer token authentication.\"\"\"\n\nimport ipaddress\nimport logging\nfr"
},
{
"path": "backend/src/module/mcp/server.py",
"chars": 2523,
"preview": "\"\"\"MCP server assembly for AutoBangumi.\n\nWires together the MCP ``Server``, SSE transport, tool/resource handlers,\nand l"
},
{
"path": "backend/src/module/mcp/tools.py",
"chars": 12030,
"preview": "import json\nimport logging\n\nfrom mcp import types\n\nfrom module.conf import VERSION\nfrom module.downloader import Downloa"
},
{
"path": "backend/src/module/models/__init__.py",
"chars": 368,
"preview": "from .bangumi import Bangumi, BangumiUpdate, Episode, Notification\nfrom .config import Config\nfrom .passkey import Passk"
},
{
"path": "backend/src/module/models/api.py",
"chars": 232,
"preview": "from pydantic import BaseModel\n\n\nclass RssLink(BaseModel):\n rss_link: str\n\n\nclass AddRule(BaseModel):\n title: str\n"
},
{
"path": "backend/src/module/models/bangumi.py",
"chars": 5441,
"preview": "from dataclasses import dataclass\nfrom typing import Optional\n\nfrom pydantic import BaseModel\nfrom sqlmodel import Field"
},
{
"path": "backend/src/module/models/config.py",
"chars": 8982,
"preview": "from os.path import expandvars\nfrom typing import Literal, Optional\n\nfrom pydantic import BaseModel, Field, field_valida"
},
{
"path": "backend/src/module/models/passkey.py",
"chars": 1884,
"preview": "\"\"\"\nWebAuthn Passkey 数据模型\n\"\"\"\n\nfrom datetime import datetime, timezone\nfrom typing import Optional\n\nfrom pydantic import"
},
{
"path": "backend/src/module/models/response.py",
"chars": 506,
"preview": "from pydantic import BaseModel, Field\n\n\nclass ResponseModel(BaseModel):\n status: bool = Field(..., json_schema_extra="
},
{
"path": "backend/src/module/models/rss.py",
"chars": 987,
"preview": "from typing import Optional\n\nfrom sqlmodel import Field, SQLModel\n\n\nclass RSSItem(SQLModel, table=True):\n id: int = F"
},
{
"path": "backend/src/module/models/torrent.py",
"chars": 1341,
"preview": "from typing import Optional\n\nfrom pydantic import BaseModel\nfrom sqlmodel import Field, SQLModel\n\n\nclass Torrent(SQLMode"
},
{
"path": "backend/src/module/models/user.py",
"chars": 745,
"preview": "from typing import Optional\n\nfrom pydantic import BaseModel\nfrom sqlmodel import Field, SQLModel\n\n\nclass User(SQLModel, "
},
{
"path": "backend/src/module/network/__init__.py",
"chars": 45,
"preview": "from .request_contents import RequestContent\n"
},
{
"path": "backend/src/module/network/request_contents.py",
"chars": 2652,
"preview": "import logging\nimport re\nimport xml.etree.ElementTree\n\nfrom module.conf import settings\nfrom module.models import Torren"
},
{
"path": "backend/src/module/network/request_url.py",
"chars": 6063,
"preview": "import asyncio\nimport logging\n\nimport httpx\nfrom httpx_socks import AsyncProxyTransport\n\nfrom module.conf import setting"
},
{
"path": "backend/src/module/network/site/__init__.py",
"chars": 30,
"preview": "from .mikan import rss_parser\n"
},
{
"path": "backend/src/module/network/site/mikan.py",
"chars": 727,
"preview": "import logging\n\nlogger = logging.getLogger(__name__)\n\n\ndef rss_parser(soup):\n results = []\n for item in soup.finda"
},
{
"path": "backend/src/module/notification/__init__.py",
"chars": 283,
"preview": "from .notification import PostNotification\nfrom .manager import NotificationManager\nfrom .base import NotificationProvid"
},
{
"path": "backend/src/module/notification/base.py",
"chars": 1417,
"preview": "\"\"\"Base class for notification providers.\"\"\"\n\nfrom abc import ABC, abstractmethod\n\nfrom module.models.bangumi import Not"
},
{
"path": "backend/src/module/notification/manager.py",
"chars": 4444,
"preview": "\"\"\"Notification manager for handling multiple providers.\"\"\"\n\nimport asyncio\nimport logging\nfrom typing import TYPE_CHECK"
},
{
"path": "backend/src/module/notification/notification.py",
"chars": 1686,
"preview": "import asyncio\nimport logging\n\nfrom module.conf import settings\nfrom module.database import Database\nfrom module.models "
},
{
"path": "backend/src/module/notification/plugin/__init__.py",
"chars": 163,
"preview": "from .bark import BarkNotification\nfrom .server_chan import ServerChanNotification\nfrom .telegram import TelegramNotific"
},
{
"path": "backend/src/module/notification/plugin/bark.py",
"chars": 955,
"preview": "import logging\n\nfrom module.models import Notification\nfrom module.network import RequestContent\n\nlogger = logging.getLo"
},
{
"path": "backend/src/module/notification/plugin/server_chan.py",
"chars": 955,
"preview": "import logging\n\nfrom module.models import Notification\nfrom module.network import RequestContent\n\nlogger = logging.getLo"
},
{
"path": "backend/src/module/notification/plugin/slack.py",
"chars": 928,
"preview": "import logging\n\nfrom module.models import Notification\nfrom module.network import RequestContent\n\nlogger = logging.getLo"
},
{
"path": "backend/src/module/notification/plugin/telegram.py",
"chars": 1281,
"preview": "import logging\n\nfrom module.models import Notification\nfrom module.network import RequestContent\nfrom module.utils impor"
},
{
"path": "backend/src/module/notification/plugin/wecom.py",
"chars": 1451,
"preview": "import logging\n\nfrom module.models import Notification\nfrom module.network import RequestContent\n\nlogger = logging.getLo"
},
{
"path": "backend/src/module/notification/providers/__init__.py",
"chars": 1340,
"preview": "\"\"\"Notification providers registry.\"\"\"\n\nfrom typing import TYPE_CHECKING\n\nfrom module.notification.providers.telegram im"
},
{
"path": "backend/src/module/notification/providers/bark.py",
"chars": 1964,
"preview": "\"\"\"Bark notification provider.\"\"\"\n\nimport logging\nfrom typing import TYPE_CHECKING\n\nfrom module.models.bangumi import No"
},
{
"path": "backend/src/module/notification/providers/discord.py",
"chars": 2082,
"preview": "\"\"\"Discord notification provider.\"\"\"\n\nimport logging\nfrom typing import TYPE_CHECKING\n\nfrom module.models.bangumi import"
},
{
"path": "backend/src/module/notification/providers/gotify.py",
"chars": 2151,
"preview": "\"\"\"Gotify notification provider.\"\"\"\n\nimport logging\nfrom typing import TYPE_CHECKING\n\nfrom module.models.bangumi import "
},
{
"path": "backend/src/module/notification/providers/pushover.py",
"chars": 2442,
"preview": "\"\"\"Pushover notification provider.\"\"\"\n\nimport logging\nfrom typing import TYPE_CHECKING\n\nfrom module.models.bangumi impor"
},
{
"path": "backend/src/module/notification/providers/server_chan.py",
"chars": 1700,
"preview": "\"\"\"Server Chan notification provider.\"\"\"\n\nimport logging\nfrom typing import TYPE_CHECKING\n\nfrom module.models.bangumi im"
},
{
"path": "backend/src/module/notification/providers/telegram.py",
"chars": 2045,
"preview": "\"\"\"Telegram notification provider.\"\"\"\n\nimport logging\nfrom typing import TYPE_CHECKING\n\nfrom module.models.bangumi impor"
},
{
"path": "backend/src/module/notification/providers/webhook.py",
"chars": 3324,
"preview": "\"\"\"Generic webhook notification provider.\"\"\"\n\nimport json\nimport logging\nimport re\nfrom typing import TYPE_CHECKING\n\nfro"
},
{
"path": "backend/src/module/notification/providers/wecom.py",
"chars": 2383,
"preview": "\"\"\"WeChat Work (企业微信) notification provider.\"\"\"\n\nimport logging\nfrom typing import TYPE_CHECKING\n\nfrom module.models.ban"
},
{
"path": "backend/src/module/parser/__init__.py",
"chars": 38,
"preview": "from .title_parser import TitleParser\n"
},
{
"path": "backend/src/module/parser/analyser/__init__.py",
"chars": 187,
"preview": "from .mikan_parser import mikan_parser\nfrom .openai import OpenAIParser\nfrom .raw_parser import raw_parser\nfrom .tmdb_pa"
},
{
"path": "backend/src/module/parser/analyser/bgm_calendar.py",
"chars": 2940,
"preview": "import logging\n\nfrom module.network import RequestContent\n\nlogger = logging.getLogger(__name__)\n\nBGM_CALENDAR_URL = \"htt"
},
{
"path": "backend/src/module/parser/analyser/bgm_parser.py",
"chars": 367,
"preview": "from module.network import RequestContent\n\n\ndef search_url(e):\n return f\"https://api.bgm.tv/search/subject/{e}?respon"
},
{
"path": "backend/src/module/parser/analyser/mikan_parser.py",
"chars": 1570,
"preview": "import logging\nimport re\n\nfrom bs4 import BeautifulSoup\nfrom urllib3.util import parse_url\n\nfrom module.network import R"
},
{
"path": "backend/src/module/parser/analyser/offset_detector.py",
"chars": 5516,
"preview": "\"\"\"Offset detector for detecting season/episode mismatches with TMDB data.\"\"\"\n\nimport logging\nfrom dataclasses import da"
},
{
"path": "backend/src/module/parser/analyser/openai.py",
"chars": 5032,
"preview": "import json\nimport logging\nfrom concurrent.futures import ThreadPoolExecutor\nfrom typing import Any, Optional\n\nfrom open"
},
{
"path": "backend/src/module/parser/analyser/raw_parser.py",
"chars": 6984,
"preview": "import logging\nimport re\n\nfrom module.models import Episode\n\nlogger = logging.getLogger(__name__)\n\nEPISODE_RE = re.compi"
},
{
"path": "backend/src/module/parser/analyser/tmdb_parser.py",
"chars": 11484,
"preview": "import asyncio\nimport logging\nimport re\nimport time\nfrom collections import OrderedDict\nfrom dataclasses import dataclas"
},
{
"path": "backend/src/module/parser/analyser/torrent_parser.py",
"chars": 5062,
"preview": "import logging\nimport re\nfrom collections import OrderedDict\nfrom pathlib import Path\n\nfrom module.models import Episode"
},
{
"path": "backend/src/module/parser/title_parser.py",
"chars": 4188,
"preview": "import logging\n\nfrom module.conf import settings\nfrom module.models import Bangumi\nfrom module.models.bangumi import Epi"
},
{
"path": "backend/src/module/rss/__init__.py",
"chars": 64,
"preview": "from .analyser import RSSAnalyser\nfrom .engine import RSSEngine\n"
},
{
"path": "backend/src/module/rss/analyser.py",
"chars": 3950,
"preview": "import logging\nimport re\n\nfrom module.conf import settings\nfrom module.models import Bangumi, ResponseModel, RSSItem, To"
},
{
"path": "backend/src/module/rss/engine.py",
"chars": 7374,
"preview": "import asyncio\nimport logging\nimport re\nfrom datetime import datetime, timezone\nfrom typing import Optional\n\nfrom module"
},
{
"path": "backend/src/module/searcher/__init__.py",
"chars": 72,
"preview": "from .provider import SEARCH_CONFIG\nfrom .searcher import SearchTorrent\n"
},
{
"path": "backend/src/module/searcher/provider.py",
"chars": 587,
"preview": "import re\n\nfrom module.conf import SEARCH_CONFIG\nfrom module.models import RSSItem\n\n\ndef search_url(site: str, keywords:"
},
{
"path": "backend/src/module/searcher/searcher.py",
"chars": 2952,
"preview": "import json\nimport logging\nfrom typing import TypeAlias\n\nfrom module.models import Bangumi, RSSItem, Torrent\nfrom module"
},
{
"path": "backend/src/module/security/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "backend/src/module/security/api.py",
"chars": 3178,
"preview": "from datetime import datetime\n\nfrom fastapi import Cookie, Depends, HTTPException, Request, status\nfrom fastapi.security"
},
{
"path": "backend/src/module/security/auth_strategy.py",
"chars": 4200,
"preview": "\"\"\"\n认证策略抽象层\n将密码认证和 Passkey 认证统一为策略模式\n\"\"\"\nfrom abc import ABC, abstractmethod\n\nfrom sqlmodel import select\n\nfrom module.d"
},
{
"path": "backend/src/module/security/jwt.py",
"chars": 1851,
"preview": "import secrets\nfrom datetime import datetime, timedelta, timezone\nfrom pathlib import Path\n\nfrom jose import JWTError, j"
},
{
"path": "backend/src/module/security/webauthn.py",
"chars": 12215,
"preview": "\"\"\"\nWebAuthn 认证服务层\n封装 py_webauthn 库的复杂性,提供清晰的注册和认证接口\n\"\"\"\n\nimport base64\nimport json\nimport logging\nimport time\nfrom typi"
},
{
"path": "backend/src/module/update/__init__.py",
"chars": 210,
"preview": "from .cross_version import cache_image, from_30_to_31, from_31_to_32, run_migrations\nfrom .data_migration import data_mi"
},
{
"path": "backend/src/module/update/cross_version.py",
"chars": 2049,
"preview": "import logging\nimport re\n\nfrom urllib3.util import parse_url\n\nfrom module.network import RequestContent\nfrom module.rss "
},
{
"path": "backend/src/module/update/data_migration.py",
"chars": 688,
"preview": "from module.conf import LEGACY_DATA_PATH\nfrom module.models import Bangumi\nfrom module.rss import RSSEngine\nfrom module."
},
{
"path": "backend/src/module/update/rss.py",
"chars": 150,
"preview": "from module.rss import RSSEngine\n\n\ndef update_main_rss(rss_link: str):\n with RSSEngine() as engine:\n engine.ad"
},
{
"path": "backend/src/module/update/startup.py",
"chars": 479,
"preview": "import logging\n\nfrom module.conf import POSTERS_PATH\nfrom module.rss import RSSEngine\n\nlogger = logging.getLogger(__name"
},
{
"path": "backend/src/module/update/version_check.py",
"chars": 1133,
"preview": "import semver\n\nfrom module.conf import VERSION, VERSION_PATH\n\n\ndef version_check() -> tuple[bool, int | None]:\n \"\"\"Ch"
},
{
"path": "backend/src/module/utils/__init__.py",
"chars": 47,
"preview": "from .cache_image import save_image, load_image"
},
{
"path": "backend/src/module/utils/bangumi_data.py",
"chars": 53,
"preview": "import logging\n\nlogger = logging.getLogger(__name__)\n"
},
{
"path": "backend/src/module/utils/cache_image.py",
"chars": 402,
"preview": "import hashlib\n\n\ndef save_image(img, suffix):\n img_hash = hashlib.md5(img).hexdigest()[0:8]\n image_path = f\"data/p"
},
{
"path": "backend/src/module/utils/json_config.py",
"chars": 419,
"preview": "import json\n\nimport httpx\n\n\ndef load(filename):\n with open(filename, \"r\", encoding=\"utf-8\") as f:\n return json"
},
{
"path": "backend/src/test/__init__.py",
"chars": 14,
"preview": "import module\n"
},
{
"path": "backend/src/test/conftest.py",
"chars": 7083,
"preview": "\"\"\"Shared test fixtures for AutoBangumi test suite.\"\"\"\n\nimport pytest\nfrom unittest.mock import AsyncMock, MagicMock, pa"
},
{
"path": "backend/src/test/e2e/Dockerfile.mock-rss",
"chars": 184,
"preview": "FROM python:3.11-slim\nRUN pip install --no-cache-dir aiohttp\nCOPY mock_rss_server.py /app/\nCOPY fixtures/ /app/fixtures/"
},
{
"path": "backend/src/test/e2e/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "backend/src/test/e2e/conftest.py",
"chars": 5559,
"preview": "\"\"\"Shared fixtures for E2E integration tests.\n\nThese tests require Docker (qBittorrent + mock RSS server) and run\nAutoBa"
},
{
"path": "backend/src/test/e2e/docker-compose.test.yml",
"chars": 796,
"preview": "services:\n qbittorrent:\n image: linuxserver/qbittorrent:latest\n container_name: ab-test-qbittorrent\n environme"
},
{
"path": "backend/src/test/e2e/fixtures/mikan.xml",
"chars": 3727,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<rss version=\"2.0\">\n <channel>\n <title>Mikan Project - E2E Test Feed</title>\n"
},
{
"path": "backend/src/test/e2e/mock_rss_server.py",
"chars": 945,
"preview": "\"\"\"Minimal HTTP server that serves static RSS XML fixtures.\"\"\"\n\nimport asyncio\nfrom pathlib import Path\n\nfrom aiohttp im"
},
{
"path": "backend/src/test/e2e/test_e2e_workflow.py",
"chars": 26616,
"preview": "\"\"\"E2E integration tests for the full AutoBangumi workflow.\n\nTests are executed in definition order within the class. E"
},
{
"path": "backend/src/test/factories.py",
"chars": 2614,
"preview": "\"\"\"Test data factories for creating model instances with sensible defaults.\"\"\"\n\nfrom datetime import datetime, timezone\n"
},
{
"path": "backend/src/test/test_api_auth.py",
"chars": 11470,
"preview": "\"\"\"Tests for Auth API endpoints.\"\"\"\n\nfrom datetime import datetime\nfrom unittest.mock import MagicMock, patch\n\nimport py"
},
{
"path": "backend/src/test/test_api_bangumi.py",
"chars": 8448,
"preview": "\"\"\"Tests for Bangumi API endpoints.\"\"\"\n\nimport pytest\nfrom unittest.mock import patch, MagicMock, AsyncMock\nfrom datetim"
},
{
"path": "backend/src/test/test_api_bangumi_extended.py",
"chars": 13105,
"preview": "\"\"\"Tests for extended Bangumi API endpoints (archive, refresh, offset, batch).\"\"\"\n\nimport pytest\nfrom unittest.mock impo"
},
{
"path": "backend/src/test/test_api_config.py",
"chars": 21063,
"preview": "\"\"\"Tests for Config API endpoints and config sanitization.\"\"\"\n\nimport pytest\nfrom unittest.mock import patch, MagicMock\n"
},
{
"path": "backend/src/test/test_api_downloader.py",
"chars": 16778,
"preview": "\"\"\"Tests for Downloader API endpoints.\"\"\"\n\nimport pytest\nfrom unittest.mock import patch, AsyncMock\n\nfrom fastapi import"
},
{
"path": "backend/src/test/test_api_log.py",
"chars": 4744,
"preview": "\"\"\"Tests for Log API endpoints.\"\"\"\n\nimport pytest\nfrom pathlib import Path\nfrom tempfile import TemporaryDirectory\nfrom "
},
{
"path": "backend/src/test/test_api_passkey.py",
"chars": 19338,
"preview": "\"\"\"Tests for Passkey (WebAuthn) API endpoints.\"\"\"\n\nfrom datetime import datetime\nfrom unittest.mock import AsyncMock, Ma"
},
{
"path": "backend/src/test/test_api_program.py",
"chars": 7560,
"preview": "\"\"\"Tests for Program API endpoints.\"\"\"\n\nimport pytest\nfrom unittest.mock import patch, MagicMock, AsyncMock\n\nfrom fastap"
},
{
"path": "backend/src/test/test_api_rss.py",
"chars": 12110,
"preview": "\"\"\"Tests for RSS API endpoints.\"\"\"\n\nimport pytest\nfrom unittest.mock import patch, MagicMock, AsyncMock\n\nfrom fastapi im"
},
{
"path": "backend/src/test/test_api_search.py",
"chars": 6045,
"preview": "\"\"\"Tests for Search API endpoints.\"\"\"\n\nimport pytest\nfrom unittest.mock import patch, MagicMock, AsyncMock\n\nfrom fastapi"
},
{
"path": "backend/src/test/test_auth.py",
"chars": 12416,
"preview": "\"\"\"Tests for authentication: JWT tokens, password hashing, login flow.\"\"\"\n\nfrom datetime import timedelta\nfrom unittest."
},
{
"path": "backend/src/test/test_config.py",
"chars": 19007,
"preview": "\"\"\"Tests for configuration: loading, env overrides, defaults, migration.\"\"\"\n\nimport json\nimport os\nimport pytest\nfrom pa"
},
{
"path": "backend/src/test/test_database.py",
"chars": 17378,
"preview": "import json\n\nimport pytest\nfrom sqlmodel import Session, SQLModel, create_engine\n\nfrom module.database.bangumi import Ba"
},
{
"path": "backend/src/test/test_download_client.py",
"chars": 16253,
"preview": "\"\"\"Tests for DownloadClient: init, set_rule, add_torrent, rename, etc.\"\"\"\n\nimport pytest\nfrom unittest.mock import Async"
},
{
"path": "backend/src/test/test_integration.py",
"chars": 14459,
"preview": "\"\"\"Integration tests: end-to-end flows with real DB and mocked externals.\"\"\"\n\nimport pytest\nfrom unittest.mock import As"
},
{
"path": "backend/src/test/test_issue_bugs.py",
"chars": 21982,
"preview": "\"\"\"Tests reproducing bugs from GitHub issues #974, #976, #977, #986.\n\nEach test class targets a specific issue with test"
},
{
"path": "backend/src/test/test_mcp_resources.py",
"chars": 14277,
"preview": "\"\"\"Tests for module.mcp.resources - handle_resource() and _bangumi_to_dict().\"\"\"\n\nimport json\nfrom unittest.mock import "
},
{
"path": "backend/src/test/test_mcp_security.py",
"chars": 8227,
"preview": "\"\"\"Tests for module.mcp.security - McpAccessMiddleware, _is_allowed(), and clear_network_cache().\"\"\"\n\nfrom unittest.mock"
},
{
"path": "backend/src/test/test_mcp_tools.py",
"chars": 21785,
"preview": "\"\"\"Tests for module.mcp.tools - _bangumi_to_dict helper and _dispatch routing.\"\"\"\n\nimport json\nfrom unittest.mock import"
},
{
"path": "backend/src/test/test_migration.py",
"chars": 19017,
"preview": "\"\"\"Tests for config and database migration from 3.1.x to 3.2.x.\"\"\"\nimport json\nimport tempfile\nfrom pathlib import Path\n"
},
{
"path": "backend/src/test/test_mock_downloader.py",
"chars": 16442,
"preview": "\"\"\"Tests for MockDownloader - state management and API contract.\"\"\"\n\nimport pytest\n\nfrom module.downloader.client.mock_d"
},
{
"path": "backend/src/test/test_notification.py",
"chars": 12671,
"preview": "\"\"\"Tests for notification: provider registry, manager, and provider implementations.\"\"\"\n\nimport pytest\nfrom unittest.moc"
},
{
"path": "backend/src/test/test_openai.py",
"chars": 2288,
"preview": "import json\nimport pytest\nfrom unittest import mock\n\nfrom module.parser.analyser.openai import DEFAULT_PROMPT, OpenAIPar"
},
{
"path": "backend/src/test/test_path.py",
"chars": 7932,
"preview": "\"\"\"Tests for TorrentPath: save path generation, file classification, parsing.\"\"\"\n\nimport pytest\nfrom unittest.mock impor"
},
{
"path": "backend/src/test/test_path_parser.py",
"chars": 5110,
"preview": "from unittest.mock import patch\n\nfrom module.conf import PLATFORM\n\n\ndef test_path_to_bangumi():\n # Test for unix-like"
},
{
"path": "backend/src/test/test_qb_downloader.py",
"chars": 18214,
"preview": "\"\"\"Tests for QbDownloader: constructor, SSL/scheme logic, auth, and error handling.\n\nThe implementation keeps _use_https"
},
{
"path": "backend/src/test/test_raw_parser.py",
"chars": 14322,
"preview": "import pytest\n\nfrom module.parser.analyser import raw_parser\n\n\ndef test_raw_parser():\n # Issue #794, RSS link: https:"
},
{
"path": "backend/src/test/test_renamer.py",
"chars": 40408,
"preview": "\"\"\"Tests for Renamer: gen_path, rename_file, rename_collection, rename flow.\"\"\"\n\nfrom unittest.mock import AsyncMock, Ma"
},
{
"path": "backend/src/test/test_rss_engine.py",
"chars": 479,
"preview": "import pytest\n\n# Skip the entire module as it requires network access and complex setup\npytestmark = pytest.mark.skip(re"
},
{
"path": "backend/src/test/test_rss_engine_new.py",
"chars": 12626,
"preview": "\"\"\"Tests for RSS engine: pull_rss, match_torrent, refresh_rss, add_rss.\"\"\"\n\nimport pytest\nfrom unittest.mock import Asyn"
},
{
"path": "backend/src/test/test_searcher.py",
"chars": 4407,
"preview": "\"\"\"Tests for search providers: URL construction, keyword handling.\"\"\"\n\nimport pytest\nfrom unittest.mock import patch\n\nfr"
},
{
"path": "backend/src/test/test_setup.py",
"chars": 11268,
"preview": "from pathlib import Path\nfrom unittest.mock import AsyncMock, patch\n\nimport pytest\nfrom fastapi.testclient import TestCl"
},
{
"path": "backend/src/test/test_title_parser.py",
"chars": 1011,
"preview": "import pytest\nfrom module.conf import settings\nfrom module.parser.title_parser import TitleParser\n\n\nclass TestTitleParse"
},
{
"path": "backend/src/test/test_tmdb.py",
"chars": 365,
"preview": "from module.parser.analyser.tmdb_parser import tmdb_parser\n\n\nasync def test_tmdb_parser():\n bangumi_title = \"海盗战记\"\n "
},
{
"path": "backend/src/test/test_torrent_parser.py",
"chars": 5108,
"preview": "import sys\n\nimport pytest\nfrom module.parser.analyser import torrent_parser\nfrom module.parser.analyser.torrent_parser i"
},
{
"path": "backend/src/test_passkey_server.py",
"chars": 935,
"preview": "\"\"\"\nMinimal test server for passkey development.\nUses the real auth and passkey API routes without the downloader check."
},
{
"path": "docs/.vitepress/config.ts",
"chars": 10743,
"preview": "import { defineConfig } from 'vitepress'\n\nconst version = `v3.2`\n\n// Shared configuration\nconst sharedConfig = {\n head:"
},
{
"path": "docs/.vitepress/theme/components/HomePreviewWebUI.vue",
"chars": 803,
"preview": "<script setup lang=\"ts\">\n</script>\n\n<template>\n <div class=\"container\">\n <img\n src=\"/image/preview/window.png\"\n"
},
{
"path": "docs/.vitepress/theme/index.ts",
"chars": 680,
"preview": "import { h, onMounted, watch, nextTick } from 'vue'\nimport Theme from 'vitepress/theme'\nimport { useRoute } from 'vitepr"
},
{
"path": "docs/.vitepress/theme/style.css",
"chars": 2297,
"preview": "/**\n * Customize default theme styling by overriding CSS variables:\n * https://github.com/vuejs/vitepress/blob/main/src/"
}
]
// ... and 272 more files (download for full content)
About this extraction
This page contains the full source code of the EstrellaXD/Auto_Bangumi GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 472 files (1.7 MB), approximately 483.4k tokens, and a symbol index with 2027 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.