From 6d4ce8383798cec959cb0bfad30236f6ee45b3c5 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Wed, 21 Oct 2026 22:03:18 +0800 Subject: [PATCH] fix(types): clear host mypy debt and prevent typing regressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。 按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩, CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。 修复空值、异步和供应方类型边界,保持插件与 API 兼容合同, 并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。 --- .github/workflows/test.yml | 5 +- app/adapters/cache/backends.py | 47 +- app/adapters/cache/redis.py | 107 +- app/adapters/external/plugin/client.py | 33 +- app/adapters/external/server.py | 14 +- app/adapters/network/browser.py | 78 +- app/adapters/network/doh.py | 19 +- app/adapters/network/http.py | 4 +- app/adapters/system/fsproxy.py | 47 +- app/adapters/system/host.py | 81 +- app/adapters/system/rust.py | 65 +- app/adapters/system/update.py | 4 +- app/adapters/web/security/access.py | 2 +- app/agent/learning/review.py | 20 +- app/agent/learning/schema.py | 10 +- app/agent/learning/tools.py | 2 +- app/agent/learning/usage.py | 6 +- app/agent/llm/capability.py | 55 +- app/agent/llm/helper.py | 103 +- app/agent/loader.py | 2 +- app/agent/mcp.py | 30 +- app/agent/middleware/code.py | 8 +- app/agent/middleware/config.py | 3 +- app/agent/middleware/guardrails.py | 18 +- app/agent/middleware/invocation.py | 21 +- app/agent/middleware/jobs.py | 10 +- app/agent/middleware/learning.py | 4 +- app/agent/middleware/memory.py | 16 +- app/agent/middleware/output.py | 3 +- app/agent/middleware/patching.py | 13 +- app/agent/middleware/plan.py | 27 +- app/agent/middleware/policy.py | 15 +- app/agent/middleware/recall.py | 16 +- app/agent/middleware/selection.py | 74 +- app/agent/middleware/skills.py | 11 +- app/agent/middleware/steering.py | 4 +- app/agent/middleware/subagents.py | 61 +- app/agent/middleware/summarization.py | 28 +- app/agent/middleware/terminal.py | 2 +- app/agent/middleware/usage.py | 20 +- app/agent/middleware/vision.py | 23 +- app/agent/orchestrator.py | 72 +- app/agent/session.py | 10 +- app/agent/skills/registry.py | 58 +- app/agent/tools/base.py | 3 +- app/agent/tools/catalog.py | 4 +- app/agent/tools/factory.py | 3 +- app/agent/tools/impl/agent_task.py | 7 +- app/agent/tools/impl/api.py | 11 +- app/agent/tools/impl/apply_patch.py | 16 +- app/agent/tools/impl/ask_user_choice.py | 12 +- app/agent/tools/impl/browse_webpage.py | 66 +- app/agent/tools/impl/edit_file.py | 10 +- app/agent/tools/impl/execute_code.py | 5 +- app/agent/tools/impl/execute_command.py | 17 +- app/agent/tools/impl/mcp.py | 2 +- app/agent/tools/impl/persona.py | 5 +- app/agent/tools/impl/query_doctor_report.py | 2 +- app/agent/tools/impl/read_file.py | 13 +- app/agent/tools/impl/recognize_captcha.py | 8 +- app/agent/tools/impl/search_web.py | 54 +- app/agent/tools/impl/send_local_file.py | 10 +- app/agent/tools/impl/send_message.py | 6 +- app/agent/tools/impl/send_voice_message.py | 6 +- app/agent/tools/impl/service.py | 13 +- app/agent/tools/impl/view_image.py | 7 +- app/agent/tools/impl/write_file.py | 12 +- app/agent/tools/result.py | 4 +- app/api/dependencies/history.py | 43 +- app/api/dependencies/workflow.py | 15 +- app/api/endpoints/agent.py | 4 +- app/api/endpoints/auth.py | 2 +- app/api/endpoints/bangumi.py | 18 +- app/api/endpoints/classification.py | 4 +- app/api/endpoints/dashboard.py | 12 +- app/api/endpoints/discover.py | 42 +- app/api/endpoints/download.py | 17 +- app/api/endpoints/github.py | 7 +- app/api/endpoints/history.py | 8 +- app/api/endpoints/identifier.py | 2 +- app/api/endpoints/llm.py | 2 +- app/api/endpoints/login.py | 5 +- app/api/endpoints/media.py | 9 +- app/api/endpoints/mediaserver.py | 26 +- app/api/endpoints/message.py | 26 +- app/api/endpoints/mfa.py | 28 +- app/api/endpoints/module.py | 8 +- app/api/endpoints/music.py | 9 +- app/api/endpoints/notification.py | 2 +- app/api/endpoints/openai.py | 21 +- app/api/endpoints/plugin.py | 55 +- app/api/endpoints/pluginclone.py | 2 +- app/api/endpoints/rule.py | 16 +- app/api/endpoints/search.py | 47 +- app/api/endpoints/settings.py | 4 +- app/api/endpoints/site.py | 53 +- app/api/endpoints/storage.py | 2 +- app/api/endpoints/subexecution.py | 10 +- app/api/endpoints/subscribe.py | 28 +- app/api/endpoints/system.py | 46 +- app/api/endpoints/tmdb.py | 8 +- app/api/endpoints/torrent.py | 28 +- app/api/endpoints/transfer.py | 80 +- app/api/endpoints/user.py | 6 +- app/api/endpoints/workflow.py | 4 +- app/api/response.py | 3 +- app/api/servarr.py | 45 +- app/api/servcookie.py | 3 +- .../classification/configuration.py | 24 +- app/application/classification/enrichment.py | 7 +- app/application/classification/execution.py | 11 +- app/application/classification/runtime.py | 4 +- app/application/directory.py | 22 +- app/application/filtering.py | 9 +- app/application/image.py | 4 +- app/application/mediaserver.py | 8 +- app/application/messaging/chat.py | 9 +- app/application/messaging/message.py | 22 +- app/application/module.py | 4 +- app/application/plugin/catalog.py | 19 +- app/application/plugin/management.py | 9 +- app/application/plugin/release.py | 9 +- app/application/query.py | 2 +- app/application/rss.py | 39 +- app/application/rules.py | 1 + app/application/security/url.py | 8 +- app/application/settings/contract.py | 2 +- app/application/site/contract.py | 34 + app/application/site/sites.pyi | 16 +- app/application/subscription/priority.py | 4 +- app/application/subscription/query.py | 4 +- app/application/subscription/write.py | 2 +- app/application/torrent/download.py | 19 +- app/application/transfer/history.py | 4 +- app/application/transfer/jobs.py | 6 +- app/chain/_contracts.py | 10 + app/chain/_interaction.py | 4 +- app/chain/_messaging.py | 52 +- app/chain/_music.py | 41 +- app/chain/_recognition.py | 6 +- app/chain/anilist.py | 22 +- app/chain/bangumi.py | 38 +- app/chain/base.py | 226 +-- app/chain/douban.py | 60 +- app/chain/download/batch.py | 3 + app/chain/download/existence.py | 6 +- app/chain/download/failure.py | 5 +- app/chain/download/history.py | 2 +- app/chain/download/selection.py | 2 +- app/chain/download/submission.py | 12 +- app/chain/download/subtitle.py | 8 +- app/chain/download/tasks.py | 3 +- app/chain/interaction.py | 4 +- app/chain/media/auxiliary.py | 2 +- app/chain/media/path.py | 2 +- app/chain/media/projection.py | 4 +- app/chain/media/recognition.py | 4 +- app/chain/mediaserver.py | 74 +- app/chain/message.py | 100 +- app/chain/music_interaction/music.py | 11 +- app/chain/recommend.py | 6 +- app/chain/scraping.py | 69 +- app/chain/search/contract.py | 8 +- app/chain/search/execution.py | 2 +- app/chain/search/manual.py | 2 +- app/chain/search/music.py | 2 +- app/chain/search/plan.py | 2 +- app/chain/search/recommend.py | 6 +- app/chain/search/result.py | 28 +- app/chain/search/scan.py | 22 +- app/chain/search/site.py | 2 +- app/chain/search/subtitle.py | 4 +- app/chain/search/title.py | 2 +- app/chain/site.py | 46 +- app/chain/storage.py | 44 +- app/chain/subscribe/completion.py | 29 +- app/chain/subscribe/create.py | 52 +- app/chain/subscribe/match.py | 23 +- app/chain/subscribe/notify.py | 4 +- app/chain/subscribe/policy.py | 9 +- app/chain/subscribe/query.py | 44 +- app/chain/subscribe/reconcile.py | 2 +- app/chain/subscribe/refresh.py | 12 +- app/chain/tmdb.py | 72 +- app/chain/torrents.py | 49 +- app/chain/transfer/execution.py | 43 +- app/chain/transfer/history.py | 5 +- app/chain/transfer/plan.py | 19 +- app/chain/transfer/queue.py | 5 +- app/chain/transfer/records.py | 13 +- app/chain/transfer/request.py | 22 +- app/chain/transfer/retry.py | 8 +- app/chain/transfer/settlement.py | 32 +- app/chain/transfer/workflow.py | 59 +- app/chain/user.py | 2 +- app/chain/workflow.py | 89 +- app/cli.py | 12 +- app/command.py | 61 +- app/db/adapters/classification.py | 7 +- app/domain/context.py | 225 +-- app/domain/meta/metabase.py | 56 +- app/domain/meta/metamusic.py | 17 +- app/domain/meta/runtime.py | 10 +- app/domain/meta/words.py | 7 +- app/domain/metainfo.py | 22 +- app/factory.py | 14 +- app/locales/en-US.json | 7 +- app/modules/_base/media.py | 2 +- app/modules/_base/mediaserver.py | 39 +- app/modules/_base/notification.py | 30 +- app/modules/anilist/__init__.py | 129 +- app/modules/anilist/anilist.py | 4 +- app/modules/bangumi/__init__.py | 139 +- app/modules/bangumi/bangumi.py | 10 +- app/modules/dingtalk/__init__.py | 2 + app/modules/discord/__init__.py | 52 +- app/modules/discord/discord.py | 14 +- app/modules/douban/__init__.py | 245 +-- app/modules/douban/apiv2.py | 8 +- app/modules/emby/__init__.py | 23 +- app/modules/emby/emby.py | 10 +- app/modules/fanart/__init__.py | 41 +- app/modules/feishu/module.py | 34 +- app/modules/filemanager/module.py | 36 +- app/modules/filemanager/storages/__init__.py | 27 +- app/modules/filemanager/storages/alipan.py | 142 +- app/modules/filemanager/storages/alist.py | 61 +- app/modules/filemanager/storages/local.py | 19 +- app/modules/filemanager/storages/rclone.py | 63 +- app/modules/filemanager/storages/smb.py | 63 +- app/modules/filemanager/storages/u115.py | 110 +- app/modules/filemanager/transhandler.py | 113 +- app/modules/filter/__init__.py | 83 +- app/modules/imdb/__init__.py | 11 +- app/modules/imdb/api.py | 58 +- app/modules/indexer/__init__.py | 54 +- app/modules/indexer/parser/sunnypt.py | 6 +- app/modules/indexer/spider/haidan.py | 4 +- app/modules/indexer/spider/hddolby.py | 16 +- app/modules/indexer/spider/rousi.py | 28 +- app/modules/indexer/spider/sunnypt.py | 26 +- app/modules/indexer/spider/tnode.py | 14 +- app/modules/indexer/spider/torrentleech.py | 16 +- app/modules/indexer/spider/yema.py | 18 +- app/modules/jellyfin/__init__.py | 27 +- app/modules/jellyfin/jellyfin.py | 10 +- app/modules/listenbrainz/__init__.py | 3 +- app/modules/lrclib/__init__.py | 6 +- app/modules/musicbrainz/__init__.py | 11 +- app/modules/navidrome/__init__.py | 31 +- app/modules/navidrome/navidrome.py | 31 +- app/modules/plex/__init__.py | 45 +- app/modules/plex/plex.py | 24 +- app/modules/qqbot/module.py | 32 +- app/modules/qqbot/qqbot.py | 31 +- app/modules/rtorrent/__init__.py | 55 +- app/modules/slack/__init__.py | 76 +- app/modules/slack/slack.py | 75 +- app/modules/subtitle/__init__.py | 15 +- app/modules/synologychat/__init__.py | 3 +- app/modules/synologychat/synologychat.py | 10 +- app/modules/telegram/module.py | 46 +- app/modules/telegram/telegram.py | 92 +- app/modules/theaudiodb/__init__.py | 10 +- app/modules/themoviedb/__init__.py | 31 +- app/modules/themoviedb/cache.py | 24 +- app/modules/themoviedb/scraper.py | 58 +- app/modules/themoviedb/tmdbv3api/tmdb.py | 4 +- app/modules/thetvdb/__init__.py | 35 +- app/modules/thetvdb/client.py | 4 +- app/modules/trimemedia/api.py | 8 +- app/modules/trimemedia/trimemedia.py | 2 + app/modules/ugreen/module.py | 7 +- app/modules/vocechat/__init__.py | 30 +- app/modules/vocechat/vocechat.py | 10 +- app/modules/webpush/__init__.py | 2 +- app/modules/wechat/__init__.py | 32 +- app/modules/wechat/wechat.py | 67 +- app/modules/wechat/wechatbot.py | 51 +- app/modules/wechatclawbot/__init__.py | 21 +- app/modules/wechatclawbot/wechatclawbot.py | 52 +- app/modules/zspace/__init__.py | 8 +- app/monitor/monitor.py | 44 +- app/monitor/poller.py | 9 +- app/monitor/snapshot.py | 8 +- app/runtime/cache.py | 107 +- app/runtime/coalesce.py | 4 +- app/runtime/config.py | 36 +- app/runtime/events.py | 78 +- app/runtime/extensions/plugin/manager.py | 20 +- app/runtime/extensions/plugin/projection.py | 18 +- app/runtime/log.py | 2 +- app/scheduler/facade.py | 3 +- app/scheduler/progress.py | 25 +- app/schemas/agent.py | 6 +- app/schemas/category.py | 15 +- app/schemas/comment.py | 15 +- app/schemas/common.py | 20 +- app/schemas/context.py | 3 +- app/schemas/dashboard.py | 1 + app/schemas/download.py | 18 +- app/schemas/event.py | 31 +- app/schemas/file.py | 2 +- app/schemas/github.py | 18 +- app/schemas/history.py | 12 +- app/schemas/initialization.py | 15 +- app/schemas/media.py | 20 +- app/schemas/mediaserver.py | 28 +- app/schemas/message.py | 11 +- app/schemas/music.py | 15 +- app/schemas/notification.py | 6 +- app/schemas/plugin.py | 82 +- app/schemas/query.py | 21 +- app/schemas/recommend.py | 3 +- app/schemas/rule.py | 24 +- app/schemas/search.py | 9 +- app/schemas/site.py | 6 +- app/schemas/storage.py | 6 +- app/schemas/subscribe.py | 18 +- app/schemas/system.py | 40 +- app/schemas/transfer.py | 62 +- app/schemas/user.py | 3 +- app/sdk/_legacy/history.py | 4 +- app/startup/composition/chain.py | 4 +- app/startup/composition/classification.py | 2 +- app/startup/composition/network.py | 4 +- app/startup/composition/site.py | 5 +- app/startup/initializers/modules.py | 42 +- app/startup/initializers/plugins.py | 12 +- app/startup/lifecycle/__init__.py | 9 +- app/testing/network.py | 14 +- app/workflow/__init__.py | 53 +- app/workflow/actions/__init__.py | 4 +- app/workflow/actions/add_download.py | 25 +- app/workflow/actions/add_subscribe.py | 4 +- app/workflow/actions/fetch_downloads.py | 2 + app/workflow/actions/fetch_medias.py | 19 +- app/workflow/actions/fetch_rss.py | 20 +- app/workflow/actions/fetch_torrents.py | 6 +- app/workflow/actions/filter_torrents.py | 8 +- app/workflow/actions/invoke_plugin.py | 9 +- app/workflow/actions/scrape_file.py | 2 +- app/workflow/actions/send_event.py | 2 +- docs/architecture-overview.md | 6 +- docs/development-setup.md | 38 +- docs/rules/03-commands.md | 5 + mypy.ini | 905 +---------- pyproject.toml | 3 + scripts/architecture/mypy_policy.py | 232 +++ .../architecture/dependency-baseline.json | 45 +- .../architecture/dependency-policy.json | 4 +- .../fixtures/architecture/mypy-baseline.json | 1093 +------------ tests/fixtures/architecture/mypy-policy.json | 1359 +++++++++++++++++ tests/test_agent_learning_review.py | 23 +- tests/test_api_optional_inputs.py | 167 ++ tests/test_api_response_injection.py | 37 + tests/test_architecture_egress.py | 6 +- tests/test_chain_missing_metadata.py | 217 +++ tests/test_indexer_optional_metadata.py | 100 ++ tests/test_monitor_lifecycle.py | 23 + tests/test_mypy_policy_gate.py | 195 +++ tests/test_notification_optional_payloads.py | 135 ++ tests/test_observability.py | 2 +- tests/test_storage_missing_metadata.py | 122 ++ tests/test_subscribe_paged_search.py | 16 + tests/test_transfer_history_legacy_compat.py | 2 +- tests/test_type_gate.py | 36 +- tests/test_typing_runtime_boundaries.py | 184 +++ tests/test_workflow_optional_boundaries.py | 127 ++ uv.lock | 40 + 370 files changed, 8014 insertions(+), 5627 deletions(-) create mode 100644 scripts/architecture/mypy_policy.py create mode 100644 tests/fixtures/architecture/mypy-policy.json create mode 100644 tests/test_api_optional_inputs.py create mode 100644 tests/test_api_response_injection.py create mode 100644 tests/test_chain_missing_metadata.py create mode 100644 tests/test_indexer_optional_metadata.py create mode 100644 tests/test_mypy_policy_gate.py create mode 100644 tests/test_notification_optional_payloads.py create mode 100644 tests/test_storage_missing_metadata.py create mode 100644 tests/test_typing_runtime_boundaries.py create mode 100644 tests/test_workflow_optional_boundaries.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9a9b4bd14..13c38948d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -106,7 +106,10 @@ jobs: - name: Install dependencies run: uv sync --locked - - name: Check governed Python types + - name: Check exact typing compatibility exceptions + run: uv run --locked --no-sync python scripts/architecture/mypy_policy.py + + - name: Check all host Python types run: uv run --locked --no-sync mypy --config-file mypy.ini - name: Check structural complexity ratchet diff --git a/app/adapters/cache/backends.py b/app/adapters/cache/backends.py index 9f624d5e8..874e13833 100644 --- a/app/adapters/cache/backends.py +++ b/app/adapters/cache/backends.py @@ -42,8 +42,9 @@ class FileBackend(CacheBackend): self, key: str, value: Any, + ttl: Optional[int] = None, region: Optional[str] = DEFAULT_CACHE_REGION, - **_kwargs, + **_kwargs: object, ) -> None: """ 通过同目录临时文件替换目标,避免读者看到未写完的内容。 @@ -53,7 +54,8 @@ class FileBackend(CacheBackend): :param region: 根目录下的缓存区域名称,使用时须为字符串 :param _kwargs: 为缓存接口兼容保留,文件后端不处理这些选项 """ - cache_path = self.base / region / key + cache_region = region or DEFAULT_CACHE_REGION + cache_path = self.base / cache_region / key cache_path.parent.mkdir(parents=True, exist_ok=True) with tempfile.NamedTemporaryFile( dir=cache_path.parent, @@ -75,7 +77,8 @@ class FileBackend(CacheBackend): :param region: 根目录下的缓存区域名称,使用时须为字符串 :return: 路径存在时返回 True """ - return (self.base / region / key).exists() + cache_region = region or DEFAULT_CACHE_REGION + return (self.base / cache_region / key).exists() def get( self, @@ -89,7 +92,8 @@ class FileBackend(CacheBackend): :param region: 根目录下的缓存区域名称,使用时须为字符串 :return: 文件内容;检查时目标不存在则返回 None """ - cache_path = self.base / region / key + cache_region = region or DEFAULT_CACHE_REGION + cache_path = self.base / cache_region / key if not cache_path.exists(): return None with cache_path.open("rb") as file_handle: @@ -106,7 +110,8 @@ class FileBackend(CacheBackend): :param key: 区域内的相对缓存路径;不存在时不操作 :param region: 根目录下的缓存区域名称,使用时须为字符串 """ - cache_path = self.base / region / key + cache_region = region or DEFAULT_CACHE_REGION + cache_path = self.base / cache_region / key if cache_path.is_file(): cache_path.unlink() elif cache_path.exists(): @@ -137,7 +142,7 @@ class FileBackend(CacheBackend): :param region: 要遍历的区域名称,使用时须为字符串 :yields: 相对于区域目录的路径与完整文件字节组成的二元组 """ - cache_path = self.base / region + cache_path = self.base / region if region else self.base if not cache_path.exists(): return for item in sorted(cache_path.rglob("*")): @@ -168,12 +173,13 @@ class AsyncFileBackend(AsyncCacheBackend): self.base = base self.base.mkdir(parents=True, exist_ok=True) - async def set( + async def set( # type: ignore[override] self, key: str, value: Any, + ttl: Optional[int] = None, region: Optional[str] = DEFAULT_CACHE_REGION, - **_kwargs, + **_kwargs: object, ) -> None: """ 通过同目录临时文件替换目标,避免读者看到未写完的内容。 @@ -183,7 +189,8 @@ class AsyncFileBackend(AsyncCacheBackend): :param region: 根目录下的缓存区域名称,使用时须为字符串 :param _kwargs: 为缓存接口兼容保留,文件后端不处理这些选项 """ - cache_path = AsyncPath(self.base) / region / key + cache_region = region or DEFAULT_CACHE_REGION + cache_path = AsyncPath(self.base) / cache_region / key await cache_path.parent.mkdir(parents=True, exist_ok=True) async with aiofiles.tempfile.NamedTemporaryFile( dir=cache_path.parent, @@ -193,7 +200,7 @@ class AsyncFileBackend(AsyncCacheBackend): temp_path = AsyncPath(tmp_file.name) await temp_path.replace(cache_path) - async def exists( + async def exists( # type: ignore[override] self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION, @@ -205,7 +212,8 @@ class AsyncFileBackend(AsyncCacheBackend): :param region: 根目录下的缓存区域名称,使用时须为字符串 :return: 路径存在时返回 True """ - return await (AsyncPath(self.base) / region / key).exists() + cache_region = region or DEFAULT_CACHE_REGION + return bool(await (AsyncPath(self.base) / cache_region / key).exists()) async def get( self, @@ -219,13 +227,14 @@ class AsyncFileBackend(AsyncCacheBackend): :param region: 根目录下的缓存区域名称,使用时须为字符串 :return: 文件内容;检查时目标不存在则返回 None """ - cache_path = AsyncPath(self.base) / region / key + cache_region = region or DEFAULT_CACHE_REGION + cache_path = AsyncPath(self.base) / cache_region / key if not await cache_path.exists(): return None async with aiofiles.open(cache_path, "rb") as file_handle: return await file_handle.read() - async def delete( + async def delete( # type: ignore[override] self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION, @@ -236,13 +245,14 @@ class AsyncFileBackend(AsyncCacheBackend): :param key: 区域内的相对缓存路径;不存在时不操作 :param region: 根目录下的缓存区域名称,使用时须为字符串 """ - cache_path = AsyncPath(self.base) / region / key + cache_region = region or DEFAULT_CACHE_REGION + cache_path = AsyncPath(self.base) / cache_region / key if await cache_path.is_file(): await cache_path.unlink() elif await cache_path.exists(): await aioshutil.rmtree(cache_path, ignore_errors=True) - async def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None: + async def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None: # type: ignore[override] """ 清理缓存目录的内容,保留区域目录本身。 @@ -257,7 +267,7 @@ class AsyncFileBackend(AsyncCacheBackend): else: await aioshutil.rmtree(item, ignore_errors=True) - async def items( + async def items( # type: ignore[override] self, region: Optional[str] = DEFAULT_CACHE_REGION, ) -> AsyncGenerator[Tuple[str, Any], None]: @@ -267,7 +277,8 @@ class AsyncFileBackend(AsyncCacheBackend): :param region: 要遍历的区域名称,使用时须为字符串 :yields: 相对于区域目录的路径与完整文件字节组成的二元组 """ - cache_path = AsyncPath(self.base) / region + cache_region = region or DEFAULT_CACHE_REGION + cache_path = AsyncPath(self.base) / cache_region if not await cache_path.exists(): return async for item in cache_path.rglob("*"): @@ -276,7 +287,7 @@ class AsyncFileBackend(AsyncCacheBackend): async with aiofiles.open(item, "rb") as file_handle: yield key, await file_handle.read() - async def close(self) -> None: + async def close(self) -> None: # type: ignore[override] """ 异步文件缓存没有需要关闭的持久连接。 """ diff --git a/app/adapters/cache/redis.py b/app/adapters/cache/redis.py index a40e61cf6..7d887c92b 100644 --- a/app/adapters/cache/redis.py +++ b/app/adapters/cache/redis.py @@ -43,7 +43,7 @@ def serialize(value: Any) -> bytes: :return: 格式名、零字节分隔符和编码内容组成的缓存数据 """ - def _is_container_type(t): + def _is_container_type(t: type[object]) -> bool: """ 判断是否为容器类型 """ @@ -104,25 +104,25 @@ class RedisHelper(ConfigReloadMixin, metaclass=Singleton): "BIG_MEMORY_MODE", } - def __init__(self): + def __init__(self) -> None: """ 初始化Redis助手实例 """ self.redis_url = get_runtime_setting('CACHE_BACKEND_URL') - self.client = None + self.client: Optional[redis.Redis] = None self._connect_lock = threading.RLock() - def _connect(self): + def _connect(self) -> redis.Redis: """ 建立Redis连接 """ if self.client is not None: - return + return self.client client = None try: with self._connect_lock: if self.client is not None: - return + return self.client self.redis_url = get_runtime_setting('CACHE_BACKEND_URL') connection_pool = redis.BlockingConnectionPool.from_url( self.redis_url, @@ -139,6 +139,7 @@ class RedisHelper(ConfigReloadMixin, metaclass=Singleton): self.client = client logger.info(f"Successfully connected to Redis:{self.redis_url}") self.set_memory_limit() + return client except Exception as e: if client: client.close() @@ -146,7 +147,7 @@ class RedisHelper(ConfigReloadMixin, metaclass=Singleton): self.client = None raise RuntimeError("Redis connection failed") from e - def on_config_changed(self): + def on_config_changed(self) -> None: """ 缓存配置变化后重建同步 Redis 连接。 """ @@ -155,18 +156,20 @@ class RedisHelper(ConfigReloadMixin, metaclass=Singleton): self.close() self._connect() - def get_reload_name(self): + def get_reload_name(self) -> str: """ 返回同步 Redis 配置重载名称。 """ return "Redis" - def set_memory_limit(self, policy: Optional[str] = "allkeys-lru"): + def set_memory_limit(self, policy: Optional[str] = "allkeys-lru") -> None: """ 动态设置Redis最大内存和内存淘汰策略 :param policy: 淘汰策略(如'allkeys-lru') """ + if self.client is None: + return try: # 非空配置直接传给 Redis;未配置时按大内存模式选择 1024 MB 或 256 MB。 maxmemory = get_runtime_setting('CACHE_REDIS_MAXMEMORY') or ( @@ -179,13 +182,13 @@ class RedisHelper(ConfigReloadMixin, metaclass=Singleton): logger.error(f"Failed to set Redis maxmemory or policy: {e}") @staticmethod - def __get_region(region: Optional[str] = None): + def __get_region(region: Optional[str] = None) -> str: """ 获取缓存的区 """ return f"region:{region}" if region else "region:DEFAULT" - def __make_redis_key(self, region: str, key: str) -> str: + def __make_redis_key(self, region: Optional[str], key: str) -> str: """ 获取缓存Key """ @@ -203,11 +206,11 @@ class RedisHelper(ConfigReloadMixin, metaclass=Singleton): parts = redis_key.split(":key:") return unquote(parts[-1]) except Exception as e: - logger.warn(f"Failed to parse redis key: {redis_key}, error: {e}") - return redis_key + logger.warn("解析 Redis 缓存键失败:%r,错误:%s", redis_key, e) + return redis_key.decode("utf-8", errors="replace") if isinstance(redis_key, bytes) else redis_key def set(self, key: str, value: Any, ttl: Optional[int] = None, - region: Optional[str] = "DEFAULT", **kwargs) -> None: + region: Optional[str] = "DEFAULT", **kwargs: Any) -> None: """ 序列化并写入缓存,连接或写入异常仅记录日志。 @@ -234,11 +237,11 @@ class RedisHelper(ConfigReloadMixin, metaclass=Singleton): :param kwargs: Redis set 的额外选项;兼容参数 maxsize 会被丢弃 :raises RuntimeError: 连接建立失败,或写入未得到 True 确认 """ - self._connect() + client = self._connect() redis_key = self.__make_redis_key(region, key) serialized_value = serialize(value) kwargs.pop("maxsize", None) - stored = self.client.set(redis_key, serialized_value, ex=ttl, **kwargs) + stored = client.set(redis_key, serialized_value, ex=ttl, **kwargs) if stored is not True: raise RuntimeError("Redis cache write was not acknowledged") @@ -251,9 +254,9 @@ class RedisHelper(ConfigReloadMixin, metaclass=Singleton): :return: 键存在时返回 True;不存在或发生故障时返回 False """ try: - self._connect() + client = self._connect() redis_key = self.__make_redis_key(region, key) - return self.client.exists(redis_key) == 1 + return bool(client.exists(redis_key) == 1) except Exception as e: logger.error(f"Failed to exists key: {key} region: {region}, error: {e}") return False @@ -267,9 +270,9 @@ class RedisHelper(ConfigReloadMixin, metaclass=Singleton): :return: 解码后的值;键不存在或发生故障时返回 None """ try: - self._connect() + client = self._connect() redis_key = self.__make_redis_key(region, key) - value = self.client.get(redis_key) + value = client.get(redis_key) if value is not None: return deserialize(value) return None @@ -303,9 +306,9 @@ class RedisHelper(ConfigReloadMixin, metaclass=Singleton): :param region: 缓存区域;为空时使用 DEFAULT :return: 领取并解码的值;键不存在时返回 None """ - self._connect() + client = self._connect() redis_key = self.__make_redis_key(region, key) - value = self.client.getdel(redis_key) + value = client.getdel(redis_key) return deserialize(value) if value is not None else None def delete(self, key: str, region: Optional[str] = "DEFAULT") -> None: @@ -316,9 +319,9 @@ class RedisHelper(ConfigReloadMixin, metaclass=Singleton): :param region: 缓存的区 """ try: - self._connect() + client = self._connect() redis_key = self.__make_redis_key(region, key) - self.client.delete(redis_key) + client.delete(redis_key) except Exception as e: logger.error(f"Failed to delete key: {key} in region: {region}, error: {e}") @@ -331,17 +334,17 @@ class RedisHelper(ConfigReloadMixin, metaclass=Singleton): :param region: 要清空的区域;None 或空字符串会执行 flushdb,影响当前数据库的全部键 """ try: - self._connect() + client = self._connect() if region: cache_region = self.__get_region(region) redis_key = f"{cache_region}:key:*" - with self.client.pipeline() as pipe: - for key in self.client.scan_iter(redis_key): + with client.pipeline() as pipe: + for key in client.scan_iter(redis_key): pipe.delete(key) pipe.execute() logger.debug(f"Cleared Redis cache for region: {region}") else: - self.client.flushdb() + client.flushdb() logger.info("All Redis cache Cleared!") except Exception as e: logger.error(f"Failed to clear cache, region: {region}, error: {e}") @@ -356,17 +359,17 @@ class RedisHelper(ConfigReloadMixin, metaclass=Singleton): :yields: 原始缓存键与解码后的值组成的二元组 """ try: - self._connect() + client = self._connect() if region: cache_region = self.__get_region(region) redis_key = f"{cache_region}:key:*" - for key in self.client.scan_iter(redis_key): - value = self.client.get(key) + for key in client.scan_iter(redis_key): + value = client.get(key) if value is not None: yield self.__get_original_key(key), deserialize(value) else: - for key in self.client.scan_iter("*"): - value = self.client.get(key) + for key in client.scan_iter("*"): + value = client.get(key) if value is not None: yield self.__get_original_key(key), deserialize(value) except Exception as e: @@ -409,7 +412,7 @@ class AsyncRedisHelper(ConfigReloadMixin, metaclass=Singleton): "BIG_MEMORY_MODE", } - def __init__(self): + def __init__(self) -> None: """ 初始化异步Redis助手实例 """ @@ -505,7 +508,7 @@ class AsyncRedisHelper(ConfigReloadMixin, metaclass=Singleton): logger.error(f"Failed to connect to Redis (async): {e}") raise RuntimeError("Redis async connection failed") from e - async def _close_client(self): + async def _close_client(self) -> None: """ 关闭当前事件循环拥有的Redis客户端连接 """ @@ -530,7 +533,7 @@ class AsyncRedisHelper(ConfigReloadMixin, metaclass=Singleton): self.client = None self._loop = None - async def on_config_changed(self): + async def on_config_changed(self) -> None: """ 缓存配置变化后异步重建 Redis 连接。 """ @@ -538,7 +541,7 @@ class AsyncRedisHelper(ConfigReloadMixin, metaclass=Singleton): await self._close_client() await self._connect() - def get_reload_name(self): + def get_reload_name(self) -> str: """ 返回异步 Redis 配置重载名称。 """ @@ -548,7 +551,7 @@ class AsyncRedisHelper(ConfigReloadMixin, metaclass=Singleton): self, policy: Optional[str] = "allkeys-lru", client: Optional[Redis] = None, - ): + ) -> None: """ 动态设置Redis最大内存和内存淘汰策略 @@ -570,13 +573,13 @@ class AsyncRedisHelper(ConfigReloadMixin, metaclass=Singleton): logger.error(f"Failed to set Redis maxmemory or policy (async): {e}") @staticmethod - def __get_region(region: Optional[str] = "DEFAULT"): + def __get_region(region: Optional[str] = "DEFAULT") -> str: """ 获取缓存的区 """ return f"region:{region}" if region else "region:DEFAULT" - def __make_redis_key(self, region: str, key: str) -> str: + def __make_redis_key(self, region: Optional[str], key: str) -> str: """ 获取缓存Key """ @@ -594,11 +597,11 @@ class AsyncRedisHelper(ConfigReloadMixin, metaclass=Singleton): parts = redis_key.split(":key:") return unquote(parts[-1]) except Exception as e: - logger.warn(f"Failed to parse redis key: {redis_key}, error: {e}") - return redis_key + logger.warn("解析 Redis 缓存键失败:%r,错误:%s", redis_key, e) + return redis_key.decode("utf-8", errors="replace") if isinstance(redis_key, bytes) else redis_key async def set(self, key: str, value: Any, ttl: Optional[int] = None, - region: Optional[str] = "DEFAULT", **kwargs) -> None: + region: Optional[str] = "DEFAULT", **kwargs: Any) -> None: """ 序列化并写入缓存,连接或写入异常仅记录日志。 @@ -631,7 +634,7 @@ class AsyncRedisHelper(ConfigReloadMixin, metaclass=Singleton): client = self.client if self.client is current_client else current_client redis_key = self.__make_redis_key(region, key) result = await client.exists(redis_key) - return result == 1 + return bool(result == 1) except Exception as e: logger.error(f"Failed to exists key (async): {key} region: {region}, error: {e}") return False @@ -764,7 +767,7 @@ class RedisBackend(AtomicCacheBackend): value: Any, ttl: Optional[int] = None, region: Optional[str] = DEFAULT_CACHE_REGION, - **kwargs, + **kwargs: Any, ) -> None: """ 写入缓存;非正有效期改为删除键,后端异常仅记录日志。 @@ -905,13 +908,13 @@ class AsyncRedisBackend(AsyncCacheBackend): self.ttl = ttl self.redis_helper = AsyncRedisHelper() - async def set( + async def set( # type: ignore[override] self, key: str, value: Any, ttl: Optional[int] = None, region: Optional[str] = DEFAULT_CACHE_REGION, - **kwargs, + **kwargs: Any, ) -> None: """ 写入缓存;非正有效期改为删除键,后端异常仅记录日志。 @@ -928,7 +931,7 @@ class AsyncRedisBackend(AsyncCacheBackend): return await self.redis_helper.set(key, value, ttl=ttl, region=region, **kwargs) - async def exists( + async def exists( # type: ignore[override] self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION, @@ -952,7 +955,7 @@ class AsyncRedisBackend(AsyncCacheBackend): """ return await self.redis_helper.get(key, region=region) - async def delete( + async def delete( # type: ignore[override] self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION, @@ -962,7 +965,7 @@ class AsyncRedisBackend(AsyncCacheBackend): """ await self.redis_helper.delete(key, region=region) - async def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None: + async def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None: # type: ignore[override] """ 清空选定区域或当前 Redis 数据库,异常仅记录日志。 @@ -970,7 +973,7 @@ class AsyncRedisBackend(AsyncCacheBackend): """ await self.redis_helper.clear(region=region) - async def items( + async def items( # type: ignore[override] self, region: Optional[str] = DEFAULT_CACHE_REGION, ) -> AsyncGenerator[Tuple[str, Any], None]: @@ -983,7 +986,7 @@ class AsyncRedisBackend(AsyncCacheBackend): async for item in self.redis_helper.items(region=region): yield item - async def close(self) -> None: + async def close(self) -> None: # type: ignore[override] """ 释放当前异步缓存实例;共享的异步 Redis 连接池由关闭流程的「异步Redis缓存连接」步骤统一关闭。 """ diff --git a/app/adapters/external/plugin/client.py b/app/adapters/external/plugin/client.py index 80160a29e..64ffad485 100644 --- a/app/adapters/external/plugin/client.py +++ b/app/adapters/external/plugin/client.py @@ -43,7 +43,7 @@ from app.foundation.environment import is_free_threaded_runtime from app.foundation.singleton import WeakSingleton from app.foundation.url import UrlUtils from app.foundation.version import compare_version -from app.runtime.cache import async_fresh, cached, fresh, is_fresh +from app.runtime.cache import AsyncCacheControls, async_fresh, cached, fresh, is_fresh from app.runtime.log import logger from app.runtime.settings import get_runtime_setting from app.runtime.tasks import get_task_registry @@ -1358,8 +1358,7 @@ class PluginMarketTransport(metaclass=WeakSingleton): except Exception as error: # noqa: BLE001 return None, error - # 缓存装饰器的动态包装未保留静态类型,保留既有类型豁免。 - @cached(maxsize=1024, ttl=1800, skip_none=False) # type: ignore[misc] + @cached(maxsize=1024, ttl=1800, skip_none=False) def get_plugin_index_result( self, repo_url: str, @@ -1414,8 +1413,7 @@ class PluginMarketTransport(metaclass=WeakSingleton): return None return payload if payload is not None else {} - # 缓存装饰器的动态包装未保留静态类型,保留既有类型豁免。 - @cached(maxsize=256, ttl=1800, shared_key="get_plugin_repo_releases") # type: ignore[misc] + @cached(maxsize=256, ttl=1800, shared_key="get_plugin_repo_releases") def _get_plugin_repo_releases( self, repo_url: str ) -> Optional[PluginReleaseList]: @@ -1736,8 +1734,7 @@ class PluginMarketTransport(metaclass=WeakSingleton): logger.warning(f"获取插件 {pid} 元数据失败:{error}") return {} - # 缓存装饰器的动态包装未保留静态类型,保留既有类型豁免。 - @cached(maxsize=1024, ttl=1800, skip_none=False) # type: ignore[misc] + @cached(maxsize=1024, ttl=1800, skip_none=False) async def async_get_plugin_index_result( self, repo_url: str, @@ -1804,8 +1801,7 @@ class PluginMarketTransport(metaclass=WeakSingleton): return None return payload if payload is not None else {} - # 缓存装饰器的动态包装未保留静态类型,保留既有类型豁免。 - @cached(maxsize=256, ttl=1800, shared_key="get_plugin_repo_releases") # type: ignore[misc] + @cached(maxsize=256, ttl=1800, shared_key="get_plugin_repo_releases") async def _async_get_plugin_repo_releases( self, repo_url: str ) -> Optional[PluginReleaseList]: @@ -1895,7 +1891,7 @@ class PluginMarketTransport(metaclass=WeakSingleton): if not repo_url: return False return bool( - await self._async_get_plugin_repo_releases.cache_exists( + await cast(AsyncCacheControls, self._async_get_plugin_repo_releases).cache_exists( self, repo_url.rstrip("/") ) ) @@ -1913,10 +1909,7 @@ class PluginMarketTransport(metaclass=WeakSingleton): await asyncio.shield(pending_normal_task) except (Exception, asyncio.CancelledError): pass - return cast( - Optional[PluginReleaseList], - await self._async_get_plugin_repo_releases(repo_url), - ) + return await self._async_get_plugin_repo_releases(repo_url) @classmethod def _remove_release_task( @@ -1977,10 +1970,7 @@ class PluginMarketClient: 读取插件索引的三态结果,供库存读取保留失败事实。 """ with fresh(force): - return cast( - Optional[PluginIndex], - self._transport.get_plugin_index_result(repo_url, package_version), - ) + return self._transport.get_plugin_index_result(repo_url, package_version) async def async_get_plugin_index_result( self, @@ -1992,13 +1982,10 @@ class PluginMarketClient: 异步读取插件索引的三态结果,供库存读取保留失败事实。 """ async with async_fresh(force): - return cast( - Optional[PluginIndex], - await self._transport.async_get_plugin_index_result( + return await self._transport.async_get_plugin_index_result( repo_url, package_version, - ), - ) + ) def get_local_candidates(self) -> PluginIndex: """ diff --git a/app/adapters/external/server.py b/app/adapters/external/server.py index 81e7b3acb..effcd43db 100644 --- a/app/adapters/external/server.py +++ b/app/adapters/external/server.py @@ -20,7 +20,6 @@ from typing import ( TypeVar, Union, cast, - no_type_check, ) from urllib.parse import parse_qs, quote, urlparse, urlsplit @@ -89,10 +88,7 @@ def _cached_server_result( """ 恢复底层包装器保持不变的原始调用签名。 """ - return cast( - Callable[_LookupParams, _LookupResult], - cached(region=region, maxsize=maxsize, ttl=ttl, skip_empty=skip_empty)(function), - ) + return cached(region=region, maxsize=maxsize, ttl=ttl, skip_empty=skip_empty)(function) return decorator @@ -136,10 +132,7 @@ def _cached_server_lookup( """ 包装单个查询函数,缓存层本身未标注类型,这里显式恢复原签名。 """ - return cast( - Callable[_LookupParams, Awaitable[Dict[str, Any]]], - cached(maxsize=maxsize, ttl=60, empty_ttl=15, local_only=True)(function), - ) + return cached(maxsize=maxsize, ttl=60, empty_ttl=15, local_only=True)(function) return decorator @@ -892,8 +885,7 @@ class MoviePilotServerHelper: return cls._post_json(cls._server_url(cls._SUBSCRIBE_DONE_PATH), payload, timeout=5) @classmethod - @no_type_check - async def async_subscribe_done(cls, payload: Dict[str, Any]): + async def async_subscribe_done(cls, payload: Dict[str, Any]) -> Optional[AsyncResponse]: """ 异步完成订阅统计,并返回可检查 HTTP 状态的响应对象。 """ diff --git a/app/adapters/network/browser.py b/app/adapters/network/browser.py index 441e2be51..8749b4382 100644 --- a/app/adapters/network/browser.py +++ b/app/adapters/network/browser.py @@ -6,10 +6,10 @@ import ipaddress import threading import time import uuid -from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import Future, ThreadPoolExecutor from contextvars import Context, copy_context from dataclasses import dataclass, field -from typing import Any, Callable, Optional, Protocol +from typing import Any, Callable, Optional, Protocol, cast from urllib.parse import urlparse from app.adapters.network.http import RequestUtils, cookie_parse @@ -253,7 +253,7 @@ def launch_browser_context(headless: bool = True, **kwargs: Any) -> BrowserConte ) from cloakbrowser import launch_context - return launch_context(headless=headless, **kwargs) + return cast(BrowserContext, launch_context(headless=headless, **kwargs)) async def launch_browser_context_async( @@ -423,15 +423,16 @@ class BrowserSessionHelper: if executor: context = copy_context() # 会话线程保持空底层上下文,每次操作只使用当前调用快照。 - future = Context().run( - executor.submit, - context.run, - cls._run_session_task, - session_key, - cls._close_session_in_thread, - session_key, - owner=owner, - ) + def close_in_context() -> bool: + """ + 在调用方上下文中关闭会话,保持会话线程底层上下文为空。 + """ + return bool(context.run( + cls._run_session_task, session_key, cls._close_session_in_thread, + session_key, owner=owner, + )) + + future: Future[bool] = Context().run(executor.submit, close_in_context) try: return future.result() finally: @@ -557,16 +558,16 @@ class BrowserSessionHelper: try: context = copy_context() # 会话线程保持空底层上下文,每次操作只使用当前调用快照。 - future = Context().run( - executor.submit, - context.run, - cls._run_session_task, - session_key, - callback, - *args, - owner=owner, - **kwargs, - ) + def invoke_in_context() -> Any: + """ + 在当前调用方快照中执行会话操作,避免上下文在会话线程间泄漏。 + """ + return context.run( + cls._run_session_task, session_key, callback, + *args, owner=owner, **kwargs, + ) + + future: Future[Any] = Context().run(executor.submit, invoke_in_context) except RuntimeError: cls._discard_session_executor(session_key, executor) continue @@ -914,13 +915,12 @@ class BrowserSessionHelper: :return: 页面快照字典 """ text_content = cls._safe_inner_text(page, "body") - result = { + elements = cls._extract_interactive_elements(page, max_elements=max_elements) + result: dict[str, Any] = { "url": getattr(page, "url", ""), "title": cls._safe_page_title(page), "text_content": cls._truncate_text(text_content, max_text_chars), - "interactive_elements": cls._extract_interactive_elements( - page, max_elements=max_elements - ), + "interactive_elements": elements, } if status is not None: result["status"] = status @@ -931,12 +931,12 @@ class BrowserSessionHelper: "text": element.get("text"), "href": element.get("href"), } - for element in result["interactive_elements"] + for element in elements if element.get("tag") == "a" and element.get("href") ][:30] forms = [ element - for element in result["interactive_elements"] + for element in elements if element.get("tag") in {"input", "textarea", "select", "button"} ][:30] if links: @@ -1190,7 +1190,7 @@ class PlaywrightHelper: 为旧插件浏览器调用提供统一的 CloakBrowser 适配。 """ - def __init__(self, browser_type: Optional[str] = None, *args, **kwargs): + def __init__(self, browser_type: Optional[str] = None, *args: Any, **kwargs: Any) -> None: """ 兼容旧的 PlaywrightHelper(browser_type=...) 构造方式。 """ @@ -1210,7 +1210,7 @@ class PlaywrightHelper: @staticmethod def __launch_cloakbrowser_context(headless: bool, user_agent: Optional[str] = None, - proxies: Optional[dict] = None) -> BrowserContext: + proxies: Optional[dict[str, Any]] = None) -> BrowserContext: """ 启动 CloakBrowser 上下文。 """ @@ -1221,7 +1221,7 @@ class PlaywrightHelper: human_preset=get_runtime_setting('CLOAKBROWSER_HUMAN_PRESET')) @staticmethod - def __fs_cookie_str(cookies: list) -> str: + def __fs_cookie_str(cookies: list[dict[str, Any]]) -> str: """ 将 FlareSolverr Cookie 列表转换为请求头字符串。 """ @@ -1232,8 +1232,8 @@ class PlaywrightHelper: @staticmethod def __flaresolverr_request(url: str, cookies: Optional[str] = None, - proxy_config: Optional[dict] = None, - timeout: Optional[int] = 60) -> Optional[dict]: + proxy_config: Optional[dict[str, Any]] = None, + timeout: Optional[int] = 60) -> Optional[dict[str, Any]]: """ 调用 FlareSolverr 解决 Cloudflare 并返回 solution 结果 参考: https://github.com/FlareSolverr/FlareSolverr @@ -1256,14 +1256,14 @@ class PlaywrightHelper: logger.debug("检测到flaresolverr代理需要认证,使用 session 模式") session_id = str(uuid.uuid4()) - create_payload: dict = { + create_payload: dict[str, Any] = { "cmd": "sessions.create", "session": session_id } # 添加代理配置到会话创建请求 if proxy_config and proxy_config.get("server"): - proxy_payload: dict = {"url": proxy_config["server"]} + proxy_payload: dict[str, Any] = {"url": proxy_config["server"]} if proxy_config.get("username"): proxy_payload["username"] = proxy_config["username"] if proxy_config.get("password"): @@ -1330,7 +1330,7 @@ class PlaywrightHelper: callback: Callable[[BrowserPage], Any], cookies: Optional[str] = None, ua: Optional[str] = None, - proxies: Optional[dict] = None, + proxies: Optional[dict[str, Any]] = None, headless: Optional[bool] = False, timeout: Optional[int] = 60) -> Any: """ @@ -1364,7 +1364,7 @@ class PlaywrightHelper: fs_cookie_header = self.__fs_cookie_str(solution.get("cookies", [])) fs_ua = solution.get("userAgent") - context = self.__launch_cloakbrowser_context(headless=headless, + context = self.__launch_cloakbrowser_context(headless=bool(headless), user_agent=fs_ua or ua, proxies=proxies) page = context.new_page() @@ -1401,7 +1401,7 @@ class PlaywrightHelper: def get_page_source(self, url: str, cookies: Optional[str] = None, ua: Optional[str] = None, - proxies: Optional[dict] = None, + proxies: Optional[dict[str, Any]] = None, headless: Optional[bool] = False, timeout: Optional[int] = 60) -> Optional[str]: """ @@ -1432,7 +1432,7 @@ class PlaywrightHelper: context = None page = None try: - context = self.__launch_cloakbrowser_context(headless=headless, + context = self.__launch_cloakbrowser_context(headless=bool(headless), user_agent=ua, proxies=proxies) page = context.new_page() diff --git a/app/adapters/network/doh.py b/app/adapters/network/doh.py index 7b2aa37a3..f46978bb1 100644 --- a/app/adapters/network/doh.py +++ b/app/adapters/network/doh.py @@ -11,7 +11,7 @@ import urllib import urllib.request from concurrent.futures import as_completed from threading import Lock -from typing import Dict, Optional +from typing import Any, Callable, Dict, Optional, cast from app.foundation.singleton import Singleton from app.runtime.execution import OwnedThreadPoolExecutor, submit_with_context @@ -30,10 +30,10 @@ _doh_timeout = 5 _doh_cache: Dict[str, str] = {} _doh_lock = Lock() # 保存原始的 socket.getaddrinfo 方法 -_orig_getaddrinfo = socket.getaddrinfo +_orig_getaddrinfo: Callable[..., Any] = socket.getaddrinfo -def _doh_setting(key: str): +def _doh_setting(key: str) -> Any: """ 读取 DoH 热更新配置,组合根未装配时兼容旧 Settings。 """ @@ -60,7 +60,7 @@ def enable_doh(enable: bool) -> bool: global _doh_enabled, _executor - def _patched_getaddrinfo(host: str, *args, **kwargs): + def _patched_getaddrinfo(host: str, *args: Any, **kwargs: Any) -> Any: """ socket.getaddrinfo的补丁版本。 """ @@ -105,7 +105,9 @@ def enable_doh(enable: bool) -> bool: return False _executor = None _doh_enabled = enable - socket.getaddrinfo = _patched_getaddrinfo if enable else _orig_getaddrinfo + socket.getaddrinfo = cast( + Any, _patched_getaddrinfo if enable else _orig_getaddrinfo + ) return True @@ -245,8 +247,11 @@ def doh_query_json(resolver: str, host: str) -> Optional[str]: return None response_body = response.read().decode("utf-8") logger.debug(f"<== body: {response_body}") - answer = json.loads(response_body)["Answer"] - return answer[0]["data"] + answer = json.loads(response_body).get("Answer") + if not isinstance(answer, list) or not answer: + return None + value = answer[0].get("data") if isinstance(answer[0], dict) else None + return value if isinstance(value, str) else None except Exception as e: logger.error(f"解析器({resolver})请求错误: {e}") return None diff --git a/app/adapters/network/http.py b/app/adapters/network/http.py index d032a3b34..6072469a4 100644 --- a/app/adapters/network/http.py +++ b/app/adapters/network/http.py @@ -619,7 +619,7 @@ class RequestUtils: proxies: Optional[dict[str, Any]] = None, session: Optional[Session] = None, use_session: bool = False, - timeout: Optional[int] = None, + timeout: Optional[float] = None, verify: Union[bool, str] = False, referer: Optional[str] = None, content_type: Optional[str] = None, @@ -1378,7 +1378,7 @@ class AsyncRequestUtils: cookies: Optional[Union[str, dict[str, Any]]] = None, proxies: Optional[Union[str, dict[str, str]]] = None, client: Optional[httpx2.AsyncClient] = None, - timeout: Optional[int] = None, + timeout: Optional[float] = None, referer: Optional[str] = None, content_type: Optional[str] = None, accept_type: Optional[str] = None, diff --git a/app/adapters/system/fsproxy.py b/app/adapters/system/fsproxy.py index ce2096903..1480b1b76 100644 --- a/app/adapters/system/fsproxy.py +++ b/app/adapters/system/fsproxy.py @@ -84,7 +84,7 @@ class FileSystemProxy: :param path: 目标路径 :return: {"size", "mtime", "dev", "ino", "is_dir", "is_file"};dev/ino 标识当前文件身份 """ - return self._call("stat", path=str(path)) + return cast(dict[str, Any], self._call("stat", path=str(path))) def exists(self, path: Path) -> bool: """ @@ -115,7 +115,7 @@ class FileSystemProxy: :param path: 目标目录 :return: 条目名列表 """ - return self._call("listdir", path=str(path)) + return cast(list[str], self._call("listdir", path=str(path))) def count_entries(self, path: Path, max_check: int = 10000) -> Dict[str, int]: """ @@ -125,7 +125,7 @@ class FileSystemProxy: :param max_check: 文件数上限,超过即提前结束 :return: 文件与目录数量字典,分别使用 file_count 和 dir_count 键 """ - return self._call("count_entries", path=str(path), max_check=max_check) + return cast(dict[str, int], self._call("count_entries", path=str(path), max_check=max_check)) def has_file_suffix(self, path: Path, extensions: List[str]) -> bool: """ @@ -141,7 +141,7 @@ class FileSystemProxy: :param dst: 目标路径 :return: 是否成功 """ - return self._call("rename", src=str(src), dst=str(dst)) + return cast(bool, self._call("rename", src=str(src), dst=str(dst))) def copy(self, src: Path, dst: Path, progress_cb: Optional[Callable[[float], None]] = None, @@ -192,9 +192,12 @@ class FileSystemProxy: :return: 操作结果 """ self._ensure_worker() + process = self._process + if process is None or process.stdin is None: + raise BrokenPipeError("文件系统代理进程未启动") message = json.dumps({"op": "copy", **payload}) + "\n" - self._process.stdin.write(message.encode("utf-8")) - self._process.stdin.flush() + process.stdin.write(message.encode("utf-8")) + process.stdin.flush() while True: response = json.loads(self._read_line(timeout=self._stall_timeout).decode("utf-8")) @@ -245,7 +248,7 @@ class FileSystemProxy: :param path: 目标文件 :return: 是否成功 """ - return self._call("unlink", path=str(path)) + return cast(bool, self._call("unlink", path=str(path))) def rmtree(self, path: Path) -> bool: """ @@ -254,7 +257,7 @@ class FileSystemProxy: :param path: 目标目录 :return: 是否成功 """ - return self._call("rmtree", path=str(path)) + return cast(bool, self._call("rmtree", path=str(path))) def close(self) -> None: """ @@ -384,9 +387,12 @@ class FileSystemProxy: :return: 操作结果 """ self._ensure_worker() + process = self._process + if process is None or process.stdin is None: + raise BrokenPipeError("文件系统代理进程未启动") message = json.dumps({"op": op, **payload}) + "\n" - self._process.stdin.write(message.encode("utf-8")) - self._process.stdin.flush() + process.stdin.write(message.encode("utf-8")) + process.stdin.flush() response = json.loads(self._read_line().decode("utf-8")) if response.get("ok"): @@ -408,9 +414,13 @@ class FileSystemProxy: # 整理链每次 stat 都会直接失败。POSIX 继续用 selector,Windows 改走线程等待。 if sys.platform == "win32": return self._read_line_windows(timeout) - if not self._selector.select(timeout=timeout): + selector = self._selector + process = self._process + if selector is None or process is None or process.stdout is None: + raise BrokenPipeError("文件系统代理进程未启动") + if not selector.select(timeout=timeout): self._raise_timeout(timeout) - line = self._process.stdout.readline() + line = process.stdout.readline() if not line: raise BrokenPipeError("文件系统代理进程已退出") return line @@ -483,14 +493,19 @@ class FileSystemProxy: if sys.platform == "win32": # NSSM 服务里再拉 python.exe 时避免弹出控制台窗口 popen_kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0) - self._process = subprocess.Popen( + process = subprocess.Popen( [sys.executable, str(_WORKER_PATH)], **popen_kwargs, ) + self._process = process + if process.stdin is None or process.stdout is None: + self._shutdown() + raise BrokenPipeError("文件系统代理进程管道创建失败") if sys.platform != "win32": - self._selector = selectors.DefaultSelector() - self._selector.register(self._process.stdout, selectors.EVENT_READ) - logger.debug(f"文件系统代理进程已启动: pid={self._process.pid}") + selector = selectors.DefaultSelector() + selector.register(process.stdout, selectors.EVENT_READ) + self._selector = selector + logger.debug(f"文件系统代理进程已启动: pid={process.pid}") def _shutdown(self) -> None: """ diff --git a/app/adapters/system/host.py b/app/adapters/system/host.py index e8320fc92..7decb3b08 100644 --- a/app/adapters/system/host.py +++ b/app/adapters/system/host.py @@ -19,12 +19,8 @@ import time import urllib.parse import uuid from pathlib import Path -from typing import List, Optional, Tuple, Union - -try: - import fcntl -except ImportError: - fcntl = None +from types import ModuleType +from typing import List, Optional, Sequence, Tuple, Union import psutil @@ -43,6 +39,13 @@ from app.schemas.dashboard import DashboardMemoryInfo as _SchemaDashboardMemoryI from app.schemas.dashboard import DashboardSystemInfo as _SchemaDashboardSystemInfo from app.schemas.dashboard import ProcessInfo as _SchemaProcessInfo +try: + import fcntl as _fcntl +except ImportError: + fcntl: Optional[ModuleType] = None +else: + fcntl = _fcntl + # Linux amd64/arm64 的用户空间接口定义:_IOR(BTRFS_IOCTL_MAGIC, 31, struct btrfs_ioctl_fs_info_args) _BTRFS_IOC_FS_INFO = 0x8400941F _BTRFS_FS_INFO_SIZE = 1024 @@ -75,6 +78,9 @@ class SystemUtils: 脱敏 URL 中的 userinfo,避免命令输出泄露镜像源或代理凭据。 """ def replace(match: re.Match[str]) -> str: + """ + 把文本中的链接规范化为消息入口可识别的形式,并保留链接后的标点。 + """ candidate = match.group(1) trailing = "" while candidate and candidate[-1] in ".,;:)": @@ -104,7 +110,7 @@ class SystemUtils: @staticmethod def execute_with_subprocess( - command: list, + command: list[str], env: Optional[dict[str, str]] = None, safe_command: Optional[list[str]] = None, timeout: Optional[float] = 300, @@ -319,7 +325,7 @@ class SystemUtils: @staticmethod async def execute_with_subprocess_async( - command: list, + command: list[str], env: Optional[dict[str, str]] = None, safe_command: Optional[list[str]] = None, timeout: Optional[float] = 300, @@ -617,7 +623,7 @@ class SystemUtils: return -1, f"当前操作系统 ({sys.platform}) 不支持引用链接 (reflink)" @staticmethod - def list_files(directory: Path, extensions: list = None, + def list_files(directory: Path, extensions: Optional[Sequence[str]] = None, min_filesize: int = 0, recursive: bool = True) -> List[Path]: """ 获取目录下所有指定扩展名的文件(包括子目录) @@ -645,7 +651,10 @@ class SystemUtils: else: pattern = re.compile(r".*") - def _scan_directory(dir_path: Path, is_recursive: bool): + def _scan_directory(dir_path: Path, is_recursive: bool) -> None: + """ + 扫描当前目录并将满足扩展名和大小要求的文件追加到结果列表。 + """ try: with os.scandir(dir_path) as entries: for entry in entries: @@ -744,7 +753,7 @@ class SystemUtils: raise RuntimeError(f"RAR 压缩包解压失败:{';'.join(errors)}") @staticmethod - def exits_files(directory: Path, extensions: list, min_filesize: int = 0, recursive: bool = True) -> bool: + def exits_files(directory: Path, extensions: Sequence[str], min_filesize: int = 0, recursive: bool = True) -> bool: """ 判断目录下是否存在指定扩展名的文件 @@ -773,6 +782,9 @@ class SystemUtils: return True def _search_files(dir_path: Path, is_recursive: bool) -> bool: + """ + 扫描目录中的有效文件,按调用方要求递归并隔离不可访问的目录。 + """ try: with os.scandir(dir_path) as entries: for entry in entries: @@ -795,7 +807,7 @@ class SystemUtils: return _search_files(directory, recursive) @staticmethod - def list_sub_files(directory: Path, extensions: list) -> List[Path]: + def list_sub_files(directory: Path, extensions: Sequence[str]) -> List[Path]: """ 列出当前目录下的所有指定扩展名的文件(不包括子目录) """ @@ -872,16 +884,16 @@ class SystemUtils: """ 计算目录的大小 - 参数: - directory_path (Path): 目录路径 - - 返回: - int: 目录的大小(以字节为单位) + :param path: 待统计的文件或目录 + :return: 总字节数;不存在或无法读取时按可统计部分返回 """ if not path or not path.exists(): return 0 - def _calc_dir_size(dir_path): + def _calc_dir_size(dir_path: Union[Path, str]) -> int: + """ + 递归统计目录内可读取文件的字节数。 + """ total = 0 try: with os.scandir(dir_path) as entries: @@ -948,10 +960,11 @@ class SystemUtils: and (SystemUtils.is_x86_64() or SystemUtils.is_aarch64()) if not use_btrfs_fsid: # 默认分支保留原有行为,不打开目录或调用 ioctl。 - disk_set = set() + disk_set: set[Union[str, int]] = set() total_free_space = 0.0 total_space = 0.0 for dir_path in dir_list: + disk: Union[str, int] if not dir_path: continue if not dir_path.exists(): @@ -969,7 +982,7 @@ class SystemUtils: total_free_space = 0.0 total_space = 0.0 # 先按 st_dev 恢复原有去重语义,再用组内唯一可信的 FSID 合并 Btrfs 子卷。 - disk_groups = {} + disk_groups: dict[int, tuple[Path, set[bytes]]] = {} for dir_path in dir_list: if not dir_path or not dir_path.exists(): continue @@ -980,13 +993,13 @@ class SystemUtils: if btrfs_fsid: disk_groups[st_dev][1].add(btrfs_fsid) - disk_set = set() + grouped_disks: set[Union[tuple[str, bytes], tuple[str, int]]] = set() for st_dev, (dir_path, fsids) in disk_groups.items(): # 同一 st_dev 出现冲突 FSID 可能是挂载变化导致的不一致快照, # 此时禁止跨 st_dev 合并,避免少计独立存储。 - disk = ("btrfs_fsid", next(iter(fsids))) if len(fsids) == 1 else ("st_dev", st_dev) - if disk not in disk_set: - disk_set.add(disk) + disk_key = ("btrfs_fsid", next(iter(fsids))) if len(fsids) == 1 else ("st_dev", st_dev) + if disk_key not in grouped_disks: + grouped_disks.add(disk_key) total_space += SystemUtils.total_space(dir_path) total_free_space += SystemUtils.free_space(dir_path) return total_space, total_free_space @@ -998,7 +1011,7 @@ class SystemUtils: """ if not os.path.exists(path): return 0.0 - return psutil.disk_usage(str(path)).free + return float(psutil.disk_usage(str(path)).free) @staticmethod def total_space(path: Path) -> float: @@ -1007,7 +1020,7 @@ class SystemUtils: """ if not os.path.exists(path): return 0.0 - return psutil.disk_usage(str(path)).total + return float(psutil.disk_usage(str(path)).total) @staticmethod def processes() -> List[_SchemaProcessInfo]: @@ -1019,8 +1032,8 @@ class SystemUtils: try: if proc.status() != psutil.STATUS_ZOMBIE: runtime = datetime.datetime.now() - datetime.datetime.fromtimestamp( - int(getattr(proc, 'create_time', 0)())) - mem_info = getattr(proc, 'memory_info', None)() + int(proc.create_time())) + mem_info = proc.memory_info() if mem_info is not None: mem_mb = round(mem_info.rss / (1024 * 1024), 1) processes.append(_SchemaProcessInfo( @@ -1092,11 +1105,11 @@ class SystemUtils: return vols @staticmethod - def cpu_usage(): + def cpu_usage() -> float: """ 获取CPU使用率 """ - return psutil.cpu_percent() + return float(psutil.cpu_percent()) @staticmethod def memory_usage() -> _SchemaDashboardMemoryInfo: @@ -1251,7 +1264,7 @@ class SystemUtils: return SystemUtils.get_config_path() / "app.env" @staticmethod - def clear(temp_path: Path, days: int): + def clear(temp_path: Path, days: int) -> None: """ 清理指定目录中指定天数前的文件,递归删除子文件及空文件夹 """ @@ -1270,7 +1283,7 @@ class SystemUtils: folder.rmdir() @staticmethod - def generate_user_unique_id(): + def generate_user_unique_id() -> Optional[str]: """ 根据优先级依次尝试生成稳定唯一ID: 1. 文件系统唯一标识符。 @@ -1278,7 +1291,7 @@ class SystemUtils: 3. 主机名。 """ - def get_filesystem_unique_id(): + def get_filesystem_unique_id() -> Optional[str]: """ 获取文件系统的唯一标识符。 使用根目录的设备号和 inode。 @@ -1291,7 +1304,7 @@ class SystemUtils: print(str(e)) return None - def get_mac_address_id(): + def get_mac_address_id() -> Optional[str]: """ 获取设备的 MAC 地址并生成唯一标识符。 """ diff --git a/app/adapters/system/rust.py b/app/adapters/system/rust.py index 055a0da87..743ac4a11 100644 --- a/app/adapters/system/rust.py +++ b/app/adapters/system/rust.py @@ -4,7 +4,10 @@ import logging from functools import lru_cache -from typing import List, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Union, cast + +if TYPE_CHECKING: + from app.domain.context import MediaInfo, MusicInfo, TorrentInfo from app.foundation.environment import is_free_threaded_runtime from app.runtime.log import log_settings, logger @@ -76,14 +79,14 @@ def import_error() -> Optional[Exception]: return _import_error -def parse_filter_rule(expression: str) -> Optional[list]: +def parse_filter_rule(expression: str) -> Optional[list[Any]]: """ 使用 Rust 解析过滤规则表达式,不可用时返回 None。 """ if not is_enabled(): return None try: - return _moviepilot_rust.parse_filter_rule_fast(expression) + return cast(Optional[list[Any]], _moviepilot_rust.parse_filter_rule_fast(expression)) except BaseException as err: _raise_non_rust_panic(err) logger.debug(f"Rust 过滤规则解析失败,回退 Python:{err}") @@ -91,12 +94,12 @@ def parse_filter_rule(expression: str) -> Optional[list]: def filter_torrents( - groups: list, - torrent_list: list, - rule_set: dict, - mediainfo=None, - metainfo_options: Optional[dict] = None, -) -> Optional[Tuple[list, list]]: + groups: list[dict[str, Any]], + torrent_list: list["TorrentInfo"], + rule_set: dict[str, Any], + mediainfo: Optional[Union["MediaInfo", "MusicInfo"]] = None, + metainfo_options: Optional[dict[str, Any]] = None, +) -> Optional[Tuple[list[tuple[int, int]], list[dict[str, Any]]]]: """ 使用 Rust 执行完整种子过滤入口,返回原列表下标、优先级和可选调试日志。 """ @@ -112,8 +115,8 @@ def filter_torrents( ) if is_debug_log_enabled() and hasattr(_moviepilot_rust, "filter_torrents_with_trace_fast"): matched_orders, traces = _moviepilot_rust.filter_torrents_with_trace_fast(*args) - return matched_orders, traces - return _moviepilot_rust.filter_torrents_fast(*args), [] + return cast(list[tuple[int, int]], matched_orders), cast(list[dict[str, Any]], traces) + return cast(list[tuple[int, int]], _moviepilot_rust.filter_torrents_fast(*args)), [] except BaseException as err: _raise_non_rust_panic(err) logger.debug(f"Rust 种子过滤失败,回退 Python:{err}") @@ -132,25 +135,25 @@ def is_debug_log_enabled() -> bool: def parse_indexer_torrents( html_text: str, domain: str, - list_config: dict, - fields: dict, - category: Optional[dict] = None, + list_config: dict[str, Any], + fields: dict[str, Any], + category: Optional[dict[str, Any]] = None, result_num: int = 100 -) -> Optional[List[dict]]: +) -> Optional[List[dict[str, Any]]]: """ 使用 Rust 批量解析普通配置站点种子列表,不可用时返回 None。 """ if not is_enabled(): return None try: - return _moviepilot_rust.parse_indexer_torrents_fast( + return cast(Optional[List[dict[str, Any]]], _moviepilot_rust.parse_indexer_torrents_fast( html_text, domain, list_config, fields, category, result_num - ) + )) except BaseException as err: _raise_non_rust_panic(err) logger.debug(f"Rust 站点列表解析失败,使用 Python 解析兜底:{err}") @@ -160,65 +163,65 @@ def parse_indexer_torrents( def parse_indexer_subtitles( html_text: str, domain: str, - list_config: dict, - fields: dict, + list_config: dict[str, Any], + fields: dict[str, Any], result_num: int = 100 -) -> Optional[List[dict]]: +) -> Optional[List[dict[str, Any]]]: """ 使用 Rust 批量解析普通配置站点字幕列表,不可用时返回 None。 """ if not is_enabled(): return None try: - return _moviepilot_rust.parse_indexer_subtitles_fast( + return cast(Optional[List[dict[str, Any]]], _moviepilot_rust.parse_indexer_subtitles_fast( html_text, domain, list_config, fields, result_num - ) + )) except BaseException as err: _raise_non_rust_panic(err) logger.debug(f"Rust 字幕列表解析失败,使用 Python 解析兜底:{err}") return None -def parse_rss_items(xml_text: str, max_items: int = 1000) -> Optional[List[dict]]: +def parse_rss_items(xml_text: str, max_items: int = 1000) -> Optional[List[dict[str, Any]]]: """ 使用 Rust 解析 RSS/Atom 条目,不可用或异常时返回 None。 """ if not is_enabled(): return None try: - return _moviepilot_rust.parse_rss_items_fast(xml_text, max_items) + return cast(Optional[List[dict[str, Any]]], _moviepilot_rust.parse_rss_items_fast(xml_text, max_items)) except BaseException as err: _raise_non_rust_panic(err) logger.debug(f"Rust RSS解析失败,使用 Python 解析兜底:{err}") return None -def parse_metainfo(title: str, subtitle: Optional[str] = None, options: Optional[dict] = None) -> Optional[dict]: +def parse_metainfo(title: str, subtitle: Optional[str] = None, options: Optional[dict[str, Any]] = None) -> Optional[dict[str, Any]]: """ 使用 Rust 从标题入口解析 MetaInfo,不可用或异常时返回 None。 """ if not is_enabled(): return None try: - return _moviepilot_rust.parse_metainfo_fast(title, subtitle, options or {}) + return cast(Optional[dict[str, Any]], _moviepilot_rust.parse_metainfo_fast(title, subtitle, options or {})) except BaseException as err: _raise_non_rust_panic(err) logger.debug(f"Rust MetaInfo解析失败,使用 Python 解析兜底:{err}") return None -def parse_metainfo_path(path: str, options: Optional[dict] = None) -> Optional[dict]: +def parse_metainfo_path(path: str, options: Optional[dict[str, Any]] = None) -> Optional[dict[str, Any]]: """ 使用 Rust 从路径入口解析 MetaInfoPath,不可用或异常时返回 None。 """ if not is_enabled(): return None try: - return _moviepilot_rust.parse_metainfo_path_fast(path, options or {}) + return cast(Optional[dict[str, Any]], _moviepilot_rust.parse_metainfo_path_fast(path, options or {})) except BaseException as err: _raise_non_rust_panic(err) logger.debug(f"Rust MetaInfoPath解析失败,使用 Python 解析兜底:{err}") @@ -229,7 +232,7 @@ def parse_metamusic( title: str, artists: Optional[List[str]] = None, year: Optional[int] = None, -) -> Optional[dict]: +) -> Optional[dict[str, Any]]: """ 使用 Rust 解析音乐资源标题,旧扩展不支持时返回 None。 @@ -252,14 +255,14 @@ def parse_metamusic( return result if isinstance(result, dict) and result else None -def find_metainfo(title: str) -> Optional[dict]: +def find_metainfo(title: str) -> Optional[dict[str, Any]]: """ 使用 Rust 提取标题中的显式媒体标签,不可用或异常时返回 None。 """ if not is_enabled(): return None try: - return _moviepilot_rust.find_metainfo_fast(title) + return cast(Optional[dict[str, Any]], _moviepilot_rust.find_metainfo_fast(title)) except BaseException as err: _raise_non_rust_panic(err) logger.debug(f"Rust 显式媒体标签解析失败,使用 Python 解析兜底:{err}") diff --git a/app/adapters/system/update.py b/app/adapters/system/update.py index 0c064c8dd..3cd52fc85 100644 --- a/app/adapters/system/update.py +++ b/app/adapters/system/update.py @@ -287,7 +287,7 @@ class SystemUpdateManager(metaclass=SingletonClass): """ 校验并原子替换状态 JSON。 """ - validated = cast(dict[str, Any], SystemUpdateStatus.model_validate(state).model_dump()) + validated = SystemUpdateStatus.model_validate(state).model_dump() self._root.mkdir(parents=True, exist_ok=True) temporary = self._state_file.with_suffix(".tmp") temporary.write_text( @@ -414,7 +414,7 @@ class SystemUpdateManager(metaclass=SingletonClass): state["auto_update"] = get_runtime_setting("MOVIEPILOT_AUTO_UPDATE") is True state["auto_update_resource"] = get_runtime_setting("AUTO_UPDATE_RESOURCE") is True state["supports_batch_update"] = True - return cast(SystemUpdateStatus, SystemUpdateStatus.model_validate(state)) + return SystemUpdateStatus.model_validate(state) def _restore_prepared_resources(self, item: dict[str, Any]) -> bool: """ diff --git a/app/adapters/web/security/access.py b/app/adapters/web/security/access.py index 6777c3b5a..a09d5df58 100644 --- a/app/adapters/web/security/access.py +++ b/app/adapters/web/security/access.py @@ -276,7 +276,7 @@ def set_or_refresh_resource_token_cookie( ) is_https = ( request.url.scheme == "https" - or request.headers.get("x-forwarded-proto", "").lower() == "https" + or (request.headers.get("x-forwarded-proto") or "").lower() == "https" ) response.set_cookie( key=project_name, diff --git a/app/agent/learning/review.py b/app/agent/learning/review.py index cbcf213fb..5ba265fe4 100644 --- a/app/agent/learning/review.py +++ b/app/agent/learning/review.py @@ -4,11 +4,12 @@ from copy import deepcopy from dataclasses import dataclass -from typing import Any, Callable +from typing import Any, Callable, cast -from langchain.agents.middleware.types import ExtendedModelResponse, ModelRequest, ModelResponse -from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage, ToolMessage +from langchain.agents.middleware.types import AgentState, ExtendedModelResponse, ModelRequest, ModelResponse +from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, SystemMessage, ToolMessage from langchain_core.utils.function_calling import convert_to_openai_tool +from langgraph.types import Command from app.agent.learning.prompts import COMBINED_REVIEW_PROMPT, MEMORY_REVIEW_PROMPT, SKILL_REVIEW_PROMPT from app.agent.learning.tools import LearningTools @@ -35,7 +36,7 @@ class ReviewSnapshot: model: Any system: SystemMessage | None - messages: list[BaseMessage] + messages: list[AnyMessage] schemas: list[dict[str, Any]] settings: dict[str, Any] tool_choice: Any @@ -123,16 +124,19 @@ class ReviewLoop: for iteration in range(16): if self.cancelled() or self.consumed >= self.budget: break + state: AgentState = {"messages": self.messages} request = ModelRequest(model=self.model, system_message=self.snapshot.system, - messages=self.messages, tools=self.snapshot.schemas, + messages=self.messages, tools=[*self.snapshot.schemas], model_settings=self.snapshot.settings, tool_choice=self.snapshot.tool_choice, - state={'messages': self.messages}, runtime=None) + state=state, runtime=None) response = await self._invoke(request) if iteration == 0 else await self.compaction.awrap_model_call(request, self._invoke) if isinstance(response, ExtendedModelResponse): - self.messages = response.command.update['messages'][1:] + # 压缩中间件的扩展响应始终携带消息状态命令。 + update = cast(dict[str, Any], cast(Command[Any], response.command).update) + self.messages = update['messages'][1:] message = response.model_response.result[-1] else: - self.messages.extend(response.result) + self.messages.extend(cast(list[AnyMessage], response.result)) message = response.result[-1] if not isinstance(message, AIMessage) or not message.tool_calls: break diff --git a/app/agent/learning/schema.py b/app/agent/learning/schema.py index db0c06b0a..651004ba2 100644 --- a/app/agent/learning/schema.py +++ b/app/agent/learning/schema.py @@ -7,7 +7,7 @@ from typing import Literal from pydantic import BaseModel, ConfigDict, Field -class SkillOperation(BaseModel): # type: ignore[misc] +class SkillOperation(BaseModel): """ 技能身份由名称选择,个人根与后台身份始终由宿主绑定。 """ @@ -75,7 +75,7 @@ class DeleteSkill(SkillOperation): absorbed_into: str | None = Field(default=None, pattern=r'^(?:[a-z0-9][a-z0-9_-]{0,63})?$', description="合并后承接内容的技能名称,作为追溯依据;未合并时可为 None 或空字符串") -class ManageSkillsInput(BaseModel): # type: ignore[misc] +class ManageSkillsInput(BaseModel): """ 最多二十个有序操作,任一失败回滚本次全部技能变更。 """ @@ -85,7 +85,7 @@ class ManageSkillsInput(BaseModel): # type: ignore[misc] min_length=1, max_length=20, description="按顺序执行的 1 至 20 个技能操作;任一操作失败时回滚本次全部变更") -class ViewSkillInput(BaseModel): # type: ignore[misc] +class ViewSkillInput(BaseModel): """ 读取具体技能或支持文件,并登记本轮实际读取的版本。 """ @@ -95,7 +95,7 @@ class ViewSkillInput(BaseModel): # type: ignore[misc] file_path: str = Field(default='SKILL.md', max_length=255, description="待读取文件在技能目录内的相对路径,默认 SKILL.md") -class MemoryOperation(BaseModel): # type: ignore[misc] +class MemoryOperation(BaseModel): """ old_text 只定位整条记忆,replace 的 content 会替换整条而非子串。 """ @@ -107,7 +107,7 @@ class MemoryOperation(BaseModel): # type: ignore[misc] new_text: str | None = Field(default=None, max_length=100_000, description="替换操作兼容的新内容字段;未提供时为 None") -class MemoryInput(BaseModel): # type: ignore[misc] +class MemoryInput(BaseModel): """ 支持单操作及原子批次形态,pending 只读列出等待人工处理的提案。 """ diff --git a/app/agent/learning/tools.py b/app/agent/learning/tools.py index 60839bbd0..5f41a26fc 100644 --- a/app/agent/learning/tools.py +++ b/app/agent/learning/tools.py @@ -16,7 +16,7 @@ from app.agent.tools.base import run_agent_blocking from app.agent.tools.tags import ToolTag -class ListSkillsInput(BaseModel): # type: ignore[misc] +class ListSkillsInput(BaseModel): """ 技能枚举只接受当前宿主作用域,不允许传入任意目录。 """ diff --git a/app/agent/learning/usage.py b/app/agent/learning/usage.py index 7430abdba..bcb6ecf9b 100644 --- a/app/agent/learning/usage.py +++ b/app/agent/learning/usage.py @@ -2,7 +2,7 @@ 复盘所有模型请求的统一计量,包括独立上下文压缩的辅助请求。 """ -from typing import Any, Callable +from typing import Any, Callable, cast from uuid import UUID from langchain_core.callbacks import BaseCallbackHandler @@ -13,7 +13,7 @@ from langchain_core.outputs import LLMResult from app.agent.middleware.usage import UsageMiddleware -class ReviewUsage(BaseCallbackHandler): # type: ignore[misc] +class ReviewUsage(BaseCallbackHandler): """ 按 SDK 请求 ID 结算实际输入 token,缺少 usage 时使用开始时的保守估算。 """ @@ -42,7 +42,7 @@ class ReviewUsage(BaseCallbackHandler): # type: ignore[misc] """ del kwargs estimate = self.pending.pop(run_id, 1) - message = next((generation.message for group in response.generations for generation in group + message = next((cast(AIMessage, getattr(generation, 'message')) for group in response.generations for generation in group if isinstance(getattr(generation, 'message', None), AIMessage)), None) usage = UsageMiddleware._extract_usage(message) if message is not None else {} self.consumed += max(1, usage.get('input_tokens') or estimate) diff --git a/app/agent/llm/capability.py b/app/agent/llm/capability.py index 0458a7a1d..d655b837c 100644 --- a/app/agent/llm/capability.py +++ b/app/agent/llm/capability.py @@ -75,16 +75,25 @@ class OpenAIAudioProvider(AudioCapabilityProvider): @staticmethod def _build_client(api_key: str, base_url: Optional[str]) -> OpenAI: + """ + 按当前语音服务凭据创建兼容 OpenAI 协议的客户端。 + """ from openai import OpenAI return OpenAI(api_key=api_key, base_url=base_url, max_retries=3) @staticmethod def _input_credentials() -> tuple[Optional[str], Optional[str]]: + """ + 读取语音输入服务的独立凭据和入口。 + """ return get_runtime_setting('AUDIO_INPUT_API_KEY'), get_runtime_setting('AUDIO_INPUT_BASE_URL') @staticmethod def _output_credentials() -> tuple[Optional[str], Optional[str]]: + """ + 读取语音输出服务的独立凭据和入口。 + """ return get_runtime_setting('AUDIO_OUTPUT_API_KEY'), get_runtime_setting('AUDIO_OUTPUT_BASE_URL') def is_available_for_audio_input(self) -> bool: @@ -195,6 +204,9 @@ class OpenAIChatAudioProvider(AudioCapabilityProvider): TRANSCODED_STT_SAMPLE_RATE = "16000" def _build_client(self, api_key: str, base_url: Optional[str]) -> OpenAI: + """ + 按当前语音服务凭据创建兼容 OpenAI 协议的客户端。 + """ from openai import OpenAI return OpenAI( @@ -205,13 +217,22 @@ class OpenAIChatAudioProvider(AudioCapabilityProvider): @staticmethod def _input_credentials() -> tuple[Optional[str], Optional[str]]: + """ + 读取语音输入服务的独立凭据和入口。 + """ return get_runtime_setting('AUDIO_INPUT_API_KEY'), get_runtime_setting('AUDIO_INPUT_BASE_URL') @staticmethod def _output_credentials() -> tuple[Optional[str], Optional[str]]: + """ + 读取语音输出服务的独立凭据和入口。 + """ return get_runtime_setting('AUDIO_OUTPUT_API_KEY'), get_runtime_setting('AUDIO_OUTPUT_BASE_URL') def _normalize_stt_model(self) -> str: + """ + 规范化语音识别模型配置,缺省时采用供应方默认模型。 + """ return self._normalize_model( model=get_runtime_setting('AUDIO_INPUT_MODEL'), supported_models=self.SUPPORTED_STT_MODELS, @@ -219,6 +240,9 @@ class OpenAIChatAudioProvider(AudioCapabilityProvider): ) def _normalize_tts_model(self) -> str: + """ + 规范化语音合成模型配置,缺省时采用供应方默认模型。 + """ return self._normalize_model( model=get_runtime_setting('AUDIO_OUTPUT_MODEL'), supported_models=self.SUPPORTED_TTS_MODELS, @@ -231,6 +255,9 @@ class OpenAIChatAudioProvider(AudioCapabilityProvider): supported_models: Optional[frozenset[str]], default_model: Optional[str], ) -> str: + """ + 按供应方支持的模型集合规范化配置,并保留允许的自定义模型名称。 + """ model = (model or "").strip() if not model: return default_model or "" @@ -242,6 +269,9 @@ class OpenAIChatAudioProvider(AudioCapabilityProvider): return default_model or model def _is_supported_tts_model(self) -> bool: + """ + 确认当前语音合成模型属于该供应方可用的能力范围。 + """ model = self._normalize_tts_model() if not model: return False @@ -252,6 +282,9 @@ class OpenAIChatAudioProvider(AudioCapabilityProvider): @classmethod def _guess_audio_mime_type(cls, filename: str) -> str: + """ + 根据文件扩展名选择供应方支持的音频 MIME,未知后缀使用默认类型。 + """ suffix = Path(filename or "").suffix.lower() if suffix in cls.SUPPORTED_AUDIO_MIME_TYPES: return cls.SUPPORTED_AUDIO_MIME_TYPES[suffix] @@ -260,6 +293,9 @@ class OpenAIChatAudioProvider(AudioCapabilityProvider): @staticmethod def _guess_audio_format(filename: str) -> str: + """ + 把音频文件后缀转换为接口约定的格式名称。 + """ suffix = Path(filename or "").suffix.lower().lstrip(".") if suffix == "opus": return "ogg" @@ -377,6 +413,9 @@ class OpenAIChatAudioProvider(AudioCapabilityProvider): @staticmethod def _extract_audio_data(message: object) -> Optional[str]: + """ + 从供应方消息的字典或对象载荷中提取音频数据。 + """ audio = getattr(message, "audio", None) if isinstance(audio, dict): return audio.get("data") @@ -384,8 +423,11 @@ class OpenAIChatAudioProvider(AudioCapabilityProvider): return getattr(audio, "data", None) extra = getattr(message, "model_extra", None) - if isinstance(extra, dict) and isinstance(extra.get("audio"), dict): - return extra["audio"].get("data") + if isinstance(extra, dict): + audio_data = extra.get("audio") + if isinstance(audio_data, dict): + data = audio_data.get("data") + return data if isinstance(data, str) else None return None def _convert_wav_to_opus(self, wav_path: Path) -> Optional[Path]: @@ -569,6 +611,9 @@ class MiMoAudioProvider(OpenAIChatAudioProvider): ) def _normalize_tts_model(self) -> str: + """ + 规范化语音合成模型配置,缺省时采用供应方默认模型。 + """ model = (get_runtime_setting('AUDIO_OUTPUT_MODEL') or "").strip().lower() if not model or not model.startswith("mimo-"): return self.DEFAULT_TTS_MODEL @@ -779,10 +824,16 @@ class AgentCapabilityManager: @staticmethod def _normalize_provider_name(provider: Optional[str]) -> str: + """ + 规范化供应方名称,未配置时使用 OpenAI。 + """ return (provider or "openai").strip().lower() @staticmethod def _get_provider_log_name(provider: AudioCapabilityProvider) -> str: + """ + 使用供应方公开名称生成日志标识,缺失时回退到类名。 + """ provider_name = getattr(provider, "name", None) return provider_name if isinstance(provider_name, str) else provider.__class__.__name__ diff --git a/app/agent/llm/helper.py b/app/agent/llm/helper.py index 4003390c1..9b07abafc 100644 --- a/app/agent/llm/helper.py +++ b/app/agent/llm/helper.py @@ -7,7 +7,7 @@ import inspect import json import time from functools import wraps -from typing import TYPE_CHECKING, Any, List, Optional, cast +from typing import TYPE_CHECKING, Any, Callable, List, Optional, cast from urllib.parse import urlsplit from uuid import uuid4 @@ -116,7 +116,7 @@ def _patch_gemini_thought_signature() -> None: # 消息中所有 function_call 都带有 thought_signature。 _original_parse_chat_history = _cm._parse_chat_history # noqa - def _patched_parse_chat_history(*args, **kwargs): + def _patched_parse_chat_history(*args: Any, **kwargs: Any) -> Any: """ 为同一消息中的每个 Gemini 工具调用补齐缺失签名。 """ @@ -337,7 +337,9 @@ def _patch_interleaved_reasoning_request_support( return @wraps(original_get_request_payload) - def _patched_get_request_payload(self, input_, *, stop=None, **kwargs): + def _patched_get_request_payload( + self: Any, input_: Any, *, stop: Any = None, **kwargs: Any + ) -> Any: """ 按模型思考协议把历史消息中的思考内容补回出站请求。 """ @@ -425,7 +427,7 @@ def _patch_openai_interleaved_reasoning_content_support() -> None: if callable(original_convert_dict): @wraps(original_convert_dict) - def _patched_convert_dict_to_message(message_dict): + def _patched_convert_dict_to_message(message_dict: Any) -> Any: """ 保留普通响应中的 reasoning_content 以供后续工具轮次复用。 """ @@ -443,7 +445,9 @@ def _patch_openai_interleaved_reasoning_content_support() -> None: if callable(original_convert_delta): @wraps(original_convert_delta) - def _patched_convert_delta_to_message_chunk(delta, default_class): + def _patched_convert_delta_to_message_chunk( + delta: Any, default_class: Any + ) -> Any: """ 保留流式思考增量,避免聚合后丢失工具轮次所需的思考内容。 """ @@ -495,7 +499,9 @@ def _patch_openai_responses_instructions_support() -> None: return @wraps(original_get_request_payload) - def _patched_get_request_payload(self, input_, *, stop=None, **kwargs): + def _patched_get_request_payload( + self: Any, input_: Any, *, stop: Any = None, **kwargs: Any + ) -> Any: """ 适配 Copilot 与 Codex 端点的参数限制及 Responses 系统指令。 """ @@ -590,7 +596,9 @@ def _patch_openai_responses_empty_output_support() -> None: return response @wraps(original_construct) - def _patched_construct_lc_result_from_responses_api(response, *args, **kwargs): + def _patched_construct_lc_result_from_responses_api( + response: Any, *args: Any, **kwargs: Any + ) -> Any: """ 在 Responses API 收尾 chunk 缺少 output 时跳过空内容遍历。 """ @@ -980,7 +988,7 @@ class LLMHelper: @classmethod def _build_google_thinking_kwargs( - cls, model_name: str, thinking_level: str + cls, model_name: str, thinking_level: Optional[str] ) -> dict[str, Any]: """ Gemini 3 使用 thinking_level;Gemini 2.5 使用 thinking_budget。 @@ -991,11 +999,12 @@ class LLMHelper: :param thinking_level: 用户选择的统一思考级别 :return: 当前模型支持的 Google 原生思考参数 """ - if not model_name or thinking_level == "auto": + level = thinking_level or "auto" + if not model_name or level == "auto": return {} if "gemini-2.5" in model_name: - if thinking_level == "off": + if level == "off": if "pro" in model_name: # Gemini 2.5 Pro 官方不支持完全关闭思考,回退到最小预算。 return { @@ -1015,7 +1024,7 @@ class LLMHelper: "max": 24576, "xhigh": 24576, } - budget = budget_map.get(thinking_level) + budget = budget_map.get(level) return ( { "thinking_budget": budget, @@ -1036,7 +1045,7 @@ class LLMHelper: "max": "high", "xhigh": "high", } - google_level = level_map.get(thinking_level) + google_level = level_map.get(level) return ( { "thinking_level": google_level, @@ -1050,7 +1059,7 @@ class LLMHelper: @classmethod def _build_kimi_thinking_kwargs( - cls, model_name: str, thinking_level: str + cls, model_name: str, thinking_level: Optional[str] ) -> dict[str, Any]: """ Kimi 当前公开文档仅支持思考开关,不支持显式深度调节。 @@ -1356,20 +1365,21 @@ class LLMHelper: 创建在最终模型绑定阶段保留缓存控制参数的适配类。 """ - class PromptCachingModel(model_cls): - """ - 在 LangChain 工具绑定后仍保留提示词缓存参数的模型适配器。 - """ + base_bind = cast(Callable[..., Any], getattr(model_cls, "bind")) - def bind(self, **kwargs: Any) -> Any: - """ - 绑定调用参数,并补入当前 Provider 的默认缓存控制。 - """ - kwargs.setdefault("cache_control", dict(cache_control)) - return super().bind(**kwargs) + def bind(self: Any, **kwargs: Any) -> Any: + """ + 绑定调用参数,并补入当前 Provider 的默认缓存控制。 + """ + kwargs.setdefault("cache_control", dict(cache_control)) + return base_bind(self, **kwargs) - PromptCachingModel.__name__ = f"PromptCaching{model_cls.__name__}" - return PromptCachingModel + model_name = f"PromptCaching{model_cls.__name__}" + return type( + model_name, + (model_cls,), + {"__doc__": "保留提示词缓存参数的模型适配器。", "bind": bind}, + ) @classmethod def _use_anthropic_prompt_cache( @@ -1577,7 +1587,7 @@ class LLMHelper: web_search_mode: str | None = None, prompt_cache_key: str | None = None, provider_runtime: LLMProviderRuntimePort | None = None, - ): + ) -> Any: """ 获取 LLM 实例;调用方负责在请求或缓存图不再使用模型时调用 close_llm。 :param streaming: 是否启用流式输出 @@ -1691,6 +1701,7 @@ class LLMHelper: ) llm_proxy = _resolve_llm_proxy(use_proxy) + llm_model: Any if runtime["runtime"] == "google": # 修补 Gemini 2.5 思考模型的 thought_signature 兼容性 _patch_gemini_thought_signature() @@ -1701,7 +1712,7 @@ class LLMHelper: from langchain_google_genai import ChatGoogleGenerativeAI # Google SDK 不接受 None;留空时保留 SDK 对各模型默认温度的选择。 - model = ChatGoogleGenerativeAI( + llm_model = ChatGoogleGenerativeAI( model=model_name, api_key=runtime["api_key"], retries=3, @@ -1726,7 +1737,7 @@ class LLMHelper: normalize_deepseek_messages=True, inject_missing_as_empty=True, ) - model = ChatDeepSeek( + llm_model = ChatDeepSeek( model=model_name, api_key=runtime["api_key"], api_base=runtime["base_url"], @@ -1763,7 +1774,7 @@ class LLMHelper: use_proxy=use_proxy, read_timeout=get_runtime_setting('LLM_TOOL_TIMEOUT'), ) - model = bedrock_model_cls( + llm_model = bedrock_model_cls( model_id=model_name, client=bedrock_client, temperature=temperature_value, @@ -1783,7 +1794,7 @@ class LLMHelper: {"type": "ephemeral"}, ) - model = anthropic_model_cls( + llm_model = anthropic_model_cls( model=model_name, api_key=runtime["api_key"], base_url=runtime["base_url"], @@ -1808,7 +1819,7 @@ class LLMHelper: if runtime.get("use_responses_api") and "chatgpt.com/backend-api/codex" in str(runtime.get("base_url") or ""): streaming = True - model = ChatOpenAI( + llm_model = ChatOpenAI( model=model_name, api_key=runtime["api_key"], max_retries=3, @@ -1831,19 +1842,19 @@ class LLMHelper: **openai_model_kwargs, ) - model.profile = cls._normalize_model_profile( - model_profile=getattr(model, "profile", None), + llm_model.profile = cls._normalize_model_profile( + model_profile=getattr(llm_model, "profile", None), runtime=runtime, ) # ChatBedrockConverse 等模型类没有 model 属性,模型名存放在 model_id。 - logged_model_name = getattr(model, "model", None) or getattr( - model, "model_id", model_name + logged_model_name = getattr(llm_model, "model", None) or getattr( + llm_model, "model_id", model_name ) - logger.debug(f"使用LLM模型: {logged_model_name},Profile: {model.profile}") + logger.debug(f"使用LLM模型: {logged_model_name},Profile: {llm_model.profile}") - cls._attach_runtime_metadata(model, runtime) - cls._attach_server_tool_metadata(model, server_tool_resolution) - return model + cls._attach_runtime_metadata(llm_model, runtime) + cls._attach_server_tool_metadata(llm_model, server_tool_resolution) + return llm_model @staticmethod async def close_llm(model: Any) -> bool: @@ -1898,7 +1909,9 @@ class LLMHelper: ): continue if block_type == "text": - text_parts.append(block.get("text", "")) + text_value = block.get("text", "") + if isinstance(text_value, str): + text_parts.append(text_value) continue if not block_type and isinstance(block.get("text"), str): text_parts.append(block.get("text", "")) @@ -1907,9 +1920,11 @@ class LLMHelper: if content.get("thought"): return "" if content.get("type") == "text": - return content.get("text", "") + text_value = content.get("text", "") + return text_value if isinstance(text_value, str) else "" if not content.get("type") and isinstance(content.get("text"), str): - return content.get("text", "") + text_value = content.get("text", "") + return text_value if isinstance(text_value, str) else "" return str(content) if fallback_to_string else "" @staticmethod @@ -1928,7 +1943,7 @@ class LLMHelper: api_protocol: str | None = None, web_search_mode: str | None = None, provider_runtime: LLMProviderRuntimePort | None = None, - ) -> dict: + ) -> dict[str, Any]: """ 使用当前配置或显式传入的临时配置执行一次最小 LLM 调用。 @@ -1940,7 +1955,7 @@ class LLMHelper: provider_name = provider if provider is not None else get_runtime_setting('LLM_PROVIDER') model_name = model if model is not None else get_runtime_setting('LLM_MODEL') start = time.perf_counter() - llm_kwargs = { + llm_kwargs: dict[str, Any] = { "streaming": False, "provider": provider_name, "model": model_name, diff --git a/app/agent/loader.py b/app/agent/loader.py index ba2c136c9..b5c03e10a 100644 --- a/app/agent/loader.py +++ b/app/agent/loader.py @@ -130,7 +130,7 @@ def get_moviepilot_agent_type() -> type: return agent_type -def get_tool_factory() -> type: +def get_tool_factory() -> Any: """ 返回 canonical 工具工厂类型。 """ diff --git a/app/agent/mcp.py b/app/agent/mcp.py index 1d492674c..ff0f511a8 100644 --- a/app/agent/mcp.py +++ b/app/agent/mcp.py @@ -12,7 +12,7 @@ import uuid from contextlib import AbstractAsyncContextManager from dataclasses import dataclass from types import TracebackType -from typing import TYPE_CHECKING, Any, Optional, Union +from typing import TYPE_CHECKING, Any, AsyncIterator, Optional, Union from urllib.parse import urljoin from app.adapters.network.http import AsyncRequestUtils @@ -160,7 +160,7 @@ def _extract_jsonrpc_result(payload: Any, request_id: str) -> Any: return payload.get("result") -async def _iter_sse_events(response: httpx2.Response) -> Any: +async def _iter_sse_events(response: httpx2.Response) -> AsyncIterator[dict[str, str]]: """ 按 SSE 事件格式迭代响应流。 """ @@ -238,6 +238,9 @@ class _StdioMcpSession: """ def __init__(self, server: AgentMcpServerConfig) -> None: + """ + 绑定 MCP 服务配置并初始化当前传输会话的资源状态。 + """ self.server = server self.process: Optional[asyncio.subprocess.Process] = None self.stderr_task: Optional[asyncio.Task[None]] = None @@ -355,6 +358,9 @@ class _HttpMcpSession: """ def __init__(self, server: AgentMcpServerConfig) -> None: + """ + 绑定 MCP 服务配置并初始化当前传输会话的资源状态。 + """ self.server = server self.session_id: Optional[str] = None @@ -443,11 +449,14 @@ class _SseMcpSession: """ def __init__(self, server: AgentMcpServerConfig) -> None: + """ + 绑定 MCP 服务配置并初始化当前传输会话的资源状态。 + """ self.server = server self.response = None self.endpoint: Optional[str] = None self._stream_manager: Optional[AbstractAsyncContextManager[Optional[httpx2.Response]]] = None - self._event_iterator = None + self._event_iterator: Optional[AsyncIterator[dict[str, str]]] = None async def __aenter__(self) -> "_SseMcpSession": """ @@ -496,7 +505,10 @@ class _SseMcpSession: await self._post(_jsonrpc_message(method, params, request_id=request_id)) timeout = _normalize_timeout(self.server.timeout) while True: - event = await asyncio.wait_for(self._event_iterator.__anext__(), timeout=timeout) + iterator = self._event_iterator + if iterator is None: + raise RuntimeError("SSE MCP 事件流未初始化") + event = await asyncio.wait_for(iterator.__anext__(), timeout=timeout) payload = _load_sse_json_payload(event.get("event", ""), event.get("data", "")) if isinstance(payload, dict) and payload.get("id") == request_id: return _extract_jsonrpc_result(payload, request_id) @@ -507,13 +519,19 @@ class _SseMcpSession: """ timeout = _normalize_timeout(self.server.timeout) while True: - event = await asyncio.wait_for(self._event_iterator.__anext__(), timeout=timeout) + iterator = self._event_iterator + if iterator is None: + raise RuntimeError("SSE MCP 事件流未初始化") + event = await asyncio.wait_for(iterator.__anext__(), timeout=timeout) if event.get("event") != "endpoint": continue endpoint = str(event.get("data") or "").strip() if not endpoint: continue - return urljoin(self.server.url, endpoint) + server_url = self.server.url + if not server_url: + raise RuntimeError("SSE MCP 服务器缺少 URL") + return urljoin(server_url, endpoint) async def _post(self, payload: dict[str, object]) -> None: """ diff --git a/app/agent/middleware/code.py b/app/agent/middleware/code.py index 7206c9e5f..291951e30 100644 --- a/app/agent/middleware/code.py +++ b/app/agent/middleware/code.py @@ -6,7 +6,7 @@ import json import uuid from collections.abc import Awaitable, Callable from dataclasses import replace -from typing import Any +from typing import Any, cast from langchain.agents.middleware.types import AgentMiddleware, ToolCallRequest from langchain_core.messages import AIMessage, ToolMessage @@ -19,7 +19,7 @@ from app.agent.tools.impl.execute_code import ExecuteCodeTool from app.agent.tools.result import inspect_tool_result -class CodeExecutionMiddleware(AgentMiddleware): # type: ignore[misc] +class CodeExecutionMiddleware(AgentMiddleware): """ 外层绑定执行窗口;内层调用保留策略、Skill、日志和原始结果归档行为。 """ @@ -93,7 +93,7 @@ class CodeExecutionMiddleware(AgentMiddleware): # type: ignore[misc] return payload -class CodeCaptureMiddleware(AgentMiddleware): # type: ignore[misc] +class CodeCaptureMiddleware(AgentMiddleware): """ 筛选后、压缩前生成真实 helper 合同,新增说明必须计入最终请求预算。 """ @@ -124,7 +124,7 @@ class CodeCaptureMiddleware(AgentMiddleware): # type: ignore[misc] result = await handler(request) if scope is None or not any(type(tool) is ExecuteCodeTool for tool in request.tools): return result - selections = {call['id']: tools for message in result.result if isinstance(message, AIMessage) + selections = {cast(str, call['id']): tools for message in result.result if isinstance(message, AIMessage) for call in message.tool_calls if call['name'] == 'execute_code'} if selections: self.execution.selections[scope] = selections diff --git a/app/agent/middleware/config.py b/app/agent/middleware/config.py index eb0e15269..141ba0be1 100644 --- a/app/agent/middleware/config.py +++ b/app/agent/middleware/config.py @@ -7,6 +7,7 @@ from collections.abc import Awaitable, Callable from langchain.agents.middleware.types import ( AgentMiddleware, + AgentState, ContextT, ModelRequest, ModelResponse, @@ -18,7 +19,7 @@ from app.agent.runtime import agent_runtime_manager from app.runtime.settings import get_runtime_setting -class RuntimeConfigMiddleware(AgentMiddleware[dict, ContextT, ResponseT]): # noqa +class RuntimeConfigMiddleware(AgentMiddleware[AgentState, ContextT, ResponseT]): # noqa """ 在每次模型调用前动态加载运行时配置。 diff --git a/app/agent/middleware/guardrails.py b/app/agent/middleware/guardrails.py index 0996f3a5d..a9dcae225 100644 --- a/app/agent/middleware/guardrails.py +++ b/app/agent/middleware/guardrails.py @@ -5,12 +5,12 @@ import asyncio import json import re -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass, field, replace -from typing import Any +from typing import Any, cast from langchain.agents.middleware.types import AgentMiddleware, ModelResponse, ToolCallRequest -from langchain_core.messages import AIMessage, HumanMessage, ToolMessage +from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, ToolMessage from langgraph.config import get_config from langgraph.graph.message import add_messages from langgraph.types import Command @@ -89,7 +89,7 @@ class GuardRun: identity: object | None = None -class ToolGuardrailsMiddleware(AgentMiddleware): # type: ignore[misc] +class ToolGuardrailsMiddleware(AgentMiddleware): """ 交互式 Web/API 默认警告,消息网关及后台默认有界停止;新用户轮次重置。 """ @@ -184,7 +184,7 @@ class ToolGuardrailsMiddleware(AgentMiddleware): # type: ignore[misc] self.release(self._scope()) raise - async def after_model_update(self, state: dict[str, Any], runtime: Any) -> dict[str, Any] | None: + async def after_model_update(self, state: Mapping[str, Any], runtime: Any) -> dict[str, Any] | None: """ 登记请求;只有短尾部行动或 Codex 特定碎片才最多追加两次继续提示。 """ @@ -197,7 +197,7 @@ class ToolGuardrailsMiddleware(AgentMiddleware): # type: ignore[misc] self._run().message_updates.clear() message = messages[-1] for call in message.tool_calls: - self.pending[call['id']] = CallObservation(call['name'], dict(call.get('args') or {}), None, ExecutionOutcome.UNKNOWN) + self.pending[cast(str, call['id'])] = CallObservation(call['name'], dict(call.get('args') or {}), None, ExecutionOutcome.UNKNOWN) continuation = None if message.tool_calls or not self._run().has_tools else self._continue(messages, message) if continuation: if continuation.get('jump_to') != 'model': @@ -278,7 +278,7 @@ class ToolGuardrailsMiddleware(AgentMiddleware): # type: ignore[misc] return ToolMessage(content=toolguard_synthetic_result(decision), tool_call_id=request.tool_call['id'], name=request.tool_call['name'], status='error', additional_kwargs={EXECUTION_OUTCOME_KEY: 'failed', GUARDRAIL_KEY: decision.to_metadata()}) - self.pending[request.tool_call['id']] = CallObservation(name, arguments, None, ExecutionOutcome.UNKNOWN) + self.pending[cast(str, request.tool_call['id'])] = CallObservation(name, arguments, None, ExecutionOutcome.UNKNOWN) raw: dict[str, str] = {} previous = TOOL_RESULT_RECORDER.get() @@ -349,7 +349,7 @@ class ToolGuardrailsMiddleware(AgentMiddleware): # type: ignore[misc] return f"\nFull fresh result: read_tool_result(result_id={payload['result_id']!r}, offset=0)." return '' - async def prepare_model(self, state: dict[str, Any], runtime: Any) -> dict[str, Any] | None: + async def prepare_model(self, state: Mapping[str, Any], runtime: Any) -> dict[str, Any] | None: """ 批次结果按请求顺序判定短循环;触发停止时保留计划和未知副作用证据。 """ @@ -357,7 +357,7 @@ class ToolGuardrailsMiddleware(AgentMiddleware): # type: ignore[misc] messages = state.get('messages', []) active_ids = {message.tool_call_id for message in messages if isinstance(message, ToolMessage)} by_id = {message.tool_call_id: message for message in messages if isinstance(message, ToolMessage)} - updates = [] + updates: list[AnyMessage] = [] for message in messages: if isinstance(message, AIMessage): for call in message.tool_calls: diff --git a/app/agent/middleware/invocation.py b/app/agent/middleware/invocation.py index 10968672d..91881f932 100644 --- a/app/agent/middleware/invocation.py +++ b/app/agent/middleware/invocation.py @@ -7,11 +7,11 @@ import hashlib import json import uuid from collections.abc import Awaitable, Callable -from typing import Annotated, Any, TypedDict +from typing import Annotated, Any, cast -from langchain.agents.middleware.types import AgentMiddleware, PrivateStateAttr, ToolCallRequest +from langchain.agents.middleware.types import AgentMiddleware, AgentState, PrivateStateAttr, ToolCallRequest from langchain_core.messages import ToolMessage -from langchain_core.tools import StructuredTool, create_schema_from_function +from langchain_core.tools import BaseTool, StructuredTool, create_schema_from_function from pydantic import Field from app.agent.policy.api import resolve_api_operation @@ -33,7 +33,7 @@ from app.runtime.log import logger GET_TOOL_EXECUTION_NAME = "get_tool_execution" -class InvocationState(TypedDict): +class InvocationState(AgentState): """ 在同一轮模型工具循环内提供稳定去重范围,用户新请求使用新范围。 @@ -64,8 +64,7 @@ def _fingerprint(value: Any) -> str: return hashlib.sha256(serialized.encode("utf-8")).hexdigest() -# follow_imports=skip 下第三方中间件基类按 Any 处理,仅忽略 SDK 边界。 -class InvocationMiddleware(AgentMiddleware): # type: ignore[misc] +class InvocationMiddleware(AgentMiddleware[InvocationState, Any, Any]): """ 维护真实工具写入的持久回执;未认领、未知或已完成调用不会盲目重放。 """ @@ -245,6 +244,8 @@ class InvocationMiddleware(AgentMiddleware): # type: ignore[misc] """ if not self._is_write(request): return await handler(request) + # _is_write 已确认工具实例存在,override 只替换规范化参数。 + tool = cast(BaseTool, request.tool) try: canonical_arguments = _canonical_arguments(request) arguments_digest = _fingerprint(canonical_arguments) @@ -252,16 +253,16 @@ class InvocationMiddleware(AgentMiddleware): # type: ignore[misc] request = request.override(tool_call={**request.tool_call, "args": canonical_arguments}) previous = await run_agent_blocking( "db", self.repository.find_unresolved, str(self.context.user_id or ""), self.context.session_id, - tool_name=request.tool.name, arguments_digest=arguments_digest, + tool_name=tool.name, arguments_digest=arguments_digest, ) if previous is not None: return await self._existing(request, previous) - invocation_id = _fingerprint([request.state.get("invocation_turn_id"), request.tool.name, arguments_digest]) + invocation_id = _fingerprint([request.state.get("invocation_turn_id"), tool.name, arguments_digest]) else: invocation_id = str(request.tool_call.get("id") or uuid.uuid4().hex) claim = await run_agent_blocking( "db", self.repository.claim, self._identity(invocation_id), - tool_name=request.tool.name, arguments_digest=arguments_digest, + tool_name=tool.name, arguments_digest=arguments_digest, ) except InvocationConflictError: return self._message(request, "failed", "同一调用 ID 的工具或参数发生变化,未执行。") @@ -269,7 +270,7 @@ class InvocationMiddleware(AgentMiddleware): # type: ignore[misc] detail = summarize_error(error, max_chars=240) logger.info(f"工具参数未通过 API 输入合同校验: {detail}") operation_id = str(request.tool_call.get("args", {}).get("operation_id", "")) - input_contract = request.tool.get_operation_input_contract(operation_id) + input_contract = cast(MoviePilotApiTool, tool).get_operation_input_contract(operation_id) return self._message( request, "failed", diff --git a/app/agent/middleware/jobs.py b/app/agent/middleware/jobs.py index 56945fb64..6abc020b5 100644 --- a/app/agent/middleware/jobs.py +++ b/app/agent/middleware/jobs.py @@ -4,7 +4,7 @@ import re from collections.abc import Awaitable, Callable -from typing import Annotated, NotRequired, TypedDict +from typing import Annotated, Any, NotRequired, TypedDict, cast import yaml # noqa from anyio import Path as AsyncPath @@ -279,7 +279,7 @@ class JobsMiddleware(AgentMiddleware[JobsState, ContextT, ResponseT]): # noqa """ 将任务文档注入模型请求的系统消息中。 """ - jobs_metadata = request.state.get("jobs_metadata", []) # noqa + jobs_metadata = cast(JobsState, request.state).get("jobs_metadata", []) # noqa # 仅注入真正活跃的任务,避免把已完成任务继续塞进心跳上下文。 active_jobs = filter_active_jobs(jobs_metadata) @@ -297,12 +297,12 @@ class JobsMiddleware(AgentMiddleware[JobsState, ContextT, ResponseT]): # noqa return request.override(system_message=new_system_message) async def abefore_agent( # noqa - self, state: JobsState, runtime: Runtime, config: RunnableConfig - ) -> JobsStateUpdate | None: + self, state: JobsState, runtime: Runtime[ContextT], config: RunnableConfig | None = None + ) -> dict[str, Any] | None: """ 在 Agent 执行前异步加载任务元数据。 """ - return JobsStateUpdate(jobs_metadata=await load_jobs_metadata(self.sources)) + return {"jobs_metadata": await load_jobs_metadata(self.sources)} async def awrap_model_call( self, diff --git a/app/agent/middleware/learning.py b/app/agent/middleware/learning.py index a02b77790..3fa7f75d0 100644 --- a/app/agent/middleware/learning.py +++ b/app/agent/middleware/learning.py @@ -13,7 +13,7 @@ from app.agent.tools.base import run_agent_blocking from app.runtime.log import logger -class LearningMiddleware(AgentMiddleware): # type: ignore[misc] +class LearningMiddleware(AgentMiddleware): """ 放在最终请求压缩、视觉转换之后,复盘才能复用实际发出的缓存前缀。 """ @@ -49,7 +49,7 @@ class LearningMiddleware(AgentMiddleware): # type: ignore[misc] return await handler(request.override(system_message=append_to_system_message(request.system_message, self.prompt))) -class LearningCaptureMiddleware(AgentMiddleware): # type: ignore[misc] +class LearningCaptureMiddleware(AgentMiddleware): """ 仅在最内层观察最终请求,避免保存压缩之前或缺少动态提示词的快照。 """ diff --git a/app/agent/middleware/memory.py b/app/agent/middleware/memory.py index 58479d46c..ba6d1cd88 100644 --- a/app/agent/middleware/memory.py +++ b/app/agent/middleware/memory.py @@ -7,7 +7,7 @@ import re from collections.abc import Awaitable, Callable from functools import partial from pathlib import Path -from typing import Annotated, Any, Literal, NotRequired, Optional, TypedDict +from typing import Annotated, Any, Literal, NotRequired, Optional, TypedDict, cast import anyio from anyio import Path as AsyncPath @@ -79,7 +79,7 @@ class MemoryStateUpdate(TypedDict): memory_empty: bool -class SearchMemoryInput(BaseModel): # type: ignore[misc] +class SearchMemoryInput(BaseModel): """ 只检索稳定偏好与主题知识,不再提供活动摘要分类。 """ @@ -451,9 +451,9 @@ class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # no async def abefore_agent( # noqa self, state: MemoryState, - runtime: Runtime, # noqa - config: RunnableConfig, - ) -> MemoryStateUpdate | None: + runtime: Runtime[ContextT], # noqa + config: RunnableConfig | None = None, + ) -> dict[str, Any] | None: """ 在代理执行前仅加载公共与当前用户的主记忆。 """ @@ -466,14 +466,14 @@ class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # no logger.info("Loaded primary memory from: %s", self.default_memory_file) if is_empty: logger.info("Primary memory is empty; onboarding prompt will be activated.") - return MemoryStateUpdate(memory_contents=contents, memory_empty=is_empty) + return {"memory_contents": contents, "memory_empty": is_empty} def modify_request(self, request: ModelRequest[ContextT]) -> ModelRequest[ContextT]: """ 把主记忆和按需检索规则注入系统消息。 """ - contents = request.state.get("memory_contents", {}) # noqa - memory_empty = request.state.get("memory_empty", False) # noqa + contents = cast(MemoryState, request.state).get("memory_contents", {}) # noqa + memory_empty = cast(MemoryState, request.state).get("memory_empty", False) # noqa memory_prompt = self._format_agent_memory(contents, memory_empty=memory_empty) return request.override( system_message=append_to_system_message(request.system_message, memory_prompt) diff --git a/app/agent/middleware/output.py b/app/agent/middleware/output.py index faf15f12e..498df37a6 100644 --- a/app/agent/middleware/output.py +++ b/app/agent/middleware/output.py @@ -50,8 +50,7 @@ class _StoredResult: requires_admin: bool -# follow_imports=skip 不分析第三方基类,仅在框架继承边界忽略 misc。 -class ToolOutputMiddleware(AgentMiddleware): # type: ignore[misc] +class ToolOutputMiddleware(AgentMiddleware): """ 包装当前图的工具结果;读取按会话与当前管理员身份双重隔离。 """ diff --git a/app/agent/middleware/patching.py b/app/agent/middleware/patching.py index 304b1ea3f..e382dc16f 100644 --- a/app/agent/middleware/patching.py +++ b/app/agent/middleware/patching.py @@ -2,10 +2,11 @@ 修复消息历史中缺少结果的工具调用,保留执行结果未知的事实。 """ +from collections.abc import Sequence from typing import Any, Optional from langchain.agents.middleware import AgentMiddleware, AgentState -from langchain_core.messages import AIMessage, BaseMessage, ToolMessage +from langchain_core.messages import AIMessage, BaseMessage, ToolCall, ToolMessage from langgraph.runtime import Runtime from langgraph.types import Overwrite @@ -16,7 +17,7 @@ class PatchToolCallsMiddleware(AgentMiddleware): """ @staticmethod - def _build_cancelled_tool_message(tool_call: dict[str, Any]) -> ToolMessage: + def _build_cancelled_tool_message(tool_call: ToolCall) -> ToolMessage: """ 缺少回执只表示结果未知,不能据此重放可能已生效的写操作。 """ @@ -36,19 +37,19 @@ class PatchToolCallsMiddleware(AgentMiddleware): ) @classmethod - def _normalize_messages(cls, messages: list[BaseMessage]) -> list[BaseMessage]: + def _normalize_messages(cls, messages: Sequence[BaseMessage]) -> list[BaseMessage]: """ 规范化工具调用消息顺序,满足 OpenAI tool_calls 协议要求。 """ if not messages or len(messages) == 0: - return messages + return list(messages) tool_messages = { msg.tool_call_id: msg for msg in messages if isinstance(msg, ToolMessage) and msg.tool_call_id } - patched_messages = [] + patched_messages: list[BaseMessage] = [] for msg in messages: if isinstance(msg, ToolMessage): continue @@ -59,7 +60,7 @@ class PatchToolCallsMiddleware(AgentMiddleware): for tool_call in msg.tool_calls: tool_call_id = tool_call.get("id") - corresponding_tool_msg = tool_messages.get(tool_call_id) + corresponding_tool_msg = tool_messages.get(tool_call_id or "") if corresponding_tool_msg: patched_messages.append(corresponding_tool_msg) else: diff --git a/app/agent/middleware/plan.py b/app/agent/middleware/plan.py index 98dfbc713..878c585c0 100644 --- a/app/agent/middleware/plan.py +++ b/app/agent/middleware/plan.py @@ -3,11 +3,12 @@ """ import json -from collections.abc import Awaitable, Callable -from typing import Annotated, Any, Literal, NotRequired, Optional, TypedDict, cast +from collections.abc import Awaitable, Callable, Sequence +from typing import Annotated, Any, Literal, NotRequired, Optional from langchain.agents.middleware.types import ( AgentMiddleware, + AgentState, ModelRequest, ModelResponse, OmitFromInput, @@ -52,8 +53,7 @@ PLAN_TOOL_DESCRIPTION = ( ) -# follow_imports=skip 不分析第三方基类与装饰器;仅在这些 SDK 边界忽略 misc。 -class PlanStep(BaseModel): # type: ignore[misc] +class PlanStep(BaseModel): """ 保存一个执行步骤及模型声明的检查结果或阻塞原因。 """ @@ -64,7 +64,7 @@ class PlanStep(BaseModel): # type: ignore[misc] status: Literal["pending", "in_progress", "completed", "blocked"] = Field(description="步骤状态:pending、in_progress、completed 或 blocked") evidence: str = Field(default="", max_length=600, description="已观察的结果或具体阻塞原因,不得包含凭据;完成或阻塞状态必须填写") - @model_validator(mode="after") # type: ignore[misc] + @model_validator(mode="after") def require_result_context(self) -> "PlanStep": """ 避免把没有检查依据的完成声明或无原因的阻塞写入计划。 @@ -74,7 +74,7 @@ class PlanStep(BaseModel): # type: ignore[misc] return self -class TaskPlan(BaseModel): # type: ignore[misc] +class TaskPlan(BaseModel): """ 限定当前任务计划的大小,避免它本身耗尽不可压缩的系统上下文。 """ @@ -86,7 +86,7 @@ class TaskPlan(BaseModel): # type: ignore[misc] steps: list[PlanStep] = Field(min_length=1, max_length=MAX_PLAN_STEPS, description="完整步骤列表,至少 1 项,最多 12 项;步骤名称不能重复") explanation: str = Field(default="", max_length=800, description="计划变化原因或继续任务需要的重要上下文;没有补充时为空") - @model_validator(mode="after") # type: ignore[misc] + @model_validator(mode="after") def require_distinct_steps(self) -> "TaskPlan": """ 同名步骤无法稳定区分进度,因此要求单份计划内步骤名称唯一。 @@ -97,7 +97,7 @@ class TaskPlan(BaseModel): # type: ignore[misc] return self -class PlanState(TypedDict): +class PlanState(AgentState): """ 计划独立于可压缩消息保存,只允许工具或内部历史恢复写入。 @@ -106,7 +106,6 @@ class PlanState(TypedDict): """ # create_agent 将中间件 schema 与标准 AgentState 合并,保留框架消息 reducer。 - messages: list[BaseMessage] task_plan: Annotated[NotRequired[dict[str, Any]], OmitFromInput] @@ -116,13 +115,13 @@ def _validated_plan(value: Any) -> Optional[dict[str, Any]]: """ try: plan = TaskPlan.model_validate(value) - return cast(dict[str, Any], TaskPlan.model_validate(sanitize_for_host(plan.model_dump())).model_dump()) + return TaskPlan.model_validate(sanitize_for_host(plan.model_dump())).model_dump() except (ValidationError, TypeError, ValueError): return None def attach_plan_snapshot( - messages: list[BaseMessage], plan: Optional[dict[str, Any]], + messages: Sequence[BaseMessage], plan: Optional[dict[str, Any]], ) -> list[BaseMessage]: """ 把脱敏计划附在末条宿主消息上,让已有消息持久化同时保存恢复点。 @@ -141,7 +140,7 @@ def attach_plan_snapshot( def _update_plan( runtime: ToolRuntime, objective: str, steps: list[PlanStep], explanation: str = "", -) -> Command: +) -> Command[Any]: """ 一次原子替换计划及工具回执,供后续模型调用和中断恢复使用。 """ @@ -158,14 +157,14 @@ def _update_plan( async def _aupdate_plan( runtime: ToolRuntime, objective: str, steps: list[PlanStep], explanation: str = "", -) -> Command: +) -> Command[Any]: """ 异步工具入口沿用相同的无 I/O 状态更新逻辑。 """ return _update_plan(runtime, objective, steps, explanation) -class PlanMiddleware(AgentMiddleware): # type: ignore[misc] +class PlanMiddleware(AgentMiddleware[PlanState, Any, Any]): """ 注入任务计划工具,并在压缩、后续用户消息和图重建后恢复工作上下文。 """ diff --git a/app/agent/middleware/policy.py b/app/agent/middleware/policy.py index 171156853..8455b24e2 100644 --- a/app/agent/middleware/policy.py +++ b/app/agent/middleware/policy.py @@ -7,8 +7,8 @@ import json from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING, Any -from langchain.agents.middleware import AgentMiddleware, ToolCallRequest, hook_config -from langchain_core.messages import AIMessage, ToolMessage +from langchain.agents.middleware import AgentMiddleware, AgentState, ToolCallRequest, hook_config +from langchain_core.messages import AIMessage, AnyMessage, ToolMessage from app.agent.policy.api import resolve_api_operation from app.agent.policy.contracts import ( @@ -45,8 +45,7 @@ SUBAGENT_READ_ACTIONS = { } -# follow_imports=skip 下只在第三方中间件基类和装饰器边界忽略 misc。 -class AgentPolicyMiddleware(AgentMiddleware): # type: ignore[misc] +class AgentPolicyMiddleware(AgentMiddleware): """ 观测进入本地 ToolNode 的 client-side 工具调用和结果。 @@ -73,8 +72,8 @@ class AgentPolicyMiddleware(AgentMiddleware): # type: ignore[misc] self.catalog = catalog self._tools = {tool.name: tool for tool in (tools or []) if getattr(tool, "name", None)} - @hook_config(can_jump_to=["end", "model"]) # type: ignore[misc] - async def aafter_model(self, state: dict[str, Any], runtime: Any) -> Any: + @hook_config(can_jump_to=["end", "model"]) + async def aafter_model(self, state: AgentState, runtime: Any) -> Any: """ 复用原有模型后置节点合并检测更新,不消耗额外图递归步数。 """ @@ -83,7 +82,7 @@ class AgentPolicyMiddleware(AgentMiddleware): # type: ignore[misc] return paused return await self.guardrails.after_model_update(state, runtime) if self.guardrails else None - async def _sensitive_pause(self, state: dict[str, Any]) -> Any: + async def _sensitive_pause(self, state: AgentState) -> Any: """ 敏感设置暂停优先于自动续行,绝不因纠偏放行未确认的调用。 """ @@ -121,7 +120,7 @@ class AgentPolicyMiddleware(AgentMiddleware): # type: ignore[misc] sensitive_call.get("args") or {}, ) - paused_messages = [ + paused_messages: list[AnyMessage] = [ ToolMessage( content=("本轮工具调用已暂停,未执行任何操作;请等待用户确认或取消敏感设置读取。"), tool_call_id=str(tool_call.get("id") or ""), diff --git a/app/agent/middleware/recall.py b/app/agent/middleware/recall.py index 268ae8e31..4949c3642 100644 --- a/app/agent/middleware/recall.py +++ b/app/agent/middleware/recall.py @@ -5,12 +5,12 @@ import json import re import time -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Sequence from dataclasses import replace from datetime import datetime, timezone from typing import Any, Literal -from langchain.agents.middleware.types import AgentMiddleware, ToolCallRequest +from langchain.agents.middleware.types import AgentMiddleware, AgentState, ToolCallRequest from langchain_core.messages import BaseMessage, ToolMessage from langchain_core.tools import StructuredTool from langgraph.types import Command @@ -50,7 +50,7 @@ def _time_bound(value: str) -> float | None: return parsed.replace(tzinfo=parsed.tzinfo or timezone.utc).timestamp() -class SearchHistoryInput(BaseModel): # type: ignore[misc] +class SearchHistoryInput(BaseModel): """ 检索参数只描述查询,不暴露可跨用户访问的 profile 或数据库路径。 """ @@ -67,7 +67,7 @@ class SearchHistoryInput(BaseModel): # type: ignore[misc] before: str = Field(default='', max_length=40, description="会话开始时间上界,不包含该时刻") exclude_session_ids: list[str] = Field(default_factory=list, max_length=20, description="已经检查过的会话标识,同时排除其关联会话链") - @model_validator(mode='after') # type: ignore[misc] + @model_validator(mode='after') def validate_modes(self) -> 'SearchHistoryInput': """ 锚点必须属于显式会话;角色与时间条件在查询前验证。 @@ -85,7 +85,7 @@ class SearchHistoryInput(BaseModel): # type: ignore[misc] return self -class RecallMiddleware(AgentMiddleware): # type: ignore[misc] +class RecallMiddleware(AgentMiddleware): """ 身份绑定到宿主会话,工具只能查询;原始证据和可执行上下文独立保存。 """ @@ -103,7 +103,7 @@ class RecallMiddleware(AgentMiddleware): # type: ignore[misc] object.__setattr__(tool, '_agent_tool_source', 'middleware:recall') self.tools = [tool] - async def _archive(self, messages: list[BaseMessage], raw: dict[str, str] | None = None, + async def _archive(self, messages: Sequence[BaseMessage], raw: dict[str, str] | None = None, call: dict[str, Any] | None = None) -> None: """ 完整脱敏在 worker 内执行;归档失败不能把已成功的工具副作用变成失败重放。 @@ -131,7 +131,7 @@ class RecallMiddleware(AgentMiddleware): # type: ignore[misc] except Exception as error: logger.warning('历史消息归档未完成: %s', type(error).__name__) - async def abefore_model(self, state: dict[str, Any], runtime: Any) -> None: + async def abefore_model(self, state: AgentState, runtime: Any) -> None: """ 在最终请求压缩前保存原始消息。 """ @@ -140,7 +140,7 @@ class RecallMiddleware(AgentMiddleware): # type: ignore[misc] self._active_message_ids = {message.id for message in messages if message.id} await self._archive(messages) - async def aafter_model(self, state: dict[str, Any], runtime: Any) -> None: + async def aafter_model(self, state: AgentState, runtime: Any) -> None: """ 基于已提交图状态标记实际移出的原文,同时保存最终回复。 """ diff --git a/app/agent/middleware/selection.py b/app/agent/middleware/selection.py index 46a2a2b63..66e0c152d 100644 --- a/app/agent/middleware/selection.py +++ b/app/agent/middleware/selection.py @@ -6,7 +6,7 @@ import json import re from collections.abc import Awaitable, Callable from dataclasses import dataclass, replace -from typing import Annotated, Any, NotRequired +from typing import Annotated, Any, NotRequired, cast from langchain.agents.middleware.tool_selection import ( DEFAULT_SYSTEM_PROMPT, @@ -14,7 +14,6 @@ from langchain.agents.middleware.tool_selection import ( ) from langchain.agents.middleware.types import ( AgentState, - ContextT, ModelRequest, ModelResponse, PrivateStateAttr, # noqa @@ -142,7 +141,7 @@ class _ToolSelectionAttempt: :param detail: 选择原因或回退说明;无需补充时为空字符串 """ - request: ModelRequest + request: ModelRequest[Any] selected_tool_names: list[str] status: str detail: str = "" @@ -273,7 +272,7 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware): ge=1, le=TOOL_DISCOVERY_MAX_RESULTS, description="Maximum number of matching tools to enable for subsequent model calls.", )] = 5, - ) -> Command: + ) -> Command[Any]: """ 返回有界工具目录并以图状态增量启用结果,不修改共享中间件实例。 """ @@ -332,7 +331,7 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware): "" ) - def _discovery_budget(self, request: ModelRequest, statuses: dict[str, str]) -> tuple[int, int]: + def _discovery_budget(self, request: ModelRequest[Any], statuses: dict[str, str]) -> tuple[int, int]: """ 复用最终请求估算器,为额外工具预留上下文余量并限制结构化参数总成本。 """ @@ -348,7 +347,7 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware): context_budget = max(0, int(context_window * TOOL_DISCOVERY_INPUT_FRACTION) - budget["estimated_input_tokens"]) return schema_budget, context_budget - def _budget_discovery_tools(self, request: ModelRequest, candidates: list[BaseTool], statuses: dict[str, str]) -> list[str]: + def _budget_discovery_tools(self, request: ModelRequest[Any], candidates: list[BaseTool], statuses: dict[str, str]) -> list[str]: """ 按最近发现优先接纳可容纳的工具,估算失败时只禁用额外发现能力。 """ @@ -378,12 +377,12 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware): context_budget -= cost return enabled - def _bind_discovery_tools(self, request: ModelRequest, selected_tool_names: list[str]) -> ModelRequest: + def _bind_discovery_tools(self, request: ModelRequest[Any], selected_tool_names: list[str]) -> ModelRequest[Any]: """ 从当前请求精确工具实例中绑定预算内发现项,并保留初选、强制和供应商工具。 """ baseline_names = list(dict.fromkeys([*selected_tool_names, *self.always_include])) - discovered_names = list(reversed(request.state.get("discovered_tool_names", [])[-TOOL_DISCOVERY_WINDOW_SIZE:])) + discovered_names = list(reversed(cast(ToolSelectionState, request.state).get("discovered_tool_names", [])[-TOOL_DISCOVERY_WINDOW_SIZE:])) baseline = self._apply_selected_tools(request, baseline_names) if not discovered_names: return baseline @@ -459,7 +458,7 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware): def _prepare_selection_request( self, - request: ModelRequest[ContextT], + request: ModelRequest[Any], ) -> Any | None: """ 准备带最近对话上下文的工具筛选请求。 @@ -631,8 +630,10 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware): response: dict[str, Any], available_tools: list[BaseTool], valid_tool_names: list[str], - request: ModelRequest[ContextT], - ) -> ModelRequest[ContextT]: + request: ModelRequest[Any], + *, + apply_max_tools: bool = True, + ) -> ModelRequest[Any]: """ 处理工具筛选响应,并在正常空结果时禁用可筛选工具。 """ @@ -659,6 +660,7 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware): available_tools, valid_tool_names, request, + apply_max_tools=apply_max_tools, ) return modified_request @@ -792,14 +794,14 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware): return self._normalize_selection_response(response) @staticmethod - def _extract_selected_tool_names(request: ModelRequest) -> list[str]: + def _extract_selected_tool_names(request: ModelRequest[Any]) -> list[str]: """ 从已筛选后的请求中提取最终工具名,保留原有顺序。 """ return [tool.name for tool in request.tools if not isinstance(tool, dict)] @staticmethod - def _count_request_tools(request: ModelRequest) -> int: + def _count_request_tools(request: ModelRequest[Any]) -> int: """ 统计当前请求中的 LangChain 工具数量,不包含 provider 原生工具字典。 """ @@ -829,9 +831,9 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware): @staticmethod def _apply_selected_tools( - request: ModelRequest[ContextT], + request: ModelRequest[Any], selected_tool_names: list[str], - ) -> ModelRequest[ContextT]: + ) -> ModelRequest[Any]: """ 将已筛选出的工具集应用到当前模型请求。 @@ -850,8 +852,8 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware): return request.override(tools=[*selected_tools, *provider_tools]) async def _aselect_request_once( - self, request: ModelRequest[ContextT] - ) -> ModelRequest[ContextT]: + self, request: ModelRequest[Any] + ) -> ModelRequest[Any]: """ 执行一次真实工具筛选,并返回筛选后的请求对象。 @@ -861,7 +863,7 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware): return (await self._aselect_request_once_with_status(request)).request async def _aselect_request_once_with_status( - self, request: ModelRequest[ContextT] + self, request: ModelRequest[Any] ) -> _ToolSelectionAttempt: """ 执行一次真实工具筛选,并携带最终状态供调用方统一记录日志。 @@ -896,13 +898,12 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware): detail=str(err), ) - # 框架允许此钩子返回局部状态更新,静态基类签名尚未表达这种返回类型。 async def abefore_agent( # noqa self, - state: ToolSelectionState, - runtime: Runtime, # noqa - config: RunnableConfig, - ) -> ToolSelectionStateUpdate | None: # ty: ignore[invalid-method-override] + state: AgentState, + runtime: Runtime[Any], # noqa + config: RunnableConfig | None = None, + ) -> dict[str, Any] | None: """ 在本轮 Agent 执行开始前完成一次真实工具筛选。 @@ -911,21 +912,8 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware): """ if not self.selection_tools or self.model is None: detail = "没有可筛选工具" if not self.selection_tools else "未配置筛选模型" - self._log_selection_attempt( - _ToolSelectionAttempt( - request=ModelRequest( - model=self.model, - tools=list(self.selection_tools), - messages=state["messages"], - state=state, - runtime=runtime, - ), - selected_tool_names=[], - status="skipped", - detail=detail, - ) - ) - return self._selection_state_update(selected_tool_names=None) + logger.info(f"工具筛选跳过: {detail}。") + return dict(self._selection_state_update(selected_tool_names=None)) selection_request = ModelRequest( model=self.model, @@ -937,19 +925,19 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware): attempt = await self._aselect_request_once_with_status(selection_request) self._log_selection_attempt(attempt) selected_tool_names = attempt.selected_tool_names - return self._selection_state_update(selected_tool_names=selected_tool_names) + return dict(self._selection_state_update(selected_tool_names=selected_tool_names)) async def awrap_model_call( self, - request: ModelRequest[ContextT], + request: ModelRequest[Any], handler: Callable[ - [ModelRequest[ContextT]], Awaitable[ModelResponse[ResponseT]] + [ModelRequest[Any]], Awaitable[ModelResponse[ResponseT]] ], ) -> ModelResponse[ResponseT]: """ 复用首轮筛选并合入本轮按需发现结果,无需追加工具筛选模型调用。 """ - selected_tool_names = request.state.get("selected_tool_names") # noqa + selected_tool_names = cast(ToolSelectionState, request.state).get("selected_tool_names") # noqa # 正常路径下,`abefore_agent()` 已经提前写入状态;这里只保留一层兜底, # 兼容直接单测或未来某些绕过 before_agent 的调用场景。 @@ -962,7 +950,7 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware): self._log_selection_attempt(attempt) request = attempt.request selected_tool_names = attempt.selected_tool_names - request.state["selected_tool_names"] = selected_tool_names # noqa + cast(ToolSelectionState, request.state)["selected_tool_names"] = selected_tool_names # noqa if selected_tool_names is not None: request = self._bind_discovery_tools(request, selected_tool_names) diff --git a/app/agent/middleware/skills.py b/app/agent/middleware/skills.py index a8e1be4dd..23e025f9c 100644 --- a/app/agent/middleware/skills.py +++ b/app/agent/middleware/skills.py @@ -7,7 +7,7 @@ import re import shutil from collections.abc import Awaitable, Callable from pathlib import Path -from typing import Annotated, Any, List, NotRequired, Optional, TypedDict +from typing import Annotated, Any, List, NotRequired, Optional, TypedDict, cast import yaml # noqa from anyio import Path as AsyncPath @@ -597,7 +597,7 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no """ 将技能文档注入模型请求的系统消息中。 """ - skills_metadata = request.state.get("skills_metadata", []) # noqa + skills_metadata = cast(SkillsState, request.state).get("skills_metadata", []) # noqa skills_list = self._format_skills_list(skills_metadata) skills_section = self.system_prompt_template.format( @@ -608,10 +608,9 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no return request.override(system_message=new_system_message) - # 框架允许此钩子返回局部状态更新,静态基类签名尚未表达这种返回类型。 async def abefore_agent( # noqa - self, state: SkillsState, runtime: Runtime, config: RunnableConfig - ) -> SkillsStateUpdate | None: # ty: ignore[invalid-method-override] + self, state: SkillsState, runtime: Runtime[ContextT], config: RunnableConfig | None = None + ) -> dict[str, Any] | None: """ 在 Agent 执行前异步加载技能元数据。 @@ -634,7 +633,7 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no skills = list(all_skills.values()) self._refresh_skill_tool_description(skills) - return SkillsStateUpdate(skills_metadata=skills) + return {"skills_metadata": skills} async def awrap_model_call( self, diff --git a/app/agent/middleware/steering.py b/app/agent/middleware/steering.py index 96be24dc1..9ba93f276 100644 --- a/app/agent/middleware/steering.py +++ b/app/agent/middleware/steering.py @@ -32,7 +32,7 @@ def _to_human_message(message: SteeringMessage) -> HumanMessage: "images": [{"index": index + 1, "type": "image"} for index, _ in enumerate(message.images)], "files": list(message.files), } - content: list[dict[str, Any]] = [ + content: list[str | dict[str, Any]] = [ { "type": "text", "text": json.dumps(payload, ensure_ascii=False, indent=2), @@ -45,7 +45,7 @@ def _to_human_message(message: SteeringMessage) -> HumanMessage: ) -class SteeringMiddleware(AgentMiddleware): # type: ignore[misc] +class SteeringMiddleware(AgentMiddleware): """ 在模型请求边界消费会话 inbox,避免并发启动第二条 Agent 图。 """ diff --git a/app/agent/middleware/subagents.py b/app/agent/middleware/subagents.py index e2c4d28e0..4e5af470c 100644 --- a/app/agent/middleware/subagents.py +++ b/app/agent/middleware/subagents.py @@ -9,7 +9,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from datetime import datetime from functools import lru_cache -from typing import Any, Literal, Optional +from typing import Any, Literal, Optional, cast from langchain.agents import create_agent from langchain.agents.middleware.types import ( @@ -250,7 +250,7 @@ class _SubAgentRuntimeTask: task_id: str description: str subagent_type: str - task: asyncio.Task + task: asyncio.Task[Any] created_at: datetime started_at: Optional[datetime] = None finished_at: Optional[datetime] = None @@ -351,8 +351,8 @@ def _cached_builtin_subagent_profiles( raise RuntimeError("运行时未加载任何子代理定义") -builtin_subagent_names.cache_clear = _cached_builtin_subagent_names.cache_clear -_builtin_subagent_profiles.cache_clear = _cached_builtin_subagent_profiles.cache_clear +cast(Any, builtin_subagent_names).cache_clear = cast(Any, _cached_builtin_subagent_names).cache_clear +cast(Any, _builtin_subagent_profiles).cache_clear = cast(Any, _cached_builtin_subagent_profiles).cache_clear def _profile_from_runtime_definition( @@ -505,7 +505,7 @@ class _SubAgentAgentProvider: self._server_tools = server_tools or [] self._policy_context = policy_context or _default_subagent_policy_context(tools) self._catalog = catalog - self._agents = {} + self._agents: dict[str, Any] = {} self._guardrails: dict[str, ToolGuardrailsMiddleware] = {} self._default_agent_name = "general-purpose" @@ -532,26 +532,27 @@ class _SubAgentAgentProvider: f"创建子代理图: subagent_type={profile.name}, tools={len(subagent_tools)}" ) guardrails = ToolGuardrailsMiddleware(self._policy_context) + middlewares: list[AgentMiddleware[Any, Any, Any]] = [ + AgentPolicyMiddleware( + context=self._policy_context, + catalog=subagent_catalog, + guardrails=guardrails, + ), + guardrails, + FinalRequestCompactionMiddleware( + summarizer=ContextPreservingSummarizationMiddleware( + model=self._model, + keep=("messages", 20), + ), + ), + VisionMiddleware(), + ] agent = create_agent( model=self._model, tools=[*subagent_tools, *self._server_tools], system_prompt=profile.prompt, name=profile.name, - middleware=[ - AgentPolicyMiddleware( - context=self._policy_context, - catalog=subagent_catalog, - guardrails=guardrails, - ), - guardrails, - FinalRequestCompactionMiddleware( - summarizer=ContextPreservingSummarizationMiddleware( - model=self._model, - keep=("messages", 20), - ), - ), - VisionMiddleware(), - ], + middleware=middlewares, ) self._agents[profile.name] = agent self._guardrails[profile.name] = guardrails @@ -765,7 +766,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware): self._semaphore = asyncio.Semaphore(SUBAGENT_MAX_CONCURRENT_TASKS) self._tasks: dict[str, _SubAgentRuntimeTask] = {} self._accepting_tasks = True - self._close_cancel_requested: set[asyncio.Task] = set() + self._close_cancel_requested: set[asyncio.Task[Any]] = set() self.tools = [ StructuredTool.from_function( coroutine=self._control_task, @@ -967,7 +968,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware): ) raise - def _mark_task_finished(self, task_id: str, task: asyncio.Task) -> None: + def _mark_task_finished(self, task_id: str, task: asyncio.Task[Any]) -> None: """ 记录任务完成时间并取出异常避免未读取告警。 """ @@ -995,7 +996,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware): task_id=task_id, description=spec.description.strip(), subagent_type="general-purpose", - task=None, + task=cast(asyncio.Task[Any], None), created_at=datetime.now(), terminal_sessions=spec.terminal_sessions, ) @@ -1005,8 +1006,8 @@ class SubAgentTaskControlMiddleware(AgentMiddleware): ) record.task = task task.add_done_callback( - lambda finished_task, finished_task_id=task_id: self._mark_task_finished( - finished_task_id, + lambda finished_task: self._mark_task_finished( + task_id, finished_task, ) ) @@ -1064,7 +1065,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware): async def _cancel_records( records: list[_SubAgentRuntimeTask], *, - cancel_requested: Optional[set[asyncio.Task]] = None, + cancel_requested: Optional[set[asyncio.Task[Any]]] = None, ) -> list[_SubAgentRuntimeTask]: """ 取消一组任务,并返回等待上限内仍未收敛的记录。 @@ -1263,7 +1264,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware): task_id=task_id, description=spec.description.strip(), subagent_type="general-purpose", - task=None, + task=cast(asyncio.Task[Any], None), created_at=datetime.now(), terminal_sessions=spec.terminal_sessions, ) @@ -1271,15 +1272,15 @@ class SubAgentTaskControlMiddleware(AgentMiddleware): def _track_pipeline_task( self, record: _SubAgentRuntimeTask, - task: asyncio.Task, + task: asyncio.Task[Any], ) -> None: """ 登记管道步骤任务,复用统一的状态和异常收口逻辑。 """ record.task = task task.add_done_callback( - lambda finished_task, finished_task_id=record.task_id: self._mark_task_finished( - finished_task_id, + lambda finished_task: self._mark_task_finished( + record.task_id, finished_task, ) ) diff --git a/app/agent/middleware/summarization.py b/app/agent/middleware/summarization.py index 5fb8768b2..7163068b7 100644 --- a/app/agent/middleware/summarization.py +++ b/app/agent/middleware/summarization.py @@ -143,26 +143,26 @@ class ContextPreservingSummarizationMiddleware(SummarizationMiddleware): None, ) if first_human_index is None: - return trim_messages( + return cast(list[AnyMessage], trim_messages( messages, max_tokens=self.trim_tokens_to_summarize, token_counter=self.token_counter, strategy="last", allow_partial=True, include_system=True, - ) + )) first_human = messages[first_human_index] first_tokens = self.token_counter([first_human]) if first_tokens >= self.trim_tokens_to_summarize: - return trim_messages( + return cast(list[AnyMessage], trim_messages( messages, max_tokens=self.trim_tokens_to_summarize, token_counter=self.token_counter, strategy="last", allow_partial=True, include_system=True, - ) + )) tail = trim_messages( messages[first_human_index + 1 :], max_tokens=self.trim_tokens_to_summarize - first_tokens, @@ -171,7 +171,7 @@ class ContextPreservingSummarizationMiddleware(SummarizationMiddleware): allow_partial=True, include_system=True, ) - return [first_human, *tail] + return [first_human, *cast(list[AnyMessage], tail)] except Exception: return trimmed_messages @@ -182,17 +182,14 @@ class ContextPreservingSummarizationMiddleware(SummarizationMiddleware): """ 显式标注摘要分区的返回类型,避免外部中间件类型缺失扩散。 """ - return cast( - tuple[list[AnyMessage], list[AnyMessage]], - SummarizationMiddleware._partition_messages(messages, cutoff_index), - ) + return SummarizationMiddleware._partition_messages(messages, cutoff_index) def _create_summary(self, messages_to_summarize: list[AnyMessage]) -> str: """ 同步摘要失败时保持原图状态。 """ formatted_messages = self._prepare_summary_input(messages_to_summarize) - summary_model = getattr(self, "_summary_model", self.model) + summary_model = cast(Any, getattr(self, "_summary_model", self.model)) try: response = summary_model.invoke( self.summary_prompt.format(messages=formatted_messages).rstrip(), @@ -205,14 +202,14 @@ class ContextPreservingSummarizationMiddleware(SummarizationMiddleware): ) except Exception as err: raise ContextSummarizationError(self._ERROR_MESSAGE) from err - return self._require_valid_summary(response.text.strip()) + return self._require_valid_summary(str(response.text).strip()) async def _acreate_summary(self, messages_to_summarize: list[AnyMessage]) -> str: """ 异步摘要失败时保持原图状态。 """ formatted_messages = self._prepare_summary_input(messages_to_summarize) - summary_model = getattr(self, "_summary_model", self.model) + summary_model = cast(Any, getattr(self, "_summary_model", self.model)) try: response = await summary_model.ainvoke( self.summary_prompt.format(messages=formatted_messages).rstrip(), @@ -225,7 +222,7 @@ class ContextPreservingSummarizationMiddleware(SummarizationMiddleware): ) except Exception as err: raise ContextSummarizationError(self._ERROR_MESSAGE) from err - return self._require_valid_summary(response.text.strip()) + return self._require_valid_summary(str(response.text).strip()) def partition_for_token_limit( self, @@ -359,7 +356,7 @@ class ContextPreservingSummarizationMiddleware(SummarizationMiddleware): """ 将摘要转换为 LangChain 约定的可识别历史消息。 """ - return self._build_new_messages(summary) + return [*self._build_new_messages(summary)] def ensure_message_ids(self, messages: list[AnyMessage]) -> None: """ @@ -403,6 +400,9 @@ class FinalRequestCompactionMiddleware(AgentMiddleware): trigger_fraction: float = 0.85, keep_fraction: float = 0.10, ) -> None: + """ + 绑定摘要策略和预算比例,供最终模型请求执行有界上下文压缩。 + """ self.summarizer = summarizer self.trigger_fraction = trigger_fraction self.keep_fraction = keep_fraction diff --git a/app/agent/middleware/terminal.py b/app/agent/middleware/terminal.py index 3880c99dd..b1495cb19 100644 --- a/app/agent/middleware/terminal.py +++ b/app/agent/middleware/terminal.py @@ -25,7 +25,7 @@ def _default_actions() -> list[Literal["read", "wait"]]: return ["read", "wait"] -class SubAgentTerminalGrant(BaseModel): # type: ignore[misc] +class SubAgentTerminalGrant(BaseModel): """ 父任务明确授予子代理的单个终端读取能力,不扩大只读策略。 """ diff --git a/app/agent/middleware/usage.py b/app/agent/middleware/usage.py index 5c412809f..5885fbc51 100644 --- a/app/agent/middleware/usage.py +++ b/app/agent/middleware/usage.py @@ -3,7 +3,7 @@ """ from collections.abc import Awaitable, Callable -from typing import Any +from typing import Any, cast from langchain.agents.middleware.types import ( AgentMiddleware, @@ -40,6 +40,9 @@ class UsageMiddleware(AgentMiddleware): @staticmethod def _coerce_int(value: Any) -> int | None: + """ + 尝试解析供应方的用量整数,无法解析时返回 None。 + """ if value is None: return None try: @@ -84,6 +87,9 @@ class UsageMiddleware(AgentMiddleware): @classmethod def _lookup_int(cls, container: Any, *keys: str) -> int | None: + """ + 按候选键读取字典或对象中的用量值,忽略无法解析的字段。 + """ if not container: return None @@ -134,6 +140,9 @@ class UsageMiddleware(AgentMiddleware): @classmethod def _extract_context_window_tokens(cls, model: Any) -> int | None: + """ + 从模型能力描述中读取上下文窗口上限,缺失时不猜测固定额度。 + """ try: profile = getattr(model, "profile", None) except Exception: @@ -184,7 +193,7 @@ class UsageMiddleware(AgentMiddleware): @classmethod def _extract_configured_output_limit_tokens( - cls, request: ModelRequest + cls, request: ModelRequest[Any] ) -> int | None: """ 读取最终请求显式配置的单次输出上限。 @@ -223,7 +232,7 @@ class UsageMiddleware(AgentMiddleware): return image_count, unknown_count @classmethod - def estimate_request(cls, request: ModelRequest) -> dict[str, Any]: + def estimate_request(cls, request: ModelRequest[Any]) -> dict[str, Any]: """ 估算最终模型输入组成,仅返回可安全暴露的聚合数字。 """ @@ -288,6 +297,9 @@ class UsageMiddleware(AgentMiddleware): @classmethod def _extract_usage(cls, ai_message: AIMessage) -> dict[str, Any]: + """ + 汇总供应方实际用量及缓存计费字段,保留缺失与已报告值的区别。 + """ usage_metadata = getattr(ai_message, "usage_metadata", None) input_tokens = cls._lookup_int(usage_metadata, "input_tokens") @@ -587,7 +599,7 @@ class UsageMiddleware(AgentMiddleware): ai_message = next( ( message - for message in reversed(response.result) + for message in reversed(cast(list[Any], response.result)) if isinstance(message, AIMessage) ), None, diff --git a/app/agent/middleware/vision.py b/app/agent/middleware/vision.py index 874ce2b7f..c4b73048b 100644 --- a/app/agent/middleware/vision.py +++ b/app/agent/middleware/vision.py @@ -5,17 +5,18 @@ import base64 import binascii import json -from collections.abc import Awaitable, Callable -from typing import Annotated, Any, NotRequired, Optional, TypedDict +from collections.abc import Awaitable, Callable, Sequence +from typing import Annotated, Any, NotRequired, Optional, cast from langchain.agents.middleware.types import ( AgentMiddleware, + AgentState, ExtendedModelResponse, ModelRequest, ModelResponse, OmitFromInput, ) -from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, ToolMessage +from langchain_core.messages import AIMessage, AnyMessage, BaseMessage, HumanMessage, ToolMessage from langgraph.types import Command from app.agent.llm.helper import LLMHelper @@ -27,7 +28,7 @@ VISION_UNAVAILABLE = "当前模型未接收这张工具图片,不能据此声 MAX_INLINE_IMAGE_CHARS = 1024 * 1024 -class VisionState(TypedDict): +class VisionState(AgentState): """ 视觉拒绝事实只属于当前图调用,主子任务不共享可变能力缓存。 @@ -35,12 +36,10 @@ class VisionState(TypedDict): :ivar tool_vision_rejected_model: 已拒绝工具图片输入的模型标识;缺失时尚未记录,不接受外部输入覆盖 """ - messages: list[BaseMessage] tool_vision_rejected_model: Annotated[NotRequired[str], OmitFromInput] -# follow_imports=skip 不展开第三方中间件基类,只在框架继承边界忽略 misc。 -class VisionMiddleware(AgentMiddleware): # type: ignore[misc] +class VisionMiddleware(AgentMiddleware[VisionState, Any, Any]): """ 在最终压缩之后投影工具图片,确保 Chat 与 Responses 都收到真实图像输入。 """ @@ -79,7 +78,7 @@ class VisionMiddleware(AgentMiddleware): # type: ignore[misc] return f"{type(model).__module__}.{type(model).__qualname__}:{id(model)}" @staticmethod - def _has_tool_images(messages: list[BaseMessage]) -> bool: + def _has_tool_images(messages: Sequence[BaseMessage]) -> bool: """ 只检查真实 ToolMessage,用户附件不由工具视觉回退擅自移除。 """ @@ -111,7 +110,7 @@ class VisionMiddleware(AgentMiddleware): # type: ignore[misc] @staticmethod def _flush_observation( - output: list[BaseMessage], observations: list[dict[str, Any]], pending: set[str], + output: list[AnyMessage], observations: list[dict[str, Any]], pending: set[str | None], ) -> None: """ 完整工具回复批次之后再附加观察,保持并行 tool_call 的协议配对。 @@ -141,9 +140,9 @@ class VisionMiddleware(AgentMiddleware): # type: ignore[misc] return request enabled = not force_text and self.supports_images(request.model) enabled = enabled and (request.state or {}).get(VISION_REJECTED_MODEL) != self._model_key(request.model) - output: list[BaseMessage] = [] + output: list[AnyMessage] = [] observations: list[dict[str, Any]] = [] - pending: set[str] = set() + pending: set[str | None] = set() for original in request.messages: if not isinstance(original, ToolMessage): self._flush_observation(output, observations, pending) @@ -158,7 +157,7 @@ class VisionMiddleware(AgentMiddleware): # type: ignore[misc] if enabled and not matched: raise ValueError("图片缺少配对的工具调用,不能生成观察消息") message.content = [block for block in message.content if not is_image_content_block(block)] - images = [image for block in image_blocks if (image := self._image_for_model(block)) is not None] + images = [image for block in image_blocks if (image := self._image_for_model(cast(dict[str, Any], block))) is not None] if enabled and images: source = json.dumps({"source": "tool", "tool": message.name, "tool_call_id": message.tool_call_id}, ensure_ascii=False) diff --git a/app/agent/orchestrator.py b/app/agent/orchestrator.py index 945c0b412..3b87adcda 100644 --- a/app/agent/orchestrator.py +++ b/app/agent/orchestrator.py @@ -13,9 +13,10 @@ import warnings from copy import deepcopy from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional, cast from langchain.agents import create_agent +from langchain.agents.middleware import AgentMiddleware from langchain_core.messages import ( # noqa: F401 AIMessage, BaseMessage, @@ -114,7 +115,7 @@ def _get_plugin_tools_revision() -> int: """ 读取插件工具目录修订号,避免 Agent 编排依赖具体管理器类型。 """ - return get_plugin_manager().get_plugin_agent_tools_revision() + return int(get_plugin_manager().get_plugin_agent_tools_revision()) warnings.filterwarnings("ignore", message=".*allowed_objects.*") @@ -569,7 +570,7 @@ class MoviePilotAgent: cls, role: str, content: str = "", - attachments: Optional[List[dict]] = None, + attachments: Optional[List[dict[str, Any]]] = None, status: str = "done", ) -> dict[str, Any]: """ @@ -594,7 +595,7 @@ class MoviePilotAgent: """ return bool(self.channel and self.source) - async def _save_display_history_messages(self, messages: List[dict]) -> None: + async def _save_display_history_messages(self, messages: List[dict[str, Any]]) -> None: """ 将一组可见消息追加到 Agent 会话历史表。 """ @@ -1283,7 +1284,7 @@ class MoviePilotAgent: self, message: str, images: Optional[List[str]], - files: Optional[List[dict]], + files: Optional[List[dict[str, Any]]], has_audio_input: bool, ) -> Optional[str]: """ @@ -1523,7 +1524,7 @@ class MoviePilotAgent: } return self._llm_runtime_config - async def _initialize_llm(self, streaming: bool = False): + async def _initialize_llm(self, streaming: bool = False) -> Any: """ 初始化 LLM :param streaming: 是否启用流式输出 @@ -1688,15 +1689,15 @@ class MoviePilotAgent: emitted_text = self.stream_handler.emit(text) self._emit_output(emitted_text) - def _initialize_tools(self) -> List: + def _initialize_tools(self) -> List[Any]: """ 初始化主 Agent 本地工具实例。 """ from app.agent.loader import get_tool_factory - return get_tool_factory().create_tools( + return list(get_tool_factory().create_tools( session_id=self.session_id, - user_id=self.user_id, + user_id=self.user_id or "", channel=self.channel, source=self.source, username=self.username, @@ -1704,7 +1705,7 @@ class MoviePilotAgent: agent_context=self._tool_context, allow_message_tools=self.allow_message_tools, data=self._data, - ) + )) def _initialize_local_tool_catalogs( self, @@ -1744,7 +1745,7 @@ class MoviePilotAgent: return self._initialize_local_tool_catalogs()[0] @staticmethod - def _filter_local_web_search_tools(tools: List, enabled: bool) -> List: + def _filter_local_web_search_tools(tools: List[Any], enabled: bool) -> List[Any]: """ 按联网搜索策略保留或移除本地 search_web 工具。 """ @@ -1763,7 +1764,7 @@ class MoviePilotAgent: self._tool_context.update(values) @staticmethod - def _public_runtime_config_signature(runtime_config: Dict[str, Any]) -> tuple: + def _public_runtime_config_signature(runtime_config: Dict[str, Any]) -> tuple[Any, ...]: """ 生成不包含密钥明文的 LLM 运行时签名。 """ @@ -1846,7 +1847,7 @@ class MoviePilotAgent: """ from app.agent.loader import get_tool_factory - return get_tool_factory().catalog_factory_revision() + return str(get_tool_factory().catalog_factory_revision()) def begin_shutdown(self) -> None: """ @@ -1880,15 +1881,15 @@ class MoviePilotAgent: """ return [messages[-1]] if messages else [] - def _initialize_subagent_tools(self) -> List: + def _initialize_subagent_tools(self) -> List[Any]: """ 初始化子代理专用静默工具列表。 """ from app.agent.loader import get_tool_factory - return get_tool_factory().create_tools( + return list(get_tool_factory().create_tools( session_id=self.session_id, - user_id=self.user_id, + user_id=self.user_id or "", channel=self.channel, source=self.source, username=self.username, @@ -1902,15 +1903,15 @@ class MoviePilotAgent: }, allow_message_tools=False, data=self._data, - ) + )) - async def _initialize_mcp_tools(self, specs=None) -> List: + async def _initialize_mcp_tools(self, specs: Optional[List[Any]] = None) -> List[Any]: """ 初始化外部 MCP 工具列表。 """ return await create_external_mcp_tools( session_id=self.session_id, - user_id=self.user_id, + user_id=self.user_id or "", channel=self.channel, source=self.source, username=self.username, @@ -1919,13 +1920,13 @@ class MoviePilotAgent: specs=specs, ) - async def _initialize_subagent_mcp_tools(self, specs=None) -> List: + async def _initialize_subagent_mcp_tools(self, specs: Optional[List[Any]] = None) -> List[Any]: """ 初始化子代理可用的外部 MCP 工具列表。 """ return await create_external_mcp_tools( session_id=self.session_id, - user_id=self.user_id, + user_id=self.user_id or "", channel=self.channel, source=self.source, username=self.username, @@ -1961,7 +1962,7 @@ class MoviePilotAgent: enable_discovery=True, ) - async def _create_agent(self, streaming: bool = False): + async def _create_agent(self, streaming: bool = False) -> Any: """ 创建 LangGraph Agent(使用 create_agent + SummarizationMiddleware) :param streaming: 是否启用流式输出 @@ -1983,7 +1984,7 @@ class MoviePilotAgent: and (datetime.now() - cached_bundle.catalog_checked_at).total_seconds() < self.TOOL_CATALOG_REFRESH_SECONDS ) - if catalog_is_fresh: + if catalog_is_fresh and cached_bundle is not None: bundle_signature = await self._agent_bundle_signature( streaming, tool_catalog=cached_bundle.tool_catalog, @@ -2097,6 +2098,9 @@ class MoviePilotAgent: cached_agent = self._graphs.get(bundle_signature, streaming) self._last_agent_cache_hit = bool(cached_agent) if cached_agent: + # 目录新鲜条件已要求 bundle 存在;这里显式恢复该不变量供静态检查。 + if cached_bundle is None: + raise RuntimeError("Agent 图缓存缺少目录快照") # 签名相同表示已编译图中的精确工具实例仍有效;新建快照仅用于复核。 cached_bundle.catalog_checked_at = datetime.now() logger.debug(f"复用会话内 Agent 图: session_id={self.session_id}") @@ -2113,7 +2117,7 @@ class MoviePilotAgent: context=policy_context, catalog=tool_catalog, tools=tools, guardrails=guardrails, ) code_middleware = CodeExecutionMiddleware(policy_middleware) - middlewares = [ + middlewares: list[AgentMiddleware[Any, Any, Any]] = [ # 宿主策略必须位于最外层,确保插件覆盖工具基类也不能绕过。 policy_middleware, code_middleware, @@ -2269,7 +2273,7 @@ class MoviePilotAgent: messages = list( await self._memory.async_get_agent_messages( session_id=self.session_id, - user_id=self.user_id, + user_id=self.user_id or "", allow_cross_owner=await self._can_restore_cross_owner_history(), legacy_username=( self.username if self.channel == NotificationChannel.WebAgent.value @@ -2288,7 +2292,7 @@ class MoviePilotAgent: "images": [{"index": index + 1, "type": "image"} for index, _ in enumerate(images or [])], "files": files or [], } - content = [ + content: list[str | dict[str, Any]] = [ { "type": "text", "text": json.dumps(request_payload, ensure_ascii=False, indent=2), @@ -2320,8 +2324,8 @@ class MoviePilotAgent: # 执行推理 result = await self._execute_agent(messages) if isinstance(result, tuple) and result: - return result[0] - return result + return cast(str, result[0]) + return cast(str, result) except Exception as e: error_message = AGENT_EXECUTION_ERROR_MESSAGE @@ -2347,13 +2351,13 @@ class MoviePilotAgent: def _build_input_display_attachments( self, images: Optional[List[str]] = None, - files: Optional[List[dict]] = None, + files: Optional[List[dict[str, Any]]] = None, has_audio_input: bool = False, - ) -> List[dict]: + ) -> List[dict[str, Any]]: """ 构造用户输入附件的展示记录。 """ - attachments: List[dict] = [] + attachments: List[dict[str, Any]] = [] for index, image in enumerate(images or [], start=1): attachments.append( { @@ -2383,7 +2387,7 @@ class MoviePilotAgent: @staticmethod async def _stream_agent_tokens( - agent, + agent: Any, messages: dict[str, Any], config: dict[str, Any], on_token: Callable[[str], None], @@ -2623,12 +2627,12 @@ class MoviePilotAgent: if self._should_persist_agent_chat(): await self._memory.async_save_agent_messages( session_id=self.session_id, - user_id=self.user_id, + user_id=self.user_id or "", messages=agent.get_state(agent_config).values.get("messages", []), ) return display_text - async def _execute_agent(self, messages: List[BaseMessage]): + async def _execute_agent(self, messages: List[BaseMessage]) -> Any: """ 调用 LangGraph Agent 执行推理。 根据运行环境选择不同的执行模式: diff --git a/app/agent/session.py b/app/agent/session.py index a6cb383b9..8e0b9023a 100644 --- a/app/agent/session.py +++ b/app/agent/session.py @@ -63,17 +63,14 @@ async def _async_start_processing_status( 在线程池中通过统一 Chain 接口启动处理状态。 """ try: - return cast( - Optional[dict[str, Any]], - AgentChain().start_message_processing_status( + return AgentChain().start_message_processing_status( channel=NotificationChannel(task.channel), source=task.source, userid=task.user_id, message_id=task.original_message_id, chat_id=task.original_chat_id, text=task.message, - ), - ) + ) except Exception as err: logger.debug(f"启动Agent消息处理状态失败: {err}") return None @@ -1095,6 +1092,9 @@ class AgentSessionOwner: self._session_shutdown_pending[session_id] = worker def _clear_pending(done: asyncio.Task[None]) -> None: + """ + 仅清理当前会话所属的已结束关闭任务,避免覆盖后续任务状态。 + """ if ( self._session_shutdown_pending.get(session_id) is done and session_id not in self._session_cleanup_pending diff --git a/app/agent/skills/registry.py b/app/agent/skills/registry.py index c89a840fc..07f122f75 100644 --- a/app/agent/skills/registry.py +++ b/app/agent/skills/registry.py @@ -10,7 +10,7 @@ import zipfile from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path -from typing import Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple from urllib.parse import urlencode, urljoin, urlparse from app.adapters.network.http import RequestUtils @@ -107,14 +107,14 @@ class SkillHelper(metaclass=WeakSingleton): """ 返回用户技能目录,所有市场安装的技能都落在这里。 """ - return get_runtime_setting('CONFIG_PATH') / "agent" / "skills" + return Path(get_runtime_setting('CONFIG_PATH')) / "agent" / "skills" @staticmethod def get_bundled_skills_dir() -> Path: """ 返回仓库内置技能目录。 """ - return get_runtime_setting('ROOT_PATH') / "skills" + return Path(get_runtime_setting('ROOT_PATH')) / "skills" @staticmethod def get_market_sources() -> List[str]: @@ -156,7 +156,7 @@ class SkillHelper(metaclass=WeakSingleton): registry = SkillHelper._parse_market_registry(normalized) if registry: - return registry["registry_url"].rstrip("/") + return str(registry["registry_url"]).rstrip("/") repo = SkillHelper._parse_market_repo(normalized) if repo: @@ -166,7 +166,7 @@ class SkillHelper(metaclass=WeakSingleton): f"{repo['repo_url']}/tree/" f"{repo['branch']}/{repo['root_path'].strip('/')}" ).rstrip("/") - return repo["repo_url"].rstrip("/") + return str(repo["repo_url"]).rstrip("/") return normalized.rstrip("/") @@ -310,7 +310,7 @@ class SkillHelper(metaclass=WeakSingleton): return f"https://github.com/{repo_url.strip('/')}" @staticmethod - def _parse_market_repo(repo_url: str) -> Optional[dict]: + def _parse_market_repo(repo_url: str) -> Optional[dict[str, Any]]: """ 解析市场仓库地址,提取仓库、分支和技能根目录信息。 """ @@ -345,7 +345,7 @@ class SkillHelper(metaclass=WeakSingleton): } @staticmethod - def _parse_market_registry(source_url: str) -> Optional[dict]: + def _parse_market_registry(source_url: str) -> Optional[dict[str, Any]]: """ 解析注册表市场地址,目前支持 ClawHub。 """ @@ -371,7 +371,7 @@ class SkillHelper(metaclass=WeakSingleton): } @staticmethod - def _read_source_meta(skill_dir: Path) -> dict: + def _read_source_meta(skill_dir: Path) -> dict[str, Any]: """ 读取技能来源元数据,用于区分本地、市场和内置技能。 """ @@ -386,7 +386,7 @@ class SkillHelper(metaclass=WeakSingleton): return {} @staticmethod - def _write_source_meta(skill_dir: Path, payload: dict) -> None: + def _write_source_meta(skill_dir: Path, payload: dict[str, Any]) -> None: """ 写入技能来源元数据,便于后续展示来源和追踪安装来源。 """ @@ -623,7 +623,7 @@ class SkillHelper(metaclass=WeakSingleton): logger.error("解析技能市场压缩包失败:%s", e) return [] - def _list_market_registry_skills(self, registry: dict) -> List[SkillInfo]: + def _list_market_registry_skills(self, registry: dict[str, Any]) -> List[SkillInfo]: """ 从注册表拉取技能列表。 @@ -794,7 +794,7 @@ class SkillHelper(metaclass=WeakSingleton): return False, f"删除技能失败:{e}" @staticmethod - def _load_json_response(response) -> dict: + def _load_json_response(response: Any) -> dict[str, Any]: """ 读取 HTTP 响应中的 JSON 数据,异常时回退为空对象。 """ @@ -806,7 +806,7 @@ class SkillHelper(metaclass=WeakSingleton): return {} @staticmethod - def _extract_registry_items(payload: dict) -> List[dict]: + def _extract_registry_items(payload: dict[str, Any]) -> List[dict[str, Any]]: """ 从不同响应结构中提取技能列表。 """ @@ -828,7 +828,7 @@ class SkillHelper(metaclass=WeakSingleton): return [item for item in items if isinstance(item, dict)] return [] - def _list_clawhub_registry_skills(self, registry: dict) -> List[SkillInfo]: + def _list_clawhub_registry_skills(self, registry: dict[str, Any]) -> List[SkillInfo]: """ 按 ClawHub 官方前端的调用方式,通过 Convex query 获取公开技能列表。 """ @@ -880,12 +880,13 @@ class SkillHelper(metaclass=WeakSingleton): return list(results.values()) def _build_registry_skill( - self, item: dict, registry: dict + self, item: dict[str, Any], registry: dict[str, Any] ) -> Optional[SkillInfo]: """ 将注册表返回的条目转换为统一的 SkillInfo。 """ - skill_data = item.get("skill") if isinstance(item.get("skill"), dict) else item + raw_skill_data = item.get("skill") + skill_data: dict[str, Any] = raw_skill_data if isinstance(raw_skill_data, dict) else item slug = ( skill_data.get("slug") or item.get("slug") @@ -939,7 +940,7 @@ class SkillHelper(metaclass=WeakSingleton): ) @staticmethod - def _extract_registry_owner_handle(item: dict) -> Optional[str]: + def _extract_registry_owner_handle(item: dict[str, Any]) -> Optional[str]: """ 尽量从注册表条目中提取作者/拥有者 handle。 """ @@ -970,18 +971,21 @@ class SkillHelper(metaclass=WeakSingleton): """ 下载注册表技能包 ZIP。 """ + registry_url = skill.registry_url + if not registry_url: + return None download_url = skill.download_url or self._build_registry_download_url( - f"{skill.registry_url.rstrip('/')}/api/v1", + f"{registry_url.rstrip('/')}/api/v1", skill.registry_slug or skill.id, ) response = self._request_registry(url=download_url) if response is None or response.status_code != 200: logger.warning("下载注册表技能失败:%s", download_url) return None - return response.content + return response.content if isinstance(response.content, bytes) else None @cached(maxsize=4, ttl=_MARKET_CACHE_TTL, skip_empty=True) - def _discover_clawhub_runtime_env(self, registry_url: str) -> Optional[dict]: + def _discover_clawhub_runtime_env(self, registry_url: str) -> Optional[dict[str, Any]]: """ 从 ClawHub 首页的 runtime env 脚本中提取当前生效的 Convex 部署地址。 """ @@ -1010,7 +1014,7 @@ class SkillHelper(metaclass=WeakSingleton): } @staticmethod - def _read_response_text(response) -> str: + def _read_response_text(response: Any) -> str: """ 尽量稳定地把 requests 响应或测试桩响应转换成文本。 """ @@ -1086,7 +1090,7 @@ class SkillHelper(metaclass=WeakSingleton): wrote = True return wrote - def _download_repo_archive(self, repo: dict) -> Optional[bytes]: + def _download_repo_archive(self, repo: dict[str, Any]) -> Optional[bytes]: """ 下载市场仓库压缩包,并在缺省分支之间做回退尝试。 """ @@ -1103,7 +1107,7 @@ class SkillHelper(metaclass=WeakSingleton): ) if response is not None and response.status_code == 200: repo["branch"] = branch - return response.content + return response.content if isinstance(response.content, bytes) else None logger.warning("下载技能市场仓库失败:%s", repo["repo_url"]) return None @@ -1111,9 +1115,9 @@ class SkillHelper(metaclass=WeakSingleton): def _request_convex_query( deployment_url: str, path: str, - args: dict, + args: dict[str, Any], timeout: int = 30, - ): + ) -> Any: """ 以官方前端相同的请求格式调用 Convex query 接口。 """ @@ -1152,9 +1156,9 @@ class SkillHelper(metaclass=WeakSingleton): @staticmethod def _request_registry( url: str, - params: Optional[dict] = None, + params: Optional[dict[str, Any]] = None, timeout: int = 30, - ): + ) -> Any: """ 请求注册表 API,兼容代理和直连场景。 """ @@ -1182,7 +1186,7 @@ class SkillHelper(metaclass=WeakSingleton): repo_name: str, is_api: bool = False, timeout: int = 30, - ): + ) -> Any: """ 按代理优先级顺序请求 GitHub 资源,兼容代理和直连场景。 """ diff --git a/app/agent/tools/base.py b/app/agent/tools/base.py index 5f54a9386..ac40c5204 100644 --- a/app/agent/tools/base.py +++ b/app/agent/tools/base.py @@ -518,6 +518,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta): """ result_max_chars: ClassVar[Optional[int]] = DEFAULT_TOOL_RESULT_MAX_CHARS + return_direct: bool = False _session_id: str = PrivateAttr() _user_id: str = PrivateAttr() @@ -722,7 +723,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta): return None @abstractmethod - async def run(self, **kwargs) -> str: + async def run(self, *args: Any, **kwargs: Any) -> str: """ 子类实现具体的工具执行逻辑 """ diff --git a/app/agent/tools/catalog.py b/app/agent/tools/catalog.py index e4289b473..ed29dbdbc 100644 --- a/app/agent/tools/catalog.py +++ b/app/agent/tools/catalog.py @@ -6,7 +6,7 @@ import hashlib import json from dataclasses import dataclass, field from types import MappingProxyType -from typing import Any, Mapping, Optional, cast +from typing import Any, Mapping, Optional from pydantic import BaseModel @@ -44,7 +44,7 @@ def _schema_digest(tool: Any) -> str: """ args_schema = getattr(tool, "args_schema", None) if isinstance(args_schema, type) and issubclass(args_schema, BaseModel): - schema_model = cast(type[BaseModel], args_schema) + schema_model = args_schema schema = schema_model.model_json_schema() elif isinstance(args_schema, Mapping): schema = dict(args_schema) diff --git a/app/agent/tools/factory.py b/app/agent/tools/factory.py index 0b31b808a..e147a186e 100644 --- a/app/agent/tools/factory.py +++ b/app/agent/tools/factory.py @@ -11,6 +11,7 @@ from typing import ( Type, TypedDict, Unpack, + cast, ) if TYPE_CHECKING: @@ -97,7 +98,7 @@ def _get_plugin_agent_tools() -> list[_PluginToolInfo]: 读取当前插件工具投影,隔离 Agent 工具工厂与 Runtime 管理器。 """ try: - return get_plugin_manager().get_plugin_agent_tools() + return cast(list[_PluginToolInfo], get_plugin_manager().get_plugin_agent_tools()) except RuntimeError as error: # 纯工具目录探针可以在启动组合根之前运行;此时只跳过可选插件工具, # 不隐式创建 PluginManager,避免冷导入重新引入 Runtime 定位器。 diff --git a/app/agent/tools/impl/agent_task.py b/app/agent/tools/impl/agent_task.py index af687211b..52f053b34 100644 --- a/app/agent/tools/impl/agent_task.py +++ b/app/agent/tools/impl/agent_task.py @@ -19,8 +19,7 @@ AgentTaskAction = Literal["create", "list", "update", "run", "delete"] AgentTaskTriggerType = Literal["date", "cron"] -# Pydantic 在运行时生成模型成员,当前类型检查环境无法完整解析其动态基类。 -class AgentTaskInput(BaseModel): # type: ignore[misc] +class AgentTaskInput(BaseModel): """ 统一管理 Agent 自主定时任务的输入参数。 """ @@ -68,7 +67,7 @@ class AgentTaskInput(BaseModel): # type: ignore[misc] ) # Pydantic 校验器由运行时注册,保留第三方装饰器所需的动态类型兼容。 - @model_validator(mode="after") # type: ignore[misc] + @model_validator(mode="after") def validate_action(self) -> "AgentTaskInput": """ 按动作校验必填字段和触发参数组合。 @@ -132,7 +131,7 @@ class AgentTaskInput(BaseModel): # type: ignore[misc] normalized_type, normalized_trigger = TimerUtils.normalize_schedule_trigger( trigger_type=self.trigger_type, # 前置分支已拒绝缺失触发值,延迟任务也已提前返回。 - trigger_value=self.trigger, # type: ignore[arg-type] + trigger_value=cast(str, self.trigger), timezone_name=get_runtime_setting("TZ"), require_future=True, ) diff --git a/app/agent/tools/impl/api.py b/app/agent/tools/impl/api.py index d12306ddd..ff3fffaf0 100644 --- a/app/agent/tools/impl/api.py +++ b/app/agent/tools/impl/api.py @@ -6,7 +6,7 @@ import json from copy import deepcopy from functools import lru_cache from pathlib import Path -from typing import Any, Dict, Literal, Optional, Type, Union, cast +from typing import Any, Dict, Literal, Optional, Type, Union from langchain_core.tools import ToolException from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator @@ -51,8 +51,7 @@ def _load_api_mcp_input_schema() -> dict[str, Any]: return payload -# Pydantic 在运行时生成模型成员,当前类型检查环境无法完整解析其动态基类。 -class MoviePilotApiInput(BaseModel): # type: ignore[misc] +class MoviePilotApiInput(BaseModel): """ MoviePilot API 网关的结构化输入参数。 """ @@ -82,7 +81,7 @@ class MoviePilotApiInput(BaseModel): # type: ignore[misc] ) # Pydantic 校验器由运行时注册,保留第三方装饰器所需的动态类型兼容。 - @field_validator("body", mode="before") # type: ignore[misc] + @field_validator("body", mode="before") @classmethod def _restore_json_string_body(cls, value: Any) -> Any: """ @@ -204,7 +203,7 @@ class MoviePilotApiTool(MoviePilotTool): return MoviePilotApiInput def _parse_input( - self, tool_input: Union[str, dict[str, Any]], tool_call_id: Optional[str], + self, tool_input: Union[str, dict[str, Any]], tool_call_id: Optional[str] = None, ) -> Union[str, dict[str, Any]]: """ 在 LangChain 解析前校验原始参数,让只读调用也能收到安全纠错回执。 @@ -215,7 +214,7 @@ class MoviePilotApiTool(MoviePilotTool): self.canonical_arguments(tool_input) except (TypeError, ValueError) as error: raise ToolException(self._invalid_input_result(operation_id, error)) from error - return cast(Union[str, dict[str, Any]], super()._parse_input(tool_input, tool_call_id)) + return super()._parse_input(tool_input, tool_call_id) def canonical_arguments(self, arguments: dict[str, Any]) -> dict[str, Any]: """ diff --git a/app/agent/tools/impl/apply_patch.py b/app/agent/tools/impl/apply_patch.py index 720bd3bf3..8db356e11 100644 --- a/app/agent/tools/impl/apply_patch.py +++ b/app/agent/tools/impl/apply_patch.py @@ -7,7 +7,7 @@ from dataclasses import dataclass, field from pathlib import Path -from typing import Optional, Type +from typing import Any, Optional, Type from anyio import Path as AsyncPath from pydantic import BaseModel, Field @@ -245,7 +245,7 @@ class ApplyPatchTool(MoviePilotTool): description="工具输入参数的验证模型或 JSON Schema", ) - def get_tool_message(self, **kwargs) -> Optional[str]: + def get_tool_message(self, **kwargs: Any) -> Optional[str]: """ 根据参数生成友好的提示消息 """ @@ -258,17 +258,19 @@ class ApplyPatchTool(MoviePilotTool): async def _plan_operations( self, operations: list[FilePatch] - ) -> tuple[Optional[list], Optional[str]]: + ) -> tuple[Optional[list[tuple[FilePatch, Path, Path, Optional[str], Optional[str]]]], Optional[str]]: """ 校验每个文件操作并计算写入内容,全部通过才返回执行计划。 """ - planned = [] + planned: list[tuple[FilePatch, Path, Path, Optional[str], Optional[str]]] = [] for file_patch in operations: resolved_path, access_error = await self._check_local_file_access( file_patch.path, operation="打补丁" ) if access_error: return None, access_error + if resolved_path is None: + return None, "错误:无法解析补丁路径" path = AsyncPath(resolved_path) exists = await path.exists() @@ -310,14 +312,14 @@ class ApplyPatchTool(MoviePilotTool): "default", calculate_file_sha256, local_path ) new_content = apply_hunks_to_content( - content, file_patch.hunks, resolved_path + content, file_patch.hunks, str(resolved_path) ) planned.append( (file_patch, resolved_path, local_path, new_content, current_sha256) ) return planned, None - async def run(self, patch: str, **kwargs) -> str: + async def run(self, patch: str, **kwargs: Any) -> str: # type: ignore[override] """ 解析并整体校验补丁后,逐文件原子应用新增、更新和删除。 """ @@ -332,6 +334,8 @@ class ApplyPatchTool(MoviePilotTool): planned, plan_error = await self._plan_operations(operations) if plan_error: return plan_error + if planned is None: + return "错误:补丁规划失败" results = [] for file_patch, resolved_path, local_path, new_content, sha256 in planned: diff --git a/app/agent/tools/impl/ask_user_choice.py b/app/agent/tools/impl/ask_user_choice.py index cde0131e1..2ffce3b92 100644 --- a/app/agent/tools/impl/ask_user_choice.py +++ b/app/agent/tools/impl/ask_user_choice.py @@ -2,7 +2,7 @@ 让用户通过按钮进行选择的工具。 """ -from typing import List, Optional, Type +from typing import Any, List, Optional, Type from pydantic import BaseModel, Field, model_validator @@ -34,7 +34,7 @@ class UserChoiceOptionInput(BaseModel): ) @model_validator(mode="after") - def validate_option(self): + def validate_option(self) -> "UserChoiceOptionInput": """ 校验按钮选项的文案和值不能为空。 """ @@ -66,7 +66,7 @@ class AskUserChoiceInput(BaseModel): ) @model_validator(mode="after") - def validate_payload(self): + def validate_payload(self) -> "AskUserChoiceInput": """ 校验按钮选择工具必须提供问题和选项。 """ @@ -113,7 +113,7 @@ class AskUserChoiceTool(MoviePilotTool): description="工具输入参数的验证模型或 JSON Schema", ) - def get_tool_message(self, **kwargs) -> Optional[str]: + def get_tool_message(self, **kwargs: Any) -> Optional[str]: """ 生成工具执行提示文案。 """ @@ -143,12 +143,12 @@ class AskUserChoiceTool(MoviePilotTool): """ return bool(self._agent_context.get("feedback_issue_rejected_quality")) - async def run( + async def run( # type: ignore[override] self, message: str, options: List[UserChoiceOptionInput], title: Optional[str] = None, - **kwargs, + **kwargs: Any, ) -> str: """ 发送按钮选择消息,并登记待回调的交互上下文。 diff --git a/app/agent/tools/impl/browse_webpage.py b/app/agent/tools/impl/browse_webpage.py index 598d79518..fec37704e 100644 --- a/app/agent/tools/impl/browse_webpage.py +++ b/app/agent/tools/impl/browse_webpage.py @@ -7,7 +7,7 @@ import binascii import json from enum import Enum from io import BytesIO -from typing import Any, Optional, Type, Union +from typing import Any, Optional, Type, Union, cast from PIL import Image from pydantic import BaseModel, Field @@ -249,7 +249,7 @@ class BrowseWebpageTool(MoviePilotTool): "success": False, "execution_outcome": "failed", "action": "screenshot", "error": code, "message": message, }) - def get_tool_message(self, **kwargs) -> Optional[str]: + def get_tool_message(self, **kwargs: Any) -> Optional[str]: """ 根据操作类型生成友好的提示消息 """ @@ -278,7 +278,7 @@ class BrowseWebpageTool(MoviePilotTool): } return action_messages.get(action, f"执行浏览器操作: {action}") - async def run( + async def run( # type: ignore[override] self, action: str, url: Optional[str] = None, @@ -293,7 +293,7 @@ class BrowseWebpageTool(MoviePilotTool): session_key: Optional[str] = None, tab_index: Optional[int] = None, allow_private_network: bool = False, - **kwargs, + **kwargs: Any, ) -> str: """ 执行浏览器操作 @@ -378,7 +378,7 @@ class BrowseWebpageTool(MoviePilotTool): tab_index=tab_index, allow_private_network=allow_private_network, ) - return result + return cast(str, result) except Exception as e: error_summary = summarize_error(e) @@ -448,7 +448,7 @@ class BrowseWebpageTool(MoviePilotTool): }, ) - def _callback(session) -> str: + def _callback(session: Any) -> str: """ 在浏览器会话所属线程执行操作,不把页面对象带回 Agent 线程。 """ @@ -467,14 +467,14 @@ class BrowseWebpageTool(MoviePilotTool): allow_private_network=allow_private_network, ) - return helper.with_session( + return cast(str, helper.with_session( session_key=session_key, callback=_callback, user_agent=user_agent, cookies=cookies, timeout=timeout, owner=owner, - ) + )) except Exception as e: error_summary = summarize_error(e) @@ -500,7 +500,7 @@ class BrowseWebpageTool(MoviePilotTool): def _do_action( self, helper: BrowserSessionHelper, - session, + session: Any, browser_action: BrowserAction, url: Optional[str], selector: Optional[str], @@ -532,7 +532,7 @@ class BrowseWebpageTool(MoviePilotTool): return self._action_goto( helper, page, - url, + cast(str, url), timeout, allow_private_network=allow_private_network, ) @@ -559,59 +559,59 @@ class BrowseWebpageTool(MoviePilotTool): return self._action_get_cookies(session, page) elif browser_action == BrowserAction.CLICK: - result = self._action_click(page, selector, timeout) + result = self._action_click(page, cast(str, selector), timeout) validate_after_action() return result elif browser_action == BrowserAction.CLICK_REF: result = self._action_click( page, - BrowserSessionHelper.ref_to_selector(ref), + BrowserSessionHelper.ref_to_selector(cast(str, ref)), timeout, - ref=ref, + ref=cast(str, ref), ) validate_after_action() return result elif browser_action == BrowserAction.FILL: - result = self._action_fill(page, selector, value, timeout) + result = self._action_fill(page, cast(str, selector), cast(str, value), timeout) validate_after_action() return result elif browser_action == BrowserAction.FILL_REF: result = self._action_fill( page, - BrowserSessionHelper.ref_to_selector(ref), - value, + BrowserSessionHelper.ref_to_selector(cast(str, ref)), + cast(str, value), timeout, - ref=ref, + ref=cast(str, ref), ) validate_after_action() return result elif browser_action == BrowserAction.SELECT: - result = self._action_select(page, selector, value, timeout) + result = self._action_select(page, cast(str, selector), value, timeout) validate_after_action() return result elif browser_action == BrowserAction.SELECT_REF: result = self._action_select( page, - BrowserSessionHelper.ref_to_selector(ref), + BrowserSessionHelper.ref_to_selector(cast(str, ref)), value, timeout, - ref=ref, + ref=cast(str, ref), ) validate_after_action() return result elif browser_action == BrowserAction.EVALUATE: - result = self._action_evaluate(page, script) + result = self._action_evaluate(page, cast(str, script)) validate_after_action() return result elif browser_action == BrowserAction.WAIT: - return self._action_wait(page, selector, timeout) + return self._action_wait(page, cast(str, selector), timeout) elif browser_action == BrowserAction.LIST_TABS: return self._json_response({"tabs": BrowserSessionHelper.list_tabs(session)}) @@ -619,7 +619,7 @@ class BrowseWebpageTool(MoviePilotTool): elif browser_action == BrowserAction.OPEN_TAB: page = helper.open_tab( session, - url=url, + url=cast(str, url), timeout=timeout, allow_private_network=allow_private_network, ) @@ -636,7 +636,7 @@ class BrowseWebpageTool(MoviePilotTool): ) elif browser_action == BrowserAction.FOCUS_TAB: - page = BrowserSessionHelper.focus_tab(session, tab_index) + page = BrowserSessionHelper.focus_tab(session, cast(int, tab_index)) validate_after_action() return self._json_response( { @@ -651,7 +651,7 @@ class BrowseWebpageTool(MoviePilotTool): ) elif browser_action == BrowserAction.CLOSE_TAB: - tabs = BrowserSessionHelper.close_tab(session, tab_index) + tabs = BrowserSessionHelper.close_tab(session, cast(int, tab_index)) return self._json_response({"success": True, "tabs": tabs}) return f"未知操作: {browser_action}" @@ -681,7 +681,7 @@ class BrowseWebpageTool(MoviePilotTool): @staticmethod def _action_goto( helper: BrowserSessionHelper, - page, + page: Any, url: str, timeout: int, allow_private_network: bool, @@ -705,7 +705,7 @@ class BrowseWebpageTool(MoviePilotTool): return BrowseWebpageTool._json_response(result) @staticmethod - def _action_get_content(page, content_type: Optional[str]) -> str: + def _action_get_content(page: Any, content_type: Optional[str]) -> str: """ 获取页面内容 """ @@ -730,7 +730,7 @@ class BrowseWebpageTool(MoviePilotTool): return BrowseWebpageTool._json_response(result) @staticmethod - def _action_screenshot(page, selector: Optional[str] = None, timeout: int = DEFAULT_TIMEOUT) -> str: + def _action_screenshot(page: Any, selector: Optional[str] = None, timeout: int = DEFAULT_TIMEOUT) -> str: """ 截取当前视口或唯一元素,保留验证码会话并对两种截图应用相同大小上限。 """ @@ -782,7 +782,7 @@ class BrowseWebpageTool(MoviePilotTool): @staticmethod def _action_click( - page, + page: Any, selector: str, timeout: int, ref: Optional[str] = None, @@ -811,7 +811,7 @@ class BrowseWebpageTool(MoviePilotTool): @staticmethod def _action_fill( - page, + page: Any, selector: str, value: str, timeout: int, @@ -835,7 +835,7 @@ class BrowseWebpageTool(MoviePilotTool): @staticmethod def _action_select( - page, + page: Any, selector: str, value: Optional[str], timeout: int, @@ -861,7 +861,7 @@ class BrowseWebpageTool(MoviePilotTool): ) @staticmethod - def _action_evaluate(page, script: str) -> str: + def _action_evaluate(page: Any, script: str) -> str: """ 执行 JavaScript """ @@ -887,7 +887,7 @@ class BrowseWebpageTool(MoviePilotTool): ) @staticmethod - def _action_wait(page, selector: str, timeout: int) -> str: + def _action_wait(page: Any, selector: str, timeout: int) -> str: """ 等待元素出现 """ diff --git a/app/agent/tools/impl/edit_file.py b/app/agent/tools/impl/edit_file.py index 4648c2fa7..d9abac148 100644 --- a/app/agent/tools/impl/edit_file.py +++ b/app/agent/tools/impl/edit_file.py @@ -4,7 +4,7 @@ import json from pathlib import Path -from typing import Optional, Type +from typing import Any, Optional, Type from anyio import Path as AsyncPath from pydantic import BaseModel, Field @@ -76,7 +76,7 @@ class EditFileTool(MoviePilotTool): description="工具输入参数的验证模型或 JSON Schema", ) - def get_tool_message(self, **kwargs) -> Optional[str]: + def get_tool_message(self, **kwargs: Any) -> Optional[str]: """ 根据参数生成友好的提示消息 """ @@ -84,14 +84,14 @@ class EditFileTool(MoviePilotTool): file_name = Path(file_path).name if file_path else "未知文件" return f"编辑文件: {file_name}" - async def run( + async def run( # type: ignore[override] self, file_path: str, old_text: str, new_text: str, replace_all: bool = False, expected_sha256: Optional[str] = None, - **kwargs, + **kwargs: Any, ) -> str: """ 校验精确匹配和可选文件版本后,以原子方式写入编辑结果。 @@ -104,6 +104,8 @@ class EditFileTool(MoviePilotTool): ) if access_error: return access_error + if resolved_path is None: + return "错误:无法解析编辑路径" if not old_text: return "错误:old_text 不能为空;创建或完整写入文件请使用 write_file。" diff --git a/app/agent/tools/impl/execute_code.py b/app/agent/tools/impl/execute_code.py index 311af0454..02364424e 100644 --- a/app/agent/tools/impl/execute_code.py +++ b/app/agent/tools/impl/execute_code.py @@ -15,8 +15,7 @@ from app.agent.tools.tags import ToolTag from app.runtime.settings import get_runtime_setting -# Pydantic 在运行时生成模型成员,当前类型检查环境无法完整解析其动态基类。 -class ExecuteCodeInput(BaseModel): # type: ignore[misc] +class ExecuteCodeInput(BaseModel): """ 代码与显式重置状态;身份、工具集合和预算由宿主提供,不由模型选择。 """ @@ -88,7 +87,7 @@ class ExecuteCodeTool(MoviePilotTool): """ return None - async def run(self, **kwargs: Any) -> str: + async def run(self, **kwargs: Any) -> str: # type: ignore[override] """ 只有真实宿主工具链绑定的当前 cell 可执行,直接外部调用不能获得默认身份。 """ diff --git a/app/agent/tools/impl/execute_command.py b/app/agent/tools/impl/execute_command.py index 5fe7ce276..557e8da4b 100644 --- a/app/agent/tools/impl/execute_command.py +++ b/app/agent/tools/impl/execute_command.py @@ -13,7 +13,7 @@ import subprocess from collections import deque from dataclasses import dataclass, field from tempfile import NamedTemporaryFile -from typing import Any, Literal, Optional, TextIO, Type +from typing import Any, Literal, Optional, TextIO, Type, cast from pydantic import BaseModel, Field @@ -113,7 +113,7 @@ class _CommandOutput: delete=False, ) self.temp_file_path = temp_file.name - self.temp_file_handle = temp_file + self.temp_file_handle = cast(TextIO, temp_file) for stream_name, chunk in self.preview_entries: self._write_chunk(stream_name, chunk) @@ -370,7 +370,7 @@ class ExecuteCommandTool(MoviePilotTool): ) result_max_chars = TERMINAL_MAX_READ_BYTES + 4096 - def get_tool_message(self, **kwargs) -> Optional[str]: + def get_tool_message(self, **kwargs: Any) -> Optional[str]: """ 根据命令动作生成友好的提示消息。 """ @@ -445,7 +445,7 @@ class ExecuteCommandTool(MoviePilotTool): return normalized, None @staticmethod - def _subprocess_kwargs() -> dict: + def _subprocess_kwargs() -> dict[str, Any]: """ 为一次性命令创建独立进程组,便于超时清理整棵子进程。 """ @@ -691,9 +691,10 @@ class ExecuteCommandTool(MoviePilotTool): ) output = _CommandOutput(preview_limit_bytes=MAX_OUTPUT_PREVIEW_BYTES) wait_task = asyncio.create_task(process.wait()) + # _subprocess_kwargs 固定把两个输出流设为 PIPE,创建成功后均为 StreamReader。 reader_tasks = [ - asyncio.create_task(self._read_stream(process.stdout, "stdout", output)), - asyncio.create_task(self._read_stream(process.stderr, "stderr", output)), + asyncio.create_task(self._read_stream(cast(asyncio.StreamReader, process.stdout), "stdout", output)), + asyncio.create_task(self._read_stream(cast(asyncio.StreamReader, process.stderr), "stderr", output)), ] timed_out = False @@ -735,7 +736,7 @@ class ExecuteCommandTool(MoviePilotTool): finally: _command_semaphore.release() - async def run( + async def run( # type: ignore[override] self, action: Optional[str] = "start", command: Optional[str] = None, @@ -755,7 +756,7 @@ class ExecuteCommandTool(MoviePilotTool): yield_time_ms: Optional[int] = TERMINAL_YIELD_DEFAULT_MS, timeout: Optional[int] = 60, confirm_dangerous: Optional[bool] = False, - **kwargs, + **kwargs: Any, ) -> str: """ 按同一启动策略执行命令,区分写入/EOF、一次中断与会话终止。 diff --git a/app/agent/tools/impl/mcp.py b/app/agent/tools/impl/mcp.py index a36cab73f..8535c83d3 100644 --- a/app/agent/tools/impl/mcp.py +++ b/app/agent/tools/impl/mcp.py @@ -66,7 +66,7 @@ class McpExternalTool(MoviePilotTool): """ return f"调用 MCP 工具: {self._spec.server.name}/{self._spec.name}" - async def run(self, **kwargs: object) -> str: + async def run(self, **kwargs: object) -> str: # type: ignore[override] """ 调用外部 MCP 工具。 diff --git a/app/agent/tools/impl/persona.py b/app/agent/tools/impl/persona.py index 9f186284a..6ad2bf5b1 100644 --- a/app/agent/tools/impl/persona.py +++ b/app/agent/tools/impl/persona.py @@ -15,8 +15,7 @@ from app.runtime.log import logger PersonaAction = Literal["list", "switch", "update"] -# Pydantic 在运行时生成模型成员,当前类型检查环境无法完整解析其动态基类。 -class PersonaInput(BaseModel): # type: ignore[misc] +class PersonaInput(BaseModel): """ 查询、切换或更新 Agent 人格的统一输入参数。 """ @@ -59,7 +58,7 @@ class PersonaInput(BaseModel): # type: ignore[misc] ) # Pydantic 校验器由运行时注册,保留第三方装饰器所需的动态类型兼容。 - @model_validator(mode="after") # type: ignore[misc] + @model_validator(mode="after") def validate_action(self) -> "PersonaInput": """ 按动作校验目标人格和更新字段。 diff --git a/app/agent/tools/impl/query_doctor_report.py b/app/agent/tools/impl/query_doctor_report.py index aef4522f9..648c2d75a 100644 --- a/app/agent/tools/impl/query_doctor_report.py +++ b/app/agent/tools/impl/query_doctor_report.py @@ -101,7 +101,7 @@ class QueryDoctorReportTool(MoviePilotTool): """ return run_doctor(deep=bool(deep)).to_dict() - async def run( + async def run( # type: ignore[override] self, deep: Optional[bool] = False, include_details: Optional[bool] = True, diff --git a/app/agent/tools/impl/read_file.py b/app/agent/tools/impl/read_file.py index b0a26347a..001818347 100644 --- a/app/agent/tools/impl/read_file.py +++ b/app/agent/tools/impl/read_file.py @@ -5,7 +5,7 @@ import hashlib import json from pathlib import Path -from typing import Optional, Type +from typing import Any, Optional, Type, cast from anyio import Path as AsyncPath from pydantic import BaseModel, Field @@ -67,7 +67,7 @@ class ReadFileTool(MoviePilotTool): description="工具输入参数的验证模型或 JSON Schema", ) - def get_tool_message(self, **kwargs) -> Optional[str]: + def get_tool_message(self, **kwargs: Any) -> Optional[str]: """ 根据参数生成友好的提示消息 """ @@ -75,13 +75,13 @@ class ReadFileTool(MoviePilotTool): file_name = Path(file_path).name if file_path else "未知文件" return f"读取文件: {file_name}" - async def run( + async def run( # type: ignore[override] self, file_path: str, start_line: Optional[int] = None, end_line: Optional[int] = None, include_metadata: bool = False, - **kwargs, + **kwargs: Any, ) -> str: """ 读取指定文本范围,必要时附带完整文件的 SHA-256 元数据。 @@ -100,8 +100,7 @@ class ReadFileTool(MoviePilotTool): ) if access_error: return access_error - - path = AsyncPath(resolved_path) + path = AsyncPath(cast(Path, resolved_path)) if not await path.exists(): return f"错误:文件 {resolved_path} 不存在" @@ -150,7 +149,7 @@ class ReadFileTool(MoviePilotTool): if truncated: return f"{content}\n\n[警告:{READ_FILE_TRUNCATION_MESSAGE}]" - return content + return str(content) except PermissionError: return f"错误:没有权限读取 {file_path}" diff --git a/app/agent/tools/impl/recognize_captcha.py b/app/agent/tools/impl/recognize_captcha.py index 6d80f8450..91ca35ca1 100644 --- a/app/agent/tools/impl/recognize_captcha.py +++ b/app/agent/tools/impl/recognize_captcha.py @@ -3,7 +3,7 @@ """ import json -from typing import Optional, Type +from typing import Any, Optional, Type from pydantic import BaseModel, Field, model_validator @@ -47,7 +47,7 @@ class RecognizeCaptchaInput(BaseModel): ) # Pydantic 校验器由运行时注册,保留第三方装饰器所需的动态类型兼容。 - @model_validator(mode="after") # type: ignore[misc] + @model_validator(mode="after") def require_image_source(self) -> "RecognizeCaptchaInput": """ 确保验证码工具至少收到图片地址或原始图片字节。 @@ -88,7 +88,7 @@ class RecognizeCaptchaTool(MoviePilotTool): description="工具输入参数的验证模型或 JSON Schema", ) - def get_tool_message(self, **kwargs) -> Optional[str]: + def get_tool_message(self, **kwargs: Any) -> Optional[str]: """ 根据验证码图片参数生成友好的提示消息。 """ @@ -178,7 +178,7 @@ class RecognizeCaptchaTool(MoviePilotTool): ensure_ascii=False, ) - async def run( + async def run( # type: ignore[override] self, image_url: Optional[str] = None, cookie: Optional[str] = None, diff --git a/app/agent/tools/impl/search_web.py b/app/agent/tools/impl/search_web.py index 58c31c52b..9b4e48dea 100644 --- a/app/agent/tools/impl/search_web.py +++ b/app/agent/tools/impl/search_web.py @@ -5,7 +5,7 @@ import json import re from dataclasses import dataclass -from typing import Dict, List, Optional, Type +from typing import Any, Dict, List, Optional, Type from urllib.parse import urlparse from ddgs import DDGS @@ -100,7 +100,7 @@ class SearchWebTool(MoviePilotTool): description="工具输入参数的验证模型或 JSON Schema", ) - def get_tool_message(self, **kwargs) -> Optional[str]: + def get_tool_message(self, **kwargs: Any) -> Optional[str]: """ 根据搜索参数生成友好的提示消息 """ @@ -115,13 +115,13 @@ class SearchWebTool(MoviePilotTool): message += f",限定站点: {site_url}" return f"{message})" - async def run( + async def run( # type: ignore[override] self, query: str, max_results: Optional[int] = MAX_SEARCH_RESULTS, search_engine: Optional[str] = DEFAULT_SEARCH_ENGINE, site_url: Optional[str] = None, - **kwargs, + **kwargs: Any, ) -> str: """ 执行网络搜索。 @@ -156,7 +156,7 @@ class SearchWebTool(MoviePilotTool): max(1, max_results or MAX_SEARCH_RESULTS), MAX_SEARCH_RESULTS, ) - results: List[Dict] = [] + results: List[Dict[str, Any]] = [] for engine in self._get_search_plan(search_engine): results = await self._search_with_backend( @@ -210,7 +210,7 @@ class SearchWebTool(MoviePilotTool): query: str, max_results: int, site_filter: Optional[_SearchSiteFilter], - ) -> List[Dict]: + ) -> List[Dict[str, Any]]: """ 使用指定后端执行搜索。 @@ -284,9 +284,9 @@ class SearchWebTool(MoviePilotTool): @staticmethod def _filter_results_by_site( - results: List[Dict], + results: List[Dict[str, Any]], site_filter: Optional[_SearchSiteFilter], - ) -> List[Dict]: + ) -> List[Dict[str, Any]]: """ 根据指定站点过滤搜索结果。 @@ -370,40 +370,43 @@ class SearchWebTool(MoviePilotTool): ) @staticmethod - def _extract_result_url(result: Dict) -> str: + def _extract_result_url(result: Dict[str, Any]) -> str: """ 从不同搜索引擎结果结构中提取 URL。 :param result: 搜索引擎返回的单条结果 :return: URL 字符串 """ - return result.get("href") or result.get("url") or "" + value = result.get("href") or result.get("url") or "" + return str(value) if value else "" @staticmethod - def _extract_result_snippet(result: Dict) -> str: + def _extract_result_snippet(result: Dict[str, Any]) -> str: """ 从不同搜索引擎结果结构中提取摘要。 :param result: 搜索引擎返回的单条结果 :return: 摘要字符串 """ - return ( + value = ( result.get("body") or result.get("snippet") or result.get("content") or "" ) + return str(value) if value else "" @staticmethod - def _get_proxy_url(proxy_setting) -> Optional[str]: + def _get_proxy_url(proxy_setting: Any) -> Optional[str]: """ 从代理设置中提取代理URL """ if not proxy_setting: return None if isinstance(proxy_setting, dict): - return proxy_setting.get("http") or proxy_setting.get("https") - return proxy_setting + value = proxy_setting.get("http") or proxy_setting.get("https") + return str(value) if value else None + return str(proxy_setting) if isinstance(proxy_setting, str) else None async def _search_ddgs( self, @@ -411,7 +414,7 @@ class SearchWebTool(MoviePilotTool): max_results: int, search_engine: str = DEFAULT_SEARCH_ENGINE, site_filter: Optional[_SearchSiteFilter] = None, - ) -> List[Dict]: + ) -> List[Dict[str, Any]]: """ 使用 DDGS 搜索引擎后端进行搜索。 @@ -423,12 +426,12 @@ class SearchWebTool(MoviePilotTool): """ try: - def sync_search(): + def sync_search() -> list[dict[str, Any]]: """ 在线程中执行同步搜索 """ - results = [] - ddgs_kwargs = {"timeout": SEARCH_TIMEOUT} + results: list[dict[str, Any]] = [] + ddgs_kwargs: dict[str, Any] = {"timeout": SEARCH_TIMEOUT} proxy_url = self._get_proxy_url(get_runtime_setting('PROXY')) if proxy_url: ddgs_kwargs["proxy"] = proxy_url @@ -459,7 +462,8 @@ class SearchWebTool(MoviePilotTool): logger.warning(f"搜索引擎搜索进程失败: {err}") return results - results = await self.run_blocking("web", sync_search) + raw_results = await self.run_blocking("web", sync_search) + results = raw_results if isinstance(raw_results, list) else [] return self._filter_results_by_site(results, site_filter) except Exception as e: @@ -467,11 +471,14 @@ class SearchWebTool(MoviePilotTool): return [] @staticmethod - def _format_and_truncate_results(results: List[Dict], max_results: int) -> Dict: + def _format_and_truncate_results( + results: List[Dict[str, Any]], max_results: int + ) -> Dict[str, Any]: """ 格式化并裁剪搜索结果 """ - formatted = {"total_results": len(results), "results": []} + formatted: dict[str, Any] = {"total_results": len(results)} + formatted_results: list[dict[str, Any]] = [] for idx, result in enumerate(results[:max_results], 1): title = result.get("title", "")[:200] @@ -487,7 +494,7 @@ class SearchWebTool(MoviePilotTool): # 清理文本 snippet = re.sub(r"\s+", " ", snippet).strip() - formatted["results"].append( + formatted_results.append( { "rank": idx, "title": title, @@ -497,6 +504,7 @@ class SearchWebTool(MoviePilotTool): } ) + formatted["results"] = formatted_results if len(results) > max_results: formatted["note"] = f"仅显示前 {max_results} 条结果。" diff --git a/app/agent/tools/impl/send_local_file.py b/app/agent/tools/impl/send_local_file.py index a2050c0a8..54f9f06fd 100644 --- a/app/agent/tools/impl/send_local_file.py +++ b/app/agent/tools/impl/send_local_file.py @@ -3,7 +3,7 @@ """ from pathlib import Path -from typing import Optional, Type +from typing import Any, Optional, Type from pydantic import BaseModel, Field, model_validator @@ -38,7 +38,7 @@ class SendLocalFileInput(BaseModel): ) @model_validator(mode="after") - def validate_file_path(self): + def validate_file_path(self) -> "SendLocalFileInput": """ 拒绝空的附件路径。 @@ -79,7 +79,7 @@ class SendLocalFileTool(MoviePilotTool): description="是否仅允许系统管理员调用", ) - def get_tool_message(self, **kwargs) -> Optional[str]: + def get_tool_message(self, **kwargs: Any) -> Optional[str]: """ 根据本地附件路径生成发送进度提示。 @@ -90,13 +90,13 @@ class SendLocalFileTool(MoviePilotTool): file_name = Path(file_path).name if file_path else "未知文件" return f"发送本地附件: {file_name}" - async def run( + async def run( # type: ignore[override] self, file_path: str, message: Optional[str] = None, title: Optional[str] = None, file_name: Optional[str] = None, - **kwargs, + **kwargs: Any, ) -> str: """ 通过当前会话渠道发送已有本地附件。 diff --git a/app/agent/tools/impl/send_message.py b/app/agent/tools/impl/send_message.py index 1a79756c3..08a6eac4b 100644 --- a/app/agent/tools/impl/send_message.py +++ b/app/agent/tools/impl/send_message.py @@ -2,7 +2,7 @@ 发送消息工具 """ -from typing import Optional, Type +from typing import Any, Optional, Type from pydantic import BaseModel, Field, model_validator @@ -94,7 +94,7 @@ class SendMessageTool(MoviePilotTool): description="是否仅允许系统管理员调用", ) - def get_tool_message(self, **kwargs) -> Optional[str]: + def get_tool_message(self, **kwargs: Any) -> Optional[str]: """ 根据消息参数生成友好的提示消息 """ @@ -117,7 +117,7 @@ class SendMessageTool(MoviePilotTool): return f"发送图片消息: {message}" return f"发送消息: {message}" - async def run( + async def run( # type: ignore[override] self, message: Optional[str] = None, title: Optional[str] = None, diff --git a/app/agent/tools/impl/send_voice_message.py b/app/agent/tools/impl/send_voice_message.py index 826ea8a54..b1c3bcc9d 100644 --- a/app/agent/tools/impl/send_voice_message.py +++ b/app/agent/tools/impl/send_voice_message.py @@ -1,7 +1,7 @@ """ 发送语音消息工具。 """ -from typing import Optional, Type +from typing import Any, Optional, Type from pydantic import BaseModel, Field @@ -61,7 +61,7 @@ class SendVoiceMessageTool(MoviePilotTool): description="工具输入参数的验证模型或 JSON Schema", ) - def get_tool_message(self, **kwargs) -> Optional[str]: + def get_tool_message(self, **kwargs: Any) -> Optional[str]: """ 生成语音回复工具的执行提示。 """ @@ -70,7 +70,7 @@ class SendVoiceMessageTool(MoviePilotTool): message = message[:40] + "..." return f"发送语音回复: {message}" - async def run(self, message: str, **kwargs: object) -> str: + async def run(self, message: str, **kwargs: Any) -> str: # type: ignore[override] """ 合成语音并发送到当前对话渠道,不支持时回退为文字。 """ diff --git a/app/agent/tools/impl/service.py b/app/agent/tools/impl/service.py index 2c4357c0c..a4ecd8d7d 100644 --- a/app/agent/tools/impl/service.py +++ b/app/agent/tools/impl/service.py @@ -10,7 +10,7 @@ import subprocess import sys from functools import lru_cache from pathlib import Path -from typing import Any, ClassVar, Dict, Optional, Type +from typing import Any, ClassVar, Dict, Optional, Type, cast from pydantic import BaseModel, ConfigDict, Field @@ -21,8 +21,7 @@ from app.runtime.settings import get_runtime_setting from app.schemas.mediaserver import MediaServerQueryOptions -# Pydantic 在运行时生成模型成员,当前类型检查环境无法完整解析其动态基类。 -class DownloaderOperationInput(BaseModel): # type: ignore[misc] +class DownloaderOperationInput(BaseModel): """ 下载器操作工具的运行时输入模型。 """ @@ -42,8 +41,7 @@ class DownloaderOperationInput(BaseModel): # type: ignore[misc] model_config = ConfigDict(extra="forbid") -# Pydantic 在运行时生成模型成员,当前类型检查环境无法完整解析其动态基类。 -class MediaServerOperationInput(BaseModel): # type: ignore[misc] +class MediaServerOperationInput(BaseModel): """ 媒体服务器操作工具的运行时输入模型。 """ @@ -63,8 +61,7 @@ class MediaServerOperationInput(BaseModel): # type: ignore[misc] model_config = ConfigDict(extra="forbid") -# Pydantic 在运行时生成模型成员,当前类型检查环境无法完整解析其动态基类。 -class DatabaseOperationInput(BaseModel): # type: ignore[misc] +class DatabaseOperationInput(BaseModel): """ 数据库操作工具的运行时输入模型。 """ @@ -408,7 +405,7 @@ class _ServiceOperationTool(MoviePilotTool): actions = _load_action_contracts(str(root_path / self._relative_script)) selector_description = None if self._selector_name: - selector_description = self.args_schema.model_json_schema()["properties"][self._selector_name][ + selector_description = cast(type[BaseModel], self.get_input_schema()).model_json_schema()["properties"][self._selector_name][ "description" ] return _build_mcp_input_schema( diff --git a/app/agent/tools/impl/view_image.py b/app/agent/tools/impl/view_image.py index 7e21f2474..f1c6f5de7 100644 --- a/app/agent/tools/impl/view_image.py +++ b/app/agent/tools/impl/view_image.py @@ -51,8 +51,7 @@ class ViewImageError(ValueError): self.message = message -# Pydantic 在运行时生成模型成员,当前类型检查环境无法完整解析其动态基类。 -class ViewImageInput(BaseModel): # type: ignore[misc] +class ViewImageInput(BaseModel): """ 图片查看工具的输入参数模型。 """ @@ -75,7 +74,7 @@ class ViewImageInput(BaseModel): # type: ignore[misc] ) # Pydantic 校验器由运行时注册,保留第三方装饰器所需的动态类型兼容。 - @model_validator(mode="after") # type: ignore[misc] + @model_validator(mode="after") def require_one_source(self) -> "ViewImageInput": """ 确保图片来源恰好是 URL、本地路径或图片内容中的一个。 @@ -196,7 +195,7 @@ class ViewImageTool(MoviePilotTool): failure = ViewImageError("invalid_image_result", "图片数据无效或超过大小上限") return self._failure(failure.code, failure.message) - async def run( + async def run( # type: ignore[override] self, url: Optional[str] = None, file_path: Optional[str] = None, diff --git a/app/agent/tools/impl/write_file.py b/app/agent/tools/impl/write_file.py index 9e0ee4a0d..6d0ff040f 100644 --- a/app/agent/tools/impl/write_file.py +++ b/app/agent/tools/impl/write_file.py @@ -3,7 +3,7 @@ """ from pathlib import Path -from typing import Optional, Type +from typing import Any, Optional, Type from anyio import Path as AsyncPath from pydantic import BaseModel, Field @@ -70,7 +70,7 @@ class WriteFileTool(MoviePilotTool): description="工具输入参数的验证模型或 JSON Schema", ) - def get_tool_message(self, **kwargs) -> Optional[str]: + def get_tool_message(self, **kwargs: Any) -> Optional[str]: """ 根据参数生成友好的提示消息 """ @@ -78,13 +78,13 @@ class WriteFileTool(MoviePilotTool): file_name = Path(file_path).name if file_path else "未知文件" return f"写入文件: {file_name}" - async def run( + async def run( # type: ignore[override] self, file_path: str, content: str, overwrite: bool = False, expected_sha256: Optional[str] = None, - **kwargs, + **kwargs: Any, ) -> str: """ 创建或显式覆盖文件,并通过可选哈希阻止陈旧写入。 @@ -97,6 +97,8 @@ class WriteFileTool(MoviePilotTool): ) if access_error: return access_error + if resolved_path is None: + return "错误:无法解析写入路径" path = AsyncPath(resolved_path) @@ -121,7 +123,7 @@ class WriteFileTool(MoviePilotTool): "default", calculate_file_sha256, local_path ) if expected_sha256: - if current_sha256.casefold() != expected_sha256.casefold(): + if current_sha256 is None or current_sha256.casefold() != expected_sha256.casefold(): return ( f"错误:文件 {resolved_path} 已在读取后发生变化,拒绝覆盖。" "请重新读取文件并基于最新内容写入。" diff --git a/app/agent/tools/result.py b/app/agent/tools/result.py index 91d880b6b..d5e0f9927 100644 --- a/app/agent/tools/result.py +++ b/app/agent/tools/result.py @@ -21,9 +21,7 @@ _ERROR_ENVELOPE_KEYS = frozenset({ }) -# follow_imports=skip 下第三方异常基类按 Any 处理,仅忽略 SDK 边界。 -# 工具异常继承第三方动态基类,当前类型检查环境缺少完整基类信息。 -class ToolExecutionError(ToolException): # type: ignore[misc] +class ToolExecutionError(ToolException): """ 携带宿主已脱敏说明的可恢复工具故障,避免伪装为成功字符串。 """ diff --git a/app/api/dependencies/history.py b/app/api/dependencies/history.py index faf456292..898bf8997 100644 --- a/app/api/dependencies/history.py +++ b/app/api/dependencies/history.py @@ -2,6 +2,8 @@ 历史、媒体服务器与 Dashboard 查询依赖。 """ +from typing import cast + from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session @@ -9,13 +11,22 @@ from sqlalchemy.orm import Session from app.api.context import get_async_session, get_host_runtime, get_sync_session from app.application.dashboard import DashboardQueryService from app.application.history import ( + AsyncDownloadHistoryQueryRepository, DownloadHistoryMutationCommand, HistoryQueryService, TransferHistoryLookupService, TransferHistoryMutationCommand, ) +from app.application.history.mutation import ( + DownloadFileMutationRepository, + DownloadHistoryMutationRepository, + HistoryUnitOfWork, +) from app.application.history.retry import clear_transfer_failures -from app.application.mediaserver import MediaServerQueryService +from app.application.mediaserver import ( + AsyncMediaServerQueryRepository, + MediaServerQueryService, +) from app.application.transfer.execution import TransferExecutionRepository from app.chain.storage import StorageChain from app.runtime.events import eventmanager @@ -32,7 +43,10 @@ def get_mediaserver_query_service( 组装媒体服务器本地条目异步查询服务。 """ return MediaServerQueryService( - repository=runtime.history.media_server_repository(db) + repository=cast( + AsyncMediaServerQueryRepository, + runtime.history.media_server_repository(db), + ) ) @@ -58,8 +72,13 @@ def get_download_history_mutation_command( 组装下载历史删除用例及其请求级事务。 """ return DownloadHistoryMutationCommand( - repository=runtime.history.download_repository(db), - unit_of_work=runtime.persistence.sync_transaction(db), + repository=cast( + DownloadHistoryMutationRepository, + runtime.history.download_repository(db), + ), + unit_of_work=cast( + HistoryUnitOfWork, runtime.persistence.sync_transaction(db) + ), ) @@ -71,7 +90,10 @@ def get_history_query_service( 组装历史列表和详情异步查询服务。 """ return HistoryQueryService( - download_repository=runtime.history.download_repository(db), + download_repository=cast( + AsyncDownloadHistoryQueryRepository, + runtime.history.download_repository(db), + ), transfer_repository=runtime.history.transfer_repository, ) @@ -113,10 +135,15 @@ def get_transfer_history_mutation_command( return TransferHistoryMutationCommand( repository=runtime.history.transfer_mutation_repository(db), - download_repository=runtime.history.download_repository(db), + download_repository=cast( + DownloadFileMutationRepository, + runtime.history.download_repository(db), + ), transfer_execution_repository=runtime.history.transfer_execution_repository, - unit_of_work=runtime.persistence.sync_transaction(db), - file_item_factory=lambda payload: _SchemaFileItem(**payload), + unit_of_work=cast( + HistoryUnitOfWork, runtime.persistence.sync_transaction(db) + ), + file_item_factory=lambda payload: _SchemaFileItem.model_validate(payload), file_exists=storage_chain.exists, delete_media_file=storage_chain.delete_media_file, publish_download_file_deleted=publish_download_file_deleted, diff --git a/app/api/dependencies/workflow.py b/app/api/dependencies/workflow.py index ad2476937..e174e09d6 100644 --- a/app/api/dependencies/workflow.py +++ b/app/api/dependencies/workflow.py @@ -2,6 +2,8 @@ 工作流领域的请求级 command/query 依赖。 """ +from typing import cast + from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session @@ -10,8 +12,11 @@ from app.adapters.external.server import MoviePilotServerHelper from app.api.context import get_async_session, get_host_runtime, get_sync_session from app.application.scheduling import get_scheduler from app.application.workflow import ( + AsyncWorkflowDefinitionRepository, + UnitOfWork, WorkflowDefinitionCommand, WorkflowMutationCommand, + WorkflowMutationRepository, WorkflowQueryService, get_workflow_manager, ) @@ -30,8 +35,10 @@ def get_workflow_mutation_command( workflow_manager = get_workflow_manager() system_config = runtime.workflow.system_config() return WorkflowMutationCommand( - repository=runtime.workflow.repository(db), - unit_of_work=runtime.persistence.sync_transaction(db), + repository=cast( + WorkflowMutationRepository, runtime.workflow.repository(db) + ), + unit_of_work=cast(UnitOfWork, runtime.persistence.sync_transaction(db)), add_timer=scheduler.update_workflow_job, remove_timer=scheduler.remove_workflow_job, load_event=workflow_manager.load_workflow_events, @@ -53,7 +60,9 @@ def get_workflow_definition_command( """ system_config = runtime.workflow.system_config() return WorkflowDefinitionCommand( - repository=runtime.workflow.repository(db), + repository=cast( + AsyncWorkflowDefinitionRepository, runtime.workflow.repository(db) + ), unit_of_work=runtime.persistence.async_transaction(db), stop_running=runtime_stop_state.stop_workflow, async_delete_cache=lambda workflow_id: system_config.async_delete( diff --git a/app/api/endpoints/agent.py b/app/api/endpoints/agent.py index ea04b2229..f8a65d2ac 100644 --- a/app/api/endpoints/agent.py +++ b/app/api/endpoints/agent.py @@ -131,7 +131,7 @@ async def save_agent_mcp_servers( _ensure_superuser(current_user) success = await agent_mcp_manager.save_servers(request.servers) return _SchemaResponse( - success=success, + success=bool(success), message="保存MCP配置成功" if success else "保存MCP配置失败", ) @@ -373,7 +373,7 @@ async def run_agent_command( ) except ValueError as error: return _SchemaResponse(success=False, message=str(error)) - return _SchemaResponse(success=True, message=data.get("message"), data=data) + return _SchemaResponse(success=True, message=data.get("message") or "", data=data) @router.get( diff --git a/app/api/endpoints/auth.py b/app/api/endpoints/auth.py index 5b8061946..72be69cff 100644 --- a/app/api/endpoints/auth.py +++ b/app/api/endpoints/auth.py @@ -25,7 +25,7 @@ router = ResponseAPIRouter() # 沿用当前 Pydantic 动态基类的类型检查兼容处理。 -class AuthExchangeRequest(BaseModel): # type: ignore[misc] +class AuthExchangeRequest(BaseModel): """ 插件认证票据兑换请求。 """ diff --git a/app/api/endpoints/bangumi.py b/app/api/endpoints/bangumi.py index 73403e74e..cf4ae3fc8 100644 --- a/app/api/endpoints/bangumi.py +++ b/app/api/endpoints/bangumi.py @@ -33,7 +33,9 @@ async def bangumi_credits( """ persons = await BangumiChain().async_bangumi_credits(bangumiid) if persons: - return persons[(page - 1) * count : page * count] + page_value = page or 1 + count_value = count or 20 + return persons[(page_value - 1) * count_value : page_value * count_value] return [] @@ -53,7 +55,12 @@ async def bangumi_recommend( """ medias = await BangumiChain().async_bangumi_recommend(bangumiid) if medias: - return [media.to_dict() for media in medias[(page - 1) * count : page * count]] + page_value = page or 1 + count_value = count or 20 + return [ + media.to_dict() + for media in medias[(page_value - 1) * count_value : page_value * count_value] + ] return [] @@ -85,7 +92,12 @@ async def bangumi_person_credits( """ medias = await BangumiChain().async_person_credits(person_id=person_id) if medias: - return [media.to_dict() for media in medias[(page - 1) * count : page * count]] + page_value = page or 1 + count_value = count or 20 + return [ + media.to_dict() + for media in medias[(page_value - 1) * count_value : page_value * count_value] + ] return [] diff --git a/app/api/endpoints/classification.py b/app/api/endpoints/classification.py index b98644755..357841eac 100644 --- a/app/api/endpoints/classification.py +++ b/app/api/endpoints/classification.py @@ -6,7 +6,7 @@ from __future__ import annotations from enum import Enum from functools import partial -from typing import cast +from typing import Any, cast from fastapi import Depends, HTTPException, Query, status from fastapi.responses import JSONResponse @@ -61,7 +61,7 @@ from app.startup.composition.context import HostRuntime router = ResponseAPIRouter() -_STRUCTURED_WRITE_RESPONSES = { +_STRUCTURED_WRITE_RESPONSES: dict[int | str, dict[str, Any]] = { status.HTTP_409_CONFLICT: { "model": Response[ClassificationRevisionConflict], "description": "分类策略 revision 冲突", diff --git a/app/api/endpoints/dashboard.py b/app/api/endpoints/dashboard.py index f9b6fb76b..d62d64c1a 100644 --- a/app/api/endpoints/dashboard.py +++ b/app/api/endpoints/dashboard.py @@ -73,7 +73,7 @@ def _build_downloader( # 下载目录空间 download_dirs = DirectoryHelper().get_local_download_dirs() _, free_space = SystemUtils.space_usage( - [Path(d.download_path) for d in download_dirs], + [Path(d.download_path) for d in download_dirs if d.download_path], btrfs_fsid_dedup=btrfs_fsid_dedup, ) # 下载器信息 @@ -81,10 +81,10 @@ def _build_downloader( transfer_infos = DashboardChain().downloader_info(name) if transfer_infos: for transfer_info in transfer_infos: - downloader_info.download_speed += transfer_info.download_speed - downloader_info.upload_speed += transfer_info.upload_speed - downloader_info.download_size += transfer_info.download_size - downloader_info.upload_size += transfer_info.upload_size + downloader_info.download_speed = (downloader_info.download_speed or 0) + (transfer_info.download_speed or 0) + downloader_info.upload_speed = (downloader_info.upload_speed or 0) + (transfer_info.upload_speed or 0) + downloader_info.download_size = (downloader_info.download_size or 0) + (transfer_info.download_size or 0) + downloader_info.upload_size = (downloader_info.upload_size or 0) + (transfer_info.upload_size or 0) downloader_info.free_space = free_space return downloader_info @@ -251,7 +251,7 @@ async def transfer( """ 查询文件整理统计信息 """ - return await service.transfer(days) + return await service.transfer(days or 7) @router.get("/cpu", summary="获取当前CPU使用率", response_model=float) diff --git a/app/api/endpoints/discover.py b/app/api/endpoints/discover.py index 1d066c6aa..084f695ce 100644 --- a/app/api/endpoints/discover.py +++ b/app/api/endpoints/discover.py @@ -38,8 +38,8 @@ def source(_: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageP event_data = DiscoverSourceEventData() event = eventmanager.send_event(ChainEventType.DiscoverSource, event_data) # 使用事件返回的上下文数据 - if event and event.event_data: - event_data: DiscoverSourceEventData = event.event_data + if event and isinstance(event.event_data, DiscoverSourceEventData): + event_data = event.event_data if event_data.extra_sources: return event_data.extra_sources return [] @@ -59,7 +59,7 @@ async def bangumi( 探索Bangumi """ medias = await BangumiChain().async_discover( - type=type, cat=cat, sort=sort, year=year, limit=count, offset=(page - 1) * count + type=type, cat=cat, sort=sort, year=year, limit=count, offset=((page or 1) - 1) * (count if count is not None else 30) ) if medias: return [media.to_dict() for media in medias] @@ -80,7 +80,7 @@ async def douban_movies( 浏览豆瓣电影信息 """ movies = await DoubanChain().async_douban_discover( - mtype=MediaType.MOVIE, sort=sort, tags=tags, page=page, count=count + mtype=MediaType.MOVIE, sort=sort or "R", tags=tags or "", page=page, count=count ) return [media.to_dict() for media in movies] if movies else [] @@ -99,7 +99,7 @@ async def douban_tvs( 浏览豆瓣剧集信息 """ tvs = await DoubanChain().async_douban_discover( - mtype=MediaType.TV, sort=sort, tags=tags, page=page, count=count + mtype=MediaType.TV, sort=sort or "R", tags=tags or "", page=page, count=count ) return [media.to_dict() for media in tvs] if tvs else [] @@ -124,14 +124,14 @@ async def tmdb_movies( """ movies = await TmdbChain().async_tmdb_discover( mtype=MediaType.MOVIE, - sort_by=sort_by, - with_genres=with_genres, - with_original_language=with_original_language, - with_keywords=with_keywords, - with_watch_providers=with_watch_providers, - vote_average=vote_average, - vote_count=vote_count, - release_date=release_date, + sort_by=sort_by or "popularity.desc", + with_genres=with_genres or "", + with_original_language=with_original_language or "", + with_keywords=with_keywords or "", + with_watch_providers=with_watch_providers or "", + vote_average=vote_average or 0.0, + vote_count=vote_count or 0, + release_date=release_date or "", page=page, ) return [movie.to_dict() for movie in movies] if movies else [] @@ -155,14 +155,14 @@ async def tmdb_tvs( """ tvs = await TmdbChain().async_tmdb_discover( mtype=MediaType.TV, - sort_by=sort_by, - with_genres=with_genres, - with_original_language=with_original_language, - with_keywords=with_keywords, - with_watch_providers=with_watch_providers, - vote_average=vote_average, - vote_count=vote_count, - release_date=release_date, + sort_by=sort_by or "popularity.desc", + with_genres=with_genres or "", + with_original_language=with_original_language or "", + with_keywords=with_keywords or "", + with_watch_providers=with_watch_providers or "", + vote_average=vote_average or 0.0, + vote_count=vote_count or 0, + release_date=release_date or "", page=page, ) return [tv.to_dict() for tv in tvs] if tvs else [] diff --git a/app/api/endpoints/download.py b/app/api/endpoints/download.py index 39581234f..4f6b5bd90 100644 --- a/app/api/endpoints/download.py +++ b/app/api/endpoints/download.py @@ -69,6 +69,8 @@ def _prepare_subtitle_download( """ if subtitle.site is None: return False, "字幕站点信息为空" + if not subtitle.enclosure: + return False, "字幕下载链接为空" clean_url = SecurityUtils.verify_signed_url( subtitle.enclosure, @@ -102,19 +104,21 @@ def _build_unrecognized_media_info( 两者都不再要求识别出统一媒体信息即可继续下载。 """ if is_music: + raw_year = metainfo.year + music_year = int(raw_year) if isinstance(raw_year, str) and raw_year.isdigit() else raw_year if isinstance(raw_year, int) else None return MusicInfo( title=metainfo.title or torrent.title, - year=metainfo.year, + year=music_year, music_type=music_type or MUSIC_ENTITY_RECORDING, ) try: media_type = MediaType(torrent.category) except (TypeError, ValueError): - media_type = MediaType.from_agent(torrent.category) + media_type = MediaType.from_agent(torrent.category or "") or MediaType.UNKNOWN if media_type == MediaType.COLLECTION: media_type = MediaType.MOVIE if media_type not in (MediaType.MOVIE, MediaType.TV): - media_type = metainfo.type + media_type = metainfo.type or MediaType.UNKNOWN # 合集类型在回退到元数据后同样归一为电影,避免落到 UNKNOWN if media_type == MediaType.COLLECTION: media_type = MediaType.MOVIE @@ -123,7 +127,7 @@ def _build_unrecognized_media_info( return MediaInfo( type=media_type, title=metainfo.name or torrent.title, - year=metainfo.year, + year=str(metainfo.year) if metainfo.year is not None else None, ) @@ -173,7 +177,7 @@ def _resolve_add_media( ) if is_music and not normalized_music_type: normalized_music_type = MUSIC_ENTITY_RECORDING - metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description, + metainfo = MetaInfo(title=torrent_in.title or "", subtitle=torrent_in.description, mtype=MediaType.MUSIC if is_music else None) if media_source and media_id: mediainfo = MediaChain().recognize_media( @@ -238,12 +242,13 @@ def download( """ 添加下载任务(含媒体信息) """ + mediainfo: MediaInfo | MusicInfo if isinstance(media_in, _SchemaMusicInfo): mediainfo = MusicInfo.from_dict(media_in.model_dump()) else: mediainfo = MediaInfo() mediainfo.from_dict(media_in.model_dump()) - metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description, mtype=mediainfo.type) + metainfo = MetaInfo(title=torrent_in.title or "", subtitle=torrent_in.description, mtype=mediainfo.type) # 种子信息 torrentinfo = TorrentInfo() torrentinfo.from_dict(torrent_in.model_dump()) diff --git a/app/api/endpoints/github.py b/app/api/endpoints/github.py index b9acc8816..7f335d358 100644 --- a/app/api/endpoints/github.py +++ b/app/api/endpoints/github.py @@ -3,6 +3,7 @@ GitHub Token 授权接口及首次初始化专用授权接口。 """ from dataclasses import asdict +from typing import Literal, cast from fastapi import Depends, HTTPException @@ -55,7 +56,7 @@ def _status_model(status: GithubAuthStatus) -> GithubTokenStatus: return GithubTokenStatus( configured=status.configured, valid=status.valid, - source=status.source, + source=cast(Literal["oauth", "manual"] | None, status.source), login=status.login, masked_token=status.masked_token, expires_at=status.expires_at, @@ -142,7 +143,7 @@ async def poll_auth( return _SchemaResponse( success=True, data=GithubDeviceAuthPoll( - state=result.state, + state=cast(Literal["pending", "authorized", "slow_down", "denied", "expired", "failed"], result.state), message=result.message, retry_after=result.retry_after, status=status, @@ -256,7 +257,7 @@ async def poll_initialization_auth( return _SchemaResponse( success=True, data=GithubDeviceAuthPoll( - state=result.state, + state=cast(Literal["pending", "authorized", "slow_down", "denied", "expired", "failed"], result.state), message=result.message, retry_after=result.retry_after, status=status, diff --git a/app/api/endpoints/history.py b/app/api/endpoints/history.py index 3e71fbeae..f4cd16b7e 100644 --- a/app/api/endpoints/history.py +++ b/app/api/endpoints/history.py @@ -483,7 +483,7 @@ def _submit_legacy_batch_ai_redo( all_history_ids: list[int], messages: list[str], task_registry: TaskRegistry, -) -> _SchemaResponse[_SchemaBatchProgressKeyData]: +) -> _SchemaResponse[dict[str, Any]]: """ 提交旧整理历史给 Agent,并构造批量进度兼容响应。 """ @@ -517,11 +517,13 @@ async def download_history( count: Optional[int] = 30, query: HistoryQueryService = Depends(get_history_query_service), _: _SchemaTokenPayload = Depends(verify_token), - response: Response = None, + response: Response = None, # type: ignore[assignment] ) -> Any: """ 按下载时间倒序查询下载历史记录 """ + page = page if page is not None else 1 + count = count if count is not None else 30 results = await query.list_download(page=page, count=count) if response is not None: response.headers[COLLECTION_TOTAL_HEADER] = str(await query.count_download()) @@ -561,6 +563,8 @@ async def transfer_history( """ 查询整理记录,title 支持通配符 * 和 ?(如 *.mkv、*2024*) """ + page = page if page is not None else 1 + count = count if count is not None else 30 result = await query.list_transfer( title=title, page=page, diff --git a/app/api/endpoints/identifier.py b/app/api/endpoints/identifier.py index c32c37d6b..99f9ed3b4 100644 --- a/app/api/endpoints/identifier.py +++ b/app/api/endpoints/identifier.py @@ -81,4 +81,4 @@ async def update_custom_identifiers( detail=str(error), ) from error data.update({"count": len(identifiers), "identifiers": identifiers}) - return Response(success=True, message=data.get("message"), data=data) + return Response(success=True, message=data.get("message") or "", data=data) diff --git a/app/api/endpoints/llm.py b/app/api/endpoints/llm.py index 7fa07a868..5b595344e 100644 --- a/app/api/endpoints/llm.py +++ b/app/api/endpoints/llm.py @@ -47,7 +47,7 @@ async def manage_provider( ) return _SchemaResponse( success=bool(result.get("success")), - message=result.get("message"), + message=result.get("message") or "", data=result.get("data"), ) diff --git a/app/api/endpoints/login.py b/app/api/endpoints/login.py index 9788f2a67..d86e2e285 100644 --- a/app/api/endpoints/login.py +++ b/app/api/endpoints/login.py @@ -4,7 +4,7 @@ import asyncio from datetime import timedelta -from typing import Annotated, Any, List +from typing import Annotated, Any, List, cast from fastapi import Depends, Form, HTTPException, Request, Response from fastapi.responses import JSONResponse @@ -34,6 +34,7 @@ from app.schemas.response import Response as _SchemaResponse from app.schemas.token import MfaChallenge as _SchemaMfaChallenge from app.schemas.token import Token as _SchemaToken from app.schemas.token import TokenPayload as _SchemaTokenPayload +from app.schemas.user import UserPermissions router = ResponseAPIRouter() _INITIALIZATION_LOCK = asyncio.Lock() @@ -240,7 +241,7 @@ def login_access_token( user_name=user.name, avatar=user.avatar, level=level, - permissions=user.permissions or {}, + permissions=cast(UserPermissions, user.permissions or {}), ) diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index 59f41e2df..f040bcca3 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -484,14 +484,15 @@ def _scrape_impl( message="音乐实体类型无效,仅支持 recording 或 album", ) # 音乐与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情 - music_info = MediaChain().recognize_media( + recognized_music = MediaChain().recognize_media( media_source=media_source or MediaSource.MusicBrainz, media_id=normalized_media_id, mtype=MediaType.MUSIC, music_type=normalized_music_type, ) - if not music_info: + if not isinstance(recognized_music, MusicInfo): return _SchemaResponse(success=False, message="刮削失败,无法识别音乐信息") + music_info = recognized_music success, message = ScrapingChain().scrape_music_metadata( fileitem=fileitem, mediainfo=music_info, @@ -512,7 +513,7 @@ def _scrape_impl( media_id=normalized_media_id, episode_group=episode_group, ) - if media_info: + if media_info and media_source: media_info.scrape_source = media_source chain.obtain_images(mediainfo=media_info) else: @@ -578,7 +579,7 @@ def scrape( def get_category_config( _: object = Depends(get_current_active_user), classification: ClassificationRuntime = Depends(get_classification_runtime), -): +) -> Any: """ 获取分类策略配置 """ diff --git a/app/api/endpoints/mediaserver.py b/app/api/endpoints/mediaserver.py index 83a8ecb95..f3dc87a76 100644 --- a/app/api/endpoints/mediaserver.py +++ b/app/api/endpoints/mediaserver.py @@ -27,7 +27,6 @@ from app.domain.metainfo import MetaInfo from app.schemas.common import ServiceClientInfo as _SchemaServiceClientInfo from app.schemas.context import MediaInfo as _SchemaMediaInfo from app.schemas.media import build_media_key, resolve_media_identity -from app.schemas.mediaserver import ExistMediaInfo as _SchemaExistMediaInfo from app.schemas.mediaserver import MediaServerExistingEpisodes as _SchemaMediaServerExistingEpisodes from app.schemas.mediaserver import MediaServerExistsData as _SchemaMediaServerExistsData from app.schemas.mediaserver import MediaServerField, MediaServerQueryOptions, NotExistMediaInfo @@ -42,11 +41,11 @@ from app.schemas.types import MediaSource, MediaType, SystemConfigKey router = ResponseAPIRouter() # 首页卡片只消费展示内容,播放进度仅由继续观看卡片使用。 -_CARD_QUERY = MediaServerQueryOptions(fields={MediaServerField.YEAR, MediaServerField.IMAGES, MediaServerField.LINK}) +_CARD_QUERY = MediaServerQueryOptions(fields=frozenset({MediaServerField.YEAR, MediaServerField.IMAGES, MediaServerField.LINK})) _RESUME_QUERY = _CARD_QUERY.with_fields(MediaServerField.USER_STATE) -_LIBRARY_QUERY = MediaServerQueryOptions(fields={ +_LIBRARY_QUERY = MediaServerQueryOptions(fields=frozenset({ MediaServerField.PATH, MediaServerField.IMAGES, MediaServerField.COUNTS, MediaServerField.LINK, -}) +})) def _require_mediaserver_result(result: Optional[List[Any]]) -> List[Any]: @@ -66,7 +65,9 @@ def _require_mediaserver_result(result: Optional[List[Any]]) -> List[Any]: summary="在线播放", response_model=_SchemaResponse[_SchemaMediaServerPlayData], ) -def play_item(itemid: str, _: _SchemaTokenPayload = Depends(verify_token)) -> _SchemaResponse: +def play_item( + itemid: str, _: _SchemaTokenPayload = Depends(verify_token) +) -> _SchemaResponse[dict[str, Any]]: """ 获取媒体服务器播放页面地址 """ @@ -78,10 +79,12 @@ def play_item(itemid: str, _: _SchemaTokenPayload = Depends(verify_token)) -> _S media_chain = MediaServerChain() for config in configs: name = config.name + if not name: + continue item = media_chain.iteminfo( server=name, item_id=itemid, - query=MediaServerQueryOptions(fields=set()), + query=MediaServerQueryOptions(fields=frozenset()), ) if item: play_url = media_chain.get_play_url(server=name, item_id=itemid) @@ -152,14 +155,15 @@ def exists(media_in: _SchemaMediaInfo, _: _SchemaTokenPayload = Depends(verify_t # 转化为媒体信息对象 mediainfo = MediaInfo() mediainfo.from_dict(media_in.model_dump()) - existsinfo: _SchemaExistMediaInfo = MediaServerChain().media_exists( + existsinfo = MediaServerChain().media_exists( mediainfo=mediainfo, query=MEDIA_SERVER_EXISTENCE_QUERY ) if not existsinfo: return {} + seasons = existsinfo.seasons or {} if media_in.season is not None: - return {media_in.season: existsinfo.seasons.get(media_in.season) or []} - return existsinfo.seasons + return {media_in.season: seasons.get(media_in.season) or []} + return seasons @router.post( @@ -177,7 +181,7 @@ def not_exists( 根据媒体信息查询缺失电影/剧集,媒体类型兼容英文与中文值 """ # 媒体信息 - meta = MetaInfo(title=media_in.title) + meta = MetaInfo(title=media_in.title or "") mtype = MediaType.from_agent(media_in.type) or MediaType(media_in.type) if media_in.type else None if mtype: meta.type = mtype @@ -257,7 +261,7 @@ def library( MediaServerChain().librarys( server=server, username=userinfo.username, - hidden=hidden, + hidden=hidden if hidden is not None else False, query=_LIBRARY_QUERY, ) ) diff --git a/app/api/endpoints/message.py b/app/api/endpoints/message.py index 66ac0f5f3..70316da36 100644 --- a/app/api/endpoints/message.py +++ b/app/api/endpoints/message.py @@ -144,7 +144,7 @@ async def user_message( form = await request.form() args = request.query_params source = args.get("source") - content_type = request.headers.get("content-type", "") + content_type = request.headers.get("content-type") or "" body_text = body.decode("utf-8", errors="replace") image_markers = [ marker @@ -183,7 +183,7 @@ async def web_message( WEB消息响应 """ images = None - content_type = request.headers.get("content-type", "") + content_type = request.headers.get("content-type") or "" if "application/json" in content_type: try: payload = await request.json() @@ -218,11 +218,14 @@ async def get_web_message( service: MessageQueryService = Depends(get_message_query_service), page: Optional[int] = 1, count: Optional[int] = 20, -): +) -> List[dict[str, Any]]: """ 获取WEB消息列表 """ - return await service.list_web(page=page, count=count) + return await service.list_web( + page=page if page is not None else 1, + count=count if count is not None else 20, + ) @router.get("/notification", summary="获取通知消息", response_model=List[_SchemaMessageHistoryItem]) @@ -237,8 +240,8 @@ async def get_notification_message( """ clear_before = _get_notification_clear_before() messages = await service.list_notifications( - page=page, - count=count, + page=page if page is not None else 1, + count=count if count is not None else 20, all_clear_before=_format_notification_clear_time(clear_before.all), system_clear_before=_format_notification_clear_time(clear_before.system), media_clear_before=_format_notification_clear_time(clear_before.media), @@ -254,7 +257,7 @@ async def get_notification_message( async def clear_notification_message( scope: _SchemaMessageClearScope = _SchemaMessageClearScope.All, _: _SchemaTokenPayload = Depends(verify_token), -) -> _SchemaResponse[_SchemaMessageClearData]: +) -> _SchemaResponse[dict[str, Any]]: """ 记录通知中心清理时间,后续通知历史查询会在服务端过滤。 """ @@ -285,18 +288,19 @@ def wechat_verify( for config in client_configs if config.type == "wechat" and config.enabled - and config.config.get("WECHAT_MODE", "app") != "bot" + and (config.config or {}).get("WECHAT_MODE", "app") != "bot" and (not source or config.name == source) ), None, ) if not client_config: return "未找到对应的消息配置" + config = client_config.config or {} try: wxcpt = WXBizMsgCrypt( - sToken=client_config.config.get("WECHAT_TOKEN"), - sEncodingAESKey=client_config.config.get("WECHAT_ENCODING_AESKEY"), - sReceiveId=client_config.config.get("WECHAT_CORPID"), + sToken=config.get("WECHAT_TOKEN"), + sEncodingAESKey=config.get("WECHAT_ENCODING_AESKEY"), + sReceiveId=config.get("WECHAT_CORPID"), ) ret, sEchoStr = wxcpt.VerifyURL( sMsgSignature=msg_signature, diff --git a/app/api/endpoints/mfa.py b/app/api/endpoints/mfa.py index 34d098bf0..e20ecc837 100644 --- a/app/api/endpoints/mfa.py +++ b/app/api/endpoints/mfa.py @@ -7,6 +7,7 @@ import json from typing import Annotated, Any, Optional from fastapi import Body, Depends, HTTPException, Request, Response +from pydantic import BaseModel as _SchemaBaseModel from pydantic import Field as _Field from app.adapters.web.security.access import set_or_refresh_resource_token_cookie @@ -41,8 +42,7 @@ from app.application.security.user import ( get_configured_user_name_lookup, ) from app.runtime.log import logger -from app.schemas.mcp import BaseModel as _SchemaBaseModel -from app.schemas.mcp import JsonData as _SchemaJsonData +from app.schemas.common import JsonData as _SchemaJsonData from app.schemas.mfa import OtpGenerateData as _SchemaOtpGenerateData from app.schemas.mfa import PasskeyInfo as _SchemaPasskeyInfo from app.schemas.mfa import PasskeyStartData as _SchemaPasskeyStartData @@ -70,7 +70,7 @@ def _build_credential_list(passkeys: list[Any]) -> list[dict[str, Any]]: ) -def _extract_and_standardize_credential_id(credential: dict) -> str: +def _extract_and_standardize_credential_id(credential: dict[str, _SchemaJsonData]) -> str: """ 从凭证中提取并标准化 credential_id @@ -79,13 +79,13 @@ def _extract_and_standardize_credential_id(credential: dict) -> str: :raises ValueError: 如果凭证无效 """ credential_id_raw = credential.get("id") or credential.get("rawId") - if not credential_id_raw: + if not isinstance(credential_id_raw, str) or not credential_id_raw: raise ValueError("无效的凭证") return PassKeyHelper.standardize_credential_id(credential_id_raw) def _verify_passkey_and_update( - credential: dict, + credential: dict[str, _SchemaJsonData], challenge: str, passkey: Any, service: PasskeyService, @@ -256,8 +256,8 @@ def passkey_register_start( options_json, challenge = PassKeyHelper.generate_registration_options( user_id=current_user.id, username=current_user.name, - display_name=current_user.settings.get("nickname") - if current_user.settings + display_name=getattr(current_user, "settings", {}).get("nickname") + if getattr(current_user, "settings", None) else None, existing_credentials=existing_credentials, ) @@ -313,11 +313,13 @@ def passkey_register_finish( # 提取transports transports = None - if ( - "response" in passkey_req.credential - and "transports" in passkey_req.credential["response"] - ): - transports = ",".join(passkey_req.credential["response"]["transports"]) + response_data = passkey_req.credential.get("response") + if isinstance(response_data, dict): + transport_data = response_data.get("transports") + if isinstance(transport_data, list): + transport_values = [item for item in transport_data if isinstance(item, str)] + if len(transport_values) == len(transport_data): + transports = ",".join(transport_values) # 保存到数据库 service.create({ @@ -481,7 +483,7 @@ def passkey_authenticate_finish( ) def passkey_list( current_user: Annotated[ApiPrincipal, Depends(get_current_active_user)], - response: Response = None, + response: Response = None, # type: ignore[assignment] service: PasskeyService = Depends(get_passkey_service), page: CompatiblePageParam = None, count: CompatibleCountParam = None, diff --git a/app/api/endpoints/module.py b/app/api/endpoints/module.py index 33684b4d3..dd5bd0b23 100644 --- a/app/api/endpoints/module.py +++ b/app/api/endpoints/module.py @@ -2,6 +2,8 @@ 系统内置模块目录、开关和可用性测试接口。 """ +from typing import Any + from fastapi import Depends from app.adapters.web.security.access import verify_token @@ -52,7 +54,7 @@ def _module_description(module_id: str, name: str, module_type: str) -> str: ) def modulelist( _: _SchemaTokenPayload = Depends(verify_token), -) -> _SchemaResponse[_SchemaSystemModuleListData]: +) -> _SchemaResponse[dict[str, Any]]: """ 查询当前配置下应参与健康检查的模块 ID 列表。 """ @@ -81,7 +83,7 @@ def modulelist( ) def module_catalog( _: _SchemaTokenPayload = Depends(verify_token), -) -> _SchemaResponse[_SchemaSystemModuleCatalogListData]: +) -> _SchemaResponse[dict[str, Any]]: """ 返回模块及其服务类型目录,供前端选择器统一构造选项。 """ @@ -127,7 +129,7 @@ def module_catalog( ) async def module_settings( _: ApiPrincipal = Depends(get_current_active_superuser_async), -) -> _SchemaResponse[_SchemaSystemModuleSettingListData]: +) -> _SchemaResponse[dict[str, Any]]: """ 查询没有其它激活配置、可由用户统一控制的内置模块。 """ diff --git a/app/api/endpoints/music.py b/app/api/endpoints/music.py index b5f6c42f8..f5915ed86 100644 --- a/app/api/endpoints/music.py +++ b/app/api/endpoints/music.py @@ -2,7 +2,7 @@ 提供音乐身份、曲目、歌词与资源发现接口。 """ -from typing import Annotated, Optional, Union +from typing import Annotated, Any, Optional, Union from fastapi import Depends, HTTPException, Query @@ -139,7 +139,7 @@ async def recognize_music( ) async def music_recognition_cache( _: object = Depends(get_current_active_superuser_async), -) -> _SchemaResponse: +) -> _SchemaResponse[dict[str, Any]]: """ 查询可管理的 MusicBrainz 识别缓存。 """ @@ -353,13 +353,12 @@ def music_library_status( media_chain = MediaChain() for item in request.items: # 音乐模型沿用动态反序列化入口,静态类型无法完整描述返回模型。 - info = MusicInfo.from_dict(item.model_dump()) # type: ignore[misc] + info = MusicInfo.from_dict(item.model_dump()) # 艺术家作品列表来自 Release Group,本身不包含曲数。此查询的语义 # 是“已存在/已入库”,无曲数时一首匹配曲目即足以阻止重复下载。 expected_tracks = info.total_tracks or 1 info.total_tracks = expected_tracks - # 音乐媒体复用既有媒体存在性接口,其静态参数类型尚未包含音乐模型。 - exists = media_chain.media_exists(mediainfo=info, query=MEDIA_SERVER_EXISTENCE_QUERY) is not None # type: ignore[arg-type] + exists = media_chain.media_exists(mediainfo=info, query=MEDIA_SERVER_EXISTENCE_QUERY) is not None assert item.media_source is not None assert item.media_id is not None statuses.append( diff --git a/app/api/endpoints/notification.py b/app/api/endpoints/notification.py index b4d30c3fe..b3921f5eb 100644 --- a/app/api/endpoints/notification.py +++ b/app/api/endpoints/notification.py @@ -106,6 +106,6 @@ def manage_channel( ) return _SchemaResponse( success=bool(result.get("success")), - message=result.get("message"), + message=result.get("message") or "", data=result.get("data"), ) diff --git a/app/api/endpoints/openai.py b/app/api/endpoints/openai.py index bb50a3d30..92d5724c0 100644 --- a/app/api/endpoints/openai.py +++ b/app/api/endpoints/openai.py @@ -8,7 +8,7 @@ import asyncio import time import uuid from threading import Lock -from typing import TYPE_CHECKING, AsyncIterator, List, Optional, Tuple, cast +from typing import TYPE_CHECKING, Any, AsyncIterator, List, Optional, Tuple, cast from fastapi import APIRouter, Depends, HTTPException, Request, Security from fastapi.responses import JSONResponse, StreamingResponse @@ -77,7 +77,7 @@ class _CollectingMoviePilotAgentMixin: _graphs: AgentGraphCache - def __init__(self, *args, stream_mode: bool = False, **kwargs): + def __init__(self, *args: Any, stream_mode: bool = False, **kwargs: Any) -> None: """ 初始化协议 Agent 的消息收集与流式模式。 """ @@ -144,6 +144,9 @@ class _OpenAIStreamingHandlerMixin: 将 Agent 流式输出转发到 OpenAI SSE 队列,不向站内消息系统落消息。 """ + _lock: Lock + _buffer: str + def __init__(self) -> None: """ 初始化流式事件队列绑定。 @@ -164,20 +167,23 @@ class _OpenAIStreamingHandlerMixin: if self._event_queue is queue: self._event_queue = None - def emit(self, token: str): + def emit(self, token: str) -> str: """ 发送已整理的流式文本到请求事件队列。 """ - emitted = super().emit(token) + emit_base = getattr(super(), "emit", None) + emitted = str(emit_base(token)) if callable(emit_base) else token if emitted and self._event_queue is not None: self._event_queue.put_nowait(emitted) + return emitted def emit_tool_message(self, message: str) -> str: """ 将工具提示转发到 OpenAI SSE 队列,同时保留流式缓冲内容。 """ # 沿用动态流式缓冲基类的方法调用,将返回值统一转换为文本。 - emitted = str(super().emit_tool_message(message)) # type: ignore[misc] + emit_base = getattr(super(), "emit_tool_message", None) + emitted = str(emit_base(message)) if callable(emit_base) else message if emitted and self._event_queue is not None: self._event_queue.put_nowait(emitted) return emitted @@ -186,10 +192,11 @@ class _OpenAIStreamingHandlerMixin: """ 结算待发送的工具摘要并写入事件队列。 """ - emitted = super().flush_pending_tool_summary() + flush_base = getattr(super(), "flush_pending_tool_summary", None) + emitted = str(flush_base()) if callable(flush_base) else "" if emitted and self._event_queue is not None: self._event_queue.put_nowait(emitted) - return emitted + return str(emitted) async def start_streaming( self, diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index 63b984fe3..cd81cfd2e 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -4,7 +4,8 @@ import asyncio import mimetypes -from typing import Annotated, Any, Dict, List, Optional +from collections.abc import AsyncIterator +from typing import Annotated, Any, Dict, List, Mapping, Optional, cast from urllib.parse import urlsplit import aiofiles @@ -92,7 +93,7 @@ from app.startup.composition.context import HostRuntime router = ResponseAPIRouter() router.routes.extend(plugin_folders_router.routes) -_plugin_release_refresh_tasks: set[asyncio.Task] = set() +_plugin_release_refresh_tasks: set[asyncio.Task[Any]] = set() def _plugin_source_identity_schema(identity: Any) -> _SchemaPluginSourceIdentity: @@ -131,7 +132,7 @@ def _schedule_plugin_release_refresh(plugin_id: str, repo_url: str, task_registr ) _plugin_release_refresh_tasks.add(task) - def _discard_task(completed_task: asyncio.Task) -> None: + def _discard_task(completed_task: asyncio.Task[Any]) -> None: """ 从受管集合移除已经结束的插件更新任务。 """ @@ -140,7 +141,7 @@ def _schedule_plugin_release_refresh(plugin_id: str, repo_url: str, task_registr task.add_done_callback(_discard_task) -def register_plugin(plugin_id: str): +def register_plugin(plugin_id: str) -> None: """ 注册一个插件相关的服务 """ @@ -205,7 +206,7 @@ async def all_plugins( max_results: Annotated[Optional[int], Query(ge=1, le=200)] = None, page: CompatiblePageParam = None, count: CompatibleCountParam = None, - response: Response = None, + response: Response = None, # type: ignore[assignment] ) -> List[_SchemaPlugin]: """ 查询插件清单;未指定分页或限量时返回完整清单。 @@ -310,7 +311,7 @@ async def plugin_releases( repo_url: Optional[str] = "", force: bool = False, task_registry: TaskRegistry = Depends(get_background_task_registry), -) -> dict: +) -> dict[str, Any]: """ 查询指定插件可直接安装的 GitHub Release 版本。 @@ -389,7 +390,7 @@ async def rate_plugin( plugin_id: str, payload: _SchemaPluginRatingRequest, _: ApiPrincipal = Depends(get_current_active_superuser_async), -) -> _SchemaResponse: +) -> _SchemaResponse[Mapping[str, Any]]: """ 为已安装插件新增或更新当前安装实例评分。 """ @@ -402,7 +403,10 @@ async def rate_plugin( ) from error if rating is None: return _SchemaResponse(success=False, message="连接MoviePilot服务器失败") - return _SchemaResponse(success=True, data=rating) + return _SchemaResponse( + success=True, + data=rating, + ) @router.post("/reload/{plugin_id}", summary="重新加载插件", response_model=_SchemaResponse[None]) @@ -617,7 +621,9 @@ def plugin_agent_pets( summary="获取插件表单页面", response_model=_SchemaJsonObject, ) -def plugin_form(plugin_id: str, _: ApiPrincipal = Depends(get_current_active_superuser)) -> dict: +def plugin_form( + plugin_id: str, _: ApiPrincipal = Depends(get_current_active_superuser) +) -> dict[str, Any]: """ 根据插件ID获取插件配置表单或Vue组件URL """ @@ -651,7 +657,9 @@ def plugin_form(plugin_id: str, _: ApiPrincipal = Depends(get_current_active_sup summary="获取插件数据页面", response_model=_SchemaJsonObject, ) -def plugin_page(plugin_id: str, _: ApiPrincipal = Depends(get_current_active_superuser)) -> dict: +def plugin_page( + plugin_id: str, _: ApiPrincipal = Depends(get_current_active_superuser) +) -> dict[str, Any]: """ 根据插件ID获取插件数据页面 """ @@ -677,11 +685,11 @@ def plugin_dashboard_meta( _: ApiPrincipal = Depends(get_current_active_superuser), page: CompatiblePageParam = None, count: CompatibleCountParam = None, -) -> List[dict]: +) -> List[dict[str, str]]: """ 获取所有插件仪表板元信息 """ - return get_plugin_manager().get_plugin_dashboard_meta() + return cast(List[dict[str, str]], get_plugin_manager().get_plugin_dashboard_meta()) @router.get("/dashboard/{plugin_id}/{key}", summary="获取插件仪表板配置") @@ -695,7 +703,10 @@ def plugin_dashboard_by_key( 根据插件ID获取插件仪表板 """ try: - return get_plugin_manager().get_plugin_dashboard(plugin_id, key, user_agent) + return cast( + Optional[_SchemaPluginDashboard], + get_plugin_manager().get_plugin_dashboard(plugin_id, key, user_agent), + ) except PluginNotFoundError as error: raise HTTPException(status_code=404, detail=str(error)) from error except PluginDashboardError as error: @@ -778,21 +789,19 @@ async def plugin_static_file( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"{plugin_file_path} 不是文件") # 判断 MIME 类型 - response_type, _ = mimetypes.guess_type(str(plugin_file_path)) + guessed_response_type = mimetypes.guess_type(str(plugin_file_path))[0] + response_type: str = guessed_response_type or "application/octet-stream" suffix = plugin_file_path.suffix.lower() # 强制修正 .mjs 和 .js 的 MIME 类型 if suffix in [".js", ".mjs"]: response_type = "application/javascript" # 如果 guess_type 没猜对 css,也修正 - elif suffix == ".css" and not response_type: + elif suffix == ".css" and not guessed_response_type: response_type = "text/css" - # 对于其他猜不出的类型 - elif not response_type: - response_type = "application/octet-stream" try: # 异步生成器函数,用于流式读取文件 - async def file_generator(): + async def file_generator() -> AsyncIterator[bytes]: """ 分块异步读取插件文件供下载响应使用。 """ @@ -931,17 +940,19 @@ async def plugin_data_summary( summary="获取插件配置", response_model=_SchemaJsonObject, ) -async def plugin_config(plugin_id: str, _: ApiPrincipal = Depends(get_current_active_superuser_async)) -> dict: +async def plugin_config( + plugin_id: str, _: ApiPrincipal = Depends(get_current_active_superuser_async) +) -> dict[str, Any]: """ 根据插件ID获取插件配置信息 """ - return get_plugin_manager().get_plugin_config(plugin_id) + return cast(dict[str, Any], get_plugin_manager().get_plugin_config(plugin_id)) @router.put("/{plugin_id}", summary="更新插件配置", response_model=_SchemaResponse[None]) def set_plugin_config( plugin_id: str, - conf: dict, + conf: dict[str, Any], _: ApiPrincipal = Depends(get_current_active_superuser), command: PluginConfigCommand = Depends(get_plugin_config_command), ) -> Any: diff --git a/app/api/endpoints/pluginclone.py b/app/api/endpoints/pluginclone.py index 8eb6ae831..7b1b8cc41 100644 --- a/app/api/endpoints/pluginclone.py +++ b/app/api/endpoints/pluginclone.py @@ -43,7 +43,7 @@ def plugin_restorable_instances( _: ApiPrincipal = Depends(get_current_active_superuser), page: CompatiblePageParam = None, count: CompatibleCountParam = None, - response: Response = None, + response: Response = None, # type: ignore[assignment] ) -> Any: """ 列出该插件名下已停用、设置仍留存可被恢复的分身 diff --git a/app/api/endpoints/rule.py b/app/api/endpoints/rule.py index 983447b5d..4dd10b0b4 100644 --- a/app/api/endpoints/rule.py +++ b/app/api/endpoints/rule.py @@ -151,7 +151,7 @@ async def add_custom_rule( data = await _service(runtime).add_custom(**payload.model_dump()) except ValueError as error: return _SchemaResponse(success=False, message=str(error)) - return _SchemaResponse(success=True, message=data.get("message"), data=data) + return _SchemaResponse(success=True, message=data.get("message") or "", data=data) @router.put( @@ -174,7 +174,7 @@ async def reorder_custom_rules( ) except ValueError as error: return _SchemaResponse(success=False, message=str(error)) - return _SchemaResponse(success=True, message=data.get("message"), data=data) + return _SchemaResponse(success=True, message=data.get("message") or "", data=data) @router.put( @@ -198,7 +198,7 @@ async def update_custom_rule( ) except ValueError as error: return _SchemaResponse(success=False, message=str(error)) - return _SchemaResponse(success=True, message=data.get("message"), data=data) + return _SchemaResponse(success=True, message=data.get("message") or "", data=data) @router.delete( @@ -218,7 +218,7 @@ async def delete_custom_rule( data = await _service(runtime).delete_custom(rule_id) except ValueError as error: return _SchemaResponse(success=False, message=str(error)) - return _SchemaResponse(success=True, message=data.get("message"), data=data) + return _SchemaResponse(success=True, message=data.get("message") or "", data=data) @router.post( @@ -238,7 +238,7 @@ async def add_rule_group( data = await _service(runtime).add_group(**payload.model_dump()) except ValueError as error: return _SchemaResponse(success=False, message=str(error)) - return _SchemaResponse(success=True, message=data.get("message"), data=data) + return _SchemaResponse(success=True, message=data.get("message") or "", data=data) @router.put( @@ -261,7 +261,7 @@ async def reorder_rule_groups( ) except ValueError as error: return _SchemaResponse(success=False, message=str(error)) - return _SchemaResponse(success=True, message=data.get("message"), data=data) + return _SchemaResponse(success=True, message=data.get("message") or "", data=data) @router.put( @@ -285,7 +285,7 @@ async def update_rule_group( ) except ValueError as error: return _SchemaResponse(success=False, message=str(error)) - return _SchemaResponse(success=True, message=data.get("message"), data=data) + return _SchemaResponse(success=True, message=data.get("message") or "", data=data) @router.delete( @@ -305,4 +305,4 @@ async def delete_rule_group( data = await _service(runtime).delete_group(name) except ValueError as error: return _SchemaResponse(success=False, message=str(error)) - return _SchemaResponse(success=True, message=data.get("message"), data=data) + return _SchemaResponse(success=True, message=data.get("message") or "", data=data) diff --git a/app/api/endpoints/search.py b/app/api/endpoints/search.py index e5acabb62..829757f88 100644 --- a/app/api/endpoints/search.py +++ b/app/api/endpoints/search.py @@ -5,7 +5,7 @@ import asyncio import json import time -from typing import Any, AsyncIterator, Iterator, List, Optional +from typing import Any, AsyncGenerator, AsyncIterator, Coroutine, Iterator, List, Optional, cast from uuid import uuid4 from fastapi import Body, Depends, Request @@ -75,7 +75,7 @@ async def _resolve_media_search_params( media_id: str, media_type: Optional[MediaType] = None, music_type: Optional[str] = None, -) -> tuple[Optional[dict], str]: +) -> tuple[Optional[dict[str, Any]], str]: """ 校验统一媒体身份并构造 SearchChain 精确搜索参数。 """ @@ -102,7 +102,7 @@ async def _resolve_media_search_params( return params, "" -def _sse_event(data: dict, locale: Optional[str] = None) -> str: +def _sse_event(data: dict[str, Any], locale: Optional[str] = None) -> str: """ 转换为SSE事件 """ @@ -120,21 +120,24 @@ def _sse_event(data: dict, locale: Optional[str] = None) -> str: return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" -def _serialize_signed_subtitle_result(subtitle: Any) -> dict: +def _serialize_signed_subtitle_result(subtitle: Any) -> dict[str, Any]: """ 序列化字幕结果并签名下载链接,签名用途绑定站点 ID。 """ data = subtitle.to_dict() if hasattr(subtitle, "to_dict") else dict(subtitle) enclosure = data.get("enclosure") + site_id = data.get("site") if enclosure: + if not isinstance(site_id, int): + raise ValueError("字幕结果缺少有效站点 ID,无法签名下载链接") data["enclosure"] = SecurityUtils.sign_url( enclosure, - purpose=SecurityUtils.subtitle_download_purpose(data.get("site")), + purpose=SecurityUtils.subtitle_download_purpose(site_id), ) return data -def _serialize_signed_subtitle_results(subtitles: List[Any]) -> List[dict]: +def _serialize_signed_subtitle_results(subtitles: List[Any]) -> List[dict[str, Any]]: """ 批量序列化字幕结果,确保返回给客户端的下载链接均已签名。 """ @@ -148,7 +151,7 @@ def _serialize_context_results(contexts: list[Context]) -> list[dict[str, Any]]: return [context.to_dict() for context in contexts] -def _sign_subtitle_search_event(event: dict) -> dict: +def _sign_subtitle_search_event(event: dict[str, Any]) -> dict[str, Any]: """ 签名字幕搜索流事件中的下载链接。 """ @@ -161,8 +164,8 @@ def _sign_subtitle_search_event(event: dict) -> dict: async def _iter_signed_subtitle_search_events( - event_source: AsyncIterator[dict], -) -> AsyncIterator[dict]: + event_source: AsyncIterator[dict[str, Any]], +) -> AsyncIterator[dict[str, Any]]: """ 输出仅包含签名字幕下载链接的搜索流事件。 """ @@ -170,7 +173,7 @@ async def _iter_signed_subtitle_search_events( yield _sign_subtitle_search_event(event) -def _merge_append_event(pending_event: Optional[dict], event: dict) -> dict: +def _merge_append_event(pending_event: Optional[dict[str, Any]], event: dict[str, Any]) -> dict[str, Any]: """ 合并短时间内连续到达的 append 事件,降低前端刷新频率。 """ @@ -187,7 +190,7 @@ def _merge_append_event(pending_event: Optional[dict], event: dict) -> dict: return merged_event -def _iter_replace_event_batches(event: dict) -> Iterator[dict]: +def _iter_replace_event_batches(event: dict[str, Any]) -> Iterator[dict[str, Any]]: """ 将超大的最终替换事件拆成有序批次,避免单个 SSE 消息承载全部完整对象。 """ @@ -217,19 +220,21 @@ def _iter_replace_event_batches(event: dict) -> Iterator[dict]: async def _iter_batched_search_events( - event_source: AsyncIterator[dict], -) -> AsyncIterator[dict]: + event_source: AsyncIterator[dict[str, Any]], +) -> AsyncGenerator[dict[str, Any], None]: """ 对搜索流事件做轻量批处理,并在上游长时间静默时发送心跳。 """ iterator = event_source.__aiter__() - pending_append_event: Optional[dict] = None - next_event_task: Optional[asyncio.Task] = None + pending_append_event: Optional[dict[str, Any]] = None + next_event_task: Optional[asyncio.Future[dict[str, Any]]] = None try: while True: if next_event_task is None: - next_event_task = asyncio.create_task(anext(iterator)) + next_event_task = asyncio.create_task( + cast(Coroutine[Any, Any, dict[str, Any]], anext(iterator)) + ) timeout = ( _SSE_APPEND_FLUSH_INTERVAL @@ -283,7 +288,9 @@ async def _iter_batched_search_events( yield pending_append_event -async def _stream_search_events(request: Request, event_source: AsyncIterator[dict]): +async def _stream_search_events( + request: Request, event_source: AsyncIterator[dict[str, Any]], +) -> AsyncGenerator[str, None]: """ 输出搜索 SSE 事件,并记录连接生命周期与传输规模。 """ @@ -429,7 +436,7 @@ async def search_by_id_stream( media_season = int(season) if season else None site_list = _parse_site_list(sites) - async def event_source(): + async def event_source() -> AsyncGenerator[dict[str, Any], None]: """ 解析媒体身份并输出精确搜索流事件。 """ @@ -676,7 +683,7 @@ async def _build_subtitle_search_source( media_episode = int(episode) if episode else None site_list = _parse_site_list(sites) - def call_search(**kwargs): + def call_search(**kwargs: Any) -> Any: """ 根据调用模式返回普通搜索协程或流式搜索迭代器。 """ @@ -738,7 +745,7 @@ async def search_subtitle_by_id_stream( stream=True, ) - async def event_source(): + async def event_source() -> AsyncGenerator[dict[str, Any], None]: """ 输出字幕精确搜索流事件。 """ diff --git a/app/api/endpoints/settings.py b/app/api/endpoints/settings.py index 1ddccfae4..e1ebf1022 100644 --- a/app/api/endpoints/settings.py +++ b/app/api/endpoints/settings.py @@ -73,7 +73,7 @@ async def set_setting( 更新系统设置(仅管理员)。 """ result = await runtime.system.update_setting(key, value) - return _SchemaResponse(success=result.success, message=result.message) + return _SchemaResponse(success=result.success, message=result.message or "") @router.get( @@ -221,4 +221,4 @@ async def update_settings( raise HTTPException(status_code=409, detail=str(error)) from error except ValueError as error: return _SchemaResponse(success=False, message=str(error)) - return _SchemaResponse(success=True, message=data.get("message"), data=data) + return _SchemaResponse(success=True, message=data.get("message") or "", data=data) diff --git a/app/api/endpoints/site.py b/app/api/endpoints/site.py index fa8e464d0..90b134b22 100644 --- a/app/api/endpoints/site.py +++ b/app/api/endpoints/site.py @@ -2,7 +2,7 @@ 提供站点配置、认证、数据查询和状态管理接口。 """ -from typing import Annotated, Any, Dict, List, Literal, Optional +from typing import Annotated, Any, List, Literal, Optional, cast from fastapi import Depends, HTTPException, Response @@ -111,7 +111,7 @@ def _project_readable_site( return _project_agent_site(site, include_secrets=False) -def _indexer_supports_media_type(indexer: dict, media_type: MediaType) -> bool: +def _indexer_supports_media_type(indexer: dict[str, Any], media_type: MediaType) -> bool: """ 判断站点索引器是否支持指定媒体类型。 @@ -120,6 +120,7 @@ def _indexer_supports_media_type(indexer: dict, media_type: MediaType) -> bool: :return: 是否应在该媒体类型的站点选择列表中显示 """ declared_media_type = indexer.get("media_type") + site_media_type: Optional[MediaType] if isinstance(declared_media_type, MediaType): site_media_type = declared_media_type elif isinstance(declared_media_type, str): @@ -167,12 +168,12 @@ def _normalize_site_ids(values: Any) -> list[int]: openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True}, ) async def read_sites( - response: Response = None, + response: Response = None, # type: ignore[assignment] query: SiteQueryService = Depends(get_site_query_service), _: ApiPrincipal = Depends(get_current_active_manage_user_async), page: CompatiblePageParam = None, count: CompatibleCountParam = None, -) -> List[dict]: +) -> List[_SchemaSite]: """ 获取站点列表 """ @@ -191,7 +192,7 @@ async def read_sites( openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True}, ) async def read_agent_sites( - response: Response = None, + response: Response = None, # type: ignore[assignment] status: Literal["active", "inactive", "all"] = "all", name: Optional[str] = None, query: SiteQueryService = Depends(get_site_query_service), @@ -237,7 +238,7 @@ async def read_agent_sites( ) async def read_sites_by_media_type( media_type: str, - response: Response = None, + response: Response = None, # type: ignore[assignment] query: SiteQueryService = Depends(get_site_query_service), _: ApiPrincipal = Depends(get_current_active_manage_user_async), page: CompatiblePageParam = None, @@ -396,7 +397,7 @@ def _update_site_cookie( password: str, code: Optional[str], query: SiteQueryService, -) -> _SchemaResponse: +) -> _SchemaResponse[None]: """ 执行站点 Cookie 与 UA 更新。 @@ -509,6 +510,8 @@ def refresh_userdata( status_code=404, detail=f"站点 {site_id} 不存在", ) + if not site.domain: + return _SchemaResponse(success=False, message="站点域名未配置") indexer = SitesHelper().get_indexer(site.domain) if not indexer: return _SchemaResponse( @@ -532,7 +535,7 @@ def refresh_userdata( openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True}, ) async def read_userdata_latest( - response: Response = None, + response: Response = None, # type: ignore[assignment] query: SiteQueryService = Depends(get_site_query_service), _: ApiPrincipal = Depends(get_current_active_manage_user_async), page: CompatiblePageParam = None, @@ -557,7 +560,7 @@ async def read_userdata_latest( ) async def read_userdata( site_id: int, - response: Response = None, + response: Response = None, # type: ignore[assignment] workdate: Optional[str] = None, query: SiteQueryService = Depends(get_site_query_service), _: ApiPrincipal = Depends(get_current_active_manage_user_async), @@ -609,6 +612,8 @@ def test_site( status_code=404, detail=f"站点 {site_id} 不存在", ) + if not site.domain: + return _SchemaResponse(success=False, message="站点域名未配置") status, message = SiteChain().test(site.domain) return _SchemaResponse(success=status, message=message) @@ -632,6 +637,8 @@ async def site_icon( status_code=404, detail=f"站点 {site_id} 不存在", ) + if not site.domain: + return _SchemaResponse(success=False, message="站点域名未配置") icon = await query.icon(site.domain) if not icon: return _SchemaResponse(success=False, message="站点图标不存在!") @@ -657,15 +664,18 @@ async def site_category( status_code=404, detail=f"站点 {site_id} 不存在", ) + if not site.domain: + raise HTTPException(status_code=400, detail="站点域名未配置") indexer = await SitesHelper().async_get_indexer(site.domain) if not indexer: raise HTTPException( status_code=404, detail=f"站点 {site.domain} 不支持", ) - category: Dict[str, List[dict]] = indexer.get("category") or [] - if not category: + category_value = indexer.get("category") + if not isinstance(category_value, dict): return [] + category = cast(dict[str, list[dict[str, Any]]], category_value) result = [] for cats in category.values(): for cat in cats: @@ -698,6 +708,8 @@ async def site_resource( status_code=404, detail=f"站点 {site_id} 不存在", ) + if not site.domain: + raise HTTPException(status_code=400, detail="站点域名未配置") torrents = await TorrentsChain().async_browse( domain=site.domain, keyword=keyword, @@ -753,7 +765,7 @@ async def read_statistic_by_domain( openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True}, ) async def read_statistics( - response: Response = None, + response: Response = None, # type: ignore[assignment] query: SiteQueryService = Depends(get_site_query_service), _: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageParam = None, @@ -777,7 +789,7 @@ async def read_statistics( openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True}, ) async def read_rss_sites( - response: Response = None, + response: Response = None, # type: ignore[assignment] query: SiteQueryService = Depends(get_site_query_service), token_payload: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageParam = None, @@ -804,7 +816,9 @@ async def read_rss_sites( @router.get("/auth", summary="查询认证站点", response_model=_SchemaJsonObject) -async def read_auth_sites(_: _SchemaTokenPayload = Depends(verify_token)) -> dict: +async def read_auth_sites( + _: _SchemaTokenPayload = Depends(verify_token), +) -> dict[str, Any]: """ 获取可认证站点列表 """ @@ -848,15 +862,16 @@ def auth_site( async def site_mapping( query: SiteQueryService = Depends(get_site_query_service), _: ApiPrincipal = Depends(get_current_active_superuser_async), -): +) -> _SchemaResponse[dict[str, str]]: """ 获取站点域名到名称的映射关系 """ try: sites = await query.list_ordered() - mapping = {} + mapping: dict[str, str] = {} for site in sites: - mapping[site.domain] = site.name + if site.domain and site.name: + mapping[site.domain] = site.name return _SchemaResponse(success=True, data=mapping) except Exception as e: return _SchemaResponse(success=False, message=f"获取映射失败:{str(e)}") @@ -867,7 +882,9 @@ async def site_mapping( summary="获取支持的站点列表", response_model=_SchemaJsonObject, ) -async def support_sites(_: ApiPrincipal = Depends(get_current_active_superuser_async)): +async def support_sites( + _: ApiPrincipal = Depends(get_current_active_superuser_async), +) -> dict[str, Any]: """ 获取支持的站点列表 """ diff --git a/app/api/endpoints/storage.py b/app/api/endpoints/storage.py index d24a52367..8138ca55b 100644 --- a/app/api/endpoints/storage.py +++ b/app/api/endpoints/storage.py @@ -182,7 +182,7 @@ def manage(request: _SchemaManageRequest, _: ApiPrincipal = Depends(get_current_ ) return _SchemaResponse( success=bool(result.get("success")), - message=result.get("message"), + message=result.get("message") or "", data=result.get("data"), ) diff --git a/app/api/endpoints/subexecution.py b/app/api/endpoints/subexecution.py index c1808d64e..6ba85ce6a 100644 --- a/app/api/endpoints/subexecution.py +++ b/app/api/endpoints/subexecution.py @@ -6,7 +6,7 @@ import time from collections.abc import AsyncIterator from dataclasses import asdict from datetime import datetime, timedelta, timezone -from typing import Any, List, Optional, cast +from typing import Any, List, Optional from fastapi import Depends, HTTPException, Path, Query, Request from starlette.responses import Response, StreamingResponse @@ -64,7 +64,7 @@ async def list_subscription_activity( if result is None: raise HTTPException(status_code=404, detail="订阅不存在或无权查看") events, next_cursor = result - return cast(_SchemaSubscriptionActivityPage, _SchemaSubscriptionActivityPage.model_validate({ + return _SchemaSubscriptionActivityPage.model_validate({ "items": [ {**{key: value for key, value in asdict(event).items() if key not in {"activity_key", "tracking", "details"}}, "details_loaded": False, @@ -72,7 +72,7 @@ async def list_subscription_activity( for event in events ], "next_cursor": next_cursor, - })) + }) @router.get( @@ -98,9 +98,9 @@ async def get_subscription_activity( ) if event is None: raise HTTPException(status_code=404, detail="活动不存在或无权查看") - return cast(_SchemaSubscriptionActivityEvent, _SchemaSubscriptionActivityEvent.model_validate({ + return _SchemaSubscriptionActivityEvent.model_validate({ key: value for key, value in asdict(event).items() if key not in {"activity_key", "tracking"} - })) + }) async def _accessible_subscription_ids( diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index f8e2043fd..a721c73ec 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -137,7 +137,7 @@ def start_subscribe_add( media_id: str, season: int, username: str, -): +) -> None: """ 启动订阅任务 """ @@ -152,11 +152,12 @@ def start_subscribe_add( ) -def build_subscribe_event_payload(subscribe: Any) -> dict: +def build_subscribe_event_payload(subscribe: Any) -> dict[str, Any]: """ 从 ORM 已加载字段构造订阅事件快照,避免异步接口里属性懒加载触发隐式 IO。 """ - return subscribe.to_dict() + payload = subscribe.to_dict() + return payload if isinstance(payload, dict) else {} def can_access_subscribe(subscribe: Any, current_user: ApiPrincipal) -> bool: @@ -204,7 +205,7 @@ def matches_subscribe_music_type( openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True}, ) async def read_subscribes( - response: Response = None, + response: Response = None, # type: ignore[assignment] query: SubscriptionQueryService = Depends(get_subscription_query_service), status_service: SubscriptionExecutionStatusService = Depends(get_subscription_execution_status_service), current_user: ApiPrincipal = Depends(get_current_active_user_async), @@ -229,7 +230,7 @@ async def read_subscribes( openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True}, ) async def list_subscribes( - response: Response = None, + response: Response = None, # type: ignore[assignment] query: SubscriptionQueryService = Depends(get_subscription_query_service), status_service: SubscriptionExecutionStatusService = Depends(get_subscription_execution_status_service), _: Optional[Annotated[str, Depends(verify_apitoken)]] = None, @@ -255,7 +256,7 @@ async def create_subscribe( *, subscribe_in: _SchemaSubscribe, current_user: ApiPrincipal = Depends(get_current_active_user_async), -) -> _SchemaResponse: +) -> _SchemaResponse[dict[str, Any]]: """ 新增订阅 """ @@ -266,7 +267,7 @@ async def create_subscribe( mtype = None # 非 TMDB 来源的标题可能自带季标记,入库前统一拆分。 if mtype != MediaType.MUSIC and normalize_media_source(subscribe_in.media_source) not in (None, MediaSource.TMDB): - meta = MetaInfo(subscribe_in.name) + meta = MetaInfo(subscribe_in.name or "") subscribe_in.name = meta.name if subscribe_in.season is None: subscribe_in.season = meta.begin_season @@ -320,7 +321,10 @@ async def update_subscribe( name=current_user.name, is_superuser=current_user.is_superuser, ) - subscribe = await mutation.get_accessible(subscribe_in.id, actor) + if subscribe_in.id is None: + return _SchemaResponse(success=False, message="订阅不存在") + subscribe_id = subscribe_in.id + subscribe = await mutation.get_accessible(subscribe_id, actor) if not subscribe: return _SchemaResponse(success=False, message="订阅不存在") subscribe_dict = subscribe_in.to_public_write_payload(exclude_unset=True) @@ -373,7 +377,7 @@ async def update_subscribe( # 更新到数据库 try: change = await mutation.update( - subscribe_in.id, + subscribe_id, subscribe_dict, actor, existing=subscribe, @@ -554,12 +558,14 @@ async def subscribe_history( count: Optional[int] = 30, query: SubscriptionQueryService = Depends(get_subscription_query_service), current_user: ApiPrincipal = Depends(get_current_active_user_async), - response: Response = None, + response: Response = None, # type: ignore[assignment] ) -> Any: """ 查询电影、电视剧或音乐订阅历史 """ username = None if current_user.is_superuser else current_user.name + page = page if page is not None else 1 + count = count if count is not None else 30 results = await query.list_history( mtype, page=page, @@ -694,7 +700,7 @@ async def popular_subscribes( ) async def user_subscribes( username: str, - response: Response = None, + response: Response = None, # type: ignore[assignment] query: SubscriptionQueryService = Depends(get_subscription_query_service), status_service: SubscriptionExecutionStatusService = Depends(get_subscription_execution_status_service), current_user: ApiPrincipal = Depends(get_current_active_user_async), diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index 906323c2e..59859a255 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -336,7 +336,7 @@ async def proxy_img( use_cookies: bool = False, if_none_match: Annotated[str | None, Header()] = None, _: _SchemaTokenPayload = Depends(verify_resource_token), -) -> Response: +) -> Optional[Response]: """ 图片代理,可选是否使用代理服务器,支持 HTTP 缓存 """ @@ -373,7 +373,7 @@ async def cache_img( url: str, if_none_match: Annotated[str | None, Header()] = None, _: _SchemaTokenPayload = Depends(verify_resource_token), -) -> Response: +) -> Optional[Response]: """ 本地缓存图片文件,支持 HTTP 缓存,如果启用全局图片缓存,则使用磁盘缓存 """ @@ -431,7 +431,7 @@ async def notification_image( summary="查询非敏感系统设置", response_model=_SchemaResponse[_SchemaJsonObject], ) -def get_global_setting(token: str) -> _SchemaResponse[_SchemaJsonObject]: +def get_global_setting(token: str) -> _SchemaResponse[dict[str, Any]]: """ 查询非敏感系统设置(默认鉴权) 仅包含登录前UI初始化必需的字段 @@ -471,7 +471,7 @@ def get_global_setting(token: str) -> _SchemaResponse[_SchemaJsonObject]: async def get_user_global_setting( _: ApiPrincipal = Depends(get_current_active_user_async), runtime: HostRuntime = Depends(get_host_runtime), -) -> _SchemaResponse[_SchemaJsonObject]: +) -> _SchemaResponse[dict[str, Any]]: """ 查询用户相关系统设置(登录后获取) 包含业务功能相关的配置和用户权限信息 @@ -613,7 +613,7 @@ async def delete_database_backup( async def get_env_setting( _: ApiPrincipal = Depends(get_current_active_superuser_async), runtime: HostRuntime = Depends(get_host_runtime), -) -> _SchemaResponse: +) -> _SchemaResponse[dict[str, Any]]: """ 查询系统环境变量,包括当前版本号(仅管理员) """ @@ -643,7 +643,7 @@ async def get_env_setting( async def usage_statistic( _: ApiPrincipal = Depends(get_current_active_user_async), runtime: HostRuntime = Depends(get_host_runtime), -) -> _SchemaResponse[_SchemaJsonObject]: +) -> _SchemaResponse[dict[str, Any]]: """ 查询安装版本统计报表 """ @@ -664,17 +664,17 @@ async def ping(_: ApiPrincipal = Depends(get_current_active_user_async)) -> _Sch response_model=_SchemaResponse[_SchemaSystemEnvironmentUpdateData], ) async def set_env_setting( - env: dict, + env: dict[str, Any], _: ApiPrincipal = Depends(get_current_active_superuser_async), runtime: HostRuntime = Depends(get_host_runtime), -): +) -> _SchemaResponse[_SchemaSystemEnvironmentUpdateData]: """ 更新系统环境变量(仅管理员) """ result = await runtime.system.update_environment(env) return _SchemaResponse( success=result.success, - message=result.message, + message=result.message or "", data=result.data, ) @@ -724,7 +724,7 @@ async def get_progress( summary="查询公开系统设置", response_model=_SchemaResponse[_SchemaValueData], ) -async def get_public_setting(key: str, _: ApiPrincipal = Depends(get_current_active_user_async)) -> _SchemaResponse[_SchemaValueData]: +async def get_public_setting(key: str, _: ApiPrincipal = Depends(get_current_active_user_async)) -> _SchemaResponse[dict[str, Any]]: """ 查询普通用户可读取的非敏感系统设置,存储配置只返回名称和类型 """ @@ -751,14 +751,14 @@ async def sync_plugin_market_from_wiki( request: Optional[_SchemaPluginMarketSyncRequest] = Body(default=None), _: ApiPrincipal = Depends(get_current_active_superuser_async), runtime: HostRuntime = Depends(get_host_runtime), -) -> _SchemaResponse: +) -> _SchemaResponse[_SchemaPluginMarketSyncData]: """ 从 Wiki 插件文档同步插件市场仓库地址。 """ result = await runtime.system.sync_plugin_market(request.wiki_url if request else None) return _SchemaResponse( success=result.success, - message=result.message, + message=result.message or "", data=result.data, ) @@ -794,7 +794,7 @@ async def get_message( if await request.is_disconnected(): break # 一次推送全部积压消息,避免页面重新打开后按 3 秒一条逐条补弹 - details = message.drain(role) + details = message.drain(role or "system") if details: for detail in details: yield f"data: {detail}\n\n" @@ -911,7 +911,7 @@ async def latest_version( runtime: HostRuntime = Depends(get_host_runtime), page: CompatiblePageParam = None, count: CompatibleCountParam = None, -) -> _SchemaResponse[_SchemaJsonObjectList]: +) -> _SchemaResponse[list[dict[str, Any]]]: """ 查询Github所有Release版本 """ @@ -931,7 +931,7 @@ def ruletest( rulegroup_name: str, subtitle: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token), -) -> _SchemaResponse[_SchemaRuleTestData]: +) -> _SchemaResponse[dict[str, Any]]: """ 过滤规则测试,规则类型 1-订阅,2-洗版,3-搜索 """ @@ -1005,7 +1005,7 @@ def ruletest( ) async def nettest_targets( _: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageParam = None, count: CompatibleCountParam = None -) -> _SchemaResponse[list[_SchemaNetTestTarget]]: +) -> _SchemaResponse[list[dict[str, Any]]]: """ 获取网络测试目标。 @@ -1037,7 +1037,7 @@ async def nettest( url: Optional[str] = None, include: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token), -) -> _SchemaResponse[_SchemaTimeData]: +) -> _SchemaResponse[dict[str, Any]]: """ 测试内置目标的网络连通性。 @@ -1052,8 +1052,8 @@ async def nettest( data = {"time": result.elapsed_ms} if result.elapsed_ms is not None else None return _SchemaResponse( success=result.success, - message=result.message, - data=data, + message=result.message or "", + data=data if data is not None else None, ) @@ -1066,7 +1066,7 @@ def restart_system( 重启系统(仅管理员) """ result = runtime.system.restart() - return _SchemaResponse(success=result.success, message=result.message) + return _SchemaResponse(success=result.success, message=result.message or "") @router.post("/upgrade", summary="Dev 更新并重启系统", response_model=_SchemaResponse[None]) @@ -1079,7 +1079,7 @@ def upgrade_system( 保留 Dev 更新入口;Release 更新必须使用后台下载与确认安装流程。 """ result = runtime.system.upgrade(mode) - return _SchemaResponse(success=result.success, message=result.message) + return _SchemaResponse(success=result.success, message=result.message or "") @router.get( @@ -1126,7 +1126,7 @@ def download_system_update( 单类或合并下载更新并立即返回状态;all 准备全部最新制品(仅管理员)。 """ result = runtime.system.download_update(request.target if request else "application") - return _SchemaResponse(success=result.success, data=result.data, message=result.message) + return _SchemaResponse(success=result.success, data=result.data, message=result.message or "") @router.post( @@ -1143,7 +1143,7 @@ def install_system_update( 校验所选制品后重启安装;all 要求全部更新就绪并只重启一次(仅管理员)。 """ result = runtime.system.install_update(request.target if request else "application") - return _SchemaResponse(success=result.success, message=result.message) + return _SchemaResponse(success=result.success, message=result.message or "") @router.get("/runscheduler", summary="运行服务", response_model=_SchemaResponse[None]) diff --git a/app/api/endpoints/tmdb.py b/app/api/endpoints/tmdb.py index e6103ed70..5cf0e5536 100644 --- a/app/api/endpoints/tmdb.py +++ b/app/api/endpoints/tmdb.py @@ -44,7 +44,7 @@ def _paginate_medias(medias: Sequence[Any], page: int, count: int) -> list[Any]: ) async def tmdb_recognition_cache( _: object = Depends(get_current_active_superuser_async), -) -> _SchemaResponse: +) -> _SchemaResponse[dict[str, Any]]: """ 查询可管理的 TheMovieDb 识别缓存。 """ @@ -190,7 +190,11 @@ async def tmdb_collection( """ medias = await TmdbChain().async_tmdb_collection(collection_id=collection_id) if medias: - return [media.to_dict() for media in medias][(page - 1) * count : page * count] + page_value = page if page is not None else 1 + count_value = count if count is not None else 20 + return _paginate_medias( + [media.to_dict() for media in medias], page_value, count_value + ) return [] diff --git a/app/api/endpoints/torrent.py b/app/api/endpoints/torrent.py index 432dbcbeb..d6a77a5f7 100644 --- a/app/api/endpoints/torrent.py +++ b/app/api/endpoints/torrent.py @@ -2,7 +2,7 @@ 提供种子详情、状态查询和资源处理接口。 """ -from typing import Optional +from typing import Any, Optional from fastapi import Depends @@ -35,7 +35,7 @@ router = ResponseAPIRouter() ) async def torrents_cache( _: object = Depends(get_current_active_superuser_async), -) -> _SchemaResponse[_SchemaTorrentCacheData]: +) -> _SchemaResponse[dict[str, Any]]: """ 获取当前种子缓存数据 """ @@ -54,19 +54,22 @@ async def torrents_cache( torrent_data = [] for domain, contexts in cache_info.items(): for context in contexts: + torrent_info = context.torrent_info + if torrent_info is None: + continue torrent_hash = HashUtils.md5( - f"{context.torrent_info.title}{context.torrent_info.description}" + f"{torrent_info.title}{torrent_info.description}" ) media_source, media_id = resolve_media_identity(media=context.media_info) torrent_data.append( { "hash": torrent_hash, "domain": domain, - "title": context.torrent_info.title, - "description": context.torrent_info.description, - "size": context.torrent_info.size, - "pubdate": context.torrent_info.pubdate, - "site_name": context.torrent_info.site_name, + "title": torrent_info.title, + "description": torrent_info.description, + "size": torrent_info.size, + "pubdate": torrent_info.pubdate, + "site_name": torrent_info.site_name, "media_name": context.media_info.title if context.media_info else "", @@ -81,8 +84,8 @@ async def torrents_cache( "resource_term": context.meta_info.resource_term if context.meta_info else "", - "enclosure": context.torrent_info.enclosure, - "page_url": context.torrent_info.page_url, + "enclosure": torrent_info.enclosure, + "page_url": torrent_info.page_url, "poster_path": context.media_info.get_poster_image() if context.media_info else "", @@ -130,6 +133,7 @@ async def delete_cache( cache_data[domain] = [ context for context in cache_data[domain] + if context.torrent_info is not None if HashUtils.md5( f"{context.torrent_info.title}{context.torrent_info.description}" ) @@ -204,7 +208,7 @@ async def reidentify_cache( media_id: Optional[str] = None, music_type: Optional[MusicTargetEntityType] = None, _: object = Depends(get_current_active_superuser_async), -) -> _SchemaResponse[_SchemaTorrentReidentifyData]: +) -> _SchemaResponse[dict[str, Any]]: """ 重新识别指定的种子 @@ -228,7 +232,7 @@ async def reidentify_cache( return _SchemaResponse( success=success, message=message, - data=data, + data=data if data is not None else None, ) except Exception as e: return _SchemaResponse(success=False, message=f"重新识别失败:{str(e)}") diff --git a/app/api/endpoints/transfer.py b/app/api/endpoints/transfer.py index 57bf73e7e..9ec445d43 100644 --- a/app/api/endpoints/transfer.py +++ b/app/api/endpoints/transfer.py @@ -4,7 +4,7 @@ import re from pathlib import Path -from typing import Annotated, Any, List, Literal, Optional, cast +from typing import Annotated, Any, List, Literal, Optional from fastapi import Depends, HTTPException, Query, status @@ -106,7 +106,9 @@ def _public_transfer_result(data: dict[str, Any]) -> dict[str, Any]: return result -def _build_failure_preview_item(file_item: FileItem, message: Optional[str]) -> dict: +def _build_failure_preview_item( + file_item: FileItem, message: Optional[str] +) -> dict[str, Any]: """ 构造手动整理预览失败项。 """ @@ -137,6 +139,15 @@ def _build_failure_preview_item(file_item: FileItem, message: Optional[str]) -> } +def _restore_history_fileitem(payload: object) -> Optional[FileItem]: + """ + 从整理历史的 JSON 快照恢复文件项,拒绝缺失或非对象载荷。 + """ + if not isinstance(payload, dict): + return None + return FileItem.model_validate(payload) + + def _format_manual_transfer_failure( *, message: Optional[object], @@ -289,9 +300,7 @@ def _manual_review_task_data( """ 把 Application 人工复核投影映射为严格公开响应。 """ - return cast( - _SchemaTransferManualReviewTaskData, - _SchemaTransferManualReviewTaskData.model_validate({ + return _SchemaTransferManualReviewTaskData.model_validate({ "task_id": task.task_id, "source": { "storage": task.source.storage, @@ -306,8 +315,7 @@ def _manual_review_task_data( "error": _public_transfer_message(task.step.error), }, "review_revision": task.review_revision, - }), - ) + }) @router.get( @@ -413,7 +421,11 @@ def resolve_transfer_manual_review( data=_SchemaTransferManualReviewData( task_id=resolved.task_id, operation_id=resolved.operation_id, - decision=resolved.decision.value, + decision=( + "applied" + if resolved.decision is TransferManualReviewDecision.APPLIED + else "not_applied" + ), state=resolved.state.value, review_revision=resolved.review_revision, ), @@ -447,12 +459,15 @@ def query_name( if not new_path: return _SchemaResponse(success=False, message="未识别到新名称") if filetype == "dir": + media_type = context.media_info.type + if media_type is None: + return _SchemaResponse(success=False, message="未识别到媒体类型") media_path = DirectoryHelper.get_media_root_path( rename_format=get_api_runtime_config_snapshot().rename_format( - context.media_info.type + media_type ), rename_path=Path(new_path), - media_type=context.media_info.type, + media_type=media_type, ) if media_path: new_name = media_path.name @@ -514,6 +529,8 @@ async def remove_queue( """ TransferChain().remove_from_queue(fileitem) # 取消整理 + if not fileitem.path: + return _SchemaResponse(success=False, message="文件路径为空") runtime_stop_state.stop_transfer(fileitem.path) return _SchemaResponse(success=True) @@ -531,19 +548,26 @@ def _resolve_manual_transfer_source_fileitems( history = history_query.get(logid) if not history: return [], f"整理记录不存在,ID:{logid}" - if history.status and ("move" in history.mode): - fileitems.append(FileItem(**history.dest_fileitem)) + if history.status and ("move" in (history.mode or "")): + fileitem = _restore_history_fileitem(history.dest_fileitem) else: - fileitems.append(FileItem(**history.src_fileitem)) + fileitem = _restore_history_fileitem(history.src_fileitem) + if fileitem is None: + return [], f"整理记录缺少文件项,ID:{logid}" + fileitems.append(fileitem) return fileitems, None if transer_item.logid: history = history_query.get(transer_item.logid) if not history: return [], f"整理记录不存在,ID:{transer_item.logid}" - if history.status and ("move" in history.mode): - return [FileItem(**history.dest_fileitem)], None - return [FileItem(**history.src_fileitem)], None + if history.status and ("move" in (history.mode or "")): + fileitem = _restore_history_fileitem(history.dest_fileitem) + else: + fileitem = _restore_history_fileitem(history.src_fileitem) + if fileitem is None: + return [], f"整理记录缺少文件项,ID:{transer_item.logid}" + return [fileitem], None if transer_item.fileitems: return [fileitem for fileitem in transer_item.fileitems if fileitem], None @@ -631,6 +655,8 @@ def match_manual_transfer_target_path( matched_directories: List[_SchemaTransferDirectoryConf] = [] target_storage = transer_item.target_storage or None for src_fileitem in _deduplicate_fileitems(src_fileitems): + if not src_fileitem.path: + return _SchemaResponse(success=False, message="源文件路径为空") directory = DirectoryHelper().get_dir( media=None, storage=src_fileitem.storage or "local", @@ -799,18 +825,19 @@ def _execute_manual_transfer( # 下载器与 Hash 是同一组下载上下文,重新识别时由当前文件路径重新匹配。 downloader = history.downloader if transer_item.from_history else None download_hash = history.download_hash if transer_item.from_history else None - if history.status and ("move" in history.mode): + if history.status and ("move" in (history.mode or "")): # 重新整理成功的转移,则使用成功的 dest 做 in_path - src_fileitems = [FileItem(**history.dest_fileitem)] + fileitem = _restore_history_fileitem(history.dest_fileitem) else: # 源路径 - src_fileitems = [FileItem(**history.src_fileitem)] + fileitem = _restore_history_fileitem(history.src_fileitem) if ( history.dest_fileitem and not transer_item.preview and not transer_item.reorganize ): - cleanup_dest_fileitem = FileItem(**history.dest_fileitem) + cleanup_dest_fileitem = _restore_history_fileitem(history.dest_fileitem) + src_fileitems = [fileitem] if fileitem else [] # 从历史数据获取信息 if transer_item.from_history: @@ -878,7 +905,7 @@ def _execute_manual_transfer( # 前端显式传入文件列表时,按选中的文件逐个处理,避免将目录整体展开。 if explicit_selected_files: - preview_items: List[dict] = [] + preview_items: List[dict[str, Any]] = [] submission_items: list[_SchemaManualTransferSubmissionItem] = [] error_messages: List[str] = [] all_success = True @@ -920,7 +947,8 @@ def _execute_manual_transfer( _public_transfer_result(errormsg).get("items") or [] ) if errormsg.get("message"): - error_messages.append(errormsg.get("message")) + message = _public_transfer_message(errormsg.get("message")) + error_messages.append(message or "") if not state: all_success = False else: @@ -940,7 +968,7 @@ def _execute_manual_transfer( submission_items.extend(_manual_submission_items(src_fileitem, state, errormsg, target_path)) if transer_item.preview: - merged_preview_items: List[dict] = [] + merged_preview_items: List[dict[str, Any]] = [] seen_sources = set() for preview_item in preview_items: source = (preview_item.get("source_storage"), preview_item.get("source")) @@ -968,7 +996,7 @@ def _execute_manual_transfer( } return _SchemaResponse( success=True, - message=merged_message or None, + message=merged_message or "", data=preview_data, ) @@ -1017,7 +1045,7 @@ def _execute_manual_transfer( # 预览失败仍返回完整预览列表,实际提交的总状态已在上方独立处理。 if isinstance(errormsg, dict): public_result = _public_transfer_result(errormsg) - return _SchemaResponse(success=True, message=public_result.get("message"), data=public_result) + return _SchemaResponse(success=True, message=public_result.get("message") or "", data=public_result) if state: return _SchemaResponse(success=True, data=errormsg or {}) return _SchemaResponse(success=False, message=_format_manual_transfer_failure( @@ -1051,7 +1079,7 @@ def recommend_episode_format( logger.warn(f"推荐集数定位模板失败:{target_path} - {errmsg}") return _SchemaResponse( success=False, - message=_public_transfer_message(errmsg), + message=_public_transfer_message(errmsg) or "", ) logger.info( f"推荐集数定位模板成功:{target_path} - 规则 {data.get('rule_name') if data else None}" diff --git a/app/api/endpoints/user.py b/app/api/endpoints/user.py index e6dbc4e27..05e560756 100644 --- a/app/api/endpoints/user.py +++ b/app/api/endpoints/user.py @@ -70,7 +70,7 @@ def _prepare_password(user_info: dict[str, Any]) -> str | None: openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True}, ) async def list_users( - response: Response = None, + response: Response = None, # type: ignore[assignment] service: UserService = Depends(get_user_service), current_user: Any = Depends(get_current_active_superuser_async), page: CompatiblePageParam = None, @@ -184,7 +184,7 @@ async def upload_avatar( service: UserService = Depends(get_user_service), file: UploadFile = File(...), current_user: Any = Depends(get_current_active_user_async), -) -> _SchemaResponse[_SchemaFileNameData]: +) -> _SchemaResponse[dict[str, Any]]: """ 上传用户头像 """ @@ -209,7 +209,7 @@ async def upload_avatar( async def get_config( key: str, current_user: Any = Depends(get_current_active_user_async), -) -> _SchemaResponse[_SchemaValueData]: +) -> _SchemaResponse[dict[str, Any]]: """ 查询用户配置 """ diff --git a/app/api/endpoints/workflow.py b/app/api/endpoints/workflow.py index 9e8ac4603..de629d9d0 100644 --- a/app/api/endpoints/workflow.py +++ b/app/api/endpoints/workflow.py @@ -51,7 +51,7 @@ router = ResponseAPIRouter() openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True}, ) async def list_workflows( - response: Response = None, + response: Response = None, # type: ignore[assignment] query: WorkflowQueryService = Depends(get_workflow_query_service), _: Any = Depends(get_current_active_manage_user_async), page: CompatiblePageParam = None, @@ -73,7 +73,7 @@ async def list_workflows( openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True}, ) async def list_agent_workflows( - response: Response = None, + response: Response = None, # type: ignore[assignment] state: Literal["W", "R", "P", "S", "F", "all"] = "all", name: Optional[str] = None, trigger_type: Literal["timer", "event", "manual", "all"] = "all", diff --git a/app/api/response.py b/app/api/response.py index 53046df5a..6673166e2 100644 --- a/app/api/response.py +++ b/app/api/response.py @@ -184,7 +184,8 @@ class ResponseAPIRoute(APIRoute): force_raw=force_raw, ) if should_wrap: - kwargs["response_model"] = Response.__class_getitem__(response_model) + # Pydantic 同时接受运行时类型别名;其类型声明只列出了 type。 + kwargs["response_model"] = Response.__class_getitem__(cast(type[Any], response_model)) endpoint = self._wrap_endpoint(endpoint) kwargs["responses"] = self._merge_error_responses( diff --git a/app/api/servarr.py b/app/api/servarr.py index abe42eda8..9edf90f90 100644 --- a/app/api/servarr.py +++ b/app/api/servarr.py @@ -25,7 +25,7 @@ from app.domain.context import MediaInfo from app.domain.metainfo import MetaInfo from app.runtime.version import get_app_version from app.schemas.response import Response as _SchemaResponse -from app.schemas.servarr import RadarrMovie, SonarrSeries +from app.schemas.servarr import RadarrMovie, SonarrSeason, SonarrSeries from app.schemas.servarr import RadarrMovie as _SchemaRadarrMovie from app.schemas.servarr import ServarrIdResponse as _SchemaServarrIdResponse from app.schemas.servarr import ServarrLanguageProfile as _SchemaServarrLanguageProfile @@ -75,15 +75,16 @@ def _resolve_series_media( meta = None if tvdbid: tvdbinfo = MediaChain().tvdb_info(tvdbid=tvdbid) - if tvdbinfo and tvdbinfo.get("name"): - meta = MetaInfo(tvdbinfo.get("name")) + tvdb_name = tvdbinfo.get("name") if tvdbinfo else None + if isinstance(tvdb_name, str) and tvdb_name: + meta = MetaInfo(tvdb_name) if not meta and title: meta = MetaInfo(title) if not meta: return None meta.type = MediaType.TV if year: - meta.year = year + meta.year = str(year) return MediaChain().recognize_by_meta(meta, obtain_images=False) @@ -622,10 +623,7 @@ async def arr_series( title=subscribe.name, seasonCount=1, seasons=[ - { - "seasonNumber": subscribe.season, - "monitored": True, - } + SonarrSeason(seasonNumber=subscribe.season, monitored=True) ], remotePoster=subscribe.poster, year=subscribe.year, @@ -675,17 +673,22 @@ def arr_series_lookup( continue # 季信息(只取默认季类型,排除特别季) + seasons_data = tvdbinfo.get("seasons") or [] sea_num = len( [ season - for season in tvdbinfo.get("seasons") + for season in seasons_data + if isinstance(season, dict) if season["type"]["id"] == tvdbinfo.get("defaultSeasonType") and season["number"] > 0 ] ) seas = list(range(1, int(sea_num) + 1)) if sea_num else [] # 根据TVDB查询媒体信息 - meta = MetaInfo(tvdbinfo.get("name")) + tvdb_name = tvdbinfo.get("name") + if not isinstance(tvdb_name, str) or not tvdb_name: + continue + meta = MetaInfo(tvdb_name) meta.type = MediaType.TV mediainfo = MediaChain().recognize_by_meta( meta, @@ -704,7 +707,7 @@ def arr_series_lookup( hasfile = False # 查询订阅信息 - seasons: List[dict] = [] + seasons: List[SonarrSeason] = [] subscribes = subscriptions.list_by_media_identity_sync( MediaSource.TMDB, str(mediainfo.tmdb_id), @@ -717,17 +720,11 @@ def arr_series_lookup( for sea in seas: if sea in sub_seas: seasons.append( - { - "seasonNumber": sea, - "monitored": True, - } + SonarrSeason(seasonNumber=sea, monitored=True) ) else: seasons.append( - { - "seasonNumber": sea, - "monitored": False, - } + SonarrSeason(seasonNumber=sea, monitored=False) ) subid = subscribes[-1].id else: @@ -735,10 +732,7 @@ def arr_series_lookup( monitored = False for sea in seas: seasons.append( - { - "seasonNumber": sea, - "monitored": False, - } + SonarrSeason(seasonNumber=sea, monitored=False) ) sonarr_series = SonarrSeries( id=subid, @@ -781,10 +775,7 @@ async def arr_serie( title=subscribe.name, seasonCount=1, seasons=[ - { - "seasonNumber": subscribe.season, - "monitored": True, - } + SonarrSeason(seasonNumber=subscribe.season, monitored=True) ], year=subscribe.year, remotePoster=subscribe.poster, diff --git a/app/api/servcookie.py b/app/api/servcookie.py index 2bdb96c26..67157bfad 100644 --- a/app/api/servcookie.py +++ b/app/api/servcookie.py @@ -10,9 +10,10 @@ from typing import Annotated, Optional import aiofiles from anyio import Path as AsyncPath -from fastapi import APIRouter, Body, Depends, Header, HTTPException, Path, Request, Response +from fastapi import APIRouter, Body, Depends, Header, HTTPException, Path, Response from fastapi.responses import PlainTextResponse from fastapi.routing import APIRoute +from starlette.requests import Request from app.api.response import ERROR_RESPONSES from app.application.configuration import get_api_runtime_config_snapshot diff --git a/app/application/classification/configuration.py b/app/application/classification/configuration.py index 3562c1b61..bb872f9fa 100644 --- a/app/application/classification/configuration.py +++ b/app/application/classification/configuration.py @@ -360,7 +360,7 @@ def with_default_music_classification(policy: ClassificationPolicy) -> Classific 为尚未配置音乐分类的策略追加安全、结构化的常用专辑分类。 """ if not needs_default_music_classification(policy): - return cast(ClassificationPolicy, policy.model_copy(deep=True)) + return policy.model_copy(deep=True) categories = [ *(item.model_copy(deep=True) for item in policy.categories), @@ -429,13 +429,10 @@ def with_default_music_classification(policy: ClassificationPolicy) -> Classific value=album_type, category_id=category_id, ) - return cast( - ClassificationPolicy, - policy.model_copy( + return policy.model_copy( deep=True, update={"categories": categories, "rules": rules}, - ), - ) + ) def needs_default_music_classification(policy: ClassificationPolicy) -> bool: @@ -534,20 +531,14 @@ class ClassificationPolicyConfigurationService: 返回与内部活动引用隔离的当前策略副本。 """ with self._lock: - return cast( - ClassificationPolicy, - self._require_state().active.model_copy(deep=True), - ) + return self._require_state().active.model_copy(deep=True) def state(self) -> ClassificationPolicyState: """ 返回与内部活动引用隔离的完整状态包副本。 """ with self._lock: - return cast( - ClassificationPolicyState, - self._require_state().model_copy(deep=True), - ) + return self._require_state().model_copy(deep=True) def history(self) -> tuple[ClassificationPolicy, ...]: """ @@ -763,10 +754,7 @@ class ClassificationPolicyConfigurationService: self._publish_memory_snapshot(stored) raise self._publish_memory_snapshot(desired) - return cast( - ClassificationPolicy, - desired.active.model_copy(deep=True), - ) + return desired.active.model_copy(deep=True) def _publish_memory_snapshot(self, state: ClassificationPolicyState) -> None: """ diff --git a/app/application/classification/enrichment.py b/app/application/classification/enrichment.py index 9cda52a30..3990a8a41 100644 --- a/app/application/classification/enrichment.py +++ b/app/application/classification/enrichment.py @@ -471,7 +471,7 @@ class ClassificationEnrichmentService: provider_id=call.provider.provider_id, provider_name=call.provider.provider_name, ) - return cast(ClassificationFacts, merged) + return merged def _warn(self, message: str) -> None: """ @@ -576,10 +576,7 @@ def _validated_response( ): continue accepted[field_id] = value - return cast( - ClassificationEnrichmentResponse, - response.model_copy(deep=True, update={"facts": accepted}), - ) + return response.model_copy(deep=True, update={"facts": accepted}) def _validate_match( diff --git a/app/application/classification/execution.py b/app/application/classification/execution.py index 94196fa6d..68aa9829b 100644 --- a/app/application/classification/execution.py +++ b/app/application/classification/execution.py @@ -7,7 +7,7 @@ from __future__ import annotations from collections.abc import Callable, Mapping from copy import deepcopy from enum import Enum -from typing import Final, Protocol, TypeAlias, cast +from typing import Final, Protocol, TypeAlias from app.application.classification.legacy import ( build_legacy_tmdb_extension_facts, @@ -536,13 +536,10 @@ def _legacy_tmdb_compatibility_facts( target = extensions.setdefault(source, {}) for field, value in values.items(): target.setdefault(field, value) - return cast( - ClassificationFacts, - facts.model_copy( + return facts.model_copy( deep=True, update={"extensions": extensions}, - ), - ) + ) def evaluate_classification_facts( @@ -686,7 +683,7 @@ def _degraded_automatic_selection( return None if not str(selection.category_id or "").strip(): return None - return cast(ClassificationSelection, selection.model_copy(deep=True)) + return selection.model_copy(deep=True) def _enum_text(value: object) -> str: diff --git a/app/application/classification/runtime.py b/app/application/classification/runtime.py index e7b7e3da2..b92b6879b 100644 --- a/app/application/classification/runtime.py +++ b/app/application/classification/runtime.py @@ -5,7 +5,7 @@ from __future__ import annotations import threading -from typing import Optional, cast +from typing import Optional from app.application.classification.configuration import ( ClassificationPolicyConfigurationService, @@ -82,7 +82,7 @@ class ClassificationRuntime: if policy is not None: return project_policy_to_legacy_category_config(policy) with self._lock: - return cast(CategoryConfig, self._legacy_config.model_copy(deep=True)) + return self._legacy_config.model_copy(deep=True) def media_categories(self) -> MediaCategoryMap: """ diff --git a/app/application/directory.py b/app/application/directory.py index b6352c04f..1b91c84fd 100644 --- a/app/application/directory.py +++ b/app/application/directory.py @@ -58,7 +58,7 @@ def build_media_download_path( not directory.media_type and directory.download_type_folder ) if type_folder_enabled: - download_path = download_path / media.type.value + download_path = download_path / (media.type or MediaType.UNKNOWN).value helper = directory_helper or DirectoryHelper() if helper.has_fixed_category(directory) or not directory.download_category_folder: @@ -382,7 +382,7 @@ class DirectoryHelper: """ 获取所有下载目录 """ - dir_confs: List[dict] = get_configured_system_config().get(SystemConfigKey.Directories) + dir_confs: List[dict[str, Any]] = get_configured_system_config().get(SystemConfigKey.Directories) if not dir_confs: return [] return [_SchemaTransferDirectoryConf(**d) for d in dir_confs] @@ -391,7 +391,7 @@ class DirectoryHelper: """ 获取所有下载目录 """ - return sorted([d for d in self.get_dirs() if d.download_path], key=lambda x: x.priority) + return sorted([d for d in self.get_dirs() if d.download_path], key=lambda x: x.priority or 0) def get_local_download_dirs(self) -> List[_SchemaTransferDirectoryConf]: """ @@ -480,7 +480,7 @@ class DirectoryHelper: """ 获取所有媒体库目录 """ - return sorted([d for d in self.get_dirs() if d.library_path], key=lambda x: x.priority) + return sorted([d for d in self.get_dirs() if d.library_path], key=lambda x: x.priority or 0) def get_local_library_dirs(self) -> List[_SchemaTransferDirectoryConf]: """ @@ -566,7 +566,10 @@ class DirectoryHelper: dirs = self.get_dirs() # 如果存在源目录,并源目录为任一下载目录的子目录时,则进行源目录匹配,否则,允许源目录按同盘优先的逻辑匹配 - matching_dirs = [d for d in dirs if src_path.is_relative_to(d.download_path)] if src_path else [] + matching_dirs = [ + d for d in dirs + if src_path and d.download_path and src_path.is_relative_to(d.download_path) + ] dirs_to_consider = matching_dirs if matching_dirs else dirs matched_dirs: list[tuple[int, int, _SchemaTransferDirectoryConf]] = [] @@ -577,7 +580,7 @@ class DirectoryHelper: continue if target_storage and d.library_storage != target_storage: continue - if dest_path and dest_path != Path(d.library_path): + if dest_path and d.library_path and dest_path != Path(d.library_path): continue rank = self.media_match_rank(d, media) if rank is not None: @@ -599,8 +602,13 @@ class DirectoryHelper: if src_path: # 同一匹配等级内优先源目录同盘,不能盖过更精确的分类引用。 for _, matched_dir in best_dirs: + if not matched_dir.download_path: + continue matched_path = Path(matched_dir.download_path) - if self._is_same_source((src_path, storage or "local"), (matched_path, matched_dir.library_storage)): + if self._is_same_source( + (src_path, storage or "local"), + (matched_path, matched_dir.library_storage or "local"), + ): return matched_dir return best_dirs[0][1] return None diff --git a/app/application/filtering.py b/app/application/filtering.py index 3776b1879..6afd2930d 100644 --- a/app/application/filtering.py +++ b/app/application/filtering.py @@ -6,7 +6,7 @@ import copy import re from collections.abc import Awaitable, Callable from contextlib import AbstractAsyncContextManager -from typing import Any, Dict, Iterable, Optional, cast +from typing import Any, Dict, Iterable, Optional from app.application.configuration import get_configured_system_config from app.application.rules import ( @@ -230,7 +230,7 @@ def serialize_custom_rule( """ 导出自定义规则并附带规则组引用,省略值为 None 的模型字段。 """ - data = cast(dict[str, Any], rule.model_dump(exclude_none=True)) + data = rule.model_dump(exclude_none=True) data["source"] = "custom" data["referenced_by_rule_groups"] = group_refs or [] return data @@ -243,7 +243,7 @@ def serialize_rule_group( """ 查询时尽量附带解析结果,便于 Agent 理解优先级层级。 """ - data = cast(dict[str, Any], group.model_dump(exclude_none=True)) + data = group.model_dump(exclude_none=True) if group.rule_string: try: parsed = parse_rule_string(group.rule_string) @@ -291,6 +291,9 @@ async def collect_rule_group_usages( } def ensure_usage(name: str) -> dict[str, Any]: + """ + 为规则组创建并复用本次统计中的使用量记录。 + """ if name not in usage_map: usage_map[name] = default_rule_group_usage() return usage_map[name] diff --git a/app/application/image.py b/app/application/image.py index 5528797d0..ff83d3d21 100644 --- a/app/application/image.py +++ b/app/application/image.py @@ -34,9 +34,9 @@ def _cached_wallpaper(*, skip_empty: bool = False) -> Callable[[_WallpaperFuncti """ 以相同有效期缓存壁纸,空列表仍按调用方策略处理。 """ - return cast(_WallpaperFunction, cached( + return cached( maxsize=1, ttl=3600, skip_empty=skip_empty, local_only=True, - )(function)) + )(function) return decorate diff --git a/app/application/mediaserver.py b/app/application/mediaserver.py index d82402f89..16d65c1a8 100644 --- a/app/application/mediaserver.py +++ b/app/application/mediaserver.py @@ -7,7 +7,7 @@ import re from collections.abc import Iterable, Mapping from dataclasses import dataclass from datetime import datetime -from typing import Any, Optional, Protocol, Union, cast +from typing import Any, Optional, Protocol, Union from app.application.service import ServiceBaseHelper from app.domain.context import MusicInfo @@ -25,9 +25,9 @@ from app.schemas.types import ( ) # 查重只需要来源身份与年份;具体音乐匹配由模块补充专辑完整性证据。 -MEDIA_SERVER_EXISTENCE_QUERY = MediaServerQueryOptions(fields={ +MEDIA_SERVER_EXISTENCE_QUERY = MediaServerQueryOptions(fields=frozenset({ MediaServerField.IDENTITY, MediaServerField.YEAR, -}) +})) @dataclass(frozen=True, slots=True) @@ -548,7 +548,7 @@ def format_emby_family_item( } if include_server_id: fields["server_id"] = item.get("ServerId") - return cast(_SchemaMediaServerItem, _SchemaMediaServerItem.model_validate(fields)) + return _SchemaMediaServerItem.model_validate(fields) except Exception as error: logger.error(error) return None diff --git a/app/application/messaging/chat.py b/app/application/messaging/chat.py index b67c1ecb8..2e44b0f59 100644 --- a/app/application/messaging/chat.py +++ b/app/application/messaging/chat.py @@ -7,7 +7,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable from dataclasses import dataclass -from typing import Any, List, Optional, Protocol, cast +from typing import Any, List, Optional, Protocol from weakref import WeakValueDictionary from app.application.database import ( @@ -467,13 +467,10 @@ class AgentChatService: """ 把持久化投影转换为会话详情 DTO。 """ - return cast( - AgentChatSessionDetail, - AgentChatSessionDetail.model_validate({ + return AgentChatSessionDetail.model_validate({ **cls.to_summary(record).model_dump(), "messages": record.messages, - }), - ) + }) @staticmethod def _project(record: Any) -> AgentChatRecord: diff --git a/app/application/messaging/message.py b/app/application/messaging/message.py index e62a264a6..1846c5f03 100644 --- a/app/application/messaging/message.py +++ b/app/application/messaging/message.py @@ -145,7 +145,7 @@ class TemplateContextBuilder: def build( self, meta: Optional[MetaBase] = None, - mediainfo: Optional[MediaInfo] = None, + mediainfo: Optional[Union[MediaInfo, MusicInfo]] = None, torrentinfo: Optional[TorrentInfo] = None, transferinfo: Optional[TransferInfo] = None, file_extension: Optional[str] = None, @@ -206,7 +206,7 @@ class TemplateContextBuilder: def _add_media_info( cls, context: Dict[str, Any], - mediainfo: Optional[MediaInfo], + mediainfo: Optional[Union[MediaInfo, MusicInfo]], aggregate_music_album: bool = False, ) -> None: """ @@ -259,7 +259,7 @@ class TemplateContextBuilder: else (context.get("track_number") or mediainfo.track_number) ) context.update({ - "type": mediainfo.type.value, + "type": (mediainfo.type.value if mediainfo.type else ""), "title": title, "name": title if is_album_context else (context.get("name") or title), "artists": artists, @@ -317,7 +317,7 @@ class TemplateContextBuilder: _meta_season = context.get("season") media_info = { # 类型 - "type": mediainfo.type.value, + "type": (mediainfo.type.value if mediainfo.type else ""), # 类别 "category": mediainfo.category, # 评分 @@ -542,7 +542,7 @@ class TemplateContextBuilder: def _add_raw_objects( context: Dict[str, Any], meta: Optional[MetaBase], - mediainfo: Optional[MediaInfo], + mediainfo: Optional[Union[MediaInfo, MusicInfo]], torrentinfo: Optional[TorrentInfo], transferinfo: Optional[TransferInfo], episodes_info: Optional[List[TmdbEpisode]], @@ -568,11 +568,19 @@ class TemplateContextBuilder: @staticmethod @overload - def __convert_invalid_characters(filename: str) -> str: ... + def __convert_invalid_characters(filename: str) -> str: + """ + 按文件名输入是否存在声明对应的清理结果,保留 None 的缺省语义。 + """ + ... @staticmethod @overload - def __convert_invalid_characters(filename: None) -> None: ... + def __convert_invalid_characters(filename: None) -> None: + """ + 按文件名输入是否存在声明对应的清理结果,保留 None 的缺省语义。 + """ + ... @staticmethod def __convert_invalid_characters(filename: Optional[str]) -> Optional[str]: diff --git a/app/application/module.py b/app/application/module.py index bf0c489b4..7e8274b26 100644 --- a/app/application/module.py +++ b/app/application/module.py @@ -75,9 +75,9 @@ class ModuleManager(metaclass=_ModuleRuntimeProxy): 应用层兼容门面,实例调用返回组合根装配的模块管理器。 """ - def __new__(cls) -> ModuleRuntime: + def __new__(cls) -> Any: """ - 返回实际模块管理器,不复制运行态注册表。 + 返回组合根提供的对象,其具体类型由装配决定,兼容门面不持有运行态注册表。 """ return get_module_manager() diff --git a/app/application/plugin/catalog.py b/app/application/plugin/catalog.py index 75e341d1c..bb7612f6d 100644 --- a/app/application/plugin/catalog.py +++ b/app/application/plugin/catalog.py @@ -8,7 +8,7 @@ import asyncio import concurrent.futures from collections.abc import Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass -from typing import Any, Literal, Optional, cast +from typing import Any, Literal, Optional from app.application.plugin.identity import ( OFFICIAL_PLUGIN_SOURCE_KEY, @@ -632,17 +632,17 @@ class PluginCatalogQuery: """ 按绑定来源收敛更新标记,不修改共享的市场候选快照。 """ - if identity is None or not identity.trusted_source_key: + if identity is None or not identity.trusted_source_key or not plugin.id: return plugin + trusted_source_key = identity.trusted_source_key + plugin_id = plugin.id bound_updates = [ candidate for candidate in online_plugins - if _matches_trusted_source(candidate, plugin.id, identity.trusted_source_key) + if _matches_trusted_source(candidate, plugin_id, trusted_source_key) ] preferred = self._process_plugins(bound_updates, []) if bound_updates else [] - return preferred[0] if preferred else cast( - Plugin, plugin.model_copy(update={"has_update": False}) - ) + return preferred[0] if preferred else plugin.model_copy(update={"has_update": False}) _catalog_query: PluginCatalogQuery | None = None @@ -710,9 +710,7 @@ def _project_update_candidate( source_type: Literal["official", "third_party"] = ( "official" if source_key == OFFICIAL_PLUGIN_SOURCE_KEY else "third_party" ) - return cast( - Plugin, - plugin.model_copy( + return plugin.model_copy( update={ "update_candidate": PluginUpdateCandidate( source_type=source_type, @@ -724,5 +722,4 @@ def _project_update_candidate( ), ) }, - ), - ) + ) diff --git a/app/application/plugin/management.py b/app/application/plugin/management.py index 2e95b5fe5..8e59b6f1e 100644 --- a/app/application/plugin/management.py +++ b/app/application/plugin/management.py @@ -2,7 +2,7 @@ 插件目录、生命周期和数据投影应用能力。 """ -from typing import Any, Iterable, Optional +from typing import Any, Iterable, Optional, cast from anyio import to_thread @@ -60,7 +60,7 @@ def reload_plugin_runtime(plugin_id: str) -> PluginRuntimeStatus: runtime_status = plugin_manager.reload_plugin_tree(plugin_id) for reload_target in plugin_manager.get_plugin_reload_targets(plugin_id): refresh_plugin_registrations(reload_target) - return runtime_status + return cast(PluginRuntimeStatus, runtime_status) def summarize_plugin(plugin: Any) -> dict[str, Any]: @@ -166,7 +166,7 @@ async def load_market_plugins(force_refresh: bool = False) -> list[Any]: local_repo_plugins = plugin_manager.get_local_repo_plugins() if not online_plugins and not local_repo_plugins: return [] - return plugin_manager.process_plugins_list(online_plugins + local_repo_plugins, []) + return cast(list[Any], plugin_manager.process_plugins_list(online_plugins + local_repo_plugins, [])) def list_installed_plugins() -> list[Any]: @@ -178,6 +178,9 @@ def list_installed_plugins() -> list[Any]: def _normalize_text(value: Optional[str]) -> str: + """ + 规范化插件检索文本,空配置按空字符串处理。 + """ return (value or "").strip().lower() diff --git a/app/application/plugin/release.py b/app/application/plugin/release.py index a071beace..b6f5b8c6b 100644 --- a/app/application/plugin/release.py +++ b/app/application/plugin/release.py @@ -6,7 +6,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass -from typing import Any, cast +from typing import Any from app.application.plugin.catalog import apply_declared_metadata_fallback from app.application.plugin.identity import PluginIdentity, PluginPayloadSourceType @@ -238,9 +238,7 @@ def _merge_market_metadata(plugin: Plugin, market_plugin: Plugin) -> Plugin: """ 在隔离副本上合并市场展示元数据。 """ - return cast( - Plugin, - plugin.model_copy( + return plugin.model_copy( update={ "repo_url": market_plugin.repo_url or plugin.repo_url, "history": market_plugin.history or {}, @@ -259,8 +257,7 @@ def _merge_market_metadata(plugin: Plugin, market_plugin: Plugin) -> Plugin: market_plugin.runtime_message or plugin.runtime_message ), } - ), - ) + ) def _project_release_item( diff --git a/app/application/query.py b/app/application/query.py index 5cd24fb4d..e837f5bf4 100644 --- a/app/application/query.py +++ b/app/application/query.py @@ -185,7 +185,7 @@ class DataQueryService: return QueryPageRequest() if isinstance(page, QueryPageRequest): return page - return cast(QueryPageRequest, QueryPageRequest.model_validate(page)) + return QueryPageRequest.model_validate(page) @staticmethod def _filter( diff --git a/app/application/rss.py b/app/application/rss.py index 5f3ba3924..887e501db 100644 --- a/app/application/rss.py +++ b/app/application/rss.py @@ -3,6 +3,7 @@ RSS 订阅获取、站点链接生成、浏览器回退与资源解析。 """ import traceback +from datetime import datetime from typing import Any, List, Optional, Protocol, Tuple, Union from urllib.parse import urljoin, urlparse @@ -155,7 +156,7 @@ class RssHelper: MAX_RSS_ITEMS = 1000 # 各站点RSS链接获取配置 - rss_link_conf = { + rss_link_conf: dict[str, dict[str, Any]] = { "default": { "xpath": "//a[@class='faqlink']/@href", "url": "getrss.php", @@ -374,7 +375,7 @@ class RssHelper: return ".".join(parts[-2:]) if len(parts) >= 2 else hostname @staticmethod - def _parse_publish_time(value: str): + def _parse_publish_time(value: str) -> Optional[datetime]: """ 将 RSS 常见日期表达解析为 datetime,无法解析时返回 None。 """ @@ -383,7 +384,7 @@ class RssHelper: except dateutil.parser.ParserError: return None - def __parse_with_rust(self, ret_xml: Optional[str]) -> Optional[list]: + def __parse_with_rust(self, ret_xml: Optional[str]) -> Optional[list[dict[str, Any]]]: """ 调用 Rust RSS 解析器,并统一处理基础 XML 校验和最大条目限制。 """ @@ -400,19 +401,19 @@ class RssHelper: logger.warning(f"RSS条目过多: 超过{self.MAX_RSS_ITEMS},仅处理前{self.MAX_RSS_ITEMS}个") return rust_items[:self.MAX_RSS_ITEMS] - def parse(self, url, proxy: bool = False, - timeout: Optional[int] = 15, headers: dict = None, ua: str = None) -> Union[List[dict], None, bool]: + def parse(self, url: str, proxy: bool = False, + timeout: Optional[int] = 15, headers: Optional[dict[str, str]] = None, ua: Optional[str] = None) -> Union[List[dict[str, Any]], None, bool]: """ 解析 RSS 地址并保留插件兼容的返回约定。 """ - url = UrlUtils.normalize_http_url(url) - if url is None: + normalized_url = UrlUtils.normalize_http_url(url) + if normalized_url is None: return False - return self._parse_impl(url, proxy=proxy, timeout=timeout, headers=headers, ua=ua) + return self._parse_impl(normalized_url, proxy=proxy, timeout=timeout, headers=headers, ua=ua) - def _parse_impl(self, url, proxy: bool = False, - timeout: Optional[int] = 15, headers: dict = None, - ua: str = None) -> Union[List[dict], None, bool]: + def _parse_impl(self, url: str, proxy: bool = False, + timeout: Optional[int] = 15, headers: Optional[dict[str, str]] = None, + ua: Optional[str] = None) -> Union[List[dict[str, Any]], None, bool]: """ 解析RSS订阅URL,获取RSS中的种子信息 :param url: RSS地址 @@ -422,7 +423,7 @@ class RssHelper: :param ua: 自定义User-Agent :return: 种子信息列表,如为None代表Rss过期,如果为False则为错误 """ - ret_array: list = [] + ret_array: list[dict[str, Any]] = [] http_port, _, _ = _require_rss_ports() try: @@ -567,12 +568,12 @@ class RssHelper: if not pubdate_nodes: pubdate_nodes = item.xpath('.//*[local-name()="pubDate"] | .//*[local-name()="published"] | .//*[local-name()="updated"]') - pubdate = "" + pubdate: Union[str, datetime] = "" if pubdate_nodes and pubdate_nodes[0].text: - pubdate = self._parse_publish_time(pubdate_nodes[0].text) - if pubdate is not None: + parsed_pubdate = self._parse_publish_time(pubdate_nodes[0].text) + if parsed_pubdate is not None: # 转为本地时区 - pubdate = pubdate.astimezone(tz=None) + pubdate = parsed_pubdate.astimezone(tz=None) # 获取豆瓣昵称 nickname_nodes = item.xpath('.//*[local-name()="creator"]') @@ -617,7 +618,7 @@ class RssHelper: return ret_array - def get_rss_link(self, url: str, cookie: str, ua: str, proxy: bool = False, timeout: int = None) -> Tuple[str, str]: + def get_rss_link(self, url: str, cookie: str, ua: str, proxy: bool = False, timeout: Optional[int] = None) -> Tuple[str, str]: """ 获取站点rss地址 :param url: 站点地址 @@ -631,6 +632,8 @@ class RssHelper: http_port, browser_port, _ = _require_rss_ports() domain = self._get_site_domain(url) site_conf = self.rss_link_conf.get(domain) or self.rss_link_conf.get("default") + if site_conf is None: + return "", f"获取RSS链接失败:未找到站点配置 {url}" rss_url = urljoin(url, site_conf.get("url")) rss_params = site_conf.get("params") if site_conf.get("render"): @@ -644,7 +647,7 @@ class RssHelper: else: res = http_port.post( url=rss_url, - data=rss_params, + data=rss_params if isinstance(rss_params, dict) else {}, cookie=cookie, ua=ua, proxies=get_chain_runtime_config_snapshot().proxy if proxy else None, diff --git a/app/application/rules.py b/app/application/rules.py index d8d5ee688..93f288ef4 100644 --- a/app/application/rules.py +++ b/app/application/rules.py @@ -537,6 +537,7 @@ class RuleHelper: if not group.media_type or ( media + and media.type and group.media_type == media.type.value and (not group.category or group.category == media.category) ) diff --git a/app/application/security/url.py b/app/application/security/url.py index 5bd1a733e..5f93d66f4 100644 --- a/app/application/security/url.py +++ b/app/application/security/url.py @@ -1063,9 +1063,11 @@ def _log_image_proxy_block_summary(summary: CoalesceSummary) -> None: `(host, reason_label)` 二元组;摘要保留首条事件的 URL 与解析 IP, 避免运维只看到 count 而无法定位是哪批请求被合并。 """ - host, reason = summary.key - payload = summary.first_payload or {} - sample_ips = ",".join(payload.get("ips") or []) + key = cast(tuple[Optional[str], str], summary.key) + host, reason = key + payload = summary.first_payload if isinstance(summary.first_payload, dict) else {} + raw_ips = payload.get("ips") + sample_ips = ",".join(str(ip) for ip in raw_ips) if isinstance(raw_ips, list) else "" logger.warn( "Blocked unsafe image URL (aggregated): " f"host={host or ''}, reason={reason}, " diff --git a/app/application/settings/contract.py b/app/application/settings/contract.py index 8391bf20c..286693753 100644 --- a/app/application/settings/contract.py +++ b/app/application/settings/contract.py @@ -718,7 +718,7 @@ def _runtime_settings_schema() -> dict[str, Any]: """ 缓存 Settings 生成的完整 JSON Schema,避免逐项重复建模。 """ - return cast(dict[str, Any], Settings.model_json_schema()) + return Settings.model_json_schema() def validate_setting_value(spec: SettingSpec, value: Any) -> None: diff --git a/app/application/site/contract.py b/app/application/site/contract.py index 759735a01..2abdae6a9 100644 --- a/app/application/site/contract.py +++ b/app/application/site/contract.py @@ -62,6 +62,40 @@ _SITE_USERDATA_MUTATION_FIELDS = frozenset( ) +class SiteCookieLoginSource(Protocol): + """ + Cookie 登录消费的只读站点字段,兼容持久化快照和 API 查询 DTO。 + """ + + @property + def id(self) -> Optional[int]: + """ + 返回站点主键,未保存的配置可为空。 + """ + ... + + @property + def url(self) -> Optional[str]: + """ + 返回站点登录入口。 + """ + ... + + @property + def proxy(self) -> Optional[int]: + """ + 返回是否使用宿主代理的配置。 + """ + ... + + @property + def timeout(self) -> Optional[int]: + """ + 返回登录请求的超时秒数。 + """ + ... + + class _FrozenJsonDict(dict[str, JsonData]): """ 保留 JSON 字典兼容读取,并拒绝原地修改。 diff --git a/app/application/site/sites.pyi b/app/application/site/sites.pyi index be795c5ea..b0352cdae 100644 --- a/app/application/site/sites.pyi +++ b/app/application/site/sites.pyi @@ -4,7 +4,7 @@ 实现由发布资源提供,此文件供宿主静态检查使用。 """ -from typing import List, Optional, Tuple +from typing import Any, List, Optional, Tuple class SitesHelper: """ @@ -44,37 +44,37 @@ class SitesHelper: """ ... - def get_indexers(self) -> List[dict]: + def get_indexers(self) -> List[dict[str, Any]]: """ 返回全部可用站点索引配置。 """ ... - async def async_get_indexers(self) -> List[dict]: + async def async_get_indexers(self) -> List[dict[str, Any]]: """ 异步返回全部可用站点索引配置。 """ ... - def get_indexer(self, domain: str) -> Optional[dict]: + def get_indexer(self, domain: str) -> Optional[dict[str, Any]]: """ 按域名返回单个站点索引配置。 """ ... - async def async_get_indexer(self, domain: str) -> Optional[dict]: + async def async_get_indexer(self, domain: str) -> Optional[dict[str, Any]]: """ 异步按域名返回单个站点索引配置。 """ ... - def get_authsites(self) -> dict: + def get_authsites(self) -> dict[str, Any]: """ 返回认证站点配置。 """ ... - def get_indexsites(self) -> dict: + def get_indexsites(self) -> dict[str, Any]: """ 返回内置站点索引配置。 """ @@ -83,7 +83,7 @@ class SitesHelper: def check_user( self, site: Optional[str] = None, - params: Optional[dict] = None, + params: Optional[dict[str, Any]] = None, ) -> Tuple[bool, str]: """ 校验用户站点认证信息并返回状态与消息。 diff --git a/app/application/subscription/priority.py b/app/application/subscription/priority.py index 1991c04de..742a8304f 100644 --- a/app/application/subscription/priority.py +++ b/app/application/subscription/priority.py @@ -644,10 +644,10 @@ def annotate_subscription_download(subscribe: SubscriptionSnapshot, context: Con else: upgraded = set() is_upgrade = bool(subscribe.best_version and subscribe.current_priority) - context.subscription_download = { + context.subscription_download = cast(dict[str, JsonData], { "subscription_id": subscribe.id, "submission_type": "upgrade" if is_upgrade else "initial", "best_version_full": is_full_best_version_enabled(subscribe), "initial_episodes": sorted(selected - upgraded), "upgrade_episodes": sorted(upgraded), - } + }) diff --git a/app/application/subscription/query.py b/app/application/subscription/query.py index 9bb47722a..4a36707df 100644 --- a/app/application/subscription/query.py +++ b/app/application/subscription/query.py @@ -6,7 +6,7 @@ from __future__ import annotations from collections.abc import Mapping from datetime import datetime, timedelta -from typing import Any, Optional, cast +from typing import Any, Optional from app.application.subscription.contract import ( SubscriptionHistoryQueryPort, @@ -66,7 +66,7 @@ class SubscriptionQueryService: payload = record.to_dict() payload["completed_tracks"] = compute_subscribe_completed_tracks(record) payload.pop("downloaded_tracks", None) - return cast(SubscribeView, SubscribeView.model_validate(payload)) + return SubscribeView.model_validate(payload) async def list_public( self, diff --git a/app/application/subscription/write.py b/app/application/subscription/write.py index 7b0f0d73d..f841a5c00 100644 --- a/app/application/subscription/write.py +++ b/app/application/subscription/write.py @@ -462,7 +462,7 @@ def _translate( { "name": mediainfo.title, "year": mediainfo.year, - "type": mediainfo.type.value, + "type": (mediainfo.type.value if mediainfo.type else ""), "media_source": str(media_source), "media_id": media_id, "episode_group": mediainfo.episode_group, diff --git a/app/application/torrent/download.py b/app/application/torrent/download.py index 8e139cf6c..0af68b35c 100644 --- a/app/application/torrent/download.py +++ b/app/application/torrent/download.py @@ -238,7 +238,7 @@ def _text_filter_failure(torrent: TorrentInfo, params: Dict[str, Any], content: ("effect", "特效", torrent.title, True), ): pattern = params.get(key) - if pattern and _filter_pattern_search(pattern, subject) != expected: + if isinstance(pattern, str) and pattern and _filter_pattern_search(pattern, subject or "") != expected: diagnostic = f"{subject} {'不匹配' if expected else '匹配'}{label}规则 {pattern}" return f"不符合{label}条件:{pattern}", diagnostic return None @@ -565,12 +565,12 @@ class TorrentHelper: for site in get_configured_site_query_service().userdata_latest_sync() } - def get_sort_str(_context): + def get_sort_str(_context: Context) -> str: """ 拼装排序字段 """ - _meta = _context.meta_info - _torrent = _context.torrent_info + _meta = _context.meta_info or MetaInfo("") + _torrent = _context.torrent_info or TorrentInfo() _media = _context.media_info _title = str(_media.title if _media else _meta.name).ljust(200, ' ') _site_order = str(999 - (_torrent.site_order or 0)).rjust(3, '0') @@ -615,6 +615,8 @@ class TorrentHelper: # 控重的主链是名称、年份、季、集 meta = context.meta_info media = context.media_info + if meta is None or media is None: + continue if media.type == MediaType.TV: media_name = "%s%s" % (media.title_year, meta.season_episode) @@ -695,13 +697,14 @@ class TorrentHelper: text_tools.normalize_upper(torrent_meta.cn_name), text_tools.normalize_upper(torrent_meta.en_name) } - {""} - if torrent_meta.type == MediaType.TV and mediainfo.type != MediaType.TV: + media_type = mediainfo.type or MediaType.UNKNOWN + if torrent_meta.type == MediaType.TV and media_type != MediaType.TV: logger.debug(f'{torrent.site_name} - {torrent.title} 种子标题类型为 {torrent_meta.type.value},' - f'不匹配 {mediainfo.type.value}') + f'不匹配 {media_type.value}') return False - if torrent.category == MediaType.TV.value and mediainfo.type != MediaType.TV: + if torrent.category == MediaType.TV.value and media_type != MediaType.TV: logger.debug(f'{torrent.site_name} - {torrent.title} 种子在站点中归类为 {torrent.category},' - f'不匹配 {mediainfo.type.value}') + f'不匹配 {media_type.value}') return False if mediainfo.year: if mediainfo.type == MediaType.TV: diff --git a/app/application/transfer/history.py b/app/application/transfer/history.py index 11809459d..634a6b6b1 100644 --- a/app/application/transfer/history.py +++ b/app/application/transfer/history.py @@ -89,7 +89,7 @@ def success_fields( dest_storage=transferinfo.target_item.storage if transferinfo.target_item else None, dest_fileitem=transferinfo.target_item.model_dump() if transferinfo.target_item else None, mode=mode, - type=mediainfo.type.value, + type=(mediainfo.type.value if mediainfo.type else ""), **classification_fields(mediainfo), title=history_title(meta, mediainfo), year=history_year(mediainfo.year), @@ -161,7 +161,7 @@ def failure_fields( dest_storage=transferinfo.target_item.storage if transferinfo.target_item else None, dest_fileitem=transferinfo.target_item.model_dump() if transferinfo.target_item else None, mode=mode, - type=mediainfo.type.value, + type=(mediainfo.type.value if mediainfo.type else ""), **classification_fields(mediainfo), title=history_title(meta, mediainfo), year=history_year(mediainfo.year or meta.year), diff --git a/app/application/transfer/jobs.py b/app/application/transfer/jobs.py index 1316e636a..dd98c6fa5 100644 --- a/app/application/transfer/jobs.py +++ b/app/application/transfer/jobs.py @@ -92,7 +92,7 @@ def _job_task_fileitem(task: TransferJobTask) -> FileItem: return cast(FileItem, task.fileitem) -def _queue_media_snapshot(media: Optional[Union[MediaInfo, MusicInfo]]) -> Optional[TransferQueueMediaData]: +def _queue_media_snapshot(media: Optional[object]) -> Optional[TransferQueueMediaData]: """ 只复制整理队列页面会展示的媒体字段,避免重复输出完整刮削结果。 """ @@ -336,13 +336,13 @@ class JobManager: ) return _SchemaMediaInfo( title=meta.name, - year=meta.year, + year=str(meta.year) if meta.year is not None else None, title_year=f"{meta.name} ({meta.year})", type=meta.type.value if meta.type else None, ) @staticmethod - def __get_meta(task: TransferTask) -> _SchemaMetaInfo: + def __get_meta(task: TransferTask) -> Union[_SchemaMetaInfo, _SchemaMusicMeta]: """ 获取元数据 """ diff --git a/app/chain/_contracts.py b/app/chain/_contracts.py index 7edc3a40c..b8a43884a 100644 --- a/app/chain/_contracts.py +++ b/app/chain/_contracts.py @@ -166,6 +166,16 @@ class MusicSubscribeMixinHost(Protocol): """ ... + def _process_search_subscription(self, *args: Any, **kwargs: Any) -> Any: + """ + 交给订阅搜索组件处理一次订阅搜索或匹配任务。 + + :param args: 订阅搜索组件的定位参数 + :param kwargs: 搜索执行上下文及可选控制参数 + :return: 搜索组件返回的任务结果 + """ + ... + def _SubscribeChain__candidate_contract_changed( self, prepared: Any, diff --git a/app/chain/_interaction.py b/app/chain/_interaction.py index 7b4c59748..2541d18ea 100644 --- a/app/chain/_interaction.py +++ b/app/chain/_interaction.py @@ -4,7 +4,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, ClassVar, Optional, Tuple, Union from app.chain._contracts import InteractionMixinHost from app.schemas.types import NotificationChannel @@ -28,7 +28,7 @@ class InteractionChainMixin: - `_interaction_handler()`:按各链业务动作构造处理器实例。 """ - __mixin_host_protocol__ = InteractionMixinHost + __mixin_host_protocol__: ClassVar[Any] = InteractionMixinHost # 交互处理器类,子类注入(如 SubscribeInteractionHandler / SiteInteractionHandler) _interaction_handler_type: Optional[type[SiteInteractionHandler | SubscribeInteractionHandler]] = None diff --git a/app/chain/_messaging.py b/app/chain/_messaging.py index 9bcb51002..851714d86 100644 --- a/app/chain/_messaging.py +++ b/app/chain/_messaging.py @@ -9,7 +9,7 @@ import copy from collections.abc import Generator, Mapping from dataclasses import dataclass from datetime import datetime -from typing import Any, Dict, List, Optional, Union, cast +from typing import Any, Callable, ClassVar, Dict, List, Optional, Union, cast from app.application.messaging.message import MessageTemplateHelper from app.application.notification import get_notification_switch @@ -197,6 +197,8 @@ def _render_notification_message( 使用同步和异步入口共用的上下文规范化并渲染通知。 """ kwargs.setdefault("current_time", datetime.now().strftime("%Y-%m-%d %H:%M:%S")) + if message is None: + return None return MessageTemplateHelper.render( message=message, meta=meta, @@ -223,7 +225,8 @@ class MessageProcessingMixin: 消息输入/处理状态机与通知派发规范化。 """ - __mixin_host_protocol__ = ChainRuntimeMixinHost + __mixin_host_protocol__: ClassVar[Any] = ChainRuntimeMixinHost + run_module: Callable[..., Any] def start_message_processing_status( self, @@ -233,7 +236,7 @@ class MessageProcessingMixin: message_id: Optional[Union[str, int]] = None, chat_id: Optional[Union[str, int]] = None, text: Optional[str] = None, - ) -> Optional[dict]: + ) -> Optional[dict[str, Any]]: """ 启动渠道侧消息输入/处理状态。 具体表现由消息模块实现,例如 typing 保活或消息 reaction。 @@ -259,7 +262,7 @@ class MessageProcessingMixin: def finish_message_processing_status( self, - status: Optional[dict] = None, + status: Optional[dict[str, Any]] = None, channel: Optional[NotificationChannel] = None, source: Optional[str] = None, userid: Optional[Union[str, int]] = None, @@ -309,7 +312,7 @@ class MessageProcessingMixin: return dispatch_message @staticmethod - def _build_notice_message_data(message: Message) -> dict: + def _build_notice_message_data(message: Message) -> dict[str, Any]: """ 构造消息通知事件数据。 """ @@ -321,8 +324,16 @@ class NotificationMixin: 通知消息发送域:渲染、隔离路由、队列发送与消息编辑。 """ - __mixin_host_protocol__ = ChainRuntimeMixinHost + __mixin_host_protocol__: ClassVar[Any] = ChainRuntimeMixinHost user_repository: ChainUserRepository + runtime_config: Any + eventmanager: Any + messageoper: Any + messagequeue: Any + run_module: Callable[..., Any] + run_module_strict: Callable[..., Any] + _normalize_notification_for_dispatch: Callable[[Message], Message] + _build_notice_message_data: Callable[[Message], dict[str, Any]] def post_message( self, @@ -331,7 +342,7 @@ class NotificationMixin: mediainfo: Optional[Union[MediaInfo, MusicInfo]] = None, torrentinfo: Optional[TorrentInfo] = None, transferinfo: Optional[TransferInfo] = None, - **kwargs, + **kwargs: Any, ) -> None: """ 发送消息 @@ -463,7 +474,7 @@ class NotificationMixin: mediainfo: Optional[Union[MediaInfo, MusicInfo]] = None, torrentinfo: Optional[TorrentInfo] = None, transferinfo: Optional[TransferInfo] = None, - **kwargs, + **kwargs: Any, ) -> None: """ 异步发送消息 @@ -553,7 +564,7 @@ class NotificationMixin: if message.save_history: self.messageoper.add(**message.model_dump(), note=note_list) dispatch_message = self._normalize_notification_for_dispatch(message) - return self.messagequeue.send_message( + self.messagequeue.send_message( "post_medias_message", message=dispatch_message, medias=medias, @@ -569,11 +580,14 @@ class NotificationMixin: :param message: 消息体 :param torrents: 种子列表 """ - note_list = [torrent.torrent_info.to_dict() for torrent in torrents] + note_list = [ + torrent.torrent_info.to_dict() if torrent.torrent_info else {} + for torrent in torrents + ] if message.save_history: self.messageoper.add(**message.model_dump(), note=note_list) dispatch_message = self._normalize_notification_for_dispatch(message) - return self.messagequeue.send_message( + self.messagequeue.send_message( "post_torrents_message", message=dispatch_message, torrents=torrents, @@ -596,13 +610,13 @@ class NotificationMixin: :param chat_id: 聊天ID(如群组ID) :return: 删除是否成功 """ - return self.run_module( + return bool(self.run_module( "delete_message", channel=channel, source=source, message_id=message_id, chat_id=chat_id, - ) + )) def edit_message( self, @@ -612,7 +626,7 @@ class NotificationMixin: chat_id: Union[str, int], text: str, title: Optional[str] = None, - buttons: Optional[List[List[dict]]] = None, + buttons: Optional[List[List[dict[str, Any]]]] = None, metadata: Optional[Dict[str, Any]] = None, ) -> bool: """ @@ -645,7 +659,7 @@ class NotificationMixin: logger.debug(f"编辑 WebAgent 消息失败: {err}") return False - return self.run_module( + return bool(self.run_module( "edit_message", channel=channel, source=source, @@ -655,7 +669,7 @@ class NotificationMixin: title=title, buttons=buttons, metadata=metadata, - ) + )) def send_direct_message(self, message: Message) -> Optional[MessageResponse]: """ @@ -665,10 +679,10 @@ class NotificationMixin: :param message: 消息体 :return: 消息响应(包含message_id, chat_id等) """ - return self.run_module( + return cast(Optional[MessageResponse], self.run_module( "send_direct_message", message=self._normalize_notification_for_dispatch(message), - ) + )) def finalize_message( self, @@ -678,4 +692,4 @@ class NotificationMixin: 对已发送消息执行渠道收尾动作。 例如关闭流式卡片状态;无特殊收尾的渠道直接返回 False。 """ - return self.run_module("finalize_message", response=response) + return bool(self.run_module("finalize_message", response=response)) diff --git a/app/chain/_music.py b/app/chain/_music.py index a9dbfd5d8..d33b77d8e 100644 --- a/app/chain/_music.py +++ b/app/chain/_music.py @@ -62,6 +62,8 @@ def _music_info_from_subscribe(subscribe: SubscriptionSnapshot) -> MusicInfo: """ year_text = str(subscribe.year or "")[:4] music_type = getattr(subscribe, "music_type", None) + if not isinstance(music_type, str): + music_type = MUSIC_ENTITY_RECORDING # 音乐订阅的 description 由标准 MusicInfo.overview 生成,首段固定为艺术家。 artist_text = str(getattr(subscribe, "description", None) or "") \ .split(" · ", maxsplit=1)[0].strip() @@ -150,7 +152,7 @@ class MusicSubscribeMixin: @staticmethod def _validate_music_subscribe_target( - mediainfo: MediaInfo, + mediainfo: MediaInfo | MusicInfo, requested_music_type: Optional[str] = None, ) -> Optional[str]: """ @@ -217,12 +219,13 @@ class MusicSubscribeMixin: mediainfo: Optional[MusicInfo] if subscribe.media_source and subscribe.media_id: # 与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情 - recognized = media_chain.recognize_media( + recognized_raw = media_chain.recognize_media( media_source=subscribe.media_source, media_id=str(subscribe.media_id), mtype=MediaType.MUSIC, music_type=getattr(subscribe, "music_type", None), ) + recognized = recognized_raw if isinstance(recognized_raw, MusicInfo) else None if recognized: mediainfo = MusicSubscribeMixin._ensure_music_subscribe_entity( subscribe, @@ -241,12 +244,13 @@ class MusicSubscribeMixin: mediainfo = _music_info_from_subscribe(subscribe) else: # 旧订阅没有实体类型时只允许走 Recording 识别,不能从全局混合搜索中猜成专辑或艺术家。 - recognized = media_chain.recognize_media( + recognized_raw = media_chain.recognize_media( meta=build_subscribe_meta(subscribe), mtype=MediaType.MUSIC, media_source=subscribe.media_source, music_type=MUSIC_ENTITY_RECORDING, ) + recognized = recognized_raw if isinstance(recognized_raw, MusicInfo) else None mediainfo = MusicSubscribeMixin._ensure_music_subscribe_entity( subscribe, recognized, @@ -266,12 +270,13 @@ class MusicSubscribeMixin: mediainfo: Optional[MusicInfo] if subscribe.media_source and subscribe.media_id: # 与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情 - recognized = await media_chain.async_recognize_media( + recognized_raw = await media_chain.async_recognize_media( media_source=subscribe.media_source, media_id=str(subscribe.media_id), mtype=MediaType.MUSIC, music_type=getattr(subscribe, "music_type", None), ) + recognized = recognized_raw if isinstance(recognized_raw, MusicInfo) else None if recognized: mediainfo = MusicSubscribeMixin._ensure_music_subscribe_entity( subscribe, @@ -287,12 +292,13 @@ class MusicSubscribeMixin: elif getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM: mediainfo = _music_info_from_subscribe(subscribe) else: - recognized = await media_chain.async_recognize_media( + recognized_raw = await media_chain.async_recognize_media( meta=build_subscribe_meta(subscribe), mtype=MediaType.MUSIC, media_source=subscribe.media_source, music_type=MUSIC_ENTITY_RECORDING, ) + recognized = recognized_raw if isinstance(recognized_raw, MusicInfo) else None mediainfo = MusicSubscribeMixin._ensure_music_subscribe_entity( subscribe, recognized, @@ -311,7 +317,7 @@ class MusicSubscribeMixin: """ 把远端识别得到的专辑类型和总曲目数同步到订阅,供搜索失败与完成历史复用。 """ - update_data = {} + update_data: dict[str, JsonData] = {} if mediainfo.music_type and getattr(subscribe, "music_type", None) != mediainfo.music_type: update_data["music_type"] = mediainfo.music_type if mediainfo.music_type == MUSIC_ENTITY_ALBUM: @@ -431,7 +437,8 @@ class MusicSubscribeMixin: return None subscribe = self._sync_music_subscribe_target(subscribe, mediainfo) meta = MetaMusic.from_music_info(mediainfo) - exists, _ = self.check_and_handle_existing_media( + host = cast(MusicSubscribeMixinHost, self) + exists, _ = host.check_and_handle_existing_media( subscribe=subscribe, meta=meta, mediainfo=mediainfo, @@ -450,7 +457,8 @@ class MusicSubscribeMixin: """ 按站点、音乐实体、订阅参数和优先级规则筛选并绑定下载上下文。 """ - sites = self.get_sub_sites(subscribe) + host = cast(MusicSubscribeMixinHost, self) + sites = host.get_sub_sites(subscribe) default_rule_key = SystemConfigKey.BestVersionFilterRuleGroups \ if subscribe.best_version else SystemConfigKey.SubscribeFilterRuleGroups rule_groups = subscribe.filter_groups or get_configured_system_config().get(default_rule_key) or [] @@ -465,27 +473,28 @@ class MusicSubscribeMixin: torrent = copy.copy(source_torrent) if sites and torrent.site not in sites: continue + torrent_title = torrent.title or "" meta = cast(MetaMusic, MetaInfo( - title=torrent.title, + title=torrent_title, subtitle=torrent.description, custom_words=custom_words, mtype=MediaType.MUSIC, )) match = match_music_resource( - mediainfo, torrent.title, torrent.description, torrent.category, meta=meta, + mediainfo, torrent_title, torrent.description or "", torrent.category, meta=meta, ) if match.status != "exact": continue activity = current_activity_run() if activity: activity.resource(torrent, "rejected", "不符合音乐订阅条件或升级优先级") - filter_params = self.get_params(subscribe) + filter_params = host.get_params(subscribe) if not torrent_helper.filter_torrent(torrent, filter_params): explain = getattr(torrent_helper, "filter_torrent_reason", None) if activity and explain: activity.resource(torrent, "rejected", explain(torrent, filter_params)) continue - filtered = self.filter_torrents( + filtered = host.filter_torrents( rule_groups=rule_groups, torrent_list=[torrent], mediainfo=mediainfo, @@ -568,7 +577,7 @@ class MusicSubscribeMixin: username=subscribe.username, save_path=subscribe.save_path, downloader=subscribe.downloader, - source=self.get_subscribe_source_keyword(subscribe), + source=cast(MusicSubscribeMixinHost, self).get_subscribe_source_keyword(subscribe), custom_words=subscribe.custom_words, governance=governance, ) @@ -606,7 +615,7 @@ class MusicSubscribeMixin: if current_subscribe is None: current_subscribe = repository.get(subscribe.id) if current_subscribe: - self.finish_subscribe_or_not( + cast(MusicSubscribeMixinHost, self).finish_subscribe_or_not( subscribe=current_subscribe, meta=MetaMusic.from_music_info(mediainfo), mediainfo=mediainfo, @@ -621,7 +630,9 @@ class MusicSubscribeMixin: """ 兼容音乐订阅入口,主动搜索、站点预算和结果处理统一由订阅搜索 任务持有者 编排。 """ - self._process_search_subscription(subscribe, None, execution_context=execution_context) + cast(MusicSubscribeMixinHost, self)._process_search_subscription( + subscribe, None, execution_context=execution_context + ) def _match_music_subscribe( self, diff --git a/app/chain/_recognition.py b/app/chain/_recognition.py index 90aef0523..413e6fb97 100644 --- a/app/chain/_recognition.py +++ b/app/chain/_recognition.py @@ -9,7 +9,7 @@ import copy import threading from dataclasses import dataclass from enum import Enum, auto -from typing import Any, Generator, Optional, Protocol, TypeVar, cast +from typing import Any, ClassVar, Generator, Optional, Protocol, TypeVar, cast from app.application.configuration import get_configured_system_config from app.application.music.observation import music_recognition_needs_confirmation @@ -467,7 +467,7 @@ class _RecognitionPlanningMixin: 封装识别计划构建及宿主协议收窄。 """ - __mixin_host_protocol__ = ChainRuntimeMixinHost + __mixin_host_protocol__: ClassVar[Any] = ChainRuntimeMixinHost eventmanager: Any _finalize_recognition_result = cast(Any, _RecognitionFinalizationOwner._finalize_recognition_result) _async_finalize_recognition_result = cast(Any, _RecognitionFinalizationOwner._async_finalize_recognition_result) @@ -535,7 +535,7 @@ class RecognitionMixin: 为媒体 Chain 提供本地识别、共享识别和插件补充识别流程。 """ - __mixin_host_protocol__ = ChainRuntimeMixinHost + __mixin_host_protocol__: ClassVar[Any] = ChainRuntimeMixinHost eventmanager: Any _finalize_recognition_result = cast(Any, _RecognitionFinalizationOwner._finalize_recognition_result) _async_finalize_recognition_result = cast(Any, _RecognitionFinalizationOwner._async_finalize_recognition_result) diff --git a/app/chain/anilist.py b/app/chain/anilist.py index 90bf48bf9..a6c4d25f0 100644 --- a/app/chain/anilist.py +++ b/app/chain/anilist.py @@ -2,7 +2,7 @@ AniList 动漫详情、发现、推荐与演职人员能力的模块调度入口。 """ -from typing import Optional +from typing import Any, Optional, cast from app.chain.base import ChainBase from app.domain.context import MediaInfo @@ -14,23 +14,25 @@ class AniListChain(ChainBase): AniList 榜单、探索与深度浏览处理链 """ - def info(self, anilist_id: int) -> Optional[dict]: + def info(self, anilist_id: int) -> Optional[dict[str, Any]]: """ 获取 AniList 动画详情。 :param anilist_id: AniList 媒体 ID :return: AniList 媒体详情 """ - return self.run_module("anilist_info", anilist_id=anilist_id) + result = self.run_module("anilist_info", anilist_id=anilist_id) + return result if isinstance(result, dict) else None - async def async_info(self, anilist_id: int) -> Optional[dict]: + async def async_info(self, anilist_id: int) -> Optional[dict[str, Any]]: """ 异步获取 AniList 动画详情。 :param anilist_id: AniList 媒体 ID :return: AniList 媒体详情 """ - return await self.async_run_module("async_anilist_info", anilist_id=anilist_id) + result = await self.async_run_module("async_anilist_info", anilist_id=anilist_id) + return result if isinstance(result, dict) else None def trending(self, page: int = 1, count: int = 20) -> list[MediaInfo]: """ @@ -72,7 +74,7 @@ class AniListChain(ChainBase): "async_anilist_popular_this_season", page=page, count=count ) or [] - def discover(self, **kwargs) -> list[MediaInfo]: + def discover(self, **kwargs: Any) -> list[MediaInfo]: """ 按组合条件探索 AniList 动画。 @@ -80,7 +82,7 @@ class AniListChain(ChainBase): """ return self.run_module("anilist_discover", **kwargs) or [] - async def async_discover(self, **kwargs) -> list[MediaInfo]: + async def async_discover(self, **kwargs: Any) -> list[MediaInfo]: """ 异步按组合条件探索 AniList 动画。 @@ -145,7 +147,8 @@ class AniListChain(ChainBase): :return: 媒体人物信息 """ - return self.run_module("anilist_person_detail", person_id=person_id) + result = self.run_module("anilist_person_detail", person_id=person_id) + return cast(_SchemaMediaPerson, result) if result else None async def async_person_detail(self, person_id: int) -> Optional[_SchemaMediaPerson]: """ @@ -153,9 +156,10 @@ class AniListChain(ChainBase): :return: 媒体人物信息 """ - return await self.async_run_module( + result = await self.async_run_module( "async_anilist_person_detail", person_id=person_id ) + return cast(_SchemaMediaPerson, result) if result else None def person_credits( self, person_id: int, page: int = 1, count: int = 20 diff --git a/app/chain/bangumi.py b/app/chain/bangumi.py index 1303bf54d..536a5c394 100644 --- a/app/chain/bangumi.py +++ b/app/chain/bangumi.py @@ -2,7 +2,7 @@ Bangumi 媒体搜索、详情与日历能力的模块调度入口。 """ -from typing import List, Optional +from typing import Any, List, Optional, cast from app.chain.base import ChainBase from app.domain.context import MediaInfo @@ -18,22 +18,22 @@ class BangumiChain(ChainBase): """ 获取Bangumi每日放送 """ - return self.run_module("bangumi_calendar") + return cast(Optional[List[MediaInfo]], self.run_module("bangumi_calendar")) - def discover(self, **kwargs) -> Optional[List[MediaInfo]]: + def discover(self, **kwargs: Any) -> Optional[List[MediaInfo]]: """ 发现Bangumi番剧 """ - return self.run_module("bangumi_discover", **kwargs) + return cast(Optional[List[MediaInfo]], self.run_module("bangumi_discover", **kwargs)) - def bangumi_info(self, bangumiid: int) -> Optional[dict]: + def bangumi_info(self, bangumiid: int) -> Optional[dict[str, Any]]: """ 获取Bangumi信息 :param bangumiid: Bangumi 条目标识 :return: Bangumi信息 """ - return self.run_module("bangumi_info", bangumiid=bangumiid) + return cast(Optional[dict[str, Any]], self.run_module("bangumi_info", bangumiid=bangumiid)) def bangumi_credits(self, bangumiid: int) -> List[_SchemaMediaPerson]: """ @@ -41,7 +41,7 @@ class BangumiChain(ChainBase): :param bangumiid: Bangumi 条目标识 """ - return self.run_module("bangumi_credits", bangumiid=bangumiid) + return cast(List[_SchemaMediaPerson], (self.run_module("bangumi_credits", bangumiid=bangumiid)) or []) def bangumi_recommend(self, bangumiid: int) -> Optional[List[MediaInfo]]: """ @@ -49,7 +49,7 @@ class BangumiChain(ChainBase): :param bangumiid: Bangumi 条目标识 """ - return self.run_module("bangumi_recommend", bangumiid=bangumiid) + return cast(Optional[List[MediaInfo]], self.run_module("bangumi_recommend", bangumiid=bangumiid)) def person_detail(self, person_id: int) -> Optional[_SchemaMediaPerson]: """ @@ -57,7 +57,7 @@ class BangumiChain(ChainBase): :param person_id: 人物ID """ - return self.run_module("bangumi_person_detail", person_id=person_id) + return cast(Optional[_SchemaMediaPerson], self.run_module("bangumi_person_detail", person_id=person_id)) def person_credits(self, person_id: int) -> Optional[List[MediaInfo]]: """ @@ -65,28 +65,28 @@ class BangumiChain(ChainBase): :param person_id: 人物ID """ - return self.run_module("bangumi_person_credits", person_id=person_id) + return cast(Optional[List[MediaInfo]], self.run_module("bangumi_person_credits", person_id=person_id)) async def async_calendar(self) -> Optional[List[MediaInfo]]: """ 获取Bangumi每日放送(异步版本) """ - return await self.async_run_module("async_bangumi_calendar") + return cast(Optional[List[MediaInfo]], await self.async_run_module("async_bangumi_calendar")) - async def async_discover(self, **kwargs) -> Optional[List[MediaInfo]]: + async def async_discover(self, **kwargs: Any) -> Optional[List[MediaInfo]]: """ 发现Bangumi番剧(异步版本) """ - return await self.async_run_module("async_bangumi_discover", **kwargs) + return cast(Optional[List[MediaInfo]], await self.async_run_module("async_bangumi_discover", **kwargs)) - async def async_bangumi_info(self, bangumiid: int) -> Optional[dict]: + async def async_bangumi_info(self, bangumiid: int) -> Optional[dict[str, Any]]: """ 获取Bangumi信息(异步版本) :param bangumiid: Bangumi 条目标识 :return: Bangumi信息 """ - return await self.async_run_module("async_bangumi_info", bangumiid=bangumiid) + return cast(Optional[dict[str, Any]], await self.async_run_module("async_bangumi_info", bangumiid=bangumiid)) async def async_bangumi_credits(self, bangumiid: int) -> List[_SchemaMediaPerson]: """ @@ -94,7 +94,7 @@ class BangumiChain(ChainBase): :param bangumiid: Bangumi 条目标识 """ - return await self.async_run_module("async_bangumi_credits", bangumiid=bangumiid) + return cast(List[_SchemaMediaPerson], (await self.async_run_module("async_bangumi_credits", bangumiid=bangumiid)) or []) async def async_bangumi_recommend(self, bangumiid: int) -> Optional[List[MediaInfo]]: """ @@ -102,7 +102,7 @@ class BangumiChain(ChainBase): :param bangumiid: Bangumi 条目标识 """ - return await self.async_run_module("async_bangumi_recommend", bangumiid=bangumiid) + return cast(Optional[List[MediaInfo]], await self.async_run_module("async_bangumi_recommend", bangumiid=bangumiid)) async def async_person_detail(self, person_id: int) -> Optional[_SchemaMediaPerson]: """ @@ -110,7 +110,7 @@ class BangumiChain(ChainBase): :param person_id: 人物ID """ - return await self.async_run_module("async_bangumi_person_detail", person_id=person_id) + return cast(Optional[_SchemaMediaPerson], await self.async_run_module("async_bangumi_person_detail", person_id=person_id)) async def async_person_credits(self, person_id: int) -> Optional[List[MediaInfo]]: """ @@ -118,4 +118,4 @@ class BangumiChain(ChainBase): :param person_id: 人物ID """ - return await self.async_run_module("async_bangumi_person_credits", person_id=person_id) + return cast(Optional[List[MediaInfo]], await self.async_run_module("async_bangumi_person_credits", person_id=person_id)) diff --git a/app/chain/base.py b/app/chain/base.py index 470fa49c9..9c1f20d49 100644 --- a/app/chain/base.py +++ b/app/chain/base.py @@ -23,6 +23,7 @@ from app.domain.meta.metabase import MetaBase from app.runtime.log import logger from app.schemas.context import MediaPerson from app.schemas.exception import BusinessRejectedError, RateLimitExceededException +from app.schemas.file import FileItem from app.schemas.mediaserver import ExistMediaInfo, MediaServerQueryOptions, WebhookEventInfo from app.schemas.message import IncomingMessage from app.schemas.system import TransferDirectoryConf @@ -35,7 +36,6 @@ from app.schemas.types import ( MediaType, TorrentStatus, ) -from app.schemas.workflow import FileItem if TYPE_CHECKING: from app.application.transfer.workflow import TransferPlanCheckpoint, TransferPlanningInput @@ -43,10 +43,12 @@ if TYPE_CHECKING: class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, metaclass=ABCMeta): """ - 处理链基类 + 处理链基类。 + + 具体模块遵守各门面声明的输入与返回契约;按名称动态分发后在门面边界恢复静态类型。 """ - def __init__(self, runtime_context: Optional[ChainRuntimeContext] = None): + def __init__(self, runtime_context: Optional[ChainRuntimeContext] = None) -> None: """ 公共初始化;未显式传入上下文时继续使用兼容运行时 能力提供方。 """ @@ -177,7 +179,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met """ await self.async_filecache.delete(filename) - def __handle_plugin_error(self, err: Exception, plugin_id: str, plugin_name: str, method: str, **kwargs): + def __handle_plugin_error(self, err: Exception, plugin_id: str, plugin_name: str, method: str, **kwargs: Any) -> None: """ 处理插件模块执行错误,通知保留调用位置和异常原因,完整堆栈写入日志与事件。 """ @@ -202,7 +204,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met }, ) - def __handle_system_error(self, err: Exception, module_id: str, module_name: str, method: str, **kwargs): + def __handle_system_error(self, err: Exception, module_id: str, module_name: str, method: str, **kwargs: Any) -> None: """ 处理系统模块执行错误,通知保留调用位置和异常原因,完整堆栈写入日志与事件。 """ @@ -232,7 +234,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met @staticmethod def __handle_rate_limit_error( - err: RateLimitExceededException, source_type: str, source_id: str, method: str, **kwargs + err: RateLimitExceededException, source_type: str, source_id: str, method: str, **kwargs: Any ) -> None: """ 处理本地限流跳过,避免预期的限流状态进入系统错误告警。 @@ -244,8 +246,8 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met def run_module( self, method: str, - *args, - **kwargs, + *args: Any, + **kwargs: Any, ) -> Any: """ 运行包含该方法的所有模块,然后返回结果 @@ -269,8 +271,8 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met async def async_run_module( self, method: str, - *args, - **kwargs, + *args: Any, + **kwargs: Any, ) -> Any: """ 异步运行包含该方法的所有模块,然后返回结果 @@ -293,7 +295,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met year: Optional[str] = None, season: Optional[int] = None, raise_exception: bool = False, - ) -> Optional[dict]: + ) -> Optional[dict[str, Any]]: """ 搜索和匹配豆瓣信息 @@ -304,7 +306,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param season: 季 :param raise_exception: 触发速率限制时是否抛出异常 """ - return self.run_module( + return cast(Optional[dict[str, Any]], self.run_module( "match_doubaninfo", name=name, imdbid=imdbid, @@ -312,7 +314,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met year=year, season=season, raise_exception=raise_exception, - ) + )) async def async_match_doubaninfo( self, @@ -322,7 +324,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met year: Optional[str] = None, season: Optional[int] = None, raise_exception: bool = False, - ) -> Optional[dict]: + ) -> Optional[dict[str, Any]]: """ 搜索和匹配豆瓣信息(异步版本) @@ -333,7 +335,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param season: 季 :param raise_exception: 触发速率限制时是否抛出异常 """ - return await self.async_run_module( + return cast(Optional[dict[str, Any]], await self.async_run_module( "async_match_doubaninfo", name=name, imdbid=imdbid, @@ -341,7 +343,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met year=year, season=season, raise_exception=raise_exception, - ) + )) def match_tmdbinfo( self, @@ -349,7 +351,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met mtype: Optional[MediaType] = None, year: Optional[str] = None, season: Optional[int] = None, - ) -> Optional[dict]: + ) -> Optional[dict[str, Any]]: """ 搜索和匹配TMDB信息 @@ -358,7 +360,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param year: 年份 :param season: 季 """ - return self.run_module("match_tmdbinfo", name=name, mtype=mtype, year=year, season=season) + return cast(Optional[dict[str, Any]], self.run_module("match_tmdbinfo", name=name, mtype=mtype, year=year, season=season)) async def async_match_tmdbinfo( self, @@ -366,7 +368,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met mtype: Optional[MediaType] = None, year: Optional[str] = None, season: Optional[int] = None, - ) -> Optional[dict]: + ) -> Optional[dict[str, Any]]: """ 搜索和匹配TMDB信息(异步版本) @@ -375,7 +377,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param year: 年份 :param season: 季 """ - return await self.async_run_module("async_match_tmdbinfo", name=name, mtype=mtype, year=year, season=season) + return cast(Optional[dict[str, Any]], await self.async_run_module("async_match_tmdbinfo", name=name, mtype=mtype, year=year, season=season)) def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]: """ @@ -386,7 +388,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met """ if mediainfo and mediainfo.type == MediaType.MUSIC: return mediainfo - return self.run_module("obtain_images", mediainfo=mediainfo) + return cast(Optional[MediaInfo], self.run_module("obtain_images", mediainfo=mediainfo)) async def async_obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]: """ @@ -397,7 +399,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met """ if mediainfo and mediainfo.type == MediaType.MUSIC: return mediainfo - return await self.async_run_module("async_obtain_images", mediainfo=mediainfo) + return cast(Optional[MediaInfo], await self.async_run_module("async_obtain_images", mediainfo=mediainfo)) def obtain_specific_image( self, @@ -418,7 +420,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param season: 季 :param episode: 集 """ - return self.run_module( + return cast(Optional[str], self.run_module( "obtain_specific_image", mediaid=mediaid, mtype=mtype, @@ -426,14 +428,14 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met image_type=image_type, season=season, episode=episode, - ) + )) def douban_info( self, doubanid: str, mtype: Optional[MediaType] = None, raise_exception: bool = False, - ) -> Optional[dict]: + ) -> Optional[dict[str, Any]]: """ 获取豆瓣信息 @@ -442,19 +444,19 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :return: 豆瓣信息 :param raise_exception: 触发速率限制时是否抛出异常 """ - return self.run_module( + return cast(Optional[dict[str, Any]], self.run_module( "douban_info", doubanid=doubanid, mtype=mtype, raise_exception=raise_exception, - ) + )) async def async_douban_info( self, doubanid: str, mtype: Optional[MediaType] = None, raise_exception: bool = False, - ) -> Optional[dict]: + ) -> Optional[dict[str, Any]]: """ 获取豆瓣信息(异步版本) @@ -463,21 +465,21 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :return: 豆瓣信息 :param raise_exception: 触发速率限制时是否抛出异常 """ - return await self.async_run_module( + return cast(Optional[dict[str, Any]], await self.async_run_module( "async_douban_info", doubanid=doubanid, mtype=mtype, raise_exception=raise_exception, - ) + )) - def tvdb_info(self, tvdbid: int) -> Optional[dict]: + def tvdb_info(self, tvdbid: int) -> Optional[dict[str, Any]]: """ 获取TVDB信息 :param tvdbid: TVDB 电视剧标识 :return: TVDB信息 """ - return self.run_module("tvdb_info", tvdbid=tvdbid) + return cast(Optional[dict[str, Any]], self.run_module("tvdb_info", tvdbid=tvdbid)) def tvdb_slug(self, tvdbid: int) -> Optional[str]: """ @@ -486,9 +488,9 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param tvdbid: TVDB 电视剧标识 :return: slug 字符串 """ - return self.run_module("tvdb_slug", tvdbid=tvdbid) + return cast(Optional[str], self.run_module("tvdb_slug", tvdbid=tvdbid)) - def tmdb_info(self, tmdbid: int, mtype: MediaType, season: Optional[int] = None) -> Optional[dict]: + def tmdb_info(self, tmdbid: int, mtype: MediaType, season: Optional[int] = None) -> Optional[dict[str, Any]]: """ 获取TMDB信息 @@ -497,9 +499,9 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param season: 季 :return: TVDB信息 """ - return self.run_module("tmdb_info", tmdbid=tmdbid, mtype=mtype, season=season) + return cast(Optional[dict[str, Any]], self.run_module("tmdb_info", tmdbid=tmdbid, mtype=mtype, season=season)) - async def async_tmdb_info(self, tmdbid: int, mtype: MediaType, season: Optional[int] = None) -> Optional[dict]: + async def async_tmdb_info(self, tmdbid: int, mtype: MediaType, season: Optional[int] = None) -> Optional[dict[str, Any]]: """ 获取TMDB信息(异步版本) @@ -508,25 +510,25 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param season: 季 :return: TVDB信息 """ - return await self.async_run_module("async_tmdb_info", tmdbid=tmdbid, mtype=mtype, season=season) + return cast(Optional[dict[str, Any]], await self.async_run_module("async_tmdb_info", tmdbid=tmdbid, mtype=mtype, season=season)) - def bangumi_info(self, bangumiid: int) -> Optional[dict]: + def bangumi_info(self, bangumiid: int) -> Optional[dict[str, Any]]: """ 获取Bangumi信息 :param bangumiid: Bangumi 条目标识 :return: Bangumi信息 """ - return self.run_module("bangumi_info", bangumiid=bangumiid) + return cast(Optional[dict[str, Any]], self.run_module("bangumi_info", bangumiid=bangumiid)) - async def async_bangumi_info(self, bangumiid: int) -> Optional[dict]: + async def async_bangumi_info(self, bangumiid: int) -> Optional[dict[str, Any]]: """ 获取Bangumi信息(异步版本) :param bangumiid: Bangumi 条目标识 :return: Bangumi信息 """ - return await self.async_run_module("async_bangumi_info", bangumiid=bangumiid) + return cast(Optional[dict[str, Any]], await self.async_run_module("async_bangumi_info", bangumiid=bangumiid)) def message_parser(self, source: str, body: Any, form: Any, args: Any) -> Optional[IncomingMessage]: """ @@ -541,7 +543,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param args: 参数 :return: 消息渠道、消息内容 """ - return self.run_module("message_parser", source=source, body=body, form=form, args=args) + return cast(Optional[IncomingMessage], self.run_module("message_parser", source=source, body=body, form=form, args=args)) def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[WebhookEventInfo]: """ @@ -552,7 +554,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param args: 请求参数 :return: 字典,解析为消息时需要包含:title、text、image """ - return self.run_module("webhook_parser", body=body, form=form, args=args) + return cast(Optional[WebhookEventInfo], self.run_module("webhook_parser", body=body, form=form, args=args)) def search_medias( self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None @@ -564,7 +566,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param media_source: 请求级搜索数据源 :return: 媒体信息列表 """ - return self.run_module("search_medias", meta=meta, media_source=media_source) + return cast(Optional[List[MediaInfo]], self.run_module("search_medias", meta=meta, media_source=media_source)) async def async_search_medias( self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None @@ -576,7 +578,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param media_source: 请求级搜索数据源 :return: 媒体信息列表 """ - return await self.async_run_module("async_search_medias", meta=meta, media_source=media_source) + return cast(Optional[List[MediaInfo]], await self.async_run_module("async_search_medias", meta=meta, media_source=media_source)) def search_persons( self, name: str, media_source: Optional[MediaSourceSelection] = None @@ -588,7 +590,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param media_source: 请求级搜索数据源 :return: 影视人物或音乐艺术家信息列表 """ - return self.run_module("search_persons", name=name, media_source=media_source) + return cast(Optional[List[Union[MediaPerson, MusicArtistInfo]]], self.run_module("search_persons", name=name, media_source=media_source)) async def async_search_persons( self, name: str, media_source: Optional[MediaSourceSelection] = None @@ -600,7 +602,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param media_source: 请求级搜索数据源 :return: 影视人物或音乐艺术家信息列表 """ - return await self.async_run_module("async_search_persons", name=name, media_source=media_source) + return cast(Optional[List[Union[MediaPerson, MusicArtistInfo]]], await self.async_run_module("async_search_persons", name=name, media_source=media_source)) def search_collections( self, name: str, media_source: Optional[MediaSourceSelection] = None @@ -612,7 +614,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param media_source: 请求级搜索数据源 :return: 合集信息列表 """ - return self.run_module("search_collections", name=name, media_source=media_source) + return cast(Optional[List[MediaInfo]], self.run_module("search_collections", name=name, media_source=media_source)) async def async_search_collections( self, name: str, media_source: Optional[MediaSourceSelection] = None @@ -624,21 +626,21 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param media_source: 请求级搜索数据源 :return: 合集信息列表 """ - return await self.async_run_module("async_search_collections", name=name, media_source=media_source) + return cast(Optional[List[MediaInfo]], await self.async_run_module("async_search_collections", name=name, media_source=media_source)) def get_search_page_size( self, - site: dict, + site: dict[str, Any], keyword: Optional[str] = None, ) -> Optional[int]: """ 获取站点搜索单页容量;返回 None 表示当前搜索入口不支持可靠翻页。 """ - return self.run_module("get_search_page_size", site=site, keyword=keyword) + return cast(Optional[int], self.run_module("get_search_page_size", site=site, keyword=keyword)) def search_torrents( self, - site: dict, + site: dict[str, Any], keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0, @@ -652,7 +654,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param page: 页码 :reutrn: 资源列表 """ - return self.run_module("search_torrents", site=site, keyword=keyword, mtype=mtype, page=page) + return cast(List[TorrentInfo], self.run_module("search_torrents", site=site, keyword=keyword, mtype=mtype, page=page)) def search_plugin_torrents( self, @@ -672,7 +674,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met def search_site_torrents( self, - site: dict, + site: dict[str, Any], keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0, @@ -689,7 +691,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met def search_subtitles( self, - site: dict, + site: dict[str, Any], keyword: str, page: Optional[int] = 0, ) -> List[SubtitleInfo]: @@ -701,11 +703,11 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param page: 页码 :return: 字幕列表 """ - return self.run_module("search_subtitles", site=site, keyword=keyword, page=page) + return cast(List[SubtitleInfo], self.run_module("search_subtitles", site=site, keyword=keyword, page=page)) async def async_search_torrents( self, - site: dict, + site: dict[str, Any], keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0, @@ -719,7 +721,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param page: 页码 :reutrn: 资源列表 """ - return await self.async_run_module("async_search_torrents", site=site, keyword=keyword, mtype=mtype, page=page) + return cast(List[TorrentInfo], await self.async_run_module("async_search_torrents", site=site, keyword=keyword, mtype=mtype, page=page)) async def async_search_plugin_torrents( self, @@ -739,7 +741,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met async def async_search_site_torrents( self, - site: dict, + site: dict[str, Any], keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0, @@ -756,7 +758,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met async def async_search_subtitles( self, - site: dict, + site: dict[str, Any], keyword: str, page: Optional[int] = 0, ) -> List[SubtitleInfo]: @@ -768,11 +770,11 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param page: 页码 :return: 字幕列表 """ - return await self.async_run_module("async_search_subtitles", site=site, keyword=keyword, page=page) + return cast(List[SubtitleInfo], await self.async_run_module("async_search_subtitles", site=site, keyword=keyword, page=page)) def refresh_torrents( self, - site: dict, + site: dict[str, Any], keyword: Optional[str] = None, cat: Optional[str] = None, page: Optional[int] = 0, @@ -788,11 +790,11 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param mtype: 媒体类型 :reutrn: 种子资源列表 """ - return self.run_module("refresh_torrents", site=site, keyword=keyword, cat=cat, page=page, mtype=mtype) + return cast(List[TorrentInfo], self.run_module("refresh_torrents", site=site, keyword=keyword, cat=cat, page=page, mtype=mtype)) async def async_refresh_torrents( self, - site: dict, + site: dict[str, Any], keyword: Optional[str] = None, cat: Optional[str] = None, page: Optional[int] = 0, @@ -808,9 +810,9 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param mtype: 媒体类型 :reutrn: 种子资源列表 """ - return await self.async_run_module( + return cast(List[TorrentInfo], await self.async_run_module( "async_refresh_torrents", site=site, keyword=keyword, cat=cat, page=page, mtype=mtype - ) + )) def filter_torrents( self, @@ -826,19 +828,19 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param mediainfo: 识别的媒体信息 :return: 过滤后的资源列表,添加资源优先级 """ - return self.run_module( + return cast(List[TorrentInfo], self.run_module( "filter_torrents", rule_groups=rule_groups, torrent_list=torrent_list, mediainfo=mediainfo, - ) + )) def download( self, content: Union[Path, str, bytes], download_dir: Path, - cookie: str, - episodes: Set[int] = None, + cookie: Optional[str], + episodes: Optional[Set[int]] = None, category: Optional[str] = None, label: Optional[str] = None, downloader: Optional[str] = None, @@ -857,7 +859,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param custom_words: 来源识别词,选集须与订阅解析采用相同规则 :return: 下载器名称、种子Hash、种子文件布局、错误原因 """ - return self.run_module( + return cast(Optional[Tuple[Optional[str], Optional[str], Optional[str], str]], self.run_module( "download", content=content, download_dir=download_dir, @@ -867,13 +869,13 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met label=label, downloader=downloader, custom_words=custom_words, - ) + )) def download_added( self, context: Context, download_dir: Path, - torrent_content: Union[str, bytes] = None, + torrent_content: Optional[Union[str, bytes]] = None, ) -> None: """ 添加下载任务成功后的模块附加处理分发,站点字幕下载由 DownloadChain 另行编排 @@ -883,17 +885,17 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param torrent_content: 种子内容,如果有则直接使用该内容,否则从 context 中获取种子文件路径 :return: None,该方法可被多个模块同时处理 """ - return self.run_module( + return cast(None, self.run_module( "download_added", context=context, torrent_content=torrent_content, download_dir=download_dir, - ) + )) def list_torrents( self, - status: TorrentStatus = None, - hashs: Union[list, str] = None, + status: Optional[TorrentStatus] = None, + hashs: Optional[Union[list[str], str]] = None, downloader: Optional[str] = None, include_all_tags: bool = False, ) -> Optional[List[DownloaderTorrent]]: @@ -906,29 +908,29 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param include_all_tags: 是否包含未打内置标签的下载任务 :return: 下载器中符合状态的种子列表 """ - return self.run_module( + return cast(Optional[List[DownloaderTorrent]], self.run_module( "list_torrents", status=status, hashs=hashs, downloader=downloader, include_all_tags=include_all_tags, - ) + )) def transfer( self, fileitem: FileItem, meta: MetaBase, mediainfo: Union[MediaInfo, MusicInfo], - target_directory: TransferDirectoryConf = None, + target_directory: Optional[TransferDirectoryConf] = None, target_storage: Optional[str] = None, - target_path: Path = None, + target_path: Optional[Path] = None, transfer_type: Optional[str] = None, - scrape: bool = None, - library_type_folder: bool = None, - library_category_folder: bool = None, - episodes_info: List[TmdbEpisode] = None, - source_oper: Callable = None, - target_oper: Callable = None, + scrape: Optional[bool] = None, + library_type_folder: Optional[bool] = None, + library_category_folder: Optional[bool] = None, + episodes_info: Optional[List[TmdbEpisode]] = None, + source_oper: Optional[object] = None, + target_oper: Optional[object] = None, preview: bool = False, ) -> Optional[TransferInfo]: """ @@ -952,7 +954,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met """ if self._legacy_transfer_command is None: raise RuntimeError("旧整理兼容命令尚未由启动组合根配置") - return self._legacy_transfer_command( + return cast(Optional[TransferInfo], self._legacy_transfer_command( fileitem=fileitem, meta=meta, mediainfo=mediainfo, @@ -967,7 +969,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met source_oper=source_oper, target_oper=target_oper, preview=preview, - ) + )) def plan_transfer( self, @@ -1043,11 +1045,11 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param hashs: 种子Hash :param downloader: 下载器 """ - return self.run_module("transfer_completed", hashs=hashs, downloader=downloader) + return cast(None, self.run_module("transfer_completed", hashs=hashs, downloader=downloader)) def remove_torrents( self, - hashs: Union[str, list], + hashs: Union[str, list[str]], delete_file: bool = True, downloader: Optional[str] = None, ) -> bool: @@ -1059,14 +1061,14 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param downloader: 下载器 :return: 下载器模块报告的操作结果,成功时为 True """ - return self.run_module( + return cast(bool, self.run_module( "remove_torrents", hashs=hashs, delete_file=delete_file, downloader=downloader, - ) + )) - def start_torrents(self, hashs: Union[list, str], downloader: Optional[str] = None) -> bool: + def start_torrents(self, hashs: Union[list[str], str], downloader: Optional[str] = None) -> bool: """ 开始下载 @@ -1074,9 +1076,9 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param downloader: 下载器 :return: 下载器模块报告的操作结果,成功时为 True """ - return self.run_module("start_torrents", hashs=hashs, downloader=downloader) + return cast(bool, self.run_module("start_torrents", hashs=hashs, downloader=downloader)) - def stop_torrents(self, hashs: Union[list, str], downloader: Optional[str] = None) -> bool: + def stop_torrents(self, hashs: Union[list[str], str], downloader: Optional[str] = None) -> bool: """ 停止下载 @@ -1084,9 +1086,9 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param downloader: 下载器 :return: 下载器模块报告的操作结果,成功时为 True """ - return self.run_module("stop_torrents", hashs=hashs, downloader=downloader) + return cast(bool, self.run_module("stop_torrents", hashs=hashs, downloader=downloader)) - def set_torrents_tag(self, hashs: Union[list, str], tags: list, downloader: Optional[str] = None) -> bool: + def set_torrents_tag(self, hashs: Union[list[str], str], tags: list[str], downloader: Optional[str] = None) -> bool: """ 设置种子标签 @@ -1095,7 +1097,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param downloader: 下载器 :return: 下载器模块报告的操作结果,成功时为 True """ - return self.run_module("set_torrents_tag", hashs=hashs, tags=tags, downloader=downloader) + return cast(bool, self.run_module("set_torrents_tag", hashs=hashs, tags=tags, downloader=downloader)) def update_torrent( self, @@ -1103,7 +1105,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met downloader: Optional[str] = None, download_limit: Optional[float] = None, upload_limit: Optional[float] = None, - tracker_list: Optional[list] = None, + tracker_list: Optional[list[str]] = None, save_path: Optional[str] = None, category: Optional[str] = None, ratio_limit: Optional[float] = None, @@ -1123,7 +1125,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param seeding_time_limit: 做种时间限制,单位分钟 :return: 各项修改结果 """ - return self.run_module( + return cast(Optional[Dict[str, bool]], self.run_module( "update_torrent", hash_string=hash_string, downloader=downloader, @@ -1134,7 +1136,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met category=category, ratio_limit=ratio_limit, seeding_time_limit=seeding_time_limit, - ) + )) def get_torrent_trackers( self, @@ -1148,11 +1150,11 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param downloader: 下载器 :return: 下载器名称到Tracker列表的映射 """ - return self.run_module( + return cast(Optional[Dict[str, List[str]]], self.run_module( "get_torrent_trackers", hash_string=hash_string, downloader=downloader, - ) + )) def torrent_files(self, tid: str, downloader: Optional[str] = None) -> Optional[List[DownloaderFile]]: """ @@ -1162,11 +1164,11 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param downloader: 下载器 :return: 与下载器 SDK 解耦的统一文件项列表 """ - return self.run_module("torrent_files", tid=tid, downloader=downloader) + return cast(Optional[List[DownloaderFile]], self.run_module("torrent_files", tid=tid, downloader=downloader)) def media_exists( self, - mediainfo: MediaInfo, + mediainfo: Union[MediaInfo, MusicInfo], itemid: Optional[str] = None, server: Optional[str] = None, *, @@ -1181,13 +1183,13 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param query: 可选查询内容投影,省略时保留原有请求和返回内容 :return: 如不存在返回None,存在时返回信息,包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}} """ - return self.run_module( + return cast(Optional[ExistMediaInfo], self.run_module( "media_exists", mediainfo=mediainfo, itemid=itemid, server=server, **({"query": query} if query is not None else {}), - ) + )) def media_files(self, mediainfo: MediaInfo) -> Optional[List[FileItem]]: """ @@ -1196,14 +1198,14 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param mediainfo: 识别的媒体信息 :return: 媒体文件列表 """ - return self.run_module("media_files", mediainfo=mediainfo) + return cast(Optional[List[FileItem]], self.run_module("media_files", mediainfo=mediainfo)) def metadata_img( self, mediainfo: MediaInfo, season: Optional[int] = None, episode: Optional[int] = None, - ) -> Optional[dict]: + ) -> Optional[dict[str, Any]]: """ 获取图片名称和url @@ -1211,9 +1213,9 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met :param season: 季号 :param episode: 集号 """ - return self.run_module("metadata_img", mediainfo=mediainfo, season=season, episode=episode) + return cast(Optional[dict[str, Any]], self.run_module("metadata_img", mediainfo=mediainfo, season=season, episode=episode)) - def register_commands(self, commands: Dict[str, dict]) -> None: + def register_commands(self, commands: Dict[str, dict[str, Any]]) -> None: """ 注册菜单命令 """ diff --git a/app/chain/douban.py b/app/chain/douban.py index e8f86e5c9..d400c68cd 100644 --- a/app/chain/douban.py +++ b/app/chain/douban.py @@ -274,7 +274,7 @@ class DoubanChain(ChainBase): :param person_id: 人物ID """ - return self.run_module("douban_person_detail", person_id=person_id) + return cast(Optional[_SchemaMediaPerson], self.run_module("douban_person_detail", person_id=person_id)) def person_credits(self, person_id: int, page: Optional[int] = 1) -> List[MediaInfo]: """ @@ -283,7 +283,7 @@ class DoubanChain(ChainBase): :param person_id: 人物ID :param page: 页码 """ - return self.run_module("douban_person_credits", person_id=person_id, page=page) + return cast(List[MediaInfo], (self.run_module("douban_person_credits", person_id=person_id, page=page)) or []) def movie_top250(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: """ @@ -292,25 +292,25 @@ class DoubanChain(ChainBase): :param page: 页码 :param count: 每页数量 """ - return self.run_module("movie_top250", page=page, count=count) + return cast(Optional[List[MediaInfo]], self.run_module("movie_top250", page=page, count=count)) def movie_showing(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: """ 获取正在上映的电影 """ - return self.run_module("movie_showing", page=page, count=count) + return cast(Optional[List[MediaInfo]], self.run_module("movie_showing", page=page, count=count)) def tv_weekly_chinese(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: """ 获取本周中国剧集榜 """ - return self.run_module("tv_weekly_chinese", page=page, count=count) + return cast(Optional[List[MediaInfo]], self.run_module("tv_weekly_chinese", page=page, count=count)) def tv_weekly_global(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: """ 获取本周全球剧集榜 """ - return self.run_module("tv_weekly_global", page=page, count=count) + return cast(Optional[List[MediaInfo]], self.run_module("tv_weekly_global", page=page, count=count)) def douban_discover(self, mtype: MediaType, sort: str, tags: str, page: Optional[int] = 0, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: @@ -324,26 +324,26 @@ class DoubanChain(ChainBase): :param count: 数量 :return: 媒体信息列表 """ - return self.run_module("douban_discover", mtype=mtype, sort=sort, tags=tags, - page=page, count=count) + return cast(Optional[List[MediaInfo]], self.run_module("douban_discover", mtype=mtype, sort=sort, tags=tags, + page=page, count=count)) def tv_animation(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: """ 获取动画剧集 """ - return self.run_module("tv_animation", page=page, count=count) + return cast(Optional[List[MediaInfo]], self.run_module("tv_animation", page=page, count=count)) def movie_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: """ 获取热门电影 """ - return self.run_module("movie_hot", page=page, count=count) + return cast(Optional[List[MediaInfo]], self.run_module("movie_hot", page=page, count=count)) def tv_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: """ 获取热门剧集 """ - return self.run_module("tv_hot", page=page, count=count) + return cast(Optional[List[MediaInfo]], self.run_module("tv_hot", page=page, count=count)) def movie_credits(self, doubanid: str) -> Optional[List[_SchemaMediaPerson]]: """ @@ -351,7 +351,7 @@ class DoubanChain(ChainBase): :param doubanid: 豆瓣ID """ - return self.run_module("douban_movie_credits", doubanid=doubanid) + return cast(Optional[List[_SchemaMediaPerson]], self.run_module("douban_movie_credits", doubanid=doubanid)) def tv_credits(self, doubanid: str) -> Optional[List[_SchemaMediaPerson]]: """ @@ -359,7 +359,7 @@ class DoubanChain(ChainBase): :param doubanid: 豆瓣ID """ - return self.run_module("douban_tv_credits", doubanid=doubanid) + return cast(Optional[List[_SchemaMediaPerson]], self.run_module("douban_tv_credits", doubanid=doubanid)) def movie_recommend(self, doubanid: str) -> List[MediaInfo]: """ @@ -367,7 +367,7 @@ class DoubanChain(ChainBase): :param doubanid: 豆瓣ID """ - return self.run_module("douban_movie_recommend", doubanid=doubanid) + return cast(List[MediaInfo], (self.run_module("douban_movie_recommend", doubanid=doubanid)) or []) def tv_recommend(self, doubanid: str) -> List[MediaInfo]: """ @@ -375,7 +375,7 @@ class DoubanChain(ChainBase): :param doubanid: 豆瓣ID """ - return self.run_module("douban_tv_recommend", doubanid=doubanid) + return cast(List[MediaInfo], (self.run_module("douban_tv_recommend", doubanid=doubanid)) or []) async def async_person_detail(self, person_id: int) -> Optional[_SchemaMediaPerson]: """ @@ -383,7 +383,7 @@ class DoubanChain(ChainBase): :param person_id: 人物ID """ - return await self.async_run_module("async_douban_person_detail", person_id=person_id) + return cast(Optional[_SchemaMediaPerson], await self.async_run_module("async_douban_person_detail", person_id=person_id)) async def async_person_credits(self, person_id: int, page: Optional[int] = 1) -> List[MediaInfo]: """ @@ -392,7 +392,7 @@ class DoubanChain(ChainBase): :param person_id: 人物ID :param page: 页码 """ - return await self.async_run_module("async_douban_person_credits", person_id=person_id, page=page) + return cast(List[MediaInfo], (await self.async_run_module("async_douban_person_credits", person_id=person_id, page=page)) or []) async def async_movie_top250(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: @@ -402,28 +402,28 @@ class DoubanChain(ChainBase): :param page: 页码 :param count: 每页数量 """ - return await self.async_run_module("async_movie_top250", page=page, count=count) + return cast(Optional[List[MediaInfo]], await self.async_run_module("async_movie_top250", page=page, count=count)) async def async_movie_showing(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: """ 获取正在上映的电影(异步版本) """ - return await self.async_run_module("async_movie_showing", page=page, count=count) + return cast(Optional[List[MediaInfo]], await self.async_run_module("async_movie_showing", page=page, count=count)) async def async_tv_weekly_chinese(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: """ 获取本周中国剧集榜(异步版本) """ - return await self.async_run_module("async_tv_weekly_chinese", page=page, count=count) + return cast(Optional[List[MediaInfo]], await self.async_run_module("async_tv_weekly_chinese", page=page, count=count)) async def async_tv_weekly_global(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: """ 获取本周全球剧集榜(异步版本) """ - return await self.async_run_module("async_tv_weekly_global", page=page, count=count) + return cast(Optional[List[MediaInfo]], await self.async_run_module("async_tv_weekly_global", page=page, count=count)) async def async_douban_discover(self, mtype: MediaType, sort: str, tags: str, page: Optional[int] = 0, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: @@ -437,29 +437,29 @@ class DoubanChain(ChainBase): :param count: 数量 :return: 媒体信息列表 """ - return await self.async_run_module("async_douban_discover", mtype=mtype, sort=sort, tags=tags, - page=page, count=count) + return cast(Optional[List[MediaInfo]], await self.async_run_module("async_douban_discover", mtype=mtype, sort=sort, tags=tags, + page=page, count=count)) async def async_tv_animation(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: """ 获取动画剧集(异步版本) """ - return await self.async_run_module("async_tv_animation", page=page, count=count) + return cast(Optional[List[MediaInfo]], await self.async_run_module("async_tv_animation", page=page, count=count)) async def async_movie_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: """ 获取热门电影(异步版本) """ - return await self.async_run_module("async_movie_hot", page=page, count=count) + return cast(Optional[List[MediaInfo]], await self.async_run_module("async_movie_hot", page=page, count=count)) async def async_tv_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: """ 获取热门剧集(异步版本) """ - return await self.async_run_module("async_tv_hot", page=page, count=count) + return cast(Optional[List[MediaInfo]], await self.async_run_module("async_tv_hot", page=page, count=count)) async def async_movie_credits(self, doubanid: str) -> Optional[List[_SchemaMediaPerson]]: """ @@ -467,7 +467,7 @@ class DoubanChain(ChainBase): :param doubanid: 豆瓣ID """ - return await self.async_run_module("async_douban_movie_credits", doubanid=doubanid) + return cast(Optional[List[_SchemaMediaPerson]], await self.async_run_module("async_douban_movie_credits", doubanid=doubanid)) async def async_tv_credits(self, doubanid: str) -> Optional[List[_SchemaMediaPerson]]: """ @@ -475,7 +475,7 @@ class DoubanChain(ChainBase): :param doubanid: 豆瓣ID """ - return await self.async_run_module("async_douban_tv_credits", doubanid=doubanid) + return cast(Optional[List[_SchemaMediaPerson]], await self.async_run_module("async_douban_tv_credits", doubanid=doubanid)) async def async_movie_recommend(self, doubanid: str) -> List[MediaInfo]: """ @@ -483,7 +483,7 @@ class DoubanChain(ChainBase): :param doubanid: 豆瓣ID """ - return await self.async_run_module("async_douban_movie_recommend", doubanid=doubanid) + return cast(List[MediaInfo], (await self.async_run_module("async_douban_movie_recommend", doubanid=doubanid)) or []) async def async_tv_recommend(self, doubanid: str) -> List[MediaInfo]: """ @@ -491,7 +491,7 @@ class DoubanChain(ChainBase): :param doubanid: 豆瓣ID """ - return await self.async_run_module("async_douban_tv_recommend", doubanid=doubanid) + return cast(List[MediaInfo], (await self.async_run_module("async_douban_tv_recommend", doubanid=doubanid)) or []) def movie_interests(self, doubanid: str, start: int = 0, count: int = 20) -> _SchemaMediaCommentPage: """ diff --git a/app/chain/download/batch.py b/app/chain/download/batch.py index a9ec9af53..31bc53620 100644 --- a/app/chain/download/batch.py +++ b/app/chain/download/batch.py @@ -213,6 +213,7 @@ class _BatchDownloadRun: """ 读取真实文件集数;保留站点失败原因用于冷却,磁力链只跳过当前阶段。 """ + assert context.torrent_info is not None torrent = context.torrent_info meta = context.meta_info assert meta is not None @@ -338,6 +339,7 @@ class _BatchDownloadRun: """ 单季核对真实文件后提交,多季沿用整包提交;成功后标记下载。 """ + assert context.torrent_info is not None complete = False if len(seasons) == 1: inspected = self._inspect_whole_season(context, media_key, seasons[0]) @@ -489,6 +491,7 @@ class _BatchDownloadRun: """ 只在成功提交选中文件后更新识别集数,失败时保留原候选供换源记账。 """ + assert context.torrent_info is not None parsed = self._read_torrent(context, partial=True) if parsed is None: return set() diff --git a/app/chain/download/existence.py b/app/chain/download/existence.py index 68fd637ff..905c54402 100644 --- a/app/chain/download/existence.py +++ b/app/chain/download/existence.py @@ -55,7 +55,7 @@ class DownloadExistenceOwner(_DownloadOwnerBase): exists_movies = self._media_exists_with_server_cache( mediainfo=cast(MediaInfo, mediainfo), query={ - "mtype": mediainfo.type.value, + "mtype": (mediainfo.type.value if mediainfo.type else ""), "title": mediainfo.title, "year": mediainfo.year, "media_source": media_source, @@ -71,7 +71,7 @@ class DownloadExistenceOwner(_DownloadOwnerBase): exists_music = self._media_exists_with_server_cache( mediainfo=cast(MediaInfo, mediainfo), query={ - "mtype": mediainfo.type.value, + "mtype": (mediainfo.type.value if mediainfo.type else ""), "title": mediainfo.title, "year": mediainfo.year, }, @@ -100,7 +100,7 @@ class DownloadExistenceOwner(_DownloadOwnerBase): exists_tvs = self._media_exists_with_server_cache( mediainfo=mediainfo, query={ - "mtype": mediainfo.type.value, + "mtype": (mediainfo.type.value if mediainfo.type else ""), "title": mediainfo.title, "year": mediainfo.year, "media_source": media_source, diff --git a/app/chain/download/failure.py b/app/chain/download/failure.py index f9d8df233..7c17216b5 100644 --- a/app/chain/download/failure.py +++ b/app/chain/download/failure.py @@ -321,15 +321,16 @@ class DownloadFailureOwner(_DownloadOwnerBase): 台账里的成因可能是真实失败,也可能是重复提交退避,所以这里只转述记录中的原因, 不把它断言成"添加下载失败"。 """ + torrent_title = context.torrent_info.title if context.torrent_info else "未知资源" reason = getattr(failure, "error_message", None) or "未知原因" retry_at = getattr(failure, "next_retry_at", None) if retry_at: logger.info( - f"{context.torrent_info.title} 暂时跳过该资源(原因:{reason})," + f"{torrent_title} 暂时跳过该资源(原因:{reason})," f"将于 {retry_at} 后重试,本次继续尝试其他资源" ) else: logger.info( - f"{context.torrent_info.title} 暂时跳过该资源(原因:{reason})," + f"{torrent_title} 暂时跳过该资源(原因:{reason})," "本次继续尝试其他资源" ) diff --git a/app/chain/download/history.py b/app/chain/download/history.py index 3e29ced16..61b2c94f1 100644 --- a/app/chain/download/history.py +++ b/app/chain/download/history.py @@ -96,7 +96,7 @@ class DownloadHistoryOwner(_DownloadOwnerBase): classification = effective_classification_snapshot(media) history = DownloadHistoryWrite( path=download_path.as_posix(), - type=media.type.value, + type=(media.type.value if media.type else ""), title=media.title or "", year=str(media.year) if media.year is not None else None, media_source=media_source, diff --git a/app/chain/download/selection.py b/app/chain/download/selection.py index fad9d0d40..c9479d763 100644 --- a/app/chain/download/selection.py +++ b/app/chain/download/selection.py @@ -191,7 +191,7 @@ class DownloadSelectionOwner(_DownloadOwnerBase): if runtime_stop_state.is_system_stopped: break media = context.media_info - if media is None: + if media is None or context.torrent_info is None: continue media_type = media.type if media_type not in downloaded_keys: diff --git a/app/chain/download/submission.py b/app/chain/download/submission.py index 75add76f5..7ca43c0da 100644 --- a/app/chain/download/submission.py +++ b/app/chain/download/submission.py @@ -282,7 +282,7 @@ class _DownloadResourceOwner(_DownloadOwnerBase): if on_failure: on_failure(error_msg or "下载种子内容为空") if error_msg and "种子数据有误" in error_msg: - helper.add_invalid(torrent.enclosure) + helper.add_invalid(torrent.enclosure or "") logger.error(f"下载种子文件失败:{torrent.title}") self.post_message(Message( channel=channel, @@ -301,7 +301,7 @@ class _DownloadResourceOwner(_DownloadOwnerBase): 以资源身份和换票凭据隔离缓存,临时链接不参与键,凭据不写入文件名。 """ identity = torrent_rules.resource_identity(torrent) - if not torrent.enclosure.startswith("[") or identity is None: + if not torrent.enclosure or not torrent.enclosure.startswith("[") or identity is None: return None scope = json.dumps((identity, torrent.enclosure, torrent.site_cookie)) return f"resource-{hashlib.sha256(scope.encode('utf-8')).hexdigest()}.torrent" @@ -312,6 +312,8 @@ class _DownloadResourceOwner(_DownloadOwnerBase): """ 先读取已验证的换票种子内容;缓存损坏时重新换票,失败与磁力链不缓存。 """ + if not torrent.enclosure: + return None cache_key = self._indirect_torrent_cache_key(torrent) cache = FileCache(local_only=True) if cache_key else None if cache is not None and cache_key is not None: @@ -371,6 +373,8 @@ class _DownloadResourceOwner(_DownloadOwnerBase): return result if not self._refresh_signed_torrent(torrent): return result + if not torrent.enclosure: + return result url, cookie = torrent.enclosure, torrent.site_cookie return result @@ -420,7 +424,7 @@ class _DownloadResourceOwner(_DownloadOwnerBase): media = context.media_info event_data = ResourceDownloadEventData( context=context, - episodes=episodes or (meta.episode_list if meta else []), + episodes=set(episodes or (meta.episode_list if meta else [])), channel=channel, origin=source, downloader=downloader, @@ -708,7 +712,7 @@ class DownloadSubmissionOwner(_DownloadResourceOwner): if not download_dir or not storage: if error_msg == "未找到下载目录": self.messagehelper.put( - f"{media.type.value} {media.title_year} 未找到下载目录!", + f"{(media.type.value if media.type else "")} {media.title_year} 未找到下载目录!", title="下载失败", role="system", level="error", diff --git a/app/chain/download/subtitle.py b/app/chain/download/subtitle.py index c6af72dd5..fb1ca359c 100644 --- a/app/chain/download/subtitle.py +++ b/app/chain/download/subtitle.py @@ -365,7 +365,7 @@ class DownloadSubtitleOwner(_DownloadOwnerBase): dir_info = DirectoryHelper().get_dir(media_info, include_unsorted=True) storage = (dir_info.storage or storage) if dir_info else storage if not dir_info: - logger.error(f"未找到下载目录:{media_info.type.value} {media_info.title_year}") + logger.error(f"未找到下载目录:{media_info.type.value if media_info.type else ""} {media_info.title_year}") return None, None, "未找到下载目录" if not dir_info.download_path: @@ -449,7 +449,7 @@ class DownloadSubtitleOwner(_DownloadOwnerBase): 保存字幕下载响应到目标目录。 """ fallback_name = self._detect_subtitle_fallback_name(subtitle, response.content) - file_name = subtitle.file_name or TorrentHelper.get_url_filename(response, subtitle.enclosure) + file_name = subtitle.file_name or TorrentHelper.get_url_filename(response, subtitle.enclosure or "") if not Path(file_name).suffix: file_name = fallback_name file_name = self._safe_subtitle_file_name( @@ -552,7 +552,7 @@ class DownloadSubtitleOwner(_DownloadOwnerBase): if not subtitle or not subtitle.enclosure: return False, "字幕下载链接为空", [] - metainfo = MetaInfo(title=subtitle.title, subtitle=subtitle.description) + metainfo = MetaInfo(title=subtitle.title or "", subtitle=subtitle.description) mediainfo = MediaChain().recognize_media( meta=metainfo, media_source=media_source, @@ -709,7 +709,7 @@ class DownloadSubtitleOwner(_DownloadOwnerBase): return torrent = context.torrent_info - if not torrent.page_url: + if not torrent or not torrent.page_url: return logger.info("开始从站点下载字幕:%s" % torrent.page_url) folder_name, _ = cast(Any, TorrentHelper)().get_fileinfo_from_torrent_content( diff --git a/app/chain/download/tasks.py b/app/chain/download/tasks.py index 51ce81471..d2f66b033 100644 --- a/app/chain/download/tasks.py +++ b/app/chain/download/tasks.py @@ -113,7 +113,8 @@ class DownloadTaskOwner(_DownloadOwnerBase): """ if not event: return - hash_str = event.event_data.get("hash") + event_data = event.event_data if isinstance(event.event_data, dict) else {} + hash_str = event_data.get("hash") if not hash_str: return logger.warn(f"检测到下载源文件被删除,删除下载任务(不含文件):{hash_str}") diff --git a/app/chain/interaction.py b/app/chain/interaction.py index 62a8bccbe..5656d0a1a 100644 --- a/app/chain/interaction.py +++ b/app/chain/interaction.py @@ -601,7 +601,7 @@ class MediaInteractionChain(ChainBase): source=source, userid=userid, username=username, - title=f"开始搜索 {mediainfo.type.value} {mediainfo.title_year} ...", + title=f"开始搜索 {(mediainfo.type or MediaType.UNKNOWN).value} {mediainfo.title_year} ...", save_history=False, ) ) @@ -1266,6 +1266,8 @@ class MediaInteractionChain(ChainBase): current_row: List[Dict[str, str]] = [] for index, context in enumerate(items, start=1): torrent = context.torrent_info + if torrent is None: + continue if max_per_row == 1: button_text = f"{index}. {torrent.site_name} - {torrent.seeders}↑" if len(button_text) > max_text_length: diff --git a/app/chain/media/auxiliary.py b/app/chain/media/auxiliary.py index 5632d00ff..91ebd636b 100644 --- a/app/chain/media/auxiliary.py +++ b/app/chain/media/auxiliary.py @@ -76,7 +76,7 @@ class MediaAuxiliaryOwner(_MediaOwnerBase): tmdb_meta.begin_season = season season_year = None if season is not None and mediainfo.season_years: - season_year = mediainfo.season_years.get(season) or mediainfo.season_years.get(str(season)) + season_year = mediainfo.season_years.get(season) tmdb_meta.year = season_year or mediainfo.year or getattr(metainfo, "year", None) return tmdb_meta diff --git a/app/chain/media/path.py b/app/chain/media/path.py index 158e75e71..a3a23298d 100644 --- a/app/chain/media/path.py +++ b/app/chain/media/path.py @@ -347,7 +347,7 @@ def _fingerprint_info_matches_evidence( return False if weak_title: return native_strong - return _fingerprint_title_matches(info, primary.title) + return _fingerprint_title_matches(info, primary.title or "") def _fingerprint_title_matches(info: MusicInfo, title_evidence: str) -> bool: diff --git a/app/chain/media/projection.py b/app/chain/media/projection.py index 01cda1ac6..4a46cccfc 100644 --- a/app/chain/media/projection.py +++ b/app/chain/media/projection.py @@ -430,7 +430,7 @@ class MediaProjectionOwner(_MediaOwnerBase): event_data, ) return ( - cast(_ProjectionPayload, event_data.media_dict) + event_data.media_dict if event and event_data.media_dict else None ) @@ -452,7 +452,7 @@ class MediaProjectionOwner(_MediaOwnerBase): event_data, ) return ( - cast(_ProjectionPayload, event_data.media_dict) + event_data.media_dict if event and event_data.media_dict else None ) diff --git a/app/chain/media/recognition.py b/app/chain/media/recognition.py index 266210157..1c6cd5b9d 100644 --- a/app/chain/media/recognition.py +++ b/app/chain/media/recognition.py @@ -247,7 +247,7 @@ class MediaRecognitionOwner(_MediaOwnerBase): 为同步与异步标题识别冻结相同的元数据副本和来源语义。 """ return _MetaRecognitionRequest( - title=metainfo.title, + title=metainfo.title or "", metainfo=metainfo, share_meta=deepcopy(metainfo), mtype=mtype, @@ -283,7 +283,7 @@ class MediaRecognitionOwner(_MediaOwnerBase): return None logger.info( f"{request.title} 识别到媒体信息:" - f"{mediainfo.type.value} {mediainfo.title_year}" + f"{(mediainfo.type or MediaType.UNKNOWN).value} {mediainfo.title_year}" ) return mediainfo diff --git a/app/chain/mediaserver.py b/app/chain/mediaserver.py index 912007be1..453fe5c07 100644 --- a/app/chain/mediaserver.py +++ b/app/chain/mediaserver.py @@ -4,7 +4,7 @@ import threading from datetime import datetime -from typing import Any, Callable, Dict, Generator, List, Optional, Tuple, Union +from typing import Any, Callable, Dict, Generator, List, Optional, Tuple, Union, cast from app.application.mediaserver import ( MediaServerRepository, @@ -28,12 +28,12 @@ from app.schemas.types import MediaType lock = threading.Lock() # 同步只保存本地缓存实际使用的业务内容;播放状态和展示资源不进入同步写端。 -_SYNC_QUERY = MediaServerQueryOptions(fields={ +_SYNC_QUERY = MediaServerQueryOptions(fields=frozenset({ MediaServerField.LIBRARY, MediaServerField.ORIGINAL_TITLE, MediaServerField.YEAR, MediaServerField.IDENTITY, MediaServerField.PATH, MediaServerField.MUSIC, -}) -_LIBRARY_SYNC_QUERY = MediaServerQueryOptions(fields={MediaServerField.PATH, MediaServerField.COUNTS}) -_EPISODE_SYNC_QUERY = MediaServerQueryOptions(fields=set()) +})) +_LIBRARY_SYNC_QUERY = MediaServerQueryOptions(fields=frozenset({MediaServerField.PATH, MediaServerField.COUNTS})) +_EPISODE_SYNC_QUERY = MediaServerQueryOptions(fields=frozenset()) class MediaServerChain(ChainBase): @@ -84,11 +84,12 @@ class MediaServerChain(ChainBase): if library.image: library.image = self._sign_image_url(library.image) if library.image_list: - library.image_list = [ - self._sign_image_url(image) - for image in library.image_list - if image - ] + signed_images: list[str] = [] + for image in library.image_list: + signed = self._sign_image_url(image) + if signed: + signed_images.append(signed) + library.image_list = signed_images return libraries def _sign_play_item_images( @@ -117,15 +118,16 @@ class MediaServerChain(ChainBase): :param query: 可选查询内容投影,省略时保留原有请求和返回内容 """ - return self._sign_library_images( + return self._sign_library_images(cast( + Optional[List[MediaServerLibrary]], self.run_module( "mediaserver_librarys", server=server, username=username, hidden=hidden, **({"query": query} if query is not None else {}), - ) - ) + ), + )) def items( self, @@ -191,11 +193,11 @@ class MediaServerChain(ChainBase): :param library_id: 媒体库ID :return: 媒体条目总数,无法获取时返回None """ - return self.run_module( + return cast(Optional[int], self.run_module( "mediaserver_items_count", server=server, library_id=library_id, - ) + )) def media_count(self, server: str) -> Optional[int]: """ @@ -222,9 +224,9 @@ class MediaServerChain(ChainBase): :param query: 可选查询内容投影,省略时保留原有请求和返回内容 """ - return self.run_module( + return cast(MediaServerItem, self.run_module( "mediaserver_iteminfo", server=server, item_id=item_id, **({"query": query} if query is not None else {}) - ) + )) def episodes( self, server: str, item_id: Union[str, int], *, query: Optional[MediaServerQueryOptions] = None @@ -234,9 +236,9 @@ class MediaServerChain(ChainBase): :param query: 可选查询内容投影,省略时保留原有请求和返回内容 """ - return self.run_module( + return cast(List[MediaServerSeasonInfo], self.run_module( "mediaserver_tv_episodes", server=server, item_id=item_id, **({"query": query} if query is not None else {}) - ) + )) def playing( self, @@ -306,7 +308,12 @@ class MediaServerChain(ChainBase): username=username, **({"query": query} if query is not None else {}), ) - return [self._sign_image_url(wallpaper) for wallpaper in wallpapers or [] if wallpaper] + signed_wallpapers: list[str] = [] + for wallpaper in wallpapers or []: + signed = self._sign_image_url(wallpaper) + if signed: + signed_wallpapers.append(signed) + return signed_wallpapers def get_latest_wallpaper( self, @@ -334,7 +341,7 @@ class MediaServerChain(ChainBase): """ 获取播放地址 """ - return self.run_module("mediaserver_play_url", server=server, item_id=item_id) + return cast(Optional[str], self.run_module("mediaserver_play_url", server=server, item_id=item_id)) def get_season_episode_ids( self, server: str, item_id: Union[str, int], season: int, *, query: Optional[MediaServerQueryOptions] = None @@ -355,17 +362,17 @@ class MediaServerChain(ChainBase): season=season, **({"query": query} if query is not None else {}), ) - return result or {} + return cast(Dict[int, str], result or {}) def get_image_cookies( self, server: Optional[str], image_url: str - ) -> Optional[str | dict]: + ) -> Optional[str | dict[str, Any]]: """ 获取图片的Cookies """ - return self.run_module( + return cast(str | dict[str, Any] | None, self.run_module( "mediaserver_image_cookies", server=server, image_url=image_url - ) + )) def _prepare_sync_contexts( self, @@ -378,17 +385,22 @@ class MediaServerChain(ChainBase): :return: (待同步服务器、服务器数量、服务器同步上下文、全局媒体总数) """ - enabled_servers = [item.name for item in mediaservers if item and item.enabled and item.name] + enabled_servers = [ + item.name for item in mediaservers + if item and item.enabled and isinstance(item.name, str) and item.name + ] repository.delete_excluded_servers(enabled_servers) selected_servers = [ - item for item in mediaservers if item and item.enabled and (not server or item.name == server) + item for item in mediaservers + if item and item.enabled and isinstance(item.name, str) + and (not server or item.name == server) ] total_servers = len(selected_servers) contexts: Dict[str, Any] = {} global_media_total = 0 global_counts_available = True for mediaserver in selected_servers: - server_name = mediaserver.name + server_name = str(mediaserver.name or "") logger.info(f"正在统计媒体服务器 {server_name} 的待同步媒体数量") libraries = self.librarys(server_name, hidden=True, query=_LIBRARY_SYNC_QUERY) if not libraries: @@ -402,6 +414,8 @@ class MediaServerChain(ChainBase): ] library_media_counts = {} for library in selected_libraries: + if library.id is None: + continue media_count = getattr(library, "item_count", None) if media_count is None: media_count = self.items_count(server=server_name, library_id=library.id) @@ -440,6 +454,8 @@ class MediaServerChain(ChainBase): logger.info(f"正在同步 {server_name} 媒体库 {library.name} ...") library_media_total = library_media_counts.get(str(library.id)) library_count = 0 + if library.id is None: + continue for item in self.items(server=server_name, library_id=library.id, query=_SYNC_QUERY): if runtime_stop_state.is_system_stopped: return total_count, global_media_finished @@ -587,7 +603,7 @@ class MediaServerChain(ChainBase): logger.info(f"媒体服务器 {mediaserver.name} 未启用,跳过") continue server_index += 1 - server_name = mediaserver.name + server_name = str(mediaserver.name or "") if progress_callback: progress_value = ( global_media_finished / global_media_total * 100 diff --git a/app/chain/message.py b/app/chain/message.py index a6ecfdf82..4e2bc31a0 100644 --- a/app/chain/message.py +++ b/app/chain/message.py @@ -10,10 +10,11 @@ import threading import uuid from collections.abc import Mapping from concurrent.futures import CancelledError as FutureCancelledError +from concurrent.futures import Future from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import Any, Dict, List, Optional, Protocol, Tuple, Union +from typing import Any, Dict, List, Optional, Protocol, Tuple, Union, cast from urllib.parse import unquote, urlparse from app.application.agent import ( @@ -146,7 +147,7 @@ class MessageChain(ChainBase): _ai_prefix = "/ai" _no_ai_prefix = "/noai" # 用户会话信息 {userid: (session_id, last_time)} - _user_sessions: Dict[Union[str, int], tuple] = {} + _user_sessions: Dict[Union[str, int], tuple[str, datetime]] = {} # 会话超时时间(分钟) _session_timeout_minutes: int = 24 * 60 @@ -242,8 +243,11 @@ class MessageChain(ChainBase): if not info: logger.info("消息链路未识别到有效消息: source=%s", source) return - source = info.source + source = str(info.source or source or "") channel = info.channel + if channel is None: + logger.warning("消息缺少有效通知渠道:source=%s", source) + return userid = info.userid # 用户名(当渠道未提供公开用户名时,回退为 userid 的字符串,避免后续类型校验异常) username = ( @@ -306,6 +310,7 @@ class MessageChain(ChainBase): """ 识别消息内容,执行操作 """ + text = text or "" images = IncomingMessage.MessageImage.normalize_list(images) # 兼容归一化:结构化回调优先,CALLBACK: 文本前缀作为旧渠道和插件直接调用的兼容入口 @@ -509,6 +514,7 @@ class MessageChain(ChainBase): """ 执行实际消息路由,便于统一包裹处理中状态。 """ + text = text or "" context = InteractionContext( channel=channel, @@ -793,6 +799,9 @@ class MessageChain(ChainBase): ] def _dispatch_agent_choice(callback_data: str, context: InteractionContext) -> InteractionDispatch: + """ + 将已解析的选择回调交给当前 Agent,并返回是否处理的结果。 + """ handled = self._handle_agent_choice_callback( callback_data=callback_data, context=context, @@ -801,6 +810,9 @@ class MessageChain(ChainBase): return InteractionDispatch(handled=handled, defer_processing_finish=handled) def _dispatch_plugin_callback(callback_data: str, context: InteractionContext) -> InteractionDispatch: + """ + 校验插件回调格式后派发交互,不接收无法识别的载荷。 + """ parsed = PluginInputInteractionHandler.parse_callback(callback_data) if not parsed: return InteractionDispatch(handled=False) @@ -946,7 +958,7 @@ class MessageChain(ChainBase): selected_text = option.value self._update_interaction_message_feedback( channel=context.channel, - source=context.source, + source=context.source or "", original_message_id=context.original_message_id, original_chat_id=context.original_chat_id, title=request.title, @@ -957,9 +969,9 @@ class MessageChain(ChainBase): return self._handle_ai_message( text=selected_text, channel=context.channel, - source=context.source, + source=context.source or "", userid=context.user_id, - username=context.username, + username=context.username or str(context.user_id), is_channel_admin=context.is_channel_admin, session_id=request.session_id, ) @@ -1068,7 +1080,7 @@ class MessageChain(ChainBase): channel: NotificationChannel, userid: Union[str, int], source: Optional[str] = None, - ): + ) -> None: """ 清除用户会话(远程命令接口) """ @@ -1105,7 +1117,7 @@ class MessageChain(ChainBase): channel: NotificationChannel, userid: Union[str, int], source: Optional[str] = None, - ): + ) -> None: """ 应急停止当前正在执行的Agent推理(远程命令接口)。 与 /clear_session 不同,此命令不会清除会话和记忆, @@ -1162,10 +1174,16 @@ class MessageChain(ChainBase): @staticmethod def _format_token_count(value: Optional[int]) -> str: + """ + 格式化 token 数量,未报告的用量显示为未知。 + """ return f"{value:,}" if value is not None else "未知" @classmethod def _format_session_status_text(cls, status: Dict[str, Any]) -> str: + """ + 把会话上下文、模型调用和用量统计转换为用户可读状态。 + """ context_window_tokens = status.get("context_window_tokens") last_input_tokens = status.get("last_input_tokens") if context_window_tokens and status.get("model_call_count"): @@ -1281,7 +1299,7 @@ class MessageChain(ChainBase): channel: NotificationChannel, userid: Union[str, int], source: Optional[str] = None, - ): + ) -> None: """ 查询当前用户的智能体会话状态。 """ @@ -1395,15 +1413,16 @@ class MessageChain(ChainBase): # 将可直接输入给 LLM 的附件统一转换为 data URL original_images = images + image_data_urls: Optional[list[str]] = None all_files = list(files or []) if images and supports_image_input( provider=self.runtime_config.llm_provider, model=self.runtime_config.llm_model, ): - images = self._download_attachments_to_data_urls( + image_data_urls = self._download_attachments_to_data_urls( images, channel, source ) - if original_images and not images and not user_message and not files: + if original_images and not image_data_urls and not user_message and not files: self.post_message( Message( channel=channel, @@ -1443,7 +1462,7 @@ class MessageChain(ChainBase): channel=channel, source=source, ) - if all_files and not prepared_files and not user_message and not images: + if all_files and not prepared_files and not user_message and not image_data_urls: self.post_message( Message( channel=channel, @@ -1460,7 +1479,7 @@ class MessageChain(ChainBase): "session_id": session_id, "user_id": str(userid), "message": user_message, - "images": images, + "images": image_data_urls, "files": prepared_files, "channel": channel.value if channel else None, "source": source, @@ -1479,7 +1498,10 @@ class MessageChain(ChainBase): main_loop_registry.require(), ) - def _report_agent_submission_failure(completed) -> None: + def _report_agent_submission_failure(completed: Future[Any]) -> None: + """ + 消费跨线程消息提交结果并反馈排队或服务错误。 + """ try: completed.result() except BaseException as error: @@ -1681,7 +1703,7 @@ class MessageChain(ChainBase): normalized_attachments = IncomingMessage.MessageImage.normalize_list(attachments) or [] if not normalized_attachments: return None - data_urls = [] + data_urls: list[str] = [] for attachment in normalized_attachments: attachment_ref = attachment.ref try: @@ -1774,7 +1796,7 @@ class MessageChain(ChainBase): """ 将图片引用转换为附件描述,以便按文件方式交给 Agent 处理。 """ - images = IncomingMessage.MessageImage.normalize_list(images) + images = IncomingMessage.MessageImage.normalize_list(images) or [] if not images: return [] @@ -1801,7 +1823,7 @@ class MessageChain(ChainBase): files: Optional[List[IncomingMessage.MessageAttachment]], channel: NotificationChannel, source: str, - ) -> Optional[List[dict]]: + ) -> Optional[List[dict[str, Any]]]: """ 下载用户上传的附件,落盘到临时目录,并生成 Agent 可消费的文件描述。 """ @@ -1858,22 +1880,22 @@ class MessageChain(ChainBase): return self._decode_data_url_bytes(file_ref) if file_ref.startswith("tg://file_id/"): file_id = file_ref.replace("tg://file_id/", "", 1) - return self.run_module( + return cast(Optional[bytes], self.run_module( "download_telegram_file_bytes", file_id=file_id, source=source - ) + )) if file_ref.startswith("tg://document_file_id/"): file_id = file_ref.replace("tg://document_file_id/", "", 1) - return self.run_module( + return cast(Optional[bytes], self.run_module( "download_telegram_file_bytes", file_id=file_id, source=source - ) + )) if file_ref.startswith("wxwork://media_id/"): - return self.run_module( + return cast(Optional[bytes], self.run_module( "download_wechat_media_bytes", media_ref=file_ref, source=source - ) + )) if file_ref.startswith("wxwork://file_media_id/"): - return self.run_module( + return cast(Optional[bytes], self.run_module( "download_wechat_media_bytes", media_ref=file_ref, source=source - ) + )) if file_ref.startswith("wxbot://image/"): data_url = self.run_module( "download_wechat_image_to_data_url", image_ref=file_ref, source=source @@ -1889,33 +1911,33 @@ class MessageChain(ChainBase): content, _ = _read_message_http(file_url) return content if file_ref.startswith("wxclaw://file/") or file_ref.startswith("wxclaw://voice/"): - return self.run_module( + return cast(Optional[bytes], self.run_module( "download_wechat_media_bytes", media_ref=file_ref, source=source - ) + )) if file_ref.startswith("feishu://file/"): - return self.run_module( + return cast(Optional[bytes], self.run_module( "download_feishu_file_bytes", file_ref=file_ref, source=source - ) + )) if file_ref.startswith("slack://file/"): - return self.run_module( + return cast(Optional[bytes], self.run_module( "download_slack_file_bytes", file_ref=file_ref, source=source - ) + )) if file_ref.startswith("discord://file/"): - return self.run_module( + return cast(Optional[bytes], self.run_module( "download_discord_file_bytes", file_ref=file_ref, source=source - ) + )) if file_ref.startswith("qq://file/"): - return self.run_module( + return cast(Optional[bytes], self.run_module( "download_qq_file_bytes", file_ref=file_ref, source=source - ) + )) if file_ref.startswith("vocechat://file/"): - return self.run_module( + return cast(Optional[bytes], self.run_module( "download_vocechat_file_bytes", file_ref=file_ref, source=source - ) + )) if file_ref.startswith("synology://file/"): - return self.run_module( + return cast(Optional[bytes], self.run_module( "download_synologychat_file_bytes", file_ref=file_ref, source=source - ) + )) if file_ref.startswith("http"): if channel == NotificationChannel.Slack: data_url = self.run_module( diff --git a/app/chain/music_interaction/music.py b/app/chain/music_interaction/music.py index 71848b802..7fcec484b 100644 --- a/app/chain/music_interaction/music.py +++ b/app/chain/music_interaction/music.py @@ -285,10 +285,11 @@ def post_music_aware_torrents( if not torrents or not isinstance(torrents[0].media_info, MusicInfo): host.post_torrents_message(message, torrents) return - message.text = "\n".join( - f"{index}.【{context.torrent_info.site_name}】" - f"{context.torrent_info.title} {context.torrent_info.seeders}↑" - for index, context in enumerate(torrents, start=1) - ) + lines = [] + for index, context in enumerate(torrents, start=1): + torrent = context.torrent_info + if torrent is not None: + lines.append(f"{index}.【{torrent.site_name}】{torrent.title} {torrent.seeders}↑") + message.text = "\n".join(lines) message.parse_mode = "" host.post_message(message) diff --git a/app/chain/recommend.py b/app/chain/recommend.py index ec08e723b..f675c97a1 100644 --- a/app/chain/recommend.py +++ b/app/chain/recommend.py @@ -2,7 +2,7 @@ 影视和音乐推荐聚合、分页缓存与定期刷新。 """ -from typing import Any, Callable, List, Optional, Protocol, Sequence, TypeVar, cast +from typing import Any, Callable, List, Optional, Protocol, Sequence, TypeVar # 导入时为 Pillow 注册 AVIF 解码能力,因此需要保留未直接引用的模块。 import pillow_avif # noqa: F401 # pylint: disable=unused-import @@ -39,9 +39,9 @@ def _cached_recommendation(ttl: int, region: str) -> Callable[[_RecommendationFu """ 应用同步或异步缓存,保留原始函数身份与返回载荷。 """ - return cast(_RecommendationFunction, cached( + return cached( ttl=ttl, region=region, skip_empty=True, - )(function)) + )(function) return decorate diff --git a/app/chain/scraping.py b/app/chain/scraping.py index 8e61b65de..123ad0b7f 100644 --- a/app/chain/scraping.py +++ b/app/chain/scraping.py @@ -12,7 +12,7 @@ from dataclasses import dataclass from pathlib import Path from tempfile import NamedTemporaryFile, TemporaryDirectory from threading import Lock -from typing import Any, Iterable, List, Optional, Protocol, Self, Tuple, Union +from typing import Any, Iterable, List, Optional, Protocol, Self, Tuple, Union, cast from app.application.audio import AudioMetadataHelper from app.application.configuration import ( @@ -551,7 +551,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): 处理 NFO 和图片文件的命名约定及存储位置 """ target_dir_item = current_fileitem - target_dir_path = Path(current_fileitem.path) + target_dir_path = Path(cast(str, current_fileitem.path)) final_filename = filename_hint # 针对 NFO 文件的特殊命名和存储逻辑 @@ -564,7 +564,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): parent_fileitem or self.storagechain.get_parent_item(current_fileitem) ) - if not parent_item: + if not parent_item or not parent_item.path: logger.error( f"无法获取文件 {current_fileitem.path} 的父目录项。" ) @@ -573,7 +573,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): None, ) target_dir_item = parent_item - target_dir_path = Path(target_dir_item.path) + target_dir_path = Path(parent_item.path) else: # 电影目录NFO (例如蓝光原盘): 放在电影目录内,名称与目录名主体一致,后缀.nfo final_filename = f"{target_dir_path.name}.nfo" @@ -589,11 +589,11 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): parent_item = parent_fileitem or self.storagechain.get_parent_item( current_fileitem ) - if not parent_item: + if not parent_item or not parent_item.path: logger.error(f"无法获取文件 {current_fileitem.path} 的父目录项。") return current_fileitem, None target_dir_item = parent_item - target_dir_path = Path(target_dir_item.path) + target_dir_path = Path(parent_item.path) # 图片通常是放在当前目录 (current_fileitem) 下 # Jellyfin/Kodi 等在季目录内使用通用图片名,而不是 season01-poster.jpg elif item_type == ScrapingTarget.SEASON: @@ -612,11 +612,11 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): parent_item = parent_fileitem or self.storagechain.get_parent_item( current_fileitem ) - if not parent_item: + if not parent_item or not parent_item.path: logger.error(f"无法获取文件 {current_fileitem.path} 的父目录项。") return current_fileitem, None target_dir_item = parent_item - target_dir_path = Path(target_dir_item.path) + target_dir_path = Path(parent_item.path) # 如果是 EPISODE 类型的图片(如thumb),通常也是放在文件同级目录,文件名与视频文件一致 elif ( metadata_type in [ScrapingMetadata.THUMB] @@ -627,11 +627,11 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): parent_item = parent_fileitem or self.storagechain.get_parent_item( current_fileitem ) - if not parent_item: + if not parent_item or not parent_item.path: logger.error(f"无法获取文件 {current_fileitem.path} 的父目录项。") return current_fileitem, None target_dir_item = parent_item - target_dir_path = Path(target_dir_item.path) + target_dir_path = Path(parent_item.path) # TODO: 考虑其他图片类型是否也需要保存到父目录 if not final_filename: @@ -676,6 +676,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): if ( not self.runtime_config.scrap_season_image_to_root or item_type != ScrapingTarget.SEASON + or not current_fileitem.path or not filename_hint or not filename_hint.lower().startswith("season") or metadata_type not in { @@ -749,13 +750,13 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): :param season: 季号 :param episode: 集号 """ - return self.run_module( + return cast(Optional[str], self.run_module( "metadata_nfo", meta=meta, mediainfo=mediainfo, season=season, episode=episode, - ) + )) def metadata_img( self, @@ -801,13 +802,13 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): """ if not event: return - event_data = event.event_data or {} - fileitem: _SchemaFileItem = event_data.get("fileitem") + event_data = event.event_data if isinstance(event.event_data, dict) else {} + fileitem = event_data.get("fileitem") file_list: List[str] = list(dict.fromkeys(event_data.get("file_list") or [])) - meta: MetaBase = event_data.get("meta") - mediainfo: MediaInfo = event_data.get("mediainfo") + meta: Optional[MetaBase] = event_data.get("meta") + mediainfo: Optional[Union[MediaInfo, MusicInfo]] = event_data.get("mediainfo") overwrite = event_data.get("overwrite", False) - if not fileitem: + if not isinstance(fileitem, _SchemaFileItem) or not fileitem.path or not fileitem.storage: return with scraping_lock: @@ -820,11 +821,12 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): file_list=file_list, ) scrape_kwargs["media_by_path"] = { - Path(context.get("path")).as_posix(): context.get("mediainfo") + Path(context_path).as_posix(): context.get("mediainfo") for context in event_data.get("file_contexts") or [] if ( isinstance(context, dict) - and context.get("path") + and isinstance(context_path := context.get("path"), str) + and context_path and isinstance(context.get("mediainfo"), MusicInfo) ) } @@ -966,7 +968,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): parent_fileitem=parent_fileitem, ) - if not nfo_path: + if not nfo_path or not base_item.storage: return file_exists = self.storagechain.get_file_item( @@ -1064,7 +1066,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): image_targets = self._expand_with_aliases(image_targets, item_type) for base_item, image_path in image_targets: - if not image_path: + if not image_path or not base_item.storage: continue file_exists = self.storagechain.get_file_item( @@ -1105,8 +1107,8 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): :param audio_files: 音乐批次已确认成功的音频文件清单 :param media_by_path: 音乐批次每个音频文件对应的标准身份 """ - if not fileitem: - return False, "未提供刮削文件" + if not fileitem or not fileitem.path or not fileitem.storage: + return False, "未提供有效的刮削文件路径和存储类型" filepath = Path(fileitem.path) is_music = ( @@ -1120,7 +1122,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): if is_music: music_info = ( mediainfo - if getattr(mediainfo, "type", None) == MediaType.MUSIC + if isinstance(mediainfo, MusicInfo) else None ) music_kwargs: dict[str, Any] = {} @@ -1144,7 +1146,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): meta = MetaInfoPath(filepath) if not mediainfo: mediainfo = MediaChain().recognize_by_meta(meta) - if not mediainfo: + if not isinstance(mediainfo, MediaInfo): logger.warn(f"{filepath} 无法识别文件媒体信息!") return False, "未识别到媒体信息" @@ -1205,7 +1207,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): if isinstance(info, MusicInfo) } file_media = [ - normalized_media_by_path.get(Path(item.path).as_posix(), mediainfo) + normalized_media_by_path.get(Path(item.path or "").as_posix(), mediainfo) for item in files ] distinct_recordings = { @@ -1396,6 +1398,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): if ( not item or item.type != "file" + or not item.path or not cls._is_music_audio_file(item.path or "") ): continue @@ -1414,6 +1417,8 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): """ 把刮削事件中的成功路径恢复为文件项,并限制在事件媒体根目录内。 """ + if not root.path or not root.storage: + return [] root_path = Path(root.path) selected: list[_SchemaFileItem] = [] for raw_path in file_list or []: @@ -1814,6 +1819,8 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): """ 查找音轨旁已存在的同步或纯文本歌词文件。 """ + if not fileitem.path or not fileitem.storage: + return None audio_path = Path(fileitem.path) for extension in self.MUSIC_LYRICS_EXTENSIONS: target_path = self._music_lyrics_path(audio_path, extension) @@ -1858,6 +1865,8 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): """ 按独立字形设置写入本地或远端歌词,保留来源内容并遵守覆盖策略。 """ + if not fileitem.path: + return False extension = lyrics.extension content = lyrics.content if not extension or not content: @@ -1944,6 +1953,8 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): """ 保留来源返回的标准 Lyricsfile,同时由主写入流程生成播放器兼容歌词。 """ + if not fileitem.path: + return False target_path = self._music_lyrics_path(Path(fileitem.path), ".lyricsfile.yaml") try: if fileitem.storage == "local": @@ -1970,6 +1981,8 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): """ 覆盖歌词格式后删除同音轨的旧扩展名文件,避免播放器优先读取过期内容。 """ + if not fileitem.path or not fileitem.storage: + return audio_path = Path(fileitem.path) for extension in self.MUSIC_LYRICS_EXTENSIONS: if extension in (keep_extension, ".lyricsfile.yaml"): @@ -2082,6 +2095,8 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): """ 处理电视剧刮削 """ + if not fileitem.path: + return filepath = Path(fileitem.path) if fileitem.type == "file": @@ -2177,7 +2192,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): if ( file.type == "dir" and file.name not in self.runtime_config.season_zero_names - and MetaInfo(file.name).begin_season is None + and MetaInfo(file.name or "").begin_season is None ): # 电视剧不处理非季子目录 continue diff --git a/app/chain/search/contract.py b/app/chain/search/contract.py index eeb635df5..1840aa48e 100644 --- a/app/chain/search/contract.py +++ b/app/chain/search/contract.py @@ -96,7 +96,13 @@ if TYPE_CHECKING: cancel_ai_recommend: Callable[..., Any] get_current_recommend_status_only: Callable[..., Any] get_recommend_status: Callable[..., Any] - is_ai_recommend_enabled: Callable[..., Any] + @property + def is_ai_recommend_enabled(self) -> bool: + """ + 返回宿主是否同时启用 Agent 与 AI 搜索推荐。 + """ + ... + last_search_params: Callable[..., Any] last_search_results: Callable[..., Any] matches_music_resource: Callable[..., Any] diff --git a/app/chain/search/execution.py b/app/chain/search/execution.py index 0c46b87c6..e1de049c8 100644 --- a/app/chain/search/execution.py +++ b/app/chain/search/execution.py @@ -176,7 +176,7 @@ def _candidate_contexts(mediainfo: MediaInfo | MusicInfo, torrents: List[Torrent 原始预览仅展示资源自身解析信息,不提前绑定未经匹配的目标媒体。 """ return [Context( - meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description, mtype=mediainfo.type), + meta_info=MetaInfo(title=torrent.title or "", subtitle=torrent.description, mtype=mediainfo.type), torrent_info=torrent, resource_source="search", media_info_is_target=False, match_status="candidate", match_reason="unverified", ) for torrent in torrents] diff --git a/app/chain/search/manual.py b/app/chain/search/manual.py index c889f81d1..1257072e6 100644 --- a/app/chain/search/manual.py +++ b/app/chain/search/manual.py @@ -122,7 +122,7 @@ class _ManualPage: torrents = self.torrents if torrents is None else torrents if self.raw_title: return SearchTitleOwner._resolve_title_request(cast(SearchTitleOwner, self.owner), _TitleSearchResolveRequest( - title=self.media.title, torrents=torrents, rule_groups=self.plan.rule_groups, + title=self.media.title or "", torrents=torrents, rule_groups=self.plan.rule_groups, mtype=None if self.media.type == MediaType.UNKNOWN else self.media.type, )).contexts return SearchResultOwner._parse_result( diff --git a/app/chain/search/music.py b/app/chain/search/music.py index e34479f04..a3a9dead5 100644 --- a/app/chain/search/music.py +++ b/app/chain/search/music.py @@ -110,7 +110,7 @@ class SearchMusicOwner(_SearchOwnerBase): 保留严格音乐候选筛选入口;底层匹配消费资源解析结果。 """ return [torrent for torrent in torrents or [] if match_music_resource( - mediainfo, torrent.title, torrent.description, torrent.category, + mediainfo, torrent.title or "", torrent.description, torrent.category, ).status == "exact"] def _process_music( diff --git a/app/chain/search/plan.py b/app/chain/search/plan.py index bc0276bf3..199808807 100644 --- a/app/chain/search/plan.py +++ b/app/chain/search/plan.py @@ -70,7 +70,7 @@ class SearchPlanOwner(_SearchOwnerBase): 只规范化影视输入的标题和季号,音乐保留其实体字段及原始名称。 """ if not isinstance(mediainfo, MusicInfo) and not mediainfo.tmdb_id: - meta = MetaInfo(title=mediainfo.title) + meta = MetaInfo(title=mediainfo.title or "") mediainfo.title = meta.name if meta.begin_season is not None: mediainfo.season = meta.begin_season diff --git a/app/chain/search/recommend.py b/app/chain/search/recommend.py index b7dc4457d..7ed45d38e 100644 --- a/app/chain/search/recommend.py +++ b/app/chain/search/recommend.py @@ -174,9 +174,8 @@ class SearchRecommendOwner(_SearchOwnerBase): AI 搜索推荐的任务协调、代际隔离与结果发布。 """ - # 组合契约以可调用成员声明此能力,实际门面对外暴露只读属性。 @property - def is_ai_recommend_enabled(self) -> bool: # type: ignore[override] + def is_ai_recommend_enabled(self) -> bool: """ 检查AI推荐功能是否已启用。 """ @@ -364,6 +363,9 @@ class SearchRecommendOwner(_SearchOwnerBase): full_output = [""] def on_output(text: str) -> None: + """ + 保存推荐 Agent 最新输出,供任务完成后发布最终结果。 + """ full_output[0] = text manager = get_running_agent_manager() diff --git a/app/chain/search/result.py b/app/chain/search/result.py index 7d9d84d9a..e88e7fefc 100644 --- a/app/chain/search/result.py +++ b/app/chain/search/result.py @@ -149,7 +149,7 @@ def _torrent_meta(torrent: TorrentInfo, custom_words: List[str], mediainfo: Medi 解析一条资源的元数据,并记录识别词改写结果。 """ meta = MetaInfo( - title=torrent.title, + title=torrent.title or "", subtitle=torrent.description, custom_words=custom_words, mtype=mediainfo.type, @@ -163,7 +163,7 @@ def _disambiguation_key(meta: MetaBase) -> Tuple[str, str, str]: """ 构造同名候选识别缓存键。 """ - return meta.cn_name or "", meta.en_name or "", meta.year or "" + return meta.cn_name or "", meta.en_name or "", str(meta.year or "") def _same_work_matched( @@ -237,7 +237,7 @@ def _match_torrents( """ 按输入顺序匹配资源,并复用同名候选的识别结果。 """ - logger.info(f"开始匹配结果 类型:{mediainfo.type.value},标题:{mediainfo.title},别名:{mediainfo.names}") + logger.info(f"开始匹配结果 类型:{(mediainfo.type or MediaType.UNKNOWN).value},标题:{mediainfo.title},别名:{mediainfo.names}") progress.update(value=51, text=f"开始匹配,总 {len(torrents)} 个资源 ...") matches: List[MatchedTorrent] = [] cache: DisambiguationCache = {} @@ -394,7 +394,7 @@ class SearchResultOwner(_SearchOwnerBase): evidence.append(SearchResourceEvidence(key, matches, latest)) if record["source"]: meta = stored_meta(record) - meta.type = mediainfo.type + meta.type = mediainfo.type or MediaType.UNKNOWN contexts[key] = _build_contexts([MatchedTorrent(torrent, meta, record["source"])], mediainfo)[0] # 临时识别失败下一页允许重试;成功消歧在本轮复用。 for ambiguity_key in [entry for entry, value in disambiguation.items() if value is None]: @@ -410,13 +410,23 @@ class SearchResultOwner(_SearchOwnerBase): 就绪目标才应用下载规则,复用已识别上下文而不再次识别整个候选池。 """ if season_episodes: - contexts = [context for context in contexts if context.meta_info is not None and TorrentHelper.match_season_episodes( - torrent=context.torrent_info, meta=context.meta_info, season_episodes=season_episodes)] - filtered = _filter_torrents(owner=self, torrents=[context.torrent_info for context in contexts], + contexts = [ + context + for context in contexts + if context.torrent_info is not None + and context.meta_info is not None + and TorrentHelper.match_season_episodes( + torrent=context.torrent_info, + meta=context.meta_info, + season_episodes=season_episodes, + ) + ] + valid_contexts = [context for context in contexts if context.torrent_info is not None] + filtered = _filter_torrents(owner=self, torrents=[context.torrent_info for context in valid_contexts if context.torrent_info is not None], mediainfo=mediainfo, rule_groups=rule_groups, filter_params=filter_params, progress=ProgressHelper(ProgressKey.Search), diagnostics=Counter()) accepted = {id(torrent) for torrent in filtered} - result = [context for context in contexts if id(context.torrent_info) in accepted] + result = [context for context in valid_contexts if context.torrent_info is not None and id(context.torrent_info) in accepted] if candidate_filter: result = candidate_filter(result) return TorrentHelper.sort_torrents(result) @@ -502,7 +512,7 @@ class SearchResultOwner(_SearchOwnerBase): """ return list( { - f"{item.torrent_info.site_name}_{item.torrent_info.title}_{item.torrent_info.description}": item + f"{item.torrent_info.site_name if item.torrent_info else ''}_{item.torrent_info.title if item.torrent_info else ''}_{item.torrent_info.description if item.torrent_info else ''}": item for item in _torrents }.values() ) diff --git a/app/chain/search/scan.py b/app/chain/search/scan.py index 2175a1c08..abe28514b 100644 --- a/app/chain/search/scan.py +++ b/app/chain/search/scan.py @@ -532,10 +532,12 @@ class SearchScan: if not torrent: return None meta = stored_meta(self.identity[key]) - meta.type = self.media.type + meta.type = self.media.type or MediaType.UNKNOWN context = Context(torrent_info=torrent, meta_info=meta, media_info=self.media, resource_source="search", media_info_is_target=True, match_status="exact") self.live[key] = context + if context.torrent_info is None: + return None context.torrent_info.search_resource_id = key return context @@ -605,6 +607,8 @@ class SearchScan: custom_words=self.plan.custom_words or [], cache=self.identity, disambiguation=self.disambiguation) for resource_id, context in contexts.items(): if resource_id in self.candidates: + if context.torrent_info is None: + continue context.torrent_info.search_resource_id = resource_id self.live[resource_id] = context self.candidates[resource_id]["torrent"] = torrent_snapshot(context.torrent_info) @@ -631,11 +635,15 @@ class SearchScan: batch: list[Context] = [] goals: set[str] = set() for context in contexts: + if context.torrent_info is None: + continue key = cast(str, context.torrent_info.search_resource_id) # 前面的恢复重取原页时,可能已移除同页其他已失效的候选。 if key not in self.candidates: continue context = self.live.get(key, context) + if context.torrent_info is None: + continue if not context.torrent_info.enclosure: if batch: break @@ -658,11 +666,19 @@ class SearchScan: """ contexts, keys = self._contexts(ready) contexts = self._filter(contexts) - accepted = {cast(str, context.torrent_info.search_resource_id) for context in contexts} + accepted = { + cast(str, context.torrent_info.search_resource_id) + for context in contexts + if context.torrent_info is not None + } self.attempted.update(keys - accepted) batch, goals = self._prepared_batch(contexts, ready) settled = submit(batch, goals) if batch else set() - self.attempted.update(cast(str, context.torrent_info.search_resource_id) for context in batch) + self.attempted.update( + cast(str, context.torrent_info.search_resource_id) + for context in batch + if context.torrent_info is not None + ) self.submitted.update(settled) self.collection.settle(settled) cached = {target for key, record in self.identity.items() if key in self.candidates and key not in self.attempted diff --git a/app/chain/search/site.py b/app/chain/search/site.py index 43213c7bf..717c5051d 100644 --- a/app/chain/search/site.py +++ b/app/chain/search/site.py @@ -23,7 +23,7 @@ class SearchSiteOwner(_SearchOwnerBase): """ if not event: return - event_data = event.event_data or {} + event_data = event.event_data if isinstance(event.event_data, dict) else {} site_id = event_data.get("site_id") if not site_id: return diff --git a/app/chain/search/subtitle.py b/app/chain/search/subtitle.py index 9cb97d116..a324f9034 100644 --- a/app/chain/search/subtitle.py +++ b/app/chain/search/subtitle.py @@ -370,7 +370,7 @@ class SearchSubtitleOwner(_SearchOwnerBase): """ mediainfo = self._copy_media_input(mediainfo) if not mediainfo.tmdb_id: - meta = MetaInfo(title=mediainfo.title) + meta = MetaInfo(title=mediainfo.title or "") mediainfo.title = meta.name mediainfo.season = cast(int, meta.begin_season) logger.info(f"开始精确搜索字幕,关键词:{mediainfo.title} ...") @@ -456,7 +456,7 @@ class SearchSubtitleOwner(_SearchOwnerBase): """ mediainfo = self._copy_media_input(mediainfo) if not mediainfo.tmdb_id: - meta = MetaInfo(title=mediainfo.title) + meta = MetaInfo(title=mediainfo.title or "") mediainfo.title = meta.name mediainfo.season = cast(int, meta.begin_season) logger.info(f"开始渐进式精确搜索字幕,关键词:{mediainfo.title} ...") diff --git a/app/chain/search/title.py b/app/chain/search/title.py index 8c7a88abf..984977658 100644 --- a/app/chain/search/title.py +++ b/app/chain/search/title.py @@ -458,7 +458,7 @@ class SearchTitleOwner(_SearchOwnerBase): """ 根据限定媒体类型构造模糊搜索结果的上下文元数据。 """ - return MetaInfo(title=torrent.title, subtitle=torrent.description, mtype=mtype) + return MetaInfo(title=torrent.title or "", subtitle=torrent.description, mtype=mtype) def _filter_title_search_torrents( self, torrents: List[TorrentInfo], rule_groups: Optional[List[str]] = None diff --git a/app/chain/site.py b/app/chain/site.py index c922c7086..bcd3b70a0 100644 --- a/app/chain/site.py +++ b/app/chain/site.py @@ -18,6 +18,7 @@ from app.application.messaging.site import SiteInteractionHandler from app.application.rss import RssHelper from app.application.security.cookie import CookieHelper from app.application.site.contract import ( + SiteCookieLoginSource, SiteMutation, SiteRepository, SiteSnapshot, @@ -312,7 +313,7 @@ class SiteChain(InteractionChainMixin, ChainBase): "TNodeSpider": self.__zhuque_test, } - def refresh_userdata(self, site: dict = None) -> Optional[SiteUserData]: + def refresh_userdata(self, site: Optional[dict[str, Any]] = None) -> Optional[SiteUserData]: """ 刷新站点的用户数据 @@ -344,7 +345,7 @@ class SiteChain(InteractionChainMixin, ChainBase): )) return userdata - def _post_site_messages(self, site: dict, userdata: SiteUserData) -> None: + def _post_site_messages(self, site: dict[str, Any], userdata: SiteUserData) -> None: """ 发送站点未读消息,并按解析器提供的来源标识做持久化去重。 @@ -386,7 +387,7 @@ class SiteChain(InteractionChainMixin, ChainBase): :param progress_callback: 定时服务进度更新回调 """ any_site_updated = False - result = {} + result: dict[str, SiteUserData] = {} sites = [site for site in SitesHelper().get_indexers() if site.get("is_active")] total_num = len(sites) if progress_callback: @@ -411,7 +412,9 @@ class SiteChain(InteractionChainMixin, ChainBase): userdata = self.refresh_userdata(site) if userdata and userdata.userid: any_site_updated = True - result[site.get("name")] = userdata + site_name = str(site.get("name") or site.get("domain") or "") + if site_name: + result[site_name] = userdata if progress_callback: progress_callback( value=index / total_num * 100 if total_num else 100, @@ -839,7 +842,7 @@ class SiteChain(InteractionChainMixin, ChainBase): self, domain: str, cookie: str, - indexer: Optional[dict], + indexer: Optional[dict[str, Any]], site_info: Optional[SiteSnapshot], repository: SiteRepository, rsshelper: RssHelper, @@ -913,19 +916,22 @@ class SiteChain(InteractionChainMixin, ChainBase): ) @staticmethod - def _cookiecloud_indexer_domain(indexer: dict, sub_domain: str) -> str: + def _cookiecloud_indexer_domain(indexer: dict[str, Any], sub_domain: str) -> str: """ 根据索引器主域名和扩展域名解析实际访问地址。 """ - if site_rules.extract_domain(indexer.get("domain")) == sub_domain: - return indexer.get("domain") - for ext_domain in indexer.get("ext_domains", []): - if site_rules.extract_domain(ext_domain) == sub_domain: - return ext_domain + indexer_domain = indexer.get("domain") + if isinstance(indexer_domain, str) and site_rules.extract_domain(indexer_domain) == sub_domain: + return indexer_domain + ext_domains = indexer.get("ext_domains") + if isinstance(ext_domains, list): + for ext_domain in ext_domains: + if isinstance(ext_domain, str) and site_rules.extract_domain(ext_domain) == sub_domain: + return ext_domain return sub_domain def _cookiecloud_connect( - self, domain_url: str, cookie: str, indexer: dict + self, domain_url: str, cookie: str, indexer: dict[str, Any] ) -> Tuple[bool, Optional[_SiteConnectionResponse]]: """ 连接新站点,必要时通过已配置代理重试。 @@ -972,7 +978,7 @@ class SiteChain(InteractionChainMixin, ChainBase): """ if not event: return - event_data = event.event_data or {} + event_data = event.event_data if isinstance(event.event_data, dict) else {} domain = event_data.get("domain") if not domain: return @@ -1017,7 +1023,7 @@ class SiteChain(InteractionChainMixin, ChainBase): """ if not event: return - event_data = event.event_data or {} + event_data = event.event_data if isinstance(event.event_data, dict) else {} domain = event_data.get("domain") if not domain: return @@ -1041,7 +1047,7 @@ class SiteChain(InteractionChainMixin, ChainBase): """ if not event: return - event_data = event.event_data or {} + event_data = event.event_data if isinstance(event.event_data, dict) else {} domain = event_data.get("domain") if not domain: return @@ -1216,7 +1222,7 @@ class SiteChain(InteractionChainMixin, ChainBase): repository.update(site_id, SiteMutation({"is_active": True})) self.remote_list(channel=channel, userid=userid, source=source) - def update_cookie(self, site_info: SiteSnapshot, + def update_cookie(self, site_info: SiteCookieLoginSource, username: str, password: str, two_step_code: Optional[str] = None) -> Tuple[bool, str]: """ 根据用户名密码更新站点Cookie @@ -1227,8 +1233,12 @@ class SiteChain(InteractionChainMixin, ChainBase): :param two_step_code: 二步验证码或密钥 :return: (是否成功, 错误信息) """ + site_id = site_info.id + site_url = site_info.url + if site_id is None or not site_url: + return False, "站点配置不完整" result = CookieHelper().get_site_cookie_ua( - url=site_info.url, + url=site_url, username=username, password=password, two_step_code=two_step_code, @@ -1239,7 +1249,7 @@ class SiteChain(InteractionChainMixin, ChainBase): cookie, ua, msg = result if not cookie: return False, msg - self.site_repository.update(site_info.id, SiteMutation({ + self.site_repository.update(site_id, SiteMutation({ "cookie": cookie, "ua": ua })) diff --git a/app/chain/storage.py b/app/chain/storage.py index 1240409ff..880c472ec 100644 --- a/app/chain/storage.py +++ b/app/chain/storage.py @@ -3,12 +3,12 @@ """ from pathlib import Path -from typing import Any, Dict, List, Optional, cast +from typing import Any, Dict, List, Optional, Sequence, cast from app.application.directory import DirectoryHelper from app.chain.base import ChainBase from app.runtime.log import logger -from app.schemas.workflow import FileItem as _SchemaFileItem +from app.schemas.file import FileItem as _SchemaFileItem class StorageChain(ChainBase): @@ -28,41 +28,41 @@ class StorageChain(ChainBase): :param params: 表单与动作参数,原样透传给模块 :return: 统一结构 {"success": bool, "message": ..., "data": ...} """ - result = self.run_module("storage_manage", storage=storage, action=action, **params) + result = cast(Dict[str, Any], self.run_module("storage_manage", storage=storage, action=action, **params)) return result or {"success": False, "message": "该存储类型未启用或不支持此管理动作"} def list_files(self, fileitem: _SchemaFileItem, recursion: bool = False) -> Optional[List[_SchemaFileItem]]: """ 查询当前目录下所有目录和文件 """ - return self.run_module("list_files", fileitem=fileitem, recursion=recursion) + return cast(Optional[List[_SchemaFileItem]], self.run_module("list_files", fileitem=fileitem, recursion=recursion)) - def any_files(self, fileitem: _SchemaFileItem, extensions: list = None) -> Optional[bool]: + def any_files(self, fileitem: _SchemaFileItem, extensions: Optional[Sequence[str]] = None) -> Optional[bool]: """ 查询当前目录下是否存在指定扩展名任意文件 """ - return self.run_module("any_files", fileitem=fileitem, extensions=extensions) + return cast(Optional[bool], self.run_module("any_files", fileitem=fileitem, extensions=extensions)) def create_folder(self, fileitem: _SchemaFileItem, name: str) -> Optional[_SchemaFileItem]: """ 创建目录 """ - return self.run_module("create_folder", fileitem=fileitem, name=name) + return cast(Optional[_SchemaFileItem], self.run_module("create_folder", fileitem=fileitem, name=name)) def get_folder(self, storage: str, path: Path) -> Optional[_SchemaFileItem]: """ 获取目录,不存在则递归创建 """ - return self.run_module("get_folder", storage=storage, path=path) + return cast(Optional[_SchemaFileItem], self.run_module("get_folder", storage=storage, path=path)) - def download_file(self, fileitem: _SchemaFileItem, path: Path = None) -> Optional[Path]: + def download_file(self, fileitem: _SchemaFileItem, path: Optional[Path] = None) -> Optional[Path]: """ 下载文件 :param fileitem: 文件项 :param path: 本地保存路径 """ - return self.run_module("download_file", fileitem=fileitem, path=path) + return cast(Optional[Path], self.run_module("download_file", fileitem=fileitem, path=path)) def upload_file(self, fileitem: _SchemaFileItem, path: Path, new_name: Optional[str] = None) -> Optional[_SchemaFileItem]: @@ -73,19 +73,19 @@ class StorageChain(ChainBase): :param path: 本地文件路径 :param new_name: 新文件名 """ - return self.run_module("upload_file", fileitem=fileitem, path=path, new_name=new_name) + return cast(Optional[_SchemaFileItem], self.run_module("upload_file", fileitem=fileitem, path=path, new_name=new_name)) def delete_file(self, fileitem: _SchemaFileItem) -> Optional[bool]: """ 删除文件或目录 """ - return self.run_module("delete_file", fileitem=fileitem) + return cast(Optional[bool], self.run_module("delete_file", fileitem=fileitem)) def rename_file(self, fileitem: _SchemaFileItem, name: str) -> Optional[bool]: """ 重命名文件或目录 """ - return self.run_module("rename_file", fileitem=fileitem, name=name) + return cast(Optional[bool], self.run_module("rename_file", fileitem=fileitem, name=name)) def exists(self, fileitem: _SchemaFileItem) -> Optional[bool]: """ @@ -97,13 +97,15 @@ class StorageChain(ChainBase): """ 查询目录或文件 """ + if not fileitem.storage or not fileitem.path: + return None return self.get_file_item(storage=fileitem.storage, path=Path(fileitem.path)) def get_file_item(self, storage: str, path: Path) -> Optional[_SchemaFileItem]: """ 根据路径获取文件项 """ - return self.run_module("get_file_item", storage=storage, path=path) + return cast(Optional[_SchemaFileItem], self.run_module("get_file_item", storage=storage, path=path)) def get_file_item_strict( self, @@ -126,11 +128,11 @@ class StorageChain(ChainBase): """ 获取上级目录项 """ - return self.run_module("get_parent_item", fileitem=fileitem) + return cast(Optional[_SchemaFileItem], self.run_module("get_parent_item", fileitem=fileitem)) def snapshot_storage(self, storage: str, path: Path, - last_snapshot_time: float = None, max_depth: int = 5, - previous_snapshot: Optional[Dict[str, Dict]] = None) -> Optional[Dict[str, Dict]]: + last_snapshot_time: Optional[float] = None, max_depth: int = 5, + previous_snapshot: Optional[Dict[str, Dict[str, Any]]] = None) -> Optional[Dict[str, Dict[str, Any]]]: """ 快照存储 @@ -140,15 +142,15 @@ class StorageChain(ChainBase): :param max_depth: 最大递归深度,避免过深遍历 :param previous_snapshot: 上次完整快照,用于增量对账 """ - return self.run_module("snapshot_storage", storage=storage, path=path, + return cast(Optional[Dict[str, Dict[str, Any]]], self.run_module("snapshot_storage", storage=storage, path=path, last_snapshot_time=last_snapshot_time, max_depth=max_depth, - previous_snapshot=previous_snapshot) + previous_snapshot=previous_snapshot)) def is_bluray_folder(self, fileitem: Optional[_SchemaFileItem]) -> bool: """ 检查是否蓝光目录 """ - if not fileitem or fileitem.type != "dir": + if not fileitem or fileitem.type != "dir" or not fileitem.storage or not fileitem.path: return False if self.get_file_item(storage=fileitem.storage, path=Path(fileitem.path) / "BDMV"): return True @@ -198,7 +200,7 @@ class StorageChain(ChainBase): associated_dir = DirectoryHelper().get_media_cleanup_root(fileitem) - while dir_item and len(Path(dir_item.path).parts) > 2: + while dir_item and dir_item.path and len(Path(dir_item.path).parts) > 2: # 共享目录边界及其祖先必须保留,边界下的影片目录仍可清理。 if associated_dir and associated_dir.is_relative_to(Path(dir_item.path)): logger.debug(f"【{dir_item.storage}】{dir_item.path} 位于资源或媒体库目录结构中,不删除") diff --git a/app/chain/subscribe/completion.py b/app/chain/subscribe/completion.py index 82127c0df..c2446c6ec 100644 --- a/app/chain/subscribe/completion.py +++ b/app/chain/subscribe/completion.py @@ -4,7 +4,7 @@ from collections.abc import Mapping from datetime import datetime -from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from typing import Any, Callable, Dict, List, Optional, Tuple, Union, cast from app.application.classification.reference import effective_classification_snapshot from app.application.subscription.contract import ( @@ -51,7 +51,14 @@ class SubscribeCompletionOwner(_SubscribeOwnerBase): """ if not downloads: return subscribe - priority = max([item.torrent_info.pri_order for item in downloads]) + priorities = [ + item.torrent_info.pri_order + for item in downloads + if item.torrent_info is not None + ] + if not priorities: + return subscribe + priority = max(priorities) now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") if subscribe.type != MediaType.MOVIE.value: @@ -241,6 +248,8 @@ class SubscribeCompletionOwner(_SubscribeOwnerBase): for context in downloads: meta = context.meta_info mediainfo = context.media_info + if meta is None or not isinstance(mediainfo, (MediaInfo, MusicInfo)): + continue subscribe_source, subscribe_media_id = resolve_media_identity(media=subscribe) media_source, media_id = resolve_media_identity(media=mediainfo) if ( @@ -351,7 +360,7 @@ class SubscribeCompletionOwner(_SubscribeOwnerBase): update_data: Dict[str, Any] = {} for download in downloads: - media = download.media_info + media = cast(Union[MediaInfo, MusicInfo], download.media_info) subscribe_identity = resolve_media_identity(media=subscribe) media_identity = resolve_media_identity(media=media) if subscribe_identity != media_identity: @@ -388,7 +397,7 @@ class SubscribeCompletionOwner(_SubscribeOwnerBase): if not valid_episodes: continue - priority = download.torrent_info.pri_order + priority = cast(Any, download.torrent_info).pri_order if ( self._SubscribeChain__is_full_best_version_enabled(subscribe) and download.confirmed_full_coverage @@ -455,7 +464,7 @@ class SubscribeCompletionOwner(_SubscribeOwnerBase): # 用订阅限定的总集数和起始集裁剪缺集范围。 if not no_exists or not no_exists.get(mediakey): return False, no_exists - no_exists_item = no_exists.get(mediakey) + no_exists_item = no_exists[mediakey] if total_episode or start_episode: logger.info(f"订阅 {subscribe_name} 设定的开始集数:{start_episode}、总集数:{total_episode}") no_exist_season = no_exists_item.get(begin_season) @@ -482,7 +491,9 @@ class SubscribeCompletionOwner(_SubscribeOwnerBase): start_episode = start if not total_episode: total_episode = total - new_episodes = list(range(max(start_episode, start), total_episode + 1)) + effective_start = start_episode if start_episode is not None else (start or 1) + effective_total = total_episode if total_episode is not None else (total or effective_start - 1) + new_episodes = list(range(max(effective_start, start or effective_start), effective_total + 1)) episodes = list(set(episode_list).intersection(set(new_episodes))) # 交集为空时,说明订阅的剧集均已入库 if not episodes: @@ -503,7 +514,7 @@ class SubscribeCompletionOwner(_SubscribeOwnerBase): total = no_exist_season.total_episode start = no_exist_season.start_episode if not episode_list: - episode_list = list(range(start, total + 1)) + episode_list = list(range(start or 1, (total or (start or 1) - 1) + 1)) episodes = list(set(episode_list).difference(set(downloaded_episodes))) # 如果存在已下载剧集,则差集为空时,说明所有均已存在 if not episodes: @@ -517,7 +528,9 @@ class SubscribeCompletionOwner(_SubscribeOwnerBase): ) else: start = start_episode or 1 - episodes = list(set(range(start, total_episode + 1)).difference(set(downloaded_episodes))) + effective_start = start or 1 + effective_total = total_episode or (effective_start - 1) + episodes = list(set(range(effective_start, effective_total + 1)).difference(set(downloaded_episodes))) # 如果存在已下载剧集,则差集为空时,说明所有均已存在 if not episodes: return True, {} diff --git a/app/chain/subscribe/create.py b/app/chain/subscribe/create.py index 4e8a6af6a..76d132e3c 100644 --- a/app/chain/subscribe/create.py +++ b/app/chain/subscribe/create.py @@ -207,7 +207,7 @@ class SubscribeCreateOwner(_SubscribeOwnerBase): mediainfo = context.mediainfo if context.mtype == MediaType.MUSIC or not mediainfo or mediainfo.media_source == MediaSource.TMDB: return - meta = MetaInfo(mediainfo.title) + meta = MetaInfo(mediainfo.title or "") mediainfo.title = meta.name if context.season is None: context.season = meta.begin_season @@ -403,6 +403,8 @@ class SubscribeCreateOwner(_SubscribeOwnerBase): 同步补齐电视剧季集信息,并保持外部集数刷新只能扩展创建目标。 """ mediainfo = context.mediainfo + if mediainfo is None: + return "未识别到媒体信息" if mediainfo.type != MediaType.TV: context.season = None return None @@ -417,6 +419,8 @@ class SubscribeCreateOwner(_SubscribeOwnerBase): cache=False, ) context.mediainfo = mediainfo + if mediainfo is None: + return "未识别到媒体信息" error = self._SubscribeChain__validate_subscribe_seasons(context) if error: return error @@ -447,6 +451,8 @@ class SubscribeCreateOwner(_SubscribeOwnerBase): 异步补齐电视剧季集信息,并复用同步入口的结果校验和字段写入规则。 """ mediainfo = context.mediainfo + if mediainfo is None: + return "未识别到媒体信息" if mediainfo.type != MediaType.TV: context.season = None return None @@ -461,6 +467,8 @@ class SubscribeCreateOwner(_SubscribeOwnerBase): cache=False, ) context.mediainfo = mediainfo + if mediainfo is None: + return "未识别到媒体信息" error = self._SubscribeChain__validate_subscribe_seasons(context) if error: return error @@ -525,8 +533,11 @@ class SubscribeCreateOwner(_SubscribeOwnerBase): """ 同步补图并写入规范媒体身份和订阅默认配置。 """ - if context.mediainfo.type != MediaType.MUSIC: - self.obtain_images(mediainfo=context.mediainfo) + mediainfo = context.mediainfo + if mediainfo is None: + return + if mediainfo.type != MediaType.MUSIC: + self.obtain_images(mediainfo=mediainfo) self._SubscribeChain__apply_subscribe_create_defaults(context) async def _SubscribeChain__async_finalize_subscribe_create_context( @@ -536,22 +547,29 @@ class SubscribeCreateOwner(_SubscribeOwnerBase): """ 异步补图并写入规范媒体身份和订阅默认配置。 """ - if context.mediainfo.type != MediaType.MUSIC: - await self.async_obtain_images(mediainfo=context.mediainfo) + mediainfo = context.mediainfo + if mediainfo is None: + return + if mediainfo.type != MediaType.MUSIC: + await self.async_obtain_images(mediainfo=mediainfo) self._SubscribeChain__apply_subscribe_create_defaults(context) def _SubscribeChain__apply_subscribe_create_defaults(self, context: _SubscribeCreateContext) -> None: """ 以最终识别结果覆盖身份字段,并补齐当前媒体类型的默认订阅参数。 """ - context.media_source, context.media_id = resolve_media_identity(media=context.mediainfo) + mediainfo = context.mediainfo + if mediainfo is None: + return + context.media_source, context.media_id = resolve_media_identity(media=mediainfo) context.options.update( { "media_source": context.media_source, "media_id": context.media_id, } ) - context.options.update(self._SubscribeChain__get_default_kwargs(context.mediainfo.type, **context.options)) + if mediainfo.type is not None: + context.options.update(self._SubscribeChain__get_default_kwargs(mediainfo.type, **context.options)) @staticmethod def _SubscribeChain__subscribe_post_commit_context( @@ -561,6 +579,8 @@ class SubscribeCreateOwner(_SubscribeOwnerBase): """ 从创建阶段状态冻结提交后副作用需要的最小快照。 """ + if context.mediainfo is None: + raise ValueError("订阅提交上下文缺少媒体信息") return _SubscribePostCommitContext( title=context.title, year=context.year, @@ -581,13 +601,16 @@ class SubscribeCreateOwner(_SubscribeOwnerBase): """ 同步提交订阅,并在提交成功后按原顺序执行消息、事件和统计。 """ + if context.mediainfo is None: + return None, "未识别到媒体信息" + mediainfo = context.mediainfo post_commit_context = self._SubscribeChain__subscribe_post_commit_context( context, self._SubscribeChain__build_subscribe_notification(context), ) try: sid, err_msg = add_subscribe( - mediainfo=context.mediainfo, + mediainfo=mediainfo, subscribe_oper=self.subscription_repository, season=context.season, username=context.username, @@ -613,13 +636,16 @@ class SubscribeCreateOwner(_SubscribeOwnerBase): """ 异步提交订阅,并在提交成功后按原顺序执行消息、事件和统计。 """ + if context.mediainfo is None: + return None, "未识别到媒体信息" + mediainfo = context.mediainfo post_commit_context = self._SubscribeChain__subscribe_post_commit_context( context, self._SubscribeChain__build_subscribe_notification(context), ) try: sid, err_msg = await async_add_subscribe( - mediainfo=context.mediainfo, + mediainfo=mediainfo, subscribe_oper=self.subscription_repository, season=context.season, username=context.username, @@ -678,11 +704,13 @@ class SubscribeCreateOwner(_SubscribeOwnerBase): media_id, kwargs, ) - if error: - return None, error + if error or context is None: + return None, error or "订阅准备失败" error = self._SubscribeChain__recognize_subscribe_media(context) if error: return None, error + if context.mediainfo is None: + return None, "未识别到媒体信息" error = self._SubscribeChain__prepare_subscribe_episodes(context) if error: return None, error @@ -726,7 +754,7 @@ class SubscribeCreateOwner(_SubscribeOwnerBase): media_id, kwargs, ) - if error or context is None: + if error or context is None or context.mediainfo is None: return None, error or "订阅准备失败" return await self._SubscribeChain__async_persist_subscribe_create(context) diff --git a/app/chain/subscribe/match.py b/app/chain/subscribe/match.py index 863697458..acce554eb 100644 --- a/app/chain/subscribe/match.py +++ b/app/chain/subscribe/match.py @@ -4,7 +4,7 @@ import copy from datetime import datetime -from typing import Any, Callable, Dict, List, Literal, Optional +from typing import Any, Callable, Dict, List, Literal, Optional, cast from app.application.configuration import get_configured_system_config from app.application.subscription.activity import current_activity_run, subscription_activity_scope @@ -60,7 +60,7 @@ def _record_filter_rejection(helper: TorrentHelper, context: Context, params: Di 由筛选组件 提供未满足条件的解释,兼容旧插件和测试替身。 """ explain = getattr(helper, "filter_torrent_reason", None) - if explain: + if explain and context.torrent_info: _record_match_resource(context, "rejected", explain(context.torrent_info, params)) @@ -330,6 +330,9 @@ def _prepare_match_torrents( # 音乐 RSS 使用订阅目标做实体匹配,不应进入影视识别并累计失败次数。 processed_torrents[domain].append(context) continue + if context.torrent_info is None or context.meta_info is None: + processed_torrents[domain].append(context) + continue if ( not context.media_info or not resolve_media_identity(media=context.media_info)[1] ) and context.media_recognize_fail_count < 3: @@ -398,7 +401,7 @@ class SubscribeMatchOwner(_SubscribeOwnerBase): continue with subscription_activity_scope(getattr(self, "subscription_activity", None), subscribe, "refresh", source) as activity: activity.counts["sites"] = len(site_facts) - activity.details["sites"] = list(site_facts) + activity.details["sites"] = cast(Any, list(site_facts)) activity.details["reason"] = "本轮没有可供此订阅匹配的共享候选" logger.warn("当前没有可检查的订阅资源") if progress_callback: @@ -590,7 +593,7 @@ class SubscribeMatchOwner(_SubscribeOwnerBase): with subscription_activity_scope(getattr(self, "subscription_activity", None), subscribe, "refresh", execution_context.source) as activity: activity.counts["discovered"] = execution_context.refresh_candidates or sum(len(items) for items in processed_torrents.values()) activity.counts["sites"] = len(execution_context.refresh_sites) or len(processed_torrents) - activity.details["sites"] = list(execution_context.refresh_sites) + activity.details["sites"] = cast(Any, list(execution_context.refresh_sites)) activity.details["candidate_scope"] = "shared" result = self._execute_match( subscribe=subscribe, processed_torrents=processed_torrents, @@ -645,7 +648,7 @@ class SubscribeMatchOwner(_SubscribeOwnerBase): return "completed" # 匹配前聚合多来源别名;清理大字段时保留匹配所需的标题合集。 - mediainfo = MediaChain().supplement_media_info(mediainfo) or mediainfo + mediainfo = cast(MediaInfo, MediaChain().supplement_media_info(mediainfo) or mediainfo) auxiliary_names = list(getattr(mediainfo, "names", None) or []) mediainfo.clear() mediainfo.names = auxiliary_names @@ -670,9 +673,9 @@ class SubscribeMatchOwner(_SubscribeOwnerBase): if runtime_stop_state.is_system_stopped or execution_context.should_stop(): break _context = copy.copy(context) - torrent_meta = _context.meta_info + torrent_meta = cast(MetaBase, _context.meta_info) torrent_mediainfo = _context.media_info - torrent_info = _context.torrent_info + torrent_info = cast(TorrentInfo, _context.torrent_info) # 不在订阅站点范围的不处理 if sub_sites and torrent_info.site not in sub_sites: @@ -683,14 +686,14 @@ class SubscribeMatchOwner(_SubscribeOwnerBase): if custom_words_list: # 从原始标题重新应用识别词,避免对已改写文本重复替换。 _, apply_words = wordsmatcher.prepare( - torrent_meta.org_string, custom_words=custom_words_list + cast(str, torrent_meta.org_string), custom_words=custom_words_list ) if apply_words: logger.debug( f"{torrent_info.site_name} - {torrent_info.title} 因订阅存在自定义识别词,重新识别元数据..." ) torrent_meta = MetaInfo( - title=torrent_info.title, + title=cast(str, torrent_info.title), subtitle=torrent_info.description, custom_words=custom_words_list, ) @@ -736,7 +739,7 @@ class SubscribeMatchOwner(_SubscribeOwnerBase): continue torrent_mediainfo = self._SubscribeChain__reconcile_candidate_media( target_mediainfo=mediainfo, - candidate_mediainfo=torrent_mediainfo, + candidate_mediainfo=cast(MediaInfo, torrent_mediainfo), torrent_meta=torrent_meta, torrent_info=torrent_info, context=_context, diff --git a/app/chain/subscribe/notify.py b/app/chain/subscribe/notify.py index b8268335b..3bc3a6797 100644 --- a/app/chain/subscribe/notify.py +++ b/app/chain/subscribe/notify.py @@ -4,7 +4,7 @@ import threading from collections.abc import Mapping -from typing import Any, List, Optional, Protocol, cast +from typing import Any, List, Optional, Protocol from app.application.configuration import ( get_chain_runtime_config_snapshot, @@ -270,7 +270,7 @@ class SubscribeNotificationOwner(_SubscribeOwnerBase): ) or message ) - return cast(dict[str, Any], rendered.model_dump(mode="json")) + return rendered.model_dump(mode="json") def _SubscribeChain__notify_subscribe_create_failure( self, diff --git a/app/chain/subscribe/policy.py b/app/chain/subscribe/policy.py index c892350be..754f96929 100644 --- a/app/chain/subscribe/policy.py +++ b/app/chain/subscribe/policy.py @@ -18,6 +18,9 @@ from app.chain.subscribe.contract import _SubscribeOwnerBase from app.domain.context import ( Context, ) +from app.domain.context import ( + TorrentInfo as _TorrentInfo, +) from app.domain.meta.metabase import MetaBase from app.runtime.log import logger from app.schemas.mediaserver import NotExistMediaInfo as _SchemaNotExistMediaInfo @@ -374,6 +377,9 @@ class SubscribePolicyOwner(_SubscribePriorityPolicyOwner): """ TV 分集洗版及尚无已有剧集的普通订阅先尝试完整包,失败后回退到按集下载。 """ + contexts = [context for context in contexts if context.torrent_info is not None] + if not contexts: + return [], no_exists activity = current_activity_run() governance: Optional[SubscriptionDownloadGovernance] = None repository = getattr(self, "subscription_repository", None) @@ -449,7 +455,8 @@ class SubscribePolicyOwner(_SubscribePriorityPolicyOwner): episode_priority_gate = self._SubscribeChain__get_episode_priority(subscribe) full_pack_contexts = [] for context in full_season_contexts: - candidate_priority = context.torrent_info.pri_order + # 入口已排除缺少种子信息的候选,沿用同一批上下文的资源优先级。 + candidate_priority = cast(_TorrentInfo, context.torrent_info).pri_order accepted = self._SubscribeChain__should_prefer_full_pack_for_episode_best_version( subscribe=subscribe, priority=candidate_priority, diff --git a/app/chain/subscribe/query.py b/app/chain/subscribe/query.py index d275df453..362fa1cfa 100644 --- a/app/chain/subscribe/query.py +++ b/app/chain/subscribe/query.py @@ -50,7 +50,7 @@ def _public_subscribe_view(subscribe: SubscriptionSnapshot) -> _SchemaSubscribe: payload = subscribe.to_dict() payload["completed_tracks"] = compute_subscribe_completed_tracks(subscribe) payload.pop("downloaded_tracks", None) - return _SchemaSubscribe(**payload) + return _SchemaSubscribe.model_validate(payload) class SubscribeQueryOwner(_SubscribeOwnerBase): @@ -193,14 +193,14 @@ class SubscribeQueryOwner(_SubscribeOwnerBase): None, MediaSource.TMDB, ): - meta = MetaInfo(subscribe_in.name) + meta = MetaInfo(subscribe_in.name or "") subscribe_in.name = meta.name if subscribe_in.season is None: subscribe_in.season = meta.begin_season if subscribe_in.name: title = subscribe_in.name else: - title = None + title = "" sid, message = type(self)().add( mtype=mtype, title=title, @@ -304,8 +304,9 @@ class SubscribeQueryOwner(_SubscribeOwnerBase): and str(subscribe.media_id).isdigit() and subscribe.type == MediaType.TV.value ): + season = subscribe.season if subscribe.season is not None else 1 tmdb_episodes = TmdbChain().tmdb_episodes( - tmdbid=int(subscribe.media_id), season=subscribe.season, episode_group=subscribe.episode_group + tmdbid=int(subscribe.media_id), season=season, episode_group=subscribe.episode_group ) if tmdb_episodes: for episode in tmdb_episodes: @@ -313,10 +314,11 @@ class SubscribeQueryOwner(_SubscribeOwnerBase): info.title = episode.name info.description = episode.overview info.backdrop = self.runtime_config.tmdb_image_url(episode.still_path, "w500") - episodes[episode.episode_number] = info + episodes[cast(int, episode.episode_number)] = info elif subscribe.type == MediaType.TV.value: # 根据开始结束集计算集信息 - for i in range(subscribe.start_episode or 1, subscribe.total_episode + 1): + total_episode = subscribe.total_episode or 0 + for i in range(subscribe.start_episode or 1, total_episode + 1): info = _SchemaSubscribeEpisodeInfo() info.title = f"第 {i} 集" episodes[i] = info @@ -356,9 +358,13 @@ class SubscribeQueryOwner(_SubscribeOwnerBase): continue episode_number = file_meta.begin_episode if episode_number and episodes.get(episode_number): - episodes[episode_number].download.append(download_file_info) + downloads = episodes[episode_number].download or [] + episodes[episode_number].download = downloads + downloads.append(download_file_info) else: - episodes[0].download.append(download_file_info) + downloads = episodes[0].download or [] + episodes[0].download = downloads + downloads.append(download_file_info) try: meta = build_subscribe_meta(subscribe) @@ -393,9 +399,13 @@ class SubscribeQueryOwner(_SubscribeOwnerBase): continue episode_number = file_meta.begin_episode if episode_number and episodes.get(episode_number): - episodes[episode_number].library.append(library_file_info) + library = episodes[episode_number].library or [] + episodes[episode_number].library = library + library.append(library_file_info) else: - episodes[0].library.append(library_file_info) + library = episodes[0].library or [] + episodes[0].library = library + library.append(library_file_info) self._append_subscribe_media_servers(subscribe, mediainfo, episodes) @@ -416,7 +426,7 @@ class SubscribeQueryOwner(_SubscribeOwnerBase): server_names = list(MediaServerHelper().get_services().keys()) def has_server_entry( - library_list: List[_SchemaSubscribeLibraryFileInfo], + library_list: Optional[List[_SchemaSubscribeLibraryFileInfo]], server_name: Optional[str], server_type: Optional[str], ) -> bool: @@ -457,7 +467,9 @@ class SubscribeQueryOwner(_SubscribeOwnerBase): if episode_info and not has_server_entry( episode_info.library, resolved_server, exists_media.server_type ): - episode_info.library.append( + library = episode_info.library or [] + episode_info.library = library + library.append( _SchemaSubscribeLibraryFileInfo( storage=server_storage, file_path=series_detail_url, @@ -476,7 +488,7 @@ class SubscribeQueryOwner(_SubscribeOwnerBase): server=resolved_server, item_id=exists_media.itemid, season=season_number, - query=MediaServerQueryOptions(fields=set()), + query=MediaServerQueryOptions(fields=frozenset()), ) for episode_number in exist_episodes: episode_info = episodes.get(episode_number) @@ -490,11 +502,13 @@ class SubscribeQueryOwner(_SubscribeOwnerBase): detail_url = ( mediaserver_chain.get_play_url( server=resolved_server, - item_id=episode_itemid, + item_id=cast(str, episode_itemid), ) or series_detail_url ) - episode_info.library.append( + library = episode_info.library or [] + episode_info.library = library + library.append( _SchemaSubscribeLibraryFileInfo( storage=server_storage, file_path=detail_url, diff --git a/app/chain/subscribe/reconcile.py b/app/chain/subscribe/reconcile.py index 2d1bd18f4..1dfc5fd67 100644 --- a/app/chain/subscribe/reconcile.py +++ b/app/chain/subscribe/reconcile.py @@ -20,7 +20,7 @@ class SubscribeReconciliationOwner(_SubscribeOwnerBase): """ if not event: return - event_data = event.event_data or {} + event_data = event.event_data if isinstance(event.event_data, dict) else {} site_id = event_data.get("site_id") if not site_id: return diff --git a/app/chain/subscribe/refresh.py b/app/chain/subscribe/refresh.py index 134799a85..dfe7ec742 100644 --- a/app/chain/subscribe/refresh.py +++ b/app/chain/subscribe/refresh.py @@ -134,7 +134,7 @@ class SubscribeRefreshOwner(SubscribeMetadataOwner): activity.status = "failed" activity.details["phase"] = "refresh_failed" activity.details["reason"] = "本轮站点候选刷新失败,尚未进入订阅匹配;请查看运行日志了解原因" - activity.details["sites"] = list(site_facts) + activity.details["sites"] = cast(Any, list(site_facts)) activity.counts["sites"] = len(site_facts) def check( @@ -405,6 +405,8 @@ class SubscribeRefreshOwner(SubscribeMetadataOwner): target_key = mediakey or subscribe_media_key(subscribe) target_season = subscribe.season + if target_key is None or target_season is None: + return candidate_total target_start = subscribe.start_episode or 1 snapshot = replace(subscribe, total_episode=old_total) try: @@ -510,8 +512,8 @@ class SubscribeRefreshOwner(SubscribeMetadataOwner): scene=scene, ) event = eventmanager.send_event(ChainEventType.SubscribeEpisodesRefresh, event_data) - if event and event.event_data: - result: SubscribeEpisodesRefreshEventData = event.event_data + if event and isinstance(event.event_data, SubscribeEpisodesRefreshEventData): + result = event.event_data if result.updated and result.total_episode: result.total_episode = max(current_total or 0, result.total_episode) total_episode: int = result.total_episode @@ -542,8 +544,8 @@ class SubscribeRefreshOwner(SubscribeMetadataOwner): scene=scene, ) event = await eventmanager.async_send_event(ChainEventType.SubscribeEpisodesRefresh, event_data) - if event and event.event_data: - result: SubscribeEpisodesRefreshEventData = event.event_data + if event and isinstance(event.event_data, SubscribeEpisodesRefreshEventData): + result = event.event_data if result.updated and result.total_episode: result.total_episode = max(current_total or 0, result.total_episode) total_episode: int = result.total_episode diff --git a/app/chain/tmdb.py b/app/chain/tmdb.py index 9b795f22e..e81878032 100644 --- a/app/chain/tmdb.py +++ b/app/chain/tmdb.py @@ -3,7 +3,7 @@ TMDB 影视发现、季集、推荐、演职人员与壁纸能力的模块调度 """ import random -from typing import List, Optional +from typing import Any, List, Optional, cast from app.chain.base import ChainBase from app.domain.context import MediaInfo @@ -44,7 +44,7 @@ class TmdbChain(ChainBase): :param page: 页码 :return: 媒体信息列表 """ - return self.run_module("tmdb_discover", mtype=mtype, + return cast(Optional[List[MediaInfo]], self.run_module("tmdb_discover", mtype=mtype, sort_by=sort_by, with_genres=with_genres, with_original_language=with_original_language, @@ -53,7 +53,7 @@ class TmdbChain(ChainBase): vote_average=vote_average, vote_count=vote_count, release_date=release_date, - page=page) + page=page)) def tmdb_trending(self, page: Optional[int] = 1) -> Optional[List[MediaInfo]]: """ @@ -62,7 +62,7 @@ class TmdbChain(ChainBase): :param page: 第几页 :return: TMDB信息列表 """ - return self.run_module("tmdb_trending", page=page) + return cast(Optional[List[MediaInfo]], self.run_module("tmdb_trending", page=page)) def tmdb_collection(self, collection_id: int) -> Optional[List[MediaInfo]]: """ @@ -70,7 +70,7 @@ class TmdbChain(ChainBase): :param collection_id: 合集ID """ - return self.run_module("tmdb_collection", collection_id=collection_id) + return cast(Optional[List[MediaInfo]], self.run_module("tmdb_collection", collection_id=collection_id)) def tmdb_seasons(self, tmdbid: int) -> List[_SchemaTmdbSeason]: """ @@ -78,7 +78,7 @@ class TmdbChain(ChainBase): :param tmdbid: TMDB 媒体标识 """ - return self.run_module("tmdb_seasons", tmdbid=tmdbid) + return cast(List[_SchemaTmdbSeason], (self.run_module("tmdb_seasons", tmdbid=tmdbid)) or []) def tmdb_group_seasons(self, group_id: str) -> List[_SchemaTmdbSeason]: """ @@ -86,7 +86,7 @@ class TmdbChain(ChainBase): :param group_id: 剧集组ID """ - return self.run_module("tmdb_group_seasons", group_id=group_id) + return cast(List[_SchemaTmdbSeason], (self.run_module("tmdb_group_seasons", group_id=group_id)) or []) def tmdb_episodes(self, tmdbid: int, season: int, episode_group: Optional[str] = None) -> List[_SchemaTmdbEpisode]: """ @@ -96,7 +96,7 @@ class TmdbChain(ChainBase): :param season: 季 :param episode_group: 剧集组 """ - return self.run_module("tmdb_episodes", tmdbid=tmdbid, season=season, episode_group=episode_group) + return cast(List[_SchemaTmdbEpisode], (self.run_module("tmdb_episodes", tmdbid=tmdbid, season=season, episode_group=episode_group)) or []) def movie_similar(self, tmdbid: int) -> Optional[List[MediaInfo]]: """ @@ -104,7 +104,7 @@ class TmdbChain(ChainBase): :param tmdbid: TMDB 媒体标识 """ - return self.run_module("tmdb_movie_similar", tmdbid=tmdbid) + return cast(Optional[List[MediaInfo]], self.run_module("tmdb_movie_similar", tmdbid=tmdbid)) def tv_similar(self, tmdbid: int) -> Optional[List[MediaInfo]]: """ @@ -112,7 +112,7 @@ class TmdbChain(ChainBase): :param tmdbid: TMDB 媒体标识 """ - return self.run_module("tmdb_tv_similar", tmdbid=tmdbid) + return cast(Optional[List[MediaInfo]], self.run_module("tmdb_tv_similar", tmdbid=tmdbid)) def movie_recommend(self, tmdbid: int) -> Optional[List[MediaInfo]]: """ @@ -120,7 +120,7 @@ class TmdbChain(ChainBase): :param tmdbid: TMDB 媒体标识 """ - return self.run_module("tmdb_movie_recommend", tmdbid=tmdbid) + return cast(Optional[List[MediaInfo]], self.run_module("tmdb_movie_recommend", tmdbid=tmdbid)) def tv_recommend(self, tmdbid: int) -> Optional[List[MediaInfo]]: """ @@ -128,7 +128,7 @@ class TmdbChain(ChainBase): :param tmdbid: TMDB 媒体标识 """ - return self.run_module("tmdb_tv_recommend", tmdbid=tmdbid) + return cast(Optional[List[MediaInfo]], self.run_module("tmdb_tv_recommend", tmdbid=tmdbid)) def movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[_SchemaMediaPerson]]: """ @@ -137,7 +137,7 @@ class TmdbChain(ChainBase): :param tmdbid: TMDB 媒体标识 :param page: 页码 """ - return self.run_module("tmdb_movie_credits", tmdbid=tmdbid, page=page) + return cast(Optional[List[_SchemaMediaPerson]], self.run_module("tmdb_movie_credits", tmdbid=tmdbid, page=page)) def tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[_SchemaMediaPerson]]: """ @@ -146,7 +146,7 @@ class TmdbChain(ChainBase): :param tmdbid: TMDB 媒体标识 :param page: 页码 """ - return self.run_module("tmdb_tv_credits", tmdbid=tmdbid, page=page) + return cast(Optional[List[_SchemaMediaPerson]], self.run_module("tmdb_tv_credits", tmdbid=tmdbid, page=page)) def person_detail(self, person_id: int) -> Optional[_SchemaMediaPerson]: """ @@ -154,7 +154,7 @@ class TmdbChain(ChainBase): :param person_id: 人物ID """ - return self.run_module("tmdb_person_detail", person_id=person_id) + return cast(Optional[_SchemaMediaPerson], self.run_module("tmdb_person_detail", person_id=person_id)) def person_credits(self, person_id: int, page: Optional[int] = 1) -> Optional[List[MediaInfo]]: """ @@ -163,7 +163,7 @@ class TmdbChain(ChainBase): :param person_id: 人物ID :param page: 页码 """ - return self.run_module("tmdb_person_credits", person_id=person_id, page=page) + return cast(Optional[List[MediaInfo]], self.run_module("tmdb_person_credits", person_id=person_id, page=page)) def get_random_wallpager(self) -> Optional[str]: """ @@ -213,7 +213,7 @@ class TmdbChain(ChainBase): :param page: 页码 :return: 媒体信息列表 """ - return await self.async_run_module("async_tmdb_discover", mtype=mtype, + return cast(Optional[List[MediaInfo]], await self.async_run_module("async_tmdb_discover", mtype=mtype, sort_by=sort_by, with_genres=with_genres, with_original_language=with_original_language, @@ -222,7 +222,7 @@ class TmdbChain(ChainBase): vote_average=vote_average, vote_count=vote_count, release_date=release_date, - page=page) + page=page)) async def async_tmdb_trending(self, page: Optional[int] = 1) -> Optional[List[MediaInfo]]: """ @@ -231,7 +231,7 @@ class TmdbChain(ChainBase): :param page: 第几页 :return: TMDB信息列表 """ - return await self.async_run_module("async_tmdb_trending", page=page) + return cast(Optional[List[MediaInfo]], await self.async_run_module("async_tmdb_trending", page=page)) async def async_tmdb_collection(self, collection_id: int) -> Optional[List[MediaInfo]]: """ @@ -239,7 +239,7 @@ class TmdbChain(ChainBase): :param collection_id: 合集ID """ - return await self.async_run_module("async_tmdb_collection", collection_id=collection_id) + return cast(Optional[List[MediaInfo]], await self.async_run_module("async_tmdb_collection", collection_id=collection_id)) async def async_tmdb_seasons(self, tmdbid: int) -> List[_SchemaTmdbSeason]: """ @@ -247,7 +247,7 @@ class TmdbChain(ChainBase): :param tmdbid: TMDB 媒体标识 """ - return await self.async_run_module("async_tmdb_seasons", tmdbid=tmdbid) + return cast(List[_SchemaTmdbSeason], (await self.async_run_module("async_tmdb_seasons", tmdbid=tmdbid)) or []) async def async_tmdb_group_seasons(self, group_id: str) -> List[_SchemaTmdbSeason]: """ @@ -255,7 +255,7 @@ class TmdbChain(ChainBase): :param group_id: 剧集组ID """ - return await self.async_run_module("async_tmdb_group_seasons", group_id=group_id) + return cast(List[_SchemaTmdbSeason], (await self.async_run_module("async_tmdb_group_seasons", group_id=group_id)) or []) async def async_tmdb_episodes(self, tmdbid: int, season: int, episode_group: Optional[str] = None) -> List[_SchemaTmdbEpisode]: @@ -266,8 +266,8 @@ class TmdbChain(ChainBase): :param season: 季 :param episode_group: 剧集组 """ - return await self.async_run_module("async_tmdb_episodes", tmdbid=tmdbid, season=season, - episode_group=episode_group) + return cast(List[_SchemaTmdbEpisode], (await self.async_run_module("async_tmdb_episodes", tmdbid=tmdbid, season=season, + episode_group=episode_group)) or []) async def async_movie_similar(self, tmdbid: int) -> Optional[List[MediaInfo]]: """ @@ -275,7 +275,7 @@ class TmdbChain(ChainBase): :param tmdbid: TMDB 媒体标识 """ - return await self.async_run_module("async_tmdb_movie_similar", tmdbid=tmdbid) + return cast(Optional[List[MediaInfo]], await self.async_run_module("async_tmdb_movie_similar", tmdbid=tmdbid)) async def async_tv_similar(self, tmdbid: int) -> Optional[List[MediaInfo]]: """ @@ -283,7 +283,7 @@ class TmdbChain(ChainBase): :param tmdbid: TMDB 媒体标识 """ - return await self.async_run_module("async_tmdb_tv_similar", tmdbid=tmdbid) + return cast(Optional[List[MediaInfo]], await self.async_run_module("async_tmdb_tv_similar", tmdbid=tmdbid)) async def async_movie_recommend(self, tmdbid: int) -> Optional[List[MediaInfo]]: """ @@ -291,7 +291,7 @@ class TmdbChain(ChainBase): :param tmdbid: TMDB 媒体标识 """ - return await self.async_run_module("async_tmdb_movie_recommend", tmdbid=tmdbid) + return cast(Optional[List[MediaInfo]], await self.async_run_module("async_tmdb_movie_recommend", tmdbid=tmdbid)) async def async_tv_recommend(self, tmdbid: int) -> Optional[List[MediaInfo]]: """ @@ -299,7 +299,7 @@ class TmdbChain(ChainBase): :param tmdbid: TMDB 媒体标识 """ - return await self.async_run_module("async_tmdb_tv_recommend", tmdbid=tmdbid) + return cast(Optional[List[MediaInfo]], await self.async_run_module("async_tmdb_tv_recommend", tmdbid=tmdbid)) async def async_movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[_SchemaMediaPerson]]: """ @@ -308,7 +308,7 @@ class TmdbChain(ChainBase): :param tmdbid: TMDB 媒体标识 :param page: 页码 """ - return await self.async_run_module("async_tmdb_movie_credits", tmdbid=tmdbid, page=page) + return cast(Optional[List[_SchemaMediaPerson]], await self.async_run_module("async_tmdb_movie_credits", tmdbid=tmdbid, page=page)) async def async_tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[_SchemaMediaPerson]]: """ @@ -317,7 +317,7 @@ class TmdbChain(ChainBase): :param tmdbid: TMDB 媒体标识 :param page: 页码 """ - return await self.async_run_module("async_tmdb_tv_credits", tmdbid=tmdbid, page=page) + return cast(Optional[List[_SchemaMediaPerson]], await self.async_run_module("async_tmdb_tv_credits", tmdbid=tmdbid, page=page)) async def async_person_detail(self, person_id: int) -> Optional[_SchemaMediaPerson]: """ @@ -325,7 +325,7 @@ class TmdbChain(ChainBase): :param person_id: 人物ID """ - return await self.async_run_module("async_tmdb_person_detail", person_id=person_id) + return cast(Optional[_SchemaMediaPerson], await self.async_run_module("async_tmdb_person_detail", person_id=person_id)) async def async_person_credits(self, person_id: int, page: Optional[int] = 1) -> Optional[List[MediaInfo]]: """ @@ -334,7 +334,7 @@ class TmdbChain(ChainBase): :param person_id: 人物ID :param page: 页码 """ - return await self.async_run_module("async_tmdb_person_credits", person_id=person_id, page=page) + return cast(Optional[List[MediaInfo]], await self.async_run_module("async_tmdb_person_credits", person_id=person_id, page=page)) async def async_get_random_wallpager(self) -> Optional[str]: """ @@ -357,19 +357,19 @@ class TmdbChain(ChainBase): return [info.backdrop_path for info in infos if info and info.backdrop_path][:num] return [] - def cache_items(self) -> list: + def cache_items(self) -> list[dict[str, Any]]: """ 查询TMDB识别缓存条目列表 """ result = self.run_module("tmdb_cache_items") - return result or [] + return cast(list[dict[str, Any]], result or []) - def delete_cache(self, cache_key: str) -> dict: + def delete_cache(self, cache_key: str) -> dict[str, Any]: """ 按缓存键删除单条TMDB识别缓存 """ result = self.run_module("tmdb_cache_delete", cache_key=cache_key) - return result or {} + return cast(dict[str, Any], result or {}) def clear_cache(self) -> None: """ diff --git a/app/chain/torrents.py b/app/chain/torrents.py index 35e870345..ace713830 100644 --- a/app/chain/torrents.py +++ b/app/chain/torrents.py @@ -4,7 +4,7 @@ import re import traceback -from typing import Callable, Dict, List, Optional, Union, cast +from typing import Any, Callable, Dict, List, Optional, Union, cast from app.application.configuration import get_configured_system_config from app.application.rss import RssHelper @@ -365,11 +365,18 @@ class TorrentsChain(ChainBase): # rss过期,尝试保留原配置生成新的rss self.__renew_rss_url(domain=domain, site=site) return [] + if not isinstance(rss_items, list): + logger.error(f'站点 {domain} RSS返回格式无效!') + return [] if not rss_items: logger.error(f'站点 {domain} 未获取到RSS数据!') return [] # 站点级媒体类型,用于给缺少分类信息的 RSS 种子补充分类 - site_media_type = MediaType.from_agent(site.get("media_type")) + media_type_value = site.get("media_type") + site_media_type = ( + MediaType.from_agent(media_type_value) + if isinstance(media_type_value, str) else None + ) ret_torrents: List[TorrentInfo] = [] try: for item in rss_items: @@ -380,13 +387,13 @@ class TorrentsChain(ChainBase): site_name=site.get("name"), site_cookie=site.get("cookie"), site_ua=site.get("ua") or self.runtime_config.user_agent, - site_proxy=site.get("proxy"), - site_order=site.get("pri"), + site_proxy=bool(site.get("proxy")), + site_order=int(site.get("pri") or 0), site_downloader=site.get("downloader"), title=item.get("title"), enclosure=item.get("enclosure"), page_url=item.get("link"), - size=item.get("size"), + size=float(item.get("size") or 0), pubdate=item["pubdate"].strftime("%Y-%m-%d %H:%M:%S") if item.get("pubdate") else None, # RSS 报文不带站点分类,按站点媒体类型补充,否则音乐资源无法进入音乐订阅匹配 category=site_media_type.value if site_media_type else None, @@ -398,7 +405,7 @@ class TorrentsChain(ChainBase): return ret_torrents @staticmethod - def _music_browse_paths(site: dict) -> List[str]: + def _music_browse_paths(site: dict[str, Any]) -> List[str]: """ 返回站点独立于默认浏览入口的音乐种子页面路径。 @@ -407,7 +414,8 @@ class TorrentsChain(ChainBase): 默认入口已覆盖音乐(音乐站点或未定义独立入口)时返回空列表。 """ # 音乐站点全站都是音乐资源,默认浏览入口已经覆盖 - if MediaType.from_agent(site.get("media_type")) == MediaType.MUSIC: + media_type_value = site.get("media_type") + if isinstance(media_type_value, str) and MediaType.from_agent(media_type_value) == MediaType.MUSIC: return [] paths = (site.get("search") or {}).get("paths") or [] if len(paths) <= 1: @@ -451,7 +459,7 @@ class TorrentsChain(ChainBase): def _refresh_indexer( self, - indexer: dict, + indexer: dict[str, Any], stype: str, include_music: bool, torrents_cache: Dict[str, List[Context]], @@ -523,7 +531,10 @@ class TorrentsChain(ChainBase): """ 刷新已有资源信息,保留未变标题的识别结果;标题变化时替换旧候选。 """ - contexts = {cls._torrent_cache_key(item.torrent_info): item for item in cached} + contexts: dict[tuple[object, ...], Context] = {} + for item in cached: + if item.torrent_info is not None: + contexts[cls._torrent_cache_key(item.torrent_info)] = item new_torrents: dict[tuple[object, ...], TorrentInfo] = {} for torrent in torrents: if not torrent.enclosure: @@ -534,6 +545,9 @@ class TorrentsChain(ChainBase): new_torrents[key] = torrent continue previous = context.torrent_info + if previous is None: + new_torrents[key] = torrent + continue if (previous.title, previous.description) == (torrent.title, torrent.description): context.torrent_info = torrent else: @@ -557,12 +571,12 @@ class TorrentsChain(ChainBase): meta: MetaBase mediainfo: MediaInfo | MusicInfo if torrent.category == MediaType.MUSIC.value: - meta = MetaInfo(title=torrent.title, subtitle=torrent.description, mtype=MediaType.MUSIC) + meta = MetaInfo(title=torrent.title or "", subtitle=torrent.description, mtype=MediaType.MUSIC) mediainfo = MusicInfo.from_meta(cast(MetaMusic, meta)) candidate_recognized = False match_source = "unknown" else: - video_meta = MetaInfo(title=torrent.title, subtitle=torrent.description) + video_meta = MetaInfo(title=torrent.title or "", subtitle=torrent.description) if torrent.title != video_meta.org_string: logger.info( f'种子名称应用识别词后发生改变:{torrent.title} => {video_meta.org_string}' @@ -737,7 +751,7 @@ class TorrentsChain(ChainBase): return str(media_source) return "unknown" - def __renew_rss_url(self, domain: str, site: dict) -> None: + def __renew_rss_url(self, domain: str, site: dict[str, Any]) -> None: """ 保留原配置生成新的rss地址 """ @@ -745,9 +759,14 @@ class TorrentsChain(ChainBase): # RSS链接过期 logger.error(f"站点 {domain} RSS链接已过期,正在尝试自动获取!") # 自动生成rss地址 + site_url = site.get("url") + site_cookie = site.get("cookie") + site_rss = site.get("rss") + if not isinstance(site_url, str) or not isinstance(site_cookie, str) or not isinstance(site_rss, str): + return rss_url, errmsg = RssHelper().get_rss_link( - url=site.get("url"), - cookie=site.get("cookie"), + url=site_url, + cookie=site_cookie, ua=site.get("ua") or self.runtime_config.user_agent, proxy=True if site.get("proxy") else False, timeout=site.get("timeout"), @@ -758,7 +777,7 @@ class TorrentsChain(ChainBase): if match: new_passkey = match.group(1) # 获取过期rss除去passkey部分 - new_rss = re.sub(r'&passkey=([a-zA-Z0-9]+)', f'&passkey={new_passkey}', site.get("rss")) + new_rss = re.sub(r'&passkey=([a-zA-Z0-9]+)', f'&passkey={new_passkey}', site_rss) logger.info(f"更新站点 {domain} RSS地址 ...") self.site_repository.update_rss(domain=domain, rss=new_rss) else: diff --git a/app/chain/transfer/execution.py b/app/chain/transfer/execution.py index 6fcf724dd..621fc3a87 100644 --- a/app/chain/transfer/execution.py +++ b/app/chain/transfer/execution.py @@ -39,6 +39,7 @@ from app.chain.transfer.music import defer_music_recognition, refresh_music_retr from app.chain.transfer.records import apply_download_history_classification from app.domain.context import MediaInfo, MusicInfo from app.domain.media import is_music_media_source +from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.runtime.log import logger from app.schemas.transfer import TransferInfo @@ -478,7 +479,9 @@ class TransferExecutionOwner(_TransferOwnerBase): self._TransferChain__claim_admitted_task(task, task.admission_task_id) def _TransferChain__handle_transfer( - self, task: TransferTask, callback: Optional[Callable] = None + self, + task: TransferTask, + callback: Optional[Callable[[TransferTask, TransferInfo], Tuple[bool, str]]] = None, ) -> Optional[Tuple[bool, str]]: """ 恢复准入时的 CUE 策略并执行,统一记录 checkpoint 前的失败或异常。 @@ -511,14 +514,19 @@ class TransferExecutionOwner(_TransferOwnerBase): return info def _TransferChain__perform_transfer( - self, task: TransferTask, callback: Optional[Callable] = None + self, + task: TransferTask, + callback: Optional[Callable[[TransferTask, TransferInfo], Tuple[bool, str]]] = None, ) -> Optional[Tuple[bool, str]]: """ 处理整理任务 """ try: if task.plan_checkpoint is not None: - return self._TransferChain__handle_planned_transfer(task, callback) + return cast( + Optional[Tuple[bool, str]], + self._TransferChain__handle_planned_transfer(task, callback), + ) refresh_music_retry_context(self, task) transferhis = self.transfer_history_repository # 显式标注联合:下面既会赋回音乐识别结果(MusicInfo),也会赋回影视识别 @@ -561,7 +569,7 @@ class TransferExecutionOwner(_TransferOwnerBase): else: if history_year_conflict: logger.info( - f"{task.fileitem.name} 文件年份 {task.meta.year} 与下载记录年份 " + f"{task.fileitem.name} 文件年份 {task.meta.year if task.meta else None} 与下载记录年份 " f"{download_history.year} 不一致,按文件名重新识别" ) mediainfo = self._recognize_task_meta(task, restore_history=not history_music_conflict) @@ -588,14 +596,15 @@ class TransferExecutionOwner(_TransferOwnerBase): "未识别到媒体信息", ) if callback: - return cast(Tuple[bool, str], callback(task, transferinfo)) + return callback(task, transferinfo) return transferinfo.success, transferinfo.message or "" mediainfo_changed = True # accepted/planned 恢复使用持久快照;在线补充会让首次执行与重放产生漂移。 if not task.planning_context_restored: - mediainfo = MediaChain().supplement_tmdb_info(mediainfo, task.meta) + supplemented = MediaChain().supplement_tmdb_info(mediainfo, task.meta) + mediainfo = cast(Union[MediaInfo, MusicInfo], supplemented) task.mediainfo = mediainfo if deferred := defer_music_recognition(self, task): return deferred @@ -608,7 +617,7 @@ class TransferExecutionOwner(_TransferOwnerBase): transfer_history = transferhis.get_by_media_identity( media_source=mediainfo.media_source.value, media_id=mediainfo.media_id, - mtype=mediainfo.type.value, + mtype=cast(MediaType, mediainfo.type).value, ) if transfer_history and mediainfo.title != transfer_history.title: mediainfo.title = transfer_history.title @@ -621,22 +630,22 @@ class TransferExecutionOwner(_TransferOwnerBase): return False, f"{task.fileitem.name} 已在整理队列中" if ( - task.mediainfo.type == MediaType.TV - and task.mediainfo.tmdb_id + mediainfo.type == MediaType.TV + and mediainfo.tmdb_id and not task.episodes_info and not task.planning_context_restored ): # 第零季表示特别篇,不能按空值回退为第一季。 - season_num = task.mediainfo.season - if season_num is None and task.meta.season_seq: - if task.meta.season_seq.isdigit(): - season_num = int(task.meta.season_seq) + season_num = cast(MediaInfo, mediainfo).season + if season_num is None and cast(MetaBase, task.meta).season_seq: + if cast(MetaBase, task.meta).season_seq.isdigit(): + season_num = int(cast(MetaBase, task.meta).season_seq) if season_num is None: season_num = 1 task.episodes_info = TmdbChain().tmdb_episodes( - tmdbid=task.mediainfo.tmdb_id, + tmdbid=mediainfo.tmdb_id, season=season_num, - episode_group=task.mediainfo.episode_group, + episode_group=mediainfo.episode_group, ) if not task.target_directory: @@ -652,7 +661,7 @@ class TransferExecutionOwner(_TransferOwnerBase): task.target_directory = DirectoryHelper().get_dir( media=task.mediainfo, storage=task.fileitem.storage, - src_path=Path(task.fileitem.path), + src_path=Path(cast(str, task.fileitem.path)), target_storage=task.target_storage, ) if not task.target_storage and task.target_directory: @@ -667,7 +676,7 @@ class TransferExecutionOwner(_TransferOwnerBase): error_message, ) if callback: - return cast(Tuple[bool, str], callback(task, transferinfo)) + return callback(task, transferinfo) return transferinfo.success, transferinfo.message or "" self.jobview.running_task(task) diff --git a/app/chain/transfer/history.py b/app/chain/transfer/history.py index c64f6a46f..9a7eb548e 100644 --- a/app/chain/transfer/history.py +++ b/app/chain/transfer/history.py @@ -111,6 +111,9 @@ class TransferHistoryOwner(_TransferOwnerBase): from app.runtime.errors import public_error_message def args_error() -> None: + """ + 向原交互入口提示整理历史参数无效,不发起新的整理操作。 + """ self.post_message( Message( channel=channel, @@ -408,7 +411,7 @@ class TransferHistoryOwner(_TransferOwnerBase): files: dict[str, int] = {} for notice in notifications: result = notice.transferinfo - paths = result.file_list_new or ([result.target_item.path] if result.target_item else []) + paths = result.file_list_new or ([result.target_item.path] if result.target_item and result.target_item.path else []) size, remainder = divmod(result.total_size or 0, len(paths) or 1) for index, path in enumerate(paths): files[path] = size + (1 if index < remainder else 0) diff --git a/app/chain/transfer/plan.py b/app/chain/transfer/plan.py index 00dd4825b..a0d3632a3 100644 --- a/app/chain/transfer/plan.py +++ b/app/chain/transfer/plan.py @@ -250,7 +250,7 @@ class TransferPlanningOwner(_TransferOwnerBase): success=False, message=error, fileitem=task.fileitem, - fail_list=[task.fileitem.path], + fail_list=[path for path in [task.fileitem.path] if path], transfer_type=task.transfer_type, need_notify=False, ) @@ -307,7 +307,7 @@ class TransferPlanningOwner(_TransferOwnerBase): success=False, message=error, fileitem=task.fileitem, - fail_list=[task.fileitem.path], + fail_list=[path for path in [task.fileitem.path] if path], transfer_type=checkpoint.resolved_transfer_type, need_notify=checkpoint.need_notify, ) @@ -402,17 +402,14 @@ class TransferPlanningOwner(_TransferOwnerBase): ) from error transfer_payload = payload.get("transferinfo") if isinstance(transfer_payload, dict): - transferinfo = cast( - TransferInfo, - TransferInfo.model_validate(transfer_payload), - ) + transferinfo = TransferInfo.model_validate(transfer_payload) elif outcome is TransferExecutionOutcome.FAILED: plan_checkpoint = task.plan_checkpoint transferinfo = TransferInfo( success=False, message=str(payload.get("error") or "整理失败"), fileitem=task.fileitem, - fail_list=[task.fileitem.path], + fail_list=[path for path in [task.fileitem.path] if path], transfer_type=( plan_checkpoint.resolved_transfer_type if plan_checkpoint is not None @@ -598,7 +595,7 @@ class TransferPlanningOwner(_TransferOwnerBase): success=False, message="整理操作多次失败,请稍后重试", fileitem=task.fileitem, - fail_list=[task.fileitem.path], + fail_list=[path for path in [task.fileitem.path] if path], transfer_type=checkpoint.resolved_transfer_type, need_notify=checkpoint.need_notify, ) @@ -646,7 +643,7 @@ class TransferPlanningOwner(_TransferOwnerBase): success=False, message="整理操作多次失败,请稍后重试", fileitem=task.fileitem, - fail_list=[task.fileitem.path], + fail_list=[path for path in [task.fileitem.path] if path], transfer_type=checkpoint.resolved_transfer_type, need_notify=checkpoint.need_notify, ) @@ -962,7 +959,7 @@ class TransferPlanningOwner(_TransferOwnerBase): success=False, message="整理失败,请稍后重试", fileitem=fileitem, - fail_list=[fileitem.path], + fail_list=[path for path in [fileitem.path] if path], transfer_type=transfer_type, ) if preview: @@ -977,7 +974,7 @@ class TransferPlanningOwner(_TransferOwnerBase): success=False, message="整理结果确认失败,后台将自动重试", fileitem=fileitem, - fail_list=[fileitem.path], + fail_list=[path for path in [fileitem.path] if path], transfer_type=transfer_type, ) return result diff --git a/app/chain/transfer/queue.py b/app/chain/transfer/queue.py index 91440ebbd..222a9df86 100644 --- a/app/chain/transfer/queue.py +++ b/app/chain/transfer/queue.py @@ -104,7 +104,7 @@ class TransferQueueOwner(_TransferOwnerBase): """ super().__init__(runtime_context=runtime_context) self._refresh_file_extensions() - self._queue = queue.Queue() + self._queue: queue.Queue[TransferQueue | object] = queue.Queue() self._transfer_threads: list[threading.Thread] = [] # 队列间隔时间(秒) self._transfer_interval = 15 @@ -1411,7 +1411,7 @@ class TransferQueueOwner(_TransferOwnerBase): """ while not runtime_stop_state.is_system_stopped and not stop_event.is_set(): try: - item: TransferQueue = self._queue.get( + item = self._queue.get( block=True, timeout=self._transfer_interval ) if item is self._QUEUE_STOP_SENTINEL: @@ -1420,6 +1420,7 @@ class TransferQueueOwner(_TransferOwnerBase): if stop_event.is_set() or runtime_stop_state.is_system_stopped: break continue + item = cast(TransferQueue, item) if stop_event.is_set() or runtime_stop_state.is_system_stopped: # 关闭信号与 queue.get 竞态时,把尚未处理的任务放回队列;其 # TransferPending 登记保持不变,供同进程重启执行线程 或下次启动回放。 diff --git a/app/chain/transfer/records.py b/app/chain/transfer/records.py index 8d600795d..a196489b1 100644 --- a/app/chain/transfer/records.py +++ b/app/chain/transfer/records.py @@ -310,7 +310,7 @@ class FileKeyMixin(_TransferOwnerBase): """ 获取文件主干名,用于判断同名附加文件。 """ - file_name = fileitem.name or Path(fileitem.path).name + file_name = fileitem.name or Path(fileitem.path or "").name return Path(file_name).stem.lower() @classmethod @@ -333,7 +333,7 @@ class FileKeyMixin(_TransferOwnerBase): if self._is_subtitle_file(extra_fileitem): return self._get_subtitle_media_stem(extra_fileitem) if self._is_music_lyrics_file(extra_fileitem): - file_name = extra_fileitem.name or Path(extra_fileitem.path).name + file_name = extra_fileitem.name or Path(extra_fileitem.path or "").name lowered = file_name.casefold() suffix = ".lyricsfile.yaml" if lowered.endswith(".lyricsfile.yaml") else Path(file_name).suffix return file_name[:-len(suffix)].casefold() if suffix else lowered @@ -377,15 +377,18 @@ class FileKeyMixin(_TransferOwnerBase): """ 获取目录缓存键。 """ - return dir_item.storage, self._normalize_dir_path(dir_item.path) + return ( + dir_item.storage or "local", + self._normalize_dir_path(dir_item.path or ""), + ) def _get_file_parent_key(self, current_item: FileItem) -> Tuple[str, str]: """ 获取文件父目录缓存键。 """ return ( - current_item.storage, - self._normalize_dir_path(Path(current_item.path).parent), + current_item.storage or "local", + self._normalize_dir_path(Path(current_item.path or "").parent), ) diff --git a/app/chain/transfer/request.py b/app/chain/transfer/request.py index dada37dfd..cdefcff7e 100644 --- a/app/chain/transfer/request.py +++ b/app/chain/transfer/request.py @@ -20,13 +20,13 @@ from app.domain.context import MediaInfo, MusicInfo from app.domain.meta.metabase import MetaBase from app.domain.metainfo import MetaInfoPath from app.schemas.exception import OperationInterrupted +from app.schemas.file import FileItem from app.schemas.transfer import TransferInfo from app.schemas.types import ( MUSIC_ENTITY_RECORDING, MediaSource, MediaType, ) -from app.schemas.workflow import FileItem def preview_media_title( @@ -108,7 +108,7 @@ class _TransferCandidatePlanner: mediainfo: Optional[Union[MediaInfo, MusicInfo]], continue_callback: Optional[Callable[[], bool]], has_episode_format_template: bool, - transfer_exclude_words: Optional[list], + transfer_exclude_words: Optional[list[str]], download_hash: Optional[str], sync_extra_files: bool, fileitem: FileItem, @@ -248,14 +248,18 @@ class _TransferCandidatePlanner: """ if self._continue_callback and not self._continue_callback(): raise OperationInterrupted() - if self._has_episode_format_template and self._formaterHandler and not self._formaterHandler.match(item.name): + item_path = item.path + if not item_path: + return False + item_name = item.name or Path(item_path).name + if self._has_episode_format_template and self._formaterHandler and not self._formaterHandler.match(item_name): return False if any( - marker in item.path + marker in item_path for marker in ("/@Recycle/", "/#recycle/", "/.", "/@eaDir") ): return False - return not self._chain._is_blocked_by_exclude_words(item.path, self._transfer_exclude_words) + return not self._chain._is_blocked_by_exclude_words(item_path, self._transfer_exclude_words) def _build_main_meta( self, @@ -266,7 +270,7 @@ class _TransferCandidatePlanner: """ 构建主视频元数据。 """ - main_path = Path(main_fileitem.path) + main_path = Path(main_fileitem.path or "") main_download_history = self._chain._resolve_download_history( repository=download_history_repository, file_path=main_path, @@ -498,7 +502,7 @@ class _TransferCandidatePlanner: ): continue - main_path = Path(main_item.path) + main_path = Path(main_item.path or "") main_download_history = self._chain._resolve_download_history( repository=download_history_repository, file_path=main_path, @@ -546,10 +550,10 @@ class _TransferCandidatePlanner: continue extra_meta = _get_cached_extra_meta( - Path(extra_item.path), + Path(extra_item.path or ""), subscribe_custom_words, ) - if not self._chain._is_same_media_meta(main_meta, extra_meta): + if extra_meta is None or not self._chain._is_same_media_meta(main_meta, extra_meta): continue if self._append_item( planned_items, diff --git a/app/chain/transfer/retry.py b/app/chain/transfer/retry.py index c4df25867..327b80143 100644 --- a/app/chain/transfer/retry.py +++ b/app/chain/transfer/retry.py @@ -278,9 +278,15 @@ class FailedRetryMixin(_TransferOwnerBase): redo_prompt = build_manual_redo_prompt(history) async def _run_ai_takeover() -> None: + """ + 运行整理失败后的 AI 接管流程,并收集最终处理说明。 + """ final_output = "" def _capture_output(text_output: str) -> None: + """ + 记录接管 Agent 的最新输出,供整理失败回执使用。 + """ nonlocal final_output final_output = text_output or "" @@ -391,7 +397,7 @@ class FailedRetryMixin(_TransferOwnerBase): if durable_retry is not None: return durable_retry # 按源目录路径重新整理 - src_path = Path(history.src) + src_path = Path(cast(str, history.src)) if not src_path.exists(): return False, f"源目录不存在:{src_path}" mediainfo: MediaInfo | MusicInfo | None diff --git a/app/chain/transfer/settlement.py b/app/chain/transfer/settlement.py index dda838bb4..b9fa039f6 100644 --- a/app/chain/transfer/settlement.py +++ b/app/chain/transfer/settlement.py @@ -375,16 +375,19 @@ class TransferSettlementOwner(_TransferOwnerBase): """ ret_status = True ret_message = "" + meta = cast(MetaBase, task.meta) + mediainfo = cast(Union[MediaInfo, MusicInfo], task.mediainfo) + transfer_mode = transferinfo.transfer_type or "" def __notify() -> None: """ 完成时发送消息、移除任务等 """ transferinfo.file_count = ( - self.jobview.count(task.mediainfo, task.meta.begin_season) or 1 + self.jobview.count(task.mediainfo, meta.begin_season) or 1 ) transferinfo.total_size = ( - self.jobview.size(task.mediainfo, task.meta.begin_season) + self.jobview.size(task.mediainfo, meta.begin_season) or task.fileitem.size ) # 发送通知,实时手动整理时不发 @@ -393,17 +396,17 @@ class TransferSettlementOwner(_TransferOwnerBase): and not self.runtime_config.transfer_success_notification_aggregation ): se_str = None - if task.mediainfo.type == MediaType.TV: + if mediainfo.type == MediaType.TV: season_episodes = self.jobview.season_episodes( - task.mediainfo, task.meta.begin_season + mediainfo, meta.begin_season ) if season_episodes: - se_str = f"{task.meta.season} {episode_rules.format_ranges(season_episodes)}" + se_str = f"{meta.season} {episode_rules.format_ranges(season_episodes)}" else: - se_str = f"{task.meta.season}" + se_str = f"{meta.season}" self.send_transfer_message( - meta=task.meta, - mediainfo=task.mediainfo, + meta=meta, + mediainfo=mediainfo, transferinfo=transferinfo, season_episode=se_str, episodes_info=task.episodes_info, @@ -475,10 +478,10 @@ class TransferSettlementOwner(_TransferOwnerBase): topic=topic, stage_history=lambda writer: add_transfer_fail( fileitem=task.fileitem, - mode=transferinfo.transfer_type if transferinfo else "", + mode=transfer_mode, downloader=task.downloader, download_hash=task.download_hash, - meta=task.meta, + meta=meta, mediainfo=task.mediainfo, transferinfo=transferinfo, retry_count=failure_count, @@ -523,7 +526,6 @@ class TransferSettlementOwner(_TransferOwnerBase): else: logger.info(f"{task.fileitem.name} 入库成功:{target_dir_path or ''}") - # 静默跳过沿用成功终态,但没有目标文件可供完成事件的消费者处理。 durable_event = ( self._durable_transfer_event(task, success=True) @@ -537,11 +539,11 @@ class TransferSettlementOwner(_TransferOwnerBase): topic=topic, stage_history=lambda writer: add_transfer_success( fileitem=task.fileitem, - mode=transferinfo.transfer_type if transferinfo else "", + mode=transfer_mode, downloader=task.downloader, download_hash=task.download_hash, - meta=task.meta, - mediainfo=task.mediainfo, + meta=meta, + mediainfo=mediainfo, transferinfo=transferinfo, transfer_history_oper=writer, ), @@ -618,7 +620,7 @@ class TransferSettlementOwner(_TransferOwnerBase): 成功移动结算后清理下载器和空目录,清理失败仅更新反馈状态。 """ tasks = self.jobview.success_tasks( - task.mediainfo, task.meta.begin_season + task.mediainfo, cast(MetaBase, task.meta).begin_season ) system_config_oper = get_configured_system_config() transfer_exclude_words = system_config_oper.get( diff --git a/app/chain/transfer/workflow.py b/app/chain/transfer/workflow.py index 6f10b1519..ce652d05b 100644 --- a/app/chain/transfer/workflow.py +++ b/app/chain/transfer/workflow.py @@ -5,7 +5,7 @@ import re import uuid from pathlib import Path -from typing import Any, Callable, Dict, Generator, List, Optional, Tuple, Union +from typing import Any, Callable, Dict, Generator, List, Optional, Tuple, Union, cast from app.application.audio import capture_audio_metadata from app.application.classification.reference import ( @@ -36,7 +36,7 @@ from app.runtime.log import logger from app.runtime.progress import ProgressHelper from app.runtime.stop import runtime_stop_state from app.schemas.exception import OperationInterrupted, StorageQueryError -from app.schemas.file import FileURI +from app.schemas.file import FileItem, FileURI from app.schemas.media import resolve_media_identity from app.schemas.system import TransferDirectoryConf from app.schemas.transfer import DownloaderTorrent, EpisodeFormat, TransferInfo @@ -46,7 +46,6 @@ from app.schemas.types import ( ProgressKey, SystemConfigKey, ) -from app.schemas.workflow import FileItem from .request import ( _should_discard_batch_music_identity, @@ -214,6 +213,10 @@ class TransferWorkflowOwner(_TransferOwnerBase): # 确保从历史记录重新整理时 能获得最新的源文件大小、修改日期等 fileitem = latest_fileitem + if not fileitem.path or not fileitem.storage: + logger.warning("整理源文件缺少路径或存储类型") + return [] + # 是否蓝光原盘子目录或文件 if __is_bluray_sub(fileitem.path): if bluray_dir := __get_bluray_dir(fileitem.storage, Path(fileitem.path)): @@ -325,7 +328,7 @@ class TransferWorkflowOwner(_TransferOwnerBase): min_filesize: int, epformat: Optional[EpisodeFormat], season: Optional[int], - continue_callback: Optional[Callable], + continue_callback: Optional[Callable[[], bool]], selected_fileitems: Optional[list[FileItem]] = None, ) -> Tuple[List[Tuple[FileItem, bool]], bool]: """ @@ -336,7 +339,7 @@ class TransferWorkflowOwner(_TransferOwnerBase): """ format_handler = ( FormatParser( - eformat=epformat.format, + eformat=epformat.format or "", details=epformat.detail, part=epformat.part, offset=epformat.offset, @@ -364,7 +367,7 @@ class TransferWorkflowOwner(_TransferOwnerBase): if continue_callback and not continue_callback(): raise OperationInterrupted() if has_template and format_handler: - if not format_handler.match(item.name): + if not format_handler.match(item.name or ""): return False matched_template = True if batch_mtype == MediaType.MUSIC: @@ -382,7 +385,7 @@ class TransferWorkflowOwner(_TransferOwnerBase): return False if not self._is_allow_filesize(item, min_filesize): return False - if any(marker in item.path for marker in ("/@Recycle/", "/#recycle/", "/.", "/@eaDir")): + if any(marker in (item.path or "") for marker in ("/@Recycle/", "/#recycle/", "/.", "/@eaDir")): logger.debug(f"{item.path} 是回收站或隐藏的文件") return False return not self._is_blocked_by_exclude_words(item.path, exclude_words) @@ -403,20 +406,20 @@ class TransferWorkflowOwner(_TransferOwnerBase): def do_transfer( self, fileitem: FileItem, - meta: MetaBase = None, + meta: Optional[MetaBase] = None, mediainfo: Optional[Union[MediaInfo, MusicInfo, MusicAlbumInfo]] = None, mtype: Optional[MediaType] = None, media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, - target_directory: TransferDirectoryConf = None, + target_directory: Optional[TransferDirectoryConf] = None, target_storage: Optional[str] = None, - target_path: Path = None, + target_path: Optional[Path] = None, transfer_type: Optional[str] = None, scrape: Optional[bool] = None, library_type_folder: Optional[bool] = None, library_category_folder: Optional[bool] = None, season: Optional[int] = None, - epformat: EpisodeFormat = None, + epformat: Optional[EpisodeFormat] = None, min_filesize: Optional[int] = 0, downloader: Optional[str] = None, download_hash: Optional[str] = None, @@ -426,11 +429,11 @@ class TransferWorkflowOwner(_TransferOwnerBase): preview: Optional[bool] = False, sync_extra_files: Optional[bool] = False, cleanup_dest_fileitem: Optional[FileItem] = None, - continue_callback: Callable = None, + continue_callback: Optional[Callable[[], bool]] = None, reorganize: Optional[bool] = False, music_release_regions: Optional[list[str]] = None, music_release_scripts: Optional[list[str]] = None, - ) -> Tuple[bool, Union[str, dict]]: + ) -> Tuple[bool, Union[str, dict[str, Any]]]: """ 兼容公开整理入口,委托给内部批次执行阶段。 @@ -468,7 +471,7 @@ class TransferWorkflowOwner(_TransferOwnerBase): music_release_scripts=music_release_scripts, ) - def _execute_transfer(self, *args: Any, **kwargs: Any) -> Tuple[bool, Union[str, dict]]: + def _execute_transfer(self, *args: Any, **kwargs: Any) -> Tuple[bool, Union[str, dict[str, Any]]]: """ 兼容旧内部钩子,统一委托请求工作流组件。 """ @@ -478,20 +481,20 @@ class TransferWorkflowOwner(_TransferOwnerBase): def _run_transfer_workflow( self, fileitem: FileItem, - meta: MetaBase = None, + meta: Optional[MetaBase] = None, mediainfo: Optional[Union[MediaInfo, MusicInfo, MusicAlbumInfo]] = None, mtype: Optional[MediaType] = None, media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, - target_directory: TransferDirectoryConf = None, + target_directory: Optional[TransferDirectoryConf] = None, target_storage: Optional[str] = None, - target_path: Path = None, + target_path: Optional[Path] = None, transfer_type: Optional[str] = None, scrape: Optional[bool] = None, library_type_folder: Optional[bool] = None, library_category_folder: Optional[bool] = None, season: Optional[int] = None, - epformat: EpisodeFormat = None, + epformat: Optional[EpisodeFormat] = None, min_filesize: Optional[int] = 0, downloader: Optional[str] = None, download_hash: Optional[str] = None, @@ -501,7 +504,7 @@ class TransferWorkflowOwner(_TransferOwnerBase): preview: Optional[bool] = False, sync_extra_files: Optional[bool] = False, cleanup_dest_fileitem: Optional[FileItem] = None, - continue_callback: Callable = None, + continue_callback: Optional[Callable[[], bool]] = None, reorganize: Optional[bool] = False, recovery_admission: Optional[TransferAdmission] = None, music_release_regions: Optional[list[str]] = None, @@ -509,7 +512,7 @@ class TransferWorkflowOwner(_TransferOwnerBase): selected_fileitems: Optional[list[FileItem]] = None, report_results: bool = False, skip_success: bool = False, - ) -> Tuple[bool, Union[str, dict]]: + ) -> Tuple[bool, Union[str, dict[str, Any]]]: """ 执行一个复杂目录的整理操作 @@ -575,7 +578,7 @@ class TransferWorkflowOwner(_TransferOwnerBase): has_episode_format_template = bool(epformat and epformat.format) formaterHandler = ( FormatParser( - eformat=epformat.format, + eformat=epformat.format or "", details=epformat.detail, part=epformat.part, offset=epformat.offset, @@ -778,7 +781,7 @@ class TransferWorkflowOwner(_TransferOwnerBase): raise OperationInterrupted() if continue_callback and not continue_callback(): raise OperationInterrupted() - file_path = Path(file_item.path) + file_path = Path(cast(str, file_item.path)) package_error = music_package_error(file_path.name) if batch_mtype == MediaType.MUSIC else None if package_error: message = f"{file_path.name}:{package_error}" @@ -1054,13 +1057,13 @@ class TransferWorkflowOwner(_TransferOwnerBase): 收集预览结果,保持任务执行顺序。 """ preview_items.append(build_transfer_preview_item(task, transferinfo)) - return transferinfo.success, transferinfo.message + return transferinfo.success, transferinfo.message or "" if transfer_tasks: total_num = len(transfer_tasks) processed_num = 0 fail_num = 0 - finished_files = [] + finished_files: list[str] = [] progress = None if not preview: @@ -1076,7 +1079,7 @@ class TransferWorkflowOwner(_TransferOwnerBase): all_success = False err_msgs.append("整理已取消,剩余文件未执行") break - if not preview: + if progress is not None: __process_msg = ( f"正在整理 ({processed_num + fail_num + 1}/{total_num}){transfer_task.fileitem.name} ..." ) @@ -1085,7 +1088,7 @@ class TransferWorkflowOwner(_TransferOwnerBase): value=(processed_num + fail_num) / total_num * 100, text=__process_msg, data={ - "current": Path(transfer_task.fileitem.path).as_posix(), + "current": Path(transfer_task.fileitem.path or "").as_posix(), "finished": finished_files, }, ) @@ -1162,12 +1165,12 @@ class TransferWorkflowOwner(_TransferOwnerBase): self.jobview.finish_task(transfer_task) self.jobview.try_remove_job(transfer_task) processed_num += 1 - finished_files.append(Path(transfer_task.fileitem.path).as_posix()) + finished_files.append(Path(transfer_task.fileitem.path or "").as_posix()) finally: transfer_tasks.clear() del transfer_tasks - if not preview: + if progress is not None: __end_msg = f"整理队列处理完成,共整理 {total_num} 个文件,失败 {fail_num} 个" logger.info(__end_msg) progress.update(value=100, text=__end_msg, data={}) diff --git a/app/chain/user.py b/app/chain/user.py index fe4997fc1..ef5bd20c3 100644 --- a/app/chain/user.py +++ b/app/chain/user.py @@ -59,7 +59,7 @@ class UserChain(ChainBase): password=password, mfa_code=mfa_code, code=code, - grant_type=grant_type + grant_type=grant_type or "password" ) logger.debug(f"认证类型:{grant_type},开始准备对用户 {username} 进行身份校验") if credentials.grant_type == "password": diff --git a/app/chain/workflow.py b/app/chain/workflow.py index 9377a3573..7a955dcbf 100644 --- a/app/chain/workflow.py +++ b/app/chain/workflow.py @@ -7,6 +7,7 @@ import copy import inspect import threading from collections import defaultdict, deque +from concurrent.futures import Future from contextvars import Context, copy_context from dataclasses import is_dataclass from datetime import date, datetime @@ -101,7 +102,7 @@ def _serialize_workflow_value(value: Any, stack: Optional[set[int]] = None) -> A return str(value) -def _serialize_workflow_context(context: ActionContext) -> dict: +def _serialize_workflow_context(context: ActionContext) -> dict[str, Any]: """ 构建可写入数据库的工作流上下文字典。 """ @@ -171,11 +172,11 @@ class WorkflowExecutor: ) self.actions: dict[str, Action] = {} for action_data in workflow.actions or (): - action = Action(**dict(action_data)) + action = Action.model_validate(dict(action_data)) if not action.id: raise ValueError("工作流动作缺少 ID") self.actions[action.id] = action - self.flows = [ActionFlow(**dict(flow)) for flow in workflow.flows or ()] + self.flows = [ActionFlow.model_validate(dict(flow)) for flow in workflow.flows or ()] execution_config = getattr(workflow, "execution_config", None) or {} execution_state = {} if from_begin else (getattr(workflow, "execution_state", None) or {}) self.execution_config = ( @@ -354,21 +355,21 @@ class WorkflowExecutor: except (TypeError, ValueError): return DEFAULT_WORKFLOW_MAX_WORKERS - def get_restored_node_metadata(self) -> dict: + def get_restored_node_metadata(self) -> dict[str, Any]: """ 获取已持久化的节点状态元数据。 """ nodes = self.restored_execution_state.get("nodes") if isinstance(self.restored_execution_state, dict) else {} return nodes if isinstance(nodes, dict) else {} - def get_restored_errors(self) -> dict: + def get_restored_errors(self) -> dict[str, Any]: """ 获取已持久化的错误状态。 """ errors = self.restored_execution_state.get("errors") if isinstance(self.restored_execution_state, dict) else {} return errors if isinstance(errors, dict) else {} - def get_restored_attempts(self) -> dict: + def get_restored_attempts(self) -> dict[str, Any]: """ 获取已持久化的节点尝试次数。 """ @@ -378,7 +379,7 @@ class WorkflowExecutor: attempts[action_id] = int(metadata.get("attempt") or 0) return attempts - def get_restored_node_states(self) -> dict: + def get_restored_node_states(self) -> dict[str, Any]: """ 获取结构化节点状态,兼容旧版 current_action 字符串。 """ @@ -442,6 +443,8 @@ class WorkflowExecutor: """ 更新上下文中的运行期状态分区。 """ + if self.context.runtime_state is None: + self.context.runtime_state = {} self.context.runtime_state.update({ "progress": self.context.progress, "finished_actions": self.finished_actions, @@ -468,7 +471,7 @@ class WorkflowExecutor: self.node_states[action_id] = state self.update_runtime_state() - def build_execution_state(self) -> dict: + def build_execution_state(self) -> dict[str, Any]: """ 构建可持久化的结构化执行状态。 """ @@ -532,7 +535,7 @@ class WorkflowExecutor: node_id, copy.deepcopy(self.context), ) - future.add_done_callback(partial(context.run, self.on_node_complete)) + future.add_done_callback(partial(self._complete_in_context, context)) except Exception as err: self.success = False self.has_failure = True @@ -586,13 +589,25 @@ class WorkflowExecutor: ) return action, action_result - def on_node_complete(self, future): + def _complete_in_context( + self, + context: Context, + future: Future[Tuple[Action, ActionResult]], + ) -> None: + """ + 在节点提交时的上下文中消费完成结果。 + """ + context.run(self.on_node_complete, future) + + def on_node_complete(self, future: Future[Tuple[Action, ActionResult]]) -> None: """ 节点完成回调:更新上下文、处理后继节点 """ action = None try: action, action_result = future.result() + if not action.id: + raise ValueError("工作流动作缺少 ID") with self.lock: if self._stop_requested(): self.success = False @@ -606,6 +621,8 @@ class WorkflowExecutor: self.finished_actions += 1 self.update_progress() + if self.context.execute_history is None: + self.context.execute_history = [] self.context.execute_history.append( ActionExecution( action=action.name, @@ -682,6 +699,8 @@ class WorkflowExecutor: self.finished_actions += 1 self.update_progress() self.set_node_state(node_id, "skipped", message=message) + if self.context.execute_history is None: + self.context.execute_history = [] self.context.execute_history.append( ActionExecution( action=self.actions[node_id].name, @@ -782,7 +801,7 @@ class WorkflowExecutor: self.context.progress = round(self.finished_actions / self.total_actions * 100) if self.total_actions else 100 self.update_runtime_state() - def build_action_inputs(self, action: Action) -> dict: + def build_action_inputs(self, action: Action) -> dict[str, Any]: """ 根据动作输入声明读取上游节点输出。 """ @@ -809,7 +828,7 @@ class WorkflowExecutor: return self.context.artifacts.get(input_path) if self.context.artifacts else value return self.resolve_context_path(input_path) - def build_action_runtime(self, action: Action) -> dict: + def build_action_runtime(self, action: Action) -> dict[str, Any]: """ 构建传递给动作的新运行期数据。 """ @@ -831,7 +850,7 @@ class WorkflowExecutor: context.artifacts = context.artifacts or {} def normalize_action_outputs(self, action: Action, action_result: ActionResult, - result_context: ActionContext) -> dict: + result_context: ActionContext) -> dict[str, Any]: """ 根据动作输出声明整理当前节点输出。 """ @@ -873,11 +892,15 @@ class WorkflowExecutor: merge_policy = output_config.get("merge") or self.get_default_merge_policy(action, target_key, value) return merge_policy == "replace" - def record_node_outputs(self, action_id: str, outputs: dict) -> None: + def record_node_outputs(self, action_id: str, outputs: dict[str, Any]) -> None: """ 记录当前节点输出,供后续条件表达式读取。 """ if outputs: + if self.context.node_outputs is None: + self.context.node_outputs = {} + if self.context.runtime_state is None: + self.context.runtime_state = {} self.context.node_outputs[action_id] = outputs self.context.runtime_state["last_outputs"] = outputs @@ -893,7 +916,7 @@ class WorkflowExecutor: current_value.update(value) setattr(self.context, key, current_value) - def merge_action_outputs(self, action: Action, outputs: dict) -> None: + def merge_action_outputs(self, action: Action, outputs: dict[str, Any]) -> None: """ 按声明式合并策略写入全局上下文和 artifacts 分区。 """ @@ -915,10 +938,12 @@ class WorkflowExecutor: if key in ActionContext.model_fields: setattr(self.context, key, merged_value) if key in ARTIFACT_FIELDS: + if self.context.artifacts is None: + self.context.artifacts = {} current_artifact = self.context.artifacts.get(key) self.context.artifacts[key] = self.apply_merge_policy(current_artifact, value, merge_policy, identity) - def get_action_output_config(self, action: Action, output_key: str) -> dict: + def get_action_output_config(self, action: Action, output_key: str) -> dict[str, Any]: """ 获取动作输出声明配置。 """ @@ -981,7 +1006,7 @@ class WorkflowExecutor: return current_value or value return current_value or value - def append_unique_values(self, current_value: Any, value: Any, identity: Optional[str] = None) -> list: + def append_unique_values(self, current_value: Any, value: Any, identity: Optional[str] = None) -> list[Any]: """ 追加列表并按身份字段去重。 """ @@ -1030,7 +1055,7 @@ class WorkflowExecutor: self.step_callback(action, self.context) @staticmethod - def extract_context_outputs(context: ActionContext) -> dict: + def extract_context_outputs(context: ActionContext) -> dict[str, Any]: """ 从动作上下文中提取非空业务字段作为节点默认输出。 """ @@ -1096,7 +1121,7 @@ class WorkflowExecutor: or self.get_action_contract(action).get("concurrency_key") ) - def get_action_contract(self, action: Action) -> dict: + def get_action_contract(self, action: Action) -> dict[str, Any]: """ 获取动作固定输入输出契约。 """ @@ -1174,9 +1199,12 @@ class WorkflowExecutor: if isinstance(node, ast.Set): return {self.evaluate_condition_node(item) for item in node.elts} if isinstance(node, ast.Dict): + if any(key is None for key in node.keys): + raise ValueError("条件表达式不支持字典展开") return { self.evaluate_condition_node(key): self.evaluate_condition_node(value) for key, value in zip(node.keys, node.values) + if key is not None } raise ValueError(f"不支持的条件表达式:{ast.dump(node)}") @@ -1224,8 +1252,9 @@ class WorkflowExecutor: return self.context.node_outputs or {} if name == "last": return self.context.runtime_state.get("last_outputs") if self.context.runtime_state else {} - if name in (self.context.node_outputs or {}): - return self.context.node_outputs[name] + node_outputs = self.context.node_outputs or {} + if name in node_outputs: + return node_outputs[name] if name in ActionContext.model_fields: return getattr(self.context, name, None) raise ValueError(f"未知上下文变量 {name}") @@ -1271,17 +1300,17 @@ class WorkflowExecutor: """ try: if isinstance(operator, ast.Eq): - return left == right + return bool(left == right) if isinstance(operator, ast.NotEq): - return left != right + return bool(left != right) if isinstance(operator, ast.Gt): - return left > right + return bool(left > right) if isinstance(operator, ast.GtE): - return left >= right + return bool(left >= right) if isinstance(operator, ast.Lt): - return left < right + return bool(left < right) if isinstance(operator, ast.LtE): - return left <= right + return bool(left <= right) if isinstance(operator, ast.In): return left in right if isinstance(operator, ast.NotIn): @@ -1323,10 +1352,12 @@ class WorkflowChain(ChainBase): """ @eventmanager.register(EventType.WorkflowExecute) - def event_process(self, event: Event): + def event_process(self, event: Event) -> None: """ 事件触发工作流执行 """ + if not isinstance(event.event_data, dict): + return workflow_id = event.event_data.get('workflow_id') if not workflow_id: return @@ -1362,7 +1393,7 @@ class WorkflowChain(ChainBase): def save_step( action: Action, context: ActionContext, - execution_state: dict, + execution_state: dict[str, Any], completed: bool, ) -> None: """ diff --git a/app/cli.py b/app/cli.py index 92a599e26..669d9e6a7 100644 --- a/app/cli.py +++ b/app/cli.py @@ -1126,10 +1126,10 @@ def _start_backend_service(timeout: int, safe: bool = False) -> Dict[str, Any]: _ensure_local_api_token() _clear_json_file(BACKEND_RUNTIME_FILE) - process = _spawn_backend_process(safe=safe) - ps_process = psutil.Process(process.pid) + spawned_process = _spawn_backend_process(safe=safe) + ps_process = psutil.Process(spawned_process.pid) runtime = { - "pid": process.pid, + "pid": spawned_process.pid, "create_time": ps_process.create_time(), "host": get_runtime_setting("HOST"), "port": get_runtime_setting("PORT"), @@ -1157,10 +1157,10 @@ def _start_frontend_service(timeout: int, backend_port: int) -> Dict[str, Any]: ) _clear_json_file(FRONTEND_RUNTIME_FILE) - process = _spawn_frontend_process(backend_port=backend_port) - ps_process = psutil.Process(process.pid) + spawned_process = _spawn_frontend_process(backend_port=backend_port) + ps_process = psutil.Process(spawned_process.pid) runtime = { - "pid": process.pid, + "pid": spawned_process.pid, "create_time": ps_process.create_time(), "host": get_runtime_setting("HOST"), "port": get_runtime_setting("NGINX_PORT"), diff --git a/app/command.py b/app/command.py index dfbdb9ce1..6939dd659 100644 --- a/app/command.py +++ b/app/command.py @@ -6,7 +6,7 @@ import copy import threading import traceback from concurrent.futures import Future -from typing import Any, Dict, Optional, Union +from typing import Any, Callable, Dict, Optional, Union from app.application.messaging.message import MessageHelper from app.application.messaging.skill import SkillInteractionHandler @@ -37,7 +37,7 @@ class CommandChain(ChainBase): """ -def _finish_command_processing_status(status: Optional[dict], user_id: Optional[str] = None) -> None: +def _finish_command_processing_status(status: Optional[dict[str, Any]], user_id: Optional[str] = None) -> None: """ 命令执行完成后通过消息模块收口渠道处理状态。 """ @@ -54,15 +54,18 @@ class Command(metaclass=Singleton): 全局命令管理,消费事件 """ - def __init__(self): + def __init__(self) -> None: + """ + 绑定命令来源、消息网关与调度服务,并安排首次菜单注册。 + """ # 插件管理器 super().__init__() # 注册的命令集合 - self._registered_commands = {} + self._registered_commands: dict[str, dict[str, Any]] = {} # 所有命令集合 - self._commands = {} + self._commands: dict[str, dict[str, Any]] = {} # 内建命令集合 - self._preset_commands = { + self._preset_commands: dict[str, dict[str, Any]] = { "/cookiecloud": { "id": "cookiecloud", "type": "scheduler", @@ -154,9 +157,9 @@ class Command(metaclass=Singleton): }, } # 插件命令集合 - self._plugin_commands = {} + self._plugin_commands: dict[str, dict[str, Any]] = {} # 其他命令集合 - self._other_commands = {} + self._other_commands: dict[str, dict[str, Any]] = {} # 初始化锁 self._rlock = threading.RLock() # 插件管理 @@ -168,7 +171,7 @@ class Command(metaclass=Singleton): # 初始化命令 self.init_commands() - def init_commands(self, pid: Optional[str] = None) -> Future: + def init_commands(self, pid: Optional[str] = None) -> Future[None]: """ 提交菜单命令重建任务,并返回可等待的完成信号。 """ @@ -193,9 +196,9 @@ class Command(metaclass=Singleton): # 触发事件允许可以拦截和调整命令 event, initial_commands = self.__trigger_register_commands_event() - if event and event.event_data: - # 如果事件返回有效的 event_data,使用事件中调整后的命令 - event_data: CommandRegisterEventData = event.event_data + if event and isinstance(event.event_data, CommandRegisterEventData): + # 使用事件中调整后的命令。 + event_data = event.event_data # 如果事件被取消,跳过命令注册 if event_data.cancel: logger.debug( @@ -243,12 +246,12 @@ class Command(metaclass=Singleton): exc_info=True, ) - def __trigger_register_commands_event(self) -> tuple[Optional[Event], dict]: + def __trigger_register_commands_event(self) -> tuple[Optional[Event], dict[str, dict[str, Any]]]: """ 触发事件,允许调整命令数据 """ - def add_commands(source, command_type): + def add_commands(source: dict[str, dict[str, Any]], command_type: str) -> None: """ 添加命令集合 """ @@ -268,7 +271,7 @@ class Command(metaclass=Singleton): commands[cmd] = command_data # 初始化命令字典 - commands: Dict[str, dict] = {} + commands: Dict[str, dict[str, Any]] = {} add_commands(self._preset_commands, "preset") add_commands(self._plugin_commands, "plugin") add_commands(self._other_commands, "other") @@ -280,7 +283,7 @@ class Command(metaclass=Singleton): event = eventmanager.send_event(ChainEventType.CommandRegister, event_data) return event, commands - def __build_plugin_commands(self, _: Optional[str] = None) -> Dict[str, dict]: + def __build_plugin_commands(self, _: Optional[str] = None) -> Dict[str, dict[str, Any]]: """ 构建插件命令 """ @@ -304,12 +307,12 @@ class Command(metaclass=Singleton): def __run_command( self, - command: Dict[str, any], + command: Dict[str, Any], data_str: Optional[str] = "", - channel: NotificationChannel = None, + channel: Optional[NotificationChannel] = None, source: Optional[str] = None, - userid: Union[str, int] = None, - ): + userid: Optional[Union[str, int]] = None, + ) -> None: """ 运行定时服务 """ @@ -339,7 +342,7 @@ class Command(metaclass=Singleton): ) else: # 命令 - cmd_data = copy.deepcopy(command["data"]) if command.get("data") else {} + cmd_data: dict[str, Any] = copy.deepcopy(command["data"]) if command.get("data") else {} args_num = ObjectUtils.arguments(command["func"]) if args_num > 0: if cmd_data: @@ -362,7 +365,7 @@ class Command(metaclass=Singleton): # 没有参数 command["func"]() - def get_commands(self): + def get_commands(self) -> dict[str, dict[str, Any]]: """ 获取命令列表 """ @@ -385,8 +388,8 @@ class Command(metaclass=Singleton): def register( self, cmd: str, - func: Any, - data: Optional[dict] = None, + func: Callable[..., Any], + data: Optional[dict[str, Any]] = None, desc: Optional[str] = None, category: Optional[str] = None, show: bool = True, @@ -407,9 +410,9 @@ class Command(metaclass=Singleton): self, cmd: str, data_str: Optional[str] = "", - channel: NotificationChannel = None, + channel: Optional[NotificationChannel] = None, source: Optional[str] = None, - userid: Union[str, int] = None, + userid: Optional[Union[str, int]] = None, ) -> None: """ 执行命令 @@ -446,7 +449,7 @@ class Command(metaclass=Singleton): ) @staticmethod - def send_plugin_event(etype: EventType, data: dict) -> None: + def send_plugin_event(etype: EventType, data: dict[str, Any]) -> None: """ 发送插件命令 """ @@ -460,6 +463,8 @@ class Command(metaclass=Singleton): "cmd": "/xxx args" } """ + if not isinstance(event.event_data, dict): + return # 命令参数 event_str = event.event_data.get("cmd") # 消息渠道 @@ -483,7 +488,7 @@ class Command(metaclass=Singleton): finally: _finish_command_processing_status( event.event_data.get("processing_status"), - user_id=event_user, + user_id=str(event_user) if event_user is not None else None, ) @eventmanager.register(EventType.ModuleReload) diff --git a/app/db/adapters/classification.py b/app/db/adapters/classification.py index c9c57ac1f..8cb0d97c2 100644 --- a/app/db/adapters/classification.py +++ b/app/db/adapters/classification.py @@ -280,12 +280,9 @@ class SystemConfigClassificationPolicyStore: if value is None: return None try: - return cast( - ClassificationPolicyState, - ClassificationPolicyState.model_validate( + return ClassificationPolicyState.model_validate( discard_removed_source_fallbacks(value) - ), - ) + ) except ValidationError as error: raise ClassificationPolicyStateCorruptError( "MediaClassificationPolicy 配置结构无效" diff --git a/app/domain/context.py b/app/domain/context.py index 9a2a6746e..a59730ce1 100644 --- a/app/domain/context.py +++ b/app/domain/context.py @@ -205,12 +205,9 @@ def _classification_result(value: object) -> ClassificationResult | None: if value is None: return None if isinstance(value, ClassificationResult): - return typing.cast(ClassificationResult, value.model_copy(deep=True)) + return value.model_copy(deep=True) if isinstance(value, Mapping): - return typing.cast( - ClassificationResult, - ClassificationResult.model_validate(dict(value)), - ) + return ClassificationResult.model_validate(dict(value)) raise TypeError("分类结果必须是 ClassificationResult、字典或空值") @@ -1672,32 +1669,32 @@ class TorrentInfo: :param search_resource_id: 自动订阅分页搜索的内部候选标识,只用于提交结果与候选对应,不进入对外字典 """ - site: int = None - site_name: str = None - site_cookie: str = None - site_ua: str = None + site: Optional[int] = None + site_name: Optional[str] = None + site_cookie: Optional[str] = None + site_ua: Optional[str] = None site_proxy: bool = False site_order: int = 0 - site_downloader: str = None - title: str = None - description: str = None - media_source: MediaSource = None - media_id: str = None - enclosure: str = None - page_url: str = None + site_downloader: Optional[str] = None + title: Optional[str] = None + description: Optional[str] = None + media_source: Optional[MediaSource] = None + media_id: Optional[str] = None + enclosure: Optional[str] = None + page_url: Optional[str] = None size: float = 0.0 seeders: int = 0 peers: int = 0 grabs: int = 0 - pubdate: str = None - date_elapsed: str = None - freedate: str = None - uploadvolumefactor: float = None - downloadvolumefactor: float = None + pubdate: Optional[str] = None + date_elapsed: Optional[str] = None + freedate: Optional[str] = None + uploadvolumefactor: Optional[float] = None + downloadvolumefactor: Optional[float] = None hit_and_run: bool = False - labels: list = field(default_factory=list) + labels: list[str] = field(default_factory=list) pri_order: int = 0 - category: str = None + category: Optional[str] = None search_resource_id: Optional[str] = None def __post_init__(self) -> None: @@ -1841,27 +1838,27 @@ class SubtitleInfo: :param file_name: 下载文件名 """ - site: int = None - site_name: str = None - site_cookie: str = None - site_ua: str = None + site: Optional[int] = None + site_name: Optional[str] = None + site_cookie: Optional[str] = None + site_ua: Optional[str] = None site_proxy: bool = False site_order: int = 0 - title: str = None - description: str = None - enclosure: str = None - page_url: str = None - language: str = None - language_icon: str = None + title: Optional[str] = None + description: Optional[str] = None + enclosure: Optional[str] = None + page_url: Optional[str] = None + language: Optional[str] = None + language_icon: Optional[str] = None size: float = 0.0 - pubdate: str = None - date_elapsed: str = None + pubdate: Optional[str] = None + date_elapsed: Optional[str] = None grabs: int = 0 - uploader: str = None - report_url: str = None - torrent_id: str = None - subtitle_id: str = None - file_name: str = None + uploader: Optional[str] = None + report_url: Optional[str] = None + torrent_id: Optional[str] = None + subtitle_id: Optional[str] = None + file_name: Optional[str] = None def __build_meta_info(self) -> Optional[dict[str, Any]]: """ @@ -1986,76 +1983,76 @@ class MediaInfo: # 内部标记:是否命中本地识别缓存,不参与序列化 recognize_cache_hit = False - media_source: MediaSource = None - media_id: str = None - scrape_source: str = None - type: MediaType = None - title: str = None - en_title: str = None - hk_title: str = None - tw_title: str = None - sg_title: str = None - year: str = None - season: int = None - tmdb_id: int = None - imdb_id: str = None - tvdb_id: int = None - tvdb_slug: str = None - douban_id: str = None - bangumi_id: int = None - anilist_id: int = None - anidb_id: int = None - collection_id: int = None - original_language: str = None - original_title: str = None - release_date: str = None - backdrop_path: str = None - poster_path: str = None - logo_path: str = None - vote_average: float = None - overview: str = None - genre_ids: list = field(default_factory=list) - names: list = field(default_factory=list) - seasons: Dict[int, list] = field(default_factory=dict) - season_info: List[dict] = field(default_factory=list) - season_years: dict = field(default_factory=dict) + media_source: Optional[MediaSource] = None + media_id: Optional[str] = None + scrape_source: Optional[str] = None + type: Optional[MediaType] = None + title: Optional[str] = None + en_title: Optional[str] = None + hk_title: Optional[str] = None + tw_title: Optional[str] = None + sg_title: Optional[str] = None + year: Optional[str] = None + season: Optional[int] = None + tmdb_id: Optional[int] = None + imdb_id: Optional[str] = None + tvdb_id: Optional[int] = None + tvdb_slug: Optional[str] = None + douban_id: Optional[str] = None + bangumi_id: Optional[int] = None + anilist_id: Optional[int] = None + anidb_id: Optional[int] = None + collection_id: Optional[int] = None + original_language: Optional[str] = None + original_title: Optional[str] = None + release_date: Optional[str] = None + backdrop_path: Optional[str] = None + poster_path: Optional[str] = None + logo_path: Optional[str] = None + vote_average: Optional[float] = None + overview: Optional[str] = None + genre_ids: list[int] = field(default_factory=list) + names: list[str] = field(default_factory=list) + seasons: dict[int, list[int]] = field(default_factory=dict) + season_info: list[dict[str, Any]] = field(default_factory=list) + season_years: dict[int, str] = field(default_factory=dict) category: str = "" library_category: str = "" metadata_category: str = "" classification: ClassificationResult | None = None classification_facts: dict[str, ClassificationFactValue] = field(default_factory=dict) - tmdb_info: dict = field(default_factory=dict) - douban_info: dict = field(default_factory=dict) - bangumi_info: dict = field(default_factory=dict) - anilist_info: dict = field(default_factory=dict) - directors: List[dict] = field(default_factory=list) - actors: List[dict] = field(default_factory=list) + tmdb_info: dict[str, Any] = field(default_factory=dict) + douban_info: dict[str, Any] = field(default_factory=dict) + bangumi_info: dict[str, Any] = field(default_factory=dict) + anilist_info: dict[str, Any] = field(default_factory=dict) + directors: List[dict[str, Any]] = field(default_factory=list) + actors: List[dict[str, Any]] = field(default_factory=list) adult: Optional[bool] = None - created_by: list = field(default_factory=list) - episode_run_time: list = field(default_factory=list) - genres: List[dict] = field(default_factory=list) - first_air_date: str = None - homepage: str = None - languages: list = field(default_factory=list) - last_air_date: str = None - networks: list = field(default_factory=list) - number_of_episodes: int = None - number_of_seasons: int = None - origin_country: list = field(default_factory=list) - original_name: str = None - production_companies: list = field(default_factory=list) - production_countries: list = field(default_factory=list) - spoken_languages: list = field(default_factory=list) - release_dates: list = field(default_factory=list) - status: str = None - tagline: str = None - vote_count: int = None - popularity: float = None - runtime: int = None - next_episode_to_air: dict = field(default_factory=dict) - content_rating: str = None - episode_groups: List[dict] = field(default_factory=list) - episode_group: str = None + created_by: list[dict[str, Any]] = field(default_factory=list) + episode_run_time: list[int] = field(default_factory=list) + genres: List[dict[str, Any]] = field(default_factory=list) + first_air_date: Optional[str] = None + homepage: Optional[str] = None + languages: list[str] = field(default_factory=list) + last_air_date: Optional[str] = None + networks: list[dict[str, Any]] = field(default_factory=list) + number_of_episodes: Optional[int] = None + number_of_seasons: Optional[int] = None + origin_country: list[str] = field(default_factory=list) + original_name: Optional[str] = None + production_companies: list[dict[str, Any]] = field(default_factory=list) + production_countries: list[dict[str, Any]] = field(default_factory=list) + spoken_languages: list[dict[str, Any]] = field(default_factory=list) + release_dates: list[dict[str, Any]] = field(default_factory=list) + status: Optional[str] = None + tagline: Optional[str] = None + vote_count: Optional[int] = None + popularity: Optional[float] = None + runtime: Optional[int] = None + next_episode_to_air: dict[str, Any] = field(default_factory=dict) + content_rating: Optional[str] = None + episode_groups: List[dict[str, Any]] = field(default_factory=list) + episode_group: Optional[str] = None def __post_init__(self) -> None: """ @@ -2268,10 +2265,16 @@ class MediaInfo: @typing.overload def get_backdrop_image(self) -> str: + """ + 按调用方指定的默认图策略获取背景图地址。 + """ ... @typing.overload def get_backdrop_image(self, default: bool) -> Union[str, bool]: + """ + 按调用方指定的默认图策略获取背景图地址。 + """ ... def get_backdrop_image(self, default: bool = False) -> Union[str, bool]: @@ -2287,10 +2290,16 @@ class MediaInfo: @typing.overload def get_message_image(self) -> str: + """ + 按调用方指定的默认图策略获取通知消息图片。 + """ ... @typing.overload def get_message_image(self, default: Optional[bool]) -> Union[str, bool]: + """ + 按调用方指定的默认图策略获取通知消息图片。 + """ ... def get_message_image(self, default: Optional[bool] = None) -> Union[str, bool]: @@ -2306,10 +2315,16 @@ class MediaInfo: @typing.overload def get_poster_image(self) -> str: + """ + 按调用方指定的默认图策略获取海报图片。 + """ ... @typing.overload def get_poster_image(self, default: Optional[bool]) -> Union[str, bool]: + """ + 按调用方指定的默认图策略获取海报图片。 + """ ... def get_poster_image(self, default: Optional[bool] = None) -> Union[str, bool]: @@ -2407,7 +2422,7 @@ class Context: meta_info: Optional[MetaBase] = None media_info: Optional[Union[MediaInfo, MusicInfo]] = None - torrent_info: TorrentInfo = None + torrent_info: Optional[TorrentInfo] = None media_recognize_fail_count: int = 0 resource_source: str = "unknown" match_source: str = "unknown" diff --git a/app/domain/meta/metabase.py b/app/domain/meta/metabase.py index 5c8dfaabc..c40c42b55 100644 --- a/app/domain/meta/metabase.py +++ b/app/domain/meta/metabase.py @@ -105,7 +105,7 @@ class MetaInfoSnapshot: cn_name: Optional[str] en_name: Optional[str] original_name: Optional[str] - year: Optional[str] + year: Optional[Union[str, int]] total_season: int begin_season: Optional[int] end_season: Optional[int] @@ -213,14 +213,14 @@ class MetaBase(object): :ivar fps: 帧率信息(纯数值) """ isfile: bool = False - title: str = "" + title: Optional[str] = "" org_string: Optional[str] = None subtitle: Optional[str] = None type: MediaType = MediaType.UNKNOWN cn_name: Optional[str] = None en_name: Optional[str] = None original_name: Optional[str] = None - year: Optional[str] = None + year: Optional[Union[str, int]] = None total_season: int = 0 begin_season: Optional[int] = None end_season: Optional[int] = None @@ -369,10 +369,10 @@ class MetaBase(object): # 第x-x集 第x集-x集 episode_between_str = SUBTITLE_EPISODE_BETWEEN_RE.search(title_text) if episode_between_str: - episodes = episode_between_str.groups() - if episodes: - begin_episode = episodes[0] - end_episode = episodes[1] + episode_range = episode_between_str.groups() + if episode_range: + begin_episode = episode_range[0] + end_episode = episode_range[1] else: return try: @@ -408,10 +408,10 @@ class MetaBase(object): try: end_episode = None if episodes.find('-') != -1: - episodes = episodes.split('-') - begin_episode = int(cn2an.cn2an(episodes[0].strip(), mode='smart')) - if len(episodes) > 1: - end_episode = int(cn2an.cn2an(episodes[1].strip(), mode='smart')) + episode_parts = episodes.split('-') + begin_episode = int(cn2an.cn2an(episode_parts[0].strip(), mode='smart')) + if len(episode_parts) > 1: + end_episode = int(cn2an.cn2an(episode_parts[1].strip(), mode='smart')) else: begin_episode = int(cn2an.cn2an(episodes, mode='smart')) except Exception as err: @@ -480,7 +480,7 @@ class MetaBase(object): self._subtitle_flag = True @property - def season(self) -> str: + def season(self) -> Optional[str]: """ 返回开始季、结束季字符串,确定是剧集没有季的返回S01。 """ @@ -502,7 +502,7 @@ class MetaBase(object): 返回开始季字符串,确定是剧集没有季的返回空。 """ if self.begin_season is not None: - return self.season + return self.season or "" else: return "" @@ -535,7 +535,7 @@ class MetaBase(object): return [self.begin_season] @property - def episode(self) -> str: + def episode(self) -> Optional[str]: """ 返回开始集、结束集字符串。 """ @@ -688,24 +688,18 @@ class MetaBase(object): :param season: 单季或季列表;未标季数的资源按第 1 季处理 :return: 全部指定季均在识别范围内时为 True """ + begin_season = self.begin_season if self.begin_season is not None else 1 if isinstance(season, list): if self.end_season is not None: - meta_season = list(range(self.begin_season, self.end_season + 1)) + meta_season = list(range(begin_season, self.end_season + 1)) else: - if self.begin_season is not None: - meta_season = [self.begin_season] - else: - meta_season = [1] + meta_season = [begin_season] return set(meta_season).issuperset(set(season)) else: if self.end_season is not None: - return self.begin_season <= int(season) <= self.end_season - else: - if self.begin_season is not None: - return int(season) == self.begin_season - else: - return int(season) == 1 + return begin_season <= int(season) <= self.end_season + return int(season) == begin_season def is_in_episode(self, episode: Union[list[Union[int, str]], int, str]) -> bool: """ @@ -714,17 +708,19 @@ class MetaBase(object): :param episode: 单集或集列表 :return: 全部指定集均在识别范围内时为 True """ + begin_episode = self.begin_episode + if begin_episode is None: + return isinstance(episode, list) and not episode if isinstance(episode, list): if self.end_episode is not None: - meta_episode = list(range(self.begin_episode, self.end_episode + 1)) + meta_episode = list(range(begin_episode, self.end_episode + 1)) else: - meta_episode = [self.begin_episode] + meta_episode = [begin_episode] return set(meta_episode).issuperset(set(episode)) else: if self.end_episode is not None: - return self.begin_episode <= int(episode) <= self.end_episode - else: - return int(episode) == self.begin_episode + return begin_episode <= int(episode) <= self.end_episode + return int(episode) == begin_episode def set_season(self, sea: Union[list[Union[int, str]], int, str]) -> None: """ diff --git a/app/domain/meta/metamusic.py b/app/domain/meta/metamusic.py index 6838f3867..f9a3bb088 100644 --- a/app/domain/meta/metamusic.py +++ b/app/domain/meta/metamusic.py @@ -913,8 +913,12 @@ def _music_pinyin_states() -> dict[str, str]: class MetaMusic(MetaBase): """ 音乐文件名及音频标签解析结果,作为 MetaBase 的音乐分支实现。 + + :ivar year: 音乐发行年份,未知时为 None """ + year: Optional[int] = None + def __init__( self, org_string: Optional[str] = None, @@ -1359,13 +1363,13 @@ class MetaMusic(MetaBase): threshold = max(2, len(tracks) // 2) album = majority_album if majority_album and album_votes[majority_album] >= threshold else None artist = majority_artist if majority_artist and artist_votes[majority_artist] >= threshold else None + album_artist = artist or directory.get("artist") return cls( org_string=directory_name, music_type=MUSIC_ENTITY_ALBUM, title=album or directory.get("album") or directory_name, album=album or directory.get("album"), - artists=[artist or directory.get("artist")] - if artist or directory.get("artist") else [], + artists=[album_artist] if album_artist else [], album_artist=artist or directory.get("artist"), year=directory.get("year"), version=directory.get("version"), @@ -1386,12 +1390,19 @@ class MetaMusic(MetaBase): self.title = value or None @property - def original_name(self) -> str: + def original_name(self) -> Optional[str]: """ 返回未经过通用识别词处理的原始名称,兼容影视识别链的公共访问。 """ return self.org_string or self.title or self.album or "" + @original_name.setter + def original_name(self, value: Optional[str]) -> None: + """ + 接受公共识别流程回写的原名,空值保留音乐元数据已有的名称回退规则。 + """ + self.org_string = value + @property def artist(self) -> str: """ diff --git a/app/domain/meta/runtime.py b/app/domain/meta/runtime.py index 640c8edd1..c25b17706 100644 --- a/app/domain/meta/runtime.py +++ b/app/domain/meta/runtime.py @@ -2,7 +2,7 @@ 声明识别加速器契约,并保存启动层注入的文件类型与加速能力。 """ -from typing import Callable, Optional, Protocol, Sequence +from typing import Any, Callable, Optional, Protocol, Sequence class MetaInfoAccelerator(Protocol): @@ -15,7 +15,7 @@ class MetaInfoAccelerator(Protocol): title: str, subtitle: Optional[str] = None, options: Optional[dict[str, object]] = None, - ) -> Optional[dict]: + ) -> Optional[dict[str, Any]]: """ 解析单个标题,无法处理时返回空值。 @@ -29,7 +29,7 @@ class MetaInfoAccelerator(Protocol): self, path: str, options: Optional[dict[str, object]] = None, - ) -> Optional[dict]: + ) -> Optional[dict[str, Any]]: """ 解析文件路径,无法处理时返回空值。 @@ -43,7 +43,7 @@ class MetaInfoAccelerator(Protocol): title: str, artists: Optional[list[str]] = None, year: Optional[int] = None, - ) -> Optional[dict]: + ) -> Optional[dict[str, Any]]: """ 解析音乐标题,无法处理时返回空值。 @@ -53,7 +53,7 @@ class MetaInfoAccelerator(Protocol): :return: 解析出的音乐字段字典;无法处理时返回 None """ - def find_metainfo(self, title: str) -> Optional[dict]: + def find_metainfo(self, title: str) -> Optional[dict[str, Any]]: """ 提取标题中的显式媒体标签。 diff --git a/app/domain/meta/words.py b/app/domain/meta/words.py index 9f6e82cfd..51f78e594 100644 --- a/app/domain/meta/words.py +++ b/app/domain/meta/words.py @@ -114,7 +114,7 @@ def _format_episode_offset(episode_num_str: str, episode_num_offset_int: int) -> 按原集数字符串格式返回偏移后的集数字符串。 """ if not episode_num_str.isdigit(): - return cn2an.an2cn(episode_num_offset_int, "low") + return str(cn2an.an2cn(episode_num_offset_int, "low")) width = len(episode_num_str) if _LEADING_ZERO_RE.search(episode_num_str) else 0 if episode_num_offset_int < 0: return f"-{str(abs(episode_num_offset_int)).zfill(width)}" @@ -168,7 +168,10 @@ class WordsMatcher(metaclass=Singleton): 按识别词顺序处理标题,并在需要时更新副标题中的集数。 """ appley_words = [] - words: List[str] = custom_words or get_custom_words() or [] + configured_words = get_custom_words() + words: List[str] = custom_words or ( + configured_words if isinstance(configured_words, list) else [] + ) for word in words: if not word or word.startswith("#"): continue diff --git a/app/domain/metainfo.py b/app/domain/metainfo.py index 91ca470f9..7d80af239 100644 --- a/app/domain/metainfo.py +++ b/app/domain/metainfo.py @@ -119,7 +119,7 @@ class _PreparedMetaInput: explicit_metainfo: Mapping[str, Any] -def _empty_metainfo() -> dict: +def _empty_metainfo() -> dict[str, Any]: """ 返回媒体标签的默认结构,避免不同识别请求之间共享可变状态。 """ @@ -137,7 +137,7 @@ def _empty_metainfo() -> dict: } -def _normalize_metainfo_identity(metainfo: dict) -> dict: +def _normalize_metainfo_identity(metainfo: dict[str, Any]) -> dict[str, Any]: """ 将解析器输出归一为唯一媒体身份,并移除历史来源专用字段。 @@ -159,7 +159,9 @@ def _normalize_metainfo_identity(metainfo: dict) -> dict: return normalized -def _apply_range_total(metainfo: dict, begin_key: str, end_key: str, total_key: str) -> None: +def _apply_range_total( + metainfo: dict[str, Any], begin_key: str, end_key: str, total_key: str +) -> None: """ 计算季/集范围总数;保留原有倒序输入自动交换的兼容行为。 """ @@ -171,7 +173,7 @@ def _apply_range_total(metainfo: dict, begin_key: str, end_key: str, total_key: metainfo[total_key] = 1 -def _rust_parse_options_cache_key(options: dict) -> str: +def _rust_parse_options_cache_key(options: dict[str, Any]) -> str: """ 生成 Rust Meta 配置缓存键,避免扩展层每次重新展开大配置。 """ @@ -196,7 +198,7 @@ def _rust_parse_options_cache_key(options: dict) -> str: return digest.hexdigest() -def _find_metainfo_python(title: str) -> Tuple[str, dict]: +def _find_metainfo_python(title: str) -> Tuple[str, dict[str, Any]]: """ 使用 Python 解析标题中的显式媒体标签,作为 Rust 入口不可用时的兜底。 """ @@ -390,7 +392,7 @@ def _build_python_meta_info( @lru_cache(maxsize=1) -def _rust_default_parse_options() -> dict: +def _rust_default_parse_options() -> dict[str, Any]: """ 缓存 Rust Meta 默认解析配置,避免热路径反复读取配置并复制流媒体平台大表。 """ @@ -414,7 +416,7 @@ def _rust_default_parse_options() -> dict: @lru_cache(maxsize=256) -def _rust_custom_parse_options(custom_words: Tuple[str, ...]) -> dict: +def _rust_custom_parse_options(custom_words: Tuple[str, ...]) -> dict[str, Any]: """ 缓存带自定义识别词的 Rust Meta 配置,避免同一组识别词重复构造配置对象。 """ @@ -424,7 +426,7 @@ def _rust_custom_parse_options(custom_words: Tuple[str, ...]) -> dict: return options -def _rust_parse_options(custom_words: Optional[List[str]] = None) -> dict: +def _rust_parse_options(custom_words: Optional[List[str]] = None) -> dict[str, Any]: """ 收集 Rust Meta 解析所需的运行时配置,避免 Rust 层直接访问数据库和 settings。 @@ -443,7 +445,7 @@ def clear_rust_parse_options_cache() -> None: _rust_custom_parse_options.cache_clear() -def _meta_from_rust(parsed: Optional[dict]) -> Optional[MetaBase]: +def _meta_from_rust(parsed: Optional[dict[str, Any]]) -> Optional[MetaBase]: """ 将 Rust 解析结果灌回现有 MetaVideo/MetaAnime 对象,保留下游属性和方法兼容性。 """ @@ -631,7 +633,7 @@ def is_anime(name: str) -> bool: return False -def find_metainfo(title: str) -> Tuple[str, dict]: +def find_metainfo(title: str) -> Tuple[str, dict[str, Any]]: """ 从标题中提取媒体信息。 """ diff --git a/app/factory.py b/app/factory.py index 0b002945b..b052bea60 100644 --- a/app/factory.py +++ b/app/factory.py @@ -3,13 +3,15 @@ """ import json -from typing import Any, Awaitable, Callable +from collections.abc import Mapping +from typing import Any, Awaitable, Callable, cast from fastapi import FastAPI, Request, Response from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from starlette.exceptions import HTTPException +from starlette.types import ExceptionHandler from app.adapters.observability.otel import build_observation_port from app.adapters.web.correlation import CorrelationIdMiddleware @@ -98,7 +100,7 @@ def _native_ai_error_response( protocol: str, status_code: int, message: str, - headers: dict[str, str] | None = None, + headers: Mapping[str, str] | None = None, ) -> JSONResponse: """ 按 OpenAI 或 Anthropic 兼容协议构造原生错误响应。 @@ -143,7 +145,7 @@ def _mcp_jsonrpc_error_response( status_code: int, code: int, message: str, - headers: dict[str, str] | None = None, + headers: Mapping[str, str] | None = None, ) -> JSONResponse: """ 构造带 HTTP 状态码的 MCP JSON-RPC 原生错误响应。 @@ -380,14 +382,14 @@ def create_app() -> FastAPI: lifespan=lifespan ) - _app.add_exception_handler(HTTPException, localized_http_exception_handler) + _app.add_exception_handler(HTTPException, cast(ExceptionHandler, localized_http_exception_handler)) _app.add_exception_handler( PersistenceUnavailableError, - persistence_unavailable_handler, + cast(ExceptionHandler, persistence_unavailable_handler), ) _app.add_exception_handler( RequestValidationError, - localized_validation_exception_handler, + cast(ExceptionHandler, localized_validation_exception_handler), ) _app.add_exception_handler(Exception, localized_unhandled_exception_handler) # 主程序静态路由统一使用 ResponseAPIRoute;动态插件注册时会显式覆盖为原生 APIRoute。 diff --git a/app/locales/en-US.json b/app/locales/en-US.json index a966bfa39..aa7a5531b 100644 --- a/app/locales/en-US.json +++ b/app/locales/en-US.json @@ -587,7 +587,12 @@ "未配置 Follow 订阅用户,跳过刷新": "No Follow subscription users configured, skipping refresh", "订阅日历预缓存完成": "Subscription calendar precache completed", "订阅活动暂时不可用": "Subscription activity is temporarily unavailable", - "活动不存在或无权查看": "Activity does not exist or access is denied" + "活动不存在或无权查看": "Activity does not exist or access is denied", + "文件路径为空": "File path is empty", + "源文件路径为空": "Source file path is empty", + "未识别到媒体类型": "Media type could not be identified", + "站点域名未配置": "Site domain is not configured", + "站点配置不完整": "Site configuration is incomplete" }, "message_patterns": [ { diff --git a/app/modules/_base/media.py b/app/modules/_base/media.py index 6af70054a..a06764cd7 100644 --- a/app/modules/_base/media.py +++ b/app/modules/_base/media.py @@ -45,7 +45,7 @@ class MediaAuxiliaryProviderMixin: if meta.begin_season is not None and mediainfo.season_years: season_year = ( mediainfo.season_years.get(meta.begin_season) - or mediainfo.season_years.get(str(meta.begin_season)) + or mediainfo.season_years.get(int(meta.begin_season)) ) meta.year = season_year or mediainfo.year or getattr(metainfo, "year", None) return meta diff --git a/app/modules/_base/mediaserver.py b/app/modules/_base/mediaserver.py index 205d3bacf..be0dbc158 100644 --- a/app/modules/_base/mediaserver.py +++ b/app/modules/_base/mediaserver.py @@ -5,10 +5,10 @@ 定时重连与连接测试。服务器差异(认证 API、存在性检查端点、连接探测方式) 通过类属性与钩子方法保留在各模块。 """ -from typing import Iterable, Optional, Tuple +from typing import Any, Iterable, Optional, Tuple, cast from app.application.mediaserver import MusicMediaServerHelper -from app.domain.context import MediaInfo +from app.domain.context import MediaInfo, MusicInfo from app.modules import TService, _MediaServerBase, _ModuleBase from app.runtime.events import eventmanager from app.runtime.log import logger @@ -85,23 +85,29 @@ class _MediaServerModuleBase(_ModuleBase, _MediaServerBase[TService]): servers = self.get_instances().items() # 遍历要认证的服务器 for name, server in servers: + service_name = name or "" + channel_name = self.get_name() or "" # 触发认证拦截事件 intercept_event = eventmanager.send_event( etype=ChainEventType.AuthIntercept, data=_SchemaAuthInterceptCredentials( username=credentials.username, - channel=self.get_name(), - service=name, + channel=channel_name, + service=service_name, status="triggered", ), ) if intercept_event and intercept_event.event_data: - intercept_data: _SchemaAuthInterceptCredentials = intercept_event.event_data + intercept_data = ( + intercept_event.event_data + if isinstance(intercept_event.event_data, _SchemaAuthInterceptCredentials) + else _SchemaAuthInterceptCredentials.model_validate(intercept_event.event_data) + ) if intercept_data.cancel: continue - token = server.authenticate(credentials.username, credentials.password) + token = cast(Any, server).authenticate(credentials.username, credentials.password) if token: - credentials.channel = self.get_name() + credentials.channel = channel_name credentials.service = name credentials.token = token return credentials @@ -136,7 +142,9 @@ class _MediaServerModuleBase(_ModuleBase, _MediaServerBase[TService]): continue if mediainfo.type == MediaType.MUSIC: # 部分服务器未实现音乐查询,退化为空列表 - matches = getattr(s, "get_music", lambda **_: [])( + if not isinstance(mediainfo, MusicInfo): + continue + matches: list[Any] = getattr(s, "get_music", lambda **_: [])( **MusicMediaServerHelper.search_params(mediainfo), **({"query": query.with_fields(MediaServerField.MUSIC)} if query is not None else {}), ) @@ -149,15 +157,16 @@ class _MediaServerModuleBase(_ModuleBase, _MediaServerBase[TService]): itemid=match.item_id, ) continue + service = cast(Any, s) if mediainfo.type == MediaType.MOVIE: if itemid: - movie = s.get_iteminfo(itemid, **({"query": query} if query is not None else {})) + movie = service.get_iteminfo(itemid, **({"query": query} if query is not None else {})) if movie: logger.info(f"媒体库 {name} 中找到了 {movie}") return _SchemaExistMediaInfo( type=MediaType.MOVIE, server_type=self._server_type_value, server=name, itemid=movie.item_id ) - movies = s.get_movies( + movies = service.get_movies( title=mediainfo.title, year=mediainfo.year, media_source=mediainfo.media_source, @@ -173,7 +182,7 @@ class _MediaServerModuleBase(_ModuleBase, _MediaServerBase[TService]): type=MediaType.MOVIE, server_type=self._server_type_value, server=name, itemid=movies[0].item_id ) else: - itemid, tvs = s.get_tv_episodes( + itemid, tvs = service.get_tv_episodes( title=mediainfo.title, year=mediainfo.year, media_source=mediainfo.media_source, @@ -201,13 +210,13 @@ class _MediaServerModuleBase(_ModuleBase, _MediaServerBase[TService]): for name, server in self.get_instances().items(): if self._is_inactive(server): logger.info(f"{self.get_name()}服务器 {name} 连接断开,尝试重连 ...") - server.reconnect() + cast(Any, server).reconnect() - def _is_inactive(self, server) -> bool: + def _is_inactive(self, server: Any) -> bool: """ 定时重连的失活判断钩子,子类可覆写(如增加配置完整性检查)。 """ - return server.is_inactive() + return bool(server.is_inactive()) def test(self) -> Optional[Tuple[bool, str]]: """ @@ -223,7 +232,7 @@ class _MediaServerModuleBase(_ModuleBase, _MediaServerBase[TService]): return False, error return True, "" - def _test_server(self, server, name: Optional[str]) -> Optional[str]: + def _test_server(self, server: Any, name: Optional[str]) -> Optional[str]: """ 连接测试钩子,返回失败信息,None 表示就绪,子类可覆写。 """ diff --git a/app/modules/_base/notification.py b/app/modules/_base/notification.py index 34caea76a..c41cccede 100644 --- a/app/modules/_base/notification.py +++ b/app/modules/_base/notification.py @@ -6,7 +6,7 @@ 类属性与钩子方法保留在各模块。 """ import copy -from typing import Dict, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple, Union, cast from app.application.messaging.channel.admin import ( matches_channel_admin, @@ -31,7 +31,7 @@ class _MessageChannelModuleBase(_ModuleBase, _MessageBase[TService]): _command_origin: Optional[str] = None @classmethod - def _get_admins(cls, config: Optional[dict]) -> List[str]: + def _get_admins(cls, config: Optional[dict[str, Any]]) -> List[str]: """ 解析渠道管理员配置,兼容逗号分隔和首尾空白。 """ @@ -39,7 +39,7 @@ class _MessageChannelModuleBase(_ModuleBase, _MessageBase[TService]): def _should_reject_admin_command( self, - config: Optional[dict], + config: Optional[dict[str, Any]], *user_ids: Optional[Union[str, int]], ) -> bool: """ @@ -50,7 +50,7 @@ class _MessageChannelModuleBase(_ModuleBase, _MessageBase[TService]): # 模块实例未初始化时 self._channel 为空,退回静态子类型声明 channel = self._channel or self.get_subtype() return not matches_channel_admin( - channel, + cast(Any, channel), config, *user_ids, ) @@ -70,7 +70,7 @@ class _MessageChannelModuleBase(_ModuleBase, _MessageBase[TService]): return False, f"{self.get_name()} {name} 未就绪{suffix}" return True, "" - def _test_connection(self, client) -> Tuple[bool, str]: + def _test_connection(self, client: Any) -> Tuple[bool, str]: """ 连接测试钩子,返回 (是否就绪, 失败信息),子类可覆写。 """ @@ -93,7 +93,7 @@ class _MessageChannelModuleBase(_ModuleBase, _MessageBase[TService]): converged = False return converged - def register_commands(self, commands: Dict[str, dict]) -> None: + def register_commands(self, commands: Dict[str, dict[str, Any]]) -> None: """ 为每个启用命令菜单的渠道实例注册可用命令。 @@ -116,14 +116,18 @@ class _MessageChannelModuleBase(_ModuleBase, _MessageBase[TService]): ChainEventType.CommandRegister, CommandRegisterEventData( commands=scoped_commands, - origin=self._command_origin or self.get_name(), - service=client_config.name, + origin=self._command_origin or self.get_name() or "", + service=client_config.name or "", ), ) # 如果事件返回有效的 event_data,使用事件中调整后的命令 if event and event.event_data: - event_data: CommandRegisterEventData = event.event_data + event_data = ( + event.event_data + if isinstance(event.event_data, CommandRegisterEventData) + else CommandRegisterEventData.model_validate(event.event_data) + ) # 如果事件被取消,跳过命令注册,并清理菜单 if event_data.cancel: self._delete_commands(client) @@ -153,19 +157,21 @@ class _MessageChannelModuleBase(_ModuleBase, _MessageBase[TService]): ) self._apply_commands(client, filtered_scoped_commands) - def _commands_enabled(self, config: Optional[dict]) -> bool: + def _commands_enabled(self, config: Optional[dict[str, Any]]) -> bool: """ 命令注册前置条件钩子,返回 False 时跳过该实例,子类可覆写。 """ return True - def _delete_commands(self, client) -> None: + def _delete_commands(self, client: Any) -> None: """ 清理已注册命令的钩子,子类可覆写(如改用菜单 API)。 """ client.delete_commands() - def _apply_commands(self, client, commands: Dict[str, dict]) -> None: + def _apply_commands( + self, client: Any, commands: Dict[str, dict[str, Any]] + ) -> None: """ 应用命令集合的钩子,子类可覆写(如改用菜单 API)。 """ diff --git a/app/modules/anilist/__init__.py b/app/modules/anilist/__init__.py index 0763b8f7b..6776530ef 100644 --- a/app/modules/anilist/__init__.py +++ b/app/modules/anilist/__init__.py @@ -15,6 +15,7 @@ from app.modules._base.media import MediaAuxiliaryProviderMixin from app.modules.anilist.anilist import AniListApi from app.runtime.log import logger from app.runtime.settings import get_runtime_setting +from app.schemas.context import MediaImageSet from app.schemas.context import MediaPerson as _SchemaMediaPerson from app.schemas.types import ( MediaRecognizeType, @@ -72,8 +73,30 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): auxiliary_media_source = MediaSource.AniList CONFIG_WATCH = {"PROXY_HOST"} - anilist_api: AniListApi = None - scraper: MediaScraperHelper = None + anilist_api: Optional[AniListApi] = None + scraper: Optional[MediaScraperHelper] = None + + def _require_api(self) -> AniListApi: + """ + 取得已激活的 AniList 客户端,避免把尚未初始化的兼容属性当作有效实例。 + + :return: 模块初始化时创建的客户端 + :raises RuntimeError: 模块尚未初始化 + """ + if self.anilist_api is None: + raise RuntimeError("AniList 模块尚未初始化") + return self.anilist_api + + def _require_scraper(self) -> MediaScraperHelper: + """ + 取得模块初始化时创建的刮削器。 + + :return: 已初始化的刮削器 + :raises RuntimeError: 模块尚未初始化 + """ + if self.scraper is None: + raise RuntimeError("AniList 刮削器尚未初始化") + return self.scraper def init_module(self) -> None: """ @@ -82,7 +105,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): self.anilist_api = AniListApi() self.scraper = MediaScraperHelper() - def init_setting(self) -> Tuple[str, Union[str, bool]]: + def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]: """ AniList 模块无需独立开关。 """ @@ -98,7 +121,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): """ 测试 AniList GraphQL API 连通性。 """ - result = self.anilist_api.search("Cowboy Bebop", count=1) + result = self._require_api().search("Cowboy Bebop", count=1) return (True, "") if result else (False, "AniList网络连接失败") @staticmethod @@ -182,7 +205,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): ) @staticmethod - def _media_type(info: dict) -> MediaType: + def _media_type(info: dict[str, Any]) -> MediaType: """ 将 AniList 发布格式转换为系统媒体类型。 @@ -192,7 +215,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): return MediaType.MOVIE if info.get("format") == "MOVIE" else MediaType.TV @classmethod - def _matches_meta(cls, meta: MetaBase, info: dict) -> bool: + def _matches_meta(cls, meta: MetaBase, info: dict[str, Any]) -> bool: """ 判断 AniList 候选项是否符合标题解析出的类型与年份。 @@ -247,7 +270,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): return [MediaInfo(anilist_info=info) for info in infos] @staticmethod - def _enrich_people(info: dict) -> dict: + def _enrich_people(info: dict[str, Any]) -> dict[str, Any]: """ 将 AniList 人物连接转换为统一媒体信息所需的演职员结构。 @@ -294,7 +317,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): return enriched @staticmethod - def _person_name(name_info: dict) -> Optional[str]: + def _person_name(name_info: dict[str, Any]) -> Optional[str]: """ 按原语言、通用名顺序选择 AniList 人物姓名。 @@ -304,7 +327,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): return name_info.get("native") or name_info.get("full") @staticmethod - def _person_date(date_info: dict) -> Optional[str]: + def _person_date(date_info: dict[str, Any]) -> Optional[str]: """ 将 AniList 人物模糊日期转换为标准日期文本。 @@ -314,7 +337,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): return format_anilist_date(date_info) @classmethod - def _build_credit_person(cls, edge: dict) -> Optional[_SchemaMediaPerson]: + def _build_credit_person(cls, edge: dict[str, Any]) -> Optional[_SchemaMediaPerson]: """ 将 AniList 角色配音关系转换为统一人物信息。 @@ -327,6 +350,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): name_info = actor.get("name") or {} character_name = (edge.get("node") or {}).get("name") or {} images = actor.get("image") or {} + image_set = MediaImageSet(**images) if isinstance(images, dict) else None return _SchemaMediaPerson( source="anilist", id=actor.get("id"), @@ -334,13 +358,13 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): original_name=name_info.get("full"), also_known_as=name_info.get("alternative") or [], character=character_name.get("native") or character_name.get("full"), - images=images, - avatar=images, + images=image_set, + avatar=image_set, url=actor.get("siteUrl"), ) @classmethod - def _build_person_detail(cls, info: dict) -> _SchemaMediaPerson: + def _build_person_detail(cls, info: dict[str, Any]) -> _SchemaMediaPerson: """ 将 AniList 人物详情转换为统一人物信息。 @@ -349,14 +373,15 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): """ name_info = info.get("name") or {} images = info.get("image") or {} + image_set = MediaImageSet(**images) if isinstance(images, dict) else None return _SchemaMediaPerson( source="anilist", id=info.get("id"), name=cls._person_name(name_info), original_name=name_info.get("full"), also_known_as=name_info.get("alternative") or [], - images=images, - avatar=images, + images=image_set, + avatar=image_set, biography=info.get("description"), birthday=cls._person_date(info.get("dateOfBirth") or {}), deathday=cls._person_date(info.get("dateOfDeath") or {}), @@ -371,7 +396,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): meta: Optional[MetaBase] = None, media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, - **kwargs, + **kwargs: Any, ) -> Optional[MediaInfo]: """ 按 AniList 标识或标题线索识别动画。 @@ -388,7 +413,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): if not plan: return None info = ( - self.anilist_api.detail(plan.media_id) + self._require_api().detail(plan.media_id) if plan.media_id is not None else self._match_by_meta(plan.require_meta()) ) @@ -396,7 +421,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): return None mediainfo = self._build_recognized_media(info, plan.meta) logger.info( - f"{plan.label} AniList识别结果:{mediainfo.type.value} " + f"{plan.label} AniList识别结果:{mediainfo.type.value if mediainfo.type else ''} " f"{mediainfo.title_year}" ) return mediainfo @@ -406,7 +431,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): meta: Optional[MetaBase] = None, media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, - **kwargs, + **kwargs: Any, ) -> Optional[MediaInfo]: """ 异步按 AniList 标识或标题线索识别动画。 @@ -423,7 +448,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): if not plan: return None info = ( - await self.anilist_api.async_detail(plan.media_id) + await self._require_api().async_detail(plan.media_id) if plan.media_id is not None else await self._async_match_by_meta(plan.require_meta()) ) @@ -431,28 +456,28 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): return None mediainfo = self._build_recognized_media(info, plan.meta) logger.info( - f"{plan.label} AniList识别结果:{mediainfo.type.value} " + f"{plan.label} AniList识别结果:{mediainfo.type.value if mediainfo.type else ''} " f"{mediainfo.title_year}" ) return mediainfo - def _match_by_meta(self, meta: MetaBase) -> Optional[dict]: + def _match_by_meta(self, meta: MetaBase) -> Optional[dict[str, Any]]: """ 同步搜索并筛选最符合标题解析结果的 AniList 条目。 :param meta: 标题解析元数据 :return: AniList 媒体详情 """ - return self._select_matching_info(meta, self.anilist_api.search(meta.name)) + return self._select_matching_info(meta, self._require_api().search(meta.name)) - async def _async_match_by_meta(self, meta: MetaBase) -> Optional[dict]: + async def _async_match_by_meta(self, meta: MetaBase) -> Optional[dict[str, Any]]: """ 异步搜索并筛选最符合标题解析结果的 AniList 条目。 :param meta: 标题解析元数据 :return: AniList 媒体详情 """ - infos = await self.anilist_api.async_search(meta.name) + infos = await self._require_api().async_search(meta.name) return self._select_matching_info(meta, infos) def search_medias( @@ -471,7 +496,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): if not plan.meta: return [] return self._project_search_results( - plan.meta, self.anilist_api.search(plan.meta.name) + plan.meta, self._require_api().search(plan.meta.name) ) async def async_search_medias( @@ -489,26 +514,26 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): return None if not plan.meta: return [] - infos = await self.anilist_api.async_search(plan.meta.name) + infos = await self._require_api().async_search(plan.meta.name) return self._project_search_results(plan.meta, infos) - def anilist_info(self, anilist_id: int) -> Optional[dict]: + def anilist_info(self, anilist_id: int) -> Optional[dict[str, Any]]: """ 读取原始 AniList 动画详情。 :param anilist_id: AniList 动画条目标识 :return: 原始详情映射;标识为空或请求失败时为 None """ - return self.anilist_api.detail(anilist_id) if anilist_id else None + return self._require_api().detail(anilist_id) if anilist_id else None - async def async_anilist_info(self, anilist_id: int) -> Optional[dict]: + async def async_anilist_info(self, anilist_id: int) -> Optional[dict[str, Any]]: """ 异步读取原始 AniList 动画详情。 :param anilist_id: AniList 动画条目标识 :return: 原始详情映射;标识为空或请求失败时为 None """ - return await self.anilist_api.async_detail(anilist_id) if anilist_id else None + return await self._require_api().async_detail(anilist_id) if anilist_id else None def anilist_trending(self, page: int = 1, count: int = 20) -> List[MediaInfo]: """ @@ -519,7 +544,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): :return: 统一媒体列表;请求失败时为空列表 """ return self._project_media_list( - self.anilist_api.trending(page=page, count=count) + self._require_api().trending(page=page, count=count) ) async def async_anilist_trending(self, page: int = 1, count: int = 20) -> List[MediaInfo]: @@ -530,7 +555,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param count: 每页条数,原样传入服务端,不在本地截断范围 :return: 统一媒体列表;请求失败时为空列表 """ - infos = await self.anilist_api.async_trending(page=page, count=count) + infos = await self._require_api().async_trending(page=page, count=count) return self._project_media_list(infos) def anilist_popular_this_season(self, page: int = 1, count: int = 20) -> List[MediaInfo]: @@ -542,7 +567,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): :return: 统一媒体列表;请求失败时为空列表 """ return self._project_media_list( - self.anilist_api.popular_this_season(page=page, count=count) + self._require_api().popular_this_season(page=page, count=count) ) async def async_anilist_popular_this_season( @@ -555,26 +580,26 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param count: 每页条数,原样传入服务端,不在本地截断范围 :return: 统一媒体列表;请求失败时为空列表 """ - infos = await self.anilist_api.async_popular_this_season(page=page, count=count) + infos = await self._require_api().async_popular_this_season(page=page, count=count) return self._project_media_list(infos) - def anilist_discover(self, **kwargs) -> List[MediaInfo]: + def anilist_discover(self, **kwargs: Any) -> List[MediaInfo]: """ 按组合条件探索动画。 :param kwargs: 传入客户端的分页与筛选条件,如 page、count、genre、season_year :return: 统一媒体列表;无结果或请求失败时为空列表 """ - return self._project_media_list(self.anilist_api.discover(**kwargs)) + return self._project_media_list(self._require_api().discover(**kwargs)) - async def async_anilist_discover(self, **kwargs) -> List[MediaInfo]: + async def async_anilist_discover(self, **kwargs: Any) -> List[MediaInfo]: """ 异步按组合条件探索动画。 :param kwargs: 传入客户端的分页与筛选条件,如 page、count、genre、season_year :return: 统一媒体列表;无结果或请求失败时为空列表 """ - infos = await self.anilist_api.async_discover(**kwargs) + infos = await self._require_api().async_discover(**kwargs) return self._project_media_list(infos) def anilist_credits( @@ -590,7 +615,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): """ persons = ( self._build_credit_person(edge) - for edge in self.anilist_api.credits(anilist_id, page=page, count=count) + for edge in self._require_api().credits(anilist_id, page=page, count=count) ) return [person for person in persons if person] @@ -605,7 +630,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param count: 每页条数,原样传入服务端,不在本地截断范围 :return: 统一人物列表,跳过无法转换的角色关系;无结果时为空列表 """ - edges = await self.anilist_api.async_credits(anilist_id, page=page, count=count) + edges = await self._require_api().async_credits(anilist_id, page=page, count=count) persons = (self._build_credit_person(edge) for edge in edges) return [person for person in persons if person] @@ -620,7 +645,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param count: 每页条数,原样传入服务端,不在本地截断范围 :return: 按推荐顺序排列的统一媒体列表;无结果时为空列表 """ - infos = self.anilist_api.recommendations(anilist_id, page=page, count=count) + infos = self._require_api().recommendations(anilist_id, page=page, count=count) return self._project_media_list(infos) async def async_anilist_recommendations( @@ -634,7 +659,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param count: 每页条数,原样传入服务端,不在本地截断范围 :return: 按推荐顺序排列的统一媒体列表;无结果时为空列表 """ - infos = await self.anilist_api.async_recommendations( + infos = await self._require_api().async_recommendations( anilist_id, page=page, count=count ) return self._project_media_list(infos) @@ -646,7 +671,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param person_id: AniList 人物标识 :return: 统一人物模型;无结果或查询失败时为 None """ - info = self.anilist_api.person_detail(person_id) + info = self._require_api().person_detail(person_id) return self._build_person_detail(info) if info else None async def async_anilist_person_detail( @@ -658,7 +683,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param person_id: AniList 人物标识 :return: 统一人物模型;无结果或查询失败时为 None """ - info = await self.anilist_api.async_person_detail(person_id) + info = await self._require_api().async_person_detail(person_id) return self._build_person_detail(info) if info else None def anilist_person_credits( @@ -672,7 +697,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param count: 每页条数,原样传入服务端,不在本地截断范围 :return: 统一媒体列表;无结果或查询失败时为空列表 """ - infos = self.anilist_api.person_credits(person_id, page=page, count=count) + infos = self._require_api().person_credits(person_id, page=page, count=count) return self._project_media_list(infos) async def async_anilist_person_credits( @@ -686,7 +711,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param count: 每页条数,原样传入服务端,不在本地截断范围 :return: 统一媒体列表;无结果或查询失败时为空列表 """ - infos = await self.anilist_api.async_person_credits( + infos = await self._require_api().async_person_credits( person_id, page=page, count=count ) return self._project_media_list(infos) @@ -696,7 +721,7 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): mediainfo: MediaInfo, season: Optional[int] = None, episode: Optional[int] = None, - **kwargs, + **kwargs: Any, ) -> Optional[str]: """ 为选择 AniList 刮削来源的媒体生成 NFO。 @@ -710,14 +735,14 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): scrape_source = mediainfo.scrape_source or get_runtime_setting('SCRAP_SOURCE') if scrape_source != "anilist": return None - return self.scraper.get_metadata_nfo(mediainfo, season=season, episode=episode) + return self._require_scraper().get_metadata_nfo(mediainfo, season=season, episode=episode) def metadata_img( self, mediainfo: MediaInfo, season: Optional[int] = None, episode: Optional[int] = None, - ) -> Optional[dict]: + ) -> Optional[dict[str, Any]]: """ 为选择 AniList 刮削来源的媒体生成图片下载清单。 @@ -729,10 +754,10 @@ class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase): scrape_source = mediainfo.scrape_source or get_runtime_setting('SCRAP_SOURCE') if scrape_source != "anilist": return None - return self.scraper.get_metadata_img(mediainfo, season=season, episode=episode) + return self._require_scraper().get_metadata_img(mediainfo, season=season, episode=episode) def clear_cache(self) -> None: """ 清理 AniList 接口缓存。 """ - self.anilist_api.clear_cache() + self._require_api().clear_cache() diff --git a/app/modules/anilist/anilist.py b/app/modules/anilist/anilist.py index 2f9c4e1a4..47307a26a 100644 --- a/app/modules/anilist/anilist.py +++ b/app/modules/anilist/anilist.py @@ -63,12 +63,12 @@ def _cached_request(shared_key: str) -> Callable[[_RequestFunction], _RequestFun """ 缓存查询结果而不转换返回载荷。 """ - return cast(_RequestFunction, cached( + return cached( maxsize=get_runtime_setting('CONF').anilist, ttl=get_runtime_setting('CONF').meta, skip_empty=True, shared_key=shared_key, - )(function)) + )(function) return decorate diff --git a/app/modules/bangumi/__init__.py b/app/modules/bangumi/__init__.py index d5e9ea7eb..2334ab60b 100644 --- a/app/modules/bangumi/__init__.py +++ b/app/modules/bangumi/__init__.py @@ -16,6 +16,7 @@ from app.modules._base.media import MediaAuxiliaryProviderMixin from app.modules.bangumi.bangumi import BangumiApi from app.runtime.log import logger from app.runtime.settings import get_runtime_setting +from app.schemas.context import MediaImageSet from app.schemas.context import MediaPerson as _SchemaMediaPerson from app.schemas.types import ( MediaRecognizeType, @@ -91,10 +92,32 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): "BANGUMI_API_DOMAIN", } - bangumiapi: BangumiApi = None - scraper: MediaScraperHelper = None + bangumiapi: Optional[BangumiApi] = None + scraper: Optional[MediaScraperHelper] = None _config: BangumiConfigSnapshot = BangumiConfigSnapshot(proxy=None) + def _require_api(self) -> BangumiApi: + """ + 取得已激活的 Bangumi 客户端,避免把尚未初始化的兼容属性当作有效实例。 + + :return: 模块初始化时创建的客户端 + :raises RuntimeError: 模块尚未初始化 + """ + if self.bangumiapi is None: + raise RuntimeError("Bangumi 模块尚未初始化") + return self.bangumiapi + + def _require_scraper(self) -> MediaScraperHelper: + """ + 取得模块初始化时创建的刮削器。 + + :return: 已初始化的刮削器 + :raises RuntimeError: 模块尚未初始化 + """ + if self.scraper is None: + raise RuntimeError("Bangumi 刮削器尚未初始化") + return self.scraper + def init_module(self) -> None: """ 初始化Bangumi客户端。 @@ -120,13 +143,13 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): # 请求缓存键已包含 API 地址,切换代理或域名后自然使用新键;这里不清缓存, # 以免 Redis 缓存在每次重载和关闭时都被清空。 if self.bangumiapi: - self.bangumiapi.close() + self._require_api().close() def test(self) -> Tuple[bool, str]: """ 测试模块连接性。 """ - api_url = self.bangumiapi.base_url if self.bangumiapi else "https://api.bgm.tv/" + api_url = self._require_api().base_url if self.bangumiapi else "https://api.bgm.tv/" ret = RequestUtils(proxies=self._config.proxy).get_res(api_url) if ret and ret.status_code == 200: return True, "" @@ -134,7 +157,7 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): return False, f"无法连接Bangumi,错误码:{ret.status_code}" return False, "Bangumi网络连接失败" - def init_setting(self) -> Tuple[str, Union[str, bool]]: + def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]: """ Bangumi模块无需独立开关。 """ @@ -278,7 +301,7 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): meta: Optional[MetaBase] = None, media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, - **kwargs, + **kwargs: Any, ) -> Optional[MediaInfo]: """ 按明确条目标识或标题候选识别 Bangumi 媒体。 @@ -300,9 +323,12 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): else self._match_by_meta(plan.require_meta()) ) if info: - actors = self.bangumiapi.credits(info.get("id")) + bangumi_id = info.get("id") + if not isinstance(bangumi_id, int): + return None + actors = self._require_api().credits(bangumi_id) mediainfo = self._build_recognized_media(info, actors, plan.meta) - logger.info(f"{plan.label} Bangumi识别结果:{mediainfo.type.value} " + logger.info(f"{plan.label} Bangumi识别结果:{mediainfo.type.value if mediainfo.type else ''} " f"{mediainfo.title_year}") return mediainfo logger.info(f"{plan.label} 未匹配到Bangumi媒体信息") @@ -314,7 +340,7 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): meta: Optional[MetaBase] = None, media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, - **kwargs, + **kwargs: Any, ) -> Optional[MediaInfo]: """ 异步按明确条目标识或标题候选识别 Bangumi 媒体。 @@ -336,9 +362,12 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): else await self._async_match_by_meta(plan.require_meta()) ) if info: - actors = await self.bangumiapi.async_credits(info.get("id")) + bangumi_id = info.get("id") + if not isinstance(bangumi_id, int): + return None + actors = await self._require_api().async_credits(bangumi_id) mediainfo = self._build_recognized_media(info, actors, plan.meta) - logger.info(f"{plan.label} Bangumi识别结果:{mediainfo.type.value} " + logger.info(f"{plan.label} Bangumi识别结果:{mediainfo.type.value if mediainfo.type else ''} " f"{mediainfo.title_year}") return mediainfo logger.info(f"{plan.label} 未匹配到Bangumi媒体信息") @@ -346,7 +375,7 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): return None @staticmethod - def _matches_meta(meta: MetaBase, info: dict) -> bool: + def _matches_meta(meta: MetaBase, info: dict[str, Any]) -> bool: """ 判断Bangumi候选项是否符合标题解析出的类型与年份。 @@ -362,29 +391,29 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): release_date = info.get("date") or info.get("air_date") or "" return not meta.year or not release_date or release_date[:4] == str(meta.year) - def _match_by_meta(self, meta: MetaBase) -> Optional[dict]: + def _match_by_meta(self, meta: MetaBase) -> Optional[dict[str, Any]]: """ 搜索并获取最符合标题解析结果的Bangumi详情。 :param meta: 标题解析元数据 :return: Bangumi媒体详情 """ - for media_id in self._candidate_ids(self.bangumiapi.search(meta.name)): - info = self.bangumiapi.detail(media_id) + for media_id in self._candidate_ids(self._require_api().search(meta.name)): + info = self._require_api().detail(media_id) if info and self._matches_meta(meta, info): return info return None - async def _async_match_by_meta(self, meta: MetaBase) -> Optional[dict]: + async def _async_match_by_meta(self, meta: MetaBase) -> Optional[dict[str, Any]]: """ 异步搜索并获取最符合标题解析结果的Bangumi详情。 :param meta: 标题解析元数据 :return: Bangumi媒体详情 """ - infos = await self.bangumiapi.async_search(meta.name) + infos = await self._require_api().async_search(meta.name) for media_id in self._candidate_ids(infos): - info = await self.bangumiapi.async_detail(media_id) + info = await self._require_api().async_detail(media_id) if info and self._matches_meta(meta, info): return info return None @@ -405,7 +434,7 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): if not plan.meta: return [] return self._project_search_results( - plan.meta, self.bangumiapi.search(plan.meta.name) + plan.meta, self._require_api().search(plan.meta.name) ) async def async_search_medias( @@ -423,10 +452,10 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): return None if not plan.meta: return [] - infos = await self.bangumiapi.async_search(plan.meta.name) + infos = await self._require_api().async_search(plan.meta.name) return self._project_search_results(plan.meta, infos) - def bangumi_info(self, bangumiid: int) -> Optional[dict]: + def bangumi_info(self, bangumiid: int) -> Optional[dict[str, Any]]: """ 获取原始 Bangumi 条目详情。 @@ -436,9 +465,9 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): if not bangumiid: return None logger.info(f"开始获取Bangumi信息:{bangumiid} ...") - return self.bangumiapi.detail(bangumiid) + return self._require_api().detail(bangumiid) - async def async_bangumi_info(self, bangumiid: int) -> Optional[dict]: + async def async_bangumi_info(self, bangumiid: int) -> Optional[dict[str, Any]]: """ 异步获取原始 Bangumi 条目详情。 @@ -448,14 +477,14 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): if not bangumiid: return None logger.info(f"开始获取Bangumi信息:{bangumiid} ...") - return await self.bangumiapi.async_detail(bangumiid) + return await self._require_api().async_detail(bangumiid) def metadata_nfo( self, mediainfo: MediaInfo, season: Optional[int] = None, episode: Optional[int] = None, - **kwargs, + **kwargs: Any, ) -> Optional[str]: """ 为选择 Bangumi 刮削来源的媒体生成 NFO 内容。 @@ -469,14 +498,14 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): scrape_source = mediainfo.scrape_source or get_runtime_setting('SCRAP_SOURCE') if scrape_source != "bangumi": return None - return self.scraper.get_metadata_nfo(mediainfo, season=season, episode=episode) + return self._require_scraper().get_metadata_nfo(mediainfo, season=season, episode=episode) def metadata_img( self, mediainfo: MediaInfo, season: Optional[int] = None, episode: Optional[int] = None, - ) -> Optional[dict]: + ) -> Optional[dict[str, Any]]: """ 为选择 Bangumi 刮削来源的媒体生成图片清单。 @@ -488,7 +517,7 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): scrape_source = mediainfo.scrape_source or get_runtime_setting('SCRAP_SOURCE') if scrape_source != "bangumi": return None - return self.scraper.get_metadata_img(mediainfo, season=season, episode=episode) + return self._require_scraper().get_metadata_img(mediainfo, season=season, episode=episode) def bangumi_calendar(self) -> Optional[List[MediaInfo]]: """ @@ -496,7 +525,7 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): :return: 媒体列表;没有放送内容或请求失败时为空列表 """ - return self._project_media_list(self.bangumiapi.calendar()) + return self._project_media_list(self._require_api().calendar()) async def async_bangumi_calendar(self) -> Optional[List[MediaInfo]]: """ @@ -504,7 +533,7 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): :return: 媒体列表;没有放送内容或请求失败时为空列表 """ - infos = await self.bangumiapi.async_calendar() + infos = await self._require_api().async_calendar() return self._project_media_list(infos) def bangumi_credits(self, bangumiid: int) -> List[_SchemaMediaPerson]: @@ -514,7 +543,7 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param bangumiid: Bangumi 条目标识,不接受其他来源标识 :return: 统一人物列表;无可用结果时为空列表 """ - return self._project_people(self.bangumiapi.credits(bangumiid)) + return self._project_people(self._require_api().credits(bangumiid)) async def async_bangumi_credits(self, bangumiid: int) -> List[_SchemaMediaPerson]: """ @@ -523,7 +552,7 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param bangumiid: Bangumi 条目标识,不接受其他来源标识 :return: 统一人物列表;无可用结果时为空列表 """ - persons = await self.bangumiapi.async_credits(bangumiid) + persons = await self._require_api().async_credits(bangumiid) return self._project_people(persons) def bangumi_recommend(self, bangumiid: int) -> List[MediaInfo]: @@ -533,7 +562,7 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param bangumiid: Bangumi 条目数字标识 :return: 统一媒体列表;无关联作品或请求失败时为空列表 """ - return self._project_media_list(self.bangumiapi.subjects(bangumiid)) + return self._project_media_list(self._require_api().subjects(bangumiid)) async def async_bangumi_recommend(self, bangumiid: int) -> List[MediaInfo]: """ @@ -542,7 +571,7 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param bangumiid: Bangumi 条目数字标识 :return: 统一媒体列表;无关联作品或请求失败时为空列表 """ - subjects = await self.bangumiapi.async_subjects(bangumiid) + subjects = await self._require_api().async_subjects(bangumiid) return self._project_media_list(subjects) def bangumi_person_detail(self, person_id: int) -> Optional[_SchemaMediaPerson]: @@ -552,7 +581,7 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param person_id: Bangumi 人物数字标识 :return: 统一人物对象;查询失败或无结果时为 None """ - personinfo = self.bangumiapi.person_detail(person_id) + personinfo = self._require_api().person_detail(person_id) if personinfo: return self._build_person_detail(personinfo) return None @@ -564,27 +593,33 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param person_id: Bangumi 人物数字标识 :return: 统一人物对象;查询失败或无结果时为 None """ - personinfo = await self.bangumiapi.async_person_detail(person_id) + personinfo = await self._require_api().async_person_detail(person_id) if personinfo: return self._build_person_detail(personinfo) return None @classmethod - def _build_person_detail(cls, personinfo: dict) -> _SchemaMediaPerson: + def _build_person_detail(cls, personinfo: dict[str, Any]) -> _SchemaMediaPerson: """ 构造Bangumi人物详情信息。 :param personinfo: Bangumi人物详情接口返回数据 :return: 媒体人物信息 """ - return _SchemaMediaPerson(source='bangumi', **{ - "id": personinfo.get("id"), - "name": personinfo.get("name"), - "images": personinfo.get("images"), - "biography": personinfo.get("summary"), - "birthday": cls._normalize_optional_string(personinfo.get("birth_day")), - "gender": personinfo.get("gender") - }) + images = personinfo.get("images") + image_set = MediaImageSet(**images) if isinstance(images, dict) else None + person_id = personinfo.get("id") + person_name = personinfo.get("name") + gender = personinfo.get("gender") + return _SchemaMediaPerson( + source="bangumi", + id=person_id if isinstance(person_id, (int, str)) else None, + name=str(person_name) if person_name is not None else None, + images=image_set, + biography=cls._normalize_optional_string(personinfo.get("summary")), + birthday=cls._normalize_optional_string(personinfo.get("birth_day")), + gender=gender if isinstance(gender, (int, str)) else None, + ) @staticmethod def _normalize_optional_string(value: object) -> Optional[str]: @@ -606,7 +641,7 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): :return: 统一媒体列表;无作品或请求失败时为空列表 """ return self._project_media_list( - self.bangumiapi.person_credits(person_id=person_id) + self._require_api().person_credits(person_id=person_id) ) async def async_bangumi_person_credits(self, person_id: int) -> List[MediaInfo]: @@ -616,26 +651,26 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param person_id: Bangumi 人物数字标识 :return: 统一媒体列表;无作品或请求失败时为空列表 """ - credits_info = await self.bangumiapi.async_person_credits(person_id=person_id) + credits_info = await self._require_api().async_person_credits(person_id=person_id) return self._project_media_list(credits_info) - def bangumi_discover(self, **kwargs) -> Optional[List[MediaInfo]]: + def bangumi_discover(self, **kwargs: Any) -> Optional[List[MediaInfo]]: """ 按筛选条件发现 Bangumi 番剧。 :param kwargs: 原样传入 API 的筛选和分页参数 :return: 统一媒体列表;无结果或请求失败时为空列表 """ - return self._project_media_list(self.bangumiapi.discover(**kwargs)) + return self._project_media_list(self._require_api().discover(**kwargs)) - async def async_bangumi_discover(self, **kwargs) -> Optional[List[MediaInfo]]: + async def async_bangumi_discover(self, **kwargs: Any) -> Optional[List[MediaInfo]]: """ 异步按筛选条件发现 Bangumi 番剧。 :param kwargs: 原样传入 API 的筛选和分页参数 :return: 统一媒体列表;无结果或请求失败时为空列表 """ - infos = await self.bangumiapi.async_discover(**kwargs) + infos = await self._require_api().async_discover(**kwargs) return self._project_media_list(infos) def clear_cache(self) -> None: @@ -643,5 +678,5 @@ class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase): 清除缓存。 """ logger.info(f"开始清除{self.get_name()}缓存 ...") - self.bangumiapi.clear_cache() + self._require_api().clear_cache() logger.info(f"{self.get_name()}缓存清除完成") diff --git a/app/modules/bangumi/bangumi.py b/app/modules/bangumi/bangumi.py index 5a898eb66..00f880303 100644 --- a/app/modules/bangumi/bangumi.py +++ b/app/modules/bangumi/bangumi.py @@ -8,7 +8,7 @@ from typing import Any, Optional, cast from urllib.parse import urlsplit from app.adapters.network.http import AsyncRequestUtils, RequestUtils -from app.runtime.cache import cached +from app.runtime.cache import CacheControls, cached from app.runtime.log import logger from app.runtime.settings import get_runtime_setting @@ -157,8 +157,7 @@ class BangumiApi: return None return self._project_response(response.status_code, payload, key) - # 缓存装饰器的运行时签名由宿主统一维护,静态检查无法完整推断。 - @cached( # type: ignore[misc] + @cached( maxsize=get_runtime_setting('CONF').bangumi, ttl=get_runtime_setting('CONF').meta, shared_key="get", @@ -193,8 +192,7 @@ class BangumiApi: **kwargs, ) - # 缓存装饰器的运行时签名由宿主统一维护,静态检查无法完整推断。 - @cached( # type: ignore[misc] + @cached( maxsize=get_runtime_setting('CONF').bangumi, ttl=get_runtime_setting('CONF').meta, shared_key="get", @@ -519,7 +517,7 @@ class BangumiApi: """ 清除 Bangumi 请求缓存。 """ - self._cached_invoke.cache_clear() + cast(CacheControls, self._cached_invoke).cache_clear() def close(self) -> None: """ diff --git a/app/modules/dingtalk/__init__.py b/app/modules/dingtalk/__init__.py index 65f0419ce..63299a29e 100644 --- a/app/modules/dingtalk/__init__.py +++ b/app/modules/dingtalk/__init__.py @@ -112,6 +112,8 @@ class DingTalkModule(_MessageChannelModuleBase[DingTalk]): lines = [] for index, context in enumerate(torrents, start=1): torrent = context.torrent_info + if torrent is None: + continue label = torrent.title if torrent.page_url: label = f"[{label}]({torrent.page_url})" diff --git a/app/modules/discord/__init__.py b/app/modules/discord/__init__.py index 4bc6e0101..dea03ceff 100644 --- a/app/modules/discord/__init__.py +++ b/app/modules/discord/__init__.py @@ -20,10 +20,11 @@ from app.schemas.message import IncomingMessage, Message, MessageResponse from app.schemas.types import ModuleType, NotificationChannel # SDK 缺失或加载失败时保留其他通知渠道的可用性。 +Discord: Any = None try: - from app.modules.discord.discord import Discord + from app.modules.discord.discord import Discord as _Discord + Discord = _Discord except Exception as err: - Discord = None logger.error(f"Discord 模块未加载,缺少依赖或初始化错误:{err}") @@ -112,11 +113,11 @@ class DiscordModule(_MessageChannelModuleBase[Discord]): """ return self._stop_service_instances() - def init_setting(self) -> Tuple[str, Union[str, bool]]: + def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]: """ 保留设置初始化钩子,实例配置由通知服务管理。 """ - pass + return None @staticmethod def _send_admin_denied( @@ -151,9 +152,9 @@ class DiscordModule(_MessageChannelModuleBase[Discord]): client_config = self.get_config(source) if not client_config: return None - client: Discord = self.get_instance(client_config.name) + client: Optional[Discord] = self.get_instance(client_config.name) try: - msg_json: dict = json.loads(body) + msg_json: dict[str, Any] = json.loads(body) except Exception as e: logger.debug(f"解析 Discord 消息失败:{str(e)}") return None @@ -231,7 +232,7 @@ class DiscordModule(_MessageChannelModuleBase[Discord]): @staticmethod def _extract_images( - msg_json: dict, + msg_json: dict[str, Any], ) -> Optional[List[IncomingMessage.MessageImage]]: """ 从Discord消息中提取图片URL。 @@ -262,7 +263,7 @@ class DiscordModule(_MessageChannelModuleBase[Discord]): return images if images else None @classmethod - def _extract_audio_refs(cls, msg_json: dict) -> Optional[List[str]]: + def _extract_audio_refs(cls, msg_json: dict[str, Any]) -> Optional[List[str]]: """ 从Discord消息中提取音频URL。 """ @@ -282,7 +283,7 @@ class DiscordModule(_MessageChannelModuleBase[Discord]): @classmethod def _extract_files( - cls, msg_json: dict + cls, msg_json: dict[str, Any] ) -> Optional[List[IncomingMessage.MessageAttachment]]: """ 从 Discord 消息中提取非图片/非音频文件。 @@ -333,10 +334,11 @@ class DiscordModule(_MessageChannelModuleBase[Discord]): file_url = unquote(file_ref.replace("discord://file/", "", 1)) resp = RequestUtils(timeout=30).get_res(file_url) if resp and resp.content: - return resp.content + content = resp.content + return content if isinstance(content, bytes) else None return None - def post_message(self, message: Message, **kwargs) -> None: + def post_message(self, message: Message, **kwargs: Any) -> None: """ 向匹配的 Discord 配置发送普通通知。 @@ -378,7 +380,7 @@ class DiscordModule(_MessageChannelModuleBase[Discord]): if not userid: logger.warn("用户没有指定 Discord 用户ID,消息无法发送") return - client: Discord = self.get_instance(conf.name) + client: Optional[Discord] = self.get_instance(conf.name) logger.debug( f"[Discord] get_instance('{conf.name}') 返回: {client is not None}" ) @@ -429,7 +431,7 @@ class DiscordModule(_MessageChannelModuleBase[Discord]): for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue - client: Discord = self.get_instance(conf.name) + client: Optional[Discord] = self.get_instance(conf.name) if client: client.send_medias_msg( title=message.title, @@ -454,7 +456,7 @@ class DiscordModule(_MessageChannelModuleBase[Discord]): for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue - client: Discord = self.get_instance(conf.name) + client: Optional[Discord] = self.get_instance(conf.name) if client: client.send_torrents_msg( title=message.title, @@ -487,7 +489,7 @@ class DiscordModule(_MessageChannelModuleBase[Discord]): for conf in self.get_configs().values(): if source != conf.name: continue - client: Discord = self.get_instance(conf.name) + client: Optional[Discord] = self.get_instance(conf.name) if client: result = client.delete_msg(message_id=message_id, chat_id=chat_id) if result: @@ -502,8 +504,8 @@ class DiscordModule(_MessageChannelModuleBase[Discord]): chat_id: Union[str, int], text: str, title: Optional[str] = None, - buttons: Optional[List[List[dict]]] = None, - metadata: Optional[dict] = None, + buttons: Optional[List[List[dict[str, Any]]]] = None, + metadata: Optional[dict[str, Any]] = None, ) -> Optional[bool]: """ 编辑消息。 @@ -523,7 +525,7 @@ class DiscordModule(_MessageChannelModuleBase[Discord]): for conf in self.get_configs().values(): if source != conf.name: continue - client: Discord = self.get_instance(conf.name) + client: Optional[Discord] = self.get_instance(conf.name) if client: result = client.send_msg( title=title or "", @@ -546,7 +548,7 @@ class DiscordModule(_MessageChannelModuleBase[Discord]): message_id: Optional[Union[str, int]] = None, chat_id: Optional[Union[str, int]] = None, text: Optional[str] = None, - ) -> Optional[dict]: + ) -> Optional[dict[str, Any]]: """ 启动会话正在输入状态。 @@ -565,7 +567,7 @@ class DiscordModule(_MessageChannelModuleBase[Discord]): config = self.get_config(source) if not config: return None - client: Discord = self.get_instance(config.name) + client: Optional[Discord] = self.get_instance(config.name) if not client: return None if not client.start_typing( @@ -589,7 +591,7 @@ class DiscordModule(_MessageChannelModuleBase[Discord]): userid: Optional[Union[str, int]] = None, message_id: Optional[Union[str, int]] = None, chat_id: Optional[Union[str, int]] = None, - status: Optional[dict] = None, + status: Optional[dict[str, Any]] = None, ) -> Optional[bool]: """ 请求停止会话正在输入状态。 @@ -609,13 +611,13 @@ class DiscordModule(_MessageChannelModuleBase[Discord]): config = self.get_config(source) if not config: return False - client: Discord = self.get_instance(config.name) + client: Optional[Discord] = self.get_instance(config.name) if not client: return False - return client.stop_typing( + return bool(client.stop_typing( userid=str(target_userid) if target_userid else None, chat_id=str(target_chat_id) if target_chat_id else None, - ) + )) def send_direct_message(self, message: Message) -> Optional[MessageResponse]: """ @@ -634,7 +636,7 @@ class DiscordModule(_MessageChannelModuleBase[Discord]): if not userid: logger.warn("用户没有指定 Discord 用户ID,消息无法发送") return None - client: Discord = self.get_instance(conf.name) + client: Optional[Discord] = self.get_instance(conf.name) if client: if message.file_path: result = client.send_file( diff --git a/app/modules/discord/discord.py b/app/modules/discord/discord.py index a5a0244d1..4f417d482 100644 --- a/app/modules/discord/discord.py +++ b/app/modules/discord/discord.py @@ -448,6 +448,9 @@ class Discord: interaction: discord.Interaction, args: Optional[str] = None, ) -> None: + """ + 把 Discord 斜杠命令及参数交给统一命令处理流程。 + """ await self._handle_slash_command(interaction, command_text, args) _callback.__name__ = f"moviepilot_{command_name}" @@ -1327,9 +1330,9 @@ class Discord: for index, media in enumerate(medias[:10], start=1): overview = media.get_overview_string(80) desc_parts = [ - f"{media.type.value} | {media.vote_star}" + f"{media.type.value if media.type else ''} | {media.vote_star}" if media.vote_star - else media.type.value, + else (media.type.value if media.type else ""), overview, ] embed = discord.Embed( @@ -1355,7 +1358,9 @@ class Discord: embeds: List[discord.Embed] = [] for index, context in enumerate(torrents[:10], start=1): torrent = context.torrent_info - meta = MetaInfo(torrent.title, torrent.description) + if torrent is None: + continue + meta = MetaInfo(torrent.title or "", torrent.description) title_text = f"{meta.season_episode} {meta.resource_term} {meta.video_term} {meta.release_group}" title_text = re.sub(r"\s+", " ", title_text).strip() detail = [ @@ -1605,6 +1610,9 @@ class Discord: return False def _clean_bot_mention(self, content: str) -> str: + """ + 去除发给当前机器人的提及前缀,保留实际命令正文。 + """ if not content: return "" if self._bot_user_id: diff --git a/app/modules/douban/__init__.py b/app/modules/douban/__init__.py index 46f193a3d..f73478a0e 100644 --- a/app/modules/douban/__init__.py +++ b/app/modules/douban/__init__.py @@ -161,8 +161,30 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): auxiliary_media_source = MediaSource.Douban _music_source = MediaSource.DoubanMusic - doubanapi: DoubanApi = None - scraper: DoubanScraper = None + doubanapi: Optional[DoubanApi] = None + scraper: Optional[DoubanScraper] = None + + def _require_api(self) -> DoubanApi: + """ + 取得模块激活时创建的豆瓣客户端,保留初始化前兼容属性为空的行为。 + + :return: 已初始化的豆瓣客户端 + :raises RuntimeError: 模块尚未初始化 + """ + if self.doubanapi is None: + raise RuntimeError("豆瓣模块尚未初始化") + return self.doubanapi + + def _require_scraper(self) -> DoubanScraper: + """ + 取得模块初始化时创建的豆瓣刮削器。 + + :return: 已初始化的豆瓣刮削器 + :raises RuntimeError: 模块尚未初始化 + """ + if self.scraper is None: + raise RuntimeError("豆瓣刮削器尚未初始化") + return self.scraper def init_module(self) -> None: """ @@ -175,7 +197,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): """ 关闭豆瓣同步 HTTP 客户端。 """ - self.doubanapi.close() + self._require_api().close() def test(self) -> Tuple[bool, str]: """ @@ -188,11 +210,11 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): return False, "豆瓣网络连接失败" return True, "" - def init_setting(self) -> Tuple[str, Union[str, bool]]: + def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]: """ 保留配置入口;豆瓣配置由运行设置管理,返回 None。 """ - pass + return None @staticmethod def get_name() -> str: @@ -251,7 +273,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): keyword = meta.album or meta.title if not keyword: return [] - result = self.doubanapi.music_search(keyword=keyword, count=max(1, min(limit, 100))) + result = self._require_api().music_search(keyword=keyword, count=max(1, min(limit, 100))) return self._build_music_search_results(result) def recognize_music( @@ -304,7 +326,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): """ if media_source != self._music_source or not media_id: return None - info = self.doubanapi.music_detail(subject_id=str(media_id)) + info = self._require_api().music_detail(subject_id=str(media_id)) return self._douban_music_to_album(info) if info else None def music_discover( @@ -335,14 +357,14 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): return None del entity if mode == "chart": - chart_items = self._build_music_search_results(self.doubanapi.music_chart()) + chart_items = self._build_music_search_results(self._require_api().music_chart()) start = max(page - 1, 0) * max(1, count) return chart_items[start:start + max(1, count)] selected_tags = [tag.strip() for tag in str(tags or "").split(",") if tag.strip()] if not selected_tags: selected_tags = ["流行"] if len(selected_tags) == 1: - result = self.doubanapi.music_tag( + result = self._require_api().music_tag( tag=selected_tags[0], start=max(page - 1, 0) * max(1, count), count=max(1, count), @@ -355,7 +377,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): scan_count = min(max(page * count * 4, 100), 300) tag_results = [ self._build_music_search_results( - self.doubanapi.music_tag(tag=tag, start=0, count=scan_count, sort=sort) + self._require_api().music_tag(tag=tag, start=0, count=scan_count, sort=sort) ) for tag in selected_tags ] @@ -384,7 +406,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): """ if media_source != self._music_source or not media_id: return None - result = self.doubanapi.music_recommendations( + result = self._require_api().music_recommendations( subject_id=str(media_id), start=0, count=max(1, count), @@ -407,12 +429,12 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): if not plan: return None if plan.media_id: - info = self.doubanapi.music_detail( + info = self._require_api().music_detail( subject_id=plan.require_album_id() ) return self._project_music_detail(plan, info) meta = plan.require_meta() - result = self.doubanapi.music_search( + result = self._require_api().music_search( keyword=plan.require_keyword(), count=20 ) candidates = self._matching_music_candidates( @@ -424,7 +446,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): tracks = [] if plan.music_type != MUSIC_ENTITY_ALBUM and meta.album and meta.title: for candidate in candidates: - info = self.doubanapi.music_detail(subject_id=str(candidate.media_id)) + info = self._require_api().music_detail(subject_id=str(candidate.media_id)) album = self._douban_music_to_album(info) if info else None if album: tracks.extend(album.tracks) @@ -446,12 +468,12 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): if not plan: return None if plan.media_id: - info = await self.doubanapi.async_music_detail( + info = await self._require_api().async_music_detail( subject_id=plan.require_album_id() ) return self._project_music_detail(plan, info) meta = plan.require_meta() - result = await self.doubanapi.async_music_search( + result = await self._require_api().async_music_search( keyword=plan.require_keyword(), count=20 ) candidates = self._matching_music_candidates( @@ -463,7 +485,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): tracks = [] if plan.music_type != MUSIC_ENTITY_ALBUM and meta.album and meta.title: for candidate in candidates: - info = await self.doubanapi.async_music_detail( + info = await self._require_api().async_music_detail( subject_id=str(candidate.media_id) ) album = self._douban_music_to_album(info) if info else None @@ -594,7 +616,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): @classmethod def _build_music_search_results( cls, - result: Optional[dict | list], + result: Optional[dict[str, Any] | list[Any]], ) -> List[MusicInfo]: """ 把豆瓣音乐搜索响应转换为专辑候选列表。 @@ -940,7 +962,9 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): return list(dict.fromkeys([k for k in [meta.cn_name, zh_name, meta.en_name] if k])) @staticmethod - def _build_search_medias_result(meta: MetaBase, items: Optional[List[dict]]) -> List[MediaInfo]: + def _build_search_medias_result( + meta: MetaBase, items: Optional[List[dict[str, Any]]] + ) -> List[MediaInfo]: """ 构建豆瓣搜索结果,并沿用原有的类型、标题包含和季信息处理规则。 """ @@ -971,9 +995,9 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): def _recognize_media_core(self, meta: Optional[MetaBase] = None, mtype: Optional[MediaType] = None, doubanid: Optional[str] = None, - douban_info_func=None, - match_doubaninfo_func=None, - **kwargs) -> Optional[MediaInfo]: + douban_info_func: Any = None, + match_doubaninfo_func: Any = None, + **kwargs: Any) -> Optional[MediaInfo]: """ 识别媒体信息的核心逻辑。 @@ -1028,9 +1052,9 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): async def _async_recognize_media_core(self, meta: Optional[MetaBase] = None, mtype: Optional[MediaType] = None, doubanid: Optional[str] = None, - async_douban_info_func=None, - async_match_doubaninfo_func=None, - **kwargs) -> Optional[MediaInfo]: + async_douban_info_func: Any = None, + async_match_doubaninfo_func: Any = None, + **kwargs: Any) -> Optional[MediaInfo]: """ 识别媒体信息的核心逻辑(异步版本)。 @@ -1147,12 +1171,12 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): mediainfo = MediaInfo(douban_info=info) if plan.meta: logger.info( - f"{plan.meta.name} 豆瓣识别结果:{mediainfo.type.value} " + f"{plan.meta.name} 豆瓣识别结果:{mediainfo.type.value if mediainfo.type else ''} " f"{mediainfo.title_year} {mediainfo.douban_id}" ) else: logger.info( - f"{plan.douban_id} 豆瓣识别结果:{mediainfo.type.value} " + f"{plan.douban_id} 豆瓣识别结果:{mediainfo.type.value if mediainfo.type else ''} " f"{mediainfo.title_year}" ) return mediainfo @@ -1161,7 +1185,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): mtype: Optional[MediaType] = None, media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, - **kwargs) -> Optional[MediaInfo]: + **kwargs: Any) -> Optional[Union[MediaInfo, MusicInfo]]: """ 识别媒体信息。 @@ -1201,7 +1225,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): mtype: Optional[MediaType] = None, media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, - **kwargs) -> Optional[MediaInfo]: + **kwargs: Any) -> Optional[Union[MediaInfo, MusicInfo]]: """ 识别媒体信息(异步版本)。 @@ -1295,11 +1319,11 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): 执行一次同步详情及人物查询,业务分类由共享状态机负责。 """ if mtype == MediaType.TV: - info = self.doubanapi.tv_detail(doubanid) - celebrity_loader = self.doubanapi.tv_celebrities + info = self._require_api().tv_detail(doubanid) + celebrity_loader = self._require_api().tv_celebrities else: - info = self.doubanapi.movie_detail(doubanid) - celebrity_loader = self.doubanapi.movie_celebrities + info = self._require_api().movie_detail(doubanid) + celebrity_loader = self._require_api().movie_celebrities if not self._accept_douban_detail(info): return None detail = cast(dict[str, Any], info) @@ -1314,11 +1338,11 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): 执行一次异步详情及人物查询,业务分类由共享状态机负责。 """ if mtype == MediaType.TV: - info = await self.doubanapi.async_tv_detail(doubanid) - celebrity_loader = self.doubanapi.async_tv_celebrities + info = await self._require_api().async_tv_detail(doubanid) + celebrity_loader = self._require_api().async_tv_celebrities else: - info = await self.doubanapi.async_movie_detail(doubanid) - celebrity_loader = self.doubanapi.async_movie_celebrities + info = await self._require_api().async_movie_detail(doubanid) + celebrity_loader = self._require_api().async_movie_celebrities if not self._accept_douban_detail(info): return None detail = cast(dict[str, Any], info) @@ -1327,7 +1351,10 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): ) @rate_limit_exponential(source="douban_info") - def douban_info(self, doubanid: str, mtype: Optional[MediaType] = None, raise_exception: bool = True) -> Optional[dict]: + def douban_info( + self, doubanid: str, mtype: Optional[MediaType] = None, + raise_exception: bool = True, + ) -> Optional[dict[str, Any]]: """ 获取豆瓣信息。 @@ -1348,8 +1375,10 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): return None @rate_limit_exponential(source="douban_info") - async def async_douban_info(self, doubanid: str, mtype: Optional[MediaType] = None, - raise_exception: bool = True) -> Optional[dict]: + async def async_douban_info( + self, doubanid: str, mtype: Optional[MediaType] = None, + raise_exception: bool = True, + ) -> Optional[dict[str, Any]]: """ 获取豆瓣信息(异步版本)。 @@ -1383,10 +1412,10 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): """ logger.info(f"开始发现豆瓣 {mtype.value} ...") if mtype == MediaType.MOVIE: - infos = self.doubanapi.movie_recommend(start=(page - 1) * count, count=count, + infos = self._require_api().movie_recommend(start=(page - 1) * count, count=count, sort=sort, tags=tags) else: - infos = self.doubanapi.tv_recommend(start=(page - 1) * count, count=count, + infos = self._require_api().tv_recommend(start=(page - 1) * count, count=count, sort=sort, tags=tags) return self._project_discover(infos) @@ -1404,10 +1433,10 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): """ logger.info(f"开始发现豆瓣 {mtype.value} ...") if mtype == MediaType.MOVIE: - infos = await self.doubanapi.async_movie_recommend(start=(page - 1) * count, count=count, + infos = await self._require_api().async_movie_recommend(start=(page - 1) * count, count=count, sort=sort, tags=tags) else: - infos = await self.doubanapi.async_tv_recommend(start=(page - 1) * count, count=count, + infos = await self._require_api().async_tv_recommend(start=(page - 1) * count, count=count, sort=sort, tags=tags) return self._project_discover(infos) @@ -1441,7 +1470,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param count: 本次最多返回的条目数 :return: 当前页或本次查询的标准对象列表;无结果时为空列表 """ - infos = self.doubanapi.movie_showing(start=(page - 1) * count, + infos = self._require_api().movie_showing(start=(page - 1) * count, count=count) return self._project_collection(infos) @@ -1453,7 +1482,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param count: 本次最多返回的条目数 :return: 当前页或本次查询的标准对象列表;无结果时为空列表 """ - infos = await self.doubanapi.async_movie_showing(start=(page - 1) * count, + infos = await self._require_api().async_movie_showing(start=(page - 1) * count, count=count) return self._project_collection(infos) @@ -1465,7 +1494,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param count: 本次最多返回的条目数 :return: 当前页或本次查询的标准对象列表;无结果时为空列表 """ - infos = self.doubanapi.tv_chinese_best_weekly(start=(page - 1) * count, + infos = self._require_api().tv_chinese_best_weekly(start=(page - 1) * count, count=count) return self._project_collection(infos) @@ -1477,7 +1506,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param count: 本次最多返回的条目数 :return: 当前页或本次查询的标准对象列表;无结果时为空列表 """ - infos = await self.doubanapi.async_tv_chinese_best_weekly(start=(page - 1) * count, + infos = await self._require_api().async_tv_chinese_best_weekly(start=(page - 1) * count, count=count) return self._project_collection(infos) @@ -1489,7 +1518,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param count: 本次最多返回的条目数 :return: 当前页或本次查询的标准对象列表;无结果时为空列表 """ - infos = self.doubanapi.tv_global_best_weekly(start=(page - 1) * count, + infos = self._require_api().tv_global_best_weekly(start=(page - 1) * count, count=count) return self._project_collection(infos) @@ -1501,7 +1530,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param count: 本次最多返回的条目数 :return: 当前页或本次查询的标准对象列表;无结果时为空列表 """ - infos = await self.doubanapi.async_tv_global_best_weekly(start=(page - 1) * count, + infos = await self._require_api().async_tv_global_best_weekly(start=(page - 1) * count, count=count) return self._project_collection(infos) @@ -1513,7 +1542,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param count: 本次最多返回的条目数 :return: 当前页或本次查询的标准对象列表;无结果时为空列表 """ - infos = self.doubanapi.tv_animation(start=(page - 1) * count, + infos = self._require_api().tv_animation(start=(page - 1) * count, count=count) return self._project_collection(infos) @@ -1525,7 +1554,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param count: 本次最多返回的条目数 :return: 当前页或本次查询的标准对象列表;无结果时为空列表 """ - infos = await self.doubanapi.async_tv_animation(start=(page - 1) * count, + infos = await self._require_api().async_tv_animation(start=(page - 1) * count, count=count) return self._project_collection(infos) @@ -1537,7 +1566,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param count: 本次最多返回的条目数 :return: 当前页或本次查询的标准对象列表;无结果时为空列表 """ - infos = self.doubanapi.movie_hot_gaia(start=(page - 1) * count, + infos = self._require_api().movie_hot_gaia(start=(page - 1) * count, count=count) return self._project_collection(infos) @@ -1549,7 +1578,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param count: 本次最多返回的条目数 :return: 当前页或本次查询的标准对象列表;无结果时为空列表 """ - infos = await self.doubanapi.async_movie_hot_gaia(start=(page - 1) * count, + infos = await self._require_api().async_movie_hot_gaia(start=(page - 1) * count, count=count) return self._project_collection(infos) @@ -1561,7 +1590,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param count: 本次最多返回的条目数 :return: 当前页或本次查询的标准对象列表;无结果时为空列表 """ - infos = self.doubanapi.tv_hot(start=(page - 1) * count, + infos = self._require_api().tv_hot(start=(page - 1) * count, count=count) return self._project_collection(infos) @@ -1573,7 +1602,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param count: 本次最多返回的条目数 :return: 当前页或本次查询的标准对象列表;无结果时为空列表 """ - infos = await self.doubanapi.async_tv_hot(start=(page - 1) * count, + infos = await self._require_api().async_tv_hot(start=(page - 1) * count, count=count) return self._project_collection(infos) @@ -1604,7 +1633,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): return None if not plan.query: return [] - result = self.doubanapi.search(plan.query) + result = self._require_api().search(plan.query) return self._project_media_search(meta, result) async def async_search_medias( @@ -1622,7 +1651,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): return None if not plan.query: return [] - result = await self.doubanapi.async_search(plan.query) + result = await self._require_api().async_search(plan.query) return self._project_media_search(meta, result) def search_persons( @@ -1640,7 +1669,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): return None if not plan.query: return [] - result = self.doubanapi.person_search(keyword=plan.query) + result = self._require_api().person_search(keyword=plan.query) return self._project_person_search(plan.query, result) async def async_search_persons( @@ -1658,7 +1687,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): return None if not plan.query: return [] - result = await self.doubanapi.async_person_search(keyword=plan.query) + result = await self._require_api().async_person_search(keyword=plan.query) return self._project_person_search(plan.query, result) @staticmethod @@ -1786,6 +1815,8 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): if mtype and mtype == MediaType.TV and season is None: season = 1 item = item_obj.get("target") + if not isinstance(item, dict): + continue title = item.get("title") if not title: continue @@ -1804,7 +1835,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): @rate_limit_exponential(source="match_doubaninfo") def match_doubaninfo(self, name: str, imdbid: Optional[str] = None, mtype: Optional[MediaType] = None, year: Optional[str] = None, season: Optional[int] = None, - raise_exception: bool = False) -> dict: + raise_exception: bool = False) -> dict[str, Any]: """ 搜索和匹配豆瓣信息。 @@ -1821,20 +1852,20 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): if imdbid: # 优先使用IMDBID查询 logger.info(f"开始使用IMDBID {imdbid} 查询豆瓣信息 ...") - result = self.doubanapi.imdbid(imdbid) + result = self._require_api().imdbid(imdbid) processed_result = self._process_imdbid_result(result, imdbid) if processed_result: return processed_result logger.info(f"开始使用名称 {name} 匹配豆瓣信息 ...") - result = self.doubanapi.search(f"{name} {year or ''}".strip()) + result = self._require_api().search(f"{name} {year or ''}".strip()) return self._process_search_results(result, name, mtype, year, season) @retry(Exception, 5, 3, 3, logger=logger) @rate_limit_exponential(source="match_doubaninfo") async def async_match_doubaninfo(self, name: str, imdbid: Optional[str] = None, mtype: Optional[MediaType] = None, year: Optional[str] = None, season: Optional[int] = None, - raise_exception: bool = False) -> dict: + raise_exception: bool = False) -> dict[str, Any]: """ 搜索和匹配豆瓣信息(异步版本)。 @@ -1851,13 +1882,13 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): if imdbid: # 优先使用IMDBID查询 logger.info(f"开始使用IMDBID {imdbid} 查询豆瓣信息 ...") - result = await self.doubanapi.async_imdbid(imdbid) + result = await self._require_api().async_imdbid(imdbid) processed_result = self._process_imdbid_result(result, imdbid) if processed_result: return processed_result logger.info(f"开始使用名称 {name} 匹配豆瓣信息 ...") - result = await self.doubanapi.async_search(f"{name} {year or ''}".strip()) + result = await self._require_api().async_search(f"{name} {year or ''}".strip()) return self._process_search_results(result, name, mtype, year, season) def movie_top250(self, page: int = 1, count: int = 30) -> List[MediaInfo]: @@ -1868,7 +1899,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param count: 本次最多返回的条目数 :return: 当前页或本次查询的标准对象列表;无结果时为空列表 """ - infos = self.doubanapi.movie_top250(start=(page - 1) * count, + infos = self._require_api().movie_top250(start=(page - 1) * count, count=count) return self._project_collection(infos) @@ -1880,11 +1911,14 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param count: 本次最多返回的条目数 :return: 当前页或本次查询的标准对象列表;无结果时为空列表 """ - infos = await self.doubanapi.async_movie_top250(start=(page - 1) * count, + infos = await self._require_api().async_movie_top250(start=(page - 1) * count, count=count) return self._project_collection(infos) - def metadata_nfo(self, mediainfo: MediaInfo, season: Optional[int] = None, **kwargs) -> Optional[str]: + def metadata_nfo( + self, mediainfo: MediaInfo, season: Optional[int] = None, + **kwargs: Any, + ) -> Optional[str]: """ 获取包含 UTF-8 编码声明的 NFO XML 文本。 @@ -1895,9 +1929,12 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): """ if (mediainfo.scrape_source or get_runtime_setting('SCRAP_SOURCE')) != "douban": return None - return self.scraper.get_metadata_nfo(mediainfo=mediainfo, season=season) + return self._require_scraper().get_metadata_nfo(mediainfo=mediainfo, season=season) - def metadata_img(self, mediainfo: MediaInfo, season: Optional[int] = None, episode: Optional[int] = None) -> Optional[dict]: + def metadata_img( + self, mediainfo: MediaInfo, season: Optional[int] = None, + episode: Optional[int] = None, + ) -> Optional[dict[str, Any]]: """ 获取图片名称和url。 @@ -1908,7 +1945,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): """ if (mediainfo.scrape_source or get_runtime_setting('SCRAP_SOURCE')) != "douban": return None - return self.scraper.get_metadata_img(mediainfo=mediainfo, season=season, episode=episode) + return self._require_scraper().get_metadata_img(mediainfo=mediainfo, season=season, episode=episode) @staticmethod def _validate_douban_obtain_images_params(mediainfo: MediaInfo) -> Optional[MediaInfo]: @@ -1921,9 +1958,9 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :return: 背景图已存在且来源满足条件时返回原对象;其他情况返回 None,交由调用方继续查询 """ if mediainfo.media_source != MediaSource.Douban and get_runtime_setting('RECOGNIZE_SOURCE') != "douban": - return None + return mediainfo if not mediainfo.douban_id: - return None + return mediainfo if mediainfo.backdrop_path: return mediainfo return None @@ -1959,10 +1996,13 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): if result is not None: return result + douban_id = mediainfo.douban_id + if not douban_id: + return mediainfo if mediainfo.type == MediaType.MOVIE: - info = self.doubanapi.movie_photos(mediainfo.douban_id) + info = self._require_api().movie_photos(douban_id) else: - info = self.doubanapi.tv_photos(mediainfo.douban_id) + info = self._require_api().tv_photos(douban_id) return self._process_douban_images(mediainfo, info) @@ -1977,10 +2017,13 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): if result is not None: return result + douban_id = mediainfo.douban_id + if not douban_id: + return mediainfo if mediainfo.type == MediaType.MOVIE: - info = await self.doubanapi.async_movie_photos(mediainfo.douban_id) + info = await self._require_api().async_movie_photos(douban_id) else: - info = await self.doubanapi.async_tv_photos(mediainfo.douban_id) + info = await self._require_api().async_tv_photos(douban_id) return self._process_douban_images(mediainfo, info) @@ -1989,7 +2032,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): 清除缓存。 """ logger.info("开始清除豆瓣缓存 ...") - self.doubanapi.clear_cache() + self._require_api().clear_cache() logger.info("豆瓣缓存清除完成") def douban_movie_credits(self, doubanid: str) -> List[_SchemaMediaPerson]: @@ -2001,7 +2044,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param doubanid: 豆瓣影视条目原生标识 :return: 当前页或本次查询的标准对象列表;无结果时为空列表 """ - result = self.doubanapi.movie_celebrities(subject_id=doubanid) + result = self._require_api().movie_celebrities(subject_id=doubanid) return self._process_celebrity_data(result) def douban_tv_credits(self, doubanid: str) -> List[_SchemaMediaPerson]: @@ -2013,7 +2056,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param doubanid: 豆瓣影视条目原生标识 :return: 当前页或本次查询的标准对象列表;无结果时为空列表 """ - result = self.doubanapi.tv_celebrities(subject_id=doubanid) + result = self._require_api().tv_celebrities(subject_id=doubanid) return self._process_celebrity_data(result) def douban_movie_recommend(self, doubanid: str) -> List[MediaInfo]: @@ -2023,7 +2066,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param doubanid: 豆瓣影视条目原生标识 :return: 当前页或本次查询的标准对象列表;无结果时为空列表 """ - recommend = self.doubanapi.movie_recommendations(subject_id=doubanid) + recommend = self._require_api().movie_recommendations(subject_id=doubanid) return self._process_recommendations(recommend) def douban_tv_recommend(self, doubanid: str) -> List[MediaInfo]: @@ -2033,7 +2076,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param doubanid: 豆瓣影视条目原生标识 :return: 当前页或本次查询的标准对象列表;无结果时为空列表 """ - recommend = self.doubanapi.tv_recommendations(subject_id=doubanid) + recommend = self._require_api().tv_recommendations(subject_id=doubanid) return self._process_recommendations(recommend) @staticmethod @@ -2146,7 +2189,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param count: 本次最多返回的条目数 :return: 短评分页对象;没有响应时返回空分页对象 """ - result = self.doubanapi.movie_interests(subject_id=doubanid, start=start, count=count) + result = self._require_api().movie_interests(subject_id=doubanid, start=start, count=count) return self._process_interests(result) def douban_tv_interests(self, doubanid: str, start: int = 0, count: int = 20) -> _SchemaMediaCommentPage: @@ -2158,7 +2201,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param count: 本次最多返回的条目数 :return: 短评分页对象;没有响应时返回空分页对象 """ - result = self.doubanapi.tv_interests(subject_id=doubanid, start=start, count=count) + result = self._require_api().tv_interests(subject_id=doubanid, start=start, count=count) return self._process_interests(result) def douban_movie_reviews(self, doubanid: str, start: int = 0, count: int = 20) -> _SchemaMediaReviewPage: @@ -2170,7 +2213,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param count: 本次最多返回的条目数 :return: 影评分页对象;没有响应时返回空分页对象 """ - result = self.doubanapi.movie_reviews(subject_id=doubanid, start=start, count=count) + result = self._require_api().movie_reviews(subject_id=doubanid, start=start, count=count) return self._process_reviews(result) def douban_tv_reviews(self, doubanid: str, start: int = 0, count: int = 20) -> _SchemaMediaReviewPage: @@ -2182,7 +2225,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param count: 本次最多返回的条目数 :return: 影评分页对象;没有响应时返回空分页对象 """ - result = self.doubanapi.tv_reviews(subject_id=doubanid, start=start, count=count) + result = self._require_api().tv_reviews(subject_id=doubanid, start=start, count=count) return self._process_reviews(result) def douban_person_detail(self, person_id: int) -> _SchemaMediaPerson: @@ -2192,7 +2235,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param person_id: 豆瓣人物原生标识 :return: 豆瓣人物对象;没有详情时返回仅带豆瓣来源的空人物对象 """ - detail = self.doubanapi.person_detail(person_id) + detail = self._require_api().person_detail(person_id) if detail: also_known_as = [] infos = detail.get("extra", {}).get("info") @@ -2220,7 +2263,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param page: 请求页码,从 1 开始 :return: 当前页或本次查询的标准对象列表;无结果时为空列表 """ - personinfo = self.doubanapi.person_detail(person_id) + personinfo = self._require_api().person_detail(person_id) if not personinfo: return [] collection_id = None @@ -2228,7 +2271,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): if module.get("type") == "work_collections": collection_id = module.get("payload", {}).get("id") if collection_id: - collections = self.doubanapi.person_work(subject_id=collection_id, start=(page - 1) * 20, count=20) + collections = self._require_api().person_work(subject_id=collection_id, start=(page - 1) * 20, count=20) if collections: works = collections.get("works") or [] return [MediaInfo(douban_info=work.get("subject")) for work in works] @@ -2263,7 +2306,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param doubanid: 豆瓣影视条目原生标识 :return: 当前页或本次查询的标准对象列表;无结果时为空列表 """ - result = await self.doubanapi.async_movie_celebrities(subject_id=doubanid) + result = await self._require_api().async_movie_celebrities(subject_id=doubanid) return self._process_celebrity_data(result) async def async_douban_tv_credits(self, doubanid: str) -> List[_SchemaMediaPerson]: @@ -2275,7 +2318,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param doubanid: 豆瓣影视条目原生标识 :return: 当前页或本次查询的标准对象列表;无结果时为空列表 """ - result = await self.doubanapi.async_tv_celebrities(subject_id=doubanid) + result = await self._require_api().async_tv_celebrities(subject_id=doubanid) return self._process_celebrity_data(result) async def async_douban_movie_recommend(self, doubanid: str) -> List[MediaInfo]: @@ -2285,7 +2328,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param doubanid: 豆瓣影视条目原生标识 :return: 当前页或本次查询的标准对象列表;无结果时为空列表 """ - recommend = await self.doubanapi.async_movie_recommendations(subject_id=doubanid) + recommend = await self._require_api().async_movie_recommendations(subject_id=doubanid) return self._process_recommendations(recommend) async def async_douban_tv_recommend(self, doubanid: str) -> List[MediaInfo]: @@ -2295,7 +2338,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param doubanid: 豆瓣影视条目原生标识 :return: 当前页或本次查询的标准对象列表;无结果时为空列表 """ - recommend = await self.doubanapi.async_tv_recommendations(subject_id=doubanid) + recommend = await self._require_api().async_tv_recommendations(subject_id=doubanid) return self._process_recommendations(recommend) async def async_douban_movie_interests(self, doubanid: str, start: int = 0, @@ -2308,7 +2351,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param count: 本次最多返回的条目数 :return: 短评分页对象;没有响应时返回空分页对象 """ - result = await self.doubanapi.async_movie_interests(subject_id=doubanid, start=start, count=count) + result = await self._require_api().async_movie_interests(subject_id=doubanid, start=start, count=count) return self._process_interests(result) async def async_douban_tv_interests(self, doubanid: str, start: int = 0, @@ -2321,7 +2364,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param count: 本次最多返回的条目数 :return: 短评分页对象;没有响应时返回空分页对象 """ - result = await self.doubanapi.async_tv_interests(subject_id=doubanid, start=start, count=count) + result = await self._require_api().async_tv_interests(subject_id=doubanid, start=start, count=count) return self._process_interests(result) async def async_douban_movie_reviews(self, doubanid: str, start: int = 0, @@ -2334,7 +2377,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param count: 本次最多返回的条目数 :return: 影评分页对象;没有响应时返回空分页对象 """ - result = await self.doubanapi.async_movie_reviews(subject_id=doubanid, start=start, count=count) + result = await self._require_api().async_movie_reviews(subject_id=doubanid, start=start, count=count) return self._process_reviews(result) async def async_douban_tv_reviews(self, doubanid: str, start: int = 0, @@ -2347,7 +2390,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param count: 本次最多返回的条目数 :return: 影评分页对象;没有响应时返回空分页对象 """ - result = await self.doubanapi.async_tv_reviews(subject_id=doubanid, start=start, count=count) + result = await self._require_api().async_tv_reviews(subject_id=doubanid, start=start, count=count) return self._process_reviews(result) async def async_douban_person_detail(self, person_id: int) -> _SchemaMediaPerson: @@ -2357,7 +2400,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param person_id: 豆瓣人物原生标识 :return: 豆瓣人物对象;没有详情时返回仅带豆瓣来源的空人物对象 """ - detail = await self.doubanapi.async_person_detail(person_id) + detail = await self._require_api().async_person_detail(person_id) if detail: also_known_as = [] infos = detail.get("extra", {}).get("info") @@ -2385,7 +2428,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :param page: 请求页码,从 1 开始 :return: 当前页或本次查询的标准对象列表;无结果时为空列表 """ - personinfo = await self.doubanapi.async_person_detail(person_id) + personinfo = await self._require_api().async_person_detail(person_id) if not personinfo: return [] collection_id = None @@ -2393,7 +2436,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): if module.get("type") == "work_collections": collection_id = module.get("payload", {}).get("id") if collection_id: - collections = await self.doubanapi.async_person_work(subject_id=collection_id, start=(page - 1) * 20, + collections = await self._require_api().async_person_work(subject_id=collection_id, start=(page - 1) * 20, count=20) if collections: works = collections.get("works") or [] diff --git a/app/modules/douban/apiv2.py b/app/modules/douban/apiv2.py index 0cf8c0ce6..7d0828e69 100644 --- a/app/modules/douban/apiv2.py +++ b/app/modules/douban/apiv2.py @@ -40,10 +40,10 @@ def _cached_music_request(function: Callable[_MusicParams, _MusicReturn]) -> Cal """ 在保持原函数签名的缓存边界上应用音乐专用命名空间与短负缓存。 """ - return cast(Callable[_MusicParams, _MusicReturn], cached( + return cached( maxsize=get_runtime_setting('CONF').douban, ttl=get_runtime_setting('CONF').meta, skip_none=True, shared_key="music_get_v2", empty_ttl=300, empty_if=_empty_music_response, - )(function)) + )(function) _QueryValue = Optional[Union[str, int]] @@ -86,10 +86,10 @@ def _cached_api_request( """ 保留底层装饰器的调用方式与缓存属性,不额外包装请求执行过程。 """ - return cast(Callable[_MusicParams, _MusicReturn], cached( + return cached( maxsize=get_runtime_setting('CONF').douban, ttl=get_runtime_setting('CONF').meta, skip_none=True, shared_key=shared_key, - )(function)) + )(function) return decorate diff --git a/app/modules/emby/__init__.py b/app/modules/emby/__init__.py index a43114da3..fa7940640 100644 --- a/app/modules/emby/__init__.py +++ b/app/modules/emby/__init__.py @@ -145,7 +145,7 @@ class EmbyModule(_MediaServerModuleBase[Emby]): server_obj = self.get_instance(server) if server_obj: return server_obj.get_librarys( - username=username, hidden=hidden, **({"query": query} if query is not None else {}) + username=username, hidden=hidden, **MediaServerQueryOptions.as_kwargs(query) ) return None @@ -171,7 +171,7 @@ class EmbyModule(_MediaServerModuleBase[Emby]): server_obj = self.get_instance(server) if server_obj: return server_obj.get_items( - library_id, start_index, limit, **({"query": query} if query is not None else {}) + library_id, start_index, limit, **MediaServerQueryOptions.as_kwargs(query) ) return None @@ -201,7 +201,7 @@ class EmbyModule(_MediaServerModuleBase[Emby]): """ server_obj = self.get_instance(server) if server_obj: - return server_obj.get_iteminfo(item_id, **({"query": query} if query is not None else {})) + return server_obj.get_iteminfo(item_id, **MediaServerQueryOptions.as_kwargs(query)) return None def mediaserver_tv_episodes( @@ -218,7 +218,10 @@ class EmbyModule(_MediaServerModuleBase[Emby]): server_obj = self.get_instance(server) if not server_obj: return None - _, seasoninfo = server_obj.get_tv_episodes(item_id=item_id, **({"query": query} if query is not None else {})) + _, seasoninfo = server_obj.get_tv_episodes( + item_id=str(item_id) if item_id is not None else None, + **MediaServerQueryOptions.as_kwargs(query), + ) if not seasoninfo: return [] return [ @@ -245,7 +248,7 @@ class EmbyModule(_MediaServerModuleBase[Emby]): server_obj = self.get_instance(server) if not server_obj: return None - return server_obj.get_resume(num=count, username=username, **({"query": query} if query is not None else {})) + return server_obj.get_resume(num=count, username=username, **MediaServerQueryOptions.as_kwargs(query)) def mediaserver_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]: """ @@ -258,7 +261,7 @@ class EmbyModule(_MediaServerModuleBase[Emby]): server_obj = self.get_instance(server) if not server_obj: return None - return server_obj.get_play_url(item_id) + return server_obj.get_play_url(str(item_id)) def mediaserver_season_episode_ids( self, server: str, item_id: Union[str, int], season: int, *, query: Optional[MediaServerQueryOptions] = None @@ -276,7 +279,7 @@ class EmbyModule(_MediaServerModuleBase[Emby]): if not server_obj: return None return server_obj.get_season_episode_ids( - str(item_id), season, **({"query": query} if query is not None else {}) + str(item_id), season, **MediaServerQueryOptions.as_kwargs(query) ) def mediaserver_latest( @@ -299,7 +302,7 @@ class EmbyModule(_MediaServerModuleBase[Emby]): server_obj = self.get_instance(server) if not server_obj: return None - return server_obj.get_latest(num=count, username=username, **({"query": query} if query is not None else {})) + return server_obj.get_latest(num=count, username=username, **MediaServerQueryOptions.as_kwargs(query)) def mediaserver_latest_images( self, @@ -329,7 +332,7 @@ class EmbyModule(_MediaServerModuleBase[Emby]): links = [] items = ( self.mediaserver_latest( - server=server, count=count, username=username, **({"query": query} if query is not None else {}) + server=server, count=count, username=username, **MediaServerQueryOptions.as_kwargs(query) ) or [] ) @@ -338,7 +341,7 @@ class EmbyModule(_MediaServerModuleBase[Emby]): continue if item.BackdropImageTags: image_url = server_obj.get_backdrop_url( - item_id=item.id, image_tag=item.BackdropImageTags[0], remote=remote + item_id=str(item.id), image_tag=item.BackdropImageTags[0], remote=remote ) if image_url: links.append(image_url) diff --git a/app/modules/emby/emby.py b/app/modules/emby/emby.py index 1e7ec81db..7625780b1 100644 --- a/app/modules/emby/emby.py +++ b/app/modules/emby/emby.py @@ -599,7 +599,7 @@ class Emby: return None, None cached_item_id = item_id if not item_id: - item_id = self.__get_emby_series_id_by_name(title, year, **({"query": query} if query is not None else {})) + item_id = self.__get_emby_series_id_by_name(title, year, **MediaServerQueryOptions.as_kwargs(query)) if item_id is None: return None, None if not item_id: @@ -612,7 +612,7 @@ class Emby: if not item_info and cached_item_id and title: # 媒体删除后重新入库会导致缓存ID失效,回退到标题搜索避免误判整部剧缺失。 logger.warning(f"Emby缓存的电视剧媒体ID {cached_item_id} 已失效,尝试按标题重新搜索:{title}") - item_id = self.__get_emby_series_id_by_name(title, year, **({"query": query} if query is not None else {})) + item_id = self.__get_emby_series_id_by_name(title, year, **MediaServerQueryOptions.as_kwargs(query)) if item_id is None: return None, None if not item_id: @@ -632,7 +632,7 @@ class Emby: params = {"Season": season, "IsMissing": "false", "api_key": self._apikey} params.update( emby_query_params( - MediaServerQueryOptions(fields=set()) if query is not None and query.fields is not None else None + MediaServerQueryOptions(fields=frozenset()) if query is not None and query.fields is not None else None ) ) res_json = RequestUtils().get_res(url, params) @@ -676,7 +676,7 @@ class Emby: params = {"Season": season, "IsMissing": "false", "api_key": self._apikey} params.update( emby_query_params( - MediaServerQueryOptions(fields=set()) if query is not None and query.fields is not None else None + MediaServerQueryOptions(fields=frozenset()) if query is not None and query.fields is not None else None ) ) res_json = RequestUtils().get_res(url, params) @@ -1004,7 +1004,7 @@ class Emby: continue if "Folder" in item.get("Type"): for items in self.get_items( - parent=item.get("Id"), **({"query": query} if query is not None else {}) + parent=item.get("Id"), **MediaServerQueryOptions.as_kwargs(query) ): yield items elif item.get("Type") in ["Movie", "Series", "MusicAlbum"]: diff --git a/app/modules/fanart/__init__.py b/app/modules/fanart/__init__.py index 9e59bc11a..67f99ab2a 100644 --- a/app/modules/fanart/__init__.py +++ b/app/modules/fanart/__init__.py @@ -5,12 +5,12 @@ Fanart 图片查询、语言选择和媒体刮削清单生成。 import asyncio import hashlib from pathlib import Path -from typing import Optional, Tuple, Union +from typing import Any, Optional, Tuple, Union, cast from app.adapters.network.http import AsyncRequestUtils, RequestUtils from app.domain.context import MediaInfo from app.modules import _ModuleBase -from app.runtime.cache import cached +from app.runtime.cache import AsyncCacheControls, CacheControls, cached from app.runtime.log import logger from app.runtime.settings import get_runtime_setting from app.runtime.tasks import get_task_registry @@ -115,7 +115,9 @@ class FanartModule(_ModuleBase): return mediainfo @classmethod - def __set_mediainfo_images(cls, mediainfo: MediaInfo, images: dict) -> None: + def __set_mediainfo_images( + cls, mediainfo: MediaInfo, images: dict[str, Any] + ) -> None: """ 显式回填 MediaInfo 支持的展示图片字段。 """ @@ -130,7 +132,7 @@ class FanartModule(_ModuleBase): mediainfo: MediaInfo, season: Optional[int] = None, episode: Optional[int] = None, - ) -> Optional[dict]: + ) -> Optional[dict[str, Any]]: """ 查询媒体级或指定季的图片清单。 @@ -146,7 +148,7 @@ class FanartModule(_ModuleBase): def __obtain_fanart_images( self, mediainfo: MediaInfo, season: Optional[int] = None - ) -> Optional[dict]: + ) -> Optional[dict[str, Any]]: """ 获取 Fanart 图片并转换为刮削图片名称。 """ @@ -160,7 +162,7 @@ class FanartModule(_ModuleBase): self, mediainfo: MediaInfo, season: Optional[int] = None, - ) -> Optional[dict]: + ) -> Optional[dict[str, Any]]: """ 异步获取 Fanart 图片并转换为刮削图片名称。 """ @@ -181,19 +183,19 @@ class FanartModule(_ModuleBase): return None if not mediainfo.tmdb_id and not mediainfo.tvdb_id: return None - if mediainfo.type == MediaType.MOVIE: - return mediainfo.type, mediainfo.tmdb_id + if mediainfo.type == MediaType.MOVIE and mediainfo.tmdb_id is not None: + return MediaType.MOVIE, mediainfo.tmdb_id if mediainfo.tvdb_id: - return mediainfo.type, mediainfo.tvdb_id + return MediaType.TV, mediainfo.tvdb_id logger.info(f"{mediainfo.title_year} 没有tvdbid,无法获取fanart图片") return None def __extract_images( self, mediainfo: MediaInfo, - result: Optional[dict], + result: Optional[dict[str, Any]], season: Optional[int] = None, - ) -> Optional[dict]: + ) -> Optional[dict[str, Any]]: """ 把服务端图片类型转换为刮削文件名,并筛选所需季号。 @@ -208,7 +210,7 @@ class FanartModule(_ModuleBase): logger.warn(f"没有获取到 {mediainfo.title_year} 的fanart图片数据") return None - ret = {} + ret: dict[str, Any] = {} for name, images in result.items(): if not images: continue @@ -258,7 +260,7 @@ class FanartModule(_ModuleBase): return None @staticmethod - def __pick_best_image(_images: list): + def __pick_best_image(_images: list[dict[str, Any]]) -> dict[str, Any]: """ 按配置语言顺序选择图片,再按点赞数从高到低选择。 @@ -321,7 +323,7 @@ class FanartModule(_ModuleBase): @cached(maxsize=get_runtime_setting('CONF').fanart, ttl=get_runtime_setting('CONF').meta, shared_key="get") def __request_fanart( cls, media_type: MediaType, queryid: Union[str, int], _key_token: str = "" - ) -> Optional[dict]: + ) -> Optional[dict[str, Any]]: """ 查询 Fanart 图集并使用密钥指纹隔离缓存。 @@ -336,7 +338,8 @@ class FanartModule(_ModuleBase): image_url, raise_exception=True ) if ret: - return ret.json() + payload = ret.json() + return payload if isinstance(payload, dict) else {} else: logger.debug(f"未能获取到 {queryid} 的Fanart图片") return {} @@ -348,7 +351,7 @@ class FanartModule(_ModuleBase): @cached(maxsize=get_runtime_setting('CONF').fanart, ttl=get_runtime_setting('CONF').meta, shared_key="get") async def __async_request_fanart( cls, media_type: MediaType, queryid: Union[str, int], _key_token: str = "" - ) -> Optional[dict]: + ) -> Optional[dict[str, Any]]: """ 异步查询 Fanart 图集并使用密钥指纹隔离缓存。 @@ -364,7 +367,7 @@ class FanartModule(_ModuleBase): ).get_json( image_url ) - if ret: + if isinstance(ret, dict): return ret logger.debug(f"未能获取到 {queryid} 的Fanart图片") return {} @@ -397,8 +400,8 @@ class FanartModule(_ModuleBase): 清理同步缓存,并由宿主登记运行中事件循环的异步清理。 """ logger.info(f"开始清除{self.get_name()}缓存 ...") - self.__request_fanart.cache_clear() - async_cache_clear = self.__async_request_fanart.cache_clear() + cast(CacheControls, self.__request_fanart).cache_clear() + async_cache_clear = cast(AsyncCacheControls, self.__async_request_fanart).cache_clear() try: asyncio.get_running_loop() except RuntimeError: diff --git a/app/modules/feishu/module.py b/app/modules/feishu/module.py index ce3bff301..e9a4a73e1 100644 --- a/app/modules/feishu/module.py +++ b/app/modules/feishu/module.py @@ -12,8 +12,7 @@ from app.domain.context import Context, MediaInfo from app.modules._base.notification import _MessageChannelModuleBase from app.modules.feishu.feishu import Feishu from app.schemas.message import IncomingMessage, Message, MessageResponse -from app.schemas.notification import NotificationChannel -from app.schemas.types import ModuleType +from app.schemas.types import ModuleType, NotificationChannel register_channel_admin_resolver( NotificationChannel.Feishu, @@ -77,7 +76,7 @@ class FeishuModule(_MessageChannelModuleBase[Feishu]): """ return 2 - def _commands_enabled(self, config: Optional[dict]) -> bool: + def _commands_enabled(self, config: Optional[dict[str, Any]]) -> bool: """ 飞书机器人无斜杠命令概念,开放平台也不提供命令注册/删除 API,。 跳过命令注册,避免基类默认钩子调用不存在的 client.register_commands。 @@ -92,7 +91,7 @@ class FeishuModule(_MessageChannelModuleBase[Feishu]): """ return self._stop_service_instances() - def init_setting(self) -> Tuple[str, Union[str, bool]]: + def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]: """ 通知模块通过系统通知配置控制实例化,这里不额外设置环境开关。 """ @@ -153,12 +152,12 @@ class FeishuModule(_MessageChannelModuleBase[Feishu]): client_config = self.get_config(source) if not client_config: return None - client: Feishu = self.get_instance(client_config.name) + client: Optional[Feishu] = self.get_instance(client_config.name) if not client: return None return client.parse_message(body) - def post_message(self, message: Message, **kwargs) -> None: + def post_message(self, message: Message, **kwargs: Any) -> None: """ 向符合通知规则的实例发送卡片、附件或语音。 @@ -169,7 +168,7 @@ class FeishuModule(_MessageChannelModuleBase[Feishu]): if not self.check_message(message, conf.name): continue userid, chat_id, receive_id_type = self._resolve_message_target(message) - client: Feishu = self.get_instance(conf.name) + client: Optional[Feishu] = self.get_instance(conf.name) if client: if message.image and message.file_path: # 普通文件无法嵌入卡片,先发送图文卡片,再单独发送附件,避免图片被 file_path 分支吞掉。 @@ -228,7 +227,7 @@ class FeishuModule(_MessageChannelModuleBase[Feishu]): if not self.check_message(message, conf.name): continue userid, chat_id, receive_id_type = self._resolve_message_target(message) - client: Feishu = self.get_instance(conf.name) + client: Optional[Feishu] = self.get_instance(conf.name) if client: client.send_medias_message( message=message, @@ -249,7 +248,7 @@ class FeishuModule(_MessageChannelModuleBase[Feishu]): if not self.check_message(message, conf.name): continue userid, chat_id, receive_id_type = self._resolve_message_target(message) - client: Feishu = self.get_instance(conf.name) + client: Optional[Feishu] = self.get_instance(conf.name) if client: client.send_torrents_message( message=message, @@ -267,8 +266,8 @@ class FeishuModule(_MessageChannelModuleBase[Feishu]): chat_id: Union[str, int], text: str, title: Optional[str] = None, - buttons: Optional[List[List[dict]]] = None, - metadata: Optional[dict] = None, + buttons: Optional[List[List[dict[str, Any]]]] = None, + metadata: Optional[dict[str, Any]] = None, ) -> Optional[bool]: """ 通过指定实例更新普通卡片或流式卡片。 @@ -288,7 +287,7 @@ class FeishuModule(_MessageChannelModuleBase[Feishu]): for conf in self.get_configs().values(): if source != conf.name: continue - client: Feishu = self.get_instance(conf.name) + client: Optional[Feishu] = self.get_instance(conf.name) if client and client.edit_message( message_id=str(message_id), title=title, @@ -313,7 +312,7 @@ class FeishuModule(_MessageChannelModuleBase[Feishu]): if not self.check_message(message, conf.name): continue userid, chat_id, receive_id_type = self._resolve_message_target(message) - client: Feishu = self.get_instance(conf.name) + client: Optional[Feishu] = self.get_instance(conf.name) if not client: continue if message.image and message.file_path: @@ -529,7 +528,7 @@ class FeishuModule(_MessageChannelModuleBase[Feishu]): message_id: Optional[Union[str, int]] = None, chat_id: Optional[Union[str, int]] = None, text: Optional[str] = None, - ) -> Optional[dict]: + ) -> Optional[dict[str, Any]]: """ 为有文本的普通消息添加“正在处理”表情。 @@ -572,7 +571,7 @@ class FeishuModule(_MessageChannelModuleBase[Feishu]): userid: Optional[Union[str, int]] = None, message_id: Optional[Union[str, int]] = None, chat_id: Optional[Union[str, int]] = None, - status: Optional[dict] = None, + status: Optional[dict[str, Any]] = None, ) -> Optional[bool]: """ 根据起始处理状态删除“正在处理”表情。 @@ -607,7 +606,10 @@ class FeishuModule(_MessageChannelModuleBase[Feishu]): """ if response.channel != self._channel or not isinstance(response.metadata, dict): return False - stream_meta = response.metadata.get("feishu_streaming") or {} + raw_stream_meta = response.metadata.get("feishu_streaming") + if not isinstance(raw_stream_meta, dict): + return False + stream_meta: dict[str, Any] = raw_stream_meta card_id = str(stream_meta.get("card_id") or "").strip() if not card_id: return False diff --git a/app/modules/filemanager/module.py b/app/modules/filemanager/module.py index 5603d9769..f365ec1da 100644 --- a/app/modules/filemanager/module.py +++ b/app/modules/filemanager/module.py @@ -37,7 +37,7 @@ class FileManagerModule(_ModuleBase): 按存储标识解析适配器;整理路径规划与写入执行分开,具体传输交给 TransHandler。 """ - _storage_schemas = [] + _storage_schemas: list[type[StorageBase]] = [] _support_storages: list[str] = [] def __init__(self) -> None: @@ -186,9 +186,11 @@ class FileManagerModule(_ModuleBase): :return: 当前返回 None,不提供额外设置初始化结果 """ - pass + return None - def storage_manage(self, storage: str, action: StorageAction, **params) -> Dict[str, Any]: + def storage_manage( + self, storage: str, action: StorageAction, **params: Any + ) -> Dict[str, Any]: """ 按存储标识路由配置、容量和登录管理操作。 @@ -260,7 +262,7 @@ class FileManagerModule(_ModuleBase): rename_dict=handler.get_naming_dict(meta=meta, mediainfo=mediainfo, episodes_info=episodes_info, - file_ext=Path(meta.title).suffix) + file_ext=Path(meta.title or "").suffix) ) return path.as_posix() if path else "" @@ -477,7 +479,7 @@ class FileManagerModule(_ModuleBase): def snapshot_storage(self, storage: str, path: Path, last_snapshot_time: Optional[float] = None, max_depth: int = 5, - previous_snapshot: Optional[Dict[str, Dict]] = None) -> Optional[Dict[str, Dict]]: + previous_snapshot: Optional[Dict[str, Dict[str, Any]]] = None) -> Optional[Dict[str, Dict[str, Any]]]: """ 委托存储生成目录树快照或增量快照。 @@ -689,7 +691,7 @@ class FileManagerModule(_ModuleBase): :return: 整理执行结果;存储不支持时返回失败结果,执行冲突或清理异常可向上传播 """ del raise_exception - source_fileitem = FileItem(**checkpoint.planning_input.source_fileitem) + source_fileitem = FileItem.model_validate(checkpoint.planning_input.source_fileitem) cleanup_before_transfer = None observe_cleanup_before_transfer = None cleanup_payload = checkpoint.planning_input.options.get( @@ -746,7 +748,7 @@ class FileManagerModule(_ModuleBase): success=False, message=f"不支持的存储类型:{source_storage}", fileitem=source_fileitem, - fail_list=[source_fileitem.path], + fail_list=[source_fileitem.path or ""], transfer_type=checkpoint.resolved_transfer_type, need_notify=checkpoint.need_notify, ) @@ -757,7 +759,7 @@ class FileManagerModule(_ModuleBase): success=False, message=f"不支持的存储类型:{checkpoint.target_storage}", fileitem=source_fileitem, - fail_list=[source_fileitem.path], + fail_list=[source_fileitem.path or ""], transfer_type=checkpoint.resolved_transfer_type, need_notify=checkpoint.need_notify, ) @@ -804,12 +806,20 @@ class FileManagerModule(_ModuleBase): album = getattr(mediainfo, "album", None) if not album and music_type == MUSIC_ENTITY_ALBUM: album = mediainfo.title + year_value = mediainfo.year + year = ( + year_value + if isinstance(year_value, int) + else int(year_value) + if isinstance(year_value, str) and year_value.isdigit() + else None + ) return MetaMusic( - title=mediainfo.title, + title=mediainfo.title or "", artists=list(getattr(mediainfo, "artists", None) or []), album=album, album_artist=getattr(mediainfo, "album_artist", None), - year=mediainfo.year, + year=year, disc_number=getattr(mediainfo, "disc_number", None), track_number=getattr(mediainfo, "track_number", None), total_tracks=getattr(mediainfo, "total_tracks", None), @@ -817,7 +827,7 @@ class FileManagerModule(_ModuleBase): media_id=getattr(mediainfo, "media_id", None), ) - meta = MetaInfo(mediainfo.title) + meta = MetaInfo(mediainfo.title or "") if meta.type == MediaType.UNKNOWN and mediainfo.type is not None: meta.type = mediainfo.type if meta.year is None: @@ -1006,6 +1016,8 @@ class FileManagerModule(_ModuleBase): getattr(mediainfo, "total_tracks", None), ) else: + if not isinstance(mediainfo, MusicInfo): + return None exists = self._music_recording_exists(fileitems, mediainfo) if not exists: logger.debug(f"{mediainfo.title_year} 在本地音乐库中尚不完整") @@ -1015,7 +1027,7 @@ class FileManagerModule(_ModuleBase): if mediainfo.type == MediaType.TV: seasons: Dict[int, list[int]] = {} for fileitem in fileitems: - file_meta = MetaInfo(fileitem.basename) + file_meta = MetaInfo(fileitem.basename or "") season_index = file_meta.begin_season if file_meta.begin_season is not None else 1 episode_index = file_meta.begin_episode if not episode_index: diff --git a/app/modules/filemanager/storages/__init__.py b/app/modules/filemanager/storages/__init__.py index 3e3df5f78..0b8bacb9c 100644 --- a/app/modules/filemanager/storages/__init__.py +++ b/app/modules/filemanager/storages/__init__.py @@ -4,7 +4,7 @@ from abc import ABCMeta, abstractmethod from pathlib import Path, PurePosixPath -from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple, Union from tqdm import tqdm @@ -16,6 +16,7 @@ from app.schemas.exception import StorageQueryError from app.schemas.file import FileItem as _SchemaFileItem from app.schemas.file import StorageUsage as _SchemaStorageUsage from app.schemas.system import StorageConf as _SchemaStorageConf +from app.schemas.types import StorageSchema _SnapshotInfo = Dict[str, Dict[str, Any]] @@ -55,7 +56,7 @@ class StorageBase(metaclass=ABCMeta): 具体实现负责外部资源、路径含义和失败语义。普通查询可返回空结果, 覆盖保护等需要区分不存在与失败的场景必须使用严格查询接口。 """ - schema = None + schema: ClassVar[StorageSchema] transtype: dict[str, str] = {} @property @@ -97,7 +98,7 @@ class StorageBase(metaclass=ABCMeta): """ pass - def generate_qrcode(self, *args, **kwargs) -> Optional[Tuple[dict[str, object], str]]: + def generate_qrcode(self, *args: Any, **kwargs: Any) -> Optional[Tuple[dict[str, object], str]]: """ 提供存储二维码登录扩展入口。 @@ -107,7 +108,7 @@ class StorageBase(metaclass=ABCMeta): """ pass - def generate_auth_url(self, *args, **kwargs) -> Optional[Tuple[dict[str, object], str]]: + def generate_auth_url(self, *args: Any, **kwargs: Any) -> Optional[Tuple[dict[str, object], str]]: """ 提供存储 OAuth2 授权地址扩展入口。 @@ -117,7 +118,7 @@ class StorageBase(metaclass=ABCMeta): """ return {}, "此存储不支持 OAuth2 授权" - def check_login(self, *args, **kwargs) -> Optional[Dict[str, str]]: + def check_login(self, *args: Any, **kwargs: Any) -> Optional[Tuple[dict[str, Any], str]]: """ 提供存储登录状态查询扩展入口。 @@ -135,14 +136,14 @@ class StorageBase(metaclass=ABCMeta): """ return self.storagehelper.get_storage(self.schema.value) - def get_conf(self) -> dict: + def get_conf(self) -> dict[str, Any]: """ 读取当前存储的配置内容。 :return: 配置字典;未配置时返回空字典 """ conf = self.get_config() - return conf.config if conf else {} + return conf.config or {} if conf else {} def set_config(self, conf: dict[str, object]) -> None: """ @@ -286,6 +287,8 @@ class StorageBase(metaclass=ABCMeta): :param fileitem: 需要查找父目录的文件或目录项 :return: 父路径对应的存储项;普通查询无结果时返回 None """ + if not fileitem.path: + return None return self.get_item(Path(fileitem.path).parent) @abstractmethod @@ -509,8 +512,14 @@ class StorageBase(metaclass=ABCMeta): :param previous_children: 旧快照目录直接子项索引 :param previous_files: 旧快照路径反向索引 """ + if not fileitem.path: + return directory_path = PurePosixPath(fileitem.path) - child_paths = {PurePosixPath(sub_file.path) for sub_file in sub_files} + child_paths = { + PurePosixPath(sub_file.path) + for sub_file in sub_files + if sub_file.path + } for direct_child_path in previous_children.pop(directory_path, ()): if direct_child_path not in child_paths: StorageBase._snapshot_remove_previous_subtree( @@ -539,6 +548,8 @@ class StorageBase(metaclass=ABCMeta): :param current_depth: 当前递归深度 """ try: + if not fileitem.path: + return if fileitem.type == "dir": if current_depth >= max_depth: return diff --git a/app/modules/filemanager/storages/alipan.py b/app/modules/filemanager/storages/alipan.py index a7174adae..57b1f8a35 100644 --- a/app/modules/filemanager/storages/alipan.py +++ b/app/modules/filemanager/storages/alipan.py @@ -8,7 +8,10 @@ import secrets import threading import time from pathlib import Path -from typing import Any, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Union, cast, overload + +if TYPE_CHECKING: + from requests import Response from app.adapters.network.http import HttpRequestError, RequestUtils from app.foundation import temporal as time_tools @@ -109,7 +112,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton): ) if not drive_id: raise NoCheckInException("【阿里云盘】请先扫码登录!") - return drive_id + return cast(str, drive_id) @property def access_token(self) -> Optional[str]: @@ -127,9 +130,13 @@ class AliPan(StorageBase, metaclass=WeakSingleton): expires_in = tokens.get("expires_in", 0) refresh_time = tokens.get("refresh_time", 0) if expires_in and refresh_time + expires_in < int(time.time()): - tokens = self.__refresh_access_token(refresh_token) - if tokens: - self.set_config({"refresh_time": int(time.time()), **tokens}) + if not isinstance(refresh_token, str) or not refresh_token: + raise SessionInvalidException("【阿里云盘】会话失效,请重新扫码登录!") + refreshed_tokens = self.__refresh_access_token(refresh_token) + if not refreshed_tokens: + return None + tokens = refreshed_tokens + self.set_config({"refresh_time": int(time.time()), **tokens}) access_token = tokens.get("access_token") if access_token: self._request_utils.update_headers( @@ -137,7 +144,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton): ) return access_token - def generate_qrcode(self) -> Tuple[dict, str]: + def generate_qrcode(self) -> Tuple[dict[str, Any], str]: """ 创建使用 PKCE plain 校验方式的设备授权二维码。 @@ -164,12 +171,14 @@ class AliPan(StorageBase, metaclass=WeakSingleton): if resp is None: return {}, "网络错误" result = resp.json() + if not isinstance(result, dict): + raise SessionInvalidException("【阿里云盘】授权接口返回无效数据") if result.get("code"): - return {}, result.get("message") + return {}, str(result.get("message") or "生成二维码失败") self._auth_state = {"sid": result.get("sid"), "code_verifier": code_verifier} return {"codeUrl": result.get("qrCodeUrl")}, "" - def check_login(self) -> Optional[Tuple[dict, str]]: + def check_login(self) -> Optional[Tuple[dict[str, Any], str]]: """ 轮询二维码状态,登录成功后持久化令牌和授权空间信息。 @@ -194,6 +203,8 @@ class AliPan(StorageBase, metaclass=WeakSingleton): if resp is None: return {}, "网络错误" result = resp.json() + if not isinstance(result, dict): + raise SessionInvalidException("【阿里云盘】授权接口返回无效数据") status = result.get("status") if status == "LoginSuccess": authCode = result.get("authCode") @@ -202,11 +213,11 @@ class AliPan(StorageBase, metaclass=WeakSingleton): if tokens: self.set_config({"refresh_time": int(time.time()), **tokens}) self.__get_drive_id() - return {"status": status, "tip": _status_text.get(status, "未知错误")}, "" + return {"status": status, "tip": _status_text.get(str(status), "未知错误")}, "" except HttpRequestError as e: return {}, str(e) - def __get_access_token(self) -> dict: + def __get_access_token(self) -> dict[str, Any]: """ 以扫码授权码和内存中的校验值换取令牌。 @@ -228,13 +239,15 @@ class AliPan(StorageBase, metaclass=WeakSingleton): if resp is None: raise SessionInvalidException("【阿里云盘】获取 access_token 失败") result = resp.json() + if not isinstance(result, dict): + raise SessionInvalidException("【阿里云盘】授权接口返回无效数据") if result.get("code"): raise Exception( f"【阿里云盘】{result.get('code')} - {result.get('message')}!" ) return result - def __refresh_access_token(self, refresh_token: str) -> Optional[dict]: + def __refresh_access_token(self, refresh_token: str) -> Optional[dict[str, Any]]: """ 使用刷新令牌获取新凭据。 @@ -254,10 +267,12 @@ class AliPan(StorageBase, metaclass=WeakSingleton): ) if resp is None: logger.error( - f"【阿里云盘】刷新 access_token 失败:refresh_token={refresh_token}" + "【阿里云盘】刷新 access_token 失败:未收到响应" ) return None result = resp.json() + if not isinstance(result, dict): + raise SessionInvalidException("【阿里云盘】授权接口返回无效数据") if result.get("code"): logger.warn( f"【阿里云盘】刷新 access_token 失败:{result.get('code')} - {result.get('message')}!" @@ -277,6 +292,8 @@ class AliPan(StorageBase, metaclass=WeakSingleton): logger.error("获取默认存储桶ID失败") return None result = resp.json() + if not isinstance(result, dict): + raise SessionInvalidException("【阿里云盘】授权接口返回无效数据") if result.get("code"): logger.warn( f"获取默认存储ID失败:{result.get('code')} - {result.get('message')}!" @@ -287,9 +304,29 @@ class AliPan(StorageBase, metaclass=WeakSingleton): self.set_config(conf) return None + @overload def _request_api( - self, method: str, endpoint: str, result_key: Optional[str] = None, **kwargs - ) -> Optional[Union[dict, list]]: + self, method: str, endpoint: str, result_key: None = None, + **kwargs: Any, + ) -> Optional[dict[str, Any]]: + """ + 返回开放接口的完整 JSON 对象。 + """ + ... + + @overload + def _request_api( + self, method: str, endpoint: str, result_key: Optional[str], + **kwargs: Any, + ) -> Optional[Union[dict[str, Any], list[Any]]]: + """ + 返回调用方选取的 JSON 对象或列表字段。 + """ + ... + + def _request_api( + self, method: str, endpoint: str, result_key: Optional[str] = None, **kwargs: Any + ) -> Optional[Union[dict[str, Any], list[Any]]]: """ 调用开放接口,并在 HTTP 限流后等待重试。 @@ -327,6 +364,9 @@ class AliPan(StorageBase, metaclass=WeakSingleton): return self._request_api(method, endpoint, result_key, **kwargs) ret_data = resp.json() + if not isinstance(ret_data, dict): + logger.warning("开放接口返回了非对象 JSON:%s", endpoint) + return None if ret_data.get("code"): if not no_error_log: logger.warn( @@ -334,10 +374,11 @@ class AliPan(StorageBase, metaclass=WeakSingleton): ) if result_key: - return ret_data.get(result_key) + value = ret_data.get(result_key) + return value if isinstance(value, (dict, list)) else None return ret_data - def __get_fileitem(self, fileinfo: dict, parent: str = "/") -> _SchemaFileItem: + def __get_fileitem(self, fileinfo: dict[str, Any], parent: str = "/") -> _SchemaFileItem: """ 将开放接口文件元数据转换为存储文件项。 @@ -359,7 +400,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton): name=fileinfo.get("name"), basename=fileinfo.get("name"), size=fileinfo.get("size"), - modify_time=time_tools.parse_timestamp(fileinfo.get("updated_at")), + modify_time=time_tools.parse_timestamp(str(fileinfo.get("updated_at") or "")), drive_id=fileinfo.get("drive_id"), ) else: @@ -370,10 +411,10 @@ class AliPan(StorageBase, metaclass=WeakSingleton): type="file", path=f"{parent}{fileinfo.get('name')}", name=fileinfo.get("name"), - basename=Path(fileinfo.get("name")).stem, + basename=Path(str(fileinfo.get("name") or "")).stem, size=fileinfo.get("size"), extension=fileinfo.get("file_extension"), - modify_time=time_tools.parse_timestamp(fileinfo.get("updated_at")), + modify_time=time_tools.parse_timestamp(str(fileinfo.get("updated_at") or "")), thumbnail=fileinfo.get("thumbnail"), drive_id=fileinfo.get("drive_id"), ) @@ -425,6 +466,8 @@ class AliPan(StorageBase, metaclass=WeakSingleton): return [item] return [] + parent_file_id: Optional[str] + drive_id: Optional[str] if fileitem.path == "/": parent_file_id = "root" drive_id = self._default_drive_id @@ -486,6 +529,8 @@ class AliPan(StorageBase, metaclass=WeakSingleton): :param name: 待创建的直接子目录名 :return: 延迟查询到的目录项;创建或查询失败时返回 None """ + if not parent_item.path: + return None resp = self._request_api( "POST", "/adrive/v1.0/openFile/create", @@ -531,7 +576,10 @@ class AliPan(StorageBase, metaclass=WeakSingleton): if file_size == 0: return "" - md5 = hashlib.md5(self.access_token.encode()).hexdigest() + access_token = self.access_token + if not access_token: + raise SessionInvalidException("【阿里云盘】会话失效,请重新扫码登录!") + md5 = hashlib.md5(access_token.encode()).hexdigest() hex_str = md5[:16] try: @@ -576,9 +624,9 @@ class AliPan(StorageBase, metaclass=WeakSingleton): parent_file_id: str, file_name: str, file_path: Path, - check_name_mode="refuse", + check_name_mode: str = "refuse", chunk_size: int = 1 * 1024 * 1024 * 1024, - ): + ) -> dict[str, Any]: """ 创建远端上传任务,预摘要匹配时提交秒传证明。 @@ -632,7 +680,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton): def _refresh_upload_urls( self, drive_id: str, file_id: str, upload_id: str, part_numbers: List[int] - ): + ) -> List[dict[str, Any]]: """ 为指定分片重新申请上传地址。 @@ -655,10 +703,10 @@ class AliPan(StorageBase, metaclass=WeakSingleton): raise Exception("【阿里云盘】刷新分片上传地址失败!") if resp.get("code"): raise Exception(resp.get("message")) - return resp.get("part_info_list", []) + return cast(list[dict[str, Any]], resp.get("part_info_list", [])) @staticmethod - def _upload_part(upload_url: str, data: bytes): + def _upload_part(upload_url: str, data: bytes) -> Optional[Response]: """ 通过独立 HTTP 请求上传一个分片。 @@ -668,7 +716,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton): """ return RequestUtils(timeout=60, verify=True).put_res(upload_url, data=data) - def _list_uploaded_parts(self, drive_id: str, file_id: str, upload_id: str) -> dict: + def _list_uploaded_parts(self, drive_id: str, file_id: str, upload_id: str) -> dict[str, Any]: """ 查询上传任务已经接收的分片以便跳过重复传输。 @@ -687,7 +735,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton): raise Exception(resp.get("message")) return resp - def _complete_upload(self, drive_id: str, file_id: str, upload_id: str): + def _complete_upload(self, drive_id: str, file_id: str, upload_id: str) -> dict[str, Any]: """ 请求云端合并分片并完成上传。 @@ -721,14 +769,17 @@ class AliPan(StorageBase, metaclass=WeakSingleton): :param new_name: 目标文件名;为空时沿用源文件名 :return: 上传后的文件项;取消、创建失败或目标暂不可见时可能返回 None """ + target_dir_path = cast(str, target_dir.path) + drive_id = cast(str, target_dir.drive_id) + parent_file_id = cast(str, target_dir.fileid) target_name = new_name or local_path.name - target_path = Path(target_dir.path) / target_name + target_path = Path(target_dir_path) / target_name file_size = local_path.stat().st_size chunk_size = 10 * 1024 * 1024 create_res = self._create_file( - drive_id=target_dir.drive_id, - parent_file_id=target_dir.fileid, + drive_id=drive_id, + parent_file_id=parent_file_id, file_name=target_name, file_path=local_path, chunk_size=chunk_size, @@ -745,12 +796,12 @@ class AliPan(StorageBase, metaclass=WeakSingleton): if not file_id: logger.warn(f"【阿里云盘】创建 {target_name} 文件失败!") return None - upload_id = create_res.get("upload_id") - part_info_list = create_res.get("part_info_list") + upload_id = cast(str, create_res.get("upload_id")) + part_info_list = cast(list[dict[str, Any]], create_res.get("part_info_list", [])) uploaded_parts = set() uploaded_info = self._list_uploaded_parts( - drive_id=target_dir.drive_id, file_id=file_id, upload_id=upload_id + drive_id=drive_id, file_id=file_id, upload_id=upload_id ) for part in uploaded_info.get("uploaded_parts", []): uploaded_parts.add(part["part_number"]) @@ -785,7 +836,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton): try: if attempt > 0: new_urls = self._refresh_upload_urls( - drive_id=target_dir.drive_id, + drive_id=drive_id, file_id=file_id, upload_id=upload_id, part_numbers=[part_num], @@ -823,7 +874,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton): progress_callback(100) result = self._complete_upload( - drive_id=target_dir.drive_id, file_id=file_id, upload_id=upload_id + drive_id=drive_id, file_id=file_id, upload_id=upload_id ) if not result: raise Exception("【阿里云盘】完成上传失败!") @@ -831,7 +882,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton): logger.warn( f"【阿里云盘】{target_name} 上传失败:{result.get('message')}!" ) - return self.__get_fileitem(result, parent=target_dir.path) + return self.__get_fileitem(result, parent=target_dir_path) def download(self, fileitem: _SchemaFileItem, path: Optional[Path] = None) -> Optional[Path]: """ @@ -843,6 +894,8 @@ class AliPan(StorageBase, metaclass=WeakSingleton): :param path: 目标目录;为空时使用配置中的临时目录,目录需已存在 :return: 下载完成的本地路径;地址获取失败、取消或传输异常时返回 None """ + if not fileitem.path: + return None download_info = self._request_api( "POST", "/adrive/v1.0/openFile/getDownloadUrl", @@ -1072,6 +1125,8 @@ class AliPan(StorageBase, metaclass=WeakSingleton): :param fileitem: 提供查询路径的文件项;其 drive_id 不参与本次查询 :return: 查到的文件项;不存在或查询失败时返回 None """ + if not fileitem.path: + return None return self.get_item(Path(fileitem.path)) def copy(self, fileitem: _SchemaFileItem, path: Path, new_name: str, **_kwargs: Any) -> bool: @@ -1086,6 +1141,8 @@ class AliPan(StorageBase, metaclass=WeakSingleton): :param new_name: 复制后的目标文件名 :return: 目录有效且复制接口成功时为 True,前置检查或复制失败时为 False """ + if not fileitem.name: + return False dest_fileitem = self.get_item(path, drive_id=fileitem.drive_id) if not dest_fileitem or dest_fileitem.type != "dir": logger.warn(f"【阿里云盘】目标路径 {path} 不存在或不是目录!") @@ -1107,8 +1164,9 @@ class AliPan(StorageBase, metaclass=WeakSingleton): return False new_path = Path(path) / fileitem.name new_file = self._delay_get_item(new_path) - self.rename(new_file, new_name) - return True + if new_file is None: + return False + return self.rename(new_file, new_name) def move(self, fileitem: _SchemaFileItem, path: Path, new_name: str) -> bool: """ @@ -1148,9 +1206,9 @@ class AliPan(StorageBase, metaclass=WeakSingleton): :param fileitem: 源文件项,当前不使用 :param target_file: 链接目标路径,当前不使用 - :return: 固定返回 None,不创建链接 + :return: 固定返回 False,不创建链接 """ - pass + return False def softlink(self, fileitem: _SchemaFileItem, target_file: Path) -> bool: """ @@ -1158,9 +1216,9 @@ class AliPan(StorageBase, metaclass=WeakSingleton): :param fileitem: 源文件项,当前不使用 :param target_file: 链接目标路径,当前不使用 - :return: 固定返回 None,不创建链接 + :return: 固定返回 False,不创建链接 """ - pass + return False def usage(self) -> Optional[_SchemaStorageUsage]: """ diff --git a/app/modules/filemanager/storages/alist.py b/app/modules/filemanager/storages/alist.py index de980506e..54625e4d7 100644 --- a/app/modules/filemanager/storages/alist.py +++ b/app/modules/filemanager/storages/alist.py @@ -7,13 +7,13 @@ import json import time from datetime import datetime from pathlib import Path -from typing import Any, Callable, List, Optional +from typing import Any, Callable, List, Optional, cast from app.adapters.network.http import RequestUtils from app.foundation.singleton import WeakSingleton from app.foundation.url import UrlUtils from app.modules.filemanager.storages import StorageBase, transfer_process -from app.runtime.cache import cached +from app.runtime.cache import CacheControls, cached from app.runtime.log import logger from app.runtime.settings import get_runtime_setting from app.runtime.stop import runtime_stop_state @@ -63,7 +63,7 @@ class Alist(StorageBase, metaclass=WeakSingleton): 清除当前地址和账号对应的临时登录令牌缓存。 """ conf = self.get_conf() - self.__login_token.cache_delete( + cast(CacheControls, self.__login_token).cache_delete( self, self.__get_base_url, conf.get("username"), conf.get("password") ) @@ -117,7 +117,7 @@ class Alist(StorageBase, metaclass=WeakSingleton): url = self.get_conf().get("url") if url is None: return "" - return UrlUtils.standardize_base_url(self.get_conf().get("url")) + return UrlUtils.standardize_base_url(str(url)) def __get_api_url(self, path: str) -> str: """ @@ -126,7 +126,7 @@ class Alist(StorageBase, metaclass=WeakSingleton): :param path: API路径 :return: 基础地址与接口路径组合后的完整请求地址 """ - return UrlUtils.adapt_request_url(self.__get_base_url, path) + return UrlUtils.adapt_request_url(self.__get_base_url, path) or "" @property def __get_valuable_toke(self) -> str: @@ -157,8 +157,11 @@ class Alist(StorageBase, metaclass=WeakSingleton): 使用账号密码生成一个临时 token。 缓存2天,提前5分钟更新 """ + url = UrlUtils.adapt_request_url(base_url, "/api/auth/login") + if not url: + return "" resp = RequestUtils(headers={"Content-Type": "application/json"}).post_res( - UrlUtils.adapt_request_url(base_url, "/api/auth/login"), + url, data=json.dumps( { "username": username, @@ -184,7 +187,8 @@ class Alist(StorageBase, metaclass=WeakSingleton): return "" logger.debug("【OpenList】AList获取令牌成功") - return result["data"]["token"] + token = result["data"]["token"] + return token if isinstance(token, str) else "" def __get_header_with_token(self) -> dict[str, str]: """ @@ -220,6 +224,8 @@ class Alist(StorageBase, metaclass=WeakSingleton): :param refresh: 是否要求服务端刷新列表缓存 :return: 文件项列表;HTTP 或业务错误返回空列表,之前成功页的结果也会丢弃 """ + if not fileitem.path: + return [] if fileitem.type == "file": item = self.get_item(Path(fileitem.path)) if item: @@ -303,6 +309,8 @@ class Alist(StorageBase, metaclass=WeakSingleton): :param name: 要创建的子目录名称 :return: 查询到的目录项;接口成功但暂不可见时按目标路径构造目录项,接口失败返回 None """ + if not fileitem.path: + return None path = Path(fileitem.path) / name resp = RequestUtils(headers=self.__get_header_with_token()).post_res( self.__get_api_url("/api/fs/mkdir"), @@ -406,7 +414,7 @@ class Alist(StorageBase, metaclass=WeakSingleton): return self.__build_fileitem(path, result["data"]) - def __build_fileitem(self, path: Path, data: dict) -> _SchemaFileItem: + def __build_fileitem(self, path: Path, data: dict[str, Any]) -> _SchemaFileItem: """ 根据接口返回数据构建文件项。 @@ -466,6 +474,8 @@ class Alist(StorageBase, metaclass=WeakSingleton): :param fileitem: 目标存储项 :return: 父目录信息;普通查询无结果时返回 None """ + if not fileitem.path: + return None return self.get_folder(Path(fileitem.path).parent) def delete(self, fileitem: _SchemaFileItem) -> bool: @@ -475,6 +485,8 @@ class Alist(StorageBase, metaclass=WeakSingleton): :param fileitem: 目标存储项;优先使用 name,否则取 path 的末段 :return: HTTP 与业务状态均为 200 返回 True;名称为空或接口失败返回 False """ + if not fileitem.path: + return False path = Path(fileitem.path) name = fileitem.name or path.name if not name: @@ -514,6 +526,8 @@ class Alist(StorageBase, metaclass=WeakSingleton): :param name: 目标名称 :return: HTTP 与业务状态均为 200 返回 True,否则返回 False;不原地修改输入项 """ + if not fileitem.path: + return False resp = RequestUtils(headers=self.__get_header_with_token()).post_res( self.__get_api_url("/api/fs/rename"), json={ @@ -558,6 +572,8 @@ class Alist(StorageBase, metaclass=WeakSingleton): :param password: 详情查询使用的可选访问密码 :return: 本地目标路径;详情查询失败或取消返回 None """ + if not fileitem.path: + return None resp = RequestUtils(headers=self.__get_header_with_token()).post_res( self.__get_api_url("/api/fs/get"), json={ @@ -590,13 +606,12 @@ class Alist(StorageBase, metaclass=WeakSingleton): download_url = UrlUtils.adapt_request_url( self.__get_base_url, f"/d{fileitem.path}" ) - if result["data"]["sign"]: + if download_url and result["data"]["sign"]: download_url = download_url + "?sign=" + result["data"]["sign"] - if not path: - local_path = get_runtime_setting('TEMP_PATH') / fileitem.name - else: - local_path = path / fileitem.name + local_path = self._build_download_path(fileitem, path or get_runtime_setting('TEMP_PATH')) + if local_path is None or not download_url: + return None request_utils = RequestUtils(headers=self.__get_header_with_token()) try: @@ -633,6 +648,8 @@ class Alist(StorageBase, metaclass=WeakSingleton): :param task: 是否要求服务端作为后台任务处理;默认 False,避免后续操作抢先执行 :return: 上传后查询到的文件项;上传、取消或元数据确认失败返回 None """ + if not fileitem.path: + return None try: target_name = new_name or path.name target_path = Path(fileitem.path) / target_name @@ -723,7 +740,7 @@ class Alist(StorageBase, metaclass=WeakSingleton): progress_callback(100) new_item = self._delay_get_item(target_path, refresh=True) - if new_item and new_name and new_name != path.name: + if new_item and new_item.path and new_name and new_name != path.name: if self.rename(new_item, new_name): return self._delay_get_item( Path(new_item.path).with_name(new_name), refresh=True @@ -764,6 +781,8 @@ class Alist(StorageBase, metaclass=WeakSingleton): :param fileitem: 目标文件或目录项 :return: 最新文件项;普通查询失败返回 None """ + if not fileitem.path: + return None return self.get_item(Path(fileitem.path)) def copy(self, fileitem: _SchemaFileItem, path: Path, new_name: str, **_kwargs: Any) -> bool: @@ -777,6 +796,8 @@ class Alist(StorageBase, metaclass=WeakSingleton): :param new_name: 目标文件名 :return: 复制接口成功返回 True,接口失败返回 False;后续重命名失败不改变成功结果 """ + if not fileitem.path or not fileitem.name: + return False resp = RequestUtils(headers=self.__get_header_with_token()).post_res( self.__get_api_url("/api/fs/copy"), json={ @@ -819,6 +840,8 @@ class Alist(StorageBase, metaclass=WeakSingleton): :param new_name: 目标文件名 :return: 目标项;同名复制成功但暂不可见时按源元数据构造,改名无法确认时返回 None """ + if not fileitem.path or not fileitem.name: + return None if not self.copy(fileitem=fileitem, path=path, new_name=new_name): return None target_path = path / new_name @@ -844,6 +867,8 @@ class Alist(StorageBase, metaclass=WeakSingleton): :param new_name: 目标文件名 :return: 移动接口成功返回 True,否则返回 False;前置重命名的布尔结果不单独检查 """ + if not fileitem.path or not fileitem.name: + return False if fileitem.name != new_name: self.rename(fileitem, new_name) resp = RequestUtils(headers=self.__get_header_with_token()).post_res( @@ -897,9 +922,9 @@ class Alist(StorageBase, metaclass=WeakSingleton): :param fileitem: 兼容接口的源文件项,当前忽略 :param target_file: 兼容接口的目标路径,当前忽略 - :return: 固定返回 None + :return: 固定返回 False """ - pass + return False def softlink(self, fileitem: _SchemaFileItem, target_file: Path) -> bool: """ @@ -907,9 +932,9 @@ class Alist(StorageBase, metaclass=WeakSingleton): :param fileitem: 兼容接口的源文件项,当前忽略 :param target_file: 兼容接口的目标路径,当前忽略 - :return: 固定返回 None + :return: 固定返回 False """ - pass + return False def usage(self) -> Optional[_SchemaStorageUsage]: """ diff --git a/app/modules/filemanager/storages/local.py b/app/modules/filemanager/storages/local.py index d1d5eecc1..250514c53 100644 --- a/app/modules/filemanager/storages/local.py +++ b/app/modules/filemanager/storages/local.py @@ -219,6 +219,8 @@ class LocalStorage(StorageBase): :param fileitem: 目标文件项;调用方应传入普通文件 :return: 最新文件信息;路径不存在时返回 None """ + if not fileitem.path: + return None path_obj = Path(fileitem.path) if not path_obj.exists(): return None @@ -259,6 +261,8 @@ class LocalStorage(StorageBase): :param name: 目标名称,目标已存在时遵循操作系统重命名语义 :return: 操作完成返回 True,源项缺失或其他失败返回 False """ + if not fileitem.path: + return False path_obj = Path(fileitem.path) try: fsproxy.rename(path_obj, path_obj.parent / name) @@ -277,6 +281,8 @@ class LocalStorage(StorageBase): :param path: 兼容远端存储接口的保存目录,当前忽略 :return: 源文件 Path 对象,不在此方法检查是否存在 """ + if not fileitem.path: + raise ValueError("本地文件项缺少路径") return Path(fileitem.path) # 中间文件使用隐藏名称及专用后缀,降低媒体库把未完成内容当作成品的风险。 @@ -466,9 +472,8 @@ class LocalStorage(StorageBase): break fdst.write(buf) copied_size += len(buf) - if progress_callback: - percent = copied_size / total_size * 100 - progress_callback(percent) + percent = copied_size / total_size * 100 + progress_callback(percent) os.utime(dest, ns=(src_stat.st_atime_ns, src_stat.st_mtime_ns)) return True except Exception as e: @@ -492,6 +497,8 @@ class LocalStorage(StorageBase): :return: 目标文件项;复制、删除源文件或后续查询失败返回 None,目标文件可能已经写入 """ try: + if not fileitem.path: + return None dir_path = Path(fileitem.path) target_path = dir_path / (new_name or path.name) # 先原子地把内容落到目标,确认完整之后才删源 @@ -556,6 +563,8 @@ class LocalStorage(StorageBase): :param new_name: 目标文件名 :return: 目标完成或源目标路径相同返回 True;复制失败返回 False """ + if not fileitem.path: + return False src = Path(fileitem.path) dest = path / new_name if src == dest: @@ -586,6 +595,8 @@ class LocalStorage(StorageBase): :param target_file: 完整目标链接路径 :return: 系统操作返回成功时为 True,否则为 False """ + if not fileitem.path: + return False file_path = Path(fileitem.path) code, message = SystemUtils.link(file_path, target_file) if code != 0: @@ -601,6 +612,8 @@ class LocalStorage(StorageBase): :param target_file: 完整目标链接路径 :return: 系统操作返回成功时为 True,否则为 False """ + if not fileitem.path: + return False file_path = Path(fileitem.path) code, message = SystemUtils.softlink(file_path, target_file) if code != 0: diff --git a/app/modules/filemanager/storages/rclone.py b/app/modules/filemanager/storages/rclone.py index 19d1d1040..d1933d8c0 100644 --- a/app/modules/filemanager/storages/rclone.py +++ b/app/modules/filemanager/storages/rclone.py @@ -4,13 +4,13 @@ import json import subprocess +import sys import threading import time from collections import OrderedDict from pathlib import Path from typing import Any, List, Optional, Union -from app.adapters.system.host import SystemUtils from app.foundation import temporal as time_tools from app.modules.filemanager.storages import StorageBase, transfer_process from app.runtime.log import logger @@ -68,7 +68,7 @@ class Rclone(StorageBase): """ pass - def set_config(self, conf: dict) -> None: + def set_config(self, conf: dict[str, Any]) -> None: """ 先持久化存储配置,再将配置内容写入指定本地文件。 @@ -80,15 +80,19 @@ class Rclone(StorageBase): filepath = conf.get("filepath") if not filepath: logger.warn("【rclone】保存配置失败:未设置配置文件路径") + return logger.info(f"【rclone】配置写入文件:{filepath}") path = Path(filepath) if not path.parent.exists(): path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(conf.get('content'), encoding='utf-8') + path.write_text(str(conf.get('content') or ''), encoding='utf-8') @staticmethod - def __get_hidden_shell(): - if SystemUtils.is_windows(): + def __get_hidden_shell() -> Optional[Any]: + """ + 在 Windows 下隐藏命令窗口,其他平台无需启动窗口配置。 + """ + if sys.platform == "win32": st = subprocess.STARTUPINFO() st.dwFlags = subprocess.STARTF_USESHOWWINDOW st.wShowWindow = subprocess.SW_HIDE @@ -127,12 +131,13 @@ class Rclone(StorageBase): return None - def __get_rcloneitem(self, item: dict, parent: Optional[str] = "/") -> _SchemaFileItem: + def __get_rcloneitem(self, item: dict[str, Any], parent: Optional[str] = "/") -> _SchemaFileItem: """ 获取rclone文件项。 """ - if not item: - return _SchemaFileItem() + name = item.get("Name") + if not isinstance(name, str) or not name: + raise ValueError("rclone 文件项缺少名称") if item.get("IsDir"): return _SchemaFileItem( storage=self.schema.value, @@ -140,7 +145,7 @@ class Rclone(StorageBase): path=f"{parent}{item.get('Name')}" + "/", name=item.get("Name"), basename=item.get("Name"), - modify_time=time_tools.parse_timestamp(item.get("ModTime")) + modify_time=time_tools.parse_timestamp(item.get("ModTime") or "") ) else: return _SchemaFileItem( @@ -148,10 +153,10 @@ class Rclone(StorageBase): type="file", path=f"{parent}{item.get('Name')}", name=item.get("Name"), - basename=Path(item.get("Name")).stem, - extension=Path(item.get("Name")).suffix[1:], + basename=Path(name).stem, + extension=Path(name).suffix[1:], size=item.get("Size"), - modify_time=time_tools.parse_timestamp(item.get("ModTime")) + modify_time=time_tools.parse_timestamp(item.get("ModTime") or "") ) @staticmethod @@ -220,6 +225,8 @@ class Rclone(StorageBase): :param fileitem: MP 远端目录或文件项;目录路径应保留末尾斜杠以便拼接子项 :return: 直接子项列表;命令或解析失败返回空列表,不能据此区分空目录 """ + if not fileitem.path: + return [] if fileitem.type == "file": return [fileitem] try: @@ -246,6 +253,8 @@ class Rclone(StorageBase): :param name: 目标子目录名称 :return: 查询到的目标项;创建或确认失败返回 None,即使创建命令已成功但目标暂不可见 """ + if not fileitem.path: + return None path = Path(self.__normalize_remote_path(Path(fileitem.path) / name)) try: retcode = subprocess.run( @@ -287,6 +296,8 @@ class Rclone(StorageBase): return folder fileitem = _SchemaFileItem(storage=self.schema.value, type="dir", path="/") for part in normalized.parts[1:]: + if not fileitem.path: + return None current_path = Path(self.__normalize_remote_path(Path(fileitem.path) / part)) with self.__get_path_lock(current_path): dir_file = self.get_item(current_path) @@ -365,6 +376,8 @@ class Rclone(StorageBase): :param fileitem: 需要删除的文件项;此方法不递归删除目录 :return: 命令退出码为 0 时返回 True,否则返回 False """ + if not fileitem.path: + return False try: retcode = subprocess.run( [ @@ -387,6 +400,8 @@ class Rclone(StorageBase): :param name: 目标名称,已存在目标的处理由 rclone 决定 :return: 命令退出码为 0 时返回 True,否则返回 False """ + if not fileitem.path: + return False try: retcode = subprocess.run( [ @@ -412,6 +427,8 @@ class Rclone(StorageBase): :param path: 本地保存目录;为空时使用 TEMP_PATH :return: 成功下载的本地路径;路径无效或传输失败返回 None """ + if not fileitem.path: + return None local_path = self._build_download_path(fileitem, path or get_runtime_setting('TEMP_PATH')) if not local_path: return None @@ -435,6 +452,7 @@ class Rclone(StorageBase): bufsize=1 ) + assert process.stdout is not None last_progress = 0.0 for line in process.stdout: if line: @@ -470,6 +488,8 @@ class Rclone(StorageBase): :param new_name: 远端目标名称;为空时使用源文件名 :return: 上传后查询到的目标项;传输或后续查询失败返回 None """ + if not fileitem.path: + return None target_name = new_name or path.name new_path = Path(fileitem.path) / target_name @@ -492,6 +512,7 @@ class Rclone(StorageBase): bufsize=1 ) + assert process.stdout is not None last_progress = 0.0 for line in process.stdout: if line: @@ -523,6 +544,8 @@ class Rclone(StorageBase): :param fileitem: 待查询的远端项 :return: 首个结果构造的文件项;执行、解析或无结果时返回 None """ + if not fileitem.path: + return None try: ret = subprocess.run( [ @@ -548,6 +571,8 @@ class Rclone(StorageBase): :param new_name: 目标文件名 :return: 命令退出码为 0 时返回 True,否则返回 False;具体移动语义由 rclone 后端决定 """ + if not fileitem.path: + return False target_path = path / new_name logger.info(f"【rclone】开始移动: {fileitem.path} -> {target_path}") @@ -569,6 +594,7 @@ class Rclone(StorageBase): bufsize=1 ) + assert process.stdout is not None last_progress = 0.0 for line in process.stdout: if line: @@ -600,6 +626,8 @@ class Rclone(StorageBase): :param new_name: 目标文件名 :return: 命令退出码为 0 时返回 True,否则返回 False;失败时不撤销已写入内容 """ + if not fileitem.path: + return False target_path = path / new_name logger.info(f"【rclone】开始复制: {fileitem.path} -> {target_path}") @@ -621,6 +649,7 @@ class Rclone(StorageBase): bufsize=1 ) + assert process.stdout is not None last_progress = 0.0 for line in process.stdout: if line: @@ -649,9 +678,9 @@ class Rclone(StorageBase): :param fileitem: 兼容接口的源文件项,当前忽略 :param target_file: 兼容接口的目标路径,当前忽略 - :return: 固定返回 None,不创建链接 + :return: 固定返回 False,不创建链接 """ - pass + return False def softlink(self, fileitem: _SchemaFileItem, target_file: Path) -> bool: """ @@ -659,9 +688,9 @@ class Rclone(StorageBase): :param fileitem: 兼容接口的源文件项,当前忽略 :param target_file: 兼容接口的目标路径,当前忽略 - :return: 固定返回 None,不创建链接 + :return: 固定返回 False,不创建链接 """ - pass + return False def usage(self) -> Optional[_SchemaStorageUsage]: """ @@ -672,7 +701,7 @@ class Rclone(StorageBase): conf = self.get_config() if not conf: return None - file_path = conf.config.get("filepath") + file_path = (conf.config or {}).get("filepath") if not file_path or not Path(file_path).exists(): return None # 读取rclone文件,检查是否有[MP]节点配置 diff --git a/app/modules/filemanager/storages/smb.py b/app/modules/filemanager/storages/smb.py index 861175a7e..5e95713fd 100644 --- a/app/modules/filemanager/storages/smb.py +++ b/app/modules/filemanager/storages/smb.py @@ -232,7 +232,7 @@ class SMB(StorageBase, metaclass=WeakSingleton): return "/" + file_path[len(prefix):].replace("\\", "/") def _create_fileitem( - self, stat_result, file_path: str, name: str, *, strict: bool = False + self, stat_result: Any, file_path: str, name: str, *, strict: bool = False ) -> _SchemaFileItem: """ 创建文件项;严格查询不能把元数据读取失败回退成普通文件。 @@ -339,22 +339,24 @@ class SMB(StorageBase, metaclass=WeakSingleton): try: self._check_connection() + file_path = fileitem.path + if not file_path: + raise StorageQueryError("【SMB】文件查询缺少路径") + if fileitem.type == "file": - if not fileitem.path: - raise StorageQueryError("【SMB】文件查询缺少路径") - item = self.get_item_strict(Path(fileitem.path)) if strict else self.detail(fileitem) + item = self.get_item_strict(Path(file_path)) if strict else self.detail(fileitem) if item: return [item] return [] - if self._multi_share and not fileitem.path.rstrip("/\\"): + if self._multi_share and not file_path.rstrip("/\\"): return [ _SchemaFileItem(storage=self.schema.value, type="dir", path=f"/{share}/", name=share, basename=share) for share in self._shares.values() ] - smb_path = self._normalize_path(fileitem.path.rstrip("/")) + smb_path = self._normalize_path(file_path.rstrip("/")) try: entries = smbclient.listdir(smb_path) @@ -400,15 +402,18 @@ class SMB(StorageBase, metaclass=WeakSingleton): import smbclient try: self._check_connection() + file_path = fileitem.path + if not file_path: + raise ValueError("父目录路径不能为空") - new_path = self._normalize_path(Path(fileitem.path) / name, writable=True) + new_path = self._normalize_path(Path(file_path) / name, writable=True) smbclient.mkdir(new_path) return _SchemaFileItem( storage=self.schema.value, type="dir", - path=f"{fileitem.path.rstrip('/')}/{name}/", + path=f"{file_path.rstrip('/')}/{name}/", name=name, basename=name, modify_time=int(time.time()), @@ -522,7 +527,7 @@ class SMB(StorageBase, metaclass=WeakSingleton): :param fileitem: 待查询的文件或目录项 :return: 最新存储项;不存在或普通查询失败返回 None """ - return self.get_item(Path(fileitem.path)) + return self.get_item(Path(fileitem.path)) if fileitem.path else None def delete(self, fileitem: _SchemaFileItem) -> bool: """ @@ -535,12 +540,15 @@ class SMB(StorageBase, metaclass=WeakSingleton): from smbprotocol.exceptions import SMBException, SMBResponseException try: self._check_connection() + file_path = fileitem.path + if not file_path: + raise ValueError("文件路径不能为空") - smb_path = self._normalize_path(fileitem.path.rstrip("/"), writable=True) - logger.info(f"【SMB】开始删除: {fileitem.path} (类型: {fileitem.type})") + smb_path = self._normalize_path(file_path.rstrip("/"), writable=True) + logger.info(f"【SMB】开始删除: {file_path} (类型: {fileitem.type})") if not smbclient.path.exists(smb_path): - logger.warn(f"【SMB】路径不存在,跳过删除: {fileitem.path}") + logger.warn(f"【SMB】路径不存在,跳过删除: {file_path}") return True if fileitem.type == "dir": @@ -633,14 +641,17 @@ class SMB(StorageBase, metaclass=WeakSingleton): import smbclient try: self._check_connection() + file_path = fileitem.path + if not file_path: + raise ValueError("文件路径不能为空") - old_path = self._normalize_path(fileitem.path.rstrip("/"), writable=True) - parent_path = Path(fileitem.path).parent + old_path = self._normalize_path(file_path.rstrip("/"), writable=True) + parent_path = Path(file_path).parent new_path = self._normalize_path(parent_path / name, writable=True) smbclient.rename(old_path, new_path) - logger.info(f"【SMB】重命名成功: {fileitem.path} -> {name}") + logger.info(f"【SMB】重命名成功: {file_path} -> {name}") return True except Exception as e: logger.error(f"【SMB】重命名失败: {e}") @@ -657,10 +668,16 @@ class SMB(StorageBase, metaclass=WeakSingleton): :return: 下载完成的本地路径;取消或传输失败返回 None """ import smbclient - local_path = self._build_download_path(fileitem, path or get_runtime_setting('TEMP_PATH')) + file_path = fileitem.path + if not file_path: + return None + temp_path = path or get_runtime_setting('TEMP_PATH') + if not isinstance(temp_path, Path): + temp_path = Path(str(temp_path or ".")) + local_path = self._build_download_path(fileitem, temp_path) if not local_path: return None - smb_path = self._normalize_path(fileitem.path) + smb_path = self._normalize_path(file_path) try: self._check_connection() @@ -669,14 +686,14 @@ class SMB(StorageBase, metaclass=WeakSingleton): file_size = fileitem.size logger.info(f"【SMB】开始下载: {fileitem.name} -> {local_path}") - progress_callback = transfer_process(Path(fileitem.path).as_posix()) + progress_callback = transfer_process(Path(file_path).as_posix()) with smbclient.open_file(smb_path, mode="rb") as src_file: with open(local_path, "wb") as dst_file: downloaded_size = 0 while True: - if runtime_stop_state.consume_transfer_stop(fileitem.path): - logger.info(f"【SMB】{fileitem.path} 下载已取消!") + if runtime_stop_state.consume_transfer_stop(file_path): + logger.info(f"【SMB】{file_path} 下载已取消!") return None chunk = src_file.read(self.chunk_size) if not chunk: @@ -710,6 +727,8 @@ class SMB(StorageBase, metaclass=WeakSingleton): :return: 上传后查询到的文件项;取消、传输或查询失败返回 None,远端可能保留部分内容 """ import smbclient + if not fileitem.path: + return None target_name = new_name or path.name target_path = Path(fileitem.path) / target_name try: @@ -835,6 +854,8 @@ class SMB(StorageBase, metaclass=WeakSingleton): from smbprotocol.exceptions import SMBResponseException try: self._check_connection() + if not fileitem.path: + raise ValueError("源文件路径不能为空") src_path = self._normalize_path(fileitem.path, writable=True) dst_path = self._normalize_path(target_file, writable=True) @@ -869,7 +890,7 @@ class SMB(StorageBase, metaclass=WeakSingleton): :param target_file: 兼容接口的目标路径,当前忽略 :return: 固定返回 None,不创建链接 """ - pass + return False def usage(self) -> Optional[_SchemaStorageUsage]: """ diff --git a/app/modules/filemanager/storages/u115.py b/app/modules/filemanager/storages/u115.py index 8e3cf8eb0..a5c1eda45 100644 --- a/app/modules/filemanager/storages/u115.py +++ b/app/modules/filemanager/storages/u115.py @@ -8,7 +8,7 @@ import time from hashlib import sha256 from pathlib import Path from threading import Lock -from typing import Any, List, Optional, Tuple, Union +from typing import Any, List, Optional, Tuple, Union, cast, overload from cryptography.hazmat.primitives import hashes @@ -153,7 +153,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): ) return access_token - def generate_auth_url(self) -> Tuple[dict, str]: + def generate_auth_url(self) -> Tuple[dict[str, Any], str]: """ 向配置的授权服务申请 OAuth2 登录地址。 @@ -186,7 +186,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): logger.error(f"【115】获取授权 URL 失败: {str(e)}") return {}, f"获取授权 URL 失败: {str(e)}" - def generate_qrcode(self) -> Tuple[dict, str]: + def generate_qrcode(self) -> Tuple[dict[str, Any], str]: """ 使用 SHA256 校验值申请设备授权二维码。 @@ -220,7 +220,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): return {"codeContent": result["data"]["qrcode"]}, "" - def check_login(self) -> Optional[Tuple[dict, str]]: + def check_login(self) -> Optional[Tuple[dict[str, Any], str]]: """ 按当前授权方式轮询登录状态,并在成功时保存令牌。 @@ -257,7 +257,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): except HttpRequestError as e: return {}, str(e) - def __check_oauth_login(self) -> Tuple[dict, str]: + def __check_oauth_login(self) -> Tuple[dict[str, Any], str]: """ 查询授权服务并转换为统一的数字登录状态。 @@ -302,7 +302,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): logger.error(f"【115】检查授权状态失败: {str(e)}") return {}, f"检查授权状态失败: {str(e)}" - def __get_access_token(self) -> dict: + def __get_access_token(self) -> dict[str, Any]: """ 使用已确认的设备码和校验原文换取访问令牌。 @@ -323,9 +323,12 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): result = resp.json() if result.get("code") != 0: raise Exception(result.get("message")) - return result["data"] + data = result["data"] + if not isinstance(data, dict): + raise ValueError("授权服务器返回的令牌数据不是对象") + return data - def __refresh_access_token(self, refresh_token: str) -> Optional[dict]: + def __refresh_access_token(self, refresh_token: str) -> Optional[dict[str, Any]]: """ 使用刷新令牌申请新的访问凭据。 @@ -338,7 +341,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): ) if resp is None: logger.error( - f"【115】刷新 access_token 失败:refresh_token={refresh_token}" + "【115】刷新 access_token 失败:未收到响应" ) return None result = resp.json() @@ -347,7 +350,28 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): f"【115】刷新 access_token 失败:{result.get('code')} - {result.get('message')}!" ) return None - return result.get("data") + data = result.get("data") + return data if isinstance(data, dict) else None + + @overload + def _request_api( + self, method: str, endpoint: str, result_key: None = None, + **kwargs: Any, + ) -> Optional[dict[str, Any]]: + """ + 返回开放接口的完整 JSON 对象。 + """ + ... + + @overload + def _request_api( + self, method: str, endpoint: str, result_key: Optional[str], + **kwargs: Any, + ) -> Optional[Union[dict[str, Any], list[Any]]]: + """ + 返回调用方选取的 JSON 对象或列表字段。 + """ + ... def _request_api( self, @@ -356,8 +380,8 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): result_key: Optional[str] = None, *, accepted_codes: Tuple[int, ...] = U115_DEFAULT_ACCEPTED_CODES, - **kwargs, - ) -> Optional[Union[dict, list]]: + **kwargs: Any, + ) -> Optional[Union[dict[str, Any], list[Any]]]: """ 执行带限流、共享冷却和有限重试的开放接口请求。 @@ -459,6 +483,9 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): ) ret_data = resp.json() + if not isinstance(ret_data, dict): + logger.warning("开放接口返回了非对象 JSON:%s", endpoint) + return None if ret_data.get("code") not in accepted_codes: error_msg = ret_data.get("message", "") if not no_error_log: @@ -491,7 +518,8 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): return None if result_key: - return ret_data.get(result_key) + value = ret_data.get(result_key) + return value if isinstance(value, (dict, list)) else None return ret_data @staticmethod @@ -511,7 +539,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): else: while chunk := f.read(8192): sha1.update(chunk) - return sha1.finalize().hex() + return cast(str, sha1.finalize().hex()) @staticmethod def __get_upload_part_size(file_size: int) -> int: @@ -551,11 +579,14 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): :param fileitem: 待查询文件或目录;目录缺少标识时先按路径补查 :return: 文件项列表;文件详情或目录标识查询失败时返回空列表 """ + if not fileitem.path: + return [] + cid: Optional[str] if fileitem.type == "file": - item = self.detail(fileitem) - if item: - return [item] + detail_item = self.detail(fileitem) + if detail_item: + return [detail_item] return [] if fileitem.path == "/": cid = "0" @@ -568,6 +599,8 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): return [] cid = _fileitem.fileid + if not cid: + return [] items = [] offset = 0 @@ -586,9 +619,11 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): ) if resp is None: raise FileNotFoundError(f"【115】{fileitem.path} 检索出错!") - if not resp: + if not isinstance(resp, list) or not resp: break for item in resp: + if not isinstance(item, dict): + continue parent_path = Path(fileitem.path) item_name = item["fn"] full_path = parent_path / item_name @@ -624,6 +659,8 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): :param name: 要创建的直接子目录名 :return: 新目录或已存在的目录项;失败时返回 None """ + if not parent_item.path: + return None new_path = Path(parent_item.path) / name resp = self._request_api( "POST", @@ -681,10 +718,10 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): :param cb: 回调参数原文 :return: OSS SDK 生成的 Base64 字符串 """ - return oss2.utils.b64encode_as_string(cb) + return cast(str, oss2.utils.b64encode_as_string(cb)) target_name = new_name or local_path.name - target_path = Path(target_dir.path) / target_name + target_path = Path(cast(str, target_dir.path)) / target_name file_size = local_path.stat().st_size file_sha1 = self._calc_sha1(local_path) file_preid = self._calc_sha1(local_path, 128 * 1024 * 1024) @@ -705,7 +742,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): if not init_resp.get("state"): logger.warn(f"【115】初始化上传失败: {init_resp.get('error')}") return None - init_result = init_resp.get("data") + init_result = cast(dict[str, Any], init_resp.get("data")) logger.debug(f"【115】上传 Step 1 初始化结果: {init_result}") bucket_name = init_result.get("bucket") object_name = init_result.get("object") @@ -734,7 +771,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): if not init_resp.get("state"): logger.warn(f"【115】上传二次认证失败: {init_resp.get('error')}") return None - init_result = init_resp.get("data") + init_result = cast(dict[str, Any], init_resp.get("data")) logger.debug(f"【115】上传 Step 2 二次认证结果: {init_result}") if not pick_code: pick_code = init_result.get("pick_code") @@ -757,7 +794,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): "data", params={"file_id": int(file_id)}, ) - if info_resp: + if isinstance(info_resp, dict) and info_resp: return _SchemaFileItem( storage=self.schema.value, fileid=str(info_resp["file_id"]), @@ -782,7 +819,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): ) token_resp = self._request_api("GET", "/open/upload/get_token", "data") - if not token_resp: + if not isinstance(token_resp, dict) or not token_resp: logger.warn("【115】获取上传凭证失败") return None logger.debug(f"【115】上传 Step 4 获取上传凭证结果: {token_resp}") @@ -802,7 +839,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): "pick_code": pick_code, }, ) - if resume_resp: + if isinstance(resume_resp, dict) and resume_resp: logger.debug(f"【115】上传 Step 5 断点续传结果: {resume_resp}") if resume_resp.get("callback"): callback = resume_resp["callback"] @@ -835,7 +872,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): :return: 使用新凭据的存储桶客户端 """ token_resp = self._request_api("GET", "/open/upload/get_token", "data") - if not token_resp: + if not isinstance(token_resp, dict) or not token_resp: raise Exception("【115】重新获取上传凭证失败") logger.info("【115】已重新获取上传凭证,继续上传") return oss2.Bucket( @@ -890,6 +927,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): progress_callback(100) + callback = cast(dict[str, str], callback) headers = { "X-oss-callback": encode_callback(callback["callback"]), "x-oss-callback-var": encode_callback(callback["callback_var"]), @@ -974,6 +1012,8 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): :param path: 本地目标目录;为空时使用临时目录,目录需已存在 :return: 下载完成的本地路径;查询失败、取消或传输异常时返回 None """ + if not fileitem.path: + return None detail = self.get_item(Path(fileitem.path)) if not detail: logger.error(f"【115】获取文件详情失败: {fileitem.name}") @@ -982,7 +1022,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): download_info = self._request_api( "POST", "/open/ufile/downurl", "data", data={"pick_code": detail.pickcode} ) - if not download_info: + if not isinstance(download_info, dict) or not download_info: logger.error(f"【115】获取下载链接失败: {fileitem.name}") return None @@ -1051,6 +1091,8 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): :param fileitem: 带远端文件标识的待删除项 :return: 接口明确返回成功时为 True;请求失败或业务拒绝时为 False """ + if not fileitem.fileid: + return False try: resp = self._request_api( "POST", "/open/ufile/delete", data={"file_ids": int(fileitem.fileid)} @@ -1067,6 +1109,8 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): :param name: 新的名称 :return: 响应中的 state 为真时返回 True;无响应或失败时返回 False """ + if not fileitem.fileid: + return False resp = self._request_api( "POST", "/open/ufile/update", @@ -1194,6 +1238,8 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): :param fileitem: 提供查询路径的文件或目录项 :return: 查到的项;不存在或查询失败时返回 None """ + if not fileitem.path: + return None return self.get_item(Path(fileitem.path)) def copy(self, fileitem: _SchemaFileItem, path: Path, new_name: str, **_kwargs: Any) -> bool: @@ -1353,9 +1399,9 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): :param fileitem: 源文件项,当前不使用 :param target_file: 链接目标路径,当前不使用 - :return: 固定返回 None,不创建链接 + :return: 固定返回 False,不创建链接 """ - pass + return False def softlink(self, fileitem: _SchemaFileItem, target_file: Path) -> bool: """ @@ -1363,9 +1409,9 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): :param fileitem: 源文件项,当前不使用 :param target_file: 链接目标路径,当前不使用 - :return: 固定返回 None,不创建链接 + :return: 固定返回 False,不创建链接 """ - pass + return False def usage(self) -> Optional[_SchemaStorageUsage]: """ @@ -1375,7 +1421,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): """ try: resp = self._request_api("GET", "/open/user/info", "data") - if not resp: + if not isinstance(resp, dict) or not resp: return None space = resp["rt_space_info"] return _SchemaStorageUsage( diff --git a/app/modules/filemanager/transhandler.py b/app/modules/filemanager/transhandler.py index eb44d379d..95fe1abce 100644 --- a/app/modules/filemanager/transhandler.py +++ b/app/modules/filemanager/transhandler.py @@ -7,7 +7,7 @@ import re from copy import deepcopy from pathlib import Path from tempfile import TemporaryDirectory -from typing import Any, Callable, List, Optional, Tuple, cast +from typing import Any, Callable, List, Optional, Tuple, Union, cast from jinja2 import Template @@ -107,7 +107,7 @@ class TransHandler: """ disc_folder = cls.__normalize_disc_folder_name(getattr(meta, "part", None)) if not disc_folder and source_item: - source_name = source_item.name or Path(source_item.path).name + source_name = source_item.name or (Path(source_item.path).name if source_item.path else "") disc_folder = cls.__normalize_disc_folder_name(source_name) if not disc_folder: match = re.search( @@ -123,7 +123,7 @@ class TransHandler: return rendered_path.parent / (disc_folder or "Disc") @staticmethod - def __update_result(result: TransferInfo, **kwargs): + def __update_result(result: TransferInfo, **kwargs: Any) -> None: """ 按字段类型合并整理结果,原地修改结果对象。 @@ -228,7 +228,7 @@ class TransHandler: :param fileitem: 待冻结的源或目标文件项 :return: 采用 JSON 值类型的字典 """ - return cast(dict[str, Any], fileitem.model_dump(mode="json")) + return fileitem.model_dump(mode="json") @staticmethod def __serialize_transfer_model(value: object) -> dict[str, Any]: @@ -332,6 +332,8 @@ class TransHandler: :param destination_dir: 对应的目标目录路径 """ for source_item in source_oper.list(source_dir) or []: + if not source_item.path or not source_item.name: + raise ValueError("源文件项缺少路径或名称") destination = destination_dir / source_item.name if source_item.type == "dir": collect(source_item, destination) @@ -387,7 +389,8 @@ class TransHandler: :param preview: 是否仅预览;目录预览不遍历并冻结叶子操作 :return: 包含最终路径、元数据快照和操作清单的检查点 """ - fileitem = FileItem(**planning_input.source_fileitem) + fileitem = FileItem.model_validate(planning_input.source_fileitem) + final_target: Optional[Path] rename_format = get_runtime_setting("RENAME_FORMAT")(mediainfo.type) planning_meta = deepcopy(meta) resolved_mediainfo = self.__serialize_transfer_model(mediainfo) @@ -422,7 +425,7 @@ class TransHandler: if not final_target: raise ValueError("重命名格式无效") else: - final_target = target_path / fileitem.name + final_target = target_path / cast(str, fileitem.name) items = ( () if preview @@ -532,7 +535,7 @@ class TransHandler: # 整轨与索引只调整专辑目录,保留原名才能让 CUE FILE 引用继续有效。 final_target = final_target.parent / Path(str(fileitem.path)).name else: - final_target = target_path / fileitem.name + final_target = target_path / cast(str, fileitem.name) return TransferPlanCheckpoint( planning_input=planning_input, @@ -661,7 +664,7 @@ class TransHandler: success=False, message=message, fileitem=fileitem, - fail_list=[fileitem.path], + fail_list=[fileitem.path] if fileitem.path else [], transfer_type=transfer_type, need_notify=need_notify, ) @@ -718,7 +721,7 @@ class TransHandler: message=plugin_reason or "插件决定不覆盖已有文件", fileitem=fileitem, target_item=target_item, - fail_list=[fileitem.path], + fail_list=[fileitem.path] if fileitem.path else [], transfer_type=transfer_type, need_notify=need_notify, overwrite_skipped=True, @@ -731,7 +734,7 @@ class TransHandler: message="媒体库存在同名文件,当前覆盖模式为不覆盖", fileitem=fileitem, target_item=target_item, - fail_list=[fileitem.path], + fail_list=[fileitem.path] if fileitem.path else [], transfer_type=transfer_type, need_notify=need_notify, overwrite_skipped=True, @@ -750,7 +753,7 @@ class TransHandler: message="媒体库存在同名音乐文件,且目标音质更好", fileitem=fileitem, target_item=target_item, - fail_list=[fileitem.path], + fail_list=[fileitem.path] if fileitem.path else [], transfer_type=transfer_type, need_notify=need_notify, overwrite_skipped=True, @@ -765,17 +768,21 @@ class TransHandler: if plugin_target_size is not None else target_item.size ) - if target_size < source_size: + sizes_known = source_size is not None and target_size is not None + if sizes_known and cast(int, target_size) < cast(int, source_size): return True, False, None return False, False, TransferInfo( success=False, - message="媒体库存在同名文件,且质量更好", + message=( + "媒体库存在同名文件,且质量更好" + if sizes_known else "无法确认源文件或目标文件大小,已跳过覆盖" + ), fileitem=fileitem, target_item=target_item, - fail_list=[fileitem.path], + fail_list=[fileitem.path] if fileitem.path else [], transfer_type=transfer_type, need_notify=need_notify, - overwrite_skipped=True, + overwrite_skipped=sizes_known, ) return False, False, None @@ -842,7 +849,7 @@ class TransHandler: if (source.storage == "local" and target.storage == "u115" and source.type == target.type == "file" and target.fileid and source.size is not None and target.size is not None): - return cast(FileItem, target.model_copy(update={"size": source.size})) + return target.model_copy(update={"size": source.size}) return target @classmethod @@ -980,8 +987,13 @@ class TransHandler: :param transfer_type: move、copy、link、softlink 或 reflink :return: 完成状态及文件证据;无法证明内容或查询失败时返回未知状态 """ + if not fileitem.path: + return TransferOperationObservation( + state=TransferOperationObservationState.UNKNOWN, + evidence=TransferStepResult(payload={"reason": "源文件项缺少路径"}), + ) try: - source_item = source_oper.get_item_strict(Path(cast(str, fileitem.path))) + source_item = source_oper.get_item_strict(Path(fileitem.path)) target_item = target_oper.get_item_strict(target_file) except Exception as error: return TransferOperationObservation( @@ -1029,7 +1041,7 @@ class TransHandler: state=TransferOperationObservationState.UNKNOWN, evidence=evidence, ) - source_path = Path(cast(str, fileitem.path)) + source_path = Path(fileitem.path) try: if transfer_type == "copy": applied = source_path.is_file() and filecmp.cmp( @@ -1100,7 +1112,10 @@ class TransHandler: :param materialize_kind: 目标落地步骤类别,默认 materialize_target :return: 目标文件项及提示;缺少成功目标证据时文件项为 None """ - fileitem = FileItem(**source_fileitem) + fileitem = FileItem.model_validate(source_fileitem) + if not fileitem.path: + return None, "源文件项缺少路径" + source_path = Path(fileitem.path) cross_storage_move = ( transfer_type == "move" and fileitem.storage != target_storage ) @@ -1167,7 +1182,7 @@ class TransHandler: 在目标已落地后单独删除跨存储 move 的源文件。 """ confirm_materialized_target() - current_source = source_oper.get_item_strict(Path(cast(str, fileitem.path))) + current_source = source_oper.get_item_strict(source_path) if current_source is not None: cls.__assert_file_identity(fileitem, current_source) if current_source is not None and not source_oper.delete(current_source): @@ -1194,7 +1209,7 @@ class TransHandler: }, execute=execute_source_delete, observe=lambda: cls.__observe_safe_delete( - source_oper, Path(cast(str, fileitem.path)), + source_oper, source_path, frozen_item=fileitem, before_delete=confirm_materialized_target, ), ) @@ -1729,7 +1744,7 @@ class TransHandler: :param step_runner: 可恢复步骤执行器;None 时沿用同步执行 :return: 整理结果,包含目标和成功文件统计或拒绝原因 """ - fileitem = FileItem(**checkpoint.planning_input.source_fileitem) + fileitem = FileItem.model_validate(checkpoint.planning_input.source_fileitem) frozen_source_payload = checkpoint.planning_input.source_fileitem target_storage = checkpoint.target_storage target_path = Path(checkpoint.final_target_path) @@ -1768,7 +1783,7 @@ class TransHandler: fileitem=fileitem, target_item=preview_target_item, target_diritem=preview_target_diritem, - file_list=[fileitem.path], + file_list=[cast(str, fileitem.path)], file_list_new=[target_path.as_posix()], file_count=0 if item_type == "dir" else 1, total_size=0 if item_type == "dir" else fileitem.size or 0, @@ -1780,7 +1795,7 @@ class TransHandler: if fileitem.type == "dir": stream_path = Path(cast(str, fileitem.path)) / "BDMV" / "STREAM" stream_sizes = [ - FileItem(**item.source_fileitem).size or 0 + FileItem.model_validate(item.source_fileitem).size or 0 for item in checkpoint.items if Path(str(item.source_fileitem.get("path", ""))).parent == stream_path @@ -1838,7 +1853,7 @@ class TransHandler: transfer_type=transfer_type, need_notify=checkpoint.need_notify, ) - source_item = FileItem(**planned_item.source_fileitem) + source_item = FileItem.model_validate(planned_item.source_fileitem) over_flag = self.__resolve_leaf_overwrite_with_step( step_runner=step_runner, planned_item=planned_item, target_oper=target_oper, transfer_type=transfer_type, @@ -1892,7 +1907,7 @@ class TransHandler: success=False, message="单文件整理计划必须且只能包含一个操作", fileitem=fileitem, - fail_list=[fileitem.path], + fail_list=[fileitem.path] if fileitem.path else [], transfer_type=transfer_type, need_notify=checkpoint.need_notify, ) @@ -1906,7 +1921,7 @@ class TransHandler: success=False, message="单文件整理计划与冻结目标不一致", fileitem=fileitem, - fail_list=[fileitem.path], + fail_list=[fileitem.path] if fileitem.path else [], transfer_type=transfer_type, need_notify=checkpoint.need_notify, ) @@ -1950,7 +1965,7 @@ class TransHandler: success=False, message=reason, fileitem=fileitem, - fail_list=[fileitem.path], + fail_list=[fileitem.path] if fileitem.path else [], transfer_type=transfer_type, need_notify=checkpoint.need_notify, ) @@ -1966,7 +1981,7 @@ class TransHandler: success=False, message=f"目标目录 {target_file.parent} 获取失败", fileitem=fileitem, - fail_list=[fileitem.path], + fail_list=[fileitem.path] if fileitem.path else [], transfer_type=transfer_type, need_notify=checkpoint.need_notify, ) @@ -2009,7 +2024,7 @@ class TransHandler: success=False, message=error, fileitem=fileitem, - fail_list=[fileitem.path], + fail_list=[fileitem.path] if fileitem.path else [], transfer_type=transfer_type, need_notify=checkpoint.need_notify, ) @@ -2131,7 +2146,7 @@ class TransHandler: if transfer_type in ("copy", "move"): target_fileitem = target_oper.get_folder(target_file.parent) - if not target_fileitem: + if not target_fileitem or not target_fileitem.path: return None, f"【{target_storage}】{target_file.parent} 目录获取失败" action_name = "copy_item" if transfer_type == "copy" else "move_item" custom_item_action = getattr(source_oper, action_name, None) @@ -2254,7 +2269,7 @@ class TransHandler: return __get_targetitem(target_file), "" return None, error elif fileitem.storage == "local" and target_storage != "local": - filepath = Path(fileitem.path) + filepath = Path(cast(str, fileitem.path)) if not filepath.exists(): return None, f"文件 {filepath} 不存在" if transfer_type == "copy": @@ -2348,13 +2363,13 @@ class TransHandler: new_file_type = "" # 先识别繁中,避免“繁体中文/繁中字”等名称被后面的“中文/中字”简中兜底规则误判。 - if re.search(_zhtw_sub_re, sub_item.name, re.I): + if re.search(_zhtw_sub_re, sub_item.name or "", re.I): new_file_type = ".zh-tw" - elif re.search(_zhcn_sub_re, sub_item.name, re.I): + elif re.search(_zhcn_sub_re, sub_item.name or "", re.I): new_file_type = ".chi.zh-cn" - elif re.search(_ja_sub_re, sub_item.name, re.I): + elif re.search(_ja_sub_re, sub_item.name or "", re.I): new_file_type = ".ja" - elif re.search(_eng_sub_re, sub_item.name, re.I): + elif re.search(_eng_sub_re, sub_item.name or "", re.I): new_file_type = ".eng" if ( @@ -2435,7 +2450,7 @@ class TransHandler: @staticmethod def get_dest_path( - mediainfo: MediaInfo, + mediainfo: Union[MediaInfo, MusicInfo], target_path: Path, need_type_folder: Optional[bool] = False, need_category_folder: Optional[bool] = False, @@ -2470,7 +2485,7 @@ class TransHandler: @staticmethod def get_dest_dir( - mediainfo: MediaInfo, + mediainfo: Union[MediaInfo, MusicInfo], target_dir: TransferDirectoryConf, need_type_folder: Optional[bool] = None, need_category_folder: Optional[bool] = None, @@ -2486,6 +2501,8 @@ class TransHandler: :param need_category_folder: 是否添加分类目录;None 时沿用目录设置 :return: 最终媒体库目录路径;不创建目录 """ + if not target_dir.library_path: + raise ValueError("媒体库目录路径为空") if need_type_folder is None: need_type_folder = target_dir.library_type_folder if need_category_folder is None: @@ -2524,10 +2541,10 @@ class TransHandler: @staticmethod def get_naming_dict( meta: MetaBase, - mediainfo: MediaInfo, + mediainfo: Union[MediaInfo, MusicInfo], file_ext: Optional[str] = None, episodes_info: Optional[List[TmdbEpisode]] = None, - ) -> dict: + ) -> dict[str, Any]: """ 构造重命名模板使用的媒体变量和安全分类路径。 @@ -2578,10 +2595,12 @@ class TransHandler: media_files = storage_oper.list(parent_item) or [] result: list[FileItem] = [] for media_file in media_files: + if not media_file.path or not media_file.extension: + continue media_path = Path(media_file.path) if media_path == path or media_file.type != "file": continue - if f".{cast(str, media_file.extension).lower()}" not in get_runtime_setting('RMT_MEDIAEXT'): + if f".{media_file.extension.lower()}" not in get_runtime_setting('RMT_MEDIAEXT'): continue filemeta = MetaInfoPath(media_path) if filemeta.season != meta.season or filemeta.episode != meta.episode: @@ -2589,7 +2608,7 @@ class TransHandler: if meta.part and filemeta.part and filemeta.part != meta.part: continue result.append(media_file) - return sorted(result, key=lambda item: (item.path, item.fileid or "")) + return sorted(result, key=lambda item: (item.path or "", item.fileid or "")) @classmethod def __delete_version_files_with_steps( @@ -2707,6 +2726,8 @@ class TransHandler: logger.info(f"目录 {path.parent} 中没有文件") return False for media_file in media_files: + if not media_file.path or not media_file.extension: + continue media_path = Path(media_file.path) if media_path == path: continue @@ -2728,7 +2749,7 @@ class TransHandler: @staticmethod def get_rename_path( template_string: str, - rename_dict: dict, + rename_dict: dict[str, Any], path: Optional[Path] = None, source_path: Optional[str] = None, source_item: Optional[FileItem] = None, @@ -2756,7 +2777,7 @@ class TransHandler: build_event = eventmanager.send_event( ChainEventType.TransferRenameBuild, build_event_data ) - if build_event and build_event.event_data: + if build_event and isinstance(build_event.event_data, TransferRenameBuildEventData): rename_dict = build_event.event_data.rename_dict template = Template(template_string) @@ -2772,8 +2793,8 @@ class TransHandler: source_item=source_item, ) event = eventmanager.send_event(ChainEventType.TransferRename, event_data) - if event and event.event_data: - event_data: TransferRenameEventData = event.event_data + if event and isinstance(event.event_data, TransferRenameEventData): + event_data = event.event_data if event_data.updated and event_data.updated_str: logger.debug( f"Render string updated by event: " diff --git a/app/modules/filter/__init__.py b/app/modules/filter/__init__.py index 3ce9ea45f..42101bda4 100644 --- a/app/modules/filter/__init__.py +++ b/app/modules/filter/__init__.py @@ -5,7 +5,7 @@ import re from copy import deepcopy from functools import lru_cache -from typing import Dict, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple, Union, cast from app.adapters.system import rust as rust_accel from app.application.rules import BUILTIN_RULE_SET, RuleHelper, RuleParser @@ -84,9 +84,9 @@ class FilterModule(_ModuleBase): } # 保留一份只读内置规则定义,方便查询工具准确区分“内置规则”和“自定义规则”。 - builtin_rule_set: Dict[str, dict] = deepcopy(BUILTIN_RULE_SET) + builtin_rule_set: Dict[str, dict[str, Any]] = deepcopy(BUILTIN_RULE_SET) # 运行期规则集 = 内置规则 + 自定义规则覆盖。 - rule_set: Dict[str, dict] = {} + rule_set: Dict[str, dict[str, Any]] = {} def __init__(self) -> None: """ @@ -110,7 +110,8 @@ class FilterModule(_ModuleBase): custom_rules = self.rulehelper.get_custom_rules() for rule in custom_rules: logger.info(f"加载自定义规则 {rule.id} - {rule.name}") - self.rule_set[rule.id] = rule.model_dump() + if rule.id: + self.rule_set[rule.id] = rule.model_dump() @staticmethod def get_name() -> str: @@ -150,7 +151,7 @@ class FilterModule(_ModuleBase): """ 保留模块自检入口;过滤器不依赖外部服务,无需执行探测。 """ - pass + return None def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]: """ @@ -176,7 +177,10 @@ class FilterModule(_ModuleBase): return torrent_list groups = self.rulehelper.get_rule_group_by_media(media=mediainfo, group_names=rule_groups) if groups: - group_defs = [group.model_dump() if hasattr(group, "model_dump") else vars(group) for group in groups] + group_defs: list[dict[str, Any]] = [ + group.model_dump() if hasattr(group, "model_dump") else vars(group) + for group in groups + ] matched_orders = rust_accel.filter_torrents( groups=group_defs, torrent_list=torrent_list, @@ -190,10 +194,10 @@ class FilterModule(_ModuleBase): torrent_list=torrent_list, mediainfo=mediainfo ) - matched_orders, traces = self.__parse_rust_filter_result(matched_orders) + parsed_orders, traces = self.__parse_rust_filter_result(matched_orders) self.__log_rust_filter_traces(traces) - ret_torrents = [] - for index, pri_order in matched_orders: + ret_torrents: list[TorrentInfo] = [] + for index, pri_order in parsed_orders: torrent = torrent_list[index] torrent.pri_order = pri_order ret_torrents.append(torrent) @@ -201,7 +205,9 @@ class FilterModule(_ModuleBase): return torrent_list @staticmethod - def __parse_rust_filter_result(result) -> Tuple[list, list]: + def __parse_rust_filter_result( + result: object, + ) -> Tuple[list[tuple[int, int]], list[Any]]: """ 兼容新旧 Rust 过滤返回值,统一拆出匹配结果和调试日志。 """ @@ -210,18 +216,32 @@ class FilterModule(_ModuleBase): and len(result) == 2 and isinstance(result[1], list) ): - return result - return result, [] + orders, traces = result + parsed_orders = [ + (int(item[0]), int(item[1])) + for item in orders + if isinstance(item, (tuple, list)) and len(item) == 2 + ] + parsed_traces = list(traces) + return parsed_orders, parsed_traces + if isinstance(result, list): + parsed_orders = [ + (int(item[0]), int(item[1])) + for item in result + if isinstance(item, (tuple, list)) and len(item) == 2 + ] + return parsed_orders, [] + return [], [] @staticmethod - def __log_rust_filter_traces(traces: list) -> None: + def __log_rust_filter_traces(traces: list[Any]) -> None: """ 输出 Rust 过滤路径返回的规则级调试日志。 """ for trace in traces: logger.debug(trace) - def __filter_torrents_with_python(self, groups: List[dict], + def __filter_torrents_with_python(self, groups: List[dict[str, Any]], torrent_list: List[TorrentInfo], mediainfo: Optional[MediaInfo] = None) -> List[TorrentInfo]: """ @@ -248,7 +268,7 @@ class FilterModule(_ModuleBase): break return ret_torrents - def __needs_metainfo_options(self, groups: List[dict]) -> bool: + def __needs_metainfo_options(self, groups: List[dict[str, Any]]) -> bool: """ 判断当前规则链是否会触发 size_range,避免无大小规则时读取 MetaInfo 运行配置。 """ @@ -264,7 +284,7 @@ class FilterModule(_ModuleBase): torrent_list: List[TorrentInfo], mediainfo: Optional[MediaInfo], parser: RuleParser, - parsed_rule_cache: Dict[str, Union[list, str]]) -> List[TorrentInfo]: + parsed_rule_cache: Dict[str, Any]) -> List[TorrentInfo]: """ 筛选命中任一优先级层级的种子,并保留输入顺序。 @@ -286,7 +306,7 @@ class FilterModule(_ModuleBase): def __get_order(self, torrent: TorrentInfo, rule_groups: List[str], mediainfo: Optional[MediaInfo], parser: RuleParser, - parsed_rule_cache: Dict[str, Union[list, str]]) -> Optional[TorrentInfo]: + parsed_rule_cache: Dict[str, Any]) -> Optional[TorrentInfo]: """ 按层级顺序匹配种子,在原对象上记录首个命中的优先级。 @@ -310,7 +330,7 @@ class FilterModule(_ModuleBase): @staticmethod def __parse_rule_group(rule_group: str, parser: RuleParser, - parsed_rule_cache: Dict[str, Union[list, str]]) -> Union[list, str]: + parsed_rule_cache: Dict[str, Any]) -> Any: """ 解析单个优先级层级,并将结果写入调用方缓存。 @@ -320,7 +340,7 @@ class FilterModule(_ModuleBase): parsed_rule_cache[rule_group] = parser.parse(rule_group).as_list()[0] return parsed_rule_cache[rule_group] - def __match_group(self, torrent: TorrentInfo, rule_group: Union[list, str], + def __match_group(self, torrent: TorrentInfo, rule_group: Any, mediainfo: Optional[MediaInfo]) -> Optional[bool]: """ 递归计算规则项以及 not、and、or 组成的布尔表达式。 @@ -329,7 +349,9 @@ class FilterModule(_ModuleBase): """ if not isinstance(rule_group, list): return self.__match_rule(torrent, rule_group, mediainfo) - elif isinstance(rule_group, list) and len(rule_group) == 1: + if not rule_group: + return False + if len(rule_group) == 1: return self.__match_group(torrent, rule_group[0], mediainfo) elif rule_group[0] == "not": return not self.__match_group(torrent, rule_group[1:], mediainfo) @@ -359,7 +381,7 @@ class FilterModule(_ModuleBase): logger.debug(f"种子 {torrent.site_name} - {torrent.title} 符合 {rule_name} 的TMDB规则,匹配成功") return True content = f"{torrent.title} {torrent.description} {' '.join(torrent.labels or [])}" - match_content = [] + match_content: list[str] = [] matchs = rule.get("match") or [] if matchs: for match in matchs: @@ -369,9 +391,9 @@ class FilterModule(_ModuleBase): if not match_value: continue if isinstance(match_value, list): - match_content.extend(match_value) + match_content.extend(str(value) for value in match_value) else: - match_content.append(match_value) + match_content.append(str(match_value)) if match_content: content = " ".join(match_content) includes = rule.get("include") or [] @@ -383,7 +405,7 @@ class FilterModule(_ModuleBase): size_range = rule.get("size_range") seeders = rule.get("seeders") downloadvolumefactor = rule.get("downloadvolumefactor") - pubdate: str = rule.get("publish_time") + pubdate = cast(str, rule.get("publish_time") or "") if includes and not any(_regex_search(include, content) for include in includes): logger.debug(f"种子 {torrent.site_name} - {torrent.title} 不包含任何项 {includes}") return False @@ -422,7 +444,7 @@ class FilterModule(_ModuleBase): return True @staticmethod - def __match_tmdb(tmdb: dict, mediainfo: Optional[MediaInfo]) -> bool: + def __match_tmdb(tmdb: dict[str, Any], mediainfo: Optional[MediaInfo]) -> bool: """ 检查媒体属性是否满足规则中的全部非空 TMDB 条件。 @@ -431,7 +453,10 @@ class FilterModule(_ModuleBase): :return: 每个条件与对应媒体属性至少有一个值相交时返回 True;没有媒体信息时返回 False """ - def __get_media_value(key: str): + def __get_media_value(key: str) -> Any: + """ + 读取过滤表达式需要的媒体属性,并沿用无效属性的回退行为。 + """ try: return getattr(mediainfo, key) except ValueError: @@ -473,17 +498,17 @@ class FilterModule(_ModuleBase): """ if not size_range: return True - meta = MetaInfo(title=torrent.title, subtitle=torrent.description) + meta = MetaInfo(title=torrent.title or "", subtitle=torrent.description) episode_count = meta.total_episode or 1 torrent_size = torrent.size / episode_count size_rule, size_min, size_max = _parse_size_range(size_range) if size_rule == "between": - if size_min <= torrent_size <= size_max: + if size_max is not None and size_min <= torrent_size <= size_max: return True elif size_rule == "gte": if torrent_size >= size_min: return True elif size_rule == "lte": - if torrent_size <= size_max: + if size_max is not None and torrent_size <= size_max: return True return False diff --git a/app/modules/imdb/__init__.py b/app/modules/imdb/__init__.py index c94c42da6..9f8c761ca 100644 --- a/app/modules/imdb/__init__.py +++ b/app/modules/imdb/__init__.py @@ -391,7 +391,8 @@ class ImdbModule(MediaAuxiliaryProviderMixin, _ModuleBase): for name in self._recognize_names(meta): logger.info("正在使用 IMDb 识别:%s ...", name) titles = self.imdb_api.search_titles(name) - title = self._pick_title(name, titles, requested_type, meta.year) + year = str(meta.year) if meta.year is not None else None + title = self._pick_title(name, titles, requested_type, year) if title: return title return None @@ -409,7 +410,7 @@ class ImdbModule(MediaAuxiliaryProviderMixin, _ModuleBase): logger.info("正在使用 IMDb 识别:%s ...", name) titles = await self.imdb_api.async_search_titles(name) title = await self._async_pick_title( - name, titles, requested_type, meta.year + name, titles, requested_type, str(meta.year) if meta.year is not None else None ) if title: return title @@ -614,7 +615,7 @@ class ImdbModule(MediaAuxiliaryProviderMixin, _ModuleBase): media_info.season = meta.begin_season logger.info( "IMDb 识别结果:%s %s %s:%s", - media_info.type.value, + (media_info.type or MediaType.MOVIE).value, media_info.title_year, media_info.media_source, media_info.media_id, @@ -767,7 +768,7 @@ class ImdbModule(MediaAuxiliaryProviderMixin, _ModuleBase): return self._project_search_results( self.imdb_api.search_titles(plan.meta.name), plan.meta.type, - plan.meta.year, + str(plan.meta.year) if plan.meta.year is not None else None, ) async def async_search_medias( @@ -789,7 +790,7 @@ class ImdbModule(MediaAuxiliaryProviderMixin, _ModuleBase): return [] titles = await self.imdb_api.async_search_titles(plan.meta.name) return self._project_search_results( - titles, plan.meta.type, plan.meta.year + titles, plan.meta.type, str(plan.meta.year) if plan.meta.year is not None else None ) def clear_cache(self) -> None: diff --git a/app/modules/imdb/api.py b/app/modules/imdb/api.py index 9d612da52..e93b312b6 100644 --- a/app/modules/imdb/api.py +++ b/app/modules/imdb/api.py @@ -3,13 +3,13 @@ IMDb 免 Key 数据接口客户端。 """ import asyncio -from typing import Any, Optional, TypeVar +from typing import Any, Optional, TypeVar, cast from urllib.parse import quote from pydantic import BaseModel, ConfigDict, Field, ValidationError from app.adapters.network.http import AsyncRequestUtils, RequestUtils -from app.runtime.cache import cached +from app.runtime.cache import AsyncCacheControls, CacheControls, cached from app.runtime.log import logger from app.runtime.settings import get_runtime_setting from app.runtime.tasks import get_task_registry @@ -17,7 +17,7 @@ from app.runtime.tasks import get_task_registry TModel = TypeVar("TModel", bound=BaseModel) -def _is_graphql_error(value: Optional[dict]) -> bool: +def _is_graphql_error(value: Optional[dict[str, Any]]) -> bool: """ 避免把 IMDb GraphQL 业务错误写入长期缓存。 """ @@ -255,7 +255,7 @@ class ImdbApi: SEARCH_URL = "https://v2.sg.media-imdb.com/suggestion/x" GRAPHQL_URL = "https://caching.graphql.imdb.com/" - def __init__(self, proxies: Optional[dict] = None) -> None: + def __init__(self, proxies: Optional[dict[str, Any]] = None) -> None: """ 按一次模块配置快照创建网络请求适配器。 """ @@ -291,11 +291,13 @@ class ImdbApi: return value @classmethod - def _freeze_params(cls, params: Optional[dict]) -> tuple[tuple[str, Any], ...]: + def _freeze_params( + cls, params: Optional[dict[str, Any]] + ) -> tuple[tuple[str, Any], ...]: """ 把请求参数冻结为可用于缓存键的有序元组。 """ - return cls._freeze_value(params or {}) + return cast(tuple[tuple[str, Any], ...], cls._freeze_value(params or {})) @cached( maxsize=get_runtime_setting('CONF').imdb, @@ -305,7 +307,7 @@ class ImdbApi: ) def _cached_get_json( self, url: str, params_key: tuple[tuple[str, Any], ...] - ) -> Optional[dict]: + ) -> Optional[dict[str, Any]]: """ 同步读取并缓存一个 IMDb JSON GET 响应。 """ @@ -319,7 +321,7 @@ class ImdbApi: ) async def _async_cached_get_json( self, url: str, params_key: tuple[tuple[str, Any], ...] - ) -> Optional[dict]: + ) -> Optional[dict[str, Any]]: """ 异步读取并缓存一个 IMDb JSON GET 响应。 """ @@ -334,7 +336,7 @@ class ImdbApi: ) def _cached_graphql( self, query: str, variables_key: tuple[tuple[str, Any], ...] - ) -> Optional[dict]: + ) -> Optional[dict[str, Any]]: """ 同步读取并缓存一个 IMDb GraphQL 响应。 """ @@ -352,7 +354,7 @@ class ImdbApi: ) async def _async_cached_graphql( self, query: str, variables_key: tuple[tuple[str, Any], ...] - ) -> Optional[dict]: + ) -> Optional[dict[str, Any]]: """ 异步读取并缓存一个 IMDb GraphQL 响应。 """ @@ -361,7 +363,9 @@ class ImdbApi: json={"query": query, "variables": dict(variables_key)}, ) - def _graphql(self, query: str, variables: dict) -> Optional[dict]: + def _graphql( + self, query: str, variables: dict[str, Any] + ) -> Optional[dict[str, Any]]: """ 同步执行 GraphQL 查询并提取 data 区域。 """ @@ -371,7 +375,9 @@ class ImdbApi: return None return response.get("data") if response else None - async def _async_graphql(self, query: str, variables: dict) -> Optional[dict]: + async def _async_graphql( + self, query: str, variables: dict[str, Any] + ) -> Optional[dict[str, Any]]: """ 异步执行 GraphQL 查询并提取 data 区域。 """ @@ -387,8 +393,8 @@ class ImdbApi: """ 清理同步缓存,并在当前或临时事件循环中清理异步缓存。 """ - self._cached_get_json.cache_clear() - self._cached_graphql.cache_clear() + cast(CacheControls, self._cached_get_json).cache_clear() + cast(CacheControls, self._cached_graphql).cache_clear() try: asyncio.get_running_loop() except RuntimeError: @@ -408,8 +414,8 @@ class ImdbApi: """ 清理 IMDb 异步 GET 与 GraphQL 请求缓存区。 """ - await self._async_cached_get_json.cache_clear() - await self._async_cached_graphql.cache_clear() + await cast(AsyncCacheControls, self._async_cached_get_json).cache_clear() + await cast(AsyncCacheControls, self._async_cached_graphql).cache_clear() def close(self) -> None: """ @@ -419,7 +425,7 @@ class ImdbApi: @staticmethod def _parse( - model: type[TModel], data: Optional[dict], operation: str + model: type[TModel], data: Optional[dict[str, Any]], operation: str ) -> Optional[TModel]: """ 把外部响应校验为内部模型,并隔离字段漂移错误。 @@ -433,7 +439,7 @@ class ImdbApi: return None @staticmethod - def _first_title(data: Optional[dict]) -> Optional[dict]: + def _first_title(data: Optional[dict[str, Any]]) -> Optional[dict[str, Any]]: """ 从 IMDb GraphQL data 中提取第一个标题对象。 """ @@ -441,7 +447,9 @@ class ImdbApi: return titles[0] if titles else None @classmethod - def _parse_search_titles(cls, data: Optional[dict], limit: int) -> list[ImdbTitle]: + def _parse_search_titles( + cls, data: Optional[dict[str, Any]], limit: int + ) -> list[ImdbTitle]: """ 把 IMDb 搜索建议响应转换为稳定标题模型。 """ @@ -474,7 +482,7 @@ class ImdbApi: return titles @classmethod - def _parse_title(cls, data: Optional[dict]) -> Optional[ImdbTitle]: + def _parse_title(cls, data: Optional[dict[str, Any]]) -> Optional[ImdbTitle]: """ 把 IMDb GraphQL 标题结构展平为内部详情模型。 """ @@ -519,7 +527,7 @@ class ImdbApi: ) @classmethod - def _parse_akas(cls, data: Optional[dict]) -> list[ImdbAka]: + def _parse_akas(cls, data: Optional[dict[str, Any]]) -> list[ImdbAka]: """ 解析 GraphQL 标题别名边。 """ @@ -532,7 +540,7 @@ class ImdbApi: ] @classmethod - def _parse_credits(cls, data: Optional[dict]) -> list[ImdbCredit]: + def _parse_credits(cls, data: Optional[dict[str, Any]]) -> list[ImdbCredit]: """ 解析 GraphQL 导演和主演边。 """ @@ -564,7 +572,7 @@ class ImdbApi: return credits @classmethod - def _parse_images(cls, data: Optional[dict]) -> list[ImdbImage]: + def _parse_images(cls, data: Optional[dict[str, Any]]) -> list[ImdbImage]: """ 解析 GraphQL 标题图片边。 """ @@ -577,7 +585,7 @@ class ImdbApi: ] @classmethod - def _parse_seasons(cls, data: Optional[dict]) -> list[ImdbSeason]: + def _parse_seasons(cls, data: Optional[dict[str, Any]]) -> list[ImdbSeason]: """ 解析 GraphQL 季编号列表。 """ @@ -587,7 +595,7 @@ class ImdbApi: @classmethod def _parse_episode_page( - cls, data: Optional[dict] + cls, data: Optional[dict[str, Any]] ) -> tuple[list[ImdbEpisode], Optional[str]]: """ 解析一页 GraphQL 单集边及下一页游标。 diff --git a/app/modules/indexer/__init__.py b/app/modules/indexer/__init__.py index cd54d2202..fd5d54e04 100644 --- a/app/modules/indexer/__init__.py +++ b/app/modules/indexer/__init__.py @@ -236,7 +236,7 @@ class IndexerModule(_ModuleBase): :return: 无需初始化设置,返回 None """ - pass + return None def site_subtitle_links(self, context: Context) -> Optional[List[str]]: """ @@ -246,15 +246,21 @@ class IndexerModule(_ModuleBase): :return: 字幕下载链接列表,不适用时返回None """ torrent = context.torrent_info + if torrent is None: + return None if torrent.site is None: return None site = get_configured_site_query_service().get_sync(torrent.site) if not site: return None + if not site.domain: + return None indexer = SitesHelper().get_indexer(site.domain) if not indexer: return None if indexer.get("parser") == "mTorrent": + if not torrent.page_url: + return None return MTorrentSpider(indexer).get_subtitle_links( torrent.page_url ) @@ -262,7 +268,7 @@ class IndexerModule(_ModuleBase): return None @staticmethod - def __search_check(site: dict, search_word: Optional[str] = None) -> bool: + def __search_check(site: dict[str, Any], search_word: Optional[str] = None) -> bool: """ 检查是否可以执行搜索。 """ @@ -297,7 +303,7 @@ class IndexerModule(_ModuleBase): return text_tools.remove_punctuation(text, replacement=" ", allow_space=True) @staticmethod - def __indexer_statistic(site: dict, error_flag: bool = False, seconds: int = 0) -> None: + def __indexer_statistic(site: dict[str, Any], error_flag: bool = False, seconds: int = 0) -> None: """ 索引器统计。 """ @@ -311,7 +317,7 @@ class IndexerModule(_ModuleBase): ) @staticmethod - async def __async_indexer_statistic(site: dict, error_flag: bool = False, seconds: int = 0) -> None: + async def __async_indexer_statistic(site: dict[str, Any], error_flag: bool = False, seconds: int = 0) -> None: """ 异步索引器统计。 """ @@ -325,7 +331,7 @@ class IndexerModule(_ModuleBase): ) @staticmethod - def __parse_result(site: dict, result_array: list, seconds: int) -> List[TorrentInfo]: + def __parse_result(site: dict[str, Any], result_array: list[dict[str, Any]], seconds: int) -> List[TorrentInfo]: """ 解析搜索结果为 TorrentInfo 对象。 @@ -337,7 +343,7 @@ class IndexerModule(_ModuleBase): return [] logger.info( f"{site.get('name')} 搜索完成,耗时 {seconds} 秒,返回数据:{len(result_array)}") - torrents = [] + torrents: List[TorrentInfo] = [] for result in result_array: result = dict(result) legacy_imdb_id = result.pop("imdbid", None) @@ -351,15 +357,15 @@ class IndexerModule(_ModuleBase): site_name=site.get("name"), site_cookie=site.get("cookie"), site_ua=site.get("ua"), - site_proxy=site.get("proxy"), - site_order=site.get("pri"), + site_proxy=bool(site.get("proxy")), + site_order=int(site.get("pri") or 0), site_downloader=site.get("downloader"), **result, )) return torrents @staticmethod - def __parse_subtitle_result(site: dict, result_array: list, seconds: int) -> List[SubtitleInfo]: + def __parse_subtitle_result(site: dict[str, Any], result_array: list[dict[str, Any]], seconds: int) -> List[SubtitleInfo]: """ 解析字幕搜索结果为 SubtitleInfo 对象。 """ @@ -372,13 +378,13 @@ class IndexerModule(_ModuleBase): site_name=site.get("name"), site_cookie=site.get("cookie"), site_ua=site.get("ua"), - site_proxy=site.get("proxy"), - site_order=site.get("pri"), + site_proxy=bool(site.get("proxy")), + site_order=int(site.get("pri") or 0), **result) for result in result_array if result.get("language") and result.get("enclosure") and result.get("title") and result.get("size")] @staticmethod - def get_search_page_size(site: dict, keyword: Optional[str] = None) -> Optional[int]: + def get_search_page_size(site: dict[str, Any], keyword: Optional[str] = None) -> Optional[int]: """ 获取站点搜索入口的单页容量。 @@ -555,7 +561,7 @@ class IndexerModule(_ModuleBase): resource_name = "字幕" if search_type == "subtitles" else "" logger.error(f"{site.get('name')} {resource_name}搜索出错:{str(error)}") - def search_torrents(self, site: dict, + def search_torrents(self, site: dict[str, Any], keyword: Optional[str] = None, mtype: Optional[MediaType] = None, cat: Optional[str] = None, @@ -617,7 +623,7 @@ class IndexerModule(_ModuleBase): seconds=outcome.seconds, ) - def search_subtitles(self, site: dict, + def search_subtitles(self, site: dict[str, Any], keyword: Optional[str] = None, page: Optional[int] = 0) -> List[SubtitleInfo]: """ @@ -677,7 +683,7 @@ class IndexerModule(_ModuleBase): seconds=outcome.seconds, ) - async def async_search_torrents(self, site: dict, + async def async_search_torrents(self, site: dict[str, Any], keyword: Optional[str] = None, mtype: Optional[MediaType] = None, cat: Optional[str] = None, @@ -739,7 +745,7 @@ class IndexerModule(_ModuleBase): seconds=outcome.seconds, ) - async def async_search_subtitles(self, site: dict, + async def async_search_subtitles(self, site: dict[str, Any], keyword: Optional[str] = None, page: Optional[int] = 0) -> List[SubtitleInfo]: """ @@ -800,12 +806,12 @@ class IndexerModule(_ModuleBase): ) @staticmethod - def __spider_search(indexer: dict, + def __spider_search(indexer: dict[str, Any], search_word: Optional[str] = None, mtype: Optional[MediaType] = None, cat: Optional[str] = None, page: Optional[int] = 0, - search_type: Optional[str] = "torrents") -> Tuple[bool, List[dict], Optional[str]]: + search_type: Optional[str] = "torrents") -> Tuple[bool, List[dict[str, Any]], Optional[str]]: """ 根据关键字搜索单个站点。 @@ -830,12 +836,12 @@ class IndexerModule(_ModuleBase): del _spider @staticmethod - async def __async_spider_search(indexer: dict, + async def __async_spider_search(indexer: dict[str, Any], search_word: Optional[str] = None, mtype: Optional[MediaType] = None, cat: Optional[str] = None, page: Optional[int] = 0, - search_type: Optional[str] = "torrents") -> Tuple[bool, List[dict], Optional[str]]: + search_type: Optional[str] = "torrents") -> Tuple[bool, List[dict[str, Any]], Optional[str]]: """ 异步根据关键字搜索单个站点。 @@ -859,7 +865,7 @@ class IndexerModule(_ModuleBase): finally: del _spider - def refresh_torrents(self, site: dict, + def refresh_torrents(self, site: dict[str, Any], keyword: Optional[str] = None, cat: Optional[str] = None, page: Optional[int] = 0, @@ -878,7 +884,7 @@ class IndexerModule(_ModuleBase): site=site, keyword=keyword, cat=cat, page=page, mtype=mtype ) - async def async_refresh_torrents(self, site: dict, + async def async_refresh_torrents(self, site: dict[str, Any], keyword: Optional[str] = None, cat: Optional[str] = None, page: Optional[int] = 0, @@ -897,7 +903,7 @@ class IndexerModule(_ModuleBase): site=site, keyword=keyword, cat=cat, page=page, mtype=mtype ) - def refresh_userdata(self, site: dict) -> Optional[SiteUserData]: + def refresh_userdata(self, site: dict[str, Any]) -> Optional[SiteUserData]: """ 刷新站点的用户数据。 @@ -914,7 +920,7 @@ class IndexerModule(_ModuleBase): schema_value = schema_value or site.get("schema") for site_schema in self._site_schemas: if site_schema.schema and site_schema.schema.value == schema_value: - site_url = site.get("url") + site_url = cast(str, site.get("url")) userdata_path = str(site.get("userdata_path") or "").strip() if userdata_path and site_url: if userdata_path.startswith(("http://", "https://")): diff --git a/app/modules/indexer/parser/sunnypt.py b/app/modules/indexer/parser/sunnypt.py index e25b4aa97..e6fa31135 100644 --- a/app/modules/indexer/parser/sunnypt.py +++ b/app/modules/indexer/parser/sunnypt.py @@ -4,7 +4,7 @@ """ import json -from typing import Optional, Tuple +from typing import Any, Optional, Tuple from urllib.parse import urlencode, urljoin from app.foundation import temporal as time_tools @@ -39,7 +39,9 @@ class SunnyPTSiteUserInfo(SiteParserBase): "X-API-Key": self.apikey, } - def _load_api_payload(self, html_text: SitePage, operation: str) -> Optional[dict]: + def _load_api_payload( + self, html_text: SitePage, operation: str + ) -> Optional[dict[str, Any]]: """ 解析并校验 SunnyPT 通用 JSON 响应。 diff --git a/app/modules/indexer/spider/haidan.py b/app/modules/indexer/spider/haidan.py index 6c6f33bf1..d360c4b11 100644 --- a/app/modules/indexer/spider/haidan.py +++ b/app/modules/indexer/spider/haidan.py @@ -66,7 +66,7 @@ class HaiDanSpider: """ return None - def __init__(self, indexer: dict): + def __init__(self, indexer: dict[str, Any]): """ 使用站点配置初始化 HaiDan API 请求上下文。 """ @@ -123,7 +123,7 @@ class HaiDanSpider: "cat": categories }) - def __parse_result(self, result: dict) -> list[dict[str, object]]: + def __parse_result(self, result: dict[str, Any]) -> list[dict[str, object]]: """ 解析结果。 """ diff --git a/app/modules/indexer/spider/hddolby.py b/app/modules/indexer/spider/hddolby.py index 5b543ca1d..00d09adac 100644 --- a/app/modules/indexer/spider/hddolby.py +++ b/app/modules/indexer/spider/hddolby.py @@ -73,7 +73,7 @@ class HddolbySpider: """ return cls._size - def __init__(self, indexer: dict): + def __init__(self, indexer: dict[str, Any]): """ 使用站点配置初始化 HDDolby API 请求上下文。 """ @@ -122,7 +122,7 @@ class HddolbySpider: "visible": 1, } - def __parse_result(self, results: List[dict]) -> List[dict]: + def __parse_result(self, results: List[dict[str, Any]]) -> List[dict[str, Any]]: """ 解析搜索结果。 """ @@ -186,7 +186,7 @@ class HddolbySpider: 'downloadvolumefactor': self.__get_downloadvolumefactor(result.get('promotion_time_type')), 'uploadvolumefactor': self.__get_uploadvolumefactor(result.get('promotion_time_type')), 'freedate': result.get('promotion_until'), - 'page_url': self._pageurl % result.get('id'), + 'page_url': self._pageurl % result.get('id') if self._pageurl else '', 'labels': torrentLabels, 'category': category } @@ -210,7 +210,7 @@ class HddolbySpider: return True, [] def search(self, keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0, - cat: Optional[str] = None) -> Tuple[bool, List[dict]]: + cat: Optional[str] = None) -> Tuple[bool, List[dict[str, Any]]]: """ 同步搜索 HDDolby 种子。 @@ -221,6 +221,9 @@ class HddolbySpider: :return: 是否发生错误及标准化资源列表;空列表也可能表示没有匹配资源 """ + if not self._searchurl: + logger.warning(f"{self._name} 未配置搜索地址,无法搜索") + return True, [] params = self.__get_params(keyword, mtype, page, cat) res = RequestUtils( @@ -237,7 +240,7 @@ class HddolbySpider: return self.__process_response(res) async def async_search(self, keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0, - cat: Optional[str] = None) -> Tuple[bool, List[dict]]: + cat: Optional[str] = None) -> Tuple[bool, List[dict[str, Any]]]: """ 异步搜索 HDDolby 种子。 @@ -247,6 +250,9 @@ class HddolbySpider: :param cat: 逗号分隔的站点分类 ID,优先于媒体类型 :return: 是否发生错误及标准化资源列表;空列表也可能表示没有匹配资源 """ + if not self._searchurl: + logger.warning(f"{self._name} 未配置搜索地址,无法搜索") + return True, [] params = self.__get_params(keyword, mtype, page, cat) res = await AsyncRequestUtils( diff --git a/app/modules/indexer/spider/rousi.py b/app/modules/indexer/spider/rousi.py index 5e5c01bcb..250a0a335 100644 --- a/app/modules/indexer/spider/rousi.py +++ b/app/modules/indexer/spider/rousi.py @@ -4,7 +4,7 @@ import base64 import json -from typing import List, Optional, Tuple, Union +from typing import Any, List, Optional, Tuple, Union from app.adapters.network.http import AsyncRequestUtils, RequestUtils from app.application.configuration import get_configured_system_config @@ -54,7 +54,7 @@ class RousiSpider: """ return cls._size - def __init__(self, indexer: dict): + def __init__(self, indexer: dict[str, Any]): """ 使用站点配置初始化 PeerGo 搜索和下载接口。 @@ -87,7 +87,7 @@ class RousiSpider: :return: 请求参数字典 """ params: dict[str, Union[int, str]] = { - "page": int(page) + 1, + "page": int(page or 0) + 1, "page_size": self._size } if keyword: @@ -138,7 +138,9 @@ class RousiSpider: return category_names if category_names else None - def __process_response(self, res) -> Tuple[bool, List[dict]]: + def __process_response( + self, res: Any + ) -> Tuple[bool, List[dict[str, Any]]]: """ 处理 API 响应。 @@ -166,7 +168,7 @@ class RousiSpider: logger.warning(f"{self._name} 搜索失败,无法连接 {self._domain}") return True, [] - def __parse_result(self, results: List[dict]) -> List[dict]: + def __parse_result(self, results: List[dict[str, Any]]) -> List[dict[str, Any]]: """ 解析搜索结果。 @@ -236,7 +238,13 @@ class RousiSpider: torrents.append(torrent) return torrents - def search(self, keyword: str, mtype: Optional[MediaType] = None, cat: Optional[str] = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]: + def search( + self, + keyword: str, + mtype: Optional[MediaType] = None, + cat: Optional[str] = None, + page: Optional[int] = 0, + ) -> Tuple[bool, List[dict[str, Any]]]: """ 同步搜索种子。 @@ -264,7 +272,13 @@ class RousiSpider: return self.__process_response(res) - async def async_search(self, keyword: str, mtype: Optional[MediaType] = None, cat: Optional[str] = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]: + async def async_search( + self, + keyword: str, + mtype: Optional[MediaType] = None, + cat: Optional[str] = None, + page: Optional[int] = 0, + ) -> Tuple[bool, List[dict[str, Any]]]: """ 异步搜索种子。 diff --git a/app/modules/indexer/spider/sunnypt.py b/app/modules/indexer/spider/sunnypt.py index bbc8909e7..a6c9a1bca 100644 --- a/app/modules/indexer/spider/sunnypt.py +++ b/app/modules/indexer/spider/sunnypt.py @@ -5,7 +5,7 @@ import base64 import json import time -from typing import List, Optional, Tuple +from typing import Any, List, Optional, Tuple from app.adapters.network.http import AsyncRequestUtils, RequestUtils from app.foundation import temporal as time_tools @@ -23,7 +23,7 @@ class SunnyPTSpider: _category_cache_ttl = 3600 _category_cache: dict[str, tuple[float, dict[str, list[str]]]] = {} - def __init__(self, indexer: dict): + def __init__(self, indexer: dict[str, Any]): """ 初始化 SunnyPT API 索引器。 @@ -56,7 +56,9 @@ class SunnyPTSpider: return cls._size @staticmethod - def _parse_configured_categories(category_config: dict) -> dict[str, list[str]]: + def _parse_configured_categories( + category_config: dict[str, Any], + ) -> dict[str, list[str]]: """ 从站点索引配置提取电影、电视剧和音乐分类 ID,作为分类接口不可用时的兜底。 @@ -116,7 +118,7 @@ class SunnyPTSpider: """ cls._category_cache[api_url] = (time.monotonic(), category_map) - def _request_headers(self) -> dict: + def _request_headers(self) -> dict[str, Any]: """ 构造 SunnyPT API 请求头。 @@ -128,7 +130,7 @@ class SunnyPTSpider: "X-API-Key": self._api_key, } - def _response_data(self, response, operation: str): + def _response_data(self, response: Any, operation: str) -> Any: """ 校验 SunnyPT 通用响应并返回 data 字段。 @@ -219,8 +221,8 @@ class SunnyPTSpider: media_type: Optional[MediaType], category: Optional[str], page: Optional[int], - category_map: dict, - ) -> dict: + category_map: dict[str, list[str]], + ) -> dict[str, Any]: """ 构造 SunnyPT 种子搜索参数。 @@ -249,7 +251,7 @@ class SunnyPTSpider: params["categories"] = categories return params - def _parse_result(self, results: List[dict]) -> List[dict]: + def _parse_result(self, results: List[dict[str, Any]]) -> List[dict[str, Any]]: """ 将 SunnyPT API 种子数据转换为 MoviePilot 标准字段。 @@ -299,7 +301,9 @@ class SunnyPTSpider: }) return torrents - def _process_search_response(self, response) -> Tuple[bool, List[dict]]: + def _process_search_response( + self, response: Any + ) -> Tuple[bool, List[dict[str, Any]]]: """ 处理 SunnyPT 种子搜索响应。 @@ -317,7 +321,7 @@ class SunnyPTSpider: mtype: Optional[MediaType] = None, cat: Optional[str] = None, page: Optional[int] = 0, - ) -> Tuple[bool, List[dict]]: + ) -> Tuple[bool, List[dict[str, Any]]]: """ 同步搜索 SunnyPT 种子。 @@ -345,7 +349,7 @@ class SunnyPTSpider: mtype: Optional[MediaType] = None, cat: Optional[str] = None, page: Optional[int] = 0, - ) -> Tuple[bool, List[dict]]: + ) -> Tuple[bool, List[dict[str, Any]]]: """ 异步搜索 SunnyPT 种子。 diff --git a/app/modules/indexer/spider/tnode.py b/app/modules/indexer/spider/tnode.py index 376ae9372..d919d8f93 100644 --- a/app/modules/indexer/spider/tnode.py +++ b/app/modules/indexer/spider/tnode.py @@ -35,7 +35,7 @@ class TNodeSpider(metaclass=SingletonClass): """ return cls._size - def __init__(self, indexer: dict): + def __init__(self, indexer: dict[str, Any]): """ 使用站点配置初始化 TNode 请求上下文。 """ @@ -92,7 +92,7 @@ class TNodeSpider(metaclass=SingletonClass): """ search_type = "imdbid" if (keyword and keyword.startswith('tt')) else "title" return { - "page": int(page) + 1, + "page": int(page or 0) + 1, "size": self._size, "type": search_type, "keyword": keyword or "", @@ -107,7 +107,7 @@ class TNodeSpider(metaclass=SingletonClass): "group": [] } - def __parse_result(self, results: List[dict]) -> List[dict]: + def __parse_result(self, results: List[dict[str, Any]]) -> List[dict[str, Any]]: """ 解析搜索结果。 """ @@ -147,7 +147,9 @@ class TNodeSpider(metaclass=SingletonClass): logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}") return True, [] - def search(self, keyword: str, page: Optional[int] = 0) -> Tuple[bool, List[dict]]: + def search( + self, keyword: str, page: Optional[int] = 0 + ) -> Tuple[bool, List[dict[str, Any]]]: """ 同步搜索 TNode 种子。 @@ -174,7 +176,9 @@ class TNodeSpider(metaclass=SingletonClass): ).post_res(url=self._searchurl, json=params) return self.__process_response(res) - async def async_search(self, keyword: str, page: Optional[int] = 0) -> Tuple[bool, List[dict]]: + async def async_search( + self, keyword: str, page: Optional[int] = 0 + ) -> Tuple[bool, List[dict[str, Any]]]: """ 异步搜索 TNode 种子。 diff --git a/app/modules/indexer/spider/torrentleech.py b/app/modules/indexer/spider/torrentleech.py index 8f7af6404..f6053f1ad 100644 --- a/app/modules/indexer/spider/torrentleech.py +++ b/app/modules/indexer/spider/torrentleech.py @@ -36,7 +36,7 @@ class TorrentLeech: """ return None if keyword else cls._size - def __init__(self, indexer: dict): + def __init__(self, indexer: dict[str, Any]): """ 初始化站点认证信息和媒体分类配置。 """ @@ -78,7 +78,11 @@ class TorrentLeech: ) return self._searchurl % (domain, encoded_keyword) - def __parse_result(self, results: List[dict], mtype: Optional[MediaType] = None) -> List[dict]: + def __parse_result( + self, + results: List[dict[str, Any]], + mtype: Optional[MediaType] = None, + ) -> List[dict[str, Any]]: """ 解析搜索结果。 """ @@ -136,7 +140,7 @@ class TorrentLeech: keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0, - ) -> Tuple[bool, List[dict]]: + ) -> Tuple[bool, List[dict[str, Any]]]: """ 同步搜索 TorrentLeech 种子。 @@ -152,7 +156,7 @@ class TorrentLeech: if keyword: url = self.__get_search_url(keyword, mtype) else: - url = self._browseurl % (self._indexer.get('domain'), int(page) + 1) + url = self._browseurl % (self._indexer.get('domain'), int(page or 0) + 1) res = RequestUtils( headers={ @@ -170,7 +174,7 @@ class TorrentLeech: keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0, - ) -> Tuple[bool, List[dict]]: + ) -> Tuple[bool, List[dict[str, Any]]]: """ 异步搜索 TorrentLeech 种子。 @@ -186,7 +190,7 @@ class TorrentLeech: if keyword: url = self.__get_search_url(keyword, mtype) else: - url = self._browseurl % (self._indexer.get('domain'), int(page) + 1) + url = self._browseurl % (self._indexer.get('domain'), int(page or 0) + 1) res = await AsyncRequestUtils( headers={ diff --git a/app/modules/indexer/spider/yema.py b/app/modules/indexer/spider/yema.py index baacc9b2c..375d02075 100644 --- a/app/modules/indexer/spider/yema.py +++ b/app/modules/indexer/spider/yema.py @@ -39,7 +39,7 @@ class YemaSpider: "12": "完结", } - def __init__(self, indexer: dict): + def __init__(self, indexer: dict[str, Any]): """ 初始化 YemaPT 开放 API 索引器。 @@ -66,7 +66,7 @@ class YemaSpider: """ return cls._size - def _request_headers(self) -> dict: + def _request_headers(self) -> dict[str, Any]: """ 构造开放 API 请求头。 @@ -121,7 +121,9 @@ class YemaSpider: return [None] @staticmethod - def _merge_search_results(result_groups: List[List[dict]]) -> List[dict]: + def _merge_search_results( + result_groups: List[List[dict[str, Any]]], + ) -> List[dict[str, Any]]: """ 按种子详情链接合并多个分类查询结果并保持首次出现顺序。 """ @@ -137,7 +139,7 @@ class YemaSpider: merged.append(torrent) return merged - def _parse_result(self, results: List[dict]) -> List[dict]: + def _parse_result(self, results: List[dict[str, Any]]) -> List[dict[str, Any]]: """ 将开放 API 种子数据转换为 MoviePilot 标准字段。 @@ -189,7 +191,9 @@ class YemaSpider: }) return torrents - def _process_search_response(self, response) -> Tuple[bool, List[dict]]: + def _process_search_response( + self, response: Any + ) -> Tuple[bool, List[dict[str, Any]]]: """ 校验开放 API 通用响应并解析搜索结果。 @@ -270,7 +274,7 @@ class YemaSpider: mtype: Optional[MediaType] = None, page: Optional[int] = 0, cat: Optional[str] = None, - ) -> Tuple[bool, List[dict]]: + ) -> Tuple[bool, List[dict[str, Any]]]: """ 同步搜索 YemaPT 公开种子。 @@ -308,7 +312,7 @@ class YemaSpider: mtype: Optional[MediaType] = None, page: Optional[int] = 0, cat: Optional[str] = None, - ) -> Tuple[bool, List[dict]]: + ) -> Tuple[bool, List[dict[str, Any]]]: """ 异步搜索 YemaPT 公开种子。 diff --git a/app/modules/jellyfin/__init__.py b/app/modules/jellyfin/__init__.py index c2635b4c6..5beb24609 100644 --- a/app/modules/jellyfin/__init__.py +++ b/app/modules/jellyfin/__init__.py @@ -145,7 +145,7 @@ class JellyfinModule(_MediaServerModuleBase[Jellyfin]): server_obj = self.get_instance(server) if server_obj: return server_obj.get_librarys( - username=username, hidden=hidden, **({"query": query} if query is not None else {}) + username=username, hidden=hidden, **MediaServerQueryOptions.as_kwargs(query) ) return None @@ -171,7 +171,7 @@ class JellyfinModule(_MediaServerModuleBase[Jellyfin]): server_obj = self.get_instance(server) if server_obj: return server_obj.get_items( - library_id, start_index, limit, **({"query": query} if query is not None else {}) + library_id, start_index, limit, **MediaServerQueryOptions.as_kwargs(query) ) return None @@ -201,7 +201,7 @@ class JellyfinModule(_MediaServerModuleBase[Jellyfin]): """ server_obj = self.get_instance(server) if server_obj: - return server_obj.get_iteminfo(item_id, **({"query": query} if query is not None else {})) + return server_obj.get_iteminfo(item_id, **MediaServerQueryOptions.as_kwargs(query)) return None def mediaserver_tv_episodes( @@ -218,7 +218,10 @@ class JellyfinModule(_MediaServerModuleBase[Jellyfin]): server_obj = self.get_instance(server) if not server_obj: return None - _, seasoninfo = server_obj.get_tv_episodes(item_id=item_id, **({"query": query} if query is not None else {})) + _, seasoninfo = server_obj.get_tv_episodes( + item_id=str(item_id) if item_id is not None else None, + **MediaServerQueryOptions.as_kwargs(query), + ) if not seasoninfo: return [] return [ @@ -245,7 +248,7 @@ class JellyfinModule(_MediaServerModuleBase[Jellyfin]): server_obj = self.get_instance(server) if not server_obj: return None - return server_obj.get_resume(num=count, username=username, **({"query": query} if query is not None else {})) + return server_obj.get_resume(num=count, username=username, **MediaServerQueryOptions.as_kwargs(query)) def mediaserver_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]: """ @@ -258,7 +261,7 @@ class JellyfinModule(_MediaServerModuleBase[Jellyfin]): server_obj = self.get_instance(server) if not server_obj: return None - return server_obj.get_play_url(item_id) + return server_obj.get_play_url(str(item_id)) def mediaserver_season_episode_ids( self, server: str, item_id: Union[str, int], season: int, *, query: Optional[MediaServerQueryOptions] = None @@ -276,7 +279,7 @@ class JellyfinModule(_MediaServerModuleBase[Jellyfin]): if not server_obj: return None return server_obj.get_season_episode_ids( - str(item_id), season, **({"query": query} if query is not None else {}) + str(item_id), season, **MediaServerQueryOptions.as_kwargs(query) ) def mediaserver_latest( @@ -299,7 +302,7 @@ class JellyfinModule(_MediaServerModuleBase[Jellyfin]): server_obj = self.get_instance(server) if not server_obj: return None - return server_obj.get_latest(num=count, username=username, **({"query": query} if query is not None else {})) + return server_obj.get_latest(num=count, username=username, **MediaServerQueryOptions.as_kwargs(query)) def mediaserver_latest_images( self, @@ -322,14 +325,14 @@ class JellyfinModule(_MediaServerModuleBase[Jellyfin]): """ if query is not None and not query.includes(MediaServerField.IMAGES): return [] - server_obj: Jellyfin = self.get_instance(server) - if not server: + server_obj = self.get_instance(server) + if not server_obj: return [] links = [] items = ( self.mediaserver_latest( - server=server, count=count, username=username, **({"query": query} if query is not None else {}) + server=server, count=count, username=username, **MediaServerQueryOptions.as_kwargs(query) ) or [] ) @@ -338,7 +341,7 @@ class JellyfinModule(_MediaServerModuleBase[Jellyfin]): continue if item.BackdropImageTags: image_url = server_obj.get_backdrop_url( - item_id=item.id, image_tag=item.BackdropImageTags[0], remote=remote + item_id=str(item.id), image_tag=item.BackdropImageTags[0], remote=remote ) if image_url: links.append(image_url) diff --git a/app/modules/jellyfin/jellyfin.py b/app/modules/jellyfin/jellyfin.py index d42a4eec5..406865635 100644 --- a/app/modules/jellyfin/jellyfin.py +++ b/app/modules/jellyfin/jellyfin.py @@ -662,7 +662,7 @@ class Jellyfin: cached_item_id = item_id if not item_id: item_id = self.__get_jellyfin_series_id_by_name( - title, year, **({"query": query} if query is not None else {}) + title, year, **MediaServerQueryOptions.as_kwargs(query) ) if item_id is None: return None, None @@ -677,7 +677,7 @@ class Jellyfin: # 媒体删除后重新入库会导致缓存ID失效,回退到标题搜索避免误判整部剧缺失。 logger.warning(f"Jellyfin缓存的电视剧媒体ID {cached_item_id} 已失效,尝试按标题重新搜索:{title}") item_id = self.__get_jellyfin_series_id_by_name( - title, year, **({"query": query} if query is not None else {}) + title, year, **MediaServerQueryOptions.as_kwargs(query) ) if item_id is None: return None, None @@ -698,7 +698,7 @@ class Jellyfin: try: params.update( emby_query_params( - MediaServerQueryOptions(fields=set()) if query is not None and query.fields is not None else None + MediaServerQueryOptions(fields=frozenset()) if query is not None and query.fields is not None else None ) ) res_json = self._request().get_res(url, params) @@ -742,7 +742,7 @@ class Jellyfin: params = {"season": season, "userId": self.user, "isMissing": "false", "api_key": self._apikey} params.update( emby_query_params( - MediaServerQueryOptions(fields=set()) if query is not None and query.fields is not None else None + MediaServerQueryOptions(fields=frozenset()) if query is not None and query.fields is not None else None ) ) res_json = self._request().get_res(url, params) @@ -1115,7 +1115,7 @@ class Jellyfin: if not item: continue if "Folder" in item.get("Type") or item.get("Type") == "BoxSet": - for items in self.get_items(item.get("Id"), **({"query": query} if query is not None else {})): + for items in self.get_items(item.get("Id"), **MediaServerQueryOptions.as_kwargs(query)): yield items elif item.get("Type") in ["Movie", "Series", "MusicAlbum"]: yield self.__format_item_info(item) diff --git a/app/modules/listenbrainz/__init__.py b/app/modules/listenbrainz/__init__.py index 11c5dcf8d..32838e3be 100644 --- a/app/modules/listenbrainz/__init__.py +++ b/app/modules/listenbrainz/__init__.py @@ -228,7 +228,8 @@ class ListenBrainzModule(_ModuleBase): f"ListenBrainz 请求失败:{response.status_code} {response.text[:200]}" ) return None - return response.json() + payload = response.json() + return payload if isinstance(payload, dict) else None except (TypeError, ValueError) as err: logger.warning(f"ListenBrainz 响应解析失败:{err}") return None diff --git a/app/modules/lrclib/__init__.py b/app/modules/lrclib/__init__.py index f8d643833..8de615a1e 100644 --- a/app/modules/lrclib/__init__.py +++ b/app/modules/lrclib/__init__.py @@ -5,14 +5,14 @@ LRCLIB 歌词检索、严格候选匹配与请求缓存。 import re import threading import time -from typing import Any, Optional, Tuple, Union +from typing import Any, Optional, Tuple, Union, cast from app.adapters.network.http import RequestUtils from app.domain.context import MusicInfo, MusicLyrics from app.domain.meta.metamusic import MetaMusic from app.foundation.text import convert as zhconv_convert from app.modules import _ModuleBase -from app.runtime.cache import cached +from app.runtime.cache import CacheControls, cached from app.runtime.log import logger from app.runtime.settings import get_runtime_setting from app.schemas.types import ModuleType, OtherModulesType @@ -34,7 +34,7 @@ class LrclibModule(_ModuleBase): """ 配置变化后清理跨实例请求缓存和供应商冷却状态。 """ - self._request_json.cache_clear() + cast(CacheControls, self._request_json).cache_clear() type(self)._cooldown_until = 0.0 def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]: diff --git a/app/modules/musicbrainz/__init__.py b/app/modules/musicbrainz/__init__.py index c86fa4be6..599a54246 100644 --- a/app/modules/musicbrainz/__init__.py +++ b/app/modules/musicbrainz/__init__.py @@ -1630,7 +1630,7 @@ class MusicBrainzModule(_ModuleBase): mtype: Optional[MediaType] = None, media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, - **kwargs, + **kwargs: Any, ) -> Optional[MusicInfo]: """ 接收统一识别请求,按明确身份查询详情或按音乐线索匹配。 @@ -1715,7 +1715,7 @@ class MusicBrainzModule(_ModuleBase): mtype: Optional[MediaType] = None, media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, - **kwargs, + **kwargs: Any, ) -> Optional[MusicInfo]: """ 异步接收统一识别请求,按明确身份查询详情或按音乐线索匹配。 @@ -2127,6 +2127,9 @@ class MusicBrainzModule(_ModuleBase): """ def _convert(run: str) -> str: + """ + 把连续中文数字转换为阿拉伯数字,保留艺术家名称中的其他文本。 + """ total, current = 0, 0 for char in run: if char in cls._CJK_DIGIT_MAP: @@ -2327,7 +2330,7 @@ class MusicBrainzModule(_ModuleBase): }, ) album = self._project_album_detail(payload) - if not album or (musicbrainz_release_id is not None and album.media_id != media_id): + if not payload or not album or (musicbrainz_release_id is not None and album.media_id != media_id): return None tracks_payload = await self._async_album_tracks_payload( payload.get("releases") or [], @@ -2380,7 +2383,7 @@ class MusicBrainzModule(_ModuleBase): }, ) album = self._project_album_detail(payload) - if not album or (musicbrainz_release_id is not None and album.media_id != media_id): + if not payload or not album or (musicbrainz_release_id is not None and album.media_id != media_id): return None tracks_payload = self._album_tracks_payload( payload.get("releases") or [], diff --git a/app/modules/navidrome/__init__.py b/app/modules/navidrome/__init__.py index a69af5ac5..6a24bb175 100644 --- a/app/modules/navidrome/__init__.py +++ b/app/modules/navidrome/__init__.py @@ -5,7 +5,7 @@ Navidrome 媒体服务器模块。 from typing import Any, Dict, Generator, List, Optional, Tuple, Union from app.application.mediaserver import MusicMediaServerHelper -from app.domain.context import MediaInfo +from app.domain.context import MediaInfo, MusicInfo from app.modules import _MediaServerBase, _ModuleBase from app.modules.navidrome.navidrome import Navidrome from app.runtime.events import eventmanager @@ -112,6 +112,8 @@ class NavidromeModule(_ModuleBase, _MediaServerBase[Navidrome]): """ if not credentials or credentials.grant_type != "password": return None + if credentials.username is None or credentials.password is None: + return None servers: list[tuple[Optional[str], Optional[Navidrome]]] = ( [(service_name, self.get_instance(service_name))] if service_name and self.get_instance(service_name) @@ -120,16 +122,21 @@ class NavidromeModule(_ModuleBase, _MediaServerBase[Navidrome]): for name, server in servers: if not server: continue + service_name_value = name or self.get_name() intercept_event = eventmanager.send_event( etype=ChainEventType.AuthIntercept, data=AuthInterceptCredentials( username=credentials.username, channel=self.get_name(), - service=name, + service=service_name_value, status="triggered", ), ) - if intercept_event and intercept_event.event_data and intercept_event.event_data.cancel: + if ( + intercept_event + and isinstance(intercept_event.event_data, AuthInterceptCredentials) + and intercept_event.event_data.cancel + ): continue token = server.authenticate(credentials.username, credentials.password) if token: @@ -156,7 +163,7 @@ class NavidromeModule(_ModuleBase, _MediaServerBase[Navidrome]): :param query: 可选查询内容投影,省略时保留原有请求和返回内容 :return: 首个匹配项的服务器和条目标识;非音乐或未找到匹配项时返回 None """ - if mediainfo.type != MediaType.MUSIC: + if mediainfo.type != MediaType.MUSIC or not isinstance(mediainfo, MusicInfo): return None servers: list[tuple[Optional[str], Optional[Navidrome]]] = ( [(server, self.get_instance(server))] if server else list(self.get_instances().items()) @@ -255,7 +262,11 @@ class NavidromeModule(_ModuleBase, _MediaServerBase[Navidrome]): """ service = self.get_instance(server) return ( - service.get_items(start_index, limit, **({"query": query} if query is not None else {})) + service.get_items( + start_index if start_index is not None else 0, + limit if limit is not None else -1, + **({"query": query} if query is not None else {}), + ) if service else None ) @@ -316,7 +327,10 @@ class NavidromeModule(_ModuleBase, _MediaServerBase[Navidrome]): :return: 播放条目列表;实例不存在时返回 None,服务端请求失败时返回空列表 """ service = self.get_instance(server) - return service.get_resume(count, **({"query": query} if query is not None else {})) if service else None + return service.get_resume( + count if count is not None else 20, + **({"query": query} if query is not None else {}), + ) if service else None def mediaserver_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]: """ @@ -347,7 +361,10 @@ class NavidromeModule(_ModuleBase, _MediaServerBase[Navidrome]): :return: 最近新增专辑列表;实例不存在时返回 None """ service = self.get_instance(server) - return service.get_latest(count, **({"query": query} if query is not None else {})) if service else None + return service.get_latest( + count if count is not None else 20, + **({"query": query} if query is not None else {}), + ) if service else None def mediaserver_latest_images( self, diff --git a/app/modules/navidrome/navidrome.py b/app/modules/navidrome/navidrome.py index b4476abed..45865f17f 100644 --- a/app/modules/navidrome/navidrome.py +++ b/app/modules/navidrome/navidrome.py @@ -30,7 +30,7 @@ class Navidrome: username: Optional[str] = None, password: Optional[str] = None, play_host: Optional[str] = None, - sync_libraries: Optional[list] = None, + sync_libraries: Optional[list[str]] = None, **kwargs: Any, ) -> None: """ @@ -75,7 +75,7 @@ class Navidrome: params.update({key: value for key, value in kwargs.items() if value is not None}) return params - def _call(self, method: str, **kwargs: Any) -> Optional[dict]: + def _call(self, method: str, **kwargs: Any) -> Optional[dict[str, Any]]: """ 通过 Subsonic 接口获取业务成功的响应体。 @@ -91,7 +91,12 @@ class Navidrome: response = RequestUtils().get_res(f"{url}/{method}", self._params(**kwargs)) if not response: return None - payload = response.json().get("subsonic-response", {}) + response_data = response.json() + if not isinstance(response_data, dict): + return None + payload = response_data.get("subsonic-response", {}) + if not isinstance(payload, dict): + return None if payload.get("status") != "ok": logger.warning(f"Navidrome API {method} 返回失败:{payload.get('error')}") return None @@ -153,13 +158,13 @@ class Navidrome: return 1 if self.user else 0 @staticmethod - def _album_name(album: dict) -> str: + def _album_name(album: dict[str, Any]) -> str: """ 从 Subsonic 专辑对象提取稳定标题。 """ return album.get("name") or album.get("album") or "" - def _album_to_item(self, album: dict) -> _SchemaMediaServerItem: + def _album_to_item(self, album: dict[str, Any]) -> _SchemaMediaServerItem: """ 将 Subsonic 专辑转换为统一媒体条目。 """ @@ -176,7 +181,7 @@ class Navidrome: note={"artist": album.get("artist"), "song_count": album.get("songCount")}, ) - def _song_to_item(self, song: dict) -> _SchemaMediaServerItem: + def _song_to_item(self, song: dict[str, Any]) -> _SchemaMediaServerItem: """ 将 Subsonic 单曲转换为统一音乐条目。 """ @@ -204,7 +209,7 @@ class Navidrome: return bool(normalized_left) and normalized_left == normalized_right @classmethod - def _same_artist(cls, item: dict, artist: Optional[str]) -> bool: + def _same_artist(cls, item: dict[str, Any], artist: Optional[str]) -> bool: """ 检查专辑或单曲的艺术家是否与目标一致;目标为空时不限制。 """ @@ -215,7 +220,7 @@ class Navidrome: for candidate in (item.get("artist"), item.get("albumArtist")) ) - def _album_cover(self, album: dict) -> Optional[str]: + def _album_cover(self, album: dict[str, Any]) -> Optional[str]: """ 返回专辑封面地址。 """ @@ -227,7 +232,9 @@ class Navidrome: return None return f"{url}/getCoverArt?{urlencode(self._params(id=cover_id, size=512))}" - def _albums(self, list_type: str = "alphabeticalByName", size: int = 500) -> List[dict]: + def _albums( + self, list_type: str = "alphabeticalByName", size: int = 500 + ) -> List[dict[str, Any]]: """ 分页读取专辑,直至短页、空页或接口失败。 @@ -235,7 +242,7 @@ class Navidrome: :param size: 每页请求数量,默认 500 :return: 已读取的专辑列表;中途失败会返回此前已读取的部分结果 """ - albums: List[dict] = [] + albums: List[dict[str, Any]] = [] offset = 0 while True: payload = self._call("getAlbumList2", type=list_type, size=size, offset=offset) @@ -392,7 +399,7 @@ class Navidrome: ] def _to_play_item( - self, album: dict, *, query: Optional[MediaServerQueryOptions] = None + self, album: dict[str, Any], *, query: Optional[MediaServerQueryOptions] = None ) -> _SchemaMediaServerPlayItem: """ 将专辑转换为仪表盘播放/最新条目。 @@ -413,7 +420,7 @@ class Navidrome: ) def _song_to_play_item( - self, song: dict, *, query: Optional[MediaServerQueryOptions] = None + self, song: dict[str, Any], *, query: Optional[MediaServerQueryOptions] = None ) -> _SchemaMediaServerPlayItem: """ 将正在播放的单曲转换为仪表盘条目,避免把所属专辑名误作曲名。 diff --git a/app/modules/plex/__init__.py b/app/modules/plex/__init__.py index 6c34259ef..73b117ebb 100644 --- a/app/modules/plex/__init__.py +++ b/app/modules/plex/__init__.py @@ -5,7 +5,7 @@ Plex 模块,将认证、媒体库查询和播放事件接入统一媒体服务 from typing import Any, Dict, Generator, Iterable, List, Optional, Tuple, Union from app.application.mediaserver import MusicMediaServerHelper -from app.domain.context import MediaInfo +from app.domain.context import MediaInfo, MusicInfo from app.modules._base.mediaserver import _MediaServerModuleBase from app.modules.plex.plex import Plex from app.runtime.events import eventmanager @@ -106,6 +106,8 @@ class PlexModule(_MediaServerModuleBase[Plex]): # Plex认证 if not credentials or credentials.grant_type != "password": return None + if credentials.username is None or credentials.password is None: + return None # 确定要认证的服务器列表 servers: Iterable[tuple[Optional[str], Plex]] if service_name: @@ -116,14 +118,15 @@ class PlexModule(_MediaServerModuleBase[Plex]): servers = self.get_instances().items() # 遍历要认证的服务器 for name, server in servers: + service_name = name or self.get_name() # 触发认证拦截事件 intercept_event = eventmanager.send_event( etype=ChainEventType.AuthIntercept, data=AuthInterceptCredentials(username=credentials.username, channel=self.get_name(), - service=name, status="triggered") + service=service_name, status="triggered") ) - if intercept_event and intercept_event.event_data: - intercept_data: AuthInterceptCredentials = intercept_event.event_data + if intercept_event and isinstance(intercept_event.event_data, AuthInterceptCredentials): + intercept_data = intercept_event.event_data if intercept_data.cancel: continue auth_result = server.authenticate(credentials.username, credentials.password) @@ -189,6 +192,8 @@ class PlexModule(_MediaServerModuleBase[Plex]): if not s: continue if mediainfo.type == MediaType.MUSIC: + if not isinstance(mediainfo, MusicInfo): + continue search_params = MusicMediaServerHelper.search_params(mediainfo) matches = s.get_music( title=search_params["title"], @@ -207,19 +212,19 @@ class PlexModule(_MediaServerModuleBase[Plex]): continue if mediainfo.type == MediaType.MOVIE: if itemid: - movie = s.get_iteminfo(itemid, **({"query": query} if query is not None else {})) + movie = s.get_iteminfo(itemid, **MediaServerQueryOptions.as_kwargs(query)) if movie: logger.info(f"媒体库 {name} 中找到了 {movie}") return _SchemaExistMediaInfo( type=MediaType.MOVIE, server_type="plex", server=name, itemid=movie.item_id ) movies = s.get_movies( - title=mediainfo.title, + title=mediainfo.title or "", original_title=mediainfo.original_title, year=mediainfo.year, media_source=mediainfo.media_source, media_id=mediainfo.media_id, - **({"query": query} if query is not None else {}), + **MediaServerQueryOptions.as_kwargs(query), ) if not movies: logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中") @@ -237,7 +242,7 @@ class PlexModule(_MediaServerModuleBase[Plex]): media_source=mediainfo.media_source, media_id=mediainfo.media_id, item_id=itemid, - **({"query": query} if query is not None else {}), + **MediaServerQueryOptions.as_kwargs(query), ) if not tvs: logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中") @@ -291,7 +296,7 @@ class PlexModule(_MediaServerModuleBase[Plex]): """ server_obj = self.get_instance(server) if server_obj: - return server_obj.get_librarys(hidden, **({"query": query} if query is not None else {})) + return server_obj.get_librarys(hidden, **MediaServerQueryOptions.as_kwargs(query)) return None def mediaserver_items( @@ -316,7 +321,7 @@ class PlexModule(_MediaServerModuleBase[Plex]): server_obj = self.get_instance(server) if server_obj: return server_obj.get_items( - library_id, start_index, limit, **({"query": query} if query is not None else {}) + library_id, start_index, limit, **MediaServerQueryOptions.as_kwargs(query) ) return None @@ -346,7 +351,7 @@ class PlexModule(_MediaServerModuleBase[Plex]): """ server_obj = self.get_instance(server) if server_obj: - return server_obj.get_iteminfo(item_id, **({"query": query} if query is not None else {})) + return server_obj.get_iteminfo(item_id, **MediaServerQueryOptions.as_kwargs(query)) return None def mediaserver_tv_episodes( @@ -363,7 +368,7 @@ class PlexModule(_MediaServerModuleBase[Plex]): server_obj = self.get_instance(server) if not server_obj: return None - _, seasoninfo = server_obj.get_tv_episodes(item_id=item_id, **({"query": query} if query is not None else {})) + _, seasoninfo = server_obj.get_tv_episodes(item_id=str(item_id), **MediaServerQueryOptions.as_kwargs(query)) if not seasoninfo: return [] return [ @@ -385,7 +390,7 @@ class PlexModule(_MediaServerModuleBase[Plex]): server_obj = self.get_instance(server) if not server_obj: return None - return server_obj.get_resume(num=count, **({"query": query} if query is not None else {})) + return server_obj.get_resume(num=count, **MediaServerQueryOptions.as_kwargs(query)) def mediaserver_latest( self, @@ -406,7 +411,7 @@ class PlexModule(_MediaServerModuleBase[Plex]): server_obj = self.get_instance(server) if not server_obj: return None - return server_obj.get_latest(num=count, **({"query": query} if query is not None else {})) + return server_obj.get_latest(num=count, **MediaServerQueryOptions.as_kwargs(query)) def mediaserver_latest_images( self, @@ -435,18 +440,20 @@ class PlexModule(_MediaServerModuleBase[Plex]): links = [] items = ( self.mediaserver_latest( - server=server, count=count, username=username, **({"query": query} if query is not None else {}) + server=server, count=count, username=username, **MediaServerQueryOptions.as_kwargs(query) ) or [] ) for item in items: if item.type not in (MediaType.MOVIE.value, MediaType.TV.value): continue + if not item.id: + continue link = server_obj.get_remote_image_by_id( - item_id=item.id, + item_id=str(item.id), image_type="Backdrop", plex_url=False, - **({"query": query} if query is not None else {}), + **MediaServerQueryOptions.as_kwargs(query), ) if link: links.append(link) @@ -463,7 +470,7 @@ class PlexModule(_MediaServerModuleBase[Plex]): server_obj = self.get_instance(server) if not server_obj: return None - return server_obj.get_play_url(item_id) + return server_obj.get_play_url(str(item_id)) def mediaserver_season_episode_ids( self, server: str, item_id: Union[str, int], season: int, *, query: Optional[MediaServerQueryOptions] = None @@ -481,5 +488,5 @@ class PlexModule(_MediaServerModuleBase[Plex]): if not server_obj: return None return server_obj.get_season_episode_ids( - str(item_id), season, **({"query": query} if query is not None else {}) + str(item_id), season, **MediaServerQueryOptions.as_kwargs(query) ) diff --git a/app/modules/plex/plex.py b/app/modules/plex/plex.py index b3a05a92f..2ae623573 100644 --- a/app/modules/plex/plex.py +++ b/app/modules/plex/plex.py @@ -510,11 +510,11 @@ class Plex: title=title, original_title=original_title, year=year, - **({"query": query} if query is not None else {}), + **MediaServerQueryOptions.as_kwargs(query), ) else: videos = self.__search_show( - title=title, original_title=original_title, year=year, **({"query": query} if query is not None else {}) + title=title, original_title=original_title, year=year, **MediaServerQueryOptions.as_kwargs(query) ) if not videos: @@ -527,7 +527,7 @@ class Plex: return None, {} episodes = videos.episodes( **( - {"params": self._query_params(MediaServerQueryOptions(fields=set()))} + {"params": self._query_params(MediaServerQueryOptions(fields=frozenset()))} if query is not None and query.fields is not None else {} ) @@ -559,13 +559,13 @@ class Plex: if not self._plex or not item_id: return {} try: - videos = self.__fetch_item(item_id, **({"query": query} if query is not None else {})) + videos = self.__fetch_item(item_id, **MediaServerQueryOptions.as_kwargs(query)) if not videos: return {} episode_ids: Dict[int, str] = {} for episode in videos.episodes( **( - {"params": self._query_params(MediaServerQueryOptions(fields=set()))} + {"params": self._query_params(MediaServerQueryOptions(fields=frozenset()))} if query is not None and query.fields is not None else {} ) @@ -644,7 +644,7 @@ class Plex: item = self._plex.fetchItem( ekey=ekey, **( - {"params": self._query_params(MediaServerQueryOptions(fields={MediaServerField.IMAGES}))} + {"params": self._query_params(MediaServerQueryOptions(fields=frozenset({MediaServerField.IMAGES})))} if query is not None and query.fields is not None else {} ), @@ -693,7 +693,7 @@ class Plex: image_type=image_type, depth=depth + 1, plex_url=plex_url, - **({"query": query} if query is not None else {}), + **MediaServerQueryOptions.as_kwargs(query), ) return image_url except Exception as e: @@ -820,8 +820,8 @@ class Plex: if not self._plex: return None try: - item = self.__fetch_item(itemid, **({"query": query} if query is not None else {})) - return self.__build_media_server_item(item, **({"query": query} if query is not None else {})) + item = self.__fetch_item(itemid, **MediaServerQueryOptions.as_kwargs(query)) + return self.__build_media_server_item(item, **MediaServerQueryOptions.as_kwargs(query)) except Exception as err: logger.error(f"获取项目详情出错:{str(err)}") return None @@ -1054,7 +1054,7 @@ class Plex: try: media_item = self._retry_timeout( lambda: self.__build_media_server_item( - item, **({"query": query} if query is not None else {}) + item, **MediaServerQueryOptions.as_kwargs(query) ), f"处理媒体项目 {getattr(item, 'key', '')}", ) @@ -1213,7 +1213,7 @@ class Plex: libraries = self.get_librarys( hidden=True, **( - {"query": MediaServerQueryOptions(fields=set())} + {"query": MediaServerQueryOptions(fields=frozenset())} if query is not None and query.fields is not None else {} ), @@ -1271,7 +1271,7 @@ class Plex: libraries = self.get_librarys( hidden=True, **( - {"query": MediaServerQueryOptions(fields=set())} + {"query": MediaServerQueryOptions(fields=frozenset())} if query is not None and query.fields is not None else {} ), diff --git a/app/modules/qqbot/module.py b/app/modules/qqbot/module.py index 3d369e0af..f43a03396 100644 --- a/app/modules/qqbot/module.py +++ b/app/modules/qqbot/module.py @@ -104,7 +104,7 @@ class QQBotModule(_MessageChannelModuleBase[QQBot]): """ return 10 - def _commands_enabled(self, config: Optional[dict]) -> bool: + def _commands_enabled(self, config: Optional[dict[str, Any]]) -> bool: """ QQ 机器人客户端未提供命令注册/删除 API,跳过命令注册,。 避免基类默认钩子调用不存在的 client.register_commands。 @@ -117,11 +117,11 @@ class QQBotModule(_MessageChannelModuleBase[QQBot]): """ return self._stop_service_instances() - def init_setting(self) -> Tuple[str, Union[str, bool]]: + def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]: """ 保留设置初始化钩子,实例配置由通知服务管理。 """ - pass + return None @staticmethod def _send_admin_denied( @@ -150,7 +150,7 @@ class QQBotModule(_MessageChannelModuleBase[QQBot]): client_config = self.get_config(source) if not client_config: return None - client: QQBot = self.get_instance(client_config.name) + client = self.get_instance(client_config.name) try: if isinstance(body, bytes): msg_body = json.loads(body) @@ -235,7 +235,7 @@ class QQBotModule(_MessageChannelModuleBase[QQBot]): @classmethod def _extract_images( - cls, msg_body: dict + cls, msg_body: dict[str, Any] ) -> Optional[List[IncomingMessage.MessageImage]]: """ 提取 QQ 图片附件与兼容图片字段,按引用去重。 @@ -300,7 +300,7 @@ class QQBotModule(_MessageChannelModuleBase[QQBot]): return deduped or None @classmethod - def _extract_audio_refs(cls, msg_body: dict) -> Optional[List[str]]: + def _extract_audio_refs(cls, msg_body: dict[str, Any]) -> Optional[List[str]]: """ 提取音频附件,并生成可回读的 QQ 文件引用。 """ @@ -334,7 +334,7 @@ class QQBotModule(_MessageChannelModuleBase[QQBot]): @classmethod def _extract_files( - cls, msg_body: dict + cls, msg_body: dict[str, Any] ) -> Optional[List[IncomingMessage.MessageAttachment]]: """ 提取图片和音频以外的文件附件,保留文件元数据。 @@ -389,11 +389,11 @@ class QQBotModule(_MessageChannelModuleBase[QQBot]): return None file_url = unquote(file_ref.replace("qq://file/", "", 1)) resp = RequestUtils(timeout=30).get_res(file_url) - if resp and resp.content: + if resp and isinstance(resp.content, bytes) and resp.content: return resp.content return None - def post_message(self, message: Message, **kwargs) -> None: + def post_message(self, message: Message, **kwargs: Any) -> None: """ 按通知路由发送 QQ 消息,并保留原正文作为权限不足时的回退。 """ @@ -409,14 +409,14 @@ class QQBotModule(_MessageChannelModuleBase[QQBot]): if userid: userid = f"group:{userid}" # 无 userid 且无默认配置时,由 client 向曾发过消息的用户/群广播 - client: QQBot = self.get_instance(conf.name) + client = self.get_instance(conf.name) if client: client.send_msg( - title=message.title, + title=message.title or "", text=message.text, image=message.image, link=message.link, - userid=userid, + userid=str(userid) if userid is not None else None, targets=targets, formatted_texts=message.channel_text_chunks(NotificationChannel.QQ), ) @@ -436,11 +436,11 @@ class QQBotModule(_MessageChannelModuleBase[QQBot]): g = targets.get("qq_group_openid") or targets.get("qq_group") if g: userid = f"group:{g}" - client: QQBot = self.get_instance(conf.name) + client = self.get_instance(conf.name) if client: client.send_medias_msg( medias=medias, - userid=userid, + userid=str(userid) if userid is not None else None, title=message.title, link=message.link, targets=targets, @@ -463,11 +463,11 @@ class QQBotModule(_MessageChannelModuleBase[QQBot]): g = targets.get("qq_group_openid") or targets.get("qq_group") if g: userid = f"group:{g}" - client: QQBot = self.get_instance(conf.name) + client = self.get_instance(conf.name) if client: client.send_torrents_msg( torrents=torrents, - userid=userid, + userid=str(userid) if userid is not None else None, title=message.title, link=message.link, targets=targets, diff --git a/app/modules/qqbot/qqbot.py b/app/modules/qqbot/qqbot.py index 2c1a0993a..49b22b9e9 100644 --- a/app/modules/qqbot/qqbot.py +++ b/app/modules/qqbot/qqbot.py @@ -46,7 +46,7 @@ class QQBot: QQ_OPENID: Optional[str] = None, QQ_GROUP_OPENID: Optional[str] = None, name: Optional[str] = None, - **kwargs: object, + **kwargs: Any, ) -> None: """ 保存机器人配置、恢复已互动对象,并启动后台网关线程。 @@ -62,7 +62,7 @@ class QQBot: """ self._gateway_stop = None self._gateway_thread: Optional[threading.Thread] = None - self._gateway_ws_holder: list = [] + self._gateway_ws_holder: list[Any] = [] if not QQ_APP_ID or not QQ_APP_SECRET: logger.error("QQ Bot 配置不完整:缺少 AppID 或 AppSecret") self._ready = False @@ -76,7 +76,7 @@ class QQBot: self._ready = True # 曾发过消息的用户/群,用于无默认接收者时的广播 {(target_id, is_group), ...} - self._known_targets: set = set() + self._known_targets: set[tuple[str, bool]] = set() _safe_name = hashlib.md5(self._config_name.encode()).hexdigest()[:12] self._cache_key = f"__qqbot_known_targets_{_safe_name}__" self._filecache = FileCache() @@ -113,7 +113,7 @@ class QQBot: except Exception as e: logger.debug(f"QQ Bot 保存 known_targets 失败: {e}") - def _forward_to_message_chain(self, payload: dict) -> bool: + def _forward_to_message_chain(self, payload: dict[str, Any]) -> bool: """ 通过受管线程池把 QQ Bot 入站载荷 转交统一消息入口。 """ @@ -123,7 +123,7 @@ class QQBot: submit=ThreadHelper().submit, ) - def _on_gateway_message(self, payload: dict) -> None: + def _on_gateway_message(self, payload: dict[str, Any]) -> None: """ Gateway 收到消息时转发至 MP 消息链,并记录发送者用于广播。 """ @@ -211,7 +211,7 @@ class QQBot: return self._ready def _get_target( - self, userid: Optional[str] = None, targets: Optional[dict] = None + self, userid: Optional[str] = None, targets: Optional[dict[str, Any]] = None ) -> tuple[Optional[str], bool]: """ 按显式接收人、目标字典、默认配置的顺序选择发送目标。 @@ -240,7 +240,7 @@ class QQBot: return None, False - def _get_broadcast_targets(self) -> list: + def _get_broadcast_targets(self) -> list[tuple[str, bool]]: """ 获取广播目标列表(曾发过消息的用户/群)。 """ @@ -353,8 +353,8 @@ class QQBot: image: Optional[str] = None, link: Optional[str] = None, userid: Optional[str] = None, - targets: Optional[dict] = None, - **kwargs, + targets: Optional[dict[str, Any]] = None, + **kwargs: Any, ) -> bool: """ 向指定目标或历史互动对象发送主动通知。 @@ -471,7 +471,7 @@ class QQBot: userid: Optional[str] = None, title: Optional[str] = None, link: Optional[str] = None, - **kwargs, + **kwargs: Any, ) -> bool: """ 将媒体列表转为文本后按主动消息规则发送。 @@ -485,7 +485,10 @@ class QQBot: """ if not medias: return False - lines = [f"{i + 1}. {m.title_year} - {m.type.value}" for i, m in enumerate(medias)] + lines = [ + f"{i + 1}. {m.title_year} - {(m.type.value if m.type else '')}" + for i, m in enumerate(medias) + ] text = "\n".join(lines) return self.send_msg( title=title or "媒体列表", @@ -501,7 +504,7 @@ class QQBot: userid: Optional[str] = None, title: Optional[str] = None, link: Optional[str] = None, - **kwargs, + **kwargs: Any, ) -> bool: """ 将种子列表转为文本后按主动消息规则发送。 @@ -518,7 +521,9 @@ class QQBot: lines = [] for i, ctx in enumerate(torrents): t = ctx.torrent_info - meta = MetaInfo(t.title, t.description) + if t is None: + continue + meta = MetaInfo(str(t.title or ""), t.description) name = f"{meta.season_episode} {meta.resource_term} {meta.video_term}" name = " ".join(name.split()) lines.append(f"{i + 1}.【{t.site_name}】{name} {size_tools.format_compact_size(t.size)} {t.seeders}↑") diff --git a/app/modules/rtorrent/__init__.py b/app/modules/rtorrent/__init__.py index ce06c6054..567425a86 100644 --- a/app/modules/rtorrent/__init__.py +++ b/app/modules/rtorrent/__init__.py @@ -3,7 +3,7 @@ rTorrent 下载模块,负责实例路由、任务投影与下载文件选择 """ from pathlib import Path -from typing import Dict, List, Optional, Set, Tuple, Union +from typing import Dict, List, Optional, Set, Tuple, Union, cast from app.domain.metainfo import MetaInfo from app.foundation import size as size_tools @@ -114,9 +114,15 @@ class RtorrentModule(_DownloaderModuleBase[Rtorrent]): ) if not torrent_from_file and not is_magnet: return None, None, None, "添加种子任务失败:无法读取种子文件" + resolved_content = cast(Union[str, bytes], resolved_content) # 失败提示使用种子名称(磁力链接保留原文),不拼接种子文件的二进制内容 torrent_desc = torrent_from_file.name if torrent_from_file else resolved_content + torrent_desc_text = ( + torrent_desc.decode(errors="replace") + if isinstance(torrent_desc, bytes) + else str(torrent_desc) + ) server = self.get_instance(downloader) if not server: @@ -157,7 +163,7 @@ class RtorrentModule(_DownloaderModuleBase[Rtorrent]): ) and torrent.get("total_size") == getattr( torrent_from_file, "total_size", 0 ): - torrent_hash = torrent.get("hash") + torrent_hash = cast(str, torrent.get("hash")) torrent_tags = [ str(t).strip() for t in torrent.get("tags", "").split(",") @@ -189,17 +195,18 @@ class RtorrentModule(_DownloaderModuleBase[Rtorrent]): finally: torrents.clear() del torrents - return None, None, None, f"添加种子任务失败:{torrent_desc}" + return None, None, None, f"添加种子任务失败:{torrent_desc_text}" else: - torrent_hash = server.get_torrent_id_by_tag(tags=tag) - if not torrent_hash: + added_torrent_hash = server.get_torrent_id_by_tag(tags=tag) + if not added_torrent_hash: return ( None, None, None, - f"下载任务添加成功,但获取rTorrent任务信息失败:{torrent_desc}", + f"下载任务添加成功,但获取rTorrent任务信息失败:{torrent_desc_text}", ) else: + torrent_hash = added_torrent_hash if episodes: torrent_files = server.get_files(torrent_hash) if not torrent_files: @@ -301,7 +308,7 @@ class RtorrentModule(_DownloaderModuleBase[Rtorrent]): hash=torrent_data.get("hash"), title=torrent_data.get("name"), name=meta.name, - year=meta.year, + year=str(meta.year) if meta.year is not None else None, season_episode=meta.season_episode, path=Path(self.normalize_return_path(torrent_path, downloader_name)), save_path=self.normalize_return_path( @@ -414,13 +421,11 @@ class RtorrentModule(_DownloaderModuleBase[Rtorrent]): server = self.get_instance(downloader) if not server: return None - org_tags = server.get_torrent_tags(ids=hashs) - if org_tags: - tags = org_tags + ["已整理"] - else: - tags = ["已整理"] - # 直接设置完整标签(覆盖) - server.set_torrents_tag(ids=hashs, tags=tags, overwrite=True) + for torrent_hash in [hashs] if isinstance(hashs, str) else hashs: + org_tags = server.get_torrent_tags(ids=torrent_hash) + tags = org_tags + ["已整理"] if org_tags else ["已整理"] + # 每个任务独立保留原标签,避免批量整理时覆盖其他任务的标签。 + server.set_torrents_tag(ids=torrent_hash, tags=tags, overwrite=True) return None def remove_torrents( @@ -440,7 +445,10 @@ class RtorrentModule(_DownloaderModuleBase[Rtorrent]): server = self.get_instance(downloader) if not server: return None - return server.delete_torrents(delete_file=delete_file, ids=hashs) + return server.delete_torrents( + delete_file=delete_file if delete_file is not None else True, + ids=hashs, + ) def set_torrents_tag( self, hashs: Union[str, list[str]], tags: list[str], @@ -565,24 +573,29 @@ class RtorrentModule(_DownloaderModuleBase[Rtorrent]): :return: 各可用实例的统计列表,速度单位为字节/秒、传输量为字节; 指定实例不可用时返回 None,统计查询失败的实例被跳过 """ + servers: list[Rtorrent] if downloader: - server: Rtorrent = self.get_instance(downloader) + server = self.get_instance(downloader) if not server: return None servers = [server] else: - servers = self.get_instances().values() + servers = list(self.get_instances().values()) ret_info = [] for server in servers: info = server.transfer_info() if not info: continue + download_speed = info.get("dl_info_speed") + upload_speed = info.get("up_info_speed") + download_size = info.get("dl_info_data") + upload_size = info.get("up_info_data") ret_info.append( _SchemaDownloaderInfo( - download_speed=info.get("dl_info_speed"), - upload_speed=info.get("up_info_speed"), - download_size=info.get("dl_info_data"), - upload_size=info.get("up_info_data"), + download_speed=float(download_speed) if isinstance(download_speed, (int, float)) else None, + upload_speed=float(upload_speed) if isinstance(upload_speed, (int, float)) else None, + download_size=float(download_size) if isinstance(download_size, (int, float)) else None, + upload_size=float(upload_size) if isinstance(upload_size, (int, float)) else None, ) ) return ret_info diff --git a/app/modules/slack/__init__.py b/app/modules/slack/__init__.py index 2077944f0..36888f7f3 100644 --- a/app/modules/slack/__init__.py +++ b/app/modules/slack/__init__.py @@ -4,7 +4,7 @@ Slack 通知路由、入站消息解析、附件下载与处理状态反馈。 import json import re -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple, Union, cast from urllib.parse import quote, unquote from app.application.messaging.channel.admin import ( @@ -155,10 +155,12 @@ class SlackModule(_MessageChannelModuleBase[Slack]): audio_refs = self._extract_audio_refs(msg_json) files = self._extract_files(msg_json) elif msg_json.get("type") == "block_actions": - userid = msg_json.get("user", {}).get("id") - callback_data = msg_json.get("actions")[0].get("value") + user_data = cast(dict[str, Any], msg_json.get("user", {})) + actions = cast(list[dict[str, Any]], msg_json.get("actions", [])) + userid = user_data.get("id") + callback_data = actions[0].get("value") text = f"CALLBACK:{callback_data}" - username = msg_json.get("user", {}).get("name") + username = user_data.get("name") if str(callback_data).strip().startswith("/") and self._should_reject_admin_command( client_config.config, userid ): @@ -168,9 +170,9 @@ class SlackModule(_MessageChannelModuleBase[Slack]): message_info = msg_json.get("message", {}) # Slack消息的时间戳作为消息ID message_ts = message_info.get("ts") - channel_id = msg_json.get("channel", {}).get("id") or msg_json.get( - "container", {} - ).get("channel_id") + channel_data = cast(dict[str, Any], msg_json.get("channel", {})) + container_data = cast(dict[str, Any], msg_json.get("container", {})) + channel_id = channel_data.get("id") or container_data.get("channel_id") logger.info( f"收到来自 {client_config.name} 的Slack按钮回调:" @@ -253,7 +255,7 @@ class SlackModule(_MessageChannelModuleBase[Slack]): @staticmethod def _extract_images( - msg_json: dict, + msg_json: dict[str, Any], ) -> Optional[List[IncomingMessage.MessageImage]]: """ 从Slack消息中提取图片URL。 @@ -284,7 +286,7 @@ class SlackModule(_MessageChannelModuleBase[Slack]): return images if images else None @classmethod - def _extract_audio_refs(cls, msg_json: dict) -> Optional[List[str]]: + def _extract_audio_refs(cls, msg_json: dict[str, Any]) -> Optional[List[str]]: """ 从Slack消息中提取音频文件引用。 """ @@ -308,7 +310,7 @@ class SlackModule(_MessageChannelModuleBase[Slack]): @classmethod def _extract_files( - cls, msg_json: dict + cls, msg_json: dict[str, Any] ) -> Optional[List[IncomingMessage.MessageAttachment]]: """ 从 Slack 消息中提取非图片/非音频文件。 @@ -414,24 +416,33 @@ class SlackModule(_MessageChannelModuleBase[Slack]): return client = self.get_instance(conf.name) if client: + userid_text = str(userid) if userid is not None else None if message.file_path: client.send_file( file_path=message.file_path, file_name=message.file_name, title=message.title, text=message.text, - userid=userid, + userid=userid_text, ) else: client.send_msg( title=message.title, text=message.text, image=message.image, - userid=userid, + userid=userid_text, link=message.link, buttons=message.buttons, - original_message_id=message.original_message_id, - original_chat_id=message.original_chat_id, + original_message_id=( + str(message.original_message_id) + if message.original_message_id is not None + else None + ), + original_chat_id=( + str(message.original_chat_id) + if message.original_chat_id is not None + else None + ), formatted_text=message.channel_text(NotificationChannel.Slack), ) @@ -456,8 +467,16 @@ class SlackModule(_MessageChannelModuleBase[Slack]): medias=medias, userid=self.get_notification_userid(message, "slack_userid"), buttons=message.buttons, - original_message_id=message.original_message_id, - original_chat_id=message.original_chat_id, + original_message_id=( + str(message.original_message_id) + if message.original_message_id is not None + else None + ), + original_chat_id=( + str(message.original_chat_id) + if message.original_chat_id is not None + else None + ), ) def post_torrents_message( @@ -481,8 +500,16 @@ class SlackModule(_MessageChannelModuleBase[Slack]): torrents=torrents, userid=self.get_notification_userid(message, "slack_userid"), buttons=message.buttons, - original_message_id=message.original_message_id, - original_chat_id=message.original_chat_id, + original_message_id=( + str(message.original_message_id) + if message.original_message_id is not None + else None + ), + original_chat_id=( + str(message.original_chat_id) + if message.original_chat_id is not None + else None + ), ) def delete_message( @@ -522,8 +549,8 @@ class SlackModule(_MessageChannelModuleBase[Slack]): chat_id: Union[str, int], text: str, title: Optional[str] = None, - buttons: Optional[List[List[dict]]] = None, - metadata: Optional[dict] = None, + buttons: Optional[List[List[dict[str, Any]]]] = None, + metadata: Optional[dict[str, Any]] = None, ) -> Optional[bool]: """ 编辑消息。 @@ -564,7 +591,7 @@ class SlackModule(_MessageChannelModuleBase[Slack]): message_id: Optional[Union[str, int]] = None, chat_id: Optional[Union[str, int]] = None, text: Optional[str] = None, - ) -> Optional[dict]: + ) -> Optional[dict[str, Any]]: """ 给入站消息添加正在处理的表情反应。 @@ -611,7 +638,7 @@ class SlackModule(_MessageChannelModuleBase[Slack]): userid: Optional[Union[str, int]] = None, message_id: Optional[Union[str, int]] = None, chat_id: Optional[Union[str, int]] = None, - status: Optional[dict] = None, + status: Optional[dict[str, Any]] = None, ) -> Optional[bool]: """ 清除入站消息的正在处理表情反应。 @@ -665,19 +692,20 @@ class SlackModule(_MessageChannelModuleBase[Slack]): return None client = self.get_instance(conf.name) if client: + userid_text = str(userid) if userid is not None else None if message.file_path: result = client.send_file( file_path=message.file_path, file_name=message.file_name, title=message.title, text=message.text, - userid=userid, + userid=userid_text, ) else: result = client.send_msg( title=message.title or "", text=message.text, - userid=userid, + userid=userid_text, formatted_text=message.channel_text(NotificationChannel.Slack), ) if result and result[0]: diff --git a/app/modules/slack/slack.py b/app/modules/slack/slack.py index 7c9478e27..3b04b705a 100644 --- a/app/modules/slack/slack.py +++ b/app/modules/slack/slack.py @@ -6,7 +6,7 @@ import json import re from pathlib import Path from threading import Lock -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple, cast from slack_bolt import App from slack_bolt.adapter.socket_mode import SocketModeHandler @@ -28,8 +28,8 @@ class Slack: Slack 通知与交互客户端。 """ - _client: WebClient = None - _service: SocketModeHandler = None + _client: Optional[WebClient] = None + _service: Optional[SocketModeHandler] = None _channel = "" _oauth_token = "" # Slack 应用命令清单的最大命令数。 @@ -45,7 +45,7 @@ class Slack: SLACK_APP_ID: Optional[str] = None, SLACK_APP_CONFIG_TOKEN: Optional[str] = None, SLACK_COMMAND_REQUEST_URL: Optional[str] = None, - **kwargs): + **kwargs: Any) -> None: """ 创建 Slack SDK 客户端,注册入站回调并连接 Socket Mode。 @@ -73,7 +73,7 @@ class Slack: return self._client = slack_app.client - self._channel = SLACK_CHANNEL + self._channel = SLACK_CHANNEL or "" self._oauth_token = SLACK_OAUTH_TOKEN self._config_name = kwargs.get("name") self._app_id = (SLACK_APP_ID or "").strip() @@ -86,14 +86,14 @@ class Slack: self._registered_command_names: set[str] = set() @slack_app.event("message") - def slack_message(message): + def slack_message(message: dict[str, Any]) -> None: """ 将普通 Slack 消息转交消息链。 """ self._forward_to_message_chain(message, timeout=10) @slack_app.action(re.compile(r"actionId-.*")) - def slack_action(ack, body): + def slack_action(ack: Callable[[], Any], body: dict[str, Any]) -> None: """ 确认交互按钮事件,并转交消息链处理。 """ @@ -101,7 +101,7 @@ class Slack: self._forward_to_message_chain(body, timeout=60) @slack_app.event("app_mention") - def slack_mention(say, body): + def slack_mention(say: Callable[[str], Any], body: dict[str, Any]) -> None: """ 回应应用提及事件,并转交消息链处理。 """ @@ -109,7 +109,7 @@ class Slack: self._forward_to_message_chain(body, timeout=10) @slack_app.shortcut(re.compile(r"/*")) - def slack_shortcut(ack, body): + def slack_shortcut(ack: Callable[[], Any], body: dict[str, Any]) -> None: """ 确认快捷操作事件,并转交消息链处理。 """ @@ -117,7 +117,7 @@ class Slack: self._forward_to_message_chain(body, timeout=10) @slack_app.command(re.compile(r"/*")) - def slack_command(ack, body): + def slack_command(ack: Callable[[], Any], body: dict[str, Any]) -> None: """ 确认斜杠命令事件,并转交消息链处理。 """ @@ -129,12 +129,12 @@ class Slack: slack_app, SLACK_APP_TOKEN ) - self._service.connect() + cast(Callable[[], None], self._service.connect)() logger.info("Slack消息接收服务启动") except Exception as err: logger.error("Slack消息接收服务启动失败: %s" % str(err)) - def _forward_to_message_chain(self, payload: dict, *, timeout: float) -> bool: + def _forward_to_message_chain(self, payload: dict[str, Any], *, timeout: float) -> bool: """ 把 Slack SDK 回调同步转交统一消息入口。 """ @@ -150,7 +150,7 @@ class Slack: """ if self._service: try: - self._service.close() + cast(Callable[[], None], self._service.close)() logger.info("Slack消息接收服务已停止") except Exception as err: logger.error("Slack消息接收服务停止失败: %s" % str(err)) @@ -165,7 +165,7 @@ class Slack: """ return True if self._client else False - def register_commands(self, commands: Dict[str, dict]) -> bool: + def register_commands(self, commands: Dict[str, dict[str, Any]]) -> bool: """ 通过 Slack App Manifest 注册 Slash Commands。 @@ -188,10 +188,12 @@ class Slack: return False return self._update_manifest_commands({}) - def _update_manifest_commands(self, commands: Dict[str, dict]) -> bool: + def _update_manifest_commands(self, commands: Dict[str, dict[str, Any]]) -> bool: """ 更新 Slack Manifest 中的 Slash Commands,保留非本实例管理的命令。 """ + if self._manifest_client is None: + return False try: manifest = self._export_manifest() if not manifest: @@ -239,6 +241,8 @@ class Slack: """ 导出 Slack App Manifest。 """ + if self._manifest_client is None: + return None result = self._manifest_client.apps_manifest_export(app_id=self._app_id) if result and result.get("ok") is False: logger.error(f"Slack Manifest 导出失败:{result.get('error')}") @@ -248,7 +252,7 @@ class Slack: manifest = json.loads(manifest) return manifest if isinstance(manifest, dict) else None - def _build_slash_commands(self, commands: Dict[str, dict]) -> List[Dict[str, Any]]: + def _build_slash_commands(self, commands: Dict[str, dict[str, Any]]) -> List[Dict[str, Any]]: """ 构建 Slack Manifest Slash Commands 配置。 """ @@ -323,10 +327,10 @@ class Slack: def send_msg(self, title: Optional[str], text: Optional[str] = None, image: Optional[str] = None, link: Optional[str] = None, - userid: Optional[str] = None, buttons: Optional[List[List[dict]]] = None, + userid: Optional[str] = None, buttons: Optional[List[List[dict[str, Any]]]] = None, original_message_id: Optional[str] = None, original_chat_id: Optional[str] = None, - formatted_text: Optional[str] = None): + formatted_text: Optional[str] = None) -> Tuple[bool, Any]: """ 发送或编辑 Slack 通知,必要时分批提交格式化内容。 @@ -343,6 +347,8 @@ class Slack: :param formatted_text: 可选 Slack mrkdwn 正文,按段落构建内容块 :return: 成功时返回 (True, 最后一批 SDK 响应),失败返回 (False, 错误文本) """ + elements: list[dict[str, Any]] + blocks: list[dict[str, Any]] if not self._client: return False, "消息客户端未就绪" if not title and not text: @@ -427,6 +433,7 @@ class Slack: """ 选择渠道正文或原有图文结构,按钮仍由发送入口统一添加。 """ + blocks: list[dict[str, Any]] blocks = cls._formatted_blocks(formatted_text) if formatted_text else [] if blocks: blocks.insert(0, {"type": "header", "text": { @@ -485,7 +492,7 @@ class Slack: text: Optional[str] = None, userid: Optional[str] = None, file_name: Optional[str] = None, - ): + ) -> Tuple[bool, Any]: """ 上传本地文件,并附带标题和正文。 @@ -572,7 +579,7 @@ class Slack: return False def send_medias_msg(self, medias: List[MediaInfo], userid: Optional[str] = None, title: Optional[str] = None, - buttons: Optional[List[List[dict]]] = None, + buttons: Optional[List[List[dict[str, Any]]]] = None, original_message_id: Optional[str] = None, original_chat_id: Optional[str] = None) -> Optional[bool]: """ @@ -586,6 +593,9 @@ class Slack: :param original_chat_id: 原消息所属频道标识 :return: 收到有效 SDK 响应时返回 True;发送失败返回 False;空媒体列表不发送 """ + elements: list[dict[str, Any]] + blocks: list[dict[str, Any]] + title_section: dict[str, Any] if not self._client: return False if not medias: @@ -615,12 +625,12 @@ class Slack: if media.get_poster_image(): if media.vote_star: text = f"{index}. *<{media.detail_link}|{media.title_year}>*" \ - f"\n类型:{media.type.value}" \ + f"\n类型:{media.type.value if media.type else ""}" \ f"\n{media.vote_star}" \ f"\n{media.get_overview_string(50)}" else: text = f"{index}. *<{media.detail_link}|{media.title_year}>*" \ - f"\n类型:{media.type.value}" \ + f"\n类型:{media.type.value if media.type else ""}" \ f"\n{media.get_overview_string(50)}" blocks.append( { @@ -673,12 +683,12 @@ class Slack: if media.get_poster_image(): if media.vote_star: text = f"{index}. *<{media.detail_link}|{media.title_year}>*" \ - f"\n类型:{media.type.value}" \ + f"\n类型:{media.type.value if media.type else ""}" \ f"\n{media.vote_star}" \ f"\n{media.get_overview_string(50)}" else: text = f"{index}. *<{media.detail_link}|{media.title_year}>*" \ - f"\n类型:{media.type.value}" \ + f"\n类型:{media.type.value if media.type else ""}" \ f"\n{media.get_overview_string(50)}" blocks.append( { @@ -732,7 +742,7 @@ class Slack: return False def send_torrents_msg(self, torrents: List[Context], userid: Optional[str] = None, title: Optional[str] = None, - buttons: Optional[List[List[dict]]] = None, + buttons: Optional[List[List[dict[str, Any]]]] = None, original_message_id: Optional[str] = None, original_chat_id: Optional[str] = None) -> Optional[bool]: """ @@ -746,6 +756,9 @@ class Slack: :param original_chat_id: 原消息所属频道标识 :return: 收到有效 SDK 响应时返回 True;发送失败返回 False;客户端未就绪返回 None """ + elements: list[dict[str, Any]] + blocks: list[dict[str, Any]] + title_section: dict[str, Any] if not self._client: return None @@ -770,8 +783,10 @@ class Slack: if buttons: for context in torrents: torrent = context.torrent_info + if not torrent: + continue site_name = torrent.site_name - meta = MetaInfo(torrent.title, torrent.description) + meta = MetaInfo(torrent.title or "", torrent.description) link = torrent.page_url title_text = f"{meta.season_episode} " \ f"{meta.resource_term} " \ @@ -828,8 +843,10 @@ class Slack: else: for context in torrents: torrent = context.torrent_info + if not torrent: + continue site_name = torrent.site_name - meta = MetaInfo(torrent.title, torrent.description) + meta = MetaInfo(torrent.title or "", torrent.description) link = torrent.page_url title_text = f"{meta.season_episode} " \ f"{meta.resource_term} " \ @@ -924,7 +941,7 @@ class Slack: logger.error(f"删除Slack消息异常: {str(e)}") return False - def __find_public_channel(self): + def __find_public_channel(self) -> str: """ 在可见公共及私有频道中按名称查找默认接收频道。 @@ -939,7 +956,7 @@ class Slack: break for channel in result["channels"]: if channel.get("name") == (self._channel or "全体"): - conversation_id = channel.get("id") + conversation_id = str(channel.get("id") or "") break except Exception as e: logger.error(f"查找Slack公共频道失败: {str(e)}") diff --git a/app/modules/subtitle/__init__.py b/app/modules/subtitle/__init__.py index 5c00f74e7..612a4a126 100644 --- a/app/modules/subtitle/__init__.py +++ b/app/modules/subtitle/__init__.py @@ -166,12 +166,13 @@ class SubtitleModule(_ModuleBase): :return: 下载链接列表;页面为空时返回空列表,缺少地址、访问失败或站点使用独立接口时返回 None """ torrent = context.torrent_info - if not torrent.page_url: + if torrent is None or not torrent.page_url: return None + page_url = torrent.page_url # 采用API访问的站点由对应爬虫模块处理,详情页HTML不含字幕元素 if torrent.site is not None: site = get_configured_site_query_service().get_sync(torrent.site) - if site and (indexer := SitesHelper().get_indexer(site.domain)): + if site and site.domain and (indexer := SitesHelper().get_indexer(site.domain)): if indexer.get("parser") == "mTorrent": return None request = RequestUtils( @@ -179,19 +180,19 @@ class SubtitleModule(_ModuleBase): ua=torrent.site_ua, proxies=get_runtime_setting('PROXY') if torrent.site_proxy else None, ) - res = request.get_res(torrent.page_url) + res = request.get_res(page_url) if res and res.status_code == 200: if not res.text: - logger.warn(f"读取页面代码失败:{torrent.page_url}") + logger.warn(f"读取页面代码失败:{page_url}") return [] html = etree.HTML(res.text) try: - return self._parse_subtitle_links(html, torrent.page_url) + return self._parse_subtitle_links(html, page_url) finally: if html is not None: del html elif res is not None: - logger.warn(f"连接 {torrent.page_url} 失败,状态码:{res.status_code}") + logger.warn(f"连接 {page_url} 失败,状态码:{res.status_code}") else: - logger.warn(f"无法打开链接:{torrent.page_url}") + logger.warn(f"无法打开链接:{page_url}") return None diff --git a/app/modules/synologychat/__init__.py b/app/modules/synologychat/__init__.py index b4a87f727..2189fa4a9 100644 --- a/app/modules/synologychat/__init__.py +++ b/app/modules/synologychat/__init__.py @@ -175,7 +175,8 @@ class SynologyChatModule(_MessageChannelModuleBase[SynologyChat]): f"files={len(files) if files else 0}" ) return IncomingMessage(channel=NotificationChannel.SynologyChat, source=client_config.name, - userid=user_id, username=user_name, + userid=user_id, + username=str(user_name) if user_name is not None else None, is_channel_admin=matches_channel_admin( NotificationChannel.SynologyChat, client_config.config, diff --git a/app/modules/synologychat/synologychat.py b/app/modules/synologychat/synologychat.py index c7b39fac4..6d197dcbe 100644 --- a/app/modules/synologychat/synologychat.py +++ b/app/modules/synologychat/synologychat.py @@ -144,14 +144,14 @@ class SynologyChat: index, media.detail_link, media.title_year, - f"类型:{media.type.value}", + f"类型:{media.type.value if media.type else ''}", f"评分:{media.vote_average}") else: caption = "%s\n%s. <%s|%s>\n_%s_" % (caption, index, media.detail_link, media.title_year, - f"类型:{media.type.value}") + f"类型:{media.type.value if media.type else ''}") index += 1 userids: List[object] @@ -190,8 +190,10 @@ class SynologyChat: index, caption = 1, "*%s*" % title for context in torrents: torrent = context.torrent_info - site_name = torrent.site_name - meta = MetaInfo(torrent.title, torrent.description) + if torrent is None: + continue + site_name = torrent.site_name or "" + meta = MetaInfo(torrent.title or "", torrent.description) link = torrent.page_url title = f"{meta.season_episode} " \ f"{meta.resource_term} " \ diff --git a/app/modules/telegram/module.py b/app/modules/telegram/module.py index 661b77f3c..ba5b1f155 100644 --- a/app/modules/telegram/module.py +++ b/app/modules/telegram/module.py @@ -112,7 +112,9 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]): client_config = self.get_config(source) if not client_config: return None - client: Telegram = self.get_instance(client_config.name) + client = self.get_instance(client_config.name) + if client is None: + return None try: message = json.loads(body) while isinstance(message, str): @@ -139,7 +141,7 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]): return None def _handle_callback_query( - self, message: dict, client_config: NotificationConf, client: Telegram + self, message: dict[str, Any], client_config: NotificationConf, client: Telegram ) -> Optional[IncomingMessage]: """ 提取按钮回调并校验命令型回调的管理员身份。 @@ -197,7 +199,7 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]): return None def _handle_text_message( - self, msg: dict, client_config: NotificationConf, client: Telegram + self, msg: dict[str, Any], client_config: NotificationConf, client: Telegram ) -> Optional[IncomingMessage]: """ 提取文本与附件,去除机器人提及并应用访问规则。 @@ -247,7 +249,7 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]): else None ) - user_list = client_config.config.get("TELEGRAM_USERS") + user_list = (client_config.config or {}).get("TELEGRAM_USERS") if cleaned_text and cleaned_text.startswith("/"): if self._should_reject_admin_command(client_config.config, user_id): @@ -284,7 +286,7 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]): return None @staticmethod - def _extract_images(msg: dict) -> Optional[List[IncomingMessage.MessageImage]]: + def _extract_images(msg: dict[str, Any]) -> Optional[List[IncomingMessage.MessageImage]]: """ 从Telegram消息中提取图片file_id。 """ @@ -319,7 +321,7 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]): return images if images else None @staticmethod - def _extract_audio_refs(msg: dict) -> Optional[List[str]]: + def _extract_audio_refs(msg: dict[str, Any]) -> Optional[List[str]]: """ 从Telegram消息中提取语音/音频 file_id。 """ @@ -339,7 +341,7 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]): return audio_refs if audio_refs else None @staticmethod - def _extract_files(msg: dict) -> Optional[List[IncomingMessage.MessageAttachment]]: + def _extract_files(msg: dict[str, Any]) -> Optional[List[IncomingMessage.MessageAttachment]]: """ 从 Telegram 消息中提取非图片文件附件。 """ @@ -362,7 +364,7 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]): ] @staticmethod - def _embed_entity_links(text: str, entities: Optional[List[dict]]) -> str: + def _embed_entity_links(text: str, entities: Optional[List[dict[str, Any]]]) -> str: """ 将 text_link 实体中的 URL 嵌入到文本中。 @@ -390,7 +392,7 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]): return text @staticmethod - def _append_reply_markup_links(text: Optional[str], reply_markup: Optional[dict]) -> Optional[str]: + def _append_reply_markup_links(text: Optional[str], reply_markup: Optional[dict[str, Any]]) -> Optional[str]: """ 将 reply_markup 中的 URL 按钮信息追加到文本末尾。 @@ -486,14 +488,14 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]): file_name=message.file_name, title=message.title, text=message.text, - userid=userid, + userid=str(userid) if userid is not None else None, original_chat_id=message.original_chat_id, parse_mode=message.parse_mode, ) elif message.voice_path: result = client.send_voice( voice_path=message.voice_path, - userid=userid, + userid=str(userid) if userid is not None else None, caption=message.voice_caption, original_chat_id=message.original_chat_id, parse_mode=message.parse_mode, @@ -509,11 +511,11 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]): title=message.title, text=message.text, image=message.image, - userid=userid, + userid=str(userid) if userid is not None else None, link=message.link, buttons=message.buttons, force_reply=message.force_reply, - original_message_id=original_message_id, + original_message_id=int(original_message_id) if original_message_id else None, original_chat_id=message.original_chat_id, disable_web_page_preview=message.disable_web_page_preview, parse_mode=message.parse_mode, @@ -544,7 +546,7 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]): userid=self.get_notification_userid(message, "telegram_userid"), link=message.link, buttons=message.buttons, - original_message_id=message.original_message_id, + original_message_id=int(message.original_message_id) if message.original_message_id else None, original_chat_id=message.original_chat_id, parse_mode=message.parse_mode, ) @@ -569,7 +571,7 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]): userid=self.get_notification_userid(message, "telegram_userid"), link=message.link, buttons=message.buttons, - original_message_id=message.original_message_id, + original_message_id=int(message.original_message_id) if message.original_message_id else None, original_chat_id=message.original_chat_id, parse_mode=message.parse_mode, ) @@ -611,8 +613,8 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]): chat_id: Union[str, int], text: str, title: Optional[str] = None, - buttons: Optional[List[List[dict]]] = None, - metadata: Optional[dict] = None, + buttons: Optional[List[List[dict[str, Any]]]] = None, + metadata: Optional[dict[str, Any]] = None, parse_mode: Optional[str] = None, ) -> Optional[bool]: """ @@ -657,7 +659,7 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]): message_id: Optional[Union[str, int]] = None, chat_id: Optional[Union[str, int]] = None, text: Optional[str] = None, - ) -> Optional[dict]: + ) -> Optional[dict[str, Any]]: """ 启动定期续发的“正在输入”状态任务。 @@ -696,7 +698,7 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]): userid: Optional[Union[str, int]] = None, message_id: Optional[Union[str, int]] = None, chat_id: Optional[Union[str, int]] = None, - status: Optional[dict] = None, + status: Optional[dict[str, Any]] = None, ) -> Optional[bool]: """ 请求停止此前启动的输入状态任务。 @@ -744,7 +746,7 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]): if message.voice_path: result = client.send_voice( voice_path=message.voice_path, - userid=userid, + userid=str(userid) if userid is not None else None, caption=message.voice_caption, original_chat_id=message.original_chat_id, parse_mode=message.parse_mode, @@ -758,10 +760,10 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]): title=message.title, text=message.text, image=message.image, - userid=userid, + userid=str(userid) if userid is not None else None, link=message.link, force_reply=message.force_reply, - original_message_id=original_message_id, + original_message_id=int(original_message_id) if original_message_id else None, original_chat_id=original_chat_id, disable_web_page_preview=message.disable_web_page_preview, parse_mode=message.parse_mode, diff --git a/app/modules/telegram/telegram.py b/app/modules/telegram/telegram.py index 7e067f594..72be309ee 100644 --- a/app/modules/telegram/telegram.py +++ b/app/modules/telegram/telegram.py @@ -8,7 +8,7 @@ import re import threading import time from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Union +from typing import Any, Callable, Dict, List, Optional, Union, cast from telegramify_markdown import richify, split_rich, standardize, telegramify @@ -96,7 +96,7 @@ class Telegram: _bot: Optional[TelegramBotApi] = None # 按回调命令保存自定义处理器 - _callback_handlers: Dict[str, Callable] = {} + _callback_handlers: Dict[str, Callable[..., Any]] = {} # 用于群聊提及识别的机器人用户名 _bot_username: Optional[str] = None # 输入状态续发、首发延迟及各场景最长展示时间,单位均为秒 @@ -116,8 +116,8 @@ class Telegram: TELEGRAM_TOKEN: Optional[str] = None, TELEGRAM_CHAT_ID: Optional[str] = None, TELEGRAM_TOPIC_ID: Optional[str] = None, - **kwargs, - ): + **kwargs: Any, + ) -> None: """ 创建独立机器人客户端并启动长轮询接收线程。 @@ -441,14 +441,14 @@ class Telegram: logger.error(f"处理按钮回调失败:{str(err)}") self._api.answer_callback_query(call["id"], "处理失败,请重试") - def _forward_to_message_chain(self, payload: dict) -> bool: + def _forward_to_message_chain(self, payload: dict[str, Any]) -> bool: """ 把 Telegram SDK 回调同步转交统一消息入口。 """ return forward_message_to_host(payload, self._config_name) @property - def bot(self): + def bot(self) -> Optional[TelegramBotApi]: """ 获取Telegram Bot实例。 @@ -490,7 +490,7 @@ class Telegram: file_path, len(resp.content), ) - return resp.content + return cast(bytes, resp.content) logger.warn( "Telegram文件下载失败: file_id=%s, file_path=%s, proxy_enabled=%s", file_id, @@ -507,8 +507,8 @@ class Telegram: 将 telegramify 文本片段转换为 Telegram MarkdownV2 字符串。 """ if entities_to_markdownv2: - return entities_to_markdownv2(item.text, item.entities) - return standardize(item.text) + return cast(str, entities_to_markdownv2(item.text, item.entities)) + return cast(str, standardize(item.text)) @staticmethod def _telegramify_item_caption(item: Text | File | Photo) -> str: @@ -518,8 +518,8 @@ class Telegram: if isinstance(item, Text): return Telegram._telegramify_item_text(item) if entities_to_markdownv2: - return entities_to_markdownv2(item.caption_text, item.caption_entities) - return standardize(item.caption_text) + return cast(str, entities_to_markdownv2(item.caption_text, item.caption_entities)) + return cast(str, standardize(item.caption_text)) @staticmethod def _normalize_parse_mode(parse_mode: Optional[str] = None) -> str: @@ -606,7 +606,7 @@ class Telegram: or Telegram._is_html_parse_mode(parse_mode) ): return text - return standardize(text) + return cast(str, standardize(text)) @staticmethod def _split_plain_text(text: str, limit: int) -> List[str]: @@ -892,7 +892,7 @@ class Telegram: image: Optional[str] = None, userid: Optional[str] = None, link: Optional[str] = None, - buttons: Optional[List[List[dict]]] = None, + buttons: Optional[List[List[dict[str, Any]]]] = None, force_reply: bool = False, original_message_id: Optional[int] = None, original_chat_id: Optional[str] = None, @@ -1252,7 +1252,7 @@ class Telegram: # 如果映射中没有,回退到使用userid作为聊天ID (私聊场景) return userid - return self._telegram_chat_id + return self._telegram_chat_id or "" def _topic_kwargs(self, chat_id: Optional[Union[str, int]]) -> dict[str, int]: """ @@ -1269,12 +1269,12 @@ class Telegram: userid: Optional[str] = None, title: Optional[str] = None, link: Optional[str] = None, - buttons: Optional[List[List[Dict]]] = None, + buttons: Optional[List[List[dict[str, Any]]]] = None, original_message_id: Optional[int] = None, original_chat_id: Optional[str] = None, stop_typing: bool = False, parse_mode: Optional[str] = None, - ) -> Optional[bool]: + ) -> Optional[Union[bool, JsonDict]]: """ 发送或编辑媒体候选列表。 @@ -1304,7 +1304,7 @@ class Telegram: media_link = self._format_link( media.title_year, media.detail_link, parse_mode ) - type_text = f"类型:{media.type.value}" + type_text = f"类型:{media.type.value if media.type else ""}" if media.vote_average: score_text = f"评分:{media.vote_average}" caption = ( @@ -1355,12 +1355,12 @@ class Telegram: userid: Optional[str] = None, title: Optional[str] = None, link: Optional[str] = None, - buttons: Optional[List[List[Dict]]] = None, + buttons: Optional[List[List[dict[str, Any]]]] = None, original_message_id: Optional[int] = None, original_chat_id: Optional[str] = None, stop_typing: bool = False, parse_mode: Optional[str] = None, - ) -> Optional[bool]: + ) -> Optional[Union[bool, JsonDict]]: """ 发送或编辑种子候选列表。 @@ -1384,12 +1384,17 @@ class Telegram: try: index = 1 caption = self._format_title(title, parse_mode) or "" - image = torrents[0].media_info.get_message_image() + if not torrents: + return False + first_media = torrents[0].media_info + image = first_media.get_message_image() if first_media else None for context in torrents: torrent = context.torrent_info - site_name = torrent.site_name - meta = MetaInfo(torrent.title, torrent.description) - link = torrent.page_url + if not torrent: + continue + site_name = torrent.site_name or "" + meta = MetaInfo(torrent.title or "", torrent.description) + link = torrent.page_url or "" title = ( f"{meta.season_episode} " f"{meta.resource_term} " @@ -1444,7 +1449,7 @@ class Telegram: self._stop_typing_if_needed(chat_id, stop_typing) @staticmethod - def _create_inline_keyboard(buttons: List[List[Dict]]) -> JsonDict: + def _create_inline_keyboard(buttons: List[List[dict[str, Any]]]) -> JsonDict: """ 创建内联键盘,忽略没有有效链接或回调数据的按钮及空行。 @@ -1508,6 +1513,7 @@ class Telegram: if not self._telegram_token or not self._telegram_chat_id: return None + target_chat_id: Union[str, int] try: if chat_id: target_chat_id = chat_id @@ -1537,7 +1543,7 @@ class Telegram: message_id: Union[str, int], text: str, title: Optional[str] = None, - buttons: Optional[List[List[dict]]] = None, + buttons: Optional[List[List[dict[str, Any]]]] = None, stop_typing: bool = False, parse_mode: Optional[str] = None, rich_message: Optional[str] = None, @@ -1693,7 +1699,7 @@ class Telegram: chat_id: str, message_id: int, text: str, - buttons: Optional[List[List[dict]]] = None, + buttons: Optional[List[List[dict[str, Any]]]] = None, image: Optional[str] = None, disable_web_page_preview: Optional[bool] = None, parse_mode: Optional[str] = None, @@ -1956,13 +1962,13 @@ class Telegram: def __send_request( self, userid: Optional[str] = None, - image="", - caption="", + image: Optional[str] = "", + caption: str = "", reply_markup: Optional[JsonDict] = None, disable_web_page_preview: Optional[bool] = None, parse_mode: Optional[str] = None, reply_to_message_id: Optional[int] = None, - ): + ) -> Optional[JsonDict]: """ 按内容长度和媒体类型拆分并发送普通消息。 @@ -1984,13 +1990,13 @@ class Telegram: kwargs.update(self._topic_kwargs(kwargs["chat_id"])) if reply_to_message_id: kwargs["reply_to_message_id"] = reply_to_message_id - image = self.__process_image(image) + image_bytes = self.__process_image(image) try: # 图片消息的标题长度限制为1024,文本消息为4096 - caption_limit = 1024 if image else 4096 + caption_limit = 1024 if image_bytes else 4096 if len(caption) < caption_limit: - ret = self.__send_short_message(image, caption, + ret = self.__send_short_message(image_bytes, caption, disable_web_page_preview=disable_web_page_preview, **kwargs) elif ( @@ -1998,15 +2004,15 @@ class Telegram: or self._is_html_parse_mode(parse_mode) ): ret = self.__send_long_plain_message( - image, + image_bytes, caption, caption_limit, disable_web_page_preview=disable_web_page_preview, **kwargs, ) else: - sent_idx = set() - ret = self.__send_long_message(image, caption, sent_idx, + sent_idx: set[int] = set() + ret = self.__send_long_message(image_bytes, caption, sent_idx, disable_web_page_preview=disable_web_page_preview, **kwargs) @@ -2029,7 +2035,7 @@ class Telegram: @retry(RetryException, logger=logger) def __send_short_message(self, image: Optional[bytes], caption: str, - disable_web_page_preview: Optional[bool] = None, **kwargs): + disable_web_page_preview: Optional[bool] = None, **kwargs: Any) -> Optional[JsonDict]: """ 发送短消息。 """ @@ -2060,7 +2066,7 @@ class Telegram: def __send_photo_or_text( self, *, photo: Any, caption: Optional[str], disable_web_page_preview: Optional[bool] = None, **kwargs: Any, - ) -> Any: + ) -> Optional[JsonDict]: """ 海报上传失败立即降级为同一正文,保留会话、话题和按钮;429 不追加请求。 """ @@ -2081,8 +2087,8 @@ class Telegram: caption: str, caption_limit: int, disable_web_page_preview: Optional[bool] = None, - **kwargs, - ): + **kwargs: Any, + ) -> Optional[JsonDict]: """ 按字符上限拆分普通或 HTML 正文并发送各分块。 @@ -2131,9 +2137,9 @@ class Telegram: @retry(RetryException, logger=logger) def __send_long_message( - self, image: Optional[bytes], caption: str, sent_idx: set, - disable_web_page_preview: Optional[bool] = None, **kwargs - ): + self, image: Optional[bytes], caption: str, sent_idx: set[int], + disable_web_page_preview: Optional[bool] = None, **kwargs: Any + ) -> Optional[JsonDict]: """ 通过 Markdown 转换器拆分长消息,并跳过已经发送的分块。 @@ -2210,7 +2216,7 @@ class Telegram: return ret - def register_commands(self, commands: Dict[str, dict]) -> None: + def register_commands(self, commands: Dict[str, dict[str, Any]]) -> None: """ 先清除旧菜单,再按输入顺序注册命令。 diff --git a/app/modules/theaudiodb/__init__.py b/app/modules/theaudiodb/__init__.py index 760944eba..12a2e5288 100644 --- a/app/modules/theaudiodb/__init__.py +++ b/app/modules/theaudiodb/__init__.py @@ -3,7 +3,7 @@ TheAudioDB 音乐搜索、身份匹配和详情转换,统一同步与异步请 """ from dataclasses import dataclass -from typing import Any, Iterable, Optional, Tuple, Union +from typing import Any, Iterable, Optional, Tuple, Union, cast from app.adapters.network.http import AsyncRequestUtils, RequestUtils from app.application.music.observation import music_request_timeout, report_music_recognition @@ -32,7 +32,7 @@ from app.domain.music import ( music_version_matches, ) from app.modules import _ModuleBase -from app.runtime.cache import cached +from app.runtime.cache import CacheControls, cached from app.runtime.log import logger from app.runtime.settings import get_runtime_setting from app.schemas.types import ( @@ -218,7 +218,7 @@ class TheAudioDbModule(_ModuleBase): mtype: Optional[MediaType] = None, media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, - **kwargs, + **kwargs: Any, ) -> Optional[MusicInfo]: """ 响应明确选择 TheAudioDB 的音乐识别请求。 @@ -262,7 +262,7 @@ class TheAudioDbModule(_ModuleBase): mtype: Optional[MediaType] = None, media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, - **kwargs, + **kwargs: Any, ) -> Optional[MusicInfo]: """ 异步响应明确选择 TheAudioDB 的音乐识别请求。 @@ -588,7 +588,7 @@ class TheAudioDbModule(_ModuleBase): """ 清除 TheAudioDB 请求缓存。 """ - self._request_json.cache_clear() + cast(CacheControls, self._request_json).cache_clear() def _search_tracks(self, meta: MetaMusic) -> list[MusicInfo]: """ diff --git a/app/modules/themoviedb/__init__.py b/app/modules/themoviedb/__init__.py index 6b6754b11..b0988a480 100644 --- a/app/modules/themoviedb/__init__.py +++ b/app/modules/themoviedb/__init__.py @@ -434,7 +434,7 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase): """ seasons: dict[int, list[Any]] = {} season_info: _TmdbDataList = [] - season_years: dict[Union[int, str], str] = {} + season_years: dict[int, str] = {} for group_season in group_seasons: season = group_season.get("order") episodes = group_season.get("episodes") @@ -621,6 +621,9 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase): return None def _collect_titles(info: _TmdbData) -> set[str]: + """ + 收集 TMDB 记录中的本地和原始标题,供候选匹配使用。 + """ titles = set() for key in ("title", "name", "original_title", "original_name"): if info.get(key): @@ -630,6 +633,9 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase): return titles def _match_score(info: _TmdbData) -> int: + """ + 按候选标题和识别名称计算匹配分值,供媒体结果排序使用。 + """ score = 0 titles = _collect_titles(info) meta_names = [n for n in [meta.cn_name, meta.en_name] if n] @@ -668,15 +674,16 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase): """ 生成名称识别的有序回退计划,确保同步与异步分支完全一致。 """ + normalized_year = str(meta.year) if meta.year is not None else None if meta.type == MediaType.UNKNOWN and not meta.year: return (_MatchStep(name=name, multi=True),) if meta.type == MediaType.TV: return ( _MatchStep( name=name, - year=meta.year, + year=normalized_year, mtype=meta.type, - season_year=meta.year, + season_year=normalized_year, season_number=meta.begin_season, include_year=True, include_season=True, @@ -687,13 +694,13 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase): return ( _MatchStep( name=name, - year=meta.year, + year=normalized_year, mtype=MediaType.MOVIE, include_year=True, ), _MatchStep( name=name, - year=meta.year, + year=normalized_year, mtype=MediaType.TV, include_year=True, include_group_seasons=True, @@ -783,11 +790,11 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase): @staticmethod def _collect_group_season_years( group_seasons: _TmdbDataList, - ) -> Dict[Union[int, str], str]: + ) -> Dict[int, str]: """ 从已获取的剧集组详情中提取每季首播年份。 """ - season_years: Dict[Union[int, str], str] = {} + season_years: Dict[int, str] = {} for group_season in group_seasons: season = group_season.get("order") episodes = group_season.get("episodes") @@ -799,7 +806,7 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase): return season_years @staticmethod - def _apply_group_season_years(mediainfo: MediaInfo, season_years: Dict[Union[int, str], str]) -> MediaInfo: + def _apply_group_season_years(mediainfo: MediaInfo, season_years: Dict[int, str]) -> MediaInfo: """ 把聚合后的剧集组年份写回媒体结果。 """ @@ -813,7 +820,7 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase): """ 处理剧集组信息。 """ - season_years = {} + season_years: Dict[int, str] = {} for group_id in self._prepare_episode_group_queries(mediainfo, episode_group, group_seasons): fetched_seasons = self.tmdb.get_tv_group_seasons(group_id) if fetched_seasons: @@ -826,7 +833,7 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase): """ 处理剧集组信息(异步版本)。 """ - season_years = {} + season_years: Dict[int, str] = {} for group_id in self._prepare_episode_group_queries(mediainfo, episode_group, group_seasons): fetched_seasons = await self.tmdb.async_get_tv_group_seasons(group_id) if fetched_seasons: @@ -840,9 +847,9 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase): mediainfo = MediaInfo(tmdb_info=info) if meta: - logger.info(f"{meta.name} TMDB识别结果:{mediainfo.type.value} {mediainfo.title_year} {mediainfo.tmdb_id}") + logger.info(f"{meta.name} TMDB识别结果:{(mediainfo.type or MediaType.UNKNOWN).value} {mediainfo.title_year} {mediainfo.tmdb_id}") else: - logger.info(f"{tmdbid} TMDB识别结果:{mediainfo.type.value} {mediainfo.title_year}") + logger.info(f"{tmdbid} TMDB识别结果:{(mediainfo.type or MediaType.UNKNOWN).value} {mediainfo.title_year}") return mediainfo def _build_media_info_result( diff --git a/app/modules/themoviedb/cache.py b/app/modules/themoviedb/cache.py index 3c96d7235..928355b9f 100644 --- a/app/modules/themoviedb/cache.py +++ b/app/modules/themoviedb/cache.py @@ -53,6 +53,8 @@ class TmdbCache(metaclass=WeakSingleton): """ 从统一文件缓存恢复仍在有效期内的 TMDB 识别数据。 """ + if self._file_cache is None or self._legacy_file_cache is None: + return try: content = self._file_cache.get(PERSISTENCE_KEY, region=PERSISTENCE_REGION) if not content: @@ -101,7 +103,7 @@ class TmdbCache(metaclass=WeakSingleton): except Exception as err: logger.error(f"加载TMDB识别缓存失败:{str(err)} - {traceback.format_exc()}") - def _set(self, key: str, value: dict, ttl: Optional[int] = None) -> None: + def _set(self, key: str, value: dict[str, Any], ttl: Optional[int] = None) -> None: """ 写入单条 TMDB 识别缓存并记录其独立过期时间,未指定 ttl 时用默认有效期。 """ @@ -121,7 +123,7 @@ class TmdbCache(metaclass=WeakSingleton): self._dirty = True self.save(force=True) - def list_items(self) -> list[dict]: + def list_items(self) -> list[dict[str, Any]]: """ 生成按缓存键排序的管理界面条目列表。 @@ -180,9 +182,9 @@ class TmdbCache(metaclass=WeakSingleton): media_type = MediaType(media_type) except (TypeError, ValueError): return False - return media_type == MediaType.MOVIE + return bool(media_type == MediaType.MOVIE) - def get(self, meta: MetaBase): + def get(self, meta: MetaBase) -> dict[str, Any]: """ 读取元数据对应的有效识别缓存,并剔除类型冲突条目。 @@ -195,7 +197,9 @@ class TmdbCache(metaclass=WeakSingleton): cache_data = self._cache.get(key) if not cache_data and self._expires_at.pop(key, None) is not None: self._dirty = True - if not cache_data or not isinstance(cache_data, dict): + if not isinstance(cache_data, dict): + return {} + if not cache_data: return {} if self.__is_type_conflicted(meta, cache_data.get("type"), cache_data.get("id")): # 脏条目不丢弃就会被无限期沿用,正确的识别逻辑永远没有执行机会 @@ -207,7 +211,7 @@ class TmdbCache(metaclass=WeakSingleton): return {} return cache_data - def delete(self, key: str) -> dict: + def delete(self, key: str) -> dict[str, Any]: """ 删除指定识别缓存并立即保存本地持久化状态。 @@ -216,7 +220,7 @@ class TmdbCache(metaclass=WeakSingleton): """ with lock: redis_data = self._cache.get(key) - if redis_data: + if isinstance(redis_data, dict) and redis_data: self._cache.delete(key) self._expires_at.pop(key, None) self._dirty = True @@ -224,7 +228,7 @@ class TmdbCache(metaclass=WeakSingleton): return redis_data return {} - def modify(self, key: str, title: str) -> dict: + def modify(self, key: str, title: str) -> dict[str, Any]: """ 修改已有缓存标题,并重置该条目的默认有效期。 @@ -234,7 +238,7 @@ class TmdbCache(metaclass=WeakSingleton): """ with lock: redis_data = self._cache.get(key) - if redis_data: + if isinstance(redis_data, dict) and redis_data: redis_data['title'] = title self._set(key, redis_data) return redis_data @@ -292,6 +296,8 @@ class TmdbCache(metaclass=WeakSingleton): """ if self._cache.is_redis(): return + if self._file_cache is None or self._legacy_file_cache is None: + return with lock: now = time() cache_items = dict(self._cache.items()) diff --git a/app/modules/themoviedb/scraper.py b/app/modules/themoviedb/scraper.py index 5bcad1961..dfc8164b3 100644 --- a/app/modules/themoviedb/scraper.py +++ b/app/modules/themoviedb/scraper.py @@ -3,7 +3,7 @@ TMDB NFO 文档生成、季集编号映射与图片地址选择。 """ from pathlib import Path -from typing import Optional, Tuple +from typing import Any, Optional, Tuple, cast from xml.dom import minidom from app.domain.context import MediaInfo @@ -34,7 +34,7 @@ class TmdbScraper: self._meta_tmdb = TmdbApi(language=get_runtime_setting('TMDB_LOCALE')) return self._meta_tmdb - def original_tmdb(self, mediainfo: Optional[MediaInfo] = None): + def original_tmdb(self, mediainfo: Optional[MediaInfo] = None) -> TmdbApi: """ 按刮削设置选择原语言或默认语言的图片查询客户端。 @@ -72,16 +72,20 @@ class TmdbScraper: mediainfo.episode_group, season=season ) else: + if mediainfo.tmdb_id is None: + return None seasoninfo = self.default_tmdb.get_tv_season_detail( mediainfo.tmdb_id, season=season ) if episode: - episodeinfo = self.__get_episode_detail( - seasoninfo, meta.begin_episode - ) + tmdb_id = mediainfo.tmdb_id + if tmdb_id is None: + return None + episode_number = meta.begin_episode or episode + episodeinfo = self.__get_episode_detail(seasoninfo, episode_number) doc = self.__gen_tv_episode_nfo_file( episodeinfo=episodeinfo, - tmdbid=mediainfo.tmdb_id, + tmdbid=tmdb_id, season=season, episode=episode, ) @@ -101,7 +105,7 @@ class TmdbScraper: mediainfo: MediaInfo, season: Optional[int] = None, episode: Optional[int] = None, - ) -> dict: + ) -> dict[str, str]: """ 生成图片文件名到下载地址的映射。 @@ -112,7 +116,7 @@ class TmdbScraper: :param episode: 非零且提供季号时查询单集截图 :return: 文件名与图片地址的字典;没有图片时返回空字典 """ - images = {} + images: dict[str, str] = {} if season is not None: if episode: if mediainfo.episode_group: @@ -120,8 +124,9 @@ class TmdbScraper: mediainfo.episode_group, season ) else: + tmdb_id = cast(int, mediainfo.tmdb_id) seasoninfo = self.original_tmdb(mediainfo).get_tv_season_detail( - mediainfo.tmdb_id, season + tmdb_id, season ) if seasoninfo: episodeinfo = self.__get_episode_detail(seasoninfo, episode) @@ -131,6 +136,8 @@ class TmdbScraper: still_url = get_runtime_setting('TMDB_IMAGE_URL')(still_path) images[still_name] = still_url else: + if mediainfo.tmdb_id is None: + return images seasoninfo = self.original_tmdb(mediainfo).get_tv_season_detail( mediainfo.tmdb_id, season ) @@ -152,7 +159,7 @@ class TmdbScraper: ) images[image_name] = attr_value - if get_runtime_setting('TMDB_SCRAP_ORIGINAL_IMAGE'): + if get_runtime_setting('TMDB_SCRAP_ORIGINAL_IMAGE') and mediainfo.tmdb_id is not None: _mediainfo = self.original_tmdb(mediainfo).get_info( mediainfo.type, mediainfo.tmdb_id ) @@ -196,7 +203,9 @@ class TmdbScraper: mediainfo.backdrop_path = get_runtime_setting('TMDB_IMAGE_URL')(backdrop_path) @staticmethod - def __pick_best_image_path(images: Optional[list]) -> Optional[str]: + def __pick_best_image_path( + images: Optional[list[dict[str, Any]]] + ) -> Optional[str]: """ 从 TMDB 图片列表中选择评分和投票数最高的一张。 """ @@ -210,10 +219,13 @@ class TmdbScraper: ), reverse=True, ) - return images[0].get("file_path") + file_path = images[0].get("file_path") + return file_path if isinstance(file_path, str) else None @staticmethod - def get_season_poster(seasoninfo: dict, season: int) -> Tuple[str, str]: + def get_season_poster( + seasoninfo: dict[str, Any], season: int + ) -> Tuple[str, str]: """ 生成季海报的 Kodi 文件名与完整下载地址。 @@ -234,13 +246,15 @@ class TmdbScraper: return "", "" @staticmethod - def __get_episode_detail(seasoninfo: dict, episode: int) -> dict: + def __get_episode_detail( + seasoninfo: dict[str, Any], episode: int + ) -> dict[str, Any]: """ 根据季信息获取集的信息。 """ for _episode_info in seasoninfo.get("episodes") or []: - if _episode_info.get("episode_number") == episode: - return _episode_info + if isinstance(_episode_info, dict) and _episode_info.get("episode_number") == episode: + return cast(dict[str, Any], _episode_info) return {} @staticmethod @@ -360,7 +374,9 @@ class TmdbScraper: return doc @staticmethod - def __gen_tv_season_nfo_file(seasoninfo: dict, season: int) -> minidom.Document: + def __gen_tv_season_nfo_file( + seasoninfo: dict[str, Any], season: int + ) -> minidom.Document: """ 生成电视剧季的NFO描述文件。 @@ -373,6 +389,7 @@ class TmdbScraper: xplot.appendChild(doc.createCDATASection(seasoninfo.get("overview") or "")) xoutline = DomUtils.add_node(doc, root, "outline") xoutline.appendChild(doc.createCDATASection(seasoninfo.get("overview") or "")) + air_date = str(seasoninfo.get("air_date") or "") DomUtils.add_node( doc, root, "title", seasoninfo.get("name") or "季 %s" % season ) @@ -382,14 +399,14 @@ class TmdbScraper: doc, root, "year", - seasoninfo.get("air_date")[:4] if seasoninfo.get("air_date") else "", + air_date[:4], ) DomUtils.add_node(doc, root, "seasonnumber", str(season)) return doc @staticmethod def __gen_tv_episode_nfo_file( - tmdbid: int, episodeinfo: dict, season: int, episode: int + tmdbid: int, episodeinfo: dict[str, Any], season: int, episode: int ) -> minidom.Document: """ 生成单集 NFO,并使用该单集自身的 TMDB 标识。 @@ -409,6 +426,7 @@ class TmdbScraper: uniqueid.setAttribute("default", "true") # 应与uniqueid一致,使用剧集id否则Jellyfin/Emby会覆盖上面的uniqueid。 DomUtils.add_node(doc, root, "tmdbid", str(episode_tmdb_id)) + air_date = str(episodeinfo.get("air_date") or "") DomUtils.add_node( doc, root, "title", episodeinfo.get("name") or "第 %s 集" % episode ) @@ -421,7 +439,7 @@ class TmdbScraper: doc, root, "year", - episodeinfo.get("air_date")[:4] if episodeinfo.get("air_date") else "", + air_date[:4], ) DomUtils.add_node(doc, root, "season", str(season)) DomUtils.add_node(doc, root, "episode", str(episode)) diff --git a/app/modules/themoviedb/tmdbv3api/tmdb.py b/app/modules/themoviedb/tmdbv3api/tmdb.py index fe13df131..64a67b3f8 100644 --- a/app/modules/themoviedb/tmdbv3api/tmdb.py +++ b/app/modules/themoviedb/tmdbv3api/tmdb.py @@ -118,14 +118,14 @@ def _cache_request(function: _RequestFunction) -> _RequestFunction: """ 保留请求的调用类型;cached 仅附加缓存管理入口而不改变返回值。 """ - return cast(_RequestFunction, cached( + return cached( maxsize=get_runtime_setting('CONF').tmdb, ttl=get_runtime_setting('CONF').meta, skip_none=True, skip_if=_is_business_failure_snapshot, empty_ttl=get_runtime_setting('EMPTY_RESULT_CACHE_TTL'), empty_if=_is_empty_result_snapshot, - )(function)) + )(function) class TMDb(object): diff --git a/app/modules/thetvdb/__init__.py b/app/modules/thetvdb/__init__.py index 5a763635a..15e9dbcc2 100644 --- a/app/modules/thetvdb/__init__.py +++ b/app/modules/thetvdb/__init__.py @@ -4,7 +4,7 @@ TVDB 模块,负责会话管理、电视剧详情及补充媒体别名。 from dataclasses import dataclass from threading import Lock -from typing import Optional, Tuple, Union +from typing import Any, Optional, Tuple, Union from app.domain.context import MediaInfo from app.domain.media import is_media_source_enabled @@ -87,7 +87,7 @@ class TheTvDbModule(_ModuleBase): if not self.tvdb or is_retry: self._initialize_tvdb_session(is_retry=is_retry) - def _handle_tvdb_call(self, method_name: str, *args, **kwargs): + def _handle_tvdb_call(self, method_name: str, *args: Any, **kwargs: Any) -> Any: """ 调用客户端方法,令牌失效时重新登录并最多重试一次。 @@ -181,7 +181,7 @@ class TheTvDbModule(_ModuleBase): """ pass - def tvdb_info(self, tvdbid: int) -> Optional[dict]: + def tvdb_info(self, tvdbid: int) -> Optional[dict[str, Any]]: """ 查询电视剧扩展详情,必要时建立会话或刷新失效令牌。 @@ -190,7 +190,8 @@ class TheTvDbModule(_ModuleBase): """ try: logger.info(f"开始获取TVDB信息: {tvdbid} ...") - return self._handle_tvdb_call("get_series_extended", tvdbid) + result = self._handle_tvdb_call("get_series_extended", tvdbid) + return result if isinstance(result, dict) else None except Exception as err: logger.error(f"获取TVDB信息失败: {str(err)}") return None @@ -205,13 +206,14 @@ class TheTvDbModule(_ModuleBase): try: result = self._handle_tvdb_call("get_series", tvdbid) if result and isinstance(result, dict): - return result.get("slug") + slug = result.get("slug") + return slug if isinstance(slug, str) else None return None except Exception as err: logger.error(f"获取TVDB slug 失败: {str(err)}") return None - def search_tvdb(self, title: str) -> list: + def search_tvdb(self, title: str) -> list[dict[str, Any]]: """ 按标题搜索 TVDB,并仅保留电视剧类型的候选。 @@ -226,7 +228,11 @@ class TheTvDbModule(_ModuleBase): if not isinstance(res, list): logger.warning(f"TVDB 搜索 '{title}' 未返回列表:{type(res)}") return [] - return [item for item in res if isinstance(item, dict) and item.get("type") == "series"] + return [ + item + for item in res + if isinstance(item, dict) and item.get("type") == "series" + ] except Exception as err: logger.error(f"用标题搜索TVDB剧集失败 ({title}): {str(err)}") return [] @@ -316,13 +322,18 @@ class TheTvDbModule(_ModuleBase): mediainfo.media_source == MediaSource.TVDB and str(mediainfo.media_id or "").isdigit() ): - return _TvdbAuxiliaryLookup( - method_name="tvdb_info", - argument=int(mediainfo.media_id), - ) + media_id = mediainfo.media_id + if isinstance(media_id, str) and media_id.isdigit(): + return _TvdbAuxiliaryLookup( + method_name="tvdb_info", + argument=int(media_id), + ) + title = mediainfo.title + if not isinstance(title, str): + return None return _TvdbAuxiliaryLookup( method_name="search_tvdb", - argument=mediainfo.title, + argument=title, ) def _load_auxiliary_candidates( diff --git a/app/modules/thetvdb/client.py b/app/modules/thetvdb/client.py index faed2e3ad..70ab49a9b 100644 --- a/app/modules/thetvdb/client.py +++ b/app/modules/thetvdb/client.py @@ -36,11 +36,11 @@ def _cache_request(function: _RequestFunction) -> _RequestFunction: """ 缓存保持原始参数和返回值,类型桥接不改变缓存配置。 """ - return cast(_RequestFunction, cached( + return cached( maxsize=get_runtime_setting('CONF').tmdb, ttl=get_runtime_setting('CONF').meta, skip_none=True, - )(function)) + )(function) class Auth: diff --git a/app/modules/trimemedia/api.py b/app/modules/trimemedia/api.py index 98c5ea220..975df9ad6 100644 --- a/app/modules/trimemedia/api.py +++ b/app/modules/trimemedia/api.py @@ -8,7 +8,7 @@ import random import time from dataclasses import dataclass from enum import Enum -from typing import List, Optional, Union +from typing import Any, List, Optional, Union from urllib.parse import quote from app.adapters.network.http import RequestUtils @@ -311,7 +311,7 @@ class Api: return self._version return None - def login(self, username, password) -> Optional[str]: + def login(self, username: str, password: str) -> Optional[str]: """ 校验访问码后登录飞牛影视,并保存访问令牌。 @@ -715,7 +715,7 @@ class Api: data: Optional[dict[str, object]] = None, base_path: Optional[str] = None, suppress_log: bool = False, - ): + ) -> Any: """ 构造签名并发送飞牛 API 请求,解析业务状态与响应数据。 @@ -751,7 +751,7 @@ class Api: code: int msg: Optional[str] = None - data: Optional[Union[dict, list, str, bool]] = None + data: Optional[Union[dict[str, Any], list[Any], str, bool]] = None class JsonEncoder(json.JSONEncoder): """ diff --git a/app/modules/trimemedia/trimemedia.py b/app/modules/trimemedia/trimemedia.py index bb2d728ac..a4ca69a23 100644 --- a/app/modules/trimemedia/trimemedia.py +++ b/app/modules/trimemedia/trimemedia.py @@ -200,6 +200,8 @@ class TrimeMedia: ) else: return False + if self._username is None or self._password is None: + return False if self._api.login(self._username, self._password) is None: return False self._userinfo = self._api.user_info() diff --git a/app/modules/ugreen/module.py b/app/modules/ugreen/module.py index 385c73ad0..417411e6f 100644 --- a/app/modules/ugreen/module.py +++ b/app/modules/ugreen/module.py @@ -293,7 +293,7 @@ class UgreenModule(_MediaServerModuleBase[Ugreen]): server_obj: Optional[Ugreen] = self.get_instance(server) if not server_obj: return None - return server_obj.get_latest(num=count) + return server_obj.get_latest(num=count if count is not None else 20) def mediaserver_latest_images( self, @@ -314,7 +314,10 @@ class UgreenModule(_MediaServerModuleBase[Ugreen]): server_obj: Optional[Ugreen] = self.get_instance(server) if not server_obj: return [] - return server_obj.get_latest_backdrops(num=count, remote=remote) or [] + return server_obj.get_latest_backdrops( + num=count if count is not None else 20, + remote=remote if remote is not None else False, + ) or [] def mediaserver_image_cookies( self, diff --git a/app/modules/vocechat/__init__.py b/app/modules/vocechat/__init__.py index bde635d57..299b948b9 100644 --- a/app/modules/vocechat/__init__.py +++ b/app/modules/vocechat/__init__.py @@ -100,11 +100,11 @@ class VoceChatModule(_MessageChannelModuleBase[VoceChat]): """ pass - def init_setting(self) -> Tuple[str, Union[str, bool]]: + def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]: """ VoceChat 不提供额外的模块初始化设置。 """ - pass + return None @staticmethod def _send_admin_denied( @@ -133,7 +133,7 @@ class VoceChatModule(_MessageChannelModuleBase[VoceChat]): client_config = self.get_config(source) if not client_config: return None - client: VoceChat = self.get_instance(client_config.name) + client = self.get_instance(client_config.name) msg_body = json.loads(body) msg_type = msg_body.get("detail", {}).get("type") if msg_type not in ("normal", "reply"): @@ -153,7 +153,7 @@ class VoceChatModule(_MessageChannelModuleBase[VoceChat]): if from_uid is None: return None actor_userid = f"UID#{from_uid}" - channel_id = client_config.config.get("VOCECHAT_CHANNEL_ID") + channel_id = (client_config.config or {}).get("VOCECHAT_CHANNEL_ID") if gid and str(gid) == str(channel_id): userid = f"GID#{gid}" else: @@ -183,7 +183,7 @@ class VoceChatModule(_MessageChannelModuleBase[VoceChat]): @classmethod def _extract_images( - cls, detail: dict + cls, detail: dict[str, Any] ) -> Optional[List[IncomingMessage.MessageImage]]: """ 从 VoceChat 文件消息中提取图片引用。 @@ -239,7 +239,7 @@ class VoceChatModule(_MessageChannelModuleBase[VoceChat]): return None @classmethod - def _extract_audio_refs(cls, detail: dict) -> Optional[List[str]]: + def _extract_audio_refs(cls, detail: dict[str, Any]) -> Optional[List[str]]: """ 从 VoceChat 文件消息中提取音频引用。 """ @@ -274,7 +274,7 @@ class VoceChatModule(_MessageChannelModuleBase[VoceChat]): @classmethod def _extract_files( - cls, detail: dict + cls, detail: dict[str, Any] ) -> Optional[List[IncomingMessage.MessageAttachment]]: """ 从 VoceChat 文件消息中提取普通附件。 @@ -320,7 +320,7 @@ class VoceChatModule(_MessageChannelModuleBase[VoceChat]): ) ] - def post_message(self, message: Message, **kwargs) -> None: + def post_message(self, message: Message, **kwargs: Any) -> None: """ 向通过渠道范围筛选的 VoceChat 配置发送普通通知。 @@ -333,7 +333,7 @@ class VoceChatModule(_MessageChannelModuleBase[VoceChat]): if not self.check_message(message, conf.name): continue userid = self.get_notification_userid(message, "vocechat_userid") - client: VoceChat = self.get_instance(conf.name) + client = self.get_instance(conf.name) if client: texts: Sequence[Optional[str]] = message.channel_text_chunks(NotificationChannel.VoceChat) or [message.text] for text in texts: @@ -352,7 +352,7 @@ class VoceChatModule(_MessageChannelModuleBase[VoceChat]): for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue - client: VoceChat = self.get_instance(conf.name) + client = self.get_instance(conf.name) if client: userid = self.get_notification_userid(message, "vocechat_userid") client.send_msg(title=message.title, userid=userid) @@ -372,16 +372,16 @@ class VoceChatModule(_MessageChannelModuleBase[VoceChat]): if not self.check_message(message, conf.name): continue userid = self.get_notification_userid(message, "vocechat_userid") - client: VoceChat = self.get_instance(conf.name) + client = self.get_instance(conf.name) if client: client.send_torrents_msg(title=message.title, torrents=torrents, userid=userid, link=message.link) - def register_commands(self, commands: Dict[str, dict]): + def register_commands(self, commands: Dict[str, dict[str, Any]]) -> None: """ VoceChat 不支持注册命令菜单。 """ - pass + return None def download_vocechat_image_to_data_url(self, image_ref: str, source: str) -> Optional[str]: """ @@ -396,7 +396,7 @@ class VoceChatModule(_MessageChannelModuleBase[VoceChat]): client_config = self.get_config(source) if not client_config: return None - client: VoceChat = self.get_instance(client_config.name) + client = self.get_instance(client_config.name) if not client: return None file_path = unquote(image_ref.replace("vocechat://file/", "", 1)) @@ -415,7 +415,7 @@ class VoceChatModule(_MessageChannelModuleBase[VoceChat]): client_config = self.get_config(source) if not client_config: return None - client: VoceChat = self.get_instance(client_config.name) + client = self.get_instance(client_config.name) if not client: return None file_path = unquote(file_ref.replace("vocechat://file/", "", 1)) diff --git a/app/modules/vocechat/vocechat.py b/app/modules/vocechat/vocechat.py index 65578015b..87d3ecb21 100644 --- a/app/modules/vocechat/vocechat.py +++ b/app/modules/vocechat/vocechat.py @@ -205,14 +205,14 @@ class VoceChat: index, media.title_year, media.detail_link, - f"类型:{media.type.value}", + f"类型:{media.type.value if media.type else ''}", f"评分:{media.vote_average}") else: caption = "%s\n%s. [%s](%s)\n_%s_" % (caption, index, media.title_year, media.detail_link, - f"类型:{media.type.value}") + f"类型:{media.type.value if media.type else ''}") index += 1 if link: @@ -252,8 +252,10 @@ class VoceChat: index, caption = 1, "**%s**" % title for context in torrents: torrent = context.torrent_info - site_name = torrent.site_name - meta = MetaInfo(torrent.title, torrent.description) + if torrent is None: + continue + site_name = torrent.site_name or "" + meta = MetaInfo(torrent.title or "", torrent.description) link = torrent.page_url title = f"{meta.season_episode} " \ f"{meta.resource_term} " \ diff --git a/app/modules/webpush/__init__.py b/app/modules/webpush/__init__.py index bcffb2d08..913444233 100644 --- a/app/modules/webpush/__init__.py +++ b/app/modules/webpush/__init__.py @@ -88,7 +88,7 @@ class WebPushModule(_ModuleBase, _MessageBase[object]): for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue - webpush_users = conf.config.get("WEBPUSH_USERNAME") or "" + webpush_users = (conf.config or {}).get("WEBPUSH_USERNAME") or "" if webpush_users: # 设定了接收用户时,非该用户的消息不接收 if not message.username or message.username not in webpush_users.split(","): diff --git a/app/modules/wechat/__init__.py b/app/modules/wechat/__init__.py index d036273df..4907c64a4 100644 --- a/app/modules/wechat/__init__.py +++ b/app/modules/wechat/__init__.py @@ -38,7 +38,7 @@ def _resolve_wechat_admin_ids(config: Optional[dict[str, object]]) -> set[str]: register_channel_admin_resolver(NotificationChannel.Wechat, _resolve_wechat_admin_ids) -class WechatModule(_MessageChannelModuleBase[WeChat]): +class WechatModule(_MessageChannelModuleBase[WeChat | WeChatBot]): """ 按实例模式路由企业微信应用消息和智能机器人消息。 @@ -110,7 +110,7 @@ class WechatModule(_MessageChannelModuleBase[WeChat]): return (config or {}).get("WECHAT_MODE", "app") == "bot" @classmethod - def _create_client(cls, conf): + def _create_client(cls, conf: Any) -> WeChat | WeChatBot: """ 按实例模式创建企业微信应用或智能机器人客户端。 """ @@ -122,7 +122,7 @@ class WechatModule(_MessageChannelModuleBase[WeChat]): """ 保留设置初始化钩子,实例配置由通知服务管理。 """ - pass + return None def message_parser(self, source: str, body: Any, form: Any, args: Any) -> Optional[IncomingMessage]: @@ -144,16 +144,17 @@ class WechatModule(_MessageChannelModuleBase[WeChat]): return None if self._is_bot_mode(client_config.config): return self._parse_bot_message(source=source, body=body, client_config=client_config) - client: WeChat = self.get_instance(client_config.name) + client = cast(WeChat, self.get_instance(client_config.name)) sVerifyMsgSig = args.get("msg_signature") sVerifyTimeStamp = args.get("timestamp") sVerifyNonce = args.get("nonce") if not sVerifyMsgSig or not sVerifyTimeStamp or not sVerifyNonce: logger.debug(f"微信请求参数错误:{args}") return None - wxcpt = WXBizMsgCrypt(sToken=client_config.config.get('WECHAT_TOKEN'), - sEncodingAESKey=client_config.config.get('WECHAT_ENCODING_AESKEY'), - sReceiveId=client_config.config.get('WECHAT_CORPID')) + config = client_config.config or {} + wxcpt = WXBizMsgCrypt(sToken=config.get('WECHAT_TOKEN'), + sEncodingAESKey=config.get('WECHAT_ENCODING_AESKEY'), + sReceiveId=config.get('WECHAT_CORPID')) if not body: logger.debug("微信请求数据为空") return None @@ -247,7 +248,9 @@ class WechatModule(_MessageChannelModuleBase[WeChat]): logger.error(f"微信消息处理发生错误:{str(err)}") return None - def _parse_bot_message(self, source: str, body: Any, client_config) -> Optional[IncomingMessage]: + def _parse_bot_message( + self, source: str, body: Any, client_config: Any + ) -> Optional[IncomingMessage]: """ 提取机器人私聊内容并检查管理员命令权限。 @@ -302,7 +305,7 @@ class WechatModule(_MessageChannelModuleBase[WeChat]): if text and text.startswith("/") and self._should_reject_admin_command( client_config.config, sender ): - client: WeChatBot = self.get_instance(client_config.name) + client = self.get_instance(client_config.name) if client: client.send_msg(title="只有管理员才有权限执行此命令", userid=sender) return None @@ -374,9 +377,11 @@ class WechatModule(_MessageChannelModuleBase[WeChat]): return None if image_ref.startswith("wxwork://media_id/") and hasattr(client, "download_media_to_data_url"): media_id = image_ref.replace("wxwork://media_id/", "", 1) - return client.download_media_to_data_url(media_id) + result = client.download_media_to_data_url(media_id) + return result if isinstance(result, str) else None if image_ref.startswith("wxbot://image/") and hasattr(client, "download_image_to_data_url"): - return client.download_image_to_data_url(image_ref) + result = client.download_image_to_data_url(image_ref) + return result if isinstance(result, str) else None return None def download_wechat_media_bytes(self, media_ref: str, source: str) -> Optional[bytes]: @@ -435,19 +440,20 @@ class WechatModule(_MessageChannelModuleBase[WeChat]): client.send_torrents_msg(title=message.title, torrents=torrents, userid=userid, link=message.link) - def _commands_enabled(self, config: Optional[dict]) -> bool: + def _commands_enabled(self, config: Optional[dict[str, Any]]) -> bool: """ 菜单注册前置条件:智能机器人模式无传统菜单,缺少解密参数时无法调用菜单 API。 """ if self._is_bot_mode(config): logger.debug("智能机器人模式,跳过传统菜单初始化") return False + config = config or {} if not config.get("WECHAT_ENCODING_AESKEY") or not config.get("WECHAT_TOKEN"): logger.debug("缺少消息解密参数,跳过菜单初始化") return False return True - def _delete_commands(self, client) -> None: + def _delete_commands(self, client: Any) -> None: """ 企业微信使用自定义菜单 API 清理命令。 """ diff --git a/app/modules/wechat/wechat.py b/app/modules/wechat/wechat.py index db5f6afe3..edaaad833 100644 --- a/app/modules/wechat/wechat.py +++ b/app/modules/wechat/wechat.py @@ -9,7 +9,7 @@ import subprocess import threading from datetime import datetime from pathlib import Path -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional from app.adapters.network.http import RequestUtils from app.application.messaging.image import build_wechat_image_url @@ -37,9 +37,9 @@ class WeChat: # 最近一次成功取得的企业微信访问令牌 _access_token = None # 企业微信访问令牌有效期,单位为秒 - _expires_in: int = None + _expires_in: Optional[int] = None # 最近一次成功获取访问令牌的本地时间 - _access_token_time: datetime = None + _access_token_time: Optional[datetime] = None # 企业标识 _corpid = None # 自建应用密钥 @@ -83,12 +83,18 @@ class WeChat: self._proxy = WECHAT_PROXY or "https://qyapi.weixin.qq.com" if self._proxy: - self._send_msg_url = UrlUtils.adapt_request_url(self._proxy, self._send_msg_url) - self._token_url = UrlUtils.adapt_request_url(self._proxy, self._token_url) - self._create_menu_url = UrlUtils.adapt_request_url(self._proxy, self._create_menu_url) - self._delete_menu_url = UrlUtils.adapt_request_url(self._proxy, self._delete_menu_url) - self._download_media_url = UrlUtils.adapt_request_url(self._proxy, self._download_media_url) - self._upload_media_url = UrlUtils.adapt_request_url(self._proxy, self._upload_media_url) + urls = ( + ("_send_msg_url", self._send_msg_url), + ("_token_url", self._token_url), + ("_create_menu_url", self._create_menu_url), + ("_delete_menu_url", self._delete_menu_url), + ("_download_media_url", self._download_media_url), + ("_upload_media_url", self._upload_media_url), + ) + for attribute, template in urls: + adapted_url = UrlUtils.adapt_request_url(self._proxy, template) + if isinstance(adapted_url, str): + setattr(self, attribute, adapted_url) if self._corpid and self._appsecret and self._appid: self.__get_access_token() @@ -102,7 +108,7 @@ class WeChat: return True if self.__get_access_token() else False @retry(RetryException, logger=logger) - def __get_access_token(self, force=False): + def __get_access_token(self, force: bool = False) -> Optional[str]: """ 复用未过期访问令牌,必要时请求新令牌。 @@ -115,7 +121,11 @@ class WeChat: if not self._access_token: token_flag = False else: - if (datetime.now() - self._access_token_time).total_seconds() >= self._expires_in: + if ( + self._access_token_time is None + or self._expires_in is None + or (datetime.now() - self._access_token_time).total_seconds() >= self._expires_in + ): token_flag = False if not token_flag or force: @@ -126,9 +136,12 @@ class WeChat: if res: ret_json = res.json() if ret_json.get("errcode") == 0: - self._access_token = ret_json.get("access_token") - self._expires_in = ret_json.get("expires_in") - self._access_token_time = datetime.now() + access_token = ret_json.get("access_token") + expires_in = ret_json.get("expires_in") + if isinstance(access_token, str) and isinstance(expires_in, int): + self._access_token = access_token + self._expires_in = expires_in + self._access_token_time = datetime.now() elif res is not None: logger.error(f"获取微信access_token失败,错误码:{res.status_code},错误原因:{res.reason}") else: @@ -387,7 +400,8 @@ class WeChat: except Exception: logger.error(f"企业微信媒体下载失败:{res.text}") return None - return res.content + content = res.content + return content if isinstance(content, bytes) else None @staticmethod def _convert_voice_to_amr(voice_path: str) -> Optional[Path]: @@ -492,7 +506,8 @@ class WeChat: if ret_json.get("errcode") != 0: logger.error(f"上传企业微信临时素材失败:{ret_json}") return None - return ret_json.get("media_id") + media_id = ret_json.get("media_id") + return media_id if isinstance(media_id, str) else None def send_voice(self, voice_path: str, userid: Optional[str] = None) -> Optional[bool]: """ @@ -558,9 +573,9 @@ class WeChat: index = 1 for media in medias: if media.vote_average: - title = f"{index}. {media.title_year}\n类型:{media.type.value},评分:{media.vote_average}" + title = f"{index}. {media.title_year}\n类型:{media.type.value if media.type else ''},评分:{media.vote_average}" else: - title = f"{index}. {media.title_year}\n类型:{media.type.value}" + title = f"{index}. {media.title_year}\n类型:{media.type.value if media.type else ''}" articles.append({ "title": title, "description": "", @@ -610,7 +625,9 @@ class WeChat: index = 1 for context in torrents: torrent = context.torrent_info - meta = MetaInfo(title=torrent.title, subtitle=torrent.description) + if torrent is None: + continue + meta = MetaInfo(title=torrent.title or "", subtitle=torrent.description) mediainfo = context.media_info torrent_title = f"{index}.【{torrent.site_name}】" \ f"{meta.season_episode} " \ @@ -625,7 +642,7 @@ class WeChat: "title": torrent_title, "description": torrent.description if index == 1 else "", "picurl": build_wechat_image_url( - mediainfo.get_message_image() if index == 1 else "" + mediainfo.get_message_image() if index == 1 and mediainfo else "" ), "url": torrent.page_url }) @@ -645,7 +662,7 @@ class WeChat: return False @retry(RetryException, logger=logger) - def __post_request(self, url: str, req_json: dict) -> bool: + def __post_request(self, url: str, req_json: Dict[str, Any]) -> bool: """ 提交企业微信 JSON 请求并处理令牌过期。 @@ -681,7 +698,7 @@ class WeChat: logger.error(f"发送请求失败,错误信息:{ret_json.get('errmsg')}") return False - def create_menus(self, commands: Dict[str, dict]) -> None: + def create_menus(self, commands: Dict[str, Dict[str, Any]]) -> None: """ 按命令分类创建应用菜单,忽略没有分类的命令。 @@ -692,9 +709,11 @@ class WeChat: try: req_url = self._create_menu_url.format(access_token="{access_token}", agentid=self._appid) - category_dict = {} + category_dict: Dict[str, Dict[str, Dict[str, Any]]] = {} for key, value in commands.items(): - category: str = value.get("category") + category = value.get("category") + if not isinstance(category, str): + continue if category: if not category_dict.get(category): category_dict[category] = {} diff --git a/app/modules/wechat/wechatbot.py b/app/modules/wechat/wechatbot.py index 8d07fb84e..20d4dfa78 100644 --- a/app/modules/wechat/wechatbot.py +++ b/app/modules/wechat/wechatbot.py @@ -10,7 +10,7 @@ import re import threading import time import uuid -from typing import Dict, List, Optional, Set, Tuple +from typing import Any, Dict, List, Optional, Set, Tuple import websocket from Crypto.Cipher import AES @@ -52,7 +52,7 @@ class WeChatBot: WECHAT_BOT_WS_URL: Optional[str] = None, WECHAT_ADMINS: Optional[str] = None, name: Optional[str] = None, - **kwargs): + **kwargs: Any) -> None: """ 加载互动用户并启动网关和心跳线程。 @@ -83,7 +83,7 @@ class WeChatBot: self._authenticated = threading.Event() self._send_lock = threading.Lock() self._acks_lock = threading.Lock() - self._pending_acks: Dict[str, dict] = {} + self._pending_acks: Dict[str, Dict[str, Any]] = {} if not self._bot_id or not self._bot_secret: logger.error("企业微信智能机器人配置不完整!") @@ -95,6 +95,9 @@ class WeChatBot: @staticmethod def _build_req_id(prefix: str) -> str: + """ + 为微信协议请求生成带操作前缀的独立请求标识。 + """ return f"{prefix}_{uuid.uuid4().hex}" @staticmethod @@ -287,7 +290,7 @@ class WeChatBot: return time.sleep(0.1) - def _on_open(self, ws) -> None: + def _on_open(self, ws: Any) -> None: """ 连接建立后提交机器人订阅凭据。 @@ -303,7 +306,7 @@ class WeChatBot: }, }) - def _on_message(self, ws, message: str) -> None: + def _on_message(self, ws: Any, message: str) -> None: """ 解析确认、订阅结果和回调事件,并更新认证状态。 @@ -338,7 +341,7 @@ class WeChatBot: elif cmd == "aibot_event_callback": self._handle_callback_event(payload) - def _on_error(self, ws, error) -> None: + def _on_error(self, ws: Any, error: Any) -> None: """ 清除认证状态并记录连接错误。 @@ -348,7 +351,7 @@ class WeChatBot: self._authenticated.clear() logger.error(f"企业微信智能机器人 WebSocket 错误:{error}") - def _on_close(self, ws, close_status_code, close_msg) -> None: + def _on_close(self, ws: Any, close_status_code: Any, close_msg: Any) -> None: """ 连接关闭时清除认证状态并记录关闭原因。 @@ -359,7 +362,7 @@ class WeChatBot: self._authenticated.clear() logger.info(f"企业微信智能机器人连接关闭:{close_status_code} {close_msg}") - def _resolve_ack(self, req_id: str, payload: dict) -> None: + def _resolve_ack(self, req_id: str, payload: Dict[str, Any]) -> None: """ 将服务器确认交给等待同一请求标识的发送线程。 @@ -373,7 +376,7 @@ class WeChatBot: pending["payload"] = payload pending["event"].set() - def _send_raw(self, payload: dict) -> None: + def _send_raw(self, payload: Dict[str, Any]) -> None: """ 向当前连接发送未经确认等待的 JSON 报文。 @@ -385,7 +388,7 @@ class WeChatBot: raise RuntimeError("企业微信智能机器人未连接") self._ws_app.send(json.dumps(payload, ensure_ascii=False)) - def _send_with_ack(self, payload: dict) -> bool: + def _send_with_ack(self, payload: Dict[str, Any]) -> bool: """ 等待认证后串行写入报文,并等待对应服务器确认。 @@ -402,17 +405,19 @@ class WeChatBot: logger.error("企业微信智能机器人未完成认证,无法发送消息") return False - pending = {"event": threading.Event(), "payload": None} + pending: Dict[str, Any] = {"event": threading.Event(), "payload": None} with self._acks_lock: self._pending_acks[req_id] = pending try: with self._send_lock: self._send_raw(payload) - if not pending["event"].wait(timeout=self._ack_timeout): + event = pending["event"] + if not isinstance(event, threading.Event) or not event.wait(timeout=self._ack_timeout): logger.error(f"企业微信智能机器人消息发送超时:req_id={req_id}") return False - ack = pending["payload"] or {} + ack_value = pending.get("payload") + ack: Dict[str, Any] = ack_value if isinstance(ack_value, dict) else {} if ack.get("errcode") != 0: logger.error( f"企业微信智能机器人消息发送失败:{ack.get('errmsg')} ({ack.get('errcode')})" @@ -423,7 +428,7 @@ class WeChatBot: with self._acks_lock: self._pending_acks.pop(req_id, None) - def _handle_callback_event(self, payload: dict) -> None: + def _handle_callback_event(self, payload: Dict[str, Any]) -> None: """ 记录旧连接被服务器替换的事件;其他事件当前忽略。 @@ -434,7 +439,7 @@ class WeChatBot: logger.info(f"企业微信智能机器人旧连接被踢下线:{self._config_name}") @staticmethod - def _extract_text_from_body(body: dict) -> Optional[str]: + def _extract_text_from_body(body: Dict[str, Any]) -> Optional[str]: """ 提取文本、语音转写或混合消息文字,无正文时回退到引用文字。 @@ -469,7 +474,7 @@ class WeChatBot: return text or None @staticmethod - def _build_image_ref(image_payload: dict) -> Optional[str]: + def _build_image_ref(image_payload: Dict[str, Any]) -> Optional[str]: """ 将图片下载地址、可选密钥和格式封装为内部引用。 @@ -500,7 +505,7 @@ class WeChatBot: @classmethod def _extract_images_from_body( - cls, body: dict + cls, body: Dict[str, Any] ) -> Optional[List["IncomingMessage.MessageImage"]]: """ 提取图片或混合消息中的图片,无正文图片时回退到引用图片。 @@ -626,7 +631,7 @@ class WeChatBot: mime_type = self._guess_mime_type(content, payload.get("mime_type") or "image/jpeg") return f"data:{mime_type};base64,{base64.b64encode(content).decode()}" - def _handle_callback_message(self, payload: dict) -> None: + def _handle_callback_message(self, payload: Dict[str, Any]) -> None: """ 接收私聊文本或图片,记录互动用户并检查管理员命令权限。 @@ -673,7 +678,7 @@ class WeChatBot: ) self._forward_to_message_chain(payload) - def _forward_to_message_chain(self, payload: dict) -> bool: + def _forward_to_message_chain(self, payload: Dict[str, Any]) -> bool: """ 通过受管线程池将原始回调提交给统一消息入口。 @@ -842,7 +847,9 @@ class WeChatBot: for index, context in enumerate(torrents, start=1): torrent = context.torrent_info - meta = MetaInfo(title=torrent.title, subtitle=torrent.description) + if torrent is None: + continue + meta = MetaInfo(title=torrent.title or "", subtitle=torrent.description) torrent_title = ( f"{index}.【{torrent.site_name}】" f"{meta.season_episode} " @@ -860,7 +867,7 @@ class WeChatBot: return self._send_markdown(content="\n\n".join(lines), userid=userid) - def create_menus(self, commands: Dict[str, dict]): + def create_menus(self, commands: Dict[str, Dict[str, Any]]) -> None: """ 保留客户端菜单接口;智能机器人模式不支持应用菜单。 @@ -868,7 +875,7 @@ class WeChatBot: """ return - def delete_menus(self): + def delete_menus(self) -> None: """ 保留客户端菜单清理接口;智能机器人模式不执行菜单操作。 """ diff --git a/app/modules/wechatclawbot/__init__.py b/app/modules/wechatclawbot/__init__.py index 2fcf2ed21..1c2a0ea37 100644 --- a/app/modules/wechatclawbot/__init__.py +++ b/app/modules/wechatclawbot/__init__.py @@ -113,17 +113,17 @@ class WechatClawBotModule(_MessageChannelModuleBase[WechatClawBot]): """ return client.test_connection() - def _service_kwargs(self, conf) -> Dict[str, Any]: + def _service_kwargs(self, conf: Any) -> Dict[str, Any]: """ 将稳定通知身份传递给 ClawBot 客户端。 """ return {"name": conf.name, "identity": conf.id, **conf.config} - def init_setting(self) -> Tuple[str, Union[str, bool]]: + def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]: """ 保留设置初始化钩子,实例配置由通知服务管理。 """ - pass + return None def channel_manage( self, @@ -246,7 +246,7 @@ class WechatClawBotModule(_MessageChannelModuleBase[WechatClawBot]): ) @staticmethod - def _load_json(body: Any) -> Optional[dict]: + def _load_json(body: Any) -> Optional[Dict[str, Any]]: """ 将内容解析为 JSON 字典。 """ @@ -371,7 +371,7 @@ class WechatClawBotModule(_MessageChannelModuleBase[WechatClawBot]): WechatClawBot._short_text(message, max_len=500), ) return None - if self._is_duplicate_message(client_config.name, message_id): + if self._is_duplicate_message(client_config.name or "", message_id): logger.info( "忽略重复的微信 ClawBot 消息:source=%s, userid=%s, message_id=%s", client_config.name, @@ -380,16 +380,17 @@ class WechatClawBotModule(_MessageChannelModuleBase[WechatClawBot]): ) return None + config = client_config.config or {} admins = [ admin.strip() - for admin in str(client_config.config.get("WECHATCLAWBOT_ADMINS") or "").split(",") + for admin in str(config.get("WECHATCLAWBOT_ADMINS") or "").split(",") if admin.strip() ] callback_data = text[9:].strip() if text.startswith("CALLBACK:") else "" is_admin_command = text.startswith("/") or callback_data.startswith("/") is_channel_admin = matches_channel_admin( NotificationChannel.WechatClawBot, - client_config.config, + config, user_id, ) if is_admin_command and admins and not is_channel_admin: @@ -431,7 +432,7 @@ class WechatClawBotModule(_MessageChannelModuleBase[WechatClawBot]): userid = self.get_notification_userid( message, "wechatclawbot_userid" ) - client: WechatClawBot = self.get_instance(conf.name) + client: Optional[WechatClawBot] = self.get_instance(conf.name) if not client: continue if message.file_path: @@ -508,7 +509,7 @@ class WechatClawBotModule(_MessageChannelModuleBase[WechatClawBot]): for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue - client: WechatClawBot = self.get_instance(conf.name) + client: Optional[WechatClawBot] = self.get_instance(conf.name) if client: client.send_medias_msg( medias=medias, @@ -527,7 +528,7 @@ class WechatClawBotModule(_MessageChannelModuleBase[WechatClawBot]): for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue - client: WechatClawBot = self.get_instance(conf.name) + client: Optional[WechatClawBot] = self.get_instance(conf.name) if client: client.send_torrents_msg( torrents=torrents, diff --git a/app/modules/wechatclawbot/wechatclawbot.py b/app/modules/wechatclawbot/wechatclawbot.py index 50435e531..b2677ad35 100644 --- a/app/modules/wechatclawbot/wechatclawbot.py +++ b/app/modules/wechatclawbot/wechatclawbot.py @@ -177,13 +177,15 @@ class ILinkClient: if not resp: return {} try: - return resp.json() or {} + payload = resp.json() + return payload if isinstance(payload, dict) else {} except Exception: text = (getattr(resp, "text", "") or "").strip() if not text: return {} try: - return json.loads(text) + payload = json.loads(text) + return payload if isinstance(payload, dict) else {} except Exception: return {} @@ -1259,7 +1261,7 @@ class ILinkClient: if isinstance(child, dict): message = child break - sender = ( + sender_value = ( message.get("from") if isinstance(message.get("from"), dict) else message.get("sender") @@ -1270,6 +1272,7 @@ class ILinkClient: if isinstance(message.get("from_user"), dict) else {} ) + sender: Dict[str, Any] = sender_value if isinstance(sender_value, dict) else {} user_id = ( self._pick_value(sender, ["user_id", "id", "wxid", "uid"]) or self._pick_value( @@ -1346,6 +1349,9 @@ class ILinkClient: files: List[Dict[str, Any]] = [] def append_text(value: Any) -> None: + """ + 收集非空消息文本并处理嵌套载荷,避免把空字段加入正文。 + """ if value in (None, ""): return if isinstance(value, dict): @@ -1435,7 +1441,8 @@ class ILinkClient: ref, _ = voice_ref audio_refs.append(ref) - item_list = message.get("item_list") if isinstance(message.get("item_list"), list) else [] + raw_item_list = message.get("item_list") + item_list: List[Any] = raw_item_list if isinstance(raw_item_list, list) else [] for one in item_list: if not isinstance(one, dict): continue @@ -1952,7 +1959,8 @@ class WechatClawBot: if resp and resp.status_code == 200 and resp.content: content_type = (resp.headers.get("Content-Type") or "").lower() if not content_type or "image" in content_type: - return resp.content + content = resp.content + return content if isinstance(content, bytes) else None except Exception as err: logger.warning(f"加载图片失败:{err}") return None @@ -2046,13 +2054,16 @@ class WechatClawBot: break self._stop_event.wait(delay) - def _forward_to_message_chain(self, payload: dict) -> bool: + def _forward_to_message_chain(self, payload: Dict[str, Any]) -> bool: """ 把 WeChatClawBot 轮询消息同步转交统一消息入口。 """ return forward_message_to_host(payload, self._config_name) def _build_known_targets(self) -> List[Dict[str, Any]]: + """ + 把已记录的微信接收方状态转换为可查询列表。 + """ known_targets = self._state.get("known_targets") or {} items = [] for userid, data in known_targets.items(): @@ -2221,7 +2232,9 @@ class WechatClawBot: return True, f"已将微信 ClawBot 登录缓存从 {source_name} 迁移到 {target_name}" @classmethod - def reconcile_cached_states(cls, previous: list[dict], current: list[dict]) -> Dict[str, Any]: + def reconcile_cached_states( + cls, previous: List[Dict[str, Any]], current: List[Dict[str, Any]] + ) -> Dict[str, Any]: """ 按稳定身份迁移改名缓存,并删除已移除渠道的缓存。 @@ -2248,29 +2261,36 @@ class WechatClawBot: for item in previous: if item.get("type") != "wechatclawbot" or str(item.get("id")) in current_ids: continue - for identity in (item.get("id"), item.get("name")): - if identity: - cache.delete(cls._build_cache_key(str(identity))) + for legacy_identity in (item.get("id"), item.get("name")): + if legacy_identity: + cache.delete(cls._build_cache_key(str(legacy_identity))) return {"success": True, "message": "通知渠道缓存已同步"} @staticmethod def _decode_ref_payload(ref: str, kind: str) -> Optional[Dict[str, Any]]: + """ + 校验本地资源引用类别并解码载荷,非法引用不进入资源读取流程。 + """ prefix = f"wxclaw://{kind}/" if not ref or not ref.startswith(prefix): return None encoded = ref.replace(prefix, "", 1) padding = "=" * (-len(encoded) % 4) try: - return json.loads( + payload = json.loads( base64.urlsafe_b64decode((encoded + padding).encode("ascii")).decode( "utf-8" ) ) + return payload if isinstance(payload, dict) else None except Exception: return None @staticmethod def _decode_aes_key(aeskey: Optional[str]) -> Optional[bytes]: + """ + 解析微信资源使用的 AES 密钥,无法识别时返回 None。 + """ if not aeskey: return None value = str(aeskey).strip() @@ -2294,6 +2314,9 @@ class WechatClawBot: @staticmethod def _unpad_bytes(content: bytes) -> Optional[bytes]: + """ + 校验并移除资源明文的块填充,非法填充返回 None。 + """ if not content: return None padding_len = content[-1] @@ -2358,6 +2381,9 @@ class WechatClawBot: @staticmethod def _guess_binary_mime(content: bytes, default: str = "application/octet-stream") -> str: + """ + 根据资源内容的文件签名识别 MIME,未知内容沿用调用方默认值。 + """ if not content: return default if content.startswith(b"\x89PNG\r\n\x1a\n"): @@ -2603,7 +2629,9 @@ class WechatClawBot: lines = [] for index, context in enumerate(torrents, start=1): torrent = context.torrent_info - meta = MetaInfo(title=torrent.title, subtitle=torrent.description) + if torrent is None: + continue + meta = MetaInfo(title=torrent.title or "", subtitle=torrent.description) text = ( f"{index}.【{torrent.site_name}】{meta.season_episode} {meta.resource_term} " f"{meta.video_term} {meta.release_group} {size_tools.format_compact_size(torrent.size)} " diff --git a/app/modules/zspace/__init__.py b/app/modules/zspace/__init__.py index f07dc96d0..667c37d37 100644 --- a/app/modules/zspace/__init__.py +++ b/app/modules/zspace/__init__.py @@ -202,7 +202,9 @@ class ZSpaceModule(_MediaServerModuleBase[ZSpace]): server_obj = self.get_instance(server) if not server_obj: return None - _, seasoninfo = server_obj.get_tv_episodes(item_id=item_id) + _, seasoninfo = server_obj.get_tv_episodes( + item_id=str(item_id) if item_id is not None else None + ) if not seasoninfo: return [] return [_SchemaMediaServerSeasonInfo( @@ -236,7 +238,7 @@ class ZSpaceModule(_MediaServerModuleBase[ZSpace]): server_obj = self.get_instance(server) if not server_obj: return None - return server_obj.get_play_url(item_id) + return server_obj.get_play_url(str(item_id)) def mediaserver_latest(self, server: Optional[str] = None, count: Optional[int] = 20, username: Optional[str] = None) -> Optional[List[_SchemaMediaServerPlayItem]]: @@ -278,7 +280,7 @@ class ZSpaceModule(_MediaServerModuleBase[ZSpace]): if item.type not in (MediaType.MOVIE.value, MediaType.TV.value): continue if item.BackdropImageTags: - image_url = server_obj.get_backdrop_url(item_id=item.id, + image_url = server_obj.get_backdrop_url(item_id=str(item.id), image_tag=item.BackdropImageTags[0], remote=remote) if image_url: diff --git a/app/monitor/monitor.py b/app/monitor/monitor.py index 5c783f805..a20272c28 100644 --- a/app/monitor/monitor.py +++ b/app/monitor/monitor.py @@ -8,7 +8,7 @@ import traceback from functools import partial from pathlib import Path from threading import Lock, Thread -from typing import Any, Callable, Dict, List, Optional, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple, cast from apscheduler.schedulers.background import BackgroundScheduler @@ -130,14 +130,14 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass): """ return "目录监控" - def save_snapshot(self, storage: str, snapshot: Dict, file_count: int = 0, + def save_snapshot(self, storage: str, snapshot: dict[str, Any], file_count: int = 0, last_snapshot_time: Optional[float] = None) -> None: """ 保存快照到文件缓存。 """ self._store.save(storage, snapshot, file_count=file_count, last_snapshot_time=last_snapshot_time) - def load_snapshot(self, storage: str) -> Optional[Dict]: + def load_snapshot(self, storage: str) -> Optional[dict[str, Any]]: """ 从文件缓存加载快照。 """ @@ -230,7 +230,7 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass): for mon_dir in monitor_dirs: if not self.__accepting_work(): return False - if not mon_dir.library_path: + if not mon_dir.download_path or not mon_dir.library_path: logger.warn(f"跳过监控配置 {mon_dir.download_path}:未设置媒体库目录") continue if mon_dir.monitor_type != "monitor": @@ -252,7 +252,8 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass): # 启动定时服务进程 if not self.__accepting_work(): return False - self._scheduler = BackgroundScheduler(timezone=get_runtime_setting('TZ')) + scheduler = BackgroundScheduler(timezone=get_runtime_setting('TZ')) + self._scheduler = scheduler mon_storages: Dict[str, List[Path]] = {} # 本地监控启动结果计数,用于输出真实的启动总结 @@ -263,21 +264,23 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass): self.__abort_interrupted_startup() return False # 检查媒体库目录是不是下载目录的子目录 - mon_path = Path(mon_dir.download_path) - target_path = Path(mon_dir.library_path) + mon_path = Path(cast(str, mon_dir.download_path)) + target_path = Path(cast(str, mon_dir.library_path)) if target_path.is_relative_to(mon_path): logger.warn(f"{target_path} 是监控目录 {mon_path} 的子目录,无法监控!") messagehelper.put(f"{target_path} 是监控目录 {mon_path} 的子目录,无法监控", title="目录监控") continue # 启动监控 - if mon_dir.storage == "local": - if self.__start_local_monitor(mon_path=mon_path, monitor_mode=mon_dir.monitor_mode): + storage = mon_dir.storage or "local" + monitor_mode = mon_dir.monitor_mode or "fast" + if storage == "local": + if self.__start_local_monitor(mon_path=mon_path, monitor_mode=monitor_mode): local_started += 1 else: local_failed += 1 else: - mon_storages.setdefault(mon_dir.storage, []).append(mon_path) + mon_storages.setdefault(storage, []).append(mon_path) for storage, paths in mon_storages.items(): if not self.__accepting_work(): @@ -286,11 +289,14 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass): # 远程目录监控 - 使用智能间隔 # 先尝试加载已有快照获取文件数量 snapshot_data = self._store.load(storage) - snapshot_is_current = ( - snapshot_data - and snapshot_data.get('version') == SnapshotStore.VERSION + snapshot_is_current = bool( + snapshot_data and snapshot_data.get('version') == SnapshotStore.VERSION + ) + file_count = ( + int(snapshot_data.get('file_count') or 0) + if snapshot_is_current and snapshot_data + else 0 ) - file_count = snapshot_data.get('file_count', 0) if snapshot_is_current else 0 interval = SnapshotStore.adjust_interval(file_count) for path in paths: logger.info(f"正在启动远程目录监控: {path} [{storage}]") @@ -299,7 +305,7 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass): logger.info(f"检测到旧版远程快照,将在首次轮询后重新校准: {storage}") logger.info(f"上次快照文件数量: {file_count}, 监控间隔: {interval}分钟") - self._scheduler.add_job( + scheduler.add_job( self.polling_observer, 'interval', minutes=interval, @@ -314,7 +320,7 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass): # 监控健康检查:重建异常监控线程、重试启动失败目录、重试历史查询失败的文件 if local_started or local_failed or mon_storages: - self._scheduler.add_job( + scheduler.add_job( self.watchdog, 'interval', seconds=self.WATCHDOG_INTERVAL, @@ -327,9 +333,9 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass): if not self.__accepting_work(): self.__abort_interrupted_startup() return False - if self._scheduler.get_jobs(): - self._scheduler.print_jobs() - self._scheduler.start() + if scheduler.get_jobs(): + scheduler.print_jobs() + scheduler.start() logger.info("定时监控服务已启动") # 输出监控总结,报告实际启动成功数而不是配置数 diff --git a/app/monitor/poller.py b/app/monitor/poller.py index 47c28ef3b..aa42f092b 100644 --- a/app/monitor/poller.py +++ b/app/monitor/poller.py @@ -5,7 +5,7 @@ import traceback from pathlib import Path, PurePosixPath from threading import Lock -from typing import Callable, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional from app.chain.storage import StorageChain from app.monitor.dispatcher import TransferDispatcher @@ -165,7 +165,12 @@ class RemotePoller: self._note_failure(storage, str(e)) return None - def _handle_changes(self, storage: str, old_snapshot: dict, new_snapshot: dict) -> None: + def _handle_changes( + self, + storage: str, + old_snapshot: dict[str, Any], + new_snapshot: dict[str, Any], + ) -> None: """ 比对快照并把变化文件送入整理链。 :param storage: 存储名称 diff --git a/app/monitor/snapshot.py b/app/monitor/snapshot.py index f7978726f..eff7eaf4c 100644 --- a/app/monitor/snapshot.py +++ b/app/monitor/snapshot.py @@ -4,7 +4,7 @@ import json import time -from typing import Dict, Optional, Set, Tuple +from typing import Any, Optional, Set, Tuple from app.runtime.cache import CacheBackend, FileCache from app.runtime.log import logger @@ -41,7 +41,7 @@ class SnapshotStore: if legacy_cache.is_redis(): self._legacy_cache = legacy_cache - def save(self, storage: str, snapshot: Dict, file_count: int = 0, + def save(self, storage: str, snapshot: dict[str, Any], file_count: int = 0, last_snapshot_time: Optional[float] = None, snapshot_time: Optional[float] = None) -> bool: """ @@ -79,7 +79,7 @@ class SnapshotStore: logger.error(f"保存快照失败: {e}") return False - def load_checked(self, storage: str) -> Tuple[Optional[Dict], bool]: + def load_checked(self, storage: str) -> Tuple[Optional[dict[str, Any]], bool]: """ 从文件缓存加载快照,并区分「快照不存在」与「读取失败」。 读取失败时不能当作首次快照处理,否则会静默丢弃已有基线。 @@ -103,7 +103,7 @@ class SnapshotStore: logger.error(f"加载快照失败: {e}") return None, False - def load(self, storage: str) -> Optional[Dict]: + def load(self, storage: str) -> Optional[dict[str, Any]]: """ 从文件缓存加载快照。 :param storage: 存储名称 diff --git a/app/runtime/cache.py b/app/runtime/cache.py index 17b7dcc60..97039fea9 100644 --- a/app/runtime/cache.py +++ b/app/runtime/cache.py @@ -14,12 +14,15 @@ from typing import ( Any, AsyncGenerator, Callable, + Coroutine, Dict, Generator, Literal, Optional, + Protocol, Tuple, TypedDict, + TypeVar, Union, Unpack, cast, @@ -349,11 +352,13 @@ class AtomicCacheBackend(CacheBackend): class AsyncCacheBackend(CacheBackend): """ - 缓存后端基类,定义通用的缓存接口(异步)。 + 异步缓存契约,保留插件依赖的同步基类继承关系。 + + 异步返回值无法满足同步父类的返回合同,兼容例外仅登记对应方法签名。 """ @abstractmethod - async def set(self, key: str, value: Any, ttl: Optional[int] = None, + async def set(self, key: str, value: Any, ttl: Optional[int] = None, # type: ignore[override] region: Optional[str] = DEFAULT_CACHE_REGION, **kwargs: object) -> None: """ 设置缓存。 @@ -367,7 +372,7 @@ class AsyncCacheBackend(CacheBackend): pass @abstractmethod - async def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool: + async def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool: # type: ignore[override] """ 判断缓存键是否存在。 @@ -389,7 +394,7 @@ class AsyncCacheBackend(CacheBackend): pass @abstractmethod - async def delete(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> None: + async def delete(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> None: # type: ignore[override] """ 删除缓存。 @@ -399,7 +404,7 @@ class AsyncCacheBackend(CacheBackend): pass @abstractmethod - async def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None: + async def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None: # type: ignore[override] """ 清除指定区域的缓存或全部缓存。 @@ -408,7 +413,7 @@ class AsyncCacheBackend(CacheBackend): pass @abstractmethod - async def items(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> AsyncGenerator[Tuple[str, Any], None]: + def items(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> AsyncGenerator[Tuple[str, Any], None]: # type: ignore[override] """ 获取指定区域的所有缓存项。 @@ -417,21 +422,21 @@ class AsyncCacheBackend(CacheBackend): """ pass - async def keys(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> AsyncGenerator[str, None]: + async def keys(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> AsyncGenerator[str, None]: # type: ignore[override] """ 获取所有缓存键,类似 dict.keys()(异步)。 """ async for key, _ in self.items(region=region): yield key - async def values(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> AsyncGenerator[Any, None]: + async def values(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> AsyncGenerator[Any, None]: # type: ignore[override] """ 获取所有缓存值,类似 dict.values()(异步)。 """ async for _, value in self.items(region=region): yield value - async def update(self, other: Dict[str, Any], region: Optional[str] = DEFAULT_CACHE_REGION, + async def update(self, other: Dict[str, Any], region: Optional[str] = DEFAULT_CACHE_REGION, # type: ignore[override] ttl: Optional[int] = None, **kwargs: object) -> None: """ 更新缓存,类似 dict.update()(异步)。 @@ -451,7 +456,7 @@ class AsyncCacheBackend(CacheBackend): return default raise KeyError(key) - async def popitem(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> Tuple[str, Any]: + async def popitem(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> Tuple[str, Any]: # type: ignore[override] """ 弹出最后一个缓存项,类似 dict.popitem()(异步)。 """ @@ -476,7 +481,7 @@ class AsyncCacheBackend(CacheBackend): return value @abstractmethod - async def close(self) -> None: + async def close(self) -> None: # type: ignore[override] """ 关闭缓存连接。 """ @@ -728,7 +733,7 @@ class AsyncMemoryBackend(AsyncCacheBackend): """ self._backend = MemoryBackend(cache_type=cache_type, maxsize=maxsize, ttl=ttl) - async def set(self, key: str, value: Any, ttl: Optional[int] = None, + async def set(self, key: str, value: Any, ttl: Optional[int] = None, # type: ignore[override] region: Optional[str] = DEFAULT_CACHE_REGION, **kwargs: object) -> None: """ 设置缓存值支持每个 key 独立配置 TTL。 @@ -740,7 +745,7 @@ class AsyncMemoryBackend(AsyncCacheBackend): """ return self._backend.set(key=key, value=value, ttl=ttl, region=region, **kwargs) - async def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool: + async def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool: # type: ignore[override] """ 判断缓存键是否存在。 @@ -760,7 +765,7 @@ class AsyncMemoryBackend(AsyncCacheBackend): """ return self._backend.get(key=key, region=region) - async def delete(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> None: + async def delete(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> None: # type: ignore[override] """ 删除缓存。 @@ -769,7 +774,7 @@ class AsyncMemoryBackend(AsyncCacheBackend): """ return self._backend.delete(key=key, region=region) - async def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None: + async def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None: # type: ignore[override] """ 清除指定区域的缓存或全部缓存。 @@ -777,7 +782,7 @@ class AsyncMemoryBackend(AsyncCacheBackend): """ return self._backend.clear(region=region) - async def items(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> AsyncGenerator[Tuple[str, Any], None]: + async def items(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> AsyncGenerator[Tuple[str, Any], None]: # type: ignore[override] """ 获取指定区域的所有缓存项。 @@ -787,7 +792,7 @@ class AsyncMemoryBackend(AsyncCacheBackend): for item in self._backend.items(region): yield item - async def close(self) -> None: + async def close(self) -> None: # type: ignore[override] """ 内存缓存不需要关闭资源。 """ @@ -938,12 +943,47 @@ def AsyncCache(cache_type: Literal['ttl', 'lru'] = 'ttl', return AsyncMemoryBackend(cache_type=cache_type, maxsize=maxsize, ttl=ttl) +_CachedFunction = TypeVar("_CachedFunction", bound=Callable[..., Any]) + + +class CacheControls(Protocol): + """ + 同步缓存包装器附加的管理接口,不改变原函数签名。 + + :ivar cache_region: 包装函数使用的缓存区域 + :ivar cache_clear: 清空该函数的缓存区域 + :ivar cache_exists: 检查完整调用参数对应的缓存;实例方法需显式传入实例 + :ivar cache_delete: 删除完整调用参数对应的缓存;实例方法需显式传入实例 + """ + + cache_region: str + cache_clear: Callable[[], None] + cache_exists: Callable[..., bool] + cache_delete: Callable[..., None] + + +class AsyncCacheControls(Protocol): + """ + 异步缓存包装器附加的管理接口,各操作需要等待完成。 + + :ivar cache_region: 包装函数使用的缓存区域 + :ivar cache_clear: 清空该函数的缓存区域 + :ivar cache_exists: 检查完整调用参数对应的缓存;实例方法需显式传入实例 + :ivar cache_delete: 删除完整调用参数对应的缓存;实例方法需显式传入实例 + """ + + cache_region: str + cache_clear: Callable[[], Coroutine[Any, Any, None]] + cache_exists: Callable[..., Coroutine[Any, Any, bool]] + cache_delete: Callable[..., Coroutine[Any, Any, None]] + + def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Optional[int] = None, ttl_provider: Optional[Callable[[], Optional[int]]] = None, skip_none: Optional[bool] = True, skip_empty: Optional[bool] = False, shared_key: Optional[str] = None, skip_if: Optional[Callable[[Any], bool]] = None, empty_ttl: Optional[int] = None, empty_if: Optional[Callable[[Any], bool]] = None, - local_only: bool = False): + local_only: bool = False) -> Callable[[_CachedFunction], _CachedFunction]: """ 自定义缓存装饰器,支持配置缓存区域的 maxsize 和每个 key 的 ttl。 @@ -969,14 +1009,11 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt :return: 装饰器函数 """ - def decorator(func): + def decorator(func: _CachedFunction) -> _CachedFunction: """ 按函数签名创建保留同步或异步调用方式的缓存包装。 """ # 函数签名在装饰后不会变化,预计算可避免每次缓存访问都重复反射。 - """ - 为目标函数绑定独立缓存区域,并保留其参数签名供缓存键计算。 - """ signature = inspect.signature(func) parameter_names = list(signature.parameters.keys()) cache_parameter_names = ( @@ -1097,7 +1134,7 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt ) @wraps(func) - async def async_wrapper(*args, **kwargs): + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: """ 异步读取有效缓存;强制刷新或未命中时等待原函数并按策略写入结果。 """ @@ -1139,11 +1176,12 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt cache_key = __get_cache_key(args, kwargs) await async_cache_backend.delete(cache_key, region=cache_region) - async_wrapper.cache_region = cache_region - async_wrapper.cache_clear = async_cache_clear - async_wrapper.cache_exists = async_cache_exists - async_wrapper.cache_delete = async_cache_delete - return async_wrapper + async_cached = cast(AsyncCacheControls, async_wrapper) + async_cached.cache_region = cache_region + async_cached.cache_clear = async_cache_clear + async_cached.cache_exists = async_cache_exists + async_cached.cache_delete = async_cache_delete + return cast(_CachedFunction, async_wrapper) else: sync_cache_backend = Cache( cache_type="ttl" if ttl is not None or ttl_provider is not None else "lru", @@ -1153,7 +1191,7 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt ) @wraps(func) - def wrapper(*args, **kwargs): + def wrapper(*args: Any, **kwargs: Any) -> Any: """ 优先返回有效缓存;强制刷新或未命中时执行同步函数并按策略写入结果。 """ @@ -1193,11 +1231,12 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt cache_key = __get_cache_key(args, kwargs) sync_cache_backend.delete(cache_key, region=cache_region) - wrapper.cache_region = cache_region - wrapper.cache_clear = cache_clear - wrapper.cache_exists = cache_exists - wrapper.cache_delete = cache_delete - return wrapper + sync_cached = cast(CacheControls, wrapper) + sync_cached.cache_region = cache_region + sync_cached.cache_clear = cache_clear + sync_cached.cache_exists = cache_exists + sync_cached.cache_delete = cache_delete + return cast(_CachedFunction, wrapper) return decorator diff --git a/app/runtime/coalesce.py b/app/runtime/coalesce.py index a9faf9f4b..0253d6bb9 100644 --- a/app/runtime/coalesce.py +++ b/app/runtime/coalesce.py @@ -11,7 +11,7 @@ import asyncio import inspect from dataclasses import dataclass from enum import Enum -from typing import Any, Awaitable, Callable, Dict, Hashable, Optional, Union +from typing import Any, Awaitable, Callable, Dict, Hashable, Optional, Union, cast from app.runtime.log import logger @@ -208,7 +208,7 @@ class EventCoalescer: try: if self._is_flush_async: # 已通过协程函数检查确认回调可等待,联合回调类型无法静态表达该收窄。 - await self._on_flush(summary) # type: ignore[misc] + await cast(Callable[[CoalesceSummary], Awaitable[None]], self._on_flush)(summary) else: self._on_flush(summary) # 基础设施不能因消费者异常崩溃 diff --git a/app/runtime/config.py b/app/runtime/config.py index ba2eda485..67c76fef9 100644 --- a/app/runtime/config.py +++ b/app/runtime/config.py @@ -24,6 +24,7 @@ from typing import ( Optional, Tuple, Union, + cast, get_args, get_origin, ) @@ -205,7 +206,7 @@ class ConfigModel(BaseModel): # 安全认证配置 SECRET_KEY: str = Field(default=secrets.token_urlsafe(32), description='密钥') RESOURCE_SECRET_KEY: str = Field(default=secrets.token_urlsafe(32), description='RESOURCE密钥') - ALLOWED_HOSTS: list = Field( + ALLOWED_HOSTS: list[str] = Field( default_factory=lambda: ["*"], description='允许的跨域来源;为 "*" 时浏览器跨域请求不携带凭据,需要跨域带 Cookie 请列出显式来源', ) @@ -263,7 +264,7 @@ class ConfigModel(BaseModel): 'feishu/discord 循环不访问数据库,走此路径的只有插件与调度器兜底分支的零星调用,因此取值不必大——它直接计入连接总额度' ), ) - DB_CONNECT_ARGS: dict = Field( + DB_CONNECT_ARGS: dict[str, Any] = Field( default_factory=dict, description=( '驱动级连接参数,透传给 create_engine/create_async_engine 的 connect_args。例如经 PgBouncer 事务模式接入时 asyncpg 需要 ' @@ -416,7 +417,7 @@ class ConfigModel(BaseModel): AUTO_UPDATE_RESOURCE: bool = Field(default=True, description='后台检查站点资源包,确认后由启动器在进程拉起前应用') # 媒体文件格式配置 - RMT_MEDIAEXT: list = Field( + RMT_MEDIAEXT: list[str] = Field( default_factory=lambda: [ ".mp4", ".mkv", @@ -437,8 +438,8 @@ class ConfigModel(BaseModel): ".tp", ".f4v", ], description='支持的视频文件后缀格式') - RMT_SUBEXT: list = Field(default_factory=lambda: [".srt", ".ass", ".ssa", ".sup"], description='支持的字幕文件后缀格式') - RMT_AUDIOEXT: list = Field( + RMT_SUBEXT: list[str] = Field(default_factory=lambda: [".srt", ".ass", ".ssa", ".sup"], description='支持的字幕文件后缀格式') + RMT_AUDIOEXT: list[str] = Field( default_factory=lambda: [ ".aac", ".ac3", @@ -513,7 +514,7 @@ class ConfigModel(BaseModel): TORRENT_TAG: str = Field(default="MOVIEPILOT", description='种子标签') DOWNLOAD_SUBTITLE: bool = Field(default=True, description='下载站点字幕') AUTO_DOWNLOAD_USER: Optional[str] = Field(default=None, description='交互搜索自动下载用户ID,使用,分割') - DOWNLOAD_TMPEXT: list = Field(default_factory=lambda: [".!qb", ".part"], description='下载器临时文件后缀') + DOWNLOAD_TMPEXT: list[str] = Field(default_factory=lambda: [".!qb", ".part"], description='下载器临时文件后缀') # 目录监控配置 MONITOR_NETWORK_FAST_MODE: bool = Field( @@ -591,7 +592,7 @@ class ConfigModel(BaseModel): "/{{album or 'Unknown Album'}}{% if year %} ({{year}}){% endif %}" "{% if total_discs and total_discs > 1 %}/Disc {{disc_number or 1}}{% endif %}" "/{% if track %}{{track}} - {% endif %}{{title}}{{fileExt}}", description='音乐重命名格式') - RENAME_FORMAT_S0_NAMES: list = Field(default=["Specials", "SPs"], description='重命名时支持的S0别名') + RENAME_FORMAT_S0_NAMES: list[str] = Field(default=["Specials", "SPs"], description='重命名时支持的S0别名') DEFAULT_SUB: Optional[str] = Field(default="zh-cn", description='为指定默认字幕添加.default后缀') SCRAP_FOLLOW_TMDB: bool = Field(default=True, description='新增已入库媒体是否跟随TMDB信息变化') RECOGNIZE_PLUGIN_FIRST: bool = Field(default=False, description='优先使用辅助识别') @@ -669,7 +670,7 @@ class ConfigModel(BaseModel): MEMORY_GC_INTERVAL: int = Field(default=30, description='主动内存回收时间间隔(分钟),0为不启用') # 安全配置 - SECURITY_IMAGE_DOMAINS: list = Field( + SECURITY_IMAGE_DOMAINS: list[str] = Field( default=[ "image.tmdb.org", "images.tmdb.org", @@ -696,8 +697,8 @@ class ConfigModel(BaseModel): "commons.wikimedia.org", "upload.wikimedia.org", ], description='允许的图片缓存域名') - IMAGE_PROXY_ALLOWED_PRIVATE_RANGES: list = Field(default=[], description='图片代理允许访问的非公网 IP/CIDR,默认不放行任何非公网解析结果') - SECURITY_IMAGE_SUFFIXES: list = Field( + IMAGE_PROXY_ALLOWED_PRIVATE_RANGES: list[str] = Field(default=[], description='图片代理允许访问的非公网 IP/CIDR,默认不放行任何非公网解析结果') + SECURITY_IMAGE_SUFFIXES: list[str] = Field( default=[".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif"], description='允许的图片文件后缀格式') PASSKEY_REQUIRE_UV: bool = Field(default=True, description='PassKey 是否强制用户验证(生物识别等)') API_DOCS_ENABLE: bool = Field( @@ -798,8 +799,7 @@ def _get_secrets_dir() -> Optional[str]: return None if value.lower() in {"", "false", "off", "0"} else value -# 严格类型检查跳过第三方源码导入,因此无法静态解析 Pydantic 来源基类。 -class _DeploymentSecretsSource(SecretsSettingsSource): # type: ignore[misc] +class _DeploymentSecretsSource(SecretsSettingsSource): """ 按配置名读取外部文件,隔离坏文件并保护编排管理的字段不被写回。 @@ -901,11 +901,11 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel): :return: 从高到低排列的来源 """ secret_source = _DeploymentSecretsSource( - settings_cls, secrets_dir=file_secret_settings.secrets_dir + settings_cls, secrets_dir=cast(SecretsSettingsSource, file_secret_settings).secrets_dir ) return init_settings, env_settings, dotenv_settings, secret_source - def __init__(self, **kwargs): + def __init__(self, **kwargs: Any) -> None: """ 加载环境配置并确保运行时目录和内置配置可用。 """ @@ -1586,7 +1586,7 @@ class GlobalVar(object): self._loop_registry.replace_compat(None) @property - def SUBSCRIPTIONS(self) -> List[dict]: + def SUBSCRIPTIONS(self) -> List[dict[str, Any]]: """ 兼容旧代码在持锁后直接访问订阅列表。 """ @@ -1626,19 +1626,19 @@ class GlobalVar(object): """ return runtime_stop_state.is_system_stopped - def get_subscriptions(self): + def get_subscriptions(self) -> list[dict[str, Any]]: """ 获取webpush订阅。 """ return self._push_registry.list() - def push_subscription(self, subscription: dict): + def push_subscription(self, subscription: dict[str, Any]) -> None: """ 添加或更新webpush订阅。 """ self._push_registry.upsert(subscription) - def remove_subscription(self, subscription: dict) -> bool: + def remove_subscription(self, subscription: dict[str, Any]) -> bool: """ 根据 endpoint 移除webpush订阅,返回是否实际删除。 """ diff --git a/app/runtime/events.py b/app/runtime/events.py index 280bbfef4..edc6e6c05 100644 --- a/app/runtime/events.py +++ b/app/runtime/events.py @@ -11,7 +11,7 @@ import uuid from contextvars import ContextVar from dataclasses import dataclass from queue import Empty, PriorityQueue -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set, Tuple, Type, TypeVar, Union +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set, Tuple, Type, TypeVar, Union, cast from app.foundation.singleton import Singleton from app.runtime.correlation import get_correlation_id @@ -29,7 +29,7 @@ from app.runtime.loop import main_loop_registry from app.runtime.observability import record_metric from app.runtime.rate import ExponentialBackoffRateLimiter from app.runtime.thread import ThreadHelper -from app.schemas.event import ChainEventData +from app.schemas.event import BaseEventData from app.schemas.types import ChainEventType, EventType if TYPE_CHECKING: @@ -77,7 +77,7 @@ class Event: """ def __init__(self, event_type: Union[EventType, ChainEventType, str], - event_data: Optional[Union[Dict, ChainEventData]] = None, + event_data: Optional[Union[Dict[str, Any], BaseEventData]] = None, priority: Optional[int] = DEFAULT_EVENT_PRIORITY, correlation_id: Optional[str] = None): """ @@ -103,7 +103,7 @@ class Event: # 事件数据 self.event_data = event_data or {} # 事件优先级 - self.priority = priority + self.priority = priority if priority is not None else DEFAULT_EVENT_PRIORITY self.correlation_id = correlation_id or get_correlation_id() def __repr__(self) -> str: @@ -122,7 +122,7 @@ class Event: return snapshot_event_data(self.event_type, self.event_data) - def __lt__(self, other): + def __lt__(self, other: "Event") -> bool: """ 定义事件对象的比较规则,基于优先级比较 优先级小的事件会被认为“更小”,优先级高的事件将被认为“更大” @@ -156,9 +156,9 @@ class EventManager(metaclass=Singleton): # 优先级队列 self.__event_queue: PriorityQueue[Any] = PriorityQueue() # 广播事件的订阅者 - self.__broadcast_subscribers: Dict[EventType, Dict[str, Callable]] = {} + self.__broadcast_subscribers: Dict[EventType, Dict[str, Callable[..., Any]]] = {} # 链式事件的订阅者 - self.__chain_subscribers: Dict[ChainEventType, Dict[str, tuple[int, Callable]]] = {} + self.__chain_subscribers: Dict[ChainEventType, Dict[str, tuple[int, Callable[..., Any]]]] = {} # 禁用的事件处理器集合 self.__disabled_handlers: Set[str] = set() # 禁用的事件处理器类集合 @@ -483,6 +483,9 @@ class EventManager(metaclass=Singleton): owner = object() async def _tracked() -> None: + """ + 在所属事件处理器上下文中执行回调,并负责完成信号和上下文恢复。 + """ started.set() context_token = _CURRENT_EVENT_HANDLER_OWNER.set(owner) try: @@ -518,16 +521,21 @@ class EventManager(metaclass=Singleton): def _complete_unstarted_submission( submitted: concurrent.futures.Future[Any], ) -> None: + """ + 关闭尚未启动就被取消的协程,并同步所属完成信号。 + """ if submitted.cancelled() and not started.is_set(): coroutine.close() completion.cancel() handle.add_done_callback(_complete_unstarted_submission) - completion.add_done_callback( - lambda _completed, current_owner=owner: ( - self.__remove_async_handle(current_owner) - ) - ) + def _remove_completed_handle(_completed: concurrent.futures.Future[Any]) -> None: + """ + 事件处理完成后移除 owner 对应的异步句柄。 + """ + self.__remove_async_handle(owner) + + completion.add_done_callback(_remove_completed_handle) return True def __remove_async_handle(self, owner: object) -> None: @@ -583,7 +591,7 @@ class EventManager(metaclass=Singleton): """ return self.__registry.check(etype) - def send_event(self, etype: Union[EventType, ChainEventType], data: Optional[Union[Dict, ChainEventData]] = None, + def send_event(self, etype: Union[EventType, ChainEventType], data: Optional[Union[Dict[str, Any], BaseEventData]] = None, priority: Optional[int] = DEFAULT_EVENT_PRIORITY) -> Optional[Event]: """ 发送事件,根据事件类型决定是广播事件还是链式事件。 @@ -606,7 +614,7 @@ class EventManager(metaclass=Singleton): def send_event_strict( self, etype: EventType, - data: Optional[Union[dict[str, object], ChainEventData]] = None, + data: Optional[Union[dict[str, object], BaseEventData]] = None, priority: Optional[int] = DEFAULT_EVENT_PRIORITY, ) -> Event: """ @@ -625,7 +633,7 @@ class EventManager(metaclass=Singleton): async def async_send_event_strict( self, etype: EventType, - data: Optional[Union[dict[str, object], ChainEventData]] = None, + data: Optional[Union[dict[str, object], BaseEventData]] = None, priority: Optional[int] = DEFAULT_EVENT_PRIORITY, ) -> Event: """ @@ -654,7 +662,7 @@ class EventManager(metaclass=Singleton): return asyncio.run_coroutine_threadsafe(coroutine, loop).result() async def async_send_event(self, etype: Union[EventType, ChainEventType], - data: Optional[Union[Dict, ChainEventData]] = None, + data: Optional[Union[Dict[str, Any], BaseEventData]] = None, priority: Optional[int] = DEFAULT_EVENT_PRIORITY) -> Optional[Event]: """ 异步发送事件,根据事件类型决定是广播事件还是链式事件。 @@ -674,8 +682,8 @@ class EventManager(metaclass=Singleton): logger.error(f"Unknown event type: {etype}") return None - def add_event_listener(self, event_type: Union[EventType, ChainEventType], handler: Callable, - priority: Optional[int] = DEFAULT_EVENT_PRIORITY): + def add_event_listener(self, event_type: Union[EventType, ChainEventType], handler: Callable[..., Any], + priority: Optional[int] = DEFAULT_EVENT_PRIORITY) -> None: """ 注册事件处理器,将处理器添加到对应的事件订阅列表中。 @@ -685,7 +693,7 @@ class EventManager(metaclass=Singleton): """ self.__registry.add(event_type, handler, priority or DEFAULT_EVENT_PRIORITY) - def remove_event_listener(self, event_type: Union[EventType, ChainEventType], handler: Callable): + def remove_event_listener(self, event_type: Union[EventType, ChainEventType], handler: Callable[..., Any]) -> None: """ 移除事件处理器,将处理器从对应事件的订阅列表中删除。 @@ -694,7 +702,7 @@ class EventManager(metaclass=Singleton): """ self.__registry.remove(event_type, handler) - def disable_event_handler(self, target: Union[Callable, type]): + def disable_event_handler(self, target: Union[Callable[..., Any], type]) -> None: """ 禁用指定的事件处理器或事件处理器类。 @@ -702,7 +710,7 @@ class EventManager(metaclass=Singleton): """ self.__registry.disable(target) - def enable_event_handler(self, target: Union[Callable, type]): + def enable_event_handler(self, target: Union[Callable[..., Any], type]) -> None: """ 启用指定的事件处理器或事件处理器类。 @@ -710,7 +718,7 @@ class EventManager(metaclass=Singleton): """ self.__registry.enable(target) - def visualize_handlers(self) -> List[Dict]: + def visualize_handlers(self) -> List[Dict[str, Any]]: """ 可视化所有事件处理器,包括是否被禁用的状态。 @@ -720,7 +728,7 @@ class EventManager(metaclass=Singleton): return self.__registry.visualize() @classmethod - def __get_handler_identifier(cls, target: Union[Callable, type]) -> Optional[str]: + def __get_handler_identifier(cls, target: Union[Callable[..., Any], type]) -> Optional[str]: """ 获取处理器或处理器类的唯一标识符,包括模块名和类名/方法名。 @@ -730,7 +738,7 @@ class EventManager(metaclass=Singleton): return EventRegistry.handler_identifier(target) @classmethod - def __get_class_from_callable(cls, handler: Callable) -> Optional[str]: + def __get_class_from_callable(cls, handler: Callable[..., Any]) -> Optional[str]: """ 获取可调用对象所属类的唯一标识符。 @@ -739,7 +747,7 @@ class EventManager(metaclass=Singleton): """ return EventRegistry.handler_class_identifier(handler) - def __is_handler_enabled(self, handler: Callable) -> bool: + def __is_handler_enabled(self, handler: Callable[..., Any]) -> bool: """ 检查处理器是否已启用(没有被禁用)。 @@ -813,7 +821,7 @@ class EventManager(metaclass=Singleton): @classmethod def __should_dispatch_to_target_plugin( cls, - handler: Callable, + handler: Callable[..., Any], handler_identifier: str, target_plugin_id: str, ) -> bool: @@ -826,7 +834,7 @@ class EventManager(metaclass=Singleton): target_plugin_id, ) - def __safe_invoke_handler(self, handler: Callable, event: Event): + def __safe_invoke_handler(self, handler: Callable[..., Any], event: Event) -> None: """ 调用处理器,处理链式或广播事件。 @@ -835,7 +843,7 @@ class EventManager(metaclass=Singleton): """ self.__dispatcher.safe_invoke_sync(handler, event) - async def __safe_invoke_handler_async(self, handler: Callable, event: Event): + async def __safe_invoke_handler_async(self, handler: Callable[..., Any], event: Event) -> None: """ 异步调用处理器,处理链式事件。 @@ -844,7 +852,7 @@ class EventManager(metaclass=Singleton): """ await self.__dispatcher.safe_invoke_async(handler, event) - def __invoke_handler_by_type_sync(self, handler: Callable, event: Event): + def __invoke_handler_by_type_sync(self, handler: Callable[..., Any], event: Event) -> None: """ 同步方式根据处理器类型调用相应的方法。 @@ -853,7 +861,7 @@ class EventManager(metaclass=Singleton): """ self.__dispatcher.invoke_sync(handler, event) - async def __invoke_handler_by_type_async(self, handler: Callable, event: Event): + async def __invoke_handler_by_type_async(self, handler: Callable[..., Any], event: Event) -> None: """ 异步方式根据处理器类型调用相应的方法。 @@ -863,7 +871,7 @@ class EventManager(metaclass=Singleton): await self.__dispatcher.invoke_async(handler, event) @staticmethod - def __parse_handler_names(handler: Callable) -> Tuple[str, str]: + def __parse_handler_names(handler: Callable[..., Any]) -> Tuple[str, str]: """ 解析处理器的类名和方法名。 @@ -873,7 +881,7 @@ class EventManager(metaclass=Singleton): return EventBindingResolver.parse_handler_names(handler) @staticmethod - def __get_handler_owner_class(handler: Callable) -> Optional[Type[Any]]: + def __get_handler_owner_class(handler: Callable[..., Any]) -> Optional[Type[Any]]: """ 从处理器对象本身解析声明它的类,不按命名约定动态导入模块。 """ @@ -881,8 +889,8 @@ class EventManager(metaclass=Singleton): def __resolve_handler( self, - handler: Callable, - ) -> Optional[Tuple[Callable, EventHandlerBinding, str, str]]: + handler: Callable[..., Any], + ) -> Optional[Tuple[Callable[..., Any], EventHandlerBinding, str, str]]: """ 将装饰阶段保存的函数解析为当前运行实例上的可调用方法。 """ @@ -959,7 +967,7 @@ class EventManager(metaclass=Singleton): if isinstance(etype, list): event_list = etype else: - event_list = [etype] + event_list = [cast(_EventKind, etype)] for event in event_list: if isinstance(event, (EventType, ChainEventType)): diff --git a/app/runtime/extensions/plugin/manager.py b/app/runtime/extensions/plugin/manager.py index 98eaaac29..20d758212 100644 --- a/app/runtime/extensions/plugin/manager.py +++ b/app/runtime/extensions/plugin/manager.py @@ -343,7 +343,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): return {pid: PluginRuntimeStatus.LOAD_FAILED} return {} - def init_plugin(self, plugin_id: str, conf: dict) -> None: + def init_plugin(self, plugin_id: str, conf: Dict[str, Any]) -> None: """ 初始化插件。 @@ -623,7 +623,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): def _get_federated_plugin_change( self, event_path: Path, - ) -> Optional[Tuple[str, Optional[dict], bool]]: + ) -> Optional[Tuple[str, Optional[Dict[str, Any]], bool]]: """ 识别运行态 Vue 插件声明目录内的构建产物变化。 @@ -640,13 +640,13 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ return self._plugin_paths.runtime_plugin(event_path) - def _get_local_plugin_candidate_from_path(self, event_path: Path) -> Optional[dict]: + def _get_local_plugin_candidate_from_path(self, event_path: Path) -> Optional[Dict[str, Any]]: """ 根据本地插件仓库路径解析具体插件候选,保留 plugins/plugins.v2 来源差异。 """ return self._plugin_paths.local_candidate(event_path) - def _sync_local_plugin_if_installed(self, pid: str, candidate: Optional[dict] = None) -> bool: + def _sync_local_plugin_if_installed(self, pid: str, candidate: Optional[Dict[str, Any]] = None) -> bool: """ 已安装本地插件源码变化时,同步到运行目录。 """ @@ -877,7 +877,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ return self._plugin_registry.settling - def get_plugin_config(self, pid: str) -> dict: + def get_plugin_config(self, pid: str) -> Dict[str, Any]: """ 获取插件配置。 @@ -984,7 +984,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ return self._plugin_instance_store.get(instance_id) is not None - def save_plugin_config(self, pid: str, conf: dict, force: bool = False) -> bool: + def save_plugin_config(self, pid: str, conf: Dict[str, Any], force: bool = False) -> bool: """ 保存插件配置。 @@ -1000,7 +1000,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): return False async def async_save_plugin_config( - self, pid: str, conf: dict, force: bool = False + self, pid: str, conf: Dict[str, Any], force: bool = False ) -> bool: """ 异步保存插件配置。 @@ -1099,7 +1099,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ return self._plugin_projection().services(pid) - def get_plugin_modules(self, pid: Optional[str] = None) -> Dict[tuple, Dict[str, Any]]: + def get_plugin_modules(self, pid: Optional[str] = None) -> Dict[tuple[str, str], Dict[str, Any]]: """ 收集插件对模块方法的实现。 @@ -1357,7 +1357,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): base_version_plugins, ) - def _process_plugin_info(self, pid: str, plugin_info: dict, market: str, + def _process_plugin_info(self, pid: str, plugin_info: Dict[str, Any], market: str, installed_apps: List[str], add_time: int, package_version: Optional[str] = None) -> Optional[_SchemaPlugin]: """ @@ -1435,7 +1435,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): def __set_and_check_auth_level( self, plugin: Union[_SchemaPlugin, Type[Any]], - source: Optional[Union[dict, Type[Any]]] = None, + source: Optional[Union[Dict[str, Any], Type[Any]]] = None, ) -> bool: """ 设置并检查插件的认证级别。 diff --git a/app/runtime/extensions/plugin/projection.py b/app/runtime/extensions/plugin/projection.py index a2468950f..344eedb52 100644 --- a/app/runtime/extensions/plugin/projection.py +++ b/app/runtime/extensions/plugin/projection.py @@ -63,7 +63,7 @@ class PluginProjection: """ 聚合插件命令并补充插件 ID。 """ - commands: list[dict] = [] + commands: list[dict[str, Any]] = [] for plugin_id, plugin in self._items(pid): if not supports_plugin_hook(plugin, "get_command"): continue @@ -81,7 +81,7 @@ class PluginProjection: """ 聚合插件 API 并补充宿主路径和默认认证方式,端点绑定发起实例的日志上下文。 """ - apis: list[dict] = [] + apis: list[dict[str, Any]] = [] for plugin_id, plugin in self._items(pid): if not supports_plugin_hook(plugin, "get_api"): continue @@ -103,7 +103,7 @@ class PluginProjection: """ 聚合启用插件的定时服务。 """ - services: list[dict] = [] + services: list[dict[str, Any]] = [] for plugin_id, plugin in self._items(pid): if not supports_plugin_hook(plugin, "get_service"): continue @@ -114,11 +114,11 @@ class PluginProjection: self._logger.error(f"获取插件 {plugin_id} 服务出错:{str(error)}") return services - def modules(self, pid: Optional[str] = None) -> Dict[tuple, Dict[str, Any]]: + def modules(self, pid: Optional[str] = None) -> Dict[tuple[str, str], Dict[str, Any]]: """ 聚合启用插件的模块方法清单。 """ - modules: dict[tuple, dict] = {} + modules: dict[tuple[str, str], dict[str, Any]] = {} for plugin_id, plugin in self._items(pid): if not supports_plugin_hook(plugin, "get_module"): continue @@ -133,7 +133,7 @@ class PluginProjection: f"插件 {plugin_id} 的 get_module() 返回值必须是字典,实际是 {type(declared).__name__}" ) continue - modules[(plugin_id, plugin.get_name())] = declared + modules[(plugin_id, plugin.get_name())] = dict(declared) except Exception as error: self._logger.error(f"获取插件 {plugin_id} 模块出错:{str(error)}") return modules @@ -142,7 +142,7 @@ class PluginProjection: """ 聚合启用插件声明的媒体数据源。 """ - sources: list[dict] = [] + sources: list[dict[str, Any]] = [] for plugin_id, plugin in self._items(pid): if not supports_plugin_hook(plugin, "get_media_source"): continue @@ -176,7 +176,7 @@ class PluginProjection: """ 聚合启用插件的工作流动作。 """ - actions: list[dict] = [] + actions: list[dict[str, Any]] = [] for plugin_id, plugin in self._items(pid): if not supports_plugin_hook(plugin, "get_actions"): continue @@ -541,7 +541,7 @@ class PluginProjection: """ 投影启用插件的单仪表板或多仪表板元信息。 """ - metadata = [] + metadata: list[dict[str, str]] = [] for plugin_id, plugin in self._items(None): if not supports_plugin_hook(plugin, "get_dashboard"): continue diff --git a/app/runtime/log.py b/app/runtime/log.py index 8d0be52dd..99ae95347 100644 --- a/app/runtime/log.py +++ b/app/runtime/log.py @@ -29,7 +29,7 @@ from pydantic import BaseModel, ConfigDict, Field # mypy 严格检查跳过第三方实现导入,因此无法在本文件解析 Pydantic 元类类型。 -class LogConfigModel(BaseModel): # type: ignore[misc] +class LogConfigModel(BaseModel): """ 描述日志级别、格式和文件写入策略。 diff --git a/app/scheduler/facade.py b/app/scheduler/facade.py index 16d427383..32ba6441a 100644 --- a/app/scheduler/facade.py +++ b/app/scheduler/facade.py @@ -159,7 +159,8 @@ class Scheduler( """ 插件重载后按当前实例重新注册全部定时服务 """ - plugin_id = event.event_data.get("plugin_id") + event_data = event.event_data if isinstance(event.event_data, dict) else {} + plugin_id = event_data.get("plugin_id") if not plugin_id: return self.update_plugin_job(plugin_id) diff --git a/app/scheduler/progress.py b/app/scheduler/progress.py index 38aaa9f03..31310d2ae 100644 --- a/app/scheduler/progress.py +++ b/app/scheduler/progress.py @@ -97,7 +97,11 @@ class SchedulerProgressOwner(_SchedulerOwnerBase): # 收尾可能发生在事件循环上(_run_coro_job),使用异步进度后端避免阻塞 progress = AsyncProgressHelper(self._get_progress_key(job_id)) try: - current_progress = await progress.get() or {} + current_progress_raw = await progress.get() + current_progress: dict[str, Any] = ( + dict(current_progress_raw) + if isinstance(current_progress_raw, dict) else {} + ) progress_value = 100 if success else current_progress.get("value", 0) await progress.end( text=f"{job_name} {'执行完成' if success else '执行失败'}", @@ -131,10 +135,14 @@ class SchedulerProgressOwner(_SchedulerOwnerBase): last_started_at = job.get("last_started_at") if job else None last_finished_at = job.get("last_finished_at") if job else None last_error = job.get("last_error") if job else None - detail = ProgressHelper(self._get_progress_key(job_id)).get() or {} + detail_raw = ProgressHelper(self._get_progress_key(job_id)).get() + detail: dict[str, Any] = ( + dict(detail_raw) if isinstance(detail_raw, dict) else {} + ) if not job and not detail: return None - data = dict(detail.get("data") or {}) + detail_data = detail.get("data") + data: dict[str, Any] = dict(detail_data) if isinstance(detail_data, dict) else {} progress_generation = data.pop("_generation", None) if job and progress_generation is not None and progress_generation != job.get("_generation", 0): detail = {} @@ -174,10 +182,14 @@ class SchedulerProgressOwner(_SchedulerOwnerBase): last_finished_at = job.get("last_finished_at") if job else None last_error = job.get("last_error") if job else None # 异步后端读取,避免在事件循环上阻塞 - detail = await AsyncProgressHelper(self._get_progress_key(job_id)).get() or {} + detail_raw = await AsyncProgressHelper(self._get_progress_key(job_id)).get() + detail: dict[str, Any] = ( + dict(detail_raw) if isinstance(detail_raw, dict) else {} + ) if not job and not detail: return None - data = dict(detail.get("data") or {}) + detail_data = detail.get("data") + data: dict[str, Any] = dict(detail_data) if isinstance(detail_data, dict) else {} progress_generation = data.pop("_generation", None) if job and progress_generation is not None and progress_generation != job.get("_generation", 0): detail = {} @@ -229,6 +241,9 @@ class SchedulerProgressOwner(_SchedulerOwnerBase): key = self._get_progress_key(job_id) async def _update() -> None: + """ + 只更新仍属于当前任务代际的进度,避免旧回调覆盖重建后的任务。 + """ with self._lock: current_job = self._jobs.get(job_id) if current_job is not job or current_job.get("_generation", 0) != generation: diff --git a/app/schemas/agent.py b/app/schemas/agent.py index 7faae5817..f6e448a3b 100644 --- a/app/schemas/agent.py +++ b/app/schemas/agent.py @@ -300,8 +300,7 @@ class AgentWebCommandInfo(BaseModel): pid: Optional[str | int] = Field(default=None, description="插件 ID") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class AgentCommandRunRequest(BaseModel): # type: ignore[misc] +class AgentCommandRunRequest(BaseModel): """ 通过 Agent API 触发斜杠命令的请求。 """ @@ -309,8 +308,7 @@ class AgentCommandRunRequest(BaseModel): # type: ignore[misc] command: str = Field(description="要执行的完整斜杠命令") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class AgentCommandRunData(BaseModel): # type: ignore[misc] +class AgentCommandRunData(BaseModel): """ 斜杠命令进入事件队列后的稳定回执。 """ diff --git a/app/schemas/category.py b/app/schemas/category.py index a76ef7b35..1ee4275a2 100644 --- a/app/schemas/category.py +++ b/app/schemas/category.py @@ -109,8 +109,7 @@ ClassificationFieldValueType: TypeAlias = Literal[ """ 字段目录支持的有限输入控件和值语义类型。 """ -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class _ClassificationModel(BaseModel): # type: ignore[misc] +class _ClassificationModel(BaseModel): """ 分类新契约的严格字段基类,避免静默吞掉前端字段拼写错误。 """ @@ -167,8 +166,7 @@ class ClassificationConditionGroup(_ClassificationModel): serialize_by_alias=True, ) - # Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 - @model_validator(mode="after") # type: ignore[misc] + @model_validator(mode="after") def validate_group_operator(self) -> "ClassificationConditionGroup": """ 确保条件组恰好选择一个组操作符。 @@ -239,8 +237,7 @@ class ClassificationPolicyState(_ClassificationModel): description="按 revision 从新到旧排列的最近历史策略", ) - # Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 - @model_validator(mode="after") # type: ignore[misc] + @model_validator(mode="after") def validate_revisions(self) -> "ClassificationPolicyState": """ 保证活动 revision 有效,且历史 revision 唯一、降序并早于活动版本。 @@ -505,8 +502,7 @@ class ClassificationFieldDefinition(_ClassificationModel): description="退役字段建议替换为的标准字段 ID", ) - # Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 - @field_validator("options", mode="before") # type: ignore[misc] + @field_validator("options", mode="before") @classmethod def normalize_options(cls, value: object) -> object: """ @@ -614,8 +610,7 @@ class ClassificationMediaPreviewInput(_ClassificationModel): kind: Literal["media"] = Field(default="media", description="预览输入类型") media: dict[str, JsonData] = Field(description="从媒体搜索结果选择的媒体信息") - # Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 - @model_validator(mode="after") # type: ignore[misc] + @model_validator(mode="after") def validate_media_identity(self) -> "ClassificationMediaPreviewInput": """ 确保搜索结果包含分类所需的来源、编号和媒体类型。 diff --git a/app/schemas/comment.py b/app/schemas/comment.py index 20277a0ed..160b1d69d 100644 --- a/app/schemas/comment.py +++ b/app/schemas/comment.py @@ -7,8 +7,7 @@ from typing import Optional from pydantic import BaseModel, Field -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class MediaCommentAuthor(BaseModel): # type: ignore[misc] +class MediaCommentAuthor(BaseModel): """ 评论作者的身份投影。 """ @@ -19,8 +18,7 @@ class MediaCommentAuthor(BaseModel): # type: ignore[misc] url: Optional[str] = Field(default=None, description="作者主页地址") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class MediaComment(BaseModel): # type: ignore[misc] +class MediaComment(BaseModel): """ 豆瓣短评条目,评分按豆瓣五分制归一。 """ @@ -34,8 +32,7 @@ class MediaComment(BaseModel): # type: ignore[misc] url: Optional[str] = Field(default=None, description="短评地址") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class MediaReview(BaseModel): # type: ignore[misc] +class MediaReview(BaseModel): """ 豆瓣影评条目,正文以摘要形式返回。 """ @@ -51,8 +48,7 @@ class MediaReview(BaseModel): # type: ignore[misc] url: Optional[str] = Field(default=None, description="影评地址") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class MediaCommentPage(BaseModel): # type: ignore[misc] +class MediaCommentPage(BaseModel): """ 短评分页结果,total 为豆瓣侧的总条目数。 """ @@ -63,8 +59,7 @@ class MediaCommentPage(BaseModel): # type: ignore[misc] items: list[MediaComment] = Field(default_factory=list, description="短评列表") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class MediaReviewPage(BaseModel): # type: ignore[misc] +class MediaReviewPage(BaseModel): """ 影评分页结果,total 为豆瓣侧的总条目数。 """ diff --git a/app/schemas/common.py b/app/schemas/common.py index 5bc071e1b..8714e72e7 100644 --- a/app/schemas/common.py +++ b/app/schemas/common.py @@ -2,22 +2,16 @@ API 端点共享的小型业务数据模型。 """ -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, Optional +from typing import Union as Union from pydantic import BaseModel, Field, RootModel -from typing_extensions import TypeAliasType +from typing_extensions import TypeAliasType as TypeAliasType -JsonData = TypeAliasType( - "JsonData", - Union[ - dict[str, "JsonData"], - list["JsonData"], - str, - int, - float, - bool, - None, - ], +# 这些类型构造器曾由 app.schemas 根入口导出,保留兼容导出。 + +type JsonData = ( + dict[str, JsonData] | list[JsonData] | str | int | float | bool | None ) """ 可递归序列化的 JSON 数据;OpenAPI 会展示每一种合法 JSON 结构。 diff --git a/app/schemas/context.py b/app/schemas/context.py index 31f70ccdb..fcb56ce16 100644 --- a/app/schemas/context.py +++ b/app/schemas/context.py @@ -361,8 +361,7 @@ class MediaInfo(OptionalMediaIdentityMixin, BaseModel): bangumi_info: Optional[dict[str, JsonData]] = Field(default=None, description="Bangumi 返回的媒体详情") anilist_info: Optional[dict[str, JsonData]] = Field(default=None, description="AniList 返回的媒体详情") - # Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 - @model_validator(mode="before") # type: ignore[misc] + @model_validator(mode="before") @classmethod def _normalize_category_compatibility(cls, value: Any) -> Any: """ diff --git a/app/schemas/dashboard.py b/app/schemas/dashboard.py index b063cd790..5124c4f66 100644 --- a/app/schemas/dashboard.py +++ b/app/schemas/dashboard.py @@ -121,6 +121,7 @@ class ScheduleInfo(BaseModel): name_i18n: Optional[str] = Field(default=None, description="多语言名称") provider: Optional[str] = Field(default=None, description="提供者") provider_i18n: Optional[str] = Field(default=None, description="多语言提供者") + execution_group: str = Field(default="default", description="调度执行组") status: Optional[str] = Field(default=None, description="调度器提供的服务运行状态展示文本,与最近一次执行结果分开") status_i18n: Optional[str] = Field(default=None, description="多语言状态") next_run: Optional[str] = Field(default=None, description="下次执行时间") diff --git a/app/schemas/download.py b/app/schemas/download.py index 2a23758a8..3ea1684b7 100644 --- a/app/schemas/download.py +++ b/app/schemas/download.py @@ -60,8 +60,7 @@ class SubtitleDownloadData(BaseModel): files: list[str] = Field(default_factory=list, description="已保存字幕文件列表") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class DownloadTaskUpdateRequest(BaseModel): # type: ignore[misc] +class DownloadTaskUpdateRequest(BaseModel): """ 下载任务高级修改请求。 """ @@ -78,8 +77,7 @@ class DownloadTaskUpdateRequest(BaseModel): # type: ignore[misc] seeding_time_limit: Optional[int] = Field(default=None, description="做种时间限制,单位为分钟;具体支持范围由下载器决定") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class DownloadTaskMutationResult(BaseModel): # type: ignore[misc] +class DownloadTaskMutationResult(BaseModel): """ 下载任务单个修改动作的执行结果。 """ @@ -89,8 +87,7 @@ class DownloadTaskMutationResult(BaseModel): # type: ignore[misc] message: str = Field(description="动作结果说明") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class DownloadTaskUpdateData(BaseModel): # type: ignore[misc] +class DownloadTaskUpdateData(BaseModel): """ 一次下载任务高级修改的聚合结果。 """ @@ -100,8 +97,7 @@ class DownloadTaskUpdateData(BaseModel): # type: ignore[misc] results: list[DownloadTaskMutationResult] = Field(default_factory=list, description="各修改动作结果") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class DownloadSourceClassificationRequest(BaseModel): # type: ignore[misc] +class DownloadSourceClassificationRequest(BaseModel): """ 已有任务的识别、归类与种子根目录重命名请求。 """ @@ -122,8 +118,7 @@ class DownloadSourceClassificationRequest(BaseModel): # type: ignore[misc] expected_content_path: Optional[str] = Field(default=None, description="执行前预期的内容路径,用于检测状态变化") expected_root_name: Optional[str] = Field(default=None, description="执行前预期的根目录名称") - # Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 - @_model_validator(mode="after") # type: ignore[misc] + @_model_validator(mode="after") def validate_mode_and_identity(self) -> "DownloadSourceClassificationRequest": """ 手动模式必须给出目录,ID 不能脱离其所属数据源。 @@ -135,8 +130,7 @@ class DownloadSourceClassificationRequest(BaseModel): # type: ignore[misc] return self -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class DownloadSourceClassificationData(BaseModel): # type: ignore[misc] +class DownloadSourceClassificationData(BaseModel): """ 识别与资源目录变更计划,包含可审计的执行结果。 """ diff --git a/app/schemas/event.py b/app/schemas/event.py index 49b9b43e7..219cab4c4 100644 --- a/app/schemas/event.py +++ b/app/schemas/event.py @@ -7,6 +7,7 @@ from enum import Enum as _Enum from pathlib import Path from typing import Annotated as _Annotated from typing import Any, Callable, Dict, Iterable, List, Optional, Set +from typing import Self as _Self from typing import Union as _Union from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator @@ -72,8 +73,7 @@ class ContextSnapshot(_ContextSnapshotBase): arbitrary_types_allowed=True, ) - # Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 - @model_validator(mode="before") # type: ignore[misc] + @model_validator(mode="before") @classmethod def coerce_runtime_context(cls, value: Any) -> Any: """ @@ -82,8 +82,7 @@ class ContextSnapshot(_ContextSnapshotBase): return _coerce_event_snapshot(value) -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class FileContextSnapshot(BaseModel): # type: ignore[misc] +class FileContextSnapshot(BaseModel): """ 音乐批次中单个文件的元数据上下文快照。 """ @@ -94,8 +93,7 @@ class FileContextSnapshot(BaseModel): # type: ignore[misc] model_config = ConfigDict(extra="allow", from_attributes=True) - # Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 - @model_validator(mode="before") # type: ignore[misc] + @model_validator(mode="before") @classmethod def coerce_runtime_context(cls, value: Any) -> Any: """ @@ -110,7 +108,7 @@ class Event(BaseModel): """ event_type: str = Field(..., description="事件类型") - event_data: Optional[dict] = Field(default={}, description="事件数据") + event_data: Optional[dict[str, Any]] = Field(default={}, description="事件数据") priority: Optional[int] = Field(0, description="事件优先级") @@ -361,7 +359,7 @@ class ConfigChangeEventData(BaseEventData): @field_validator("key", mode="before") @classmethod - def convert_to_set(cls, v): + def convert_to_set(cls, v: Any) -> set[str]: """ 将输入的 str、list、dict.keys() 等转为 set。 """ @@ -644,8 +642,7 @@ class AuthCredentials(ChainEventData): @model_validator(mode="before") @classmethod - # 保留既有校验器的参数签名,由 Pydantic 在验证前传入原始凭据映射。 - def check_fields_based_on_grant_type(cls, values): # noqa + def check_fields_based_on_grant_type(cls, values: dict[str, Any]) -> dict[str, Any]: """ 补齐默认密码认证类型,并检查对应凭据是否齐全。 @@ -697,7 +694,7 @@ class CommandRegisterEventData(ChainEventData): """ # 输入参数 - commands: Dict[str, dict] = Field(..., description="菜单命令") + commands: Dict[str, dict[str, Any]] = Field(..., description="菜单命令") origin: str = Field(..., description="事件源,可以是 Chain 或具体的模块名称") service: Optional[str] = Field(..., description="服务名称") @@ -790,7 +787,7 @@ class ResourceDownloadEventData(ChainEventData): channel: Optional[NotificationChannel] = Field(None, description="通知渠道") origin: Optional[str] = Field(None, description="来源(消息通知、Subscribe、Manual等)") downloader: Optional[str] = Field(None, description="下载器") - options: Optional[dict] = Field(default={}, description="其他参数") + options: Optional[dict[str, Any]] = Field(default={}, description="其他参数") # 输出参数 cancel: bool = Field(default=False, description="是否取消下载,默认值为 False") @@ -810,7 +807,7 @@ class TransferInterceptEventData(ChainEventData): target_storage: str = Field(..., description="目标存储") target_path: Path = Field(..., description="目标路径") transfer_type: str = Field(..., description="整理方式(copy、move、link、softlink、reflink等)") - options: Optional[dict] = Field(default=None, description="其他参数") + options: Optional[dict[str, Any]] = Field(default=None, description="其他参数") # 输出参数 cancel: bool = Field(default=False, description="是否取消下载,默认值为 False") @@ -834,7 +831,7 @@ class TransferOverwriteCheckEventData(ChainEventData): target_path: Path = Field(..., description="目标文件路径") overwrite_mode: str = Field(..., description="覆盖模式(always、size、never、latest)") transfer_type: str = Field(..., description="整理方式(copy、move、link、softlink、reflink等)") - options: Optional[dict] = Field(default=None, description="其他参数") + options: Optional[dict[str, Any]] = Field(default=None, description="其他参数") # 输出参数 source_size: Optional[int] = Field( @@ -952,7 +949,7 @@ class MediaRecognizeConvertEventData(RequiredMediaIdentityMixin, ChainEventData) target_media_source: MediaSource = Field(..., description="需要转换到的内置或插件扩展媒体来源") # 输出参数 - media_dict: dict = Field( + media_dict: dict[str, Any] = Field( default_factory=dict, description="TheMovieDb/豆瓣的媒体数据" ) @@ -966,7 +963,7 @@ class StorageOperSelectionEventData(ChainEventData): storage: Optional[str] = Field(default=None, description="存储类型") # 输出参数 - storage_oper: Optional[Callable] = Field(default=None, description="存储操作对象") + storage_oper: Optional[Callable[..., Any]] = Field(default=None, description="存储操作对象") class SubscribeEpisodesRefreshEventData(OptionalMediaIdentityMixin, ChainEventData): @@ -1012,7 +1009,7 @@ class SubscribeModifiedEventData(BaseEventData): idempotency_key: Optional[str] = Field(default=None, description="宿主生成的幂等键") @model_validator(mode="after") - def compute_fields(self): + def compute_fields(self) -> _Self: """ 比较新旧订阅快照,将真实变更字段写入事件。 diff --git a/app/schemas/file.py b/app/schemas/file.py index 7f9df7bb0..703edd101 100644 --- a/app/schemas/file.py +++ b/app/schemas/file.py @@ -27,7 +27,7 @@ class FileURI(BaseModel): """ 文件 URI,本地存储直接返回路径,其他存储带上存储前缀。 """ - return self.path if self.storage == "local" else f"{self.storage}:{self.path}" + return (self.path or "/") if self.storage == "local" else f"{self.storage}:{self.path}" @classmethod def from_uri(cls, uri: str) -> "FileURI": diff --git a/app/schemas/github.py b/app/schemas/github.py index 5051994c5..e642b6a6e 100644 --- a/app/schemas/github.py +++ b/app/schemas/github.py @@ -7,8 +7,7 @@ from typing import Any, Literal from pydantic import BaseModel, Field -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class GithubCommunityProfile(BaseModel): # type: ignore[misc] +class GithubCommunityProfile(BaseModel): """ 实例 Token 对应 GitHub 用户的社区勋章快照,不包含凭据。 @@ -43,8 +42,7 @@ class GithubCommunityProfile(BaseModel): # type: ignore[misc] refreshing: bool = Field(default=False, description="后台正在预热或更新;接口已立即返回当前快照") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class GithubTokenStatus(BaseModel): # type: ignore[misc] +class GithubTokenStatus(BaseModel): """ 描述当前 GitHub Token 的可用状态,不包含 Token 原文。 """ @@ -64,8 +62,7 @@ class GithubTokenStatus(BaseModel): # type: ignore[misc] ) -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class GithubDeviceAuthStart(BaseModel): # type: ignore[misc] +class GithubDeviceAuthStart(BaseModel): """ 返回 GitHub Device Flow 的浏览器操作信息。 """ @@ -77,8 +74,7 @@ class GithubDeviceAuthStart(BaseModel): # type: ignore[misc] interval_seconds: int = Field(description="建议的轮询间隔秒数") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class GithubDeviceAuthPollRequest(BaseModel): # type: ignore[misc] +class GithubDeviceAuthPollRequest(BaseModel): """ 轮询 GitHub Device Flow 所需的会话标识。 """ @@ -86,8 +82,7 @@ class GithubDeviceAuthPollRequest(BaseModel): # type: ignore[misc] session_id: str = Field(min_length=1, max_length=128, description="待查询的 GitHub 设备授权会话标识") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class GithubDeviceAuthPoll(BaseModel): # type: ignore[misc] +class GithubDeviceAuthPoll(BaseModel): """ 返回设备授权当前阶段,授权成功时附带脱敏状态。 """ @@ -100,8 +95,7 @@ class GithubDeviceAuthPoll(BaseModel): # type: ignore[misc] status: GithubTokenStatus | None = Field(default=None, description="授权成功后的 Token 状态") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class GithubManualTokenRequest(BaseModel): # type: ignore[misc] +class GithubManualTokenRequest(BaseModel): """ 兼容手动填写 GitHub Token 的请求体。 """ diff --git a/app/schemas/history.py b/app/schemas/history.py index 06df04a67..2088ddc1b 100644 --- a/app/schemas/history.py +++ b/app/schemas/history.py @@ -100,8 +100,7 @@ class TransferHistory(OptionalMediaIdentityMixin, BaseModel): model_config = ConfigDict(from_attributes=True) - # Pydantic 的动态装饰器类型会被存量 mypy 配置解析为 Any,公开 schema 仍需保留该校验。 - @_field_validator("errmsg", mode="before") # type: ignore[misc] + @_field_validator("errmsg", mode="before") @classmethod def _sanitize_error_message(cls, value: object) -> Optional[str]: """ @@ -122,8 +121,7 @@ class BatchTransferHistoryRedoRequest(BaseModel): history_ids: list[int] = Field(default_factory=list, description="历史记录 ID 列表") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class TransferHistoryDiscardResult(BaseModel): # type: ignore[misc] +class TransferHistoryDiscardResult(BaseModel): """ 损坏整理任务清理结果,标识保留供后续操作的历史记录。 """ @@ -140,8 +138,7 @@ class TransferHistoryPage(BaseModel): total: int = Field(default=0, description="记录总数") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class TransferHistoryDeleteStep(BaseModel): # type: ignore[misc] +class TransferHistoryDeleteStep(BaseModel): """ 整理历史删除流程中单个文件目标的执行结果。 """ @@ -152,8 +149,7 @@ class TransferHistoryDeleteStep(BaseModel): # type: ignore[misc] message: str = Field(default="", description="仅用于无法继续处理时给出可读原因,正常状态为空") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class TransferHistoryDeleteResult(BaseModel): # type: ignore[misc] +class TransferHistoryDeleteResult(BaseModel): """ 整理历史删除及源/目标文件清理的分项结果。 """ diff --git a/app/schemas/initialization.py b/app/schemas/initialization.py index 9fa279b97..063968986 100644 --- a/app/schemas/initialization.py +++ b/app/schemas/initialization.py @@ -7,8 +7,7 @@ import re from pydantic import BaseModel, Field, field_validator, model_validator -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class InitializationStatus(BaseModel): # type: ignore[misc] +class InitializationStatus(BaseModel): """ 描述 MoviePilot 的首次初始化状态。 @@ -19,8 +18,7 @@ class InitializationStatus(BaseModel): # type: ignore[misc] configured_username: str | None = Field(default=None, description="部署配置指定的管理员名,未指定时为空") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class InitializationRequest(BaseModel): # type: ignore[misc] +class InitializationRequest(BaseModel): """ 首次初始化超级管理员、密码和 API Key 所需的提交数据。 """ @@ -30,8 +28,7 @@ class InitializationRequest(BaseModel): # type: ignore[misc] confirm_password: str = Field(min_length=6, max_length=50, description="再次输入的管理员密码,必须与 password 完全一致") api_key: str = Field(min_length=16, max_length=256, description="接口访问密钥,长度为 16 至 256 个字符") - # Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 - @field_validator("username", "api_key") # type: ignore[misc] + @field_validator("username", "api_key") @classmethod def strip_text(cls, value: str) -> str: """ @@ -39,8 +36,7 @@ class InitializationRequest(BaseModel): # type: ignore[misc] """ return value.strip() - # Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 - @field_validator("password", "confirm_password") # type: ignore[misc] + @field_validator("password", "confirm_password") @classmethod def validate_password_shape(cls, value: str) -> str: """ @@ -50,8 +46,7 @@ class InitializationRequest(BaseModel): # type: ignore[misc] raise ValueError("密码需要同时包含字母、数字、特殊字符中的至少两项,且长度为 6-50 位") return value - # Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 - @model_validator(mode="after") # type: ignore[misc] + @model_validator(mode="after") def passwords_match(self) -> "InitializationRequest": """ 确保两次输入的密码完全一致。 diff --git a/app/schemas/media.py b/app/schemas/media.py index a9bb00164..66c5e0816 100644 --- a/app/schemas/media.py +++ b/app/schemas/media.py @@ -9,7 +9,7 @@ **表示规则**而非领域策略——真正的策略(按配置选来源、判音乐实体类型)仍留在 app/domain/media.py。放在这里,持久化层就不必为了一条表示规则去依赖领域层。 """ -from typing import Any, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, Optional, Self, Tuple, Union from pydantic import model_validator @@ -171,8 +171,17 @@ def normalize_media_identity_payload( class OptionalMediaIdentityMixin: """ 为可选媒体身份模型统一校验内置或插件来源与原生 ID 的成对约束。 + + :ivar model_fields_set: Pydantic 记录的显式输入字段 + :ivar media_source: 宿主模型声明的可选媒体来源 + :ivar media_id: 宿主模型声明的可选来源原生标识 """ + if TYPE_CHECKING: + model_fields_set: set[str] + media_source: Optional[MediaSource] + media_id: Optional[str] + @model_validator(mode="before") @classmethod def _normalize_legacy_media_source(cls, value: object) -> object: @@ -199,7 +208,7 @@ class OptionalMediaIdentityMixin: return value @model_validator(mode="after") - def _validate_optional_media_identity(self): + def _validate_optional_media_identity(self) -> Self: """ 规范化 ID,并拒绝显式半对、空白或零值身份。 """ @@ -224,10 +233,15 @@ class OptionalMediaIdentityMixin: class RequiredMediaIdentityMixin: """ 为必填媒体身份模型统一校验内置或插件来源与原生 ID。 + + :ivar media_id: 宿主模型声明的必填来源原生标识 """ + if TYPE_CHECKING: + media_id: str + @model_validator(mode="after") - def _validate_required_media_identity(self): + def _validate_required_media_identity(self) -> Self: """ 去除 ID 两端空白,并拒绝空白或零值身份。 """ diff --git a/app/schemas/mediaserver.py b/app/schemas/mediaserver.py index 9f6dcf098..da83c0012 100644 --- a/app/schemas/mediaserver.py +++ b/app/schemas/mediaserver.py @@ -5,6 +5,7 @@ from enum import Enum from pathlib import Path from typing import Any, Dict, List, Optional, Union +from typing import TypedDict as _TypedDict from pydantic import BaseModel, ConfigDict, Field, RootModel, model_validator @@ -41,8 +42,7 @@ class MediaServerField(str, Enum): LINK = "link" -# 当前 mypy 配置跳过 Pydantic 实现,无法静态解析 BaseModel 基类。 -class MediaServerQueryOptions(BaseModel): # type: ignore[misc] +class MediaServerQueryOptions(BaseModel): """ 不依赖服务端协议的媒体查询投影,未指定内容组时保留原行为。 @@ -57,6 +57,16 @@ class MediaServerQueryOptions(BaseModel): # type: ignore[misc] model_config = ConfigDict(frozen=True, extra="forbid") + @staticmethod + def as_kwargs(query: Optional["MediaServerQueryOptions"]) -> "_MediaServerQueryKwargs": + """ + 仅在指定投影时转发 query,保持旧客户端和插件未传该参数的调用约定。 + + :param query: 可选的媒体查询投影 + :return: 有类型约束的可选关键字参数 + """ + return {"query": query} if query is not None else {} + def includes(self, field: MediaServerField) -> bool: """ 判断调用方是否需要某个内容组。 @@ -78,6 +88,16 @@ class MediaServerQueryOptions(BaseModel): # type: ignore[misc] return MediaServerQueryOptions(fields=self.fields.union(fields)) +class _MediaServerQueryKwargs(_TypedDict, total=False): + """ + 媒体客户端调用中可省略的投影参数。 + + :param query: 仅在调用方明确指定时传入的查询投影 + """ + + query: MediaServerQueryOptions + + class ExistMediaInfo(BaseModel): """ 媒体服务器存在媒体信息。 @@ -100,7 +120,6 @@ class MediaServerPlayData(BaseModel): url: str = Field(description="播放地址") item_id: Optional[str] = Field(default=None, description="媒体项目 ID") server_id: Optional[str] = Field(default=None, description="媒体服务器 ID") - server_type: Optional[str] = Field(default=None, description="媒体服务器类型") class MediaServerExistsData(BaseModel): @@ -147,6 +166,7 @@ class MediaServerLibrary(BaseModel): id: Optional[Union[str, int]] = Field(default=None, description="记录标识") item_id: Optional[Union[str, int]] = Field(default=None, description="媒体服务器项目ID") server_id: Optional[str] = Field(default=None, description="媒体服务器ID") + server_type: Optional[str] = Field(default=None, description="媒体服务器类型") name: Optional[str] = Field(default=None, description="名称") path: Optional[Union[str, List[str]]] = Field(default=None, description="路径") type: Optional[str] = Field(default=None, description="类型") @@ -154,7 +174,6 @@ class MediaServerLibrary(BaseModel): image: Optional[str] = Field(default=None, description="封面图") image_list: Optional[List[str]] = Field(default=None, description="封面图列表") link: Optional[str] = Field(default=None, description="跳转链接") - server_type: Optional[str] = Field(default=None, description="服务器类型") use_cookies: Optional[bool] = Field(default=None, description="飞牛的图片需要Cookies") @@ -178,6 +197,7 @@ class MediaServerItem(OptionalMediaIdentityMixin, BaseModel): server: Optional[str] = Field(default=None, description="服务器") library: Optional[Union[str, int]] = Field(default=None, description="媒体库ID") server_id: Optional[str] = Field(default=None, description="媒体服务器ID") + server_type: Optional[str] = Field(default=None, description="媒体服务器类型") item_id: Optional[str] = Field(default=None, description="媒体项目 ID") item_type: Optional[str] = Field(default=None, description="类型") title: Optional[str] = Field(default=None, description="标题") diff --git a/app/schemas/message.py b/app/schemas/message.py index d8a333132..712f2ae38 100644 --- a/app/schemas/message.py +++ b/app/schemas/message.py @@ -180,11 +180,10 @@ class IncomingMessage(BaseModel): date: Optional[str] = Field(default=None, description="时间") action: Optional[int] = Field(default=0, description="消息方向") is_callback: Optional[bool] = Field(default=False, description="是否为回调消息") - callback_data: Optional[str] = Field(default=None, description="回调数据") message_id: Optional[Union[str, int]] = Field(default=None, description="消息ID(用于回调时定位原消息)") chat_id: Optional[str] = Field(default=None, description="聊天ID(用于回调时定位聊天)") reply_to_message_id: Optional[Union[str, int]] = Field(default=None, description="回复目标消息ID(用于 ForceReply 等回复场景)") - callback_query: Optional[Dict] = Field(default=None, description="完整的回调查询信息(原始数据)") + callback_query: Optional[Dict[str, Any]] = Field(default=None, description="完整的回调查询信息(原始数据)") images: Optional[List[MessageImage]] = Field(default=None, description="图片列表(图片URL或file_id)") audio_refs: Optional[List[str]] = Field(default=None, description="语音/音频引用列表") files: Optional[List[MessageAttachment]] = Field(default=None, description="文件附件列表") @@ -200,7 +199,7 @@ class IncomingMessage(BaseModel): """ return cls.MessageImage.normalize_list(value) - def to_dict(self): + def to_dict(self) -> dict[str, Any]: """ 转换为字典。 """ @@ -284,8 +283,8 @@ class Message(BaseModel): username: Optional[Union[str, int]] = Field(default=None, description="用户名称") date: Optional[str] = Field(default=None, description="时间") action: Optional[int] = Field(default=1, description="消息方向") - targets: Optional[dict] = Field(default=None, description="消息目标用户ID字典,未指定用户ID时使用") - buttons: Optional[List[List[dict]]] = Field( + targets: Optional[dict[str, Any]] = Field(default=None, description="消息目标用户ID字典,未指定用户ID时使用") + buttons: Optional[List[List[dict[str, Any]]]] = Field( default=None, description="按钮列表,格式:[[{\"text\": \"按钮文本\", \"callback_data\": \"回调数据\", \"url\": \"链接\"}]]", ) @@ -353,7 +352,7 @@ class Message(BaseModel): """ return self._notification_route_scope - def to_dict(self): + def to_dict(self) -> dict[str, Any]: """ 转换为字典。 """ diff --git a/app/schemas/music.py b/app/schemas/music.py index 191a3eabc..597365388 100644 --- a/app/schemas/music.py +++ b/app/schemas/music.py @@ -147,8 +147,7 @@ class MusicInfo(OptionalMediaIdentityMixin, BaseModel): """ return v.strip() if isinstance(v, str) and v.strip() else None - # Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 - @model_validator(mode="before") # type: ignore[misc] + @model_validator(mode="before") @classmethod def _normalize_category_compatibility(cls, value: Any) -> Any: """ @@ -181,16 +180,14 @@ class MusicInfo(OptionalMediaIdentityMixin, BaseModel): return None -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class MusicLibraryStatusRequest(BaseModel): # type: ignore[misc] +class MusicLibraryStatusRequest(BaseModel): """ 批量查询音乐媒体库状态的请求。 """ items: list[MusicInfo] = Field(min_length=1, max_length=500, description="待查询入库状态的音乐专辑列表,数量为 1 至 500 个") - # Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 - @field_validator("items") # type: ignore[misc] + @field_validator("items") @classmethod def _validate_album_identities(cls, items: list[MusicInfo]) -> list[MusicInfo]: """ @@ -211,8 +208,7 @@ class MusicLibraryStatusRequest(BaseModel): # type: ignore[misc] return items -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class MusicLibraryStatus(BaseModel): # type: ignore[misc] +class MusicLibraryStatus(BaseModel): """ 音乐专辑在媒体库中的存在状态。 """ @@ -303,8 +299,7 @@ class MusicAlbumInfo(OptionalMediaIdentityMixin, BaseModel): """ return v.strip() if isinstance(v, str) and v.strip() else None - # Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 - @model_validator(mode="before") # type: ignore[misc] + @model_validator(mode="before") @classmethod def _normalize_category_compatibility(cls, value: Any) -> Any: """ diff --git a/app/schemas/notification.py b/app/schemas/notification.py index a6c6c3c30..7f2e936a9 100644 --- a/app/schemas/notification.py +++ b/app/schemas/notification.py @@ -11,8 +11,7 @@ from pydantic import BaseModel, Field from app.schemas.types import NotificationChannel -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class WechatClawBotKnownTarget(BaseModel): # type: ignore[misc] +class WechatClawBotKnownTarget(BaseModel): """ 微信 ClawBot 已知消息目标。 """ @@ -22,8 +21,7 @@ class WechatClawBotKnownTarget(BaseModel): # type: ignore[misc] last_active: Optional[int | float] = Field(default=None, description="消息目标最近活跃时间") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class WechatClawBotData(BaseModel): # type: ignore[misc] +class WechatClawBotData(BaseModel): """ 微信 ClawBot 登录状态或操作结果。 """ diff --git a/app/schemas/plugin.py b/app/schemas/plugin.py index b7b0423d7..4241a6250 100644 --- a/app/schemas/plugin.py +++ b/app/schemas/plugin.py @@ -53,8 +53,7 @@ class PluginSourceBindingStatus(str, _Enum): LOCAL_ONLY = "local_only" -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class PluginUpdateCandidate(BaseModel): # type: ignore[misc] +class PluginUpdateCandidate(BaseModel): """ 插件市场为已安装插件选择的当前更新候选。 """ @@ -117,7 +116,7 @@ class PluginInstance(BaseModel): return self.instance_id == self.source_plugin_id # mypy 尚不识别 computed_field 与 property 叠加时的装饰器顺序。 - @_computed_field( # type: ignore[prop-decorator, misc] + @_computed_field( # type: ignore[prop-decorator] description="实例实现模式:virtual 为共享源码的分身,host 为源插件本体自身", ) @property @@ -128,8 +127,7 @@ class PluginInstance(BaseModel): return "host" if self.is_host else "virtual" -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class PluginInstanceEnabledRequest(BaseModel): # type: ignore[misc] +class PluginInstanceEnabledRequest(BaseModel): """ 启用或停用一个插件实例的请求参数。 """ @@ -139,8 +137,7 @@ class PluginInstanceEnabledRequest(BaseModel): # type: ignore[misc] ) -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class PluginInstancePurgeRequest(BaseModel): # type: ignore[misc] +class PluginInstancePurgeRequest(BaseModel): """ 彻底清理一个插件实例时选定的删除范围。 @@ -157,8 +154,7 @@ class PluginInstancePurgeRequest(BaseModel): # type: ignore[misc] ) -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class PluginInstancePurgeOutcome(BaseModel): # type: ignore[misc] +class PluginInstancePurgeOutcome(BaseModel): """ 彻底清理的执行结果。 """ @@ -170,8 +166,7 @@ class PluginInstancePurgeOutcome(BaseModel): # type: ignore[misc] ) -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class PluginInstanceLogLevel(BaseModel): # type: ignore[misc] +class PluginInstanceLogLevel(BaseModel): """ 单个实例的日志等级设置与生效结果。 """ @@ -182,8 +177,7 @@ class PluginInstanceLogLevel(BaseModel): # type: ignore[misc] effective_level: str = Field(description="按过期回落判定后实际生效的日志等级") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class PluginInstanceLogLevelOverview(BaseModel): # type: ignore[misc] +class PluginInstanceLogLevelOverview(BaseModel): """ 插件全部实例(含本体)的日志等级设置总览。 """ @@ -194,8 +188,7 @@ class PluginInstanceLogLevelOverview(BaseModel): # type: ignore[misc] ) -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class PluginInstanceLogLevelUpdateRequest(BaseModel): # type: ignore[misc] +class PluginInstanceLogLevelUpdateRequest(BaseModel): """ 设置实例日志等级覆盖的请求参数。 """ @@ -216,7 +209,7 @@ class Plugin(BaseModel): _package_version: Optional[str] = _PrivateAttr(default=None) - id: str = Field(default=None, description="记录标识") + id: Optional[str] = Field(default=None, description="记录标识") plugin_name: Optional[str] = Field(default=None, description="插件名称") plugin_desc: Optional[str] = Field(default=None, description="插件描述") plugin_icon: Optional[str] = Field(default=None, description="插件图标") @@ -293,8 +286,7 @@ class PluginRuntimeSummary(BaseModel): ) -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class PluginRuntimeCommandCapability(BaseModel): # type: ignore[misc] +class PluginRuntimeCommandCapability(BaseModel): """ 插件运行时注册命令的安全只读投影。 """ @@ -304,8 +296,7 @@ class PluginRuntimeCommandCapability(BaseModel): # type: ignore[misc] plugin_id: Optional[str] = Field(default=None, description="注册命令的插件 ID") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class PluginRuntimeActionCapability(BaseModel): # type: ignore[misc] +class PluginRuntimeActionCapability(BaseModel): """ 插件运行时注册动作的安全只读投影。 """ @@ -314,8 +305,7 @@ class PluginRuntimeActionCapability(BaseModel): # type: ignore[misc] name: Optional[str] = Field(default=None, description="动作名称") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class PluginRuntimeActionGroup(BaseModel): # type: ignore[misc] +class PluginRuntimeActionGroup(BaseModel): """ 按插件归组的运行时动作投影。 """ @@ -325,8 +315,7 @@ class PluginRuntimeActionGroup(BaseModel): # type: ignore[misc] actions: List[PluginRuntimeActionCapability] = Field(default_factory=list, description="可用动作列表") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class PluginRuntimeServiceCapability(BaseModel): # type: ignore[misc] +class PluginRuntimeServiceCapability(BaseModel): """ 插件定时服务的安全只读投影。 """ @@ -336,8 +325,7 @@ class PluginRuntimeServiceCapability(BaseModel): # type: ignore[misc] trigger: Optional[str] = Field(default=None, description="定时触发器说明") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class PluginRuntimeCapabilities(BaseModel): # type: ignore[misc] +class PluginRuntimeCapabilities(BaseModel): """ 插件命令、动作和定时服务的公共安全能力快照。 """ @@ -347,8 +335,7 @@ class PluginRuntimeCapabilities(BaseModel): # type: ignore[misc] services: List[PluginRuntimeServiceCapability] = Field(default_factory=list, description="插件声明的服务能力") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class PluginDataKeySummary(BaseModel): # type: ignore[misc] +class PluginDataKeySummary(BaseModel): """ 单个插件持久化键的不含值诊断摘要。 """ @@ -365,8 +352,7 @@ class PluginDataKeySummary(BaseModel): # type: ignore[misc] sensitive: bool = Field(description="键名是否符合凭据字段规则") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class PluginDataSummary(BaseModel): # type: ignore[misc] +class PluginDataSummary(BaseModel): """ 插件持久化数据的不含原值诊断摘要。 """ @@ -432,8 +418,7 @@ class PluginCloneRequest(BaseModel): ) -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class PluginCloneOutcome(BaseModel): # type: ignore[misc] +class PluginCloneOutcome(BaseModel): """ 一次分身创建或恢复的结果。 @@ -444,8 +429,7 @@ class PluginCloneOutcome(BaseModel): # type: ignore[misc] instance_id: str = Field(description="新建或恢复出来的分身实例 ID") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class PluginRestorableInstance(BaseModel): # type: ignore[misc] +class PluginRestorableInstance(BaseModel): """ 一个已停用、其设置仍留存可被恢复的分身实例。 @@ -460,8 +444,7 @@ class PluginRestorableInstance(BaseModel): # type: ignore[misc] has_config: bool = Field(default=False, description="是否留有业务参数") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class PluginSourceIdentity(BaseModel): # type: ignore[misc] +class PluginSourceIdentity(BaseModel): """ 显式换源确认所需的插件来源身份投影。 """ @@ -481,8 +464,7 @@ class PluginSourceIdentity(BaseModel): # type: ignore[misc] revision: int = Field(ge=1, description="显式换源使用的身份 CAS revision") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class PluginSourceCandidate(BaseModel): # type: ignore[misc] +class PluginSourceCandidate(BaseModel): """ 一个可供管理员识别的脱敏插件来源候选。 """ @@ -503,8 +485,7 @@ class PluginSourceCandidate(BaseModel): # type: ignore[misc] ) -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class PluginSourceOptions(BaseModel): # type: ignore[misc] +class PluginSourceOptions(BaseModel): """ 来源选择界面所需的当前身份、候选和准入状态。 """ @@ -525,8 +506,7 @@ class PluginSourceOptions(BaseModel): # type: ignore[misc] ) -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class PluginSourceInstallRequest(BaseModel): # type: ignore[misc] +class PluginSourceInstallRequest(BaseModel): """ 管理员为未绑定插件明确选择初始在线来源的请求参数。 """ @@ -541,8 +521,7 @@ class PluginSourceInstallRequest(BaseModel): # type: ignore[misc] description="是否强制重新下载并安装所选来源载荷", ) - # Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 - @field_validator("repo_url") # type: ignore[misc] + @field_validator("repo_url") @classmethod def normalize_repo_url(cls, value: str) -> str: """ @@ -556,8 +535,7 @@ class PluginSourceInstallRequest(BaseModel): # type: ignore[misc] return normalized -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class PluginSourceChangeRequest(BaseModel): # type: ignore[misc] +class PluginSourceChangeRequest(BaseModel): """ 管理员显式切换插件在线来源的请求参数。 """ @@ -572,8 +550,7 @@ class PluginSourceChangeRequest(BaseModel): # type: ignore[misc] description="指定安装的 Release 资产版本;为空时使用当前索引版本", ) - # Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 - @field_validator("repo_url") # type: ignore[misc] + @field_validator("repo_url") @classmethod def normalize_repo_url(cls, value: str) -> str: """ @@ -620,8 +597,7 @@ class PluginSidebarNavItem(BaseModel): order: int = Field(default=0, description="同组内排序,越小越靠前") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class PluginAgentPet(BaseModel): # type: ignore[misc] +class PluginAgentPet(BaseModel): """ 插件声明的 Agent 助手形象(前端按 plugin_id + key 选择并挂载联邦组件)。 """ @@ -780,8 +756,7 @@ class PluginFoldersData(RootModel[Dict[str, Union[List[str], PluginFolderConfigD """ -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class PluginFolderUpdateRequest(BaseModel): # type: ignore[misc] +class PluginFolderUpdateRequest(BaseModel): """ 插件文件夹名称和展示字段的增量更新请求。 """ @@ -814,8 +789,7 @@ class PluginFolderUpdateRequest(BaseModel): # type: ignore[misc] ) -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class PluginFolderPluginsUpdateRequest(BaseModel): # type: ignore[misc] +class PluginFolderPluginsUpdateRequest(BaseModel): """ 插件文件夹成员顺序的条件替换请求。 """ diff --git a/app/schemas/query.py b/app/schemas/query.py index 958ee1c02..d7a6e1b99 100644 --- a/app/schemas/query.py +++ b/app/schemas/query.py @@ -17,8 +17,7 @@ DEFAULT_QUERY_PAGE_SIZE = 50 MAX_QUERY_PAGE_SIZE = 200 -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class _QueryInput(BaseModel): # type: ignore[misc] # 当前严格类型检查配置跳过 Pydantic 导入 +class _QueryInput(BaseModel): """ 查询输入共同的严格字段合同。 """ @@ -73,8 +72,7 @@ class QueryPageRequest(_QueryInput): sort: QuerySort = Field(default_factory=QuerySort, description="分页查询的排序规则,默认按日期降序") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class QueryPage(BaseModel, Generic[T]): # type: ignore[misc] # 当前严格类型检查配置跳过 Pydantic 导入 +class QueryPage(BaseModel, Generic[T]): """ 公开查询返回的分页 DTO。 """ @@ -103,8 +101,7 @@ class MediaIdentityQuery(_QueryInput): media_source: Optional[MediaSource] = Field(default=None, description="媒体数据源") media_id: Optional[str] = Field(default=None, description="数据源原生 ID") - # Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 - @model_validator(mode="after") # type: ignore[misc] + @model_validator(mode="after") def _validate_media_identity(self) -> "MediaIdentityQuery": """ 规范化 ID,并拒绝显式半对、空白或零值身份。 @@ -120,8 +117,7 @@ class MediaIdentityQuery(_QueryInput): return self -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class _QuerySnapshot(BaseModel): # type: ignore[misc] # 当前严格类型检查配置跳过 Pydantic 导入 +class _QuerySnapshot(BaseModel): """ 只读查询 DTO 的媒体身份与脏数据归一化边界。 """ @@ -131,8 +127,7 @@ class _QuerySnapshot(BaseModel): # type: ignore[misc] # 当前严格类型检 model_config = ConfigDict(from_attributes=True) - # Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 - @field_validator("media_source", mode="before") # type: ignore[misc] + @field_validator("media_source", mode="before") @classmethod def _normalize_source(cls, value: Any) -> Optional[MediaSource]: """ @@ -140,8 +135,7 @@ class _QuerySnapshot(BaseModel): # type: ignore[misc] # 当前严格类型检 """ return normalize_media_source(value) - # Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 - @model_validator(mode="after") # type: ignore[misc] + @model_validator(mode="after") def _normalize_identity_pair(self) -> "_QuerySnapshot": """ 输出中的脏半对身份按无身份处理,避免向插件传播不可用主键。 @@ -337,8 +331,7 @@ class TransferHistorySnapshot(_QuerySnapshot): files: Optional[JsonData] = Field(default=None, description="文件清单") episode_group: Optional[str] = Field(default=None, description="剧集组") - # Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 - @field_validator("status", mode="before") # type: ignore[misc] + @field_validator("status", mode="before") @classmethod def _normalize_status(cls, value: object) -> bool: """ diff --git a/app/schemas/recommend.py b/app/schemas/recommend.py index 6caedab54..47b74dc28 100644 --- a/app/schemas/recommend.py +++ b/app/schemas/recommend.py @@ -7,8 +7,7 @@ from typing import Optional from pydantic import BaseModel, Field -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class AgentRecommendationItem(BaseModel): # type: ignore[misc] +class AgentRecommendationItem(BaseModel): """ 影视、动画与音乐推荐共用的有界字段投影。 """ diff --git a/app/schemas/rule.py b/app/schemas/rule.py index 0cb43a464..fd28b5a64 100644 --- a/app/schemas/rule.py +++ b/app/schemas/rule.py @@ -8,8 +8,7 @@ from pydantic import BaseModel from pydantic import Field as _Field -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class CustomRule(BaseModel): # type: ignore[misc] +class CustomRule(BaseModel): """ 自定义规则项。 """ @@ -23,8 +22,7 @@ class CustomRule(BaseModel): # type: ignore[misc] publish_time: Optional[str] = _Field(default=None, description="距离发布经过的分钟数;单值为最小等待时间,区间表示允许的时间范围") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class FilterRuleGroup(BaseModel): # type: ignore[misc] +class FilterRuleGroup(BaseModel): """ 过滤规则组。 """ @@ -35,8 +33,7 @@ class FilterRuleGroup(BaseModel): # type: ignore[misc] category: Optional[str] = _Field(default=None, description="适用媒体类别 None-全部 对应二级分类") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class CustomFilterRuleCreateRequest(BaseModel): # type: ignore[misc] +class CustomFilterRuleCreateRequest(BaseModel): """ 新增自定义过滤规则请求。 """ @@ -50,8 +47,7 @@ class CustomFilterRuleCreateRequest(BaseModel): # type: ignore[misc] publish_time: Optional[str] = _Field(default=None, description="距离发布经过的分钟数;单值为最小等待时间,区间表示允许的时间范围") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class CustomFilterRuleUpdateRequest(BaseModel): # type: ignore[misc] +class CustomFilterRuleUpdateRequest(BaseModel): """ 更新自定义过滤规则请求。 """ @@ -65,8 +61,7 @@ class CustomFilterRuleUpdateRequest(BaseModel): # type: ignore[misc] publish_time: Optional[str] = _Field(default=None, description="距离发布经过的分钟数;单值为最小等待时间,区间表示允许的时间范围") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class CustomFilterRuleReorderRequest(BaseModel): # type: ignore[misc] +class CustomFilterRuleReorderRequest(BaseModel): """ 调整自定义过滤规则顺序请求。 """ @@ -75,8 +70,7 @@ class CustomFilterRuleReorderRequest(BaseModel): # type: ignore[misc] expected_rule_ids: Optional[list[str]] = _Field(default=None, description="调整前预期的规则标识顺序") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class FilterRuleGroupCreateRequest(BaseModel): # type: ignore[misc] +class FilterRuleGroupCreateRequest(BaseModel): """ 新增过滤规则组请求。 """ @@ -87,8 +81,7 @@ class FilterRuleGroupCreateRequest(BaseModel): # type: ignore[misc] category: Optional[str] = _Field(default=None, description="媒体分类") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class FilterRuleGroupUpdateRequest(BaseModel): # type: ignore[misc] +class FilterRuleGroupUpdateRequest(BaseModel): """ 更新过滤规则组请求。 """ @@ -99,8 +92,7 @@ class FilterRuleGroupUpdateRequest(BaseModel): # type: ignore[misc] category: Optional[str] = _Field(default=None, description="媒体分类") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class FilterRuleGroupReorderRequest(BaseModel): # type: ignore[misc] +class FilterRuleGroupReorderRequest(BaseModel): """ 调整过滤规则组顺序请求。 """ diff --git a/app/schemas/search.py b/app/schemas/search.py index c25973161..f2b47d0a7 100644 --- a/app/schemas/search.py +++ b/app/schemas/search.py @@ -12,8 +12,7 @@ from app.schemas.context import SubtitleInfo from app.schemas.context import TorrentInfo as TorrentInfo -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class SearchSourcePage(BaseModel): # type: ignore[misc] +class SearchSourcePage(BaseModel): """ 单页 SSE 的来源结果;客户端持有页号并回传 source,原始页摘要由后端缓存。 """ @@ -25,8 +24,7 @@ class SearchSourcePage(BaseModel): # type: ignore[misc] error: Optional[str] = Field(default=None, description="本页请求失败的原因") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class SearchLastContextData(BaseModel): # type: ignore[misc] +class SearchLastContextData(BaseModel): """ 上一次搜索的请求参数与结果。 """ @@ -35,8 +33,7 @@ class SearchLastContextData(BaseModel): # type: ignore[misc] results: list[Union[_Context, SubtitleInfo]] = Field(default_factory=list, description="查询结果列表") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class SearchRecommendStatusData(BaseModel): # type: ignore[misc] +class SearchRecommendStatusData(BaseModel): """ AI 搜索结果推荐任务状态。 """ diff --git a/app/schemas/site.py b/app/schemas/site.py index 19947a814..373e0d242 100644 --- a/app/schemas/site.py +++ b/app/schemas/site.py @@ -44,8 +44,7 @@ class Site(BaseModel): model_config = ConfigDict(from_attributes=True) -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class SitePriorityUpdate(BaseModel): # type: ignore[misc] +class SitePriorityUpdate(BaseModel): """ 站点批量优先级更新项。 """ @@ -118,8 +117,7 @@ class SiteCookieUpdate(BaseModel): code: Optional[str] = Field(None, description="二步验证码或密钥") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class SiteCookieSet(BaseModel): # type: ignore[misc] +class SiteCookieSet(BaseModel): """ 直接保存浏览器登录后取得的站点 Cookie 与 User-Agent。 """ diff --git a/app/schemas/storage.py b/app/schemas/storage.py index 8534dd604..a6205fa24 100644 --- a/app/schemas/storage.py +++ b/app/schemas/storage.py @@ -34,8 +34,7 @@ class StorageLoginStatusData(BaseModel): tip: str = Field(description="状态提示") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class StorageOption(BaseModel): # type: ignore[misc] +class StorageOption(BaseModel): """ 前端选择控件可安全消费的存储摘要。 """ @@ -44,8 +43,7 @@ class StorageOption(BaseModel): # type: ignore[misc] type: str = Field(description="存储类型标识") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class StorageCatalogOption(BaseModel): # type: ignore[misc] +class StorageCatalogOption(BaseModel): """ 前端新增存储和路径选择器可消费的存储类型目录项。 """ diff --git a/app/schemas/subscribe.py b/app/schemas/subscribe.py index 385bc6a3f..2e559c982 100644 --- a/app/schemas/subscribe.py +++ b/app/schemas/subscribe.py @@ -13,8 +13,7 @@ from app.schemas.types import MUSIC_ENTITY_ALBUM as _MUSIC_ENTITY_ALBUM from app.schemas.types import MediaSource, MediaType -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class SubscriptionActivityEvent(BaseModel): # type: ignore[misc] +class SubscriptionActivityEvent(BaseModel): """ 面向用户的结构化业务活动,明细只包含安全资源摘要。 """ @@ -30,8 +29,7 @@ class SubscriptionActivityEvent(BaseModel): # type: ignore[misc] details_loaded: bool = Field(default=True, description="是否已加载活动详情") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class SubscriptionActivityPage(BaseModel): # type: ignore[misc] +class SubscriptionActivityPage(BaseModel): """ 业务活动游标分页;升级前的记录不会由运行日志补造。 """ @@ -118,8 +116,7 @@ def compute_subscribe_completed_tracks(subscribe: Any) -> Optional[int]: return min(completed_tracks, total_tracks) -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class SubscriptionExecutionStatus(BaseModel): # type: ignore[misc] +class SubscriptionExecutionStatus(BaseModel): """ 订阅列表可见的当前业务执行状态。 """ @@ -138,8 +135,7 @@ class SubscriptionExecutionStatus(BaseModel): # type: ignore[misc] model_config = ConfigDict(from_attributes=True) -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class SubscriptionBatchStatus(BaseModel): # type: ignore[misc] +class SubscriptionBatchStatus(BaseModel): """ 订阅搜索批次的用户可见进度和操作能力。 """ @@ -167,8 +163,7 @@ class SubscriptionBatchStatus(BaseModel): # type: ignore[misc] model_config = ConfigDict(from_attributes=True) -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class SubscriptionSearchSubmission(BaseModel): # type: ignore[misc] +class SubscriptionSearchSubmission(BaseModel): """ 手工订阅搜索已安排后的轻量跟踪信息。 """ @@ -432,8 +427,7 @@ class SubscrbieInfo(BaseModel): ) -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class SubscribeDeletionResult(BaseModel): # type: ignore[misc] +class SubscribeDeletionResult(BaseModel): """ 订阅删除成功后的机器可判断结果。 """ diff --git a/app/schemas/system.py b/app/schemas/system.py index 5633094de..6b743b8ec 100644 --- a/app/schemas/system.py +++ b/app/schemas/system.py @@ -39,9 +39,9 @@ class MediaServerConf(BaseModel): name: Optional[str] = Field(default=None, description="名称") type: Optional[str] = Field(default=None, description="类型 emby/zspace/jellyfin/plex/trimemedia/ugreen/navidrome") - config: Optional[dict] = Field(default_factory=dict, description="配置") + config: Optional[dict[str, Any]] = Field(default_factory=dict, description="配置") enabled: Optional[bool] = Field(default=False, description="是否启用") - sync_libraries: Optional[list] = Field(default_factory=list, description="同步媒体体库列表") + sync_libraries: Optional[list[str]] = Field(default_factory=list, description="同步媒体体库列表") sync_interval: Optional[int] = Field(default=None, description="自动同步间隔(小时),未设置时使用旧全局配置") @field_validator("sync_interval", mode="before") @@ -73,7 +73,7 @@ class DownloaderConf(BaseModel): name: Optional[str] = Field(default=None, description="名称") type: Optional[str] = Field(default=None, description="类型 qbittorrent/transmission/rtorrent") default: Optional[bool] = Field(default=False, description="是否默认") - config: Optional[dict] = Field(default_factory=dict, description="配置") + config: Optional[dict[str, Any]] = Field(default_factory=dict, description="配置") enabled: Optional[bool] = Field(default=False, description="是否启用") path_mapping: Optional[list[tuple[str, str]]] = Field(default_factory=list, description="路径映射") @@ -102,7 +102,7 @@ class NotificationSwitchConf(BaseModel): 通知场景开关配置。 """ - type: str = Field(default=None, description="场景名称") + type: Optional[str] = Field(default=None, description="场景名称") action: Optional[str] = Field(default="all", description="通知范围 all/user/admin") @@ -124,7 +124,7 @@ class StorageConf(BaseModel): type: Optional[str] = Field(default=None, description="类型 local/alipan/u115/rclone/alist") name: Optional[str] = Field(default=None, description="名称") - config: Optional[dict] = Field(default_factory=dict, description="配置") + config: Optional[dict[str, Any]] = Field(default_factory=dict, description="配置") class SystemEnvironmentUpdateData(BaseModel): @@ -136,8 +136,7 @@ class SystemEnvironmentUpdateData(BaseModel): failed_updates: dict[str, tuple[Optional[bool], str]] = Field(default_factory=dict, description="更新失败的环境项") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class SystemSettingsUpdateRequest(BaseModel): # type: ignore[misc] +class SystemSettingsUpdateRequest(BaseModel): """ 统一系统设置更新请求。 """ @@ -188,8 +187,7 @@ class SystemSettingsUpdateRequest(BaseModel): # type: ignore[misc] ) -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class CustomIdentifiersUpdateRequest(BaseModel): # type: ignore[misc] +class CustomIdentifiersUpdateRequest(BaseModel): """ 完整替换自定义识别词的请求。 """ @@ -210,8 +208,7 @@ SystemUpdateType = Literal["application", "resources"] SystemUpdateTarget = Literal["application", "resources", "all"] -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class SystemUpdateItemStatus(BaseModel): # type: ignore[misc] +class SystemUpdateItemStatus(BaseModel): """ 单类升级的可恢复状态快照。 """ @@ -244,8 +241,7 @@ class SystemUpdateItemStatus(BaseModel): # type: ignore[misc] can_install: bool = Field(default=False, description="当前版本是否可以安装") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class SystemUpdateRequest(BaseModel): # type: ignore[misc] +class SystemUpdateRequest(BaseModel): """ 单类或合并升级动作的请求体,all 在一次重启中安装全部已准备更新。 """ @@ -350,8 +346,7 @@ class SystemModuleListData(BaseModel): modules: list[SystemModuleInfo] = Field(default_factory=list, description="模块列表") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class SystemModuleSettingInfo(BaseModel): # type: ignore[misc] +class SystemModuleSettingInfo(BaseModel): """ 可由用户统一开关的内置模块设置摘要。 """ @@ -365,8 +360,7 @@ class SystemModuleSettingInfo(BaseModel): # type: ignore[misc] enabled: bool = Field(description="是否启用") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class SystemModuleSettingListData(BaseModel): # type: ignore[misc] +class SystemModuleSettingListData(BaseModel): """ 可由用户统一开关的内置模块设置列表。 """ @@ -374,8 +368,7 @@ class SystemModuleSettingListData(BaseModel): # type: ignore[misc] modules: list[SystemModuleSettingInfo] = Field(default_factory=list, description="模块列表") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class SystemModuleCatalogInfo(BaseModel): # type: ignore[misc] +class SystemModuleCatalogInfo(BaseModel): """ 前端服务选择器可消费的宿主模块目录项。 """ @@ -393,8 +386,7 @@ class SystemModuleCatalogInfo(BaseModel): # type: ignore[misc] active: bool = Field(default=False, description="当前模块是否处于活动状态") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class SystemModuleCatalogListData(BaseModel): # type: ignore[misc] +class SystemModuleCatalogListData(BaseModel): """ 宿主模块及其服务类型目录。 """ @@ -402,8 +394,7 @@ class SystemModuleCatalogListData(BaseModel): # type: ignore[misc] modules: list[SystemModuleCatalogInfo] = Field(default_factory=list, description="模块列表") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class DatabaseBackupArtifactData(BaseModel): # type: ignore[misc] +class DatabaseBackupArtifactData(BaseModel): """ Web 管理端可见的受管数据库备份摘要。 """ @@ -418,8 +409,7 @@ class DatabaseBackupArtifactData(BaseModel): # type: ignore[misc] size: int = Field(description="文件大小,单位为字节") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class DatabaseBackupVerificationData(BaseModel): # type: ignore[misc] +class DatabaseBackupVerificationData(BaseModel): """ 受管数据库备份的脱敏校验结果。 """ diff --git a/app/schemas/transfer.py b/app/schemas/transfer.py index 4988e1d4a..5a82d1fc3 100644 --- a/app/schemas/transfer.py +++ b/app/schemas/transfer.py @@ -119,8 +119,7 @@ class TransferJob(BaseModel): tasks: Optional[List[TransferJobTask]] = Field(default_factory=list, description="任务列表") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class TransferQueueFileItemData(BaseModel): # type: ignore[misc] +class TransferQueueFileItemData(BaseModel): """ 整理队列快照只需要的源文件字段,不携带云盘目录树和 provider 扩展字段。 """ @@ -132,8 +131,7 @@ class TransferQueueFileItemData(BaseModel): # type: ignore[misc] size: Optional[int] = Field(default=None, description="文件大小,单位为字节") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class TransferQueueMediaData(BaseModel): # type: ignore[misc] +class TransferQueueMediaData(BaseModel): """ 整理队列 UI 所需的媒体身份与海报摘要。 """ @@ -148,8 +146,7 @@ class TransferQueueMediaData(BaseModel): # type: ignore[misc] origin_country: Optional[List[str]] = Field(default_factory=list, description="原产国") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class TransferQueueTaskData(BaseModel): # type: ignore[misc] +class TransferQueueTaskData(BaseModel): """ 整理队列 UI 所需的任务状态与源文件摘要。 """ @@ -158,8 +155,7 @@ class TransferQueueTaskData(BaseModel): # type: ignore[misc] state: Optional[str] = Field(default=None, description="当前业务状态") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class TransferQueueItemData(BaseModel): # type: ignore[misc] +class TransferQueueItemData(BaseModel): """ 单个媒体分组的轻量整理队列投影。 """ @@ -169,8 +165,7 @@ class TransferQueueItemData(BaseModel): # type: ignore[misc] tasks: List[TransferQueueTaskData] = Field(default_factory=list, description="任务列表") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class TransferQueuePageData(BaseModel): # type: ignore[misc] +class TransferQueuePageData(BaseModel): """ 前端整理队列的受限快照。 """ @@ -191,10 +186,10 @@ class TransferInfo(BaseModel): target_item: Optional[FileItem] = Field(default=None, description="转移后路径") transfer_type: Optional[str] = Field(default=None, description="整理方式") file_count: Optional[int] = Field(default=0, description="处理文件数") - file_list: Optional[list] = Field(default_factory=list, description="处理文件清单") - file_list_new: Optional[list] = Field(default_factory=list, description="目标文件清单") + file_list: Optional[list[str]] = Field(default_factory=list, description="处理文件清单") + file_list_new: Optional[list[str]] = Field(default_factory=list, description="目标文件清单") total_size: Optional[int] = Field(default=0, description="本次处理文件的总大小,单位为字节") - fail_list: Optional[list] = Field(default_factory=list, description="失败清单") + fail_list: Optional[list[str]] = Field(default_factory=list, description="失败清单") message: Optional[str] = Field(default=None, description="错误信息") need_scrape: Optional[bool] = Field(default=False, description="是否需要刮削") need_notify: Optional[bool] = Field(default=False, description="是否需要通知") @@ -217,8 +212,7 @@ class TransferInfo(BaseModel): return dicts -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class TransferManualReviewRequest(BaseModel): # type: ignore[misc] +class TransferManualReviewRequest(BaseModel): """ 人工判定外部结果不确定整理步骤的请求。 """ @@ -235,8 +229,7 @@ class TransferManualReviewRequest(BaseModel): # type: ignore[misc] description="判定为 applied 时必填的外部结果证据", ) - # Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 - @model_validator(mode="after") # type: ignore[misc] + @model_validator(mode="after") def validate_result_payload(self) -> "TransferManualReviewRequest": """ 要求已发生判定携带可持久化的结果证据。 @@ -246,8 +239,7 @@ class TransferManualReviewRequest(BaseModel): # type: ignore[misc] return self -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class TransferManualReviewData(BaseModel): # type: ignore[misc] +class TransferManualReviewData(BaseModel): """ 人工复核提交后的公开状态投影。 """ @@ -259,8 +251,7 @@ class TransferManualReviewData(BaseModel): # type: ignore[misc] review_revision: int = Field(description="人工复核状态的版本号") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class TransferManualReviewSourceData(BaseModel): # type: ignore[misc] +class TransferManualReviewSourceData(BaseModel): """ 人工复核任务的源文件身份。 """ @@ -269,8 +260,7 @@ class TransferManualReviewSourceData(BaseModel): # type: ignore[misc] path: str = Field(description="文件或目录路径") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class TransferManualReviewStepData(BaseModel): # type: ignore[misc] +class TransferManualReviewStepData(BaseModel): """ 人工复核步骤的公开意图与事实证据。 """ @@ -282,8 +272,7 @@ class TransferManualReviewStepData(BaseModel): # type: ignore[misc] error: Optional[str] = Field(default=None, description="错误摘要") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class TransferManualReviewTaskData(BaseModel): # type: ignore[misc] +class TransferManualReviewTaskData(BaseModel): """ 可由管理员发现和判定的 durable 整理任务。 """ @@ -295,8 +284,7 @@ class TransferManualReviewTaskData(BaseModel): # type: ignore[misc] review_revision: int = Field(description="人工复核状态的版本号") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class TransferManualReviewPageData(BaseModel): # type: ignore[misc] +class TransferManualReviewPageData(BaseModel): """ 人工复核任务分页结果。 """ @@ -341,7 +329,7 @@ class ManualTransferItem(OptionalMediaIdentityMixin, BaseModel): 手动整理请求,媒体身份接受内置或插件来源与原生 ID。 """ - fileitem: FileItem = Field(default=None, description="文件项") + fileitem: Optional[FileItem] = Field(default=None, description="文件项") fileitems: Optional[List[FileItem]] = Field(default=None, description="文件项列表(前端多选时传入)") logid: Optional[int] = Field(default=None, description="日志ID") logids: Optional[List[int]] = Field(default=None, description="日志ID列表(前端多选历史记录时传入)") @@ -383,8 +371,7 @@ class ManualTransferItem(OptionalMediaIdentityMixin, BaseModel): reorganize: Optional[bool] = Field(default=False, description="重新整理,清理命中的成功历史及其旧目标") skip_success: bool = Field(default=False, description="跳过成功历史,优先于重新整理;预览与执行使用相同过滤范围") - # Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 - @model_validator(mode="after") # type: ignore[misc] + @model_validator(mode="after") def validate_music_release_selection(self) -> "ManualTransferItem": """ 具体发行只用于明确的 MusicBrainz 专辑,禁止混用录音或其它来源身份。 @@ -398,8 +385,7 @@ class ManualTransferItem(OptionalMediaIdentityMixin, BaseModel): self.musicbrainz_release_id = self.musicbrainz_release_id.lower() return self - # Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 - @model_validator(mode="after") # type: ignore[misc] + @model_validator(mode="after") def normalize_music_release_preferences(self) -> "ManualTransferItem": """ 规范手动发行偏好并拒绝重复或非法 ISO 代码。 @@ -453,8 +439,7 @@ class ManualTransferPreviewSummary(BaseModel): failed: int = Field(default=0, description="失败条目数量") -# 与下方执行回执相同:mypy将Pydantic基类视为Any,字段仍由公开模型校验。 -class MusicTransferCandidate(BaseModel): # type: ignore[misc] +class MusicTransferCandidate(BaseModel): """ 待确认候选的最小身份摘要;录音、发行组和发行ID保持各自语义。 """ @@ -469,8 +454,7 @@ class MusicTransferCandidate(BaseModel): # type: ignore[misc] year: Optional[str] = Field(default=None, description="媒体发行年份") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class MusicTransferPreview(BaseModel): # type: ignore[misc] +class MusicTransferPreview(BaseModel): """ 音乐识别证据与当前扫描分组;与文件执行成功状态相互独立。 """ @@ -552,8 +536,7 @@ class ManualTransferResultData(BaseModel): message: Optional[str] = Field(default=None, description="面向调用方的结果说明") -# 现有 mypy 配置把 Pydantic 基类视为 Any,公开字段仍由显式模型校验。 -class ManualTransferSubmissionItem(BaseModel): # type: ignore[misc] +class ManualTransferSubmissionItem(BaseModel): """ 实际提交的逐文件回执,接收和等待重试均不表示已入库。 """ @@ -571,8 +554,7 @@ class ManualTransferSubmissionItem(BaseModel): # type: ignore[misc] overwrite_skipped: bool = Field(default=False, description="这是一次正常的策略裁决而非整理故障,调用方据此决定是否写失败历史与推送失败通知") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class ManualTransferSubmissionData(BaseModel): # type: ignore[misc] +class ManualTransferSubmissionData(BaseModel): """ 实际提交结果,即使批次失败也保留已接收、完成和跳过的文件。 """ diff --git a/app/schemas/user.py b/app/schemas/user.py index 689b9d7c2..1e529f30e 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -77,8 +77,7 @@ class UserUpdate(UserBase): permissions: Optional[UserPermissions] = Field(default_factory=UserPermissions, description="权限") -# Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 -class CurrentUserUpdate(BaseModel): # type: ignore[misc] +class CurrentUserUpdate(BaseModel): """ 当前用户可自助更新的资料与通知身份设置。 """ diff --git a/app/sdk/_legacy/history.py b/app/sdk/_legacy/history.py index 9f9f608a9..f05e57bd4 100644 --- a/app/sdk/_legacy/history.py +++ b/app/sdk/_legacy/history.py @@ -103,9 +103,9 @@ class TransferHistoryOper(CanonicalTransferHistoryOper): transfer_history_oper=_LegacyTransferHistoryStager(self), ) - def add_force( # type: ignore[no-untyped-def] + def add_force( self, - **kwargs, + **kwargs: Any, ) -> Optional[TransferHistorySnapshot]: """ 按旧动态字段签名替换同源历史,并返回脱离 Session 的快照。 diff --git a/app/startup/composition/chain.py b/app/startup/composition/chain.py index e7eba15b6..113a8c1f7 100644 --- a/app/startup/composition/chain.py +++ b/app/startup/composition/chain.py @@ -150,11 +150,11 @@ def configure_wallpaper_services() -> None: tmdb_wallpaper=lambda: TmdbChain().get_random_wallpager(), tmdb_wallpapers=lambda count: TmdbChain().get_trending_wallpapers(count), mediaserver_wallpaper=lambda: MediaServerChain().get_latest_wallpaper( - query=MediaServerQueryOptions(fields={MediaServerField.IMAGES}), + query=MediaServerQueryOptions(fields=frozenset({MediaServerField.IMAGES})), ), mediaserver_wallpapers=lambda count: MediaServerChain().get_latest_wallpapers( count=count, - query=MediaServerQueryOptions(fields={MediaServerField.IMAGES}), + query=MediaServerQueryOptions(fields=frozenset({MediaServerField.IMAGES})), ), ) diff --git a/app/startup/composition/classification.py b/app/startup/composition/classification.py index 8ad9fb285..02385f554 100644 --- a/app/startup/composition/classification.py +++ b/app/startup/composition/classification.py @@ -284,7 +284,7 @@ def _legacy_config_snapshot(value: Mapping[str, Any]) -> CategoryConfig: 为兼容 GET 保存可解析的旧配置快照;异常结构由迁移诊断负责说明。 """ try: - return cast(CategoryConfig, CategoryConfig.model_validate(value)) + return CategoryConfig.model_validate(value) except ValidationError: return CategoryConfig() diff --git a/app/startup/composition/network.py b/app/startup/composition/network.py index e76660129..eee3bd2c3 100644 --- a/app/startup/composition/network.py +++ b/app/startup/composition/network.py @@ -320,7 +320,7 @@ class _MessageIngressAdapter: 同步投递消息,关闭响应后返回状态码。 """ response = RequestUtils( - timeout=timeout, # type: ignore[arg-type] + timeout=timeout, headers=dict(headers), ).post_res( url, @@ -348,7 +348,7 @@ class _MessageIngressAdapter: 异步投递消息,关闭响应后返回状态码。 """ response = await AsyncRequestUtils( - timeout=timeout, # type: ignore[arg-type] + timeout=timeout, headers=dict(headers), ).post_res( url, diff --git a/app/startup/composition/site.py b/app/startup/composition/site.py index c502d0157..1d6268526 100644 --- a/app/startup/composition/site.py +++ b/app/startup/composition/site.py @@ -117,10 +117,7 @@ class _RssParserAdapter: """ 调用原生解析器,不可用时保留 None 兜底语义。 """ - return cast( - Optional[list[dict[str, Any]]], - rust_accel.parse_rss_items(content, max_items), - ) + return rust_accel.parse_rss_items(content, max_items) class _CookieBrowserAdapter: diff --git a/app/startup/initializers/modules.py b/app/startup/initializers/modules.py index 76b4db2f4..91fabe512 100644 --- a/app/startup/initializers/modules.py +++ b/app/startup/initializers/modules.py @@ -5,13 +5,12 @@ import asyncio import inspect import sys -from typing import Callable, cast +from typing import Any, Callable, cast # SitesHelper涉及资源包拉取,提前引入并容错提示 try: - from app.application.site.sites import SitesHelper # noqa # pylint: disable=import-error,no-name-in-module + from app.application.site.sites import SitesHelper as _SitesHelper # noqa # pylint: disable=import-error,no-name-in-module except ImportError as e: - SitesHelper = None error_message = f"错误: {str(e)}\n站点认证及索引相关资源导入失败,请尝试重建容器或手动拉取资源" print(error_message, file=sys.stderr) sys.exit(1) @@ -36,7 +35,11 @@ from app.application.messaging.message import ( stop_message, ) from app.application.messaging.webagent.events import dispatch_web_agent_message_event -from app.application.module import configure_module_runtime, reset_module_runtime +from app.application.module import ( + ModuleRuntime, + configure_module_runtime, + reset_module_runtime, +) from app.application.outbox import configure_outbox_dispatcher from app.application.plugin.runtime import get_existing_plugin_manager from app.application.security.url import close_image_proxy_block_log_coalescer @@ -47,7 +50,8 @@ from app.db.session import ( close_database, ) from app.runtime.config import settings as legacy_settings -from app.runtime.events import EventHandlerBinding, EventManager +from app.runtime.event.binding import EventHandlerBinding +from app.runtime.events import EventManager from app.runtime.execution import run_in_threadpool_to_completion from app.runtime.extensions.module.manager import ModuleManager from app.runtime.extensions.service import ( @@ -62,8 +66,8 @@ from app.runtime.settings import ( from app.runtime.tasks import get_task_registry from app.runtime.thread import ThreadHelper from app.schemas.category import ClassificationFactValue, ClassificationFieldDefinition -from app.schemas.message import Message, MessageType -from app.schemas.types import EventType, SystemConfigKey +from app.schemas.message import Message +from app.schemas.types import EventType, MessageType, SystemConfigKey from app.startup.composition.agent import ( compose_agent, publish_agent_services, @@ -138,6 +142,8 @@ from app.startup.initializers.scheduler import ( reset_scheduler_bindings, ) +SitesHelper: Any = _SitesHelper + def _plugin_classification_fields() -> tuple[ClassificationFieldDefinition, ...]: """ @@ -190,10 +196,12 @@ def configure_runtime_data_providers() -> None: 在启动组合层装配模块和配置目录的运行时读取能力。 """ configure_service_config_reader(lambda key: get_configured_system_config().get(key)) - configure_module_runtime(lambda: ModuleManager()) + configure_module_runtime(lambda: cast(ModuleRuntime, ModuleManager())) configure_service_directory( configs=ServiceConfigHelper.get_configs, - modules=lambda module_type: ModuleManager().get_running_type_modules(module_type), + modules=lambda module_type: list( + ModuleManager().get_running_type_modules(module_type) + ), ) @@ -433,7 +441,7 @@ def configure_host_event_handler_resolver() -> None: EventManager().register_handler_instance_resolver("host", resolve) -def start_frontend(): +def start_frontend() -> None: """ 启动前端服务 """ @@ -455,7 +463,7 @@ def start_frontend(): subprocess.Popen("start nginx.exe", cwd=run_nginx_dir, shell=True) -def stop_frontend(): +def stop_frontend() -> None: """ 停止前端服务 """ @@ -466,7 +474,7 @@ def stop_frontend(): subprocess.Popen("taskkill /f /im nginx.exe", shell=True) -def clear_temp(): +def clear_temp() -> None: """ 清理临时文件和图片缓存 """ @@ -484,7 +492,7 @@ def clear_temp(): clear_package_tool_cache() -def clear_package_tool_cache(): +def clear_package_tool_cache() -> None: """ 清理 pip/uv 包下载缓存,只处理 MoviePilot 管理的工具子目录。 """ @@ -500,7 +508,7 @@ def clear_package_tool_cache(): logger.warning("清理包下载缓存失败:%s - %s", cache_path, err) -def user_auth(): +def user_auth() -> None: """ 用户认证检查 """ @@ -526,7 +534,7 @@ def user_auth(): logger.info(f"用户认证失败,{msg}") -def check_auth(): +def check_auth() -> None: """ 检查认证状态 """ @@ -566,7 +574,7 @@ async def drain_events() -> bool: event_manager = EventManager.get_existing_instance() if event_manager is None: return True - return await event_manager.drain_async(seal=True) + return bool(await event_manager.drain_async(seal=True)) async def settle_events() -> bool: @@ -576,7 +584,7 @@ async def settle_events() -> bool: event_manager = EventManager.get_existing_instance() if event_manager is None: return True - return await event_manager.drain_async(seal=False) + return bool(await event_manager.drain_async(seal=False)) async def stop_modules() -> bool: diff --git a/app/startup/initializers/plugins.py b/app/startup/initializers/plugins.py index 6886e13b0..5546588a8 100644 --- a/app/startup/initializers/plugins.py +++ b/app/startup/initializers/plugins.py @@ -49,7 +49,10 @@ from app.application.plugin.identity import ( TrustedPluginSourceType, normalize_physical_plugin_id, ) -from app.application.plugin.install import PluginInstallCommand +from app.application.plugin.install import ( + PluginInstallCommand, + PluginPackageTransactionPort, +) from app.application.plugin.inventory import PluginCandidateInventoryReader from app.application.plugin.lifecycle import PluginStartupLease from app.application.plugin.migration import ( @@ -64,6 +67,7 @@ from app.application.plugin.rating import ( ) from app.application.plugin.recovery import ( PluginInstallationRecoveryService, + PluginRecoveryPackagePort, configure_plugin_installation_recovery, ) from app.application.plugin.release import ( @@ -658,7 +662,7 @@ def configure_plugin_services() -> None: ) or [], loadable_marker=plugin_manager.mark_plugin_loadable, plugin_ids_provider=plugin_manager.get_plugin_ids, - packages=package_manager, + packages=cast(PluginPackageTransactionPort, package_manager), install_reporter=lambda plugin_id, repo_url: ( MoviePilotServerHelper.async_install_plugin_reg( plugin_id=plugin_id, @@ -689,7 +693,7 @@ def configure_plugin_services() -> None: configure_plugin_installation_recovery( PluginInstallationRecoveryService( persistence=persistence, - packages=package_manager, + packages=cast(PluginRecoveryPackagePort, package_manager), ) ) configure_plugin_identity_migration( @@ -1095,6 +1099,8 @@ def _local_plugin_sources(plugin_manager: PluginManager) -> set[str]: logger.warning("跳过无效的已安装插件 ID %s:%s", plugin_id, error) candidates: set[str] = set() for plugin in plugin_manager.get_local_repo_plugins(): + if not isinstance(plugin.id, str): + continue try: source_id = normalize_physical_plugin_id(plugin.id) except ValueError: diff --git a/app/startup/lifecycle/__init__.py b/app/startup/lifecycle/__init__.py index 4d98b1191..c248c01e9 100644 --- a/app/startup/lifecycle/__init__.py +++ b/app/startup/lifecycle/__init__.py @@ -6,7 +6,7 @@ import asyncio import inspect import time from contextlib import asynccontextmanager -from typing import AsyncIterator, Awaitable, Callable +from typing import AsyncIterator, Awaitable, Callable, cast from fastapi import FastAPI @@ -196,6 +196,9 @@ def offload_shutdown_callback( """ async def invoke() -> object: + """ + 把同步生命周期回调交给线程池,并等待其完整结束。 + """ return await run_in_threadpool_to_completion(callback) return invoke @@ -236,7 +239,7 @@ async def stop_lifecycle_components( ): completed = await run_shutdown_step( component.name, - component.stop, + cast(Callable[[], object], component.stop), component.stop_timeout_seconds, ) if completed: @@ -779,7 +782,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: active_start_component = component await run_startup_step( component.name, - component.start, + cast(Callable[[], object], component.start), component.start_timeout_seconds, ) started_component_names.add(component.name) diff --git a/app/testing/network.py b/app/testing/network.py index bc2ef5702..5da5f616c 100644 --- a/app/testing/network.py +++ b/app/testing/network.py @@ -8,6 +8,8 @@ DNS 查询与套接字连接,并在用例结束时检查是否发生了被业 from __future__ import annotations import ipaddress +from collections.abc import Iterator +from typing import Any, NoReturn import pytest @@ -16,7 +18,7 @@ _ALLOWED_NETWORK_HOSTS = {"127.0.0.1", "::1", "localhost", "0.0.0.0", "::", ""} @pytest.fixture(autouse=True) -def block_real_network(monkeypatch): +def block_real_network(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: """ 拦截测试中的非本地主机访问,并在用例结束时复核违规尝试。 @@ -30,9 +32,9 @@ def block_real_network(monkeypatch): _real_getaddrinfo = socket.getaddrinfo _real_connect = socket.socket.connect - attempts = [] + attempts: list[tuple[str, object]] = [] - def _is_allowed_host(host) -> bool: + def _is_allowed_host(host: object) -> bool: """ 判断主机参数是否属于无需外部出站的本地地址。 @@ -48,7 +50,7 @@ def block_real_network(monkeypatch): except ValueError: return False - def _blocked(operation: str, host): + def _blocked(operation: str, host: object) -> NoReturn: """ 记录违规访问并立即抛出异常,使收尾也能发现被吞掉的尝试。 @@ -61,7 +63,7 @@ def block_real_network(monkeypatch): f"测试禁止真实出站网络:尝试通过 {operation} 访问 {host!r};请 mock 对应外部依赖" ) - def _guarded_getaddrinfo(host, *args, **kwargs): + def _guarded_getaddrinfo(host: Any, *args: Any, **kwargs: Any) -> list[tuple[Any, ...]]: """ 先检查主机范围,再委托原 DNS 解析函数。 @@ -74,7 +76,7 @@ def block_real_network(monkeypatch): _blocked("DNS", host) return _real_getaddrinfo(host, *args, **kwargs) - def _guarded_connect(sock, address): + def _guarded_connect(sock: socket.socket, address: Any) -> None: """ 检查网络地址元组中的主机,允许本地套接字继续连接。 diff --git a/app/workflow/__init__.py b/app/workflow/__init__.py index 53fe87722..c8a685813 100644 --- a/app/workflow/__init__.py +++ b/app/workflow/__init__.py @@ -4,7 +4,7 @@ import threading from time import monotonic, sleep -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple, cast from pydantic import BaseModel @@ -161,19 +161,21 @@ class WorkflowManager(metaclass=Singleton): return converged def execute(self, workflow_id: int, action: Action, context: Optional[ActionContext] = None, - inputs: Optional[dict] = None, runtime: Optional[dict] = None, + inputs: Optional[dict[str, Any]] = None, + runtime: Optional[dict[str, Any]] = None, cancel_token: Optional[Any] = None) -> ActionResult: """ 执行工作流动作 """ if not context: context = ActionContext() - if action.type not in self._actions: + action_type = action.type + if not action_type or action_type not in self._actions: logger.error(f"未找到动作: {action.type} - {action.name}") return ActionResult(success=False, message=" ", context=context) retry_config = self._get_retry_config(action) - max_attempts = retry_config["max_attempts"] + max_attempts = int(retry_config["max_attempts"]) interval = retry_config["interval"] backoff = retry_config["backoff"] action_result = ActionResult(success=False, message="", context=context) @@ -233,11 +235,15 @@ class WorkflowManager(metaclass=Singleton): ) def _execute_action_once(self, workflow_id: int, action: Action, context: ActionContext, - inputs: dict, runtime: dict, cancel_token: Optional[Any]) -> ActionResult: + inputs: dict[str, Any], runtime: dict[str, Any], + cancel_token: Optional[Any]) -> ActionResult: """ 运行单次尝试,保留同步动作真实返回后再判断失败和超时的语义。 """ - action_obj = self._actions[action.type](action.id) + action_type = action.type + if not action_type or action_type not in self._actions: + return ActionResult(success=False, message="未找到动作", context=context) + action_obj = self._actions[action_type](action.id) logger.info(f"执行动作: {action.id} - {action.name}") try: action_result = self._run_action_with_loop( @@ -255,7 +261,7 @@ class WorkflowManager(metaclass=Singleton): return action_result def _run_action_with_loop(self, workflow_id: int, action: Action, action_obj: Any, - context: ActionContext, inputs: dict, runtime: dict, + context: ActionContext, inputs: dict[str, Any], runtime: dict[str, Any], cancel_token: Optional[Any]) -> ActionResult: """ 循环前后均检查截止时间,并向动作传递协作令牌;不分离阻塞节点线程。 @@ -293,7 +299,8 @@ class WorkflowManager(metaclass=Singleton): self._sleep_with_cancel(workflow_id, wait_seconds, action_token) def _call_action(self, workflow_id: int, action: Action, action_obj: Any, - context: ActionContext, inputs: dict, runtime: dict) -> ActionResult: + context: ActionContext, inputs: dict[str, Any], + runtime: dict[str, Any]) -> ActionResult: """ 兼容新旧动作入口并把真实同步返回值转换为统一结果。 """ @@ -318,7 +325,7 @@ class WorkflowManager(metaclass=Singleton): timeout = action.timeout or self._get_action_data_value(action, "timeout") return int(timeout) if timeout else None - def _get_retry_config(self, action: Action) -> dict: + def _get_retry_config(self, action: Action) -> dict[str, float | int]: """ 归一化动作重试次数、间隔和退避配置。 """ @@ -352,7 +359,7 @@ class WorkflowManager(metaclass=Singleton): return sleep(max(0.0, min(0.1, deadline - monotonic()))) - def list_actions(self) -> List[dict]: + def list_actions(self) -> List[dict[str, Any]]: """ 获取所有动作 """ @@ -369,14 +376,14 @@ class WorkflowManager(metaclass=Singleton): } for key, action in self._actions.items() ] - def get_action_contract(self, action_type: str) -> dict: + def get_action_contract(self, action_type: str) -> dict[str, Any]: """ 获取动作输入输出契约。 """ action = self._actions.get(action_type) if not action or not hasattr(action, "get_contract"): return {} - return action.get_contract() + return cast(dict[str, Any], action.get_contract()) def update_workflow_event(self, workflow: WorkflowSnapshot) -> None: """ @@ -473,7 +480,8 @@ class WorkflowManager(metaclass=Singleton): :param event: 当前事件及其载荷 """ try: - event_type_str = str(event.event_type.value) + event_type = event.event_type + event_type_str = str(getattr(event_type, "value", event_type)) with self._lock: if event_type_str not in self._event_workflows: return @@ -506,7 +514,10 @@ class WorkflowManager(metaclass=Singleton): logger.warning(f"工作流 {workflow.name} 正在运行中,跳过重复触发") return - logger.info(f"事件 {event.event_type.value} 触发工作流: {workflow.name}") + event_type = event.event_type + logger.info( + f"事件 {getattr(event_type, 'value', event_type)} 触发工作流: {workflow.name}" + ) # 发送工作流执行事件以启动工作流 eventmanager.send_event(EventType.WorkflowExecute, { @@ -516,7 +527,7 @@ class WorkflowManager(metaclass=Singleton): except Exception as e: logger.error(f"触发工作流 {workflow_id} 失败: {e}") - def _check_event_conditions(self, workflow, event: Event) -> bool: + def _check_event_conditions(self, workflow: Any, event: Event) -> bool: """ 检查事件是否满足工作流的触发条件 """ @@ -524,7 +535,11 @@ class WorkflowManager(metaclass=Singleton): return True conditions = workflow.event_conditions - event_data = event.event_data or {} + event_data: Any = event.event_data + if isinstance(event_data, BaseModel): + event_data = event_data.model_dump() + if not isinstance(event_data, dict): + return False # 检查字段匹配条件 for field, expected_value in conditions.items(): @@ -543,7 +558,9 @@ class WorkflowManager(metaclass=Singleton): return True @staticmethod - def _check_complex_condition(actual_value: any, condition: dict) -> bool: + def _check_complex_condition( + actual_value: Any, condition: dict[str, Any] + ) -> bool: """ 检查复杂条件匹配 支持的操作符:equals, not_equals, contains, not_contains, in, not_in, regex @@ -573,7 +590,7 @@ class WorkflowManager(metaclass=Singleton): return False return True - def get_event_workflows(self) -> dict: + def get_event_workflows(self) -> dict[str, list[int]]: """ 获取所有事件触发的工作流 """ diff --git a/app/workflow/actions/__init__.py b/app/workflow/actions/__init__.py index b86ebea17..e5f876542 100644 --- a/app/workflow/actions/__init__.py +++ b/app/workflow/actions/__init__.py @@ -281,8 +281,10 @@ class BaseAction(ABC): return context.runtime_state = context.runtime_state or {} context.runtime_state["current_action_runtime"] = { - key: value for key, value in runtime.items() + key: value + for key, value in runtime.items() if key != "cancel_token" + and isinstance(value, (dict, list, str, int, float, type(None))) } def _extract_outputs_from_context(self, context: ActionContext) -> dict[str, Any]: diff --git a/app/workflow/actions/add_download.py b/app/workflow/actions/add_download.py index c3fca0c72..0aff91c72 100644 --- a/app/workflow/actions/add_download.py +++ b/app/workflow/actions/add_download.py @@ -3,7 +3,7 @@ """ from dataclasses import fields -from typing import Any, Iterator, Optional +from typing import Any, Iterator, Optional, cast from pydantic import Field @@ -50,7 +50,7 @@ class AddDownloadAction(BaseAction): 初始化下载结果集合和动作错误状态。 """ super().__init__(action_id) - self._added_downloads: list[str | tuple[Optional[str], Optional[str]]] = [] + self._added_downloads: list[str] = [] self._has_error = False name = "添加下载" @@ -98,21 +98,21 @@ class AddDownloadAction(BaseAction): else: # 电视剧 exists_seasons = exists_info.seasons or {} - if len(t.meta_info.season_list) > 1: + meta_info = cast(MetaBase, t.meta_info) + season_list = getattr(meta_info, "season_list", []) or [] + if len(season_list) > 1: # 多季不下载 logger.warning(f"{t.meta_info.title} 有多季,跳过") continue else: - season_num = ( - t.meta_info.begin_season - if t.meta_info.begin_season is not None - else (t.meta_info.season_list[0] if t.meta_info.season_list else 1) - ) + begin_season = getattr(meta_info, "begin_season", None) + season_num = begin_season if begin_season is not None else (season_list[0] if season_list else 1) exists_episodes = exists_seasons.get(season_num) - if exists_episodes and t.meta_info.episode_list: - if set(t.meta_info.episode_list).issubset(exists_episodes): + episode_list = getattr(meta_info, "episode_list", []) or [] + if exists_episodes and episode_list: + if set(episode_list).issubset(exists_episodes): logger.warning( - f"{t.meta_info.title} 第 {season_num} 季第 {t.meta_info.episode_list} 集已存在,跳过" + f"{meta_info.title} 第 {season_num} 季第 {episode_list} 集已存在,跳过" ) continue @@ -124,7 +124,8 @@ class AddDownloadAction(BaseAction): label=action_params.labels, ) if did: - self._added_downloads.append(did) + download_id = cast(str, did[1] if isinstance(did, tuple) else did) + self._added_downloads.append(download_id) # 保存缓存 self.save_cache(workflow_id, cache_key) diff --git a/app/workflow/actions/add_subscribe.py b/app/workflow/actions/add_subscribe.py index 1a1a2c1cf..1910314b9 100644 --- a/app/workflow/actions/add_subscribe.py +++ b/app/workflow/actions/add_subscribe.py @@ -71,8 +71,8 @@ class AddSubscribeAction(BaseAction): # 添加订阅 _started = True sid, message = subscribechain.add(mtype=mediainfo.type, - title=mediainfo.title, - year=mediainfo.year, + title=mediainfo.title or "", + year=mediainfo.year or "", season=mediainfo.season, media_source=mediainfo.media_source, media_id=mediainfo.media_id, diff --git a/app/workflow/actions/fetch_downloads.py b/app/workflow/actions/fetch_downloads.py index 38c976c8d..0c282fa03 100644 --- a/app/workflow/actions/fetch_downloads.py +++ b/app/workflow/actions/fetch_downloads.py @@ -58,6 +58,8 @@ class FetchDownloadsAction(BaseAction): if runtime_stop_state.is_workflow_stopped(workflow_id): break logger.info(f"获取下载任务 {download.download_id} 状态 ...") + if not download.download_id: + continue torrents = ActionChain().list_torrents( hashs=[download.download_id], downloader=download.downloader, diff --git a/app/workflow/actions/fetch_medias.py b/app/workflow/actions/fetch_medias.py index 21a0a3bb2..a674e9674 100644 --- a/app/workflow/actions/fetch_medias.py +++ b/app/workflow/actions/fetch_medias.py @@ -2,7 +2,7 @@ 从配置来源获取媒体列表并写入工作流上下文。 """ -from typing import List, Optional +from typing import Any, List, Optional from pydantic import Field @@ -38,6 +38,9 @@ class FetchMediasAction(BaseAction): } def __init__(self, action_id: str): + """ + 初始化获取媒体动作及本次执行的媒体结果列表。 + """ super().__init__(action_id) self._medias: list[MediaInfo] = [] @@ -115,9 +118,11 @@ class FetchMediasAction(BaseAction): event = eventmanager.send_event(ChainEventType.RecommendSource, event_data) # 使用事件返回的上下文数据 if event and event.event_data: - event_data: RecommendSourceEventData = event.event_data - if event_data.extra_sources: - self.__inner_sources.extend([s.model_dump() for s in event_data.extra_sources]) + source_data = event.event_data + if not isinstance(source_data, RecommendSourceEventData): + raise AttributeError("推荐来源事件载荷缺少 extra_sources") + if source_data.extra_sources: + self.__inner_sources.extend([s.model_dump() for s in source_data.extra_sources]) name = "获取媒体数据" description = "获取榜单等媒体数据列表" @@ -132,7 +137,7 @@ class FetchMediasAction(BaseAction): """ return not self._has_error - def __get_source(self, source: str): + def __get_source(self, source: str) -> Optional[dict[str, Any]]: """ 获取数据源 """ @@ -148,7 +153,7 @@ class FetchMediasAction(BaseAction): action_params = FetchMediasParams.model_validate(params) try: if action_params.source_type == "ranking": - for api_path in action_params.sources: + for api_path in action_params.sources or []: if runtime_stop_state.is_workflow_stopped(workflow_id): break source = self.__get_source(api_path) @@ -192,6 +197,8 @@ class FetchMediasAction(BaseAction): self._has_error = True if self._medias: + if context.medias is None: + context.medias = [] context.medias.extend(self._medias) self.job_done(f"获取到 {len(self._medias)} 条媒数据") diff --git a/app/workflow/actions/fetch_rss.py b/app/workflow/actions/fetch_rss.py index abd4ba44f..3719c7fd5 100644 --- a/app/workflow/actions/fetch_rss.py +++ b/app/workflow/actions/fetch_rss.py @@ -2,7 +2,7 @@ 解析 RSS 资源并按需匹配媒体信息。 """ -from typing import Optional +from typing import Any, Optional, cast from pydantic import Field @@ -21,7 +21,7 @@ class FetchRssParams(ActionParams): """ 获取RSS资源列表参数 """ - url: str = Field(default=None, description="RSS地址") + url: Optional[str] = Field(default=None, description="RSS地址") proxy: Optional[bool] = Field(default=False, description="是否使用代理") timeout: Optional[int] = Field(default=15, description="超时时间") content_type: Optional[str] = Field(default=None, description="请求使用的 Content-Type 头;不传时由客户端决定") @@ -46,7 +46,7 @@ class FetchRssAction(BaseAction): :param action_id: 当前工作流动作的标识 """ super().__init__(action_id) - self._rss_torrents = [] + self._rss_torrents: list[Context] = [] self._has_error = False name = "获取RSS资源" @@ -62,7 +62,7 @@ class FetchRssAction(BaseAction): """ return not self._has_error - def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext: + def execute(self, workflow_id: int, params: dict[str, Any], context: ActionContext) -> ActionContext: """ 请求 RSS 地址并将解析出的资源加入工作流上下文。 @@ -87,11 +87,11 @@ class FetchRssAction(BaseAction): proxy=( get_chain_runtime_config_snapshot().proxy if action_params.proxy - else None + else False ), timeout=action_params.timeout, headers=headers) - if rss_items is None or rss_items is False: + if not isinstance(rss_items, list): logger.error(f'RSS地址 {action_params.url} 请求失败!') self._has_error = True return context @@ -110,10 +110,10 @@ class FetchRssAction(BaseAction): title=item.get("title"), enclosure=item.get("enclosure"), page_url=item.get("link"), - size=item.get("size"), + size=float(item["size"]) if isinstance(item.get("size"), (int, float)) else 0.0, pubdate=item["pubdate"].strftime("%Y-%m-%d %H:%M:%S") if item.get("pubdate") else None, ) - meta = MetaInfo(title=torrentinfo.title, subtitle=torrentinfo.description) + meta = MetaInfo(title=torrentinfo.title or "", subtitle=torrentinfo.description) mediainfo = None if action_params.match_media: mediainfo = MediaChain().recognize_by_meta( @@ -127,7 +127,9 @@ class FetchRssAction(BaseAction): if self._rss_torrents: logger.info(f"获取到 {len(self._rss_torrents)} 个RSS资源") - context.torrents.extend(self._rss_torrents) + if context.torrents is None: + context.torrents = [] + context.torrents.extend(cast(Any, self._rss_torrents)) self.job_done(f"获取到 {len(self._rss_torrents)} 个资源") return context diff --git a/app/workflow/actions/fetch_torrents.py b/app/workflow/actions/fetch_torrents.py index 84b21b663..17283f491 100644 --- a/app/workflow/actions/fetch_torrents.py +++ b/app/workflow/actions/fetch_torrents.py @@ -4,7 +4,7 @@ import random import time -from typing import List, Optional, cast +from typing import List, Optional from pydantic import Field @@ -93,7 +93,7 @@ class FetchTorrentsAction(BaseAction): obtain_images=False, ) if not torrent.media_info: - logger.warning(f"{torrent.torrent_info.title} 未识别到媒体信息") + logger.warning(f"{torrent.torrent_info.title if torrent.torrent_info else ''} 未识别到媒体信息") continue self._torrents.append(self._to_workflow_context(torrent)) else: @@ -132,4 +132,4 @@ class FetchTorrentsAction(BaseAction): """ 把领域搜索结果投影为工作流传输上下文。 """ - return cast(WorkflowContext, WorkflowContext.model_validate(context.to_dict())) + return WorkflowContext.model_validate(context.to_dict()) diff --git a/app/workflow/actions/filter_torrents.py b/app/workflow/actions/filter_torrents.py index e040e0fb4..0a17c97ac 100644 --- a/app/workflow/actions/filter_torrents.py +++ b/app/workflow/actions/filter_torrents.py @@ -46,7 +46,7 @@ class FilterTorrentsAction(BaseAction): 初始化过滤后资源集合。 """ super().__init__(action_id) - self._torrents = [] + self._torrents: list[WorkflowTorrentContext] = [] name = "过滤资源" description = "对资源列表数据进行过滤" @@ -64,7 +64,7 @@ class FilterTorrentsAction(BaseAction): 过滤torrents中的资源 """ action_params = FilterTorrentsParams.model_validate(params) - for torrent in context.torrents: + for torrent in context.torrents or []: if runtime_stop_state.is_workflow_stopped(workflow_id): break if isinstance(torrent, dict): @@ -83,11 +83,11 @@ class FilterTorrentsAction(BaseAction): } ): if ActionChain().filter_torrents( - rule_groups=action_params.rule_groups, + rule_groups=action_params.rule_groups or [], torrent_list=[torrent_info], mediainfo=media_info ): - if torrent.torrent_info is not None and torrent.torrent_info is not torrent_info: + if torrent.torrent_info is not None: # 过滤链把规则优先级写在还原出的领域副本上,需同步回保留在结果中的 Schema 原对象。 torrent.torrent_info.pri_order = torrent_info.pri_order self._torrents.append(torrent) diff --git a/app/workflow/actions/invoke_plugin.py b/app/workflow/actions/invoke_plugin.py index a5977ee00..53aa47513 100644 --- a/app/workflow/actions/invoke_plugin.py +++ b/app/workflow/actions/invoke_plugin.py @@ -2,6 +2,8 @@ 调用插件公开的工作流动作。 """ +from typing import Optional + from pydantic import Field from app.application.plugin.runtime import get_plugin_manager @@ -15,8 +17,8 @@ class InvokePluginParams(ActionParams): """ 调用插件动作参数 """ - plugin_id: str = Field(default=None, description="插件ID") - action_id: str = Field(default=None, description="动作ID") + plugin_id: Optional[str] = Field(default=None, description="插件ID") + action_id: Optional[str] = Field(default=None, description="动作ID") action_params: dict[str, JsonData] = Field(default_factory=dict, description="动作参数") @@ -28,6 +30,9 @@ class InvokePluginAction(BaseAction): contract = {} def __init__(self, action_id: str): + """ + 绑定工作流动作标识,并初始化插件执行结果状态。 + """ super().__init__(action_id) self._success = False diff --git a/app/workflow/actions/scrape_file.py b/app/workflow/actions/scrape_file.py index 7e5fa5152..527947b82 100644 --- a/app/workflow/actions/scrape_file.py +++ b/app/workflow/actions/scrape_file.py @@ -63,7 +63,7 @@ class ScrapeFileAction(BaseAction): """ # 失败次数 _failed_count = 0 - for fileitem in context.fileitems: + for fileitem in context.fileitems or []: if runtime_stop_state.is_workflow_stopped(workflow_id): break if fileitem in self._scraped_files: diff --git a/app/workflow/actions/send_event.py b/app/workflow/actions/send_event.py index 3d963d3db..1ebdbc057 100644 --- a/app/workflow/actions/send_event.py +++ b/app/workflow/actions/send_event.py @@ -40,7 +40,7 @@ class SendEventAction(BaseAction): 发送工作流事件,以更插件干预工作流执行 """ # 触发资源下载事件,更新执行上下文 - event = eventmanager.send_event(ChainEventType.WorkflowExecution, context) + event = eventmanager.send_event(ChainEventType.WorkflowExecution, context.model_dump()) if event and isinstance(event.event_data, ActionContext): context = event.event_data diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 0a0789d2c..dbec23a15 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -802,7 +802,7 @@ flowchart LR | 事件合同 | `scripts/architecture/event_facts.py` 是生产/消费事实的唯一收集器;`runtime-contract-policy.json` 独立准入消费者身份,禁止靠生成快照绕过语义检查 | | 事务和配置 | `transaction-debt-baseline.json`、`configuration-debt-baseline.json` 检查底层事务所有权和配置边界;Model/Oper 不得重新创建或提交独立 Session | | SDK / Compat | manifest、SDK 导出和官方插件快照共同核对旧导入路径、符号身份与行为边界;迁移时同步 canonical 导入、公开契约及兼容测试 | -| 类型与复杂度 | `mypy.ini` 严格清单与全宿主逐文件/错误码 ratchet;未列入 mypy 债务基线的新文件必须零错误,既有债务只能减少;复杂度约束 C901/PLR1702,行数只作观察 | +| 类型与复杂度 | `mypy.ini` 默认严格检查全部宿主,类型错误预算为零;`mypy_policy.py` 锁定兼容例外、检查配置与本地类型桩,禁止新增豁免;复杂度约束 C901/PLR1702,行数只作观察 | | 后台与启动 | 并发所有权、异步阻塞、TaskRegistry/生命周期测试和启动性能检查;结构计数与耗时预算分开维护 | | 行为与覆盖率 | pytest 离线隔离执行;CI 按全部宿主文件的覆盖率低水位检查,新文件至少 80%,本机覆盖率不能回写 canonical baseline | @@ -811,13 +811,13 @@ flowchart LR ### 10.2 当前架构快照 -以下是 2026-10-20 核对的 `tests/fixtures/architecture/` 数据;这是源码合同快照, +以下是 2026-10-21 核对的 `tests/fixtures/architecture/` 数据;这是源码合同快照, 不代表某个已发布镜像或正在运行的实例已经验证。 | 指标 | 当前值 | |---|---:| | Python 模块 | 1127 | -| 内部导入边 | 9,639 | +| 内部导入边 | 9,640 | | 非平凡 SCC | 1(29 个模块的 TMDB vendor 精确 containment) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 51(精确 policy 准入,Application / Chain 均为 0) | diff --git a/docs/development-setup.md b/docs/development-setup.md index e0b77f0b9..fd6dacf5e 100644 --- a/docs/development-setup.md +++ b/docs/development-setup.md @@ -244,13 +244,13 @@ Pylint 由 `v3` PR/push 的 GitHub Actions 执行,`app/` 全量 Pylint 是建 站点采集器源码与构建工作流位于 [MoviePilot-Site-Collector](https://github.com/jxxghp/MoviePilot-Site-Collector),使用独立的 `site-adapter-collector-v1.0.x` Release,仅在采集器发布输入变化时递增版本并构建,不跟随后端 Release。下载入口为 [采集器独立 Release](https://github.com/jxxghp/MoviePilot-Site-Collector/releases/latest),自动版本和手动测试构建规则见 [采集器发布说明](https://github.com/jxxghp/MoviePilot-Site-Collector/blob/main/docs/site-adapter-collector-release.md#维护者发布流程)。 -Ruff/Mypy 基线只允许收紧,不接受新增诊断或类型错误增长;受治零错误文件由 `mypy.ini` 的 -`files=` 维护。Mypy 完整 ratchet 固定按 Linux/Python 3.14 分析。Coverage job 在 `v3` PR/push +Ruff/Mypy 基线只允许收紧,不接受新增诊断或类型错误增长。`mypy.ini` 默认严格检查整个 `app/`, +仅排除由插件发布链路治理的运行时 `app/plugins/` 副本;新宿主文件自动纳入检查。 +Mypy 完整 ratchet 固定按 Linux/Python 3.14 分析。Coverage job 在 `v3` PR/push 运行,按 `tests/run.py` 的分片合同执行并合并报告,除 `app/plugins` 外全部 `app` 文件按逐文件 低水位检查,存量只能提升,新文件必须达到 80%。 -逐文件治理以 `mypy.ini` 的严格清单和 -`tests/fixtures/architecture/mypy-baseline.json` 的逐文件、逐错误码低水位为准; -尚未清零的文件须先确认实际合同和适用门禁,不以定向扫描结果宣称交付。 +宿主类型错误基线 `tests/fixtures/architecture/mypy-baseline.json` 已清零,不能重新添加诊断预算; +兼容例外使用下文的精确登记,不以定向扫描结果宣称全量交付。 每个 Coverage 分片预算为 15 分钟(测试 step 为 10 分钟),报告与 ratchet 为 10 分钟;不得靠 跳过测试或产物规避超时。Coverage 只接受 GitHub Actions 的 Ubuntu/Python 3.14、locked 依赖和全量测试工件,本机 macOS 报告仅供诊断,不得写入并提交 canonical baseline。 @@ -272,6 +272,34 @@ uv run --locked --no-sync python scripts/architecture/complexity.py CI 上传 `complexity-report` 工件,门禁失败时也保留已生成的指标。指标口径以 `scripts/architecture/complexity.py` 与 `scripts/architecture/mypy_ratchet.py` 的实现为准。 +### 6.2 类型兼容例外门禁 + +`scripts/architecture/mypy_policy.py` 核对 +`tests/fixtures/architecture/mypy-policy.json` 中每项兼容原因、所属方法、错误码和语法指纹。 +保留例外涉及异步工具旧接口、动态 Facade 描述符、运行时 MRO/元类、FastAPI Response +注入默认值和供应方类型限制; +例外只作用于登记语句,方法体继续严格检查。二进制站点接口的 `sites.pyi` 按文件摘要登记。 + +门禁拒绝新增或变化的 `type: ignore`、没有错误码的忽略、`no_type_check`、文件内 mypy +配置和未经复核的本地类型桩;同时锁定完整 mypy 配置,防止缩小检查范围或扩大导入豁免。 +供应方导入仍使用现有 `follow_imports=skip` 边界;已接入的 LangChain/LangGraph、Pydantic、 +FastAPI/Starlette、Slack 等读取真实供应方声明,不允许用本地类型桩遮蔽它们。 +缺失导入豁免只覆盖配置中逐一列出的既有无类型 SDK,新依赖不会自动得到豁免。 +psutil、regex、tqdm 的正式类型声明由锁文件中的开发依赖提供。 +默认检查同时保留既有的异步阻塞治理脚本,并覆盖新的类型例外门禁自身。 + +```bash +uv run --locked --no-sync python scripts/architecture/mypy_policy.py +uv run --locked --no-sync mypy --config-file mypy.ini +uv run --locked --no-sync python scripts/architecture/mypy_ratchet.py +# 修复例外后仅删除已失效的登记;存在任何增长时拒绝写入 +uv run --locked --no-sync python scripts/architecture/mypy_policy.py --write +``` + +新增兼容边界必须说明真实运行合同与无法直接使用供应方声明的原因,补充行为验证, +人工复核登记和配置差异;不能用写入命令自动接收。CI 执行同一门禁,并测试新文件报错、 +例外转移、类型桩遮蔽和写入拒绝行为。发布前以最终提交的全量检查结果为准。 + ### 7. 参考资源 - [uv 官方文档](https://docs.astral.sh/uv/) diff --git a/docs/rules/03-commands.md b/docs/rules/03-commands.md index 52784fa3b..bc83597bb 100644 --- a/docs/rules/03-commands.md +++ b/docs/rules/03-commands.md @@ -70,6 +70,11 @@ uv run --locked --no-sync pylint app/ # 检查指定模块的代码质量 uv run --locked --no-sync pylint app/chain/download.py + +# 检查全宿主类型及精确兼容例外,禁止新增豁免 +uv run --locked --no-sync python scripts/architecture/mypy_policy.py +uv run --locked --no-sync mypy --config-file mypy.ini +uv run --locked --no-sync python scripts/architecture/mypy_ratchet.py ``` **Rules:** diff --git a/mypy.ini b/mypy.ini index fe20d0d6e..c20391b68 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2,896 +2,18 @@ python_version = 3.14 strict = True warn_unused_configs = True -ignore_missing_imports = True +ignore_missing_imports = False follow_imports = skip show_error_codes = True pretty = True files = - app/modules/plex/plex.py, - app/modules/feishu/feishu.py, - app/modules/emby/emby.py, - app/modules/discord/discord.py, - app/modules/zspace/zspace.py, - app/modules/jellyfin/jellyfin.py, - app/modules/ugreen/ugreen.py, - app/adapters/external/ocr.py, - app/agent/tools/catalog.py, - app/runtime/extensions/module/adapter.py, - app/modules/indexer/parser/rousi.py, - app/adapters/system/stdio.py, - app/adapters/web/plugin/routes.py, - app/api/endpoints/workflow.py, - app/adapters/external/cookiecloud.py, - app/modules/indexer/parser/bitpt.py, - app/modules/indexer/parser/zhixing.py, - app/chain/transfer/format.py, - app/application/formatting.py, - app/modules/transmission/__init__.py, - app/modules/transmission/transmission.py, - app/modules/qbittorrent/__init__.py, - app/modules/_base/downloader.py, - app/monitor/dispatcher.py, - app/application/messaging/skill.py, - app/application/messaging/message.py, - app/workflow/actions/add_subscribe.py, - app/workflow/actions/scan_file.py, - app/workflow/actions/transfer_file.py, - app/workflow/actions/add_download.py, - app/workflow/actions/__init__.py, - app/workflow/actions/note.py, - app/workflow/actions/send_message.py, - app/workflow/actions/filter_medias.py, - app/workflow/actions/fetch_downloads.py, - app/modules/qbittorrent/qbittorrent.py, - app/modules/postgresql/__init__.py, - app/modules/redis/__init__.py, - app/chain/theaudiodb.py, - app/monitor/syslimits.py, - app/workflow/actions/fetch_torrents.py, - app/modules/synologychat/__init__.py, - app/modules/dingtalk/__init__.py, - app/modules/synologychat/synologychat.py, - app/modules/__init__.py, - app/modules/trimemedia/trimemedia.py, - app/modules/themoviedb/__init__.py, - app/runtime/debounce.py, - app/modules/vocechat/vocechat.py, - app/chain/system.py, - app/application/subscription/query.py, - app/chain/transfer/plan.py, - app/application/rules.py, - app/api/endpoints/agent.py, - app/application/messaging/chat.py, - app/chain/recommend.py, - app/application/image.py, - app/modules/anilist/anilist.py, - app/api/endpoints/webhook.py, - app/api/endpoints/notification.py, - app/api/endpoints/llm.py, - app/api/dependencies/auth.py, - app/application/security/auth.py, - app/runtime/state.py, - app/cli.py, - app/api/endpoints/mcp.py, - app/api/endpoints/login.py, - app/api/endpoints/anthropic.py, - app/api/response.py, - app/factory.py, - app/__init__.py, - app/adapters/__init__.py, - app/adapters/cache/__init__.py, - app/adapters/external/__init__.py, - app/adapters/external/github.py, - app/adapters/external/location.py, - app/adapters/external/market.py, - app/adapters/external/server.py, - app/adapters/external/plugin/__init__.py, - app/adapters/external/plugin/client.py, - app/adapters/external/wechat.py, - app/adapters/network/__init__.py, - app/adapters/network/cloudflare.py, - app/adapters/network/feishu.py, - app/adapters/network/http.py, - app/adapters/network/ip.py, - app/adapters/network/resolver.py, - app/adapters/network/webpush.py, - app/adapters/observability/__init__.py, - app/adapters/observability/otel.py, - app/adapters/system/__init__.py, - app/adapters/system/backup/__init__.py, - app/adapters/system/backup/database.py, - app/adapters/system/backup/files.py, - app/adapters/system/code/__init__.py, - app/adapters/system/code/client.py, - app/adapters/system/code/kernel.py, - app/adapters/system/code/output.py, - app/adapters/system/code/process.py, - app/adapters/system/code/runner.py, - app/adapters/system/code/staging.py, - app/adapters/system/display/__init__.py, - app/adapters/system/display/resource.py, - app/adapters/system/fsworker.py, - app/adapters/system/package.py, - app/adapters/system/plugin/__init__.py, - app/adapters/system/plugin/dependency.py, - app/adapters/system/plugin/health.py, - app/adapters/system/plugin/manifest.py, - app/adapters/system/plugin/package.py, - app/adapters/system/resource.py, - app/adapters/system/update.py, - app/adapters/web/__init__.py, - app/adapters/web/correlation.py, - app/adapters/web/docs.py, - app/adapters/web/health.py, - app/adapters/web/metrics.py, - app/adapters/web/plugin/__init__.py, - app/adapters/web/security/__init__.py, - app/adapters/web/security/access.py, - app/agent/__init__.py, - app/agent/api/__init__.py, - app/agent/api/arguments.py, - app/agent/api/executor.py, - app/agent/callback/__init__.py, - app/agent/capabilities/__init__.py, - app/agent/capabilities/adapter.py, - app/agent/code/__init__.py, - app/agent/code/authority.py, - app/agent/code/manager.py, - app/agent/code/result.py, - app/agent/contracts.py, - app/agent/guardrails/__init__.py, - app/agent/guardrails/completion.py, - app/agent/guardrails/controller.py, - app/agent/guardrails/results.py, - app/agent/history/__init__.py, - app/agent/history/importer.py, - app/agent/history/message.py, - app/agent/history/query.py, - app/agent/history/schema.py, - app/agent/history/search.py, - app/agent/history/storage.py, - app/agent/history/view.py, - app/agent/learning/__init__.py, - app/agent/learning/files.py, - app/agent/learning/matching.py, - app/agent/learning/memory.py, - app/agent/learning/prompts.py, - app/agent/learning/review.py, - app/agent/learning/schema.py, - app/agent/learning/session.py, - app/agent/learning/skills.py, - app/agent/learning/threats.py, - app/agent/learning/tools.py, - app/agent/learning/usage.py, - app/agent/lifecycle.py, - app/agent/llm/__init__.py, - app/agent/llm/auth.py, - app/agent/llm/catalog.py, - app/agent/llm/discovery.py, - app/agent/llm/gateway.py, - app/agent/llm/provider.py, - app/agent/llm/runtime.py, - app/agent/llm/session.py, - app/agent/llm/tools.py, - app/agent/loader.py, - app/agent/manager.py, - app/agent/middleware/__init__.py, - app/agent/middleware/code.py, - app/agent/middleware/guardrails.py, - app/agent/middleware/invocation.py, - app/agent/middleware/learning.py, - app/agent/middleware/output.py, - app/agent/middleware/plan.py, - app/agent/middleware/policy.py, - app/agent/middleware/recall.py, - app/agent/middleware/steering.py, - app/agent/middleware/terminal.py, - app/agent/middleware/utils.py, - app/agent/middleware/vision.py, - app/agent/policy/__init__.py, - app/agent/policy/api.py, - app/agent/policy/contracts.py, - app/agent/policy/mcp.py, - app/agent/policy/orchestrator.py, - app/agent/policy/registry.py, - app/agent/policy/sanitizer.py, - app/agent/prompt/transfer.py, - app/agent/session.py, - app/agent/shell.py, - app/agent/skills/__init__.py, - app/agent/steering.py, - app/agent/tasks.py, - app/agent/terminal/__init__.py, - app/agent/terminal/manager.py, - app/agent/terminal/output.py, - app/agent/terminal/ownership.py, - app/agent/terminal/session.py, - app/agent/tools/__init__.py, - app/agent/tools/impl/__init__.py, - app/agent/tools/impl/_command_safety.py, - app/agent/tools/impl/_file_write_utils.py, - app/agent/tools/impl/agent_task.py, - app/agent/tools/impl/api.py, - app/agent/tools/impl/execute_code.py, - app/agent/tools/impl/persona.py, - app/agent/tools/impl/service.py, - app/agent/tools/impl/view_image.py, - app/agent/tools/projection.py, - app/agent/tools/result.py, - app/agent/tools/tags.py, - app/agent/web.py, - app/api/__init__.py, - app/api/apiv1.py, - app/api/context.py, - app/api/data.py, - app/api/dependencies/__init__.py, - app/api/dependencies/data.py, - app/api/dependencies/github.py, - app/api/dependencies/plugin.py, - app/api/dependencies/subscription.py, - app/api/deps.py, - app/api/endpoints/__init__.py, - app/api/endpoints/anilist.py, - app/api/endpoints/classification.py, - app/api/endpoints/douban.py, - app/api/endpoints/github.py, - app/api/endpoints/identifier.py, - app/api/endpoints/module.py, - app/api/endpoints/pluginclone.py, - app/api/endpoints/pluginfolder.py, - app/api/endpoints/plugininstance.py, - app/api/endpoints/pluginloglevel.py, - app/api/endpoints/plugintarget.py, - app/api/endpoints/recommend.py, - app/api/endpoints/rule.py, - app/api/endpoints/settings.py, - app/api/endpoints/subexecution.py, - app/api/endpoints/submaintenance.py, - app/api/endpoints/transferhistory.py, - app/api/presentation/__init__.py, - app/api/presentation/sse.py, - app/api/principal.py, - app/api/protocol.py, - app/api/routers.py, - app/application/__init__.py, - app/application/agent.py, - app/application/agenttask.py, - app/application/audio.py, - app/application/backup.py, - app/application/chain/__init__.py, - app/application/chain/context.py, - app/application/chain/events.py, - app/application/classification/__init__.py, - app/application/classification/analysis.py, - app/application/classification/catalog.py, - app/application/classification/compiler.py, - app/application/classification/configuration.py, - app/application/classification/contract.py, - app/application/classification/enrichment.py, - app/application/classification/execution.py, - app/application/classification/legacy.py, - app/application/classification/migration.py, - app/application/classification/projection.py, - app/application/classification/reference.py, - app/application/classification/runtime.py, - app/application/commands.py, - app/application/community/__init__.py, - app/application/community/medals.py, - app/application/configuration.py, - app/application/dashboard.py, - app/application/database.py, - app/application/download/__init__.py, - app/application/download/admission.py, - app/application/download/classification.py, - app/application/download/failures.py, - app/application/download/organization.py, - app/application/download/selection.py, - app/application/download/tasks.py, - app/application/download/validation.py, - app/application/downloader.py, - app/application/filtering.py, - app/application/github_auth.py, - app/application/history/contracts.py, - app/application/history/mutation.py, - app/application/history/retry.py, - app/application/invocation.py, - app/application/maintenance.py, - app/application/mediaserver.py, - app/application/messaging/__init__.py, - app/application/messaging/channel/__init__.py, - app/application/messaging/channel/admin.py, - app/application/messaging/image.py, - app/application/messaging/ingress.py, - app/application/messaging/interaction/agent.py, - app/application/messaging/media.py, - app/application/messaging/recall.py, - app/application/messaging/router.py, - app/application/messaging/session.py, - app/application/messaging/update.py, - app/application/messaging/webagent/__init__.py, - app/application/messaging/webagent/events.py, - app/application/messaging/webagent/stream.py, - app/application/music/__init__.py, - app/application/music/catalog.py, - app/application/music/observation.py, - app/application/music/projection.py, - app/application/music/recognition.py, - app/application/nettest/__init__.py, - app/application/nettest/catalogue.py, - app/application/nettest/domain.py, - app/application/network.py, - app/application/notification.py, - app/application/outbox.py, - app/application/plugin/__init__.py, - app/application/plugin/admission.py, - app/application/plugin/catalog.py, - app/application/plugin/config.py, - app/application/plugin/data.py, - app/application/plugin/declaration.py, - app/application/plugin/folders.py, - app/application/plugin/gateway.py, - app/application/plugin/identity.py, - app/application/plugin/install.py, - app/application/plugin/inventory.py, - app/application/plugin/lifecycle.py, - app/application/plugin/migration.py, - app/application/plugin/rating.py, - app/application/plugin/recovery.py, - app/application/plugin/release.py, - app/application/plugin/routes.py, - app/application/plugin/runtime.py, - app/application/plugin/source.py, - app/application/plugin/transaction.py, - app/application/query.py, - app/application/recognition.py, - app/application/scheduling.py, - app/application/search/__init__.py, - app/application/search/session.py, - app/application/search/state.py, - app/application/security/__init__.py, - app/application/security/image.py, - app/application/security/otp.py, - app/application/security/passkey.py, - app/application/security/secrets.py, - app/application/security/throttle.py, - app/application/security/token.py, - app/application/security/twofactor.py, - app/application/security/user.py, - app/application/security/userconfig.py, - app/application/server/__init__.py, - app/application/server/payload.py, - app/application/server/report.py, - app/application/server/share.py, - app/application/service.py, - app/application/settings/__init__.py, - app/application/settings/contract.py, - app/application/settings/service.py, - app/application/site/__init__.py, - app/application/site/auth.py, - app/application/site/contract.py, - app/application/site/health.py, - app/application/site/mutation.py, - app/application/site/observation.py, - app/application/site/query.py, - app/application/storage.py, - app/application/subscription/__init__.py, - app/application/subscription/activity.py, - app/application/subscription/candidates.py, - app/application/subscription/complete.py, - app/application/subscription/delete.py, - app/application/subscription/execution.py, - app/application/subscription/facts.py, - app/application/subscription/identity.py, - app/application/subscription/mutation.py, - app/application/subscription/observability.py, - app/application/subscription/priority.py, - app/application/subscription/search.py, - app/application/subscription/sitebudget.py, - app/application/subscription/status.py, - app/application/system.py, - app/application/torrent/__init__.py, - app/application/torrent/cache.py, - app/application/transfer/__init__.py, - app/application/transfer/checkpoint.py, - app/application/transfer/execution.py, - app/application/transfer/feedback.py, - app/application/transfer/history.py, - app/application/transfer/jobs.py, - app/application/transfer/models.py, - app/application/transfer/notifications.py, - app/application/transfer/projection.py, - app/application/transfer/recovery.py, - app/application/transfer/workflow.py, - app/application/workflow.py, - app/chain/__init__.py, - app/chain/_contracts.py, - app/chain/_recognition.py, - app/chain/acoustid.py, - app/chain/agent.py, - app/chain/download/__init__.py, - app/chain/download/batch.py, - app/chain/download/contract.py, - app/chain/download/existence.py, - app/chain/download/facade.py, - app/chain/download/failure.py, - app/chain/download/history.py, - app/chain/download/ports.py, - app/chain/download/processing.py, - app/chain/download/selection.py, - app/chain/download/submission.py, - app/chain/download/subtitle.py, - app/chain/download/tasks.py, - app/chain/listenbrainz.py, - app/chain/lyrics.py, - app/chain/media/__init__.py, - app/chain/media/album.py, - app/chain/media/auxiliary.py, - app/chain/media/cache.py, - app/chain/media/catalog.py, - app/chain/media/contract.py, - app/chain/media/facade.py, - app/chain/media/path.py, - app/chain/media/plugin.py, - app/chain/media/projection.py, - app/chain/media/recognition.py, - app/chain/media/search.py, - app/chain/music_interaction/__init__.py, - app/chain/music_interaction/music.py, - app/chain/music_interaction/subscribe.py, - app/chain/notification.py, - app/chain/search/__init__.py, - app/chain/search/cache.py, - app/chain/search/contract.py, - app/chain/search/execution.py, - app/chain/search/facade.py, - app/chain/search/manual.py, - app/chain/search/media.py, - app/chain/search/music.py, - app/chain/search/pagination.py, - app/chain/search/plan.py, - app/chain/search/provider.py, - app/chain/search/recommend.py, - app/chain/search/result.py, - app/chain/search/scan.py, - app/chain/search/site.py, - app/chain/search/subtitle.py, - app/chain/search/title.py, - app/chain/subscribe/__init__.py, - app/chain/subscribe/context.py, - app/chain/subscribe/contract.py, - app/chain/subscribe/identity.py, - app/chain/subscribe/metadata.py, - app/chain/subscribe/music.py, - app/chain/subscribe/notify.py, - app/chain/subscribe/reconcile.py, - app/chain/subscribe/searchtask.py, - app/chain/transfer/__init__.py, - app/chain/transfer/checkpoint.py, - app/chain/transfer/contract.py, - app/chain/transfer/music.py, - app/chain/tvdb.py, - app/chain/user.py, - app/chain/webhook.py, - app/db/__init__.py, - app/db/adapters/__init__.py, - app/db/adapters/agent.py, - app/db/adapters/chain.py, - app/db/adapters/classification.py, - app/db/adapters/configuration.py, - app/db/adapters/download.py, - app/db/adapters/history/__init__.py, - app/db/adapters/history/download.py, - app/db/adapters/history/transfer.py, - app/db/adapters/invocation.py, - app/db/adapters/mediaserver.py, - app/db/adapters/outbox.py, - app/db/adapters/pluginidentity.py, - app/db/adapters/plugininstallation.py, - app/db/adapters/query.py, - app/db/adapters/recall.py, - app/db/adapters/searchsession.py, - app/db/adapters/site.py, - app/db/adapters/subscription.py, - app/db/adapters/subscriptionactivity.py, - app/db/adapters/subscriptionsearch.py, - app/db/adapters/subscriptionstatus.py, - app/db/adapters/transaction.py, - app/db/adapters/transfer/__init__.py, - app/db/adapters/transfer/admission.py, - app/db/adapters/transfer/execution.py, - app/db/adapters/user.py, - app/db/adapters/workflow.py, - app/db/base.py, - app/db/decorators.py, - app/db/diagnostics.py, - app/db/engine.py, - app/db/health.py, - app/db/maintenance.py, - app/db/models/__init__.py, - app/db/models/_constraints.py, - app/db/models/_identity.py, - app/db/models/agentchat.py, - app/db/models/agentinvocation.py, - app/db/models/agenttask.py, - app/db/models/agenttaskrun.py, - app/db/models/downloadfailure.py, - app/db/models/downloadhistory.py, - app/db/models/mediaserver.py, - app/db/models/message.py, - app/db/models/outbox.py, - app/db/models/passkey.py, - app/db/models/plugindata.py, - app/db/models/pluginidentity.py, - app/db/models/plugininstallation.py, - app/db/models/plugininstance.py, - app/db/models/searchsession.py, - app/db/models/site.py, - app/db/models/siteicon.py, - app/db/models/sitestatistic.py, - app/db/models/siteuserdata.py, - app/db/models/subscribe.py, - app/db/models/subscribeactivity.py, - app/db/models/subscribehistory.py, - app/db/models/subscriptionsearch.py, - app/db/models/systemconfig.py, - app/db/models/transferexecutionstep.py, - app/db/models/transferhistory.py, - app/db/models/transferpending.py, - app/db/models/transfersettlementreceipt.py, - app/db/models/user.py, - app/db/models/userconfig.py, - app/db/models/workflow.py, - app/db/oper/__init__.py, - app/db/oper/agentchat.py, - app/db/oper/agentinvocation.py, - app/db/oper/agenttask.py, - app/db/oper/downloadfailure.py, - app/db/oper/downloadhistory.py, - app/db/oper/mediaserver.py, - app/db/oper/message.py, - app/db/oper/passkey.py, - app/db/oper/plugindata.py, - app/db/oper/pluginidentity.py, - app/db/oper/plugininstance.py, - app/db/oper/query.py, - app/db/oper/searchsession.py, - app/db/oper/site.py, - app/db/oper/subscribe.py, - app/db/oper/subscribeactivity.py, - app/db/oper/subscribehistory.py, - app/db/oper/subscriptionsearch.py, - app/db/oper/systemconfig.py, - app/db/oper/transferexecutionstep.py, - app/db/oper/transferhistory.py, - app/db/oper/transferpending.py, - app/db/oper/transfersettlementreceipt.py, - app/db/oper/user.py, - app/db/oper/userconfig.py, - app/db/oper/workflow.py, - app/db/plugin/__init__.py, - app/db/plugin/base.py, - app/db/plugin/container.py, - app/db/plugin/locator.py, - app/db/plugin/migration.py, - app/db/plugin/registry.py, - app/db/session.py, - app/db/uow.py, - app/db/worker.py, - app/doctor/__init__.py, - app/doctor/dependencies.py, - app/doctor/formatters.py, - app/doctor/models.py, - app/domain/__init__.py, - app/domain/classification/__init__.py, - app/domain/classification/conditions.py, - app/domain/classification/evaluator.py, - app/domain/classification/facts.py, - app/domain/classification/fields.py, - app/domain/classification/sources.py, - app/domain/classification/validation.py, - app/domain/classification/vocabulary.py, - app/domain/episode.py, - app/domain/github.py, - app/domain/media.py, - app/domain/meta/__init__.py, - app/domain/meta/customization.py, - app/domain/meta/infopath.py, - app/domain/meta/releasegroup.py, - app/domain/meta/streamingplatform.py, - app/domain/music.py, - app/domain/plugin.py, - app/domain/projection/__init__.py, - app/domain/projection/anilist.py, - app/domain/projection/bangumi.py, - app/domain/projection/douban.py, - app/domain/projection/mapping.py, - app/domain/projection/tmdb.py, - app/domain/scraper.py, - app/domain/search.py, - app/domain/site.py, - app/domain/title.py, - app/domain/tokens.py, - app/domain/torrent.py, - app/foundation/__init__.py, - app/foundation/collections.py, - app/foundation/crypto.py, - app/foundation/dom.py, - app/foundation/environment.py, - app/foundation/identity.py, - app/foundation/reflection.py, - app/foundation/singleton.py, - app/foundation/size.py, - app/foundation/text.py, - app/foundation/url.py, - app/foundation/version.py, - app/modules/_base/__init__.py, - app/modules/_base/media.py, - app/modules/amll/__init__.py, - app/modules/amll/lyrics.py, - app/modules/amll/matching.py, - app/modules/amll/module.py, - app/modules/bangumi/bangumi.py, - app/modules/dingtalk/dingtalk.py, - app/modules/douban/scraper.py, - app/modules/douban/apiv2.py, - app/modules/feishu/__init__.py, - app/modules/feishu/openapi.py, - app/modules/filemanager/__init__.py, - app/modules/filemanager/storages/alistgo.py, - app/modules/indexer/parser/__init__.py, - app/modules/indexer/parser/discuz.py, - app/modules/indexer/parser/file_list.py, - app/modules/indexer/parser/gazelle.py, - app/modules/indexer/parser/hddolby.py, - app/modules/indexer/parser/ipt.py, - app/modules/indexer/parser/mtorrent.py, - app/modules/indexer/parser/nexus_audiences.py, - app/modules/indexer/parser/nexus_hhanclub.py, - app/modules/indexer/parser/nexus_php.py, - app/modules/indexer/parser/nexus_project.py, - app/modules/indexer/parser/nexus_rabbit.py, - app/modules/indexer/parser/small_horse.py, - app/modules/indexer/parser/tnode.py, - app/modules/indexer/parser/torrent_leech.py, - app/modules/indexer/parser/unit3d.py, - app/modules/indexer/parser/yema.py, - app/modules/indexer/spider/__init__.py, - app/modules/indexer/spider/milkie.py, - app/modules/qqbot/__init__.py, - app/modules/qqbot/api.py, - app/modules/rtorrent/rtorrent.py, - app/modules/telegram/__init__.py, - app/modules/telegram/botapi.py, - app/modules/themoviedb/category.py, - app/modules/themoviedb/tmdbapi.py, - app/modules/themoviedb/tmdbv3api/__init__.py, - app/modules/themoviedb/tmdbv3api/exceptions.py, - app/modules/themoviedb/tmdbv3api/objs/__init__.py, - app/modules/themoviedb/tmdbv3api/objs/account.py, - app/modules/themoviedb/tmdbv3api/objs/auth.py, - app/modules/themoviedb/tmdbv3api/objs/certification.py, - app/modules/themoviedb/tmdbv3api/objs/change.py, - app/modules/themoviedb/tmdbv3api/objs/company.py, - app/modules/themoviedb/tmdbv3api/objs/configuration.py, - app/modules/themoviedb/tmdbv3api/objs/credit.py, - app/modules/themoviedb/tmdbv3api/objs/find.py, - app/modules/themoviedb/tmdbv3api/objs/genre.py, - app/modules/themoviedb/tmdbv3api/objs/group.py, - app/modules/themoviedb/tmdbv3api/objs/keyword.py, - app/modules/themoviedb/tmdbv3api/objs/list.py, - app/modules/themoviedb/tmdbv3api/objs/episode.py, - app/modules/themoviedb/tmdbv3api/objs/person.py, - app/modules/themoviedb/tmdbv3api/objs/search.py, - app/modules/themoviedb/tmdbv3api/objs/season.py, - app/modules/themoviedb/tmdbv3api/objs/movie.py, - app/modules/themoviedb/tmdbv3api/objs/network.py, - app/modules/themoviedb/tmdbv3api/objs/provider.py, - app/modules/themoviedb/tmdbv3api/objs/review.py, - app/modules/themoviedb/tmdbv3api/objs/trending.py, - app/modules/themoviedb/tmdbv3api/objs/tv.py, - app/modules/themoviedb/tmdbv3api/tmdb.py, - app/modules/thetvdb/client.py, - app/modules/trimemedia/__init__.py, - app/modules/ugreen/__init__.py, - app/modules/ugreen/api.py, - app/modules/vyo/__init__.py, - app/modules/vyo/api.py, - app/modules/vyo/module.py, - app/modules/vyo/vyo.py, - app/monitor/__init__.py, - app/monitor/recovery.py, - app/monitor/watcher.py, - app/runtime/__init__.py, - app/runtime/rate.py, - app/runtime/capabilities/__init__.py, - app/runtime/capabilities/errors.py, - app/runtime/capabilities/model.py, - app/runtime/capabilities/registry.py, - app/runtime/capabilities/runtime.py, - app/runtime/coalesce.py, - app/runtime/compat/__init__.py, - app/runtime/compat/diagnostics.py, - app/runtime/compat/manifest.py, - app/runtime/compat/resources.py, - app/runtime/correlation.py, - app/runtime/dependencies/__init__.py, - app/runtime/dependencies/native.py, - app/runtime/dependencies/profile.py, - app/runtime/deprecation/__init__.py, - app/runtime/deprecation/notices.py, - app/runtime/deprecation/policy.py, - app/runtime/errors.py, - app/runtime/event/__init__.py, - app/runtime/event/binding.py, - app/runtime/event/contracts.py, - app/runtime/event/dispatch.py, - app/runtime/event/errors.py, - app/runtime/event/registry.py, - app/runtime/event/snapshot.py, - app/runtime/execution.py, - app/runtime/extensions/__init__.py, - app/runtime/extensions/module/__init__.py, - app/runtime/extensions/module/contracts.py, - app/runtime/extensions/module/dispatcher.py, - app/runtime/extensions/module/quality.py, - app/runtime/extensions/plugin/__init__.py, - app/runtime/extensions/plugin/access.py, - app/runtime/extensions/plugin/admission.py, - app/runtime/extensions/plugin/catalog.py, - app/runtime/extensions/plugin/classification.py, - app/runtime/extensions/plugin/clone.py, - app/runtime/extensions/plugin/contracts.py, - app/runtime/extensions/plugin/database.py, - app/runtime/extensions/plugin/datadir.py, - app/runtime/extensions/plugin/dependency.py, - app/runtime/extensions/plugin/gil.py, - app/runtime/extensions/plugin/lifecycle.py, - app/runtime/extensions/plugin/loader.py, - app/runtime/extensions/plugin/loglevel.py, - app/runtime/extensions/plugin/metadata.py, - app/runtime/extensions/plugin/monitor.py, - app/runtime/extensions/plugin/paths.py, - app/runtime/extensions/plugin/registry.py, - app/runtime/extensions/plugin/runtime.py, - app/runtime/extensions/plugin/storage.py, - app/runtime/extensions/plugin/sync.py, - app/runtime/extensions/plugin/system.py, - app/runtime/extensions/plugin/target.py, - app/runtime/extensions/plugin/tools.py, - app/runtime/extensions/resource.py, - app/runtime/extensions/service.py, - app/runtime/gc.py, - app/runtime/health.py, - app/runtime/localization.py, - app/runtime/log.py, - app/runtime/loop.py, - app/runtime/observability/__init__.py, - app/runtime/progress.py, - app/runtime/reload.py, - app/runtime/resources.py, - app/runtime/settings.py, - app/runtime/stop.py, - app/runtime/tasks.py, - app/runtime/thread.py, - app/runtime/topology.py, - app/runtime/version.py, - app/runtime/webpush.py, - app/scheduler/__init__.py, - app/scheduler/bridge.py, - app/scheduler/catalog.py, - app/scheduler/chain.py, - app/scheduler/contract.py, - app/scheduler/execution.py, - app/scheduler/facade.py, - app/scheduler/lifecycle.py, - app/scheduler/maintenance.py, - app/scheduler/oncejob.py, - app/scheduler/progress.py, - app/scheduler/reconcile.py, - app/scheduler/registry.py, - app/scheduler/services.py, - app/schemas/__init__.py, - app/schemas/exception.py, - app/schemas/exports.py, - app/schemas/github.py, - app/schemas/initialization.py, - app/schemas/query.py, - app/schemas/recommend.py, - app/sdk/__init__.py, - app/sdk/_legacy/__init__.py, - app/sdk/_legacy/history.py, - app/sdk/_legacy/site.py, - app/sdk/_legacy/subscribe.py, - app/sdk/_legacy/transfer.py, - app/sdk/_legacy/transferpending.py, - app/sdk/_legacy/user.py, - app/sdk/_legacy/workflow.py, - app/sdk/browser.py, - app/sdk/cache.py, - app/sdk/chain.py, - app/sdk/classification.py, - app/sdk/config.py, - app/sdk/database.py, - app/sdk/events.py, - app/sdk/feishu.py, - app/sdk/logging.py, - app/sdk/media.py, - app/sdk/network.py, - app/sdk/plugin/__init__.py, - app/sdk/plugin/base.py, - app/sdk/plugin/manager.py, - app/sdk/queries.py, - app/sdk/scheduler.py, - app/sdk/security.py, - app/sdk/services.py, - app/sdk/string.py, - app/sdk/utilities.py, - app/startup/__init__.py, - app/startup/composition/__init__.py, - app/startup/composition/agent.py, - app/startup/composition/chain.py, - app/startup/composition/classification.py, - app/startup/composition/configuration.py, - app/startup/composition/context.py, - app/startup/composition/domain.py, - app/startup/composition/enrichment.py, - app/startup/composition/network.py, - app/startup/composition/outbox.py, - app/startup/composition/plugin.py, - app/startup/composition/resource.py, - app/startup/composition/runtime.py, - app/startup/composition/security.py, - app/startup/composition/server.py, - app/startup/composition/site.py, - app/startup/composition/system.py, - app/startup/initializers/__init__.py, - app/startup/initializers/agent.py, - app/startup/initializers/cache.py, - app/startup/initializers/chain.py, - app/startup/initializers/database.py, - app/startup/initializers/domain.py, - app/startup/initializers/monitor.py, - app/startup/initializers/network.py, - app/startup/initializers/resources.py, - app/startup/initializers/routers.py, - app/startup/initializers/scheduler.py, - app/startup/initializers/site.py, - app/startup/initializers/transfer.py, - app/startup/initializers/workflow.py, - app/startup/lifecycle/components.py, - app/testing/__init__.py, + app, scripts/architecture/async_blocking.py, - app/agent/tools/impl/mcp.py, - app/application/messaging/plugin.py, - app/chain/interaction.py, - app/chain/transfer/facade.py, - app/chain/transfer/history.py, - app/chain/transfer/scrape.py, - app/domain/meta/metaanime.py, - app/domain/meta/metavideo.py, - app/modules/qqbot/gateway.py, - app/modules/themoviedb/tmdbv3api/as_obj.py, - app/runtime/compat/imports.py, - app/schemas/types.py, - app/agent/prompt/__init__.py, - app/agent/runtime.py, - app/agent/skills/metadata.py, - app/application/messaging/interaction/__init__.py, - app/application/messaging/site.py, - app/application/messaging/subscribe.py, - app/application/security/cookie.py, - app/chain/_interaction.py, - app/doctor/checks.py, - app/foundation/temporal.py, - app/modules/indexer/spider/mtorrent.py, - app/modules/musicbrainz/cache.py, - app/modules/trimemedia/module.py, - app/runtime/extensions/module/manager.py, - app/runtime/scheduling.py, - app/testing/bootstrap.py + scripts/architecture/mypy_policy.py +exclude = ^app/plugins(?:/|$) +platform = linux -# 宿主内部导入读取真实 .py/.pyi 合同;仅 files 中的目标在此严格检查。 -# 未纳入清单的历史债务继续由完整 app 显式扫描逐文件、逐错误码守住低水位。 +# 完整宿主源码读取真实 .py/.pyi 合同;运行时插件副本由插件发布链路治理。 [mypy-app.*] follow_imports = silent follow_imports_for_stubs = True @@ -899,3 +21,18 @@ follow_imports_for_stubs = True # 读取已安装供应方的真实装饰器与路由签名,避免把已声明类型的接口视为 Any。 [mypy-click.*,fastapi.*] follow_imports = silent + +# 读取 Slack SDK 已声明的事件回调与客户端签名。 +[mypy-slack_bolt.*,slack_sdk.*] +follow_imports = silent + +# 使用 Pydantic 模型与 Uvicorn 的真实类型,避免从 Any 继承或擦除校验入口。 +[mypy-pydantic.*,pydantic_settings.*,typing_extensions.*,uvicorn.*,pytest.*,_pytest.*] +follow_imports = silent + +[mypy-langchain.*,langchain_core.*,langgraph.*,starlette.*] +follow_imports = silent + +# 这些既有 SDK 未提供 py.typed 或类型桩;新增依赖不能复用全局缺失导入豁免。 +[mypy-lxml.*,Pinyin2Hanzi.*,anitopy.*,apscheduler.*,boto3.*,botocore.*,cloakbrowser.*,cn2an.*,http_ece.*,moviepilot_rust.*,oss2.*,pillow_avif.*,py_vapid.*,pyquery.*,pystray.*,smbclient.*,smbprotocol.*,telegramify_markdown.*,torrentool.*] +ignore_missing_imports = True diff --git a/pyproject.toml b/pyproject.toml index c978a3d2d..b7a657b41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,9 +116,12 @@ dev = [ "ruff~=0.16.4", "types-aiofiles~=25.1.0.20260518", "types-dateparser~=1.4.3.20260907", + "types-psutil~=7.2.2.20260906", "types-python-dateutil~=2.9.0.20260807", "types-pytz~=2026.5.0.20261006", "types-pyyaml~=6.0.12.20260906", + "types-regex~=2026.9.29.20261017", + "types-tqdm~=4.70.0.20260906", ] runtime-standard = [ "Brotli==1.2.0", diff --git a/scripts/architecture/mypy_policy.py b/scripts/architecture/mypy_policy.py new file mode 100644 index 000000000..8cbe4b03b --- /dev/null +++ b/scripts/architecture/mypy_policy.py @@ -0,0 +1,232 @@ +""" +锁定宿主的精确类型兼容例外,禁止新增忽略、类型桩和检查范围退化。 +""" + +from __future__ import annotations + +import argparse +import ast +import configparser +import copy +import hashlib +import io +import json +import os +import re +import tokenize +from collections import Counter +from pathlib import Path +from typing import Any + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_POLICY = PROJECT_ROOT / "tests/fixtures/architecture/mypy-policy.json" +_IGNORE = re.compile(r"#\s*type:\s*ignore(?:\[([^\]]+)\])?") +_SKIP_DIRECTORIES = frozenset({ + ".git", ".venv", "venv", "node_modules", "__pycache__", ".mypy_cache", + ".pytest_cache", ".ruff_cache", +}) + + +def configuration(root: Path) -> dict[str, dict[str, str]]: + """ + 读取完整 mypy 配置,新增排除项和局部宽松选项必须经过显式复核。 + """ + parser = configparser.ConfigParser(interpolation=None) + with (root / "mypy.ini").open(encoding="utf-8") as stream: + parser.read_file(stream) + return {section: dict(sorted(parser.items(section))) for section in sorted(parser.sections())} + + +def _statements(tree: ast.AST, owner: str = "") -> list[tuple[ast.stmt, str]]: + """ + 给每条语句标记所属类和方法,移动忽略到其他方法不能复用原额度。 + """ + result = [] + if isinstance(tree, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): + owner = tree.name if owner == "" else f"{owner}.{tree.name}" + if isinstance(tree, ast.stmt): + result.append((tree, owner)) + for child in ast.iter_child_nodes(tree): + result.extend(_statements(child, owner)) + return result + + +def _statement_at(statements: list[tuple[ast.stmt, str]], line: int) -> tuple[ast.stmt, str]: + """ + 选择忽略所在的最小语句;定义行只锁定完整签名,不把整个方法当作豁免。 + """ + candidates = [] + for node, owner in statements: + start = node.lineno + if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) and node.decorator_list: + start = min(start, *(item.lineno for item in node.decorator_list)) + if start <= line <= (node.end_lineno or start): + candidates.append((node, owner, start)) + if not candidates: + raise ValueError(f"第 {line} 行的类型忽略没有对应语句,禁止文件级忽略") + node, owner, _ = min(candidates, key=lambda item: (item[0].end_lineno or item[2]) - item[2]) + if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): + if node.body and line >= node.body[0].lineno: + raise ValueError(f"第 {line} 行禁止使用独立的类型忽略") + node = copy.copy(node) + node.body = [] + return node, owner + + +def _reject_unchecked_decorators(tree: ast.Module, path: str) -> None: + """ + 拒绝直接、别名或模块限定的 no_type_check 装饰器,不能跳过整个方法。 + """ + names = {"no_type_check", "no_type_check_decorator"} + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module in {"typing", "typing_extensions"}: + names.update(alias.asname or alias.name for alias in node.names if alias.name in names) + for node in ast.walk(tree): + if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): + for decorator in node.decorator_list: + target = decorator.func if isinstance(decorator, ast.Call) else decorator + name = target.attr if isinstance(target, ast.Attribute) else getattr(target, "id", "") + if name in names: + raise ValueError(f"{path}:{decorator.lineno}: 禁止 no_type_check 装饰器") + + +def source_ignores(path: Path, root: Path) -> list[dict[str, Any]]: + """ + 用语法结构与错误码生成忽略身份,普通换行不会改变身份,语义变化必须复核。 + """ + source = path.read_text(encoding="utf-8") + relative = path.relative_to(root).as_posix() + tree = ast.parse(source, filename=relative) + _reject_unchecked_decorators(tree, relative) + statements = _statements(tree) + result = [] + for token in tokenize.generate_tokens(io.StringIO(source).readline): + if token.type != tokenize.COMMENT: + continue + if re.match(r"#\s*mypy\s*:", token.string): + raise ValueError(f"{relative}:{token.start[0]}: 禁止文件内 mypy 配置") + match = _IGNORE.match(token.string) + if match is None: + continue + if not match.group(1): + raise ValueError(f"{relative}:{token.start[0]}: 类型忽略必须指定错误码") + codes = sorted(code.strip() for code in match.group(1).split(",")) + if not all(re.fullmatch(r"[a-z][a-z0-9-]*", code) for code in codes): + raise ValueError(f"{relative}:{token.start[0]}: 无效的类型错误码") + node, owner = _statement_at(statements, token.start[0]) + signature = ast.unparse(node) + digest = hashlib.sha256((ast.dump(node) + json.dumps(codes)).encode()).hexdigest() + result.append({"path": relative, "owner": owner, "codes": codes, + "fingerprint": digest, "statement": signature}) + return result + + +def collect(root: Path) -> tuple[list[dict[str, Any]], dict[str, str]]: + """ + 扫描全部宿主文件和本地类型桩,新文件自动纳入;运行时插件副本单独治理。 + """ + ignores = [] + stubs = {} + for directory, children, filenames in os.walk(root): + children[:] = sorted(name for name in children if name not in _SKIP_DIRECTORIES) + parent = Path(directory) + relative = parent.relative_to(root) + if relative.parts[:2] == ("app", "plugins"): + children[:] = [] + continue + for name in sorted(filenames): + path = parent / name + if path.suffix == ".pyi": + stubs[path.relative_to(root).as_posix()] = hashlib.sha256(path.read_bytes()).hexdigest() + if relative.parts[:1] == ("app",) and path.suffix in {".py", ".pyi"}: + ignores.extend(source_ignores(path, root)) + return ignores, dict(sorted(stubs.items())) + + +def _identity(entry: dict[str, Any]) -> tuple[str, str, str]: + """ + 按文件、所属方法和完整语句指纹比较,不提供可在方法间转移的数量预算。 + """ + return entry["path"], entry["owner"], entry["fingerprint"] + + +def compare( + root: Path, policy: dict[str, Any], ignores: list[dict[str, Any]], stubs: dict[str, str], +) -> tuple[list[str], list[str]]: + """ + 区分新增退化与已删除例外,只有后者允许用 --write 固化。 + """ + regressions = [] + stale = [] + if configuration(root) != policy["configuration"]: + regressions.append("mypy 配置发生变化:检查范围和依赖类型边界需要复核") + reasons = policy["reasons"] + registered = policy["ignores"] + for entry in [*registered, *policy["stubs"]]: + if not reasons.get(entry.get("reason")): + regressions.append(f"{entry['path']}: 类型例外缺少登记原因") + old = Counter(_identity(entry) for entry in registered) + new = Counter(_identity(entry) for entry in ignores) + for (path, owner, _), count in sorted((new - old).items()): + regressions.append(f"{path}:{owner}: 新增或变化的类型忽略 x{count}") + for (path, owner, _), count in sorted((old - new).items()): + stale.append(f"{path}:{owner}: 已移除类型忽略 x{count},需要固化低水位") + old_stubs = {entry["path"]: entry["sha256"] for entry in policy["stubs"]} + for path, digest in stubs.items(): + if old_stubs.get(path) != digest: + regressions.append(f"{path}: 新增或变化的本地类型桩,必须复核真实接口") + for path in old_stubs.keys() - stubs.keys(): + stale.append(f"{path}: 已移除类型桩,需要固化低水位") + return regressions, stale + + +def reduce_policy(policy: dict[str, Any], ignores: list[dict[str, Any]], stubs: dict[str, str]) -> dict[str, Any]: + """ + 只删除已消除的登记项,不接受新增忽略或新类型桩。 + """ + result = copy.deepcopy(policy) + remaining = Counter(_identity(entry) for entry in ignores) + kept = [] + for entry in policy["ignores"]: + identity = _identity(entry) + if remaining[identity]: + kept.append(entry) + remaining[identity] -= 1 + result["ignores"] = kept + result["stubs"] = [entry for entry in policy["stubs"] if entry["path"] in stubs] + used_reasons = {entry["reason"] for entry in [*result["ignores"], *result["stubs"]]} + result["reasons"] = {name: value for name, value in policy["reasons"].items() if name in used_reasons} + return result + + +def main() -> int: + """ + 检查精确例外,或在没有退化时删除失效登记;不提供自动接收例外的写入入口。 + """ + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--write", action="store_true", help="仅固化已消除的类型例外") + parser.add_argument("--policy", type=Path, default=DEFAULT_POLICY) + args = parser.parse_args() + try: + policy = json.loads(args.policy.read_text(encoding="utf-8")) + ignores, stubs = collect(PROJECT_ROOT) + regressions, stale = compare(PROJECT_ROOT, policy, ignores, stubs) + except (OSError, ValueError, KeyError) as error: + print(f"类型例外门禁无法完成检查:{error}") + return 1 + if regressions: + print("\n".join(regressions)) + print("禁止扩大类型豁免;--write 只能删除已经消除的例外。") + return 1 + if args.write: + args.policy.write_text(json.dumps(reduce_policy(policy, ignores, stubs), ensure_ascii=False, indent=2) + "\n", + encoding="utf-8") + elif stale: + print("\n".join(stale)) + return 1 + print(f"类型例外门禁通过({len(ignores)} 处精确忽略,{len(stubs)} 份已核对类型桩)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 5a585e4b8..dfd8d7f27 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -56,7 +56,7 @@ "from:redis.asyncio.Redis as Redis", "import:redis as redis" ], - "fingerprint": "49f0b28ef731b25aa772d6887febd762d03b981a1f52d6dfc8dff82f2f489f81", + "fingerprint": "81a91a371296446093a9796421f72fb76ea54a040a3c288c469c6c5b4dfd6c39", "kind": "network_sdk", "source": "app.adapters.cache.redis", "target": "redis", @@ -81,21 +81,9 @@ "RedisHelper._connect|call:BlockingConnectionPool.from_url", "RedisHelper._connect|call:Redis", "RedisHelper._connect|call:ping", - "RedisHelper.clear|call:flushdb", - "RedisHelper.clear|call:pipeline", - "RedisHelper.clear|call:scan_iter", "RedisHelper.close|call:close", - "RedisHelper.consume|call:getdel", - "RedisHelper.delete|call:delete", - "RedisHelper.exists|call:exists", - "RedisHelper.get|call:get", - "RedisHelper.items|call:get", - "RedisHelper.items|call:get", - "RedisHelper.items|call:scan_iter", - "RedisHelper.items|call:scan_iter", "RedisHelper.set_memory_limit|call:config_set", - "RedisHelper.set_memory_limit|call:config_set", - "RedisHelper.store|call:set" + "RedisHelper.set_memory_limit|call:config_set" ] }, { @@ -786,7 +774,7 @@ "from:slack_bolt.App as App", "from:slack_bolt.adapter.socket_mode.SocketModeHandler as SocketModeHandler" ], - "fingerprint": "be5dcb032ece8d8627abeb243f98143aaf60f26751ab6e5e098813d4048419e6", + "fingerprint": "d970999c7c77ca5e93ec8a2773fa0b3db10df63ecce780b5fd52ca038734666a", "kind": "network_sdk", "source": "app.modules.slack.slack", "target": "slack_bolt", @@ -796,7 +784,6 @@ "Slack.__init__|call:action", "Slack.__init__|call:adapter.socket_mode.SocketModeHandler", "Slack.__init__|call:command", - "Slack.__init__|call:connect", "Slack.__init__|call:event", "Slack.__init__|call:event", "Slack.__init__|call:shortcut", @@ -809,8 +796,7 @@ "Slack.send_msg|call:client.chat_postMessage", "Slack.send_msg|call:client.chat_update", "Slack.send_torrents_msg|call:client.chat_postMessage", - "Slack.send_torrents_msg|call:client.chat_update", - "Slack.stop|call:close" + "Slack.send_torrents_msg|call:client.chat_update" ] }, { @@ -975,8 +961,8 @@ "runtime_only": true } }, - "edge_count": 9639, - "edge_sha256": "33f2426e9c4ab48bf751747cf33990aaccbfceb03200476db7baa457fa47b62e", + "edge_count": 9640, + "edge_sha256": "db43a5eb49af3c4c08dff6b6238b1c1a9ec0a408d1c79315a0ac0e720857722d", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -2227,6 +2213,7 @@ "app.api.dependencies.history -> app.application", "app.api.dependencies.history -> app.application.dashboard", "app.api.dependencies.history -> app.application.history", + "app.api.dependencies.history -> app.application.history.mutation", "app.api.dependencies.history -> app.application.history.retry", "app.api.dependencies.history -> app.application.mediaserver", "app.api.dependencies.history -> app.application.transfer", @@ -2628,6 +2615,7 @@ "app.api.endpoints.login -> app.schemas.initialization", "app.api.endpoints.login -> app.schemas.response", "app.api.endpoints.login -> app.schemas.token", + "app.api.endpoints.login -> app.schemas.user", "app.api.endpoints.mcp -> app.adapters", "app.api.endpoints.mcp -> app.adapters.web", "app.api.endpoints.mcp -> app.adapters.web.security", @@ -2754,7 +2742,7 @@ "app.api.endpoints.mfa -> app.runtime", "app.api.endpoints.mfa -> app.runtime.log", "app.api.endpoints.mfa -> app.schemas", - "app.api.endpoints.mfa -> app.schemas.mcp", + "app.api.endpoints.mfa -> app.schemas.common", "app.api.endpoints.mfa -> app.schemas.mfa", "app.api.endpoints.mfa -> app.schemas.response", "app.api.endpoints.mfa -> app.schemas.token", @@ -4580,13 +4568,13 @@ "app.chain.base -> app.schemas", "app.chain.base -> app.schemas.context", "app.chain.base -> app.schemas.exception", + "app.chain.base -> app.schemas.file", "app.chain.base -> app.schemas.mediaserver", "app.chain.base -> app.schemas.message", "app.chain.base -> app.schemas.system", "app.chain.base -> app.schemas.tmdb", "app.chain.base -> app.schemas.transfer", "app.chain.base -> app.schemas.types", - "app.chain.base -> app.schemas.workflow", "app.chain.dashboard -> app.chain", "app.chain.dashboard -> app.chain.base", "app.chain.dashboard -> app.schemas", @@ -5423,7 +5411,7 @@ "app.chain.storage -> app.runtime", "app.chain.storage -> app.runtime.log", "app.chain.storage -> app.schemas", - "app.chain.storage -> app.schemas.workflow", + "app.chain.storage -> app.schemas.file", "app.chain.subscribe.completion -> app.application", "app.chain.subscribe.completion -> app.application.classification", "app.chain.subscribe.completion -> app.application.classification.reference", @@ -5823,6 +5811,7 @@ "app.chain.transfer.execution -> app.domain.context", "app.chain.transfer.execution -> app.domain.media", "app.chain.transfer.execution -> app.domain.meta", + "app.chain.transfer.execution -> app.domain.meta.metabase", "app.chain.transfer.execution -> app.domain.meta.metamusic", "app.chain.transfer.execution -> app.runtime", "app.chain.transfer.execution -> app.runtime.log", @@ -6048,9 +6037,9 @@ "app.chain.transfer.request -> app.domain.metainfo", "app.chain.transfer.request -> app.schemas", "app.chain.transfer.request -> app.schemas.exception", + "app.chain.transfer.request -> app.schemas.file", "app.chain.transfer.request -> app.schemas.transfer", "app.chain.transfer.request -> app.schemas.types", - "app.chain.transfer.request -> app.schemas.workflow", "app.chain.transfer.retry -> app.application", "app.chain.transfer.retry -> app.application.agent", "app.chain.transfer.retry -> app.application.history", @@ -6151,7 +6140,6 @@ "app.chain.transfer.workflow -> app.schemas.system", "app.chain.transfer.workflow -> app.schemas.transfer", "app.chain.transfer.workflow -> app.schemas.types", - "app.chain.transfer.workflow -> app.schemas.workflow", "app.chain.tvdb -> app.chain", "app.chain.tvdb -> app.chain.base", "app.chain.user -> app.application", @@ -7345,7 +7333,6 @@ "app.modules.feishu.module -> app.modules.feishu.feishu", "app.modules.feishu.module -> app.schemas", "app.modules.feishu.module -> app.schemas.message", - "app.modules.feishu.module -> app.schemas.notification", "app.modules.feishu.module -> app.schemas.types", "app.modules.feishu.openapi -> app.adapters", "app.modules.feishu.openapi -> app.adapters.network", @@ -7397,6 +7384,7 @@ "app.modules.filemanager.storages -> app.schemas.exception", "app.modules.filemanager.storages -> app.schemas.file", "app.modules.filemanager.storages -> app.schemas.system", + "app.modules.filemanager.storages -> app.schemas.types", "app.modules.filemanager.storages.alipan -> app.adapters", "app.modules.filemanager.storages.alipan -> app.adapters.network", "app.modules.filemanager.storages.alipan -> app.adapters.network.http", @@ -7455,9 +7443,6 @@ "app.modules.filemanager.storages.local -> app.schemas.exception", "app.modules.filemanager.storages.local -> app.schemas.file", "app.modules.filemanager.storages.local -> app.schemas.types", - "app.modules.filemanager.storages.rclone -> app.adapters", - "app.modules.filemanager.storages.rclone -> app.adapters.system", - "app.modules.filemanager.storages.rclone -> app.adapters.system.host", "app.modules.filemanager.storages.rclone -> app.foundation", "app.modules.filemanager.storages.rclone -> app.foundation.temporal", "app.modules.filemanager.storages.rclone -> app.modules", @@ -10196,6 +10181,8 @@ "app.startup.initializers.modules -> app.monitor.monitor", "app.startup.initializers.modules -> app.runtime", "app.startup.initializers.modules -> app.runtime.config", + "app.startup.initializers.modules -> app.runtime.event", + "app.startup.initializers.modules -> app.runtime.event.binding", "app.startup.initializers.modules -> app.runtime.events", "app.startup.initializers.modules -> app.runtime.execution", "app.startup.initializers.modules -> app.runtime.extensions", diff --git a/tests/fixtures/architecture/dependency-policy.json b/tests/fixtures/architecture/dependency-policy.json index 606397a46..7fa4344b4 100644 --- a/tests/fixtures/architecture/dependency-policy.json +++ b/tests/fixtures/architecture/dependency-policy.json @@ -100,7 +100,7 @@ "owner": "$source", "reason": "精确 Adapter source 持有其 Redis、浏览器、HTTP 或 DNS transport;上层只能消费 Port 或统一 Facade。", "facts": [ - {"source": "app.adapters.cache.redis", "target": "redis", "kind": "network_sdk", "fingerprint": "49f0b28ef731b25aa772d6887febd762d03b981a1f52d6dfc8dff82f2f489f81"}, + {"source": "app.adapters.cache.redis", "target": "redis", "kind": "network_sdk", "fingerprint": "81a91a371296446093a9796421f72fb76ea54a040a3c288c469c6c5b4dfd6c39"}, {"source": "app.adapters.network.browser", "target": "cloakbrowser", "kind": "network_sdk", "fingerprint": "15c1777b14eb9147d6cab9783f67577011714f9220600dda5db5269b59726173"}, {"source": "app.adapters.network.doh", "target": "socket.getaddrinfo", "kind": "protocol_operation", "fingerprint": "4ff03419dfacc6bf582b7d4421dd5a0666a63f8ca79be2b1e625f4c8f4c96b71"}, {"source": "app.adapters.network.doh", "target": "urllib.request", "kind": "raw_transport", "fingerprint": "6f5f5fd3da02a9e780ea5e7cc1e47bd962314a1a358f14b4ee698485f96ab52b"}, @@ -143,7 +143,7 @@ {"source": "app.modules.filemanager.storages.u115", "target": "oss2", "kind": "network_sdk", "fingerprint": "f7b89c8ae6dad2603f0a9e0caaa159769aef5b3581d7e728f62445b979366eae"}, {"source": "app.modules.plex.plex", "target": "plexapi", "kind": "network_sdk", "fingerprint": "0fe42fa932618997d4f589a5217a36b45f7ac2947212a371bdfbbc55d9cf1def"}, {"source": "app.modules.qbittorrent.qbittorrent", "target": "qbittorrentapi", "kind": "network_sdk", "fingerprint": "b2f5a27f0c54cf95ed42fe99848fd6c9c0641caca8bc4ff8006a411cb427066d"}, - {"source": "app.modules.slack.slack", "target": "slack_bolt", "kind": "network_sdk", "fingerprint": "be5dcb032ece8d8627abeb243f98143aaf60f26751ab6e5e098813d4048419e6"}, + {"source": "app.modules.slack.slack", "target": "slack_bolt", "kind": "network_sdk", "fingerprint": "d970999c7c77ca5e93ec8a2773fa0b3db10df63ecce780b5fd52ca038734666a"}, {"source": "app.modules.slack.slack", "target": "slack_sdk", "kind": "network_sdk", "fingerprint": "7559f31e4172ad3bbbaf161e1164ea48b997c56d05c77b49b82626298c39aa14"}, {"source": "app.modules.transmission.transmission", "target": "transmission_rpc", "kind": "network_sdk", "fingerprint": "1652e661cb17dbadb039fdc4ab73d6d06e47eb118292ba3693313e45834959cf"} ] diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index b334a0d1e..0967ef424 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -1,1092 +1 @@ -{ - "app/adapters/cache/backends.py": { - "no-any-return": 1, - "no-untyped-def": 2, - "operator": 5, - "override": 9 - }, - "app/adapters/cache/redis.py": { - "arg-type": 9, - "no-any-return": 2, - "no-untyped-call": 14, - "no-untyped-def": 17, - "override": 7, - "return-value": 2, - "str-bytes-safe": 2, - "union-attr": 14 - }, - "app/adapters/network/browser.py": { - "arg-type": 4, - "no-any-return": 2, - "no-untyped-def": 1, - "operator": 1, - "type-arg": 8, - "union-attr": 5, - "var-annotated": 2 - }, - "app/adapters/network/doh.py": { - "assignment": 1, - "no-any-return": 1, - "no-untyped-def": 3 - }, - "app/adapters/system/fsproxy.py": { - "arg-type": 1, - "no-any-return": 6, - "union-attr": 11 - }, - "app/adapters/system/host.py": { - "assignment": 4, - "misc": 1, - "no-any-return": 2, - "no-untyped-call": 3, - "no-untyped-def": 7, - "operator": 1, - "type-arg": 5, - "var-annotated": 1 - }, - "app/adapters/system/rust.py": { - "no-any-return": 7, - "no-untyped-def": 1, - "type-arg": 18 - }, - "app/agent/llm/capability.py": { - "no-any-return": 1 - }, - "app/agent/llm/helper.py": { - "arg-type": 7, - "misc": 1, - "no-any-return": 2, - "no-untyped-def": 7, - "type-arg": 1, - "union-attr": 2 - }, - "app/agent/mcp.py": { - "attr-defined": 2, - "no-any-return": 1, - "return-value": 1, - "type-var": 1 - }, - "app/agent/memory/__init__.py": { - "misc": 2 - }, - "app/agent/middleware/config.py": { - "misc": 1 - }, - "app/agent/middleware/jobs.py": { - "misc": 2, - "valid-type": 1 - }, - "app/agent/middleware/memory.py": { - "misc": 2, - "valid-type": 2 - }, - "app/agent/middleware/patching.py": { - "misc": 1 - }, - "app/agent/middleware/selection.py": { - "misc": 2, - "no-any-return": 1, - "valid-type": 1 - }, - "app/agent/middleware/skills.py": { - "misc": 3, - "valid-type": 1 - }, - "app/agent/middleware/subagents.py": { - "arg-type": 2, - "attr-defined": 2, - "misc": 7, - "type-arg": 5, - "var-annotated": 1 - }, - "app/agent/middleware/summarization.py": { - "misc": 2, - "no-any-return": 6, - "union-attr": 2 - }, - "app/agent/middleware/usage.py": { - "misc": 1 - }, - "app/agent/orchestrator.py": { - "arg-type": 4, - "attr-defined": 6, - "dict-item": 1, - "no-any-return": 6, - "no-untyped-def": 6, - "type-arg": 12, - "union-attr": 3 - }, - "app/agent/skills/registry.py": { - "misc": 3, - "no-any-return": 7, - "no-untyped-def": 5, - "type-arg": 14, - "union-attr": 13 - }, - "app/agent/tools/base.py": { - "misc": 1, - "no-untyped-def": 1 - }, - "app/agent/tools/factory.py": { - "no-any-return": 1 - }, - "app/agent/tools/impl/apply_patch.py": { - "arg-type": 5, - "misc": 1, - "no-untyped-def": 2, - "override": 1, - "type-arg": 1, - "union-attr": 1 - }, - "app/agent/tools/impl/ask_user_choice.py": { - "misc": 2, - "no-untyped-def": 4, - "override": 1 - }, - "app/agent/tools/impl/browse_webpage.py": { - "arg-type": 13, - "misc": 1, - "no-any-return": 2, - "no-untyped-def": 12, - "override": 1 - }, - "app/agent/tools/impl/edit_file.py": { - "arg-type": 1, - "misc": 1, - "no-untyped-def": 2, - "override": 1 - }, - "app/agent/tools/impl/execute_command.py": { - "arg-type": 2, - "assignment": 1, - "misc": 1, - "no-untyped-def": 2, - "type-arg": 1 - }, - "app/agent/tools/impl/query_doctor_report.py": { - "misc": 1 - }, - "app/agent/tools/impl/read_file.py": { - "misc": 1, - "no-any-return": 1, - "no-untyped-def": 2, - "override": 1 - }, - "app/agent/tools/impl/recognize_captcha.py": { - "misc": 1, - "no-untyped-def": 1 - }, - "app/agent/tools/impl/search_web.py": { - "assignment": 1, - "attr-defined": 1, - "misc": 1, - "no-any-return": 1, - "no-untyped-def": 4, - "override": 1, - "type-arg": 8 - }, - "app/agent/tools/impl/send_local_file.py": { - "misc": 1, - "no-untyped-def": 3, - "override": 1 - }, - "app/agent/tools/impl/send_message.py": { - "misc": 2, - "no-untyped-def": 1 - }, - "app/agent/tools/impl/send_voice_message.py": { - "misc": 1, - "no-untyped-def": 1, - "override": 1 - }, - "app/agent/tools/impl/write_file.py": { - "arg-type": 1, - "misc": 1, - "no-untyped-def": 2, - "override": 1, - "union-attr": 1 - }, - "app/agent/tools/manager.py": { - "attr-defined": 2 - }, - "app/api/dependencies/history.py": { - "arg-type": 6 - }, - "app/api/dependencies/workflow.py": { - "arg-type": 3 - }, - "app/api/endpoints/bangumi.py": { - "operator": 12 - }, - "app/api/endpoints/dashboard.py": { - "arg-type": 2, - "no-untyped-call": 2, - "operator": 12 - }, - "app/api/endpoints/discover.py": { - "arg-type": 20, - "no-redef": 1, - "operator": 2 - }, - "app/api/endpoints/download.py": { - "arg-type": 6, - "assignment": 5, - "misc": 1 - }, - "app/api/endpoints/history.py": { - "arg-type": 4 - }, - "app/api/endpoints/media.py": { - "assignment": 2, - "no-untyped-def": 1 - }, - "app/api/endpoints/mediaserver.py": { - "arg-type": 4, - "assignment": 1, - "type-arg": 1, - "union-attr": 1 - }, - "app/api/endpoints/message.py": { - "arg-type": 4, - "no-untyped-def": 1, - "union-attr": 4 - }, - "app/api/endpoints/mfa.py": { - "attr-defined": 4, - "call-overload": 1, - "index": 2, - "misc": 7, - "operator": 1, - "type-arg": 2 - }, - "app/api/endpoints/music.py": { - "type-arg": 1 - }, - "app/api/endpoints/openai.py": { - "attr-defined": 1, - "has-type": 1, - "misc": 2, - "no-any-return": 1, - "no-untyped-def": 2 - }, - "app/api/endpoints/plugin.py": { - "assignment": 1, - "no-any-return": 4, - "no-untyped-call": 1, - "no-untyped-def": 2, - "type-arg": 9 - }, - "app/api/endpoints/search.py": { - "arg-type": 2, - "attr-defined": 1, - "no-untyped-call": 3, - "no-untyped-def": 4, - "type-arg": 14 - }, - "app/api/endpoints/site.py": { - "arg-type": 5, - "assignment": 2, - "no-untyped-def": 2, - "return-value": 1, - "type-arg": 5 - }, - "app/api/endpoints/subscribe.py": { - "arg-type": 5, - "no-any-return": 1, - "no-untyped-def": 1, - "type-arg": 2 - }, - "app/api/endpoints/system.py": { - "arg-type": 1, - "no-untyped-def": 1, - "type-arg": 3 - }, - "app/api/endpoints/tmdb.py": { - "operator": 4, - "type-arg": 1 - }, - "app/api/endpoints/transfer.py": { - "arg-type": 9, - "operator": 3, - "type-arg": 3 - }, - "app/api/servarr.py": { - "arg-type": 2, - "assignment": 1, - "no-any-return": 1, - "type-arg": 1, - "union-attr": 1 - }, - "app/api/servcookie.py": { - "misc": 1, - "no-any-return": 5 - }, - "app/application/directory.py": { - "arg-type": 6, - "return-value": 2, - "type-arg": 1 - }, - "app/application/module.py": { - "misc": 1 - }, - "app/application/plugin/management.py": { - "no-any-return": 2 - }, - "app/application/rss.py": { - "assignment": 5, - "attr-defined": 4, - "no-untyped-def": 3, - "type-arg": 5 - }, - "app/application/security/url.py": { - "has-type": 2, - "misc": 1 - }, - "app/application/site/sites.pyi": { - "type-arg": 7 - }, - "app/application/torrent/download.py": { - "no-untyped-call": 1, - "no-untyped-def": 1, - "union-attr": 4 - }, - "app/chain/_messaging.py": { - "arg-type": 1, - "attr-defined": 26, - "no-any-return": 6, - "no-untyped-def": 2, - "type-arg": 4 - }, - "app/chain/_music.py": { - "arg-type": 6, - "assignment": 1, - "attr-defined": 7 - }, - "app/chain/anilist.py": { - "no-any-return": 4, - "no-untyped-def": 2, - "type-arg": 2 - }, - "app/chain/bangumi.py": { - "no-any-return": 14, - "no-untyped-def": 2, - "type-arg": 2 - }, - "app/chain/base.py": { - "assignment": 12, - "attr-defined": 1, - "no-any-return": 46, - "no-untyped-def": 7, - "type-arg": 30 - }, - "app/chain/douban.py": { - "no-any-return": 28 - }, - "app/chain/mediaserver.py": { - "arg-type": 3, - "misc": 2, - "no-any-return": 5, - "type-arg": 1 - }, - "app/chain/message.py": { - "arg-type": 17, - "assignment": 2, - "no-any-return": 11, - "no-untyped-def": 4, - "type-arg": 2, - "union-attr": 1, - "var-annotated": 1 - }, - "app/chain/scraping.py": { - "arg-type": 28, - "assignment": 4, - "misc": 1, - "no-any-return": 1 - }, - "app/chain/site.py": { - "assignment": 1, - "misc": 3, - "no-any-return": 1, - "return-value": 2, - "type-arg": 5 - }, - "app/chain/storage.py": { - "arg-type": 9, - "assignment": 3, - "attr-defined": 1, - "no-any-return": 11, - "type-arg": 2 - }, - "app/chain/subscribe/completion.py": { - "arg-type": 2, - "operator": 3, - "type-var": 1, - "union-attr": 8 - }, - "app/chain/subscribe/create.py": { - "arg-type": 10, - "union-attr": 11 - }, - "app/chain/subscribe/facade.py": { - "misc": 4 - }, - "app/chain/subscribe/match.py": { - "arg-type": 4, - "assignment": 1, - "union-attr": 7 - }, - "app/chain/subscribe/query.py": { - "arg-type": 5, - "index": 1, - "operator": 1, - "union-attr": 6 - }, - "app/chain/subscribe/refresh.py": { - "arg-type": 3, - "assignment": 2 - }, - "app/chain/tmdb.py": { - "no-any-return": 28, - "type-arg": 2 - }, - "app/chain/torrents.py": { - "arg-type": 19, - "type-arg": 3, - "union-attr": 2 - }, - "app/chain/transfer/execution.py": { - "arg-type": 1, - "no-any-return": 2, - "type-arg": 2, - "union-attr": 15 - }, - "app/chain/transfer/queue.py": { - "var-annotated": 1 - }, - "app/chain/transfer/records.py": { - "arg-type": 4, - "return-value": 2 - }, - "app/chain/transfer/request.py": { - "arg-type": 4, - "assignment": 1, - "attr-defined": 1, - "operator": 1, - "type-arg": 1 - }, - "app/chain/transfer/retry.py": { - "arg-type": 1 - }, - "app/chain/transfer/settlement.py": { - "arg-type": 5, - "union-attr": 7 - }, - "app/chain/transfer/workflow.py": { - "arg-type": 9, - "assignment": 10, - "attr-defined": 1, - "operator": 1, - "return-value": 1, - "type-arg": 6, - "union-attr": 3, - "var-annotated": 1 - }, - "app/chain/workflow.py": { - "arg-type": 1, - "call-arg": 1, - "index": 4, - "no-any-return": 6, - "no-untyped-def": 2, - "type-arg": 16, - "union-attr": 4 - }, - "app/command.py": { - "arg-type": 2, - "assignment": 5, - "misc": 4, - "no-untyped-call": 3, - "no-untyped-def": 4, - "type-arg": 7, - "valid-type": 1, - "var-annotated": 1 - }, - "app/domain/context.py": { - "assignment": 86, - "type-arg": 24 - }, - "app/domain/meta/metabase.py": { - "arg-type": 2, - "list-item": 1, - "operator": 2 - }, - "app/domain/meta/metamusic.py": { - "arg-type": 2, - "assignment": 7, - "list-item": 1, - "override": 3 - }, - "app/domain/meta/runtime.py": { - "type-arg": 4 - }, - "app/domain/meta/words.py": { - "assignment": 1, - "no-any-return": 1 - }, - "app/domain/metainfo.py": { - "type-arg": 10 - }, - "app/main.py": { - "misc": 1 - }, - "app/modules/_base/mediaserver.py": { - "arg-type": 2, - "assignment": 1, - "attr-defined": 5, - "no-any-return": 1, - "no-untyped-def": 2, - "var-annotated": 1 - }, - "app/modules/_base/notification.py": { - "arg-type": 1, - "assignment": 1, - "no-untyped-def": 3, - "type-arg": 5 - }, - "app/modules/anilist/__init__.py": { - "assignment": 2, - "no-untyped-def": 5, - "return-value": 1, - "type-arg": 12 - }, - "app/modules/bangumi/__init__.py": { - "arg-type": 2, - "assignment": 2, - "no-untyped-def": 5, - "return-value": 1, - "type-arg": 7 - }, - "app/modules/discord/__init__.py": { - "arg-type": 5, - "assignment": 10, - "empty-body": 1, - "misc": 1, - "no-any-return": 1, - "no-untyped-def": 1, - "truthy-function": 1, - "type-arg": 8 - }, - "app/modules/douban/__init__.py": { - "assignment": 3, - "empty-body": 1, - "no-any-return": 1, - "no-untyped-def": 5, - "return-value": 2, - "type-arg": 8 - }, - "app/modules/emby/__init__.py": { - "arg-type": 3 - }, - "app/modules/fanart/__init__.py": { - "misc": 2, - "no-any-return": 1, - "no-untyped-def": 1, - "type-arg": 9, - "var-annotated": 1 - }, - "app/modules/feishu/module.py": { - "assignment": 6, - "attr-defined": 1, - "no-untyped-def": 1, - "return-value": 1, - "type-arg": 5, - "union-attr": 8 - }, - "app/modules/filemanager/module.py": { - "arg-type": 8, - "assignment": 1, - "no-any-return": 1, - "no-untyped-def": 1, - "type-arg": 1, - "var-annotated": 1 - }, - "app/modules/filemanager/storages/__init__.py": { - "arg-type": 3, - "attr-defined": 3, - "index": 1, - "no-untyped-def": 3, - "return-value": 1, - "type-arg": 1 - }, - "app/modules/filemanager/storages/alipan.py": { - "arg-type": 17, - "assignment": 4, - "empty-body": 2, - "no-any-return": 5, - "no-untyped-def": 6, - "operator": 1, - "override": 1, - "return-value": 1, - "type-arg": 8, - "union-attr": 25 - }, - "app/modules/filemanager/storages/alist.py": { - "arg-type": 13, - "assignment": 1, - "empty-body": 2, - "misc": 1, - "no-any-return": 4, - "operator": 4, - "return-value": 1, - "type-arg": 1 - }, - "app/modules/filemanager/storages/local.py": { - "arg-type": 7, - "assignment": 1, - "truthy-function": 1 - }, - "app/modules/filemanager/storages/rclone.py": { - "arg-type": 13, - "assignment": 1, - "attr-defined": 3, - "empty-body": 2, - "no-untyped-call": 13, - "no-untyped-def": 1, - "type-arg": 2, - "union-attr": 5 - }, - "app/modules/filemanager/storages/smb.py": { - "arg-type": 8, - "assignment": 1, - "empty-body": 1, - "no-untyped-def": 1, - "union-attr": 5 - }, - "app/modules/filemanager/storages/u115.py": { - "arg-type": 10, - "assignment": 3, - "call-overload": 15, - "empty-body": 2, - "no-any-return": 6, - "no-untyped-def": 1, - "override": 1, - "type-arg": 8, - "union-attr": 32 - }, - "app/modules/filemanager/transhandler.py": { - "arg-type": 15, - "assignment": 1, - "no-redef": 1, - "no-untyped-def": 2, - "operator": 6, - "type-arg": 2, - "union-attr": 2 - }, - "app/modules/filter/__init__.py": { - "arg-type": 2, - "assignment": 2, - "index": 1, - "no-untyped-def": 2, - "operator": 2, - "type-arg": 11 - }, - "app/modules/imdb/__init__.py": { - "assignment": 10, - "no-any-return": 1 - }, - "app/modules/imdb/api.py": { - "misc": 5, - "no-any-return": 2, - "type-arg": 18 - }, - "app/modules/indexer/__init__.py": { - "arg-type": 15, - "no-any-return": 1, - "type-arg": 19 - }, - "app/modules/indexer/parser/sunnypt.py": { - "type-arg": 1 - }, - "app/modules/indexer/spider/haidan.py": { - "type-arg": 2 - }, - "app/modules/indexer/spider/hddolby.py": { - "arg-type": 2, - "operator": 1, - "type-arg": 4 - }, - "app/modules/indexer/spider/rousi.py": { - "arg-type": 1, - "no-untyped-def": 1, - "type-arg": 5 - }, - "app/modules/indexer/spider/sunnypt.py": { - "no-untyped-def": 3, - "type-arg": 9 - }, - "app/modules/indexer/spider/tnode.py": { - "arg-type": 1, - "misc": 2, - "type-arg": 4 - }, - "app/modules/indexer/spider/torrentleech.py": { - "arg-type": 2, - "type-arg": 4 - }, - "app/modules/indexer/spider/yema.py": { - "no-untyped-def": 1, - "type-arg": 7 - }, - "app/modules/jellyfin/__init__.py": { - "arg-type": 3, - "assignment": 1 - }, - "app/modules/listenbrainz/__init__.py": { - "misc": 2, - "no-any-return": 2 - }, - "app/modules/lrclib/__init__.py": { - "misc": 1 - }, - "app/modules/musicbrainz/__init__.py": { - "misc": 2, - "no-untyped-def": 2 - }, - "app/modules/navidrome/__init__.py": { - "arg-type": 9, - "union-attr": 1 - }, - "app/modules/navidrome/navidrome.py": { - "no-any-return": 1, - "type-arg": 11 - }, - "app/modules/plex/__init__.py": { - "arg-type": 7, - "assignment": 1 - }, - "app/modules/qqbot/module.py": { - "arg-type": 4, - "assignment": 4, - "empty-body": 1, - "no-any-return": 1, - "no-untyped-def": 1, - "type-arg": 4 - }, - "app/modules/qqbot/qqbot.py": { - "no-untyped-def": 3, - "type-arg": 7 - }, - "app/modules/rtorrent/__init__.py": { - "arg-type": 5, - "assignment": 2, - "str-bytes-safe": 2 - }, - "app/modules/slack/__init__.py": { - "arg-type": 7, - "index": 1, - "type-arg": 7 - }, - "app/modules/slack/slack.py": { - "assignment": 1, - "dict-item": 1, - "list-item": 1, - "no-untyped-call": 5, - "no-untyped-def": 9, - "type-arg": 7, - "union-attr": 2, - "var-annotated": 3 - }, - "app/modules/subtitle/__init__.py": { - "arg-type": 1 - }, - "app/modules/telegram/module.py": { - "arg-type": 9, - "assignment": 1, - "type-arg": 11, - "union-attr": 1 - }, - "app/modules/telegram/telegram.py": { - "assignment": 1, - "no-any-return": 8, - "no-untyped-def": 10, - "return-value": 1, - "type-arg": 10, - "union-attr": 1, - "var-annotated": 1 - }, - "app/modules/theaudiodb/__init__.py": { - "misc": 2, - "no-untyped-def": 2 - }, - "app/modules/themoviedb/cache.py": { - "no-any-return": 3, - "no-untyped-def": 1, - "type-arg": 4, - "union-attr": 5 - }, - "app/modules/themoviedb/scraper.py": { - "arg-type": 1, - "index": 2, - "no-any-return": 2, - "no-untyped-def": 1, - "type-arg": 6 - }, - "app/modules/thetvdb/__init__.py": { - "no-any-return": 2, - "no-untyped-def": 2, - "type-arg": 2 - }, - "app/modules/trimemedia/api.py": { - "no-untyped-def": 2, - "type-arg": 2 - }, - "app/modules/ugreen/module.py": { - "arg-type": 3 - }, - "app/modules/vocechat/__init__.py": { - "assignment": 6, - "empty-body": 1, - "no-untyped-def": 2, - "type-arg": 4, - "union-attr": 1 - }, - "app/modules/webpush/__init__.py": { - "union-attr": 1 - }, - "app/modules/wechat/__init__.py": { - "assignment": 2, - "no-any-return": 1, - "no-untyped-def": 3, - "type-arg": 1, - "union-attr": 4 - }, - "app/modules/wechat/wechat.py": { - "assignment": 9, - "no-any-return": 2, - "no-untyped-def": 1, - "type-arg": 2, - "union-attr": 1, - "var-annotated": 1 - }, - "app/modules/wechat/wechatbot.py": { - "no-untyped-def": 7, - "type-arg": 11, - "union-attr": 3, - "var-annotated": 1 - }, - "app/modules/wechatclawbot/__init__.py": { - "arg-type": 1, - "assignment": 3, - "empty-body": 1, - "no-untyped-def": 1, - "type-arg": 1, - "union-attr": 1 - }, - "app/modules/wechatclawbot/wechatclawbot.py": { - "arg-type": 2, - "assignment": 1, - "no-any-return": 3, - "type-arg": 2, - "union-attr": 1 - }, - "app/modules/zspace/__init__.py": { - "arg-type": 3 - }, - "app/monitor/monitor.py": { - "arg-type": 4, - "attr-defined": 5, - "type-arg": 2, - "union-attr": 1 - }, - "app/monitor/poller.py": { - "type-arg": 1 - }, - "app/monitor/snapshot.py": { - "type-arg": 3 - }, - "app/runtime/cache.py": { - "attr-defined": 12, - "no-any-return": 1, - "no-untyped-def": 4, - "override": 17 - }, - "app/runtime/config.py": { - "misc": 4, - "no-untyped-call": 1, - "no-untyped-def": 3, - "type-arg": 13 - }, - "app/runtime/events.py": { - "list-item": 1, - "misc": 1, - "no-untyped-def": 9, - "type-arg": 22 - }, - "app/runtime/extensions/plugin/manager.py": { - "type-arg": 10 - }, - "app/runtime/extensions/plugin/projection.py": { - "assignment": 1, - "type-arg": 8, - "var-annotated": 1 - }, - "app/schemas/agent.py": { - "misc": 24 - }, - "app/schemas/cache.py": { - "misc": 3 - }, - "app/schemas/category.py": { - "misc": 3 - }, - "app/schemas/common.py": { - "misc": 13 - }, - "app/schemas/context.py": { - "misc": 18 - }, - "app/schemas/dashboard.py": { - "misc": 10 - }, - "app/schemas/download.py": { - "misc": 4 - }, - "app/schemas/event.py": { - "misc": 7, - "no-untyped-def": 3, - "type-arg": 7 - }, - "app/schemas/file.py": { - "misc": 3, - "return-value": 1 - }, - "app/schemas/history.py": { - "misc": 4 - }, - "app/schemas/llm.py": { - "misc": 9 - }, - "app/schemas/mcp.py": { - "misc": 23 - }, - "app/schemas/media.py": { - "attr-defined": 5, - "has-type": 1, - "misc": 1, - "no-untyped-def": 2 - }, - "app/schemas/mediaserver.py": { - "misc": 14 - }, - "app/schemas/message.py": { - "misc": 15, - "no-redef": 1, - "no-untyped-def": 2, - "type-arg": 3 - }, - "app/schemas/mfa.py": { - "misc": 4 - }, - "app/schemas/monitoring.py": { - "misc": 6 - }, - "app/schemas/music.py": { - "misc": 10 - }, - "app/schemas/openai.py": { - "misc": 23 - }, - "app/schemas/plugin.py": { - "assignment": 1, - "misc": 16 - }, - "app/schemas/response.py": { - "misc": 3 - }, - "app/schemas/servarr.py": { - "misc": 18 - }, - "app/schemas/servcookie.py": { - "misc": 5 - }, - "app/schemas/site.py": { - "misc": 8 - }, - "app/schemas/storage.py": { - "misc": 3 - }, - "app/schemas/subscribe.py": { - "misc": 10, - "no-any-return": 1 - }, - "app/schemas/system.py": { - "misc": 15, - "type-arg": 4 - }, - "app/schemas/tmdb.py": { - "misc": 5 - }, - "app/schemas/token.py": { - "misc": 3 - }, - "app/schemas/transfer.py": { - "misc": 16, - "type-arg": 3 - }, - "app/schemas/user.py": { - "misc": 3 - }, - "app/schemas/workflow.py": { - "misc": 19 - }, - "app/startup/composition/cache.py": { - "no-untyped-call": 1 - }, - "app/startup/initializers/command.py": { - "no-untyped-call": 2 - }, - "app/startup/initializers/modules.py": { - "arg-type": 2, - "assignment": 1, - "attr-defined": 2, - "misc": 1, - "no-any-return": 2, - "no-untyped-call": 4, - "no-untyped-def": 6, - "return-value": 2 - }, - "app/startup/initializers/plugins.py": { - "arg-type": 2 - }, - "app/startup/lifecycle/__init__.py": { - "arg-type": 2 - }, - "app/testing/network.py": { - "no-untyped-def": 6 - }, - "app/workflow/__init__.py": { - "index": 1, - "no-any-return": 1, - "no-untyped-def": 1, - "type-arg": 9, - "union-attr": 2, - "valid-type": 1 - }, - "app/workflow/actions/fetch_medias.py": { - "no-redef": 1, - "no-untyped-def": 1, - "union-attr": 1 - }, - "app/workflow/actions/fetch_rss.py": { - "arg-type": 6, - "type-arg": 1, - "union-attr": 2, - "var-annotated": 1 - }, - "app/workflow/actions/filter_torrents.py": { - "union-attr": 1, - "var-annotated": 1 - }, - "app/workflow/actions/scrape_file.py": { - "union-attr": 1 - } -} +{} diff --git a/tests/fixtures/architecture/mypy-policy.json b/tests/fixtures/architecture/mypy-policy.json new file mode 100644 index 000000000..8b64c1d49 --- /dev/null +++ b/tests/fixtures/architecture/mypy-policy.json @@ -0,0 +1,1359 @@ +{ + "configuration": { + "mypy": { + "exclude": "^app/plugins(?:/|$)", + "files": "\napp,\nscripts/architecture/async_blocking.py,\nscripts/architecture/mypy_policy.py", + "follow_imports": "skip", + "ignore_missing_imports": "False", + "platform": "linux", + "pretty": "True", + "python_version": "3.14", + "show_error_codes": "True", + "strict": "True", + "warn_unused_configs": "True" + }, + "mypy-app.*": { + "follow_imports": "silent", + "follow_imports_for_stubs": "True" + }, + "mypy-click.*,fastapi.*": { + "follow_imports": "silent" + }, + "mypy-langchain.*,langchain_core.*,langgraph.*,starlette.*": { + "follow_imports": "silent" + }, + "mypy-lxml.*,Pinyin2Hanzi.*,anitopy.*,apscheduler.*,boto3.*,botocore.*,cloakbrowser.*,cn2an.*,http_ece.*,moviepilot_rust.*,oss2.*,pillow_avif.*,py_vapid.*,pyquery.*,pystray.*,smbclient.*,smbprotocol.*,telegramify_markdown.*,torrentool.*": { + "ignore_missing_imports": "True" + }, + "mypy-pydantic.*,pydantic_settings.*,typing_extensions.*,uvicorn.*,pytest.*,_pytest.*": { + "follow_imports": "silent" + }, + "mypy-slack_bolt.*,slack_sdk.*": { + "follow_imports": "silent" + } + }, + "reasons": { + "async-tool-run": "宿主和插件调用 MoviePilotTool.run 的异步接口;LangChain BaseTool.run 是同步入口。保留既有公开参数和 await 合同,只豁免该签名的 override,方法体仍严格检查。", + "runtime-mro": "WebAgent 混入类由惰性加载器在运行时与实际 Agent/StreamingHandler 组合。静态 object 基类没有 super 成员;提前继承具体实现会改变惰性加载与 MRO,仅保留现有 super 调用。", + "classmethod-descriptor": "稳定 Facade 重绑 Owner 的原始 classmethod 描述符以保留类绑定、元数据和兼容身份;mypy 不能表达绑定方法 __func__ 的描述符再绑定。改成包装函数会改变公开身份和绑定行为。", + "sqlalchemy-runtime": "SQLAlchemy 声明式元类和 __table__ 在运行时生成;当前供应方导入边界不展开 ORM 实现。保留既有插件模型类型与装饰器,不通过伪造类型桩替代真实模型合同。", + "requests-adapter": "requests HTTPAdapter 的供应方导入边界保留原有不透明实现,TLS 适配器必须继承实际 SDK 类,不能替换为本地伪造基类。", + "scheduler-executor": "APScheduler 未提供可用的完整类型声明,自有执行器必须继承其运行时 BasePoolExecutor,保持调度器协议和生命周期。", + "spider-projection": "站点解析器刻意投影 __class__ 和 __dict__,兼容资源模块对解析结果身份及 JSON 字典的访问;更改特殊属性会破坏现有资源调用协议。", + "pydantic-computed": "Pydantic computed_field 需要叠加 property;mypy 的 prop-decorator 不能完整表达此组合。保留 Schema 和只读计算字段行为。", + "legacy-subscribe": "稳定 SDK 接收历史插件的宽参数/返回合同并转接新订阅服务;旧入口与新实现签名不同,不能直接修改插件可见 API。", + "legacy-config-int": "绿联历史配置接受任意对象尝试 int 转换,并在原有异常分支回退;静态 object 不承诺 __int__。提前收窄允许类型会改变旧配置转换和回退行为。", + "binary-site-contract": "站点资源是独立构建与发布的二进制模块,本地 sites.pyi 描述其公开接口。按文件摘要锁定,接口变化需与资源源码、宿主和插件调用方一并复核。", + "fastapi-response-default": "FastAPI 只把精确的 Response 类型识别为响应注入参数,Optional[Response] 会导致路由注册失败;None 默认值同时兼容旧的直接调用。保留原参数顺序和默认值,仅锁定 assignment 例外,接口方法体继续严格检查。", + "async-cache-compat": "异步缓存后端保留同步 CacheBackend 的公开继承关系,async 方法与同步父类返回合同天然不兼容;精确锁定 override 例外,避免改变插件的 isinstance 和生命周期行为。" + }, + "ignores": [ + { + "path": "app/adapters/cache/backends.py", + "owner": "AsyncFileBackend.clear", + "codes": [ + "override" + ], + "fingerprint": "bc19c333b779598deafefc06b5826a7f128de97d72d668b8ab4a870bd0398ab9", + "statement": "async def clear(self, region: Optional[str]=DEFAULT_CACHE_REGION) -> None:", + "reason": "async-cache-compat" + }, + { + "path": "app/adapters/cache/backends.py", + "owner": "AsyncFileBackend.close", + "codes": [ + "override" + ], + "fingerprint": "78edb2946bb92329b0b3ed060c1db6f9053ba47361725801308f1ace2e4dc433", + "statement": "async def close(self) -> None:", + "reason": "async-cache-compat" + }, + { + "path": "app/adapters/cache/backends.py", + "owner": "AsyncFileBackend.delete", + "codes": [ + "override" + ], + "fingerprint": "fddfa0d2ed7e7941772f3c6138cc6481f299c2f75484897a400c7da889958014", + "statement": "async def delete(self, key: str, region: Optional[str]=DEFAULT_CACHE_REGION) -> None:", + "reason": "async-cache-compat" + }, + { + "path": "app/adapters/cache/backends.py", + "owner": "AsyncFileBackend.exists", + "codes": [ + "override" + ], + "fingerprint": "e282cac78a82d69f6af6790cd22e6fdea879906e68e8aa6638b9a465d80216f1", + "statement": "async def exists(self, key: str, region: Optional[str]=DEFAULT_CACHE_REGION) -> bool:", + "reason": "async-cache-compat" + }, + { + "path": "app/adapters/cache/backends.py", + "owner": "AsyncFileBackend.items", + "codes": [ + "override" + ], + "fingerprint": "555bb945526fb79419457c217930b25ca98c9beffc87cfa31a8ca8ed6cdc6ed4", + "statement": "async def items(self, region: Optional[str]=DEFAULT_CACHE_REGION) -> AsyncGenerator[Tuple[str, Any], None]:", + "reason": "async-cache-compat" + }, + { + "path": "app/adapters/cache/backends.py", + "owner": "AsyncFileBackend.set", + "codes": [ + "override" + ], + "fingerprint": "e3600731a3292de4c39c9ec850a9b97611856942324d8e41da65247d7c151d61", + "statement": "async def set(self, key: str, value: Any, ttl: Optional[int]=None, region: Optional[str]=DEFAULT_CACHE_REGION, **_kwargs: object) -> None:", + "reason": "async-cache-compat" + }, + { + "path": "app/adapters/cache/redis.py", + "owner": "AsyncRedisBackend.clear", + "codes": [ + "override" + ], + "fingerprint": "bc19c333b779598deafefc06b5826a7f128de97d72d668b8ab4a870bd0398ab9", + "statement": "async def clear(self, region: Optional[str]=DEFAULT_CACHE_REGION) -> None:", + "reason": "async-cache-compat" + }, + { + "path": "app/adapters/cache/redis.py", + "owner": "AsyncRedisBackend.close", + "codes": [ + "override" + ], + "fingerprint": "78edb2946bb92329b0b3ed060c1db6f9053ba47361725801308f1ace2e4dc433", + "statement": "async def close(self) -> None:", + "reason": "async-cache-compat" + }, + { + "path": "app/adapters/cache/redis.py", + "owner": "AsyncRedisBackend.delete", + "codes": [ + "override" + ], + "fingerprint": "fddfa0d2ed7e7941772f3c6138cc6481f299c2f75484897a400c7da889958014", + "statement": "async def delete(self, key: str, region: Optional[str]=DEFAULT_CACHE_REGION) -> None:", + "reason": "async-cache-compat" + }, + { + "path": "app/adapters/cache/redis.py", + "owner": "AsyncRedisBackend.exists", + "codes": [ + "override" + ], + "fingerprint": "e282cac78a82d69f6af6790cd22e6fdea879906e68e8aa6638b9a465d80216f1", + "statement": "async def exists(self, key: str, region: Optional[str]=DEFAULT_CACHE_REGION) -> bool:", + "reason": "async-cache-compat" + }, + { + "path": "app/adapters/cache/redis.py", + "owner": "AsyncRedisBackend.items", + "codes": [ + "override" + ], + "fingerprint": "555bb945526fb79419457c217930b25ca98c9beffc87cfa31a8ca8ed6cdc6ed4", + "statement": "async def items(self, region: Optional[str]=DEFAULT_CACHE_REGION) -> AsyncGenerator[Tuple[str, Any], None]:", + "reason": "async-cache-compat" + }, + { + "path": "app/adapters/cache/redis.py", + "owner": "AsyncRedisBackend.set", + "codes": [ + "override" + ], + "fingerprint": "05a96e54e25bfb59ad8727e5b0e7b5df755a202a93ea90c1bbf9ce13b388822a", + "statement": "async def set(self, key: str, value: Any, ttl: Optional[int]=None, region: Optional[str]=DEFAULT_CACHE_REGION, **kwargs: Any) -> None:", + "reason": "async-cache-compat" + }, + { + "path": "app/adapters/network/http.py", + "owner": "_TLS12HttpAdapter", + "codes": [ + "misc" + ], + "fingerprint": "f4155d1f7d7f84695a2f9fc67b1f00784abcdb6eb40aad5fd3b6d07a65ab02b3", + "statement": "class _TLS12HttpAdapter(HTTPAdapter):", + "reason": "requests-adapter" + }, + { + "path": "app/agent/tools/impl/agent_task.py", + "owner": "AgentTaskTool.run", + "codes": [ + "override" + ], + "fingerprint": "a4fa8063df424138584a2003156c9e9b9c0fc53eac08c18cc9c5bd83f8a5f9bb", + "statement": "async def run(self, action: AgentTaskAction, task_id: Optional[int]=None, name: Optional[str]=None, content: Optional[str]=None, trigger_type: Optional[AgentTaskTriggerType]=None, trigger: Optional[str]=None, delay_minutes: Optional[int]=None, enabled: Optional[bool]=None, **kwargs: object) -> str:", + "reason": "async-tool-run" + }, + { + "path": "app/agent/tools/impl/api.py", + "owner": "MoviePilotApiTool.run", + "codes": [ + "override" + ], + "fingerprint": "aed418852cffcb505f0cd88dba48e7e9ceda36c9b36a7d7e5c37fbd2c47339b0", + "statement": "async def run(self, operation_id: str, path_params: Optional[Dict[str, Any]]=None, query: Optional[Dict[str, Any]]=None, body: Any=None, **kwargs: Any) -> str:", + "reason": "async-tool-run" + }, + { + "path": "app/agent/tools/impl/apply_patch.py", + "owner": "ApplyPatchTool.run", + "codes": [ + "override" + ], + "fingerprint": "28f9f430879e4b1762b680ab287e67fcec74e7899976d71d1f233f5d09d26c9f", + "statement": "async def run(self, patch: str, **kwargs: Any) -> str:", + "reason": "async-tool-run" + }, + { + "path": "app/agent/tools/impl/ask_user_choice.py", + "owner": "AskUserChoiceTool.run", + "codes": [ + "override" + ], + "fingerprint": "aa08e4f8127fe6b8220751aa4c0b6c9183a6ef6a37071c5ab58f3c541cb5dd3d", + "statement": "async def run(self, message: str, options: List[UserChoiceOptionInput], title: Optional[str]=None, **kwargs: Any) -> str:", + "reason": "async-tool-run" + }, + { + "path": "app/agent/tools/impl/browse_webpage.py", + "owner": "BrowseWebpageTool.run", + "codes": [ + "override" + ], + "fingerprint": "81cf8e97a6185b6057d44621b89d4d6f4b2f5addcf38f7e04253560494573ff7", + "statement": "async def run(self, action: str, url: Optional[str]=None, selector: Optional[str]=None, ref: Optional[str]=None, value: Optional[str]=None, script: Optional[str]=None, content_type: Optional[str]='text', timeout: Optional[int]=DEFAULT_TIMEOUT, cookies: Optional[str]=None, user_agent: Optional[str]=None, session_key: Optional[str]=None, tab_index: Optional[int]=None, allow_private_network: bool=False, **kwargs: Any) -> str:", + "reason": "async-tool-run" + }, + { + "path": "app/agent/tools/impl/edit_file.py", + "owner": "EditFileTool.run", + "codes": [ + "override" + ], + "fingerprint": "601a931d8a7d0e60b8cce97b87ab384b914ceecba3a9a0177159308159c7448d", + "statement": "async def run(self, file_path: str, old_text: str, new_text: str, replace_all: bool=False, expected_sha256: Optional[str]=None, **kwargs: Any) -> str:", + "reason": "async-tool-run" + }, + { + "path": "app/agent/tools/impl/execute_code.py", + "owner": "ExecuteCodeTool.run", + "codes": [ + "override" + ], + "fingerprint": "b72afc2a3f7fed4d70ae51926c81297e5a899de770cfc501b5d3220e3a8ea4a2", + "statement": "async def run(self, **kwargs: Any) -> str:", + "reason": "async-tool-run" + }, + { + "path": "app/agent/tools/impl/execute_command.py", + "owner": "ExecuteCommandTool.run", + "codes": [ + "override" + ], + "fingerprint": "73ff17b061a3770d723fce95ce25ffa6f7d04fbc9fe83a990ba6f8dd791a68a8", + "statement": "async def run(self, action: Optional[str]='start', command: Optional[str]=None, session_id: Optional[str]=None, input_text: Optional[str]=None, close_stdin: Optional[bool]=False, signal_name: Optional[str]='TERM', cwd: Optional[str]=None, env: Optional[dict[str, Any]]=None, shell: Optional[str]=None, login: Optional[bool]=None, use_pty: Optional[bool]=True, since_seq: Optional[int]=None, since_offset: Optional[int]=None, max_bytes: Optional[int]=TERMINAL_DEFAULT_READ_BYTES, timeout_ms: Optional[int]=TERMINAL_WAIT_DEFAULT_MS, yield_time_ms: Optional[int]=TERMINAL_YIELD_DEFAULT_MS, timeout: Optional[int]=60, confirm_dangerous: Optional[bool]=False, **kwargs: Any) -> str:", + "reason": "async-tool-run" + }, + { + "path": "app/agent/tools/impl/mcp.py", + "owner": "McpExternalTool.run", + "codes": [ + "override" + ], + "fingerprint": "a27ae52f9f63fdf0a5a99d36881d89150185c12e19aafc622c836ebdebbd718f", + "statement": "async def run(self, **kwargs: object) -> str:", + "reason": "async-tool-run" + }, + { + "path": "app/agent/tools/impl/persona.py", + "owner": "PersonaTool.run", + "codes": [ + "override" + ], + "fingerprint": "6e18ee8f913ef1f522011d9531f558055fe0bff5c0d9d0bc371d9c094f5f7747", + "statement": "async def run(self, action: PersonaAction, query: Optional[str]=None, persona_id: Optional[str]=None, label: Optional[str]=None, description: Optional[str]=None, aliases: Optional[list[str]]=None, instructions: Optional[str]=None, append_instructions: Optional[list[str]]=None, create_if_missing: bool=False, **kwargs: object) -> str:", + "reason": "async-tool-run" + }, + { + "path": "app/agent/tools/impl/query_doctor_report.py", + "owner": "QueryDoctorReportTool.run", + "codes": [ + "override" + ], + "fingerprint": "6d4a61a3652e6318f9cb416c4f931921fdd47c6a76b44bc2c1f90aaecaab7a66", + "statement": "async def run(self, deep: Optional[bool]=False, include_details: Optional[bool]=True, **kwargs: object) -> str:", + "reason": "async-tool-run" + }, + { + "path": "app/agent/tools/impl/read_file.py", + "owner": "ReadFileTool.run", + "codes": [ + "override" + ], + "fingerprint": "797d2d7ca5120b395828831d6fc02a47cb7639e3e33839c413441e8ca5f89397", + "statement": "async def run(self, file_path: str, start_line: Optional[int]=None, end_line: Optional[int]=None, include_metadata: bool=False, **kwargs: Any) -> str:", + "reason": "async-tool-run" + }, + { + "path": "app/agent/tools/impl/recognize_captcha.py", + "owner": "RecognizeCaptchaTool.run", + "codes": [ + "override" + ], + "fingerprint": "6475ed9eea3e63c5692b594a98eba7940c24f56c5ec715703194b7a07312e989", + "statement": "async def run(self, image_url: Optional[str]=None, cookie: Optional[str]=None, user_agent: Optional[str]=None, allow_private_network: bool=False, image_data: Optional[bytes]=None, **kwargs: object) -> str:", + "reason": "async-tool-run" + }, + { + "path": "app/agent/tools/impl/search_web.py", + "owner": "SearchWebTool.run", + "codes": [ + "override" + ], + "fingerprint": "b6f69ec94c24eabdf9e13790ecbb8547213de27fc9678a00912e7dec10c17583", + "statement": "async def run(self, query: str, max_results: Optional[int]=MAX_SEARCH_RESULTS, search_engine: Optional[str]=DEFAULT_SEARCH_ENGINE, site_url: Optional[str]=None, **kwargs: Any) -> str:", + "reason": "async-tool-run" + }, + { + "path": "app/agent/tools/impl/send_local_file.py", + "owner": "SendLocalFileTool.run", + "codes": [ + "override" + ], + "fingerprint": "9bb59a2247715db217b01a8339bad4ca425257154dd09eb5669b6e7f19102540", + "statement": "async def run(self, file_path: str, message: Optional[str]=None, title: Optional[str]=None, file_name: Optional[str]=None, **kwargs: Any) -> str:", + "reason": "async-tool-run" + }, + { + "path": "app/agent/tools/impl/send_message.py", + "owner": "SendMessageTool.run", + "codes": [ + "override" + ], + "fingerprint": "3df5820bef1ae2fe290acf99aacf15374fafcd07452b5a22268e24f7f7acb71d", + "statement": "async def run(self, message: Optional[str]=None, title: Optional[str]=None, image_url: Optional[str]=None, rich_message: Optional[str]=None, **kwargs: object) -> str:", + "reason": "async-tool-run" + }, + { + "path": "app/agent/tools/impl/send_voice_message.py", + "owner": "SendVoiceMessageTool.run", + "codes": [ + "override" + ], + "fingerprint": "549cf1b911dc465406799932690c206316c37b973ce3a9ee1dffd56b407c48f0", + "statement": "async def run(self, message: str, **kwargs: Any) -> str:", + "reason": "async-tool-run" + }, + { + "path": "app/agent/tools/impl/service.py", + "owner": "DatabaseOperationTool.run", + "codes": [ + "override" + ], + "fingerprint": "38561b80a7162239f895942d0d6b7657c405fa1fbac1a2d53f4885de9d0ebd7e", + "statement": "async def run(self, action: str, arguments: Optional[Dict[str, Any]]=None) -> str:", + "reason": "async-tool-run" + }, + { + "path": "app/agent/tools/impl/service.py", + "owner": "DownloaderOperationTool.run", + "codes": [ + "override" + ], + "fingerprint": "53005c98e40dbb21d4c1dd722838ffb9fad460892833a66761602944680cf594", + "statement": "async def run(self, action: str, arguments: Optional[Dict[str, Any]]=None, client: Optional[str]=None) -> str:", + "reason": "async-tool-run" + }, + { + "path": "app/agent/tools/impl/service.py", + "owner": "MediaServerOperationTool.run", + "codes": [ + "override" + ], + "fingerprint": "4d73bd3f5b90da07c39715a2b85dc1878cb636d73c8912182fd5849387b97547", + "statement": "async def run(self, action: str, arguments: Optional[Dict[str, Any]]=None, server: Optional[str]=None) -> str:", + "reason": "async-tool-run" + }, + { + "path": "app/agent/tools/impl/view_image.py", + "owner": "ViewImageTool.run", + "codes": [ + "override" + ], + "fingerprint": "3fbaa5b2c4b8bee9d2ed56b607ff22ffc0daa0b149b9c2d7488cf7d57a20edee", + "statement": "async def run(self, url: Optional[str]=None, file_path: Optional[str]=None, image_data: Optional[Union[str, bytes]]=None, detail: Literal['auto', 'low', 'high']='auto', **kwargs: Any) -> str:", + "reason": "async-tool-run" + }, + { + "path": "app/agent/tools/impl/write_file.py", + "owner": "WriteFileTool.run", + "codes": [ + "override" + ], + "fingerprint": "c97ce49a816ace2f9d279602bc2c3ecd3ab69aa37465cde93ac9e41537610d01", + "statement": "async def run(self, file_path: str, content: str, overwrite: bool=False, expected_sha256: Optional[str]=None, **kwargs: Any) -> str:", + "reason": "async-tool-run" + }, + { + "path": "app/agent/web.py", + "owner": "_WebAgentMoviePilotAgentMixin._build_tool_context", + "codes": [ + "misc" + ], + "fingerprint": "981be9c80d4a4b9973dd5204fafc707846fd56fd37074a996ca7827d56165ece", + "statement": "context = await super()._build_tool_context(should_dispatch_reply)", + "reason": "runtime-mro" + }, + { + "path": "app/agent/web.py", + "owner": "_WebAgentStreamingHandlerMixin._uses_structured_tool_events", + "codes": [ + "misc" + ], + "fingerprint": "e3b187588ec63d629078672303101832847f97c17ffa225e9677de1f6653fa78", + "statement": "return bool(self._on_tool_event and super()._is_verbose_mode())", + "reason": "runtime-mro" + }, + { + "path": "app/agent/web.py", + "owner": "_WebAgentStreamingHandlerMixin.emit", + "codes": [ + "misc" + ], + "fingerprint": "e123ca1bd891c68bd9eec991e259e1185a415e9e473b9b1a0e7440511b15d116", + "statement": "emitted = super().emit(token)", + "reason": "runtime-mro" + }, + { + "path": "app/agent/web.py", + "owner": "_WebAgentStreamingHandlerMixin.emit_tool_message", + "codes": [ + "misc" + ], + "fingerprint": "c178a3f0b3d3c36dc3e71586a87d5b599d03d300c4d0105406645f1bf517b2f2", + "statement": "emitted = str(super().emit_tool_message(normalized_message))", + "reason": "runtime-mro" + }, + { + "path": "app/agent/web.py", + "owner": "_WebAgentStreamingHandlerMixin.flush_pending_tool_summary", + "codes": [ + "misc" + ], + "fingerprint": "03a1ba19159b924ca480ca7fb24544433e242d095278a0a3555d5c02182cad46", + "statement": "emitted = super().flush_pending_tool_summary()", + "reason": "runtime-mro" + }, + { + "path": "app/agent/web.py", + "owner": "_WebAgentStreamingHandlerMixin.record_tool_call", + "codes": [ + "misc" + ], + "fingerprint": "f86b77d69cdafe0787717188b4ad7fc0904e28c4aee079735d985f97255a54af", + "statement": "super().record_tool_call(tool_name=tool_name, tool_message=tool_message, tool_kwargs=tool_kwargs)", + "reason": "runtime-mro" + }, + { + "path": "app/agent/web.py", + "owner": "_WebAgentStreamingHandlerMixin.report_tool_call", + "codes": [ + "misc" + ], + "fingerprint": "f4e107fca8878ad1c4300e4aaa0de6fc52fbda7ea855047cbe1c91b3923b2381", + "statement": "return str(super().report_tool_call(tool_name=tool_name, tool_message=tool_message, tool_kwargs=tool_kwargs) or '')", + "reason": "runtime-mro" + }, + { + "path": "app/agent/web.py", + "owner": "_WebAgentStreamingHandlerMixin.thinking_finished", + "codes": [ + "misc" + ], + "fingerprint": "065bb44bb36070ffb0aefe2bd9c5cbdf3f65be80ee191de70adeadd066688d96", + "statement": "super().thinking_finished()", + "reason": "runtime-mro" + }, + { + "path": "app/agent/web.py", + "owner": "_WebAgentStreamingHandlerMixin.thinking_started", + "codes": [ + "misc" + ], + "fingerprint": "753ebc3b9c3ba19544545d81c143478adbf7eca9a3a0931ddaa54d00def46183", + "statement": "super().thinking_started()", + "reason": "runtime-mro" + }, + { + "path": "app/agent/web.py", + "owner": "_WebAgentStreamingHandlerMixin.tool_call_finished", + "codes": [ + "misc" + ], + "fingerprint": "61b7c164ce6f64b8e319db324357cbfa50e00434d8bcc42f20a0ff60eb20682a", + "statement": "super().tool_call_finished(tool_id, status)", + "reason": "runtime-mro" + }, + { + "path": "app/agent/web.py", + "owner": "_WebAgentStreamingHandlerMixin.tool_call_started", + "codes": [ + "misc" + ], + "fingerprint": "741ca414f7a036bba0b3377d0e3037638aa98e1deff1909aa76a7bf15815c72c", + "statement": "super().tool_call_started(tool_name, tool_message)", + "reason": "runtime-mro" + }, + { + "path": "app/api/endpoints/history.py", + "owner": "download_history", + "codes": [ + "assignment" + ], + "fingerprint": "e7327c23ed2dd984fc664c9d0dc8341d955337c6472d02702665ac5e5ecdbad7", + "statement": "@router.get('/download', summary='查询下载历史记录', response_model=List[_SchemaDownloadHistory], openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True})\nasync def download_history(page: Optional[int]=1, count: Optional[int]=30, query: HistoryQueryService=Depends(get_history_query_service), _: _SchemaTokenPayload=Depends(verify_token), response: Response=None) -> Any:", + "reason": "fastapi-response-default" + }, + { + "path": "app/api/endpoints/mfa.py", + "owner": "passkey_list", + "codes": [ + "assignment" + ], + "fingerprint": "654f847b3b762dc8acb465d48dc8eb3ae4b4c5cc2d58b6462dcbea5d125c6dca", + "statement": "@router.get('/passkey/list', summary='获取当前用户的 PassKey 列表', response_model=_SchemaResponse[list[_SchemaPasskeyInfo]], openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True})\ndef passkey_list(current_user: Annotated[ApiPrincipal, Depends(get_current_active_user)], response: Response=None, service: PasskeyService=Depends(get_passkey_service), page: CompatiblePageParam=None, count: CompatibleCountParam=None) -> Any:", + "reason": "fastapi-response-default" + }, + { + "path": "app/api/endpoints/plugin.py", + "owner": "all_plugins", + "codes": [ + "assignment" + ], + "fingerprint": "5f6a039d011683394079e360301c9a5ea1b5f5aa09ee4e4826c56fdaff1aaf4f", + "statement": "@router.get('/', summary='所有插件', response_model=List[_SchemaPlugin], openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True})\nasync def all_plugins(_: ApiPrincipal=Depends(get_current_active_superuser_async), state: Optional[str]='all', force: bool=False, query: Optional[str]=None, max_results: Annotated[Optional[int], Query(ge=1, le=200)]=None, page: CompatiblePageParam=None, count: CompatibleCountParam=None, response: Response=None) -> List[_SchemaPlugin]:", + "reason": "fastapi-response-default" + }, + { + "path": "app/api/endpoints/pluginclone.py", + "owner": "plugin_restorable_instances", + "codes": [ + "assignment" + ], + "fingerprint": "d18fd318165eae7ef0aff5a8b88d0a1ea4c8b4d93a01d00155c6d779279a3d93", + "statement": "@router.get('/clone/{plugin_id}/restorable', summary='列出可恢复的已停用分身', response_model=List[_SchemaPluginRestorableInstance], openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True})\ndef plugin_restorable_instances(plugin_id: str, _: ApiPrincipal=Depends(get_current_active_superuser), page: CompatiblePageParam=None, count: CompatibleCountParam=None, response: Response=None) -> Any:", + "reason": "fastapi-response-default" + }, + { + "path": "app/api/endpoints/site.py", + "owner": "read_agent_sites", + "codes": [ + "assignment" + ], + "fingerprint": "ec4309e6fdb9f6bac1d58a2ca622738c53aa76a962db240f70c7e00fb1d7d644", + "statement": "@router.get('/agent', summary='查询 Agent 可用站点', response_model=List[_SchemaJsonObject], openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True})\nasync def read_agent_sites(response: Response=None, status: Literal['active', 'inactive', 'all']='all', name: Optional[str]=None, query: SiteQueryService=Depends(get_site_query_service), current_user: Any=Depends(get_current_active_user_async), page: CompatiblePageParam=None, count: CompatibleCountParam=None) -> List[dict[str, JsonData]]:", + "reason": "fastapi-response-default" + }, + { + "path": "app/api/endpoints/site.py", + "owner": "read_rss_sites", + "codes": [ + "assignment" + ], + "fingerprint": "a0403da9f7c8b6b49abfc99b194cdd9e517c0913b8c9ef739a5dad2d2f213fa0", + "statement": "@router.get('/rss', summary='所有订阅站点', response_model=List[_SchemaSite], openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True})\nasync def read_rss_sites(response: Response=None, query: SiteQueryService=Depends(get_site_query_service), token_payload: _SchemaTokenPayload=Depends(verify_token), page: CompatiblePageParam=None, count: CompatibleCountParam=None) -> List[_SchemaSite | dict[str, JsonData]]:", + "reason": "fastapi-response-default" + }, + { + "path": "app/api/endpoints/site.py", + "owner": "read_sites", + "codes": [ + "assignment" + ], + "fingerprint": "30da12edc5e17efcbac408a03e569d3367c434168c33838ecfec80d316ccafe9", + "statement": "@router.get('/', summary='所有站点', response_model=List[_SchemaSite], openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True})\nasync def read_sites(response: Response=None, query: SiteQueryService=Depends(get_site_query_service), _: ApiPrincipal=Depends(get_current_active_manage_user_async), page: CompatiblePageParam=None, count: CompatibleCountParam=None) -> List[_SchemaSite]:", + "reason": "fastapi-response-default" + }, + { + "path": "app/api/endpoints/site.py", + "owner": "read_sites_by_media_type", + "codes": [ + "assignment" + ], + "fingerprint": "516f867a614eb3e4e087a2d68824f7bfc68394f4c6530af300914654724c46df", + "statement": "@router.get('/media/{media_type}', summary='按媒体类型获取可搜索站点', response_model=List[_SchemaSite], openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True})\nasync def read_sites_by_media_type(media_type: str, response: Response=None, query: SiteQueryService=Depends(get_site_query_service), _: ApiPrincipal=Depends(get_current_active_manage_user_async), page: CompatiblePageParam=None, count: CompatibleCountParam=None) -> List[_SchemaSite]:", + "reason": "fastapi-response-default" + }, + { + "path": "app/api/endpoints/site.py", + "owner": "read_statistics", + "codes": [ + "assignment" + ], + "fingerprint": "4d039846f54c2384890bb483f6c01ed8c740811b7637fef6e65cc48fed74d98e", + "statement": "@router.get('/statistic', summary='所有站点统计信息', response_model=List[_SchemaSiteStatistic], openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True})\nasync def read_statistics(response: Response=None, query: SiteQueryService=Depends(get_site_query_service), _: _SchemaTokenPayload=Depends(verify_token), page: CompatiblePageParam=None, count: CompatibleCountParam=None) -> Any:", + "reason": "fastapi-response-default" + }, + { + "path": "app/api/endpoints/site.py", + "owner": "read_userdata", + "codes": [ + "assignment" + ], + "fingerprint": "e6e9db2ebeb1bfb88f648ee5a1a6f386574ecf1a0fee8e3a20f520809e463e3b", + "statement": "@router.get('/userdata/{site_id}', summary='查询某站点用户数据', response_model=_SchemaResponse[list[_SchemaSiteUserData]], openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True})\nasync def read_userdata(site_id: int, response: Response=None, workdate: Optional[str]=None, query: SiteQueryService=Depends(get_site_query_service), _: ApiPrincipal=Depends(get_current_active_manage_user_async), page: CompatiblePageParam=None, count: CompatibleCountParam=None) -> Any:", + "reason": "fastapi-response-default" + }, + { + "path": "app/api/endpoints/site.py", + "owner": "read_userdata_latest", + "codes": [ + "assignment" + ], + "fingerprint": "0227700f2929c70a4b0c7df9db8b69c5da7450092e7e260cf5598c834d0f7d12", + "statement": "@router.get('/userdata/latest', summary='查询所有站点最新用户数据', response_model=List[_SchemaSiteUserData], openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True})\nasync def read_userdata_latest(response: Response=None, query: SiteQueryService=Depends(get_site_query_service), _: ApiPrincipal=Depends(get_current_active_manage_user_async), page: CompatiblePageParam=None, count: CompatibleCountParam=None) -> Any:", + "reason": "fastapi-response-default" + }, + { + "path": "app/api/endpoints/subscribe.py", + "owner": "list_subscribes", + "codes": [ + "assignment" + ], + "fingerprint": "b36833696f77492798f44e25bc64c613b3a5b6e232a3df2d2337475afa2c30fa", + "statement": "@router.get('/list', summary='查询所有订阅(API_TOKEN)', response_model=List[_SchemaSubscribe], openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True})\nasync def list_subscribes(response: Response=None, query: SubscriptionQueryService=Depends(get_subscription_query_service), status_service: SubscriptionExecutionStatusService=Depends(get_subscription_execution_status_service), _: Optional[Annotated[str, Depends(verify_apitoken)]]=None, page: CompatiblePageParam=None, count: CompatibleCountParam=None) -> Any:", + "reason": "fastapi-response-default" + }, + { + "path": "app/api/endpoints/subscribe.py", + "owner": "read_subscribes", + "codes": [ + "assignment" + ], + "fingerprint": "ed2e2a56d57aabca69b5a5778313547b43d081b1d98757758797c34aa5fb9f42", + "statement": "@router.get('/', summary='查询所有订阅', response_model=List[_SchemaSubscribe], openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True})\nasync def read_subscribes(response: Response=None, query: SubscriptionQueryService=Depends(get_subscription_query_service), status_service: SubscriptionExecutionStatusService=Depends(get_subscription_execution_status_service), current_user: ApiPrincipal=Depends(get_current_active_user_async), page: CompatiblePageParam=None, count: CompatibleCountParam=None) -> Any:", + "reason": "fastapi-response-default" + }, + { + "path": "app/api/endpoints/subscribe.py", + "owner": "subscribe_history", + "codes": [ + "assignment" + ], + "fingerprint": "ac30ef74741b93c556c16d34d74aa02334a761c24300522c16830145b72775de", + "statement": "@router.get('/history/{mtype}', summary='查询订阅历史', response_model=List[_SchemaSubscribe], openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True})\nasync def subscribe_history(mtype: str, page: Optional[int]=1, count: Optional[int]=30, query: SubscriptionQueryService=Depends(get_subscription_query_service), current_user: ApiPrincipal=Depends(get_current_active_user_async), response: Response=None) -> Any:", + "reason": "fastapi-response-default" + }, + { + "path": "app/api/endpoints/subscribe.py", + "owner": "user_subscribes", + "codes": [ + "assignment" + ], + "fingerprint": "c31b6cd8ff4195705e72c8c0d7c4a51ece77426c26fecc7e3f5c1369a34e045e", + "statement": "@router.get('/user/{username}', summary='用户订阅', response_model=List[_SchemaSubscribe], openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True})\nasync def user_subscribes(username: str, response: Response=None, query: SubscriptionQueryService=Depends(get_subscription_query_service), status_service: SubscriptionExecutionStatusService=Depends(get_subscription_execution_status_service), current_user: ApiPrincipal=Depends(get_current_active_user_async), page: CompatiblePageParam=None, count: CompatibleCountParam=None) -> Any:", + "reason": "fastapi-response-default" + }, + { + "path": "app/api/endpoints/user.py", + "owner": "list_users", + "codes": [ + "assignment" + ], + "fingerprint": "a58d920e4edf317adad4f8b110ce1a0a9d2f61ac27bf6ab7abe1bf129b8f6fc6", + "statement": "@router.get('/', summary='所有用户', response_model=List[_SchemaUser], openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True})\nasync def list_users(response: Response=None, service: UserService=Depends(get_user_service), current_user: Any=Depends(get_current_active_superuser_async), page: CompatiblePageParam=None, count: CompatibleCountParam=None) -> Any:", + "reason": "fastapi-response-default" + }, + { + "path": "app/api/endpoints/workflow.py", + "owner": "list_agent_workflows", + "codes": [ + "assignment" + ], + "fingerprint": "8f4f92cd77eb0ecd82aec77d674c4e0b3de719dee710552c359947504b9723c5", + "statement": "@router.get('/agent', summary='查询 Agent 可用工作流', response_model=List[_SchemaJsonObject], openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True})\nasync def list_agent_workflows(response: Response=None, state: Literal['W', 'R', 'P', 'S', 'F', 'all']='all', name: Optional[str]=None, trigger_type: Literal['timer', 'event', 'manual', 'all']='all', query: WorkflowQueryService=Depends(get_workflow_query_service), _: Any=Depends(get_current_active_manage_user_async), page: CompatiblePageParam=None, count: CompatibleCountParam=None) -> List[dict[str, Any]]:", + "reason": "fastapi-response-default" + }, + { + "path": "app/api/endpoints/workflow.py", + "owner": "list_workflows", + "codes": [ + "assignment" + ], + "fingerprint": "d1290f1c6190ea9bb8afe4f01985ff8aacc1e1cd0e1386b48c41539b0a5b12d1", + "statement": "@router.get('/', summary='所有工作流', response_model=List[_SchemaWorkflow], openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True})\nasync def list_workflows(response: Response=None, query: WorkflowQueryService=Depends(get_workflow_query_service), _: Any=Depends(get_current_active_manage_user_async), page: CompatiblePageParam=None, count: CompatibleCountParam=None) -> Any:", + "reason": "fastapi-response-default" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "var-annotated" + ], + "fingerprint": "0570ed4bd4416ae711cf59f0de02402de4d715e79f2154a1a96c546ac51dc46f", + "statement": "_directory_audio_files = classmethod(MediaAlbumOwner._directory_audio_files.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "attr-defined" + ], + "fingerprint": "0d815e0793c1a5d359fe1dc0c6ce2fd349ec1a9c1b62baf927e1ea49a4167779", + "statement": "_validate_music_result = classmethod(MediaCatalogOwner._validate_music_result.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "attr-defined" + ], + "fingerprint": "0ec6075c461d12869f4476731fbae9f25105a3afa0b61eb37e329fcdd912c2f3", + "statement": "_align_selected_music_album = classmethod(MediaAlbumOwner._align_selected_music_album.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "attr-defined" + ], + "fingerprint": "2021a2bde9f0421ee0869a2a68e80a611fb52db3932c5051b02e7c28a9dce6fd", + "statement": "_album_track_map = classmethod(MediaAlbumOwner._album_track_map.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "attr-defined" + ], + "fingerprint": "22f1a54afdb0f2fc1c236e607431f3b15aacc1573b47fe69ef2218276065e2a2", + "statement": "_music_artist_aliases = classmethod(MediaCatalogOwner._music_artist_aliases.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "var-annotated" + ], + "fingerprint": "3706d107e669f4e49f925c244a9dd02439da46a95b02d970dfe25ef06805621d", + "statement": "is_audio_path = classmethod(MediaPathOwner.is_audio_path.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "attr-defined" + ], + "fingerprint": "38ad1ac372ca3694a71986d50695a8504cc254b078ba6ffd22c5b458b7710a30", + "statement": "read_path_meta = classmethod(MediaPathOwner.read_path_meta.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "var-annotated" + ], + "fingerprint": "3986bba4b0d8495d064469ce6a81922063a4b89efa38c864bae5339ff5c1a17e", + "statement": "read_path_meta = classmethod(MediaPathOwner.read_path_meta.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "var-annotated" + ], + "fingerprint": "44814ccb6d50080c435212e7314db50ad9145660dfc501e53053d07ca2e29e27", + "statement": "_music_artist_aliases = classmethod(MediaCatalogOwner._music_artist_aliases.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "attr-defined" + ], + "fingerprint": "46c14c1c79b9e0e6ee78eda4a7ce7541172afb8c1f1f6981d7567498652834d1", + "statement": "_simplify_recognized_music_info = classmethod(MediaCatalogOwner._simplify_recognized_music_info.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "attr-defined" + ], + "fingerprint": "4c3270411ac04c023ada518490c18ead3ea825e8cb07b2236c279ed6e22953d4", + "statement": "_simplify_music_artist_name = classmethod(MediaCatalogOwner._simplify_music_artist_name.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "attr-defined" + ], + "fingerprint": "56023b3a87d38e3fab51229441cfee4e8072c49af7dfd4f70593c823575e4f0a", + "statement": "_music_info_from_path_meta = classmethod(MediaPathOwner._music_info_from_path_meta.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "var-annotated" + ], + "fingerprint": "5842de98fec388475ad0888a0456c49881455f81fea7c285cc4d12a73b69e5e1", + "statement": "_simplify_recognized_music_mapping = classmethod(MediaCatalogOwner._simplify_recognized_music_mapping.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "var-annotated" + ], + "fingerprint": "5eca110a9187a19eabb1d22f2839b47e5486730cd94c7cf0a66af97ea822fce3", + "statement": "_music_info_from_path_meta = classmethod(MediaPathOwner._music_info_from_path_meta.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "var-annotated" + ], + "fingerprint": "63482c720b5edb1c03eb982a2311291cbc70abe5daed39288e70a1e14e421c93", + "statement": "_align_selected_music_album = classmethod(MediaAlbumOwner._align_selected_music_album.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "var-annotated" + ], + "fingerprint": "6ab399787808e43803877f367874bb7f9ecada613a15a1df842178cdb641e3d0", + "statement": "_validate_music_result = classmethod(MediaCatalogOwner._validate_music_result.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "attr-defined" + ], + "fingerprint": "6f5543c6f0f41dfadc72bf9a4d83c12b1f827298c698250d834b5758eb3b90ad", + "statement": "_music_search_sources = classmethod(MediaCatalogOwner._music_search_sources.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "attr-defined" + ], + "fingerprint": "6fe030a2c6b3a8afd4eed7ab080afe34ac40049de435316bf1cb757c1d5b80e3", + "statement": "_merge_media_auxiliary = classmethod(MediaAuxiliaryOwner._merge_media_auxiliary.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "var-annotated" + ], + "fingerprint": "7b28e3b4c3585055f2c14107789b81831545a0210b27aeb959278fa892ba2834", + "statement": "normalize_music_candidates = classmethod(MediaCatalogOwner.normalize_music_candidates.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "var-annotated" + ], + "fingerprint": "8a5a27268e65ef0ad5332c49884b334be83f2352b4bea845e8ce4c46a9e540f4", + "statement": "_album_track_map = classmethod(MediaAlbumOwner._album_track_map.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "attr-defined" + ], + "fingerprint": "9a4723753a8209e255b67f3e72a3eff388bbabe0778b26fde02b307097cfc4f0", + "statement": "_align_music_album_tracks = classmethod(MediaAlbumOwner._align_music_album_tracks.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "var-annotated" + ], + "fingerprint": "a097b7811a4ddd8adb7dcabb17e87de13025e261e944d8b5669ff2a6072f4c0a", + "statement": "_music_search_sources = classmethod(MediaCatalogOwner._music_search_sources.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "var-annotated" + ], + "fingerprint": "aee5477134432061fb49e4a5e3a59b55770a8fb950737b605f2cc8d482c88835", + "statement": "_merge_media_auxiliary = classmethod(MediaAuxiliaryOwner._merge_media_auxiliary.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "attr-defined" + ], + "fingerprint": "b41e20f699560cd62af6e41c62ec3aa63b8495ba4ea8d2732058f3918a5fc192", + "statement": "normalize_music_candidates = classmethod(MediaCatalogOwner.normalize_music_candidates.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "attr-defined" + ], + "fingerprint": "b93f7db5a7793838d6056c7d5cbcff2273809f3ca3289615a2d0a55f0637a50a", + "statement": "_simplify_music_artist_names = classmethod(MediaCatalogOwner._simplify_music_artist_names.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "attr-defined" + ], + "fingerprint": "c627d54cd8eea549612d88fdd2b2f9c6dd66385d0d5a855a99338272611bde64", + "statement": "_directory_audio_files = classmethod(MediaAlbumOwner._directory_audio_files.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "var-annotated" + ], + "fingerprint": "dae933a20547c02b187205ae4db227fce8b65a6f01db3e165bb3b6af3f0ff3a4", + "statement": "_simplify_recognized_music_info = classmethod(MediaCatalogOwner._simplify_recognized_music_info.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "var-annotated" + ], + "fingerprint": "e305787009b2eacd046687a66e08a8635bc64c2ba80e19c4f0e5019fd4ae8879", + "statement": "_simplify_music_artist_names = classmethod(MediaCatalogOwner._simplify_music_artist_names.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "attr-defined" + ], + "fingerprint": "f0cc91f248439ac1ac7a99155f1e5d69882ac9add4dc4a6effe0cc55e881d059", + "statement": "is_audio_path = classmethod(MediaPathOwner.is_audio_path.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "attr-defined" + ], + "fingerprint": "f9f6b52c3381ddff7c68dc4d5e98581bd6ccea6ed8b3220dc68aaedbef8c5e88", + "statement": "_simplify_recognized_music_mapping = classmethod(MediaCatalogOwner._simplify_recognized_music_mapping.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "var-annotated" + ], + "fingerprint": "fb2e55aec6b9d8ecdbdff858fadb834de3852d30f72d7c1ce0dcdd8e0ca32817", + "statement": "_simplify_music_artist_name = classmethod(MediaCatalogOwner._simplify_music_artist_name.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/media/facade.py", + "owner": "MediaChain", + "codes": [ + "var-annotated" + ], + "fingerprint": "ff1854943abe279d9ffbda327bde936caee491e3077f474584102deb2e199e08", + "statement": "_align_music_album_tracks = classmethod(MediaAlbumOwner._align_music_album_tracks.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/search/facade.py", + "owner": "SearchChain", + "codes": [ + "var-annotated" + ], + "fingerprint": "0534fa093d83e54f44f88cfe4a0e6b9911faf7b7f833555d44751546164eb0b4", + "statement": "music_site_keywords = classmethod(SearchMusicOwner.music_site_keywords.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/search/facade.py", + "owner": "SearchChain", + "codes": [ + "attr-defined" + ], + "fingerprint": "13ffb6c0ef9f22ad52de3e09fab2bf6018460662d116b42a8998475b995a2a86", + "statement": "music_site_keywords = classmethod(SearchMusicOwner.music_site_keywords.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/search/facade.py", + "owner": "SearchChain", + "codes": [ + "attr-defined" + ], + "fingerprint": "24a0f52fd733cbd71ba20345d48a15cbb94cdf83f1f9e1369a84794c813914b8", + "statement": "_build_search_pages = classmethod(SearchPaginationOwner._build_search_pages.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/search/facade.py", + "owner": "SearchChain", + "codes": [ + "var-annotated" + ], + "fingerprint": "652a2dbd28d6178d912b65141be13ed5670a728dfa4c3bbb7a427dd0077228eb", + "statement": "matches_music_resource = classmethod(SearchMusicOwner.matches_music_resource.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/search/facade.py", + "owner": "SearchChain", + "codes": [ + "var-annotated" + ], + "fingerprint": "6e4f93789e2fd8ae5371fe31a252e444a61aa59099e6442d35a52844a667797b", + "statement": "_build_search_pages = classmethod(SearchPaginationOwner._build_search_pages.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/chain/search/facade.py", + "owner": "SearchChain", + "codes": [ + "attr-defined" + ], + "fingerprint": "f4872a6d3027bd98ce80f5beed79b0ed99d5a49eb3777b6796a361adb018f31d", + "statement": "matches_music_resource = classmethod(SearchMusicOwner.matches_music_resource.__func__)", + "reason": "classmethod-descriptor" + }, + { + "path": "app/db/base.py", + "owner": "Base", + "codes": [ + "misc" + ], + "fingerprint": "3bb5a847e40d07c7e6f9dda4a26a277df7273d78589a9ceaee68558885493979", + "statement": "class Base(DeclarativeBase):", + "reason": "sqlalchemy-runtime" + }, + { + "path": "app/db/base.py", + "owner": "Base.__tablename__", + "codes": [ + "misc" + ], + "fingerprint": "57c745aed8f43675bec5b346b56bd45fe081e37bf52d1c9650b2e8c9c4c0d629", + "statement": "@declared_attr.directive\ndef __tablename__(cls) -> str:", + "reason": "sqlalchemy-runtime" + }, + { + "path": "app/db/plugin/base.py", + "owner": "plugin_declarative_base.PluginBase", + "codes": [ + "misc" + ], + "fingerprint": "eeb199e0d1a7909c5a89deb23631a06015c1414cfc2d4885df2c5b20395aea38", + "statement": "class PluginBase(DeclarativeBase):", + "reason": "sqlalchemy-runtime" + }, + { + "path": "app/db/plugin/registry.py", + "owner": "_create_declared_tables", + "codes": [ + "attr-defined" + ], + "fingerprint": "f8e4fe51dedb2b2d10fcc2311676590dba91d6c0a4eae5060ada81ac512e92bd", + "statement": "tables = list(dict.fromkeys((model.__table__ for model in models)))", + "reason": "sqlalchemy-runtime" + }, + { + "path": "app/modules/indexer/spider/__init__.py", + "owner": "SiteSpider.__class__", + "codes": [ + "override" + ], + "fingerprint": "ab6dfeaaf9542210a4f6c60ea7793021a25d56c5cfad2eb62dd166ff9e0f5c3d", + "statement": "@property\ndef __class__(self) -> type[object]:", + "reason": "spider-projection" + }, + { + "path": "app/modules/indexer/spider/__init__.py", + "owner": "SiteSpider.__class__", + "codes": [ + "misc" + ], + "fingerprint": "ff6bea975fcee835bf85c3a2ea8150134a3b2d8bee2d38c9b859beccc5ea8e0a", + "statement": "@property\ndef __class__(self) -> type[object]:", + "reason": "spider-projection" + }, + { + "path": "app/modules/indexer/spider/__init__.py", + "owner": "SiteSpider.__dict__", + "codes": [ + "override" + ], + "fingerprint": "799037200dc4c488a45ccbfc321d617ced938fa21fddac5a130aa4759c4e637f", + "statement": "@property\ndef __dict__(self) -> JsonDict:", + "reason": "spider-projection" + }, + { + "path": "app/modules/ugreen/ugreen.py", + "owner": "Ugreen.__resolve_scan_type", + "codes": [ + "arg-type" + ], + "fingerprint": "c3a418491a2a1ec84cc8d579e4c354c1565adb1361e977b1536e18e4215fc877", + "statement": "parsed = int(value)", + "reason": "legacy-config-int" + }, + { + "path": "app/runtime/cache.py", + "owner": "AsyncCacheBackend.clear", + "codes": [ + "override" + ], + "fingerprint": "bfdfeb2364e57a5e6d724bd50934c95c72c287132209bfda063bff4c956f64ed", + "statement": "@abstractmethod\nasync def clear(self, region: Optional[str]=DEFAULT_CACHE_REGION) -> None:", + "reason": "async-cache-compat" + }, + { + "path": "app/runtime/cache.py", + "owner": "AsyncCacheBackend.close", + "codes": [ + "override" + ], + "fingerprint": "8ceae0aaadb21b36fcb1409e4823ffb19f58f637995ff26b3a93613b5a507c2a", + "statement": "@abstractmethod\nasync def close(self) -> None:", + "reason": "async-cache-compat" + }, + { + "path": "app/runtime/cache.py", + "owner": "AsyncCacheBackend.delete", + "codes": [ + "override" + ], + "fingerprint": "c3f5f2b08160396ac214922ddb4ee95f38035f1f4daf1a5ebb071e53d8f057ac", + "statement": "@abstractmethod\nasync def delete(self, key: str, region: Optional[str]=DEFAULT_CACHE_REGION) -> None:", + "reason": "async-cache-compat" + }, + { + "path": "app/runtime/cache.py", + "owner": "AsyncCacheBackend.exists", + "codes": [ + "override" + ], + "fingerprint": "c94771c944b6ae5bb5a2b5aa5359ee0b52c12b66bd226d0b0316da645a033b7b", + "statement": "@abstractmethod\nasync def exists(self, key: str, region: Optional[str]=DEFAULT_CACHE_REGION) -> bool:", + "reason": "async-cache-compat" + }, + { + "path": "app/runtime/cache.py", + "owner": "AsyncCacheBackend.items", + "codes": [ + "override" + ], + "fingerprint": "0f6ad6734a64b5ea7a2b077d5f05a430cad2fbdfc484b786b7bfd182b5f7748d", + "statement": "@abstractmethod\ndef items(self, region: Optional[str]=DEFAULT_CACHE_REGION) -> AsyncGenerator[Tuple[str, Any], None]:", + "reason": "async-cache-compat" + }, + { + "path": "app/runtime/cache.py", + "owner": "AsyncCacheBackend.keys", + "codes": [ + "override" + ], + "fingerprint": "421a679f846b86fea8a52de57899f6db31b89d18b7eb6c81357055894ea2d9c1", + "statement": "async def keys(self, region: Optional[str]=DEFAULT_CACHE_REGION) -> AsyncGenerator[str, None]:", + "reason": "async-cache-compat" + }, + { + "path": "app/runtime/cache.py", + "owner": "AsyncCacheBackend.popitem", + "codes": [ + "override" + ], + "fingerprint": "66bdcfff130aab0333d9d35d830a06f288b5368d7ed6f73dc9eb3711e69f761d", + "statement": "async def popitem(self, region: Optional[str]=DEFAULT_CACHE_REGION) -> Tuple[str, Any]:", + "reason": "async-cache-compat" + }, + { + "path": "app/runtime/cache.py", + "owner": "AsyncCacheBackend.set", + "codes": [ + "override" + ], + "fingerprint": "3f3a256046828d07111e9da5a12981dfcf24450dbbe949fd1d870685d89443d7", + "statement": "@abstractmethod\nasync def set(self, key: str, value: Any, ttl: Optional[int]=None, region: Optional[str]=DEFAULT_CACHE_REGION, **kwargs: object) -> None:", + "reason": "async-cache-compat" + }, + { + "path": "app/runtime/cache.py", + "owner": "AsyncCacheBackend.update", + "codes": [ + "override" + ], + "fingerprint": "0203836f2d112deaf02950e2d6c7d5016dda24fc7232acdd5125b5f8c6739e3c", + "statement": "async def update(self, other: Dict[str, Any], region: Optional[str]=DEFAULT_CACHE_REGION, ttl: Optional[int]=None, **kwargs: object) -> None:", + "reason": "async-cache-compat" + }, + { + "path": "app/runtime/cache.py", + "owner": "AsyncCacheBackend.values", + "codes": [ + "override" + ], + "fingerprint": "54c7fbfea95a0f431f714db5b2dc76e7232fe077049b497c434508fcf1c7eae2", + "statement": "async def values(self, region: Optional[str]=DEFAULT_CACHE_REGION) -> AsyncGenerator[Any, None]:", + "reason": "async-cache-compat" + }, + { + "path": "app/runtime/cache.py", + "owner": "AsyncMemoryBackend.clear", + "codes": [ + "override" + ], + "fingerprint": "bc19c333b779598deafefc06b5826a7f128de97d72d668b8ab4a870bd0398ab9", + "statement": "async def clear(self, region: Optional[str]=DEFAULT_CACHE_REGION) -> None:", + "reason": "async-cache-compat" + }, + { + "path": "app/runtime/cache.py", + "owner": "AsyncMemoryBackend.close", + "codes": [ + "override" + ], + "fingerprint": "78edb2946bb92329b0b3ed060c1db6f9053ba47361725801308f1ace2e4dc433", + "statement": "async def close(self) -> None:", + "reason": "async-cache-compat" + }, + { + "path": "app/runtime/cache.py", + "owner": "AsyncMemoryBackend.delete", + "codes": [ + "override" + ], + "fingerprint": "fddfa0d2ed7e7941772f3c6138cc6481f299c2f75484897a400c7da889958014", + "statement": "async def delete(self, key: str, region: Optional[str]=DEFAULT_CACHE_REGION) -> None:", + "reason": "async-cache-compat" + }, + { + "path": "app/runtime/cache.py", + "owner": "AsyncMemoryBackend.exists", + "codes": [ + "override" + ], + "fingerprint": "e282cac78a82d69f6af6790cd22e6fdea879906e68e8aa6638b9a465d80216f1", + "statement": "async def exists(self, key: str, region: Optional[str]=DEFAULT_CACHE_REGION) -> bool:", + "reason": "async-cache-compat" + }, + { + "path": "app/runtime/cache.py", + "owner": "AsyncMemoryBackend.items", + "codes": [ + "override" + ], + "fingerprint": "555bb945526fb79419457c217930b25ca98c9beffc87cfa31a8ca8ed6cdc6ed4", + "statement": "async def items(self, region: Optional[str]=DEFAULT_CACHE_REGION) -> AsyncGenerator[Tuple[str, Any], None]:", + "reason": "async-cache-compat" + }, + { + "path": "app/runtime/cache.py", + "owner": "AsyncMemoryBackend.set", + "codes": [ + "override" + ], + "fingerprint": "8c9fe376712df0536754bedef77de172907603767b149b0d2fb10472339b08d6", + "statement": "async def set(self, key: str, value: Any, ttl: Optional[int]=None, region: Optional[str]=DEFAULT_CACHE_REGION, **kwargs: object) -> None:", + "reason": "async-cache-compat" + }, + { + "path": "app/scheduler/catalog.py", + "owner": "_SchedulerThreadPoolExecutor", + "codes": [ + "misc" + ], + "fingerprint": "e7cc9561bf928fe6f284136818baaeb579644a0679b3d9b257944e75d87b5a74", + "statement": "class _SchedulerThreadPoolExecutor(BasePoolExecutor):", + "reason": "scheduler-executor" + }, + { + "path": "app/schemas/plugin.py", + "owner": "PluginInstance.mode", + "codes": [ + "prop-decorator" + ], + "fingerprint": "204f9df3bafb4ffc7804befd4ee2ba7f31c783e566211a534c5b57efa90b1195", + "statement": "@_computed_field(description='实例实现模式:virtual 为共享源码的分身,host 为源插件本体自身')\n@property\ndef mode(self) -> Literal['virtual', 'host']:", + "reason": "pydantic-computed" + }, + { + "path": "app/sdk/_legacy/subscribe.py", + "owner": "SubscribeOper.add", + "codes": [ + "override" + ], + "fingerprint": "702faa66888a04859e3bebf647d2b30c3f4fc329ce9a4be053e9fcc2dabb5dcf", + "statement": "def add(self, mediainfo: Optional[MediaInfo | MusicInfo]=None, **kwargs: Any) -> tuple[int, str]:", + "reason": "legacy-subscribe" + }, + { + "path": "app/sdk/_legacy/subscribe.py", + "owner": "SubscribeOper.async_add", + "codes": [ + "override" + ], + "fingerprint": "fa462ab703861b536ebdc33fa7e7f659e74b0858a80cb6f5ed6391797470078f", + "statement": "async def async_add(self, mediainfo: Optional[MediaInfo | MusicInfo]=None, **kwargs: Any) -> tuple[int, str]:", + "reason": "legacy-subscribe" + } + ], + "stubs": [ + { + "path": "app/application/site/sites.pyi", + "sha256": "b3f0ded8022739b361b730c7dcc47b949c28f0be466d846c15b396937f8b86f0", + "reason": "binary-site-contract" + } + ] +} diff --git a/tests/test_agent_learning_review.py b/tests/test_agent_learning_review.py index d0caed20d..dec4a67bb 100644 --- a/tests/test_agent_learning_review.py +++ b/tests/test_agent_learning_review.py @@ -6,14 +6,16 @@ import asyncio import json from copy import deepcopy from typing import Any +from unittest.mock import AsyncMock import pytest from langchain.agents import create_agent -from langchain.agents.middleware.types import ModelRequest, ModelResponse +from langchain.agents.middleware.types import ExtendedModelResponse, ModelRequest, ModelResponse from langchain_core.language_models.chat_models import BaseChatModel from langchain_core.messages import AIMessage, HumanMessage, SystemMessage from langchain_core.outputs import ChatGeneration, ChatResult from langchain_core.tools import StructuredTool +from langgraph.types import Command from pydantic import Field from app.agent.learning.review import ReviewLoop, ReviewSnapshot @@ -118,6 +120,25 @@ def test_review_keeps_prefix_schema_and_denies_business_tools(tmp_path): asyncio.run(scenario()) +def test_review_compaction_uses_its_own_command_state(tmp_path, monkeypatch): + """ + 复盘压缩返回的消息状态替换隔离循环,不修改捕获的父对话快照。 + """ + owner = session(tmp_path) + model = ReviewModel(responses=[response('memory', {'action': 'list'})]) + original = [HumanMessage(content='已交付任务')] + snapshot = ReviewSnapshot(model, SystemMessage(content='system'), original, [], {}, None) + loop = ReviewLoop(snapshot, owner.tools, review_memory=True, review_skills=False, + cancelled=lambda: False, on_usage=lambda _usage: None) + compacted = [SystemMessage(content='system'), HumanMessage(content='压缩事实'), AIMessage(content='done')] + result = ExtendedModelResponse(model_response=ModelResponse(result=[compacted[-1]]), + command=Command(update={'messages': compacted})) + monkeypatch.setattr(loop.compaction, 'awrap_model_call', AsyncMock(return_value=result)) + assert asyncio.run(loop.run()) == [] + assert loop.messages == compacted[1:] + assert snapshot.messages == original + + @pytest.mark.parametrize('review_memory,review_skills,scope', [ (True, False, 'memory maintenance only'), (False, True, 'personal skill maintenance only'), diff --git a/tests/test_api_optional_inputs.py b/tests/test_api_optional_inputs.py new file mode 100644 index 000000000..765e04388 --- /dev/null +++ b/tests/test_api_optional_inputs.py @@ -0,0 +1,167 @@ +""" +验证 API 可选参数及缺失业务元数据的边界行为,保持旧直接调用兼容。 +""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +from fastapi import HTTPException + +from app.api.endpoints import bangumi, download, history, mediaserver, message, search, site, tmdb, transfer +from app.domain.context import MediaInfo +from app.schemas.context import MediaInfo as MediaInfoInput +from app.schemas.context import SubtitleInfo +from app.schemas.file import FileItem +from app.schemas.transfer import ManualTransferItem +from app.schemas.types import MediaType + + +@pytest.mark.parametrize("endpoint_name", ["bangumi_credits", "bangumi_recommend", "bangumi_person_credits"]) +@pytest.mark.parametrize(("page", "count", "expected"), [(None, None, slice(0, 20)), (2, 3, slice(3, 6))]) +def test_bangumi_optional_pagination(endpoint_name, page, count, expected, monkeypatch): + """ + 演职员与推荐列表的空分页参数使用默认页,显式分页仍按原顺序截取。 + """ + values = [MediaInfo(title=f"作品 {index}") for index in range(25)] + chain = SimpleNamespace( + async_bangumi_credits=AsyncMock(return_value=values), + async_bangumi_recommend=AsyncMock(return_value=values), + async_person_credits=AsyncMock(return_value=values), + ) + monkeypatch.setattr(bangumi, "BangumiChain", Mock(return_value=chain)) + result = asyncio.run(getattr(bangumi, endpoint_name)(1, page=page, count=count, _=None)) + selected = values[expected] + assert result == (selected if endpoint_name == "bangumi_credits" else [value.to_dict() for value in selected]) + + +def test_tmdb_collection_defaults_optional_pagination(monkeypatch): + """ + 合集的空分页值回退第一页,响应继续使用原有字典投影。 + """ + values = [MediaInfo(title=f"电影 {index}") for index in range(25)] + chain = SimpleNamespace(async_tmdb_collection=AsyncMock(return_value=values)) + monkeypatch.setattr(tmdb, "TmdbChain", Mock(return_value=chain)) + result = asyncio.run(tmdb.tmdb_collection(1, page=None, count=None, _=None)) + assert result == [value.to_dict() for value in values[:20]] + + +def test_history_and_web_message_optional_pagination(): + """ + 旧调用显式传 None 时仍向查询服务提供有效分页整数。 + """ + query = SimpleNamespace(list_transfer=AsyncMock(return_value={"list": [], "total": 0})) + response = asyncio.run(history.transfer_history(page=None, count=None, query=query, _=None)) + query.list_transfer.assert_awaited_once_with(title=None, page=1, count=30, status=None) + assert response.success is True + messages = SimpleNamespace(list_web=AsyncMock(return_value=[])) + assert asyncio.run(message.get_web_message(service=messages, page=None, count=None, _=None)) == [] + messages.list_web.assert_awaited_once_with(page=1, count=20) + + +@pytest.mark.parametrize("season", [None, 2]) +def test_mediaserver_empty_seasons_are_a_valid_empty_result(season, monkeypatch): + """ + 已存在媒体但缺少季集合时返回空集合或指定季空列表,不抛属性错误。 + """ + chain = SimpleNamespace(media_exists=Mock(return_value=SimpleNamespace(seasons=None))) + monkeypatch.setattr(mediaserver, "MediaServerChain", Mock(return_value=chain)) + result = mediaserver.exists(MediaInfoInput(type=MediaType.TV, title="剧集", season=season), _=None) + assert result == ({} if season is None else {season: []}) + + +def test_subtitle_without_download_url_stops_before_signature_or_site_lookup(): + """ + 缺少下载链接的字幕直接返回可读错误,避免把 None 交给签名校验。 + """ + subtitle = SubtitleInfo(site=1, title="empty.srt") + assert download._prepare_subtitle_download(subtitle) == (False, "字幕下载链接为空") + + +@pytest.mark.parametrize("site_id", [None, "1", {}]) +def test_subtitle_with_invalid_site_id_cannot_leave_unsigned_url(site_id): + """ + 有链接但没有整数站点 ID 的字幕不能生成不受站点用途约束的下载链接。 + """ + with pytest.raises(ValueError, match="站点 ID"): + search._serialize_signed_subtitle_result({"site": site_id, "enclosure": "https://example.test/sub.srt"}) + + +@pytest.mark.parametrize("endpoint_name", ["refresh_userdata", "test_site", "site_icon", "site_category", "site_resource"]) +def test_site_without_domain_never_reaches_indexer(endpoint_name, monkeypatch): + """ + 已保存但未配置域名的站点返回业务错误,不能被索引器当成全站查询。 + """ + configured_site = SimpleNamespace(domain=None) + query = SimpleNamespace(get_sync=Mock(return_value=configured_site), get=AsyncMock(return_value=configured_site)) + helper = Mock(side_effect=AssertionError("缺少域名不应调用索引器")) + monkeypatch.setattr(site, "SitesHelper", helper) + endpoint = getattr(site, endpoint_name) + if endpoint_name in {"site_category", "site_resource"}: + with pytest.raises(HTTPException) as error: + asyncio.run(endpoint(1, query=query, _=None)) + assert error.value.status_code == 400 + assert error.value.detail == "站点域名未配置" + else: + response = endpoint(1, query=query, _=None) + if endpoint_name == "site_icon": + response = asyncio.run(response) + assert response.success is False + assert response.message == "站点域名未配置" + helper.assert_not_called() + + +@pytest.mark.parametrize("category", [None, [], {"movie": [{"id": 1}], "other": [{"id": 1}, {"id": 2}]}]) +def test_site_category_only_projects_category_mappings(category, monkeypatch): + """ + 分类配置缺失或格式错误返回空列表,合法配置去重后保持顺序。 + """ + query = SimpleNamespace(get=AsyncMock(return_value=SimpleNamespace(domain="https://example.test"))) + helper = SimpleNamespace(async_get_indexer=AsyncMock(return_value={"category": category})) + monkeypatch.setattr(site, "SitesHelper", Mock(return_value=helper)) + result = asyncio.run(site.site_category(1, query=query, _=None)) + assert result == ([{"id": 1}, {"id": 2}] if isinstance(category, dict) else []) + + +@pytest.mark.parametrize("batch", [False, True]) +@pytest.mark.parametrize("moved", [False, True]) +def test_manual_transfer_history_without_file_snapshot_returns_business_error(batch, moved): + """ + 历史记录缺少来源或移动后目标快照时拒绝重做,并指出具体记录 ID。 + """ + query = SimpleNamespace(get=Mock(return_value=SimpleNamespace( + status=moved, mode="move" if moved else None, src_fileitem=None, dest_fileitem=None, + ))) + request = ManualTransferItem(**({"logids": [7]} if batch else {"logid": 7})) + items, error = transfer._resolve_manual_transfer_source_fileitems(request, query) + assert items == [] + assert error == "整理记录缺少文件项,ID:7" + + +@pytest.mark.parametrize("batch", [False, True]) +def test_manual_transfer_history_after_move_uses_destination_snapshot(batch): + """ + 成功移动的历史重做使用目标快照,避免重新访问已经搬走的源文件。 + """ + payload = {"storage": "local", "type": "file", "path": "/library/movie.mkv"} + query = SimpleNamespace(get=Mock(return_value=SimpleNamespace( + status=True, mode="move", src_fileitem=None, dest_fileitem=payload, + ))) + request = ManualTransferItem(**({"logids": [7]} if batch else {"logid": 7})) + items, error = transfer._resolve_manual_transfer_source_fileitems(request, query) + assert error is None + assert [item.path for item in items] == ["/library/movie.mkv"] + + +def test_manual_target_path_skips_source_without_path(monkeypatch): + """ + 缺少源路径的项被去重阶段过滤,不把当前工作目录用作整理路径匹配条件。 + """ + request = ManualTransferItem(fileitem=FileItem(storage="local", type="file", path=None)) + helper = Mock(side_effect=AssertionError("缺失路径的项不应匹配目录")) + monkeypatch.setattr(transfer, "DirectoryHelper", helper) + response = transfer.match_manual_transfer_target_path(request, history_query=Mock(), _=None) + assert response.success is True + assert response.data["target_path"] is None + helper.assert_not_called() diff --git a/tests/test_api_response_injection.py b/tests/test_api_response_injection.py new file mode 100644 index 000000000..2eba8dcb4 --- /dev/null +++ b/tests/test_api_response_injection.py @@ -0,0 +1,37 @@ +""" +验证旧直接调用默认值与 FastAPI 响应注入保持兼容。 +""" + +import importlib +import inspect +import json +from pathlib import Path + +import pytest +from fastapi.routing import APIRoute + + +def _response_endpoints() -> list[tuple[str, str]]: + """ + 对每个已登记的响应默认值兼容入口执行真实路由检查。 + """ + root = Path(__file__).resolve().parents[1] + policy = json.loads((root / "tests/fixtures/architecture/mypy-policy.json").read_text()) + return [(entry["path"][:-3].replace("/", "."), entry["owner"]) + for entry in policy["ignores"] if entry["reason"] == "fastapi-response-default"] + + +@pytest.mark.parametrize(("module_name", "endpoint_name"), _response_endpoints()) +def test_response_default_retains_framework_injection(module_name: str, endpoint_name: str) -> None: + """ + None 默认值仍允许旧直接调用,HTTP 入口必须注入 Response 而不是生成请求字段。 + """ + module = importlib.import_module(module_name) + endpoint = getattr(module, endpoint_name) + assert inspect.signature(endpoint).parameters["response"].default is None + routes = [route for route in module.router.routes + if isinstance(route, APIRoute) and inspect.unwrap(route.endpoint) is inspect.unwrap(endpoint)] + assert routes + for route in routes: + assert route.dependant.response_param_name == "response" + assert all(field.name != "response" for field in [*route.dependant.query_params, *route.dependant.body_params]) diff --git a/tests/test_architecture_egress.py b/tests/test_architecture_egress.py index 19fef112d..5170f17a4 100644 --- a/tests/test_architecture_egress.py +++ b/tests/test_architecture_egress.py @@ -89,7 +89,8 @@ FROZEN_EGRESS_REASON_BY_EDGE = { for edge in edges } FROZEN_EGRESS_FINGERPRINT_BY_EDGE = { - ("app.adapters.cache.redis", "redis"): "49f0b28ef731b25aa772d6887febd762d03b981a1f52d6dfc8dff82f2f489f81", + # Redis 客户端局部化后,扫描器保留了完整调用面;传输边界未增加。 + ("app.adapters.cache.redis", "redis"): "81a91a371296446093a9796421f72fb76ea54a040a3c288c469c6c5b4dfd6c39", ("app.adapters.external.server", "httpx2"): "e9aeb562213af38c644c457850853bba9f3043c56e710c3adde98adc6a4b9dba", ("app.adapters.external.server", "requests"): "e8c747a7a4e96c3b1dc102bf49a37881cc674bcfb17b98693cac966afc3c3037", ("app.adapters.external.plugin.client", "httpx2"): "2d249b0906bc7f8fa29f569d6bcc5667d4290a551b1a06bcdf85e09ec50a793d", @@ -136,7 +137,8 @@ FROZEN_EGRESS_FINGERPRINT_BY_EDGE = { ("app.modules.rtorrent.rtorrent", "http.client"): "6adc93b3bc479dfe81197554c7977930a60abc51714f14ef29298eb40730aaf7", ("app.modules.rtorrent.rtorrent", "socket.create_connection"): "878114d6b1bda091bd3b3d8aa819831d84381682feba78267592d2593295af90", ("app.modules.rtorrent.rtorrent", "xmlrpc.client.ServerProxy"): "2ac64b5670c930bc28bb2135cc4b73891c1cae01a74a9586f74e302878c45c85", - ("app.modules.slack.slack", "slack_bolt"): "be5dcb032ece8d8627abeb243f98143aaf60f26751ab6e5e098813d4048419e6", + # Slack 客户端守卫和返回类型收紧后,扫描器保留了完整调用面;传输边界未增加。 + ("app.modules.slack.slack", "slack_bolt"): "d970999c7c77ca5e93ec8a2773fa0b3db10df63ecce780b5fd52ca038734666a", ("app.modules.slack.slack", "slack_sdk"): "7559f31e4172ad3bbbaf161e1164ea48b997c56d05c77b49b82626298c39aa14", # Session 类型使扫描器识别原有的两处 close;未新增发送或会话关闭路径。 ("app.modules.themoviedb.tmdbv3api.tmdb", "requests"): "6c4ea48dd83616375a48785f9de9f477268f9236029e57b17ca1d2bde538fd4c", diff --git a/tests/test_chain_missing_metadata.py b/tests/test_chain_missing_metadata.py new file mode 100644 index 000000000..b28e38603 --- /dev/null +++ b/tests/test_chain_missing_metadata.py @@ -0,0 +1,217 @@ +""" +验证订阅、整理及资源链在缺失媒体或文件元数据时停止写入并保留有效结果。 +""" + +import asyncio +import inspect +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from app.application.subscription.contract import SubscriptionSnapshot +from app.application.torrent.download import TorrentHelper +from app.chain import _music +from app.chain._messaging import _render_notification_message +from app.chain.interaction import MediaInteractionChain +from app.chain.storage import StorageChain +from app.chain.subscribe.completion import SubscribeCompletionOwner +from app.chain.subscribe.create import SubscribeCreateOwner +from app.chain.subscribe.facade import SubscribeChain +from app.chain.subscribe.match import _prepare_match_torrents +from app.chain.torrents import TorrentsChain +from app.chain.transfer.request import _TransferCandidatePlanner +from app.chain.transfer.workflow import TransferWorkflowOwner +from app.domain.context import Context, MediaInfo +from app.domain.meta.metamusic import MetaMusic +from app.domain.metainfo import MetaInfo +from app.schemas.file import FileItem +from app.schemas.mediaserver import NotExistMediaInfo +from app.schemas.types import MediaSource, MediaType, NotificationChannel + + +@pytest.mark.parametrize("method_name", [ + "_SubscribeChain__prepare_subscribe_episodes", "_SubscribeChain__async_prepare_subscribe_episodes", + "_SubscribeChain__finalize_subscribe_create_context", "_SubscribeChain__async_finalize_subscribe_create_context", + "_SubscribeChain__apply_subscribe_create_defaults", "_SubscribeChain__persist_subscribe_create", + "_SubscribeChain__async_persist_subscribe_create", +]) +def test_subscription_stages_without_media_do_not_write(method_name): + """ + 阶段边界接收到空识别结果时返回失败或停止补图,不访问仓储和通知端口。 + """ + owner = object.__new__(SubscribeCreateOwner) + context = SimpleNamespace(mediainfo=None) + result = getattr(owner, method_name)(context) + if inspect.isawaitable(result): + result = asyncio.run(result) + if "persist" in method_name: + assert result == (None, "未识别到媒体信息") + elif "prepare" in method_name: + assert result == "未识别到媒体信息" + else: + assert result is None + + +def test_subscription_cannot_freeze_post_commit_context_without_media(): + """ + 不完整识别结果不能进入提交后的事件、通知与统计阶段。 + """ + with pytest.raises(ValueError, match="缺少媒体信息"): + SubscribeCreateOwner._SubscribeChain__subscribe_post_commit_context(SimpleNamespace(mediainfo=None), None) + + +@pytest.mark.parametrize("asynchronous", [False, True]) +def test_episode_refresh_losing_media_is_a_creation_error(asynchronous, monkeypatch): + """ + 补齐季集的二次识别失败时返回业务错误,不能继续解引用旧媒体或提交订阅。 + """ + media = MediaInfo(type=MediaType.TV, media_source=MediaSource.TMDB, media_id="1") + context = SimpleNamespace(mediainfo=media, season=None, options={}, episode_group=None) + chain = SimpleNamespace(recognize_media=Mock(return_value=None), async_recognize_media=AsyncMock(return_value=None)) + monkeypatch.setattr("app.chain.subscribe.create.MediaChain", Mock(return_value=chain)) + owner = object.__new__(SubscribeCreateOwner) + method = owner._SubscribeChain__async_prepare_subscribe_episodes if asynchronous else owner._SubscribeChain__prepare_subscribe_episodes + result = method(context) + assert (asyncio.run(result) if asynchronous else result) == "未识别到媒体信息" + assert context.mediainfo is None + + +def test_movie_priority_ignores_download_context_without_torrent(): + """ + 缺少种子信息的下载上下文不覆盖现有电影优先级。 + """ + owner = object.__new__(SubscribeCompletionOwner) + subscribe = SubscriptionSnapshot(id=1, name="Demo", type=MediaType.MOVIE.value, current_priority=80) + result = owner._SubscribeChain__update_movie_download_priority(subscribe, MediaInfo(), [Context()]) + assert result is subscribe + assert result.current_priority == 80 + + +def test_missing_season_scope_subtracts_confirmed_downloads(): + """ + 季范围未包含目标季时仍按订阅总集数扣除已下载集,不能丢失下载事实。 + """ + missing = {"tmdb:1": {2: NotExistMediaInfo(season=2, episodes=[1, 2], total_episode=2)}} + complete, result = SubscribeCompletionOwner._SubscribeChain__get_subscribe_no_exits( + "剧集", missing, "tmdb:1", 1, 3, None, [1], + ) + assert complete is False + assert set(result["tmdb:1"][1].episodes) == {2, 3} + + +@pytest.mark.parametrize("media_type", [MediaType.MOVIE, MediaType.TV]) +def test_subscribe_file_projection_initializes_download_and_library_lists(media_type, monkeypatch): + """ + 电影和电视剧的首次文件记录会建立独立列表,同时保留下载与媒体库来源。 + """ + tv = media_type == MediaType.TV + filename = "Demo.S01E01.mkv" if tv else "Demo.2026.mkv" + subscribe = SubscriptionSnapshot(id=1, name="Demo", type=media_type.value, year="2026", season=1 if tv else None, + media_source="douban", media_id="1", start_episode=1, total_episode=2) + history = SimpleNamespace(download_hash="hash", torrent_name="torrent", torrent_site="site") + file = SimpleNamespace(filepath=filename, downloader="qb", fullpath=f"/downloads/{filename}") + repository = SimpleNamespace(get_by_media_identity=Mock(return_value=[history]), get_files_by_hash=Mock(return_value=[file])) + chain = SubscribeChain() + monkeypatch.setattr(chain, "download_history_repository", repository) + monkeypatch.setattr(chain, "media_files", Mock(return_value=[FileItem(storage="local", type="file", path=f"/library/{filename}")])) + monkeypatch.setattr(chain, "_append_subscribe_media_servers", Mock()) + media_chain = SimpleNamespace(recognize_media=Mock(return_value=MediaInfo(type=media_type, title="Demo"))) + monkeypatch.setattr("app.chain.subscribe.query.MediaChain", Mock(return_value=media_chain)) + result = chain.subscribe_files_info(subscribe) + episode = result.episodes[1 if tv else 0] + assert [item.file_path for item in episode.download] == [f"/downloads/{filename}"] + assert [item.file_path for item in episode.library] == [f"/library/{filename}"] + assert episode.download[0].hash == "hash" + + +def test_total_episode_decrease_without_season_keeps_candidate(monkeypatch): + """ + 未指定季的旧电视剧订阅不推断其他季的缺集事实来阻止集数更新。 + """ + owner = SubscribeChain() + monkeypatch.setattr(owner, "_SubscribeChain__is_full_best_version_enabled", Mock(return_value=False)) + subscribe = SubscriptionSnapshot(id=1, name="Demo", type=MediaType.TV.value, total_episode=12, season=None) + assert owner._SubscribeChain__resolve_total_episode_decrease(subscribe, 10, MetaInfo("Demo"), MediaInfo()) == 10 + + +def test_incomplete_resource_is_not_included_in_grouped_downloads(monkeypatch): + """ + 缺少元数据或媒体识别结果的资源不能参与按媒体名称分组去重。 + """ + helper = TorrentHelper() + contexts = [Context(), Context(meta_info=MetaInfo("Demo"))] + monkeypatch.setattr(helper, "sort_torrents", Mock(return_value=contexts)) + assert helper.sort_group_torrents(contexts) == [] + + +def test_storage_and_transfer_require_source_path(monkeypatch): + """ + 路径缺失时存储查询与整理候选均停止,不把默认目录当作源文件。 + """ + item = FileItem(storage="local", type="file", path=None) + storage = StorageChain() + lookup = Mock(side_effect=AssertionError("缺少路径不应查询存储")) + monkeypatch.setattr(storage, "get_file_item", lookup) + assert storage.get_item(item) is None + owner = object.__new__(TransferWorkflowOwner) + assert owner._TransferChain__get_trans_fileitems(item, None, verify_file_exists=False) == [] + planner = object.__new__(_TransferCandidatePlanner) + planner._continue_callback = None + assert planner._is_allowed_transfer_item(item, False) is False + lookup.assert_not_called() + + +def test_music_subscription_missing_type_defaults_to_recording(): + """ + 旧音乐订阅缺少实体类型时恢复为单曲,原名回写仍保留可空回退语义。 + """ + subscribe = SubscriptionSnapshot(id=1, name="歌名", type=MediaType.MUSIC.value, music_type=None) + assert _music._music_info_from_subscribe(subscribe).music_type == "recording" + meta = MetaMusic("原始歌名") + meta.original_name = "回写歌名" + assert meta.original_name == "回写歌名" + meta.original_name = None + assert meta.org_string is None + + +@pytest.mark.parametrize("missing_key", ["url", "cookie", "rss"]) +def test_expired_rss_without_required_site_fields_does_not_request(missing_key, monkeypatch): + """ + 站点配置不完整时不生成续期请求,避免把无效参数交给认证与 RSS 服务。 + """ + site = {"url": "https://site.test", "cookie": "cookie", "rss": "https://site.test/rss"} + site[missing_key] = None + request = Mock(side_effect=AssertionError("配置不完整不应续期")) + monkeypatch.setattr("app.chain.torrents.RssHelper", request) + object.__new__(TorrentsChain)._TorrentsChain__renew_rss_url("site.test", site) + request.assert_not_called() + + +def test_missing_notification_template_does_not_render_message(): + """ + 缺少消息模板时不把空值交给渲染器,也不生成空通知。 + """ + assert _render_notification_message(message=None, meta=None, mediainfo=None, torrentinfo=None, + transferinfo=None, kwargs={}) is None + + +def test_subscription_preparation_preserves_incomplete_context_without_recognition(): + """ + 缺少种子或元数据的上下文保留给后续判定,不启动媒体识别或累计失败。 + """ + context = Context() + result = _prepare_match_torrents({"site": [context]}) + assert result == {"site": [context]} + assert context.media_recognize_fail_count == 0 + + +def test_torrent_buttons_skip_missing_resource(): + """ + 空候选不生成下载按钮,自动选择按钮仍保留有效交互请求标识。 + """ + owner = object.__new__(MediaInteractionChain) + buttons = owner._create_torrent_buttons(NotificationChannel.Telegram, + SimpleNamespace(request_id="request"), [Context()], 1, 1) + assert len(buttons) == 1 + assert buttons[0][0]["callback_data"] == "media:request:download:0" diff --git a/tests/test_indexer_optional_metadata.py b/tests/test_indexer_optional_metadata.py new file mode 100644 index 000000000..1bcbe6ebb --- /dev/null +++ b/tests/test_indexer_optional_metadata.py @@ -0,0 +1,100 @@ +""" +验证站点索引、字幕与缓存适配器对缺失配置及可选后端的处理。 +""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from app.application.rss import RssHelper +from app.domain.context import Context, TorrentInfo +from app.modules.indexer import IndexerModule +from app.modules.indexer.spider.hddolby import HddolbySpider +from app.modules.subtitle import SubtitleModule +from app.modules.themoviedb.cache import TmdbCache + + +@pytest.mark.parametrize("context", [Context(), Context(torrent_info=TorrentInfo(site=1)), Context(torrent_info=TorrentInfo(site=2))]) +def test_indexer_incomplete_subtitle_metadata_does_not_request(context, monkeypatch): + """ + 缺少资源、域名或详情页时,索引字幕入口不访问爬虫。 + """ + query = SimpleNamespace(get_sync=Mock(side_effect=[SimpleNamespace(domain=None)] if context.torrent_info and context.torrent_info.site == 1 else [SimpleNamespace(domain="https://site.test")])) + monkeypatch.setattr("app.modules.indexer.get_configured_site_query_service", Mock(return_value=query)) + helper = SimpleNamespace(get_indexer=Mock(return_value={"parser": "mTorrent"})) + monkeypatch.setattr("app.modules.indexer.SitesHelper", Mock(return_value=helper)) + spider = Mock(side_effect=AssertionError("详情信息缺失不能查询字幕")) + monkeypatch.setattr("app.modules.indexer.MTorrentSpider", spider) + assert object.__new__(IndexerModule).site_subtitle_links(context) is None + spider.assert_not_called() + + +def test_hddolby_unconfigured_search_does_not_open_connection(monkeypatch): + """ + 未配置搜索地址时同步和异步入口均返回失败,不创建网络客户端。 + """ + spider = HddolbySpider({}) + request = Mock(side_effect=AssertionError("未配置地址不应发起请求")) + monkeypatch.setattr("app.modules.indexer.spider.hddolby.RequestUtils", request) + monkeypatch.setattr("app.modules.indexer.spider.hddolby.AsyncRequestUtils", request) + assert spider.search("Demo") == (True, []) + assert asyncio.run(spider.async_search("Demo")) == (True, []) + request.assert_not_called() + + +def test_subtitle_page_without_site_domain_still_handles_empty_page(monkeypatch): + """ + 有详情地址但无域名的站点可沿用页面查询,空页面返回空结果而非解析错误。 + """ + query = SimpleNamespace(get_sync=Mock(return_value=SimpleNamespace(domain=None))) + monkeypatch.setattr("app.modules.subtitle.get_configured_site_query_service", Mock(return_value=query)) + helper = Mock(side_effect=AssertionError("未配置域名不能查询站点索引")) + monkeypatch.setattr("app.modules.subtitle.SitesHelper", helper) + request = SimpleNamespace(get_res=Mock(return_value=SimpleNamespace(status_code=200, text=""))) + monkeypatch.setattr("app.modules.subtitle.RequestUtils", Mock(return_value=request)) + context = Context(torrent_info=TorrentInfo(site=1, page_url="https://site.test/details/1")) + assert object.__new__(SubtitleModule).site_subtitle_links(context) == [] + request.get_res.assert_called_once_with("https://site.test/details/1") + helper.assert_not_called() + + +def test_rss_link_without_site_configuration_is_a_readable_error(monkeypatch): + """ + 没有站点及默认 RSS 配置时明确失败,不发送无法确定目标的请求。 + """ + helper = RssHelper() + monkeypatch.setattr(helper, "rss_link_conf", {}) + http = Mock() + browser = Mock() + monkeypatch.setattr("app.application.rss._require_rss_ports", Mock(return_value=(http, browser, Mock()))) + assert helper.get_rss_link("https://site.test", "cookie", "ua") == ("", "获取RSS链接失败:未找到站点配置 https://site.test") + assert http.mock_calls == [] + assert browser.mock_calls == [] + + +def test_tmdb_cache_without_file_backend_does_not_persist(): + """ + 文件后端尚未装配时恢复和保存均停止,保留内存缓存而不访问空对象。 + """ + cache = object.__new__(TmdbCache) + cache._cache = SimpleNamespace(is_redis=Mock(return_value=False)) + cache._file_cache = None + cache._legacy_file_cache = None + assert cache._restore() is None + assert cache.save(force=True) is None + + +@pytest.mark.parametrize("value", [{}, [], "invalid", None]) +def test_tmdb_cache_invalid_entry_returns_miss(value, monkeypatch): + """ + 空字典与非字典缓存条目均按未命中处理,不把无效类型交给识别调用方。 + """ + cache = object.__new__(TmdbCache) + cache._cache = SimpleNamespace(get=Mock(return_value=value), is_redis=Mock(return_value=False)) + cache._file_cache = None + cache._legacy_file_cache = None + cache._expires_at = {} + monkeypatch.setattr(cache, "_TmdbCache__get_key", Mock(return_value="demo")) + assert cache.get(SimpleNamespace()) == {} diff --git a/tests/test_monitor_lifecycle.py b/tests/test_monitor_lifecycle.py index e3798190b..7ae5e5325 100644 --- a/tests/test_monitor_lifecycle.py +++ b/tests/test_monitor_lifecycle.py @@ -14,6 +14,7 @@ import pytest from app.foundation.singleton import SingletonClass from app.monitor.monitor import Monitor from app.monitor.recovery import RecoveryExecutor, RecoveryState +from app.monitor.snapshot import SnapshotStore from app.startup.initializers.monitor import init_monitor, stop_monitor @@ -45,6 +46,28 @@ def _build_monitor() -> Monitor: return monitor +def test_remote_monitor_missing_file_count_uses_initial_interval(monkeypatch): + """ + 旧远端快照的空文件计数按零处理,监控启动仍注册有效轮询与健康检查任务。 + """ + monitor = _build_monitor() + monitor._store = MagicMock() + monitor._store.load.return_value = {"version": SnapshotStore.VERSION, "file_count": None} + directory = SimpleNamespace(download_path="/remote/inbox", library_path="/remote/library", + monitor_type="monitor", storage="smb", monitor_mode=None) + helper = SimpleNamespace(get_download_dirs=MagicMock(return_value=[directory])) + monkeypatch.setattr("app.monitor.monitor.DirectoryHelper", MagicMock(return_value=helper)) + monkeypatch.setattr("app.monitor.monitor.MessageHelper", MagicMock()) + scheduler = MagicMock() + monkeypatch.setattr("app.monitor.monitor.BackgroundScheduler", MagicMock(return_value=scheduler)) + assert monitor._Monitor__initialize_monitors() is True + jobs = scheduler.add_job.call_args_list + polling = next(call for call in jobs if call.args[0] == monitor.polling_observer) + assert polling.kwargs["minutes"] == SnapshotStore.adjust_interval(0) + assert polling.kwargs["kwargs"]["storage"] == "smb" + scheduler.start.assert_called_once() + + def _blocking_watcher(mon_path: Path): """ 构造只有显式 release 后才退出的 watcher 与控制事件。 diff --git a/tests/test_mypy_policy_gate.py b/tests/test_mypy_policy_gate.py new file mode 100644 index 000000000..fdcf7b40f --- /dev/null +++ b/tests/test_mypy_policy_gate.py @@ -0,0 +1,195 @@ +""" +验证类型例外不能增加、转移或通过配置和本地类型桩掩盖。 +""" + +import copy +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from scripts.architecture import mypy_policy + + +@pytest.fixture +def policy_root(tmp_path: Path) -> Path: + """ + 隔离宿主源码和配置,门禁测试不读取或修改运行时数据。 + """ + (tmp_path / "app").mkdir() + (tmp_path / "mypy.ini").write_text("[mypy]\nstrict = True\nfiles = app\n", encoding="utf-8") + return tmp_path + + +def _source(root: Path, source: str, name: str = "example.py") -> None: + """ + 写入待扫描的独立宿主文件。 + """ + (root / "app" / name).write_text(source, encoding="utf-8") + + +def _policy(root: Path) -> dict: + """ + 为测试中的已知例外建立有原因的初始登记。 + """ + ignores, stubs = mypy_policy.collect(root) + return { + "configuration": mypy_policy.configuration(root), + "reasons": {"reviewed": "测试中的既有第三方兼容接口"}, + "ignores": [{**entry, "reason": "reviewed"} for entry in ignores], + "stubs": [{"path": path, "sha256": digest, "reason": "reviewed"} for path, digest in stubs.items()], + } + + +def test_policy_accepts_only_exact_reviewed_ignore(policy_root: Path) -> None: + """ + 空白和注释调整不消耗新的例外额度。 + """ + source = "def run(value: str) -> str: # type: ignore[override]\n return value\n" + _source(policy_root, source) + policy = _policy(policy_root) + _source(policy_root, "\n# 已核对的接口。\n" + source) + assert mypy_policy.compare(policy_root, policy, *mypy_policy.collect(policy_root)) == ([], []) + + +@pytest.mark.parametrize("replacement", [ + "def other(value: str) -> str: # type: ignore[override]\n return value\n", + "def run(value: int) -> str: # type: ignore[override]\n return str(value)\n", + "def run(value: str) -> str: # type: ignore[return-value]\n return value\n", +]) +def test_policy_rejects_reusing_ignore_for_another_contract(policy_root: Path, replacement: str) -> None: + """ + 方法、参数合同或错误码变化不能复用相同数量的旧例外。 + """ + _source(policy_root, "def run(value: str) -> str: # type: ignore[override]\n return value\n") + policy = _policy(policy_root) + _source(policy_root, replacement) + regressions, _ = mypy_policy.compare(policy_root, policy, *mypy_policy.collect(policy_root)) + assert any("新增或变化的类型忽略" in message for message in regressions) + + +def test_new_file_and_duplicate_ignore_cannot_reuse_allowance(policy_root: Path) -> None: + """ + 新文件和同一函数内重复的忽略均独立阻断,不能借用既有条目。 + """ + source = "def run() -> str:\n return 1 # type: ignore[return-value]\n" + _source(policy_root, source) + policy = _policy(policy_root) + _source(policy_root, source, "new.py") + _source(policy_root, source.replace(" return 1", " value = 2 # type: ignore[return-value]\n return 1")) + regressions, _ = mypy_policy.compare(policy_root, policy, *mypy_policy.collect(policy_root)) + assert len(regressions) == 2 + + +@pytest.mark.parametrize("source", [ + "def run() -> str:\n return 1 # type: ignore\n", + "# mypy: ignore-errors\ndef run():\n return 1\n", + "from typing import no_type_check\n@no_type_check\ndef run():\n return 1\n", + "from typing import no_type_check as unchecked\n@unchecked\ndef run():\n return 1\n", + "import typing as t\n@t.no_type_check\ndef run():\n return 1\n", + "# type: ignore[override]\ndef run():\n return 1\n", +]) +def test_policy_rejects_broad_suppression(policy_root: Path, source: str) -> None: + """ + 文件级忽略、无错误码忽略和 no_type_check 的常见写法均不能进入宿主。 + """ + _source(policy_root, source) + with pytest.raises(ValueError): + mypy_policy.collect(policy_root) + + +@pytest.mark.parametrize("config", [ + "[mypy]\nstrict = False\nfiles = app\n", + "[mypy]\nstrict = True\nfiles = app/old.py\n", + "[mypy]\nstrict = True\nfiles = app\nexclude = app/new.py\n", + "[mypy]\nstrict = True\nfiles = app\n[mypy-app.new]\nignore_errors = True\n", +]) +def test_policy_locks_full_configuration(policy_root: Path, config: str) -> None: + """ + 降低严格程度、缩小文件范围和新增模块豁免必须被门禁拒绝。 + """ + policy = _policy(policy_root) + (policy_root / "mypy.ini").write_text(config, encoding="utf-8") + regressions, _ = mypy_policy.compare(policy_root, policy, *mypy_policy.collect(policy_root)) + assert regressions == ["mypy 配置发生变化:检查范围和依赖类型边界需要复核"] + + +def test_new_or_changed_local_stubs_are_rejected(policy_root: Path) -> None: + """ + 类型桩不可以悄悄覆盖供应方真实签名,仓库外层的新类型桩也必须扫描。 + """ + stub = policy_root / "app" / "site.pyi" + stub.write_text("def run() -> str: ...\n", encoding="utf-8") + policy = _policy(policy_root) + stub.write_text("def run() -> int: ...\n", encoding="utf-8") + (policy_root / "vendor.pyi").write_text("run: object\n", encoding="utf-8") + regressions, _ = mypy_policy.compare(policy_root, policy, *mypy_policy.collect(policy_root)) + assert len(regressions) == 2 + assert all("本地类型桩" in message for message in regressions) + + +def test_policy_reduction_preserves_reasons_and_cannot_add_entries(policy_root: Path) -> None: + """ + 债务消除必须固化低水位,写入器只保留原本已登记且仍存在的条目。 + """ + _source(policy_root, "def run() -> str:\n return 1 # type: ignore[return-value]\n") + policy = _policy(policy_root) + _source(policy_root, "def run() -> str:\n return 'ok'\n") + current = mypy_policy.collect(policy_root) + regressions, stale = mypy_policy.compare(policy_root, policy, *current) + assert not regressions and len(stale) == 1 + reduced = mypy_policy.reduce_policy(policy, *current) + assert reduced["ignores"] == [] + assert reduced["reasons"] == {} + assert policy["ignores"] + assert mypy_policy.compare(policy_root, reduced, *current) == ([], []) + + +def test_write_refuses_growth_and_preserves_registry(policy_root: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """ + --write 不得把新增忽略写进登记表,失败时原文件内容保持完整。 + """ + policy = _policy(policy_root) + path = policy_root / "policy.json" + content = json.dumps(policy) + path.write_text(content, encoding="utf-8") + _source(policy_root, "def run() -> str:\n return 1 # type: ignore[return-value]\n") + monkeypatch.setattr(mypy_policy, "PROJECT_ROOT", policy_root) + monkeypatch.setattr(sys, "argv", ["mypy_policy.py", "--write", "--policy", str(path)]) + assert mypy_policy.main() == 1 + assert path.read_text(encoding="utf-8") == content + + +def test_missing_reason_is_rejected(policy_root: Path) -> None: + """ + 即使指纹未变化,没有说明兼容原因的例外仍然无效。 + """ + _source(policy_root, "def run() -> str:\n return 1 # type: ignore[return-value]\n") + policy = copy.deepcopy(_policy(policy_root)) + policy["reasons"].clear() + regressions, _ = mypy_policy.compare(policy_root, policy, *mypy_policy.collect(policy_root)) + assert len(regressions) == 1 and "缺少登记原因" in regressions[0] + + +def test_default_mypy_scope_rejects_new_host_file(policy_root: Path) -> None: + """ + 实际执行 mypy,确认新文件自动纳入检查且返回值类型错误会阻断。 + """ + _source(policy_root, "def run() -> str:\n return 1\n", "new.py") + result = subprocess.run([sys.executable, "-m", "mypy", "--no-incremental", "--no-pretty"], + cwd=policy_root, capture_output=True, text=True, check=False) + assert result.returncode == 1 + assert "app/new.py:2: error:" in result.stdout + assert "[return-value]" in result.stdout + + +def test_host_diagnostic_budget_remains_zero() -> None: + """ + 已清零的宿主不得重新添加错误数量预算,兼容问题只能经过精确登记。 + """ + root = Path(__file__).resolve().parents[1] + assert json.loads((root / "tests/fixtures/architecture/mypy-baseline.json").read_text()) == {} + workflow = (root / ".github/workflows/test.yml").read_text() + assert "python scripts/architecture/mypy_policy.py" in workflow diff --git a/tests/test_notification_optional_payloads.py b/tests/test_notification_optional_payloads.py new file mode 100644 index 000000000..fa3809739 --- /dev/null +++ b/tests/test_notification_optional_payloads.py @@ -0,0 +1,135 @@ +""" +验证通知渠道对缺失资源、附件类型及可选实例的兼容处理。 +""" + +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from app.domain.context import Context, MediaInfo, TorrentInfo +from app.modules.dingtalk import DingTalkModule +from app.modules.discord import DiscordModule +from app.modules.feishu.module import FeishuModule +from app.modules.qqbot.module import QQBotModule +from app.modules.qqbot.qqbot import QQBot +from app.modules.slack.slack import Slack +from app.modules.synologychat.synologychat import SynologyChat +from app.modules.vocechat import VoceChatModule +from app.modules.vocechat.vocechat import VoceChat +from app.modules.wechat import WechatModule +from app.modules.wechat.wechatbot import WeChatBot +from app.modules.wechatclawbot import WechatClawBotModule +from app.schemas.message import Message, MessageResponse +from app.schemas.types import NotificationChannel + + +@pytest.mark.parametrize("client_class", [QQBot, SynologyChat, VoceChat, WeChatBot, Slack]) +def test_torrent_notifications_skip_missing_torrent_and_preserve_valid_items(client_class, monkeypatch): + """ + 列表中的缺失资源不阻断有效候选发送,也不会把空上下文渲染为一条种子。 + """ + client = object.__new__(client_class) + sent = Mock(return_value=True) + if client_class is QQBot: + monkeypatch.setattr(client, "send_msg", sent) + elif client_class is SynologyChat: + client._webhook_url = "https://chat.test" + client._token = "test" + monkeypatch.setattr(client, "_SynologyChat__send_request", sent) + elif client_class is VoceChat: + client._client = Mock() + monkeypatch.setattr(client, "_VoceChat__send_request", sent) + elif client_class is WeChatBot: + monkeypatch.setattr(client, "_send_markdown", sent) + else: + client._client = SimpleNamespace(chat_postMessage=sent) + valid = Context(torrent_info=TorrentInfo(title="Demo.2026.1080p", site_name="ExampleSite", size=1024, seeders=3)) + assert client.send_torrents_msg([Context(), valid], userid="1", title="候选") is True + assert sent.call_count == 1 + assert "ExampleSite" in str(sent.call_args) + assert str(sent.call_args).count("ExampleSite") == 1 + + +def test_dingtalk_missing_torrent_does_not_hide_later_candidate(monkeypatch): + """ + 钉钉 Markdown 列表跳过空资源后继续展示可点击的有效候选。 + """ + module = object.__new__(DingTalkModule) + sent = Mock() + monkeypatch.setattr(module, "_post_list_message", sent) + message = Message(title="候选") + module.post_torrents_message(message, [Context(), Context(torrent_info=TorrentInfo( + title="Demo", page_url="https://site.test/details/1", + ))]) + sent.assert_called_once_with(message, "2. [Demo](https://site.test/details/1)") + + +@pytest.mark.parametrize(("module_class", "scheme", "method", "module_path"), [ + (DiscordModule, "discord", "download_discord_file_bytes", "app.modules.discord"), + (QQBotModule, "qq", "download_qq_file_bytes", "app.modules.qqbot.module"), +]) +@pytest.mark.parametrize("payload", [b"attachment", "invalid text", None]) +def test_attachment_downloads_require_bytes(module_class, scheme, method, module_path, payload, monkeypatch): + """ + 附件传输仅返回原始字节,错误文本或空响应不会进入语音与文件处理流程。 + """ + module = object.__new__(module_class) + monkeypatch.setattr(module, "get_config", Mock(return_value=SimpleNamespace(name="source"))) + request = SimpleNamespace(get_res=Mock(return_value=SimpleNamespace(content=payload))) + monkeypatch.setattr(f"{module_path}.RequestUtils", Mock(return_value=request)) + result = getattr(module, method)(f"{scheme}://file/https://cdn.test/file", "source") + assert result == (payload if isinstance(payload, bytes) else None) + request.get_res.assert_called_once_with("https://cdn.test/file") + + +@pytest.mark.parametrize("reference", ["wxwork://media_id/image", "wxbot://image/image"]) +@pytest.mark.parametrize("payload", ["data:image/png;base64,c2FmZQ==", b"invalid"]) +def test_wechat_images_only_expose_data_url_text(reference, payload, monkeypatch): + """ + 应用与机器人图片下载结果必须是文本地址,字节结果不能作为 data URL 外泄。 + """ + module = object.__new__(WechatModule) + client = SimpleNamespace(download_media_to_data_url=Mock(return_value=payload), download_image_to_data_url=Mock(return_value=payload)) + monkeypatch.setattr(module, "get_config", Mock(return_value=SimpleNamespace(name="source"))) + monkeypatch.setattr(module, "get_instance", Mock(return_value=client)) + assert module.download_wechat_image_to_data_url(reference, "source") == (payload if isinstance(payload, str) else None) + assert module._commands_enabled(None) is False + + +@pytest.mark.parametrize("module_class", [VoceChatModule, WechatClawBotModule]) +def test_notification_without_active_client_does_not_send(module_class, monkeypatch): + """ + 配置存在而客户端尚未激活时,列表通知停止发送,不访问可选实例属性。 + """ + module = object.__new__(module_class) + monkeypatch.setattr(module, "get_configs", Mock(return_value={"source": SimpleNamespace(name="source")})) + monkeypatch.setattr(module, "check_message", Mock(return_value=True)) + instance = Mock(return_value=None) + monkeypatch.setattr(module, "get_instance", instance) + module.post_medias_message(Message(title="媒体"), [MediaInfo(title="Demo")]) + assert instance.call_count == 1 + + +def test_feishu_invalid_stream_state_does_not_finalize_card(monkeypatch): + """ + 非字典流式卡片状态不能被当作可关闭的消息,避免发送错误的卡片 ID。 + """ + module = object.__new__(FeishuModule) + module._channel = NotificationChannel.Feishu + client = Mock() + monkeypatch.setattr(module, "get_instance", client) + response = MessageResponse(channel=NotificationChannel.Feishu, metadata={"feishu_streaming": ["invalid"]}) + assert module.finalize_message(response) is False + client.assert_not_called() + + +@pytest.mark.parametrize(("manifest", "expected"), [("{\"features\": {}}", {"features": {}}), ([], None)]) +def test_slack_manifest_export_requires_object(manifest, expected): + """ + Slack 清单支持 JSON 文本与对象,错误集合类型不会进入命令更新流程。 + """ + client = object.__new__(Slack) + client._app_id = "app" + client._manifest_client = SimpleNamespace(apps_manifest_export=Mock(return_value={"ok": True, "manifest": manifest})) + assert client._export_manifest() == expected diff --git a/tests/test_observability.py b/tests/test_observability.py index 697d0e3bc..b564f015d 100644 --- a/tests/test_observability.py +++ b/tests/test_observability.py @@ -224,7 +224,7 @@ def test_legacy_facades_record_public_and_private_hits() -> None: assert MoviePilotServerHelper._has_header({}, "X-Test") is False assert inspect.iscoroutinefunction(MoviePilotServerHelper.async_subscribe_done) assert str(inspect.signature(MoviePilotServerHelper.async_subscribe_done)) == ( - "(payload: Dict[str, Any])" + "(payload: Dict[str, Any]) -> httpx2.Response | None" ) hits = [ diff --git a/tests/test_storage_missing_metadata.py b/tests/test_storage_missing_metadata.py new file mode 100644 index 000000000..d9014a1d6 --- /dev/null +++ b/tests/test_storage_missing_metadata.py @@ -0,0 +1,122 @@ +""" +验证存储项缺少路径或名称时不会访问远端、删除本地数据或构造错误目标。 +""" + +from pathlib import Path +from unittest.mock import Mock + +import pytest + +from app.modules.filemanager.storages import StorageBase +from app.modules.filemanager.storages.alipan import AliPan +from app.modules.filemanager.storages.alist import Alist +from app.modules.filemanager.storages.local import LocalStorage +from app.modules.filemanager.storages.rclone import Rclone +from app.modules.filemanager.storages.smb import SMB +from app.schemas.file import FileItem + + +@pytest.mark.parametrize("storage_class", [Alist, LocalStorage, Rclone, SMB]) +@pytest.mark.parametrize("missing_path", [None, ""]) +def test_missing_path_operations_stop_before_io(storage_class, missing_path, tmp_path, monkeypatch): + """ + 空路径项不能被当作当前目录或远端根目录执行查询、传输、重命名及删除。 + """ + storage = storage_class() + item = FileItem(storage=storage.schema.value, type="file", path=missing_path) + query = Mock(side_effect=AssertionError("空路径不应触发查询")) + monkeypatch.setattr(storage, "get_item", query) + if isinstance(storage, SMB): + monkeypatch.setattr(storage, "_check_connection", Mock()) + assert storage.detail(item) is None + assert storage.delete(item) is False + assert storage.rename(item, "renamed.mkv") is False + assert storage.upload(item, tmp_path / "source.mkv") is None + if isinstance(storage, LocalStorage): + with pytest.raises(ValueError, match="缺少路径"): + storage.download(item, tmp_path) + assert storage.copy(item, tmp_path, "copy.mkv") is False + assert storage.move(item, tmp_path, "move.mkv") is False + assert storage.link(item, tmp_path / "hardlink.mkv") is False + assert storage.softlink(item, tmp_path / "symlink.mkv") is False + else: + assert storage.download(item, tmp_path) is None + if isinstance(storage, (Alist, Rclone)): + assert storage.list(item) == [] + assert storage.create_folder(item, "child") is None + assert storage.copy(item, tmp_path, "copy.mkv") is False + assert storage.move(item, tmp_path, "move.mkv") is False + if isinstance(storage, Alist): + assert storage.get_parent(item) is None + assert storage.copy_item(item, tmp_path, "copy.mkv") is None + query.assert_not_called() + assert list(tmp_path.iterdir()) == [] + + +@pytest.mark.parametrize("storage_class", [AliPan, Alist, Rclone, SMB]) +def test_cloud_backends_report_unsupported_links(storage_class, tmp_path): + """ + 不支持链接的存储明确返回失败,调用方不能把空返回值误判为已完成。 + """ + storage = storage_class() + item = FileItem(storage=storage.schema.value, type="file", path="/source.mkv") + assert storage.softlink(item, tmp_path / "symlink.mkv") is False + if not isinstance(storage, SMB): + assert storage.link(item, tmp_path / "hardlink.mkv") is False + assert list(tmp_path.iterdir()) == [] + + +def test_alipan_incomplete_metadata_does_not_query_or_create(monkeypatch): + """ + 阿里云盘的目录创建、详情及复制拒绝缺失必要元数据的输入。 + """ + storage = AliPan() + request = Mock(side_effect=AssertionError("不完整文件项不应触发 API")) + monkeypatch.setattr(storage, "_request_api", request) + query = Mock(side_effect=AssertionError("不完整文件项不应查询目标")) + monkeypatch.setattr(storage, "get_item", query) + item = FileItem(storage=storage.schema.value, type="file", path=None) + assert storage.detail(item) is None + assert storage.create_folder(item, "child") is None + assert storage.copy(item, Path("/target"), "copy.mkv") is False + request.assert_not_called() + query.assert_not_called() + + +def test_missing_snapshot_path_preserves_previous_files(monkeypatch): + """ + 缺少目录路径的快照输入保留旧文件索引,避免把未查询到的数据清成空目录。 + """ + storage = LocalStorage() + item = FileItem(storage=storage.schema.value, type="dir", path=None) + files = {"/keep.mkv": {"size": 123}} + children = {} + previous = {} + query = Mock(side_effect=AssertionError("空路径不应遍历目录")) + monkeypatch.setattr(storage, "_snapshot_list", query) + assert StorageBase.get_parent(storage, item) is None + StorageBase._snapshot_remove_deleted_children(storage, item, [], files, children, previous) + StorageBase._snapshot_file(storage, item, files, children, previous, None, 5) + assert files == {"/keep.mkv": {"size": 123}} + query.assert_not_called() + + +def test_rclone_config_without_content_writes_empty_file(tmp_path, monkeypatch): + """ + 缺省配置内容保存为空文本,未提供路径时不会尝试打开文件。 + """ + storage = Rclone() + monkeypatch.setattr(StorageBase, "set_config", Mock()) + storage.set_config({}) + config_path = tmp_path / "nested" / "rclone.conf" + storage.set_config({"filepath": str(config_path), "content": None}) + assert config_path.read_text(encoding="utf-8") == "" + + +@pytest.mark.parametrize("payload", [{}, {"Name": None}, {"Name": 123}]) +def test_rclone_listing_rejects_item_without_name(payload): + """ + 远端列表中的无名项不能被投影成当前目录或名称为 None 的文件。 + """ + with pytest.raises(ValueError, match="缺少名称"): + Rclone()._Rclone__get_rcloneitem(payload) diff --git a/tests/test_subscribe_paged_search.py b/tests/test_subscribe_paged_search.py index 00a7f71ab..4a4c22584 100644 --- a/tests/test_subscribe_paged_search.py +++ b/tests/test_subscribe_paged_search.py @@ -82,6 +82,22 @@ def _missing(scope, item): subscribe=item, meta=build_subscribe_meta(item), mediainfo=scope.media, mediakey="tmdb:1")[1] +def test_full_pack_candidate_without_torrent_cannot_submit_download(subscription, monkeypatch): + """ + 整包候选缺少种子信息时不比较优先级或提交下载,保留原始缺集范围。 + """ + scope = subscription + monkeypatch.setattr(scope.chain, "subscription_repository", SimpleNamespace()) + context = Context(meta_info=MetaInfo("Example Show S01"), media_info=scope.media) + missing = {"tmdb:1": {1: NotExistMediaInfo(season=1, episodes=[1], total_episode=1, start_episode=1)}} + downloads, remaining = scope.chain._SubscribeChain__download_best_version_with_full_pack_first( + contexts=[context], no_exists=missing, subscribe=scope.initial, mediakey="tmdb:1", + ) + assert downloads == [] + assert remaining == missing + scope.download.download_single.assert_not_called() + + def test_paged_search_keeps_new_missing_episodes_in_completion(subscription, monkeypatch): """ 搜索中扩展总集数后,下载结算保留新缺集,检查阶段可先同步旧范围。 diff --git a/tests/test_transfer_history_legacy_compat.py b/tests/test_transfer_history_legacy_compat.py index 99b62b047..64b057f14 100644 --- a/tests/test_transfer_history_legacy_compat.py +++ b/tests/test_transfer_history_legacy_compat.py @@ -43,7 +43,7 @@ def test_legacy_transfer_history_oper_preserves_exact_write_abi() -> None: assert public_methods == {"add_success", "add_fail", "add_force"} assert str(inspect.signature(oper_type.add_force)) == ( - "(self, **kwargs) -> app.application.history.contracts.TransferHistorySnapshot | None" + "(self, **kwargs: Any) -> app.application.history.contracts.TransferHistorySnapshot | None" ) assert str(inspect.signature(oper_type.add_success)) == ( "(self, fileitem: app.schemas.file.FileItem, mode: str, " diff --git a/tests/test_type_gate.py b/tests/test_type_gate.py index ff0b13025..11f822a05 100644 --- a/tests/test_type_gate.py +++ b/tests/test_type_gate.py @@ -1,5 +1,5 @@ """ -渐进式 mypy 门禁配置与可执行性测试。 +全量宿主 mypy 门禁配置与可执行性测试。 """ import configparser @@ -13,7 +13,7 @@ MYPY_CONFIG = PROJECT_ROOT / "mypy.ini" def test_mypy_gate_has_explicit_strict_scope_without_global_ignore() -> None: """ - 门禁必须列出治理文件,且不能用 app 级 ignore_errors 消音。 + 门禁默认覆盖全部宿主和治理脚本,且不能用 app 级 ignore_errors 消音。 """ parser = configparser.ConfigParser() parser.read(MYPY_CONFIG, encoding="utf-8") @@ -25,34 +25,20 @@ def test_mypy_gate_has_explicit_strict_scope_without_global_ignore() -> None: } assert settings.getboolean("strict") is True - assert "app/runtime/event/contracts.py" in governed_files - assert "app/runtime/extensions/module/contracts.py" in governed_files - assert "app/runtime/extensions/module/dispatcher.py" in governed_files - assert "app/runtime/extensions/module/quality.py" in governed_files - assert "app/runtime/event/errors.py" in governed_files - assert "app/application/scheduling.py" in governed_files - assert "scripts/architecture/async_blocking.py" in governed_files - assert "app/startup/composition/context.py" in governed_files - assert "app/startup/composition/configuration.py" in governed_files - assert "app/db/adapters/download.py" in governed_files - assert "app/db/adapters/workflow.py" in governed_files - assert "app/application/workflow.py" in governed_files - assert "app/api/context.py" in governed_files - assert "app/db/base.py" in governed_files - assert "app/db/uow.py" in governed_files - assert { - "app/runtime/tasks.py", - "app/startup/lifecycle/components.py", - "app/scheduler/registry.py", - } <= governed_files - assert len(governed_files) >= 37 - assert any(path.startswith("app/domain/") for path in governed_files) + # app 目录递归治理,新文件无需逐个添加,保留既有脚本并检查新门禁自身。 + assert governed_files == { + "app", + "scripts/architecture/async_blocking.py", + "scripts/architecture/mypy_policy.py", + } + assert settings.getboolean("ignore_missing_imports") is False + assert settings["exclude"] == r"^app/plugins(?:/|$)" assert "ignore_errors" not in MYPY_CONFIG.read_text(encoding="utf-8") def test_mypy_governed_scope_passes() -> None: """ - 使用锁定到开发依赖的 mypy 执行当前严格文件清单。 + 使用锁定到开发依赖的 mypy 执行全部宿主与治理脚本。 """ result = subprocess.run( [sys.executable, "-m", "mypy", "--config-file", "mypy.ini"], diff --git a/tests/test_typing_runtime_boundaries.py b/tests/test_typing_runtime_boundaries.py new file mode 100644 index 000000000..28cd9a4cf --- /dev/null +++ b/tests/test_typing_runtime_boundaries.py @@ -0,0 +1,184 @@ +""" +覆盖类型收敛过程中明确化的外部响应、可选资源及动态模块边界。 +""" + +import asyncio +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, Mock + +import pytest + +from app.adapters.network import doh +from app.adapters.system.fsproxy import FileSystemProxy +from app.agent.llm.capability import OpenAIChatAudioProvider +from app.agent.llm.helper import LLMHelper +from app.agent.mcp import _SseMcpSession +from app.agent.tools.impl.apply_patch import ApplyPatchTool +from app.agent.tools.impl.edit_file import EditFileTool +from app.agent.tools.impl.write_file import WriteFileTool +from app.chain.anilist import AniListChain +from app.modules.bangumi import BangumiModule +from app.schemas.agent import AgentMcpServerConfig + + +@pytest.mark.parametrize(("answer", "expected"), [ + (None, None), ([], None), ([None], None), ([{"data": 123}], None), + ([{"data": "203.0.113.7"}], "203.0.113.7"), +]) +def test_doh_json_only_accepts_text_address(answer, expected, monkeypatch): + """ + DNS JSON 缺少答案或返回错误类型时视为未解析,合法地址原样返回。 + """ + response = MagicMock(status=200) + response.read.return_value = json.dumps({"Answer": answer}).encode() + response.__enter__.return_value = response + request = Mock(return_value=response) + monkeypatch.setattr(doh.urllib.request, "urlopen", request) + assert doh.doh_query_json("resolver.test", "example.test") == expected + assert request.call_count == 1 + + +@pytest.mark.parametrize("operation", ["request", "stream", "read"]) +def test_fsproxy_without_worker_fails_before_pipe_access(operation, monkeypatch): + """ + 代理没有进程或标准管道时报告通信错误,不能把缺失资源当作成功结果。 + """ + proxy = FileSystemProxy() + monkeypatch.setattr(proxy, "_ensure_worker", Mock()) + with pytest.raises(BrokenPipeError, match="进程未启动"): + if operation == "request": + proxy._request("stat", {"path": "/unused"}) + elif operation == "stream": + proxy._request_stream({}, None, None) + else: + proxy._read_line() + proxy.close() + + +def test_fsproxy_worker_without_pipe_is_closed(monkeypatch): + """ + 子进程创建成功但管道缺失时立即收尾,不留下不可用的代理进程。 + """ + process = Mock(stdin=None, stdout=None) + proxy = FileSystemProxy() + shutdown = Mock() + monkeypatch.setattr(proxy, "_shutdown", shutdown) + monkeypatch.setattr("app.adapters.system.fsproxy.subprocess.Popen", Mock(return_value=process)) + with pytest.raises(BrokenPipeError, match="管道创建失败"): + proxy._ensure_worker() + assert shutdown.call_count == 2 + + +@pytest.mark.parametrize(("audio", "expected"), [ + (None, None), ({"data": None}, None), ({"data": 123}, None), ({"data": "encoded-audio"}, "encoded-audio"), +]) +def test_audio_extra_requires_string_payload(audio, expected): + """ + 供应方扩展字段中的音频仅接受编码字符串,避免非文本载荷流入文件解码。 + """ + message = SimpleNamespace(model_extra={"audio": audio}) + assert OpenAIChatAudioProvider._extract_audio_data(message) == expected + + +@pytest.mark.parametrize(("payload", "expected"), [ + ({"type": "text", "text": 12}, ""), + ({"type": "text", "text": "正文"}, "正文"), + ({"text": "普通正文"}, "普通正文"), + ([{"type": "text", "text": 12}, {"type": "text", "text": "正文"}], "正文"), +]) +def test_llm_text_projection_discards_non_text_content(payload, expected): + """ + 混合响应块中的非字符串 text 不会泄漏为正文或导致拼接失败。 + """ + assert LLMHelper.extract_text_content(payload) == expected + + +def test_sse_mcp_uninitialized_stream_is_an_explicit_session_error(monkeypatch): + """ + SSE 会话尚无事件流时不能发起初始化或请求,并保留明确可诊断的错误。 + """ + session = _SseMcpSession(AgentMcpServerConfig(id="sse", name="SSE", transport="sse", url="https://mcp.test")) + monkeypatch.setattr(session, "_post", AsyncMock()) + with pytest.raises(RuntimeError, match="事件流未初始化"): + asyncio.run(session._read_endpoint()) + with pytest.raises(RuntimeError, match="事件流未初始化"): + asyncio.run(session.request("tools/list")) + + +@pytest.mark.parametrize(("server_url", "expected"), [(None, None), ("https://mcp.test/sse", "https://mcp.test/messages")]) +def test_sse_mcp_endpoint_requires_server_url(server_url, expected): + """ + 相对 SSE 投递地址必须绑定配置的服务 URL,缺失时拒绝生成错误地址。 + """ + session = _SseMcpSession(AgentMcpServerConfig(id="sse", name="SSE", transport="sse", url=server_url)) + + async def events(): + """ + 提供服务端声明的相对消息投递地址。 + """ + yield {"event": "endpoint", "data": "/messages"} + + session._event_iterator = events() + if expected is None: + with pytest.raises(RuntimeError, match="缺少 URL"): + asyncio.run(session._read_endpoint()) + else: + assert asyncio.run(session._read_endpoint()) == expected + + +@pytest.mark.parametrize("tool_class", [EditFileTool, WriteFileTool, ApplyPatchTool]) +def test_file_tools_reject_missing_resolved_path(tool_class, monkeypatch, tmp_path): + """ + 权限边界未解析出路径时拒绝编辑、写入及补丁,不能落到当前目录。 + """ + tool = tool_class(session_id="missing-path", user_id="admin") + tool.set_agent_context({"is_admin": True}) + monkeypatch.setattr(tool_class, "_check_local_file_access", AsyncMock(return_value=(None, None))) + path = tmp_path / "safe.txt" + if tool_class is EditFileTool: + result = asyncio.run(tool.run(str(path), "before", "after")) + elif tool_class is WriteFileTool: + result = asyncio.run(tool.run(str(path), "after")) + else: + result = asyncio.run(tool.run(f"*** Begin Patch\n*** Add File: {path}\n+after\n*** End Patch")) + assert "无法解析" in result + assert not path.exists() + + +def test_apply_patch_without_plan_never_writes(monkeypatch, tmp_path): + """ + 补丁规划未产生结果时整体失败,不尝试执行部分文件操作。 + """ + tool = ApplyPatchTool(session_id="missing-plan", user_id="admin") + monkeypatch.setattr(ApplyPatchTool, "_plan_operations", AsyncMock(return_value=(None, None))) + path = tmp_path / "safe.txt" + result = asyncio.run(tool.run(f"*** Begin Patch\n*** Add File: {path}\n+after\n*** End Patch")) + assert result == "错误:补丁规划失败" + assert not path.exists() + + +@pytest.mark.parametrize("result", [None, [], {"id": 1}]) +def test_anilist_detail_requires_object_response(result, monkeypatch): + """ + 动态模块详情只接受字典,错误列表与空响应不会被投影成媒体详情。 + """ + chain = AniListChain() + monkeypatch.setattr(chain, "run_module", Mock(return_value=result)) + monkeypatch.setattr(chain, "async_run_module", AsyncMock(return_value=result)) + expected = result if isinstance(result, dict) else None + assert chain.info(1) == expected + assert asyncio.run(chain.async_info(1)) == expected + + +def test_bangumi_uninitialized_dependencies_report_lifecycle_error(monkeypatch): + """ + 未激活的 Bangumi 客户端与刮削器返回生命周期错误,不触发外部调用。 + """ + module = object.__new__(BangumiModule) + monkeypatch.setattr(module, "bangumiapi", None) + monkeypatch.setattr(module, "scraper", None) + with pytest.raises(RuntimeError, match="尚未初始化"): + module._require_api() + with pytest.raises(RuntimeError, match="尚未初始化"): + module._require_scraper() diff --git a/tests/test_workflow_optional_boundaries.py b/tests/test_workflow_optional_boundaries.py new file mode 100644 index 000000000..c3456bf72 --- /dev/null +++ b/tests/test_workflow_optional_boundaries.py @@ -0,0 +1,127 @@ +""" +验证工作流可空集合、动作缺失和事件载荷的执行边界。 +""" + +import ast +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from pydantic import BaseModel + +from app.chain.workflow import WorkflowChain, WorkflowExecutor +from app.schemas.download import DownloadTask +from app.schemas.event import RecommendSourceEventData +from app.schemas.workflow import Action, ActionContext +from app.workflow import WorkflowManager +from app.workflow.actions.fetch_downloads import FetchDownloadsAction +from app.workflow.actions.fetch_medias import FetchMediasAction +from app.workflow.actions.fetch_rss import FetchRssAction + + +@pytest.mark.parametrize(("operator", "left", "right", "expected"), [ + (ast.NotEq(), 1, 2, True), (ast.GtE(), 2, 2, True), + (ast.Lt(), 1, 2, True), (ast.LtE(), 2, 2, True), + (ast.LtE(), None, 2, False), +]) +def test_workflow_comparisons_return_boolean(operator, left, right, expected): + """ + 条件比较保留相等边界,并将不可比较的空值判为不匹配。 + """ + assert WorkflowExecutor.compare_values(left, operator, right) is expected + + +def test_workflow_condition_rejects_dictionary_unpacking(): + """ + 条件字典仅允许显式键值,不执行字典展开或任意对象表达式。 + """ + executor = object.__new__(WorkflowExecutor) + expression = ast.parse("{**payload}", mode="eval").body + with pytest.raises(ValueError, match="不支持字典展开"): + executor.evaluate_condition_node(expression) + assert executor.evaluate_condition_node(ast.parse("{'key': 1}", mode="eval").body) == {"key": 1} + + +def test_workflow_execute_event_ignores_non_mapping_payload(monkeypatch): + """ + 无工作流字段的事件不能触发执行,也不会因空载荷抛异常。 + """ + process = Mock() + monkeypatch.setattr(WorkflowChain, "process", process) + object.__new__(WorkflowChain).event_process(SimpleNamespace(event_data=None)) + process.assert_not_called() + + +def test_workflow_conditions_accept_model_payload_and_reject_invalid_payload(): + """ + 事件模型使用公开字段参与条件匹配,非字典载荷不能意外满足条件。 + """ + class Payload(BaseModel): + """ + 提供事件条件使用的结构化业务字段。 + """ + + status: str + + manager = object.__new__(WorkflowManager) + workflow = SimpleNamespace(event_conditions={"status": "ready"}) + assert manager._check_event_conditions(workflow, SimpleNamespace(event_data=Payload(status="ready"))) is True + assert manager._check_event_conditions(workflow, SimpleNamespace(event_data=["ready"])) is False + + +def test_workflow_missing_action_is_a_failed_attempt(): + """ + 动作不存在时单次尝试返回失败,避免索引不存在的动作构造器。 + """ + manager = object.__new__(WorkflowManager) + manager._actions = {} + context = ActionContext() + result = manager._execute_action_once(1, Action(id="missing", type=None), context, {}, {}, None) + assert result.success is False + assert result.message == "未找到动作" + assert result.context is context + + +def test_download_action_without_hash_does_not_query_downloader(monkeypatch): + """ + 缺少下载标识的旧上下文不会扩大成下载器全量查询。 + """ + chain = Mock(side_effect=AssertionError("缺少标识不应查询下载器")) + monkeypatch.setattr("app.workflow.actions.fetch_downloads.ActionChain", chain) + monkeypatch.setattr("app.workflow.actions.fetch_downloads.runtime_stop_state.is_workflow_stopped", Mock(return_value=False)) + context = ActionContext(downloads=[DownloadTask(download_id=None)]) + FetchDownloadsAction("downloads").execute(1, {}, context) + chain.assert_not_called() + assert context.downloads[0].download_id is None + + +def test_rss_action_initializes_nullable_torrent_collection(monkeypatch): + """ + 可空的旧资源集合能够接收新 RSS 结果,未提供体积时按未知大小处理。 + """ + helper = SimpleNamespace(parse=Mock(return_value=[{"title": "Demo.2026", "enclosure": "https://site.test/torrent"}])) + monkeypatch.setattr("app.workflow.actions.fetch_rss.RssHelper", Mock(return_value=helper)) + monkeypatch.setattr("app.workflow.actions.fetch_rss.runtime_stop_state.is_workflow_stopped", Mock(return_value=False)) + context = ActionContext(torrents=None) + result = FetchRssAction("rss").execute(1, {"url": "https://site.test/rss", "match_media": False}, context) + assert result is context + assert len(context.torrents) == 1 + assert context.torrents[0].torrent_info.size == 0 + + +def test_media_action_initializes_nullable_media_collection(monkeypatch): + """ + 旧上下文媒体列表为空时仍接收榜单结果,结构化推荐事件维持兼容。 + """ + chain = Mock() + chain.tmdb_trending.return_value = [{"title": "Demo"}] + monkeypatch.setattr("app.workflow.actions.fetch_medias.RecommendChain", Mock(return_value=chain)) + monkeypatch.setattr("app.workflow.actions.fetch_medias.eventmanager.send_event", Mock(return_value=SimpleNamespace( + event_data=RecommendSourceEventData(extra_sources=[]), + ))) + monkeypatch.setattr("app.workflow.actions.fetch_medias.runtime_stop_state.is_workflow_stopped", Mock(return_value=False)) + context = ActionContext(medias=None) + action = FetchMediasAction("medias") + assert action.execute(1, {"sources": ["recommend/tmdb_trending"]}, context) is context + assert [media.title for media in context.medias] == ["Demo"] + assert action.success is True diff --git a/uv.lock b/uv.lock index 77dab8584..c02fdee54 100644 --- a/uv.lock +++ b/uv.lock @@ -1764,9 +1764,12 @@ dev = [ { name = "ruff" }, { name = "types-aiofiles" }, { name = "types-dateparser" }, + { name = "types-psutil" }, { name = "types-python-dateutil" }, { name = "types-pytz" }, { name = "types-pyyaml" }, + { name = "types-regex" }, + { name = "types-tqdm" }, ] runtime-free-threaded = [ { name = "bcrypt", version = "5.0.0", source = { registry = "https://pypi.org/simple" } }, @@ -1890,9 +1893,12 @@ dev = [ { name = "ruff", specifier = "~=0.16.4" }, { name = "types-aiofiles", specifier = "~=25.1.0.20260518" }, { name = "types-dateparser", specifier = "~=1.4.3.20260907" }, + { name = "types-psutil", specifier = "~=7.2.2.20260906" }, { name = "types-python-dateutil", specifier = "~=2.9.0.20260807" }, { name = "types-pytz", specifier = "~=2026.5.0.20261006" }, { name = "types-pyyaml", specifier = "~=6.0.12.20260906" }, + { name = "types-regex", specifier = "~=2026.9.29.20261017" }, + { name = "types-tqdm", specifier = "~=4.70.0.20260906" }, ] runtime-free-threaded = [ { name = "bcrypt", specifier = "~=5.0.0" }, @@ -1920,6 +1926,13 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/23/74/50964e19b2be867b7f9d191cd18e8ffaed3303028d94303fdc23759f4f89/moviepilot_rust-0.3.9-cp314-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:63f7ed8ad85c308072977ac53c97844e7d930372493853092c68d7544eac0d38", size = 5730756, upload-time = "2026-10-20T05:48:07.794Z" }, { url = "https://files.pythonhosted.org/packages/3a/02/ceda2e6cb607eb401f527ec1d13840e271f8d01b84f41e44bdcc8d6000bf/moviepilot_rust-0.3.9-cp314-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:98c98bd8ff89a0254d4c586f33e3f7e7223611525bd60fea2959cae2fd16363c", size = 5954183, upload-time = "2026-10-20T05:48:09.693Z" }, { url = "https://files.pythonhosted.org/packages/15/2c/f01cb31fd62ce21d207b33ea6ea3005d89c326c3237f60b52f4f1a0b1f68/moviepilot_rust-0.3.9-cp314-abi3-win_amd64.whl", hash = "sha256:a40362d9ac729cc1b4e3567609ad700722b192748c5c33618e35ca53c0289382", size = 5520554, upload-time = "2026-10-20T05:48:11.625Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/3038623acb0522a9f04541f284545656dce75a8d3d9b161f5da785531a42/moviepilot_rust-0.3.9-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:78bff5beaf9564f66275e74abfa5c0f095e8019b9c2ebbafa5151f5b78cd4e7e", size = 5494495, upload-time = "2026-10-20T05:48:13.862Z" }, + { url = "https://files.pythonhosted.org/packages/9a/35/647212eba221293c79427e60a00e1491b3e1a2ba0815f6041d7fdf813aa3/moviepilot_rust-0.3.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54bde70d09165a67aeb267ff8614d9c9170e274dc067af6572ee6603d714a8e5", size = 5386670, upload-time = "2026-10-20T05:48:15.955Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cb/63730ee1679a5348013640d3328eb42d70bb26eb1b3bfb779a973703683b/moviepilot_rust-0.3.9-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bacee0b6adfc3c833dac3b21f77830f8758962535e81cc0d01b325028b07f152", size = 5518623, upload-time = "2026-10-20T05:48:17.816Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ad/56dc8830f89831c9fc09babb15749c27d77d11e60376e93f991f587c79a9/moviepilot_rust-0.3.9-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dddb686771c64b884afd8ab27c680d12d26ec77cb326e504cce1622f0f6d9abe", size = 5713813, upload-time = "2026-10-20T05:48:19.738Z" }, + { url = "https://files.pythonhosted.org/packages/31/51/fc3a23ddeb34580b973ebdd31daf20886848ccdffad332ae9daa61ff4f49/moviepilot_rust-0.3.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c45b0a787463f2cfd196cdce1d59890988505a36f7866048cdfa3cd880668ea", size = 5728684, upload-time = "2026-10-20T05:48:21.873Z" }, + { url = "https://files.pythonhosted.org/packages/a6/dc/2130a5e68f0e61dbbd824cbb5f50a98012ee8c4f7f271ea61e453b7ee3c4/moviepilot_rust-0.3.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1cdc56c0978eff23821de33e98848d2a9519d91dcdf10df3811634585c2b0f12", size = 5953102, upload-time = "2026-10-20T05:48:23.785Z" }, + { url = "https://files.pythonhosted.org/packages/3c/eb/2bfc9351648a73319bafc16777b7efee7a3e20cc38c0aaf902fca613cc75/moviepilot_rust-0.3.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9f9e9738e851ee9a1aea96e0b9afc5ed4d5401c0ba170f95f8e08b483f048375", size = 5519802, upload-time = "2026-10-20T05:48:25.556Z" }, ] [[package]] @@ -3268,6 +3281,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/5a/4fc8ea9c86e10465e820f920bc4983137c356b75b12b81574bb9b4d42605/types_dateparser-1.4.3.20260907-py3-none-any.whl", hash = "sha256:344c979fe7c0633f0a4f5098d7b26402a4969806ef7fc22f902d303f061593e1", size = 25729, upload-time = "2026-09-07T06:43:18.074Z" }, ] +[[package]] +name = "types-psutil" +version = "7.2.2.20260906" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/97/0a/f48b9b0ab5ba8599fd117309e345152bf82c756c72be2ff0f635780c2792/types_psutil-7.2.2.20260906.tar.gz", hash = "sha256:93abf22cf9a62b915f724e433bde702995ac274865425fd4a76d1d9b5828da1a", size = 27396, upload-time = "2026-09-06T06:35:24.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/8d/81a86107486e1c23f5ca21bea1e9e5b18a50754242e7a0b82f41f9ae8384/types_psutil-7.2.2.20260906-py3-none-any.whl", hash = "sha256:db00baf7f96c3f63421c4d3d68d373923094a0dfddf13e922c5c5fbc42488159", size = 33383, upload-time = "2026-09-06T06:35:23.575Z" }, +] + [[package]] name = "types-python-dateutil" version = "2.9.0.20260807" @@ -3295,6 +3317,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/c0/fc0644b7ddcfb969e95845837143cb5173ddd6e06ee4ba5fc493cd9329b7/types_pyyaml-6.0.12.20260906-py3-none-any.whl", hash = "sha256:bca893ff0d51df5c9053137d5d0e6ccd36e939a196356f1d5c16372422f5137b", size = 21282, upload-time = "2026-09-06T06:35:34.372Z" }, ] +[[package]] +name = "types-regex" +version = "2026.9.29.20261017" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/bb/15d3c5f2f21ebf040430970e57bf8c438701e21ecd155607860e1dcff4f2/types_regex-2026.9.29.20261017.tar.gz", hash = "sha256:1eb0d21f77331e0f87268eeb1a75c4ac8025665a211c6e2e8ba084504b1c299f", size = 13971, upload-time = "2026-10-17T06:20:04.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/2d/4a7cde6131caf872b33d25a66ffd938ebd10758e84527eb3c456cb558e77/types_regex-2026.9.29.20261017-py3-none-any.whl", hash = "sha256:d0bc5d8f44d2039f40f52b272e672266ba6cc1710956c6c7f5045000fea433b8", size = 11194, upload-time = "2026-10-17T06:20:03.623Z" }, +] + +[[package]] +name = "types-tqdm" +version = "4.70.0.20260906" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/92/517d71d5a399cd2f8ead3b4328d987262478f22fbbe7f8312360d4c321d0/types_tqdm-4.70.0.20260906.tar.gz", hash = "sha256:6581dc996152a87d51ce2ea169169187ffc0a96dacdb8adbdef29cbee8c1e631", size = 19309, upload-time = "2026-09-06T06:35:31.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/ea/d12697988ab2c672877796ecaea69666e2379ee7b40f01f4540250d5a97b/types_tqdm-4.70.0.20260906-py3-none-any.whl", hash = "sha256:c3b88c694928515d601f73fa6646aa43adc0ef735bedbeebf0cc543f6a201ae8", size = 25181, upload-time = "2026-09-06T06:35:30.931Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0"