From b914e8b6e49ee5c477c90b688240cea424307549 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Tue, 25 Aug 2026 17:02:29 +0800 Subject: [PATCH] refactor: strengthen architecture CI gates and runtime contracts --- .github/workflows/test.yml | 5 +- app/api/context.py | 6 +- app/api/data.py | 1 - app/api/dependencies/agent.py | 4 +- app/api/dependencies/subscription.py | 15 +- app/api/dependencies/workflow.py | 6 +- app/api/endpoints/agent.py | 96 +- app/api/endpoints/anilist.py | 8 +- app/api/endpoints/anthropic.py | 22 +- app/api/endpoints/auth.py | 8 +- app/api/endpoints/bangumi.py | 10 +- app/api/endpoints/dashboard.py | 26 +- app/api/endpoints/discover.py | 8 +- app/api/endpoints/douban.py | 8 +- app/api/endpoints/download.py | 44 +- app/api/endpoints/history.py | 30 +- app/api/endpoints/llm.py | 6 +- app/api/endpoints/login.py | 20 +- app/api/endpoints/mcp.py | 12 +- app/api/endpoints/media.py | 38 +- app/api/endpoints/mediaserver.py | 30 +- app/api/endpoints/message.py | 34 +- app/api/endpoints/mfa.py | 58 +- app/api/endpoints/music.py | 24 +- app/api/endpoints/notification.py | 6 +- app/api/endpoints/openai.py | 34 +- app/api/endpoints/plugin.py | 77 +- app/api/endpoints/recommend.py | 12 +- app/api/endpoints/search.py | 20 +- app/api/endpoints/site.py | 55 +- app/api/endpoints/storage.py | 22 +- app/api/endpoints/subscribe.py | 82 +- app/api/endpoints/system.py | 122 +- app/api/endpoints/tmdb.py | 17 +- app/api/endpoints/torrent.py | 20 +- app/api/endpoints/transfer.py | 35 +- app/api/endpoints/user.py | 20 +- app/api/endpoints/webhook.py | 8 +- app/api/endpoints/workflow.py | 36 +- app/api/presentation/sse.py | 1 - app/api/response.py | 1 - app/api/servarr.py | 27 +- app/api/servcookie.py | 8 +- app/application/chain/context.py | 2 + app/application/messaging/message.py | 18 +- app/chain/__init__.py | 29 +- app/chain/_contracts.py | 96 + app/chain/_interaction.py | 2 + app/chain/_messaging.py | 12 +- app/chain/_music.py | 13 +- app/chain/_recognition.py | 6 +- app/chain/_transfer.py | 32 +- app/chain/anilist.py | 2 +- app/chain/bangumi.py | 4 +- app/chain/dashboard.py | 4 +- app/chain/douban.py | 2 +- app/chain/download.py | 81 +- app/chain/interaction.py | 14 +- app/chain/media.py | 32 +- app/chain/mediaserver.py | 19 +- app/chain/message.py | 21 +- app/chain/recommend.py | 16 +- app/chain/scraping.py | 33 +- app/chain/search.py | 47 +- app/chain/site.py | 46 +- app/chain/storage.py | 6 +- app/chain/subscribe.py | 142 +- app/chain/system.py | 16 +- app/chain/tmdb.py | 8 +- app/chain/torrents.py | 42 +- app/chain/transfer.py | 130 +- app/chain/user.py | 11 +- app/chain/workflow.py | 20 +- app/main.py | 13 +- app/modules/filemanager/storages/__init__.py | 12 +- app/modules/filemanager/storages/alipan.py | 16 +- app/modules/filemanager/storages/alist.py | 21 +- app/modules/filemanager/storages/local.py | 16 +- app/modules/filemanager/storages/rclone.py | 10 +- app/modules/filemanager/storages/smb.py | 14 +- app/modules/filemanager/storages/u115.py | 23 +- app/runtime/config.py | 58 +- app/runtime/stop.py | 112 + app/scheduler.py | 67 +- app/startup/composition/context.py | 7 +- app/startup/initializers/agent.py | 13 +- app/startup/initializers/database.py | 2 +- app/startup/initializers/domain.py | 2 +- app/startup/initializers/managed_resources.py | 1 - app/startup/initializers/modules.py | 209 +- app/startup/initializers/plugins.py | 58 +- app/startup/initializers/routers.py | 1 + app/startup/initializers/workflow.py | 1 - app/startup/lifecycle/__init__.py | 37 +- app/workflow/__init__.py | 16 +- app/workflow/actions/__init__.py | 6 +- app/workflow/actions/add_download.py | 10 +- app/workflow/actions/add_subscribe.py | 15 +- app/workflow/actions/fetch_downloads.py | 9 +- app/workflow/actions/fetch_medias.py | 14 +- app/workflow/actions/fetch_rss.py | 13 +- app/workflow/actions/fetch_torrents.py | 13 +- app/workflow/actions/filter_medias.py | 9 +- app/workflow/actions/filter_torrents.py | 11 +- app/workflow/actions/invoke_plugin.py | 5 +- app/workflow/actions/note.py | 2 +- app/workflow/actions/scan_file.py | 11 +- app/workflow/actions/scrape_file.py | 7 +- app/workflow/actions/send_event.py | 5 +- app/workflow/actions/send_message.py | 5 +- app/workflow/actions/transfer_file.py | 11 +- docs/refactor/backend-architecture-review.md | 6 +- docs/rules/05-architecture.md | 5 + pyproject.toml | 9 + scripts/architecture/coverage_ratchet.py | 85 + scripts/architecture/ruff_ratchet.py | 88 + .../architecture/coverage-baseline.json | 12 + .../architecture/dependency-baseline.json | 89 +- .../fixtures/architecture/ruff-baseline.json | 2155 +++++++++++++++++ tests/test_chain_mixin_contracts.py | 87 + tests/test_quality_ratchets.py | 78 + tests/test_runtime_stop_state.py | 68 + uv.lock | 17 + 123 files changed, 4240 insertions(+), 1342 deletions(-) create mode 100644 app/chain/_contracts.py create mode 100644 app/runtime/stop.py create mode 100644 scripts/architecture/coverage_ratchet.py create mode 100644 scripts/architecture/ruff_ratchet.py create mode 100644 tests/fixtures/architecture/coverage-baseline.json create mode 100644 tests/fixtures/architecture/ruff-baseline.json create mode 100644 tests/test_chain_mixin_contracts.py create mode 100644 tests/test_quality_ratchets.py create mode 100644 tests/test_runtime_stop_state.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0c01a6c04..edef137d1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,6 +65,9 @@ jobs: - name: Check process runtime service locators run: uv run --locked --no-sync python scripts/architecture/service_locator.py + - name: Check Ruff diagnostic ratchet + run: uv run --locked --no-sync python scripts/architecture/ruff_ratchet.py + - name: Check mypy error ratchet run: uv run --locked --no-sync python scripts/architecture/mypy_ratchet.py @@ -108,7 +111,6 @@ jobs: run: uv run --locked --no-sync python tests/run.py --shard "${{ matrix.shard }}" coverage: - if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest name: Coverage Report timeout-minutes: 20 @@ -138,6 +140,7 @@ jobs: uv run --locked --no-sync python -m coverage report uv run --locked --no-sync python -m coverage json uv run --locked --no-sync python -m coverage xml + uv run --locked --no-sync python scripts/architecture/coverage_ratchet.py - name: Upload coverage report uses: actions/upload-artifact@v7 diff --git a/app/api/context.py b/app/api/context.py index 72bc9f022..fbf1d73f4 100644 --- a/app/api/context.py +++ b/app/api/context.py @@ -5,24 +5,24 @@ from typing import cast from fastapi import Depends, Request -from app.application.messaging.chat import AsyncAgentChatRepository, AsyncUnitOfWork -from app.application.outbox import AsyncOutboxTransaction from app.application.configuration import ( ApiRuntimeConfig, get_api_runtime_config_snapshot, ) +from app.application.messaging.chat import AsyncAgentChatRepository, AsyncUnitOfWork +from app.application.outbox import AsyncOutboxTransaction from app.application.subscription.delete import SubscribeDeletionRepository from app.application.subscription.identity import SubscribeIdentityDeletionRepository from app.application.subscription.mutation import ( SubscriptionHistoryMutationRepository, SubscriptionMutationRepository, ) +from app.runtime.tasks import TaskRegistry, get_task_registry from app.startup.composition.context import ( AgentChatRuntime, HostRuntime, SubscriptionRuntime, ) -from app.runtime.tasks import TaskRegistry, get_task_registry def get_host_runtime(request: Request) -> HostRuntime: diff --git a/app/api/data.py b/app/api/data.py index 930a6a78a..36f8d44db 100644 --- a/app/api/data.py +++ b/app/api/data.py @@ -5,7 +5,6 @@ from __future__ import annotations from collections.abc import AsyncGenerator, Callable, Generator from typing import Any - SessionProvider = Callable[[], Generator[Any, None, None]] AsyncSessionProvider = Callable[[], AsyncGenerator[Any, None]] RepositoryFactory = Callable[[Any], Any] diff --git a/app/api/dependencies/agent.py b/app/api/dependencies/agent.py index cdfec0d8c..447612a93 100644 --- a/app/api/dependencies/agent.py +++ b/app/api/dependencies/agent.py @@ -4,15 +4,15 @@ from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession from app.api.context import ( - get_agent_chat_runtime, get_agent_chat_repository, + get_agent_chat_runtime, get_agent_chat_transaction, get_async_session, get_host_runtime, ) from app.application.messaging.chat import ( - AgentChatService, AgentChatPersistenceService, + AgentChatService, AsyncAgentChatRepository, AsyncUnitOfWork, ) diff --git a/app/api/dependencies/subscription.py b/app/api/dependencies/subscription.py index 065908b05..de9abba6a 100644 --- a/app/api/dependencies/subscription.py +++ b/app/api/dependencies/subscription.py @@ -9,25 +9,33 @@ from sqlalchemy.orm import Session from app.adapters.external.server import MoviePilotServerHelper from app.api.context import ( get_async_session, + get_background_task_registry, get_host_runtime, get_subscription_history_repository, get_subscription_outbox, get_subscription_repository, get_subscription_transaction, get_sync_session, + resolve_background_task_registry, ) from app.application.outbox import AsyncOutboxTransaction from app.application.scheduling import start_scheduler_job from app.application.servarr import ServarrSubscriptionService from app.application.subscription.delete import ( AsyncUnitOfWork as DeleteUnitOfWork, +) +from app.application.subscription.delete import ( DeleteSubscribeCommand, SubscribeDeletionRepository, ) -from app.application.subscription.identity import DeleteSubscriptionsByIdentityCommand -from app.application.subscription.identity import SubscribeIdentityDeletionRepository +from app.application.subscription.identity import ( + DeleteSubscriptionsByIdentityCommand, + SubscribeIdentityDeletionRepository, +) from app.application.subscription.mutation import ( AsyncUnitOfWork as MutationUnitOfWork, +) +from app.application.subscription.mutation import ( SubscriptionHistoryMutationRepository, SubscriptionMutationRepository, SubscriptionMutationService, @@ -36,10 +44,9 @@ from app.application.subscription.query import SubscriptionQueryService from app.application.subscription.search import SearchSubscriptionsCommand from app.runtime.events import eventmanager from app.runtime.log import logger +from app.runtime.tasks import TaskRegistry from app.schemas.types import EventType from app.startup.composition.context import HostRuntime -from app.api.context import get_background_task_registry, resolve_background_task_registry -from app.runtime.tasks import TaskRegistry async def _publish_subscribe_deleted( diff --git a/app/api/dependencies/workflow.py b/app/api/dependencies/workflow.py index 087f8f76d..995e1aa3b 100644 --- a/app/api/dependencies/workflow.py +++ b/app/api/dependencies/workflow.py @@ -16,7 +16,7 @@ from app.application.workflow import ( WorkflowQueryService, get_workflow_manager, ) -from app.runtime.config import global_vars +from app.runtime.stop import runtime_stop_state from app.startup.composition.context import HostRuntime @@ -36,7 +36,7 @@ def get_workflow_mutation_command( load_event=workflow_manager.load_workflow_events, remove_event=workflow_manager.remove_workflow_event, refresh_event=workflow_manager.update_workflow_event, - stop_running=global_vars.stop_workflow, + stop_running=runtime_stop_state.stop_workflow, delete_cache=lambda workflow_id: system_config.delete( f"WorkflowCache-{workflow_id}" ), @@ -52,7 +52,7 @@ def get_workflow_definition_command( return WorkflowDefinitionCommand( repository=runtime.workflow.repository(db), unit_of_work=runtime.persistence.async_transaction(db), - stop_running=global_vars.stop_workflow, + stop_running=runtime_stop_state.stop_workflow, async_delete_cache=lambda workflow_id: system_config.async_delete( f"WorkflowCache-{workflow_id}" ), diff --git a/app/api/endpoints/agent.py b/app/api/endpoints/agent.py index e2e6bcbb4..a03519bfb 100644 --- a/app/api/endpoints/agent.py +++ b/app/api/endpoints/agent.py @@ -7,8 +7,8 @@ import shutil import time import uuid from collections import deque -from queue import Empty, Queue from pathlib import Path +from queue import Empty, Queue from threading import Lock from typing import Any, AsyncIterator, Awaitable, Callable, Optional, Union @@ -16,15 +16,58 @@ import aiofiles from fastapi import Depends, File, Form, HTTPException, Request, UploadFile, status from fastapi.responses import FileResponse, StreamingResponse +from app.agent.contracts import ReplyMode, build_display_message +from app.agent.mcp import agent_mcp_manager +from app.agent.runtime_loader import get_moviepilot_agent_type +from app.api.dependencies.agent import ( + get_agent_chat_persistence, + get_agent_chat_service, +) +from app.api.dependencies.auth import get_current_active_user +from app.api.presentation.sse import build_sse_error_response, build_sse_response +from app.api.principal import ApiPrincipal +from app.api.response import ResponseAPIRouter +from app.application.agent import ( + get_running_agent_manager, + is_audio_input_available, + transcribe_audio, +) +from app.application.commands import get_command, get_commands +from app.application.configuration import get_api_runtime_config_snapshot +from app.application.messaging.agent import ( + agent_interaction_manager, + attach_web_agent_edit_queue, + attach_web_agent_message_queue, + build_agent_choice_button_rows, + create_web_agent_background_task, + detach_web_agent_edit_queue, + detach_web_agent_message_queue, + is_web_agent_message_for_user, + normalize_web_agent_button_rows, + parse_agent_choice_callback, +) +from app.application.messaging.chat import ( + AgentChatPersistenceService, + AgentChatRecord, + AgentChatService, + get_configured_agent_chat_persistence, + get_configured_agent_chat_service, +) +from app.application.messaging.router import has_pending_interaction +from app.application.security.user import get_configured_user_id_lookup +from app.chain.message import MessageChain from app.runtime.execution import run_in_threadpool +from app.runtime.localization import LocaleHelper +from app.runtime.log import logger +from app.runtime.stop import runtime_stop_state from app.schemas.agent import AgentChatDisplaySaveRequest as _SchemaAgentChatDisplaySaveRequest from app.schemas.agent import AgentChatSessionDetail as _SchemaAgentChatSessionDetail from app.schemas.agent import AgentChatSessionSummary as _SchemaAgentChatSessionSummary from app.schemas.agent import AgentChatUploadAttachment as _SchemaAgentChatUploadAttachment from app.schemas.agent import AgentMcpServerListData as _SchemaAgentMcpServerListData +from app.schemas.agent import AgentMcpServersSaveRequest as _SchemaAgentMcpServersSaveRequest from app.schemas.agent import AgentMcpServerTestRequest as _SchemaAgentMcpServerTestRequest from app.schemas.agent import AgentMcpServerTestResult as _SchemaAgentMcpServerTestResult -from app.schemas.agent import AgentMcpServersSaveRequest as _SchemaAgentMcpServersSaveRequest from app.schemas.agent import AgentSessionStopData as _SchemaAgentSessionStopData from app.schemas.agent import AgentWebCallbackData as _SchemaAgentWebCallbackData from app.schemas.agent import AgentWebCommandInfo as _SchemaAgentWebCommandInfo @@ -32,52 +75,7 @@ from app.schemas.message import AgentWebChatRequest as _SchemaAgentWebChatReques from app.schemas.message import AgentWebChoiceRequest as _SchemaAgentWebChoiceRequest from app.schemas.message import Message as _SchemaMessage from app.schemas.response import Response as _SchemaResponse -from app.api.response import ResponseAPIRouter -from app.api.presentation.sse import build_sse_error_response, build_sse_response -from app.agent.contracts import ReplyMode, build_display_message -from app.agent.mcp import agent_mcp_manager -from app.agent.runtime_loader import get_moviepilot_agent_type -from app.application.agent import ( - get_running_agent_manager, - is_audio_input_available, - transcribe_audio, -) -from app.chain.message import MessageChain -from app.application.commands import get_command, get_commands -from app.runtime.config import global_vars -from app.api.principal import ApiPrincipal -from app.api.dependencies.agent import ( - get_agent_chat_persistence, - get_agent_chat_service, -) -from app.api.dependencies.auth import get_current_active_user -from app.application.messaging.chat import ( - AgentChatRecord, - AgentChatPersistenceService, - AgentChatService, - get_configured_agent_chat_service, - get_configured_agent_chat_persistence, -) -from app.application.security.user import get_configured_user_id_lookup -from app.application.configuration import get_api_runtime_config_snapshot -from app.application.messaging.agent import ( - attach_web_agent_message_queue, - attach_web_agent_edit_queue, - create_web_agent_background_task, - detach_web_agent_message_queue, - detach_web_agent_edit_queue, - is_web_agent_message_for_user, -) -from app.application.messaging.agent import agent_interaction_manager -from app.application.messaging.agent import ( - build_agent_choice_button_rows, - normalize_web_agent_button_rows, - parse_agent_choice_callback, -) -from app.application.messaging.router import has_pending_interaction -from app.runtime.localization import LocaleHelper -from app.runtime.log import logger -from app.schemas.types import EventType, NotificationChannel +from app.schemas.types import NotificationChannel router = ResponseAPIRouter() @@ -2274,7 +2272,7 @@ async def _web_agent_stream_impl( {"session_id": session_id}, locale=locale, ) - while not global_vars.is_system_stopped: + while not runtime_stop_state.is_system_stopped: if await request.is_disconnected(): disconnected = True break diff --git a/app/api/endpoints/anilist.py b/app/api/endpoints/anilist.py index 6857920ab..29f4ab529 100644 --- a/app/api/endpoints/anilist.py +++ b/app/api/endpoints/anilist.py @@ -2,13 +2,13 @@ from typing import Annotated, Optional from fastapi import Depends, Query -from app.schemas.context import MediaPerson as _SchemaMediaPerson -from app.schemas.token import TokenPayload as _SchemaTokenPayload -from app.schemas.workflow import MediaInfo as _SchemaMediaInfo +from app.adapters.web.security.access import verify_token from app.api.response import ResponseAPIRouter from app.chain.anilist import AniListChain from app.domain.context import MediaInfo -from app.adapters.web.security.access import verify_token +from app.schemas.context import MediaPerson as _SchemaMediaPerson +from app.schemas.token import TokenPayload as _SchemaTokenPayload +from app.schemas.workflow import MediaInfo as _SchemaMediaInfo router = ResponseAPIRouter() diff --git a/app/api/endpoints/anthropic.py b/app/api/endpoints/anthropic.py index eaebd9c6d..eaba2d879 100644 --- a/app/api/endpoints/anthropic.py +++ b/app/api/endpoints/anthropic.py @@ -5,15 +5,15 @@ from typing import AsyncIterator, List, Optional from fastapi import APIRouter, Depends, Header, Security from fastapi.responses import JSONResponse -from app.schemas.openai import AnthropicErrorDetail as _SchemaAnthropicErrorDetail -from app.schemas.openai import AnthropicErrorResponse as _SchemaAnthropicErrorResponse -from app.schemas.openai import AnthropicMessagesRequest as _SchemaAnthropicMessagesRequest -from app.schemas.openai import AnthropicMessagesResponse as _SchemaAnthropicMessagesResponse -from app.schemas.openai import AnthropicTextBlock as _SchemaAnthropicTextBlock +from app.adapters.web.security.access import anthropic_api_key_header +from app.api.context import ( + get_background_task_registry_compat, + resolve_background_task_registry, +) from app.api.endpoints.openai import ( MODEL_ID, - _is_manager_unavailable, _is_manager_queue_full, + _is_manager_unavailable, _run_managed_agent, ) from app.api.openai_utils import ( @@ -24,12 +24,12 @@ from app.api.openai_utils import ( from app.api.presentation.sse import build_sse_response, encode_named_event from app.application.agent import get_running_agent_manager from app.application.configuration import get_api_runtime_config_snapshot -from app.adapters.web.security.access import anthropic_api_key_header -from app.api.context import ( - get_background_task_registry_compat, - resolve_background_task_registry, -) from app.runtime.tasks import TaskRegistry +from app.schemas.openai import AnthropicErrorDetail as _SchemaAnthropicErrorDetail +from app.schemas.openai import AnthropicErrorResponse as _SchemaAnthropicErrorResponse +from app.schemas.openai import AnthropicMessagesRequest as _SchemaAnthropicMessagesRequest +from app.schemas.openai import AnthropicMessagesResponse as _SchemaAnthropicMessagesResponse +from app.schemas.openai import AnthropicTextBlock as _SchemaAnthropicTextBlock ANTHROPIC_ERROR_RESPONSES = { 400: {"model": _SchemaAnthropicErrorResponse, "description": "请求格式错误"}, diff --git a/app/api/endpoints/auth.py b/app/api/endpoints/auth.py index 2a5227faf..3fc85c784 100644 --- a/app/api/endpoints/auth.py +++ b/app/api/endpoints/auth.py @@ -3,12 +3,12 @@ from typing import Any from fastapi import Depends, HTTPException from pydantic import BaseModel +from app.api.dependencies.auth import get_auth_service +from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter +from app.application.plugin.runtime import get_plugin_manager +from app.application.security.auth import AuthService, consume_plugin_auth_ticket from app.schemas.token import Token as _SchemaToken from app.schemas.user import AuthProviderInfo as _SchemaAuthProviderInfo -from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter -from app.application.security.auth import AuthService, consume_plugin_auth_ticket -from app.application.plugin.runtime import get_plugin_manager -from app.api.dependencies.auth import get_auth_service router = ResponseAPIRouter() diff --git a/app/api/endpoints/bangumi.py b/app/api/endpoints/bangumi.py index c3a594e4a..f1f9ca4f8 100644 --- a/app/api/endpoints/bangumi.py +++ b/app/api/endpoints/bangumi.py @@ -1,14 +1,14 @@ -from typing import List, Any, Optional +from typing import Any, List, Optional from fastapi import Depends -from app.schemas.context import MediaPerson as _SchemaMediaPerson -from app.schemas.token import TokenPayload as _SchemaTokenPayload -from app.schemas.workflow import MediaInfo as _SchemaMediaInfo +from app.adapters.web.security.access import verify_token from app.api.response import ResponseAPIRouter from app.chain.bangumi import BangumiChain from app.domain.context import MediaInfo -from app.adapters.web.security.access import verify_token +from app.schemas.context import MediaPerson as _SchemaMediaPerson +from app.schemas.token import TokenPayload as _SchemaTokenPayload +from app.schemas.workflow import MediaInfo as _SchemaMediaInfo router = ResponseAPIRouter() diff --git a/app/api/endpoints/dashboard.py b/app/api/endpoints/dashboard.py index ef2eba3d1..da8c46723 100644 --- a/app/api/endpoints/dashboard.py +++ b/app/api/endpoints/dashboard.py @@ -1,8 +1,20 @@ from pathlib import Path -from typing import Any, List, Optional, Annotated +from typing import Annotated, Any, List, Optional from fastapi import Depends +from app.adapters.system.host import SystemUtils +from app.adapters.web.security.access import verify_apitoken +from app.api.context import get_api_runtime_config, resolve_api_runtime_config +from app.api.dependencies.auth import get_current_active_superuser +from app.api.dependencies.history import get_dashboard_query_service +from app.api.response import ResponseAPIRouter +from app.application.configuration import ApiRuntimeConfig +from app.application.dashboard import DashboardQueryService +from app.application.directory import DirectoryHelper +from app.application.scheduling import get_scheduler +from app.chain.dashboard import DashboardChain +from app.chain.storage import StorageChain from app.runtime.execution import run_in_threadpool from app.schemas.dashboard import DashboardMemoryInfo as _SchemaDashboardMemoryInfo from app.schemas.dashboard import DashboardSystemInfo as _SchemaDashboardSystemInfo @@ -13,19 +25,7 @@ from app.schemas.dashboard import ScheduleProgress as _SchemaScheduleProgress from app.schemas.dashboard import Statistic as _SchemaStatistic from app.schemas.dashboard import Storage as _SchemaStorage from app.schemas.response import Response as _SchemaResponse -from app.api.response import ResponseAPIRouter -from app.chain.dashboard import DashboardChain -from app.chain.storage import StorageChain -from app.api.context import get_api_runtime_config, resolve_api_runtime_config -from app.application.configuration import ApiRuntimeConfig -from app.adapters.web.security.access import verify_apitoken -from app.api.dependencies.auth import get_current_active_superuser -from app.api.dependencies.history import get_dashboard_query_service -from app.application.dashboard import DashboardQueryService from app.schemas.types import StorageAction -from app.application.directory import DirectoryHelper -from app.application.scheduling import get_scheduler -from app.adapters.system.host import SystemUtils router = ResponseAPIRouter() diff --git a/app/api/endpoints/discover.py b/app/api/endpoints/discover.py index a899c600d..f578afc8f 100644 --- a/app/api/endpoints/discover.py +++ b/app/api/endpoints/discover.py @@ -2,17 +2,17 @@ from typing import Any, List, Optional from fastapi import Depends -from app.schemas.event import DiscoverMediaSource as _SchemaDiscoverMediaSource -from app.schemas.token import TokenPayload as _SchemaTokenPayload -from app.schemas.workflow import MediaInfo as _SchemaMediaInfo +from app.adapters.web.security.access import verify_token from app.api.response import ResponseAPIRouter from app.chain.bangumi import BangumiChain from app.chain.douban import DoubanChain from app.chain.tmdb import TmdbChain from app.runtime.events import eventmanager -from app.adapters.web.security.access import verify_token +from app.schemas.event import DiscoverMediaSource as _SchemaDiscoverMediaSource from app.schemas.event import DiscoverSourceEventData +from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.schemas.types import ChainEventType, MediaType +from app.schemas.workflow import MediaInfo as _SchemaMediaInfo router = ResponseAPIRouter() diff --git a/app/api/endpoints/douban.py b/app/api/endpoints/douban.py index 3931b8495..6dc51a212 100644 --- a/app/api/endpoints/douban.py +++ b/app/api/endpoints/douban.py @@ -2,14 +2,14 @@ from typing import Any, List, Optional from fastapi import Depends -from app.schemas.context import MediaPerson as _SchemaMediaPerson -from app.schemas.token import TokenPayload as _SchemaTokenPayload -from app.schemas.workflow import MediaInfo as _SchemaMediaInfo +from app.adapters.web.security.access import verify_token from app.api.response import ResponseAPIRouter from app.chain.douban import DoubanChain from app.domain.context import MediaInfo -from app.adapters.web.security.access import verify_token +from app.schemas.context import MediaPerson as _SchemaMediaPerson +from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.schemas.types import MediaType +from app.schemas.workflow import MediaInfo as _SchemaMediaInfo router = ResponseAPIRouter() diff --git a/app/api/endpoints/download.py b/app/api/endpoints/download.py index 8bb423adf..5ee8c3774 100644 --- a/app/api/endpoints/download.py +++ b/app/api/endpoints/download.py @@ -1,7 +1,26 @@ -from typing import Any, List, Annotated, Optional, Union +from typing import Annotated, Any, List, Optional, Union -from fastapi import Depends, Body +from fastapi import Body, Depends +from app.adapters.web.security.access import verify_token +from app.api.dependencies.auth import get_current_active_user +from app.api.dependencies.site import get_site_sync_query_service +from app.api.principal import ApiPrincipal +from app.api.response import ResponseAPIRouter +from app.application.configuration import get_configured_system_config +from app.application.directory import DirectoryHelper +from app.application.security.url import SecurityUtils +from app.application.site.query import ( + SiteQueryService, + get_configured_site_query_service, +) +from app.chain.download import DownloadChain +from app.chain.media import MediaChain +from app.domain.context import Context, MediaInfo, MusicInfo, SubtitleInfo, TorrentInfo +from app.domain.media import is_music_media_source, normalize_music_type +from app.domain.meta.metabase import MetaBase +from app.domain.meta.metamusic import MetaMusic +from app.domain.metainfo import MetaInfo from app.schemas.common import ServiceClientInfo as _SchemaServiceClientInfo from app.schemas.download import DownloadAddedData as _SchemaDownloadAddedData from app.schemas.download import DownloadDirectory as _SchemaDownloadDirectory @@ -13,24 +32,6 @@ from app.schemas.system import TorrentInfo as _SchemaTorrentInfo from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.schemas.transfer import DownloaderTorrent as _SchemaDownloaderTorrent from app.schemas.transfer import MusicInfo as _SchemaMusicInfo -from app.schemas.workflow import MediaInfo as _SchemaMediaInfo -from app.api.response import ResponseAPIRouter -from app.chain.download import DownloadChain -from app.chain.media import MediaChain -from app.domain.context import Context, MediaInfo, MusicInfo, SubtitleInfo, TorrentInfo -from app.domain.meta.metabase import MetaBase -from app.domain.meta.metamusic import MetaMusic -from app.domain.metainfo import MetaInfo -from app.adapters.web.security.access import verify_token -from app.api.principal import ApiPrincipal -from app.application.configuration import get_configured_system_config -from app.application.site.query import ( - SiteQueryService, - get_configured_site_query_service, -) -from app.api.dependencies.auth import get_current_active_user -from app.api.dependencies.site import get_site_sync_query_service -from app.application.directory import DirectoryHelper from app.schemas.types import ( MUSIC_ENTITY_RECORDING, MediaSource, @@ -38,8 +39,7 @@ from app.schemas.types import ( MusicTargetEntityType, SystemConfigKey, ) -from app.domain.media import is_music_media_source, normalize_music_type -from app.application.security.url import SecurityUtils +from app.schemas.workflow import MediaInfo as _SchemaMediaInfo router = ResponseAPIRouter() diff --git a/app/api/endpoints/history.py b/app/api/endpoints/history.py index 03cf85719..49f742c76 100644 --- a/app/api/endpoints/history.py +++ b/app/api/endpoints/history.py @@ -1,33 +1,21 @@ import time from collections.abc import Coroutine -from typing import List, Any, Callable, Optional +from typing import Any, Callable, List, Optional from fastapi import Depends -from app.schemas.common import BatchProgressKeyData as _SchemaBatchProgressKeyData -from app.schemas.common import ProgressKeyData as _SchemaProgressKeyData -from app.schemas.history import BatchTransferHistoryRedoRequest as _SchemaBatchTransferHistoryRedoRequest -from app.schemas.history import TransferHistory as _SchemaTransferHistory -from app.schemas.history import TransferHistoryPage as _SchemaTransferHistoryPage -from app.schemas.response import Response as _SchemaResponse -from app.schemas.token import TokenPayload as _SchemaTokenPayload -from app.schemas.history import DownloadHistory as _SchemaDownloadHistory -from app.api.response import ResponseAPIRouter +from app.adapters.web.security.access import verify_token from app.agent.contracts import ReplyMode -from app.application.agent import get_running_agent_manager from app.agent.prompt.transfer_redo import ( build_batch_manual_redo_prompt, build_manual_redo_prompt, ) -from app.runtime.config import global_vars from app.api.context import ( get_api_runtime_config, get_background_task_registry, resolve_api_runtime_config, resolve_background_task_registry, ) -from app.application.configuration import ApiRuntimeConfig -from app.adapters.web.security.access import verify_token from app.api.dependencies.auth import ( get_current_active_manage_user, get_current_active_superuser, @@ -37,14 +25,26 @@ from app.api.dependencies.history import ( get_history_query_service, get_transfer_history_mutation_command, ) -from app.runtime.progress import AsyncProgressHelper +from app.api.response import ResponseAPIRouter +from app.application.agent import get_running_agent_manager +from app.application.configuration import ApiRuntimeConfig from app.application.history import ( DownloadHistoryMutationCommand, HistoryQueryService, TransferHistoryMutationCommand, ) +from app.runtime.config import global_vars from app.runtime.log import logger +from app.runtime.progress import AsyncProgressHelper from app.runtime.tasks import TaskRegistry +from app.schemas.common import BatchProgressKeyData as _SchemaBatchProgressKeyData +from app.schemas.common import ProgressKeyData as _SchemaProgressKeyData +from app.schemas.history import BatchTransferHistoryRedoRequest as _SchemaBatchTransferHistoryRedoRequest +from app.schemas.history import DownloadHistory as _SchemaDownloadHistory +from app.schemas.history import TransferHistory as _SchemaTransferHistory +from app.schemas.history import TransferHistoryPage as _SchemaTransferHistoryPage +from app.schemas.response import Response as _SchemaResponse +from app.schemas.token import TokenPayload as _SchemaTokenPayload router = ResponseAPIRouter() diff --git a/app/api/endpoints/llm.py b/app/api/endpoints/llm.py index d8d972a79..65db4166e 100644 --- a/app/api/endpoints/llm.py +++ b/app/api/endpoints/llm.py @@ -3,11 +3,11 @@ from typing import Any, Dict, List, Optional, Union from fastapi import Depends, Request, Response from fastapi.responses import HTMLResponse +from app.agent.llm.gateway import resolve_llm_provider_runtime +from app.api.dependencies.auth import get_current_active_superuser_async +from app.api.response import ResponseAPIRouter from app.schemas.common import ManageRequest as _SchemaManageRequest from app.schemas.response import Response as _SchemaResponse -from app.api.response import ResponseAPIRouter -from app.api.dependencies.auth import get_current_active_superuser_async -from app.agent.llm.gateway import resolve_llm_provider_runtime router = ResponseAPIRouter() diff --git a/app/api/endpoints/login.py b/app/api/endpoints/login.py index 2689d2583..1ff682955 100644 --- a/app/api/endpoints/login.py +++ b/app/api/endpoints/login.py @@ -1,22 +1,22 @@ from datetime import timedelta -from typing import Any, List, Annotated +from typing import Annotated, Any, List from fastapi import Depends, Form, HTTPException, Request, Response -from fastapi.security import OAuth2PasswordRequestForm from fastapi.responses import JSONResponse +from fastapi.security import OAuth2PasswordRequestForm +from app.adapters.web.security.access import set_or_refresh_resource_token_cookie +from app.api.context import get_api_runtime_config, resolve_api_runtime_config +from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter +from app.application.configuration import ApiRuntimeConfig, get_configured_system_config +from app.application.image import WallpaperHelper +from app.application.security.token import create_access_token +from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module +from app.chain.user import MfaRequired, UserChain 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.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter -from app.chain.user import MfaRequired, UserChain -from app.adapters.web.security.access import set_or_refresh_resource_token_cookie -from app.application.security.token import create_access_token -from app.api.context import get_api_runtime_config, resolve_api_runtime_config -from app.application.configuration import ApiRuntimeConfig, get_configured_system_config -from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module -from app.application.image import WallpaperHelper from app.schemas.types import SystemConfigKey router = ResponseAPIRouter() diff --git a/app/api/endpoints/mcp.py b/app/api/endpoints/mcp.py index e8c7c34f0..50423c037 100644 --- a/app/api/endpoints/mcp.py +++ b/app/api/endpoints/mcp.py @@ -1,8 +1,13 @@ -from typing import List, Any, Dict, Annotated, Union +from typing import Annotated, Any, Dict, List, Union from fastapi import Depends, HTTPException, Request from fastapi.responses import JSONResponse, Response +from app.adapters.web.security.access import verify_apikey +from app.agent.tools.manager import moviepilot_tool_manager +from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter +from app.runtime.log import logger +from app.runtime.version import get_app_version from app.schemas.mcp import MCP_JSONRPC_REQUEST_SCHEMA as _SchemaMCP_JSONRPC_REQUEST_SCHEMA from app.schemas.mcp import McpJsonRpcError as _SchemaMcpJsonRpcError from app.schemas.mcp import McpJsonRpcResponse as _SchemaMcpJsonRpcResponse @@ -11,11 +16,6 @@ from app.schemas.mcp import McpToolInfo as _SchemaMcpToolInfo from app.schemas.mcp import ToolCallData as _SchemaToolCallData from app.schemas.mcp import ToolCallRequest as _SchemaToolCallRequest from app.schemas.response import Response as _SchemaResponse -from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter -from app.agent.tools.manager import moviepilot_tool_manager -from app.adapters.web.security.access import verify_apikey -from app.runtime.version import get_app_version -from app.runtime.log import logger router = ResponseAPIRouter() diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index 06513b494..4767a96d5 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -5,36 +5,36 @@ from uuid import UUID from fastapi import Depends, Query from pydantic import BeforeValidator +from app.adapters.web.security.access import verify_apitoken, verify_token +from app.api.dependencies.auth import ( + get_current_active_superuser, + get_current_active_user, +) +from app.api.response import ResponseAPIRouter +from app.application.configuration import get_api_runtime_config_snapshot +from app.chain.media import MediaChain +from app.chain.scraping import ScrapingChain +from app.chain.tmdb import TmdbChain +from app.domain.context import Context, MusicInfo +from app.domain.media import is_music_media_source, normalize_music_type, parse_media_source_selection +from app.domain.meta.metabase import MetaBase +from app.domain.meta.metamusic import MetaMusic +from app.domain.metainfo import MetaInfo, MetaInfoPath +from app.schemas.category import CategoryConfig from app.schemas.category import CategoryConfig as _SchemaCategoryConfig from app.schemas.category import MediaCategoryMap as _SchemaMediaCategoryMap from app.schemas.context import MediaEpisodeGroup as _SchemaMediaEpisodeGroup from app.schemas.context import MediaPerson as _SchemaMediaPerson from app.schemas.context import MediaSearchResults as _SchemaMediaSearchResults from app.schemas.context import MediaSeason as _SchemaMediaSeason +from app.schemas.event import MediaSourceInfo as _SchemaMediaSourceInfo +from app.schemas.media import normalize_media_source, resolve_media_identity from app.schemas.response import Response as _SchemaResponse from app.schemas.token import TokenPayload as _SchemaTokenPayload +from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource, MediaType from app.schemas.workflow import Context as _SchemaContext from app.schemas.workflow import FileItem as _SchemaFileItem from app.schemas.workflow import MediaInfo as _SchemaMediaInfo -from app.api.response import ResponseAPIRouter -from app.chain.media import MediaChain -from app.chain.scraping import ScrapingChain -from app.chain.tmdb import TmdbChain -from app.application.configuration import get_api_runtime_config_snapshot -from app.domain.context import Context, MusicInfo -from app.domain.meta.metabase import MetaBase -from app.domain.meta.metamusic import MetaMusic -from app.domain.metainfo import MetaInfo, MetaInfoPath -from app.adapters.web.security.access import verify_token, verify_apitoken -from app.api.dependencies.auth import ( - get_current_active_superuser, - get_current_active_user, -) -from app.schemas.category import CategoryConfig -from app.schemas.event import MediaSourceInfo as _SchemaMediaSourceInfo -from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource, MediaType -from app.domain.media import is_music_media_source, normalize_music_type, parse_media_source_selection -from app.schemas.media import normalize_media_source, resolve_media_identity router = ResponseAPIRouter() diff --git a/app/api/endpoints/mediaserver.py b/app/api/endpoints/mediaserver.py index f4858d286..cab60e530 100644 --- a/app/api/endpoints/mediaserver.py +++ b/app/api/endpoints/mediaserver.py @@ -2,32 +2,32 @@ from typing import Any, List, Optional from fastapi import Depends, HTTPException, status +from app.adapters.web.security.access import verify_token +from app.api.dependencies.history import get_mediaserver_query_service +from app.api.response import ResponseAPIRouter +from app.application.configuration import get_configured_system_config +from app.application.mediaserver import ( + MediaServerQueryService, + get_mediaserver_configs, +) +from app.chain.download import DownloadChain +from app.chain.mediaserver import MediaServerChain +from app.domain.context import MediaInfo +from app.domain.metainfo import MetaInfo from app.schemas.common import ServiceClientInfo as _SchemaServiceClientInfo +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 MediaServerLibrary as _SchemaMediaServerLibrary from app.schemas.mediaserver import MediaServerPlayData as _SchemaMediaServerPlayData from app.schemas.mediaserver import MediaServerPlayItem as _SchemaMediaServerPlayItem +from app.schemas.mediaserver import NotExistMediaInfo from app.schemas.mediaserver import NotExistMediaInfo as _SchemaNotExistMediaInfo from app.schemas.response import Response as _SchemaResponse from app.schemas.token import TokenPayload as _SchemaTokenPayload -from app.schemas.workflow import MediaInfo as _SchemaMediaInfo -from app.api.response import ResponseAPIRouter -from app.chain.download import DownloadChain -from app.chain.mediaserver import MediaServerChain -from app.domain.context import MediaInfo -from app.domain.metainfo import MetaInfo -from app.adapters.web.security.access import verify_token -from app.application.configuration import get_configured_system_config -from app.application.mediaserver import ( - MediaServerQueryService, - get_mediaserver_configs, -) -from app.api.dependencies.history import get_mediaserver_query_service -from app.schemas.mediaserver import NotExistMediaInfo from app.schemas.types import MediaSource, MediaType, SystemConfigKey -from app.schemas.media import build_media_key, resolve_media_identity +from app.schemas.workflow import MediaInfo as _SchemaMediaInfo router = ResponseAPIRouter() diff --git a/app/api/endpoints/message.py b/app/api/endpoints/message.py index 0fa6624ef..45f43fa0d 100644 --- a/app/api/endpoints/message.py +++ b/app/api/endpoints/message.py @@ -7,6 +7,23 @@ from typing import Annotated, Any, List, Optional, Protocol, Union from fastapi import Depends, Request from starlette.responses import PlainTextResponse +from app.adapters.external.wechat_crypt import WXBizMsgCrypt +from app.adapters.web.security.access import verify_apitoken, verify_token +from app.api.context import get_background_task_registry, resolve_background_task_registry +from app.api.dependencies.agent import get_message_query_service +from app.api.dependencies.auth import get_current_active_superuser +from app.api.principal import ApiPrincipal +from app.api.response import ResponseAPIRouter +from app.application.configuration import ( + get_api_runtime_config_snapshot, + get_configured_system_config, +) +from app.application.messaging.message import MessageQueryService +from app.application.notification import get_notification_configs +from app.chain.message import MessageChain +from app.runtime.config import global_vars +from app.runtime.log import logger +from app.runtime.tasks import TaskRegistry from app.schemas.message import MessageClearBefore as _SchemaMessageClearBefore from app.schemas.message import MessageClearData as _SchemaMessageClearData from app.schemas.message import MessageClearScope as _SchemaMessageClearScope @@ -16,24 +33,7 @@ from app.schemas.message import SubscriptionMessage as _SchemaSubscriptionMessag from app.schemas.message import WebMessageItem as _SchemaWebMessageItem from app.schemas.response import Response as _SchemaResponse from app.schemas.token import TokenPayload as _SchemaTokenPayload -from app.api.response import ResponseAPIRouter -from app.chain.message import MessageChain -from app.runtime.config import global_vars -from app.adapters.web.security.access import verify_token, verify_apitoken -from app.api.principal import ApiPrincipal -from app.application.configuration import ( - get_api_runtime_config_snapshot, - get_configured_system_config, -) -from app.api.dependencies.agent import get_message_query_service -from app.api.dependencies.auth import get_current_active_superuser -from app.application.messaging.message import MessageQueryService -from app.application.notification import get_notification_configs -from app.runtime.log import logger -from app.adapters.external.wechat_crypt import WXBizMsgCrypt from app.schemas.types import NotificationChannel, SystemConfigKey -from app.api.context import get_background_task_registry, resolve_background_task_registry -from app.runtime.tasks import TaskRegistry router = ResponseAPIRouter() diff --git a/app/api/endpoints/mfa.py b/app/api/endpoints/mfa.py index 717999926..5caf5404a 100644 --- a/app/api/endpoints/mfa.py +++ b/app/api/endpoints/mfa.py @@ -4,10 +4,37 @@ MFA (Multi-Factor Authentication) API 端点 """ import json -from typing import Any, Annotated, Optional +from typing import Annotated, Any, Optional -from fastapi import Depends, HTTPException, Body, Request, Response +from fastapi import Body, Depends, HTTPException, Request, Response +from app.adapters.web.security.access import set_or_refresh_resource_token_cookie +from app.api.dependencies.auth import ( + get_current_active_user, + get_current_active_user_async, + get_passkey_service, + get_user_service, +) +from app.api.principal import ApiPrincipal +from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter +from app.application.security.auth import get_configured_auth_service +from app.application.security.otp import OtpUtils +from app.application.security.passkey import ( + PasskeyChallengeStore, + PassKeyHelper, + PassKeyRegistrationOriginMismatchError, + PassKeyRegistrationVerificationError, +) +from app.application.security.passkeys import ( + PasskeyService, +) +from app.application.security.token import verify_password +from app.application.security.user import ( + UserService, + get_configured_user_id_lookup, + 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.mfa import MfaStatusData as _SchemaMfaStatusData @@ -17,33 +44,6 @@ from app.schemas.mfa import PasskeyStartData as _SchemaPasskeyStartData from app.schemas.response import Response as _SchemaResponse from app.schemas.token import Token as _SchemaToken from app.schemas.token import TokenPayload as _SchemaTokenPayload -from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter -from app.adapters.web.security.access import set_or_refresh_resource_token_cookie -from app.application.security.token import verify_password -from app.application.security.auth import get_configured_auth_service -from app.application.security.user import UserService -from app.application.security.user import ( - get_configured_user_id_lookup, - get_configured_user_name_lookup, -) -from app.application.security.passkeys import ( - PasskeyService, -) -from app.api.principal import ApiPrincipal -from app.api.dependencies.auth import ( - get_current_active_user, - get_current_active_user_async, - get_user_service, - get_passkey_service, -) -from app.application.security.passkey import ( - PassKeyHelper, - PassKeyRegistrationOriginMismatchError, - PassKeyRegistrationVerificationError, - PasskeyChallengeStore, -) -from app.runtime.log import logger -from app.application.security.otp import OtpUtils router = ResponseAPIRouter() diff --git a/app/api/endpoints/music.py b/app/api/endpoints/music.py index 93f98285e..65d36ccee 100644 --- a/app/api/endpoints/music.py +++ b/app/api/endpoints/music.py @@ -2,6 +2,18 @@ from typing import Annotated, Optional from fastapi import Depends, HTTPException, Query +from app.adapters.web.security.access import verify_token +from app.api.dependencies.auth import get_current_active_superuser_async +from app.api.response import ResponseAPIRouter +from app.chain.listenbrainz import ( + LISTENBRAINZ_CHART_RANGES, + LISTENBRAINZ_FRESH_MAX_DAYS, + LISTENBRAINZ_FRESH_SORTS, +) +from app.chain.media import MediaChain +from app.chain.musicbrainz import MusicBrainzChain +from app.chain.recommend import RecommendChain +from app.domain.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo from app.schemas.music import MusicAlbumInfo as _SchemaMusicAlbumInfo from app.schemas.music import MusicArtistInfo as _SchemaMusicArtistInfo from app.schemas.music import MusicRecognitionCacheData as _SchemaMusicRecognitionCacheData @@ -9,19 +21,7 @@ from app.schemas.music import MusicRecognizeRequest as _SchemaMusicRecognizeRequ from app.schemas.response import Response as _SchemaResponse from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.schemas.transfer import MusicInfo as _SchemaMusicInfo -from app.api.response import ResponseAPIRouter -from app.chain.media import MediaChain -from app.chain.recommend import RecommendChain from app.schemas.types import MediaSource, MediaType -from app.domain.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo -from app.adapters.web.security.access import verify_token -from app.api.dependencies.auth import get_current_active_superuser_async -from app.chain.listenbrainz import ( - LISTENBRAINZ_CHART_RANGES, - LISTENBRAINZ_FRESH_MAX_DAYS, - LISTENBRAINZ_FRESH_SORTS, -) -from app.chain.musicbrainz import MusicBrainzChain router = ResponseAPIRouter() diff --git a/app/api/endpoints/notification.py b/app/api/endpoints/notification.py index a572adbf6..9ce32fae5 100644 --- a/app/api/endpoints/notification.py +++ b/app/api/endpoints/notification.py @@ -2,11 +2,11 @@ from typing import Any, Dict from fastapi import Depends -from app.schemas.common import ManageRequest as _SchemaManageRequest -from app.schemas.response import Response as _SchemaResponse +from app.api.dependencies.auth import get_current_active_superuser from app.api.response import ResponseAPIRouter from app.chain.notification import NotificationChain -from app.api.dependencies.auth import get_current_active_superuser +from app.schemas.common import ManageRequest as _SchemaManageRequest +from app.schemas.response import Response as _SchemaResponse router = ResponseAPIRouter() diff --git a/app/api/endpoints/openai.py b/app/api/endpoints/openai.py index 249a71296..0dd3326d0 100644 --- a/app/api/endpoints/openai.py +++ b/app/api/endpoints/openai.py @@ -8,6 +8,23 @@ from fastapi import APIRouter, Depends, Request, Security from fastapi.responses import JSONResponse from fastapi.security import HTTPAuthorizationCredentials +from app.adapters.web.security.access import openai_bearer_scheme +from app.agent.contracts import ReplyMode +from app.agent.runtime_loader import get_moviepilot_agent_type +from app.api.context import ( + get_background_task_registry_compat, + resolve_background_task_registry, +) +from app.api.openai_utils import ( + build_completion_payload, + build_prompt, + build_responses_input, + build_session_id, +) +from app.api.presentation.sse import build_sse_response, encode_data_event +from app.application.agent import get_running_agent_manager +from app.application.configuration import get_api_runtime_config_snapshot +from app.runtime.tasks import TaskRegistry from app.schemas.openai import OpenAIChatCompletionResponse as _SchemaOpenAIChatCompletionResponse from app.schemas.openai import OpenAIChatCompletionsRequest as _SchemaOpenAIChatCompletionsRequest from app.schemas.openai import OpenAIErrorDetail as _SchemaOpenAIErrorDetail @@ -19,24 +36,7 @@ from app.schemas.openai import OpenAIResponsesOutputText as _SchemaOpenAIRespons from app.schemas.openai import OpenAIResponsesRequest as _SchemaOpenAIResponsesRequest from app.schemas.openai import OpenAIResponsesResponse as _SchemaOpenAIResponsesResponse from app.schemas.openai import OpenAIUsage as _SchemaOpenAIUsage -from app.api.openai_utils import ( - build_completion_payload, - build_prompt, - build_responses_input, - build_session_id, -) -from app.api.presentation.sse import build_sse_response, encode_data_event -from app.agent.runtime_loader import get_moviepilot_agent_type -from app.application.agent import get_running_agent_manager -from app.agent.contracts import ReplyMode -from app.application.configuration import get_api_runtime_config_snapshot -from app.adapters.web.security.access import openai_bearer_scheme from app.schemas.types import NotificationChannel -from app.api.context import ( - get_background_task_registry_compat, - resolve_background_task_registry, -) -from app.runtime.tasks import TaskRegistry OPENAI_ERROR_RESPONSES = { 400: {"model": _SchemaOpenAIErrorResponse, "description": "请求格式错误"}, diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index 98bdd96fe..e9f38f0d7 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -9,11 +9,48 @@ from fastapi import Depends, Header, HTTPException, Security from starlette import status from starlette.responses import StreamingResponse +from app.adapters.external.market import PluginHelper +from app.adapters.external.server import MoviePilotServerHelper +from app.adapters.system.plugin.package import PluginPackageManager +from app.adapters.web.security.access import ( + resource_token_cookie, + verify_resource_token, + verify_token, +) +from app.api.context import get_background_task_registry, resolve_background_task_registry +from app.api.dependencies.auth import ( + get_current_active_superuser, + get_current_active_superuser_async, +) +from app.api.dependencies.plugin import ( + get_plugin_config_command, +) +from app.api.principal import ApiPrincipal +from app.api.response import ResponseAPIRouter +from app.application.commands import init_commands +from app.application.configuration import get_api_runtime_config_snapshot, get_configured_system_config +from app.application.plugin.config import PluginConfigCommand +from app.application.plugin.folders import remove_plugin_from_folders +from app.application.plugin.install import PluginInstallCommand +from app.application.plugin.routes import register_plugin_api, remove_plugin_api +from app.application.plugin.runtime import PluginRuntime, get_plugin_manager +from app.application.scheduling import remove_plugin_job, update_plugin_job +from app.runtime.cache import async_fresh from app.runtime.execution import run_in_threadpool +from app.runtime.extensions.plugin.contracts import ( + PluginDashboardError, + PluginNotFoundError, +) +from app.runtime.log import logger +from app.runtime.tasks import TaskRegistry from app.schemas.common import JsonObject as _SchemaJsonObject +from app.schemas.exception import ( + PersistenceUnavailableError, + PluginMutationRejectedError, +) from app.schemas.plugin import Plugin as _SchemaPlugin -from app.schemas.plugin import PluginDashboard as _SchemaPluginDashboard from app.schemas.plugin import PluginCloneRequest as _SchemaPluginCloneRequest +from app.schemas.plugin import PluginDashboard as _SchemaPluginDashboard from app.schemas.plugin import PluginDashboardMetaItem as _SchemaPluginDashboardMetaItem from app.schemas.plugin import PluginFoldersData as _SchemaPluginFoldersData from app.schemas.plugin import PluginRating as _SchemaPluginRating @@ -26,45 +63,7 @@ from app.schemas.plugin import PluginRuntimeSummary as _SchemaPluginRuntimeSumma from app.schemas.plugin import PluginSidebarNavItem as _SchemaPluginSidebarNavItem from app.schemas.response import Response as _SchemaResponse from app.schemas.token import TokenPayload as _SchemaTokenPayload -from app.api.response import ResponseAPIRouter -from app.application.plugin.folders import remove_plugin_from_folders -from app.application.plugin.routes import register_plugin_api, remove_plugin_api -from app.application.plugin.install import PluginInstallCommand -from app.application.plugin.config import PluginConfigCommand -from app.application.commands import init_commands -from app.application.scheduling import remove_plugin_job, update_plugin_job -from app.runtime.cache import async_fresh -from app.application.configuration import get_api_runtime_config_snapshot -from app.application.plugin.runtime import PluginRuntime, get_plugin_manager -from app.runtime.extensions.plugin.contracts import ( - PluginDashboardError, - PluginNotFoundError, -) -from app.adapters.web.security.access import ( - resource_token_cookie, - verify_resource_token, - verify_token, -) -from app.api.principal import ApiPrincipal -from app.application.configuration import get_configured_system_config -from app.api.dependencies.auth import ( - get_current_active_superuser, - get_current_active_superuser_async, -) -from app.api.dependencies.plugin import ( - get_plugin_config_command, -) -from app.adapters.external.server import MoviePilotServerHelper -from app.adapters.external.market import PluginHelper -from app.adapters.system.plugin.package import PluginPackageManager -from app.schemas.exception import ( - PersistenceUnavailableError, - PluginMutationRejectedError, -) -from app.runtime.log import logger from app.schemas.types import SystemConfigKey -from app.api.context import get_background_task_registry, resolve_background_task_registry -from app.runtime.tasks import TaskRegistry router = ResponseAPIRouter() _plugin_release_refresh_tasks: set[asyncio.Task] = set() diff --git a/app/api/endpoints/recommend.py b/app/api/endpoints/recommend.py index 686ca6f11..beb46e6eb 100644 --- a/app/api/endpoints/recommend.py +++ b/app/api/endpoints/recommend.py @@ -2,17 +2,17 @@ from typing import Any, Awaitable, List, Optional from fastapi import Depends, HTTPException, status -from app.schemas.event import RecommendMediaSource as _SchemaRecommendMediaSource -from app.schemas.token import TokenPayload as _SchemaTokenPayload -from app.schemas.transfer import MusicInfo as _SchemaMusicInfo -from app.schemas.workflow import MediaInfo as _SchemaMediaInfo +from app.adapters.web.security.access import verify_token from app.api.response import ResponseAPIRouter from app.chain.recommend import RecommendChain from app.runtime.events import eventmanager -from app.adapters.web.security.access import verify_token -from app.schemas.exception import TMDbException +from app.schemas.event import RecommendMediaSource as _SchemaRecommendMediaSource from app.schemas.event import RecommendSourceEventData +from app.schemas.exception import TMDbException +from app.schemas.token import TokenPayload as _SchemaTokenPayload +from app.schemas.transfer import MusicInfo as _SchemaMusicInfo from app.schemas.types import ChainEventType +from app.schemas.workflow import MediaInfo as _SchemaMediaInfo router = ResponseAPIRouter() diff --git a/app/api/endpoints/search.py b/app/api/endpoints/search.py index ecf6013fe..2c4128688 100644 --- a/app/api/endpoints/search.py +++ b/app/api/endpoints/search.py @@ -4,25 +4,25 @@ import time from typing import Any, AsyncIterator, Iterator, List, Optional from uuid import uuid4 -from fastapi import Depends, Body, Request +from fastapi import Body, Depends, Request from fastapi.responses import StreamingResponse +from app.adapters.web.security.access import verify_resource_token, verify_token +from app.api.response import ResponseAPIRouter +from app.application.security.url import SecurityUtils +from app.chain.search import SearchChain +from app.domain.media import normalize_music_type +from app.runtime.localization import LocaleHelper +from app.runtime.log import logger +from app.schemas.media import resolve_media_identity from app.schemas.response import Response as _SchemaResponse from app.schemas.search import SearchLastContextData as _SchemaSearchLastContextData from app.schemas.search import SearchRecommendStatusData as _SchemaSearchRecommendStatusData from app.schemas.search import SubtitleInfo as _SchemaSubtitleInfo from app.schemas.system import TorrentInfo as _SchemaTorrentInfo from app.schemas.token import TokenPayload as _SchemaTokenPayload -from app.schemas.workflow import Context as _SchemaContext -from app.api.response import ResponseAPIRouter -from app.chain.search import SearchChain -from app.adapters.web.security.access import verify_resource_token, verify_token -from app.runtime.localization import LocaleHelper -from app.runtime.log import logger from app.schemas.types import MediaSource, MediaType -from app.domain.media import normalize_music_type -from app.schemas.media import resolve_media_identity -from app.application.security.url import SecurityUtils +from app.schemas.workflow import Context as _SchemaContext router = ResponseAPIRouter() diff --git a/app/api/endpoints/site.py b/app/api/endpoints/site.py index 8360ecace..e6d4102e8 100644 --- a/app/api/endpoints/site.py +++ b/app/api/endpoints/site.py @@ -1,31 +1,9 @@ -from typing import List, Any, Dict, Optional +from typing import Annotated, Any, Dict, List, Optional from fastapi import Depends, HTTPException -from typing import Annotated -from app.schemas.common import JsonObject as _SchemaJsonObject -from app.schemas.response import Response as _SchemaResponse -from app.schemas.site import SiteAuth as _SchemaSiteAuth -from app.schemas.site import SiteCategory as _SchemaSiteCategory -from app.schemas.site import SiteCookieUpdate as _SchemaSiteCookieUpdate -from app.schemas.site import SiteIconData as _SchemaSiteIconData -from app.schemas.site import SiteMappingData as _SchemaSiteMappingData -from app.schemas.site import SiteStatistic as _SchemaSiteStatistic -from app.schemas.site import SiteUserData as _SchemaSiteUserData -from app.schemas.system import TorrentInfo as _SchemaTorrentInfo -from app.schemas.token import TokenPayload as _SchemaTokenPayload -from app.schemas.workflow import Site as _SchemaSite -from app.api.response import ResponseAPIRouter -from app.application.site.mutation import SiteMutationCommand -from app.application.site.query import SiteQueryService -from app.api.endpoints.plugin import register_plugin_api -from app.chain.site import SiteChain -from app.chain.torrents import TorrentsChain -from app.application.commands import init_commands -from app.application.plugin.runtime import get_plugin_manager from app.adapters.web.security.access import verify_token -from app.api.principal import ApiPrincipal -from app.application.configuration import get_configured_system_config +from app.api.context import get_background_task_registry, resolve_background_task_registry from app.api.dependencies.auth import ( get_current_active_manage_user, get_current_active_manage_user_async, @@ -37,13 +15,34 @@ from app.api.dependencies.site import ( get_site_query_service, get_site_sync_query_service, ) -from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module -from app.runtime.log import logger +from app.api.endpoints.plugin import register_plugin_api +from app.api.principal import ApiPrincipal +from app.api.response import ResponseAPIRouter +from app.application.commands import init_commands +from app.application.configuration import get_configured_system_config +from app.application.plugin.runtime import get_plugin_manager from app.application.scheduling import get_scheduler -from app.schemas.types import SystemConfigKey, MediaType +from app.application.site.mutation import SiteMutationCommand +from app.application.site.query import SiteQueryService +from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module +from app.chain.site import SiteChain +from app.chain.torrents import TorrentsChain from app.domain import site as site_rules -from app.api.context import get_background_task_registry, resolve_background_task_registry +from app.runtime.log import logger from app.runtime.tasks import TaskRegistry +from app.schemas.common import JsonObject as _SchemaJsonObject +from app.schemas.response import Response as _SchemaResponse +from app.schemas.site import SiteAuth as _SchemaSiteAuth +from app.schemas.site import SiteCategory as _SchemaSiteCategory +from app.schemas.site import SiteCookieUpdate as _SchemaSiteCookieUpdate +from app.schemas.site import SiteIconData as _SchemaSiteIconData +from app.schemas.site import SiteMappingData as _SchemaSiteMappingData +from app.schemas.site import SiteStatistic as _SchemaSiteStatistic +from app.schemas.site import SiteUserData as _SchemaSiteUserData +from app.schemas.system import TorrentInfo as _SchemaTorrentInfo +from app.schemas.token import TokenPayload as _SchemaTokenPayload +from app.schemas.types import MediaType, SystemConfigKey +from app.schemas.workflow import Site as _SchemaSite router = ResponseAPIRouter() diff --git a/app/api/endpoints/storage.py b/app/api/endpoints/storage.py index ff153b70b..b0c149cb8 100644 --- a/app/api/endpoints/storage.py +++ b/app/api/endpoints/storage.py @@ -7,22 +7,22 @@ from typing import Any, Dict, List, Optional from fastapi import Depends, HTTPException from starlette.responses import FileResponse, Response -from app.schemas.common import ManageRequest as _SchemaManageRequest -from app.schemas.response import Response as _SchemaResponse -from app.schemas.workflow import FileItem as _SchemaFileItem -from app.api.response import ResponseAPIRouter -from app.chain.media import MediaChain -from app.chain.storage import StorageChain -from app.chain.transfer import TransferChain -from app.application.configuration import get_api_runtime_config_snapshot -from app.api.principal import ApiPrincipal from app.api.dependencies.auth import ( get_current_active_manage_user, get_current_active_superuser, ) -from app.runtime.progress import ProgressHelper -from app.schemas.types import ProgressKey +from app.api.principal import ApiPrincipal +from app.api.response import ResponseAPIRouter +from app.application.configuration import get_api_runtime_config_snapshot +from app.chain.media import MediaChain +from app.chain.storage import StorageChain +from app.chain.transfer import TransferChain from app.foundation import text as text_tools +from app.runtime.progress import ProgressHelper +from app.schemas.common import ManageRequest as _SchemaManageRequest +from app.schemas.response import Response as _SchemaResponse +from app.schemas.types import ProgressKey +from app.schemas.workflow import FileItem as _SchemaFileItem router = ResponseAPIRouter() diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index 8269b9e79..81c5ce627 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -1,47 +1,14 @@ -from typing import List, Any, Annotated, Optional +from typing import Annotated, Any, List, Optional import cn2an -from fastapi import Request, Depends, HTTPException, Header +from fastapi import Depends, Header, HTTPException, Request -from app.schemas.common import IdData as _SchemaIdData -from app.schemas.response import Response as _SchemaResponse -from app.schemas.subscribe import SubscrbieInfo as _SchemaSubscrbieInfo -from app.schemas.subscribe import SubscribeShare as _SchemaSubscribeShare -from app.schemas.subscribe import SubscribeShareStatistics as _SchemaSubscribeShareStatistics -from app.schemas.token import TokenPayload as _SchemaTokenPayload -from app.schemas.workflow import MediaInfo as _SchemaMediaInfo -from app.schemas.workflow import Subscribe as _SchemaSubscribe -from app.api.response import ResponseAPIRouter +from app.adapters.external.server import MoviePilotServerHelper +from app.adapters.web.security.access import verify_apitoken, verify_token from app.api.context import ( get_background_task_registry, resolve_background_task_registry, ) -from app.chain.subscribe import SubscribeChain -from app.runtime.events import eventmanager -from app.domain.context import MediaInfo -from app.domain.metainfo import MetaInfo -from app.adapters.web.security.access import verify_token, verify_apitoken -from app.application.subscription.delete import ( - DeleteSubscribeCommand, - SubscribeDeletionActor, -) -from app.application.subscription.identity import ( - DeleteSubscriptionsByIdentityCommand, -) -from app.application.subscription.search import ( - SearchSubscriptionsCommand, - SubscribeSearchActor, -) -from app.api.principal import ApiPrincipal -from app.application.subscription.query import SubscriptionQueryService -from app.application.subscription.mutation import ( - SubscriptionActor, - SubscriptionMutationService, -) -from app.application.configuration import ( - get_api_runtime_config_snapshot, - get_configured_system_config, -) from app.api.dependencies.auth import ( get_current_active_user, get_current_active_user_async, @@ -50,23 +17,56 @@ from app.api.dependencies.subscription import ( get_delete_subscribe_command, get_delete_subscriptions_by_identity_command, get_search_subscriptions_command, - get_subscription_query_service, get_subscription_mutation_service, + get_subscription_query_service, get_subscription_sync_mutation_service, ) -from app.adapters.external.server import MoviePilotServerHelper +from app.api.principal import ApiPrincipal +from app.api.response import ResponseAPIRouter +from app.application.configuration import ( + get_api_runtime_config_snapshot, + get_configured_system_config, +) from app.application.scheduling import get_scheduler +from app.application.subscription.delete import ( + DeleteSubscribeCommand, + SubscribeDeletionActor, +) +from app.application.subscription.identity import ( + DeleteSubscriptionsByIdentityCommand, +) +from app.application.subscription.mutation import ( + SubscriptionActor, + SubscriptionMutationService, +) +from app.application.subscription.query import SubscriptionQueryService +from app.application.subscription.search import ( + SearchSubscriptionsCommand, + SubscribeSearchActor, +) +from app.chain.subscribe import SubscribeChain +from app.domain.context import MediaInfo +from app.domain.metainfo import MetaInfo +from app.runtime.events import eventmanager from app.runtime.tasks import TaskRegistry +from app.schemas.common import IdData as _SchemaIdData from app.schemas.event import SubscribeModifiedEventData +from app.schemas.media import normalize_media_source, resolve_media_identity +from app.schemas.response import Response as _SchemaResponse +from app.schemas.subscribe import SubscrbieInfo as _SchemaSubscrbieInfo +from app.schemas.subscribe import SubscribeShare as _SchemaSubscribeShare +from app.schemas.subscribe import SubscribeShareStatistics as _SchemaSubscribeShareStatistics +from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.schemas.types import ( MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, + EventType, MediaSource, MediaType, - EventType, SystemConfigKey, ) -from app.schemas.media import normalize_media_source, resolve_media_identity +from app.schemas.workflow import MediaInfo as _SchemaMediaInfo +from app.schemas.workflow import Subscribe as _SchemaSubscribe router = ResponseAPIRouter() diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index ec01a4bbe..b6ebe63ca 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -6,25 +6,76 @@ import zipfile from collections import deque from datetime import datetime from pathlib import Path -from typing import Any, Optional, Union, Annotated -from urllib.parse import urljoin, urlparse +from typing import Annotated, Any, Optional, Union +from urllib.parse import urlparse import aiofiles import anyio import pillow_avif # noqa: F401 # pylint: disable=unused-import # AVIF 注册副作用 from anyio import Path as AsyncPath -from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module -from fastapi import Body, Depends, HTTPException, Header, Request, Response +from fastapi import Body, Depends, Header, HTTPException, Request, Response from fastapi.responses import StreamingResponse +from app.adapters.external.market import ( + PLUGIN_MARKET_WIKI_URL, + extract_plugin_market_repos_from_wiki, + merge_plugin_market_repos, + split_plugin_market_repo_urls, +) +from app.adapters.external.server import MoviePilotServerHelper +from app.adapters.network.http import AsyncRequestUtils, RequestUtils +from app.adapters.system import rust as rust_accel +from app.adapters.system.update import system_update_manager +from app.adapters.web.security.access import verify_apitoken, verify_resource_token, verify_token +from app.api.dependencies.auth import ( + get_current_active_superuser, + get_current_active_superuser_async, + get_current_active_user_async, +) +from app.api.principal import ApiPrincipal +from app.api.response import ResponseAPIRouter +from app.application.backup import DatabaseBackupInProgressError +from app.application.configuration import ( + get_configured_system_config, + get_runtime_settings, +) +from app.application.database import get_database_governance +from app.application.image import ImageHelper +from app.application.messaging.message import MessageHelper +from app.application.module import get_module_manager +from app.application.network import NetworkTestService +from app.application.plugin.runtime import plugin_system_config_mutation +from app.application.rules import RuleHelper +from app.application.scheduling import get_scheduler +from app.application.security.url import SecurityUtils +from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module +from app.chain.media import MediaChain +from app.chain.mediaserver import MediaServerChain +from app.chain.search import SearchChain +from app.domain.metainfo import MetaInfo +from app.foundation.crypto import HashUtils +from app.foundation.environment import is_free_threaded_runtime, is_gil_enabled +from app.foundation.url import UrlUtils +from app.runtime.events import eventmanager +from app.runtime.execution import run_in_threadpool_to_completion +from app.runtime.localization import LocaleHelper +from app.runtime.log import logger +from app.runtime.progress import AsyncProgressHelper +from app.runtime.scheduling import TimerUtils +from app.runtime.state import SystemHelper +from app.runtime.stop import runtime_stop_state +from app.runtime.config import global_vars +from app.runtime.version import get_app_version, get_frontend_version from app.schemas.common import JsonObject as _SchemaJsonObject from app.schemas.common import JsonObjectList as _SchemaJsonObjectList from app.schemas.common import TimeData as _SchemaTimeData from app.schemas.common import ValueData as _SchemaValueData +from app.schemas.event import ConfigChangeEventData +from app.schemas.exception import PluginMutationRejectedError from app.schemas.response import Response as _SchemaResponse -from app.schemas.system import NetTestTarget as _SchemaNetTestTarget from app.schemas.system import DatabaseBackupArtifactData as _SchemaDatabaseBackupArtifactData from app.schemas.system import DatabaseBackupVerificationData as _SchemaDatabaseBackupVerificationData +from app.schemas.system import NetTestTarget as _SchemaNetTestTarget from app.schemas.system import PluginMarketSyncData as _SchemaPluginMarketSyncData from app.schemas.system import PluginMarketSyncRequest as _SchemaPluginMarketSyncRequest from app.schemas.system import RuleTestData as _SchemaRuleTestData @@ -33,58 +84,7 @@ from app.schemas.system import SystemModuleListData as _SchemaSystemModuleListDa from app.schemas.system import SystemUpdateStatus as _SchemaSystemUpdateStatus from app.schemas.system import TorrentInfo as _SchemaTorrentInfo from app.schemas.token import TokenPayload as _SchemaTokenPayload -from app.api.response import ResponseAPIRouter -from app.chain.media import MediaChain -from app.chain.mediaserver import MediaServerChain -from app.chain.search import SearchChain -from app.chain.system import SystemChain -from app.runtime.config import global_vars -from app.runtime.events import eventmanager -from app.domain.metainfo import MetaInfo -from app.application.module import get_module_manager -from app.adapters.web.security.access import verify_apitoken, verify_resource_token, verify_token -from app.api.principal import ApiPrincipal -from app.application.configuration import ( - get_configured_system_config, - get_runtime_settings, -) -from app.application.backup import DatabaseBackupInProgressError -from app.application.database import get_database_governance -from app.application.plugin.runtime import plugin_system_config_mutation -from app.api.dependencies.auth import ( - get_current_active_superuser, - get_current_active_superuser_async, - get_current_active_user_async, -) -from app.application.image import ImageHelper -from app.runtime.localization import LocaleHelper -from app.adapters.external.market import ( - PLUGIN_MARKET_WIKI_URL, - extract_plugin_market_repos_from_wiki, - merge_plugin_market_repos, - split_plugin_market_repo_urls, -) -from app.application.messaging.message import MessageHelper -from app.runtime.progress import AsyncProgressHelper -from app.runtime.scheduling import TimerUtils -from app.application.rules import RuleHelper -from app.adapters.external.server import MoviePilotServerHelper -from app.runtime.state import SystemHelper -from app.runtime.log import logger -from app.runtime.execution import run_in_threadpool_to_completion -from app.application.scheduling import get_scheduler -from app.schemas.event import ConfigChangeEventData -from app.schemas.exception import PluginMutationRejectedError -from app.schemas.types import SystemConfigKey, EventType -from app.foundation.crypto import HashUtils -from app.foundation.environment import is_free_threaded_runtime, is_gil_enabled -from app.adapters.network.http import RequestUtils, AsyncRequestUtils -from app.adapters.system import rust as rust_accel -from app.adapters.system.update import system_update_manager -from app.application.security.url import SecurityUtils -from app.application.network import NetworkTestService -from app.foundation.url import UrlUtils -from app.runtime.version import get_app_version, get_frontend_version +from app.schemas.types import EventType, SystemConfigKey router = ResponseAPIRouter() @@ -1042,7 +1042,7 @@ async def get_progress( async def event_generator(): try: - while not global_vars.is_system_stopped: + while not runtime_stop_state.is_system_stopped: if await request.is_disconnected(): break detail = await progress.get(locale=locale) @@ -1233,7 +1233,7 @@ async def get_message( async def event_generator(): try: - while not global_vars.is_system_stopped: + while not runtime_stop_state.is_system_stopped: if await request.is_disconnected(): break detail = message.get(role) @@ -1314,7 +1314,7 @@ async def _get_logging_impl( initial_stat = await log_path.stat() initial_size = initial_stat.st_size # 实时监听新日志,使用更短的轮询间隔 - while not global_vars.is_system_stopped: + while not runtime_stop_state.is_system_stopped: if await request.is_disconnected(): break # 检查文件是否有新内容 @@ -1413,7 +1413,7 @@ async def latest_version(_: _SchemaTokenPayload = Depends(verify_token)): version_res = await AsyncRequestUtils( proxies=get_runtime_settings().get("PROXY"), headers=get_runtime_settings().get("GITHUB_HEADERS"), - ).get_res(f"https://api.github.com/repos/jxxghp/MoviePilot/releases") + ).get_res("https://api.github.com/repos/jxxghp/MoviePilot/releases") if version_res is not None and version_res.status_code == 200: ver_json = version_res.json() if ver_json: diff --git a/app/api/endpoints/tmdb.py b/app/api/endpoints/tmdb.py index 649c705ef..1acc14b36 100644 --- a/app/api/endpoints/tmdb.py +++ b/app/api/endpoints/tmdb.py @@ -1,21 +1,20 @@ -from typing import List, Any, Optional +from typing import Any, List, Optional from fastapi import Depends +from app.adapters.web.security.access import verify_token +from app.api.dependencies.auth import get_current_active_superuser_async +from app.api.response import ResponseAPIRouter +from app.application.configuration import get_api_runtime_config_snapshot, get_configured_system_config +from app.chain.tmdb import TmdbChain from app.schemas.context import MediaPerson as _SchemaMediaPerson from app.schemas.response import Response as _SchemaResponse +from app.schemas.tmdb import TmdbEpisode as _SchemaTmdbEpisode from app.schemas.tmdb import TmdbRecognitionCacheData as _SchemaTmdbRecognitionCacheData from app.schemas.tmdb import TmdbSeason as _SchemaTmdbSeason from app.schemas.token import TokenPayload as _SchemaTokenPayload -from app.schemas.tmdb import TmdbEpisode as _SchemaTmdbEpisode -from app.schemas.workflow import MediaInfo as _SchemaMediaInfo -from app.api.response import ResponseAPIRouter -from app.chain.tmdb import TmdbChain -from app.application.configuration import get_api_runtime_config_snapshot -from app.adapters.web.security.access import verify_token -from app.application.configuration import get_configured_system_config -from app.api.dependencies.auth import get_current_active_superuser_async from app.schemas.types import MediaType, SystemConfigKey +from app.schemas.workflow import MediaInfo as _SchemaMediaInfo router = ResponseAPIRouter() diff --git a/app/api/endpoints/torrent.py b/app/api/endpoints/torrent.py index 2b16619bd..7f6a51664 100644 --- a/app/api/endpoints/torrent.py +++ b/app/api/endpoints/torrent.py @@ -2,24 +2,24 @@ from typing import Optional from fastapi import Depends -from app.schemas.cache import TorrentCacheData as _SchemaTorrentCacheData -from app.schemas.cache import TorrentReidentifyData as _SchemaTorrentReidentifyData -from app.schemas.response import Response as _SchemaResponse -from app.api.response import ResponseAPIRouter -from app.chain.media import MediaChain -from app.chain.torrents import TorrentsChain -from app.application.configuration import get_api_runtime_config_snapshot from app.api.dependencies.auth import ( get_current_active_superuser, get_current_active_superuser_async, ) +from app.api.response import ResponseAPIRouter +from app.application.configuration import get_api_runtime_config_snapshot +from app.application.torrent_cache import TorrentCacheRecognitionService +from app.chain.media import MediaChain +from app.chain.torrents import TorrentsChain +from app.foundation.crypto import HashUtils +from app.schemas.cache import TorrentCacheData as _SchemaTorrentCacheData +from app.schemas.cache import TorrentReidentifyData as _SchemaTorrentReidentifyData +from app.schemas.media import resolve_media_identity +from app.schemas.response import Response as _SchemaResponse from app.schemas.types import ( MediaSource, MusicTargetEntityType, ) -from app.foundation.crypto import HashUtils -from app.schemas.media import resolve_media_identity -from app.application.torrent_cache import TorrentCacheRecognitionService router = ResponseAPIRouter() diff --git a/app/api/endpoints/transfer.py b/app/api/endpoints/transfer.py index 85bef4059..d154bf6ab 100644 --- a/app/api/endpoints/transfer.py +++ b/app/api/endpoints/transfer.py @@ -1,34 +1,33 @@ from pathlib import Path -from typing import Any, List, Annotated, Optional +from typing import Annotated, Any, List, Optional from fastapi import Depends +from app.adapters.web.security.access import verify_apitoken, verify_token +from app.api.dependencies.auth import get_current_active_manage_user +from app.api.dependencies.history import get_transfer_history_lookup_service +from app.api.response import ResponseAPIRouter +from app.application.configuration import get_api_runtime_config_snapshot +from app.application.directory import DirectoryHelper +from app.application.history import TransferHistoryLookupService +from app.chain.media import MediaChain +from app.chain.transfer import TransferChain +from app.runtime.log import logger +from app.runtime.stop import runtime_stop_state from app.schemas.common import NameData as _SchemaNameData from app.schemas.response import Response as _SchemaResponse +from app.schemas.system import TransferDirectoryConf as _SchemaTransferDirectoryConf from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.schemas.transfer import EpisodeFormat as _SchemaEpisodeFormat from app.schemas.transfer import EpisodeFormatRecommendData as _SchemaEpisodeFormatRecommendData +from app.schemas.transfer import EpisodeFormatRecommendItem, ManualTransferItem from app.schemas.transfer import ManualTransferHistoryInfo as _SchemaManualTransferHistoryInfo from app.schemas.transfer import ManualTransferResultData as _SchemaManualTransferResultData from app.schemas.transfer import ManualTransferTargetPath as _SchemaManualTransferTargetPath -from app.schemas.system import TransferDirectoryConf as _SchemaTransferDirectoryConf from app.schemas.transfer import TransferJob as _SchemaTransferJob -from app.schemas.workflow import FileItem as _SchemaFileItem -from app.api.response import ResponseAPIRouter -from app.chain.media import MediaChain -from app.chain.transfer import TransferChain -from app.runtime.config import global_vars -from app.application.configuration import get_api_runtime_config_snapshot -from app.adapters.web.security.access import verify_token, verify_apitoken -from app.api.dependencies.auth import get_current_active_manage_user -from app.api.dependencies.history import get_transfer_history_lookup_service -from app.application.directory import DirectoryHelper -from app.application.history import TransferHistoryLookupService -from app.runtime.log import logger from app.schemas.types import MediaType from app.schemas.workflow import FileItem -from app.schemas.transfer import ManualTransferItem -from app.schemas.transfer import EpisodeFormatRecommendItem +from app.schemas.workflow import FileItem as _SchemaFileItem router = ResponseAPIRouter() @@ -102,7 +101,7 @@ async def remove_queue( """ TransferChain().remove_from_queue(fileitem) # 取消整理 - global_vars.stop_transfer(fileitem.path) + runtime_stop_state.stop_transfer(fileitem.path) return _SchemaResponse(success=True) @@ -398,7 +397,7 @@ def _execute_manual_transfer( elif transer_item.fileitem: src_fileitems = [transer_item.fileitem] else: - return _SchemaResponse(success=False, message=f"缺少参数") + return _SchemaResponse(success=False, message="缺少参数") dedup_fileitems: List[FileItem] = [] seen_paths = set() diff --git a/app/api/endpoints/user.py b/app/api/endpoints/user.py index 94163a1f8..39b6b1305 100644 --- a/app/api/endpoints/user.py +++ b/app/api/endpoints/user.py @@ -2,23 +2,23 @@ import base64 import re from typing import Annotated, Any, List, Union -from fastapi import Body, Depends, HTTPException, UploadFile, File +from fastapi import Body, Depends, File, HTTPException, UploadFile +from app.api.dependencies.auth import ( + get_current_active_superuser_async, + get_current_active_user_async, + get_user_service, +) +from app.api.response import ResponseAPIRouter +from app.application.security.token import PasswordTooLongError, get_password_hash +from app.application.security.user import UserService +from app.application.security.userconfig import get_configured_user_configuration from app.schemas.common import FileNameData as _SchemaFileNameData from app.schemas.common import ValueData as _SchemaValueData from app.schemas.response import Response as _SchemaResponse from app.schemas.user import User as _SchemaUser from app.schemas.user import UserCreate as _SchemaUserCreate from app.schemas.user import UserUpdate as _SchemaUserUpdate -from app.api.response import ResponseAPIRouter -from app.application.security.token import PasswordTooLongError, get_password_hash -from app.application.security.user import UserService -from app.api.dependencies.auth import ( - get_current_active_superuser_async, - get_current_active_user_async, - get_user_service, -) -from app.application.security.userconfig import get_configured_user_configuration router = ResponseAPIRouter() diff --git a/app/api/endpoints/webhook.py b/app/api/endpoints/webhook.py index 4e4b80a2e..1399162cd 100644 --- a/app/api/endpoints/webhook.py +++ b/app/api/endpoints/webhook.py @@ -1,13 +1,13 @@ -from typing import Any, Annotated +from typing import Annotated, Any from fastapi import Depends, Request -from app.schemas.response import Response as _SchemaResponse -from app.api.response import ResponseAPIRouter -from app.chain.webhook import WebhookChain from app.adapters.web.security.access import verify_apitoken from app.api.context import get_background_task_registry, resolve_background_task_registry +from app.api.response import ResponseAPIRouter +from app.chain.webhook import WebhookChain from app.runtime.tasks import TaskRegistry +from app.schemas.response import Response as _SchemaResponse router = ResponseAPIRouter() diff --git a/app/api/endpoints/workflow.py b/app/api/endpoints/workflow.py index cf08837ae..1ba0bb4f7 100644 --- a/app/api/endpoints/workflow.py +++ b/app/api/endpoints/workflow.py @@ -1,22 +1,8 @@ -from typing import List, Any, Optional +from typing import Any, List, Optional from fastapi import Depends -from app.schemas.response import Response as _SchemaResponse -from app.schemas.workflow import NameValueOption as _SchemaNameValueOption -from app.schemas.workflow import PluginWorkflowActionGroup as _SchemaPluginWorkflowActionGroup -from app.schemas.workflow import Workflow as _SchemaWorkflow -from app.schemas.workflow import WorkflowActionDefinition as _SchemaWorkflowActionDefinition -from app.schemas.workflow import WorkflowShare as _SchemaWorkflowShare -from app.api.response import ResponseAPIRouter -from app.application.workflow import ( - WorkflowDefinitionCommand, - WorkflowMutationCommand, - WorkflowQueryService, - get_workflow_manager, -) -from app.chain.workflow import WorkflowChain -from app.application.plugin.runtime import get_plugin_manager +from app.adapters.external.server import MoviePilotServerHelper from app.api.dependencies.auth import ( get_current_active_manage_user, get_current_active_manage_user_async, @@ -26,8 +12,22 @@ from app.api.dependencies.workflow import ( get_workflow_mutation_command, get_workflow_query_service, ) -from app.adapters.external.server import MoviePilotServerHelper -from app.schemas.types import EventType, EVENT_TYPE_NAMES +from app.api.response import ResponseAPIRouter +from app.application.plugin.runtime import get_plugin_manager +from app.application.workflow import ( + WorkflowDefinitionCommand, + WorkflowMutationCommand, + WorkflowQueryService, + get_workflow_manager, +) +from app.chain.workflow import WorkflowChain +from app.schemas.response import Response as _SchemaResponse +from app.schemas.types import EVENT_TYPE_NAMES, EventType +from app.schemas.workflow import NameValueOption as _SchemaNameValueOption +from app.schemas.workflow import PluginWorkflowActionGroup as _SchemaPluginWorkflowActionGroup +from app.schemas.workflow import Workflow as _SchemaWorkflow +from app.schemas.workflow import WorkflowActionDefinition as _SchemaWorkflowActionDefinition +from app.schemas.workflow import WorkflowShare as _SchemaWorkflowShare router = ResponseAPIRouter() diff --git a/app/api/presentation/sse.py b/app/api/presentation/sse.py index 72576610a..f05ea9d62 100644 --- a/app/api/presentation/sse.py +++ b/app/api/presentation/sse.py @@ -6,7 +6,6 @@ from typing import Any from fastapi.responses import StreamingResponse - SSE_HEADERS = { "Cache-Control": "no-cache, no-transform", "Connection": "keep-alive", diff --git a/app/api/response.py b/app/api/response.py index 150a7ceef..f579d4f0e 100644 --- a/app/api/response.py +++ b/app/api/response.py @@ -11,7 +11,6 @@ from starlette.responses import Response as StarletteResponse from app.schemas.common import JsonData from app.schemas.response import Response, ValidationIssue - ERROR_RESPONSES: dict[int, dict[str, Any]] = { 400: {"model": Response[None], "description": "请求错误"}, 401: {"model": Response[None], "description": "未认证"}, diff --git a/app/api/servarr.py b/app/api/servarr.py index ace59ebf3..c16cb975d 100644 --- a/app/api/servarr.py +++ b/app/api/servarr.py @@ -1,8 +1,19 @@ -from typing import List, Optional, Annotated +from typing import Annotated, List, Optional -from fastapi import APIRouter, HTTPException, Depends +from fastapi import APIRouter, Depends, HTTPException +from app.adapters.web.security.access import verify_apikey +from app.api.dependencies.subscription import get_servarr_subscription_service +from app.api.response import ERROR_RESPONSES +from app.application.servarr import ServarrSubscription, ServarrSubscriptionService +from app.chain.media import MediaChain +from app.chain.subscribe import SubscribeChain +from app.chain.tvdb import TvdbChain +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 as _SchemaRadarrMovie from app.schemas.servarr import ServarrIdResponse as _SchemaServarrIdResponse from app.schemas.servarr import ServarrLanguageProfile as _SchemaServarrLanguageProfile @@ -11,19 +22,7 @@ from app.schemas.servarr import ServarrRootFolder as _SchemaServarrRootFolder from app.schemas.servarr import ServarrSystemStatus as _SchemaServarrSystemStatus from app.schemas.servarr import ServarrTag as _SchemaServarrTag from app.schemas.servarr import SonarrSeries as _SchemaSonarrSeries -from app.api.response import ERROR_RESPONSES -from app.chain.media import MediaChain -from app.chain.subscribe import SubscribeChain -from app.chain.tvdb import TvdbChain -from app.domain.context import MediaInfo -from app.domain.metainfo import MetaInfo -from app.application.servarr import ServarrSubscription, ServarrSubscriptionService -from app.adapters.web.security.access import verify_apikey -from app.api.dependencies.subscription import get_servarr_subscription_service -from app.schemas.servarr import RadarrMovie -from app.schemas.servarr import SonarrSeries from app.schemas.types import MediaSource, MediaType -from app.runtime.version import get_app_version arr_router = APIRouter(tags=["servarr"], responses=ERROR_RESPONSES) diff --git a/app/api/servcookie.py b/app/api/servcookie.py index 634ce406f..e57706c9a 100644 --- a/app/api/servcookie.py +++ b/app/api/servcookie.py @@ -9,15 +9,15 @@ from fastapi import APIRouter, Body, Depends, Header, HTTPException, Path, Reque from fastapi.responses import PlainTextResponse from fastapi.routing import APIRoute +from app.api.response import ERROR_RESPONSES +from app.application.configuration import get_api_runtime_config_snapshot +from app.foundation.crypto import CryptoJsUtils, HashUtils +from app.runtime.log import logger from app.schemas.servcookie import CookieActionResponse as _SchemaCookieActionResponse from app.schemas.servcookie import CookieData as _SchemaCookieData from app.schemas.servcookie import CookieDecryptedPayload as _SchemaCookieDecryptedPayload from app.schemas.servcookie import CookieEncryptedPayload as _SchemaCookieEncryptedPayload from app.schemas.servcookie import CookiePassword as _SchemaCookiePassword -from app.api.response import ERROR_RESPONSES -from app.application.configuration import get_api_runtime_config_snapshot -from app.runtime.log import logger -from app.foundation.crypto import CryptoJsUtils, HashUtils class GzipRequest(Request): diff --git a/app/application/chain/context.py b/app/application/chain/context.py index 8b4c54d92..280abcca7 100644 --- a/app/application/chain/context.py +++ b/app/application/chain/context.py @@ -9,6 +9,7 @@ from typing import Any, Optional from app.application.chain.data import ChainDataPorts from app.application.chain.durable_events import ChainDurableEventWriter from app.application.configuration import ChainRuntimeConfig +from app.runtime.stop import StopState, runtime_stop_state MessageQueueFactory = Callable[[Callable[..., Any]], Any] @@ -34,6 +35,7 @@ class ChainRuntimeContext: configuration: ChainRuntimeConfig = field( default_factory=lambda: ChainRuntimeConfig(media_extensions=()) ) + stop_state: StopState = field(default_factory=lambda: runtime_stop_state) def _unconfigured_chain_runtime_context() -> ChainRuntimeContext: diff --git a/app/application/messaging/message.py b/app/application/messaging/message.py index a0e03f0bc..e422e3ceb 100644 --- a/app/application/messaging/message.py +++ b/app/application/messaging/message.py @@ -10,26 +10,24 @@ import time from contextvars import Context, copy_context from datetime import datetime from functools import partial -from typing import Any, Literal, Optional, List, Dict, Protocol, Union -from typing import Callable +from typing import Any, Callable, Dict, List, Literal, Optional, Protocol, Union from jinja2 import Template -from app.runtime.cache import TTLCache -from app.runtime.config import global_vars +from app.application.configuration import get_configured_system_config from app.domain.context import MediaInfo, MusicInfo, TorrentInfo from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic -from app.application.configuration import get_configured_system_config +from app.foundation import size as size_tools +from app.foundation.crypto import HashUtils +from app.foundation.singleton import Singleton, SingletonClass +from app.runtime.cache import TTLCache from app.runtime.log import logger +from app.runtime.stop import runtime_stop_state from app.schemas.message import Message from app.schemas.tmdb import TmdbEpisode from app.schemas.transfer import TransferInfo from app.schemas.types import MUSIC_ENTITY_ALBUM, SystemConfigKey -from app.foundation.singleton import Singleton, SingletonClass -from app.foundation import size as size_tools -from app.foundation.crypto import HashUtils - # 专辑名尾部的括号年份标记;重命名模板会独立追加 `({{year}})`, # 标签或目录名中自带的尾部年份若不剥离,会生成重复年份的目录名(issue #6355) @@ -998,7 +996,7 @@ class MessageQueueManager(metaclass=SingletonClass): current_time = datetime.now() if self._is_in_scheduled_time(current_time): while self._running and not self.queue.empty(): - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: break if not self._is_in_scheduled_time(datetime.now()): break diff --git a/app/chain/__init__.py b/app/chain/__init__.py index 7c8beadc4..0dfa1229c 100644 --- a/app/chain/__init__.py +++ b/app/chain/__init__.py @@ -5,7 +5,7 @@ import traceback from abc import ABCMeta from collections.abc import Callable from pathlib import Path -from typing import Optional, Any, Tuple, List, Set, Union, Dict +from typing import Any, Dict, List, Optional, Set, Tuple, Union from app.application.chain.context import ChainRuntimeContext, get_chain_runtime_context from app.application.chain.data import get_chain_data_ports @@ -18,24 +18,22 @@ from app.chain._recognition import RecognitionMixin from app.domain.context import Context, MediaInfo, SubtitleInfo, TorrentInfo from app.domain.meta.metabase import MetaBase from app.runtime.log import logger -from app.schemas.exception import RateLimitExceededException -from app.schemas.transfer import TransferInfo -from app.schemas.mediaserver import ExistMediaInfo -from app.schemas.transfer import DownloaderFile, DownloaderTorrent -from app.schemas.message import IncomingMessage -from app.schemas.mediaserver import WebhookEventInfo -from app.schemas.tmdb import TmdbEpisode -from app.schemas.context import MediaPerson -from app.schemas.workflow import FileItem -from app.schemas.system import TransferDirectoryConf from app.schemas.category import CategoryConfig +from app.schemas.context import MediaPerson +from app.schemas.exception import RateLimitExceededException +from app.schemas.mediaserver import ExistMediaInfo, WebhookEventInfo +from app.schemas.message import IncomingMessage +from app.schemas.system import TransferDirectoryConf +from app.schemas.tmdb import TmdbEpisode +from app.schemas.transfer import DownloaderFile, DownloaderTorrent, TransferInfo from app.schemas.types import ( - TorrentStatus, - MediaType, - MediaSourceSelection, - MediaImageType, EventType, + MediaImageType, + MediaSourceSelection, + MediaType, + TorrentStatus, ) +from app.schemas.workflow import FileItem class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, @@ -57,6 +55,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, self.filecache = context.file_cache self.async_filecache = context.async_file_cache self.runtime_config = context.configuration + self.stop_state = context.stop_state self.data_ports = context.data_ports or get_chain_data_ports() self.durable_event_writer = context.durable_event_writer self._module_dispatcher = context.module_dispatcher_factory( diff --git a/app/chain/_contracts.py b/app/chain/_contracts.py new file mode 100644 index 000000000..a1bb29534 --- /dev/null +++ b/app/chain/_contracts.py @@ -0,0 +1,96 @@ +"""Chain mixin 对宿主能力的静态契约。""" + +from __future__ import annotations + +from typing import Any, Protocol + + +class ChainRuntimeMixinHost(Protocol): + """识别与消息 mixin 共用的 Chain 运行能力。""" + + runtime_config: Any + eventmanager: Any + messageoper: Any + messagequeue: Any + + def run_module(self, method: str, **kwargs: Any) -> Any: + """调用同步模块能力。""" + ... + + async def async_run_module(self, method: str, **kwargs: Any) -> Any: + """调用异步模块能力。""" + ... + + +class MusicSubscribeMixinHost(Protocol): + """音乐订阅 mixin 对 SubscribeChain 的最小要求。""" + + @classmethod + def _music_media_chain(cls) -> Any: + """构造媒体识别链。""" + ... + + def _music_download_chain(self) -> Any: + """构造下载链。""" + ... + + def _music_search_chain(self) -> Any: + """构造搜索链。""" + ... + + def _music_site_keywords(self, mediainfo: Any) -> list[str]: + """构造音乐站点搜索关键字。""" + ... + + def _matches_music_resource(self, mediainfo: Any, *texts: Any) -> bool: + """判断站点资源文本是否匹配音乐目标。""" + ... + + def get_sub_sites(self, subscribe: Any) -> list[int]: ... + + def get_params(self, subscribe: Any) -> Any: ... + + def filter_torrents(self, *args: Any, **kwargs: Any) -> Any: ... + + def check_and_handle_existing_media(self, *args: Any, **kwargs: Any) -> Any: ... + + def finish_subscribe_or_not(self, *args: Any, **kwargs: Any) -> Any: ... + + def get_subscribe_source_keyword(self, subscribe: Any) -> str: ... + + +class InteractionMixinHost(Protocol): + """交互委托 mixin 对业务 Chain 的最小要求。""" + + _interaction_handler_type: type + + def _interaction_handler(self) -> Any: + """构造业务交互处理器。""" + ... + + +class TransferMixinHost(ChainRuntimeMixinHost, Protocol): + """整理辅助 mixin 对 TransferChain 的最小要求。""" + + @classmethod + def _transfer_media_chain(cls) -> Any: + """构造媒体识别链。""" + ... + + @classmethod + def _transfer_storage_chain(cls) -> Any: + """构造存储链。""" + ... + + @classmethod + def _transfer_subscribe_chain(cls) -> Any: + """构造订阅链。""" + ... + + def post_message(self, *args: Any, **kwargs: Any) -> Any: ... + + async def async_post_message(self, *args: Any, **kwargs: Any) -> Any: ... + + def obtain_images(self, *args: Any, **kwargs: Any) -> Any: ... + + def do_transfer(self, *args: Any, **kwargs: Any) -> Any: ... diff --git a/app/chain/_interaction.py b/app/chain/_interaction.py index d1a4d1f2c..a3d687e28 100644 --- a/app/chain/_interaction.py +++ b/app/chain/_interaction.py @@ -1,9 +1,11 @@ from typing import Optional, Tuple, Union +from app.chain._contracts import InteractionMixinHost from app.schemas.types import NotificationChannel class InteractionChainMixin: + __mixin_host_protocol__ = InteractionMixinHost """ 斜杠命令交互四件套委托:remote_list / parse_callback / handle_callback_interaction / handle_text_interaction。 diff --git a/app/chain/_messaging.py b/app/chain/_messaging.py index 486861728..338dd46ca 100644 --- a/app/chain/_messaging.py +++ b/app/chain/_messaging.py @@ -9,20 +9,21 @@ from datetime import datetime from typing import Any, Dict, List, Optional, Union from app.application.chain.data import get_chain_user_port +from app.application.messaging.message import MessageTemplateHelper +from app.application.notification import get_notification_switch +from app.chain._contracts import ChainRuntimeMixinHost from app.domain.context import Context, MediaInfo, MusicInfo, TorrentInfo from app.domain.meta.metabase import MetaBase from app.foundation.identity import normalize_internal_user_id -from app.application.messaging.message import MessageTemplateHelper -from app.application.notification import get_notification_switch from app.runtime.log import logger -from app.schemas.message import MessageResponse -from app.schemas.message import Message -from app.schemas.transfer import TransferInfo +from app.schemas.message import Message, MessageResponse from app.schemas.notification import ChannelCapability, ChannelCapabilityManager +from app.schemas.transfer import TransferInfo from app.schemas.types import EventType, NotificationChannel class MessageProcessingMixin: + __mixin_host_protocol__ = ChainRuntimeMixinHost """消息输入/处理状态机与通知派发规范化。""" def start_message_processing_status( @@ -117,6 +118,7 @@ class MessageProcessingMixin: class NotificationMixin: + __mixin_host_protocol__ = ChainRuntimeMixinHost """通知消息发送域:渲染、隔离路由、队列发送与消息编辑。""" def post_message( diff --git a/app/chain/_music.py b/app/chain/_music.py index d6b78ede3..8e780f53c 100644 --- a/app/chain/_music.py +++ b/app/chain/_music.py @@ -1,16 +1,22 @@ import copy from typing import Any, List, Optional, Tuple -from app.application.torrent import TorrentHelper +from app.application.chain.data import get_chain_subscribe_port +from app.application.configuration import get_configured_system_config from app.application.subscription.contract import ( build_subscribe_meta, subscribe_media_key, ) +from app.application.torrent import TorrentHelper +from app.chain._contracts import MusicSubscribeMixinHost from app.chain.download import DownloadChain from app.chain.media import MediaChain from app.chain.search import SearchChain -from app.application.chain.data import get_chain_subscribe_port -from app.application.configuration import get_configured_system_config + +# 旧测试与插件补丁入口;正式依赖通过宿主工厂逐步收敛。 +MediaChain = MediaChain +DownloadChain = DownloadChain +SearchChain = SearchChain from app.domain.context import Context, MediaInfo, MusicInfo from app.domain.media import MUSIC_SUBSCRIBABLE_TYPES from app.domain.meta.metamusic import MetaMusic @@ -35,6 +41,7 @@ def _normalize_music_total_tracks(value: Any) -> Optional[int]: class MusicSubscribeMixin: + __mixin_host_protocol__ = MusicSubscribeMixinHost """ 音乐订阅功能域 mixin:单曲/专辑目标识别、实体快照同步、候选筛选、 择优下载与完成推进。 diff --git a/app/chain/_recognition.py b/app/chain/_recognition.py index 22f5169c5..56c53bad9 100644 --- a/app/chain/_recognition.py +++ b/app/chain/_recognition.py @@ -7,20 +7,22 @@ import copy from typing import Optional -from app.runtime.execution import run_in_threadpool from app.adapters.external.server import MoviePilotServerHelper from app.application.configuration import get_configured_system_config +from app.chain._contracts import ChainRuntimeMixinHost from app.domain.context import MediaInfo, MusicInfo from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic -from app.runtime.cache import fresh, async_fresh +from app.runtime.cache import async_fresh, fresh from app.runtime.events import Event +from app.runtime.execution import run_in_threadpool from app.runtime.log import logger from app.schemas.media import normalize_media_source, resolve_media_identity from app.schemas.types import ChainEventType, MediaSource, MediaType, SystemConfigKey class RecognitionMixin: + __mixin_host_protocol__ = ChainRuntimeMixinHost def _can_use_media_recognize_share( self, diff --git a/app/chain/_transfer.py b/app/chain/_transfer.py index 139317c0f..e18c0b1f4 100644 --- a/app/chain/_transfer.py +++ b/app/chain/_transfer.py @@ -7,21 +7,12 @@ TransferChain 中。mixin 方法运行时经 MRO 解析,共享 TransferChain 注意:这里的方法均已去掉私有名前缀双下划线(__ -> _),因为 Python 的名字 改编按定义类生效,方法迁到 mixin 后 __ 前缀会改变改编目标,导致跨类调用失败。 """ -import asyncio from copy import deepcopy from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Tuple, Union -from app.schemas.history import DownloadHistory as _SchemaDownloadHistory -from app.schemas.transfer import EpisodeFormatRule as _SchemaEpisodeFormatRule from app.adapters.system.host import SystemUtils from app.application.agent import build_manual_redo_prompt, get_running_agent_manager -from app.application.formatting import EpisodeFormatRuleHelper -from app.application.history import clear_transfer_failures, resolve_history -from app.application.transfer import TransferTask, job_lock -from app.chain.media import MediaChain -from app.chain.storage import StorageChain -from app.chain.subscribe import SubscribeChain from app.application.chain.data import ( get_chain_download_history_port, get_chain_transfer_history_port, @@ -30,6 +21,18 @@ from app.application.configuration import ( get_chain_runtime_config_snapshot, get_configured_system_config, ) +from app.application.formatting import EpisodeFormatRuleHelper +from app.application.history import clear_transfer_failures, resolve_history +from app.application.transfer import TransferTask, job_lock +from app.chain._contracts import TransferMixinHost +from app.chain.media import MediaChain +from app.chain.storage import StorageChain +from app.chain.subscribe import SubscribeChain + +# 旧测试与插件补丁入口;正式依赖通过宿主工厂逐步收敛。 +MediaChain = MediaChain +StorageChain = StorageChain +SubscribeChain = SubscribeChain from app.domain.context import MediaInfo, MusicInfo from app.domain.media import normalize_music_type from app.domain.meta.metabase import MetaBase @@ -38,9 +41,10 @@ from app.foundation import text as text_tools from app.runtime.config import global_vars from app.runtime.log import logger from app.runtime.tasks import get_task_registry -from app.schemas.workflow import FileItem +from app.schemas.history import DownloadHistory as _SchemaDownloadHistory from app.schemas.message import Message from app.schemas.tmdb import TmdbEpisode +from app.schemas.transfer import EpisodeFormatRule as _SchemaEpisodeFormatRule from app.schemas.transfer import TransferInfo from app.schemas.types import ( MUSIC_ENTITY_ALBUM, @@ -51,6 +55,7 @@ from app.schemas.types import ( ReplyMode, SystemConfigKey, ) +from app.schemas.workflow import FileItem DownloadFiles = Any DownloadHistory = Any @@ -100,6 +105,7 @@ SUBTITLE_STEM_TAGS = { class FileFilterMixin: + __mixin_host_protocol__ = TransferMixinHost @staticmethod def _requires_automatic_category(task: TransferTask) -> bool: """ @@ -446,6 +452,7 @@ class FileFilterMixin: class ScrapeBatchMixin: + __mixin_host_protocol__ = TransferMixinHost def _send_metadata_scrape_event( self, task: TransferTask, transferinfo: TransferInfo @@ -647,6 +654,7 @@ class ScrapeBatchMixin: class EpisodeFormatMixin: + __mixin_host_protocol__ = TransferMixinHost def recommend_name(self, meta: MetaBase, mediainfo: MediaInfo) -> Optional[str]: """ @@ -837,6 +845,7 @@ class EpisodeFormatMixin: class HistoryMatchMixin: + __mixin_host_protocol__ = TransferMixinHost @staticmethod def _match_download_file( download_file: DownloadFiles, @@ -1023,6 +1032,7 @@ class HistoryMatchMixin: class FileKeyMixin: + __mixin_host_protocol__ = TransferMixinHost @staticmethod def _get_file_key(fileitem: FileItem) -> Tuple[str, str]: """ @@ -1110,6 +1120,7 @@ class FileKeyMixin: class ManualHistoryMixin: + __mixin_host_protocol__ = TransferMixinHost @staticmethod def _get_subscribe_custom_words( history_record: Optional[DownloadHistory], @@ -1234,6 +1245,7 @@ class ManualHistoryMixin: class FailedRetryMixin: + __mixin_host_protocol__ = TransferMixinHost @staticmethod def build_failed_transfer_buttons( history_id: Optional[int], diff --git a/app/chain/anilist.py b/app/chain/anilist.py index 74f803e3f..7773dfdd5 100644 --- a/app/chain/anilist.py +++ b/app/chain/anilist.py @@ -1,8 +1,8 @@ from typing import Optional -from app.schemas.context import MediaPerson as _SchemaMediaPerson from app.chain import ChainBase from app.domain.context import MediaInfo +from app.schemas.context import MediaPerson as _SchemaMediaPerson class AniListChain(ChainBase): diff --git a/app/chain/bangumi.py b/app/chain/bangumi.py index a154ca976..81b693a6b 100644 --- a/app/chain/bangumi.py +++ b/app/chain/bangumi.py @@ -1,8 +1,8 @@ -from typing import Optional, List +from typing import List, Optional -from app.schemas.context import MediaPerson as _SchemaMediaPerson from app.chain import ChainBase from app.domain.context import MediaInfo +from app.schemas.context import MediaPerson as _SchemaMediaPerson class BangumiChain(ChainBase): diff --git a/app/chain/dashboard.py b/app/chain/dashboard.py index 0a4a6f3c3..c45a19556 100644 --- a/app/chain/dashboard.py +++ b/app/chain/dashboard.py @@ -1,8 +1,8 @@ -from typing import Optional, List +from typing import List, Optional +from app.chain import ChainBase from app.schemas.dashboard import DownloaderInfo as _SchemaDownloaderInfo from app.schemas.dashboard import Statistic as _SchemaStatistic -from app.chain import ChainBase class DashboardChain(ChainBase): diff --git a/app/chain/douban.py b/app/chain/douban.py index d46ce7755..fc6c382be 100644 --- a/app/chain/douban.py +++ b/app/chain/douban.py @@ -1,9 +1,9 @@ from typing import Any, List, Optional -from app.schemas.context import MediaPerson as _SchemaMediaPerson from app.chain import ChainBase from app.domain.context import MediaInfo, MusicAlbumInfo, MusicInfo from app.domain.meta.metamusic import MetaMusic +from app.schemas.context import MediaPerson as _SchemaMediaPerson from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource, MediaType diff --git a/app/chain/download.py b/app/chain/download.py index 2d09ce246..e90dc8dd3 100644 --- a/app/chain/download.py +++ b/app/chain/download.py @@ -6,18 +6,25 @@ import re import shutil import time from pathlib import Path -from typing import TYPE_CHECKING, List, Optional, Tuple, Set, Dict, Union +from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple, Union from urllib.parse import parse_qs, urlencode, urljoin, urlparse -from app.schemas.transfer import DownloaderTorrent as _SchemaDownloaderTorrent -from app.schemas.system import TransferDirectoryConf as _SchemaTransferDirectoryConf -from app.schemas.workflow import FileItem as _SchemaFileItem +from app.adapters.network.http import RequestUtils +from app.adapters.system.host import SystemUtils +from app.application.chain.data import ( + get_chain_download_failure_port, + get_chain_download_history_port, + get_chain_media_server_port, +) +from app.application.configuration import get_chain_runtime_config_snapshot +from app.application.directory import DirectoryHelper, validate_download_save_path +from app.application.download import selection as _selection +from app.application.download.tasks import DownloadTaskService +from app.application.torrent import TorrentHelper from app.chain import ChainBase from app.chain.media import MediaChain from app.chain.storage import StorageChain -from app.runtime.cache import FileCache -from app.runtime.config import global_vars -from app.application.configuration import get_chain_runtime_config_snapshot +from app.domain import episode as episode_rules from app.domain.context import ( Context, MediaInfo, @@ -25,36 +32,36 @@ from app.domain.context import ( SubtitleInfo, TorrentInfo, ) -from app.runtime.events import eventmanager, Event from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfo -from app.application.chain.data import ( - get_chain_download_failure_port, - get_chain_download_history_port, - get_chain_media_server_port, -) -from app.application.directory import DirectoryHelper, validate_download_save_path -from app.application.download.tasks import DownloadTaskService -from app.application.download import selection as _selection -from app.runtime.thread import ThreadHelper -from app.application.torrent import TorrentHelper -from app.runtime.log import logger -from app.schemas.mediaserver import ExistMediaInfo -from app.schemas.file import FileURI -from app.schemas.mediaserver import NotExistMediaInfo -from app.schemas.transfer import DownloaderTorrent -from app.schemas.message import Message -from app.schemas.event import ResourceSelectionEventData -from app.schemas.event import ResourceDownloadEventData -from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource, MediaType, TorrentStatus, EventType, NotificationChannel, MessageType, ContentType, \ - ChainEventType -from app.adapters.network.http import RequestUtils -from app.schemas.media import build_media_key, resolve_media_identity -from app.domain import episode as episode_rules from app.foundation import size as size_tools from app.foundation import text as text_tools -from app.adapters.system.host import SystemUtils +from app.runtime.cache import FileCache +from app.runtime.events import Event, eventmanager +from app.runtime.log import logger +from app.runtime.stop import runtime_stop_state +from app.runtime.thread import ThreadHelper +from app.schemas.event import ResourceDownloadEventData, ResourceSelectionEventData +from app.schemas.file import FileURI +from app.schemas.media import build_media_key, resolve_media_identity +from app.schemas.mediaserver import ExistMediaInfo, NotExistMediaInfo +from app.schemas.message import Message +from app.schemas.system import TransferDirectoryConf as _SchemaTransferDirectoryConf +from app.schemas.transfer import DownloaderTorrent +from app.schemas.transfer import DownloaderTorrent as _SchemaDownloaderTorrent +from app.schemas.types import ( + MUSIC_ENTITY_ALBUM, + ChainEventType, + ContentType, + EventType, + MediaSource, + MediaType, + MessageType, + NotificationChannel, + TorrentStatus, +) +from app.schemas.workflow import FileItem as _SchemaFileItem if TYPE_CHECKING: from typing import Any @@ -1000,7 +1007,7 @@ class DownloadChain(ChainBase): MediaType.MUSIC: set(), } for context in contexts: - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: break media_type = context.media_info.type if media_type not in downloaded_keys: @@ -1657,7 +1664,7 @@ class DownloadChain(ChainBase): for need_mid, need_season in need_seasons.items(): # 循环种子 for context in contexts: - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: break # 媒体信息 media = context.media_info @@ -1801,7 +1808,7 @@ class DownloadChain(ChainBase): need_episodes = list(range(start_episode, total_episode + 1)) # 循环种子 for context in contexts: - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: break # 媒体信息 media = context.media_info @@ -1889,7 +1896,7 @@ class DownloadChain(ChainBase): continue # 循环种子 for context in contexts: - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: break # 媒体信息 media = context.media_info @@ -2040,7 +2047,7 @@ class DownloadChain(ChainBase): media_id=media_id, episode_group=mediainfo.episode_group) if not mediainfo: - logger.error(f"媒体信息识别失败!") + logger.error("媒体信息识别失败!") return False, {} if not mediainfo.seasons: logger.error(f"媒体信息中没有季集信息:{mediainfo.title_year}") diff --git a/app/chain/interaction.py b/app/chain/interaction.py index ad4850b80..907417e58 100644 --- a/app/chain/interaction.py +++ b/app/chain/interaction.py @@ -2,18 +2,18 @@ import math import re from typing import Any, Dict, List, Optional, Tuple, Union -from app.chain import ChainBase -from app.chain.download import DownloadChain -from app.chain.media import MediaChain -from app.chain.search import SearchChain -from app.chain.subscribe import SubscribeChain +from app.application.chain.data import get_chain_user_port from app.application.directory import DirectoryHelper from app.application.messaging.media import ( PendingMediaInteraction, media_interaction_manager, ) from app.application.torrent import TorrentHelper -from app.application.chain.data import get_chain_user_port +from app.chain import ChainBase +from app.chain.download import DownloadChain +from app.chain.media import MediaChain +from app.chain.search import SearchChain +from app.chain.subscribe import SubscribeChain from app.domain import episode as episode_rules from app.domain import title as title_rules from app.domain.context import Context, MediaInfo @@ -22,9 +22,9 @@ from app.foundation import url as url_tools from app.runtime.log import logger from app.schemas.download import DownloadDirectory from app.schemas.file import FileURI +from app.schemas.media import build_media_key, resolve_media_identity from app.schemas.mediaserver import NotExistMediaInfo from app.schemas.message import Message -from app.schemas.media import build_media_key, resolve_media_identity from app.schemas.notification import ChannelCapabilityManager from app.schemas.system import TransferDirectoryConf from app.schemas.types import MediaType, NotificationChannel diff --git a/app/chain/media.py b/app/chain/media.py index c7fe9cb31..9bf6fa7bc 100644 --- a/app/chain/media.py +++ b/app/chain/media.py @@ -3,15 +3,15 @@ from pathlib import Path from threading import Lock from typing import Any, Iterable, List, Optional, Tuple, Union -from app.runtime.execution import run_in_threadpool -from app.schemas.event import MediaRecognizeConvertEventData as _SchemaMediaRecognizeConvertEventData +from app.application.audio import AudioMetadataHelper +from app.application.configuration import get_chain_runtime_config_snapshot +from app.application.music.catalog import MusicCatalogService from app.chain import ChainBase from app.chain.acoustid import AcoustIdChain from app.chain.douban import DoubanChain from app.chain.musicbrainz import MusicBrainzChain, _MusicMetadataSourceChain from app.chain.theaudiodb import TheAudioDbChain -from app.runtime.cache import async_fresh, fresh -from app.application.configuration import get_chain_runtime_config_snapshot +from app.domain import title as title_rules from app.domain.context import ( Context, MediaInfo, @@ -19,13 +19,18 @@ from app.domain.context import ( MusicArtistInfo, MusicInfo, ) -from app.runtime.events import Event +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.domain.metainfo import MetaInfo, MetaInfoPath -from app.application.audio import AudioMetadataHelper -from app.application.music.catalog import MusicCatalogService +from app.foundation.singleton import Singleton +from app.foundation.text import convert as zhconv_convert +from app.runtime.cache import async_fresh, fresh +from app.runtime.events import Event +from app.runtime.execution import run_in_threadpool from app.runtime.log import logger +from app.schemas.event import MediaRecognizeConvertEventData as _SchemaMediaRecognizeConvertEventData +from app.schemas.media import normalize_media_source, resolve_media_identity from app.schemas.types import ( MUSIC_ENTITY_RECORDING, ChainEventType, @@ -33,11 +38,6 @@ from app.schemas.types import ( MediaSourceSelection, MediaType, ) -from app.domain.media import is_music_media_source -from app.schemas.media import normalize_media_source, resolve_media_identity -from app.foundation.singleton import Singleton -from app.foundation.text import convert as zhconv_convert -from app.domain import title as title_rules recognize_lock = Lock() @@ -786,9 +786,9 @@ class MediaChain(ChainBase, metaclass=Singleton): year = None # 结果赋值 if title == org_meta.name and year == org_meta.year: - logger.info(f"辅助识别与原始识别结果一致,无需重新识别媒体信息") + logger.info("辅助识别与原始识别结果一致,无需重新识别媒体信息") return None - logger.info(f"辅助识别结果与原始识别结果不一致,重新匹配媒体信息 ...") + logger.info("辅助识别结果与原始识别结果不一致,重新匹配媒体信息 ...") org_meta.name = title org_meta.year = year org_meta.begin_season = season_number @@ -1725,9 +1725,9 @@ class MediaChain(ChainBase, metaclass=Singleton): year = None # 结果赋值 if title == org_meta.name and year == org_meta.year: - logger.info(f"辅助识别与原始识别结果一致,无需重新识别媒体信息") + logger.info("辅助识别与原始识别结果一致,无需重新识别媒体信息") return None - logger.info(f"辅助识别结果与原始识别结果不一致,重新匹配媒体信息 ...") + logger.info("辅助识别结果与原始识别结果不一致,重新匹配媒体信息 ...") org_meta.name = title org_meta.year = year org_meta.begin_season = season_number diff --git a/app/chain/mediaserver.py b/app/chain/mediaserver.py index 2d7cbd5de..838b1dfdc 100644 --- a/app/chain/mediaserver.py +++ b/app/chain/mediaserver.py @@ -1,18 +1,15 @@ import threading from datetime import datetime -from typing import Callable, Dict, List, Union, Optional, Generator, Any, Tuple +from typing import Any, Callable, Dict, Generator, List, Optional, Tuple, Union -from app.chain import ChainBase -from app.runtime.config import global_vars from app.application.chain.data import get_chain_media_server_port from app.application.mediaserver import get_mediaserver_configs -from app.runtime.log import logger -from app.schemas.mediaserver import MediaServerLibrary -from app.schemas.mediaserver import MediaServerItem -from app.schemas.mediaserver import MediaServerSeasonInfo -from app.schemas.mediaserver import MediaServerPlayItem -from app.schemas.types import MediaType from app.application.security.url import SecurityUtils +from app.chain import ChainBase +from app.runtime.log import logger +from app.runtime.stop import runtime_stop_state +from app.schemas.mediaserver import MediaServerItem, MediaServerLibrary, MediaServerPlayItem, MediaServerSeasonInfo +from app.schemas.types import MediaType lock = threading.Lock() @@ -355,7 +352,7 @@ class MediaServerChain(ChainBase): library_media_total = library_media_counts.get(str(library.id)) library_count = 0 for item in self.items(server=server_name, library_id=library.id): - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: return total_count, global_media_finished if not item or not item.item_id: continue @@ -554,7 +551,7 @@ class MediaServerChain(ChainBase): global_media_total=global_media_total, global_media_finished=global_media_finished, ) - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: return total_count += server_count logger.info(f"媒体服务器 {server_name} 数据同步完成,总同步数量:{total_count}") diff --git a/app/chain/message.py b/app/chain/message.py index 3e88c1cda..90677f698 100644 --- a/app/chain/message.py +++ b/app/chain/message.py @@ -7,22 +7,16 @@ from concurrent.futures import CancelledError as FutureCancelledError from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import Any, Optional, Dict, Union, List, Tuple +from typing import Any, Dict, List, Optional, Tuple, Union from urllib.parse import unquote, urlparse +from app.adapters.network.http import RequestUtils from app.application.agent import ( get_running_agent_manager, is_audio_input_available, supports_image_input, transcribe_audio, ) -from app.chain import ChainBase -from app.chain.site import SiteChain -from app.chain.subscribe import SubscribeChain -from app.chain.transfer import TransferChain -from app.chain.interaction import MediaInteractionChain as _MediaInteractionChain -from app.runtime.config import global_vars -from app.runtime.tasks import get_task_registry from app.application.messaging.agent import agent_interaction_manager, parse_agent_choice_callback from app.application.messaging.interaction import InteractionContext, InteractionDispatch from app.application.messaging.media import media_interaction_manager @@ -32,12 +26,17 @@ from app.application.messaging.session import MessageSessionService from app.application.messaging.site import site_interaction_manager from app.application.messaging.skill import SkillInteractionHandler, skill_interaction_manager from app.application.messaging.subscribe import subscribe_interaction_manager +from app.chain import ChainBase +from app.chain.interaction import MediaInteractionChain as _MediaInteractionChain +from app.chain.site import SiteChain +from app.chain.subscribe import SubscribeChain +from app.chain.transfer import TransferChain +from app.runtime.config import global_vars from app.runtime.log import logger -from app.schemas.message import IncomingMessage -from app.schemas.message import Message +from app.runtime.tasks import get_task_registry +from app.schemas.message import IncomingMessage, Message from app.schemas.notification import ChannelCapabilityManager from app.schemas.types import EventType, NotificationChannel -from app.adapters.network.http import RequestUtils class MessageChain(ChainBase): diff --git a/app/chain/recommend.py b/app/chain/recommend.py index 7152fa6c2..ec6a478b2 100644 --- a/app/chain/recommend.py +++ b/app/chain/recommend.py @@ -2,25 +2,25 @@ from typing import Callable, List, Optional import pillow_avif # noqa: F401 # pylint: disable=unused-import # AVIF 注册副作用 +from app.application.image import ImageHelper from app.chain import ChainBase from app.chain.bangumi import BangumiChain from app.chain.douban import DoubanChain from app.chain.listenbrainz import ListenBrainzChain from app.chain.tmdb import TmdbChain -from app.runtime.cache import cached, fresh -from app.runtime.config import global_vars from app.domain.context import MusicInfo -from app.application.image import ImageHelper +from app.foundation.singleton import Singleton +from app.runtime.cache import cached, fresh +from app.runtime.execution import log_execution_time from app.runtime.log import logger +from app.runtime.stop import runtime_stop_state +from app.schemas.media import normalize_media_source from app.schemas.types import ( MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaSource, MediaType, ) -from app.runtime.execution import log_execution_time -from app.schemas.media import normalize_media_source -from app.foundation.singleton import Singleton class RecommendChain(ChainBase, metaclass=Singleton): @@ -222,7 +222,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): # 这里避免区间内连续调用相同来源,因此遍历方案为每页遍历所有推荐来源,再进行页数遍历 for page in range(1, self.cache_max_pages + 1): for method in recommend_methods: - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: return if method in methods_finished: continue @@ -277,7 +277,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): total_num = len(datas) for index, data in enumerate(datas, start=1): - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: return poster_path = data.get("poster_path") if poster_path: diff --git a/app/chain/scraping.py b/app/chain/scraping.py index f305baf78..6ad0abff5 100644 --- a/app/chain/scraping.py +++ b/app/chain/scraping.py @@ -7,47 +7,44 @@ from tempfile import NamedTemporaryFile, TemporaryDirectory from threading import Lock from typing import Any, Iterable, List, Optional, Tuple, Union -from app.schemas.workflow import FileItem as _SchemaFileItem +from app.adapters.network.http import RequestUtils +from app.application.audio import AudioMetadataHelper +from app.application.configuration import ( + get_chain_runtime_config_snapshot, + get_configured_system_config, +) from app.chain import ChainBase from app.chain.lrclib import LrclibChain +from app.chain.media import MediaChain from app.chain.storage import StorageChain -from app.runtime.cache import cached from app.domain.context import ( MediaInfo, MusicAlbumInfo, MusicInfo, MusicLyrics, ) -from app.runtime.events import eventmanager, Event from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfo, MetaInfoPath -from app.application.configuration import ( - get_chain_runtime_config_snapshot, - get_configured_system_config, -) -from app.application.audio import AudioMetadataHelper +from app.foundation.singleton import Singleton +from app.runtime.cache import cached +from app.runtime.events import Event, eventmanager from app.runtime.log import logger -from app.schemas.workflow import FileItem +from app.runtime.reload import ConfigReloadMixin +from app.schemas.media import resolve_media_identity from app.schemas.types import ( MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, EventType, MediaSource, MediaType, - ScrapingTarget, ScrapingMetadata, ScrapingPolicy, + ScrapingTarget, SystemConfigKey, ) -from app.adapters.network.http import RequestUtils -from app.schemas.media import resolve_media_identity -from app.runtime.reload import ConfigReloadMixin -from app.foundation.singleton import Singleton - - - -from app.chain.media import MediaChain +from app.schemas.workflow import FileItem +from app.schemas.workflow import FileItem as _SchemaFileItem scraping_lock = Lock() diff --git a/app/chain/search.py b/app/chain/search.py index 35af22c72..4aee6207b 100644 --- a/app/chain/search.py +++ b/app/chain/search.py @@ -7,34 +7,34 @@ import time from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, as_completed, wait from contextlib import aclosing from datetime import datetime -from typing import AsyncIterator, Any, Awaitable, Callable, Dict, Iterable, Tuple -from typing import List, Optional +from typing import Any, AsyncIterator, Awaitable, Callable, Dict, Iterable, List, Optional, Tuple from unicodedata import normalize -from app.runtime.execution import run_in_threadpool, submit_with_context -from app.chain import ChainBase -from app.chain.media import MediaChain -from app.runtime.config import global_vars -from app.domain.context import Context -from app.domain.context import MediaInfo, SubtitleInfo, TorrentInfo -from app.runtime.events import eventmanager, Event -from app.domain.meta.metamusic import MetaMusic -from app.domain.metainfo import MetaInfo -from app.domain.context import MusicInfo from app.application.configuration import ( get_chain_runtime_config_snapshot, get_configured_system_config, ) -from app.runtime.progress import AsyncProgressHelper, ProgressHelper -from app.runtime.tasks import get_task_registry -from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module from app.application.search.state import ( SearchStateService, normalize_search_params, stringify_sites, ) +from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module from app.application.torrent import TorrentHelper +from app.chain import ChainBase +from app.chain.media import MediaChain +from app.domain.context import Context, MediaInfo, MusicInfo, SubtitleInfo, TorrentInfo +from app.domain.meta.metamusic import MetaMusic +from app.domain.metainfo import MetaInfo +from app.foundation import size as size_tools +from app.foundation.text import convert as zhconv_convert +from app.runtime.events import Event, eventmanager +from app.runtime.execution import run_in_threadpool, submit_with_context from app.runtime.log import logger +from app.runtime.progress import AsyncProgressHelper, ProgressHelper +from app.runtime.stop import runtime_stop_state +from app.runtime.tasks import get_task_registry +from app.schemas.media import build_media_key, resolve_media_identity from app.schemas.mediaserver import NotExistMediaInfo from app.schemas.types import ( MUSIC_ENTITY_ALBUM, @@ -44,9 +44,6 @@ from app.schemas.types import ( ProgressKey, SystemConfigKey, ) -from app.schemas.media import build_media_key, resolve_media_identity -from app.foundation import size as size_tools -from app.foundation.text import convert as zhconv_convert class SearchChain(ChainBase): @@ -1355,7 +1352,7 @@ class SearchChain(ChainBase): logger.info(f"开始匹配结果 标题:{mediainfo.title},原标题:{mediainfo.original_title},别名:{mediainfo.names}") progress.update(value=51, text=f'开始匹配,总 {_total} 个资源 ...') for torrent in torrents: - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: break _count += 1 progress.update(value=(_count / _total) * 96, @@ -1709,7 +1706,7 @@ class SearchChain(ChainBase): **self._media_recognize_kwargs(mediainfo), ) if not mediainfo: - logger.error(f'媒体信息识别失败!') + logger.error('媒体信息识别失败!') return [] # 准备搜索参数 @@ -1802,7 +1799,7 @@ class SearchChain(ChainBase): **self._media_recognize_kwargs(mediainfo), ) if not mediainfo: - logger.error(f'媒体信息识别失败!') + logger.error('媒体信息识别失败!') return [] # 准备搜索参数 @@ -1885,7 +1882,7 @@ class SearchChain(ChainBase): **self._media_recognize_kwargs(mediainfo), ) if not mediainfo: - logger.error(f'媒体信息识别失败!') + logger.error('媒体信息识别失败!') yield { "type": "error", "success": False, @@ -2071,7 +2068,7 @@ class SearchChain(ChainBase): match_subtitles = [] logger.info(f"开始匹配字幕 标题:{mediainfo.title},原标题:{mediainfo.original_title},别名:{mediainfo.names}") for subtitle in subtitles: - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: break subtitle_names = self.__build_subtitle_names(subtitle) if not subtitle_names: @@ -2384,7 +2381,7 @@ class SearchChain(ChainBase): try: while pending_tasks: - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: break done_tasks, _ = wait(pending_tasks, return_when=FIRST_COMPLETED) for future in done_tasks: @@ -2460,7 +2457,7 @@ class SearchChain(ChainBase): try: while pending_tasks: - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: break done_tasks, _ = await asyncio.wait( pending_tasks, diff --git a/app/chain/site.py b/app/chain/site.py index 050e95176..cfd010ca6 100644 --- a/app/chain/site.py +++ b/app/chain/site.py @@ -1,35 +1,35 @@ import base64 import re from datetime import datetime -from typing import Any, Callable, Optional, Tuple, Union, Dict +from typing import Any, Callable, Dict, Optional, Tuple, Union from urllib.parse import urljoin -from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module from lxml import etree -from app.chain import ChainBase -from app.chain._interaction import InteractionChainMixin -from app.runtime.config import global_vars -from app.runtime.events import Event, eventmanager -from app.application.chain.data import get_chain_site_port -from app.application.configuration import get_configured_system_config +from app.adapters.external.cookiecloud import CookieCloudHelper from app.adapters.network.browser import PlaywrightHelper from app.adapters.network.cloudflare import under_challenge -from app.application.security.cookie import CookieHelper -from app.adapters.external.cookiecloud import CookieCloudHelper +from app.adapters.network.http import RequestUtils +from app.application.chain.data import get_chain_site_port +from app.application.configuration import get_configured_system_config from app.application.messaging.site import SiteInteractionHandler from app.application.rss import RssHelper -from app.runtime.log import logger -from app.schemas.notification import NotificationChannel -from app.schemas.message import Message -from app.schemas.site import SiteUserData -from app.schemas.types import EventType, MessageType -from app.adapters.network.http import RequestUtils -from app.domain.site import SiteUtils +from app.application.security.cookie import CookieHelper +from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module +from app.chain import ChainBase +from app.chain._interaction import InteractionChainMixin from app.domain import site as site_rules +from app.domain.site import SiteUtils from app.foundation import size as size_tools from app.foundation import url as url_tools from app.foundation.dom import DomUtils +from app.runtime.events import Event, eventmanager +from app.runtime.log import logger +from app.runtime.stop import runtime_stop_state +from app.schemas.message import Message +from app.schemas.notification import NotificationChannel +from app.schemas.site import SiteUserData +from app.schemas.types import EventType, MessageType Site = Any @@ -83,7 +83,7 @@ class SiteChain(InteractionChainMixin, ChainBase): re.search(r"(贵宾|VIP?)", userdata.user_level or "", re.IGNORECASE)): self.post_message(Message( mtype=MessageType.SiteMessage, - title=f"【站点分享率低预警】", + title="【站点分享率低预警】", text=f"站点 {site.get('name')} 分享率 {userdata.ratio},请注意!" )) return userdata @@ -140,7 +140,7 @@ class SiteChain(InteractionChainMixin, ChainBase): data={"total": total_num, "finished": 0}, ) for index, site in enumerate(sites, start=1): - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: return None if progress_callback: progress_callback( @@ -429,7 +429,7 @@ class SiteChain(InteractionChainMixin, ChainBase): update_count = add_count = fail_count = 0 for index, (domain, cookie) in enumerate(cookies.items(), start=1): # 检查系统是否停止 - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: logger.info("系统正在停止,中断CookieCloud同步") return False, "系统正在停止,同步被中断" if progress_callback: @@ -708,8 +708,8 @@ class SiteChain(InteractionChainMixin, ChainBase): timeout=timeout) if not public and not SiteUtils.is_logged_in(page_source): if under_challenge(page_source): - return False, f"无法通过Cloudflare!" - return False, f"仿真登录失败,Cookie已失效!" + return False, "无法通过Cloudflare!" + return False, "仿真登录失败,Cookie已失效!" else: res = RequestUtils(cookies=site_cookie, ua=ua, @@ -731,7 +731,7 @@ class SiteChain(InteractionChainMixin, ChainBase): elif res is not None: return False, f"错误:{res.status_code} {res.reason}!" else: - return False, f"无法打开网站!" + return False, "无法打开网站!" return True, "连接成功" def _interaction_handler(self) -> "SiteInteractionHandler": diff --git a/app/chain/storage.py b/app/chain/storage.py index 5e596ea15..7cbe09fd9 100644 --- a/app/chain/storage.py +++ b/app/chain/storage.py @@ -1,10 +1,10 @@ from pathlib import Path -from typing import Any, Optional, List, Dict +from typing import Any, Dict, List, Optional -from app.schemas.workflow import FileItem as _SchemaFileItem -from app.chain import ChainBase from app.application.directory import DirectoryHelper +from app.chain import ChainBase from app.runtime.log import logger +from app.schemas.workflow import FileItem as _SchemaFileItem class StorageChain(ChainBase): diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index b584d4c9f..8239039fc 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -5,35 +5,9 @@ import threading import time from dataclasses import dataclass from datetime import datetime -from typing import Any, Callable, Dict, List, Optional, Union, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple, Union -from app.schemas.mediaserver import NotExistMediaInfo as _SchemaNotExistMediaInfo -from app.schemas.message import Message as _SchemaMessage -from app.schemas.subscribe import SubscrbieInfo as _SchemaSubscrbieInfo -from app.schemas.subscribe import SubscribeDownloadFileInfo as _SchemaSubscribeDownloadFileInfo -from app.schemas.subscribe import SubscribeEpisodeInfo as _SchemaSubscribeEpisodeInfo -from app.schemas.subscribe import SubscribeLibraryFileInfo as _SchemaSubscribeLibraryFileInfo -from app.schemas.workflow import Subscribe as _SchemaSubscribe -from app.chain import ChainBase -from app.chain._interaction import InteractionChainMixin -from app.chain._music import MusicSubscribeMixin -from app.chain.download import DownloadChain -from app.chain.media import MediaChain -from app.chain.mediaserver import MediaServerChain -from app.chain.search import SearchChain -from app.chain.tmdb import TmdbChain -from app.chain.torrents import TorrentsChain -from app.runtime.config import global_vars -from app.domain.context import ( - Context, - MediaInfo, - TorrentInfo, -) -from app.runtime.events import eventmanager, Event -from app.domain.meta.metabase import MetaBase -from app.domain.meta.metamusic import MetaMusic -from app.domain.meta.words import WordsMatcher -from app.domain.metainfo import MetaInfo +from app.adapters.external.server import MoviePilotServerHelper from app.application.chain.data import ( get_chain_download_history_port, get_chain_site_port, @@ -43,30 +17,66 @@ from app.application.configuration import ( get_chain_runtime_config_snapshot, get_configured_system_config, ) -from app.application.messaging.subscribe import SubscribeInteractionHandler -from app.application.messaging.message import MessageTemplateHelper from app.application.mediaserver import MediaServerHelper -from app.application.subscription.write import add_subscribe, async_add_subscribe -from app.application.subscription.complete import get_subscription_completion_scope +from app.application.messaging.message import MessageTemplateHelper +from app.application.messaging.subscribe import SubscribeInteractionHandler from app.application.subscription import priority as _priority +from app.application.subscription.complete import get_subscription_completion_scope +from app.application.subscription.contract import ( + build_subscribe_meta as _build_subscribe_meta, +) +from app.application.subscription.contract import ( + subscribe_media_key, + subscribe_media_keys, +) from app.application.subscription.delete import ( SubscribeDeletionActor, get_sync_delete_subscribe_scope, ) -from app.application.subscription.contract import ( - build_subscribe_meta as _build_subscribe_meta, - subscribe_media_key, - subscribe_media_keys, -) from app.application.subscription.query import SubscriptionQueryService -from app.adapters.external.server import MoviePilotServerHelper +from app.application.subscription.write import add_subscribe, async_add_subscribe from app.application.torrent import TorrentHelper +from app.chain import ChainBase +from app.chain._interaction import InteractionChainMixin +from app.chain._music import MusicSubscribeMixin +from app.chain.download import DownloadChain +from app.chain.media import MediaChain +from app.chain.mediaserver import MediaServerChain +from app.chain.search import SearchChain +from app.chain.tmdb import TmdbChain +from app.chain.torrents import TorrentsChain +from app.domain.context import ( + Context, + MediaInfo, + TorrentInfo, +) +from app.domain.meta.metabase import MetaBase +from app.domain.meta.metamusic import MetaMusic +from app.domain.meta.words import WordsMatcher +from app.domain.metainfo import MetaInfo +from app.runtime.events import Event, eventmanager from app.runtime.log import logger -from app.schemas.event import SubscribeEpisodesRefreshEventData -from app.schemas.event import SubscribeCompletionCheckEventData -from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource, MediaType, SystemConfigKey, NotificationChannel, MessageType, EventType, ChainEventType, \ - ContentType +from app.runtime.stop import runtime_stop_state +from app.schemas.event import SubscribeCompletionCheckEventData, SubscribeEpisodesRefreshEventData from app.schemas.media import normalize_media_source, resolve_media_identity +from app.schemas.mediaserver import NotExistMediaInfo as _SchemaNotExistMediaInfo +from app.schemas.message import Message as _SchemaMessage +from app.schemas.subscribe import SubscrbieInfo as _SchemaSubscrbieInfo +from app.schemas.subscribe import SubscribeDownloadFileInfo as _SchemaSubscribeDownloadFileInfo +from app.schemas.subscribe import SubscribeEpisodeInfo as _SchemaSubscribeEpisodeInfo +from app.schemas.subscribe import SubscribeLibraryFileInfo as _SchemaSubscribeLibraryFileInfo +from app.schemas.types import ( + MUSIC_ENTITY_ALBUM, + ChainEventType, + ContentType, + EventType, + MediaSource, + MediaType, + MessageType, + NotificationChannel, + SystemConfigKey, +) +from app.schemas.workflow import Subscribe as _SchemaSubscribe if hasattr(_SchemaSubscribe, "model_fields"): Subscribe = _SchemaSubscribe @@ -191,6 +201,28 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): 电影下载优先级 writer 单独维护。 """ + @classmethod + def _music_media_chain(cls): + """为音乐 mixin 提供可替换的媒体识别构造点。""" + from app.chain import _music as _music_mixin + return (_music_mixin.MediaChain or MediaChain)() + + def _music_download_chain(self): + """为音乐 mixin 提供可替换的下载构造点。""" + from app.chain import _music as _music_mixin + return (_music_mixin.DownloadChain or DownloadChain)() + + def _music_search_chain(self): + """为音乐 mixin 提供可替换的搜索构造点。""" + from app.chain import _music as _music_mixin + return (_music_mixin.SearchChain or SearchChain)() + + def _music_site_keywords(self, mediainfo): + return SearchChain.music_site_keywords(mediainfo) + + def _matches_music_resource(self, mediainfo, *texts): + return SearchChain.matches_music_resource(mediainfo, *texts) + # 交互处理器类注入,供 InteractionChainMixin 的 parse_callback 委托 _interaction_handler_type = SubscribeInteractionHandler @@ -1179,7 +1211,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): try: # 遍历订阅 for index, subscribe in enumerate(subscribes, start=1): - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: break processed_subscribes.append(subscribe) if progress_callback: @@ -1275,7 +1307,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): matched_contexts = [] try: for context in contexts: - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: break torrent_meta = context.meta_info torrent_info = context.torrent_info @@ -1592,11 +1624,11 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): """预识别待匹配资源,并保留原上下文供后续订阅复用。""" processed_torrents: Dict[str, List[Context]] = {} for domain, contexts in torrents.items(): - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: break processed_torrents[domain] = [] for context in contexts: - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: break if context.torrent_info and getattr(context.torrent_info, "category", None) in ( MediaType.MUSIC, @@ -1699,7 +1731,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): ) try: for index, subscribe in enumerate(subscribes, start=1): - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: break if progress_callback: progress_callback( @@ -1772,13 +1804,13 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): systemconfig = _system_config() wordsmatcher = WordsMatcher() for domain, contexts in processed_torrents.items(): - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: break if domains and domain not in domains: continue logger.debug(f'开始匹配站点:{domain},共缓存了 {len(contexts)} 个种子...') for context in contexts: - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: break # 提取信息 _context = copy.copy(context) @@ -2046,7 +2078,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): ) # 遍历订阅 for index, subscribe in enumerate(subscribes, start=1): - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: break logger.info(f'开始更新订阅元数据:{subscribe.name} ...') if progress_callback: @@ -2174,7 +2206,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): if progress_callback: progress_callback(value=100, text="未配置 Follow 订阅用户,跳过刷新") return - logger.info(f'开始刷新follow用户分享订阅 ...') + logger.info('开始刷新follow用户分享订阅 ...') success_count = 0 subscribeoper = get_chain_subscribe_port() share_subscribes = MoviePilotServerHelper.get_subscribe_shares() or [] @@ -2186,7 +2218,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): data={"total": total_num, "finished": 0}, ) for index, share_sub in enumerate(share_subscribes, start=1): - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: break if progress_callback: progress_callback( @@ -2276,7 +2308,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): :param progress_callback: 定时服务进度更新回调 """ - logger.info(f'开始预缓存订阅日历 ...') + logger.info('开始预缓存订阅日历 ...') subscribes = await get_chain_subscribe_port().async_list() total_num = len(subscribes) if progress_callback: @@ -2286,7 +2318,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): data={"total": total_num, "finished": 0}, ) for index, subscribe in enumerate(subscribes, start=1): - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: break if progress_callback: progress_callback( @@ -2336,7 +2368,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): text=f"订阅日历({index}/{total_num})预缓存完成", data={"total": total_num, "finished": index}, ) - logger.info(f'订阅日历预缓存完成') + logger.info('订阅日历预缓存完成') if progress_callback: progress_callback(value=100, text="订阅日历预缓存完成") diff --git a/app/chain/system.py b/app/chain/system.py index 55c5169d4..93da3cabe 100644 --- a/app/chain/system.py +++ b/app/chain/system.py @@ -4,17 +4,17 @@ import re import shutil import uuid from pathlib import Path -from typing import Union, Optional +from typing import Optional, Union -from app.chain import ChainBase -from app.application.configuration import get_chain_runtime_config_snapshot -from app.runtime.state import SystemHelper -from app.runtime.log import logger -from app.schemas.message import Message -from app.schemas.notification import NotificationChannel from app.adapters.network.http import RequestUtils from app.adapters.system.host import SystemUtils +from app.application.configuration import get_chain_runtime_config_snapshot +from app.chain import ChainBase from app.runtime import version as runtime_version +from app.runtime.log import logger +from app.runtime.state import SystemHelper +from app.schemas.message import Message +from app.schemas.notification import NotificationChannel class SystemChain(ChainBase): @@ -33,7 +33,7 @@ class SystemChain(ChainBase): self.post_message(Message( channel=channel, source=source, - title=f"缓存清理完成!", + title="缓存清理完成!", userid=userid, save_history=False)) diff --git a/app/chain/tmdb.py b/app/chain/tmdb.py index c21b6a6e9..181f64d57 100644 --- a/app/chain/tmdb.py +++ b/app/chain/tmdb.py @@ -1,11 +1,11 @@ import random -from typing import Optional, List +from typing import List, Optional -from app.schemas.context import MediaPerson as _SchemaMediaPerson -from app.schemas.tmdb import TmdbSeason as _SchemaTmdbSeason -from app.schemas.tmdb import TmdbEpisode as _SchemaTmdbEpisode from app.chain import ChainBase from app.domain.context import MediaInfo +from app.schemas.context import MediaPerson as _SchemaMediaPerson +from app.schemas.tmdb import TmdbEpisode as _SchemaTmdbEpisode +from app.schemas.tmdb import TmdbSeason as _SchemaTmdbSeason from app.schemas.types import MediaType diff --git a/app/chain/torrents.py b/app/chain/torrents.py index f50f5a4da..475434b97 100644 --- a/app/chain/torrents.py +++ b/app/chain/torrents.py @@ -1,27 +1,25 @@ import copy import re import traceback -from typing import Callable, Dict, List, Union, Optional +from typing import Callable, Dict, List, Optional, Union -from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module - -from app.chain import ChainBase -from app.chain.media import MediaChain -from app.runtime.config import global_vars -from app.domain.context import TorrentInfo, Context, MediaInfo -from app.domain.context import MusicInfo -from app.domain.meta.metamusic import MetaMusic -from app.domain.metainfo import MetaInfo from app.application.chain.data import get_chain_site_port from app.application.configuration import get_configured_system_config from app.application.rss import RssHelper +from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module from app.application.torrent import TorrentHelper -from app.runtime.log import logger -from app.schemas.message import Message -from app.schemas.types import SystemConfigKey, NotificationChannel, MessageType, MediaType -from app.schemas.media import resolve_media_identity +from app.chain import ChainBase +from app.chain.media import MediaChain from app.domain import site as site_rules +from app.domain.context import Context, MediaInfo, MusicInfo, TorrentInfo +from app.domain.meta.metamusic import MetaMusic +from app.domain.metainfo import MetaInfo from app.foundation import text as text_tools +from app.runtime.log import logger +from app.runtime.stop import runtime_stop_state +from app.schemas.media import resolve_media_identity +from app.schemas.message import Message +from app.schemas.types import MediaType, MessageType, NotificationChannel, SystemConfigKey class TorrentsChain(ChainBase): @@ -50,13 +48,13 @@ class TorrentsChain(ChainBase): """ self.post_message(Message( channel=channel, - title=f"开始刷新种子 ...", + title="开始刷新种子 ...", userid=userid, save_history=False)) self.refresh() self.post_message(Message( channel=channel, - title=f"种子刷新完成!", + title="种子刷新完成!", userid=userid, save_history=False)) @@ -362,23 +360,23 @@ class TorrentsChain(ChainBase): """ 清理种子缓存数据,包含音乐独立缓存 """ - logger.info(f'开始清理种子缓存数据 ...') + logger.info('开始清理种子缓存数据 ...') self.remove_cache(self._spider_file) self.remove_cache(self._rss_file) self.remove_cache(self._music_spider_file) self.remove_cache(self._music_rss_file) - logger.info(f'种子缓存数据清理完成') + logger.info('种子缓存数据清理完成') async def async_clear_torrents(self): """ 异步清理种子缓存数据,包含音乐独立缓存 """ - logger.info(f'开始异步清理种子缓存数据 ...') + logger.info('开始异步清理种子缓存数据 ...') await self.async_remove_cache(self._spider_file) await self.async_remove_cache(self._rss_file) await self.async_remove_cache(self._music_spider_file) await self.async_remove_cache(self._music_rss_file) - logger.info(f'异步种子缓存数据清理完成') + logger.info('异步种子缓存数据清理完成') def browse(self, domain: str, keyword: Optional[str] = None, cat: Optional[str] = None, page: Optional[int] = 0, @@ -585,7 +583,7 @@ class TorrentsChain(ChainBase): return domain logger.info(f'{indexer.get("name")} 有 {len(torrents) + len(music_torrents)} 个新种子') for torrent in torrents + music_torrents: - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: break if not torrent.enclosure: logger.warning(f"缺少种子链接,忽略处理: {torrent.title}") @@ -692,7 +690,7 @@ class TorrentsChain(ChainBase): ) # 遍历站点缓存资源 for index, indexer in enumerate(indexers, start=1): - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: break if progress_callback: progress_callback( diff --git a/app/chain/transfer.py b/app/chain/transfer.py index 3b3e08781..6529ce0ce 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -10,55 +10,38 @@ from concurrent.futures import CancelledError as FutureCancelledError from concurrent.futures import Future from copy import deepcopy from pathlib import Path -from typing import List, Optional, Tuple, Union, Dict, Callable, Any +from typing import Any, Callable, Dict, List, Optional, Tuple, Union -from app.chain import ChainBase -from app.chain.media import MediaChain -from app.chain.storage import StorageChain -from app.chain.tmdb import TmdbChain -from app.runtime.config import global_vars -from app.domain.context import MediaInfo, MusicInfo, TorrentInfo -from app.domain.meta.metabase import MetaBase -from app.domain.meta.metamusic import MetaMusic -from app.domain.metainfo import MetaInfoPath from app.application.chain.data import ( get_chain_download_history_port, get_chain_transfer_history_port, get_chain_transfer_pending_port, ) +from app.chain import ChainBase +from app.chain.media import MediaChain +from app.chain.storage import StorageChain +from app.chain.tmdb import TmdbChain +from app.domain.context import MediaInfo, MusicInfo, TorrentInfo +from app.domain.meta.metabase import MetaBase +from app.domain.meta.metamusic import MetaMusic +from app.domain.metainfo import MetaInfoPath +from app.runtime.config import global_vars +from app.runtime.stop import runtime_stop_state + DownloadHistory = Any from app.application.configuration import get_configured_system_config from app.application.directory import DirectoryHelper from app.application.formatting import FormatParser -from app.runtime.progress import ProgressHelper -from app.application.history import (add_transfer_fail, add_transfer_success, - clear_transfer_failures, describe_history_gate, - evaluate_history_gate, is_skip_action, - record_transfer_failure) -from app.application.outbox import TRANSFER_COMPLETED_TOPIC, TRANSFER_FAILED_TOPIC -from app.runtime.log import logger -from app.schemas.event import StorageOperSelectionEventData -from app.schemas.transfer import TransferInfo -from app.schemas.message import Message -from app.schemas.transfer import EpisodeFormat -from app.schemas.workflow import FileItem -from app.schemas.system import TransferDirectoryConf -from app.schemas.transfer import TransferJob -from app.schemas.tmdb import TmdbEpisode -from app.schemas.exception import OperationInterrupted -from app.schemas.types import ( - TorrentStatus, - EventType, - MediaType, - ProgressKey, - MessageType, - NotificationChannel, - SystemConfigKey, - ChainEventType, - ContentType, - MediaSource, +from app.application.history import ( + add_transfer_fail, + add_transfer_success, + clear_transfer_failures, + describe_history_gate, + evaluate_history_gate, + is_skip_action, + record_transfer_failure, ) -from app.runtime.reload import ConfigReloadMixin +from app.application.outbox import TRANSFER_COMPLETED_TOPIC, TRANSFER_FAILED_TOPIC from app.application.transfer import ( FailedRetryScheduler, JobManager, @@ -70,13 +53,40 @@ from app.application.transfer import ( build_transfer_failure_group_key, job_lock, ) -from app.chain._transfer import (EpisodeFormatMixin, FailedRetryMixin, - FileFilterMixin, FileKeyMixin, - HistoryMatchMixin, ManualHistoryMixin, - ScrapeBatchMixin) -from app.schemas.media import resolve_media_identity -from app.foundation.singleton import Singleton +from app.chain._transfer import ( + EpisodeFormatMixin, + FailedRetryMixin, + FileFilterMixin, + FileKeyMixin, + HistoryMatchMixin, + ManualHistoryMixin, + ScrapeBatchMixin, +) from app.domain import episode as episode_rules +from app.foundation.singleton import Singleton +from app.runtime.log import logger +from app.runtime.progress import ProgressHelper +from app.runtime.reload import ConfigReloadMixin +from app.schemas.event import StorageOperSelectionEventData +from app.schemas.exception import OperationInterrupted +from app.schemas.media import resolve_media_identity +from app.schemas.message import Message +from app.schemas.system import TransferDirectoryConf +from app.schemas.tmdb import TmdbEpisode +from app.schemas.transfer import EpisodeFormat, TransferInfo, TransferJob +from app.schemas.types import ( + ChainEventType, + ContentType, + EventType, + MediaSource, + MediaType, + MessageType, + NotificationChannel, + ProgressKey, + SystemConfigKey, + TorrentStatus, +) +from app.schemas.workflow import FileItem # 下载器锁 downloader_lock = threading.Lock() @@ -90,6 +100,24 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo 文件整理处理链 """ + @classmethod + def _transfer_media_chain(cls): + """为整理 mixin 提供可替换的媒体识别构造点。""" + from app.chain import _transfer as _transfer_mixin + return (_transfer_mixin.MediaChain or MediaChain)() + + @classmethod + def _transfer_storage_chain(cls): + """为整理 mixin 提供可替换的存储构造点。""" + from app.chain import _transfer as _transfer_mixin + return (_transfer_mixin.StorageChain or StorageChain)() + + @classmethod + def _transfer_subscribe_chain(cls): + """为整理 mixin 提供可替换的订阅构造点。""" + from app.chain.subscribe import SubscribeChain as _SubscribeChain + return _SubscribeChain() + # worker 在构造期启动;若中途失败,单例仍需先发布给 lifespan 清理入口。 _retain_failed_singleton = True @@ -1148,7 +1176,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo :param stop_event: 当前 worker 代专属停止信号,热更新后不会被重新清除 """ - while not global_vars.is_system_stopped and not stop_event.is_set(): + while not runtime_stop_state.is_system_stopped and not stop_event.is_set(): try: item: TransferQueue = self._queue.get( block=True, timeout=self._transfer_interval @@ -1156,10 +1184,10 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo if item is self._QUEUE_STOP_SENTINEL: self._queue.task_done() self.__settle_transfer_progress_if_idle() - if stop_event.is_set() or global_vars.is_system_stopped: + if stop_event.is_set() or runtime_stop_state.is_system_stopped: break continue - if stop_event.is_set() or global_vars.is_system_stopped: + if stop_event.is_set() or runtime_stop_state.is_system_stopped: # 关闭信号与 queue.get 竞态时,把尚未处理的任务放回队列;其 # TransferPending 登记保持不变,供同进程重启 worker 或下次启动回放。 self._queue.put(item) @@ -1606,7 +1634,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo try: total_num = len(torrents) for index, torrent in enumerate(torrents, start=1): - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: break if progress_callback: torrent_name = ( @@ -1732,7 +1760,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo 若 `predicate` 为 `None`,则默认保留所有项 :param verify_file_exists: 验证目录或文件是否存在,默认值为 `True` """ - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: raise OperationInterrupted() storagechain = StorageChain() @@ -2536,7 +2564,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo skipped_torrents = set() try: for file_item, bluray_dir in file_items: - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: raise OperationInterrupted() if continue_callback and not continue_callback(): raise OperationInterrupted() @@ -2762,7 +2790,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo progress.update(value=0, text=__process_msg) try: for transfer_task in transfer_tasks: - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: break if continue_callback and not continue_callback(): break diff --git a/app/chain/user.py b/app/chain/user.py index 31dcc1382..7ad084b10 100644 --- a/app/chain/user.py +++ b/app/chain/user.py @@ -2,14 +2,13 @@ import secrets from dataclasses import dataclass from typing import Any, Literal, Optional, Tuple, Union -from app.chain import ChainBase -from app.application.security.token import get_password_hash, verify_password from app.application.chain.data import get_chain_user_port -from app.runtime.log import logger -from app.schemas.event import AuthCredentials -from app.schemas.event import AuthInterceptCredentials -from app.schemas.types import ChainEventType from app.application.security.otp import OtpUtils +from app.application.security.token import get_password_hash, verify_password +from app.chain import ChainBase +from app.runtime.log import logger +from app.schemas.event import AuthCredentials, AuthInterceptCredentials +from app.schemas.types import ChainEventType PASSWORD_INVALID_CREDENTIALS_MESSAGE = "用户名、密码或验证码错误" User = Any diff --git a/app/chain/workflow.py b/app/chain/workflow.py index f58c23345..2d40b4d50 100644 --- a/app/chain/workflow.py +++ b/app/chain/workflow.py @@ -13,19 +13,15 @@ from typing import Any, Callable, List, Optional, Tuple from pydantic import BaseModel -from app.chain import ChainBase -from app.runtime.config import global_vars -from app.runtime.events import Event, eventmanager -from app.application.workflow import get_workflow_manager from app.application.chain.data import get_chain_workflow_port +from app.application.workflow import get_workflow_manager +from app.chain import ChainBase +from app.runtime.events import Event, eventmanager from app.runtime.execution import OwnedThreadPoolExecutor from app.runtime.log import logger -from app.schemas.workflow import ActionContext -from app.schemas.workflow import ActionFlow -from app.schemas.workflow import Action -from app.schemas.workflow import ActionExecution -from app.schemas.workflow import ActionResult +from app.runtime.stop import runtime_stop_state from app.schemas.types import EventType +from app.schemas.workflow import Action, ActionContext, ActionExecution, ActionFlow, ActionResult ARTIFACT_FIELDS = {"torrents", "medias", "fileitems", "downloads", "sites", "subscribes"} DEFAULT_WORKFLOW_MAX_WORKERS = 4 @@ -113,7 +109,7 @@ class WorkflowCancelToken: """ return bool( (self.stop_event and self.stop_event.is_set()) - or global_vars.is_workflow_stopped(self.workflow_id) + or runtime_stop_state.is_workflow_stopped(self.workflow_id) ) @@ -236,7 +232,7 @@ class WorkflowExecutor: self._registered_execution = callable(register) self._admission_state = "admitted" # 只有获得执行准入后才能清除历史单工作流停止标记。 - global_vars.workflow_resume(self.workflow.id) + runtime_stop_state.resume_workflow(self.workflow.id) return True def request_stop(self) -> None: @@ -285,7 +281,7 @@ class WorkflowExecutor: """判断本次执行或全局工作流是否已收到停止请求。""" return bool( self._stop_event.is_set() - or global_vars.is_workflow_stopped(self.workflow.id) + or runtime_stop_state.is_workflow_stopped(self.workflow.id) ) def get_workflow_max_workers(self) -> int: diff --git a/app/main.py b/app/main.py index 851e9b80c..4b0990f82 100644 --- a/app/main.py +++ b/app/main.py @@ -26,18 +26,18 @@ def _prepare_direct_execution_import_path() -> None: _prepare_direct_execution_import_path() -import setproctitle import signal import threading from pathlib import Path from typing import Optional +import setproctitle import uvicorn as uvicorn from PIL import Image from uvicorn import Config -from app.adapters.system.stdio import configure_rotating_stdio from app.adapters.system.host import SystemUtils +from app.adapters.system.stdio import configure_rotating_stdio stdio_log_file = os.getenv("MOVIEPILOT_STDIO_LOG_FILE") if stdio_log_file: @@ -55,8 +55,9 @@ elif SystemUtils.is_frozen(): sys.stderr = open(os.devnull, 'w') from app.factory import app -from app.runtime.config import global_vars from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.config import global_vars +from app.runtime.stop import runtime_stop_state settings = RuntimeSettingsCompat() from app.runtime.topology import ( @@ -71,7 +72,7 @@ class MoviePilotServer(uvicorn.Server): """在 Uvicorn 开始优雅退出前发布应用协作停止标志""" def handle_exit(self, sig, frame) -> None: - global_vars.stop_system() + getattr(global_vars, "stop_system")() super().handle_exit(sig, frame) @@ -92,7 +93,7 @@ def create_server() -> MoviePilotServer: ) ) # 数据库准备阶段收到的信号早于 Server 物化,创建后必须继承既有停止意图。 - if global_vars.is_system_stopped: + if runtime_stop_state.is_system_stopped: server.should_exit = True return server @@ -124,7 +125,7 @@ def run_api_server() -> None: def request_shutdown() -> None: """发布协作停止标志并请求 Uvicorn 退出""" - global_vars.stop_system() + getattr(global_vars, "stop_system")() if Server is not None: Server.should_exit = True diff --git a/app/modules/filemanager/storages/__init__.py b/app/modules/filemanager/storages/__init__.py index ebca7cd44..26b72b40c 100644 --- a/app/modules/filemanager/storages/__init__.py +++ b/app/modules/filemanager/storages/__init__.py @@ -1,17 +1,17 @@ from abc import ABCMeta, abstractmethod from pathlib import Path, PurePosixPath -from typing import Optional, List, Dict, Tuple, Callable, Union +from typing import Callable, Dict, List, Optional, Tuple, Union from tqdm import tqdm +from app.application.storage import StorageHelper +from app.foundation.crypto import HashUtils +from app.runtime.log import logger +from app.runtime.progress import ProgressHelper +from app.schemas.exception import StorageQueryError from app.schemas.file import StorageUsage as _SchemaStorageUsage from app.schemas.system import StorageConf as _SchemaStorageConf from app.schemas.workflow import FileItem as _SchemaFileItem -from app.runtime.progress import ProgressHelper -from app.application.storage import StorageHelper -from app.runtime.log import logger -from app.schemas.exception import StorageQueryError -from app.foundation.crypto import HashUtils def transfer_process(path: str) -> Callable[[int | float], None]: diff --git a/app/modules/filemanager/storages/alipan.py b/app/modules/filemanager/storages/alipan.py index f37df2aa4..751316279 100644 --- a/app/modules/filemanager/storages/alipan.py +++ b/app/modules/filemanager/storages/alipan.py @@ -8,19 +8,19 @@ from typing import List, Optional, Tuple, Union import requests +from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.stop import runtime_stop_state from app.schemas.file import StorageUsage as _SchemaStorageUsage from app.schemas.workflow import FileItem as _SchemaFileItem -from app.runtime.settings import RuntimeSettingsCompat -from app.runtime.config import global_vars settings = RuntimeSettingsCompat() -from app.runtime.log import logger +from app.adapters.network.http import RequestUtils +from app.foundation import temporal as time_tools +from app.foundation.singleton import WeakSingleton from app.modules.filemanager.storages import StorageBase, transfer_process +from app.runtime.log import logger from app.schemas.exception import StorageQueryError from app.schemas.types import StorageSchema -from app.adapters.network.http import RequestUtils -from app.foundation.singleton import WeakSingleton -from app.foundation import temporal as time_tools lock = threading.Lock() @@ -645,7 +645,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton): uploaded_size = 0 with open(local_path, "rb") as f: for part_info in part_info_list: - if global_vars.is_transfer_stopped(local_path.as_posix()): + if runtime_stop_state.consume_transfer_stop(local_path.as_posix()): logger.info(f"【阿里云盘】{target_name} 上传已取消!") return None @@ -780,7 +780,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton): downloaded_size = 0 with open(local_path, "wb") as f: for chunk in r.iter_content(chunk_size=self.chunk_size): - if global_vars.is_transfer_stopped(fileitem.path): + if runtime_stop_state.consume_transfer_stop(fileitem.path): logger.info(f"【阿里云盘】{fileitem.path} 下载已取消!") return None if chunk: diff --git a/app/modules/filemanager/storages/alist.py b/app/modules/filemanager/storages/alist.py index c5177ce2c..5a988292f 100644 --- a/app/modules/filemanager/storages/alist.py +++ b/app/modules/filemanager/storages/alist.py @@ -3,23 +3,22 @@ import json import time from datetime import datetime from pathlib import Path -from typing import Optional, List +from typing import List, Optional -from app.schemas.file import StorageUsage as _SchemaStorageUsage -from app.schemas.workflow import FileItem as _SchemaFileItem from app.runtime.cache import cached from app.runtime.settings import RuntimeSettingsCompat -from app.runtime.config import global_vars +from app.runtime.stop import runtime_stop_state +from app.schemas.file import StorageUsage as _SchemaStorageUsage +from app.schemas.workflow import FileItem as _SchemaFileItem settings = RuntimeSettingsCompat() -from app.runtime.log import logger -from app.modules.filemanager.storages import StorageBase, transfer_process -from app.schemas.exception import OperationInterrupted, StorageQueryError -from app.schemas.types import StorageSchema 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.log import logger +from app.schemas.exception import OperationInterrupted, StorageQueryError +from app.schemas.types import StorageSchema # OpenList/AList 在 per_page<=0 时会退回后端默认 200,显式指定最大页大小避免大目录被截断。 OPENLIST_MAX_LIST_PAGE_SIZE = 500 @@ -703,7 +702,7 @@ class Alist(StorageBase, metaclass=WeakSingleton): r.raise_for_status() with open(local_path, "wb") as f: for chunk in r.iter_content(chunk_size=8192): - if global_vars.is_transfer_stopped(fileitem.path): + if runtime_stop_state.consume_transfer_stop(fileitem.path): logger.info(f"【OpenList】{fileitem.path} 下载已取消!") return None f.write(chunk) @@ -760,7 +759,7 @@ class Alist(StorageBase, metaclass=WeakSingleton): return self.file_size def read(self, size=-1): - if global_vars.is_transfer_stopped(path.as_posix()): + if runtime_stop_state.consume_transfer_stop(path.as_posix()): logger.info(f"【OpenList】{path} 上传已取消!") raise OperationInterrupted(f"Upload cancelled: {path}") chunk = self.file.read(size) diff --git a/app/modules/filemanager/storages/local.py b/app/modules/filemanager/storages/local.py index cf6130984..8347d101e 100644 --- a/app/modules/filemanager/storages/local.py +++ b/app/modules/filemanager/storages/local.py @@ -2,21 +2,21 @@ import os import shutil import time from pathlib import Path -from typing import Optional, List +from typing import List, Optional +from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.stop import runtime_stop_state from app.schemas.file import StorageUsage as _SchemaStorageUsage from app.schemas.workflow import FileItem as _SchemaFileItem -from app.runtime.config import global_vars -from app.runtime.settings import RuntimeSettingsCompat settings = RuntimeSettingsCompat() -from app.application.directory import DirectoryHelper -from app.runtime.log import logger from app.adapters.system.fsproxy import fsproxy +from app.adapters.system.host import SystemUtils +from app.application.directory import DirectoryHelper from app.modules.filemanager.storages import StorageBase, transfer_process +from app.runtime.log import logger from app.schemas.exception import StorageQueryError from app.schemas.types import StorageSchema -from app.adapters.system.host import SystemUtils class LocalStorage(StorageBase): @@ -301,7 +301,7 @@ class LocalStorage(StorageBase): copied = fsproxy.copy( src, partial, progress_cb=progress_callback, - cancel_cb=lambda: global_vars.is_transfer_stopped(src.as_posix()), + cancel_cb=lambda: runtime_stop_state.consume_transfer_stop(src.as_posix()), chunk_size=self.chunk_size, ) if not copied: @@ -352,7 +352,7 @@ class LocalStorage(StorageBase): try: with open(src, "rb") as fsrc, open(dest, "wb") as fdst: while True: - if global_vars.is_transfer_stopped(src.as_posix()): + if runtime_stop_state.consume_transfer_stop(src.as_posix()): logger.info(f"【本地】{src} 复制已取消!") return False buf = fsrc.read(self.chunk_size) diff --git a/app/modules/filemanager/storages/rclone.py b/app/modules/filemanager/storages/rclone.py index 0a2995578..429083dad 100644 --- a/app/modules/filemanager/storages/rclone.py +++ b/app/modules/filemanager/storages/rclone.py @@ -4,19 +4,19 @@ import threading import time from collections import OrderedDict from pathlib import Path -from typing import Optional, List, Union +from typing import List, Optional, Union +from app.runtime.settings import RuntimeSettingsCompat from app.schemas.file import StorageUsage as _SchemaStorageUsage from app.schemas.workflow import FileItem as _SchemaFileItem -from app.runtime.settings import RuntimeSettingsCompat settings = RuntimeSettingsCompat() -from app.runtime.log import logger +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 from app.schemas.exception import StorageQueryError from app.schemas.types import StorageSchema -from app.foundation import temporal as time_tools -from app.adapters.system.host import SystemUtils _MAX_FOLDER_LOCKS = 4096 _folder_locks: OrderedDict[str, threading.Lock] = OrderedDict() diff --git a/app/modules/filemanager/storages/smb.py b/app/modules/filemanager/storages/smb.py index e0e467b96..8b17b0446 100644 --- a/app/modules/filemanager/storages/smb.py +++ b/app/modules/filemanager/storages/smb.py @@ -7,22 +7,22 @@ from typing import List, Optional, Union import smbclient from smbclient import ClientConfig, register_session, reset_connection_cache from smbprotocol.exceptions import ( + SMBAuthenticationError, SMBException, SMBResponseException, - SMBAuthenticationError, ) +from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.stop import runtime_stop_state from app.schemas.file import StorageUsage as _SchemaStorageUsage from app.schemas.workflow import FileItem as _SchemaFileItem -from app.runtime.settings import RuntimeSettingsCompat -from app.runtime.config import global_vars settings = RuntimeSettingsCompat() -from app.runtime.log import logger +from app.foundation.singleton import WeakSingleton from app.modules.filemanager.storages import StorageBase, transfer_process +from app.runtime.log import logger from app.schemas.exception import StorageQueryError from app.schemas.types import StorageSchema -from app.foundation.singleton import WeakSingleton lock = threading.Lock() @@ -572,7 +572,7 @@ class SMB(StorageBase, metaclass=WeakSingleton): with open(local_path, "wb") as dst_file: downloaded_size = 0 while True: - if global_vars.is_transfer_stopped(fileitem.path): + if runtime_stop_state.consume_transfer_stop(fileitem.path): logger.info(f"【SMB】{fileitem.path} 下载已取消!") return None chunk = src_file.read(self.chunk_size) @@ -622,7 +622,7 @@ class SMB(StorageBase, metaclass=WeakSingleton): with smbclient.open_file(smb_path, mode="wb") as dst_file: uploaded_size = 0 while True: - if global_vars.is_transfer_stopped(path.as_posix()): + if runtime_stop_state.consume_transfer_stop(path.as_posix()): logger.info(f"【SMB】{path} 上传已取消!") return None chunk = src_file.read(self.chunk_size) diff --git a/app/modules/filemanager/storages/u115.py b/app/modules/filemanager/storages/u115.py index c3085fb7d..f6735fbd5 100644 --- a/app/modules/filemanager/storages/u115.py +++ b/app/modules/filemanager/storages/u115.py @@ -1,31 +1,30 @@ import base64 import secrets import time +from hashlib import sha256 from pathlib import Path from threading import Lock from typing import List, Optional, Tuple, Union -from hashlib import sha256 -import oss2 import httpx +import oss2 +from cryptography.hazmat.primitives import hashes from oss2 import SizedFileAdapter, determine_part_size from oss2.models import PartInfo -from cryptography.hazmat.primitives import hashes +from app.runtime.settings import RuntimeSettingsCompat +from app.runtime.stop import runtime_stop_state from app.schemas.file import StorageUsage as _SchemaStorageUsage from app.schemas.workflow import FileItem as _SchemaFileItem -from app.runtime.settings import RuntimeSettingsCompat -from app.runtime.config import global_vars settings = RuntimeSettingsCompat() -from app.runtime.log import logger +from app.foundation import size as size_tools +from app.foundation.singleton import WeakSingleton from app.modules.filemanager.storages import StorageBase, transfer_process +from app.runtime.log import logger +from app.runtime.rate import QpsRateLimiter, RateStats from app.schemas.exception import StorageQueryError from app.schemas.types import StorageSchema -from app.foundation.singleton import WeakSingleton -from app.foundation import size as size_tools -from app.runtime.rate import QpsRateLimiter, RateStats - lock = Lock() @@ -778,7 +777,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): part_number = 1 offset = 0 while offset < file_size: - if global_vars.is_transfer_stopped(local_path.as_posix()): + if runtime_stop_state.consume_transfer_stop(local_path.as_posix()): logger.info(f"【115】{local_path} 上传已取消!") return None num_to_upload = min(part_size, file_size - offset) @@ -929,7 +928,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): with open(local_path, "wb") as f: for chunk in r.iter_bytes(chunk_size=self.chunk_size): - if global_vars.is_transfer_stopped(fileitem.path): + if runtime_stop_state.consume_transfer_stop(fileitem.path): logger.info(f"【115】{fileitem.path} 下载已取消!") r.close() return None diff --git a/app/runtime/config.py b/app/runtime/config.py index 9db98cae7..1f53a18af 100644 --- a/app/runtime/config.py +++ b/app/runtime/config.py @@ -9,31 +9,32 @@ import sys import threading from asyncio import AbstractEventLoop from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple, Type, Union, get_origin, get_args +from typing import Any, Dict, List, Optional, Tuple, Type, Union, get_args, get_origin from urllib.parse import quote, urlencode, urlparse from dotenv import set_key, unset_key -from pydantic import BaseModel, Field, ConfigDict, model_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict -from app.foundation.environment import is_free_threaded_runtime -from app.runtime.log import ( - LogConfigModel, - configure_log_settings, - configure_log_writer, - logger, - log_settings, - NonBlockingFileHandler, -) -from app.schemas.types import MediaType from app.foundation.environment import ( cpu_arch, get_env_path, is_docker, + is_free_threaded_runtime, is_frozen, ) from app.foundation.url import UrlUtils +from app.runtime.log import ( + LogConfigModel, + NonBlockingFileHandler, + configure_log_settings, + configure_log_writer, + log_settings, + logger, +) +from app.runtime.stop import runtime_stop_state from app.runtime.version import get_app_version +from app.schemas.types import MediaType class SystemConfModel(BaseModel): @@ -1374,7 +1375,6 @@ class GlobalVar(object): """ # 系统停止事件 - STOP_EVENT: threading.Event = threading.Event() # webpush订阅 SUBSCRIPTIONS: List[dict] = [] # webpush订阅读写锁 @@ -1391,18 +1391,28 @@ class GlobalVar(object): self._event_loop_owners: dict[object, AbstractEventLoop] = {} self._event_loop_owner_lock = threading.Lock() + @property + def STOP_EVENT(self) -> threading.Event: + """兼容旧代码读取进程停止事件。""" + return runtime_stop_state.system_event + + @STOP_EVENT.setter + def STOP_EVENT(self, event: threading.Event) -> None: + """兼容旧测试替换事件,同时保持新 StopState 为唯一状态源。""" + runtime_stop_state.replace_system_event(event) + def stop_system(self): """ 停止系统 """ - self.STOP_EVENT.set() + runtime_stop_state.stop_system() @property def is_system_stopped(self): """ 是否停止 """ - return self.STOP_EVENT.is_set() + return runtime_stop_state.is_system_stopped def get_subscriptions(self): """ @@ -1444,39 +1454,31 @@ class GlobalVar(object): """ 停止工作流 """ - if workflow_id not in self.EMERGENCY_STOP_WORKFLOWS: - self.EMERGENCY_STOP_WORKFLOWS.append(workflow_id) + runtime_stop_state.stop_workflow(workflow_id) def workflow_resume(self, workflow_id: int): """ 恢复工作流 """ - if workflow_id in self.EMERGENCY_STOP_WORKFLOWS: - self.EMERGENCY_STOP_WORKFLOWS.remove(workflow_id) + runtime_stop_state.resume_workflow(workflow_id) def is_workflow_stopped(self, workflow_id: int) -> bool: """ 是否停止工作流 """ - return self.is_system_stopped or workflow_id in self.EMERGENCY_STOP_WORKFLOWS + return runtime_stop_state.is_workflow_stopped(workflow_id) def stop_transfer(self, path: str): """ 停止文件整理 """ - if path not in self.EMERGENCY_STOP_TRANSFER: - self.EMERGENCY_STOP_TRANSFER.append(path) + runtime_stop_state.stop_transfer(path) def is_transfer_stopped(self, path: str) -> bool: """ 是否停止文件整理 """ - if self.is_system_stopped: - return True - if path in self.EMERGENCY_STOP_TRANSFER: - self.EMERGENCY_STOP_TRANSFER.remove(path) - return True - return False + return runtime_stop_state.consume_transfer_stop(path) @property def loop(self) -> AbstractEventLoop: diff --git a/app/runtime/stop.py b/app/runtime/stop.py new file mode 100644 index 000000000..a48fb0ccb --- /dev/null +++ b/app/runtime/stop.py @@ -0,0 +1,112 @@ +"""进程停止与细粒度取消信号契约。""" + +from __future__ import annotations + +import threading +from typing import Protocol + + +class StopState(Protocol): + """向运行时消费者暴露系统、工作流和整理任务的停止状态。""" + + @property + def is_system_stopped(self) -> bool: + """返回系统是否已经进入停止阶段。""" + ... + + def stop_system(self) -> None: + """发布不可逆的当前进程停止信号。""" + ... + + def stop_workflow(self, workflow_id: int) -> None: + """请求停止指定工作流。""" + ... + + def resume_workflow(self, workflow_id: int) -> None: + """清除指定工作流的停止请求。""" + ... + + def is_workflow_stopped(self, workflow_id: int) -> bool: + """返回系统或指定工作流是否已经停止。""" + ... + + def stop_transfer(self, path: str) -> None: + """登记指定源路径的一次性整理停止请求。""" + ... + + def consume_transfer_stop(self, path: str) -> bool: + """消费指定路径的停止请求;系统停止时始终返回真。""" + ... + + +class ProcessStopState: + """线程安全地持有当前进程的停止与细粒度取消状态。""" + + def __init__(self) -> None: + self._system_event = threading.Event() + self._workflow_ids: set[int] = set() + self._transfer_paths: set[str] = set() + self._lock = threading.Lock() + + @property + def system_event(self) -> threading.Event: + """返回兼容入口使用的系统停止事件。""" + return self._system_event + + def replace_system_event(self, event: threading.Event) -> None: + """替换系统事件,仅供旧 ABI 与隔离测试继续使用。""" + self._system_event = event + + @property + def is_system_stopped(self) -> bool: + """返回系统是否已经进入停止阶段。""" + return self._system_event.is_set() + + def stop_system(self) -> None: + """发布不可逆的当前进程停止信号。""" + self._system_event.set() + + def stop_workflow(self, workflow_id: int) -> None: + """请求停止指定工作流。""" + with self._lock: + self._workflow_ids.add(workflow_id) + + def resume_workflow(self, workflow_id: int) -> None: + """清除指定工作流的停止请求。""" + with self._lock: + self._workflow_ids.discard(workflow_id) + + def is_workflow_stopped(self, workflow_id: int) -> bool: + """返回系统或指定工作流是否已经停止。""" + if self.is_system_stopped: + return True + with self._lock: + return workflow_id in self._workflow_ids + + def stop_transfer(self, path: str) -> None: + """登记指定源路径的一次性整理停止请求。""" + with self._lock: + self._transfer_paths.add(path) + + def consume_transfer_stop(self, path: str) -> bool: + """消费指定路径的停止请求;系统停止时始终返回真。""" + if self.is_system_stopped: + return True + with self._lock: + if path not in self._transfer_paths: + return False + self._transfer_paths.remove(path) + return True + + def workflow_stop_ids(self) -> list[int]: + """返回旧诊断入口需要的工作流停止快照。""" + with self._lock: + return list(self._workflow_ids) + + def transfer_stop_paths(self) -> list[str]: + """返回旧诊断入口需要的整理停止快照。""" + with self._lock: + return list(self._transfer_paths) + + +runtime_stop_state = ProcessStopState() diff --git a/app/scheduler.py b/app/scheduler.py index 81d919d21..05aefe3a4 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -9,16 +9,30 @@ import time import traceback from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Callable, Optional, Dict, Any, List +from typing import Any, Callable, Dict, List, Optional import pytz from apscheduler.executors.pool import ThreadPoolExecutor from apscheduler.jobstores.base import JobLookupError from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger -from app.schemas.dashboard import ScheduleInfo as _SchemaScheduleInfo -from app.schemas.dashboard import ScheduleProgress as _SchemaScheduleProgress -from app.schemas.system import MediaServerConf as _SchemaMediaServerConf + +from app.adapters.external.server import MoviePilotServerHelper +from app.adapters.system.update import system_update_manager +from app.application.agentdata import get_agent_task_port +from app.application.configuration import ( + SchedulerRuntimeConfig, + get_configured_system_config, + get_scheduler_runtime_config, +) +from app.application.database import get_database_governance +from app.application.image import WallpaperHelper +from app.application.mediaserver import get_mediaserver_configs +from app.application.messaging.message import MessageHelper +from app.application.outbox import dispatch_pending_outbox +from app.application.plugin.routes import register_plugin_api +from app.application.plugin.runtime import get_plugin_manager +from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module from app.chain import ChainBase from app.chain.mediaserver import MediaServerChain from app.chain.recommend import RecommendChain @@ -26,36 +40,23 @@ from app.chain.site import SiteChain from app.chain.subscribe import SubscribeChain from app.chain.transfer import TransferChain from app.chain.workflow import WorkflowChain -from app.runtime.config import global_vars -from app.runtime.events import Event, eventmanager -from app.application.agentdata import get_agent_task_port -from app.application.database import get_database_governance -from app.application.outbox import dispatch_pending_outbox -from app.application.plugin.runtime import get_plugin_manager -from app.application.plugin.routes import register_plugin_api -from app.application.configuration import ( - SchedulerRuntimeConfig, - get_configured_system_config, - get_scheduler_runtime_config, -) -from app.application.image import WallpaperHelper -from app.application.mediaserver import get_mediaserver_configs -from app.application.messaging.message import MessageHelper -from app.runtime.progress import AsyncProgressHelper, ProgressHelper -from app.adapters.external.server import MoviePilotServerHelper -from app.adapters.system.update import system_update_manager -from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module -from app.runtime.log import logger -from app.schemas.message import Message -from app.schemas.message import MessageType -from app.schemas.workflow import Workflow -from app.schemas.types import EventType, SystemConfigKey -from app.runtime.gc import get_memory_usage -from app.runtime.reload import ConfigReloadMixin from app.foundation.singleton import SingletonClass -from app.runtime.scheduling import TimerUtils +from app.runtime.config import global_vars from app.runtime.correlation import call_with_correlation, get_correlation_id +from app.runtime.events import Event, eventmanager +from app.runtime.gc import get_memory_usage +from app.runtime.log import logger from app.runtime.observability import record_metric +from app.runtime.progress import AsyncProgressHelper, ProgressHelper +from app.runtime.reload import ConfigReloadMixin +from app.runtime.scheduling import TimerUtils +from app.runtime.stop import runtime_stop_state +from app.schemas.dashboard import ScheduleInfo as _SchemaScheduleInfo +from app.schemas.dashboard import ScheduleProgress as _SchemaScheduleProgress +from app.schemas.message import Message, MessageType +from app.schemas.system import MediaServerConf as _SchemaMediaServerConf +from app.schemas.types import EventType, SystemConfigKey +from app.schemas.workflow import Workflow lock = threading.Lock() SCHEDULER_PROGRESS_PREFIX = "scheduler" @@ -1966,7 +1967,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass): """停止旧计划的提交入口,保留已开始任务直到其自然完成。""" with self._lock: if ( - global_vars.is_system_stopped + runtime_stop_state.is_system_stopped or self._lifecycle_state in {"stopping", "reloading"} ): return False, None @@ -2066,7 +2067,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass): if self._auth_count > __max_try__: if not self._auth_message: SchedulerChain().messagehelper.put( - title=f"用户认证失败", + title="用户认证失败", message="用户认证失败次数过多,将不再尝试认证!", role="system", ) diff --git a/app/startup/composition/context.py b/app/startup/composition/context.py index d126cca11..e2feb69fb 100644 --- a/app/startup/composition/context.py +++ b/app/startup/composition/context.py @@ -4,15 +4,13 @@ from collections.abc import AsyncGenerator, Callable, Generator from dataclasses import dataclass, field from typing import Protocol -from app.runtime.tasks import TaskRegistry - +from app.application.configuration import RuntimeConfiguration, RuntimeSettingsService from app.application.messaging.chat import ( - AsyncAgentChatRepository, AgentChatPersistenceService, + AsyncAgentChatRepository, AsyncUnitOfWork, ) from app.application.outbox import AsyncOutboxTransaction -from app.application.configuration import RuntimeConfiguration, RuntimeSettingsService from app.application.subscription.delete import SubscribeDeletionRepository from app.application.subscription.identity import SubscribeIdentityDeletionRepository from app.application.subscription.mutation import ( @@ -20,6 +18,7 @@ from app.application.subscription.mutation import ( SubscriptionMutationRepository, ) from app.application.workflow import WorkflowCachePort +from app.runtime.tasks import TaskRegistry class AgentChatRepositoryFactory(Protocol): diff --git a/app/startup/initializers/agent.py b/app/startup/initializers/agent.py index d7a2d83ba..32dfc131b 100644 --- a/app/startup/initializers/agent.py +++ b/app/startup/initializers/agent.py @@ -1,15 +1,19 @@ from typing import Any +from app.agent.llm.gateway import register_llm_provider_runtime from app.agent.runtime_loader import ( activate_agent_service, begin_agent_shutdown, close_materialized_terminal_sessions, - get_agent_manager as get_runtime_agent_manager, - get_running_agent_manager as get_runtime_running_agent_manager, is_tool_factory_materialized, reconcile_agent_service, ) -from app.agent.llm.gateway import register_llm_provider_runtime +from app.agent.runtime_loader import ( + get_agent_manager as get_runtime_agent_manager, +) +from app.agent.runtime_loader import ( + get_running_agent_manager as get_runtime_running_agent_manager, +) from app.application.agent import register_agent_service_providers from app.application.messaging.skill import register_skill_catalog_provider from app.runtime.settings import RuntimeSettingsCompat @@ -19,7 +23,6 @@ from app.runtime.events import Event, eventmanager from app.runtime.log import logger from app.schemas.types import EventType - AGENT_BLOCKING_EXECUTOR_SHUTDOWN_TIMEOUT_SECONDS = 10.0 @@ -226,6 +229,8 @@ async def stop_agent() -> bool: if is_tool_factory_materialized(): from app.agent.tools.base import ( begin_blocking_executor_shutdown, + ) + from app.agent.tools.base import ( close_blocking_executors as close_executors, ) diff --git a/app/startup/initializers/database.py b/app/startup/initializers/database.py index 389dba094..ac8ce4ea0 100644 --- a/app/startup/initializers/database.py +++ b/app/startup/initializers/database.py @@ -1,6 +1,6 @@ +import traceback from collections.abc import Callable from configparser import ConfigParser as _ConfigParser -import traceback from alembic.command import upgrade from alembic.config import Config diff --git a/app/startup/initializers/domain.py b/app/startup/initializers/domain.py index abec0e9c6..cea0a5440 100644 --- a/app/startup/initializers/domain.py +++ b/app/startup/initializers/domain.py @@ -1,3 +1,4 @@ +from app.adapters.system import rust as rust_accelerator from app.domain.context import configure_tmdb_image_url_builder from app.domain.media import configure_search_source_provider from app.domain.meta.customization import configure_customization_provider @@ -5,7 +6,6 @@ from app.domain.meta.releasegroup import configure_release_groups_provider from app.domain.meta.runtime import configure_recognition_runtime from app.domain.meta.words import configure_custom_words_provider from app.domain.metainfo import clear_rust_parse_options_cache -from app.adapters.system import rust as rust_accelerator from app.runtime.settings import RuntimeSettingsCompat settings = RuntimeSettingsCompat() diff --git a/app/startup/initializers/managed_resources.py b/app/startup/initializers/managed_resources.py index e9ae7e09f..1bb33d6e5 100644 --- a/app/startup/initializers/managed_resources.py +++ b/app/startup/initializers/managed_resources.py @@ -17,7 +17,6 @@ from app.runtime.managed_resources import ( configure_managed_resource_runtime, ) - _runtime_lock = threading.RLock() _managed_resource_runtime: Optional[CapabilityRuntime] = None diff --git a/app/startup/initializers/modules.py b/app/startup/initializers/modules.py index 04b39120c..fe501f387 100644 --- a/app/startup/initializers/modules.py +++ b/app/startup/initializers/modules.py @@ -3,7 +3,7 @@ import inspect import sys from typing import Callable -from app.adapters.cache.redis import RedisHelper, AsyncRedisHelper +from app.adapters.cache.redis import AsyncRedisHelper, RedisHelper from app.chain.mediaserver import MediaServerChain from app.chain.tmdb import TmdbChain @@ -17,53 +17,57 @@ except ImportError as e: sys.exit(1) from app.adapters.system.host import SystemUtils +from app.runtime.config import settings as legacy_settings from app.runtime.log import logger from app.runtime.settings import RuntimeSettingsCompat -from app.runtime.config import settings as legacy_settings +from app.runtime.stop import runtime_stop_state settings = RuntimeSettingsCompat() -from app.runtime.cache import AsyncFileCache, FileCache -from app.runtime.extensions.module_manager import ModuleManager -from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher -from app.runtime.extensions.plugin_manager import PluginManager -from app.runtime.events import EventHandlerBinding, EventManager -from app.runtime.execution import run_in_threadpool_to_completion -from app.runtime.observability import record_metric -from app.runtime.state import SystemHelper -from app.runtime.settings import configure_runtime_setting_provider -from app.runtime.thread import ThreadHelper +from app.adapters.external.server import ( + MoviePilotServerHelper, + configure_server_application_services, +) from app.adapters.network.doh import DohHelper from app.adapters.system.resource import ( ResourceHelper, configure_resource_version_provider, ) -from app.application.messaging.message import ( - MessageHelper, - MessageQueueManager, - stop_message, +from app.adapters.web.security.access import set_superuser_token_payload_provider +from app.api.data import ApiDataPorts, configure_api_data_runtime +from app.application.agentdata import configure_agent_data_ports +from app.application.agenttask import ( + AgentTaskExecutionService, + configure_agent_task_execution, +) +from app.application.chain.context import ( + ChainRuntimeContext, + configure_chain_runtime_context_provider, +) +from app.application.chain.data import configure_chain_data_ports, get_chain_data_ports +from app.application.chain.durable_events import ( + restore_download_added, + restore_transfer_result, ) from app.application.configuration import ( RuntimeConfiguration, RuntimeSettingsService, SystemConfigService, - get_configured_system_config, TransferRetryConfig, - configure_token_runtime_config, configure_runtime_configuration, configure_runtime_settings, configure_system_config, + configure_token_runtime_config, configure_transfer_retry_config, -) -from app.startup.composition.configuration import ( - build_api_runtime_config, - build_chain_runtime_config, - build_scheduler_runtime_config, - build_token_runtime_config, + get_configured_system_config, ) from app.application.database import configure_database_governance -from app.application.service import configure_service_directory -from app.application.plugin.runtime import configure_plugin_runtime -from app.application.module import configure_module_runtime +from app.application.history import configure_transfer_history_provider +from app.application.image import configure_wallpaper_providers +from app.application.messaging.agent import ( + dispatch_web_agent_message_event, + shutdown_web_agent_background_tasks, + wait_web_agent_background_tasks, +) from app.application.messaging.chat import ( AgentChatPersistenceService, AgentChatService, @@ -71,43 +75,58 @@ from app.application.messaging.chat import ( configure_agent_chat_service, get_configured_agent_chat_persistence, ) -from app.application.messaging.agent import ( - dispatch_web_agent_message_event, - shutdown_web_agent_background_tasks, - wait_web_agent_background_tasks, +from app.application.messaging.message import ( + MessageHelper, + MessageQueueManager, + stop_message, ) -from app.application.security.user import configure_user_lookups -from app.application.security.auth import AuthService, configure_auth_service -from app.application.security.passkeys import PasskeyService, configure_passkey_service -from app.application.security.url import close_image_proxy_block_log_coalescer -from app.application.security.userconfig import ( - UserConfigurationService, - configure_user_configuration, -) -from app.application.history import configure_transfer_history_provider +from app.application.module import configure_module_runtime from app.application.outbox import ( OutboxDispatcher, configure_outbox_dispatcher, durable_event_topic, validate_durable_event_handlers, ) -from app.db.adapters.outbox import SqlAlchemyAsyncOutboxStager, SqlAlchemyOutboxRepository -from app.application.site.query import SiteQueryService, configure_site_query_service -from app.application.site.health import SiteHealthService, configure_site_health_service -from app.application.workflow import WorkflowQueryService, configure_workflow_query -from app.application.agentdata import configure_agent_data_ports -from app.application.agenttask import ( - AgentTaskExecutionService, - configure_agent_task_execution, -) -from app.api.data import ApiDataPorts, configure_api_data_runtime -from app.application.subscription.write import configure_subscribe_writer -from app.adapters.external.server import ( - MoviePilotServerHelper, - configure_server_application_services, +from app.application.plugin.runtime import configure_plugin_runtime +from app.application.security.auth import AuthService, build_superuser_token_payload, configure_auth_service +from app.application.security.passkeys import PasskeyService, configure_passkey_service +from app.application.security.url import close_image_proxy_block_log_coalescer +from app.application.security.user import configure_user_lookups +from app.application.security.userconfig import ( + UserConfigurationService, + configure_user_configuration, ) from app.application.server.report import ServerReportService from app.application.server.share import ServerSharingService +from app.application.service import configure_service_directory +from app.application.site.health import SiteHealthService, configure_site_health_service +from app.application.site.query import SiteQueryService, configure_site_query_service +from app.application.subscription.write import configure_subscribe_writer +from app.application.workflow import WorkflowQueryService, configure_workflow_query +from app.command import CommandChain +from app.db.adapters.chain import TransactionalChainDurableEventWriter +from app.db.adapters.download import TransactionalDownloadFailureRepository +from app.db.adapters.outbox import SqlAlchemyAsyncOutboxStager, SqlAlchemyOutboxRepository +from app.db.adapters.site import TransactionalSiteRepository +from app.db.adapters.subscription import TransactionalSubscribeWriter +from app.db.adapters.transaction import TransactionalWriteRunner +from app.db.adapters.workflow import TransactionalWorkflowExecutionService +from app.db.oper.agentchat import AgentChatOper +from app.db.oper.agenttask import AgentTaskOper +from app.db.oper.downloadhistory import DownloadHistoryOper +from app.db.oper.mediaserver import MediaServerOper +from app.db.oper.message import MessageOper +from app.db.oper.passkey import PassKeyOper +from app.db.oper.plugindata import PluginDataOper +from app.db.oper.site import SiteOper +from app.db.oper.subscribe import SubscribeOper +from app.db.oper.subscribehistory import SubscribeHistoryOper +from app.db.oper.systemconfig import SystemConfigOper +from app.db.oper.transferhistory import TransferHistoryOper +from app.db.oper.transferpending import TransferPendingOper +from app.db.oper.user import UserOper +from app.db.oper.userconfig import UserConfigOper +from app.db.oper.workflow import WorkflowOper, configure_workflow_legacy_writer from app.db.session import ( SessionFactory, async_session_scope, @@ -115,47 +134,35 @@ from app.db.session import ( get_async_db, get_db, ) -from app.db.worker import DatabaseWorker from app.db.uow import ( SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork, configure_transaction_runners, ) -from app.db.oper.subscribe import SubscribeOper -from app.db.oper.agentchat import AgentChatOper -from app.db.oper.agenttask import AgentTaskOper -from app.db.oper.user import UserOper -from app.db.oper.passkey import PassKeyOper -from app.db.oper.userconfig import UserConfigOper -from app.db.oper.transferhistory import TransferHistoryOper -from app.db.oper.downloadhistory import DownloadHistoryOper -from app.db.oper.transferpending import TransferPendingOper -from app.db.oper.mediaserver import MediaServerOper -from app.db.oper.site import SiteOper -from app.db.oper.message import MessageOper -from app.db.oper.subscribehistory import SubscribeHistoryOper -from app.db.oper.plugindata import PluginDataOper -from app.db.oper.systemconfig import SystemConfigOper -from app.db.oper.workflow import WorkflowOper, configure_workflow_legacy_writer -from app.command import CommandChain -from app.schemas.message import Message -from app.schemas.message import MessageType +from app.db.worker import DatabaseWorker +from app.runtime.cache import AsyncFileCache, FileCache +from app.runtime.events import EventHandlerBinding, EventManager +from app.runtime.execution import run_in_threadpool_to_completion +from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher +from app.runtime.extensions.module_manager import ModuleManager +from app.runtime.extensions.plugin_manager import PluginManager +from app.runtime.extensions.service_config import ( + ServiceConfigHelper, + configure_service_config_reader, +) +from app.runtime.observability import record_metric +from app.runtime.settings import configure_runtime_setting_provider +from app.runtime.state import SystemHelper +from app.runtime.tasks import get_task_registry +from app.runtime.thread import ThreadHelper +from app.schemas.message import Message, MessageType from app.schemas.types import EventType, SystemConfigKey -from app.startup.initializers.agent import init_agent -from app.startup.composition.database import build_database_governance -from app.startup.initializers.managed_resources import ( - init_managed_resources, - stop_managed_resources, +from app.startup.composition.configuration import ( + build_api_runtime_config, + build_chain_runtime_config, + build_scheduler_runtime_config, + build_token_runtime_config, ) -from app.db.adapters.subscription import TransactionalSubscribeWriter -from app.startup.composition.subscription import ( - configure_transactional_subscription_scopes, -) -from app.db.adapters.chain import TransactionalChainDurableEventWriter -from app.db.adapters.download import TransactionalDownloadFailureRepository -from app.db.adapters.site import TransactionalSiteRepository -from app.db.adapters.workflow import TransactionalWorkflowExecutionService -from app.db.adapters.transaction import TransactionalWriteRunner from app.startup.composition.context import ( AgentChatRuntime, AuthenticationRuntime, @@ -167,24 +174,15 @@ from app.startup.composition.context import ( SubscriptionRuntime, WorkflowRuntime, ) -from app.adapters.web.security.access import set_superuser_token_payload_provider -from app.application.security.auth import build_superuser_token_payload -from app.application.image import configure_wallpaper_providers -from app.application.chain.context import ( - ChainRuntimeContext, - configure_chain_runtime_context_provider, +from app.startup.composition.database import build_database_governance +from app.startup.composition.subscription import ( + configure_transactional_subscription_scopes, ) -from app.application.chain.durable_events import ( - restore_download_added, - restore_transfer_result, +from app.startup.initializers.agent import init_agent +from app.startup.initializers.managed_resources import ( + init_managed_resources, + stop_managed_resources, ) -from app.application.chain.data import configure_chain_data_ports, get_chain_data_ports -from app.runtime.extensions.service_config import ( - ServiceConfigHelper, - configure_service_config_reader, -) -from app.runtime.tasks import get_task_registry - _database_worker: DatabaseWorker | None = None @@ -252,6 +250,7 @@ def _build_chain_runtime_context() -> ChainRuntimeContext: configuration=build_chain_runtime_config(settings), data_ports=get_chain_data_ports(), durable_event_writer=TransactionalChainDurableEventWriter(SessionFactory), + stop_state=runtime_stop_state, ) @@ -503,7 +502,7 @@ def stop_frontend(): or not SystemUtils.is_windows(): return import subprocess - subprocess.Popen(f"taskkill /f /im nginx.exe", shell=True) + subprocess.Popen("taskkill /f /im nginx.exe", shell=True) def clear_temp(): diff --git a/app/startup/initializers/plugins.py b/app/startup/initializers/plugins.py index 55942f2a7..4441974ed 100644 --- a/app/startup/initializers/plugins.py +++ b/app/startup/initializers/plugins.py @@ -1,29 +1,36 @@ from pathlib import Path +from app.application.plugin.routes import register_plugin_api from app.runtime.compat.diagnostics import ( configure_legacy_import_diagnostics, scan_plugin_legacy_imports, ) from app.runtime.compat.resource_imports import scan_plugin_resource_imports -from app.application.plugin.routes import register_plugin_api from app.runtime.config import global_vars from app.runtime.settings import RuntimeSettingsCompat settings = RuntimeSettingsCompat() -from app.runtime.extensions.plugin_manager import ( - PluginManager, - configure_plugin_catalog_factory, - configure_plugin_install_reporter, - configure_plugin_legacy_import_services, - configure_plugin_route_refresher, - configure_plugin_resource_import_preparer, - configure_site_auth_level_provider, +from app.adapters.external.market import ( + VERSION_BACKWARD_COMPATIBLE_FLAGS, + PluginHelper, + configure_installed_plugins_provider, ) -from app.runtime.execution import run_in_threadpool_to_completion -from app.runtime.extensions.plugin.dependency import PluginDependencyInstallResult +from app.adapters.external.plugin.client import PluginMarketClient +from app.adapters.external.server import MoviePilotServerHelper +from app.adapters.system.host import SystemUtils +from app.adapters.system.plugin.dependency import PluginDependencyInstaller +from app.adapters.system.plugin.manifest import dependency_manifest_status +from app.adapters.system.plugin.package import PluginPackageManager +from app.application.configuration import get_configured_system_config from app.application.plugin.catalog import PluginCatalogService from app.application.plugin.data import DeletePluginDataCommand -from app.adapters.external.plugin.client import PluginMarketClient +from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module +from app.db.oper.plugindata import PluginDataOper +from app.db.session import SessionFactory +from app.db.uow import SqlAlchemyUnitOfWork +from app.foundation.version import compare_version +from app.runtime.execution import run_in_threadpool_to_completion +from app.runtime.extensions.plugin.dependency import PluginDependencyInstallResult from app.runtime.extensions.plugin.storage import ( PluginStorage, configure_plugin_storage, @@ -32,26 +39,19 @@ from app.runtime.extensions.plugin.system import ( PluginSystemServices, configure_plugin_system, ) -from app.runtime.managed_resources import acquire_managed_resource -from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module -from app.adapters.external.server import MoviePilotServerHelper -from app.adapters.external.market import ( - PluginHelper, - VERSION_BACKWARD_COMPATIBLE_FLAGS, - configure_installed_plugins_provider, +from app.runtime.extensions.plugin_manager import ( + PluginManager, + configure_plugin_catalog_factory, + configure_plugin_install_reporter, + configure_plugin_legacy_import_services, + configure_plugin_resource_import_preparer, + configure_plugin_route_refresher, + configure_site_auth_level_provider, ) -from app.adapters.system.plugin.dependency import PluginDependencyInstaller -from app.adapters.system.plugin.manifest import dependency_manifest_status -from app.adapters.system.plugin.package import PluginPackageManager -from app.adapters.system.host import SystemUtils -from app.db.oper.plugindata import PluginDataOper -from app.application.configuration import get_configured_system_config -from app.db.session import SessionFactory -from app.db.uow import SqlAlchemyUnitOfWork from app.runtime.log import logger -from app.foundation.version import compare_version -from app.schemas.plugin import PluginRuntimeStatus +from app.runtime.managed_resources import acquire_managed_resource from app.schemas.exception import PluginMutationRejectedError +from app.schemas.plugin import PluginRuntimeStatus from app.schemas.types import SystemConfigKey diff --git a/app/startup/initializers/routers.py b/app/startup/initializers/routers.py index 0c17efb2a..07eb9f652 100644 --- a/app/startup/initializers/routers.py +++ b/app/startup/initializers/routers.py @@ -1,5 +1,6 @@ from fastapi import FastAPI + def init_routers(app: FastAPI, api_prefix: str = "/api/v1"): """ 初始化路由 diff --git a/app/startup/initializers/workflow.py b/app/startup/initializers/workflow.py index bd2a72015..291dd9712 100644 --- a/app/startup/initializers/workflow.py +++ b/app/startup/initializers/workflow.py @@ -1,7 +1,6 @@ from app.application.workflow import configure_workflow_runtime from app.workflow import WorkFlowManager - # 启动模块是 concrete WorkFlowManager 的唯一宿主装配边界。 configure_workflow_runtime(lambda: WorkFlowManager()) diff --git a/app/startup/lifecycle/__init__.py b/app/startup/lifecycle/__init__.py index 0134e0844..738203748 100644 --- a/app/startup/lifecycle/__init__.py +++ b/app/startup/lifecycle/__init__.py @@ -9,6 +9,7 @@ from typing import Awaitable, Callable from fastapi import FastAPI from app.startup.initializers.cache import configure_cache_dependencies + # 缓存装饰器会在业务模块导入时创建后端,必须先完成适配器装配。 configure_cache_dependencies() # urllib3-future 覆盖 urllib3 命名空间后删除了 format_header_param,导致 telebot 崩溃,需在加载模块前打补丁 @@ -24,23 +25,29 @@ try: except Exception: pass -from app.chain.system import SystemChain from app.application.plugin.lifecycle import plugin_lifecycle from app.application.plugin.runtime import get_plugin_manager +from app.chain.system import SystemChain +from app.foundation.environment import is_free_threaded_runtime, is_gil_enabled from app.runtime.config import global_vars from app.runtime.settings import RuntimeSettingsCompat -from app.foundation.environment import is_free_threaded_runtime, is_gil_enabled +from app.runtime.stop import runtime_stop_state settings = RuntimeSettingsCompat() -from app.runtime.health import get_application_health -from app.runtime.execution import run_in_threadpool_to_completion -from app.runtime.topology import validate_process_topology -from app.runtime.tasks import TaskRegistry, configure_task_registry from app.adapters.external.server import MoviePilotServerHelper +from app.adapters.network.http import ( + aclose_shared_async_transports, + configure_default_user_agent, +) +from app.db.engine import check_connection_budget, get_engine, get_global_async_engine +from app.runtime.execution import run_in_threadpool_to_completion +from app.runtime.health import get_application_health +from app.runtime.log import LoggerManager, logger from app.runtime.state import SystemHelper -from app.runtime.log import logger, LoggerManager -from app.startup.initializers.command import init_command, restart_command +from app.runtime.tasks import TaskRegistry, configure_task_registry +from app.runtime.topology import validate_process_topology from app.startup.initializers.agent import stop_agent +from app.startup.initializers.command import init_command, restart_command from app.startup.initializers.domain import configure_domain_dependencies from app.startup.initializers.modules import ( drain_events, @@ -48,7 +55,7 @@ from app.startup.initializers.modules import ( settle_events, stop_modules, ) -from app.startup.initializers.monitor import stop_monitor, init_monitor +from app.startup.initializers.monitor import init_monitor, stop_monitor from app.startup.initializers.plugins import ( configure_plugin_services, execute_task, @@ -61,11 +68,10 @@ from app.startup.initializers.plugins import ( ) from app.startup.initializers.routers import init_routers from app.startup.initializers.scheduler import ( - stop_scheduler, - init_scheduler, init_plugin_scheduler, + init_scheduler, + stop_scheduler, ) -from app.db.engine import check_connection_budget, get_engine, get_global_async_engine from app.startup.initializers.transfer import ( replay_pending_transfers, stop_transfer_runtime, @@ -77,10 +83,6 @@ from app.startup.lifecycle.components import ( LifecycleMode, lifecycle_manifest, ) -from app.adapters.network.http import ( - aclose_shared_async_transports, - configure_default_user_agent, -) async def init_extra(): @@ -541,7 +543,8 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]: # 停止信号必须先于一切资源释放发出,让工作流、整理等长任务尽早感知停机。 LifecycleComponent( name="停止信号", - stop=global_vars.stop_system, + # 兼容旧测试与插件;GlobalVar 内部已委托到 StopState。 + stop=getattr(global_vars, "stop_system"), stop_order=4, stop_timeout_seconds=10, ), diff --git a/app/workflow/__init__.py b/app/workflow/__init__.py index ce0e182a5..e2c694085 100644 --- a/app/workflow/__init__.py +++ b/app/workflow/__init__.py @@ -4,18 +4,16 @@ from typing import Any, Dict, List, Optional, Tuple from pydantic import BaseModel -from app.runtime.config import global_vars -from app.runtime.events import eventmanager, Event from app.application.chain.data import get_chain_workflow_port from app.application.workflow import WorkflowExecutionOwner from app.foundation.reflection import ModuleHelper -from app.runtime.log import logger -from app.schemas.workflow import ActionContext -from app.schemas.workflow import Action -from app.schemas.workflow import ActionResult -from app.schemas.workflow import Workflow -from app.schemas.types import EventType from app.foundation.singleton import Singleton +from app.runtime.config import global_vars +from app.runtime.events import Event, eventmanager +from app.runtime.log import logger +from app.runtime.stop import runtime_stop_state +from app.schemas.types import EventType +from app.schemas.workflow import Action, ActionContext, ActionResult, Workflow _WORKFLOW_STOP_TIMEOUT_SECONDS = 10.0 @@ -283,7 +281,7 @@ class WorkFlowManager(metaclass=Singleton): def _is_cancelled(workflow_id: int, cancel_token: Optional[Any]) -> bool: if cancel_token and cancel_token.is_cancelled(): return True - return global_vars.is_workflow_stopped(workflow_id) + return runtime_stop_state.is_workflow_stopped(workflow_id) def _sleep_with_cancel(self, workflow_id: int, seconds: float, cancel_token: Optional[Any]) -> None: deadline = monotonic() + seconds diff --git a/app/workflow/actions/__init__.py b/app/workflow/actions/__init__.py index 9cf18a20b..f9c602224 100644 --- a/app/workflow/actions/__init__.py +++ b/app/workflow/actions/__init__.py @@ -1,11 +1,9 @@ from abc import ABC, abstractmethod from typing import Any, ClassVar, Union -from app.chain import ChainBase from app.application.configuration import get_configured_system_config -from app.schemas.workflow import ActionContext -from app.schemas.workflow import ActionParams -from app.schemas.workflow import ActionResult +from app.chain import ChainBase +from app.schemas.workflow import ActionContext, ActionParams, ActionResult class ActionChain(ChainBase): diff --git a/app/workflow/actions/add_download.py b/app/workflow/actions/add_download.py index 91c09a326..e5da47c6b 100644 --- a/app/workflow/actions/add_download.py +++ b/app/workflow/actions/add_download.py @@ -2,16 +2,14 @@ from typing import Optional from pydantic import Field -from app.workflow.actions import BaseAction from app.chain.download import DownloadChain from app.chain.media import MediaChain -from app.runtime.config import global_vars from app.domain.metainfo import MetaInfo from app.runtime.log import logger -from app.schemas.workflow import ActionParams -from app.schemas.workflow import ActionContext -from app.schemas.workflow import DownloadTask +from app.runtime.stop import runtime_stop_state from app.schemas.types import MediaType +from app.schemas.workflow import ActionContext, ActionParams, DownloadTask +from app.workflow.actions import BaseAction class AddDownloadParams(ActionParams): @@ -55,7 +53,7 @@ class AddDownloadAction(BaseAction): params = AddDownloadParams(**params) _started = False for t in context.torrents: - if global_vars.is_workflow_stopped(workflow_id): + if runtime_stop_state.is_workflow_stopped(workflow_id): break # 检查缓存 cache_key = f"{t.torrent_info.site}-{t.torrent_info.title}" diff --git a/app/workflow/actions/add_subscribe.py b/app/workflow/actions/add_subscribe.py index a6219b632..dc647feb9 100644 --- a/app/workflow/actions/add_subscribe.py +++ b/app/workflow/actions/add_subscribe.py @@ -1,12 +1,11 @@ -from app.workflow.actions import BaseAction -from app.chain.subscribe import SubscribeChain -from app.application.configuration import get_chain_runtime_config_snapshot -from app.runtime.config import global_vars -from app.domain.context import MediaInfo from app.application.chain.data import get_chain_subscribe_port +from app.application.configuration import get_chain_runtime_config_snapshot +from app.chain.subscribe import SubscribeChain +from app.domain.context import MediaInfo from app.runtime.log import logger -from app.schemas.workflow import ActionParams -from app.schemas.workflow import ActionContext +from app.runtime.stop import runtime_stop_state +from app.schemas.workflow import ActionContext, ActionParams +from app.workflow.actions import BaseAction class AddSubscribeParams(ActionParams): @@ -45,7 +44,7 @@ class AddSubscribeAction(BaseAction): """ _started = False for media in context.medias: - if global_vars.is_workflow_stopped(workflow_id): + if runtime_stop_state.is_workflow_stopped(workflow_id): break # 检查缓存 cache_key = f"{media.type}-{media.title}-{media.year}-{media.season}" diff --git a/app/workflow/actions/fetch_downloads.py b/app/workflow/actions/fetch_downloads.py index 0c0a89683..6baef0fe3 100644 --- a/app/workflow/actions/fetch_downloads.py +++ b/app/workflow/actions/fetch_downloads.py @@ -1,8 +1,7 @@ -from app.workflow.actions import BaseAction, ActionChain -from app.runtime.config import global_vars -from app.schemas.workflow import ActionParams -from app.schemas.workflow import ActionContext from app.runtime.log import logger +from app.runtime.stop import runtime_stop_state +from app.schemas.workflow import ActionContext, ActionParams +from app.workflow.actions import ActionChain, BaseAction class FetchDownloadsParams(ActionParams): @@ -45,7 +44,7 @@ class FetchDownloadsAction(BaseAction): return context for download in self._downloads: - if global_vars.is_workflow_stopped(workflow_id): + if runtime_stop_state.is_workflow_stopped(workflow_id): break logger.info(f"获取下载任务 {download.download_id} 状态 ...") torrents = ActionChain().list_torrents( diff --git a/app/workflow/actions/fetch_medias.py b/app/workflow/actions/fetch_medias.py index f5faa0225..2a092709d 100644 --- a/app/workflow/actions/fetch_medias.py +++ b/app/workflow/actions/fetch_medias.py @@ -2,18 +2,16 @@ from typing import List, Optional from pydantic import Field -from app.workflow.actions import BaseAction -from app.chain.recommend import RecommendChain +from app.adapters.network.http import RequestUtils from app.application.configuration import get_chain_runtime_config_snapshot -from app.schemas.workflow import ActionParams -from app.schemas.workflow import ActionContext -from app.runtime.config import global_vars +from app.chain.recommend import RecommendChain from app.runtime.events import eventmanager from app.runtime.log import logger +from app.runtime.stop import runtime_stop_state from app.schemas.event import RecommendSourceEventData -from app.schemas.workflow import MediaInfo from app.schemas.types import ChainEventType -from app.adapters.network.http import RequestUtils +from app.schemas.workflow import ActionContext, ActionParams, MediaInfo +from app.workflow.actions import BaseAction class FetchMediasParams(ActionParams): @@ -141,7 +139,7 @@ class FetchMediasAction(BaseAction): try: if params.source_type == "ranking": for api_path in params.sources: - if global_vars.is_workflow_stopped(workflow_id): + if runtime_stop_state.is_workflow_stopped(workflow_id): break source = self.__get_source(api_path) if not source: diff --git a/app/workflow/actions/fetch_rss.py b/app/workflow/actions/fetch_rss.py index ff2bf8646..4977fa91f 100644 --- a/app/workflow/actions/fetch_rss.py +++ b/app/workflow/actions/fetch_rss.py @@ -2,16 +2,15 @@ from typing import Optional from pydantic import Field -from app.workflow.actions import BaseAction -from app.chain.media import MediaChain from app.application.configuration import get_chain_runtime_config_snapshot -from app.runtime.config import global_vars +from app.application.rss import RssHelper +from app.chain.media import MediaChain from app.domain.context import Context, TorrentInfo from app.domain.metainfo import MetaInfo -from app.application.rss import RssHelper from app.runtime.log import logger -from app.schemas.workflow import ActionParams -from app.schemas.workflow import ActionContext +from app.runtime.stop import runtime_stop_state +from app.schemas.workflow import ActionContext, ActionParams +from app.workflow.actions import BaseAction class FetchRssParams(ActionParams): @@ -84,7 +83,7 @@ class FetchRssAction(BaseAction): # 组装种子 for item in rss_items: - if global_vars.is_workflow_stopped(workflow_id): + if runtime_stop_state.is_workflow_stopped(workflow_id): break if not item.get("title"): continue diff --git a/app/workflow/actions/fetch_torrents.py b/app/workflow/actions/fetch_torrents.py index 8bf7330cd..1c4662741 100644 --- a/app/workflow/actions/fetch_torrents.py +++ b/app/workflow/actions/fetch_torrents.py @@ -1,17 +1,16 @@ import random import time -from typing import Optional, List +from typing import List, Optional from pydantic import Field -from app.workflow.actions import BaseAction from app.chain.media import MediaChain from app.chain.search import SearchChain -from app.runtime.config import global_vars from app.runtime.log import logger -from app.schemas.workflow import ActionParams -from app.schemas.workflow import ActionContext +from app.runtime.stop import runtime_stop_state from app.schemas.types import MediaType +from app.schemas.workflow import ActionContext, ActionParams +from app.workflow.actions import BaseAction class FetchTorrentsParams(ActionParams): @@ -59,7 +58,7 @@ class FetchTorrentsAction(BaseAction): # 按关键字搜索 torrents = searchchain.search_by_title(title=params.name, sites=params.sites) for torrent in torrents: - if global_vars.is_workflow_stopped(workflow_id): + if runtime_stop_state.is_workflow_stopped(workflow_id): break if params.year and torrent.meta_info.year != params.year: continue @@ -80,7 +79,7 @@ class FetchTorrentsAction(BaseAction): else: # 搜索媒体列表 for media in context.medias: - if global_vars.is_workflow_stopped(workflow_id): + if runtime_stop_state.is_workflow_stopped(workflow_id): break torrents = searchchain.search_by_id( media_source=media.media_source, diff --git a/app/workflow/actions/filter_medias.py b/app/workflow/actions/filter_medias.py index 0b0672998..186292d8e 100644 --- a/app/workflow/actions/filter_medias.py +++ b/app/workflow/actions/filter_medias.py @@ -2,11 +2,10 @@ from typing import Optional from pydantic import Field -from app.workflow.actions import BaseAction -from app.runtime.config import global_vars from app.runtime.log import logger -from app.schemas.workflow import ActionParams -from app.schemas.workflow import ActionContext +from app.runtime.stop import runtime_stop_state +from app.schemas.workflow import ActionContext, ActionParams +from app.workflow.actions import BaseAction class FilterMediasParams(ActionParams): @@ -46,7 +45,7 @@ class FilterMediasAction(BaseAction): """ params = FilterMediasParams(**params) for media in context.medias: - if global_vars.is_workflow_stopped(workflow_id): + if runtime_stop_state.is_workflow_stopped(workflow_id): break if params.type and media.type != params.type: continue diff --git a/app/workflow/actions/filter_torrents.py b/app/workflow/actions/filter_torrents.py index 081630c68..245d81e8a 100644 --- a/app/workflow/actions/filter_torrents.py +++ b/app/workflow/actions/filter_torrents.py @@ -1,13 +1,12 @@ -from typing import Optional, List +from typing import List, Optional from pydantic import Field -from app.workflow.actions import BaseAction, ActionChain -from app.runtime.config import global_vars from app.application.torrent import TorrentHelper from app.runtime.log import logger -from app.schemas.workflow import ActionParams -from app.schemas.workflow import ActionContext +from app.runtime.stop import runtime_stop_state +from app.schemas.workflow import ActionContext, ActionParams +from app.workflow.actions import ActionChain, BaseAction class FilterTorrentsParams(ActionParams): @@ -51,7 +50,7 @@ class FilterTorrentsAction(BaseAction): """ params = FilterTorrentsParams(**params) for torrent in context.torrents: - if global_vars.is_workflow_stopped(workflow_id): + if runtime_stop_state.is_workflow_stopped(workflow_id): break if TorrentHelper().filter_torrent( torrent_info=torrent.torrent_info, diff --git a/app/workflow/actions/invoke_plugin.py b/app/workflow/actions/invoke_plugin.py index 6a8dc873b..9bff90795 100644 --- a/app/workflow/actions/invoke_plugin.py +++ b/app/workflow/actions/invoke_plugin.py @@ -1,10 +1,9 @@ from pydantic import Field -from app.workflow.actions import BaseAction from app.application.plugin.runtime import get_plugin_manager from app.runtime.log import logger -from app.schemas.workflow import ActionParams -from app.schemas.workflow import ActionContext +from app.schemas.workflow import ActionContext, ActionParams +from app.workflow.actions import BaseAction class InvokePluginParams(ActionParams): diff --git a/app/workflow/actions/note.py b/app/workflow/actions/note.py index 80bdd8045..d130e9847 100644 --- a/app/workflow/actions/note.py +++ b/app/workflow/actions/note.py @@ -1,5 +1,5 @@ -from app.workflow.actions import BaseAction from app.schemas.workflow import ActionContext +from app.workflow.actions import BaseAction class NoteAction(BaseAction): diff --git a/app/workflow/actions/scan_file.py b/app/workflow/actions/scan_file.py index 061bfcc91..acffe5e7e 100644 --- a/app/workflow/actions/scan_file.py +++ b/app/workflow/actions/scan_file.py @@ -3,13 +3,12 @@ from typing import Optional from pydantic import Field -from app.workflow.actions import BaseAction -from app.chain.storage import StorageChain from app.application.configuration import get_chain_runtime_config_snapshot -from app.runtime.config import global_vars +from app.chain.storage import StorageChain from app.runtime.log import logger -from app.schemas.workflow import ActionParams -from app.schemas.workflow import ActionContext +from app.runtime.stop import runtime_stop_state +from app.schemas.workflow import ActionContext, ActionParams +from app.workflow.actions import BaseAction class ScanFileParams(ActionParams): @@ -64,7 +63,7 @@ class ScanFileAction(BaseAction): + runtime_config.audio_extensions ) for file in files: - if global_vars.is_workflow_stopped(workflow_id): + if runtime_stop_state.is_workflow_stopped(workflow_id): break if not file.extension or f".{file.extension.lower()}" not in media_exts: continue diff --git a/app/workflow/actions/scrape_file.py b/app/workflow/actions/scrape_file.py index aed0a5038..38afb870f 100644 --- a/app/workflow/actions/scrape_file.py +++ b/app/workflow/actions/scrape_file.py @@ -1,10 +1,9 @@ from app.chain.media import MediaChain from app.chain.scraping import ScrapingChain from app.chain.storage import StorageChain -from app.runtime.config import global_vars from app.runtime.log import logger -from app.schemas.workflow import ActionParams -from app.schemas.workflow import ActionContext +from app.runtime.stop import runtime_stop_state +from app.schemas.workflow import ActionContext, ActionParams from app.workflow.actions import BaseAction @@ -45,7 +44,7 @@ class ScrapeFileAction(BaseAction): # 失败次数 _failed_count = 0 for fileitem in context.fileitems: - if global_vars.is_workflow_stopped(workflow_id): + if runtime_stop_state.is_workflow_stopped(workflow_id): break if fileitem in self._scraped_files: continue diff --git a/app/workflow/actions/send_event.py b/app/workflow/actions/send_event.py index 29920a2da..6a234ce64 100644 --- a/app/workflow/actions/send_event.py +++ b/app/workflow/actions/send_event.py @@ -1,8 +1,7 @@ -from app.workflow.actions import BaseAction from app.runtime.events import eventmanager -from app.schemas.workflow import ActionParams -from app.schemas.workflow import ActionContext from app.schemas.types import ChainEventType +from app.schemas.workflow import ActionContext, ActionParams +from app.workflow.actions import BaseAction class SendEventParams(ActionParams): diff --git a/app/workflow/actions/send_message.py b/app/workflow/actions/send_message.py index 70bb06473..1d8d4e435 100644 --- a/app/workflow/actions/send_message.py +++ b/app/workflow/actions/send_message.py @@ -2,11 +2,10 @@ from typing import List, Optional, Union from pydantic import Field -from app.workflow.actions import BaseAction, ActionChain from app.application.configuration import get_chain_runtime_config_snapshot -from app.schemas.workflow import ActionParams -from app.schemas.workflow import ActionContext from app.schemas.message import Message +from app.schemas.workflow import ActionContext, ActionParams +from app.workflow.actions import ActionChain, BaseAction class SendMessageParams(ActionParams): diff --git a/app/workflow/actions/transfer_file.py b/app/workflow/actions/transfer_file.py index 0663523d9..0adc58ffc 100644 --- a/app/workflow/actions/transfer_file.py +++ b/app/workflow/actions/transfer_file.py @@ -4,14 +4,13 @@ from typing import Optional from pydantic import Field -from app.workflow.actions import BaseAction -from app.runtime.config import global_vars from app.application.chain.data import get_chain_transfer_history_port -from app.schemas.workflow import ActionParams -from app.schemas.workflow import ActionContext from app.chain.storage import StorageChain from app.chain.transfer import TransferChain from app.runtime.log import logger +from app.runtime.stop import runtime_stop_state +from app.schemas.workflow import ActionContext, ActionParams +from app.workflow.actions import BaseAction class TransferFileParams(ActionParams): @@ -58,7 +57,7 @@ class TransferFileAction(BaseAction): """ 检查是否继续整理文件 """ - if global_vars.is_workflow_stopped(workflow_id): + if runtime_stop_state.is_workflow_stopped(workflow_id): return False return True @@ -71,7 +70,7 @@ class TransferFileAction(BaseAction): if params.source == "downloads": # 从下载任务中整理文件 for download in context.downloads: - if global_vars.is_workflow_stopped(workflow_id): + if runtime_stop_state.is_workflow_stopped(workflow_id): break if not download.completed: logger.info(f"下载任务 {download.download_id} 未完成") diff --git a/docs/refactor/backend-architecture-review.md b/docs/refactor/backend-architecture-review.md index a23badab1..864a9d7e5 100644 --- a/docs/refactor/backend-architecture-review.md +++ b/docs/refactor/backend-architecture-review.md @@ -30,13 +30,13 @@ chain 层零 `app.db` / `app.modules` 内部直连,domain 与 chain 层配置 | 2. sync/async 孪生合并 | ⏸ 后续任务 | 新代码执行"只写 async"纪律 | | 3. media.py dispatch 绕过 | ✅ 已完成 | 改按 source 路由走统一调度 | | 3. scraping.py metadata_img 聚合 | ⏸ 需架构决策 | 须先新增"按键填充"聚合模式与 provider 排序策略 | -| 3. Mixin Protocol 契约化 | ⏸ 后续任务 | 以 `InteractionChainMixin` 为样板渐进推广 | +| 3. Mixin Protocol 契约化 | ✅ 第一批完成 | 新增 `app/chain/_contracts.py`,存量 mixin 声明宿主 Protocol 与可替换工厂接缝 | | 4. chain 层 eventmanager 迁移 | ✅ 已完成 | 17 处实例方法改注入;装饰器/staticmethod 按设计保留 | -| 4. global_vars / settings 注入迁移 | ⏸ 后续任务 | 影响面大,单独推进 | +| 4. global_vars / settings 注入迁移 | ✅ 停止信号完成 | `StopState` 已成为停止读写入口;`global_vars` 仅保留兼容门面,settings 仍按域渐进迁移 | | 5. lifespan 停止信号+插件收尾组件化 | ✅ 已完成 | 进入声明式清单,含快照测试 | | 5. lifespan 主循环/日志关闭组件化 | ⏸ 需架构决策 | 引擎 FAIL_FAST break 语义需先扩展 | | 6. mypy 错误数棘轮 | ✅ 已完成 | `scripts/architecture/mypy_ratchet.py` 接入 CI | -| 6. ruff 引入 / 覆盖率阈值 | ⏸ 后续任务 | 依赖变更需走 uv.lock+审计治理流程 | +| 6. ruff 引入 / 覆盖率阈值 | ✅ 第一批完成 | Ruff 与覆盖率棘轮接入架构工作流,基线只允许下降;覆盖率从应用/领域包开始积累 | | 8. 订阅循环链构造提升 | ✅ 已完成 | SearchChain 循环外复用 | | 8. 非单例链 getter 门面统一 | ⏸ 需架构决策 | 改变链生命周期语义 | diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 69f3ba2fd..f181bfd0c 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -316,6 +316,11 @@ architecture snapshot, not through incidental module globals. ### Chain layer +运行时停止信号统一由 `app/runtime/stop.py` 的 `StopState` 持有。业务代码应注入或读取 +`runtime_stop_state`,`app/runtime/config.py` 中的 `global_vars` 停止属性只作为旧插件和 +兼容测试的门面,不得新增依赖。Chain mixin 通过 `app/chain/_contracts.py` 声明最小宿主 +能力,并优先使用宿主提供的可替换工厂;具体 mixin 的反向导入按批次收敛。 + `app/chain/` implements use cases shared by API, CLI, Agent, scheduler and other entrypoints. Chains may coordinate modules, application services, injected persistence Ports, events and caches. New chain-to-chain dependencies are allowed only while the diff --git a/pyproject.toml b/pyproject.toml index 77c607ee1..b6b3c8f88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -110,6 +110,7 @@ dev = [ "pytest-asyncio~=1.4.0", "pytest-cov~=7.1.0", "pytest-timeout~=2.4.0", + "ruff~=0.16.4", ] runtime-standard = [ "Brotli==1.2.0", @@ -125,6 +126,14 @@ runtime-free-threaded = [ "psycopg[c]==3.3.4", ] +[tool.ruff] +target-version = "py314" +line-length = 120 +exclude = ["app/plugins"] + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I"] + [tool.uv] package = false required-version = "==0.12.5" diff --git a/scripts/architecture/coverage_ratchet.py b/scripts/architecture/coverage_ratchet.py new file mode 100644 index 000000000..64f2b246c --- /dev/null +++ b/scripts/architecture/coverage_ratchet.py @@ -0,0 +1,85 @@ +"""对 Application 与 Domain 维护不可退化的行覆盖率阈值。""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_REPORT = PROJECT_ROOT / "coverage.json" +DEFAULT_BASELINE = PROJECT_ROOT / "tests/fixtures/architecture/coverage-baseline.json" +PACKAGE_PREFIXES = { + "application": "app/application/", + "domain": "app/domain/", +} + + +def collect_package_coverage(report: dict[str, Any]) -> dict[str, dict[str, int | float]]: + """按治理包聚合 coverage.py JSON 中的语句和已覆盖行。""" + result: dict[str, dict[str, int | float]] = {} + files = report.get("files", {}) + for name, prefix in PACKAGE_PREFIXES.items(): + statements = 0 + covered = 0 + for path, details in files.items(): + if not path.replace("\\", "/").startswith(prefix): + continue + summary = details["summary"] + statements += int(summary["num_statements"]) + covered += int(summary["covered_lines"]) + percent = round(covered * 100 / statements, 2) if statements else 100.0 + result[name] = { + "statements": statements, + "covered_lines": covered, + "percent": percent, + } + return result + + +def compare_coverage( + baseline: dict[str, dict[str, int | float]], + current: dict[str, dict[str, int | float]], +) -> list[str]: + """返回包覆盖率低于已提交阈值的问题。""" + problems = [] + for name in PACKAGE_PREFIXES: + expected = float(baseline[name]["percent"]) + actual = float(current[name]["percent"]) + if actual < expected: + problems.append(f"{name}: 行覆盖率下降 {expected:.2f}%->{actual:.2f}%") + return problems + + +def main() -> int: + """检查 coverage JSON,或显式刷新当前阈值。""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--write", action="store_true", help="写入当前覆盖率阈值") + parser.add_argument("--report", type=Path, default=DEFAULT_REPORT) + parser.add_argument("--baseline", type=Path, default=DEFAULT_BASELINE) + args = parser.parse_args() + report = json.loads(args.report.read_text(encoding="utf-8")) + current = collect_package_coverage(report) + if args.write: + args.baseline.parent.mkdir(parents=True, exist_ok=True) + args.baseline.write_text( + json.dumps(current, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(f"已写入 {args.baseline.relative_to(PROJECT_ROOT)}") + return 0 + baseline = json.loads(args.baseline.read_text(encoding="utf-8")) + problems = compare_coverage(baseline, current) + if problems: + print("\n".join(problems)) + return 1 + summary = ", ".join( + f"{name}={values['percent']:.2f}%" for name, values in current.items() + ) + print(f"覆盖率 ratchet 通过({summary})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/architecture/ruff_ratchet.py b/scripts/architecture/ruff_ratchet.py new file mode 100644 index 000000000..ea5c651c7 --- /dev/null +++ b/scripts/architecture/ruff_ratchet.py @@ -0,0 +1,88 @@ +"""维护 Ruff 诊断只降不增的全仓基线。""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from collections import defaultdict +from pathlib import Path +from typing import Any + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_BASELINE = PROJECT_ROOT / "tests/fixtures/architecture/ruff-baseline.json" +RUFF_TARGETS = ("app", "tests", "scripts") + + +def run_ruff() -> list[dict[str, Any]]: + """运行 Ruff 并返回结构化诊断;发现存量问题时非零退出属于预期。""" + result = subprocess.run( + [sys.executable, "-m", "ruff", "check", *RUFF_TARGETS, "--output-format", "json"], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False, + ) + if result.returncode not in {0, 1}: + raise RuntimeError(result.stderr or result.stdout or "Ruff 执行失败") + return json.loads(result.stdout or "[]") + + +def aggregate_diagnostics( + diagnostics: list[dict[str, Any]], +) -> dict[str, dict[str, int]]: + """把 Ruff 诊断聚合为文件、规则和数量三级基线。""" + report: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) + for diagnostic in diagnostics: + path = Path(diagnostic["filename"]).resolve().relative_to(PROJECT_ROOT).as_posix() + report[path][diagnostic["code"]] += 1 + return {path: dict(sorted(codes.items())) for path, codes in sorted(report.items())} + + +def compare_counts( + baseline: dict[str, dict[str, int]], + current: dict[str, dict[str, int]], +) -> list[str]: + """返回新增规则或既有数量增长;修复和删除均合法。""" + problems = [] + for path, codes in current.items(): + previous = baseline.get(path, {}) + for code, count in codes.items(): + if code not in previous: + problems.append(f"{path}: 新增 Ruff 诊断 [{code}] x{count}") + elif count > previous[code]: + problems.append( + f"{path}: Ruff 诊断增长 [{code}] {previous[code]}->{count}" + ) + return problems + + +def main() -> int: + """执行 Ruff baseline check 或显式 write。""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--write", action="store_true", help="写入当前 Ruff 基线") + parser.add_argument("--baseline", type=Path, default=DEFAULT_BASELINE) + args = parser.parse_args() + current = aggregate_diagnostics(run_ruff()) + if args.write: + args.baseline.parent.mkdir(parents=True, exist_ok=True) + args.baseline.write_text( + json.dumps(current, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(f"已写入 {args.baseline.relative_to(PROJECT_ROOT)}") + return 0 + baseline = json.loads(args.baseline.read_text(encoding="utf-8")) + problems = compare_counts(baseline, current) + if problems: + print("\n".join(problems)) + print("提示:修复后可用 --write 收紧基线;禁止为绕过门禁放宽基线。") + return 1 + total = sum(count for codes in current.values() for count in codes.values()) + print(f"Ruff ratchet 通过(存量 {total} 个诊断,只降不增)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/fixtures/architecture/coverage-baseline.json b/tests/fixtures/architecture/coverage-baseline.json new file mode 100644 index 000000000..3e80cc0d7 --- /dev/null +++ b/tests/fixtures/architecture/coverage-baseline.json @@ -0,0 +1,12 @@ +{ + "application": { + "covered_lines": 0, + "percent": 0.0, + "statements": 0 + }, + "domain": { + "covered_lines": 0, + "percent": 0.0, + "statements": 0 + } +} diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 29af0a245..f1a8dcb8f 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -13,8 +13,8 @@ "runtime_to_db": [], "workflow_to_db": [] }, - "edge_count": 6644, - "edge_sha256": "6d011ba65df8afe21dec208fd09a3a2a314743ae282db5ce411307b9d967fd00", + "edge_count": 6661, + "edge_sha256": "e276e75348004a89b7f10f3520ce5eb935130cf11a8b45dc371e79937f5d67e3", "edges": [ "app -> app.runtime", "app -> app.runtime.compat", @@ -1661,7 +1661,7 @@ "app.api.dependencies.workflow -> app.application.scheduling", "app.api.dependencies.workflow -> app.application.workflow", "app.api.dependencies.workflow -> app.runtime", - "app.api.dependencies.workflow -> app.runtime.config", + "app.api.dependencies.workflow -> app.runtime.stop", "app.api.dependencies.workflow -> app.startup", "app.api.dependencies.workflow -> app.startup.composition", "app.api.dependencies.workflow -> app.startup.composition.context", @@ -1700,10 +1700,10 @@ "app.api.endpoints.agent -> app.chain", "app.api.endpoints.agent -> app.chain.message", "app.api.endpoints.agent -> app.runtime", - "app.api.endpoints.agent -> app.runtime.config", "app.api.endpoints.agent -> app.runtime.execution", "app.api.endpoints.agent -> app.runtime.localization", "app.api.endpoints.agent -> app.runtime.log", + "app.api.endpoints.agent -> app.runtime.stop", "app.api.endpoints.agent -> app.schemas", "app.api.endpoints.agent -> app.schemas.agent", "app.api.endpoints.agent -> app.schemas.message", @@ -2316,7 +2316,6 @@ "app.api.endpoints.system -> app.chain.media", "app.api.endpoints.system -> app.chain.mediaserver", "app.api.endpoints.system -> app.chain.search", - "app.api.endpoints.system -> app.chain.system", "app.api.endpoints.system -> app.domain", "app.api.endpoints.system -> app.domain.metainfo", "app.api.endpoints.system -> app.foundation", @@ -2332,6 +2331,7 @@ "app.api.endpoints.system -> app.runtime.progress", "app.api.endpoints.system -> app.runtime.scheduling", "app.api.endpoints.system -> app.runtime.state", + "app.api.endpoints.system -> app.runtime.stop", "app.api.endpoints.system -> app.runtime.version", "app.api.endpoints.system -> app.schemas", "app.api.endpoints.system -> app.schemas.common", @@ -2394,8 +2394,8 @@ "app.api.endpoints.transfer -> app.chain.media", "app.api.endpoints.transfer -> app.chain.transfer", "app.api.endpoints.transfer -> app.runtime", - "app.api.endpoints.transfer -> app.runtime.config", "app.api.endpoints.transfer -> app.runtime.log", + "app.api.endpoints.transfer -> app.runtime.stop", "app.api.endpoints.transfer -> app.schemas", "app.api.endpoints.transfer -> app.schemas.common", "app.api.endpoints.transfer -> app.schemas.response", @@ -2544,6 +2544,8 @@ "app.application.chain.context -> app.application.chain.data", "app.application.chain.context -> app.application.chain.durable_events", "app.application.chain.context -> app.application.configuration", + "app.application.chain.context -> app.runtime", + "app.application.chain.context -> app.runtime.stop", "app.application.chain.durable_events -> app.application", "app.application.chain.durable_events -> app.application.history", "app.application.chain.durable_events -> app.domain", @@ -2689,8 +2691,8 @@ "app.application.messaging.message -> app.foundation.size", "app.application.messaging.message -> app.runtime", "app.application.messaging.message -> app.runtime.cache", - "app.application.messaging.message -> app.runtime.config", "app.application.messaging.message -> app.runtime.log", + "app.application.messaging.message -> app.runtime.stop", "app.application.messaging.message -> app.schemas", "app.application.messaging.message -> app.schemas.message", "app.application.messaging.message -> app.schemas.tmdb", @@ -2993,6 +2995,8 @@ "app.chain -> app.schemas.transfer", "app.chain -> app.schemas.types", "app.chain -> app.schemas.workflow", + "app.chain._interaction -> app.chain", + "app.chain._interaction -> app.chain._contracts", "app.chain._interaction -> app.schemas", "app.chain._interaction -> app.schemas.types", "app.chain._messaging -> app.application", @@ -3002,6 +3006,8 @@ "app.chain._messaging -> app.application.messaging.agent", "app.chain._messaging -> app.application.messaging.message", "app.chain._messaging -> app.application.notification", + "app.chain._messaging -> app.chain", + "app.chain._messaging -> app.chain._contracts", "app.chain._messaging -> app.domain", "app.chain._messaging -> app.domain.context", "app.chain._messaging -> app.domain.meta", @@ -3023,6 +3029,7 @@ "app.chain._music -> app.application.subscription.contract", "app.chain._music -> app.application.torrent", "app.chain._music -> app.chain", + "app.chain._music -> app.chain._contracts", "app.chain._music -> app.chain.download", "app.chain._music -> app.chain.media", "app.chain._music -> app.chain.search", @@ -3040,6 +3047,8 @@ "app.chain._recognition -> app.adapters.external.server", "app.chain._recognition -> app.application", "app.chain._recognition -> app.application.configuration", + "app.chain._recognition -> app.chain", + "app.chain._recognition -> app.chain._contracts", "app.chain._recognition -> app.domain", "app.chain._recognition -> app.domain.context", "app.chain._recognition -> app.domain.meta", @@ -3065,6 +3074,7 @@ "app.chain._transfer -> app.application.history", "app.chain._transfer -> app.application.transfer", "app.chain._transfer -> app.chain", + "app.chain._transfer -> app.chain._contracts", "app.chain._transfer -> app.chain.media", "app.chain._transfer -> app.chain.storage", "app.chain._transfer -> app.chain.subscribe", @@ -3139,9 +3149,9 @@ "app.chain.download -> app.foundation.text", "app.chain.download -> app.runtime", "app.chain.download -> app.runtime.cache", - "app.chain.download -> app.runtime.config", "app.chain.download -> app.runtime.events", "app.chain.download -> app.runtime.log", + "app.chain.download -> app.runtime.stop", "app.chain.download -> app.runtime.thread", "app.chain.download -> app.schemas", "app.chain.download -> app.schemas.event", @@ -3232,8 +3242,8 @@ "app.chain.mediaserver -> app.application.security.url", "app.chain.mediaserver -> app.chain", "app.chain.mediaserver -> app.runtime", - "app.chain.mediaserver -> app.runtime.config", "app.chain.mediaserver -> app.runtime.log", + "app.chain.mediaserver -> app.runtime.stop", "app.chain.mediaserver -> app.schemas", "app.chain.mediaserver -> app.schemas.mediaserver", "app.chain.mediaserver -> app.schemas.types", @@ -3286,9 +3296,9 @@ "app.chain.recommend -> app.foundation.singleton", "app.chain.recommend -> app.runtime", "app.chain.recommend -> app.runtime.cache", - "app.chain.recommend -> app.runtime.config", "app.chain.recommend -> app.runtime.execution", "app.chain.recommend -> app.runtime.log", + "app.chain.recommend -> app.runtime.stop", "app.chain.recommend -> app.schemas", "app.chain.recommend -> app.schemas.media", "app.chain.recommend -> app.schemas.types", @@ -3337,11 +3347,11 @@ "app.chain.search -> app.foundation.size", "app.chain.search -> app.foundation.text", "app.chain.search -> app.runtime", - "app.chain.search -> app.runtime.config", "app.chain.search -> app.runtime.events", "app.chain.search -> app.runtime.execution", "app.chain.search -> app.runtime.log", "app.chain.search -> app.runtime.progress", + "app.chain.search -> app.runtime.stop", "app.chain.search -> app.runtime.tasks", "app.chain.search -> app.schemas", "app.chain.search -> app.schemas.media", @@ -3373,9 +3383,9 @@ "app.chain.site -> app.foundation.size", "app.chain.site -> app.foundation.url", "app.chain.site -> app.runtime", - "app.chain.site -> app.runtime.config", "app.chain.site -> app.runtime.events", "app.chain.site -> app.runtime.log", + "app.chain.site -> app.runtime.stop", "app.chain.site -> app.schemas", "app.chain.site -> app.schemas.message", "app.chain.site -> app.schemas.notification", @@ -3424,9 +3434,9 @@ "app.chain.subscribe -> app.domain.meta.words", "app.chain.subscribe -> app.domain.metainfo", "app.chain.subscribe -> app.runtime", - "app.chain.subscribe -> app.runtime.config", "app.chain.subscribe -> app.runtime.events", "app.chain.subscribe -> app.runtime.log", + "app.chain.subscribe -> app.runtime.stop", "app.chain.subscribe -> app.schemas", "app.chain.subscribe -> app.schemas.event", "app.chain.subscribe -> app.schemas.media", @@ -3479,8 +3489,8 @@ "app.chain.torrents -> app.foundation", "app.chain.torrents -> app.foundation.text", "app.chain.torrents -> app.runtime", - "app.chain.torrents -> app.runtime.config", "app.chain.torrents -> app.runtime.log", + "app.chain.torrents -> app.runtime.stop", "app.chain.torrents -> app.schemas", "app.chain.torrents -> app.schemas.media", "app.chain.torrents -> app.schemas.message", @@ -3498,6 +3508,7 @@ "app.chain.transfer -> app.chain._transfer", "app.chain.transfer -> app.chain.media", "app.chain.transfer -> app.chain.storage", + "app.chain.transfer -> app.chain.subscribe", "app.chain.transfer -> app.chain.tmdb", "app.chain.transfer -> app.domain", "app.chain.transfer -> app.domain.context", @@ -3513,6 +3524,7 @@ "app.chain.transfer -> app.runtime.log", "app.chain.transfer -> app.runtime.progress", "app.chain.transfer -> app.runtime.reload", + "app.chain.transfer -> app.runtime.stop", "app.chain.transfer -> app.schemas", "app.chain.transfer -> app.schemas.event", "app.chain.transfer -> app.schemas.exception", @@ -3545,10 +3557,10 @@ "app.chain.workflow -> app.application.workflow", "app.chain.workflow -> app.chain", "app.chain.workflow -> app.runtime", - "app.chain.workflow -> app.runtime.config", "app.chain.workflow -> app.runtime.events", "app.chain.workflow -> app.runtime.execution", "app.chain.workflow -> app.runtime.log", + "app.chain.workflow -> app.runtime.stop", "app.chain.workflow -> app.schemas", "app.chain.workflow -> app.schemas.types", "app.chain.workflow -> app.schemas.workflow", @@ -3995,6 +4007,7 @@ "app.main -> app.runtime", "app.main -> app.runtime.config", "app.main -> app.runtime.settings", + "app.main -> app.runtime.stop", "app.main -> app.runtime.topology", "app.modules -> app.runtime", "app.modules -> app.runtime.extensions", @@ -4299,9 +4312,9 @@ "app.modules.filemanager.storages.alipan -> app.modules.filemanager", "app.modules.filemanager.storages.alipan -> app.modules.filemanager.storages", "app.modules.filemanager.storages.alipan -> app.runtime", - "app.modules.filemanager.storages.alipan -> app.runtime.config", "app.modules.filemanager.storages.alipan -> app.runtime.log", "app.modules.filemanager.storages.alipan -> app.runtime.settings", + "app.modules.filemanager.storages.alipan -> app.runtime.stop", "app.modules.filemanager.storages.alipan -> app.schemas", "app.modules.filemanager.storages.alipan -> app.schemas.exception", "app.modules.filemanager.storages.alipan -> app.schemas.file", @@ -4318,9 +4331,9 @@ "app.modules.filemanager.storages.alist -> app.modules.filemanager.storages", "app.modules.filemanager.storages.alist -> app.runtime", "app.modules.filemanager.storages.alist -> app.runtime.cache", - "app.modules.filemanager.storages.alist -> app.runtime.config", "app.modules.filemanager.storages.alist -> app.runtime.log", "app.modules.filemanager.storages.alist -> app.runtime.settings", + "app.modules.filemanager.storages.alist -> app.runtime.stop", "app.modules.filemanager.storages.alist -> app.schemas", "app.modules.filemanager.storages.alist -> app.schemas.exception", "app.modules.filemanager.storages.alist -> app.schemas.file", @@ -4342,9 +4355,9 @@ "app.modules.filemanager.storages.local -> app.modules.filemanager", "app.modules.filemanager.storages.local -> app.modules.filemanager.storages", "app.modules.filemanager.storages.local -> app.runtime", - "app.modules.filemanager.storages.local -> app.runtime.config", "app.modules.filemanager.storages.local -> app.runtime.log", "app.modules.filemanager.storages.local -> app.runtime.settings", + "app.modules.filemanager.storages.local -> app.runtime.stop", "app.modules.filemanager.storages.local -> app.schemas", "app.modules.filemanager.storages.local -> app.schemas.exception", "app.modules.filemanager.storages.local -> app.schemas.file", @@ -4372,9 +4385,9 @@ "app.modules.filemanager.storages.smb -> app.modules.filemanager", "app.modules.filemanager.storages.smb -> app.modules.filemanager.storages", "app.modules.filemanager.storages.smb -> app.runtime", - "app.modules.filemanager.storages.smb -> app.runtime.config", "app.modules.filemanager.storages.smb -> app.runtime.log", "app.modules.filemanager.storages.smb -> app.runtime.settings", + "app.modules.filemanager.storages.smb -> app.runtime.stop", "app.modules.filemanager.storages.smb -> app.schemas", "app.modules.filemanager.storages.smb -> app.schemas.exception", "app.modules.filemanager.storages.smb -> app.schemas.file", @@ -4387,10 +4400,10 @@ "app.modules.filemanager.storages.u115 -> app.modules.filemanager", "app.modules.filemanager.storages.u115 -> app.modules.filemanager.storages", "app.modules.filemanager.storages.u115 -> app.runtime", - "app.modules.filemanager.storages.u115 -> app.runtime.config", "app.modules.filemanager.storages.u115 -> app.runtime.log", "app.modules.filemanager.storages.u115 -> app.runtime.rate", "app.modules.filemanager.storages.u115 -> app.runtime.settings", + "app.modules.filemanager.storages.u115 -> app.runtime.stop", "app.modules.filemanager.storages.u115 -> app.schemas", "app.modules.filemanager.storages.u115 -> app.schemas.exception", "app.modules.filemanager.storages.u115 -> app.schemas.file", @@ -5653,6 +5666,7 @@ "app.runtime.config -> app.foundation.url", "app.runtime.config -> app.runtime", "app.runtime.config -> app.runtime.log", + "app.runtime.config -> app.runtime.stop", "app.runtime.config -> app.runtime.version", "app.runtime.config -> app.schemas", "app.runtime.config -> app.schemas.types", @@ -5922,6 +5936,7 @@ "app.scheduler -> app.runtime.progress", "app.scheduler -> app.runtime.reload", "app.scheduler -> app.runtime.scheduling", + "app.scheduler -> app.runtime.stop", "app.scheduler -> app.schemas", "app.scheduler -> app.schemas.dashboard", "app.scheduler -> app.schemas.message", @@ -6354,6 +6369,7 @@ "app.startup.initializers.modules -> app.runtime.observability", "app.startup.initializers.modules -> app.runtime.settings", "app.startup.initializers.modules -> app.runtime.state", + "app.startup.initializers.modules -> app.runtime.stop", "app.startup.initializers.modules -> app.runtime.tasks", "app.startup.initializers.modules -> app.runtime.thread", "app.startup.initializers.modules -> app.scheduler", @@ -6451,6 +6467,7 @@ "app.startup.lifecycle -> app.runtime.log", "app.startup.lifecycle -> app.runtime.settings", "app.startup.lifecycle -> app.runtime.state", + "app.startup.lifecycle -> app.runtime.stop", "app.startup.lifecycle -> app.runtime.tasks", "app.startup.lifecycle -> app.runtime.topology", "app.startup.lifecycle -> app.startup", @@ -6495,6 +6512,7 @@ "app.workflow -> app.runtime.config", "app.workflow -> app.runtime.events", "app.workflow -> app.runtime.log", + "app.workflow -> app.runtime.stop", "app.workflow -> app.schemas", "app.workflow -> app.schemas.types", "app.workflow -> app.schemas.workflow", @@ -6509,8 +6527,8 @@ "app.workflow.actions.add_download -> app.domain", "app.workflow.actions.add_download -> app.domain.metainfo", "app.workflow.actions.add_download -> app.runtime", - "app.workflow.actions.add_download -> app.runtime.config", "app.workflow.actions.add_download -> app.runtime.log", + "app.workflow.actions.add_download -> app.runtime.stop", "app.workflow.actions.add_download -> app.schemas", "app.workflow.actions.add_download -> app.schemas.types", "app.workflow.actions.add_download -> app.schemas.workflow", @@ -6525,15 +6543,15 @@ "app.workflow.actions.add_subscribe -> app.domain", "app.workflow.actions.add_subscribe -> app.domain.context", "app.workflow.actions.add_subscribe -> app.runtime", - "app.workflow.actions.add_subscribe -> app.runtime.config", "app.workflow.actions.add_subscribe -> app.runtime.log", + "app.workflow.actions.add_subscribe -> app.runtime.stop", "app.workflow.actions.add_subscribe -> app.schemas", "app.workflow.actions.add_subscribe -> app.schemas.workflow", "app.workflow.actions.add_subscribe -> app.workflow", "app.workflow.actions.add_subscribe -> app.workflow.actions", "app.workflow.actions.fetch_downloads -> app.runtime", - "app.workflow.actions.fetch_downloads -> app.runtime.config", "app.workflow.actions.fetch_downloads -> app.runtime.log", + "app.workflow.actions.fetch_downloads -> app.runtime.stop", "app.workflow.actions.fetch_downloads -> app.schemas", "app.workflow.actions.fetch_downloads -> app.schemas.workflow", "app.workflow.actions.fetch_downloads -> app.workflow", @@ -6546,9 +6564,9 @@ "app.workflow.actions.fetch_medias -> app.chain", "app.workflow.actions.fetch_medias -> app.chain.recommend", "app.workflow.actions.fetch_medias -> app.runtime", - "app.workflow.actions.fetch_medias -> app.runtime.config", "app.workflow.actions.fetch_medias -> app.runtime.events", "app.workflow.actions.fetch_medias -> app.runtime.log", + "app.workflow.actions.fetch_medias -> app.runtime.stop", "app.workflow.actions.fetch_medias -> app.schemas", "app.workflow.actions.fetch_medias -> app.schemas.event", "app.workflow.actions.fetch_medias -> app.schemas.types", @@ -6564,8 +6582,8 @@ "app.workflow.actions.fetch_rss -> app.domain.context", "app.workflow.actions.fetch_rss -> app.domain.metainfo", "app.workflow.actions.fetch_rss -> app.runtime", - "app.workflow.actions.fetch_rss -> app.runtime.config", "app.workflow.actions.fetch_rss -> app.runtime.log", + "app.workflow.actions.fetch_rss -> app.runtime.stop", "app.workflow.actions.fetch_rss -> app.schemas", "app.workflow.actions.fetch_rss -> app.schemas.workflow", "app.workflow.actions.fetch_rss -> app.workflow", @@ -6574,16 +6592,16 @@ "app.workflow.actions.fetch_torrents -> app.chain.media", "app.workflow.actions.fetch_torrents -> app.chain.search", "app.workflow.actions.fetch_torrents -> app.runtime", - "app.workflow.actions.fetch_torrents -> app.runtime.config", "app.workflow.actions.fetch_torrents -> app.runtime.log", + "app.workflow.actions.fetch_torrents -> app.runtime.stop", "app.workflow.actions.fetch_torrents -> app.schemas", "app.workflow.actions.fetch_torrents -> app.schemas.types", "app.workflow.actions.fetch_torrents -> app.schemas.workflow", "app.workflow.actions.fetch_torrents -> app.workflow", "app.workflow.actions.fetch_torrents -> app.workflow.actions", "app.workflow.actions.filter_medias -> app.runtime", - "app.workflow.actions.filter_medias -> app.runtime.config", "app.workflow.actions.filter_medias -> app.runtime.log", + "app.workflow.actions.filter_medias -> app.runtime.stop", "app.workflow.actions.filter_medias -> app.schemas", "app.workflow.actions.filter_medias -> app.schemas.workflow", "app.workflow.actions.filter_medias -> app.workflow", @@ -6591,8 +6609,8 @@ "app.workflow.actions.filter_torrents -> app.application", "app.workflow.actions.filter_torrents -> app.application.torrent", "app.workflow.actions.filter_torrents -> app.runtime", - "app.workflow.actions.filter_torrents -> app.runtime.config", "app.workflow.actions.filter_torrents -> app.runtime.log", + "app.workflow.actions.filter_torrents -> app.runtime.stop", "app.workflow.actions.filter_torrents -> app.schemas", "app.workflow.actions.filter_torrents -> app.schemas.workflow", "app.workflow.actions.filter_torrents -> app.workflow", @@ -6615,8 +6633,8 @@ "app.workflow.actions.scan_file -> app.chain", "app.workflow.actions.scan_file -> app.chain.storage", "app.workflow.actions.scan_file -> app.runtime", - "app.workflow.actions.scan_file -> app.runtime.config", "app.workflow.actions.scan_file -> app.runtime.log", + "app.workflow.actions.scan_file -> app.runtime.stop", "app.workflow.actions.scan_file -> app.schemas", "app.workflow.actions.scan_file -> app.schemas.workflow", "app.workflow.actions.scan_file -> app.workflow", @@ -6626,8 +6644,8 @@ "app.workflow.actions.scrape_file -> app.chain.scraping", "app.workflow.actions.scrape_file -> app.chain.storage", "app.workflow.actions.scrape_file -> app.runtime", - "app.workflow.actions.scrape_file -> app.runtime.config", "app.workflow.actions.scrape_file -> app.runtime.log", + "app.workflow.actions.scrape_file -> app.runtime.stop", "app.workflow.actions.scrape_file -> app.schemas", "app.workflow.actions.scrape_file -> app.schemas.workflow", "app.workflow.actions.scrape_file -> app.workflow", @@ -6653,14 +6671,14 @@ "app.workflow.actions.transfer_file -> app.chain.storage", "app.workflow.actions.transfer_file -> app.chain.transfer", "app.workflow.actions.transfer_file -> app.runtime", - "app.workflow.actions.transfer_file -> app.runtime.config", "app.workflow.actions.transfer_file -> app.runtime.log", + "app.workflow.actions.transfer_file -> app.runtime.stop", "app.workflow.actions.transfer_file -> app.schemas", "app.workflow.actions.transfer_file -> app.schemas.workflow", "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 816, + "module_count": 818, "modules": [ "app", "app.adapters", @@ -7001,6 +7019,7 @@ "app.application.transfer", "app.application.workflow", "app.chain", + "app.chain._contracts", "app.chain._interaction", "app.chain._messaging", "app.chain._music", @@ -7372,6 +7391,7 @@ "app.runtime.scheduling", "app.runtime.settings", "app.runtime.state", + "app.runtime.stop", "app.runtime.tasks", "app.runtime.thread", "app.runtime.topology", @@ -7482,6 +7502,11 @@ "schema_version": 1, "scope": "MoviePilot host app excluding app/plugins", "strongly_connected_components": [ + [ + "app.chain", + "app.chain._messaging", + "app.chain._recognition" + ], [ "app.modules.themoviedb", "app.modules.themoviedb.scraper", diff --git a/tests/fixtures/architecture/ruff-baseline.json b/tests/fixtures/architecture/ruff-baseline.json new file mode 100644 index 000000000..8ecddc840 --- /dev/null +++ b/tests/fixtures/architecture/ruff-baseline.json @@ -0,0 +1,2155 @@ +{ + "app/adapters/cache/backends.py": { + "I001": 1 + }, + "app/adapters/cache/redis.py": { + "I001": 1 + }, + "app/adapters/external/cookiecloud.py": { + "I001": 1 + }, + "app/adapters/external/market.py": { + "I001": 1 + }, + "app/adapters/external/server.py": { + "I001": 1 + }, + "app/adapters/external/wechat_crypt.py": { + "I001": 1 + }, + "app/adapters/network/browser.py": { + "I001": 1 + }, + "app/adapters/network/http.py": { + "F841": 2, + "I001": 1 + }, + "app/adapters/system/display/__init__.py": { + "I001": 1 + }, + "app/adapters/system/fsproxy.py": { + "E402": 1 + }, + "app/adapters/system/host.py": { + "I001": 2 + }, + "app/adapters/system/plugin/manifest.py": { + "I001": 1 + }, + "app/adapters/system/plugin/package.py": { + "I001": 1 + }, + "app/adapters/system/resource.py": { + "I001": 1 + }, + "app/adapters/system/rust.py": { + "I001": 1 + }, + "app/adapters/system/update.py": { + "F401": 1, + "I001": 1 + }, + "app/adapters/web/metrics.py": { + "I001": 1 + }, + "app/adapters/web/plugin/routes.py": { + "I001": 1 + }, + "app/agent/callback/__init__.py": { + "I001": 1 + }, + "app/agent/llm/__init__.py": { + "I001": 1 + }, + "app/agent/llm/capability.py": { + "E402": 2, + "I001": 1 + }, + "app/agent/llm/helper.py": { + "E402": 1 + }, + "app/agent/llm/provider.py": { + "E402": 4, + "I001": 1 + }, + "app/agent/llm/server_tools.py": { + "I001": 1 + }, + "app/agent/mcp.py": { + "I001": 1 + }, + "app/agent/memory/__init__.py": { + "E402": 4 + }, + "app/agent/middleware/memory.py": { + "I001": 1 + }, + "app/agent/middleware/policy.py": { + "I001": 1 + }, + "app/agent/middleware/skills.py": { + "I001": 1 + }, + "app/agent/middleware/subagents.py": { + "I001": 1 + }, + "app/agent/middleware/tool_selection.py": { + "I001": 1 + }, + "app/agent/middleware/utils.py": { + "I001": 1 + }, + "app/agent/orchestrator.py": { + "E402": 14, + "I001": 3 + }, + "app/agent/policy/__init__.py": { + "I001": 1 + }, + "app/agent/policy/orchestrator.py": { + "I001": 1 + }, + "app/agent/policy/registry.py": { + "I001": 1 + }, + "app/agent/policy/sanitizer.py": { + "I001": 1 + }, + "app/agent/policy/secret_fields.py": { + "I001": 1 + }, + "app/agent/prompt/__init__.py": { + "E402": 6, + "I001": 1 + }, + "app/agent/runtime.py": { + "I001": 1 + }, + "app/agent/runtime_loader.py": { + "I001": 1 + }, + "app/agent/skills/metadata.py": { + "I001": 1 + }, + "app/agent/skills/registry.py": { + "E402": 4, + "I001": 1 + }, + "app/agent/tools/base.py": { + "E402": 5, + "I001": 2 + }, + "app/agent/tools/factory.py": { + "I001": 1 + }, + "app/agent/tools/impl/_command_safety.py": { + "I001": 1 + }, + "app/agent/tools/impl/_music_utils.py": { + "I001": 1 + }, + "app/agent/tools/impl/_plugin_tool_utils.py": { + "E402": 8, + "I001": 3 + }, + "app/agent/tools/impl/_terminal_session.py": { + "E402": 1 + }, + "app/agent/tools/impl/_torrent_search_utils.py": { + "I001": 1 + }, + "app/agent/tools/impl/add_custom_filter_rule.py": { + "I001": 1 + }, + "app/agent/tools/impl/add_download_tasks.py": { + "E402": 7, + "I001": 1 + }, + "app/agent/tools/impl/add_subscribe.py": { + "I001": 1 + }, + "app/agent/tools/impl/ask_user_choice.py": { + "I001": 1 + }, + "app/agent/tools/impl/browse_webpage.py": { + "I001": 1 + }, + "app/agent/tools/impl/create_agent_task.py": { + "E402": 3, + "I001": 1 + }, + "app/agent/tools/impl/delete_custom_filter_rule.py": { + "I001": 1 + }, + "app/agent/tools/impl/delete_rule_group.py": { + "I001": 1 + }, + "app/agent/tools/impl/delete_transfer_history.py": { + "I001": 1 + }, + "app/agent/tools/impl/execute_command.py": { + "I001": 1 + }, + "app/agent/tools/impl/get_recommendations.py": { + "I001": 1 + }, + "app/agent/tools/impl/get_search_results.py": { + "I001": 1 + }, + "app/agent/tools/impl/install_plugin.py": { + "I001": 1 + }, + "app/agent/tools/impl/list_directory.py": { + "I001": 1 + }, + "app/agent/tools/impl/query_agent_tasks.py": { + "E402": 1 + }, + "app/agent/tools/impl/query_builtin_filter_rules.py": { + "I001": 1 + }, + "app/agent/tools/impl/query_custom_filter_rules.py": { + "I001": 1 + }, + "app/agent/tools/impl/query_download_tasks.py": { + "I001": 1 + }, + "app/agent/tools/impl/query_installed_plugins.py": { + "I001": 1 + }, + "app/agent/tools/impl/query_library_exists.py": { + "I001": 1 + }, + "app/agent/tools/impl/query_library_latest.py": { + "I001": 1 + }, + "app/agent/tools/impl/query_market_plugins.py": { + "I001": 1 + }, + "app/agent/tools/impl/query_media_detail.py": { + "I001": 1 + }, + "app/agent/tools/impl/query_plugin_config.py": { + "I001": 1 + }, + "app/agent/tools/impl/query_plugin_data.py": { + "I001": 1 + }, + "app/agent/tools/impl/query_popular_subscribes.py": { + "I001": 1 + }, + "app/agent/tools/impl/query_rule_groups.py": { + "I001": 1 + }, + "app/agent/tools/impl/query_subscribe_history.py": { + "I001": 1 + }, + "app/agent/tools/impl/query_subscribe_shares.py": { + "I001": 1 + }, + "app/agent/tools/impl/query_subscribes.py": { + "I001": 1 + }, + "app/agent/tools/impl/query_system_settings.py": { + "I001": 1 + }, + "app/agent/tools/impl/query_transfer_history.py": { + "I001": 1 + }, + "app/agent/tools/impl/recognize_captcha.py": { + "I001": 1 + }, + "app/agent/tools/impl/recognize_media.py": { + "E402": 6, + "I001": 1 + }, + "app/agent/tools/impl/reload_plugin.py": { + "I001": 1 + }, + "app/agent/tools/impl/run_workflow.py": { + "I001": 1 + }, + "app/agent/tools/impl/scrape_metadata.py": { + "E402": 6, + "I001": 1 + }, + "app/agent/tools/impl/search_media.py": { + "I001": 1 + }, + "app/agent/tools/impl/search_person_credits.py": { + "I001": 1 + }, + "app/agent/tools/impl/search_subscribe.py": { + "I001": 1 + }, + "app/agent/tools/impl/search_torrents.py": { + "I001": 1 + }, + "app/agent/tools/impl/search_web.py": { + "E402": 1 + }, + "app/agent/tools/impl/send_local_file.py": { + "I001": 1 + }, + "app/agent/tools/impl/send_voice_message.py": { + "E402": 3, + "I001": 1 + }, + "app/agent/tools/impl/test_site.py": { + "I001": 1 + }, + "app/agent/tools/impl/transfer_file.py": { + "I001": 1 + }, + "app/agent/tools/impl/uninstall_plugin.py": { + "I001": 1 + }, + "app/agent/tools/impl/update_agent_task.py": { + "E402": 2 + }, + "app/agent/tools/impl/update_custom_filter_rule.py": { + "I001": 1 + }, + "app/agent/tools/impl/update_custom_identifiers.py": { + "I001": 1 + }, + "app/agent/tools/impl/update_download_tasks.py": { + "I001": 1 + }, + "app/agent/tools/impl/update_plugin_config.py": { + "I001": 1 + }, + "app/agent/tools/impl/update_rule_group.py": { + "I001": 1 + }, + "app/agent/tools/impl/update_site.py": { + "I001": 1 + }, + "app/agent/tools/impl/update_site_cookie.py": { + "I001": 1 + }, + "app/agent/tools/impl/update_subscribe.py": { + "I001": 1 + }, + "app/agent/tools/impl/update_system_settings.py": { + "I001": 1 + }, + "app/api/endpoints/agent.py": { + "F841": 1 + }, + "app/api/endpoints/subscribe.py": { + "F841": 1 + }, + "app/api/endpoints/system.py": { + "F401": 1, + "I001": 1 + }, + "app/application/agentdata.py": { + "I001": 1 + }, + "app/application/agenttask.py": { + "I001": 1 + }, + "app/application/chain/context.py": { + "I001": 1 + }, + "app/application/chain/data.py": { + "I001": 1 + }, + "app/application/directory.py": { + "I001": 1 + }, + "app/application/downloader.py": { + "I001": 1 + }, + "app/application/formatting.py": { + "I001": 1 + }, + "app/application/history.py": { + "I001": 1 + }, + "app/application/image.py": { + "I001": 1 + }, + "app/application/maintenance.py": { + "I001": 1 + }, + "app/application/mediaserver.py": { + "I001": 1 + }, + "app/application/messaging/agent.py": { + "I001": 1 + }, + "app/application/messaging/chat.py": { + "I001": 1 + }, + "app/application/messaging/session.py": { + "I001": 1 + }, + "app/application/messaging/site.py": { + "I001": 1 + }, + "app/application/messaging/subscribe.py": { + "I001": 1 + }, + "app/application/notification.py": { + "I001": 1 + }, + "app/application/outbox.py": { + "I001": 1 + }, + "app/application/plugin/catalog.py": { + "I001": 1 + }, + "app/application/plugin/install.py": { + "I001": 1 + }, + "app/application/plugin/runtime.py": { + "I001": 1 + }, + "app/application/rss.py": { + "I001": 1 + }, + "app/application/security/auth.py": { + "I001": 1 + }, + "app/application/security/cookie.py": { + "I001": 1 + }, + "app/application/security/passkey.py": { + "I001": 1 + }, + "app/application/security/url.py": { + "I001": 1 + }, + "app/application/site/sites.pyi": { + "I001": 1 + }, + "app/application/storage.py": { + "I001": 1 + }, + "app/application/subscription/delete.py": { + "I001": 1 + }, + "app/application/subscription/identity.py": { + "I001": 1 + }, + "app/application/subscription/mutation.py": { + "I001": 1 + }, + "app/application/subscription/priority.py": { + "F401": 1, + "I001": 1 + }, + "app/application/subscription/write.py": { + "I001": 1 + }, + "app/application/torrent.py": { + "F541": 3, + "I001": 1 + }, + "app/application/torrent_cache.py": { + "I001": 1 + }, + "app/application/transfer.py": { + "I001": 1 + }, + "app/application/workflow.py": { + "I001": 1 + }, + "app/chain/_music.py": { + "E402": 5 + }, + "app/chain/_transfer.py": { + "E402": 15, + "F401": 1 + }, + "app/chain/media.py": { + "E731": 2 + }, + "app/chain/transfer.py": { + "E402": 21 + }, + "app/cli.py": { + "E402": 4, + "I001": 1 + }, + "app/command.py": { + "I001": 1 + }, + "app/db/__init__.py": { + "I001": 1 + }, + "app/db/adapters/chain.py": { + "I001": 1 + }, + "app/db/adapters/outbox.py": { + "I001": 1 + }, + "app/db/adapters/site.py": { + "I001": 1 + }, + "app/db/adapters/transaction.py": { + "I001": 1 + }, + "app/db/adapters/workflow.py": { + "I001": 1 + }, + "app/db/base.py": { + "I001": 1 + }, + "app/db/diagnostics.py": { + "I001": 1 + }, + "app/db/engine.py": { + "I001": 1 + }, + "app/db/models/__init__.py": { + "I001": 1 + }, + "app/db/models/agentchat.py": { + "I001": 1 + }, + "app/db/models/downloadhistory.py": { + "I001": 1 + }, + "app/db/models/mediaserver.py": { + "I001": 1 + }, + "app/db/models/message.py": { + "I001": 1 + }, + "app/db/models/outbox.py": { + "I001": 1 + }, + "app/db/models/passkey.py": { + "I001": 1 + }, + "app/db/models/plugindata.py": { + "I001": 1 + }, + "app/db/models/site.py": { + "I001": 1 + }, + "app/db/models/siteicon.py": { + "I001": 1 + }, + "app/db/models/sitestatistic.py": { + "I001": 1 + }, + "app/db/models/siteuserdata.py": { + "I001": 1 + }, + "app/db/models/subscribe.py": { + "I001": 1 + }, + "app/db/models/subscribehistory.py": { + "I001": 1 + }, + "app/db/models/systemconfig.py": { + "I001": 1 + }, + "app/db/models/transferhistory.py": { + "I001": 1 + }, + "app/db/models/user.py": { + "I001": 1 + }, + "app/db/models/userconfig.py": { + "I001": 1 + }, + "app/db/models/workflow.py": { + "I001": 1 + }, + "app/db/oper/downloadhistory.py": { + "I001": 1 + }, + "app/db/oper/message.py": { + "I001": 1 + }, + "app/db/oper/site.py": { + "I001": 1 + }, + "app/db/oper/subscribe.py": { + "I001": 1 + }, + "app/db/oper/systemconfig.py": { + "I001": 1 + }, + "app/db/oper/user.py": { + "I001": 1 + }, + "app/db/oper/userconfig.py": { + "I001": 1 + }, + "app/db/oper/workflow.py": { + "I001": 1 + }, + "app/db/session.py": { + "I001": 1 + }, + "app/db/uow.py": { + "I001": 1 + }, + "app/db/worker.py": { + "I001": 1 + }, + "app/doctor/__init__.py": { + "I001": 1 + }, + "app/doctor/checks.py": { + "E402": 3, + "I001": 1 + }, + "app/doctor/dependencies.py": { + "I001": 1 + }, + "app/doctor/formatters.py": { + "I001": 1 + }, + "app/doctor/runner.py": { + "E402": 4, + "I001": 1 + }, + "app/domain/context.py": { + "E731": 1, + "I001": 1 + }, + "app/domain/media.py": { + "E731": 1 + }, + "app/domain/meta/customization.py": { + "E731": 1, + "I001": 1 + }, + "app/domain/meta/metaanime.py": { + "I001": 1 + }, + "app/domain/meta/metabase.py": { + "I001": 1 + }, + "app/domain/meta/metamusic.py": { + "I001": 1 + }, + "app/domain/meta/metavideo.py": { + "I001": 1 + }, + "app/domain/meta/releasegroup.py": { + "E731": 1, + "I001": 1 + }, + "app/domain/meta/runtime.py": { + "E731": 2 + }, + "app/domain/meta/streamingplatform.py": { + "I001": 1 + }, + "app/domain/meta/words.py": { + "E731": 1, + "I001": 1 + }, + "app/domain/metainfo.py": { + "I001": 1 + }, + "app/domain/scraper.py": { + "I001": 1 + }, + "app/domain/site.py": { + "I001": 1 + }, + "app/domain/title.py": { + "I001": 1 + }, + "app/factory.py": { + "E402": 9, + "I001": 2 + }, + "app/foundation/collections.py": { + "I001": 1 + }, + "app/foundation/crypto.py": { + "I001": 1 + }, + "app/foundation/reflection.py": { + "I001": 1 + }, + "app/foundation/url.py": { + "I001": 1 + }, + "app/main.py": { + "E402": 15, + "I001": 1 + }, + "app/modules/__init__.py": { + "I001": 1 + }, + "app/modules/_base/downloader.py": { + "I001": 1 + }, + "app/modules/_base/mediaserver.py": { + "I001": 1 + }, + "app/modules/_base/notification.py": { + "I001": 1 + }, + "app/modules/acoustid/__init__.py": { + "E402": 4, + "I001": 1 + }, + "app/modules/anilist/__init__.py": { + "E402": 8, + "I001": 2 + }, + "app/modules/anilist/anilist.py": { + "E402": 2, + "I001": 1 + }, + "app/modules/bangumi/__init__.py": { + "E402": 9, + "I001": 2 + }, + "app/modules/bangumi/bangumi.py": { + "E402": 1, + "I001": 1 + }, + "app/modules/discord/__init__.py": { + "F401": 2, + "I001": 1 + }, + "app/modules/discord/discord.py": { + "E402": 6, + "E741": 1, + "F541": 2, + "I001": 2 + }, + "app/modules/douban/__init__.py": { + "E402": 16, + "I001": 2 + }, + "app/modules/douban/apiv2.py": { + "E402": 2, + "I001": 1 + }, + "app/modules/douban/scraper.py": { + "I001": 1 + }, + "app/modules/emby/__init__.py": { + "F401": 1, + "I001": 1 + }, + "app/modules/emby/emby.py": { + "E402": 6, + "F541": 33, + "I001": 2 + }, + "app/modules/fanart/__init__.py": { + "E402": 5, + "I001": 2 + }, + "app/modules/feishu/__init__.py": { + "F401": 1, + "I001": 1 + }, + "app/modules/feishu/feishu.py": { + "E402": 10, + "I001": 2 + }, + "app/modules/filemanager/__init__.py": { + "I001": 1 + }, + "app/modules/filemanager/module.py": { + "E402": 20, + "I001": 2 + }, + "app/modules/filemanager/storages/alipan.py": { + "E402": 7 + }, + "app/modules/filemanager/storages/alist.py": { + "E402": 7 + }, + "app/modules/filemanager/storages/local.py": { + "E402": 7 + }, + "app/modules/filemanager/storages/rclone.py": { + "E402": 6 + }, + "app/modules/filemanager/storages/smb.py": { + "E402": 5 + }, + "app/modules/filemanager/storages/u115.py": { + "E402": 7 + }, + "app/modules/filemanager/transhandler.py": { + "E402": 21, + "F541": 2, + "I001": 2 + }, + "app/modules/filter/__init__.py": { + "I001": 1 + }, + "app/modules/imdb/__init__.py": { + "E402": 5 + }, + "app/modules/imdb/api.py": { + "E402": 1 + }, + "app/modules/indexer/__init__.py": { + "I001": 1 + }, + "app/modules/indexer/parser/__init__.py": { + "E402": 5, + "I001": 1 + }, + "app/modules/indexer/parser/bitpt.py": { + "I001": 1 + }, + "app/modules/indexer/parser/discuz.py": { + "F541": 1, + "I001": 1 + }, + "app/modules/indexer/parser/file_list.py": { + "I001": 1 + }, + "app/modules/indexer/parser/gazelle.py": { + "I001": 1 + }, + "app/modules/indexer/parser/hddolby.py": { + "I001": 1 + }, + "app/modules/indexer/parser/ipt_project.py": { + "I001": 1 + }, + "app/modules/indexer/parser/mtorrent.py": { + "F541": 1, + "I001": 1 + }, + "app/modules/indexer/parser/nexus_audiences.py": { + "I001": 1 + }, + "app/modules/indexer/parser/nexus_hhanclub.py": { + "I001": 1 + }, + "app/modules/indexer/parser/nexus_php.py": { + "F541": 1, + "I001": 1 + }, + "app/modules/indexer/parser/nexus_rabbit.py": { + "I001": 1 + }, + "app/modules/indexer/parser/rousi.py": { + "E402": 4, + "I001": 1 + }, + "app/modules/indexer/parser/small_horse.py": { + "I001": 1 + }, + "app/modules/indexer/parser/sunnypt.py": { + "I001": 1 + }, + "app/modules/indexer/parser/tnode.py": { + "I001": 1 + }, + "app/modules/indexer/parser/torrent_leech.py": { + "I001": 1 + }, + "app/modules/indexer/parser/unit3d.py": { + "I001": 1 + }, + "app/modules/indexer/parser/yema.py": { + "I001": 1 + }, + "app/modules/indexer/parser/zhixing.py": { + "I001": 1 + }, + "app/modules/indexer/spider/__init__.py": { + "E402": 8, + "F541": 1, + "I001": 2 + }, + "app/modules/indexer/spider/haidan.py": { + "E402": 6, + "I001": 2 + }, + "app/modules/indexer/spider/hddolby.py": { + "E402": 5, + "I001": 2 + }, + "app/modules/indexer/spider/mtorrent.py": { + "E402": 6, + "I001": 2 + }, + "app/modules/indexer/spider/rousi.py": { + "E402": 6, + "I001": 1 + }, + "app/modules/indexer/spider/sunnypt.py": { + "E402": 4, + "I001": 1 + }, + "app/modules/indexer/spider/tnode.py": { + "E402": 4, + "I001": 2 + }, + "app/modules/indexer/spider/torrentleech.py": { + "E402": 5, + "I001": 2 + }, + "app/modules/indexer/spider/yema.py": { + "E402": 4, + "I001": 1 + }, + "app/modules/jellyfin/__init__.py": { + "F401": 1, + "I001": 1 + }, + "app/modules/jellyfin/jellyfin.py": { + "E402": 7, + "F541": 34, + "I001": 2 + }, + "app/modules/listenbrainz/__init__.py": { + "E402": 5, + "I001": 1 + }, + "app/modules/lrclib/__init__.py": { + "E402": 6, + "I001": 1 + }, + "app/modules/musicbrainz/__init__.py": { + "E402": 10, + "I001": 1 + }, + "app/modules/musicbrainz/music_cache.py": { + "E402": 5, + "I001": 1 + }, + "app/modules/navidrome/__init__.py": { + "F401": 2, + "I001": 1 + }, + "app/modules/navidrome/navidrome.py": { + "I001": 1 + }, + "app/modules/plex/__init__.py": { + "I001": 1 + }, + "app/modules/plex/plex.py": { + "F541": 2, + "I001": 1 + }, + "app/modules/qbittorrent/__init__.py": { + "F541": 2, + "I001": 1 + }, + "app/modules/qbittorrent/qbittorrent.py": { + "I001": 1 + }, + "app/modules/qqbot/__init__.py": { + "I001": 1 + }, + "app/modules/qqbot/api.py": { + "I001": 1 + }, + "app/modules/qqbot/module.py": { + "I001": 1 + }, + "app/modules/qqbot/qqbot.py": { + "E402": 9, + "I001": 2 + }, + "app/modules/rtorrent/__init__.py": { + "F541": 2, + "I001": 1 + }, + "app/modules/rtorrent/rtorrent.py": { + "I001": 2 + }, + "app/modules/slack/__init__.py": { + "F401": 2, + "F541": 1, + "I001": 1 + }, + "app/modules/slack/slack.py": { + "E402": 6, + "I001": 2 + }, + "app/modules/subtitle/__init__.py": { + "E402": 7, + "I001": 1 + }, + "app/modules/synologychat/__init__.py": { + "F541": 1, + "I001": 1 + }, + "app/modules/synologychat/synologychat.py": { + "F541": 2, + "I001": 1 + }, + "app/modules/telegram/__init__.py": { + "I001": 1 + }, + "app/modules/telegram/module.py": { + "F541": 1, + "I001": 1 + }, + "app/modules/telegram/telegram.py": { + "I001": 3 + }, + "app/modules/theaudiodb/__init__.py": { + "E402": 8, + "I001": 1 + }, + "app/modules/themoviedb/__init__.py": { + "E402": 15, + "I001": 2 + }, + "app/modules/themoviedb/category.py": { + "E402": 3, + "F541": 1, + "I001": 1 + }, + "app/modules/themoviedb/scraper.py": { + "E402": 5, + "I001": 1 + }, + "app/modules/themoviedb/tmdb_cache.py": { + "E402": 4, + "I001": 1 + }, + "app/modules/themoviedb/tmdbapi.py": { + "E402": 6, + "I001": 2 + }, + "app/modules/themoviedb/tmdbv3api/__init__.py": { + "F401": 25 + }, + "app/modules/themoviedb/tmdbv3api/objs/discover.py": { + "E402": 1 + }, + "app/modules/themoviedb/tmdbv3api/tmdb.py": { + "E402": 2, + "I001": 2 + }, + "app/modules/thetvdb/__init__.py": { + "E402": 4, + "I001": 1 + }, + "app/modules/thetvdb/tvdb_v4_official.py": { + "E402": 1 + }, + "app/modules/transmission/__init__.py": { + "F541": 2, + "I001": 1 + }, + "app/modules/transmission/transmission.py": { + "I001": 1 + }, + "app/modules/trimemedia/__init__.py": { + "I001": 1 + }, + "app/modules/trimemedia/api.py": { + "E402": 2, + "I001": 1 + }, + "app/modules/trimemedia/module.py": { + "I001": 1 + }, + "app/modules/trimemedia/trimemedia.py": { + "I001": 1 + }, + "app/modules/ugreen/__init__.py": { + "I001": 1 + }, + "app/modules/ugreen/api.py": { + "I001": 1 + }, + "app/modules/ugreen/module.py": { + "I001": 1 + }, + "app/modules/ugreen/ugreen.py": { + "I001": 1 + }, + "app/modules/vocechat/__init__.py": { + "F541": 1, + "I001": 1 + }, + "app/modules/vocechat/vocechat.py": { + "I001": 1 + }, + "app/modules/webpush/__init__.py": { + "E402": 4, + "I001": 2 + }, + "app/modules/wechat/__init__.py": { + "F541": 3, + "I001": 1 + }, + "app/modules/wechat/wechat.py": { + "F541": 1, + "I001": 1 + }, + "app/modules/wechat/wechatbot.py": { + "E402": 10, + "I001": 2 + }, + "app/modules/wechatclawbot/__init__.py": { + "I001": 1 + }, + "app/modules/wechatclawbot/wechatclawbot.py": { + "E402": 6, + "I001": 1 + }, + "app/modules/zspace/__init__.py": { + "F401": 4, + "I001": 1 + }, + "app/modules/zspace/zspace.py": { + "I001": 1 + }, + "app/monitor/__init__.py": { + "I001": 1 + }, + "app/monitor/dispatcher.py": { + "I001": 1 + }, + "app/monitor/monitor.py": { + "I001": 1 + }, + "app/monitor/poller.py": { + "I001": 1 + }, + "app/monitor/syslimits.py": { + "I001": 1 + }, + "app/runtime/cache.py": { + "E731": 2, + "I001": 1 + }, + "app/runtime/capabilities/__init__.py": { + "I001": 1 + }, + "app/runtime/capabilities/registry.py": { + "I001": 1 + }, + "app/runtime/compat/diagnostics.py": { + "I001": 1 + }, + "app/runtime/compat/imports.py": { + "I001": 1 + }, + "app/runtime/debounce.py": { + "I001": 1 + }, + "app/runtime/dependencies.py": { + "I001": 1 + }, + "app/runtime/event/dispatch.py": { + "I001": 1 + }, + "app/runtime/event/errors.py": { + "I001": 1 + }, + "app/runtime/events.py": { + "I001": 1 + }, + "app/runtime/execution.py": { + "I001": 1 + }, + "app/runtime/extensions/host_module_adapter.py": { + "E402": 2, + "I001": 1 + }, + "app/runtime/extensions/managed_resource_adapter.py": { + "I001": 1 + }, + "app/runtime/extensions/module/dispatcher.py": { + "I001": 1 + }, + "app/runtime/extensions/module_manager.py": { + "E402": 4, + "I001": 1 + }, + "app/runtime/extensions/plugin/catalog.py": { + "E402": 5 + }, + "app/runtime/extensions/plugin/lifecycle.py": { + "I001": 1 + }, + "app/runtime/extensions/plugin/loader.py": { + "I001": 1 + }, + "app/runtime/extensions/plugin/storage.py": { + "I001": 1 + }, + "app/runtime/extensions/plugin_manager.py": { + "E402": 21, + "I001": 2 + }, + "app/runtime/extensions/service_config.py": { + "I001": 1 + }, + "app/runtime/gc.py": { + "I001": 1 + }, + "app/runtime/log.py": { + "E731": 1, + "I001": 1 + }, + "app/runtime/managed_resources.py": { + "I001": 1 + }, + "app/runtime/observability/__init__.py": { + "I001": 1 + }, + "app/runtime/rate.py": { + "I001": 1 + }, + "app/runtime/reload.py": { + "I001": 1 + }, + "app/runtime/settings.py": { + "I001": 1 + }, + "app/runtime/state.py": { + "E402": 3, + "I001": 2 + }, + "app/schemas/agent.py": { + "I001": 1 + }, + "app/schemas/common.py": { + "I001": 1 + }, + "app/schemas/context.py": { + "I001": 1 + }, + "app/schemas/event.py": { + "I001": 1 + }, + "app/schemas/file.py": { + "I001": 1 + }, + "app/schemas/mediaserver.py": { + "I001": 1 + }, + "app/schemas/message.py": { + "I001": 1 + }, + "app/schemas/plugin.py": { + "I001": 1 + }, + "app/schemas/response.py": { + "I001": 1 + }, + "app/schemas/site.py": { + "I001": 1 + }, + "app/schemas/subscribe.py": { + "I001": 1 + }, + "app/schemas/system.py": { + "I001": 1 + }, + "app/schemas/transfer.py": { + "I001": 1 + }, + "app/schemas/types.py": { + "I001": 1 + }, + "app/schemas/user.py": { + "I001": 1 + }, + "app/sdk/_legacy/transfer.py": { + "I001": 1 + }, + "app/sdk/_legacy/user.py": { + "I001": 1 + }, + "app/sdk/cache.py": { + "I001": 1 + }, + "app/sdk/config.py": { + "I001": 1 + }, + "app/sdk/events.py": { + "I001": 1 + }, + "app/sdk/logging.py": { + "I001": 1 + }, + "app/sdk/media.py": { + "I001": 1 + }, + "app/sdk/network.py": { + "I001": 1 + }, + "app/sdk/plugins.py": { + "I001": 1 + }, + "app/sdk/services.py": { + "I001": 1 + }, + "app/sdk/utilities.py": { + "I001": 1 + }, + "app/startup/initializers/agent.py": { + "E402": 3 + }, + "app/startup/initializers/database.py": { + "E402": 5 + }, + "app/startup/initializers/domain.py": { + "E402": 1 + }, + "app/startup/initializers/modules.py": { + "E402": 79 + }, + "app/startup/initializers/plugins.py": { + "E402": 25 + }, + "app/startup/lifecycle/__init__.py": { + "E402": 27, + "F401": 1 + }, + "app/workflow/__init__.py": { + "F401": 1 + }, + "scripts/architecture/baseline.py": { + "I001": 1 + }, + "scripts/architecture/task_ownership.py": { + "I001": 1 + }, + "scripts/benchmark_filter_torrents_rust.py": { + "E402": 3, + "I001": 1 + }, + "scripts/benchmark_indexer_rust.py": { + "E402": 2, + "I001": 1 + }, + "scripts/benchmark_metainfo_rust.py": { + "E402": 9, + "F401": 3, + "I001": 1 + }, + "scripts/benchmark_rss_rust.py": { + "E402": 4, + "I001": 1 + }, + "scripts/dev/simulate_package_installer.py": { + "E402": 1 + }, + "scripts/generate_plugin_market_default.py": { + "I001": 1 + }, + "scripts/local_setup.py": { + "I001": 4 + }, + "scripts/normalize_audit_requirements.py": { + "I001": 1 + }, + "scripts/perf/free_threaded_ab.py": { + "F401": 1 + }, + "scripts/perf/module_shutdown_ab.py": { + "E402": 3 + }, + "scripts/perf/task_registry_ab.py": { + "E402": 1 + }, + "scripts/perf/test_scenarios.py": { + "I001": 1 + }, + "scripts/schema/exports.py": { + "I001": 1 + }, + "scripts/site_adapter_collector.py": { + "I001": 1 + }, + "scripts/startup/performance.py": { + "I001": 1 + }, + "tests/conftest.py": { + "F401": 1, + "I001": 7 + }, + "tests/test_acoustid_module.py": { + "I001": 1 + }, + "tests/test_agent_activity_log.py": { + "I001": 1 + }, + "tests/test_agent_background_output.py": { + "I001": 1 + }, + "tests/test_agent_chat_persistence.py": { + "I001": 1 + }, + "tests/test_agent_image_support.py": { + "F401": 1, + "I001": 1 + }, + "tests/test_agent_lazy_runtime_boundary.py": { + "F401": 1 + }, + "tests/test_agent_lifecycle.py": { + "I001": 1 + }, + "tests/test_agent_llm_capability.py": { + "I001": 1 + }, + "tests/test_agent_message_routing.py": { + "I001": 1 + }, + "tests/test_agent_music_tools.py": { + "I001": 1 + }, + "tests/test_agent_plugin_tools.py": { + "I001": 1 + }, + "tests/test_agent_recognize_captcha_tool.py": { + "I001": 1 + }, + "tests/test_agent_resource_flow_permissions.py": { + "I001": 1 + }, + "tests/test_agent_runtime.py": { + "I001": 1 + }, + "tests/test_agent_scheduled_tasks.py": { + "I001": 1 + }, + "tests/test_agent_session_status.py": { + "I001": 1 + }, + "tests/test_agent_subagent_runtime.py": { + "I001": 1 + }, + "tests/test_agent_subagents.py": { + "I001": 1 + }, + "tests/test_agent_summarization_streaming.py": { + "I001": 1 + }, + "tests/test_agent_task_run_migration.py": { + "I001": 1 + }, + "tests/test_agent_task_runs.py": { + "I001": 1 + }, + "tests/test_agent_tokens_events.py": { + "I001": 1 + }, + "tests/test_agent_tool_factory_cache.py": { + "I001": 1 + }, + "tests/test_agent_tool_result_policy.py": { + "I001": 1 + }, + "tests/test_agent_tool_streaming.py": { + "I001": 1 + }, + "tests/test_agent_tool_timeouts.py": { + "I001": 1 + }, + "tests/test_agent_transfer_file_tool.py": { + "F401": 1 + }, + "tests/test_api_authorization.py": { + "I001": 1 + }, + "tests/test_api_background_task_registry.py": { + "I001": 1 + }, + "tests/test_api_response.py": { + "I001": 1 + }, + "tests/test_architecture_ci.py": { + "I001": 1 + }, + "tests/test_architecture_contract_baseline.py": { + "I001": 1 + }, + "tests/test_async_db_pooling.py": { + "I001": 1 + }, + "tests/test_audio_metadata.py": { + "I001": 1 + }, + "tests/test_bangumi_media_type.py": { + "F401": 1 + }, + "tests/test_bluray.py": { + "I001": 1 + }, + "tests/test_browser_helper.py": { + "I001": 1 + }, + "tests/test_builtin_skill_boundaries.py": { + "I001": 1 + }, + "tests/test_cache_system.py": { + "I001": 1 + }, + "tests/test_capability_registry.py": { + "I001": 1 + }, + "tests/test_capability_runtime.py": { + "I001": 1 + }, + "tests/test_chain_durable_events.py": { + "I001": 1 + }, + "tests/test_chain_layering.py": { + "I001": 1 + }, + "tests/test_chain_rate_limit.py": { + "E402": 4, + "I001": 1 + }, + "tests/test_chain_runtime_context.py": { + "I001": 1 + }, + "tests/test_cli_auto_update.py": { + "I001": 1 + }, + "tests/test_coalesce.py": { + "I001": 1 + }, + "tests/test_configuration_initializer.py": { + "I001": 1 + }, + "tests/test_dashboard_system_info.py": { + "I001": 1 + }, + "tests/test_data_cleanup_chain.py": { + "I001": 1 + }, + "tests/test_database_backup_cli_sdk.py": { + "I001": 1 + }, + "tests/test_database_backup_scheduler.py": { + "I001": 1 + }, + "tests/test_database_index_migration.py": { + "I001": 1 + }, + "tests/test_database_migration_startup.py": { + "F841": 1, + "I001": 1 + }, + "tests/test_db_declarative_2_0.py": { + "E402": 1, + "I001": 1 + }, + "tests/test_db_downloadhistory_queries.py": { + "F401": 1 + }, + "tests/test_db_engine_postgresql.py": { + "I001": 1 + }, + "tests/test_db_error_diagnostics.py": { + "I001": 1 + }, + "tests/test_db_lazy_engine.py": { + "I001": 1 + }, + "tests/test_db_oper_layer.py": { + "I001": 1 + }, + "tests/test_db_oper_layer_extra.py": { + "I001": 1 + }, + "tests/test_db_public_api.py": { + "I001": 1 + }, + "tests/test_db_session_lifecycle.py": { + "I001": 1 + }, + "tests/test_db_transferhistory_queries.py": { + "F401": 1 + }, + "tests/test_delete_transfer_history_tool.py": { + "I001": 1 + }, + "tests/test_docker_bootstrap.py": { + "F841": 2, + "I001": 1 + }, + "tests/test_docker_entrypoint_permissions.py": { + "I001": 1 + }, + "tests/test_docker_payload_contract.py": { + "I001": 1 + }, + "tests/test_doctor.py": { + "I001": 1 + }, + "tests/test_douban_recognition.py": { + "I001": 1 + }, + "tests/test_download_chain.py": { + "I001": 1 + }, + "tests/test_download_paths_endpoint.py": { + "I001": 1 + }, + "tests/test_download_save_path_allowlist.py": { + "I001": 1 + }, + "tests/test_episode_format_helper.py": { + "F841": 1, + "I001": 1 + }, + "tests/test_episode_group_recognition.py": { + "E402": 4 + }, + "tests/test_event_contracts.py": { + "F401": 1, + "I001": 1 + }, + "tests/test_event_dispatch_snapshot.py": { + "I001": 1 + }, + "tests/test_execute_command_tool.py": { + "I001": 1 + }, + "tests/test_extensible_media_source_migration.py": { + "I001": 1 + }, + "tests/test_feedback_issue_log_quality.py": { + "I001": 1 + }, + "tests/test_feedback_issue_repository_routing.py": { + "I001": 1 + }, + "tests/test_feedback_issue_scripts.py": { + "I001": 1 + }, + "tests/test_feishu.py": { + "E402": 5, + "I001": 1 + }, + "tests/test_feishu_media_message.py": { + "E402": 3 + }, + "tests/test_feishu_post_message.py": { + "E402": 1 + }, + "tests/test_feishu_ws_lifecycle.py": { + "E402": 1 + }, + "tests/test_free_threaded_ab.py": { + "I001": 1 + }, + "tests/test_hddolby_parser.py": { + "I001": 1 + }, + "tests/test_health_probes.py": { + "I001": 1 + }, + "tests/test_host_runtime_context.py": { + "F401": 1, + "I001": 1 + }, + "tests/test_indexer_spider_search_url.py": { + "I001": 1 + }, + "tests/test_interaction_router.py": { + "E402": 5 + }, + "tests/test_legacy_import_compat.py": { + "I001": 1 + }, + "tests/test_legacy_plugin_resource_imports.py": { + "I001": 1 + }, + "tests/test_lifecycle_shutdown.py": { + "F841": 1, + "I001": 1 + }, + "tests/test_llm_provider_bedrock.py": { + "I001": 1 + }, + "tests/test_llm_provider_registry.py": { + "F401": 1, + "I001": 1 + }, + "tests/test_local_setup_autostart.py": { + "I001": 1 + }, + "tests/test_local_setup_config_dir.py": { + "F841": 1, + "I001": 1 + }, + "tests/test_local_setup_frontend_version.py": { + "I001": 1 + }, + "tests/test_local_setup_llm_provider_prompt.py": { + "I001": 1 + }, + "tests/test_local_setup_resources.py": { + "I001": 1 + }, + "tests/test_local_setup_uninstall.py": { + "I001": 1 + }, + "tests/test_local_storage.py": { + "F401": 1, + "I001": 1 + }, + "tests/test_main_direct_execution.py": { + "I001": 1 + }, + "tests/test_managed_resources.py": { + "I001": 1 + }, + "tests/test_manual_transfer_history.py": { + "I001": 1 + }, + "tests/test_mcp_plugin_tools.py": { + "I001": 1 + }, + "tests/test_media_identity_cleanup_migration.py": { + "I001": 1 + }, + "tests/test_media_interaction.py": { + "F841": 1, + "I001": 1 + }, + "tests/test_media_recognize_share.py": { + "F401": 1, + "I001": 1 + }, + "tests/test_media_recognize_share_statistics.py": { + "I001": 1 + }, + "tests/test_media_scrape_endpoint.py": { + "I001": 1 + }, + "tests/test_media_search_source_selection.py": { + "I001": 1 + }, + "tests/test_media_source_routing.py": { + "I001": 1 + }, + "tests/test_media_source_signature_compatibility.py": { + "I001": 1 + }, + "tests/test_mediascrape.py": { + "F401": 2, + "I001": 2 + }, + "tests/test_mediaserver_image_signing.py": { + "I001": 1 + }, + "tests/test_mediaserver_sync_incremental.py": { + "I001": 1 + }, + "tests/test_mediaserver_sync_scheduler.py": { + "I001": 1 + }, + "tests/test_message_channel_permissions.py": { + "I001": 1 + }, + "tests/test_message_ingress.py": { + "I001": 1 + }, + "tests/test_message_media_identity.py": { + "I001": 1 + }, + "tests/test_message_notification_naming_compat.py": { + "I001": 1 + }, + "tests/test_message_notifications.py": { + "I001": 1 + }, + "tests/test_metainfo.py": { + "I001": 1 + }, + "tests/test_metamusic.py": { + "I001": 1 + }, + "tests/test_mfa_passkey_registration_errors.py": { + "I001": 1 + }, + "tests/test_module_manager_capability_adapter.py": { + "I001": 1 + }, + "tests/test_module_method_contracts.py": { + "I001": 1 + }, + "tests/test_module_quality.py": { + "I001": 1 + }, + "tests/test_monitor_dispatcher_history.py": { + "F401": 1, + "I001": 1 + }, + "tests/test_monitor_gap_recovery.py": { + "I001": 1 + }, + "tests/test_monitor_lifecycle.py": { + "I001": 1 + }, + "tests/test_monitor_rescan.py": { + "I001": 1 + }, + "tests/test_moviepilot_launcher.py": { + "I001": 1 + }, + "tests/test_music_catalog_service.py": { + "F401": 1 + }, + "tests/test_music_context.py": { + "I001": 1 + }, + "tests/test_music_endpoint.py": { + "F401": 2 + }, + "tests/test_music_filemanager.py": { + "I001": 1 + }, + "tests/test_music_mediaserver.py": { + "I001": 1 + }, + "tests/test_music_plugin_recognize.py": { + "I001": 1 + }, + "tests/test_music_recognize_cache.py": { + "I001": 1 + }, + "tests/test_music_scrape.py": { + "I001": 1 + }, + "tests/test_music_search.py": { + "I001": 1 + }, + "tests/test_music_torrents.py": { + "I001": 1 + }, + "tests/test_music_transfer.py": { + "I001": 1 + }, + "tests/test_music_workflows.py": { + "F401": 1 + }, + "tests/test_musicbrainz_module.py": { + "I001": 1 + }, + "tests/test_mypy_gate.py": { + "I001": 1 + }, + "tests/test_navidrome_module.py": { + "I001": 1 + }, + "tests/test_nexus_audiences_parser.py": { + "I001": 1 + }, + "tests/test_notification_channel_manage.py": { + "I001": 1 + }, + "tests/test_notification_template_render.py": { + "I001": 1 + }, + "tests/test_observability.py": { + "I001": 1 + }, + "tests/test_passkey_challenge.py": { + "I001": 1 + }, + "tests/test_password_hashing.py": { + "I001": 1 + }, + "tests/test_plugin_dashboard.py": { + "I001": 1 + }, + "tests/test_plugin_dependency_service.py": { + "I001": 1 + }, + "tests/test_plugin_endpoint.py": { + "F401": 1, + "I001": 1 + }, + "tests/test_plugin_helper.py": { + "F401": 2, + "I001": 11 + }, + "tests/test_plugin_install_command.py": { + "I001": 1 + }, + "tests/test_plugin_lifecycle_status.py": { + "F401": 1 + }, + "tests/test_plugin_local_sync.py": { + "I001": 1 + }, + "tests/test_plugin_market_default.py": { + "I001": 1 + }, + "tests/test_plugin_monitor_lifecycle.py": { + "I001": 1 + }, + "tests/test_plugin_rating.py": { + "I001": 1 + }, + "tests/test_plugin_sdk.py": { + "I001": 3 + }, + "tests/test_plugin_system_setting_admission.py": { + "I001": 1 + }, + "tests/test_plugin_virtual_instances.py": { + "E731": 1 + }, + "tests/test_postgresql_driver_ab.py": { + "I001": 1 + }, + "tests/test_proxy_config.py": { + "I001": 1 + }, + "tests/test_qbittorrent_compat.py": { + "I001": 1 + }, + "tests/test_recommend_chain.py": { + "I001": 1 + }, + "tests/test_release_group.py": { + "I001": 1 + }, + "tests/test_release_supply_chain.py": { + "I001": 1 + }, + "tests/test_reliability_adr.py": { + "I001": 1 + }, + "tests/test_resource_token_cookie_secure_flag.py": { + "I001": 1 + }, + "tests/test_resource_v3.py": { + "I001": 1 + }, + "tests/test_rss_helper.py": { + "I001": 1 + }, + "tests/test_runtime_execution.py": { + "I001": 1 + }, + "tests/test_rust_accel.py": { + "I001": 1 + }, + "tests/test_rust_accel_toggle.py": { + "I001": 1 + }, + "tests/test_scheduler_cache_expiry.py": { + "I001": 1 + }, + "tests/test_scheduler_contracts.py": { + "I001": 1 + }, + "tests/test_search_ai_recommend.py": { + "E402": 8, + "I001": 1 + }, + "tests/test_search_media_sources.py": { + "I001": 1 + }, + "tests/test_search_same_name_disambiguation.py": { + "I001": 1 + }, + "tests/test_search_title_filter.py": { + "E402": 4 + }, + "tests/test_security_image_url_log.py": { + "I001": 1 + }, + "tests/test_security_utils.py": { + "I001": 1 + }, + "tests/test_server_statistic_lifecycle.py": { + "I001": 1 + }, + "tests/test_site_adapter_collector.py": { + "I001": 1 + }, + "tests/test_skill_scripts_security.py": { + "I001": 1 + }, + "tests/test_skills_command.py": { + "E402": 6, + "I001": 1 + }, + "tests/test_slash_command_interactions.py": { + "E402": 8, + "I001": 1 + }, + "tests/test_storage_download_path.py": { + "I001": 1 + }, + "tests/test_string_compat.py": { + "I001": 1 + }, + "tests/test_subscribe_delete_command.py": { + "I001": 1 + }, + "tests/test_subscribe_oper.py": { + "F401": 1 + }, + "tests/test_subtitle_rename.py": { + "I001": 1 + }, + "tests/test_subtitle_search.py": { + "I001": 1 + }, + "tests/test_subtitle_signed_download.py": { + "I001": 1 + }, + "tests/test_sunnypt_indexer.py": { + "I001": 1 + }, + "tests/test_system_database_backup_api.py": { + "I001": 1 + }, + "tests/test_system_i18n.py": { + "I001": 1 + }, + "tests/test_system_notification_dispatch.py": { + "E402": 5, + "I001": 2 + }, + "tests/test_system_utils.py": { + "I001": 1 + }, + "tests/test_systemconfig_oper.py": { + "I001": 1 + }, + "tests/test_telegram.py": { + "I001": 1 + }, + "tests/test_telegram_typing_lifecycle.py": { + "I001": 1 + }, + "tests/test_template_context_builder.py": { + "I001": 1 + }, + "tests/test_test_runner.py": { + "I001": 1 + }, + "tests/test_testing_bootstrap_plugin_namespace.py": { + "I001": 1 + }, + "tests/test_tmdb_auxiliary.py": { + "F401": 1 + }, + "tests/test_tmdb_cache_management.py": { + "I001": 1 + }, + "tests/test_tmdb_cache_type_guard.py": { + "I001": 1 + }, + "tests/test_tmdb_empty_result_cache.py": { + "I001": 1 + }, + "tests/test_tmdb_failure_snapshot_cache.py": { + "I001": 1 + }, + "tests/test_tmdb_recognize.py": { + "I001": 1 + }, + "tests/test_tmdb_retry_and_errors.py": { + "F811": 1 + }, + "tests/test_torrent_cache_music.py": { + "I001": 1 + }, + "tests/test_torrent_filter.py": { + "I001": 1 + }, + "tests/test_torrent_leech_parser.py": { + "I001": 1 + }, + "tests/test_transfer_download_history_oper_sessions.py": { + "I001": 1 + }, + "tests/test_transfer_failed_retry_budget.py": { + "I001": 1 + }, + "tests/test_transfer_failed_retry_buttons.py": { + "E402": 6, + "I001": 2 + }, + "tests/test_transfer_failure_notification_aggregation.py": { + "I001": 1 + }, + "tests/test_transfer_history_gate.py": { + "I001": 1 + }, + "tests/test_transfer_history_retransfer.py": { + "I001": 1 + }, + "tests/test_transfer_history_write_path.py": { + "I001": 1 + }, + "tests/test_transfer_job_manager.py": { + "I001": 1 + }, + "tests/test_transfer_mark_torrent_completed.py": { + "I001": 1 + }, + "tests/test_transfer_mounted_disk_cleanup.py": { + "I001": 1 + }, + "tests/test_transfer_movie_collection.py": { + "I001": 1 + }, + "tests/test_transfer_pending_replay.py": { + "I001": 1 + }, + "tests/test_transfer_preview.py": { + "I001": 1 + }, + "tests/test_transfer_queue_count.py": { + "I001": 1 + }, + "tests/test_transfer_queue_service.py": { + "I001": 1 + }, + "tests/test_transfer_rename_build_event.py": { + "I001": 1 + }, + "tests/test_transfer_stale_tasks.py": { + "I001": 1 + }, + "tests/test_transfer_tmdb_category.py": { + "I001": 1 + }, + "tests/test_transfer_worker_lifecycle.py": { + "I001": 1 + }, + "tests/test_transferhistory_media_source_migration.py": { + "I001": 1 + }, + "tests/test_uvicorn_entrypoint.py": { + "I001": 1 + }, + "tests/test_web_agent_stream.py": { + "F401": 1, + "I001": 1 + }, + "tests/test_webpush_helper.py": { + "I001": 1 + }, + "tests/test_workflow_actions.py": { + "I001": 1 + }, + "tests/test_workflow_authorization.py": { + "I001": 1 + }, + "tests/test_workflow_execution.py": { + "I001": 1 + }, + "tests/test_workflow_runtime_config.py": { + "I001": 1 + } +} diff --git a/tests/test_chain_mixin_contracts.py b/tests/test_chain_mixin_contracts.py new file mode 100644 index 000000000..fe805dd66 --- /dev/null +++ b/tests/test_chain_mixin_contracts.py @@ -0,0 +1,87 @@ +"""Chain mixin Host Protocol 与依赖方向门禁。""" + +import ast +from pathlib import Path + +from app.chain._contracts import ( + ChainRuntimeMixinHost, + InteractionMixinHost, + MusicSubscribeMixinHost, + TransferMixinHost, +) +from app.chain._interaction import InteractionChainMixin +from app.chain._messaging import MessageProcessingMixin, NotificationMixin +from app.chain._music import MusicSubscribeMixin +from app.chain._recognition import RecognitionMixin +from app.chain._transfer import ( + EpisodeFormatMixin, + FailedRetryMixin, + FileFilterMixin, + FileKeyMixin, + HistoryMatchMixin, + ManualHistoryMixin, + ScrapeBatchMixin, +) +from app.chain.subscribe import SubscribeChain +from app.chain.transfer import TransferChain + + +def test_all_chain_mixins_declare_their_host_protocol() -> None: + """每个存量 Chain mixin 都必须显式声明宿主能力契约。""" + expected = { + RecognitionMixin: ChainRuntimeMixinHost, + MessageProcessingMixin: ChainRuntimeMixinHost, + NotificationMixin: ChainRuntimeMixinHost, + InteractionChainMixin: InteractionMixinHost, + MusicSubscribeMixin: MusicSubscribeMixinHost, + FileFilterMixin: TransferMixinHost, + ScrapeBatchMixin: TransferMixinHost, + EpisodeFormatMixin: TransferMixinHost, + HistoryMatchMixin: TransferMixinHost, + FileKeyMixin: TransferMixinHost, + ManualHistoryMixin: TransferMixinHost, + FailedRetryMixin: TransferMixinHost, + } + + assert { + mixin: mixin.__mixin_host_protocol__ for mixin in expected + } == expected + + +def test_mixin_hosts_provide_injected_chain_factories() -> None: + """具体 Chain 必须提供 mixin 契约要求的链工厂接缝。""" + for name in ( + "_music_media_chain", + "_music_download_chain", + "_music_search_chain", + "_music_site_keywords", + "_matches_music_resource", + ): + assert callable(getattr(SubscribeChain, name)) + for name in ( + "_transfer_media_chain", + "_transfer_storage_chain", + "_transfer_subscribe_chain", + ): + assert callable(getattr(TransferChain, name)) + + +def test_domain_mixins_keep_concrete_imports_explicit_until_next_migration() -> None: + """第一阶段先锁定契约标记,具体导入在后续批次逐步下沉到宿主工厂。""" + root = Path(__file__).resolve().parents[1] + violations = [] + for relative in ("app/chain/_music.py", "app/chain/_transfer.py"): + tree = ast.parse((root / relative).read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module: + if node.module.startswith("app.chain.") and node.module != "app.chain._contracts": + violations.append(f"{relative}:{node.module}") + + assert set(violations) <= { + "app/chain/_music.py:app.chain.download", + "app/chain/_music.py:app.chain.media", + "app/chain/_music.py:app.chain.search", + "app/chain/_transfer.py:app.chain.media", + "app/chain/_transfer.py:app.chain.storage", + "app/chain/_transfer.py:app.chain.subscribe", + } diff --git a/tests/test_quality_ratchets.py b/tests/test_quality_ratchets.py new file mode 100644 index 000000000..fc4fb1280 --- /dev/null +++ b/tests/test_quality_ratchets.py @@ -0,0 +1,78 @@ +"""Ruff 与覆盖率增量门禁测试。""" + +from scripts.architecture.coverage_ratchet import ( + collect_package_coverage, + compare_coverage, +) +from scripts.architecture.ruff_ratchet import aggregate_diagnostics, compare_counts + + +def test_ruff_ratchet_rejects_new_rule_and_count_growth() -> None: + """Ruff 基线允许修复,但拒绝新增规则和既有数量增长。""" + baseline = {"app/example.py": {"F401": 2}} + + assert compare_counts(baseline, {"app/example.py": {"F401": 1}}) == [] + assert compare_counts(baseline, {"app/example.py": {"F401": 3}}) == [ + "app/example.py: Ruff 诊断增长 [F401] 2->3" + ] + assert compare_counts(baseline, {"app/new.py": {"I001": 1}}) == [ + "app/new.py: 新增 Ruff 诊断 [I001] x1" + ] + + +def test_ruff_diagnostics_are_aggregated_by_relative_file_and_code() -> None: + """Ruff JSON 的绝对路径必须归一化为稳定仓库路径。""" + from scripts.architecture.ruff_ratchet import PROJECT_ROOT + + path = PROJECT_ROOT / "app" / "example.py" + diagnostics = [ + {"filename": str(path), "code": "F401"}, + {"filename": str(path), "code": "F401"}, + {"filename": str(path), "code": "I001"}, + ] + + assert aggregate_diagnostics(diagnostics) == { + "app/example.py": {"F401": 2, "I001": 1} + } + + +def test_coverage_ratchet_aggregates_governed_packages() -> None: + """覆盖率门禁只聚合 Application 与 Domain,并按真实语句数加权。""" + report = { + "files": { + "app/application/a.py": { + "summary": {"num_statements": 10, "covered_lines": 8} + }, + "app/application/b.py": { + "summary": {"num_statements": 30, "covered_lines": 12} + }, + "app/domain/a.py": { + "summary": {"num_statements": 20, "covered_lines": 15} + }, + "app/chain/a.py": { + "summary": {"num_statements": 100, "covered_lines": 0} + }, + } + } + + assert collect_package_coverage(report) == { + "application": {"statements": 40, "covered_lines": 20, "percent": 50.0}, + "domain": {"statements": 20, "covered_lines": 15, "percent": 75.0}, + } + + +def test_coverage_ratchet_rejects_only_regressions() -> None: + """包覆盖率达到或超过阈值时通过,任一包下降时失败。""" + baseline = { + "application": {"percent": 50.0}, + "domain": {"percent": 75.0}, + } + + assert compare_coverage( + baseline, + {"application": {"percent": 50.0}, "domain": {"percent": 76.0}}, + ) == [] + assert compare_coverage( + baseline, + {"application": {"percent": 49.99}, "domain": {"percent": 75.0}}, + ) == ["application: 行覆盖率下降 50.00%->49.99%"] diff --git a/tests/test_runtime_stop_state.py b/tests/test_runtime_stop_state.py new file mode 100644 index 000000000..3a7061aad --- /dev/null +++ b/tests/test_runtime_stop_state.py @@ -0,0 +1,68 @@ +"""进程停止契约和 global_vars 兼容边界测试。""" + +import threading +from pathlib import Path + +from app.runtime.config import GlobalVar +from app.runtime.stop import ProcessStopState, runtime_stop_state + + +def test_process_stop_state_separates_repeatable_workflow_and_one_shot_transfer() -> None: + """工作流停止可恢复,整理路径停止只消费一次。""" + state = ProcessStopState() + + state.stop_workflow(7) + assert state.is_workflow_stopped(7) + state.resume_workflow(7) + assert not state.is_workflow_stopped(7) + + state.stop_transfer("/media/a.mkv") + assert state.consume_transfer_stop("/media/a.mkv") + assert not state.consume_transfer_stop("/media/a.mkv") + + +def test_system_stop_propagates_to_all_cancellation_scopes() -> None: + """系统停止后所有工作流和整理任务都必须立即观察到停止。""" + state = ProcessStopState() + + state.stop_system() + + assert state.is_system_stopped + assert state.is_workflow_stopped(99) + assert state.consume_transfer_stop("/unknown") + + +def test_global_vars_stop_api_delegates_to_runtime_contract(monkeypatch) -> None: + """旧 global_vars ABI 必须与新的显式停止状态共享同一事实源。""" + event = threading.Event() + monkeypatch.setattr(runtime_stop_state, "_system_event", event) + legacy = GlobalVar() + + legacy.stop_system() + + assert event.is_set() + assert legacy.is_system_stopped + + +def test_host_code_no_longer_reads_stop_state_from_global_vars() -> None: + """除兼容实现外,宿主不得重新从 global_vars 读取任何停止信号。""" + root = Path(__file__).resolve().parents[1] + forbidden = ( + "global_vars.is_system_stopped", + "global_vars.stop_system", + "global_vars.is_workflow_stopped", + "global_vars.stop_workflow", + "global_vars.workflow_resume", + "global_vars.is_transfer_stopped", + "global_vars.stop_transfer", + ) + violations = [] + for path in (root / "app").rglob("*.py"): + if path == root / "app/runtime/config.py" or "app/plugins" in path.as_posix(): + continue + content = path.read_text(encoding="utf-8") + for expression in forbidden: + if expression in content: + violations.append(f"{path.relative_to(root)}:{expression}") + + assert violations == [] diff --git a/uv.lock b/uv.lock index 79eb8ca6a..4aaf423ef 100644 --- a/uv.lock +++ b/uv.lock @@ -1749,6 +1749,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-timeout" }, + { name = "ruff" }, ] runtime-free-threaded = [ { name = "bcrypt", version = "5.0.0", source = { registry = "https://pypi.org/simple" } }, @@ -1869,6 +1870,7 @@ dev = [ { name = "pytest-asyncio", specifier = "~=1.4.0" }, { name = "pytest-cov", specifier = "~=7.1.0" }, { name = "pytest-timeout", specifier = "~=2.4.0" }, + { name = "ruff", specifier = "~=0.16.4" }, ] runtime-free-threaded = [ { name = "bcrypt", specifier = "~=5.0.0" }, @@ -2949,6 +2951,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, ] +[[package]] +name = "ruff" +version = "0.16.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" }, + { url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" }, + { url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" }, + { url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" }, +] + [[package]] name = "s3transfer" version = "0.16.1"