版本: v3.0 基于: 需求分析文档 v2.0(含资源站点功能)+ MoviePilot 架构研究 最后更新: 2026-02-26
参考 MoviePilot v2 架构,采用 Chain + Module + Plugin 三层架构,针对音乐资源管理领域进行适配:
-
核心原则
- Chain 层: 业务逻辑编排,协调模块和插件
- Module 层: 独立功能模块(资源站点、元数据、下载器、媒体服务器)
- Plugin 层: 用户扩展,支持劫持系统模块
- 事件驱动: 松耦合模块间通信
- 配置驱动: 通过配置文件控制行为
-
音乐资源管理适配
- 元数据源: MusicBrainz(替代 TMDB)
- 媒体类型: Artist、Album、Track(替代 Movie、TV、Episode)
- 播放功能: 音频流、播放列表
- 订阅类型: 艺术家/专辑订阅
- 资源站点: 支持多个音乐 资源站点
- 下载方式: 种子文件 + 下载器(qBittorrent/Transmission)
职责: 管理 资源站点,搜索音乐资源,下载种子文件
class TorrentsChain(ChainBase):
"""资源链"""
def search(self, keyword: str, sites: List[int] = None,
category: str = None, filters: dict = None) -> List[TorrentInfo]:
"""在资源站点搜索音乐资源
Args:
keyword: 搜索关键词(艺术家/专辑/曲目)
sites: 站点 ID 列表,为空则搜索所有启用站点
category: 分类(music/flac/mp3/lossless)
filters: 高级过滤条件(大小、编码、免费状态等)
Returns:
TorrentInfo 列表
"""
results = []
# 获取要搜索的站点
if sites:
site_list = [self.get_site(site_id) for site_id in sites]
else:
site_list = self.run_module("get_active_sites")
# 并发搜索
for site in site_list:
try:
site_results = self.run_module(
"search_torrents",
site=site,
keyword=keyword,
category=category,
filters=filters
)
results.extend(site_results)
except Exception as e:
logger.error(f"站点 {site.name} 搜索失败:{e}")
# 去重和排序
results = self.deduplicate_and_sort(results)
return results
def download_torrent(self, torrent: TorrentInfo) -> str:
"""下载种子文件
Args:
torrent: TorrentInfo 对象
Returns:
种子文件路径
"""
# 从站点下载种子
site = self.get_site(torrent.site)
torrent_file = self.run_module("download_torrent", site, torrent)
# 保存种子文件
torrent_path = self.save_torrent(torrent_file, torrent)
# 发送事件
self.send_event(EventType.TorrentDownloaded, {
"torrent": torrent,
"path": torrent_path
})
return torrent_path
def push_to_downloader(self, torrent: TorrentInfo, downloader: str) -> DownloadTask:
"""推送到下载器
Args:
torrent: TorrentInfo 对象
downloader: 下载器类型(qbittorrent/transmission)
Returns:
DownloadTask 对象
"""
# 创建下载任务
task = DownloadTask(
torrent_id=torrent.site,
torrent_name=torrent.title,
torrent_url=torrent.enclosure,
downloader=downloader,
status="downloading"
)
# 推送到下载器
if downloader == "qbittorrent":
self.run_module("push_to_qbittorrent", task)
elif downloader == "transmission":
self.run_module("push_to_transmission", task)
# 发送事件
self.send_event(EventType.DownloadStarted, {
"task": task
})
return task
def browse_site(self, domain: str, page: int = 0,
category: str = None) -> List[TorrentInfo]:
"""浏览站点首页
Args:
domain: 站点域名
page: 页码
category: 分类
Returns:
TorrentInfo 列表
"""
site = self.get_site_by_domain(domain)
if not site:
return []
results = self.run_module(
"browse_site",
site=site,
page=page,
category=category
)
return results
def get_site_rss(self, domain: str) -> List[TorrentInfo]:
"""获取站点 RSS
Args:
domain: 站点域名
Returns:
TorrentInfo 列表
"""
site = self.get_site_by_domain(domain)
if not site or not site.rss:
return []
# 解析 RSS
rss_items = self.run_module(
"parse_rss",
url=site.rss,
use_proxy=site.proxy,
timeout=site.timeout,
ua=site.ua
)
# 组装 TorrentInfo
results = []
for item in rss_items:
torrent_info = TorrentInfo(
site=site.id,
site_name=site.name,
site_cookie=site.cookie,
site_ua=site.ua,
site_proxy=site.proxy,
site_order=site.pri,
site_downloader=site.downloader,
title=item.title,
enclosure=item.enclosure,
page_url=item.link,
size=item.size,
pubdate=item.pubdate,
)
results.append(torrent_info)
return resultsclass SiteModule:
"""站点模块"""
@classmethod
def init_setting(cls):
return ("SITE_ENABLE", True)
def __init__(self):
self.cookie_jar = http.cookiejar.CookieJar()
self.opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(self.cookie_jar)
)
def login(self, site: Site):
"""登录站点"""
if site.cookie:
# 设置 Cookie
self.set_cookie(site.cookie)
return True
# 如果没有 Cookie,尝试 Passkey 登录
if site.passkey:
return self.login_with_passkey(site.passkey)
return False
def search_torrents(self, site: Site, keyword: str,
category: str = None, filters: dict = None) -> List[TorrentInfo]:
"""搜索种子
Args:
site: 站点配置
keyword: 搜索关键词
category: 分类
filters: 过滤条件
Returns:
TorrentInfo 列表
"""
# 构建搜索 URL
search_url = self.build_search_url(site, keyword, category, filters)
# 发送请求
response = self.request(search_url, site)
# 解析搜索结果
results = self.parse_search_results(response, site)
return results
def download_torrent(self, site: Site, torrent: TorrentInfo) -> bytes:
"""下载种子文件
Args:
site: 站点配置
torrent: TorrentInfo 对象
Returns:
种子文件内容
"""
response = self.request(torrent.enclosure, site)
return response.content
def browse_site(self, site: Site, page: int = 0,
category: str = None) -> List[TorrentInfo]:
"""浏览站点首页"""
browse_url = self.build_browse_url(site, page, category)
response = self.request(browse_url, site)
results = self.parse_browse_results(response, site)
return results
def request(self, url: str, site: Site):
"""发送 HTTP 请求"""
if site.proxy:
# 使用代理
proxy_handler = urllib.request.ProxyHandler({'http': site.proxy, 'https': site.proxy})
opener = urllib.request.build_opener(proxy_handler)
else:
opener = self.opener
# 设置 User-Agent
headers = {'User-Agent': site.ua or settings.USER_AGENT}
request = urllib.request.Request(url, headers=headers)
try:
response = opener.open(request, timeout=site.timeout or 15)
return response
except Exception as e:
logger.error(f"请求 {url} 失败:{e}")
raise支持不同的资源站点,每个站点有自己的适配器。用户可以通过插件系统扩展自定义站点适配器。
# SiteAdapterModule - 适配器基类
class SiteAdapter(SiteModule):
"""资源站点适配器"""
pass
# 用户可以通过插件系统扩展自定义站点适配器class Site(Base):
"""资源站点表"""
__tablename__ = "site"
id = Column(Integer, primary_key=True, autoincrement=True)
# 站点名称
name = Column(String(100), nullable=False)
# 域名 Key
domain = Column(String(255), unique=True, index=True, nullable=False)
# 站点地址
url = Column(String(500), nullable=False)
# 站点优先级(1-10,数字越大优先级越高)
pri = Column(Integer, default=1, index=True)
# RSS 地址
rss = Column(String(500))
# Cookie
cookie = Column(Text)
# Passkey
passkey = Column(String(100))
# User-Agent
ua = Column(String(500))
# 是否使用代理(0-否,1-是)
proxy = Column(Integer, default=0)
# 过滤规则
filter = Column(Text)
# 是否渲染(是否启用搜索)
render = Column(Integer, default=1)
# 是否公开站点
public = Column(Integer, default=0)
# 附加信息
note = Column(JSON)
# 流控单位周期
limit_interval = Column(Integer, default=0)
# 流控次数
limit_count = Column(Integer, default=0)
# 流控间隔(秒)
limit_seconds = Column(Integer, default=0)
# 超时时间(秒)
timeout = Column(Integer, default=15)
# 是否启用
is_active = Column(Boolean, default=True)
# 关联下载器(qbittorrent/transmission)
downloader = Column(String(50))
# 最后修改时间
lst_mod_date = Column(String, default=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
# 创建时间
created_at = Column(DateTime, default=datetime.now)
# 更新时间
updated_at = Column(DateTime, onupdate=datetime.now)class TorrentInfo(BaseModel):
"""种子信息"""
# 站点信息
site: int = None # 站点 ID
site_name: str = None # 站点名称
site_cookie: str = None # 站点 Cookie
site_ua: str = None # 站点 User-Agent
site_proxy: bool = False # 是否使用代理
site_order: int = 0 # 站点优先级
site_downloader: str = None # 关联下载器
# 种子信息
title: str = None # 种子标题
description: str = None # 副标题
enclosure: str = None # 下载链接
page_url: str = None # 详情页面
size: float = 0.0 # 文件大小(字节)
seeders: int = 0 # 做种数
leechers: int = 0 # 下载数
grabs: int = 0 # 完成数
pubdate: str = None # 发布时间
date_elapsed: str = None # 已发布时间
# 促销信息
upload_factor: float = None # 上传倍率
download_factor: float = None # 下载倍率
hit_and_run: bool = False # HR 状态
# 分类
category: str = None # 分类
# 其他
labels: List[str] = [] # 标签
pri_order: int = 0 # 种子优先级| 方法 | 路径 | 描述 | 参数 |
|---|---|---|---|
| GET | /sites |
列出 资源站点 | - |
| POST | /sites |
添加 资源站点 | name, domain, url, cookie, passkey, ua, proxy, downloader, pri |
| GET | /sites/{id} |
获取站点详情 | - |
| PUT | /sites/{id} |
更新站点 | - |
| DELETE | /sites/{id} |
删除站点 | - |
| POST | /sites/{id}/test |
测试站点连接 | - |
| PUT | /sites/{id}/enable |
启用站点 | - |
| PUT | /sites/{id}/disable |
禁用站点 | - |
| 方法 | 路径 | 描述 | 参数 |
|---|---|---|---|
| GET | /search/torrents |
搜索资源 | keyword, sites, category, filters |
| GET | /sites/{domain}/browse |
浏览站点首页 | page, category |
| GET | /sites/{domain}/rss |
获取站点 RSS | - |
| 方法 | 路径 | 描述 | 参数 |
|---|---|---|---|
| POST | /download/torrent |
下载种子文件 | torrent_id |
| POST | /download/push |
推送到下载器 | torrent_id, downloader |
| GET | /download/history |
下载历史 | page, page_size, status |
src/
├── views/
│ ├── site/
│ │ ├── SiteListView.vue # 资源站点列表
│ │ └── SiteDetailView.vue # 站点详情
│ ├── search/
│ │ └── SearchView.vue # 资源搜索
│ └── download/
│ └── DownloadView.vue # 下载管理
<!-- SiteListView.vue -->
<template>
<div class="site-list">
<n-button type="primary" @click="showAddSiteModal">添加站点</n-button>
<n-data-table
:columns="columns"
:data="sites"
:pagination="pagination"
/>
<n-modal v-model:show="showAddModal">
<n-form :model="newSite" label-placement="left">
<n-form-item label="站点名称">
<n-input v-model:value="newSite.name" placeholder="自定义站点名称" />
</n-form-item>
<n-form-item label="站点地址">
<n-input v-model:value="newSite.url" placeholder="https://example.com" />
</n-form-item>
<n-form-item label="Cookie">
<n-input type="textarea" v-model:value="newSite.cookie" placeholder="ccc_xxx=..." />
</n-form-item>
<n-form-item label="下载器">
<n-select v-model:value="newSite.downloader">
<n-option value="qbittorrent">qBittorrent</n-option>
<n-option value="transmission">Transmission</n-option>
</n-select>
</n-form-item>
<n-form-item>
<n-button type="primary" @click="addSite">添加</n-button>
</n-form-item>
</n-form>
</n-modal>
</div>
</template>目标: 实现 资源站点管理和音乐资源下载
任务列表:
| 任务 | 描述 | 预计工时 |
|---|---|---|
| M4-T1 | 资源站点数据模型 | 4h |
| M4-T2 | TorrentsChain 实现 | 10h |
| M4-T3 | 站点模块 | 12h |
| M4-T4 | 适配器 | 8h |
| M4-T5 | 搜索 API | 6h |
| M4-T6 | 下载 API | 6h |
| M4-T7 | 前端站点管理界面 | 8h |
| M4-T8 | 前端搜索界面 | 6h |
| M4-T9 | 前端下载管理界面 | 6h |
- MoviePilot: https://github.com/jxxghp/MoviePilot
- MoviePilot Wiki: https://wiki.movie-pilot.org
- MusicBrainz API: https://musicbrainz.org/doc/MusicBrainz_API
- FastAPI: https://fastapi.tiangolo.com/
- Vue 3: https://vuejs.org/