diff --git a/README.md b/README.md index 5ff15668..b41148aa 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,8 @@ docker pull jxxghp/moviepilot:latest **MEDIASERVER:** 媒体服务器,支持`emby`/`jellyfin`/`plex`,同时还需要配置对应媒体服务器的环境变量,非对应媒体服务器的变量可删除,推荐使用`emby` +**MEDIASERVER_SYNC_INTERVAL:** 媒体服务器同步间隔(小时),默认`6`,留空则不同步 + `emby`设置项: - **EMBY_HOST:** Emby服务器地址,格式:`ip:port`,https需要添加`https://`前缀 @@ -218,10 +220,10 @@ docker pull jxxghp/moviepilot:latest - [x] 自定义识别词 - [x] 便捷工具 - [x] 过滤规则维护 +- [x] 本地存在标识 +- [ ] 插件管理 - [ ] 手动整理功能增强 -- [ ] 本地存在标识 - [ ] 媒体详情页面 - [ ] 洗版支持 -- [ ] 插件管理 diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index 9cab5056..c983f687 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -1,10 +1,13 @@ from typing import List, Any from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session from app import schemas from app.chain.media import MediaChain from app.core.security import verify_token +from app.db import get_db +from app.db.mediaserver_oper import MediaServerOper router = APIRouter() @@ -35,3 +38,22 @@ def search_by_title(title: str, if medias: return [media.to_dict() for media in medias[(page - 1) * count: page * count]] 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 {} + }) diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index b5b44541..4c542259 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -1,5 +1,4 @@ import json -import json import time from datetime import datetime from typing import Union diff --git a/app/chain/douban.py b/app/chain/douban.py index de1a9517..6037a7b5 100644 --- a/app/chain/douban.py +++ b/app/chain/douban.py @@ -12,7 +12,7 @@ from app.core.context import MediaInfo from app.core.metainfo import MetaInfo from app.helper.rss import RssHelper from app.log import logger -from app.schemas import MediaType, Notification, MessageChannel, NotificationType +from app.schemas import MediaType, Notification, MessageChannel 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)) self.sync() - self.post_message(Notification(channel=channel, mtype=NotificationType.Subscribe, + self.post_message(Notification(channel=channel, title="同步豆瓣想看数据完成!", userid=userid)) def sync(self): diff --git a/app/chain/mediaserver.py b/app/chain/mediaserver.py new file mode 100644 index 00000000..5e43d1fc --- /dev/null +++ b/app/chain/mediaserver.py @@ -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) diff --git a/app/chain/site.py b/app/chain/site.py index 284ff50f..1fcdc37a 100644 --- a/app/chain/site.py +++ b/app/chain/site.py @@ -123,7 +123,8 @@ class SiteChain(ChainBase): if not site: self.post_message(Notification( channel=channel, - title=f"站点编号 {site_id} 不存在!", userid=userid)) + title=f"站点编号 {site_id} 不存在!", + userid=userid)) return # 禁用站点 self.siteoper.update(site_id, { diff --git a/app/command.py b/app/command.py index 852ca2a0..bea1fb70 100644 --- a/app/command.py +++ b/app/command.py @@ -6,6 +6,7 @@ from app.chain import ChainBase from app.chain.cookiecloud import CookieCloudChain from app.chain.douban import DoubanChain from app.chain.download import DownloadChain +from app.chain.mediaserver import MediaServerChain from app.chain.site import SiteChain from app.chain.subscribe import SubscribeChain from app.chain.transfer import TransferChain @@ -74,6 +75,11 @@ class Command(metaclass=Singleton): "description": "同步豆瓣想看", "data": {} }, + "/mediaserver_sync": { + "func": MediaServerChain().remote_sync, + "description": "同步媒体服务器", + "data": {} + }, "/subscribes": { "func": SubscribeChain().remote_list, "description": "查询订阅", diff --git a/app/core/config.py b/app/core/config.py index 9ba6285d..cf1efe95 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -113,6 +113,8 @@ class Settings(BaseSettings): DOWNLOAD_CATEGORY: bool = False # 媒体服务器 emby/jellyfin/plex MEDIASERVER: str = "emby" + # 媒体服务器同步间隔(小时) + MEDIASERVER_SYNC_INTERVAL: int = 6 # EMBY服务器地址,IP:PORT EMBY_HOST: str = None # EMBY Api Key diff --git a/app/db/mediaserver_oper.py b/app/db/mediaserver_oper.py new file mode 100644 index 00000000..f610f034 --- /dev/null +++ b/app/db/mediaserver_oper.py @@ -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 diff --git a/app/db/models/mediaserver.py b/app/db/models/mediaserver.py new file mode 100644 index 00000000..ca77ddc7 --- /dev/null +++ b/app/db/models/mediaserver.py @@ -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() diff --git a/app/modules/douban/scraper.py b/app/modules/douban/scraper.py index 501439ec..4e532822 100644 --- a/app/modules/douban/scraper.py +++ b/app/modules/douban/scraper.py @@ -181,4 +181,4 @@ class DoubanScraper: return xml_str = doc.toprettyxml(indent=" ", encoding="utf-8") file_path.write_bytes(xml_str) - logger.info(f"NFO文件已保存:{file_path}") \ No newline at end of file + logger.info(f"NFO文件已保存:{file_path}") diff --git a/app/modules/emby/__init__.py b/app/modules/emby/__init__.py index ee589cd6..dea6ad1d 100644 --- a/app/modules/emby/__init__.py +++ b/app/modules/emby/__init__.py @@ -1,5 +1,5 @@ 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.core.context import MediaInfo @@ -97,3 +97,50 @@ class EmbyModule(_ModuleBase): episode_count=media_statistic.get("EpisodeCount") 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()] diff --git a/app/modules/emby/emby.py b/app/modules/emby/emby.py index 1112c50f..52871915 100644 --- a/app/modules/emby/emby.py +++ b/app/modules/emby/emby.py @@ -1,7 +1,7 @@ import json import re 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.log import logger @@ -43,7 +43,7 @@ class Emby(metaclass=Singleton): logger.error(f"连接Library/SelectableMediaFolders 出错:" + str(e)) return [] - def get_emby_librarys(self) -> List[dict]: + def __get_emby_librarys(self) -> List[dict]: """ 获取Emby媒体库列表 """ @@ -61,6 +61,29 @@ class Emby(metaclass=Singleton): logger.error(f"连接User/Views 出错:" + str(e)) 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]]: """ 获得管理员用户 @@ -269,12 +292,14 @@ class Emby(metaclass=Singleton): return [] def get_tv_episodes(self, + item_id: str = None, title: str = None, year: str = None, tmdb_id: int = None, season: int = None) -> Optional[Dict[int, list]]: """ 根据标题和年份和季,返回Emby中的剧集列表 + :param item_id: Emby中的ID :param title: 标题 :param year: 年份 :param tmdb_id: TMDBID @@ -284,16 +309,17 @@ class Emby(metaclass=Singleton): if not self._host or not self._apikey: return None # 电视剧 - item_id = self.__get_emby_series_id_by_name(title, year) - if item_id is None: - return None if not item_id: - 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): + item_id = self.__get_emby_series_id_by_name(title, year) + if item_id is None: + return None + if not item_id: 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 查集的信息 if not season: season = "" @@ -475,6 +501,42 @@ class Emby(metaclass=Singleton): logger.error(f"连接Items/Id出错:" + str(e)) 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: """ 解析Emby Webhook报文 diff --git a/app/modules/jellyfin/__init__.py b/app/modules/jellyfin/__init__.py index 560f5889..710667d0 100644 --- a/app/modules/jellyfin/__init__.py +++ b/app/modules/jellyfin/__init__.py @@ -1,6 +1,6 @@ import json 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.core.context import MediaInfo @@ -89,3 +89,50 @@ class JellyfinModule(_ModuleBase): episode_count=media_statistic.get("EpisodeCount") 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()] diff --git a/app/modules/jellyfin/jellyfin.py b/app/modules/jellyfin/jellyfin.py index ac6720d2..47c5433f 100644 --- a/app/modules/jellyfin/jellyfin.py +++ b/app/modules/jellyfin/jellyfin.py @@ -1,9 +1,10 @@ import json 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.log import logger +from app.schemas import MediaType from app.utils.http import RequestUtils from app.utils.singleton import Singleton from app.utils.string import StringUtils @@ -22,7 +23,7 @@ class Jellyfin(metaclass=Singleton): self._user = self.get_user() self._serverid = self.get_server_id() - def get_jellyfin_librarys(self) -> List[dict]: + def __get_jellyfin_librarys(self) -> List[dict]: """ 获取Jellyfin媒体库的信息 """ @@ -40,6 +41,29 @@ class Jellyfin(metaclass=Singleton): logger.error(f"连接Users/Views 出错:" + str(e)) 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: """ 获得用户数量 @@ -243,12 +267,14 @@ class Jellyfin(metaclass=Singleton): return [] def get_tv_episodes(self, + item_id: str = None, title: str = None, year: str = None, tmdb_id: int = None, - season: int = None) -> Optional[Dict[str, list]]: + season: int = None) -> Optional[Dict[int, list]]: """ 根据标题和年份和季,返回Jellyfin中的剧集列表 + :param item_id: Jellyfin中的Id :param title: 标题 :param year: 年份 :param tmdb_id: TMDBID @@ -258,16 +284,17 @@ class Jellyfin(metaclass=Singleton): if not self._host or not self._apikey or not self._user: return None # 查TVID - item_id = self.__get_jellyfin_series_id_by_name(title, year) - if item_id is None: - return None if not item_id: - 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): + item_id = self.__get_jellyfin_series_id_by_name(title, year) + if item_id is None: + return None + if not item_id: 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: season = "" try: @@ -338,6 +365,24 @@ class Jellyfin(metaclass=Singleton): logger.error(f"连接Library/Refresh出错:" + str(e)) 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: """ 获取单个项目详情 @@ -356,20 +401,38 @@ class Jellyfin(metaclass=Singleton): logger.error(f"连接Users/Items出错:" + str(e)) return {} - def get_webhook_message(self, message: dict) -> dict: + def get_items(self, parent: str) -> Generator: """ - 解析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 + if not parent: + yield {} + if not self._host or not self._apikey: + 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 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(result.get("Id")): + yield item + except Exception as e: + logger.error(f"连接Users/Items出错:" + str(e)) + yield {} diff --git a/app/modules/plex/__init__.py b/app/modules/plex/__init__.py index 1abb98fa..05d89664 100644 --- a/app/modules/plex/__init__.py +++ b/app/modules/plex/__init__.py @@ -1,5 +1,5 @@ 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.core.context import MediaInfo @@ -86,3 +86,50 @@ class PlexModule(_ModuleBase): episode_count=media_statistic.get("EpisodeCount") or 0, 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()] diff --git a/app/modules/plex/plex.py b/app/modules/plex/plex.py index 1bec1b0d..77d4f25c 100644 --- a/app/modules/plex/plex.py +++ b/app/modules/plex/plex.py @@ -1,6 +1,6 @@ import json 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 plexapi import media @@ -8,7 +8,7 @@ from plexapi.server import PlexServer from app.core.config import settings from app.log import logger -from app.schemas import RefreshMediaItem +from app.schemas import RefreshMediaItem, MediaType from app.utils.singleton import Singleton @@ -30,6 +30,34 @@ class Plex(metaclass=Singleton): self._plex = None 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]]: """ 获取Plex活动记录 @@ -111,11 +139,13 @@ class Plex(metaclass=Singleton): return ret_movies def get_tv_episodes(self, + item_id: str = None, title: 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 year: 年份,可以为空,为空时不按年份过滤 :param season: 季号,数字 @@ -123,7 +153,10 @@ class Plex(metaclass=Singleton): """ if not self._plex: 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: return {} episodes = videos[0].episodes() @@ -252,6 +285,38 @@ class Plex(metaclass=Singleton): break 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: """ 解析Plex报文 diff --git a/app/scheduler.py b/app/scheduler.py index cf874b49..d2770970 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -8,6 +8,7 @@ from apscheduler.schedulers.background import BackgroundScheduler from app.chain import ChainBase from app.chain.cookiecloud import CookieCloudChain from app.chain.douban import DoubanChain +from app.chain.mediaserver import MediaServerChain from app.chain.subscribe import SubscribeChain from app.chain.transfer import TransferChain 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), name="同步CookieCloud站点") + # 媒体服务器同步 + if settings.MEDIASERVER_SYNC_INTERVAL: + self._scheduler.add_job(MediaServerChain().sync, "interval", + hours=settings.MEDIASERVER_SYNC_INTERVAL, name="同步媒体服务器") + # 新增订阅时搜索(5分钟检查一次) self._scheduler.add_job(SubscribeChain().search, "interval", minutes=5, kwargs={'state': 'N'}) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index 06cfac75..5dc38971 100644 --- a/app/schemas/__init__.py +++ b/app/schemas/__init__.py @@ -8,3 +8,7 @@ from .servarr import * from .plugin import * from .history import * from .dashboard import * +from .mediaserver import * +from .message import * +from .tmdb import * +from .transfer import * diff --git a/app/schemas/context.py b/app/schemas/context.py index 15db4bff..ae961a84 100644 --- a/app/schemas/context.py +++ b/app/schemas/context.py @@ -1,10 +1,7 @@ -from pathlib import Path -from typing import Optional, Dict, List, Union +from typing import Optional, Dict, List from pydantic import BaseModel -from app.schemas.types import MediaType, NotificationType, MessageChannel - class MetaInfo(BaseModel): """ @@ -199,165 +196,3 @@ class Context(BaseModel): media_info: Optional[MediaInfo] = 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 diff --git a/app/schemas/mediaserver.py b/app/schemas/mediaserver.py new file mode 100644 index 00000000..d6e8aa37 --- /dev/null +++ b/app/schemas/mediaserver.py @@ -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]] = [] diff --git a/app/schemas/message.py b/app/schemas/message.py new file mode 100644 index 00000000..2849d53a --- /dev/null +++ b/app/schemas/message.py @@ -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 diff --git a/app/schemas/tmdb.py b/app/schemas/tmdb.py new file mode 100644 index 00000000..58c21618 --- /dev/null +++ b/app/schemas/tmdb.py @@ -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] = [] diff --git a/app/schemas/transfer.py b/app/schemas/transfer.py new file mode 100644 index 00000000..5048b941 --- /dev/null +++ b/app/schemas/transfer.py @@ -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