- 字幕编排上移 DownloadChain.download_site_subtitles,SubtitleModule 仅保留站点链接解析 - TransferChain.recommend_name 上移 TV episodes_info 获取,filemanager 模块不再导入 TmdbChain - endpoint 穿透修复:WXBizMsgCrypt3 迁至 adapters/external/wechat_crypt.py; music/tmdb 缓存管理、listenbrainz 常量、TMDbException、WechatClawBot 辅助统一经 chain 包装 - RuleParser 与 builtin_rules 合并为 application/filter_rules.py; fsproxy/fsworker 迁至 adapters/system/ - chain/__init__.py 删除 qbittorrentapi/transmission_rpc 导入,消除后端协议类型泄漏 - 架构守护测试新增三项检查:模块间隔离、入口层穿透、下载器 SDK 泄漏 - 文档同步:05-architecture.md 记录 DB/Oper 聚合例外与迁移文件位置,AGENTS.md 更新所有权表
18 KiB
05 - Architecture and Modules
Directory Model
MoviePilot keeps the established product packages such as app/chain,
app/agent, app/modules, app/db, app/api, app/startup and
app/workflow in their original locations. The historical app/core,
app/helper and app/utils roots are virtual compatibility packages only;
physical Python sources must not be recreated there.
The legacy roots have no physical directories in the source tree. Current
images and update flows write site resources only to app/application/site/;
plugin imports under app.helper.* are resolved exclusively by the exact
runtime compatibility manifest.
Capabilities migrated out of those legacy roots are organized by technical responsibility:
Entrypoints / Plugins
|
v
API / Agent / CLI / Scheduler / Workflow
|
v
Chain orchestration ---------> Application services
| |
+----------> Modules / DB <----+
|
v
Domain / Runtime contracts
|
v
Foundation / Adapters
Startup remains the composition root. SDK and compatibility are boundaries,
not dependencies of canonical implementation modules.
Directory grouping does not override dependency direction. The architecture gate builds the complete Python module graph and rejects cycles even when a cycle passes through an established package that was not moved.
Canonical Migrated Packages
| Package | Ownership |
|---|---|
app/foundation/ |
Stateless, config-free and I/O-free primitives: reflection and dynamic import, crypto, DOM parsing, identity, collections, singleton, text conversion/segmentation, URL and version helpers |
app/domain/ |
Pure MoviePilot business semantics for media, recognition, sites and torrents; live configuration, persistence, transport and acceleration are injected |
app/application/ |
Focused stateful application services, configured capability selection and service-bound rules |
app/runtime/ |
Process-wide config, events, complete logging runtime, cache contracts/in-memory policy, execution, localization, scheduling, restart state, concurrency, GC and rate limits |
app/adapters/ |
Concrete technical I/O and named external ecosystems, split by cache, network, system and external boundaries |
app/sdk/ |
Stable, deliberately curated imports for plugin authors |
The packages above are the only top-level roots created by the legacy-module refactor. Existing product roots remain unchanged rather than being moved only to make the directory tree look symmetrical.
Application boundaries
| Path | Ownership |
|---|---|
app/application/*.py |
Audio, directory, downloader, filter, formatting, transfer history, image, media-server, notification, recognition, RSS, storage and torrent application services |
app/application/site/ |
Configured site catalog, authentication level and index-resource capability; the generated extension and its data bundle stay together here |
app/application/messaging/ |
Message rendering/routing, interactions and the Agent-to-message bridge: interaction.py shared interaction contracts and view helpers; router.py unified interaction priority and callback dispatch; site.py/subscribe.py/skill.py per-command sessions, input parsing and views; media.py media interaction state while the business workflow stays in MediaInteractionChain; plugin.py plugin input capture and plugin button callbacks; agent.py agent choice state, callback protocol and WebAgent bridge; message.py notification rendering, templates and queue. Not a public SDK recommended for direct plugin use |
app/application/security/ |
Authentication, authorization, cookies, passkeys, OTP/two-factor, path/URL safety, SSRF and signing policy |
Application services may use domain rules, runtime contracts, Oper classes and
adapters. Multi-domain workflows still belong in the existing app/chain/
package. Chain, Service and Manager remain class patterns; they do not
create additional top-level directory categories.
Runtime boundaries
| Path | Ownership |
|---|---|
app/runtime/config.py |
Deployment configuration and resolved runtime settings |
app/runtime/events.py |
Event contracts, dispatch and resolver registration |
app/runtime/log.py |
Complete console/plugin/file logging runtime and shutdown |
app/runtime/cache.py |
Cache protocols, memory implementations, decorators and proxies |
app/runtime/state.py |
Process restart and update state |
app/runtime/extensions/ |
Module, plugin and configured-service discovery/registration/lifecycle |
app/runtime/compat/ |
Standard-library-only exact legacy import routing and DEBUG diagnostics |
app/startup/ remains the established composition root and is not nested under
runtime. It injects providers and callbacks, orders initialization/shutdown and
decides restart policy. Lower-level runtime modules must not import startup.
Adapter boundaries
| Path | Ownership |
|---|---|
app/adapters/cache/ |
Redis and filesystem cache implementations and Redis clients |
app/adapters/network/ |
Generic HTTP, browser, DNS, Cloudflare and IP transport mechanisms |
app/adapters/system/ |
OS/filesystem/process facilities, stdio, display, packages, resources and optional Rust acceleration |
app/adapters/external/ |
CookieCloud, plugin market, OCR, IP-location providers and MoviePilot Server |
Generic protocol transport belongs in adapters/network; a named product or
ecosystem workflow belongs in adapters/external. An adapter may depend on
foundation, domain models, schemas and narrowly required runtime contracts, but
must not import application services, runtime/extensions, runtime/compat or
the plugin SDK.
RSS is not classified as a transport adapter merely because it uses HTTP. The
current RssHelper combines feed parsing, torrent item semantics, configured
site-specific URL discovery and browser fallback, so it belongs to
app/application/rss.py and consumes network adapters. Likewise, the generated
site extension owns the configured catalog/authentication/index capability and
lives in app/application/site/; only its download and file installation
mechanism remains in app/adapters/system/resource.py.
app/foundation/crypto.py stays in foundation because it contains only generic
RSA, digest and CryptoJS-compatible AES primitives and has no settings, policy,
I/O or logging. Authentication, token, passkey, signing and two-factor policy
still belongs in app/application/security/; callers decide how cryptographic
failures are reported.
Domain subdomains
app/domain/ is a business package, not a synonym for every file whose name
mentions media, site or torrent:
| Subdomain | Modules and ownership |
|---|---|
| Media | context.py owns Context, MediaInfo and TorrentInfo; media.py owns source/ID normalization; title.py owns title-candidate and search-keyword rules; episode.py owns episode-range display; scraper.py owns Kodi-style NFO reading and metadata document generation |
| Recognition | metainfo.py, meta/ and tokens.py parse names, paths, release groups, streaming platforms, anime, video and music metadata |
| Site | site.py owns site-domain exceptions and interprets HTML into business states such as logged-in and checked-in; configured catalog/auth/index resources stay in app/application/site/, generic URL/DOM parsing stays in foundation and network access stays in adapters |
| Torrent | torrent.py owns magnet-link semantics; configured download/cache/file behavior stays in app/application/torrent.py |
app/domain may depend only on schemas and foundation. It must not read global
settings, access DB/network/filesystem adapters, import Rust, discover services
or initialize process runtime state.
StringUtils is not a canonical implementation type. Generic text, capacity,
time, URL, DOM, hash and version functions live under app.foundation; media
title, episode, site and torrent rules live in their owning domain modules. Host
code must import those implementations directly. app.sdk.string.StringUtils
only composes the complete historical static-method surface for plugins, and
both app.utils.string and the retired app.domain.string resolve to that same
SDK module through the compatibility manifest.
Established Packages That Stay in Place
The following roots predate this migration and must not be moved or renamed as part of migrated-capability cleanup:
app/agent/app/api/app/chain/app/db/app/doctor/app/modules/app/monitor/app/plugins/app/schemas/app/startup/app/testing/app/workflow/
Necessary canonical import updates are allowed; changing their physical layout or product responsibilities requires a separate architectural decision.
Placement Decision Order
Use these questions in order before creating or moving a migrated capability:
- Is it generic, stateless, independent of MoviePilot state and free of I/O?
Put it in
app/foundation. - Is it a pure MoviePilot business rule/model? Put it in
app/domain. - Does it read persisted configuration or coordinate one focused configured
capability? Put it in
app/application. - Is it authentication, authorization, signing, SSRF, URL/path safety, OTP,
passkey or two-factor policy? Put it in
app/application/security. - Is it message rendering, routing or interaction behavior? Put it in
app/application/messaging. - Is it process-wide configuration, events, logging, cache policy, execution,
scheduling, concurrency, GC or restart state? Put it in
app/runtime. - Does it discover/manage modules, plugins or configured service providers?
Put it in
app/runtime/extensions. - Does it perform concrete cache, network, OS/process, filesystem, stdio,
package/resource or Rust I/O? Put it under the matching
app/adapterstechnical boundary. - Does it implement a named external product/ecosystem? Put it in
app/adapters/external. - Is it public to plugins or only preserving an old path? Curate it in
app/sdkor map it inapp/runtime/compat; never move implementation there.
Do not create generic common, helper or utils buckets. Reuse does not erase
ownership.
Existing Chain, Module and DB Layers
Chain layer
app/chain/ implements use cases shared by API, CLI, Agent, scheduler and other
entrypoints. Chains may coordinate modules, application services, Oper classes,
events and caches. New chain-to-chain dependencies are allowed only while the
static graph remains acyclic. Backend protocol details and HTTP request objects
do not belong here.
Module layer
app/modules/ contains pluggable downloaders, media servers, metadata sources,
message channels, indexers and storage providers. New direct module-to-module or
module-to-chain dependencies are forbidden; cross-module orchestration belongs
in a chain. The directory remains unchanged because discovery and plugin code
depend on this established runtime root.
DB / Oper layer
SQLAlchemy models stay under app/db/models/; the data access classes live in
app/db/oper/ and mirror them one-for-one (models/subscribe.py ↔
oper/subscribe.py), so a filename carries only the entity and the package name
carries the role. Two verified aggregation exceptions exist: the site family
(Passkey, SiteIcon, SiteStatistic, SiteUserData) is consolidated in
oper/site.py, and AgentTaskRun lives in oper/agenttask.py. Chains, modules,
application services and endpoints use Oper
classes instead of issuing SQLAlchemy queries directly. Every schema change
requires an Alembic migration under database/versions/.
Oper classes take and return persistence values, not domain objects. Translating
MediaInfo / MetaBase into a row is business logic and belongs in
app/application/ — see application/subscribe.py and application/history.py
for the two write paths. Column-type coercion (numeric year to string, boolean
switches to integers) stays in the Oper because it follows the column, not the
caller.
Invariants that must hold for every write are enforced at the mapper rather
than at each call site: app/db/models/_identity.py normalizes
media_source / media_id on before_insert / before_update, so a new write
path cannot forget them. Identity representation rules themselves
(alias folding, trimming, rejecting zero) live in app/schemas/media.py
alongside the two identity mixins; app/domain/media.py keeps only source
policy. app/db therefore has no dependency on app/domain.
Composition and Compatibility Boundaries
- Startup registers concrete cache factories before decorated business modules
are imported. Cache contracts remain in
app/runtime/cache.py; Redis/file implementations remain inapp/adapters/cache/backends.py. app/runtime/log.pyis a dependency leaf with noapp.*imports. Foundation emits no runtime logs; upper-layer owners decide whether failures are operationally relevant.app/adapters/system/resource.pyonly reports whether installation occurred;app/startup/modules_initializer.pysupplies the loaded site-resource versions and decides whether to restart. The adapter never imports the site application service.- Configured notification discovery lives in
app/application/notification.py. Web Push subscription and manual-send HTTP behavior stays inapp/api/endpoints/message.py. app/runtime/compatstores string mappings and resolves aliases lazily. It may not eagerly import canonical MoviePilot modules.- 已删除的
app.db.<entity>_oper路径继续由精确模块映射提供给旧插件;其中订阅写入、 整理历史写入和拆分后的用户认证依赖通过app.sdk._legacy薄门面委托 canonical Application/Oper,不把领域对象或 HTTP 依赖重新引回 DB 层。 - 物理模块仍存在但公开符号已经迁走时(例如
app.domain.media的身份原语、app.schemas的整理工作项),兼容 Finder 在标准 Loader 执行后叠加白名单符号路由; canonical 模块不得为兼容而反向 importapp.runtime.compat。 - Canonical implementation packages may not import
app/runtime/compatorapp/sdk. - Host code uses canonical paths. Only
app/plugins/and compatibility tests may useapp.core,app.helper,app.utilsorapp.log. - New plugins use
app.sdk. In DEBUG mode, a legacy plugin import remains functional and emits one actionable warning per plugin and legacy module. - Delayed imports are not accepted as a way to hide dependency cycles.
Permitted Call Directions
| Direction | Status |
|---|---|
entrypoint -> chain / application / Oper |
Allowed according to workflow complexity |
chain -> module / application / Oper / canonical capability |
Allowed |
application -> domain / runtime / adapter / Oper |
Allowed |
module -> canonical capability / Oper |
Allowed |
module -> module / chain |
Forbidden for new code |
adapter -> application / runtime.extensions / sdk / compat |
Forbidden |
domain -> runtime / adapter / application / DB |
Forbidden |
foundation -> other app packages |
Forbidden |
canonical implementation -> sdk / compat |
Forbidden |
compat -> canonical implementation at module import time |
Forbidden |
| Any import that creates a module-level cycle | Forbidden |
Key File Locations
| Path | Purpose |
|---|---|
app/runtime/config.py |
ConfigModel, Settings and deployment configuration |
app/runtime/events.py |
EventManager, Event and event resolver registration |
app/runtime/extensions/module_manager.py |
Module discovery and lifecycle |
app/runtime/extensions/plugin_manager.py |
Plugin discovery and lifecycle |
app/foundation/reflection.py |
Generic reflection and Python module discovery |
app/adapters/network/http.py |
Shared synchronous and asynchronous HTTP clients |
app/application/rss.py |
Configured RSS retrieval and parsing |
app/application/site/sites.* |
Generated site catalog, authentication and index capability plus its colocated data bundle |
app/runtime/cache.py |
Cache contracts, memory backend, decorators and proxies |
app/adapters/cache/backends.py |
Redis and filesystem cache adapters |
app/adapters/system/resource.py |
Runtime resource detection/download/installation |
app/adapters/system/fsproxy.py |
Timeout-guarded local filesystem operations in a killable subprocess (with colocated fsworker.py) |
app/adapters/external/wechat_crypt.py |
WeChat enterprise-message XML encryption/decryption protocol |
app/application/filter_rules.py |
Built-in torrent filter rule set and rule parser |
app/adapters/external/market.py |
Plugin repository discovery and installation |
app/application/security/url.py |
URL/path validation, SSRF protection and signed image policy |
app/application/mediaserver.py |
Configured media-server discovery and identity matching |
app/runtime/compat/manifest.py |
Exact legacy-to-canonical import manifest |
app/sdk/ |
Stable plugin imports |
Run tests/test_architecture_dependencies.py after every ownership or import
change. It rejects physical legacy or retired canonical sources, forbidden
upward dependencies, SDK/compat backreferences, any strongly connected
component containing a migrated module, module-to-module or module-to-chain
imports, entrypoint (api/agent/monitor/workflow/doctor) imports of
app.modules internals, and downloader SDK (qbittorrentapi,
transmission_rpc) imports inside app/chain.
Last Updated: 2026-08-15