mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +08:00
refactor: 推进后端分层架构治理
This commit is contained in:
+35
-235
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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 DTO(TransferTask.download_history)。本函数只按 getattr 取
|
||||
# year 与 type,对两者一视同仁
|
||||
media: Union[DownloadHistory, schemas.DownloadHistory, MediaInfo, MusicInfo]
|
||||
media: Union[DownloadHistory, _SchemaDownloadHistory, MediaInfo, MusicInfo]
|
||||
) -> bool:
|
||||
"""
|
||||
判断文件名年份是否与已识别电影年份冲突。
|
||||
|
||||
@@ -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 人物详情。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
# 发出下载任务删除事件,如需处理辅种,可监听该事件
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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.jpg,thumb.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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user