feat 本地存在标识&媒体库同步

This commit is contained in:
jxxghp 2023-07-22 11:49:22 +08:00
parent ce05b25f87
commit d93bc31092
24 changed files with 873 additions and 217 deletions

View File

@ -106,6 +106,8 @@ docker pull jxxghp/moviepilot:latest
**MEDIASERVER** 媒体服务器,支持`emby`/`jellyfin`/`plex`,同时还需要配置对应媒体服务器的环境变量,非对应媒体服务器的变量可删除,推荐使用`emby` **MEDIASERVER** 媒体服务器,支持`emby`/`jellyfin`/`plex`,同时还需要配置对应媒体服务器的环境变量,非对应媒体服务器的变量可删除,推荐使用`emby`
**MEDIASERVER_SYNC_INTERVAL:** 媒体服务器同步间隔(小时),默认`6`,留空则不同步
`emby`设置项: `emby`设置项:
- **EMBY_HOST** Emby服务器地址格式`ip:port`https需要添加`https://`前缀 - **EMBY_HOST** Emby服务器地址格式`ip:port`https需要添加`https://`前缀
@ -218,10 +220,10 @@ docker pull jxxghp/moviepilot:latest
- [x] 自定义识别词 - [x] 自定义识别词
- [x] 便捷工具 - [x] 便捷工具
- [x] 过滤规则维护 - [x] 过滤规则维护
- [x] 本地存在标识
- [ ] 插件管理
- [ ] 手动整理功能增强 - [ ] 手动整理功能增强
- [ ] 本地存在标识
- [ ] 媒体详情页面 - [ ] 媒体详情页面
- [ ] 洗版支持 - [ ] 洗版支持
- [ ] 插件管理

View File

@ -1,10 +1,13 @@
from typing import List, Any from typing import List, Any
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app import schemas from app import schemas
from app.chain.media import MediaChain from app.chain.media import MediaChain
from app.core.security import verify_token from app.core.security import verify_token
from app.db import get_db
from app.db.mediaserver_oper import MediaServerOper
router = APIRouter() router = APIRouter()
@ -35,3 +38,22 @@ def search_by_title(title: str,
if medias: if medias:
return [media.to_dict() for media in medias[(page - 1) * count: page * count]] return [media.to_dict() for media in medias[(page - 1) * count: page * count]]
return [] return []
@router.get("/exists", summary="本地是否存在", response_model=schemas.Response)
def exists(title: str = None,
year: int = None,
mtype: str = None,
tmdbid: int = None,
season: int = None,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
判断本地是否存在
"""
exist = MediaServerOper(db).exists(
title=title, year=year, mtype=mtype, tmdbid=tmdbid, season=season
)
return schemas.Response(success=True if exist else False, data={
"item": exist or {}
})

View File

@ -1,5 +1,4 @@
import json import json
import json
import time import time
from datetime import datetime from datetime import datetime
from typing import Union from typing import Union

View File

@ -12,7 +12,7 @@ from app.core.context import MediaInfo
from app.core.metainfo import MetaInfo from app.core.metainfo import MetaInfo
from app.helper.rss import RssHelper from app.helper.rss import RssHelper
from app.log import logger from app.log import logger
from app.schemas import MediaType, Notification, MessageChannel, NotificationType from app.schemas import MediaType, Notification, MessageChannel
class DoubanChain(ChainBase): class DoubanChain(ChainBase):
@ -95,10 +95,10 @@ class DoubanChain(ChainBase):
""" """
同步豆瓣想看数据发送消息 同步豆瓣想看数据发送消息
""" """
self.post_message(Notification(channel=channel, mtype=NotificationType.Subscribe, self.post_message(Notification(channel=channel,
title="开始同步豆瓣想看 ...", userid=userid)) title="开始同步豆瓣想看 ...", userid=userid))
self.sync() self.sync()
self.post_message(Notification(channel=channel, mtype=NotificationType.Subscribe, self.post_message(Notification(channel=channel,
title="同步豆瓣想看数据完成!", userid=userid)) title="同步豆瓣想看数据完成!", userid=userid))
def sync(self): def sync(self):

88
app/chain/mediaserver.py Normal file
View File

@ -0,0 +1,88 @@
import json
import threading
from typing import List, Union, Generator
from app import schemas
from app.chain import ChainBase
from app.core.config import settings
from app.db.mediaserver_oper import MediaServerOper
from app.log import logger
from app.schemas import MessageChannel, Notification
lock = threading.Lock()
class MediaServerChain(ChainBase):
"""
媒体服务器处理链
"""
def __init__(self):
super().__init__()
self.mediaserverdb = MediaServerOper()
def librarys(self) -> List[schemas.MediaServerLibrary]:
"""
获取媒体服务器所有媒体库
"""
return self.run_module("mediaserver_librarys")
def items(self, library_id: Union[str, int]) -> Generator:
"""
获取媒体服务器所有项目
"""
return self.run_module("mediaserver_items", library_id=library_id)
def episodes(self, item_id: Union[str, int]) -> List[schemas.MediaServerSeasonInfo]:
"""
获取媒体服务器剧集信息
"""
return self.run_module("mediaserver_tv_episodes", item_id=item_id)
def remote_sync(self, channel: MessageChannel, userid: Union[int, str]):
"""
同步豆瓣想看数据发送消息
"""
self.post_message(Notification(channel=channel,
title="开始媒体服务器 ...", userid=userid))
self.sync()
self.post_message(Notification(channel=channel,
title="同步媒体服务器完成!", userid=userid))
def sync(self):
"""
同步媒体库所有数据到本地数据库
"""
with lock:
logger.info("开始同步媒体库数据 ...")
# 汇总统计
total_count = 0
# 清空登记薄
self.mediaserverdb.empty(server=settings.MEDIASERVER)
for library in self.librarys():
logger.info(f"正在同步媒体库 {library.name} ...")
library_count = 0
for item in self.items(library.id):
if not item:
continue
if not item.item_id:
continue
# 计数
library_count += 1
seasoninfo = {}
# 类型
item_type = "电视剧" if item.item_type in ['Series', 'show'] else "电影"
if item_type == "电视剧":
# 查询剧集信息
espisodes_info = self.episodes(item.item_id)
for episode in espisodes_info:
seasoninfo[episode.season] = episode.episodes
# 插入数据
item_dict = item.dict()
item_dict['seasoninfo'] = json.dumps(seasoninfo)
item_dict['item_type'] = item_type
self.mediaserverdb.add(**item_dict)
logger.info(f"媒体库 {library.name} 同步完成,共同步数量:{library_count}")
# 总数累加
total_count += library_count
logger.info("【MediaServer】媒体库数据同步完成同步数量%s" % total_count)

View File

@ -123,7 +123,8 @@ class SiteChain(ChainBase):
if not site: if not site:
self.post_message(Notification( self.post_message(Notification(
channel=channel, channel=channel,
title=f"站点编号 {site_id} 不存在!", userid=userid)) title=f"站点编号 {site_id} 不存在!",
userid=userid))
return return
# 禁用站点 # 禁用站点
self.siteoper.update(site_id, { self.siteoper.update(site_id, {

View File

@ -6,6 +6,7 @@ from app.chain import ChainBase
from app.chain.cookiecloud import CookieCloudChain from app.chain.cookiecloud import CookieCloudChain
from app.chain.douban import DoubanChain from app.chain.douban import DoubanChain
from app.chain.download import DownloadChain from app.chain.download import DownloadChain
from app.chain.mediaserver import MediaServerChain
from app.chain.site import SiteChain from app.chain.site import SiteChain
from app.chain.subscribe import SubscribeChain from app.chain.subscribe import SubscribeChain
from app.chain.transfer import TransferChain from app.chain.transfer import TransferChain
@ -74,6 +75,11 @@ class Command(metaclass=Singleton):
"description": "同步豆瓣想看", "description": "同步豆瓣想看",
"data": {} "data": {}
}, },
"/mediaserver_sync": {
"func": MediaServerChain().remote_sync,
"description": "同步媒体服务器",
"data": {}
},
"/subscribes": { "/subscribes": {
"func": SubscribeChain().remote_list, "func": SubscribeChain().remote_list,
"description": "查询订阅", "description": "查询订阅",

View File

@ -113,6 +113,8 @@ class Settings(BaseSettings):
DOWNLOAD_CATEGORY: bool = False DOWNLOAD_CATEGORY: bool = False
# 媒体服务器 emby/jellyfin/plex # 媒体服务器 emby/jellyfin/plex
MEDIASERVER: str = "emby" MEDIASERVER: str = "emby"
# 媒体服务器同步间隔(小时)
MEDIASERVER_SYNC_INTERVAL: int = 6
# EMBY服务器地址IP:PORT # EMBY服务器地址IP:PORT
EMBY_HOST: str = None EMBY_HOST: str = None
# EMBY Api Key # EMBY Api Key

View File

@ -0,0 +1,54 @@
import json
from typing import Optional
from app.db import DbOper, SessionLocal
from app.db.models.mediaserver import MediaServerItem
class MediaServerOper(DbOper):
"""
媒体服务器数据管理
"""
def __init__(self, db=SessionLocal()):
super().__init__(db)
def add(self, **kwargs) -> bool:
"""
新增媒体服务器数据
"""
item = MediaServerItem(**kwargs)
if not item.get_by_itemid(self._db, kwargs.get("item_id")):
item.create(self._db)
return True
return False
def empty(self, server: str):
"""
清空媒体服务器数据
"""
MediaServerItem.empty(self._db, server)
def exists(self, **kwargs) -> Optional[MediaServerItem]:
"""
判断媒体服务器数据是否存在
"""
if kwargs.get("tmdbid"):
# 优先按TMDBID查
item = MediaServerItem.exist_by_tmdbid(self._db, tmdbid=kwargs.get("tmdbid"),
mtype=kwargs.get("mtype"))
else:
# 按标题、类型、年份查
item = MediaServerItem.exists_by_title(self._db, title=kwargs.get("title"),
mtype=kwargs.get("mtype"), year=kwargs.get("year"))
if not item:
return None
if kwargs.get("season"):
# 判断季是否存在
if not item.seasoninfo:
return None
seasoninfo = json.loads(item.seasoninfo) or {}
if kwargs.get("season") not in seasoninfo.keys():
return None
return item

View File

@ -0,0 +1,61 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, Sequence
from sqlalchemy.orm import Session
from app.db.models import Base
class MediaServerItem(Base):
"""
站点表
"""
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
# 服务器类型
server = Column(String)
# 媒体库ID
library = Column(String)
# ID
item_id = Column(String, index=True)
# 类型
item_type = Column(String)
# 标题
title = Column(String, index=True)
# 原标题
original_title = Column(String)
# 年份
year = Column(String)
# TMDBID
tmdbid = Column(Integer, index=True)
# IMDBID
imdbid = Column(String, index=True)
# TVDBID
tvdbid = Column(String, index=True)
# 路径
path = Column(String)
# 季集
seasoninfo = Column(String)
# 备注
note = Column(String)
# 同步时间
lst_mod_date = Column(String, default=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
@staticmethod
def get_by_itemid(db: Session, item_id: str):
return db.query(MediaServerItem).filter(MediaServerItem.item_id == item_id).first()
@staticmethod
def empty(db: Session, server: str):
db.query(MediaServerItem).filter(MediaServerItem.server == server).delete()
db.commit()
@staticmethod
def exist_by_tmdbid(db: Session, tmdbid: int, mtype: str):
return db.query(MediaServerItem).filter(MediaServerItem.tmdbid == tmdbid,
MediaServerItem.item_type == mtype).first()
@staticmethod
def exists_by_title(db: Session, title: str, mtype: str, year: str):
return db.query(MediaServerItem).filter(MediaServerItem.title == title,
MediaServerItem.item_type == mtype,
MediaServerItem.year == str(year)).first()

View File

@ -181,4 +181,4 @@ class DoubanScraper:
return return
xml_str = doc.toprettyxml(indent=" ", encoding="utf-8") xml_str = doc.toprettyxml(indent=" ", encoding="utf-8")
file_path.write_bytes(xml_str) file_path.write_bytes(xml_str)
logger.info(f"NFO文件已保存{file_path}") logger.info(f"NFO文件已保存{file_path}")

View File

@ -1,5 +1,5 @@
from pathlib import Path from pathlib import Path
from typing import Optional, Tuple, Union, Any from typing import Optional, Tuple, Union, Any, List, Generator
from app import schemas from app import schemas
from app.core.context import MediaInfo from app.core.context import MediaInfo
@ -97,3 +97,50 @@ class EmbyModule(_ModuleBase):
episode_count=media_statistic.get("EpisodeCount") or 0, episode_count=media_statistic.get("EpisodeCount") or 0,
user_count=user_count or 0 user_count=user_count or 0
) )
def mediaserver_librarys(self) -> List[schemas.MediaServerLibrary]:
"""
媒体库列表
"""
librarys = self.emby.get_librarys()
if not librarys:
return []
return [schemas.MediaServerLibrary(
server="emby",
id=library.get("id"),
name=library.get("name"),
type=library.get("type"),
path=library.get("path")
) for library in librarys]
def mediaserver_items(self, library_id: str) -> Generator:
"""
媒体库项目列表
"""
items = self.emby.get_items(library_id)
for item in items:
yield schemas.MediaServerItem(
server="emby",
library=item.get("library"),
item_id=item.get("id"),
item_type=item.get("type"),
title=item.get("title"),
original_title=item.get("original_title"),
year=item.get("year"),
tmdbid=item.get("tmdbid"),
imdbid=item.get("imdbid"),
tvdbid=item.get("tvdbid"),
path=item.get("path"),
)
def mediaserver_tv_episodes(self, item_id: Union[str, int]) -> List[schemas.MediaServerSeasonInfo]:
"""
获取剧集信息
"""
seasoninfo = self.emby.get_tv_episodes(item_id=item_id)
if not seasoninfo:
return []
return [schemas.MediaServerSeasonInfo(
season=season,
episodes=episodes
) for season, episodes in seasoninfo.items()]

View File

@ -1,7 +1,7 @@
import json import json
import re import re
from pathlib import Path from pathlib import Path
from typing import List, Optional, Union, Dict from typing import List, Optional, Union, Dict, Generator
from app.core.config import settings from app.core.config import settings
from app.log import logger from app.log import logger
@ -43,7 +43,7 @@ class Emby(metaclass=Singleton):
logger.error(f"连接Library/SelectableMediaFolders 出错:" + str(e)) logger.error(f"连接Library/SelectableMediaFolders 出错:" + str(e))
return [] return []
def get_emby_librarys(self) -> List[dict]: def __get_emby_librarys(self) -> List[dict]:
""" """
获取Emby媒体库列表 获取Emby媒体库列表
""" """
@ -61,6 +61,29 @@ class Emby(metaclass=Singleton):
logger.error(f"连接User/Views 出错:" + str(e)) logger.error(f"连接User/Views 出错:" + str(e))
return [] return []
def get_librarys(self):
"""
获取媒体服务器所有媒体库列表
"""
if not self._host or not self._apikey:
return []
libraries = []
for library in self.__get_emby_librarys() or []:
match library.get("CollectionType"):
case "movies":
library_type = MediaType.MOVIE.value
case "tvshows":
library_type = MediaType.TV.value
case _:
continue
libraries.append({
"id": library.get("Id"),
"name": library.get("Name"),
"path": library.get("Path"),
"type": library_type
})
return libraries
def get_user(self, user_name: str = None) -> Optional[Union[str, int]]: def get_user(self, user_name: str = None) -> Optional[Union[str, int]]:
""" """
获得管理员用户 获得管理员用户
@ -269,12 +292,14 @@ class Emby(metaclass=Singleton):
return [] return []
def get_tv_episodes(self, def get_tv_episodes(self,
item_id: str = None,
title: str = None, title: str = None,
year: str = None, year: str = None,
tmdb_id: int = None, tmdb_id: int = None,
season: int = None) -> Optional[Dict[int, list]]: season: int = None) -> Optional[Dict[int, list]]:
""" """
根据标题和年份和季返回Emby中的剧集列表 根据标题和年份和季返回Emby中的剧集列表
:param item_id: Emby中的ID
:param title: 标题 :param title: 标题
:param year: 年份 :param year: 年份
:param tmdb_id: TMDBID :param tmdb_id: TMDBID
@ -284,16 +309,17 @@ class Emby(metaclass=Singleton):
if not self._host or not self._apikey: if not self._host or not self._apikey:
return None return None
# 电视剧 # 电视剧
item_id = self.__get_emby_series_id_by_name(title, year)
if item_id is None:
return None
if not item_id: if not item_id:
return {} item_id = self.__get_emby_series_id_by_name(title, year)
# 验证tmdbid是否相同 if item_id is None:
item_tmdbid = self.get_iteminfo(item_id).get("ProviderIds", {}).get("Tmdb") return None
if tmdb_id and item_tmdbid: if not item_id:
if str(tmdb_id) != str(item_tmdbid):
return {} return {}
# 验证tmdbid是否相同
item_tmdbid = self.get_iteminfo(item_id).get("ProviderIds", {}).get("Tmdb")
if tmdb_id and item_tmdbid:
if str(tmdb_id) != str(item_tmdbid):
return {}
# /Shows/Id/Episodes 查集的信息 # /Shows/Id/Episodes 查集的信息
if not season: if not season:
season = "" season = ""
@ -475,6 +501,42 @@ class Emby(metaclass=Singleton):
logger.error(f"连接Items/Id出错" + str(e)) logger.error(f"连接Items/Id出错" + str(e))
return {} return {}
def get_items(self, parent: str) -> Generator:
"""
获取媒体服务器所有媒体库列表
"""
if not parent:
yield {}
if not self._host or not self._apikey:
yield {}
req_url = "%semby/Users/%s/Items?ParentId=%s&api_key=%s" % (self._host, self._user, parent, self._apikey)
try:
res = RequestUtils().get_res(req_url)
if res and res.status_code == 200:
results = res.json().get("Items") or []
for result in results:
if not result:
continue
if result.get("Type") in ["Movie", "Series"]:
item_info = self.get_iteminfo(result.get("Id"))
yield {"id": result.get("Id"),
"library": item_info.get("ParentId"),
"type": item_info.get("Type"),
"title": item_info.get("Name"),
"original_title": item_info.get("OriginalTitle"),
"year": item_info.get("ProductionYear"),
"tmdbid": item_info.get("ProviderIds", {}).get("Tmdb"),
"imdbid": item_info.get("ProviderIds", {}).get("Imdb"),
"tvdbid": item_info.get("ProviderIds", {}).get("Tvdb"),
"path": item_info.get("Path"),
"json": str(item_info)}
elif "Folder" in result.get("Type"):
for item in self.get_items(parent=result.get('Id')):
yield item
except Exception as e:
logger.error(f"连接Users/Items出错" + str(e))
yield {}
def get_webhook_message(self, message_str: str) -> dict: def get_webhook_message(self, message_str: str) -> dict:
""" """
解析Emby Webhook报文 解析Emby Webhook报文

View File

@ -1,6 +1,6 @@
import json import json
from pathlib import Path from pathlib import Path
from typing import Optional, Tuple, Union, Any from typing import Optional, Tuple, Union, Any, List, Generator
from app import schemas from app import schemas
from app.core.context import MediaInfo from app.core.context import MediaInfo
@ -89,3 +89,50 @@ class JellyfinModule(_ModuleBase):
episode_count=media_statistic.get("EpisodeCount") or 0, episode_count=media_statistic.get("EpisodeCount") or 0,
user_count=user_count or 0 user_count=user_count or 0
) )
def mediaserver_librarys(self) -> List[schemas.MediaServerLibrary]:
"""
媒体库列表
"""
librarys = self.jellyfin.get_librarys()
if not librarys:
return []
return [schemas.MediaServerLibrary(
server="jellyfin",
id=library.get("id"),
name=library.get("name"),
type=library.get("type"),
path=library.get("path")
) for library in librarys]
def mediaserver_items(self, library_id: str) -> Generator:
"""
媒体库项目列表
"""
items = self.jellyfin.get_items(library_id)
for item in items:
yield schemas.MediaServerItem(
server="jellyfin",
library=item.get("library"),
item_id=item.get("id"),
item_type=item.get("type"),
title=item.get("title"),
original_title=item.get("original_title"),
year=item.get("year"),
tmdbid=item.get("tmdbid"),
imdbid=item.get("imdbid"),
tvdbid=item.get("tvdbid"),
path=item.get("path"),
)
def mediaserver_tv_episodes(self, item_id: Union[str, int]) -> List[schemas.MediaServerSeasonInfo]:
"""
获取剧集信息
"""
seasoninfo = self.jellyfin.get_tv_episodes(item_id=item_id)
if not seasoninfo:
return []
return [schemas.MediaServerSeasonInfo(
season=season,
episodes=episodes
) for season, episodes in seasoninfo.items()]

View File

@ -1,9 +1,10 @@
import json import json
import re import re
from typing import List, Union, Optional, Dict from typing import List, Union, Optional, Dict, Generator
from app.core.config import settings from app.core.config import settings
from app.log import logger from app.log import logger
from app.schemas import MediaType
from app.utils.http import RequestUtils from app.utils.http import RequestUtils
from app.utils.singleton import Singleton from app.utils.singleton import Singleton
from app.utils.string import StringUtils from app.utils.string import StringUtils
@ -22,7 +23,7 @@ class Jellyfin(metaclass=Singleton):
self._user = self.get_user() self._user = self.get_user()
self._serverid = self.get_server_id() self._serverid = self.get_server_id()
def get_jellyfin_librarys(self) -> List[dict]: def __get_jellyfin_librarys(self) -> List[dict]:
""" """
获取Jellyfin媒体库的信息 获取Jellyfin媒体库的信息
""" """
@ -40,6 +41,29 @@ class Jellyfin(metaclass=Singleton):
logger.error(f"连接Users/Views 出错:" + str(e)) logger.error(f"连接Users/Views 出错:" + str(e))
return [] return []
def get_librarys(self):
"""
获取媒体服务器所有媒体库列表
"""
if not self._host or not self._apikey:
return []
libraries = []
for library in self.__get_jellyfin_librarys() or []:
match library.get("CollectionType"):
case "movies":
library_type = MediaType.MOVIE.value
case "tvshows":
library_type = MediaType.TV.value
case _:
continue
libraries.append({
"id": library.get("Id"),
"name": library.get("Name"),
"path": library.get("Path"),
"type": library_type
})
return libraries
def get_user_count(self) -> int: def get_user_count(self) -> int:
""" """
获得用户数量 获得用户数量
@ -243,12 +267,14 @@ class Jellyfin(metaclass=Singleton):
return [] return []
def get_tv_episodes(self, def get_tv_episodes(self,
item_id: str = None,
title: str = None, title: str = None,
year: str = None, year: str = None,
tmdb_id: int = None, tmdb_id: int = None,
season: int = None) -> Optional[Dict[str, list]]: season: int = None) -> Optional[Dict[int, list]]:
""" """
根据标题和年份和季返回Jellyfin中的剧集列表 根据标题和年份和季返回Jellyfin中的剧集列表
:param item_id: Jellyfin中的Id
:param title: 标题 :param title: 标题
:param year: 年份 :param year: 年份
:param tmdb_id: TMDBID :param tmdb_id: TMDBID
@ -258,16 +284,17 @@ class Jellyfin(metaclass=Singleton):
if not self._host or not self._apikey or not self._user: if not self._host or not self._apikey or not self._user:
return None return None
# 查TVID # 查TVID
item_id = self.__get_jellyfin_series_id_by_name(title, year)
if item_id is None:
return None
if not item_id: if not item_id:
return {} item_id = self.__get_jellyfin_series_id_by_name(title, year)
# 验证tmdbid是否相同 if item_id is None:
item_tmdbid = self.get_iteminfo(item_id).get("ProviderIds", {}).get("Tmdb") return None
if tmdb_id and item_tmdbid: if not item_id:
if str(tmdb_id) != str(item_tmdbid):
return {} return {}
# 验证tmdbid是否相同
item_tmdbid = self.get_iteminfo(item_id).get("ProviderIds", {}).get("Tmdb")
if tmdb_id and item_tmdbid:
if str(tmdb_id) != str(item_tmdbid):
return {}
if not season: if not season:
season = "" season = ""
try: try:
@ -338,6 +365,24 @@ class Jellyfin(metaclass=Singleton):
logger.error(f"连接Library/Refresh出错" + str(e)) logger.error(f"连接Library/Refresh出错" + str(e))
return False return False
def get_webhook_message(self, message: dict) -> dict:
"""
解析Jellyfin报文
"""
eventItem = {'event': message.get('NotificationType', ''),
'item_name': message.get('Name'),
'user_name': message.get('NotificationUsername'),
"channel": "jellyfin"
}
# 获取消息图片
if eventItem.get("item_id"):
# 根据返回的item_id去调用媒体服务器获取
eventItem['image_url'] = self.get_remote_image_by_id(item_id=eventItem.get('item_id'),
image_type="Backdrop")
return eventItem
def get_iteminfo(self, itemid: str) -> dict: def get_iteminfo(self, itemid: str) -> dict:
""" """
获取单个项目详情 获取单个项目详情
@ -356,20 +401,38 @@ class Jellyfin(metaclass=Singleton):
logger.error(f"连接Users/Items出错" + str(e)) logger.error(f"连接Users/Items出错" + str(e))
return {} return {}
def get_webhook_message(self, message: dict) -> dict: def get_items(self, parent: str) -> Generator:
""" """
解析Jellyfin报文 获取媒体服务器所有媒体库列表
""" """
eventItem = {'event': message.get('NotificationType', ''), if not parent:
'item_name': message.get('Name'), yield {}
'user_name': message.get('NotificationUsername'), if not self._host or not self._apikey:
"channel": "jellyfin" yield {}
} req_url = "%sUsers/%s/Items?parentId=%s&api_key=%s" % (self._host, self._user, parent, self._apikey)
try:
# 获取消息图片 res = RequestUtils().get_res(req_url)
if eventItem.get("item_id"): if res and res.status_code == 200:
# 根据返回的item_id去调用媒体服务器获取 results = res.json().get("Items") or []
eventItem['image_url'] = self.get_remote_image_by_id(item_id=eventItem.get('item_id'), for result in results:
image_type="Backdrop") if not result:
continue
return eventItem if result.get("Type") in ["Movie", "Series"]:
item_info = self.get_iteminfo(result.get("Id"))
yield {"id": result.get("Id"),
"library": item_info.get("ParentId"),
"type": item_info.get("Type"),
"title": item_info.get("Name"),
"original_title": item_info.get("OriginalTitle"),
"year": item_info.get("ProductionYear"),
"tmdbid": item_info.get("ProviderIds", {}).get("Tmdb"),
"imdbid": item_info.get("ProviderIds", {}).get("Imdb"),
"tvdbid": item_info.get("ProviderIds", {}).get("Tvdb"),
"path": item_info.get("Path"),
"json": str(item_info)}
elif "Folder" in result.get("Type"):
for item in self.get_items(result.get("Id")):
yield item
except Exception as e:
logger.error(f"连接Users/Items出错" + str(e))
yield {}

View File

@ -1,5 +1,5 @@
from pathlib import Path from pathlib import Path
from typing import Optional, Tuple, Union, Any from typing import Optional, Tuple, Union, Any, List, Generator
from app import schemas from app import schemas
from app.core.context import MediaInfo from app.core.context import MediaInfo
@ -86,3 +86,50 @@ class PlexModule(_ModuleBase):
episode_count=media_statistic.get("EpisodeCount") or 0, episode_count=media_statistic.get("EpisodeCount") or 0,
user_count=1 user_count=1
) )
def mediaserver_librarys(self) -> List[schemas.MediaServerLibrary]:
"""
媒体库列表
"""
librarys = self.plex.get_librarys()
if not librarys:
return []
return [schemas.MediaServerLibrary(
server="plex",
id=library.get("id"),
name=library.get("name"),
type=library.get("type"),
path=library.get("path")
) for library in librarys]
def mediaserver_items(self, library_id: str) -> Generator:
"""
媒体库项目列表
"""
items = self.plex.get_items(library_id)
for item in items:
yield schemas.MediaServerItem(
server="plex",
library=item.get("library"),
item_id=item.get("id"),
item_type=item.get("type"),
title=item.get("title"),
original_title=item.get("original_title"),
year=item.get("year"),
tmdbid=item.get("tmdbid"),
imdbid=item.get("imdbid"),
tvdbid=item.get("tvdbid"),
path=item.get("path"),
)
def mediaserver_tv_episodes(self, item_id: Union[str, int]) -> List[schemas.MediaServerSeasonInfo]:
"""
获取剧集信息
"""
seasoninfo = self.plex.get_tv_episodes(item_id=item_id)
if not seasoninfo:
return []
return [schemas.MediaServerSeasonInfo(
season=season,
episodes=episodes
) for season, episodes in seasoninfo.items()]

View File

@ -1,6 +1,6 @@
import json import json
from pathlib import Path from pathlib import Path
from typing import List, Optional, Dict, Tuple from typing import List, Optional, Dict, Tuple, Generator
from urllib.parse import quote_plus from urllib.parse import quote_plus
from plexapi import media from plexapi import media
@ -8,7 +8,7 @@ from plexapi.server import PlexServer
from app.core.config import settings from app.core.config import settings
from app.log import logger from app.log import logger
from app.schemas import RefreshMediaItem from app.schemas import RefreshMediaItem, MediaType
from app.utils.singleton import Singleton from app.utils.singleton import Singleton
@ -30,6 +30,34 @@ class Plex(metaclass=Singleton):
self._plex = None self._plex = None
logger.error(f"Plex服务器连接失败{str(e)}") logger.error(f"Plex服务器连接失败{str(e)}")
def get_librarys(self):
"""
获取媒体服务器所有媒体库列表
"""
if not self._plex:
return []
try:
self._libraries = self._plex.library.sections()
except Exception as err:
logger.error(f"获取媒体服务器所有媒体库列表出错:{str(err)}")
return []
libraries = []
for library in self._libraries:
match library.type:
case "movie":
library_type = MediaType.MOVIE.value
case "show":
library_type = MediaType.TV.value
case _:
continue
libraries.append({
"id": library.key,
"name": library.title,
"path": library.locations,
"type": library_type
})
return libraries
def get_activity_log(self, num: int = 30) -> Optional[List[dict]]: def get_activity_log(self, num: int = 30) -> Optional[List[dict]]:
""" """
获取Plex活动记录 获取Plex活动记录
@ -111,11 +139,13 @@ class Plex(metaclass=Singleton):
return ret_movies return ret_movies
def get_tv_episodes(self, def get_tv_episodes(self,
item_id: str = None,
title: str = None, title: str = None,
year: str = None, year: str = None,
season: int = None) -> Optional[Dict[str, list]]: season: int = None) -> Optional[Dict[int, list]]:
""" """
根据标题年份季查询电视剧所有集信息 根据标题年份季查询电视剧所有集信息
:param item_id: 媒体ID
:param title: 标题 :param title: 标题
:param year: 年份可以为空为空时不按年份过滤 :param year: 年份可以为空为空时不按年份过滤
:param season: 季号数字 :param season: 季号数字
@ -123,7 +153,10 @@ class Plex(metaclass=Singleton):
""" """
if not self._plex: if not self._plex:
return {} return {}
videos = self._plex.library.search(title=title, year=year, libtype="show") if item_id:
videos = self._plex.library.sectionByID(item_id).all()
else:
videos = self._plex.library.search(title=title, year=year, libtype="show")
if not videos: if not videos:
return {} return {}
episodes = videos[0].episodes() episodes = videos[0].episodes()
@ -252,6 +285,38 @@ class Plex(metaclass=Singleton):
break break
return ids return ids
def get_items(self, parent: str) -> Generator:
"""
获取媒体服务器所有媒体库列表
"""
if not parent:
yield {}
if not self._plex:
yield {}
try:
section = self._plex.library.sectionByID(parent)
if section:
for item in section.all():
if not item:
continue
ids = self.__get_ids(item.guids)
path = None
if item.locations:
path = item.locations[0]
yield {"id": item.key,
"library": item.librarySectionID,
"type": item.type,
"title": item.title,
"original_title": item.originalTitle,
"year": item.year,
"tmdbid": ids['tmdb_id'],
"imdbid": ids['imdb_id'],
"tvdbid": ids['tvdb_id'],
"path": path}
except Exception as err:
logger.error(f"获取媒体库列表出错:{err}")
yield {}
def get_webhook_message(self, message_str: str) -> dict: def get_webhook_message(self, message_str: str) -> dict:
""" """
解析Plex报文 解析Plex报文

View File

@ -8,6 +8,7 @@ from apscheduler.schedulers.background import BackgroundScheduler
from app.chain import ChainBase from app.chain import ChainBase
from app.chain.cookiecloud import CookieCloudChain from app.chain.cookiecloud import CookieCloudChain
from app.chain.douban import DoubanChain from app.chain.douban import DoubanChain
from app.chain.mediaserver import MediaServerChain
from app.chain.subscribe import SubscribeChain from app.chain.subscribe import SubscribeChain
from app.chain.transfer import TransferChain from app.chain.transfer import TransferChain
from app.core.config import settings from app.core.config import settings
@ -48,6 +49,11 @@ class Scheduler(metaclass=Singleton):
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=1), next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=1),
name="同步CookieCloud站点") name="同步CookieCloud站点")
# 媒体服务器同步
if settings.MEDIASERVER_SYNC_INTERVAL:
self._scheduler.add_job(MediaServerChain().sync, "interval",
hours=settings.MEDIASERVER_SYNC_INTERVAL, name="同步媒体服务器")
# 新增订阅时搜索5分钟检查一次 # 新增订阅时搜索5分钟检查一次
self._scheduler.add_job(SubscribeChain().search, "interval", self._scheduler.add_job(SubscribeChain().search, "interval",
minutes=5, kwargs={'state': 'N'}) minutes=5, kwargs={'state': 'N'})

View File

@ -8,3 +8,7 @@ from .servarr import *
from .plugin import * from .plugin import *
from .history import * from .history import *
from .dashboard import * from .dashboard import *
from .mediaserver import *
from .message import *
from .tmdb import *
from .transfer import *

View File

@ -1,10 +1,7 @@
from pathlib import Path from typing import Optional, Dict, List
from typing import Optional, Dict, List, Union
from pydantic import BaseModel from pydantic import BaseModel
from app.schemas.types import MediaType, NotificationType, MessageChannel
class MetaInfo(BaseModel): class MetaInfo(BaseModel):
""" """
@ -199,165 +196,3 @@ class Context(BaseModel):
media_info: Optional[MediaInfo] = None media_info: Optional[MediaInfo] = None
# 种子信息 # 种子信息
torrent_info: Optional[TorrentInfo] = None torrent_info: Optional[TorrentInfo] = None
class TransferTorrent(BaseModel):
"""
待转移任务信息
"""
title: Optional[str] = None
path: Optional[Path] = None
hash: Optional[str] = None
tags: Optional[str] = None
class DownloadingTorrent(BaseModel):
"""
下载中任务信息
"""
hash: Optional[str] = None
title: Optional[str] = None
name: Optional[str] = None
year: Optional[str] = None
season_episode: Optional[str] = None
size: Optional[float] = 0
progress: Optional[float] = 0
state: Optional[str] = 'downloading'
upspeed: Optional[str] = None
dlspeed: Optional[str] = None
media: Optional[dict] = {}
class TransferInfo(BaseModel):
"""
文件转移结果信息
"""
# 转移⼁路径
path: Optional[Path] = None
# 转移后路径
target_path: Optional[Path] = None
# 处理文件数
file_count: Optional[int] = 0
# 总文件大小
total_size: Optional[float] = 0
# 失败清单
fail_list: Optional[list] = []
# 错误信息
message: Optional[str] = None
class ExistMediaInfo(BaseModel):
"""
媒体服务器存在媒体信息
"""
# 类型 电影、电视剧
type: Optional[MediaType]
# 季
seasons: Optional[Dict[int, list]] = {}
class NotExistMediaInfo(BaseModel):
"""
媒体服务器不存在媒体信息
"""
# 季
season: Optional[int] = None
# 剧集列表
episodes: Optional[list] = []
# 总集数
total_episodes: Optional[int] = 0
# 开始集
start_episode: Optional[int] = 0
class RefreshMediaItem(BaseModel):
"""
媒体库刷新信息
"""
# 标题
title: Optional[str] = None
# 年份
year: Optional[str] = None
# 类型
type: Optional[MediaType] = None
# 类别
category: Optional[str] = None
# 目录
target_path: Optional[Path] = None
class TmdbSeason(BaseModel):
"""
TMDB季信息
"""
air_date: Optional[str] = None
episode_count: Optional[int] = None
name: Optional[str] = None
overview: Optional[str] = None
poster_path: Optional[str] = None
season_number: Optional[int] = None
vote_average: Optional[float] = None
class TmdbEpisode(BaseModel):
"""
TMDB集信息
"""
air_date: Optional[str] = None
episode_number: Optional[int] = None
name: Optional[str] = None
overview: Optional[str] = None
runtime: Optional[int] = None
season_number: Optional[int] = None
still_path: Optional[str] = None
vote_average: Optional[float] = None
crew: Optional[list] = []
guest_stars: Optional[list] = []
class Notification(BaseModel):
"""
消息
"""
# 消息渠道
channel: Optional[MessageChannel] = None
# 消息类型
mtype: Optional[NotificationType] = None
# 标题
title: Optional[str] = None
# 文本内容
text: Optional[str] = None
# 图片
image: Optional[str] = None
# 链接
link: Optional[str] = None
# 用户ID
userid: Optional[Union[str, int]] = None
class CommingMessage(BaseModel):
"""
外来消息
"""
# 用户ID
userid: Optional[Union[str, int]] = None
# 用户名称
username: Optional[str] = None
# 消息渠道
channel: Optional[MessageChannel] = None
# 消息体
text: Optional[str] = None
class NotificationSwitch(BaseModel):
"""
消息开关
"""
# 消息类型
mtype: Optional[str] = None
# 微信开关
wechat: Optional[bool] = False
# TG开关
telegram: Optional[bool] = False
# Slack开关
slack: Optional[bool] = False

111
app/schemas/mediaserver.py Normal file
View File

@ -0,0 +1,111 @@
from pathlib import Path
from typing import Optional, Dict, Union, List
from pydantic import BaseModel
from app.schemas.types import MediaType
class ExistMediaInfo(BaseModel):
"""
媒体服务器存在媒体信息
"""
# 类型 电影、电视剧
type: Optional[MediaType]
# 季
seasons: Optional[Dict[int, list]] = {}
class NotExistMediaInfo(BaseModel):
"""
媒体服务器不存在媒体信息
"""
# 季
season: Optional[int] = None
# 剧集列表
episodes: Optional[list] = []
# 总集数
total_episodes: Optional[int] = 0
# 开始集
start_episode: Optional[int] = 0
class RefreshMediaItem(BaseModel):
"""
媒体库刷新信息
"""
# 标题
title: Optional[str] = None
# 年份
year: Optional[str] = None
# 类型
type: Optional[MediaType] = None
# 类别
category: Optional[str] = None
# 目录
target_path: Optional[Path] = None
class MediaServerLibrary(BaseModel):
"""
媒体服务器媒体库信息
"""
# 服务器
server: Optional[str] = None
# ID
id: Optional[Union[str, int]] = None
# 名称
name: Optional[str] = None
# 路径
path: Optional[Union[str, list]] = None
# 类型
type: Optional[str] = None
# 封面图
image: Optional[str] = None
class MediaServerItem(BaseModel):
"""
媒体服务器媒体信息
"""
# ID
id: Optional[Union[str, int]] = None
# 服务器
server: Optional[str] = None
# 媒体库ID
library: Optional[Union[str, int]] = None
# ID
item_id: Optional[str] = None
# 类型
item_type: Optional[str] = None
# 标题
title: Optional[str] = None
# 原标题
original_title: Optional[str] = None
# 年份
year: Optional[str] = None
# TMDBID
tmdbid: Optional[int] = None
# IMDBID
imdbid: Optional[str] = None
# TVDBID
tvdbid: Optional[str] = None
# 路径
path: Optional[str] = None
# 季集
seasoninfo: Optional[Dict[int, list]] = None
# 备注
note: Optional[str] = None
# 同步时间
lst_mod_date: Optional[str] = None
class Config:
orm_mode = True
class MediaServerSeasonInfo(BaseModel):
"""
媒体服务器媒体剧集信息
"""
season: Optional[int] = None
episodes: Optional[List[int]] = []

53
app/schemas/message.py Normal file
View File

@ -0,0 +1,53 @@
from typing import Optional, Union
from pydantic import BaseModel
from app.schemas.types import NotificationType, MessageChannel
class CommingMessage(BaseModel):
"""
外来消息
"""
# 用户ID
userid: Optional[Union[str, int]] = None
# 用户名称
username: Optional[str] = None
# 消息渠道
channel: Optional[MessageChannel] = None
# 消息体
text: Optional[str] = None
class Notification(BaseModel):
"""
消息
"""
# 消息渠道
channel: Optional[MessageChannel] = None
# 消息类型
mtype: Optional[NotificationType] = None
# 标题
title: Optional[str] = None
# 文本内容
text: Optional[str] = None
# 图片
image: Optional[str] = None
# 链接
link: Optional[str] = None
# 用户ID
userid: Optional[Union[str, int]] = None
class NotificationSwitch(BaseModel):
"""
消息开关
"""
# 消息类型
mtype: Optional[str] = None
# 微信开关
wechat: Optional[bool] = False
# TG开关
telegram: Optional[bool] = False
# Slack开关
slack: Optional[bool] = False

32
app/schemas/tmdb.py Normal file
View File

@ -0,0 +1,32 @@
from typing import Optional
from pydantic import BaseModel
class TmdbSeason(BaseModel):
"""
TMDB季信息
"""
air_date: Optional[str] = None
episode_count: Optional[int] = None
name: Optional[str] = None
overview: Optional[str] = None
poster_path: Optional[str] = None
season_number: Optional[int] = None
vote_average: Optional[float] = None
class TmdbEpisode(BaseModel):
"""
TMDB集信息
"""
air_date: Optional[str] = None
episode_number: Optional[int] = None
name: Optional[str] = None
overview: Optional[str] = None
runtime: Optional[int] = None
season_number: Optional[int] = None
still_path: Optional[str] = None
vote_average: Optional[float] = None
crew: Optional[list] = []
guest_stars: Optional[list] = []

49
app/schemas/transfer.py Normal file
View File

@ -0,0 +1,49 @@
from pathlib import Path
from typing import Optional
from pydantic import BaseModel
class TransferTorrent(BaseModel):
"""
待转移任务信息
"""
title: Optional[str] = None
path: Optional[Path] = None
hash: Optional[str] = None
tags: Optional[str] = None
class DownloadingTorrent(BaseModel):
"""
下载中任务信息
"""
hash: Optional[str] = None
title: Optional[str] = None
name: Optional[str] = None
year: Optional[str] = None
season_episode: Optional[str] = None
size: Optional[float] = 0
progress: Optional[float] = 0
state: Optional[str] = 'downloading'
upspeed: Optional[str] = None
dlspeed: Optional[str] = None
media: Optional[dict] = {}
class TransferInfo(BaseModel):
"""
文件转移结果信息
"""
# 转移⼁路径
path: Optional[Path] = None
# 转移后路径
target_path: Optional[Path] = None
# 处理文件数
file_count: Optional[int] = 0
# 总文件大小
total_size: Optional[float] = 0
# 失败清单
fail_list: Optional[list] = []
# 错误信息
message: Optional[str] = None