Merge remote-tracking branch 'upstream/dev' into feature/db

This commit is contained in:
InfinityPacer
2024-09-30 14:31:20 +08:00
21 changed files with 325 additions and 100 deletions

3
.gitignore vendored
View File

@@ -14,6 +14,9 @@ app/plugins/**
config/cookies/**
config/user.db
config/sites/**
config/logs/
config/temp/
config/cache/
*.pyc
*.log
.vscode

View File

@@ -398,7 +398,7 @@ def user_subscribes(
return Subscribe.list_by_username(db, username)
@router.get("/files/{subscribe_id}", summary="订阅相关文件信息", response_model=List[schemas.SubscrbieInfo])
@router.get("/files/{subscribe_id}", summary="订阅相关文件信息", response_model=schemas.SubscrbieInfo)
def subscribe_files(
subscribe_id: int,
db: Session = Depends(get_db),

View File

@@ -30,6 +30,10 @@ class MediaChain(ChainBase, metaclass=Singleton):
# 临时识别结果 {title, name, year, season, episode}
recognize_temp: Optional[dict] = None
def __init__(self):
super().__init__()
self.storagechain = StorageChain()
def metadata_nfo(self, meta: MetaBase, mediainfo: MediaInfo,
season: int = None, episode: int = None) -> Optional[str]:
"""
@@ -348,37 +352,35 @@ class MediaChain(ChainBase, metaclass=Singleton):
def scrape_metadata(self, fileitem: schemas.FileItem,
meta: MetaBase = None, mediainfo: MediaInfo = None,
init_folder: bool = True, parent: schemas.FileItem = None):
init_folder: bool = True, parent: schemas.FileItem = None,
overwrite: bool = False):
"""
手动刮削媒体信息
:param fileitem: 刮削目录或文件
:param meta: 元数据
:param mediainfo: 媒体信息
:param init_folder: 是否刮削根目录
:param parent: 上级目录
:param overwrite: 是否覆盖已有文件
"""
def __list_files(_fileitem: schemas.FileItem):
"""
列出下级文件
"""
return StorageChain().list_files(fileitem=_fileitem)
return self.storagechain.list_files(fileitem=_fileitem)
def __save_file(_fileitem: schemas.FileItem, _path: Path, _content: Union[bytes, str],
overwrite: bool = False):
def __save_file(_fileitem: schemas.FileItem, _path: Path, _content: Union[bytes, str]):
"""
保存或上传文件
:param _fileitem: 关联的媒体文件项
:param _path: 元数据文件路径
:param _content: 文件内容
:param overwrite: 是否覆盖
"""
if not overwrite and _path.exists():
return
tmp_file = settings.TEMP_PATH / _path.name
tmp_file.write_bytes(_content)
upload_item = copy.deepcopy(_fileitem)
upload_item.path = str(_path)
upload_item.name = _path.name
upload_item.basename = _path.stem
upload_item.extension = _path.suffix
logger.info(f"保存文件:【{_fileitem.storage}{_path}")
StorageChain().upload_file(fileitem=upload_item, path=tmp_file)
self.storagechain.upload_file(fileitem=_fileitem, path=tmp_file)
if tmp_file.exists():
tmp_file.unlink()
@@ -419,7 +421,11 @@ class MediaChain(ChainBase, metaclass=Singleton):
logger.warn(f"{filepath.name} nfo文件生成失败")
return
# 保存或上传nfo文件到上级目录
__save_file(_fileitem=parent, _path=filepath.with_suffix(".nfo"), _content=movie_nfo)
nfo_path = filepath.with_suffix(".nfo")
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
logger.info(f"已存在nfo文件{nfo_path}")
return
__save_file(_fileitem=parent, _path=nfo_path, _content=movie_nfo)
else:
# 电影目录
files = __list_files(_fileitem=fileitem)
@@ -438,6 +444,9 @@ class MediaChain(ChainBase, metaclass=Singleton):
and attr_value.startswith("http"):
image_name = attr_name.replace("_path", "") + Path(attr_value).suffix
image_path = filepath / image_name
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=image_path):
logger.info(f"已存在图片文件:{image_path}")
continue
# 下载图片
content = __download_image(_url=attr_value)
# 写入图片到当前目录
@@ -461,17 +470,24 @@ class MediaChain(ChainBase, metaclass=Singleton):
logger.warn(f"{filepath.name} nfo生成失败")
return
# 保存或上传nfo文件到上级目录
__save_file(_fileitem=parent, _path=filepath.with_suffix(".nfo"), _content=episode_nfo)
nfo_path = filepath.with_suffix(".nfo")
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
logger.info(f"已存在nfo文件{nfo_path}")
return
__save_file(_fileitem=parent, _path=nfo_path, _content=episode_nfo)
# 获取集的图片
image_dict = self.metadata_img(mediainfo=file_mediainfo,
season=file_meta.begin_season, episode=file_meta.begin_episode)
if image_dict:
for episode, image_url in image_dict.items():
image_path = filepath.with_suffix(Path(image_url).suffix)
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=image_path):
logger.info(f"已存在图片文件:{image_path}")
continue
# 下载图片
content = __download_image(image_url)
# 保存图片文件到当前目录
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
__save_file(_fileitem=parent, _path=image_path, _content=content)
else:
# 当前为目录,处理目录内的文件
@@ -493,12 +509,18 @@ class MediaChain(ChainBase, metaclass=Singleton):
return
# 写入nfo到根目录
nfo_path = filepath / "season.nfo"
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
logger.info(f"已存在nfo文件{nfo_path}")
return
__save_file(_fileitem=fileitem, _path=nfo_path, _content=season_nfo)
# TMDB季poster图片
image_dict = self.metadata_img(mediainfo=mediainfo, season=season_meta.begin_season)
if image_dict:
for image_name, image_url in image_dict.items():
image_path = filepath.with_name(image_name)
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=image_path):
logger.info(f"已存在图片文件:{image_path}")
continue
# 下载图片
content = __download_image(image_url)
# 保存图片文件到当前目录
@@ -511,12 +533,18 @@ class MediaChain(ChainBase, metaclass=Singleton):
return
# 写入tvshow nfo到根目录
nfo_path = filepath / "tvshow.nfo"
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
logger.info(f"已存在nfo文件{nfo_path}")
return
__save_file(_fileitem=fileitem, _path=nfo_path, _content=tv_nfo)
# 生成目录图片
image_dict = self.metadata_img(mediainfo=mediainfo)
if image_dict:
for image_name, image_url in image_dict.items():
image_path = filepath / image_name
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=image_path):
logger.info(f"已存在图片文件:{image_path}")
continue
# 下载图片
content = __download_image(image_url)
# 保存图片文件到当前目录

View File

@@ -64,6 +64,7 @@ class SiteChain(ChainBase):
userdata: SiteUserData = self.run_module("refresh_userdata", site=site)
if userdata:
self.siteoper.update_userdata(domain=StringUtils.get_url_domain(site.get("domain")),
name=site.get("name"),
payload=userdata.dict())
return userdata

View File

@@ -25,7 +25,8 @@ from app.helper.message import MessageHelper
from app.helper.subscribe import SubscribeHelper
from app.helper.torrent import TorrentHelper
from app.log import logger
from app.schemas import NotExistMediaInfo, Notification, SubscrbieInfo, SubscribeEpisodeInfo
from app.schemas import NotExistMediaInfo, Notification, SubscrbieInfo, SubscribeEpisodeInfo, SubscribeDownloadFileInfo, \
SubscribeLibraryFileInfo
from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType, EventType
@@ -1167,47 +1168,34 @@ class SubscribeChain(ChainBase):
return
# 返回订阅数据
subscribe_info = SubscrbieInfo(
id=subscribe.id,
name=subscribe.name,
year=subscribe.year,
type=subscribe.type,
tmdbid=subscribe.tmdbid,
doubanid=subscribe.doubanid,
season=subscribe.season,
poster=subscribe.poster,
backdrop=subscribe.backdrop,
vote=subscribe.vote,
description=subscribe.description,
episodes_info={}
)
subscribe_info = SubscrbieInfo()
# 所有集的数据
episodes_info = {}
episodes: Dict[int, SubscribeEpisodeInfo] = {}
if subscribe.tmdbid and subscribe.type == MediaType.TV.value:
# 查询TMDB中的集信息
tmdb_episodes = self.tmdbchain.tmdb_episodes(
tmdbid=subscribe.tmdb_id,
tmdbid=subscribe.tmdbid,
season=subscribe.season
)
if tmdb_episodes:
for episode in tmdb_episodes:
episode_info = SubscribeEpisodeInfo()
episodes_info.title = episode.name
episodes_info.description = episode.overview
episodes_info.backdrop = f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/w500${episode.still_path}"
episodes_info[episode.episode_number] = episode_info
info = SubscribeEpisodeInfo()
info.title = episode.name
info.description = episode.overview
info.backdrop = f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/w500${episode.still_path}"
episodes[episode.episode_number] = info
elif subscribe.type == MediaType.TV.value:
# 根据开始结束集计算集信息
for i in range(subscribe.start_episode or 1, subscribe.total_episode + 1):
episode_info = SubscribeEpisodeInfo()
episode_info.title = f'{i}'
episodes_info[i] = episode_info
info = SubscribeEpisodeInfo()
info.title = f'{i}'
episodes[i] = info
else:
# 电影
episode_info = SubscribeEpisodeInfo()
episode_info.title = subscribe.name
episodes_info[0] = episode_info
info = SubscribeEpisodeInfo()
info.title = subscribe.name
episodes[0] = info
# 所有下载记录
download_his = self.downloadhis.get_by_mediaid(tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid)
@@ -1221,14 +1209,20 @@ class SubscribeChain(ChainBase):
for file in files:
# 识别文件名
file_meta = MetaInfo(file.filepath)
# 下载文件信息
file_info = SubscribeDownloadFileInfo(
torrent_title=his.torrent_name,
site_name=his.torrent_site,
downloader=file.downloader,
hash=his.download_hash,
file_path=file.fullpath,
)
if subscribe.type == MediaType.TV.value:
episode_number = file_meta.begin_episode
if episode_number and episodes_info.get(episode_number):
episodes_info[episode_number].download_file = file.fullpath
episodes_info[episode_number].torrent = torrent_url
if episode_number and episodes.get(episode_number):
episodes[episode_number].download.append(file_info)
else:
episodes_info[0].download_file = file.fullpath
episodes_info[0].torrent = torrent_url
episodes[0].download.append(file_info)
# 生成元数据
meta = MetaInfo(subscribe.name)
@@ -1255,13 +1249,19 @@ class SubscribeChain(ChainBase):
for fileitem in library_fileitems:
# 识别文件名
file_meta = MetaInfo(fileitem.path)
# 媒体库文件信息
file_info = SubscribeLibraryFileInfo(
storage=fileitem.storage,
file_path=fileitem.path,
)
if subscribe.type == MediaType.TV.value:
episode_number = file_meta.begin_episode
if episode_number and episodes_info.get(episode_number):
episodes_info[episode_number].library_file = fileitem.path
if episode_number and episodes.get(episode_number):
episodes[episode_number].library.append(file_info)
else:
episodes_info[0].library_file = fileitem.path
episodes[0].library.append(file_info)
# 更新订阅信息
subscribe_info.episodes_info = episodes_info
subscribe_info.subscribe = Subscribe(**subscribe.to_dict())
subscribe_info.episodes = episodes
return subscribe_info

View File

@@ -13,6 +13,8 @@ class SiteUserData(Base):
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
# 站点域名
domain = Column(String, index=True)
# 站点名称
name = Column(String)
# 用户名
username = Column(String)
# 用户ID

View File

@@ -104,7 +104,7 @@ class SiteOper(DbOper):
})
return True, "更新站点RSS地址成功"
def update_userdata(self, domain: str, payload: dict) -> Tuple[bool, str]:
def update_userdata(self, domain: str, name: str, payload: dict) -> Tuple[bool, str]:
"""
更新站点用户数据
"""
@@ -113,6 +113,7 @@ class SiteOper(DbOper):
current_time = datetime.now().strftime('%H:%M:%S')
payload.update({
"domain": domain,
"name": name,
"updated_day": current_day,
"updated_time": current_time
})
@@ -127,6 +128,12 @@ class SiteOper(DbOper):
SiteUserData(**payload).create(self._db)
return True, "更新站点用户数据成功"
def get_userdata(self) -> List[SiteUserData]:
"""
获取站点用户数据
"""
return SiteUserData.list(self._db)
def get_userdata_by_domain(self, domain: str, workdate: str = None) -> List[SiteUserData]:
"""
获取站点用户数据

View File

@@ -1,5 +1,16 @@
class DownloaderHelper:
from app.helper.servicebase import ServiceBaseHelper
from app.schemas import DownloaderConf
from app.schemas.types import SystemConfigKey
class DownloaderHelper(ServiceBaseHelper[DownloaderConf]):
"""
下载器帮助类
"""
pass
def __init__(self):
super().__init__(
config_key=SystemConfigKey.Downloaders,
conf_type=DownloaderConf,
modules=["QbittorrentModule", "TransmissionModule"]
)

View File

@@ -1,5 +1,16 @@
class MediaServerHelper:
from app.helper.servicebase import ServiceBaseHelper
from app.schemas import MediaServerConf
from app.schemas.types import SystemConfigKey
class MediaServerHelper(ServiceBaseHelper[MediaServerConf]):
"""
媒体服务器帮助类
"""
pass
def __init__(self):
super().__init__(
config_key=SystemConfigKey.MediaServers,
conf_type=MediaServerConf,
modules=["PlexModule", "EmbyModule", "JellyfinModule"]
)

View File

@@ -1,5 +1,17 @@
class NotificationHelper:
from app.helper.servicebase import ServiceBaseHelper
from app.schemas import NotificationConf
from app.schemas.types import SystemConfigKey
class NotificationHelper(ServiceBaseHelper[NotificationConf]):
"""
消息通知渠道帮助类
消息通知帮助类
"""
pass
def __init__(self):
super().__init__(
config_key=SystemConfigKey.Notifications,
conf_type=NotificationConf,
modules=["WechatModule", "WebPushModule", "VoceChatModule", "TelegramModule", "SynologyChatModule",
"SlackModule"]
)

97
app/helper/servicebase.py Normal file
View File

@@ -0,0 +1,97 @@
from typing import Dict, List, Optional, Type, TypeVar, Generic, Iterator
from app.core.module import ModuleManager
from app.helper.serviceconfig import ServiceConfigHelper
from app.schemas import ServiceInfo
from app.schemas.types import SystemConfigKey
TConf = TypeVar("TConf")
class ServiceBaseHelper(Generic[TConf]):
"""
通用服务帮助类,抽象获取配置和服务实例的通用逻辑
"""
def __init__(self, config_key: SystemConfigKey, conf_type: Type[TConf], modules: List[str]):
self.modulemanager = ModuleManager()
self.config_key = config_key
self.conf_type = conf_type
self.modules = modules
def get_configs(self, include_disabled: bool = False) -> Dict[str, TConf]:
"""
获取配置列表
:param include_disabled: 是否包含禁用的配置,默认 False仅返回启用的配置
:return: 配置字典
"""
configs: List[TConf] = ServiceConfigHelper.get_configs(self.config_key, self.conf_type)
return {
config.name: config
for config in configs
if (config.name and config.type and config.enabled) or include_disabled
} if configs else {}
def get_config(self, name: str) -> Optional[TConf]:
"""
获取指定名称配置
"""
if not name:
return None
configs = self.get_configs()
return configs.get(name)
def iterate_module_instances(self) -> Iterator[ServiceInfo]:
"""
迭代所有模块的实例及其对应的配置,返回 ServiceInfo 实例
"""
configs = self.get_configs()
for module_name in self.modules:
module = self.modulemanager.get_running_module(module_name)
if not module:
continue
module_instances = module.get_instances()
if not isinstance(module_instances, dict):
continue
for name, instance in module_instances.items():
if not instance:
continue
config = configs.get(name)
service_info = ServiceInfo(
name=name,
instance=instance,
module=module,
type=config.type if config else None,
config=config
)
yield service_info
def get_services(self, type_filter: Optional[str] = None) -> Dict[str, ServiceInfo]:
"""
获取服务信息列表,并根据类型过滤
:param type_filter: 需要过滤的服务类型
:return: 过滤后的服务信息字典
"""
return {
service_info.name: service_info
for service_info in self.iterate_module_instances()
if service_info.config and (type_filter is None or service_info.type == type_filter)
}
def get_service(self, name: str, type_filter: Optional[str] = None) -> Optional[ServiceInfo]:
"""
获取指定名称的服务信息,并根据类型过滤
:param name: 服务名称
:param type_filter: 需要过滤的服务类型
:return: 对应的服务信息,若不存在或类型不匹配则返回 None
"""
if not name:
return None
for service_info in self.iterate_module_instances():
if service_info.name == name:
if service_info.config and (type_filter is None or service_info.type == type_filter):
return service_info
return None

View File

@@ -56,7 +56,7 @@ class ServiceConfigHelper:
@staticmethod
def get_notification_switch(mtype: NotificationType) -> Optional[str]:
"""
获取消息通知场景开关
获取指定类型的消息通知场景开关
"""
switchs = ServiceConfigHelper.get_notification_switches()
for switch in switchs:

View File

@@ -90,6 +90,14 @@ class ServiceBase(Generic[TService, TConf], metaclass=ABCMeta):
# 如果传入的是工厂函数,直接调用工厂函数
self._instances[conf.name] = service_type(conf)
def get_instances(self) -> Dict[str, TService]:
"""
获取服务实例列表
:return: 返回服务实例列表
"""
return self._instances
def get_instance(self, name: str) -> Optional[TService]:
"""
获取服务实例

View File

@@ -187,7 +187,7 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
"""
server: Emby = self.get_instance(server)
if server:
yield from server.get_items(library_id, start_index, limit)
return server.get_items(library_id, start_index, limit)
return None
def mediaserver_iteminfo(self, server: str, item_id: str) -> Optional[schemas.MediaServerItem]:

View File

@@ -1093,9 +1093,6 @@ class FileManagerModule(_ModuleBase):
media_path = dir_path / rel_path.parts[0]
else:
continue
# 检查媒体文件夹是否存在
if not media_path.exists():
continue
# 检索媒体文件
fileitem = storage_oper.get_item(media_path)
if not fileitem:

View File

@@ -185,7 +185,7 @@ class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]):
"""
server: Jellyfin = self.get_instance(server)
if server:
yield from server.get_items(library_id, start_index, limit)
return server.get_items(library_id, start_index, limit)
return None
def mediaserver_iteminfo(self, server: str, item_id: str) -> Optional[schemas.MediaServerItem]:

View File

@@ -173,7 +173,7 @@ class PlexModule(_ModuleBase, _MediaServerBase[Plex]):
"""
server: Plex = self.get_instance(server)
if server:
yield from server.get_items(library_id, start_index, limit)
return server.get_items(library_id, start_index, limit)
return None
def mediaserver_iteminfo(self, server: str, item_id: str) -> Optional[schemas.MediaServerItem]:

View File

@@ -81,43 +81,41 @@ class Subscribe(BaseModel):
orm_mode = True
class SubscribeDownloadFileInfo(BaseModel):
# 种子名称
torrent_title: Optional[str] = None
# 站点名称
site_name: Optional[str] = None
# 下载器
downloader: Optional[str] = None
# hash
hash: Optional[str] = None
# 文件路径
file_path: Optional[str] = None
class SubscribeLibraryFileInfo(BaseModel):
# 存储
storage: Optional[str] = "local"
# 文件路径
file_path: Optional[str] = None
class SubscribeEpisodeInfo(BaseModel):
# 种子地址
torrent: Optional[str] = None
# 下载文件路程
download_file: Optional[str] = None
# 媒体库文件路径
library_file: Optional[str] = None
# 标题
title: Optional[str] = None
# 描述
description: Optional[str] = None
# 背景图
backdrop: Optional[str] = None
# 下载文件信息
download: Optional[List[SubscribeDownloadFileInfo]] = []
# 媒体库文件信息
library: Optional[List[SubscribeLibraryFileInfo]] = []
class SubscrbieInfo(BaseModel):
# 订阅ID
id: Optional[int] = None
# 订阅名称
name: Optional[str] = None
# 订阅年份
year: Optional[str] = None
# 订阅类型 电影/电视剧
type: Optional[str] = None
# 媒体ID
tmdbid: Optional[int] = None
doubanid: Optional[str] = None
bangumiid: Optional[int] = None
# 季号
season: Optional[int] = None
# 海报
poster: Optional[str] = None
# 背景图
backdrop: Optional[str] = None
# 评分
vote: Optional[int] = 0
# 描述
description: Optional[str] = None
# 订阅信息
subscribe: Optional[Subscribe] = None
# 集信息 {集号: {download: 文件路径library: 文件路径, backdrop: url, title: 标题, description: 描述}}
episodes_info: Optional[Dict[int, SubscribeEpisodeInfo]] = {}
episodes: Optional[Dict[int, SubscribeEpisodeInfo]] = {}

View File

@@ -1,8 +1,26 @@
from typing import Optional
from dataclasses import dataclass
from typing import Optional, Any
from pydantic import BaseModel
@dataclass
class ServiceInfo:
"""
封装服务相关信息的数据类
"""
# 名称
name: Optional[str] = None
# 实例
instance: Optional[Any] = None
# 模块
module: Optional[Any] = None
# 类型
type: Optional[str] = None
# 配置
config: Optional[Any] = None
class MediaServerConf(BaseModel):
"""
媒体服务器配置

View File

@@ -18,8 +18,10 @@ class TorrentStatus(Enum):
class EventType(Enum):
# 插件需要重载
PluginReload = "plugin.reload"
# 插件动作
# 触发插件动作
PluginAction = "plugin.action"
# 插件触发事件
PluginTriggered = "plugin.triggered"
# 执行命令
CommandExcute = "command.excute"
# 站点已删除

View File

@@ -0,0 +1,30 @@
"""2.0.2
Revision ID: 0fb94bf69b38
Revises: 262735d025da
Create Date: 2024-09-30 10:03:58.546036
"""
import contextlib
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0fb94bf69b38'
down_revision = '262735d025da'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with contextlib.suppress(Exception):
with op.batch_alter_table("siteuserdata") as batch_op:
batch_op.add_column(sa.Column('name', sa.VARCHAR))
# ### end Alembic commands ###
def downgrade() -> None:
pass