refactor: 推进后端分层架构治理

This commit is contained in:
jxxghp
2026-08-18 00:29:14 +08:00
parent 5d0bacabd5
commit 5128ae9e1e
363 changed files with 34179 additions and 6019 deletions
+35 -235
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import inspect
import pickle
import traceback
from abc import ABCMeta
@@ -8,32 +7,23 @@ from collections.abc import Callable
from pathlib import Path
from typing import Optional, Any, Tuple, List, Set, Union, Dict
from fastapi.concurrency import run_in_threadpool
from app.application.messaging.message import MessageHelper, MessageQueueManager
from app.application.chain.context import ChainRuntimeContext, get_chain_runtime_context
from app.chain._messaging import MessageProcessingMixin, NotificationMixin
from app.chain._recognition import RecognitionMixin
from app.db.oper.message import MessageOper
from app.domain.context import Context, MediaInfo, SubtitleInfo, TorrentInfo
from app.domain.meta.metabase import MetaBase
from app.foundation.reflection import ObjectUtils
from app.runtime.cache import FileCache, AsyncFileCache
from app.runtime.events import EventManager
from app.runtime.extensions.module_manager import ModuleManager
from app.runtime.extensions.plugin_manager import PluginManager
from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher
from app.runtime.log import logger
from app.schemas import (
RateLimitExceededException,
TransferInfo,
ExistMediaInfo,
DownloaderTorrent,
IncomingMessage,
WebhookEventInfo,
TmdbEpisode,
MediaPerson,
FileItem,
TransferDirectoryConf,
)
from app.schemas.exception import RateLimitExceededException
from app.schemas.transfer import TransferInfo
from app.schemas.mediaserver import ExistMediaInfo
from app.schemas.transfer import 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.types import (
TorrentStatus,
@@ -50,18 +40,26 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin,
处理链基类
"""
def __init__(self):
def __init__(self, runtime_context: Optional[ChainRuntimeContext] = None):
"""
公共初始化
公共初始化;未显式传入上下文时继续使用兼容运行时 provider。
"""
self.modulemanager = ModuleManager()
self.eventmanager = EventManager()
self.messageoper = MessageOper()
self.messagehelper = MessageHelper()
self.messagequeue = MessageQueueManager(send_callback=self.run_module)
self.pluginmanager = PluginManager()
self.filecache = FileCache()
self.async_filecache = AsyncFileCache()
context = runtime_context or get_chain_runtime_context()
self.modulemanager = context.module_manager
self.eventmanager = context.event_manager
self.messageoper = context.message_oper
self.messagehelper = context.message_helper
self.pluginmanager = context.plugin_manager
self.filecache = context.file_cache
self.async_filecache = context.async_file_cache
self._module_dispatcher = ModuleInvocationDispatcher(
module_catalog=self.modulemanager,
plugin_catalog=self.pluginmanager,
plugin_error_handler=self.__handle_plugin_error,
system_error_handler=self.__handle_system_error,
rate_limit_handler=self.__handle_rate_limit_error,
)
self.messagequeue = context.message_queue_factory(self.run_module)
def load_cache(self, filename: str) -> Any:
"""
@@ -121,16 +119,6 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin,
"""
await self.async_filecache.delete(filename)
@staticmethod
def __is_valid_empty(ret):
"""
判断结果是否为空
"""
if isinstance(ret, tuple):
return all(value is None for value in ret)
else:
return ret is None
def __handle_plugin_error(
self, err: Exception, plugin_id: str, plugin_name: str, method: str, **kwargs
):
@@ -195,178 +183,6 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin,
raise err
logger.info(f"{source_type} {source_id}.{method} 已限流,跳过执行:{str(err)}")
def __execute_plugin_modules(
self, method: str, result: Any, *args, **kwargs
) -> Any:
"""
执行插件模块
"""
for plugin, module_dict in self.pluginmanager.get_plugin_modules().items():
plugin_id, plugin_name = plugin
if method in module_dict:
func = module_dict[method]
if func:
try:
logger.info(f"请求插件 {plugin_name} 执行:{method} ...")
if self.__is_valid_empty(result):
# 返回None,第一次执行或者需继续执行下一模块
result = func(*args, **kwargs)
elif isinstance(result, list):
# 返回为列表,有多个模块运行结果时进行合并
temp = func(*args, **kwargs)
if isinstance(temp, list):
result.extend(temp)
else:
break
except RateLimitExceededException as err:
self.__handle_rate_limit_error(
err, "插件", plugin_id, method, **kwargs
)
except Exception as err:
self.__handle_plugin_error(
err, plugin_id, plugin_name, method, **kwargs
)
return result
async def __async_execute_plugin_modules(
self, method: str, result: Any, *args, **kwargs
) -> Any:
"""
异步执行插件模块
"""
for plugin, module_dict in self.pluginmanager.get_plugin_modules().items():
plugin_id, plugin_name = plugin
if method in module_dict:
func = module_dict[method]
if func:
try:
logger.info(f"请求插件 {plugin_name} 执行:{method} ...")
if self.__is_valid_empty(result):
# 返回None,第一次执行或者需继续执行下一模块
if inspect.iscoroutinefunction(func):
result = await func(*args, **kwargs)
else:
# 插件同步函数在异步环境中运行,避免阻塞
result = await run_in_threadpool(func, *args, **kwargs)
elif isinstance(result, list):
# 返回为列表,有多个模块运行结果时进行合并
if inspect.iscoroutinefunction(func):
temp = await func(*args, **kwargs)
else:
# 插件同步函数在异步环境中运行,避免阻塞
temp = await run_in_threadpool(func, *args, **kwargs)
if isinstance(temp, list):
result.extend(temp)
else:
break
except RateLimitExceededException as err:
self.__handle_rate_limit_error(
err, "插件", plugin_id, method, **kwargs
)
except Exception as err:
self.__handle_plugin_error(
err, plugin_id, plugin_name, method, **kwargs
)
return result
def __execute_system_modules(
self, method: str, result: Any, *args, **kwargs
) -> Any:
"""
执行系统模块
"""
logger.debug(f"请求系统模块执行:{method} ...")
for module in sorted(
self.modulemanager.get_running_modules(method),
key=lambda x: x.get_priority(),
):
module_id = module.__class__.__name__
try:
module_name = module.get_name()
except Exception as err:
logger.debug(f"获取模块名称出错:{str(err)}")
module_name = module_id
try:
func = getattr(module, method)
if self.__is_valid_empty(result):
# 返回None,第一次执行或者需继续执行下一模块
result = func(*args, **kwargs)
elif ObjectUtils.check_signature(func, result):
# 返回结果与方法签名一致,将结果传入
result = func(result)
elif isinstance(result, list):
# 返回为列表,有多个模块运行结果时进行合并
temp = func(*args, **kwargs)
if isinstance(temp, list):
result.extend(temp)
else:
# 中止继续执行
break
except RateLimitExceededException as err:
self.__handle_rate_limit_error(
err, "模块", module_id, method, **kwargs
)
except Exception as err:
logger.error(traceback.format_exc())
self.__handle_system_error(
err, module_id, module_name, method, **kwargs
)
return result
async def __async_execute_system_modules(
self, method: str, result: Any, *args, **kwargs
) -> Any:
"""
异步执行系统模块
"""
logger.debug(f"请求系统模块执行:{method} ...")
for module in sorted(
self.modulemanager.get_running_modules(method),
key=lambda x: x.get_priority(),
):
module_id = module.__class__.__name__
try:
module_name = module.get_name()
except Exception as err:
logger.debug(f"获取模块名称出错:{str(err)}")
module_name = module_id
try:
func = getattr(module, method)
if self.__is_valid_empty(result):
# 返回None,第一次执行或者需继续执行下一模块
if inspect.iscoroutinefunction(func):
result = await func(*args, **kwargs)
else:
# 系统同步模块在异步路径里也必须切到线程池,避免阻塞共享事件循环。
result = await run_in_threadpool(func, *args, **kwargs)
elif ObjectUtils.check_signature(func, result):
# 返回结果与方法签名一致,将结果传入
if inspect.iscoroutinefunction(func):
result = await func(result)
else:
result = await run_in_threadpool(func, result)
elif isinstance(result, list):
# 返回为列表,有多个模块运行结果时进行合并
if inspect.iscoroutinefunction(func):
temp = await func(*args, **kwargs)
else:
temp = await run_in_threadpool(func, *args, **kwargs)
if isinstance(temp, list):
result.extend(temp)
else:
# 中止继续执行
break
except RateLimitExceededException as err:
self.__handle_rate_limit_error(
err, "模块", module_id, method, **kwargs
)
except Exception as err:
logger.error(traceback.format_exc())
self.__handle_system_error(
err, module_id, module_name, method, **kwargs
)
return result
def run_module(
self,
method: str,
@@ -379,15 +195,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin,
:param method: 模块方法名称
"""
# 执行插件模块
result = self.__execute_plugin_modules(method, None, *args, **kwargs)
if not self.__is_valid_empty(result) and not isinstance(result, list):
# 插件模块返回结果不为空且不是列表,直接返回
return result
# 执行系统模块
return self.__execute_system_modules(method, result, *args, **kwargs)
return self._module_dispatcher.dispatch(method, *args, **kwargs)
async def async_run_module(
self,
@@ -402,18 +210,10 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin,
:param method: 模块方法名称
"""
# 执行插件模块
result = await self.__async_execute_plugin_modules(
method, None, *args, **kwargs
)
if not self.__is_valid_empty(result) and not isinstance(result, list):
# 插件模块返回结果不为空且不是列表,直接返回
return result
# 执行系统模块
return await self.__async_execute_system_modules(
method, result, *args, **kwargs
return await self._module_dispatcher.async_dispatch(
method,
*args,
**kwargs,
)
def match_doubaninfo(
+3 -1
View File
@@ -16,7 +16,9 @@ from app.application.messaging.message import MessageTemplateHelper
from app.runtime.config import settings
from app.runtime.extensions.service_registry import ServiceConfigHelper
from app.runtime.log import logger
from app.schemas import MessageResponse, Message, TransferInfo
from app.schemas.message import MessageResponse
from app.schemas.message import Message
from app.schemas.transfer import TransferInfo
from app.schemas.message import ChannelCapability, ChannelCapabilityManager
from app.schemas.types import EventType, NotificationChannel
+7 -9
View File
@@ -2,6 +2,10 @@ import copy
from typing import Any, List, Optional, Tuple
from app.application.torrent import TorrentHelper
from app.application.subscription.contract import (
build_subscribe_meta,
subscribe_media_key,
)
from app.chain.download import DownloadChain
from app.chain.media import MediaChain
from app.chain.search import SearchChain
@@ -37,8 +41,8 @@ class MusicSubscribeMixin:
该域方法通过 self 复用 SubscribeChain 主体的 get_sub_sites / get_params /
filter_torrents / check_and_handle_existing_media / finish_subscribe_or_not /
get_subscribe_source_keyword 等编排能力,因此仅作为 mixin 混入 SubscribeChain
不独立成链。build_subscribe_meta / _subscribe_media_key 等订阅通用辅助仍保留在
subscribe.py,方法内延迟导入以避免 _music ↔ subscribe 的模块级循环
不独立成链。订阅元数据与媒体键由 Application 共享契约提供,避免 mixin 与
SubscribeChain 主体形成双向模块依赖
"""
@staticmethod
@@ -100,8 +104,6 @@ class MusicSubscribeMixin:
@staticmethod
def _recognize_music_subscribe(subscribe: Subscribe) -> Optional[MusicInfo]:
"""按订阅身份恢复音乐目标,远端暂不可用时使用已持久化的稳定快照。"""
# 延迟导入订阅通用辅助,避免 _music ↔ subscribe 模块级循环
from app.chain.subscribe import build_subscribe_meta
if subscribe.media_source and subscribe.media_id:
# 与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情
mediainfo = MediaChain().recognize_media(
@@ -131,8 +133,6 @@ class MusicSubscribeMixin:
@staticmethod
async def _async_recognize_music_subscribe(subscribe: Subscribe) -> Optional[MusicInfo]:
"""异步按订阅身份恢复音乐目标,远端暂不可用时使用已持久化的稳定快照。"""
# 延迟导入订阅通用辅助,避免 _music ↔ subscribe 模块级循环
from app.chain.subscribe import build_subscribe_meta
if subscribe.media_source and subscribe.media_id:
# 与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情
mediainfo = await MediaChain().async_recognize_media(
@@ -219,8 +219,6 @@ class MusicSubscribeMixin:
subscribe: Subscribe,
) -> Optional[Tuple[MusicInfo, MetaMusic]]:
"""识别音乐订阅目标、同步实体快照,并在搜索前处理已完整入库的目标。"""
# 延迟导入订阅通用辅助,避免 _music ↔ subscribe 模块级循环
from app.chain.subscribe import _subscribe_media_key
mediainfo = self._recognize_music_subscribe(subscribe)
if not mediainfo:
logger.warning(
@@ -241,7 +239,7 @@ class MusicSubscribeMixin:
subscribe=subscribe,
meta=meta,
mediainfo=mediainfo,
mediakey=_subscribe_media_key(subscribe),
mediakey=subscribe_media_key(subscribe),
)
if exists:
return None
+11 -12
View File
@@ -12,7 +12,8 @@ from copy import deepcopy
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
from app import schemas
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
@@ -33,19 +34,17 @@ from app.domain.meta.metamusic import MetaMusic
from app.foundation import text as text_tools
from app.runtime.config import global_vars, settings
from app.runtime.log import logger
from app.schemas import (
FileItem,
Message,
TmdbEpisode,
TransferInfo,
)
from app.schemas.agent import ReplyMode
from app.schemas.workflow import FileItem
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,
EventType,
MediaSource,
MediaType,
NotificationChannel,
ReplyMode,
SystemConfigKey,
)
@@ -722,17 +721,17 @@ class EpisodeFormatMixin:
return state, errmsg, data
@staticmethod
def _get_episode_format_rules() -> List[schemas.EpisodeFormatRule]:
def _get_episode_format_rules() -> List[_SchemaEpisodeFormatRule]:
"""
获取启用的集数定位规则
"""
rule_items = SystemConfigOper().get(SystemConfigKey.EpisodeFormatRuleTable) or []
rules: List[schemas.EpisodeFormatRule] = []
rules: List[_SchemaEpisodeFormatRule] = []
for item in rule_items:
if not isinstance(item, dict):
continue
try:
rule = schemas.EpisodeFormatRule(**item)
rule = _SchemaEpisodeFormatRule(**item)
except Exception as err:
logger.warn(f"忽略无效的集数定位规则:{err}")
continue
@@ -941,7 +940,7 @@ class HistoryMatchMixin:
# 两种 DownloadHistory 都会进来:库模型(本文件按 ORM 行查历史)与
# schemas DTOTransferTask.download_history)。本函数只按 getattr 取
# year 与 type,对两者一视同仁
media: Union[DownloadHistory, schemas.DownloadHistory, MediaInfo, MusicInfo]
media: Union[DownloadHistory, _SchemaDownloadHistory, MediaInfo, MusicInfo]
) -> bool:
"""
判断文件名年份是否与已识别电影年份冲突。
+5 -5
View File
@@ -1,6 +1,6 @@
from typing import Optional
from app import schemas
from app.schemas.context import MediaPerson as _SchemaMediaPerson
from app.chain import ChainBase
from app.domain.context import MediaInfo
@@ -86,7 +86,7 @@ class AniListChain(ChainBase):
def credits(
self, anilist_id: int, page: int = 1, count: int = 20
) -> list[schemas.MediaPerson]:
) -> list[_SchemaMediaPerson]:
"""
获取 AniList 动画配音演员。
@@ -98,7 +98,7 @@ class AniListChain(ChainBase):
async def async_credits(
self, anilist_id: int, page: int = 1, count: int = 20
) -> list[schemas.MediaPerson]:
) -> list[_SchemaMediaPerson]:
"""
异步获取 AniList 动画配音演员。
@@ -135,7 +135,7 @@ class AniListChain(ChainBase):
count=count,
) or []
def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
def person_detail(self, person_id: int) -> Optional[_SchemaMediaPerson]:
"""
获取 AniList 人物详情。
@@ -143,7 +143,7 @@ class AniListChain(ChainBase):
"""
return self.run_module("anilist_person_detail", person_id=person_id)
async def async_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
async def async_person_detail(self, person_id: int) -> Optional[_SchemaMediaPerson]:
"""
异步获取 AniList 人物详情。
+5 -5
View File
@@ -1,6 +1,6 @@
from typing import Optional, List
from app import schemas
from app.schemas.context import MediaPerson as _SchemaMediaPerson
from app.chain import ChainBase
from app.domain.context import MediaInfo
@@ -30,7 +30,7 @@ class BangumiChain(ChainBase):
"""
return self.run_module("bangumi_info", bangumiid=bangumiid)
def bangumi_credits(self, bangumiid: int) -> List[schemas.MediaPerson]:
def bangumi_credits(self, bangumiid: int) -> List[_SchemaMediaPerson]:
"""
根据BangumiID查询电影演职员表
:param bangumiid: BangumiID
@@ -44,7 +44,7 @@ class BangumiChain(ChainBase):
"""
return self.run_module("bangumi_recommend", bangumiid=bangumiid)
def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
def person_detail(self, person_id: int) -> Optional[_SchemaMediaPerson]:
"""
根据人物ID查询Bangumi人物详情
:param person_id: 人物ID
@@ -78,7 +78,7 @@ class BangumiChain(ChainBase):
"""
return await self.async_run_module("async_bangumi_info", bangumiid=bangumiid)
async def async_bangumi_credits(self, bangumiid: int) -> List[schemas.MediaPerson]:
async def async_bangumi_credits(self, bangumiid: int) -> List[_SchemaMediaPerson]:
"""
根据BangumiID查询电影演职员表(异步版本)
:param bangumiid: BangumiID
@@ -92,7 +92,7 @@ class BangumiChain(ChainBase):
"""
return await self.async_run_module("async_bangumi_recommend", bangumiid=bangumiid)
async def async_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
async def async_person_detail(self, person_id: int) -> Optional[_SchemaMediaPerson]:
"""
根据人物ID查询Bangumi人物详情(异步版本)
:param person_id: 人物ID
+4 -3
View File
@@ -1,6 +1,7 @@
from typing import Optional, List
from app import schemas
from app.schemas.dashboard import DownloaderInfo as _SchemaDownloaderInfo
from app.schemas.dashboard import Statistic as _SchemaStatistic
from app.chain import ChainBase
@@ -8,13 +9,13 @@ class DashboardChain(ChainBase):
"""
各类仪表板统计处理链
"""
def media_statistic(self, server: Optional[str] = None) -> Optional[List[schemas.Statistic]]:
def media_statistic(self, server: Optional[str] = None) -> Optional[List[_SchemaStatistic]]:
"""
媒体数量统计
"""
return self.run_module("media_statistic", server=server)
def downloader_info(self, downloader: Optional[str] = None) -> Optional[List[schemas.DownloaderInfo]]:
def downloader_info(self, downloader: Optional[str] = None) -> Optional[List[_SchemaDownloaderInfo]]:
"""
下载器信息
"""
+8 -9
View File
@@ -1,11 +1,10 @@
from typing import Any, List, Optional
from app import schemas
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 import MediaType
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource, MediaType
class DoubanChain(ChainBase):
@@ -223,7 +222,7 @@ class DoubanChain(ChainBase):
return None
return album
def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
def person_detail(self, person_id: int) -> Optional[_SchemaMediaPerson]:
"""
根据人物ID查询豆瓣人物详情
:param person_id: 人物ID
@@ -296,14 +295,14 @@ class DoubanChain(ChainBase):
"""
return self.run_module("tv_hot", page=page, count=count)
def movie_credits(self, doubanid: str) -> Optional[List[schemas.MediaPerson]]:
def movie_credits(self, doubanid: str) -> Optional[List[_SchemaMediaPerson]]:
"""
根据TMDBID查询电影演职人员
:param doubanid: 豆瓣ID
"""
return self.run_module("douban_movie_credits", doubanid=doubanid)
def tv_credits(self, doubanid: str) -> Optional[List[schemas.MediaPerson]]:
def tv_credits(self, doubanid: str) -> Optional[List[_SchemaMediaPerson]]:
"""
根据TMDBID查询电视剧演职人员
:param doubanid: 豆瓣ID
@@ -324,7 +323,7 @@ class DoubanChain(ChainBase):
"""
return self.run_module("douban_tv_recommend", doubanid=doubanid)
async def async_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
async def async_person_detail(self, person_id: int) -> Optional[_SchemaMediaPerson]:
"""
根据人物ID查询豆瓣人物详情(异步版本)
:param person_id: 人物ID
@@ -404,14 +403,14 @@ class DoubanChain(ChainBase):
"""
return await self.async_run_module("async_tv_hot", page=page, count=count)
async def async_movie_credits(self, doubanid: str) -> Optional[List[schemas.MediaPerson]]:
async def async_movie_credits(self, doubanid: str) -> Optional[List[_SchemaMediaPerson]]:
"""
根据TMDBID查询电影演职人员(异步版本)
:param doubanid: 豆瓣ID
"""
return await self.async_run_module("async_douban_movie_credits", doubanid=doubanid)
async def async_tv_credits(self, doubanid: str) -> Optional[List[schemas.MediaPerson]]:
async def async_tv_credits(self, doubanid: str) -> Optional[List[_SchemaMediaPerson]]:
"""
根据TMDBID查询电视剧演职人员(异步版本)
:param doubanid: 豆瓣ID
+32 -42
View File
@@ -9,7 +9,9 @@ from pathlib import Path
from typing import TYPE_CHECKING, List, Optional, Tuple, Set, Dict, Union
from urllib.parse import parse_qs, urlencode, urljoin, urlparse
from app import schemas
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.chain import ChainBase
from app.chain.media import MediaChain
from app.chain.storage import StorageChain
@@ -30,11 +32,17 @@ from app.db.oper.downloadfailure import DownloadFailureOper
from app.db.oper.downloadhistory import DownloadHistoryOper
from app.db.oper.mediaserver import MediaServerOper
from app.application.directory import DirectoryHelper, validate_download_save_path
from app.application.download.tasks import DownloadTaskService
from app.runtime.thread import ThreadHelper
from app.application.torrent import TorrentHelper
from app.runtime.log import logger
from app.schemas import ExistMediaInfo, FileURI, NotExistMediaInfo, DownloaderTorrent, Message, ResourceSelectionEventData, \
ResourceDownloadEventData
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
@@ -221,7 +229,7 @@ class DownloadChain(ChainBase):
storage_chain: StorageChain,
storage: str,
target_path: Path,
) -> Tuple[Optional[schemas.FileItem], str]:
) -> Tuple[Optional[_SchemaFileItem], str]:
"""
获取字幕保存目录,返回失败原因供前端展示。
"""
@@ -296,7 +304,7 @@ class DownloadChain(ChainBase):
@staticmethod
def _append_download_classification(
root_path: Path,
dir_info: schemas.TransferDirectoryConf,
dir_info: _SchemaTransferDirectoryConf,
media_info: MediaInfo,
) -> Path:
"""
@@ -318,7 +326,7 @@ class DownloadChain(ChainBase):
def _upload_subtitle_file(
storage_chain: StorageChain,
storage: str,
working_dir_item: schemas.FileItem,
working_dir_item: _SchemaFileItem,
subtitle_file: Path,
) -> Tuple[Optional[str], str]:
"""
@@ -2030,51 +2038,33 @@ class DownloadChain(ChainBase):
"""
查询正在下载的任务
"""
torrents = self.list_torrents(downloader=name, status=TorrentStatus.DOWNLOADING)
if not torrents:
return []
history_map = DownloadHistoryOper().get_by_hashes(
[torrent.hash for torrent in torrents if torrent.hash]
)
ret_torrents = []
for torrent in torrents:
history = history_map.get(torrent.hash)
if history:
# 媒体信息
torrent.media = {
"media_source": history.media_source,
"media_id": history.media_id,
"type": history.type,
"title": history.title,
"season": history.seasons,
"episode": history.episodes,
"image": history.poster,
"poster": history.poster,
"backdrop": history.image,
}
torrent.site_name = history.torrent_site
# 下载用户
torrent.userid = history.userid
torrent.username = history.username
ret_torrents.append(torrent)
return ret_torrents
return self._download_task_service().downloading(name)
def set_downloading(self, hash_str, oper: str, name: Optional[str] = None) -> bool:
"""
控制下载任务 start/stop
"""
if oper == "start":
return self.start_torrents(hashs=[hash_str], downloader=name)
elif oper == "stop":
return self.stop_torrents(hashs=[hash_str], downloader=name)
return False
return self._download_task_service().set_downloading(
hash_str,
oper,
name,
)
def remove_downloading(self, hash_str: str, name: Optional[str] = None) -> bool:
"""
删除下载任务
"""
return self.remove_torrents(hashs=[hash_str], downloader=name)
return self._download_task_service().remove_downloading(hash_str, name)
def _download_task_service(self) -> DownloadTaskService:
"""构造绑定当前下载器能力与历史仓储的任务服务。"""
return DownloadTaskService(
list_torrents=self.list_torrents,
get_history_by_hashes=DownloadHistoryOper().get_by_hashes,
start_torrents=self.start_torrents,
stop_torrents=self.stop_torrents,
remove_torrents=self.remove_torrents,
)
@eventmanager.register(EventType.DownloadFileDeleted)
def download_file_deleted(self, event: Event):
@@ -2088,7 +2078,7 @@ class DownloadChain(ChainBase):
return
logger.warn(f"检测到下载源文件被删除,删除下载任务(不含文件):{hash_str}")
# 先查询种子
torrents: List[schemas.DownloaderTorrent] = self.list_torrents(hashs=[hash_str])
torrents: List[_SchemaDownloaderTorrent] = self.list_torrents(hashs=[hash_str])
if torrents:
self.remove_torrents(hashs=[hash_str], delete_file=False)
# 发出下载任务删除事件,如需处理辅种,可监听该事件
+4 -1
View File
@@ -21,7 +21,10 @@ from app.domain.meta.metabase import MetaBase
from app.foundation import url as url_tools
from app.runtime.config import settings
from app.runtime.log import logger
from app.schemas import DownloadDirectory, FileURI, NotExistMediaInfo, Message
from app.schemas.download import DownloadDirectory
from app.schemas.file import FileURI
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
+23 -69
View File
@@ -1,4 +1,3 @@
import asyncio
from copy import deepcopy
from pathlib import Path
from threading import Lock
@@ -6,7 +5,7 @@ from typing import Any, Iterable, List, Optional, Tuple, Union
from fastapi.concurrency import run_in_threadpool
from app import schemas
from app.schemas.event import MediaRecognizeConvertEventData as _SchemaMediaRecognizeConvertEventData
from app.chain import ChainBase
from app.chain.acoustid import AcoustIdChain
from app.chain.douban import DoubanChain
@@ -26,13 +25,11 @@ 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.runtime.log import logger
from app.schemas import FileItem
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_RECORDING,
ChainEventType,
EventType,
MediaSource,
MediaSourceSelection,
MediaType,
@@ -92,19 +89,11 @@ class MediaChain(ChainBase, metaclass=Singleton):
media_source: Optional[MediaSourceSelection],
) -> list[MediaSource]:
"""解析有序音乐搜索来源集合,保留合法插件扩展来源并去重。"""
if not media_source:
return [cls._music_primary_source]
raw_sources = (
(media_source,)
if isinstance(media_source, MediaSource)
else media_source
)
sources: list[MediaSource] = []
for raw_source in raw_sources:
source = normalize_media_source(raw_source)
if source and source not in sources:
sources.append(source)
return sources
return MusicCatalogService(
source_resolver=cls._music_source_chain,
warning=logger.warning,
primary_source=cls._music_primary_source,
).search_sources(media_source)
@staticmethod
async def _async_search_music_source(
@@ -127,29 +116,15 @@ class MediaChain(ChainBase, metaclass=Singleton):
limit: Optional[int] = None,
) -> list[MusicInfo]:
"""标准化并按来源身份或元数据去重音乐候选。"""
results: list[MusicInfo] = []
identities: set[tuple[str, ...]] = set()
for candidate in candidates or []:
info = candidate if isinstance(candidate, MusicInfo) else MusicInfo.from_dict(candidate)
if info.media_source and info.media_id:
identity = (
"id", str(info.media_source).casefold(),
str(info.music_type).casefold(), str(info.media_id).casefold(),
)
else:
identity = (
"metadata", str(info.music_type).casefold(),
MetaMusic.compact_text(info.title),
MetaMusic.compact_text(info.artist),
MetaMusic.compact_text(info.album),
)
if identity in identities:
continue
identities.add(identity)
results.append(info)
if limit and len(results) >= limit:
break
return results
return MusicCatalogService.normalize_candidates(candidates, limit)
def _music_catalog(self) -> MusicCatalogService:
"""构造绑定当前来源解析规则的音乐目录服务。"""
return MusicCatalogService(
source_resolver=self._music_source_chain,
warning=logger.warning,
primary_source=self._music_primary_source,
)
def search_music(
self,
@@ -158,20 +133,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
media_source: Optional[MediaSourceSelection] = None,
) -> list[MusicInfo]:
"""按一个或多个音乐来源搜索候选,未指定时使用 MusicBrainz。"""
meta = MetaMusic.parse_query(query)
candidates: list[MusicInfo] = []
for source in self._music_search_sources(media_source):
chain = self._music_source_chain(source)
if not chain:
continue
try:
candidates.extend(chain.search_music(meta, limit=limit))
except Exception as err:
logger.warning(f"音乐来源 {source} 搜索失败:{str(err)}")
return self.normalize_music_candidates(
candidates,
limit=limit,
)
return self._music_catalog().search(query, limit, media_source)
async def async_search_music(
self,
@@ -180,18 +142,10 @@ class MediaChain(ChainBase, metaclass=Singleton):
media_source: Optional[MediaSourceSelection] = None,
) -> list[MusicInfo]:
"""并行搜索一个或多个音乐来源,单一来源失败不影响其它结果。"""
meta = MetaMusic.parse_query(query)
searches = []
for source in self._music_search_sources(media_source):
chain = self._music_source_chain(source)
if chain:
searches.append(
self._async_search_music_source(chain, source, meta, limit)
)
source_results = await asyncio.gather(*searches) if searches else []
return self.normalize_music_candidates(
[candidate for results in source_results for candidate in results],
limit=limit,
return await self._music_catalog().async_search(
query,
limit,
media_source,
)
@staticmethod
@@ -1540,7 +1494,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
mtype=mtype or MediaInfo.get_bangumi_media_type(source_info),
season=season if season is not None else meta.begin_season,
)
event_data = schemas.MediaRecognizeConvertEventData(
event_data = _SchemaMediaRecognizeConvertEventData(
media_source=media_source,
media_id=media_id,
target_media_source=target_source,
@@ -2086,7 +2040,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
mtype=mtype or MediaInfo.get_bangumi_media_type(source_info),
season=season if season is not None else meta.begin_season,
)
event_data = schemas.MediaRecognizeConvertEventData(
event_data = _SchemaMediaRecognizeConvertEventData(
media_source=media_source,
media_id=media_id,
target_media_source=target_source,
+4 -1
View File
@@ -7,7 +7,10 @@ from app.runtime.config import global_vars
from app.db.oper.mediaserver import MediaServerOper
from app.runtime.extensions.service_registry import ServiceConfigHelper
from app.runtime.log import logger
from app.schemas import MediaServerLibrary, MediaServerItem, MediaServerSeasonInfo, MediaServerPlayItem
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
+30 -59
View File
@@ -1,12 +1,10 @@
import asyncio
import base64
import math
import mimetypes
import re
import time
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta
from datetime import datetime
from pathlib import Path
from typing import Any, Optional, Dict, Union, List, Tuple
from urllib.parse import unquote, urlparse
@@ -18,37 +16,26 @@ from app.application.agent import (
transcribe_audio,
)
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.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 settings, global_vars
from app.domain.context import MediaInfo, Context
from app.domain.meta.metabase import MetaBase
from app.db.oper.user import UserOper
from app.application.directory import DirectoryHelper
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
from app.application.messaging.plugin import PluginInputInteractionHandler
from app.application.messaging.router import CallbackRoute, InteractionRouter, SessionRoute
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.application.torrent import TorrentHelper
from app.runtime.log import logger
from app.schemas import IncomingMessage, DownloadDirectory, FileURI, NotExistMediaInfo, Message
from app.schemas.message import IncomingMessage
from app.schemas.message import Message
from app.schemas.notification import ChannelCapabilityManager
from app.schemas.system import TransferDirectoryConf
from app.schemas.types import EventType, NotificationChannel, MediaType
from app.schemas.types import EventType, NotificationChannel
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.domain import title as title_rules
from app.foundation import url as url_tools
class MessageChain(ChainBase):
@@ -91,12 +78,15 @@ class MessageChain(ChainBase):
"""
清理超过复用窗口的用户会话映射并同步释放旧 Agent 实例
"""
timeout = timedelta(minutes=self._session_timeout_minutes)
for userid, (session_id, last_time) in list(self._user_sessions.items()):
if current_time - last_time <= timeout:
continue
self._user_sessions.pop(userid, None)
self._schedule_agent_session_clear(session_id, userid)
self._message_session_service().cleanup(current_time)
def _message_session_service(self) -> MessageSessionService:
"""用类级兼容映射构建可测试的用户会话服务。"""
return MessageSessionService(
sessions=self._user_sessions,
timeout_minutes=self._session_timeout_minutes,
expired_handler=self._schedule_agent_session_clear,
)
@dataclass
class _ProcessingStatus:
@@ -874,39 +864,21 @@ class MessageChain(ChainBase):
获取或创建会话ID
如果用户上次会话在15分钟内则复用相同的会话ID否则创建新的会话ID
"""
current_time = datetime.now()
self._cleanup_expired_user_sessions(current_time)
# 检查用户是否有已存在的会话
if userid in self._user_sessions:
session_id, last_time = self._user_sessions[userid]
# 计算时间差
time_diff = current_time - last_time
# 如果时间差小于等于xx分钟,复用会话ID
if time_diff <= timedelta(minutes=self._session_timeout_minutes):
# 更新最后使用时间
self._user_sessions[userid] = (session_id, current_time)
logger.info(
f"复用会话ID: {session_id}, 用户: {userid}, 距离上次会话: {time_diff.total_seconds() / 60:.1f}分钟"
)
return session_id
# 创建新的会话ID
new_session_id = f"user_{userid}_{int(time.time())}"
self._user_sessions[userid] = (new_session_id, current_time)
logger.info(f"创建新会话ID: {new_session_id}, 用户: {userid}")
return new_session_id
resolution = self._message_session_service().resolve(userid)
if resolution.reused:
logger.info(
f"复用会话ID: {resolution.session_id}, 用户: {userid}, "
f"距离上次会话: {resolution.inactive_minutes:.1f}分钟"
)
else:
logger.info(f"创建新会话ID: {resolution.session_id}, 用户: {userid}")
return resolution.session_id
def _bind_session_id(self, userid: Union[str, int], session_id: str) -> None:
"""
将用户会话绑定到指定的 session_id并刷新最后活动时间
"""
old_session = self._user_sessions.get(userid)
if old_session and old_session[0] != session_id:
self._schedule_agent_session_clear(old_session[0], userid)
self._user_sessions[userid] = (session_id, datetime.now())
self._message_session_service().bind(userid, session_id)
def bind_user_session(self, userid: Union[str, int], session_id: str) -> None:
"""
@@ -951,8 +923,8 @@ class MessageChain(ChainBase):
清除指定用户的会话信息
返回是否成功清除
"""
if userid in self._user_sessions:
session_id, _ = self._user_sessions.pop(userid)
session_id = self._message_session_service().clear(userid)
if session_id:
logger.info(f"已清除用户 {userid} 的会话: {session_id}")
return True
return False
@@ -967,9 +939,8 @@ class MessageChain(ChainBase):
清除用户会话远程命令接口
"""
# 获取并清除会话信息
session_id = None
if userid in self._user_sessions:
session_id, _ = self._user_sessions.pop(userid)
session_id = self._message_session_service().clear(userid)
if session_id:
logger.info(f"已清除用户 {userid} 的会话: {session_id}")
# 如果有会话ID,同时清除智能体的会话记忆
@@ -1022,7 +993,7 @@ class MessageChain(ChainBase):
停止后用户仍可继续对话
"""
# 查找用户的会话ID(不弹出,保留会话)
session_info = self._user_sessions.get(userid)
session_info = self._message_session_service().get(userid)
if session_info:
session_id, _ = session_info
manager = get_running_agent_manager()
@@ -1182,7 +1153,7 @@ class MessageChain(ChainBase):
source: Optional[str] = None,
):
"""查询当前用户的智能体会话状态。"""
session_info = self._user_sessions.get(userid)
session_info = self._message_session_service().get(userid)
if not session_info:
self.post_message(
Message(
+2 -2
View File
@@ -1,6 +1,6 @@
from typing import Callable, List, Optional
import pillow_avif # noqa 用于自动注册AVIF支持
import pillow_avif # noqa: F401 # pylint: disable=unused-import # AVIF 注册副作用
from app.chain import ChainBase
from app.chain.bangumi import BangumiChain
@@ -12,11 +12,11 @@ from app.runtime.config import settings, global_vars
from app.domain.context import MusicInfo
from app.application.image import ImageHelper
from app.runtime.log import logger
from app.schemas import MediaType
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
+49 -54
View File
@@ -7,16 +7,13 @@ from tempfile import NamedTemporaryFile, TemporaryDirectory
from threading import Lock
from typing import Any, Iterable, List, Optional, Tuple, Union
from fastapi.concurrency import run_in_threadpool
from app import schemas
from app.schemas.workflow import FileItem as _SchemaFileItem
from app.chain import ChainBase
from app.chain.lrclib import LrclibChain
from app.chain.storage import StorageChain
from app.runtime.cache import async_fresh, cached, fresh
from app.runtime.cache import cached
from app.runtime.config import settings
from app.domain.context import (
Context,
MediaInfo,
MusicAlbumInfo,
MusicInfo,
@@ -29,11 +26,10 @@ from app.domain.metainfo import MetaInfo, MetaInfoPath
from app.db.oper.systemconfig import SystemConfigOper
from app.application.audio import AudioMetadataHelper
from app.runtime.log import logger
from app.schemas import FileItem
from app.schemas.workflow import FileItem
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_RECORDING,
ChainEventType,
EventType,
MediaSource,
MediaType,
@@ -43,8 +39,7 @@ from app.schemas.types import (
SystemConfigKey,
)
from app.adapters.network.http import RequestUtils
from app.domain.media import is_music_media_source
from app.schemas.media import normalize_media_source, resolve_media_identity
from app.schemas.media import resolve_media_identity
from app.runtime.reload import ConfigReloadMixin
from app.foundation.singleton import Singleton
@@ -267,7 +262,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
return False
def _save_file(
self, fileitem: schemas.FileItem, path: Path, content: Union[bytes, str]
self, fileitem: _SchemaFileItem, path: Path, content: Union[bytes, str]
):
"""
保存或上传文件
@@ -305,7 +300,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
self._cleanup_temp_file(tmp_file_path)
def _download_and_save_image(
self, fileitem: schemas.FileItem, path: Path, url: str
self, fileitem: _SchemaFileItem, path: Path, url: str
):
"""
流式下载图片并保存到文件
@@ -354,12 +349,12 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def _get_target_fileitem_and_path(
self,
current_fileitem: schemas.FileItem,
current_fileitem: _SchemaFileItem,
item_type: ScrapingTarget,
metadata_type: ScrapingMetadata,
filename_hint: Optional[str] = None,
parent_fileitem: Optional[schemas.FileItem] = None,
) -> Tuple[schemas.FileItem, Optional[Path]]:
parent_fileitem: Optional[_SchemaFileItem] = None,
) -> Tuple[_SchemaFileItem, Optional[Path]]:
"""
根据当前上下文刮削项类型和元数据类型生成目标 FileItem Path
处理 NFO 和图片文件的命名约定及存储位置
@@ -460,12 +455,12 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def _get_target_fileitems_and_paths(
self,
current_fileitem: schemas.FileItem,
current_fileitem: _SchemaFileItem,
item_type: ScrapingTarget,
metadata_type: ScrapingMetadata,
filename_hint: Optional[str] = None,
parent_fileitem: Optional[schemas.FileItem] = None,
) -> List[Tuple[schemas.FileItem, Path]]:
parent_fileitem: Optional[_SchemaFileItem] = None,
) -> List[Tuple[_SchemaFileItem, Path]]:
"""
根据刮削上下文生成一个或多个保存目标
季图片需要同时兼容根目录 seasonxx-poster 和季目录 poster 两种命名
@@ -508,9 +503,9 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def _expand_with_aliases(
self,
targets: List[Tuple[schemas.FileItem, Path]],
targets: List[Tuple[_SchemaFileItem, Path]],
item_type: ScrapingTarget,
) -> List[Tuple[schemas.FileItem, Path]]:
) -> List[Tuple[_SchemaFileItem, Path]]:
"""
为兼容多媒体服务器扩展图片保存目标列表添加别名文件
例如 backdrop.jpg 同时保存为 fanart.jpgthumb.jpg 同时保存为 landscape.jpg
@@ -738,11 +733,11 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def _scrape_nfo_generic(
self,
current_fileitem: schemas.FileItem,
current_fileitem: _SchemaFileItem,
meta: MetaBase,
mediainfo: MediaInfo,
item_type: ScrapingTarget,
parent_fileitem: Optional[schemas.FileItem] = None,
parent_fileitem: Optional[_SchemaFileItem] = None,
overwrite: bool = False,
season_number: Optional[int] = None,
episode_number: Optional[int] = None,
@@ -792,10 +787,10 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def _scrape_images_generic(
self,
current_fileitem: schemas.FileItem,
current_fileitem: _SchemaFileItem,
mediainfo: MediaInfo,
item_type: ScrapingTarget,
parent_fileitem: Optional[schemas.FileItem] = None,
parent_fileitem: Optional[_SchemaFileItem] = None,
overwrite: bool = False,
season_number: Optional[int] = None,
episode_number: Optional[int] = None,
@@ -893,14 +888,14 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def scrape_metadata(
self,
fileitem: schemas.FileItem,
fileitem: _SchemaFileItem,
meta: MetaBase = None,
mediainfo: Union[MediaInfo, MusicInfo] = None,
init_folder: bool = True,
parent: schemas.FileItem = None,
parent: _SchemaFileItem = None,
overwrite: bool = False,
recursive: bool = True,
audio_files: Optional[list[schemas.FileItem]] = None,
audio_files: Optional[list[_SchemaFileItem]] = None,
media_by_path: Optional[dict[str, MusicInfo]] = None,
) -> tuple[bool, str]:
"""
@@ -992,11 +987,11 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def scrape_music_metadata(
self,
fileitem: schemas.FileItem,
fileitem: _SchemaFileItem,
mediainfo: Optional[MusicInfo] = None,
overwrite: bool = True,
media_source: Optional[MediaSource] = None,
audio_files: Optional[list[schemas.FileItem]] = None,
audio_files: Optional[list[_SchemaFileItem]] = None,
media_by_path: Optional[dict[str, MusicInfo]] = None,
) -> tuple[bool, str]:
"""为音频文件或目录写入音乐标签和封面,应用系统刮削策略,复用现有存储下载上传能力。
@@ -1157,7 +1152,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
"""判断路径是否指向系统支持的音频文件。"""
return Path(path).suffix.lower() in settings.RMT_AUDIOEXT
def _music_audio_fileitems(self, fileitem: schemas.FileItem) -> list[schemas.FileItem]:
def _music_audio_fileitems(self, fileitem: _SchemaFileItem) -> list[_SchemaFileItem]:
"""展开待刮削目录并过滤系统支持的音频文件。"""
if fileitem.type != "dir":
return [fileitem] if self._is_music_audio_file(fileitem.path or "") else []
@@ -1170,10 +1165,10 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
@classmethod
def _normalize_music_audio_fileitems(
cls,
fileitems: Iterable[schemas.FileItem],
) -> list[schemas.FileItem]:
fileitems: Iterable[_SchemaFileItem],
) -> list[_SchemaFileItem]:
"""过滤并按存储路径去重已选音频文件,保持调用方给出的顺序。"""
normalized: list[schemas.FileItem] = []
normalized: list[_SchemaFileItem] = []
seen: set[tuple[str, str]] = set()
for item in fileitems or []:
if (
@@ -1191,12 +1186,12 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def _music_event_audio_fileitems(
self,
root: schemas.FileItem,
root: _SchemaFileItem,
file_list: Iterable[str],
) -> list[schemas.FileItem]:
) -> list[_SchemaFileItem]:
"""把刮削事件中的成功路径恢复为文件项,并限制在事件媒体根目录内。"""
root_path = Path(root.path)
selected: list[schemas.FileItem] = []
selected: list[_SchemaFileItem] = []
for raw_path in file_list or []:
audio_path = Path(raw_path)
if not self._is_music_audio_file(audio_path.as_posix()):
@@ -1208,7 +1203,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
storage=root.storage,
path=audio_path,
)
selected.append(item or schemas.FileItem(
selected.append(item or _SchemaFileItem(
storage=root.storage,
path=audio_path.as_posix(),
type="file",
@@ -1220,7 +1215,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def _scrape_music_file(
self,
fileitem: schemas.FileItem,
fileitem: _SchemaFileItem,
mediainfo: Optional[MusicInfo],
write_tags: bool,
tag_overwrite: bool,
@@ -1285,7 +1280,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def _apply_music_file_scrape(
self,
fileitem: schemas.FileItem,
fileitem: _SchemaFileItem,
local_path: Path,
mediainfo: Optional[MusicInfo],
write_tags: bool,
@@ -1479,7 +1474,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def _scrape_music_lyrics(
self,
fileitem: schemas.FileItem,
fileitem: _SchemaFileItem,
local_path: Path,
scrape_info: Optional[MetaMusic | MusicInfo],
lyrics_option: Optional[ScrapingOption],
@@ -1516,8 +1511,8 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def _find_music_lyrics_sidecar(
self,
fileitem: schemas.FileItem,
) -> Optional[schemas.FileItem]:
fileitem: _SchemaFileItem,
) -> Optional[_SchemaFileItem]:
"""查找音轨旁已存在的同步或纯文本歌词文件。"""
audio_path = Path(fileitem.path)
for extension in self.MUSIC_LYRICS_EXTENSIONS:
@@ -1531,7 +1526,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def _write_music_lyrics_sidecar(
self,
fileitem: schemas.FileItem,
fileitem: _SchemaFileItem,
local_path: Path,
lyrics: MusicLyrics,
overwrite: bool,
@@ -1582,7 +1577,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def _remove_alternate_music_lyrics(
self,
fileitem: schemas.FileItem,
fileitem: _SchemaFileItem,
keep_extension: str,
) -> None:
"""覆盖歌词格式后删除同音轨的旧扩展名文件,避免播放器优先读取过期内容。"""
@@ -1599,11 +1594,11 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def _handle_movie_scraping(
self,
fileitem: schemas.FileItem,
fileitem: _SchemaFileItem,
meta: MetaBase,
mediainfo: MediaInfo,
init_folder: bool,
parent: schemas.FileItem,
parent: _SchemaFileItem,
overwrite: bool,
recursive: bool,
):
@@ -1641,7 +1636,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def _handle_movie_directory(
self,
fileitem: schemas.FileItem,
fileitem: _SchemaFileItem,
meta: MetaBase,
mediainfo: MediaInfo,
init_folder: bool,
@@ -1688,11 +1683,11 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def _handle_tv_scraping(
self,
fileitem: schemas.FileItem,
fileitem: _SchemaFileItem,
meta: MetaBase,
mediainfo: MediaInfo,
init_folder: bool,
parent: schemas.FileItem,
parent: _SchemaFileItem,
overwrite: bool,
recursive: bool,
):
@@ -1725,10 +1720,10 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def _handle_tv_episode_file(
self,
fileitem: schemas.FileItem,
fileitem: _SchemaFileItem,
filepath: Path,
mediainfo: MediaInfo,
parent: schemas.FileItem,
parent: _SchemaFileItem,
overwrite: bool,
):
"""
@@ -1779,12 +1774,12 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def _handle_tv_directory(
self,
fileitem: schemas.FileItem,
fileitem: _SchemaFileItem,
filepath: Path,
meta: MetaBase,
mediainfo: MediaInfo,
init_folder: bool,
parent: schemas.FileItem,
parent: _SchemaFileItem,
overwrite: bool,
recursive: bool,
):
@@ -1823,11 +1818,11 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def _initialize_tv_directory_metadata(
self,
fileitem: schemas.FileItem,
fileitem: _SchemaFileItem,
filepath: Path,
meta: MetaBase,
mediainfo: MediaInfo,
parent: schemas.FileItem,
parent: _SchemaFileItem,
overwrite: bool,
):
"""
+51 -72
View File
@@ -24,9 +24,14 @@ from app.domain.context import MusicInfo
from app.db.oper.systemconfig import SystemConfigOper
from app.runtime.progress import ProgressHelper
from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module
from app.application.search.state import (
SearchStateService,
normalize_search_params,
stringify_sites,
)
from app.application.torrent import TorrentHelper
from app.runtime.log import logger
from app.schemas import NotExistMediaInfo
from app.schemas.mediaserver import NotExistMediaInfo
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
EventType,
@@ -35,7 +40,7 @@ from app.schemas.types import (
ProgressKey,
SystemConfigKey,
)
from app.schemas.media import build_media_key, parse_media_key, resolve_media_identity
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
@@ -291,7 +296,7 @@ class SearchChain(ChainBase):
"""
将站点ID列表转换为前端可直接复用的查询字符串
"""
return ",".join(str(site) for site in sites) if sites else ""
return stringify_sites(sites)
@staticmethod
def _normalize_search_params(params: Optional[Dict[str, Any]]) -> Optional[Dict[str, str]]:
@@ -299,35 +304,19 @@ class SearchChain(ChainBase):
规范化上次搜索参数供前端结果页重新搜索使用旧复合关键字仅在
缓存读取边界转换为独立的媒体来源和原生 ID
"""
if not isinstance(params, dict):
return None
return normalize_search_params(params)
media_source, media_id = resolve_media_identity(
media_source=params.get("media_source"),
media_id=params.get("media_id"),
def _search_state(self) -> SearchStateService:
"""构造绑定当前 Chain 缓存端口的搜索状态服务。"""
return SearchStateService(
save_cache=self.save_cache,
load_cache=self.load_cache,
async_save_cache=self.async_save_cache,
async_load_cache=self.async_load_cache,
params_key=self.__search_params_temp_file,
result_key=self.__result_temp_file,
subtitle_result_key=self.__subtitle_result_temp_file,
)
keyword = str(params.get("keyword") or "")
if not media_source and keyword:
media_source, media_id = parse_media_key(keyword)
if media_source and media_id:
keyword = ""
normalized = {
"keyword": keyword,
"media_source": str(media_source) if media_source else "",
"media_id": media_id or "",
"type": str(params.get("type") or ""),
"area": str(params.get("area") or ""),
"title": str(params.get("title") or ""),
"year": str(params.get("year") or ""),
"season": str(params["season"]) if params.get("season") is not None else "",
"episode": str(params.get("episode") or ""),
"sites": str(params.get("sites") or ""),
"result_type": str(params.get("result_type") or "torrent"),
}
if params.get("music_type"):
normalized["music_type"] = str(params["music_type"])
return normalized if normalized["keyword"] or media_id else None
def save_last_search_params(
self,
@@ -348,24 +337,20 @@ class SearchChain(ChainBase):
"""
保存最后一次资源搜索参数标题搜索与精确身份搜索使用互斥字段
"""
params = self._normalize_search_params(
{
"keyword": keyword,
"media_source": media_source,
"media_id": media_id,
"type": mtype.value if isinstance(mtype, MediaType) else mtype,
"area": area,
"title": title,
"year": year,
"season": season,
"episode": episode,
"sites": self._stringify_sites(sites),
"music_type": music_type,
"result_type": result_type or "torrent",
}
self._search_state().save_params(
keyword=keyword,
media_source=media_source,
media_id=media_id,
mtype=mtype,
area=area,
title=title,
year=year,
season=season,
episode=episode,
sites=sites,
music_type=music_type,
result_type=result_type,
)
if params:
self.save_cache(params, self.__search_params_temp_file)
async def async_save_last_search_params(
self,
@@ -386,38 +371,32 @@ class SearchChain(ChainBase):
"""
异步保存最后一次资源搜索参数标题搜索与精确身份搜索使用互斥字段
"""
params = self._normalize_search_params(
{
"keyword": keyword,
"media_source": media_source,
"media_id": media_id,
"type": mtype.value if isinstance(mtype, MediaType) else mtype,
"area": area,
"title": title,
"year": year,
"season": season,
"episode": episode,
"sites": self._stringify_sites(sites),
"music_type": music_type,
"result_type": result_type or "torrent",
}
await self._search_state().async_save_params(
keyword=keyword,
media_source=media_source,
media_id=media_id,
mtype=mtype,
area=area,
title=title,
year=year,
season=season,
episode=episode,
sites=sites,
music_type=music_type,
result_type=result_type,
)
if params:
await self.async_save_cache(params, self.__search_params_temp_file)
def last_search_params(self) -> Optional[Dict[str, str]]:
"""
获取上次搜索使用的参数
"""
return self._normalize_search_params(self.load_cache(self.__search_params_temp_file))
return self._search_state().load_params()
async def async_last_search_params(self) -> Optional[Dict[str, str]]:
"""
异步获取上次搜索使用的参数
"""
return self._normalize_search_params(
await self.async_load_cache(self.__search_params_temp_file)
)
return await self._search_state().async_load_params()
@staticmethod
def _normalize_ai_indices(ai_indices: List[Any]) -> List[int]:
@@ -510,7 +489,7 @@ class SearchChain(ChainBase):
通过统一后台提示词机制执行资源推荐
"""
from app.application.agent import get_prompt_manager, get_running_agent_manager
from app.schemas.agent import ReplyMode
from app.schemas.types import ReplyMode
prompt = get_prompt_manager().render_system_task_message(
"search_recommend",
@@ -733,19 +712,19 @@ class SearchChain(ChainBase):
"""
获取上次搜索结果
"""
return self.load_cache(self.__result_temp_file)
return self._search_state().load_results()
async def async_last_search_results(self) -> Optional[List[Context]]:
"""
异步获取上次搜索结果
"""
return await self.async_load_cache(self.__result_temp_file)
return await self._search_state().async_load_results()
async def async_last_subtitle_search_results(self) -> Optional[List[SubtitleInfo]]:
"""
异步获取上次字幕搜索结果
"""
return await self.async_load_cache(self.__subtitle_result_temp_file)
return await self._search_state().async_load_subtitle_results()
async def async_search_subtitles_by_title(self, title: str, page: Optional[int] = 0,
sites: List[int] = None,
+3 -1
View File
@@ -21,7 +21,9 @@ from app.adapters.external.cookiecloud import CookieCloudHelper
from app.application.messaging.site import SiteInteractionHandler
from app.application.rss import RssHelper
from app.runtime.log import logger
from app.schemas import NotificationChannel, Message, SiteUserData
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
+17 -17
View File
@@ -1,7 +1,7 @@
from pathlib import Path
from typing import Any, Optional, List, Dict
from app import schemas
from app.schemas.workflow import FileItem as _SchemaFileItem
from app.chain import ChainBase
from app.runtime.config import settings
from app.application.directory import DirectoryHelper
@@ -28,31 +28,31 @@ class StorageChain(ChainBase):
result = self.run_module("storage_manage", storage=storage, action=action, **params)
return result or {"success": False, "message": "该存储类型未启用或不支持此管理动作"}
def list_files(self, fileitem: schemas.FileItem, recursion: bool = False) -> Optional[List[schemas.FileItem]]:
def list_files(self, fileitem: _SchemaFileItem, recursion: bool = False) -> Optional[List[_SchemaFileItem]]:
"""
查询当前目录下所有目录和文件
"""
return self.run_module("list_files", fileitem=fileitem, recursion=recursion)
def any_files(self, fileitem: schemas.FileItem, extensions: list = None) -> Optional[bool]:
def any_files(self, fileitem: _SchemaFileItem, extensions: list = None) -> Optional[bool]:
"""
查询当前目录下是否存在指定扩展名任意文件
"""
return self.run_module("any_files", fileitem=fileitem, extensions=extensions)
def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]:
def create_folder(self, fileitem: _SchemaFileItem, name: str) -> Optional[_SchemaFileItem]:
"""
创建目录
"""
return self.run_module("create_folder", fileitem=fileitem, name=name)
def get_folder(self, storage: str, path: Path) -> Optional[schemas.FileItem]:
def get_folder(self, storage: str, path: Path) -> Optional[_SchemaFileItem]:
"""
获取目录不存在则递归创建
"""
return self.run_module("get_folder", storage=storage, path=path)
def download_file(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]:
def download_file(self, fileitem: _SchemaFileItem, path: Path = None) -> Optional[Path]:
"""
下载文件
:param fileitem: 文件项
@@ -60,8 +60,8 @@ class StorageChain(ChainBase):
"""
return self.run_module("download_file", fileitem=fileitem, path=path)
def upload_file(self, fileitem: schemas.FileItem, path: Path,
new_name: Optional[str] = None) -> Optional[schemas.FileItem]:
def upload_file(self, fileitem: _SchemaFileItem, path: Path,
new_name: Optional[str] = None) -> Optional[_SchemaFileItem]:
"""
上传文件
:param fileitem: 保存目录项
@@ -70,37 +70,37 @@ class StorageChain(ChainBase):
"""
return self.run_module("upload_file", fileitem=fileitem, path=path, new_name=new_name)
def delete_file(self, fileitem: schemas.FileItem) -> Optional[bool]:
def delete_file(self, fileitem: _SchemaFileItem) -> Optional[bool]:
"""
删除文件或目录
"""
return self.run_module("delete_file", fileitem=fileitem)
def rename_file(self, fileitem: schemas.FileItem, name: str) -> Optional[bool]:
def rename_file(self, fileitem: _SchemaFileItem, name: str) -> Optional[bool]:
"""
重命名文件或目录
"""
return self.run_module("rename_file", fileitem=fileitem, name=name)
def exists(self, fileitem: schemas.FileItem) -> Optional[bool]:
def exists(self, fileitem: _SchemaFileItem) -> Optional[bool]:
"""
判断文件或目录是否存在
"""
return True if self.get_item(fileitem) else False
def get_item(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
def get_item(self, fileitem: _SchemaFileItem) -> Optional[_SchemaFileItem]:
"""
查询目录或文件
"""
return self.get_file_item(storage=fileitem.storage, path=Path(fileitem.path))
def get_file_item(self, storage: str, path: Path) -> Optional[schemas.FileItem]:
def get_file_item(self, storage: str, path: Path) -> Optional[_SchemaFileItem]:
"""
根据路径获取文件项
"""
return self.run_module("get_file_item", storage=storage, path=path)
def get_parent_item(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
def get_parent_item(self, fileitem: _SchemaFileItem) -> Optional[_SchemaFileItem]:
"""
获取上级目录项
"""
@@ -121,7 +121,7 @@ class StorageChain(ChainBase):
last_snapshot_time=last_snapshot_time, max_depth=max_depth,
previous_snapshot=previous_snapshot)
def is_bluray_folder(self, fileitem: Optional[schemas.FileItem]) -> bool:
def is_bluray_folder(self, fileitem: Optional[_SchemaFileItem]) -> bool:
"""
检查是否蓝光目录
"""
@@ -134,7 +134,7 @@ class StorageChain(ChainBase):
return False
@staticmethod
def contains_bluray_subdirectories(fileitems: Optional[List[schemas.FileItem]]) -> bool:
def contains_bluray_subdirectories(fileitems: Optional[List[_SchemaFileItem]]) -> bool:
"""
判断是否包含蓝光必备的文件夹
"""
@@ -144,7 +144,7 @@ class StorageChain(ChainBase):
for item in fileitems or []
)
def delete_media_file(self, fileitem: schemas.FileItem, delete_self: bool = True) -> bool:
def delete_media_file(self, fileitem: _SchemaFileItem, delete_self: bool = True) -> bool:
"""
删除媒体文件以及不含媒体文件的目录
"""
+68 -95
View File
@@ -6,7 +6,13 @@ import time
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional, Union, Tuple
from app import schemas
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
@@ -35,37 +41,25 @@ from app.db.oper.systemconfig import SystemConfigOper
from app.application.messaging.subscribe import SubscribeInteractionHandler
from app.application.mediaserver import MediaServerHelper
from app.application.subscribe import add_subscribe, async_add_subscribe
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.torrent import TorrentHelper
from app.runtime.log import logger
from app.schemas import (SubscribeEpisodesRefreshEventData,
SubscribeCompletionCheckEventData)
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.schemas.media import build_media_key, normalize_media_source, resolve_media_identity
from app.schemas.media import normalize_media_source, resolve_media_identity
def build_subscribe_meta(subscribe: Subscribe) -> MetaBase:
"""
按订阅对象构造主程序链路共用的媒体元数据
"""
if subscribe.type == MediaType.MUSIC.value:
is_album = getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM
return MetaMusic(
title=subscribe.name,
album=subscribe.name if is_album else None,
year=subscribe.year,
total_tracks=getattr(subscribe, "total_tracks", None) if is_album else None,
media_source=subscribe.media_source,
media_id=str(subscribe.media_id) if subscribe.media_id is not None else None,
)
meta = MetaInfo(subscribe.name)
meta.year = subscribe.year
meta.begin_season = subscribe.season
meta.type = MediaType(subscribe.type)
meta.media_source = subscribe.media_source
meta.media_id = subscribe.media_id
return meta
"""兼容旧导入路径,转发订阅媒体元数据构造。"""
return _build_subscribe_meta(subscribe)
def _media_recognize_kwargs(mediainfo: MediaInfo) -> dict:
@@ -87,19 +81,13 @@ def _subscribe_recognize_kwargs(subscribe: Subscribe) -> dict:
def _subscribe_media_key(subscribe: Subscribe) -> Union[str, int, None]:
"""返回订阅缺失集映射使用的稳定媒体键。"""
media_source, media_id = resolve_media_identity(media=subscribe)
return build_media_key(media_source, media_id) or media_id
"""兼容旧导入路径,返回订阅缺失集使用的稳定媒体键。"""
return subscribe_media_key(subscribe)
def _subscribe_media_keys(subscribe: Subscribe) -> List[Union[str, int]]:
"""返回缺失集缓存可识别的规范媒体键。"""
media_source, media_id = resolve_media_identity(media=subscribe)
candidates = [
build_media_key(media_source, media_id),
media_id,
]
return [candidate for candidate in candidates if candidate not in (None, "")]
"""兼容旧导入路径,返回规范媒体键与旧纯 ID 键。"""
return subscribe_media_keys(subscribe)
class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
@@ -258,7 +246,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
def compute_lack_episode(
cls,
subscribe: Subscribe,
no_exists: Optional[Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]]] = None,
no_exists: Optional[Dict[Union[int, str], Dict[int, _SchemaNotExistMediaInfo]]] = None,
) -> int:
"""
计算订阅范围内尚未下载到任何版本的集数
@@ -681,7 +669,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
cls,
subscribe: Subscribe,
mediakey: Union[int, str],
) -> Optional[Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]]]:
) -> Optional[Dict[Union[int, str], Dict[int, _SchemaNotExistMediaInfo]]]:
"""
构造分集洗版优先全集时使用的整季缺失范围
"""
@@ -698,7 +686,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
return {
mediakey: {
subscribe.season: schemas.NotExistMediaInfo(
subscribe.season: _SchemaNotExistMediaInfo(
season=subscribe.season,
episodes=[],
total_episode=subscribe.total_episode,
@@ -711,14 +699,14 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
def __download_best_version_with_full_pack_first(
self,
contexts: List[Context],
no_exists: Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]],
no_exists: Dict[Union[int, str], Dict[int, _SchemaNotExistMediaInfo]],
subscribe: Subscribe,
mediakey: Union[int, str],
username: Optional[str] = None,
save_path: Optional[str] = None,
downloader: Optional[str] = None,
source: Optional[str] = None,
) -> Tuple[List[Context], Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]]]:
) -> Tuple[List[Context], Dict[Union[int, str], Dict[int, _SchemaNotExistMediaInfo]]]:
"""
TV 分集洗版先尝试覆盖目标范围的全集资源失败后回退到按集下载
"""
@@ -972,7 +960,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
logger.error(f'{mediainfo.title_year} {err_msg}')
if not exist_ok and message:
# 失败发回原用户
self.post_message(schemas.Message(channel=channel,
self.post_message(_SchemaMessage(channel=channel,
source=source,
mtype=MessageType.Subscribe,
title=f"{mediainfo.title_year} {metainfo.season} "
@@ -990,7 +978,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
# 订阅成功按规则发送消息
self.post_message(
schemas.Message(
_SchemaMessage(
channel=channel,
source=source,
mtype=MessageType.Subscribe,
@@ -1176,7 +1164,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
logger.error(f'{mediainfo.title_year} {err_msg}')
if not exist_ok and message:
# 失败发回原用户
await self.async_post_message(schemas.Message(channel=channel,
await self.async_post_message(_SchemaMessage(channel=channel,
source=source,
mtype=MessageType.Subscribe,
title=f"{mediainfo.title_year} {metainfo.season} "
@@ -1194,7 +1182,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
# 订阅成功按规则发送消息
await self.async_post_message(
schemas.Message(
_SchemaMessage(
channel=channel,
source=source,
mtype=MessageType.Subscribe,
@@ -1234,21 +1222,16 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
return sid, err_msg
@staticmethod
def exists(mediainfo: MediaInfo, meta: MetaBase = None):
def _subscription_query() -> SubscriptionQueryService:
"""构造绑定订阅 Oper 的查询应用服务。"""
return SubscriptionQueryService(SubscribeOper())
@classmethod
def exists(cls, mediainfo: MediaInfo, meta: MetaBase = None):
"""
判断订阅是否已存在
"""
media_source, media_id = resolve_media_identity(media=mediainfo)
if SubscribeOper().exists(
media_source=media_source,
media_id=media_id,
music_type=getattr(mediainfo, "music_type", None)
if mediainfo.type == MediaType.MUSIC else None,
season=meta.begin_season if meta else None,
episode_group=mediainfo.episode_group,
):
return True
return False
return cls._subscription_query().exists(mediainfo, meta)
def search(
self,
@@ -1529,7 +1512,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
def finish_subscribe_or_not(self, subscribe: Subscribe, meta: MetaBase, mediainfo: MediaInfo,
downloads: List[Context] = None,
lefts: Dict[Union[int | str], Dict[int, schemas.NotExistMediaInfo]] = None,
lefts: Dict[Union[int | str], Dict[int, _SchemaNotExistMediaInfo]] = None,
force: Optional[bool] = False):
"""
判断是否应完成订阅
@@ -1685,9 +1668,8 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
def has_music_subscribe(self) -> bool:
"""判断是否存在可搜索状态的音乐订阅,用于决定是否额外刷新站点音乐入口。"""
return any(
subscribe.type == MediaType.MUSIC.value
for subscribe in SubscribeOper().list(self.get_states_for_search('R')) or []
return self._subscription_query().has_music(
self.get_states_for_search('R')
)
def match(
@@ -2238,18 +2220,9 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
"""
从来源获取订阅
"""
source_keyword = self.parse_subscribe_source_keyword(source)
if not source_keyword:
return None
# 只保留需要的字段动态获取订阅
valid_fields = {
k: v for k, v in source_keyword.items()
if k in [
"type", "season", "media_source", "media_id", "music_type",
]
}
# 暂时不考虑订阅历史, 若有必要再添加
return SubscribeOper().get_by(**valid_fields)
return self._subscription_query().get_by_source(
self.parse_subscribe_source_keyword(source)
)
@staticmethod
def follow(progress_callback: Optional[Callable[..., None]] = None) -> None:
@@ -2301,10 +2274,10 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
continue
# 去除无效属性
for key in list(share_sub.keys()):
if not hasattr(schemas.Subscribe(), key):
if not hasattr(_SchemaSubscribe(), key):
share_sub.pop(key)
# 类型转换
subscribe_in = schemas.Subscribe(**share_sub)
subscribe_in = _SchemaSubscribe(**share_sub)
mtype = MediaType(subscribe_in.type)
# 非 TMDB 标题可能携带季号,入库前统一拆分。
if (
@@ -2507,7 +2480,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
def __prepare_subscribe_progress_fields(
cls,
subscribe: Subscribe,
no_exists: Optional[Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]]] = None,
no_exists: Optional[Dict[Union[int, str], Dict[int, _SchemaNotExistMediaInfo]]] = None,
touch_last_update: Optional[bool] = False,
) -> Dict[str, Any]:
"""
@@ -2541,7 +2514,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
def __refresh_subscribe_progress_with_no_exists(
self,
subscribe: Subscribe,
no_exists: Optional[Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]]] = None,
no_exists: Optional[Dict[Union[int, str], Dict[int, _SchemaNotExistMediaInfo]]] = None,
touch_last_update: Optional[bool] = False,
scene: str = "download",
) -> Dict[str, Any]:
@@ -2835,7 +2808,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
# 完成订阅按规则发送消息
self.post_message(
schemas.Message(
_SchemaMessage(
mtype=MessageType.Subscribe,
ctype=ContentType.SubscribeComplete,
image=mediainfo.get_message_image(),
@@ -2870,7 +2843,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
删除订阅
"""
if not arg_str:
self.post_message(schemas.Message(
self.post_message(_SchemaMessage(
channel=channel,
source=source,
title="请输入正确的命令格式:/subscribe_delete [id]"
@@ -2887,7 +2860,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
subscribe_id = int(arg_str)
subscribe = subscribeoper.get(subscribe_id)
if not subscribe:
self.post_message(schemas.Message(
self.post_message(_SchemaMessage(
channel=channel, source=source,
title=f"订阅编号 {subscribe_id} 不存在!",
userid=userid,
@@ -2906,13 +2879,13 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
@staticmethod
def __get_subscribe_no_exits(subscribe_name: str,
no_exists: Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]],
no_exists: Dict[Union[int, str], Dict[int, _SchemaNotExistMediaInfo]],
mediakey: Union[str, int],
begin_season: int,
total_episode: Optional[int],
start_episode: Optional[int],
downloaded_episodes: List[int] = None
) -> Tuple[bool, Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]]]:
) -> Tuple[bool, Dict[Union[int, str], Dict[int, _SchemaNotExistMediaInfo]]]:
"""
根据订阅开始集数和总集数结合TMDB信息计算当前订阅的缺失集数
:param subscribe_name: 订阅名称
@@ -2972,7 +2945,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
if not episodes:
return True, {}
# 更新集合
no_exists[mediakey][begin_season] = schemas.NotExistMediaInfo(
no_exists[mediakey][begin_season] = _SchemaNotExistMediaInfo(
season=begin_season,
episodes=episodes,
total_episode=total_episode,
@@ -3000,7 +2973,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
if not episodes:
return True, {}
# 更新集合
no_exists[mediakey][begin_season] = schemas.NotExistMediaInfo(
no_exists[mediakey][begin_season] = _SchemaNotExistMediaInfo(
season=begin_season,
episodes=episodes,
total_episode=total,
@@ -3015,7 +2988,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
# 如果存在已下载剧集,则差集为空时,说明所有均已存在
if not episodes:
return True, {}
no_exists[mediakey][begin_season] = schemas.NotExistMediaInfo(
no_exists[mediakey][begin_season] = _SchemaNotExistMediaInfo(
season=begin_season,
episodes=episodes,
total_episode=total_episode,
@@ -3115,7 +3088,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
"min_seeders_time": default_rule.get("min_seeders_time"),
}.items() if value is not None}
def subscribe_files_info(self, subscribe: Subscribe) -> Optional[schemas.SubscrbieInfo]:
def subscribe_files_info(self, subscribe: Subscribe) -> Optional[_SchemaSubscrbieInfo]:
"""
订阅相关的下载和文件信息
"""
@@ -3123,10 +3096,10 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
return None
# 返回订阅数据
subscribe_info = schemas.SubscrbieInfo()
subscribe_info = _SchemaSubscrbieInfo()
# 所有集的数据
episodes: Dict[int, schemas.SubscribeEpisodeInfo] = {}
episodes: Dict[int, _SchemaSubscribeEpisodeInfo] = {}
if (
subscribe.media_source == MediaSource.TMDB.value
and subscribe.media_id
@@ -3141,7 +3114,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
)
if tmdb_episodes:
for episode in tmdb_episodes:
info = schemas.SubscribeEpisodeInfo()
info = _SchemaSubscribeEpisodeInfo()
info.title = episode.name
info.description = episode.overview
info.backdrop = settings.TMDB_IMAGE_URL(episode.still_path, "w500")
@@ -3149,12 +3122,12 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
elif subscribe.type == MediaType.TV.value:
# 根据开始结束集计算集信息
for i in range(subscribe.start_episode or 1, subscribe.total_episode + 1):
info = schemas.SubscribeEpisodeInfo()
info = _SchemaSubscribeEpisodeInfo()
info.title = f'{i}'
episodes[i] = info
else:
# 电影
info = schemas.SubscribeEpisodeInfo()
info = _SchemaSubscribeEpisodeInfo()
info.title = subscribe.name
episodes[0] = info
@@ -3174,7 +3147,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
# 识别文件名
file_meta = MetaInfo(file.filepath)
# 下载文件信息
file_info = schemas.SubscribeDownloadFileInfo(
file_info = _SchemaSubscribeDownloadFileInfo(
torrent_title=his.torrent_name,
site_name=his.torrent_site,
downloader=file.downloader,
@@ -3218,7 +3191,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
# 识别文件名
file_meta = MetaInfo(fileitem.path)
# 媒体库文件信息
file_info = schemas.SubscribeLibraryFileInfo(
file_info = _SchemaSubscribeLibraryFileInfo(
storage=fileitem.storage,
file_path=fileitem.path,
)
@@ -3236,7 +3209,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
mediaserver_chain = MediaServerChain()
server_names = list(MediaServerHelper().get_services().keys())
def _has_server_entry(library_list: List[schemas.SubscribeLibraryFileInfo],
def _has_server_entry(library_list: List[_SchemaSubscribeLibraryFileInfo],
server_name: Optional[str],
server_type: Optional[str]) -> bool:
for info in library_list or []:
@@ -3288,7 +3261,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
item_id=episode_itemid,
) or series_detail_url
episode_info.library.append(
schemas.SubscribeLibraryFileInfo(
_SchemaSubscribeLibraryFileInfo(
storage=server_storage,
file_path=detail_url,
server=resolved_server,
@@ -3301,7 +3274,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
if episode_info and not _has_server_entry(
episode_info.library, resolved_server, exists_media.server_type):
episode_info.library.append(
schemas.SubscribeLibraryFileInfo(
_SchemaSubscribeLibraryFileInfo(
storage=server_storage,
file_path=series_detail_url,
server=resolved_server,
@@ -3409,7 +3382,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
return True, {}
no_exists = {
mediakey: {
subscribe.season: schemas.NotExistMediaInfo(
subscribe.season: _SchemaNotExistMediaInfo(
season=subscribe.season,
episodes=pending_episodes,
total_episode=effective_total_episode,
+2 -1
View File
@@ -9,7 +9,8 @@ from app.runtime.config import settings
from app.runtime.extensions.plugin_manager import PluginManager
from app.runtime.state import SystemHelper
from app.runtime.log import logger
from app.schemas import Message, NotificationChannel
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 version import FRONTEND_VERSION, APP_VERSION
+16 -14
View File
@@ -1,10 +1,12 @@
import random
from typing import Optional, List
from app import schemas
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 import MediaType
from app.schemas.types import MediaType
class TmdbChain(ChainBase):
@@ -61,21 +63,21 @@ class TmdbChain(ChainBase):
"""
return self.run_module("tmdb_collection", collection_id=collection_id)
def tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]:
def tmdb_seasons(self, tmdbid: int) -> List[_SchemaTmdbSeason]:
"""
根据TMDBID查询themoviedb所有季信息
:param tmdbid: TMDBID
"""
return self.run_module("tmdb_seasons", tmdbid=tmdbid)
def tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]:
def tmdb_group_seasons(self, group_id: str) -> List[_SchemaTmdbSeason]:
"""
根据剧集组ID查询themoviedb所有季集信息
:param group_id: 剧集组ID
"""
return self.run_module("tmdb_group_seasons", group_id=group_id)
def tmdb_episodes(self, tmdbid: int, season: int, episode_group: Optional[str] = None) -> List[schemas.TmdbEpisode]:
def tmdb_episodes(self, tmdbid: int, season: int, episode_group: Optional[str] = None) -> List[_SchemaTmdbEpisode]:
"""
根据TMDBID查询某季的所有信信息
:param tmdbid: TMDBID
@@ -112,7 +114,7 @@ class TmdbChain(ChainBase):
"""
return self.run_module("tmdb_tv_recommend", tmdbid=tmdbid)
def movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[schemas.MediaPerson]]:
def movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[_SchemaMediaPerson]]:
"""
根据TMDBID查询电影演职人员
:param tmdbid: TMDBID
@@ -120,7 +122,7 @@ class TmdbChain(ChainBase):
"""
return self.run_module("tmdb_movie_credits", tmdbid=tmdbid, page=page)
def tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[schemas.MediaPerson]]:
def tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[_SchemaMediaPerson]]:
"""
根据TMDBID查询电视剧演职人员
:param tmdbid: TMDBID
@@ -128,7 +130,7 @@ class TmdbChain(ChainBase):
"""
return self.run_module("tmdb_tv_credits", tmdbid=tmdbid, page=page)
def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
def person_detail(self, person_id: int) -> Optional[_SchemaMediaPerson]:
"""
根据TMDBID查询演职员详情
:param person_id: 人物ID
@@ -215,14 +217,14 @@ class TmdbChain(ChainBase):
"""
return await self.async_run_module("async_tmdb_collection", collection_id=collection_id)
async def async_tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]:
async def async_tmdb_seasons(self, tmdbid: int) -> List[_SchemaTmdbSeason]:
"""
根据TMDBID查询themoviedb所有季信息异步版本
:param tmdbid: TMDBID
"""
return await self.async_run_module("async_tmdb_seasons", tmdbid=tmdbid)
async def async_tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]:
async def async_tmdb_group_seasons(self, group_id: str) -> List[_SchemaTmdbSeason]:
"""
根据剧集组ID查询themoviedb所有季集信息异步版本
:param group_id: 剧集组ID
@@ -230,7 +232,7 @@ class TmdbChain(ChainBase):
return await self.async_run_module("async_tmdb_group_seasons", group_id=group_id)
async def async_tmdb_episodes(self, tmdbid: int, season: int,
episode_group: Optional[str] = None) -> List[schemas.TmdbEpisode]:
episode_group: Optional[str] = None) -> List[_SchemaTmdbEpisode]:
"""
根据TMDBID查询某季的所有信信息异步版本
:param tmdbid: TMDBID
@@ -268,7 +270,7 @@ class TmdbChain(ChainBase):
"""
return await self.async_run_module("async_tmdb_tv_recommend", tmdbid=tmdbid)
async def async_movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[schemas.MediaPerson]]:
async def async_movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[_SchemaMediaPerson]]:
"""
根据TMDBID查询电影演职人员异步版本
:param tmdbid: TMDBID
@@ -276,7 +278,7 @@ class TmdbChain(ChainBase):
"""
return await self.async_run_module("async_tmdb_movie_credits", tmdbid=tmdbid, page=page)
async def async_tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[schemas.MediaPerson]]:
async def async_tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[_SchemaMediaPerson]]:
"""
根据TMDBID查询电视剧演职人员异步版本
:param tmdbid: TMDBID
@@ -284,7 +286,7 @@ class TmdbChain(ChainBase):
"""
return await self.async_run_module("async_tmdb_tv_credits", tmdbid=tmdbid, page=page)
async def async_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
async def async_person_detail(self, person_id: int) -> Optional[_SchemaMediaPerson]:
"""
根据TMDBID查询演职员详情异步版本
:param person_id: 人物ID
+1 -1
View File
@@ -17,7 +17,7 @@ from app.db.oper.systemconfig import SystemConfigOper
from app.application.rss import RssHelper
from app.application.torrent import TorrentHelper
from app.runtime.log import logger
from app.schemas import Message
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.domain import site as site_rules
+24 -30
View File
@@ -31,16 +31,14 @@ from app.application.history import (add_transfer_fail, add_transfer_success,
evaluate_history_gate, is_skip_action,
record_transfer_failure)
from app.runtime.log import logger
from app.schemas import StorageOperSelectionEventData
from app.schemas import (
TransferInfo,
Message,
EpisodeFormat,
FileItem,
TransferDirectoryConf,
TransferJob,
TmdbEpisode,
)
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,
@@ -56,7 +54,7 @@ from app.schemas.types import (
)
from app.runtime.reload import ConfigReloadMixin
from app.application.transfer import (FailedRetryScheduler, JobManager,
TransferQueue, TransferTask, job_lock)
TransferQueueService, TransferTask, job_lock)
from app.chain._transfer import (EpisodeFormatMixin, FailedRetryMixin,
FileFilterMixin, FileKeyMixin,
HistoryMatchMixin, ManualHistoryMixin,
@@ -501,20 +499,19 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
:param task: 任务信息
:return: True表示任务已添加到队列False表示任务无效或已存在重复
"""
if not task:
return False
# 维护整理任务视图,如果任务已存在则不添加到队列
if not self.__put_to_jobview(task):
return False
self._register_scrape_batch_task(task)
# 添加到队列
self._queue.put(TransferQueue(task=task, callback=self.__default_callback))
# 落盘登记:队列是纯内存的,进程重启(挂载挂死后的人工重启、升级、OOM)
# 会让队列连同「这些文件还没整理」这个事实一起蒸发,而已稳定落地的文件
# 不会再产生任何监控事件,等于永久漏件。登记放在入队之后,宁可多留一条
# 由回放时的整理历史查重挡掉,也不制造「已入队但未登记」的窗口
self.__register_pending(task)
return True
return self._transfer_queue_service().put(task, self.__default_callback)
def _transfer_queue_service(self) -> TransferQueueService:
"""构建保持旧队列对象和私有兼容接缝的应用服务。"""
return TransferQueueService(
register_task=self.__put_to_jobview,
enqueue=self._queue.put,
before_enqueue=self._register_scrape_batch_task,
after_enqueue=self.__register_pending,
remove_task=self.jobview.remove_task,
list_tasks=self.jobview.list_jobs,
expire_tasks=self.__expire_stale_transfer_tasks,
)
def replay_pending(self):
"""
@@ -686,9 +683,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
"""
从待整理队列移除
"""
if not fileitem:
return
self.jobview.remove_task(fileitem)
self._transfer_queue_service().remove(fileitem)
def __start_job_execution(self, task: TransferTask):
"""在作业视图支持执行租约时标记主程序任务开始执行。"""
@@ -1159,8 +1154,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
"""
获取整理任务列表
"""
self.__expire_stale_transfer_tasks()
return self.jobview.list_jobs()
return self._transfer_queue_service().list()
def process(self, progress_callback: Optional[Callable[..., None]] = None) -> bool:
"""
+2 -1
View File
@@ -8,7 +8,8 @@ from app.application.security.access import get_password_hash, verify_password
from app.db.models.user import User
from app.db.oper.user import UserOper
from app.runtime.log import logger
from app.schemas import AuthCredentials, AuthInterceptCredentials
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
+5 -1
View File
@@ -18,7 +18,11 @@ from app.runtime.events import Event, eventmanager
from app.db.models import Workflow
from app.db.oper.workflow import WorkflowOper
from app.runtime.log import logger
from app.schemas import ActionContext, ActionFlow, Action, ActionExecution, ActionResult
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.schemas.types import EventType
from app.workflow import WorkFlowManager