Compare commits

...

60 Commits

Author SHA1 Message Date
jxxghp
6a2cde0664 - Bug修复 2024-01-16 20:24:58 +08:00
jxxghp
c86cc2cb51 Merge pull request #1353 from thsrite/main 2024-01-12 19:04:14 +08:00
thsrite
6d7a63ff61 fix c044e594 2024-01-12 10:07:39 +08:00
thsrite
c044e59481 fix emby/jellyfin首页继续观看、最近添加 2024-01-12 09:56:55 +08:00
jxxghp
3c31bf24e5 v1.5.9
- 修复了个别情况下订阅重复下载的问题
- 仪表板中继续观看和最近添加组件支持过滤媒体库黑名单内容
- 增加了Fanart的开关设置(FANART_ENABLE,默认开),关闭后可减少网络请求但刮削图片数量会大幅减少
2024-01-11 20:18:58 +08:00
jxxghp
d89c80ac89 fix #1296 2024-01-11 16:35:51 +08:00
jxxghp
8236d6c8d7 Merge pull request #1328 from thsrite/main 2024-01-10 12:25:19 +08:00
thsrite
3646540a7f fix 2024-01-10 11:21:03 +08:00
thsrite
c1ecdfc61d Revert "fix #907"
This reverts commit 4dcefb141a.
2024-01-10 11:19:25 +08:00
thsrite
7587946d51 fix c674e320 2024-01-10 10:53:32 +08:00
jxxghp
3ad64baaeb 更新 README.md 2024-01-10 10:38:43 +08:00
jxxghp
24c43b53a2 Merge pull request #1338 from thsrite/fanart_switch 2024-01-10 10:29:59 +08:00
thsrite
53a6a1c691 feat Fanart开关支持环境变量配置,默认开启 2024-01-10 10:13:51 +08:00
jxxghp
c3ba83c7ca fix:订阅重复下载问题 2024-01-09 13:16:39 +08:00
jxxghp
d9b349873e v1.5.8
- 修复了启用内置代理时媒体组件无法显示图片的问题
- 优化媒体组件用户匹配,优先展示媒体服务器中同名用户的信息
- 用户认证失败时发送消息提醒
- UI主题支持跟随系统主题自动切换
2024-01-08 13:18:34 +08:00
thsrite
4dcefb141a fix #907 2024-01-08 13:05:48 +08:00
thsrite
c674e32046 fix 首页继续观看、最近添加排除黑名单媒体库 2024-01-08 13:05:09 +08:00
jxxghp
8aa1027aae fix image proxy 2024-01-08 12:24:00 +08:00
jxxghp
b4cb9c3fb3 fix 2024-01-07 18:24:13 +08:00
jxxghp
d82ab5d60d feat:用户认证失败时发送消息提醒 2024-01-07 12:10:51 +08:00
jxxghp
979b636eec fix bug 2024-01-07 11:51:49 +08:00
jxxghp
bf8a75b201 fix:优化emby、jellyfin用户匹配 2024-01-07 11:46:29 +08:00
jxxghp
87111c8736 fix exists api 2024-01-06 11:20:08 +08:00
jxxghp
9b97e478aa - 修复Plex媒体图片展示与跳转 2024-01-06 10:59:46 +08:00
jxxghp
2af7abee3c fix #1320 2024-01-06 08:46:18 +08:00
jxxghp
2c8a41ebad fix #1316 2024-01-06 08:31:07 +08:00
jxxghp
c632cfd6b9 - 优化媒体组件的图片代理 2024-01-05 21:40:21 +08:00
jxxghp
7f05df2fb3 fix count 2024-01-05 21:37:19 +08:00
jxxghp
ff33432809 fix api 2024-01-05 21:35:01 +08:00
jxxghp
0a57e69bcf v1.5.7
- 媒体服务器支持配置外网播放地址,媒体详情支持跳转在线播放
- `设定-订阅`中增加了文件大少过滤规则,以及控制订阅时是否立即弹出编辑框的选项(默认关闭)
- 仪表板显示的组件支持自定义,同时增加了媒体库相关面板组件
- 支持插件将定时作业任务注册到主程序,以在`设定-服务`中统一管理
2024-01-05 20:47:53 +08:00
jxxghp
7af8b15dbb fix apis 2024-01-05 20:31:15 +08:00
jxxghp
bc4931d971 fix api 2024-01-05 20:21:19 +08:00
jxxghp
cfb029b6b4 fix api 2024-01-05 15:58:47 +08:00
jxxghp
6fa50101a6 Merge pull request #1314 from thsrite/main 2024-01-05 13:00:38 +08:00
thsrite
843fbc83f4 fix 集如果带有.会刮削错误 2024-01-05 12:53:47 +08:00
jxxghp
55f8fb3b66 Merge pull request #1313 from thsrite/main 2024-01-05 11:52:39 +08:00
thsrite
a47774472d fix bug 2024-01-05 11:50:05 +08:00
jxxghp
713f4ca356 fix typo 2024-01-05 08:18:01 +08:00
jxxghp
b06795510a feat:插件支持注册公共服务 2024-01-05 08:12:27 +08:00
jxxghp
0f57ec099a Merge remote-tracking branch 'origin/main' 2024-01-04 20:54:46 +08:00
jxxghp
8325caabdc fix api 2024-01-04 20:53:59 +08:00
jxxghp
44d276d7e7 Merge pull request #1305 from honue/main 2024-01-04 07:08:07 +08:00
honue
935340561b package获取失败,增加日志warn 2024-01-03 22:34:01 +08:00
jxxghp
a60fde3b91 fix 2024-01-03 21:29:23 +08:00
jxxghp
163a855d5c fix play url api 2024-01-03 18:38:40 +08:00
jxxghp
c9b1e75361 fix 2024-01-03 18:07:48 +08:00
jxxghp
a9932d0866 fix 2024-01-03 17:40:13 +08:00
jxxghp
11d29919bf feat:大小过滤 2024-01-03 17:28:11 +08:00
jxxghp
4fe755332d fix bug 2024-01-03 12:42:47 +08:00
jxxghp
0095e0f4dd feat:播放跳转api 2024-01-03 12:02:08 +08:00
jxxghp
322c72ab54 feat:mediaserver apis 2024-01-02 20:54:54 +08:00
jxxghp
4d51459a47 v1.5.6
- 修复了插件重复显示的问题
- 站点资源支持显示免费剩余时间和H&R标志(仅部分站点)
- 刷流插件升级,支持排除H&R

提示:涉及前端改动时,可能需要清理浏览器缓存才能显示更新内容
2024-01-01 20:18:20 +08:00
jxxghp
d51de30898 Merge remote-tracking branch 'origin/main' 2024-01-01 19:44:08 +08:00
jxxghp
90f9edbf24 fix bug 2024-01-01 19:43:55 +08:00
jxxghp
8aa10457a7 Merge pull request #1294 from honue/main 2024-01-01 15:46:09 +08:00
honue
ab584720c6 fix 本地插件未安装,但不在市场显示的情况(v2) 2024-01-01 15:33:13 +08:00
jxxghp
56ad281cb6 feat:4X 2024-01-01 11:56:03 +08:00
jxxghp
61281cca02 feat:免费剩余时间 && HR 2024-01-01 10:22:18 +08:00
jxxghp
b53dbbc38e rollback #1287 2023-12-31 10:36:09 +08:00
jxxghp
3f88cfba28 fix #1287 2023-12-31 10:34:21 +08:00
35 changed files with 1035 additions and 119 deletions

View File

@@ -108,6 +108,7 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
- **TMDB_IMAGE_DOMAIN** TMDB图片地址默认`image.tmdb.org`可配置为其它中转代理以加速TMDB图片显示`static-mdb.v.geilijiasu.com`
- **WALLPAPER** 登录首页电影海报,`tmdb`/`bing`,默认`tmdb`
- **RECOGNIZE_SOURCE** 媒体信息识别来源,`themoviedb`/`douban`,默认`themoviedb`,使用`douban`时不支持二级分类
- **FANART_ENABLE** Fanart开关`true`/`false`,默认`true`,关闭后刮削的图片类型会大幅减少
---
- **SCRAP_METADATA** 刮削入库的媒体文件,`true`/`false`,默认`true`
- **SCRAP_SOURCE** 刮削元数据及图片使用的数据源,`themoviedb`/`douban`,默认`themoviedb`
@@ -195,16 +196,19 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
- `emby`设置项:
- **EMBY_HOST** Emby服务器地址格式`ip:port`https需要添加`https://`前缀
- **EMBY_PLAY_HOST** EMBY外网地址格式`http(s)://DOMAIN:PORT`,未设置时使用`EMBY_HOST`
- **EMBY_API_KEY** Emby Api Key在`设置->高级->API密钥`处生成
- `jellyfin`设置项:
- **JELLYFIN_HOST** Jellyfin服务器地址格式`ip:port`https需要添加`https://`前缀
- **JELLYFIN_PLAY_HOST** Jellyfin外网地址格式`http(s)://DOMAIN:PORT`,未设置时使用`JELLYFIN_HOST`
- **JELLYFIN_API_KEY** Jellyfin Api Key在`设置->高级->API密钥`处生成
- `plex`设置项:
- **PLEX_HOST** Plex服务器地址格式`ip:port`https需要添加`https://`前缀
- **PLEX_PLAY_HOST** Plex外网地址格式`http(s)://DOMAIN:PORT`,未设置时使用`PLEX_HOST`
- **PLEX_TOKEN** Plex网页Url中的`X-Plex-Token`通过浏览器F12->网络从请求URL中获取
- **MEDIASERVER_SYNC_INTERVAL:** 媒体服务器同步间隔(小时),默认`6`,留空则不同步
- **MEDIASERVER_SYNC_BLACKLIST:** 媒体服务器同步黑名单,多个媒体库名称使用,分割

View File

@@ -1,7 +1,7 @@
from fastapi import APIRouter
from app.api.endpoints import login, user, site, message, webhook, subscribe, \
media, douban, search, plugin, tmdb, history, system, download, dashboard, filebrowser, transfer
media, douban, search, plugin, tmdb, history, system, download, dashboard, filebrowser, transfer, mediaserver
api_router = APIRouter()
api_router.include_router(login.router, prefix="/login", tags=["login"])
@@ -21,3 +21,4 @@ api_router.include_router(download.router, prefix="/download", tags=["download"]
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
api_router.include_router(filebrowser.router, prefix="/filebrowser", tags=["filebrowser"])
api_router.include_router(transfer.router, prefix="/transfer", tags=["transfer"])
api_router.include_router(mediaserver.router, prefix="/mediaserver", tags=["mediaserver"])

View File

@@ -1,16 +1,14 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
from app import schemas
from app.chain.download import DownloadChain
from app.chain.media import MediaChain
from app.core.context import MediaInfo, Context, TorrentInfo
from app.core.metainfo import MetaInfo
from app.core.security import verify_token
from app.db.models.user import User
from app.db.userauth import get_current_active_user
from app.schemas import NotExistMediaInfo, MediaType
router = APIRouter()
@@ -53,41 +51,6 @@ def add_downloading(
})
@router.post("/notexists", summary="查询缺失媒体信息", response_model=List[NotExistMediaInfo])
def exists(media_in: schemas.MediaInfo,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询缺失媒体信息
"""
# 媒体信息
meta = MetaInfo(title=media_in.title)
mtype = MediaType(media_in.type) if media_in.type else None
if mtype:
meta.type = mtype
if media_in.season:
meta.begin_season = media_in.season
meta.type = MediaType.TV
if media_in.year:
meta.year = media_in.year
if media_in.tmdb_id or media_in.douban_id:
mediainfo = MediaChain().recognize_media(meta=meta, mtype=mtype,
tmdbid=media_in.tmdb_id, doubanid=media_in.douban_id)
else:
mediainfo = MediaChain().recognize_by_meta(metainfo=meta)
# 查询缺失信息
if not mediainfo:
raise HTTPException(status_code=404, detail="媒体信息不存在")
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
exist_flag, no_exists = DownloadChain().get_no_exists_info(meta=meta, mediainfo=mediainfo)
if mediainfo.type == MediaType.MOVIE:
# 电影已存在时返回空列表,存在时返回空对像列表
return [] if exist_flag else [NotExistMediaInfo()]
elif no_exists and no_exists.get(mediakey):
# 电视剧返回缺失的剧集
return list(no_exists.get(mediakey).values())
return []
@router.get("/start/{hashString}", summary="开始任务", response_model=schemas.Response)
def start_downloading(
hashString: str,

View File

@@ -56,7 +56,9 @@ async def login_access_token(
logger.info(f"用户 {user.name} 登录成功!")
return schemas.Token(
access_token=security.create_access_token(
user.id,
userid=user.id,
username=user.name,
super_user=user.is_superuser,
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
),
token_type="bearer",

View File

@@ -1,7 +1,6 @@
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
@@ -9,8 +8,6 @@ from app.core.config import settings
from app.core.context import Context
from app.core.metainfo import MetaInfo
from app.core.security import verify_token, verify_uri_token
from app.db import get_db
from app.db.mediaserver_oper import MediaServerOper
from app.schemas import MediaType
router = APIRouter()
@@ -79,28 +76,6 @@ def search_by_title(title: str,
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:
"""
判断本地是否存在
"""
meta = MetaInfo(title)
if not season:
season = meta.begin_season
exist = MediaServerOper(db).exists(
title=meta.name, year=year, mtype=mtype, tmdbid=tmdbid, season=season
)
return schemas.Response(success=True if exist else False, data={
"item": exist or {}
})
@router.get("/{mediaid}", summary="查询媒体详情", response_model=schemas.MediaInfo)
def media_info(mediaid: str, type_name: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:

View File

@@ -0,0 +1,146 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import schemas
from app.chain.download import DownloadChain
from app.chain.media import MediaChain
from app.chain.mediaserver import MediaServerChain
from app.core.config import settings
from app.core.metainfo import MetaInfo
from app.core.security import verify_token
from app.db import get_db
from app.db.mediaserver_oper import MediaServerOper
from app.db.models import MediaServerItem
from app.schemas import MediaType, NotExistMediaInfo
router = APIRouter()
@router.get("/play/{itemid}", summary="在线播放")
def play_item(itemid: str) -> schemas.Response:
"""
获取媒体服务器播放页面地址
"""
if not itemid:
return schemas.Response(success=False, msg="参数错误")
if not settings.MEDIASERVER:
return schemas.Response(success=False, msg="未配置媒体服务器")
mediaserver = settings.MEDIASERVER.split(",")[0]
play_url = MediaServerChain().get_play_url(server=mediaserver, item_id=itemid)
# 重定向到play_url
if not play_url:
return schemas.Response(success=False, msg="未找到播放地址")
return schemas.Response(success=True, data={
"url": play_url
})
@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:
"""
判断本地是否存在
"""
meta = MetaInfo(title)
if not season:
season = meta.begin_season
# 返回对象
ret_info = {}
# 本地数据库是否存在
exist: MediaServerItem = MediaServerOper(db).exists(
title=meta.name, year=year, mtype=mtype, tmdbid=tmdbid, season=season
)
if exist:
ret_info = {
"id": exist.item_id
}
"""
else:
# 服务器是否存在
mediainfo = MediaInfo()
mediainfo.from_dict({
"title": meta.name,
"year": year or meta.year,
"type": mtype or meta.type,
"tmdb_id": tmdbid,
"season": season
})
exist: schemas.ExistMediaInfo = MediaServerChain().media_exists(
mediainfo=mediainfo
)
if exist:
ret_info = {
"id": exist.itemid
}
"""
return schemas.Response(success=True if exist else False, data={
"item": ret_info
})
@router.post("/notexists", summary="查询缺失媒体信息", response_model=List[schemas.NotExistMediaInfo])
def not_exists(media_in: schemas.MediaInfo,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询缺失媒体信息
"""
# 媒体信息
meta = MetaInfo(title=media_in.title)
mtype = MediaType(media_in.type) if media_in.type else None
if mtype:
meta.type = mtype
if media_in.season:
meta.begin_season = media_in.season
meta.type = MediaType.TV
if media_in.year:
meta.year = media_in.year
if media_in.tmdb_id or media_in.douban_id:
mediainfo = MediaChain().recognize_media(meta=meta, mtype=mtype,
tmdbid=media_in.tmdb_id, doubanid=media_in.douban_id)
else:
mediainfo = MediaChain().recognize_by_meta(metainfo=meta)
# 查询缺失信息
if not mediainfo:
raise HTTPException(status_code=404, detail="媒体信息不存在")
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
exist_flag, no_exists = DownloadChain().get_no_exists_info(meta=meta, mediainfo=mediainfo)
if mediainfo.type == MediaType.MOVIE:
# 电影已存在时返回空列表,存在时返回空对像列表
return [] if exist_flag else [NotExistMediaInfo()]
elif no_exists and no_exists.get(mediakey):
# 电视剧返回缺失的剧集
return list(no_exists.get(mediakey).values())
return []
@router.get("/latest", summary="最新入库条目", response_model=List[schemas.MediaServerPlayItem])
def latest(count: int = 18,
userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
获取媒体服务器最新入库条目
"""
return MediaServerChain().latest(count=count, username=userinfo.username) or []
@router.get("/playing", summary="正在播放条目", response_model=List[schemas.MediaServerPlayItem])
def playing(count: int = 12,
userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
获取媒体服务器正在播放条目
"""
return MediaServerChain().playing(count=count, username=userinfo.username) or []
@router.get("/library", summary="媒体库列表", response_model=List[schemas.MediaServerLibrary])
def library(userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
获取媒体服务器媒体库列表
"""
return MediaServerChain().librarys(username=userinfo.username) or []

View File

@@ -35,14 +35,12 @@ def all_plugins(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
elif plugin.get("has_update"):
plugin["installed"] = False
plugins.append(plugin)
# 本地未安装的插件
# 本地插件存在但未安装且本地插件不在online插件
plugin_ids = [plugin["id"] for plugin in plugins]
for plugin in local_plugins:
if plugin["id"] not in installed_ids:
if plugin["id"] not in installed_ids \
and plugin["id"] not in plugin_ids:
plugins.append(plugin)
elif plugin.get("has_update"):
plugin["installed"] = False
plugins.append(plugin)
return plugins

View File

@@ -24,14 +24,17 @@ from version import APP_VERSION
router = APIRouter()
@router.get("/img/{imgurl:path}", summary="图片代理")
def get_img(imgurl: str) -> Any:
@router.get("/img/{imgurl:path}/{proxy}", summary="图片代理")
def get_img(imgurl: str, proxy: bool = False) -> Any:
"""
通过图片代理(使用代理服务器)
"""
if not imgurl:
return None
response = RequestUtils(ua=settings.USER_AGENT, proxies=settings.PROXY).get_res(url=imgurl)
if proxy:
response = RequestUtils(ua=settings.USER_AGENT, proxies=settings.PROXY).get_res(url=imgurl)
else:
response = RequestUtils(ua=settings.USER_AGENT).get_res(url=imgurl)
if response:
return Response(content=response.content, media_type="image/jpeg")
return None

View File

@@ -55,6 +55,8 @@ class DownloadChain(ChainBase):
msg_text = f"{msg_text}\n种子:{torrent.title}"
if torrent.pubdate:
msg_text = f"{msg_text}\n发布时间:{torrent.pubdate}"
if torrent.freedate:
msg_text = f"{msg_text}\n免费时间:{StringUtils.diff_time_str(torrent.freedate)}"
if torrent.seeders:
msg_text = f"{msg_text}\n做种数:{torrent.seeders}"
if torrent.uploadvolumefactor and torrent.downloadvolumefactor:
@@ -355,12 +357,13 @@ class DownloadChain(ChainBase):
need = list(set(_need).difference(set(_current)))
# 清除已下载的季信息
seas = copy.deepcopy(no_exists.get(_mid))
for _sea in list(seas):
if _sea not in need:
no_exists[_mid].pop(_sea)
if not no_exists.get(_mid) and no_exists.get(_mid) is not None:
no_exists.pop(_mid)
break
if seas:
for _sea in list(seas):
if _sea not in need:
no_exists[_mid].pop(_sea)
if not no_exists.get(_mid) and no_exists.get(_mid) is not None:
no_exists.pop(_mid)
break
return need
def __update_episodes(_mid: Union[int, str], _sea: int, _need: list, _current: set) -> list:
@@ -488,6 +491,9 @@ class DownloadChain(ChainBase):
need_season = __update_seasons(_mid=need_mid,
_need=need_season,
_current=torrent_season)
if not need_season:
# 全部下载完成
break
# 电视剧季内的集匹配
if no_exists:
# TMDBID列表

View File

@@ -1,6 +1,6 @@
import json
import threading
from typing import List, Union
from typing import List, Union, Optional
from app import schemas
from app.chain import ChainBase
@@ -20,11 +20,11 @@ class MediaServerChain(ChainBase):
super().__init__()
self.dboper = MediaServerOper()
def librarys(self, server: str) -> List[schemas.MediaServerLibrary]:
def librarys(self, server: str = None, username: str = None) -> List[schemas.MediaServerLibrary]:
"""
获取媒体服务器所有媒体库
"""
return self.run_module("mediaserver_librarys", server=server)
return self.run_module("mediaserver_librarys", server=server, username=username)
def items(self, server: str, library_id: Union[str, int]) -> List[schemas.MediaServerItem]:
"""
@@ -44,6 +44,24 @@ class MediaServerChain(ChainBase):
"""
return self.run_module("mediaserver_tv_episodes", server=server, item_id=item_id)
def playing(self, count: int = 20, server: str = None, username: str = None) -> List[schemas.MediaServerPlayItem]:
"""
获取媒体服务器正在播放信息
"""
return self.run_module("mediaserver_playing", count=count, server=server, username=username)
def latest(self, count: int = 20, server: str = None, username: str = None) -> List[schemas.MediaServerPlayItem]:
"""
获取媒体服务器最新入库条目
"""
return self.run_module("mediaserver_latest", count=count, server=server, username=username)
def get_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]:
"""
获取播放地址
"""
return self.run_module("mediaserver_play_url", server=server, item_id=item_id)
def sync(self):
"""
同步媒体库所有数据到本地数据库

View File

@@ -2,7 +2,7 @@ import pickle
import re
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from typing import Dict
from typing import Dict, Tuple
from typing import List, Optional
from app.chain import ChainBase
@@ -153,6 +153,7 @@ class SearchChain(ChainBase):
return []
# 使用过滤规则再次过滤
torrents = self.filter_torrents_by_rule(torrents=torrents,
mediainfo=mediainfo,
filter_rule=filter_rule)
if not torrents:
logger.warn(f'{keyword or mediainfo.title} 没有符合过滤规则的资源')
@@ -336,12 +337,14 @@ class SearchChain(ChainBase):
def filter_torrents_by_rule(self,
torrents: List[TorrentInfo],
filter_rule: Dict[str, str] = None
mediainfo: MediaInfo,
filter_rule: Dict[str, str] = None,
) -> List[TorrentInfo]:
"""
使用过滤规则过滤种子
:param torrents: 种子列表
:param filter_rule: 过滤规则
:param mediainfo: 媒体信息
"""
if not filter_rule:
@@ -359,6 +362,26 @@ class SearchChain(ChainBase):
resolution = filter_rule.get("resolution")
# 特效
effect = filter_rule.get("effect")
# 电影大小
movie_size = filter_rule.get("movie_size")
# 剧集单集大小
tv_size = filter_rule.get("tv_size")
def __get_size_range(size_str: str) -> Tuple[float, float]:
"""
获取大小范围
"""
if not size_str:
return 0, 0
try:
size_range = size_str.split("-")
if len(size_range) == 1:
return 0, float(size_range[0])
elif len(size_range) == 2:
return float(size_range[0]), float(size_range[1])
except Exception as e:
print(str(e))
return 0, 0
def __filter_torrent(t: TorrentInfo) -> bool:
"""
@@ -394,6 +417,36 @@ class SearchChain(ChainBase):
logger.info(f"{t.title} 不匹配特效规则 {effect}")
return False
# 大小
if movie_size or tv_size:
if mediainfo.type == MediaType.TV:
size = tv_size
else:
size = movie_size
# 大小范围
begin_size, end_size = __get_size_range(size)
if begin_size and end_size:
meta = MetaInfo(title=t.title, subtitle=t.description)
# 集数
if mediainfo.type == MediaType.TV:
# 电视剧
season = meta.begin_season or 1
if meta.total_episode:
# 识别的总集数
episodes_num = meta.total_episode
else:
# 整季集数
episodes_num = len(mediainfo.seasons.get(season) or [1])
# 比较大小
if not (begin_size * 1024 ** 3 <= (t.size / episodes_num) <= end_size * 1024 ** 3):
logger.info(f"{t.title} {StringUtils.str_filesize(t.size)} "
f"{episodes_num}集,不匹配大小规则 {size}")
return False
else:
# 电影比较大小
if not (begin_size * 1024 ** 3 <= t.size <= end_size * 1024 ** 3):
logger.info(f"{t.title} {StringUtils.str_filesize(t.size)} 不匹配大小规则 {size}")
return False
return True
# 使用默认过滤规则再次过滤

View File

@@ -59,6 +59,8 @@ class Settings(BaseSettings):
TMDB_API_KEY: str = "db55323b8d3e4154498498a75642b381"
# TVDB API Key
TVDB_API_KEY: str = "6b481081-10aa-440c-99f2-21d17717ee02"
# Fanart开关
FANART_ENABLE: bool = True
# Fanart API Key
FANART_API_KEY: str = "d2d31f9ecabea050fc7d68aa3146015f"
# 支持的后缀格式
@@ -161,14 +163,20 @@ class Settings(BaseSettings):
MEDIASERVER_SYNC_BLACKLIST: str = None
# EMBY服务器地址IP:PORT
EMBY_HOST: str = None
# EMBY外网地址http(s)://DOMAIN:PORT未设置时使用EMBY_HOST
EMBY_PLAY_HOST: str = None
# EMBY Api Key
EMBY_API_KEY: str = None
# Jellyfin服务器地址IP:PORT
JELLYFIN_HOST: str = None
# Jellyfin外网地址http(s)://DOMAIN:PORT未设置时使用JELLYFIN_HOST
JELLYFIN_PLAY_HOST: str = None
# Jellyfin Api Key
JELLYFIN_API_KEY: str = None
# Plex服务器地址IP:PORT
PLEX_HOST: str = None
# Plex外网地址http(s)://DOMAIN:PORT未设置时使用PLEX_HOST
PLEX_PLAY_HOST: str = None
# Plex Token
PLEX_TOKEN: str = None
# 转移方式 link/copy/move/softlink

View File

@@ -6,6 +6,7 @@ from app.core.config import settings
from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo
from app.schemas.types import MediaType
from app.utils.string import StringUtils
@dataclass
@@ -44,6 +45,8 @@ class TorrentInfo:
pubdate: str = None
# 已过时间
date_elapsed: str = None
# 免费截止时间
freedate: str = None
# 上传因子
uploadvolumefactor: float = None
# 下载因子
@@ -90,7 +93,9 @@ class TorrentInfo:
"1.0 1.0": "普通",
"1.0 0.0": "免费",
"2.0 1.0": "2X",
"4.0 1.0": "4X",
"2.0 0.0": "2X免费",
"4.0 0.0": "4X免费",
"1.0 0.5": "50%",
"2.0 0.5": "2X 50%",
"1.0 0.7": "70%",
@@ -105,12 +110,22 @@ class TorrentInfo:
"""
return self.get_free_string(self.uploadvolumefactor, self.downloadvolumefactor)
@property
def freedate_diff(self):
"""
返回免费剩余时间
"""
if not self.freedate:
return ""
return StringUtils.diff_time_str(self.freedate)
def to_dict(self):
"""
返回字典
"""
dicts = asdict(self)
dicts["volume_factor"] = self.volume_factor
dicts["freedate_diff"] = self.freedate_diff
return dicts

View File

@@ -270,7 +270,7 @@ class MetaVideo(MetaBase):
self.tokens.get_next()
self._last_token_type = "part"
self._continue_flag = False
self._stop_name_flag = False
# self._stop_name_flag = False
def __init_year(self, token: str):
if not self.name:

View File

@@ -213,6 +213,26 @@ class PluginManager(metaclass=Singleton):
ret_apis.extend(apis)
return ret_apis
def get_plugin_services(self) -> List[Dict[str, Any]]:
"""
获取插件服务
[{
"id": "服务ID",
"name": "服务名称",
"trigger": "触发器cron、interval、date、CronTrigger.from_crontab()",
"func": self.xxx,
"kwagrs": {} # 定时器参数
}]
"""
ret_services = []
for pid, plugin in self._running_plugins.items():
if hasattr(plugin, "get_service") \
and ObjectUtils.check_method(plugin.get_service):
services = plugin.get_service()
if services:
ret_services.extend(services)
return ret_services
def run_plugin_method(self, pid: str, method: str, *args, **kwargs) -> Any:
"""
运行插件方法
@@ -243,6 +263,8 @@ class PluginManager(metaclass=Singleton):
markets = settings.PLUGIN_MARKET.split(",")
for market in markets:
online_plugins = self.pluginhelper.get_plugins(market) or {}
if not online_plugins:
logger.warn(f"获取插件库失败 {market}")
for pid, plugin in online_plugins.items():
# 运行状插件
plugin_obj = self._running_plugins.get(pid)

View File

@@ -26,7 +26,8 @@ reusable_oauth2 = OAuth2PasswordBearer(
def create_access_token(
subject: Union[str, Any], expires_delta: timedelta = None
userid: Union[str, Any], username: str, super_user: bool = False,
expires_delta: timedelta = None
) -> str:
if expires_delta:
expire = datetime.utcnow() + expires_delta
@@ -34,7 +35,12 @@ def create_access_token(
expire = datetime.utcnow() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode = {"exp": expire, "sub": str(subject)}
to_encode = {
"exp": expire,
"sub": str(userid),
"username": username,
"super_user": super_user
}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt

View File

@@ -24,9 +24,10 @@ from app.helper.thread import ThreadHelper
from app.helper.display import DisplayHelper
from app.helper.resource import ResourceHelper
from app.helper.sites import SitesHelper
from app.helper.message import MessageHelper
from app.scheduler import Scheduler
from app.command import Command
from app.command import Command, CommandChian
from app.schemas import Notification, NotificationType
# App
App = FastAPI(title=settings.PROJECT_NAME,
@@ -139,6 +140,22 @@ def start_tray():
threading.Thread(target=TrayIcon.run, daemon=True).start()
def check_auth():
"""
检查认证状态
"""
if SitesHelper().auth_level < 2:
err_msg = "用户认证失败,站点相关功能将无法使用!"
MessageHelper().put(f"注意:{err_msg}")
CommandChian().post_message(
Notification(
mtype=NotificationType.Manual,
title="MoviePilot用户认证",
text=err_msg
)
)
@App.on_event("shutdown")
def shutdown_server():
"""
@@ -183,6 +200,8 @@ def start_module():
init_routers()
# 启动前端服务
start_frontend()
# 检查认证状态
check_auth()
if __name__ == '__main__':

View File

@@ -103,13 +103,13 @@ class EmbyModule(_ModuleBase):
media_statistic.user_count = self.emby.get_user_count()
return [media_statistic]
def mediaserver_librarys(self, server: str) -> Optional[List[schemas.MediaServerLibrary]]:
def mediaserver_librarys(self, server: str = None, username: str = None) -> Optional[List[schemas.MediaServerLibrary]]:
"""
媒体库列表
"""
if server != "emby":
if server and server != "emby":
return None
return self.emby.get_librarys()
return self.emby.get_librarys(username)
def mediaserver_items(self, server: str, library_id: str) -> Optional[Generator]:
"""
@@ -141,3 +141,29 @@ class EmbyModule(_ModuleBase):
season=season,
episodes=episodes
) for season, episodes in seasoninfo.items()]
def mediaserver_playing(self, count: int = 20,
server: str = None, username: str = None) -> List[schemas.MediaServerPlayItem]:
"""
获取媒体服务器正在播放信息
"""
if server and server != "emby":
return []
return self.emby.get_resume(num=count, username=username)
def mediaserver_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]:
"""
获取媒体库播放地址
"""
if server != "emby":
return None
return self.emby.get_play_url(item_id)
def mediaserver_latest(self, count: int = 20,
server: str = None, username: str = None) -> List[schemas.MediaServerPlayItem]:
"""
获取媒体服务器最新入库条目
"""
if server and server != "emby":
return []
return self.emby.get_latest(num=count, username=username)

View File

@@ -22,9 +22,16 @@ class Emby(metaclass=Singleton):
self._host += "/"
if not self._host.startswith("http"):
self._host = "http://" + self._host
self._playhost = settings.EMBY_PLAY_HOST
if self._playhost:
if not self._playhost.endswith("/"):
self._playhost += "/"
if not self._playhost.startswith("http"):
self._playhost = "http://" + self._playhost
self._apikey = settings.EMBY_API_KEY
self.user = self.get_user(settings.SUPERUSER)
self.folders = self.get_emby_folders()
self.serverid = self.get_server_id()
def is_inactive(self) -> bool:
"""
@@ -59,13 +66,17 @@ class Emby(metaclass=Singleton):
logger.error(f"连接Library/SelectableMediaFolders 出错:" + str(e))
return []
def __get_emby_librarys(self) -> List[dict]:
def __get_emby_librarys(self, username: str = None) -> List[dict]:
"""
获取Emby媒体库列表
"""
if not self._host or not self._apikey:
return []
req_url = f"{self._host}emby/Users/{self.user}/Views?api_key={self._apikey}"
if username:
user = self.get_user(username)
else:
user = self.user
req_url = f"{self._host}emby/Users/{user}/Views?api_key={self._apikey}"
try:
res = RequestUtils().get_res(req_url)
if res:
@@ -77,14 +88,17 @@ class Emby(metaclass=Singleton):
logger.error(f"连接User/Views 出错:" + str(e))
return []
def get_librarys(self) -> List[schemas.MediaServerLibrary]:
def get_librarys(self, username: str = None) -> List[schemas.MediaServerLibrary]:
"""
获取媒体服务器所有媒体库列表
"""
if not self._host or not self._apikey:
return []
libraries = []
for library in self.__get_emby_librarys() or []:
black_list = (settings.MEDIASERVER_SYNC_BLACKLIST or '').split(",")
for library in self.__get_emby_librarys(username) or []:
if library.get("Name") in black_list:
continue
match library.get("CollectionType"):
case "movies":
library_type = MediaType.MOVIE.value
@@ -92,13 +106,17 @@ class Emby(metaclass=Singleton):
library_type = MediaType.TV.value
case _:
continue
image = self.__get_local_image_by_id(library.get("Id"))
libraries.append(
schemas.MediaServerLibrary(
server="emby",
id=library.get("Id"),
name=library.get("Name"),
path=library.get("Path"),
type=library_type
type=library_type,
image=image,
link=f'{self._playhost or self._host}web/index.html'
f'#!/videos?serverId={self.serverid}&parentId={library.get("Id")}'
)
)
return libraries
@@ -907,3 +925,160 @@ class Emby(metaclass=Singleton):
except Exception as e:
logger.error(f"连接Emby出错" + str(e))
return None
def get_play_url(self, item_id: str) -> str:
"""
拼装媒体播放链接
:param item_id: 媒体的的ID
"""
return f"{self._playhost or self._host}web/index.html#!" \
f"/item?id={item_id}&context=home&serverId={self.serverid}"
def __get_backdrop_url(self, item_id: str, image_tag: str) -> str:
"""
获取Emby的Backdrop图片地址
:param: item_id: 在Emby中的ID
:param: image_tag: 图片的tag
:param: remote 是否远程使用TG微信等客户端调用应为True
:param: inner 是否NT内部调用为True是会使用NT中转
"""
if not self._host or not self._apikey:
return ""
if not image_tag or not item_id:
return ""
return f"{self._host}Items/{item_id}/" \
f"Images/Backdrop?tag={image_tag}&fillWidth=666&api_key={self._apikey}"
def __get_local_image_by_id(self, item_id: str) -> str:
"""
根据ItemId从媒体服务器查询本地图片地址
:param: item_id: 在Emby中的ID
:param: remote 是否远程使用TG微信等客户端调用应为True
:param: inner 是否NT内部调用为True是会使用NT中转
"""
if not self._host or not self._apikey:
return ""
return "%sItems/%s/Images/Primary" % (self._host, item_id)
def get_resume(self, num: int = 12, username: str = None) -> Optional[List[schemas.MediaServerPlayItem]]:
"""
获得继续观看
"""
if not self._host or not self._apikey:
return None
if username:
user = self.get_user(username)
else:
user = self.user
req_url = (f"{self._host}Users/{user}/Items/Resume?"
f"Limit=100&MediaTypes=Video&api_key={self._apikey}&Fields=ProductionYear,Path")
try:
res = RequestUtils().get_res(req_url)
if res:
result = res.json().get("Items") or []
ret_resume = []
# 用户媒体库文件夹列表(排除黑名单)
library_folders = self.get_user_library_folders()
for item in result:
if len(ret_resume) == num:
break
if item.get("Type") not in ["Movie", "Episode"]:
continue
item_path = item.get("Path")
if item_path and library_folders and not any(
str(item_path).startswith(folder) for folder in library_folders):
continue
item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value
link = self.get_play_url(item.get("Id"))
if item_type == MediaType.MOVIE.value:
title = item.get("Name")
subtitle = item.get("ProductionYear")
else:
title = f'{item.get("SeriesName")}'
subtitle = f'S{item.get("ParentIndexNumber")}:{item.get("IndexNumber")} - {item.get("Name")}'
if item_type == MediaType.MOVIE.value:
if item.get("BackdropImageTags"):
image = self.__get_backdrop_url(item_id=item.get("Id"),
image_tag=item.get("BackdropImageTags")[0])
else:
image = self.__get_local_image_by_id(item.get("Id"))
else:
image = self.__get_backdrop_url(item_id=item.get("SeriesId"),
image_tag=item.get("SeriesPrimaryImageTag"))
if not image:
image = self.__get_local_image_by_id(item.get("SeriesId"))
ret_resume.append(schemas.MediaServerPlayItem(
id=item.get("Id"),
title=title,
subtitle=subtitle,
type=item_type,
image=image,
link=link,
percent=item.get("UserData", {}).get("PlayedPercentage")
))
return ret_resume
else:
logger.error(f"Users/Items/Resume 未获取到返回数据")
except Exception as e:
logger.error(f"连接Users/Items/Resume出错" + str(e))
return []
def get_latest(self, num: int = 20, username: str = None) -> Optional[List[schemas.MediaServerPlayItem]]:
"""
获得最近更新
"""
if not self._host or not self._apikey:
return None
if username:
user = self.get_user(username)
else:
user = self.user
req_url = (f"{self._host}Users/{user}/Items/Latest?"
f"Limit=100&MediaTypes=Video&api_key={self._apikey}&Fields=ProductionYear,Path")
try:
res = RequestUtils().get_res(req_url)
if res:
result = res.json() or []
ret_latest = []
# 用户媒体库文件夹列表(排除黑名单)
library_folders = self.get_user_library_folders()
for item in result:
if len(ret_latest) == num:
break
if item.get("Type") not in ["Movie", "Series"]:
continue
item_path = item.get("Path")
if item_path and library_folders and not any(
str(item_path).startswith(folder) for folder in library_folders):
continue
item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value
link = self.get_play_url(item.get("Id"))
image = self.__get_local_image_by_id(item_id=item.get("Id"))
ret_latest.append(schemas.MediaServerPlayItem(
id=item.get("Id"),
title=item.get("Name"),
subtitle=item.get("ProductionYear"),
type=item_type,
image=image,
link=link
))
return ret_latest
else:
logger.error(f"Users/Items/Latest 未获取到返回数据")
except Exception as e:
logger.error(f"连接Users/Items/Latest出错" + str(e))
return []
def get_user_library_folders(self):
"""
获取Emby媒体库文件夹列表排除黑名单
"""
if not self._host or not self._apikey:
return []
library_folders = []
black_list = (settings.MEDIASERVER_SYNC_BLACKLIST or '').split(",")
for library in self.get_emby_folders() or []:
if library.get("Name") in black_list:
continue
library_folders += [folder.get("Path") for folder in library.get("SubFolders")]
return library_folders

View File

@@ -326,6 +326,8 @@ class FanartModule(_ModuleBase):
:param mediainfo: 识别的媒体信息
:return: 更新后的媒体信息
"""
if not settings.FANART_ENABLE:
return None
if not mediainfo.tmdb_id and not mediainfo.tvdb_id:
return None
if mediainfo.type == MediaType.MOVIE:

View File

@@ -43,7 +43,7 @@ class IndexerModule(_ModuleBase):
# 确认搜索的名字
if not keywords:
# 浏览种子页
keywords = [None]
keywords = ['']
# 开始索引
result_array = []

View File

@@ -12,9 +12,9 @@ from ruamel.yaml import CommentedMap
from app.core.config import settings
from app.helper.browser import PlaywrightHelper
from app.log import logger
from app.schemas.types import MediaType
from app.utils.http import RequestUtils
from app.utils.string import StringUtils
from app.schemas.types import MediaType
class TorrentSpider:
@@ -547,6 +547,29 @@ class TorrentSpider:
else:
self.torrents_info['labels'] = []
def __get_free_date(self, torrent):
# free date
if 'freedate' not in self.fields:
return
selector = self.fields.get('freedate', {})
freedate = torrent(selector.get('selector', '')).clone()
self.__remove(freedate, selector)
items = self.__attribute_or_text(freedate, selector)
self.torrents_info['freedate'] = self.__index(items, selector)
self.torrents_info['freedate'] = self.__filter_text(self.torrents_info.get('freedate'),
selector.get('filters'))
def __get_hit_and_run(self, torrent):
# hitandrun
if 'hr' not in self.fields:
return
selector = self.fields.get('hr', {})
hit_and_run = torrent(selector.get('selector', ''))
if hit_and_run:
self.torrents_info['hit_and_run'] = True
else:
self.torrents_info['hit_and_run'] = False
def get_info(self, torrent) -> dict:
"""
解析单条种子数据
@@ -566,13 +589,15 @@ class TorrentSpider:
self.__get_uploadvolumefactor(torrent)
self.__get_pubdate(torrent)
self.__get_date_elapsed(torrent)
self.__get_free_date(torrent)
self.__get_labels(torrent)
self.__get_hit_and_run(torrent)
except Exception as err:
logger.error("%s 搜索出现错误:%s" % (self.indexername, str(err)))
return self.torrents_info
@staticmethod
def __filter_text(text, filters):
def __filter_text(text: str, filters: list):
"""
对文件进行处理
"""
@@ -613,7 +638,7 @@ class TorrentSpider:
item.remove(v)
@staticmethod
def __attribute_or_text(item, selector):
def __attribute_or_text(item, selector: dict):
if not selector:
return item
if not item:
@@ -625,7 +650,7 @@ class TorrentSpider:
return items
@staticmethod
def __index(items, selector):
def __index(items: list, selector: dict):
if not items:
return None
if selector:

View File

@@ -101,13 +101,13 @@ class JellyfinModule(_ModuleBase):
media_statistic.user_count = self.jellyfin.get_user_count()
return [media_statistic]
def mediaserver_librarys(self, server: str) -> Optional[List[schemas.MediaServerLibrary]]:
def mediaserver_librarys(self, server: str = None, username: str = None) -> Optional[List[schemas.MediaServerLibrary]]:
"""
媒体库列表
"""
if server != "jellyfin":
if server and server != "jellyfin":
return None
return self.jellyfin.get_librarys()
return self.jellyfin.get_librarys(username)
def mediaserver_items(self, server: str, library_id: str) -> Optional[Generator]:
"""
@@ -139,3 +139,29 @@ class JellyfinModule(_ModuleBase):
season=season,
episodes=episodes
) for season, episodes in seasoninfo.items()]
def mediaserver_playing(self, count: int = 20,
server: str = None, username: str = None) -> List[schemas.MediaServerPlayItem]:
"""
获取媒体服务器正在播放信息
"""
if server and server != "jellyfin":
return []
return self.jellyfin.get_resume(num=count, username=username)
def mediaserver_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]:
"""
获取媒体库播放地址
"""
if server != "jellyfin":
return None
return self.jellyfin.get_play_url(item_id)
def mediaserver_latest(self, count: int = 20,
server: str = None, username: str = None) -> List[schemas.MediaServerPlayItem]:
"""
获取媒体服务器最新入库条目
"""
if server and server != "jellyfin":
return []
return self.jellyfin.get_latest(num=count, username=username)

View File

@@ -20,6 +20,12 @@ class Jellyfin(metaclass=Singleton):
self._host += "/"
if not self._host.startswith("http"):
self._host = "http://" + self._host
self._playhost = settings.JELLYFIN_PLAY_HOST
if self._playhost:
if not self._playhost.endswith("/"):
self._playhost += "/"
if not self._playhost.startswith("http"):
self._playhost = "http://" + self._playhost
self._apikey = settings.JELLYFIN_API_KEY
self.user = self.get_user(settings.SUPERUSER)
self.serverid = self.get_server_id()
@@ -39,13 +45,35 @@ 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_folders(self) -> List[dict]:
"""
获取Jellyfin媒体库路径列表
"""
if not self._host or not self._apikey:
return []
req_url = "%Library/SelectableMediaFolders?api_key=%s" % (self._host, self._apikey)
try:
res = RequestUtils().get_res(req_url)
if res:
return res.json()
else:
logger.error(f"Library/SelectableMediaFolders 未获取到返回数据")
return []
except Exception as e:
logger.error(f"连接Library/SelectableMediaFolders 出错:" + str(e))
return []
def __get_jellyfin_librarys(self, username: str = None) -> List[dict]:
"""
获取Jellyfin媒体库的信息
"""
if not self._host or not self._apikey:
return []
req_url = f"{self._host}Users/{self.user}/Views?api_key={self._apikey}"
if username:
user = self.get_user(username)
else:
user = self.user
req_url = f"{self._host}Users/{user}/Views?api_key={self._apikey}"
try:
res = RequestUtils().get_res(req_url)
if res:
@@ -57,14 +85,17 @@ class Jellyfin(metaclass=Singleton):
logger.error(f"连接Users/Views 出错:" + str(e))
return []
def get_librarys(self):
def get_librarys(self, username: str = None) -> List[schemas.MediaServerLibrary]:
"""
获取媒体服务器所有媒体库列表
"""
if not self._host or not self._apikey:
return []
libraries = []
for library in self.__get_jellyfin_librarys() or []:
black_list = (settings.MEDIASERVER_SYNC_BLACKLIST or '').split(",")
for library in self.__get_jellyfin_librarys(username) or []:
if library.get("Name") in black_list:
continue
match library.get("CollectionType"):
case "movies":
library_type = MediaType.MOVIE.value
@@ -72,13 +103,21 @@ class Jellyfin(metaclass=Singleton):
library_type = MediaType.TV.value
case _:
continue
image = self.__get_local_image_by_id(library.get("Id"))
link = f"{self._playhost or self._host}web/index.html#!" \
f"/movies.html?topParentId={library.get('Id')}" \
if library_type == MediaType.MOVIE.value \
else f"{self._playhost or self._host}web/index.html#!" \
f"/tv.html?topParentId={library.get('Id')}"
libraries.append(
schemas.MediaServerLibrary(
server="jellyfin",
id=library.get("Id"),
name=library.get("Name"),
path=library.get("Path"),
type=library_type
type=library_type,
image=image,
link=link
))
return libraries
@@ -587,3 +626,154 @@ class Jellyfin(metaclass=Singleton):
except Exception as e:
logger.error(f"连接Jellyfin出错" + str(e))
return None
def get_play_url(self, item_id: str) -> str:
"""
拼装媒体播放链接
:param item_id: 媒体的的ID
"""
return f"{self._playhost or self._host}web/index.html#!" \
f"/details?id={item_id}&serverId={self.serverid}"
def __get_local_image_by_id(self, item_id: str) -> str:
"""
根据ItemId从媒体服务器查询有声书图片地址
:param: item_id: 在Emby中的ID
:param: remote 是否远程使用TG微信等客户端调用应为True
:param: inner 是否NT内部调用为True是会使用NT中转
"""
if not self._host or not self._apikey:
return ""
return "%sItems/%s/Images/Primary" % (self._host, item_id)
def __get_backdrop_url(self, item_id: str, image_tag: str) -> str:
"""
获取Backdrop图片地址
:param: item_id: 在Emby中的ID
:param: image_tag: 图片的tag
:param: remote 是否远程使用TG微信等客户端调用应为True
:param: inner 是否NT内部调用为True是会使用NT中转
"""
if not self._host or not self._apikey:
return ""
if not image_tag or not item_id:
return ""
return f"{self._host}Items/{item_id}/" \
f"Images/Backdrop?tag={image_tag}&fillWidth=666&api_key={self._apikey}"
def get_resume(self, num: int = 12, username: str = None) -> Optional[List[schemas.MediaServerPlayItem]]:
"""
获得继续观看
"""
if not self._host or not self._apikey:
return None
if username:
user = self.get_user(username)
else:
user = self.user
req_url = (f"{self._host}Users/{user}/Items/Resume?"
f"Limit=100&MediaTypes=Video&api_key={self._apikey}&Fields=ProductionYear,Path")
try:
res = RequestUtils().get_res(req_url)
if res:
result = res.json().get("Items") or []
ret_resume = []
# 用户媒体库文件夹列表(排除黑名单)
library_folders = self.get_user_library_folders()
for item in result:
if len(ret_resume) == num:
break
if item.get("Type") not in ["Movie", "Episode"]:
continue
item_path = item.get("Path")
if item_path and library_folders and not any(
str(item_path).startswith(folder) for folder in library_folders):
continue
item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value
link = self.get_play_url(item.get("Id"))
if item.get("BackdropImageTags"):
image = self.__get_backdrop_url(item_id=item.get("Id"),
image_tag=item.get("BackdropImageTags")[0])
else:
image = self.__get_local_image_by_id(item.get("Id"))
if item_type == MediaType.MOVIE.value:
title = item.get("Name")
subtitle = item.get("ProductionYear")
else:
title = f'{item.get("SeriesName")}'
subtitle = f'S{item.get("ParentIndexNumber")}:{item.get("IndexNumber")} - {item.get("Name")}'
ret_resume.append(schemas.MediaServerPlayItem(
id=item.get("Id"),
title=title,
subtitle=subtitle,
type=item_type,
image=image,
link=link,
percent=item.get("UserData", {}).get("PlayedPercentage")
))
return ret_resume
else:
logger.error(f"Users/Items/Resume 未获取到返回数据")
except Exception as e:
logger.error(f"连接Users/Items/Resume出错" + str(e))
return []
def get_latest(self, num=20, username: str = None) -> Optional[List[schemas.MediaServerPlayItem]]:
"""
获得最近更新
"""
if not self._host or not self._apikey:
return None
if username:
user = self.get_user(username)
else:
user = self.user
req_url = (f"{self._host}Users/{user}/Items/Latest?"
f"Limit=100&MediaTypes=Video&api_key={self._apikey}&Fields=ProductionYear,Path")
try:
res = RequestUtils().get_res(req_url)
if res:
result = res.json() or []
ret_latest = []
# 用户媒体库文件夹列表(排除黑名单)
library_folders = self.get_user_library_folders()
for item in result:
if len(ret_latest) == num:
break
if item.get("Type") not in ["Movie", "Series"]:
continue
item_path = item.get("Path")
if item_path and library_folders and not any(
str(item_path).startswith(folder) for folder in library_folders):
continue
item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value
link = self.get_play_url(item.get("Id"))
image = self.__get_local_image_by_id(item_id=item.get("Id"))
ret_latest.append(schemas.MediaServerPlayItem(
id=item.get("Id"),
title=item.get("Name"),
subtitle=item.get("ProductionYear"),
type=item_type,
image=image,
link=link
))
return ret_latest
else:
logger.error(f"Users/Items/Latest 未获取到返回数据")
except Exception as e:
logger.error(f"连接Users/Items/Latest出错" + str(e))
return []
def get_user_library_folders(self):
"""
获取Emby媒体库文件夹列表排除黑名单
"""
if not self._host or not self._apikey:
return []
library_folders = []
black_list = (settings.MEDIASERVER_SYNC_BLACKLIST or '').split(",")
for library in self.get_jellyfin_folders() or []:
if library.get("Name") in black_list:
continue
library_folders += [folder.get("Path") for folder in library.get("SubFolders")]
return library_folders

View File

@@ -95,11 +95,11 @@ class PlexModule(_ModuleBase):
media_statistic.user_count = 1
return [media_statistic]
def mediaserver_librarys(self, server: str) -> Optional[List[schemas.MediaServerLibrary]]:
def mediaserver_librarys(self, server: str = None, **kwargs) -> Optional[List[schemas.MediaServerLibrary]]:
"""
媒体库列表
"""
if server != "plex":
if server and server != "plex":
return None
return self.plex.get_librarys()
@@ -133,3 +133,27 @@ class PlexModule(_ModuleBase):
season=season,
episodes=episodes
) for season, episodes in seasoninfo.items()]
def mediaserver_playing(self, count: int = 20, server: str = None, **kwargs) -> List[schemas.MediaServerPlayItem]:
"""
获取媒体服务器正在播放信息
"""
if server and server != "plex":
return []
return self.plex.get_resume(count)
def mediaserver_latest(self, count: int = 20, server: str = None, **kwargs) -> List[schemas.MediaServerPlayItem]:
"""
获取媒体服务器最新入库条目
"""
if server and server != "plex":
return []
return self.plex.get_latest(count)
def mediaserver_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]:
"""
获取媒体库播放地址
"""
if server != "plex":
return None
return self.plex.get_play_url(item_id)

View File

@@ -1,4 +1,5 @@
import json
from functools import lru_cache
from pathlib import Path
from typing import List, Optional, Dict, Tuple, Generator, Any
from urllib.parse import quote_plus
@@ -15,6 +16,8 @@ from app.utils.singleton import Singleton
class Plex(metaclass=Singleton):
_plex = None
def __init__(self):
self._host = settings.PLEX_HOST
if self._host:
@@ -22,6 +25,12 @@ class Plex(metaclass=Singleton):
self._host += "/"
if not self._host.startswith("http"):
self._host = "http://" + self._host
self._playhost = settings.PLEX_PLAY_HOST
if self._playhost:
if not self._playhost.endswith("/"):
self._playhost += "/"
if not self._playhost.startswith("http"):
self._playhost = "http://" + self._playhost
self._token = settings.PLEX_TOKEN
if self._host and self._token:
try:
@@ -50,6 +59,43 @@ class Plex(metaclass=Singleton):
self._plex = None
logger.error(f"Plex服务器连接失败{str(e)}")
@lru_cache(maxsize=10)
def __get_library_images(self, library_key: str, mtype: int) -> Optional[List[str]]:
"""
获取媒体服务器最近添加的媒体的图片列表
param: library_key
param: type type的含义: 1 电影 2 剧集 详见 plexapi/utils.py中SEARCHTYPES的定义
"""
if not self._plex:
return None
# 返回结果
poster_urls = {}
# 页码计数
container_start = 0
# 需要的总条数/每页的条数
total_size = 4
# 如果总数不足,接续获取下一页
while len(poster_urls) < total_size:
items = self._plex.fetchItems(f"/hubs/home/recentlyAdded?type={mtype}&sectionID={library_key}",
container_size=total_size,
container_start=container_start)
for item in items:
if item.type == 'episode':
# 如果是剧集的单集,则去找上级的图片
if item.parentThumb is not None:
poster_urls[item.parentThumb] = None
else:
# 否则就用自己的图片
if item.thumb is not None:
poster_urls[item.thumb] = None
if len(poster_urls) == total_size:
break
if len(items) < total_size:
break
container_start += total_size
return [f"{self._host.rstrip('/') + url}?X-Plex-Token={self._token}" for url in
list(poster_urls.keys())[:total_size]]
def get_librarys(self) -> List[schemas.MediaServerLibrary]:
"""
获取媒体服务器所有媒体库列表
@@ -62,12 +108,17 @@ class Plex(metaclass=Singleton):
logger.error(f"获取媒体服务器所有媒体库列表出错:{str(err)}")
return []
libraries = []
black_list = (settings.MEDIASERVER_SYNC_BLACKLIST or '').split(",")
for library in self._libraries:
if library.title in black_list:
continue
match library.type:
case "movie":
library_type = MediaType.MOVIE.value
image_list = self.__get_library_images(library.key, 1)
case "show":
library_type = MediaType.TV.value
image_list = self.__get_library_images(library.key, 2)
case _:
continue
libraries.append(
@@ -75,7 +126,10 @@ class Plex(metaclass=Singleton):
id=library.key,
name=library.title,
path=library.locations,
type=library_type
type=library_type,
image_list=image_list,
link=f"{self._playhost or self._host}web/index.html#!/media/{self._plex.machineIdentifier}"
f"/com.plexapp.plugins.library?source={library.key}"
)
)
return libraries
@@ -543,3 +597,63 @@ class Plex(metaclass=Singleton):
获取plex对象以便直接操作
"""
return self._plex
def get_play_url(self, item_id: str) -> str:
"""
拼装媒体播放链接
:param item_id: 媒体的的ID
"""
return f'{self._playhost or self._host}web/index.html#!/server/{self._plex.machineIdentifier}/details?key={item_id}'
def get_resume(self, num: int = 12) -> Optional[List[schemas.MediaServerPlayItem]]:
"""
获取继续观看的媒体
"""
if not self._plex:
return []
items = self._plex.fetchItems('/hubs/continueWatching/items', container_start=0, container_size=num)
ret_resume = []
for item in items:
item_type = MediaType.MOVIE.value if item.TYPE == "movie" else MediaType.TV.value
if item_type == MediaType.MOVIE.value:
title = item.title
subtitle = item.year
else:
title = item.grandparentTitle
subtitle = f"S{item.parentIndex}:E{item.index} - {item.title}"
link = self.get_play_url(item.key)
image = item.artUrl
ret_resume.append(schemas.MediaServerPlayItem(
id=item.key,
title=title,
subtitle=subtitle,
type=item_type,
image=image,
link=link,
percent=item.viewOffset / item.duration * 100 if item.viewOffset and item.duration else 0
))
return ret_resume[:num]
def get_latest(self, num: int = 20) -> Optional[List[schemas.MediaServerPlayItem]]:
"""
获取最近添加媒体
"""
if not self._plex:
return None
items = self._plex.fetchItems('/library/recentlyAdded', container_start=0, container_size=num)
ret_resume = []
for item in items:
item_type = MediaType.MOVIE.value if item.TYPE == "movie" else MediaType.TV.value
link = self.get_play_url(item.key)
title = item.title if item_type == MediaType.MOVIE.value else \
"%s%s" % (item.parentTitle, item.index)
image = item.posterUrl
ret_resume.append(schemas.MediaServerPlayItem(
id=item.key,
title=title,
subtitle=item.year,
type=item_type,
image=image,
link=link
))
return ret_resume[:num]

View File

@@ -81,7 +81,7 @@ class TmdbScraper:
dir_path=file_path.parents[1])
# 生成根目录图片
for attr_name, attr_value in vars(mediainfo).items():
if attr_value \
if attr_name \
and attr_name.endswith("_path") \
and not attr_name.startswith("season") \
and attr_value \
@@ -135,7 +135,8 @@ class TmdbScraper:
file_path=file_path)
# 集的图片
episode_image = episodeinfo.get("still_path")
image_path = file_path.with_name(file_path.stem + "-thumb").with_suffix(Path(episode_image).suffix)
image_path = file_path.with_name(file_path.stem + "-thumb.jpg").with_suffix(
Path(episode_image).suffix)
if episode_image and (self._force_img or not image_path.exists()):
self.__save_image(
f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{episode_image}",

View File

@@ -57,7 +57,7 @@ class _PluginBase(metaclass=ABCMeta):
@abstractmethod
def get_command() -> List[Dict[str, Any]]:
"""
获取插件命令
注册插件远程命令
[{
"cmd": "/xx",
"event": EventType.xx,
@@ -71,7 +71,7 @@ class _PluginBase(metaclass=ABCMeta):
@abstractmethod
def get_api(self) -> List[Dict[str, Any]]:
"""
获取插件API
注册插件API
[{
"path": "/xx",
"endpoint": self.xxx,
@@ -82,6 +82,19 @@ class _PluginBase(metaclass=ABCMeta):
"""
pass
def get_service(self) -> List[Dict[str, Any]]:
"""
注册插件公共服务
[{
"id": "服务ID",
"name": "服务名称",
"trigger": "触发器cron/interval/date/CronTrigger.from_crontab()",
"func": self.xxx,
"kwargs": {} # 定时器参数
}]
"""
pass
@abstractmethod
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
"""

View File

@@ -16,6 +16,7 @@ from app.chain.tmdb import TmdbChain
from app.chain.torrents import TorrentsChain
from app.chain.transfer import TransferChain
from app.core.config import settings
from app.core.plugin import PluginManager
from app.log import logger
from app.utils.singleton import Singleton
from app.utils.timer import TimerUtils
@@ -227,6 +228,27 @@ class Scheduler(metaclass=Singleton):
}
)
# 注册插件公共服务
plugin_services = PluginManager().get_plugin_services()
for service in plugin_services:
try:
self._jobs[service["id"]] = {
"func": service["func"],
"running": False,
}
self._scheduler.add_job(
self.start,
service["trigger"],
id=service["id"],
name=service["name"],
**service["kwargs"],
kwargs={
'job_id': service["id"]
}
)
except Exception as e:
logger.error(f"注册插件服务失败:{str(e)} - {service}")
# 打印服务
logger.debug(self._scheduler.print_jobs())

View File

@@ -196,6 +196,8 @@ class TorrentInfo(BaseModel):
pubdate: Optional[str] = None
# 已过时间
date_elapsed: Optional[str] = None
# 免费截止时间
freedate: Optional[str] = None
# 上传因子
uploadvolumefactor: Optional[float] = None
# 下载因子
@@ -208,6 +210,8 @@ class TorrentInfo(BaseModel):
pri_order: Optional[int] = 0
# 促销
volume_factor: Optional[str] = None
# 剩余免费时间
freedate_diff: Optional[str] = None
class Context(BaseModel):

View File

@@ -66,6 +66,10 @@ class MediaServerLibrary(BaseModel):
type: Optional[str] = None
# 封面图
image: Optional[str] = None
# 封面图列表
image_list: Optional[List[str]] = None
# 跳转链接
link: Optional[str] = None
class MediaServerItem(BaseModel):
@@ -139,3 +143,16 @@ class WebhookEventInfo(BaseModel):
save_reason: Optional[str] = None
item_isvirtual: Optional[bool] = None
media_type: Optional[str] = None
class MediaServerPlayItem(BaseModel):
"""
媒体服务器可播放项目信息
"""
id: Optional[Union[str, int]] = None
title: Optional[str] = None
subtitle: Optional[str] = None
type: Optional[str] = None
image: Optional[str] = None
link: Optional[str] = None
percent: Optional[float] = None

View File

@@ -14,3 +14,7 @@ class Token(BaseModel):
class TokenPayload(BaseModel):
# 用户ID
sub: Optional[int] = None
# 用户名
username: Optional[str] = None
# 超级用户
super_user: Optional[bool] = None

View File

@@ -711,3 +711,29 @@ class StringUtils:
return -1
else:
return 0
@staticmethod
def diff_time_str(time_str: str):
"""
输入YYYY-MM-DD HH24:MI:SS 格式的时间字符串返回距离现在的剩余时间xx天xx小时xx分钟
"""
if not time_str:
return ''
try:
time_obj = datetime.datetime.strptime(time_str, '%Y-%m-%d %H:%M:%S')
except ValueError:
return ''
now = datetime.datetime.now()
diff = time_obj - now
diff_seconds = diff.seconds
diff_days = diff.days
diff_hours = diff_seconds // 3600
diff_minutes = (diff_seconds % 3600) // 60
if diff_days > 0:
return f'{diff_days}{diff_hours}小时{diff_minutes}分钟'
elif diff_hours > 0:
return f'{diff_hours}小时{diff_minutes}分钟'
elif diff_minutes > 0:
return f'{diff_minutes}分钟'
else:
return ''

View File

@@ -87,16 +87,22 @@ TR_PASSWORD=
####################################
# EMBY服务器地址IP:PORT
EMBY_HOST=
# EMBY外网地址http(s)://DOMAIN:PORT未设置时使用EMBY_HOST
EMBY_PLAY_HOST=
# EMBY Api Key
EMBY_API_KEY=
# Jellyfin服务器地址IP:PORT
JELLYFIN_HOST=
# Jellyfin外网地址http(s)://DOMAIN:PORT未设置时使用JELLYFIN_HOST
JELLYFIN_PLAY_HOST=
# Jellyfin Api Key
JELLYFIN_API_KEY=
# Plex服务器地址IP:PORT
PLEX_HOST=
# Plex外网地址http(s)://DOMAIN:PORT未设置时使用PLEX_HOST
PLEX_PLAY_HOST=
# Plex Token
PLEX_TOKEN=
@@ -113,6 +119,8 @@ TMDB_IMAGE_DOMAIN=image.tmdb.org
TMDB_API_DOMAIN=api.themoviedb.org
# 媒体识别来源 themoviedb/douban使用themoviedb时需要确保能正常连接api.themoviedb.org使用douban时不支持二级分类
RECOGNIZE_SOURCE=themoviedb
# Fanart开关
FANART_ENABLE=true
# 【*】消息通知渠道 telegram/wechat/slack多个通知渠道用,分隔,需要在上面配置对应消息通知渠道的参数
MESSAGER=telegram

View File

@@ -1 +1 @@
APP_VERSION = 'v1.5.5-1'
APP_VERSION = 'v1.5.9-1'