mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-10 17:42:45 +08:00
Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab73dbb3cd | ||
|
|
cb042dbe68 | ||
|
|
bba0d363d7 | ||
|
|
8635d8c53f | ||
|
|
dae6894e8b | ||
|
|
b76991a027 | ||
|
|
de61c43db4 | ||
|
|
890afc2a72 | ||
|
|
8d4e1f3af6 | ||
|
|
85507a4fff | ||
|
|
6d395f9866 | ||
|
|
c589f42181 | ||
|
|
87bb121060 | ||
|
|
42cd35ab3c | ||
|
|
669da0d882 | ||
|
|
9ac1346f80 | ||
|
|
f6981734d0 | ||
|
|
cb6aa61b6b | ||
|
|
2ed9cfcc9a | ||
|
|
2e796f41cb | ||
|
|
7d13e43c6f | ||
|
|
db684de6e9 | ||
|
|
510ef59aa0 | ||
|
|
d56083a29e | ||
|
|
8aed2b334e | ||
|
|
3bf27f224c | ||
|
|
dc9a54e74f | ||
|
|
79dc194dd6 | ||
|
|
8e12249201 | ||
|
|
4fa8f5b248 | ||
|
|
3089c0c524 | ||
|
|
ba1ca0819e | ||
|
|
4666b9051d | ||
|
|
56c524a822 | ||
|
|
43e8df1b9f | ||
|
|
dbc465f6e5 | ||
|
|
bfbd3c527c | ||
|
|
412405f69b | ||
|
|
12b74eb04f | ||
|
|
2305a6287a | ||
|
|
68245be081 | ||
|
|
29e01294bd | ||
|
|
d35bee54a6 | ||
|
|
bf63be18e4 | ||
|
|
3dc7adc61a | ||
|
|
047d1e0afd | ||
|
|
7c017faf31 | ||
|
|
7a59565761 | ||
|
|
9afb904d40 | ||
|
|
8189de589a | ||
|
|
c458d7525d | ||
|
|
5c7bd95f6b | ||
|
|
70c4509682 | ||
|
|
f34e36c571 | ||
|
|
5054ffe7e4 | ||
|
|
ed30933ca2 | ||
|
|
2a4111ecce | ||
|
|
5bc8709605 | ||
|
|
efa2edf869 | ||
|
|
5c1e972feb | ||
|
|
8c23e7a7b7 | ||
|
|
57183f8cdc | ||
|
|
0481b49c04 | ||
|
|
7eb9b5e92d | ||
|
|
2a409d83d4 | ||
|
|
785a3f5de8 | ||
|
|
7c17c1c73b | ||
|
|
0ea429782c | ||
|
|
7a8f880dbe | ||
|
|
0a86b72110 | ||
|
|
cb5c06ee7e | ||
|
|
9f22ce5cc0 | ||
|
|
86e1fbc28a | ||
|
|
a5c5f7c718 | ||
|
|
ff5d94782f | ||
|
|
58a1bd2c86 | ||
|
|
f78ba6afb0 | ||
|
|
331f3455f8 | ||
|
|
ad0241b7f1 | ||
|
|
d9508533e1 | ||
|
|
6d2059447e | ||
|
|
11d4f27268 | ||
|
|
a29f987649 | ||
|
|
3e692c790e | ||
|
|
35cc214492 | ||
|
|
bae7bff70d | ||
|
|
71ef6f6a61 |
@@ -21,6 +21,21 @@ def calendar(page: int = 1,
|
|||||||
return RecommendChain().bangumi_calendar(page=page, count=count)
|
return RecommendChain().bangumi_calendar(page=page, count=count)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/subjects", summary="搜索Bangumi", response_model=List[schemas.MediaInfo])
|
||||||
|
def bangumi_subjects(type: int = 2,
|
||||||
|
cat: int = None,
|
||||||
|
sort: str = 'rank',
|
||||||
|
year: int = None,
|
||||||
|
page: int = 1,
|
||||||
|
count: int = 30,
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
搜索Bangumi
|
||||||
|
"""
|
||||||
|
return RecommendChain().bangumi_discover(type=type, cat=cat, sort=sort, year=year,
|
||||||
|
page=page, count=count)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/credits/{bangumiid}", summary="查询Bangumi演职员表", response_model=List[schemas.MediaPerson])
|
@router.get("/credits/{bangumiid}", summary="查询Bangumi演职员表", response_model=List[schemas.MediaPerson])
|
||||||
def bangumi_credits(bangumiid: int,
|
def bangumi_credits(bangumiid: int,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
@@ -61,13 +76,14 @@ def bangumi_person(person_id: int,
|
|||||||
@router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo])
|
@router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo])
|
||||||
def bangumi_person_credits(person_id: int,
|
def bangumi_person_credits(person_id: int,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
|
count: int = 20,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
根据人物ID查询人物参演作品
|
根据人物ID查询人物参演作品
|
||||||
"""
|
"""
|
||||||
medias = BangumiChain().person_credits(person_id=person_id)
|
medias = BangumiChain().person_credits(person_id=person_id)
|
||||||
if medias:
|
if medias:
|
||||||
return [media.to_dict() for media in medias[(page - 1) * 20: page * 20]]
|
return [media.to_dict() for media in medias[(page - 1) * count: page * count]]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ from app.db import get_db
|
|||||||
from app.db.models.subscribe import Subscribe
|
from app.db.models.subscribe import Subscribe
|
||||||
from app.db.models.subscribehistory import SubscribeHistory
|
from app.db.models.subscribehistory import SubscribeHistory
|
||||||
from app.db.models.user import User
|
from app.db.models.user import User
|
||||||
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
from app.db.user_oper import get_current_active_user
|
from app.db.user_oper import get_current_active_user
|
||||||
from app.helper.subscribe import SubscribeHelper
|
from app.helper.subscribe import SubscribeHelper
|
||||||
from app.scheduler import Scheduler
|
from app.scheduler import Scheduler
|
||||||
from app.schemas.types import MediaType, EventType
|
from app.schemas.types import MediaType, EventType, SystemConfigKey
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -108,6 +109,7 @@ def update_subscribe(
|
|||||||
if not subscribe:
|
if not subscribe:
|
||||||
return schemas.Response(success=False, message="订阅不存在")
|
return schemas.Response(success=False, message="订阅不存在")
|
||||||
# 避免更新缺失集数
|
# 避免更新缺失集数
|
||||||
|
old_subscribe_dict = subscribe.to_dict()
|
||||||
subscribe_dict = subscribe_in.dict()
|
subscribe_dict = subscribe_in.dict()
|
||||||
if not subscribe_in.lack_episode:
|
if not subscribe_in.lack_episode:
|
||||||
# 没有缺失集数时,缺失集数清空,避免更新为0
|
# 没有缺失集数时,缺失集数清空,避免更新为0
|
||||||
@@ -125,7 +127,8 @@ def update_subscribe(
|
|||||||
# 发送订阅调整事件
|
# 发送订阅调整事件
|
||||||
eventmanager.send_event(EventType.SubscribeModified, {
|
eventmanager.send_event(EventType.SubscribeModified, {
|
||||||
"subscribe_id": subscribe.id,
|
"subscribe_id": subscribe.id,
|
||||||
"subscribe_info": subscribe_dict,
|
"old_subscribe_info": old_subscribe_dict,
|
||||||
|
"subscribe_info": subscribe.to_dict(),
|
||||||
})
|
})
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
@@ -145,9 +148,16 @@ def update_subscribe_status(
|
|||||||
valid_states = ["R", "P", "S"]
|
valid_states = ["R", "P", "S"]
|
||||||
if state not in valid_states:
|
if state not in valid_states:
|
||||||
return schemas.Response(success=False, message="无效的订阅状态")
|
return schemas.Response(success=False, message="无效的订阅状态")
|
||||||
|
old_subscribe_dict = subscribe.to_dict()
|
||||||
subscribe.update(db, {
|
subscribe.update(db, {
|
||||||
"state": state
|
"state": state
|
||||||
})
|
})
|
||||||
|
# 发送订阅调整事件
|
||||||
|
eventmanager.send_event(EventType.SubscribeModified, {
|
||||||
|
"subscribe_id": subscribe.id,
|
||||||
|
"old_subscribe_info": old_subscribe_dict,
|
||||||
|
"subscribe_info": subscribe.to_dict(),
|
||||||
|
})
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -212,11 +222,18 @@ def reset_subscribes(
|
|||||||
"""
|
"""
|
||||||
subscribe = Subscribe.get(db, subid)
|
subscribe = Subscribe.get(db, subid)
|
||||||
if subscribe:
|
if subscribe:
|
||||||
|
old_subscribe_dict = subscribe.to_dict()
|
||||||
subscribe.update(db, {
|
subscribe.update(db, {
|
||||||
"note": [],
|
"note": [],
|
||||||
"lack_episode": subscribe.total_episode,
|
"lack_episode": subscribe.total_episode,
|
||||||
"state": "R"
|
"state": "R"
|
||||||
})
|
})
|
||||||
|
# 发送订阅调整事件
|
||||||
|
eventmanager.send_event(EventType.SubscribeModified, {
|
||||||
|
"subscribe_id": subscribe.id,
|
||||||
|
"old_subscribe_info": old_subscribe_dict,
|
||||||
|
"subscribe_info": subscribe.to_dict(),
|
||||||
|
})
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
return schemas.Response(success=False, message="订阅不存在")
|
return schemas.Response(success=False, message="订阅不存在")
|
||||||
|
|
||||||
@@ -497,6 +514,42 @@ def subscribe_fork(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/follow", summary="查询已Follow的订阅分享人", response_model=List[str])
|
||||||
|
def followed_subscribers(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
查询已Follow的订阅分享人
|
||||||
|
"""
|
||||||
|
return SystemConfigOper().get(SystemConfigKey.FollowSubscribers) or []
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/follow", summary="Follow订阅分享人", response_model=schemas.Response)
|
||||||
|
def follow_subscriber(
|
||||||
|
share_uid: str = None,
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
Follow订阅分享人
|
||||||
|
"""
|
||||||
|
subscribers = SystemConfigOper().get(SystemConfigKey.FollowSubscribers) or []
|
||||||
|
if share_uid and share_uid not in subscribers:
|
||||||
|
subscribers.append(share_uid)
|
||||||
|
SystemConfigOper().set(SystemConfigKey.FollowSubscribers, subscribers)
|
||||||
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/follow", summary="取消Follow订阅分享人", response_model=schemas.Response)
|
||||||
|
def unfollow_subscriber(
|
||||||
|
share_uid: str = None,
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
取消Follow订阅分享人
|
||||||
|
"""
|
||||||
|
subscribers = SystemConfigOper().get(SystemConfigKey.FollowSubscribers) or []
|
||||||
|
if share_uid and share_uid in subscribers:
|
||||||
|
subscribers.remove(share_uid)
|
||||||
|
SystemConfigOper().set(SystemConfigKey.FollowSubscribers, subscribers)
|
||||||
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/shares", summary="查询分享的订阅", response_model=List[schemas.SubscribeShare])
|
@router.get("/shares", summary="查询分享的订阅", response_model=List[schemas.SubscribeShare])
|
||||||
def popular_subscribes(
|
def popular_subscribes(
|
||||||
name: str = None,
|
name: str = None,
|
||||||
|
|||||||
@@ -118,6 +118,11 @@ def tmdb_person_credits(person_id: int,
|
|||||||
def tmdb_movies(sort_by: str = "popularity.desc",
|
def tmdb_movies(sort_by: str = "popularity.desc",
|
||||||
with_genres: str = "",
|
with_genres: str = "",
|
||||||
with_original_language: str = "",
|
with_original_language: str = "",
|
||||||
|
with_keywords: str = "",
|
||||||
|
with_watch_providers: str = "",
|
||||||
|
vote_average: float = 0,
|
||||||
|
vote_count: int = 0,
|
||||||
|
release_date: str = "",
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
@@ -126,6 +131,11 @@ def tmdb_movies(sort_by: str = "popularity.desc",
|
|||||||
return RecommendChain().tmdb_movies(sort_by=sort_by,
|
return RecommendChain().tmdb_movies(sort_by=sort_by,
|
||||||
with_genres=with_genres,
|
with_genres=with_genres,
|
||||||
with_original_language=with_original_language,
|
with_original_language=with_original_language,
|
||||||
|
with_keywords=with_keywords,
|
||||||
|
with_watch_providers=with_watch_providers,
|
||||||
|
vote_average=vote_average,
|
||||||
|
vote_count=vote_count,
|
||||||
|
release_date=release_date,
|
||||||
page=page)
|
page=page)
|
||||||
|
|
||||||
|
|
||||||
@@ -133,6 +143,11 @@ def tmdb_movies(sort_by: str = "popularity.desc",
|
|||||||
def tmdb_tvs(sort_by: str = "popularity.desc",
|
def tmdb_tvs(sort_by: str = "popularity.desc",
|
||||||
with_genres: str = "",
|
with_genres: str = "",
|
||||||
with_original_language: str = "",
|
with_original_language: str = "",
|
||||||
|
with_keywords: str = "",
|
||||||
|
with_watch_providers: str = "",
|
||||||
|
vote_average: float = 0,
|
||||||
|
vote_count: int = 0,
|
||||||
|
release_date: str = "",
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
@@ -141,6 +156,11 @@ def tmdb_tvs(sort_by: str = "popularity.desc",
|
|||||||
return RecommendChain().tmdb_tvs(sort_by=sort_by,
|
return RecommendChain().tmdb_tvs(sort_by=sort_by,
|
||||||
with_genres=with_genres,
|
with_genres=with_genres,
|
||||||
with_original_language=with_original_language,
|
with_original_language=with_original_language,
|
||||||
|
with_keywords=with_keywords,
|
||||||
|
with_watch_providers=with_watch_providers,
|
||||||
|
vote_average=vote_average,
|
||||||
|
vote_count=vote_count,
|
||||||
|
release_date=release_date,
|
||||||
page=page)
|
page=page)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -680,6 +680,14 @@ def arr_add_series(tv: schemas.SonarrSeries,
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@arr_router.put("/series", summary="更新剧集订阅")
|
||||||
|
def arr_update_series(tv: schemas.SonarrSeries) -> Any:
|
||||||
|
"""
|
||||||
|
更新Sonarr剧集订阅
|
||||||
|
"""
|
||||||
|
return arr_add_series(tv)
|
||||||
|
|
||||||
|
|
||||||
@arr_router.delete("/series/{tid}", summary="删除剧集订阅")
|
@arr_router.delete("/series/{tid}", summary="删除剧集订阅")
|
||||||
def arr_remove_series(tid: int, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any:
|
def arr_remove_series(tid: int, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -531,6 +531,9 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
# 管理员发过了,此消息不发了
|
# 管理员发过了,此消息不发了
|
||||||
logger.info(f"用户 {send_message.username} 不存在,消息无法发送到对应用户")
|
logger.info(f"用户 {send_message.username} 不存在,消息无法发送到对应用户")
|
||||||
continue
|
continue
|
||||||
|
elif send_message.username == settings.SUPERUSER:
|
||||||
|
# 管理员同名已发送
|
||||||
|
admin_sended = True
|
||||||
else:
|
else:
|
||||||
# 按原消息发送全体
|
# 按原消息发送全体
|
||||||
if not admin_sended:
|
if not admin_sended:
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ class BangumiChain(ChainBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("bangumi_calendar")
|
return self.run_module("bangumi_calendar")
|
||||||
|
|
||||||
|
def discover(self, **kwargs) -> Optional[List[MediaInfo]]:
|
||||||
|
"""
|
||||||
|
发现Bangumi番剧
|
||||||
|
"""
|
||||||
|
return self.run_module("bangumi_discover", **kwargs)
|
||||||
|
|
||||||
def bangumi_info(self, bangumiid: int) -> Optional[dict]:
|
def bangumi_info(self, bangumiid: int) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
获取Bangumi信息
|
获取Bangumi信息
|
||||||
|
|||||||
@@ -444,7 +444,8 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
for file in files:
|
for file in files:
|
||||||
self.scrape_metadata(fileitem=file,
|
self.scrape_metadata(fileitem=file,
|
||||||
meta=meta, mediainfo=mediainfo,
|
meta=meta, mediainfo=mediainfo,
|
||||||
init_folder=False, parent=fileitem)
|
init_folder=False, parent=fileitem,
|
||||||
|
overwrite=overwrite)
|
||||||
# 生成目录内图片文件
|
# 生成目录内图片文件
|
||||||
if init_folder:
|
if init_folder:
|
||||||
# 图片
|
# 图片
|
||||||
@@ -515,7 +516,8 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
self.scrape_metadata(fileitem=file,
|
self.scrape_metadata(fileitem=file,
|
||||||
meta=meta, mediainfo=mediainfo,
|
meta=meta, mediainfo=mediainfo,
|
||||||
parent=fileitem if file.type == "file" else None,
|
parent=fileitem if file.type == "file" else None,
|
||||||
init_folder=True if file.type == "dir" else False)
|
init_folder=True if file.type == "dir" else False,
|
||||||
|
overwrite=overwrite)
|
||||||
# 生成目录的nfo和图片
|
# 生成目录的nfo和图片
|
||||||
if init_folder:
|
if init_folder:
|
||||||
# 识别文件夹名称
|
# 识别文件夹名称
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import threading
|
import threading
|
||||||
from typing import List, Union, Optional, Generator
|
from typing import List, Union, Optional, Generator
|
||||||
|
|
||||||
from cachetools import cached, TTLCache
|
|
||||||
|
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
|
from app.core.cache import cached
|
||||||
from app.core.config import global_vars
|
from app.core.config import global_vars
|
||||||
from app.db.mediaserver_oper import MediaServerOper
|
from app.db.mediaserver_oper import MediaServerOper
|
||||||
from app.helper.service import ServiceConfigHelper
|
from app.helper.service import ServiceConfigHelper
|
||||||
@@ -94,7 +93,7 @@ class MediaServerChain(ChainBase):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("mediaserver_latest", count=count, server=server, username=username)
|
return self.run_module("mediaserver_latest", count=count, server=server, username=username)
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=1, ttl=3600))
|
@cached(maxsize=1, ttl=3600)
|
||||||
def get_latest_wallpapers(self, server: str = None, count: int = 10,
|
def get_latest_wallpapers(self, server: str = None, count: int = 10,
|
||||||
remote: bool = True, username: str = None) -> List[str]:
|
remote: bool = True, username: str = None) -> List[str]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -295,6 +295,8 @@ class MessageChain(ChainBase):
|
|||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
best_version = True
|
best_version = True
|
||||||
|
# 转换用户名
|
||||||
|
mp_name = self.useroper.get_name(**{f"{channel.name.lower()}_userid": userid}) if channel else None
|
||||||
# 添加订阅,状态为N
|
# 添加订阅,状态为N
|
||||||
self.subscribechain.add(title=mediainfo.title,
|
self.subscribechain.add(title=mediainfo.title,
|
||||||
year=mediainfo.year,
|
year=mediainfo.year,
|
||||||
@@ -304,7 +306,7 @@ class MessageChain(ChainBase):
|
|||||||
channel=channel,
|
channel=channel,
|
||||||
source=source,
|
source=source,
|
||||||
userid=userid,
|
userid=userid,
|
||||||
username=username,
|
username=mp_name or username,
|
||||||
best_version=best_version)
|
best_version=best_version)
|
||||||
elif cache_type == "Torrent":
|
elif cache_type == "Torrent":
|
||||||
if int(text) == 0:
|
if int(text) == 0:
|
||||||
@@ -505,6 +507,8 @@ class MessageChain(ChainBase):
|
|||||||
note = downloaded
|
note = downloaded
|
||||||
else:
|
else:
|
||||||
note = None
|
note = None
|
||||||
|
# 转换用户名
|
||||||
|
mp_name = self.useroper.get_name(**{f"{channel.name.lower()}_userid": userid}) if channel else None
|
||||||
# 添加订阅,状态为R
|
# 添加订阅,状态为R
|
||||||
self.subscribechain.add(title=_current_media.title,
|
self.subscribechain.add(title=_current_media.title,
|
||||||
year=_current_media.year,
|
year=_current_media.year,
|
||||||
@@ -514,7 +518,7 @@ class MessageChain(ChainBase):
|
|||||||
channel=channel,
|
channel=channel,
|
||||||
source=source,
|
source=source,
|
||||||
userid=userid,
|
userid=userid,
|
||||||
username=username,
|
username=mp_name or username,
|
||||||
state="R",
|
state="R",
|
||||||
note=note)
|
note=note)
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import inspect
|
|
||||||
import io
|
import io
|
||||||
import tempfile
|
import tempfile
|
||||||
from functools import wraps
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, List
|
from typing import Any, List
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from cachetools import TTLCache
|
|
||||||
from cachetools.keys import hashkey
|
|
||||||
|
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
from app.chain.bangumi import BangumiChain
|
from app.chain.bangumi import BangumiChain
|
||||||
from app.chain.douban import DoubanChain
|
from app.chain.douban import DoubanChain
|
||||||
from app.chain.tmdb import TmdbChain
|
from app.chain.tmdb import TmdbChain
|
||||||
|
from app.core.cache import cache_backend, cached
|
||||||
from app.core.config import settings, global_vars
|
from app.core.config import settings, global_vars
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas import MediaType
|
from app.schemas import MediaType
|
||||||
@@ -23,42 +20,7 @@ from app.utils.singleton import Singleton
|
|||||||
|
|
||||||
# 推荐相关的专用缓存
|
# 推荐相关的专用缓存
|
||||||
recommend_ttl = 24 * 3600
|
recommend_ttl = 24 * 3600
|
||||||
recommend_cache = TTLCache(maxsize=256, ttl=recommend_ttl)
|
recommend_cache_region = "recommend"
|
||||||
|
|
||||||
|
|
||||||
# 推荐缓存装饰器,避免偶发网络获取数据为空时,页面由于缓存为空长时间渲染异常问题
|
|
||||||
def cached_with_empty_check(func: Callable):
|
|
||||||
"""
|
|
||||||
缓存装饰器,用于缓存函数的返回结果,仅在结果非空时进行缓存
|
|
||||||
|
|
||||||
:param func: 被装饰的函数
|
|
||||||
:return: 包装后的函数
|
|
||||||
"""
|
|
||||||
|
|
||||||
@wraps(func)
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
signature = inspect.signature(func)
|
|
||||||
resolved_kwargs = {}
|
|
||||||
# 获取默认值并结合传递的参数(如果有)
|
|
||||||
for param, value in signature.parameters.items():
|
|
||||||
if param in kwargs:
|
|
||||||
# 使用显式传递的参数
|
|
||||||
resolved_kwargs[param] = kwargs[param]
|
|
||||||
elif value.default is not inspect.Parameter.empty:
|
|
||||||
# 没有传递参数时使用默认值
|
|
||||||
resolved_kwargs[param] = value.default
|
|
||||||
# 使用 cachetools 缓存,构造缓存键
|
|
||||||
cache_key = f"{func.__name__}_{hashkey(*args, **resolved_kwargs)}"
|
|
||||||
if cache_key in recommend_cache:
|
|
||||||
return recommend_cache[cache_key]
|
|
||||||
result = func(*args, **kwargs)
|
|
||||||
# 如果返回值为空,则不缓存
|
|
||||||
if result in [None, [], {}]:
|
|
||||||
return result
|
|
||||||
recommend_cache[cache_key] = result
|
|
||||||
return result
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
class RecommendChain(ChainBase, metaclass=Singleton):
|
class RecommendChain(ChainBase, metaclass=Singleton):
|
||||||
@@ -78,7 +40,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
刷新推荐
|
刷新推荐
|
||||||
"""
|
"""
|
||||||
logger.debug("Starting to refresh Recommend data.")
|
logger.debug("Starting to refresh Recommend data.")
|
||||||
recommend_cache.clear()
|
cache_backend.clear(region=recommend_cache_region)
|
||||||
logger.debug("Recommend Cache has been cleared.")
|
logger.debug("Recommend Cache has been cleared.")
|
||||||
|
|
||||||
# 推荐来源方法
|
# 推荐来源方法
|
||||||
@@ -194,9 +156,16 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
logger.debug(f"Failed to write cache file {cache_path} for URL {url}: {e}")
|
logger.debug(f"Failed to write cache file {cache_path} for URL {url}: {e}")
|
||||||
|
|
||||||
@log_execution_time(logger=logger)
|
@log_execution_time(logger=logger)
|
||||||
@cached_with_empty_check
|
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||||
def tmdb_movies(self, sort_by: str = "popularity.desc", with_genres: str = "",
|
def tmdb_movies(self, sort_by: str = "popularity.desc",
|
||||||
with_original_language: str = "", page: int = 1) -> Any:
|
with_genres: str = "",
|
||||||
|
with_original_language: str = "",
|
||||||
|
with_keywords: str = "",
|
||||||
|
with_watch_providers: str = "",
|
||||||
|
vote_average: float = 0,
|
||||||
|
vote_count: int = 0,
|
||||||
|
release_date: str = "",
|
||||||
|
page: int = 1) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
TMDB热门电影
|
TMDB热门电影
|
||||||
"""
|
"""
|
||||||
@@ -204,13 +173,25 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
sort_by=sort_by,
|
sort_by=sort_by,
|
||||||
with_genres=with_genres,
|
with_genres=with_genres,
|
||||||
with_original_language=with_original_language,
|
with_original_language=with_original_language,
|
||||||
|
with_keywords=with_keywords,
|
||||||
|
with_watch_providers=with_watch_providers,
|
||||||
|
vote_average=vote_average,
|
||||||
|
vote_count=vote_count,
|
||||||
|
release_date=release_date,
|
||||||
page=page)
|
page=page)
|
||||||
return [movie.to_dict() for movie in movies] if movies else []
|
return [movie.to_dict() for movie in movies] if movies else []
|
||||||
|
|
||||||
@log_execution_time(logger=logger)
|
@log_execution_time(logger=logger)
|
||||||
@cached_with_empty_check
|
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||||
def tmdb_tvs(self, sort_by: str = "popularity.desc", with_genres: str = "",
|
def tmdb_tvs(self, sort_by: str = "popularity.desc",
|
||||||
with_original_language: str = "zh|en|ja|ko", page: int = 1) -> Any:
|
with_genres: str = "",
|
||||||
|
with_original_language: str = "zh|en|ja|ko",
|
||||||
|
with_keywords: str = "",
|
||||||
|
with_watch_providers: str = "",
|
||||||
|
vote_average: float = 0,
|
||||||
|
vote_count: int = 0,
|
||||||
|
release_date: str = "",
|
||||||
|
page: int = 1) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
TMDB热门电视剧
|
TMDB热门电视剧
|
||||||
"""
|
"""
|
||||||
@@ -218,12 +199,17 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
sort_by=sort_by,
|
sort_by=sort_by,
|
||||||
with_genres=with_genres,
|
with_genres=with_genres,
|
||||||
with_original_language=with_original_language,
|
with_original_language=with_original_language,
|
||||||
|
with_keywords=with_keywords,
|
||||||
|
with_watch_providers=with_watch_providers,
|
||||||
|
vote_average=vote_average,
|
||||||
|
vote_count=vote_count,
|
||||||
|
release_date=release_date,
|
||||||
page=page)
|
page=page)
|
||||||
return [tv.to_dict() for tv in tvs] if tvs else []
|
return [tv.to_dict() for tv in tvs] if tvs else []
|
||||||
|
|
||||||
@log_execution_time(logger=logger)
|
@log_execution_time(logger=logger)
|
||||||
@cached_with_empty_check
|
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||||
def tmdb_trending(self, page: int = 1) -> Any:
|
def tmdb_trending(self, page: int = 1) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
TMDB流行趋势
|
TMDB流行趋势
|
||||||
"""
|
"""
|
||||||
@@ -231,8 +217,8 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
return [info.to_dict() for info in infos] if infos else []
|
return [info.to_dict() for info in infos] if infos else []
|
||||||
|
|
||||||
@log_execution_time(logger=logger)
|
@log_execution_time(logger=logger)
|
||||||
@cached_with_empty_check
|
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||||
def bangumi_calendar(self, page: int = 1, count: int = 30) -> Any:
|
def bangumi_calendar(self, page: int = 1, count: int = 30) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
Bangumi每日放送
|
Bangumi每日放送
|
||||||
"""
|
"""
|
||||||
@@ -240,8 +226,25 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
return [media.to_dict() for media in medias[(page - 1) * count: page * count]] if medias else []
|
return [media.to_dict() for media in medias[(page - 1) * count: page * count]] if medias else []
|
||||||
|
|
||||||
@log_execution_time(logger=logger)
|
@log_execution_time(logger=logger)
|
||||||
@cached_with_empty_check
|
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||||
def douban_movie_showing(self, page: int = 1, count: int = 30) -> Any:
|
def bangumi_discover(self, type: int = 2,
|
||||||
|
cat: int = None,
|
||||||
|
sort: str = 'rank',
|
||||||
|
year: int = None,
|
||||||
|
count: int = 30,
|
||||||
|
page: int = 1) -> List[dict]:
|
||||||
|
"""
|
||||||
|
搜索Bangumi
|
||||||
|
"""
|
||||||
|
medias = self.bangumichain.discover(type=type, cat=cat, sort=sort, year=year,
|
||||||
|
limit=count, offset=(page - 1) * count)
|
||||||
|
if medias:
|
||||||
|
return [media.to_dict() for media in medias]
|
||||||
|
return []
|
||||||
|
|
||||||
|
@log_execution_time(logger=logger)
|
||||||
|
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||||
|
def douban_movie_showing(self, page: int = 1, count: int = 30) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
豆瓣正在热映
|
豆瓣正在热映
|
||||||
"""
|
"""
|
||||||
@@ -249,8 +252,8 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
return [media.to_dict() for media in movies] if movies else []
|
return [media.to_dict() for media in movies] if movies else []
|
||||||
|
|
||||||
@log_execution_time(logger=logger)
|
@log_execution_time(logger=logger)
|
||||||
@cached_with_empty_check
|
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||||
def douban_movies(self, sort: str = "R", tags: str = "", page: int = 1, count: int = 30) -> Any:
|
def douban_movies(self, sort: str = "R", tags: str = "", page: int = 1, count: int = 30) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
豆瓣最新电影
|
豆瓣最新电影
|
||||||
"""
|
"""
|
||||||
@@ -259,8 +262,8 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
return [media.to_dict() for media in movies] if movies else []
|
return [media.to_dict() for media in movies] if movies else []
|
||||||
|
|
||||||
@log_execution_time(logger=logger)
|
@log_execution_time(logger=logger)
|
||||||
@cached_with_empty_check
|
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||||
def douban_tvs(self, sort: str = "R", tags: str = "", page: int = 1, count: int = 30) -> Any:
|
def douban_tvs(self, sort: str = "R", tags: str = "", page: int = 1, count: int = 30) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
豆瓣最新电视剧
|
豆瓣最新电视剧
|
||||||
"""
|
"""
|
||||||
@@ -269,8 +272,8 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
return [media.to_dict() for media in tvs] if tvs else []
|
return [media.to_dict() for media in tvs] if tvs else []
|
||||||
|
|
||||||
@log_execution_time(logger=logger)
|
@log_execution_time(logger=logger)
|
||||||
@cached_with_empty_check
|
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||||
def douban_movie_top250(self, page: int = 1, count: int = 30) -> Any:
|
def douban_movie_top250(self, page: int = 1, count: int = 30) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
豆瓣电影TOP250
|
豆瓣电影TOP250
|
||||||
"""
|
"""
|
||||||
@@ -278,8 +281,8 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
return [media.to_dict() for media in movies] if movies else []
|
return [media.to_dict() for media in movies] if movies else []
|
||||||
|
|
||||||
@log_execution_time(logger=logger)
|
@log_execution_time(logger=logger)
|
||||||
@cached_with_empty_check
|
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||||
def douban_tv_weekly_chinese(self, page: int = 1, count: int = 30) -> Any:
|
def douban_tv_weekly_chinese(self, page: int = 1, count: int = 30) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
豆瓣国产剧集榜
|
豆瓣国产剧集榜
|
||||||
"""
|
"""
|
||||||
@@ -287,8 +290,8 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
return [media.to_dict() for media in tvs] if tvs else []
|
return [media.to_dict() for media in tvs] if tvs else []
|
||||||
|
|
||||||
@log_execution_time(logger=logger)
|
@log_execution_time(logger=logger)
|
||||||
@cached_with_empty_check
|
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||||
def douban_tv_weekly_global(self, page: int = 1, count: int = 30) -> Any:
|
def douban_tv_weekly_global(self, page: int = 1, count: int = 30) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
豆瓣全球剧集榜
|
豆瓣全球剧集榜
|
||||||
"""
|
"""
|
||||||
@@ -296,8 +299,8 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
return [media.to_dict() for media in tvs] if tvs else []
|
return [media.to_dict() for media in tvs] if tvs else []
|
||||||
|
|
||||||
@log_execution_time(logger=logger)
|
@log_execution_time(logger=logger)
|
||||||
@cached_with_empty_check
|
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||||
def douban_tv_animation(self, page: int = 1, count: int = 30) -> Any:
|
def douban_tv_animation(self, page: int = 1, count: int = 30) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
豆瓣热门动漫
|
豆瓣热门动漫
|
||||||
"""
|
"""
|
||||||
@@ -305,8 +308,8 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
return [media.to_dict() for media in tvs] if tvs else []
|
return [media.to_dict() for media in tvs] if tvs else []
|
||||||
|
|
||||||
@log_execution_time(logger=logger)
|
@log_execution_time(logger=logger)
|
||||||
@cached_with_empty_check
|
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||||
def douban_movie_hot(self, page: int = 1, count: int = 30) -> Any:
|
def douban_movie_hot(self, page: int = 1, count: int = 30) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
豆瓣热门电影
|
豆瓣热门电影
|
||||||
"""
|
"""
|
||||||
@@ -314,8 +317,8 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
return [media.to_dict() for media in movies] if movies else []
|
return [media.to_dict() for media in movies] if movies else []
|
||||||
|
|
||||||
@log_execution_time(logger=logger)
|
@log_execution_time(logger=logger)
|
||||||
@cached_with_empty_check
|
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||||
def douban_tv_hot(self, page: int = 1, count: int = 30) -> Any:
|
def douban_tv_hot(self, page: int = 1, count: int = 30) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
豆瓣热门电视剧
|
豆瓣热门电视剧
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import time
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, List, Optional, Union, Tuple
|
from typing import Dict, List, Optional, Union, Tuple
|
||||||
|
|
||||||
|
from app import schemas
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
from app.chain.download import DownloadChain
|
from app.chain.download import DownloadChain
|
||||||
from app.chain.media import MediaChain
|
from app.chain.media import MediaChain
|
||||||
@@ -27,8 +28,6 @@ from app.helper.message import MessageHelper
|
|||||||
from app.helper.subscribe import SubscribeHelper
|
from app.helper.subscribe import SubscribeHelper
|
||||||
from app.helper.torrent import TorrentHelper
|
from app.helper.torrent import TorrentHelper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas import NotExistMediaInfo, Notification, SubscrbieInfo, SubscribeEpisodeInfo, SubscribeDownloadFileInfo, \
|
|
||||||
SubscribeLibraryFileInfo
|
|
||||||
from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType, EventType
|
from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType, EventType
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
|
|
||||||
@@ -166,21 +165,23 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
'downloader': self.__get_default_subscribe_config(mediainfo.type, "downloader") if not kwargs.get(
|
'downloader': self.__get_default_subscribe_config(mediainfo.type, "downloader") if not kwargs.get(
|
||||||
"downloader") else kwargs.get("downloader"),
|
"downloader") else kwargs.get("downloader"),
|
||||||
'save_path': self.__get_default_subscribe_config(mediainfo.type, "save_path") if not kwargs.get(
|
'save_path': self.__get_default_subscribe_config(mediainfo.type, "save_path") if not kwargs.get(
|
||||||
"save_path") else kwargs.get("save_path")
|
"save_path") else kwargs.get("save_path"),
|
||||||
|
'filter_groups': self.__get_default_subscribe_config(mediainfo.type, "filter_groups") if not kwargs.get(
|
||||||
|
"filter_groups") else kwargs.get("filter_groups"),
|
||||||
})
|
})
|
||||||
sid, err_msg = self.subscribeoper.add(mediainfo=mediainfo, season=season, username=username, **kwargs)
|
sid, err_msg = self.subscribeoper.add(mediainfo=mediainfo, season=season, username=username, **kwargs)
|
||||||
if not sid:
|
if not sid:
|
||||||
logger.error(f'{mediainfo.title_year} {err_msg}')
|
logger.error(f'{mediainfo.title_year} {err_msg}')
|
||||||
if not exist_ok and message:
|
if not exist_ok and message:
|
||||||
# 失败发回原用户
|
# 失败发回原用户
|
||||||
self.post_message(Notification(channel=channel,
|
self.post_message(schemas.Notification(channel=channel,
|
||||||
source=source,
|
source=source,
|
||||||
mtype=NotificationType.Subscribe,
|
mtype=NotificationType.Subscribe,
|
||||||
title=f"{mediainfo.title_year} {metainfo.season} "
|
title=f"{mediainfo.title_year} {metainfo.season} "
|
||||||
f"添加订阅失败!",
|
f"添加订阅失败!",
|
||||||
text=f"{err_msg}",
|
text=f"{err_msg}",
|
||||||
image=mediainfo.get_message_image(),
|
image=mediainfo.get_message_image(),
|
||||||
userid=userid))
|
userid=userid))
|
||||||
return None, err_msg
|
return None, err_msg
|
||||||
elif message:
|
elif message:
|
||||||
logger.info(f'{mediainfo.title_year} {metainfo.season} 添加订阅成功')
|
logger.info(f'{mediainfo.title_year} {metainfo.season} 添加订阅成功')
|
||||||
@@ -193,12 +194,12 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
else:
|
else:
|
||||||
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
|
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
|
||||||
# 订阅成功按规则发送消息
|
# 订阅成功按规则发送消息
|
||||||
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
self.post_message(schemas.Notification(mtype=NotificationType.Subscribe,
|
||||||
title=f"{mediainfo.title_year} {metainfo.season} 已添加订阅",
|
title=f"{mediainfo.title_year} {metainfo.season} 已添加订阅",
|
||||||
text=text,
|
text=text,
|
||||||
image=mediainfo.get_message_image(),
|
image=mediainfo.get_message_image(),
|
||||||
link=link,
|
link=link,
|
||||||
username=username))
|
username=username))
|
||||||
# 发送事件
|
# 发送事件
|
||||||
EventManager().send_event(EventType.SubscribeAdded, {
|
EventManager().send_event(EventType.SubscribeAdded, {
|
||||||
"subscribe_id": sid,
|
"subscribe_id": sid,
|
||||||
@@ -409,7 +410,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
|
|
||||||
def finish_subscribe_or_not(self, subscribe: Subscribe, meta: MetaBase, mediainfo: MediaInfo,
|
def finish_subscribe_or_not(self, subscribe: Subscribe, meta: MetaBase, mediainfo: MediaInfo,
|
||||||
downloads: List[Context] = None,
|
downloads: List[Context] = None,
|
||||||
lefts: Dict[Union[int | str], Dict[int, NotExistMediaInfo]] = None,
|
lefts: Dict[Union[int | str], Dict[int, schemas.NotExistMediaInfo]] = None,
|
||||||
force: bool = False):
|
force: bool = False):
|
||||||
"""
|
"""
|
||||||
判断是否应完成订阅
|
判断是否应完成订阅
|
||||||
@@ -464,18 +465,16 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
# 从系统配置获取默认订阅站点
|
# 从系统配置获取默认订阅站点
|
||||||
default_sites = self.systemconfig.get(SystemConfigKey.RssSites) or []
|
default_sites = self.systemconfig.get(SystemConfigKey.RssSites) or []
|
||||||
# 如果订阅未指定站点信息,直接返回默认站点
|
# 如果订阅未指定站点,直接返回默认站点
|
||||||
if not subscribe.sites:
|
if not subscribe.sites:
|
||||||
return default_sites
|
return default_sites
|
||||||
|
# 如果默认订阅站点未设置,直接返回订阅指定站点
|
||||||
|
if not default_sites:
|
||||||
|
return subscribe.sites or []
|
||||||
# 尝试解析订阅中的站点数据
|
# 尝试解析订阅中的站点数据
|
||||||
user_sites = subscribe.sites
|
user_sites = subscribe.sites
|
||||||
# 计算 user_sites 和 default_sites 的交集
|
# 计算 user_sites 和 default_sites 的交集
|
||||||
intersection_sites = [site for site in user_sites if site in default_sites]
|
intersection_sites = [site for site in user_sites if site in default_sites]
|
||||||
# 如果交集与原始订阅不一致,更新数据库
|
|
||||||
if set(intersection_sites) != set(user_sites):
|
|
||||||
self.subscribeoper.update(subscribe.id, {
|
|
||||||
"sites": intersection_sites
|
|
||||||
})
|
|
||||||
# 如果交集为空,返回默认站点
|
# 如果交集为空,返回默认站点
|
||||||
return intersection_sites if intersection_sites else default_sites
|
return intersection_sites if intersection_sites else default_sites
|
||||||
|
|
||||||
@@ -574,9 +573,9 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# 有自定义识别词时,需要判断是否需要重新识别
|
# 有自定义识别词时,需要判断是否需要重新识别
|
||||||
apply_words = None
|
|
||||||
if custom_words_list:
|
if custom_words_list:
|
||||||
_, apply_words = WordsMatcher().prepare(torrent_info.title,
|
# 使用org_string,应用一次后理论上不能再次应用
|
||||||
|
_, apply_words = WordsMatcher().prepare(torrent_meta.org_string,
|
||||||
custom_words=custom_words_list)
|
custom_words=custom_words_list)
|
||||||
if apply_words:
|
if apply_words:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -584,6 +583,8 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
# 重新识别元数据
|
# 重新识别元数据
|
||||||
torrent_meta = MetaInfo(title=torrent_info.title, subtitle=torrent_info.description,
|
torrent_meta = MetaInfo(title=torrent_info.title, subtitle=torrent_info.description,
|
||||||
custom_words=custom_words_list)
|
custom_words=custom_words_list)
|
||||||
|
# 更新元数据缓存
|
||||||
|
context.meta_info = torrent_meta
|
||||||
# 媒体信息需要重新识别
|
# 媒体信息需要重新识别
|
||||||
torrent_mediainfo = None
|
torrent_mediainfo = None
|
||||||
|
|
||||||
@@ -594,8 +595,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
torrent_mediainfo = self.recognize_media(meta=torrent_meta)
|
torrent_mediainfo = self.recognize_media(meta=torrent_meta)
|
||||||
if torrent_mediainfo:
|
if torrent_mediainfo:
|
||||||
# 更新种子缓存
|
# 更新种子缓存
|
||||||
if not apply_words:
|
context.media_info = torrent_mediainfo
|
||||||
context.media_info = torrent_mediainfo
|
|
||||||
else:
|
else:
|
||||||
# 通过标题匹配兜底
|
# 通过标题匹配兜底
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -607,9 +607,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
logger.info(
|
logger.info(
|
||||||
f'{mediainfo.title_year} 通过标题匹配到可选资源:{torrent_info.site_name} - {torrent_info.title}')
|
f'{mediainfo.title_year} 通过标题匹配到可选资源:{torrent_info.site_name} - {torrent_info.title}')
|
||||||
torrent_mediainfo = mediainfo
|
torrent_mediainfo = mediainfo
|
||||||
# 更新种子缓存
|
context.media_info = torrent_mediainfo
|
||||||
if not apply_words:
|
|
||||||
context.media_info = mediainfo
|
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -784,6 +782,68 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
})
|
})
|
||||||
logger.info(f'{subscribe.name} 订阅元数据更新完成')
|
logger.info(f'{subscribe.name} 订阅元数据更新完成')
|
||||||
|
|
||||||
|
def follow(self):
|
||||||
|
"""
|
||||||
|
刷新follow的用户分享,并自动添加订阅
|
||||||
|
"""
|
||||||
|
follow_users: List[str] = self.systemconfig.get(SystemConfigKey.FollowSubscribers)
|
||||||
|
if not follow_users:
|
||||||
|
return
|
||||||
|
share_subs = self.subscribehelper.get_shares()
|
||||||
|
logger.info(f'开始刷新follow用户分享订阅 ...')
|
||||||
|
success_count = 0
|
||||||
|
for share_sub in share_subs:
|
||||||
|
uid = share_sub.get("share_uid")
|
||||||
|
if uid and uid in follow_users:
|
||||||
|
# 订阅已存在则跳过
|
||||||
|
if self.subscribeoper.exists(tmdbid=share_sub.get("tmdbid"),
|
||||||
|
doubanid=share_sub.get("doubanid"),
|
||||||
|
season=share_sub.get("season")):
|
||||||
|
continue
|
||||||
|
# 已经订阅过跳过
|
||||||
|
if self.subscribeoper.exist_history(tmdbid=share_sub.get("tmdbid"),
|
||||||
|
doubanid=share_sub.get("doubanid"),
|
||||||
|
season=share_sub.get("season")):
|
||||||
|
continue
|
||||||
|
# 去除无效属性
|
||||||
|
for key in list(share_sub.keys()):
|
||||||
|
if not hasattr(schemas.Subscribe(), key):
|
||||||
|
share_sub.pop(key)
|
||||||
|
# 类型转换
|
||||||
|
subscribe_in = schemas.Subscribe(**share_sub)
|
||||||
|
mtype = MediaType(subscribe_in.type)
|
||||||
|
# 豆瓣标题处理
|
||||||
|
if subscribe_in.doubanid or subscribe_in.bangumiid:
|
||||||
|
meta = MetaInfo(subscribe_in.name)
|
||||||
|
subscribe_in.name = meta.name
|
||||||
|
subscribe_in.season = meta.begin_season
|
||||||
|
# 标题转换
|
||||||
|
if subscribe_in.name:
|
||||||
|
title = subscribe_in.name
|
||||||
|
else:
|
||||||
|
title = None
|
||||||
|
sid, message = SubscribeChain().add(mtype=mtype,
|
||||||
|
title=title,
|
||||||
|
year=subscribe_in.year,
|
||||||
|
tmdbid=subscribe_in.tmdbid,
|
||||||
|
season=subscribe_in.season,
|
||||||
|
doubanid=subscribe_in.doubanid,
|
||||||
|
bangumiid=subscribe_in.bangumiid,
|
||||||
|
username="订阅分享",
|
||||||
|
best_version=subscribe_in.best_version,
|
||||||
|
save_path=subscribe_in.save_path,
|
||||||
|
search_imdbid=subscribe_in.search_imdbid,
|
||||||
|
custom_words=subscribe_in.custom_words,
|
||||||
|
media_category=subscribe_in.media_category,
|
||||||
|
filter_groups=subscribe_in.filter_groups,
|
||||||
|
exist_ok=True)
|
||||||
|
if sid:
|
||||||
|
success_count += 1
|
||||||
|
logger.info(f'follow用户分享订阅 {title} 添加成功')
|
||||||
|
else:
|
||||||
|
logger.error(f'follow用户分享订阅 {title} 添加失败:{message}')
|
||||||
|
logger.info(f'follow用户分享订阅刷新完成,共添加 {success_count} 个订阅')
|
||||||
|
|
||||||
def __update_subscribe_note(self, subscribe: Subscribe, downloads: List[Context]):
|
def __update_subscribe_note(self, subscribe: Subscribe, downloads: List[Context]):
|
||||||
"""
|
"""
|
||||||
更新已下载信息到note字段
|
更新已下载信息到note字段
|
||||||
@@ -840,7 +900,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
return note
|
return note
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def __update_lack_episodes(self, lefts: Dict[Union[int, str], Dict[int, NotExistMediaInfo]],
|
def __update_lack_episodes(self, lefts: Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]],
|
||||||
subscribe: Subscribe,
|
subscribe: Subscribe,
|
||||||
mediainfo: MediaInfo,
|
mediainfo: MediaInfo,
|
||||||
update_date: bool = False):
|
update_date: bool = False):
|
||||||
@@ -895,11 +955,11 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
else:
|
else:
|
||||||
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
|
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
|
||||||
# 完成订阅按规则发送消息
|
# 完成订阅按规则发送消息
|
||||||
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
self.post_message(schemas.Notification(mtype=NotificationType.Subscribe,
|
||||||
title=f'{mediainfo.title_year} {meta.season} 已完成{msgstr}',
|
title=f'{mediainfo.title_year} {meta.season} 已完成{msgstr}',
|
||||||
image=mediainfo.get_message_image(),
|
image=mediainfo.get_message_image(),
|
||||||
link=link,
|
link=link,
|
||||||
username=subscribe.username))
|
username=subscribe.username))
|
||||||
# 发送事件
|
# 发送事件
|
||||||
EventManager().send_event(EventType.SubscribeComplete, {
|
EventManager().send_event(EventType.SubscribeComplete, {
|
||||||
"subscribe_id": subscribe.id,
|
"subscribe_id": subscribe.id,
|
||||||
@@ -919,9 +979,9 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
subscribes = self.subscribeoper.list()
|
subscribes = self.subscribeoper.list()
|
||||||
if not subscribes:
|
if not subscribes:
|
||||||
self.post_message(Notification(channel=channel,
|
self.post_message(schemas.Notification(channel=channel,
|
||||||
source=source,
|
source=source,
|
||||||
title='没有任何订阅!', userid=userid))
|
title='没有任何订阅!', userid=userid))
|
||||||
return
|
return
|
||||||
title = f"共有 {len(subscribes)} 个订阅,回复对应指令操作: " \
|
title = f"共有 {len(subscribes)} 个订阅,回复对应指令操作: " \
|
||||||
f"\n- 删除订阅:/subscribe_delete [id]" \
|
f"\n- 删除订阅:/subscribe_delete [id]" \
|
||||||
@@ -937,8 +997,8 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
f"[{subscribe.total_episode - (subscribe.lack_episode or subscribe.total_episode)}"
|
f"[{subscribe.total_episode - (subscribe.lack_episode or subscribe.total_episode)}"
|
||||||
f"/{subscribe.total_episode}]")
|
f"/{subscribe.total_episode}]")
|
||||||
# 发送列表
|
# 发送列表
|
||||||
self.post_message(Notification(channel=channel, source=source,
|
self.post_message(schemas.Notification(channel=channel, source=source,
|
||||||
title=title, text='\n'.join(messages), userid=userid))
|
title=title, text='\n'.join(messages), userid=userid))
|
||||||
|
|
||||||
def remote_delete(self, arg_str: str, channel: MessageChannel,
|
def remote_delete(self, arg_str: str, channel: MessageChannel,
|
||||||
userid: Union[str, int] = None, source: str = None):
|
userid: Union[str, int] = None, source: str = None):
|
||||||
@@ -946,9 +1006,9 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
删除订阅
|
删除订阅
|
||||||
"""
|
"""
|
||||||
if not arg_str:
|
if not arg_str:
|
||||||
self.post_message(Notification(channel=channel, source=source,
|
self.post_message(schemas.Notification(channel=channel, source=source,
|
||||||
title="请输入正确的命令格式:/subscribe_delete [id],"
|
title="请输入正确的命令格式:/subscribe_delete [id],"
|
||||||
"[id]为订阅编号", userid=userid))
|
"[id]为订阅编号", userid=userid))
|
||||||
return
|
return
|
||||||
arg_strs = str(arg_str).split()
|
arg_strs = str(arg_str).split()
|
||||||
for arg_str in arg_strs:
|
for arg_str in arg_strs:
|
||||||
@@ -958,8 +1018,8 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
subscribe_id = int(arg_str)
|
subscribe_id = int(arg_str)
|
||||||
subscribe = self.subscribeoper.get(subscribe_id)
|
subscribe = self.subscribeoper.get(subscribe_id)
|
||||||
if not subscribe:
|
if not subscribe:
|
||||||
self.post_message(Notification(channel=channel, source=source,
|
self.post_message(schemas.Notification(channel=channel, source=source,
|
||||||
title=f"订阅编号 {subscribe_id} 不存在!", userid=userid))
|
title=f"订阅编号 {subscribe_id} 不存在!", userid=userid))
|
||||||
return
|
return
|
||||||
# 删除订阅
|
# 删除订阅
|
||||||
self.subscribeoper.delete(subscribe_id)
|
self.subscribeoper.delete(subscribe_id)
|
||||||
@@ -973,13 +1033,13 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __get_subscribe_no_exits(subscribe_name: str,
|
def __get_subscribe_no_exits(subscribe_name: str,
|
||||||
no_exists: Dict[Union[int, str], Dict[int, NotExistMediaInfo]],
|
no_exists: Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]],
|
||||||
mediakey: Union[str, int],
|
mediakey: Union[str, int],
|
||||||
begin_season: int,
|
begin_season: int,
|
||||||
total_episode: int,
|
total_episode: int,
|
||||||
start_episode: int,
|
start_episode: int,
|
||||||
downloaded_episodes: List[int] = None
|
downloaded_episodes: List[int] = None
|
||||||
) -> Tuple[bool, Dict[Union[int, str], Dict[int, NotExistMediaInfo]]]:
|
) -> Tuple[bool, Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]]]:
|
||||||
"""
|
"""
|
||||||
根据订阅开始集数和总集数,结合TMDB信息计算当前订阅的缺失集数
|
根据订阅开始集数和总集数,结合TMDB信息计算当前订阅的缺失集数
|
||||||
:param subscribe_name: 订阅名称
|
:param subscribe_name: 订阅名称
|
||||||
@@ -1029,7 +1089,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
# 与原集列表取交集
|
# 与原集列表取交集
|
||||||
episodes = list(set(episode_list).intersection(set(new_episodes)))
|
episodes = list(set(episode_list).intersection(set(new_episodes)))
|
||||||
# 更新集合
|
# 更新集合
|
||||||
no_exists[mediakey][begin_season] = NotExistMediaInfo(
|
no_exists[mediakey][begin_season] = schemas.NotExistMediaInfo(
|
||||||
season=begin_season,
|
season=begin_season,
|
||||||
episodes=episodes,
|
episodes=episodes,
|
||||||
total_episode=total_episode,
|
total_episode=total_episode,
|
||||||
@@ -1056,7 +1116,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
if not episodes:
|
if not episodes:
|
||||||
return True, {}
|
return True, {}
|
||||||
# 更新集合
|
# 更新集合
|
||||||
no_exists[mediakey][begin_season] = NotExistMediaInfo(
|
no_exists[mediakey][begin_season] = schemas.NotExistMediaInfo(
|
||||||
season=begin_season,
|
season=begin_season,
|
||||||
episodes=episodes,
|
episodes=episodes,
|
||||||
total_episode=total,
|
total_episode=total,
|
||||||
@@ -1070,7 +1130,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
# 如果存在已下载剧集,则差集为空时,说明所有均已存在
|
# 如果存在已下载剧集,则差集为空时,说明所有均已存在
|
||||||
if not episodes:
|
if not episodes:
|
||||||
return True, {}
|
return True, {}
|
||||||
no_exists[mediakey][begin_season] = NotExistMediaInfo(
|
no_exists[mediakey][begin_season] = schemas.NotExistMediaInfo(
|
||||||
season=begin_season,
|
season=begin_season,
|
||||||
episodes=episodes,
|
episodes=episodes,
|
||||||
total_episode=total_episode,
|
total_episode=total_episode,
|
||||||
@@ -1157,7 +1217,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
"min_seeders_time": default_rule.get("min_seeders_time"),
|
"min_seeders_time": default_rule.get("min_seeders_time"),
|
||||||
}.items() if value is not None}
|
}.items() if value is not None}
|
||||||
|
|
||||||
def subscribe_files_info(self, subscribe: Subscribe) -> Optional[SubscrbieInfo]:
|
def subscribe_files_info(self, subscribe: Subscribe) -> Optional[schemas.SubscrbieInfo]:
|
||||||
"""
|
"""
|
||||||
订阅相关的下载和文件信息
|
订阅相关的下载和文件信息
|
||||||
"""
|
"""
|
||||||
@@ -1165,10 +1225,10 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# 返回订阅数据
|
# 返回订阅数据
|
||||||
subscribe_info = SubscrbieInfo()
|
subscribe_info = schemas.SubscrbieInfo()
|
||||||
|
|
||||||
# 所有集的数据
|
# 所有集的数据
|
||||||
episodes: Dict[int, SubscribeEpisodeInfo] = {}
|
episodes: Dict[int, schemas.SubscribeEpisodeInfo] = {}
|
||||||
if subscribe.tmdbid and subscribe.type == MediaType.TV.value:
|
if subscribe.tmdbid and subscribe.type == MediaType.TV.value:
|
||||||
# 查询TMDB中的集信息
|
# 查询TMDB中的集信息
|
||||||
tmdb_episodes = self.tmdbchain.tmdb_episodes(
|
tmdb_episodes = self.tmdbchain.tmdb_episodes(
|
||||||
@@ -1177,7 +1237,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
)
|
)
|
||||||
if tmdb_episodes:
|
if tmdb_episodes:
|
||||||
for episode in tmdb_episodes:
|
for episode in tmdb_episodes:
|
||||||
info = SubscribeEpisodeInfo()
|
info = schemas.SubscribeEpisodeInfo()
|
||||||
info.title = episode.name
|
info.title = episode.name
|
||||||
info.description = episode.overview
|
info.description = episode.overview
|
||||||
info.backdrop = f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/w500${episode.still_path}"
|
info.backdrop = f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/w500${episode.still_path}"
|
||||||
@@ -1185,12 +1245,12 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
elif subscribe.type == MediaType.TV.value:
|
elif subscribe.type == MediaType.TV.value:
|
||||||
# 根据开始结束集计算集信息
|
# 根据开始结束集计算集信息
|
||||||
for i in range(subscribe.start_episode or 1, subscribe.total_episode + 1):
|
for i in range(subscribe.start_episode or 1, subscribe.total_episode + 1):
|
||||||
info = SubscribeEpisodeInfo()
|
info = schemas.SubscribeEpisodeInfo()
|
||||||
info.title = f'第 {i} 集'
|
info.title = f'第 {i} 集'
|
||||||
episodes[i] = info
|
episodes[i] = info
|
||||||
else:
|
else:
|
||||||
# 电影
|
# 电影
|
||||||
info = SubscribeEpisodeInfo()
|
info = schemas.SubscribeEpisodeInfo()
|
||||||
info.title = subscribe.name
|
info.title = subscribe.name
|
||||||
episodes[0] = info
|
episodes[0] = info
|
||||||
|
|
||||||
@@ -1205,7 +1265,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
# 识别文件名
|
# 识别文件名
|
||||||
file_meta = MetaInfo(file.filepath)
|
file_meta = MetaInfo(file.filepath)
|
||||||
# 下载文件信息
|
# 下载文件信息
|
||||||
file_info = SubscribeDownloadFileInfo(
|
file_info = schemas.SubscribeDownloadFileInfo(
|
||||||
torrent_title=his.torrent_name,
|
torrent_title=his.torrent_name,
|
||||||
site_name=his.torrent_site,
|
site_name=his.torrent_site,
|
||||||
downloader=file.downloader,
|
downloader=file.downloader,
|
||||||
@@ -1248,7 +1308,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
# 识别文件名
|
# 识别文件名
|
||||||
file_meta = MetaInfo(fileitem.path)
|
file_meta = MetaInfo(fileitem.path)
|
||||||
# 媒体库文件信息
|
# 媒体库文件信息
|
||||||
file_info = SubscribeLibraryFileInfo(
|
file_info = schemas.SubscribeLibraryFileInfo(
|
||||||
storage=fileitem.storage,
|
storage=fileitem.storage,
|
||||||
file_path=fileitem.path,
|
file_path=fileitem.path,
|
||||||
)
|
)
|
||||||
@@ -1308,7 +1368,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
# 对于电视剧,构造缺失的媒体信息
|
# 对于电视剧,构造缺失的媒体信息
|
||||||
no_exists = {
|
no_exists = {
|
||||||
mediakey: {
|
mediakey: {
|
||||||
subscribe.season: NotExistMediaInfo(
|
subscribe.season: schemas.NotExistMediaInfo(
|
||||||
season=subscribe.season,
|
season=subscribe.season,
|
||||||
episodes=[],
|
episodes=[],
|
||||||
total_episode=subscribe.total_episode,
|
total_episode=subscribe.total_episode,
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import random
|
import random
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
from cachetools import cached, TTLCache
|
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
|
from app.core.cache import cached
|
||||||
from app.core.context import MediaInfo
|
from app.core.context import MediaInfo
|
||||||
from app.schemas import MediaType
|
from app.schemas import MediaType
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
@@ -15,19 +14,38 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
|||||||
TheMovieDB处理链,单例运行
|
TheMovieDB处理链,单例运行
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def tmdb_discover(self, mtype: MediaType, sort_by: str, with_genres: str,
|
def tmdb_discover(self, mtype: MediaType,
|
||||||
with_original_language: str, page: int = 1) -> Optional[List[MediaInfo]]:
|
sort_by: str,
|
||||||
|
with_genres: str,
|
||||||
|
with_original_language: str,
|
||||||
|
with_keywords: str,
|
||||||
|
with_watch_providers: str,
|
||||||
|
vote_average: float,
|
||||||
|
vote_count: int,
|
||||||
|
release_date: str,
|
||||||
|
page: int = 1) -> Optional[List[MediaInfo]]:
|
||||||
"""
|
"""
|
||||||
:param mtype: 媒体类型
|
:param mtype: 媒体类型
|
||||||
:param sort_by: 排序方式
|
:param sort_by: 排序方式
|
||||||
:param with_genres: 类型
|
:param with_genres: 类型
|
||||||
:param with_original_language: 语言
|
:param with_original_language: 语言
|
||||||
|
:param with_keywords: 关键字
|
||||||
|
:param with_watch_providers: 提供商
|
||||||
|
:param vote_average: 评分
|
||||||
|
:param vote_count: 评分人数
|
||||||
|
:param release_date: 上映日期
|
||||||
:param page: 页码
|
:param page: 页码
|
||||||
:return: 媒体信息列表
|
:return: 媒体信息列表
|
||||||
"""
|
"""
|
||||||
return self.run_module("tmdb_discover", mtype=mtype,
|
return self.run_module("tmdb_discover", mtype=mtype,
|
||||||
sort_by=sort_by, with_genres=with_genres,
|
sort_by=sort_by,
|
||||||
|
with_genres=with_genres,
|
||||||
with_original_language=with_original_language,
|
with_original_language=with_original_language,
|
||||||
|
with_keywords=with_keywords,
|
||||||
|
with_watch_providers=with_watch_providers,
|
||||||
|
vote_average=vote_average,
|
||||||
|
vote_count=vote_count,
|
||||||
|
release_date=release_date,
|
||||||
page=page)
|
page=page)
|
||||||
|
|
||||||
def tmdb_trending(self, page: int = 1) -> Optional[List[MediaInfo]]:
|
def tmdb_trending(self, page: int = 1) -> Optional[List[MediaInfo]]:
|
||||||
@@ -119,7 +137,7 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("tmdb_person_credits", person_id=person_id, page=page)
|
return self.run_module("tmdb_person_credits", person_id=person_id, page=page)
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=1, ttl=3600))
|
@cached(maxsize=1, ttl=3600)
|
||||||
def get_random_wallpager(self) -> Optional[str]:
|
def get_random_wallpager(self) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
获取随机壁纸,缓存1个小时
|
获取随机壁纸,缓存1个小时
|
||||||
@@ -133,7 +151,7 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
|||||||
return info.backdrop_path
|
return info.backdrop_path
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=1, ttl=3600))
|
@cached(maxsize=1, ttl=3600)
|
||||||
def get_trending_wallpapers(self, num: int = 10) -> List[str]:
|
def get_trending_wallpapers(self, num: int = 10) -> List[str]:
|
||||||
"""
|
"""
|
||||||
获取所有流行壁纸
|
获取所有流行壁纸
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ class JobManager:
|
|||||||
# 计算状态为完成的任务数
|
# 计算状态为完成的任务数
|
||||||
if __mediaid__ not in self._job_view:
|
if __mediaid__ not in self._job_view:
|
||||||
return 0
|
return 0
|
||||||
return sum([task.fileitem.size for task in self._job_view[__mediaid__].tasks if task.state == "completed"])
|
return sum([task.fileitem.size for task in self._job_view[__mediaid__].tasks if task.state == "completed" and task.fileitem.size is not None])
|
||||||
|
|
||||||
def total(self) -> int:
|
def total(self) -> int:
|
||||||
"""
|
"""
|
||||||
@@ -453,9 +453,12 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
|||||||
if transferinfo.transfer_type in ["move"]:
|
if transferinfo.transfer_type in ["move"]:
|
||||||
# 所有成功的业务
|
# 所有成功的业务
|
||||||
tasks = self.jobview.success_tasks(task.mediainfo, task.meta.begin_season)
|
tasks = self.jobview.success_tasks(task.mediainfo, task.meta.begin_season)
|
||||||
|
# 记录已处理的种子hash
|
||||||
|
processed_hashes = set()
|
||||||
for t in tasks:
|
for t in tasks:
|
||||||
# 下载器hash
|
# 下载器hash
|
||||||
if t.download_hash:
|
if t.download_hash and t.download_hash not in processed_hashes:
|
||||||
|
processed_hashes.add(t.download_hash)
|
||||||
if self.remove_torrents(t.download_hash, downloader=t.downloader):
|
if self.remove_torrents(t.download_hash, downloader=t.downloader):
|
||||||
logger.info(f"移动模式删除种子成功:{t.download_hash} ")
|
logger.info(f"移动模式删除种子成功:{t.download_hash} ")
|
||||||
# 删除残留目录
|
# 删除残留目录
|
||||||
@@ -660,22 +663,22 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
|||||||
if transfer_history:
|
if transfer_history:
|
||||||
mediainfo.title = transfer_history.title
|
mediainfo.title = transfer_history.title
|
||||||
|
|
||||||
# 获取集数据
|
|
||||||
if not task.episodes_info and mediainfo.type == MediaType.TV:
|
|
||||||
if task.meta.begin_season is None:
|
|
||||||
task.meta.begin_season = 1
|
|
||||||
mediainfo.season = mediainfo.season or task.meta.begin_season
|
|
||||||
task.episodes_info = self.tmdbchain.tmdb_episodes(
|
|
||||||
tmdbid=mediainfo.tmdb_id,
|
|
||||||
season=mediainfo.season
|
|
||||||
)
|
|
||||||
|
|
||||||
# 更新任务信息
|
# 更新任务信息
|
||||||
task.mediainfo = mediainfo
|
task.mediainfo = mediainfo
|
||||||
# 更新队列任务
|
# 更新队列任务
|
||||||
curr_task = self.jobview.remove_task(task.fileitem)
|
curr_task = self.jobview.remove_task(task.fileitem)
|
||||||
self.jobview.add_task(task, state=curr_task.state if curr_task else "waiting")
|
self.jobview.add_task(task, state=curr_task.state if curr_task else "waiting")
|
||||||
|
|
||||||
|
# 获取集数据
|
||||||
|
if not task.episodes_info and task.mediainfo.type == MediaType.TV:
|
||||||
|
if task.meta.begin_season is None:
|
||||||
|
task.meta.begin_season = 1
|
||||||
|
task.mediainfo.season = task.mediainfo.season or task.meta.begin_season
|
||||||
|
task.episodes_info = self.tmdbchain.tmdb_episodes(
|
||||||
|
tmdbid=task.mediainfo.tmdb_id,
|
||||||
|
season=task.mediainfo.season
|
||||||
|
)
|
||||||
|
|
||||||
# 查询整理目标目录
|
# 查询整理目标目录
|
||||||
if not task.target_directory:
|
if not task.target_directory:
|
||||||
if task.target_path:
|
if task.target_path:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import copy
|
||||||
import threading
|
import threading
|
||||||
import traceback
|
import traceback
|
||||||
from typing import Any, Union, Dict, Optional
|
from typing import Any, Union, Dict, Optional
|
||||||
@@ -303,7 +304,7 @@ class Command(metaclass=Singleton):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# 命令
|
# 命令
|
||||||
cmd_data = command['data'] if command.get('data') else {}
|
cmd_data = copy.deepcopy(command['data']) if command.get('data') else {}
|
||||||
args_num = ObjectUtils.arguments(command['func'])
|
args_num = ObjectUtils.arguments(command['func'])
|
||||||
if args_num > 0:
|
if args_num > 0:
|
||||||
if cmd_data:
|
if cmd_data:
|
||||||
|
|||||||
552
app/core/cache.py
Normal file
552
app/core/cache.py
Normal file
@@ -0,0 +1,552 @@
|
|||||||
|
import inspect
|
||||||
|
import json
|
||||||
|
import pickle
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import redis
|
||||||
|
from cachetools import TTLCache
|
||||||
|
from cachetools.keys import hashkey
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.log import logger
|
||||||
|
|
||||||
|
# 默认缓存区
|
||||||
|
DEFAULT_CACHE_REGION = "DEFAULT"
|
||||||
|
|
||||||
|
|
||||||
|
class CacheBackend(ABC):
|
||||||
|
"""
|
||||||
|
缓存后端基类,定义通用的缓存接口
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def set(self, key: str, value: Any, ttl: int, region: str = DEFAULT_CACHE_REGION, **kwargs) -> None:
|
||||||
|
"""
|
||||||
|
设置缓存
|
||||||
|
|
||||||
|
:param key: 缓存的键
|
||||||
|
:param value: 缓存的值
|
||||||
|
:param ttl: 缓存的存活时间,单位秒
|
||||||
|
:param region: 缓存的区
|
||||||
|
:param kwargs: 其他参数
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def exists(self, key: str, region: str = DEFAULT_CACHE_REGION) -> bool:
|
||||||
|
"""
|
||||||
|
判断缓存键是否存在
|
||||||
|
|
||||||
|
:param key: 缓存的键
|
||||||
|
:param region: 缓存的区
|
||||||
|
:return: 存在返回 True,否则返回 False
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get(self, key: str, region: str = DEFAULT_CACHE_REGION) -> Any:
|
||||||
|
"""
|
||||||
|
获取缓存
|
||||||
|
|
||||||
|
:param key: 缓存的键
|
||||||
|
:param region: 缓存的区
|
||||||
|
:return: 返回缓存的值,如果缓存不存在返回 None
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def delete(self, key: str, region: str = DEFAULT_CACHE_REGION) -> None:
|
||||||
|
"""
|
||||||
|
删除缓存
|
||||||
|
|
||||||
|
:param key: 缓存的键
|
||||||
|
:param region: 缓存的区
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def clear(self, region: Optional[str] = None) -> None:
|
||||||
|
"""
|
||||||
|
清除指定区域的缓存或全部缓存
|
||||||
|
|
||||||
|
:param region: 缓存的区
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def close(self) -> None:
|
||||||
|
"""
|
||||||
|
关闭缓存连接
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_region(region: str = DEFAULT_CACHE_REGION):
|
||||||
|
"""
|
||||||
|
获取缓存的区
|
||||||
|
"""
|
||||||
|
return f"region:{region}" if region else "region:default"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_cache_key(func, args, kwargs):
|
||||||
|
"""
|
||||||
|
获取缓存的键,通过哈希函数对函数的参数进行处理
|
||||||
|
:param func: 被装饰的函数
|
||||||
|
:param args: 位置参数
|
||||||
|
:param kwargs: 关键字参数
|
||||||
|
:return: 缓存键
|
||||||
|
"""
|
||||||
|
signature = inspect.signature(func)
|
||||||
|
# 绑定传入的参数并应用默认值
|
||||||
|
bound = signature.bind(*args, **kwargs)
|
||||||
|
bound.apply_defaults()
|
||||||
|
# 忽略第一个参数,如果它是实例(self)或类(cls)
|
||||||
|
parameters = list(signature.parameters.keys())
|
||||||
|
if parameters and parameters[0] in ("self", "cls"):
|
||||||
|
bound.arguments.pop(parameters[0], None)
|
||||||
|
# 按照函数签名顺序提取参数值列表
|
||||||
|
keys = [
|
||||||
|
bound.arguments[param] for param in signature.parameters if param in bound.arguments
|
||||||
|
]
|
||||||
|
# 使用有序参数生成缓存键
|
||||||
|
return f"{func.__name__}_{hashkey(*keys)}"
|
||||||
|
|
||||||
|
|
||||||
|
class CacheToolsBackend(CacheBackend):
|
||||||
|
"""
|
||||||
|
基于 `cachetools.TTLCache` 实现的缓存后端
|
||||||
|
|
||||||
|
特性:
|
||||||
|
- 支持动态设置缓存的 TTL(Time To Live,存活时间)和最大条目数(Maxsize)
|
||||||
|
- 缓存实例按区域(region)划分,不同 region 拥有独立的缓存实例
|
||||||
|
- 同一 region 共享相同的 TTL 和 Maxsize,设置时只能作用于整个 region
|
||||||
|
|
||||||
|
限制:
|
||||||
|
- 不支持按 `key` 独立隔离 TTL 和 Maxsize,仅支持作用于 region 级别
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, maxsize: int = 1000, ttl: int = 1800):
|
||||||
|
"""
|
||||||
|
初始化缓存实例
|
||||||
|
|
||||||
|
:param maxsize: 缓存的最大条目数
|
||||||
|
:param ttl: 默认缓存存活时间,单位秒
|
||||||
|
"""
|
||||||
|
self.maxsize = maxsize
|
||||||
|
self.ttl = ttl
|
||||||
|
# 存储各个 region 的缓存实例,region -> TTLCache
|
||||||
|
self._region_caches: Dict[str, TTLCache] = {}
|
||||||
|
|
||||||
|
def __get_region_cache(self, region: str) -> Optional[TTLCache]:
|
||||||
|
"""
|
||||||
|
获取指定区域的缓存实例,如果不存在则返回 None
|
||||||
|
"""
|
||||||
|
region = self.get_region(region)
|
||||||
|
return self._region_caches.get(region)
|
||||||
|
|
||||||
|
def set(self, key: str, value: Any, ttl: int = None, region: str = DEFAULT_CACHE_REGION, **kwargs) -> None:
|
||||||
|
"""
|
||||||
|
设置缓存值支持每个 key 独立配置 TTL 和 Maxsize
|
||||||
|
|
||||||
|
:param key: 缓存的键
|
||||||
|
:param value: 缓存的值
|
||||||
|
:param ttl: 缓存的存活时间,单位秒如果未传入则使用默认值
|
||||||
|
:param region: 缓存的区
|
||||||
|
:param kwargs: maxsize: 缓存的最大条目数如果未传入则使用默认值
|
||||||
|
"""
|
||||||
|
ttl = ttl or self.ttl
|
||||||
|
maxsize = kwargs.get("maxsize", self.maxsize)
|
||||||
|
region = self.get_region(region)
|
||||||
|
# 如果该 key 尚未有缓存实例,则创建一个新的 TTLCache 实例
|
||||||
|
region_cache = self._region_caches.setdefault(region, TTLCache(maxsize=maxsize, ttl=ttl))
|
||||||
|
# 设置缓存值
|
||||||
|
region_cache[key] = value
|
||||||
|
|
||||||
|
def exists(self, key: str, region: str = DEFAULT_CACHE_REGION) -> bool:
|
||||||
|
"""
|
||||||
|
判断缓存键是否存在
|
||||||
|
|
||||||
|
:param key: 缓存的键
|
||||||
|
:param region: 缓存的区
|
||||||
|
:return: 存在返回 True,否则返回 False
|
||||||
|
"""
|
||||||
|
region_cache = self.__get_region_cache(region)
|
||||||
|
if region_cache is None:
|
||||||
|
return False
|
||||||
|
return key in region_cache
|
||||||
|
|
||||||
|
def get(self, key: str, region: str = DEFAULT_CACHE_REGION) -> Any:
|
||||||
|
"""
|
||||||
|
获取缓存的值
|
||||||
|
|
||||||
|
:param key: 缓存的键
|
||||||
|
:param region: 缓存的区
|
||||||
|
:return: 返回缓存的值,如果缓存不存在返回 None
|
||||||
|
"""
|
||||||
|
region_cache = self.__get_region_cache(region)
|
||||||
|
if region_cache is None:
|
||||||
|
return None
|
||||||
|
return region_cache.get(key)
|
||||||
|
|
||||||
|
def delete(self, key: str, region: str = DEFAULT_CACHE_REGION) -> None:
|
||||||
|
"""
|
||||||
|
删除缓存
|
||||||
|
|
||||||
|
:param key: 缓存的键
|
||||||
|
:param region: 缓存的区
|
||||||
|
"""
|
||||||
|
region_cache = self.__get_region_cache(region)
|
||||||
|
if region_cache is None:
|
||||||
|
return None
|
||||||
|
del region_cache[key]
|
||||||
|
|
||||||
|
def clear(self, region: Optional[str] = None) -> None:
|
||||||
|
"""
|
||||||
|
清除指定区域的缓存或全部缓存
|
||||||
|
|
||||||
|
:param region: 缓存的区
|
||||||
|
"""
|
||||||
|
if region:
|
||||||
|
# 清理指定缓存区
|
||||||
|
region_cache = self.__get_region_cache(region)
|
||||||
|
if region_cache:
|
||||||
|
region_cache.clear()
|
||||||
|
logger.info(f"Cleared cache for region: {region}")
|
||||||
|
else:
|
||||||
|
# 清除所有区域的缓存
|
||||||
|
for region_cache in self._region_caches.values():
|
||||||
|
region_cache.clear()
|
||||||
|
logger.info("Cleared all cache")
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""
|
||||||
|
内存缓存不需要关闭资源
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RedisBackend(CacheBackend):
|
||||||
|
"""
|
||||||
|
基于 Redis 实现的缓存后端,支持通过 Redis 存储缓存
|
||||||
|
|
||||||
|
特性:
|
||||||
|
- 支持动态设置缓存的 TTL(Time To Live,存活时间)
|
||||||
|
- 支持分区域(region)管理缓存,不同的 region 采用独立的命名空间
|
||||||
|
- 支持自定义最大内存限制(maxmemory)和内存淘汰策略(如 allkeys-lru)
|
||||||
|
|
||||||
|
限制:
|
||||||
|
- 由于 Redis 的分布式特性,写入和读取可能受到网络延迟的影响
|
||||||
|
- Pickle 反序列化可能存在安全风险,需进一步重构调用来源,避免复杂对象缓存
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 类型缓存集合,针对非容器简单类型
|
||||||
|
_complex_serializable_types = set()
|
||||||
|
_simple_serializable_types = set()
|
||||||
|
|
||||||
|
def __init__(self, redis_url: str = "redis://localhost", ttl: int = 1800):
|
||||||
|
"""
|
||||||
|
初始化 Redis 缓存实例
|
||||||
|
|
||||||
|
:param redis_url: Redis 服务的 URL
|
||||||
|
:param ttl: 缓存的存活时间,单位秒
|
||||||
|
"""
|
||||||
|
self.redis_url = redis_url
|
||||||
|
self.ttl = ttl
|
||||||
|
try:
|
||||||
|
self.client = redis.Redis.from_url(
|
||||||
|
redis_url,
|
||||||
|
decode_responses=False,
|
||||||
|
socket_timeout=30,
|
||||||
|
socket_connect_timeout=5,
|
||||||
|
health_check_interval=60,
|
||||||
|
)
|
||||||
|
# 测试连接,确保 Redis 可用
|
||||||
|
self.client.ping()
|
||||||
|
logger.debug(f"Successfully connected to Redis")
|
||||||
|
self.set_memory_limit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to connect to Redis: {e}")
|
||||||
|
raise RuntimeError("Redis connection failed") from e
|
||||||
|
|
||||||
|
def set_memory_limit(self, policy: str = "allkeys-lru"):
|
||||||
|
"""
|
||||||
|
动态设置 Redis 最大内存和内存淘汰策略
|
||||||
|
:param policy: 淘汰策略(如 'allkeys-lru')
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 如果有显式值,则直接使用,为 0 时说明不限制,如果未配置,开启 BIG_MEMORY_MODE 时为 "1024mb",未开启时为 "256mb"
|
||||||
|
maxmemory = settings.CACHE_REDIS_MAXMEMORY or ("1024mb" if settings.BIG_MEMORY_MODE else "256mb")
|
||||||
|
self.client.config_set("maxmemory", maxmemory)
|
||||||
|
self.client.config_set("maxmemory-policy", policy)
|
||||||
|
logger.debug(f"Redis maxmemory set to {maxmemory}, policy: {policy}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to set Redis maxmemory or policy: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_container_type(t):
|
||||||
|
return t in (list, dict, tuple, set)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def serialize(cls, value: Any) -> bytes:
|
||||||
|
"""
|
||||||
|
将值序列化为二进制数据,根据序列化方式标识格式
|
||||||
|
"""
|
||||||
|
vt = type(value)
|
||||||
|
# 针对非容器类型使用缓存策略
|
||||||
|
if not cls.is_container_type(vt):
|
||||||
|
# 如果已知需要复杂序列化
|
||||||
|
if vt in cls._complex_serializable_types:
|
||||||
|
return b"PICKLE" + b"\x00" + pickle.dumps(value)
|
||||||
|
# 如果已知可以简单序列化
|
||||||
|
if vt in cls._simple_serializable_types:
|
||||||
|
json_data = json.dumps(value).encode("utf-8")
|
||||||
|
return b"JSON" + b"\x00" + json_data
|
||||||
|
# 对于未知的非容器类型,尝试简单序列化,如抛出异常,再使用复杂序列化
|
||||||
|
try:
|
||||||
|
json_data = json.dumps(value).encode("utf-8")
|
||||||
|
cls._simple_serializable_types.add(vt)
|
||||||
|
return b"JSON" + b"\x00" + json_data
|
||||||
|
except TypeError:
|
||||||
|
cls._complex_serializable_types.add(vt)
|
||||||
|
return b"PICKLE" + b"\x00" + pickle.dumps(value)
|
||||||
|
# 针对容器类型,每次尝试简单序列化,不使用缓存
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
json_data = json.dumps(value).encode("utf-8")
|
||||||
|
return b"JSON" + b"\x00" + json_data
|
||||||
|
except TypeError:
|
||||||
|
return b"PICKLE" + b"\x00" + pickle.dumps(value)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def deserialize(cls, value: bytes) -> Any:
|
||||||
|
"""
|
||||||
|
将二进制数据反序列化为原始值,根据格式标识区分序列化方式
|
||||||
|
"""
|
||||||
|
format_marker, data = value.split(b"\x00", 1)
|
||||||
|
if format_marker == b"JSON":
|
||||||
|
return json.loads(data.decode("utf-8"))
|
||||||
|
elif format_marker == b"PICKLE":
|
||||||
|
return pickle.loads(data)
|
||||||
|
else:
|
||||||
|
raise ValueError("Unknown serialization format")
|
||||||
|
|
||||||
|
# @staticmethod
|
||||||
|
# def serialize(value: Any) -> bytes:
|
||||||
|
# return msgpack.packb(value, use_bin_type=True)
|
||||||
|
#
|
||||||
|
# @staticmethod
|
||||||
|
# def deserialize(value: bytes) -> Any:
|
||||||
|
# return msgpack.unpackb(value, raw=False)
|
||||||
|
|
||||||
|
def get_redis_key(self, region: str, key: str) -> str:
|
||||||
|
"""
|
||||||
|
获取缓存 Key
|
||||||
|
"""
|
||||||
|
# 使用 region 作为缓存键的一部分
|
||||||
|
region = self.get_region(quote(region))
|
||||||
|
return f"{region}:key:{quote(key)}"
|
||||||
|
|
||||||
|
def set(self, key: str, value: Any, ttl: int = None, region: str = DEFAULT_CACHE_REGION, **kwargs) -> None:
|
||||||
|
"""
|
||||||
|
设置缓存
|
||||||
|
|
||||||
|
:param key: 缓存的键
|
||||||
|
:param value: 缓存的值
|
||||||
|
:param ttl: 缓存的存活时间,单位秒如果未传入则使用默认值
|
||||||
|
:param region: 缓存的区
|
||||||
|
:param kwargs: kwargs
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
ttl = ttl or self.ttl
|
||||||
|
redis_key = self.get_redis_key(region, key)
|
||||||
|
# 对值进行序列化
|
||||||
|
serialized_value = self.serialize(value)
|
||||||
|
kwargs.pop("maxsize", None)
|
||||||
|
self.client.set(redis_key, serialized_value, ex=ttl, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to set key: {key} in region: {region}, error: {e}")
|
||||||
|
|
||||||
|
def exists(self, key: str, region: str = DEFAULT_CACHE_REGION) -> bool:
|
||||||
|
"""
|
||||||
|
判断缓存键是否存在
|
||||||
|
|
||||||
|
:param key: 缓存的键
|
||||||
|
:param region: 缓存的区
|
||||||
|
:return: 存在返回 True,否则返回 False
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
redis_key = self.get_redis_key(region, key)
|
||||||
|
return self.client.exists(redis_key) == 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to exists key: {key} region: {region}, error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get(self, key: str, region: str = DEFAULT_CACHE_REGION) -> Optional[Any]:
|
||||||
|
"""
|
||||||
|
获取缓存的值
|
||||||
|
|
||||||
|
:param key: 缓存的键
|
||||||
|
:param region: 缓存的区
|
||||||
|
:return: 返回缓存的值,如果缓存不存在返回 None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
redis_key = self.get_redis_key(region, key)
|
||||||
|
value = self.client.get(redis_key)
|
||||||
|
if value is not None:
|
||||||
|
return self.deserialize(value) # noqa
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get key: {key} in region: {region}, error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def delete(self, key: str, region: str = DEFAULT_CACHE_REGION) -> None:
|
||||||
|
"""
|
||||||
|
删除缓存
|
||||||
|
|
||||||
|
:param key: 缓存的键
|
||||||
|
:param region: 缓存的区
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
redis_key = self.get_redis_key(region, key)
|
||||||
|
self.client.delete(redis_key)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete key: {key} in region: {region}, error: {e}")
|
||||||
|
|
||||||
|
def clear(self, region: Optional[str] = None) -> None:
|
||||||
|
"""
|
||||||
|
清除指定区域的缓存或全部缓存
|
||||||
|
|
||||||
|
:param region: 缓存的区
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if region:
|
||||||
|
cache_region = self.get_region(quote(region))
|
||||||
|
redis_key = f"{cache_region}:key:*"
|
||||||
|
# self.client.delete(*self.client.keys(redis_key))
|
||||||
|
with self.client.pipeline() as pipe:
|
||||||
|
for key in self.client.scan_iter(redis_key):
|
||||||
|
pipe.delete(key)
|
||||||
|
pipe.execute()
|
||||||
|
logger.info(f"Cleared Redis cache for region: {region}")
|
||||||
|
else:
|
||||||
|
self.client.flushdb()
|
||||||
|
logger.info("Cleared all Redis cache")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to clear cache, region: {region}, error: {e}")
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""
|
||||||
|
关闭 Redis 客户端的连接池
|
||||||
|
"""
|
||||||
|
if self.client:
|
||||||
|
self.client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_cache_backend(maxsize: int = 1000, ttl: int = 1800) -> CacheBackend:
|
||||||
|
"""
|
||||||
|
根据配置获取缓存后端实例
|
||||||
|
|
||||||
|
:param maxsize: 缓存的最大条目数
|
||||||
|
:param ttl: 缓存的默认存活时间,单位秒
|
||||||
|
:return: 返回缓存后端实例
|
||||||
|
"""
|
||||||
|
cache_type = settings.CACHE_BACKEND_TYPE
|
||||||
|
logger.debug(f"Cache backend type from settings: {cache_type}")
|
||||||
|
|
||||||
|
if cache_type == "redis":
|
||||||
|
redis_url = settings.CACHE_BACKEND_URL
|
||||||
|
if redis_url:
|
||||||
|
try:
|
||||||
|
logger.debug(f"Attempting to use RedisBackend with URL: {redis_url}, TTL: {ttl}")
|
||||||
|
return RedisBackend(redis_url=redis_url, ttl=ttl)
|
||||||
|
except RuntimeError:
|
||||||
|
logger.warning("Falling back to CacheToolsBackend due to Redis connection failure.")
|
||||||
|
else:
|
||||||
|
logger.debug("Cache backend type is redis, but no valid REDIS_URL found. "
|
||||||
|
"Falling back to CacheToolsBackend.")
|
||||||
|
|
||||||
|
# 如果不是 Redis,回退到内存缓存
|
||||||
|
logger.debug(f"Using CacheToolsBackend with default maxsize: {maxsize}, TTL: {ttl}")
|
||||||
|
return CacheToolsBackend(maxsize=maxsize, ttl=ttl)
|
||||||
|
|
||||||
|
|
||||||
|
def cached(region: Optional[str] = None, maxsize: int = 1000, ttl: int = 1800,
|
||||||
|
skip_none: bool = True, skip_empty: bool = False):
|
||||||
|
"""
|
||||||
|
自定义缓存装饰器,支持为每个 key 动态传递 maxsize 和 ttl
|
||||||
|
|
||||||
|
:param region: 缓存的区
|
||||||
|
:param maxsize: 缓存的最大条目数,默认值为 1000
|
||||||
|
:param ttl: 缓存的存活时间,单位秒,默认值为 1800
|
||||||
|
:param skip_none: 跳过 None 缓存,默认为 True
|
||||||
|
:param skip_empty: 跳过空值缓存(如 None, [], {}, "", set()),默认为 False
|
||||||
|
:return: 装饰器函数
|
||||||
|
"""
|
||||||
|
|
||||||
|
def should_cache(value: Any) -> bool:
|
||||||
|
"""
|
||||||
|
判断是否应该缓存结果,如果返回值是 None 或空值则不缓存
|
||||||
|
|
||||||
|
:param value: 要判断的缓存值
|
||||||
|
:return: 是否缓存结果
|
||||||
|
"""
|
||||||
|
if skip_none and value is None:
|
||||||
|
return False
|
||||||
|
# if skip_empty and value in [None, [], {}, "", set()]:
|
||||||
|
if skip_empty and not value:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def decorator(func):
|
||||||
|
|
||||||
|
# 获取缓存区
|
||||||
|
cache_region = region if region is not None else f"{func.__module__}.{func.__name__}"
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
# 获取缓存键
|
||||||
|
cache_key = cache_backend.get_cache_key(func, args, kwargs)
|
||||||
|
# 尝试获取缓存
|
||||||
|
cached_value = cache_backend.get(cache_key, region=cache_region)
|
||||||
|
if should_cache(cached_value):
|
||||||
|
return cached_value
|
||||||
|
# 执行函数并缓存结果
|
||||||
|
result = func(*args, **kwargs)
|
||||||
|
# 判断是否需要缓存
|
||||||
|
if not should_cache(result):
|
||||||
|
return result
|
||||||
|
# 设置缓存(如果有传入的 maxsize 和 ttl,则覆盖默认值)
|
||||||
|
cache_backend.set(cache_key, result, ttl=ttl, maxsize=maxsize, region=cache_region)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def cache_clear():
|
||||||
|
"""
|
||||||
|
清理缓存区
|
||||||
|
"""
|
||||||
|
# 清理缓存区
|
||||||
|
cache_backend.clear(region=cache_region)
|
||||||
|
|
||||||
|
wrapper.cache_region = cache_region
|
||||||
|
wrapper.cache_clear = cache_clear
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
# 缓存后端实例
|
||||||
|
cache_backend = get_cache_backend()
|
||||||
|
|
||||||
|
|
||||||
|
def close_cache() -> None:
|
||||||
|
"""
|
||||||
|
关闭缓存后端连接并清理资源
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if cache_backend:
|
||||||
|
cache_backend.close()
|
||||||
|
logger.info("Cache backend closed successfully.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.info(f"Error while closing cache backend: {e}")
|
||||||
@@ -71,6 +71,12 @@ class ConfigModel(BaseModel):
|
|||||||
DB_TIMEOUT: int = 60
|
DB_TIMEOUT: int = 60
|
||||||
# SQLite 是否启用 WAL 模式,默认关闭
|
# SQLite 是否启用 WAL 模式,默认关闭
|
||||||
DB_WAL_ENABLE: bool = False
|
DB_WAL_ENABLE: bool = False
|
||||||
|
# 缓存类型,支持 cachetools 和 redis,默认使用 cachetools
|
||||||
|
CACHE_BACKEND_TYPE: str = "cachetools"
|
||||||
|
# 缓存连接字符串,仅外部缓存(如 Redis、Memcached)需要
|
||||||
|
CACHE_BACKEND_URL: Optional[str] = None
|
||||||
|
# Redis 缓存最大内存限制,未配置时,如开启大内存模式时为 "1024mb",未开启时为 "256mb"
|
||||||
|
CACHE_REDIS_MAXMEMORY: Optional[str] = None
|
||||||
# 配置文件目录
|
# 配置文件目录
|
||||||
CONFIG_DIR: Optional[str] = None
|
CONFIG_DIR: Optional[str] = None
|
||||||
# 超级管理员
|
# 超级管理员
|
||||||
@@ -351,7 +357,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
|||||||
return default, True
|
return default, True
|
||||||
|
|
||||||
@validator('*', pre=True, always=True)
|
@validator('*', pre=True, always=True)
|
||||||
def generic_type_validator(cls, value: Any, field): # noqa
|
def generic_type_validator(cls, value: Any, field): # noqa
|
||||||
"""
|
"""
|
||||||
通用校验器,尝试将配置值转换为期望的类型
|
通用校验器,尝试将配置值转换为期望的类型
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -262,6 +262,8 @@ class MediaInfo:
|
|||||||
runtime: int = None
|
runtime: int = None
|
||||||
# 下一集
|
# 下一集
|
||||||
next_episode_to_air: dict = field(default_factory=dict)
|
next_episode_to_air: dict = field(default_factory=dict)
|
||||||
|
# 内容分级
|
||||||
|
content_rating: str = None
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
# 设置媒体信息
|
# 设置媒体信息
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class MetaBase(object):
|
|||||||
_subtitle_flag = False
|
_subtitle_flag = False
|
||||||
_title_episodel_re = r"Episode\s+(\d{1,4})"
|
_title_episodel_re = r"Episode\s+(\d{1,4})"
|
||||||
_subtitle_season_re = r"(?<![全共]\s*)[第\s]+([0-9一二三四五六七八九十S\-]+)\s*季(?!\s*[全共])"
|
_subtitle_season_re = r"(?<![全共]\s*)[第\s]+([0-9一二三四五六七八九十S\-]+)\s*季(?!\s*[全共])"
|
||||||
_subtitle_season_all_re = r"[全共]\s*([0-9一二三四五六七八九十]+)\s*季|([0-9一二三四五六七八九十]+)\s*季\s*全"
|
_subtitle_season_all_re = r"[全共]\s*([0-9一二三四五六七八九十]+)\s*季"
|
||||||
_subtitle_episode_re = r"(?<![全共]\s*)[第\s]+([0-9一二三四五六七八九十百零EP]+)\s*[集话話期幕](?!\s*[全共])"
|
_subtitle_episode_re = r"(?<![全共]\s*)[第\s]+([0-9一二三四五六七八九十百零EP]+)\s*[集话話期幕](?!\s*[全共])"
|
||||||
_subtitle_episode_between_re = r"[第]*\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]?\s*-\s*第*\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]"
|
_subtitle_episode_between_re = r"[第]*\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]?\s*-\s*第*\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]"
|
||||||
_subtitle_episode_all_re = r"([0-9一二三四五六七八九十百零]+)\s*集\s*全|[全共]\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]"
|
_subtitle_episode_all_re = r"([0-9一二三四五六七八九十百零]+)\s*集\s*全|[全共]\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]"
|
||||||
@@ -247,7 +247,7 @@ class MetaBase(object):
|
|||||||
self.type = MediaType.TV
|
self.type = MediaType.TV
|
||||||
self._subtitle_flag = True
|
self._subtitle_flag = True
|
||||||
return
|
return
|
||||||
# x集全
|
# x集全/全x集
|
||||||
episode_all_str = re.search(r'%s' % self._subtitle_episode_all_re, title_text, re.IGNORECASE)
|
episode_all_str = re.search(r'%s' % self._subtitle_episode_all_re, title_text, re.IGNORECASE)
|
||||||
if episode_all_str:
|
if episode_all_str:
|
||||||
episode_all = episode_all_str.group(1)
|
episode_all = episode_all_str.group(1)
|
||||||
@@ -259,8 +259,6 @@ class MetaBase(object):
|
|||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}')
|
logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}')
|
||||||
return
|
return
|
||||||
self.begin_episode = None
|
|
||||||
self.end_episode = None
|
|
||||||
self.type = MediaType.TV
|
self.type = MediaType.TV
|
||||||
self._subtitle_flag = True
|
self._subtitle_flag = True
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -73,6 +73,18 @@ class SubscribeHistory(Base):
|
|||||||
result = db.query(SubscribeHistory).filter(
|
result = db.query(SubscribeHistory).filter(
|
||||||
SubscribeHistory.type == mtype
|
SubscribeHistory.type == mtype
|
||||||
).order_by(
|
).order_by(
|
||||||
SubscribeHistory.date.desc()
|
SubscribeHistory.date.desc()
|
||||||
).offset((page - 1) * count).limit(count).all()
|
).offset((page - 1) * count).limit(count).all()
|
||||||
return list(result)
|
return list(result)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@db_query
|
||||||
|
def exists(db: Session, tmdbid: int = None, doubanid: str = None, season: int = None):
|
||||||
|
if tmdbid:
|
||||||
|
if season:
|
||||||
|
return db.query(SubscribeHistory).filter(SubscribeHistory.tmdbid == tmdbid,
|
||||||
|
SubscribeHistory.season == season).first()
|
||||||
|
return db.query(SubscribeHistory).filter(SubscribeHistory.tmdbid == tmdbid).first()
|
||||||
|
elif doubanid:
|
||||||
|
return db.query(SubscribeHistory).filter(SubscribeHistory.doubanid == doubanid).first()
|
||||||
|
return None
|
||||||
|
|||||||
@@ -118,3 +118,16 @@ class SubscribeOper(DbOper):
|
|||||||
kwargs.pop("id")
|
kwargs.pop("id")
|
||||||
subscribe = SubscribeHistory(**kwargs)
|
subscribe = SubscribeHistory(**kwargs)
|
||||||
subscribe.create(self._db)
|
subscribe.create(self._db)
|
||||||
|
|
||||||
|
def exist_history(self, tmdbid: int = None, doubanid: str = None, season: int = None):
|
||||||
|
"""
|
||||||
|
判断是否存在订阅历史
|
||||||
|
"""
|
||||||
|
if tmdbid:
|
||||||
|
if season:
|
||||||
|
return True if SubscribeHistory.exists(self._db, tmdbid=tmdbid, season=season) else False
|
||||||
|
else:
|
||||||
|
return True if SubscribeHistory.exists(self._db, tmdbid=tmdbid) else False
|
||||||
|
elif doubanid:
|
||||||
|
return True if SubscribeHistory.exists(self._db, doubanid=doubanid) else False
|
||||||
|
return False
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from typing import Optional
|
from typing import Optional, List
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException
|
from fastapi import Depends, HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -51,6 +51,12 @@ class UserOper(DbOper):
|
|||||||
用户管理
|
用户管理
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def list(self) -> List[User]:
|
||||||
|
"""
|
||||||
|
获取用户列表
|
||||||
|
"""
|
||||||
|
return User.list(self._db)
|
||||||
|
|
||||||
def add(self, **kwargs):
|
def add(self, **kwargs):
|
||||||
"""
|
"""
|
||||||
新增用户
|
新增用户
|
||||||
@@ -67,27 +73,6 @@ class UserOper(DbOper):
|
|||||||
def get_permissions(self, name: str) -> dict:
|
def get_permissions(self, name: str) -> dict:
|
||||||
"""
|
"""
|
||||||
获取用户权限
|
获取用户权限
|
||||||
{
|
|
||||||
"admin": "管理员",
|
|
||||||
"usermanage": "用户管理",
|
|
||||||
"dashboard": "仪表板",
|
|
||||||
"ranking": "推荐榜单",
|
|
||||||
"resource": {
|
|
||||||
"search": "搜索站点资源",
|
|
||||||
"download": "下载站点资源",
|
|
||||||
},
|
|
||||||
"subscribe": {
|
|
||||||
"request": "提交订阅请求",
|
|
||||||
"autopass": "订阅请求自动批准"
|
|
||||||
"approve": "审批订阅请求",
|
|
||||||
"calendar": "查看订阅日历",
|
|
||||||
"manage": "管理所有订阅"
|
|
||||||
},
|
|
||||||
"downloading": {
|
|
||||||
"view": "查看正在下载任务",
|
|
||||||
"manager": "管理正在下载任务"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
"""
|
||||||
user = User.get_by_name(self._db, name)
|
user = User.get_by_name(self._db, name)
|
||||||
if user:
|
if user:
|
||||||
@@ -111,3 +96,16 @@ class UserOper(DbOper):
|
|||||||
if settings:
|
if settings:
|
||||||
return settings.get(key)
|
return settings.get(key)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_name(self, **kwargs) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
根据绑定账号获取用户名称
|
||||||
|
"""
|
||||||
|
users = self.list()
|
||||||
|
for user in users:
|
||||||
|
user_setting = user.settings
|
||||||
|
if user_setting:
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
if user_setting.get(k) == str(v):
|
||||||
|
return user.name
|
||||||
|
return None
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ class FormatParser(object):
|
|||||||
# `details` 格式为 `X`
|
# `details` 格式为 `X`
|
||||||
start_ep = self.__offset.replace("EP", str(self._start_ep))
|
start_ep = self.__offset.replace("EP", str(self._start_ep))
|
||||||
return int(eval(start_ep)), None, self.part
|
return int(eval(start_ep)), None, self.part
|
||||||
else:
|
elif not self._format:
|
||||||
# `details` 格式为 `X,X`
|
# `details` 格式为 `X,X`
|
||||||
start_ep = self.__offset.replace("EP", str(self._start_ep))
|
start_ep = self.__offset.replace("EP", str(self._start_ep))
|
||||||
end_ep = self.__offset.replace("EP", str(self._end_ep))
|
end_ep = self.__offset.replace("EP", str(self._end_ep))
|
||||||
|
|||||||
@@ -64,13 +64,12 @@ class ModuleHelper:
|
|||||||
|
|
||||||
def reload_sub_modules(parent_module, parent_module_name):
|
def reload_sub_modules(parent_module, parent_module_name):
|
||||||
"""重新加载一级子模块"""
|
"""重新加载一级子模块"""
|
||||||
for sub_importer, sub_module_name, sub_is_pkg in pkgutil.walk_packages(parent_module.__path__):
|
for sub_importer, sub_module_name, sub_is_pkg in pkgutil.walk_packages(parent_module.__path__, parent_module_name+'.'):
|
||||||
full_sub_module_name = f'{parent_module_name}.{sub_module_name}'
|
|
||||||
try:
|
try:
|
||||||
full_sub_module = importlib.import_module(full_sub_module_name)
|
full_sub_module = importlib.import_module(sub_module_name)
|
||||||
importlib.reload(full_sub_module)
|
importlib.reload(full_sub_module)
|
||||||
except Exception as sub_err:
|
except Exception as sub_err:
|
||||||
logger.debug(f'加载子模块 {full_sub_module_name} 失败:{str(sub_err)} - {traceback.format_exc()}')
|
logger.debug(f'加载子模块 {sub_module_name} 失败:{str(sub_err)} - {traceback.format_exc()}')
|
||||||
|
|
||||||
# 遍历包中的所有子模块
|
# 遍历包中的所有子模块
|
||||||
for importer, package_name, is_pkg in pkgutil.iter_modules(packages.__path__):
|
for importer, package_name, is_pkg in pkgutil.iter_modules(packages.__path__):
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import traceback
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Tuple, Set
|
from typing import Any, Dict, List, Optional, Tuple, Set
|
||||||
|
|
||||||
from cachetools import TTLCache, cached
|
|
||||||
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||||
from packaging.version import Version, InvalidVersion
|
from packaging.version import Version, InvalidVersion
|
||||||
from pkg_resources import Requirement, working_set
|
from pkg_resources import Requirement, working_set
|
||||||
|
|
||||||
|
from app.core.cache import cached
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.systemconfig_oper import SystemConfigOper
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
@@ -38,24 +38,26 @@ class PluginHelper(metaclass=Singleton):
|
|||||||
if self.install_report():
|
if self.install_report():
|
||||||
self.systemconfig.set(SystemConfigKey.PluginInstallReport, "1")
|
self.systemconfig.set(SystemConfigKey.PluginInstallReport, "1")
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=1000, ttl=1800))
|
@cached(maxsize=1000, ttl=1800)
|
||||||
def get_plugins(self, repo_url: str, package_version: str = None) -> Dict[str, dict]:
|
def get_plugins(self, repo_url: str, package_version: str = None) -> Optional[Dict[str, dict]]:
|
||||||
"""
|
"""
|
||||||
获取Github所有最新插件列表
|
获取Github所有最新插件列表
|
||||||
:param repo_url: Github仓库地址
|
:param repo_url: Github仓库地址
|
||||||
:param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本
|
:param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本
|
||||||
"""
|
"""
|
||||||
if not repo_url:
|
if not repo_url:
|
||||||
return {}
|
return None
|
||||||
|
|
||||||
user, repo = self.get_repo_info(repo_url)
|
user, repo = self.get_repo_info(repo_url)
|
||||||
if not user or not repo:
|
if not user or not repo:
|
||||||
return {}
|
return None
|
||||||
|
|
||||||
raw_url = self._base_url.format(user=user, repo=repo)
|
raw_url = self._base_url.format(user=user, repo=repo)
|
||||||
package_url = f"{raw_url}package.{package_version}.json" if package_version else f"{raw_url}package.json"
|
package_url = f"{raw_url}package.{package_version}.json" if package_version else f"{raw_url}package.json"
|
||||||
|
|
||||||
res = self.__request_with_fallback(package_url, headers=settings.REPO_GITHUB_HEADERS(repo=f"{user}/{repo}"))
|
res = self.__request_with_fallback(package_url, headers=settings.REPO_GITHUB_HEADERS(repo=f"{user}/{repo}"))
|
||||||
|
if res is None:
|
||||||
|
return None
|
||||||
if res:
|
if res:
|
||||||
try:
|
try:
|
||||||
return json.loads(res.text)
|
return json.loads(res.text)
|
||||||
@@ -113,7 +115,7 @@ class PluginHelper(metaclass=Singleton):
|
|||||||
return None, None
|
return None, None
|
||||||
return user, repo
|
return user, repo
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=1, ttl=1800))
|
@cached(maxsize=1, ttl=1800)
|
||||||
def get_statistic(self) -> Dict:
|
def get_statistic(self) -> Dict:
|
||||||
"""
|
"""
|
||||||
获取插件安装统计
|
获取插件安装统计
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
from threading import Thread
|
from threading import Thread
|
||||||
from typing import List, Tuple
|
from typing import List, Tuple
|
||||||
|
|
||||||
from cachetools import TTLCache, cached
|
from app.core.cache import cached, cache_backend
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.subscribe_oper import SubscribeOper
|
from app.db.subscribe_oper import SubscribeOper
|
||||||
from app.db.systemconfig_oper import SystemConfigOper
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
@@ -31,7 +30,7 @@ class SubscribeHelper(metaclass=Singleton):
|
|||||||
|
|
||||||
_sub_fork = f"{settings.MP_SERVER_HOST}/subscribe/fork/%s"
|
_sub_fork = f"{settings.MP_SERVER_HOST}/subscribe/fork/%s"
|
||||||
|
|
||||||
_shares_cache = TTLCache(maxsize=20, ttl=1800)
|
_shares_cache_region = "subscribe_share"
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.systemconfig = SystemConfigOper()
|
self.systemconfig = SystemConfigOper()
|
||||||
@@ -41,7 +40,7 @@ class SubscribeHelper(metaclass=Singleton):
|
|||||||
if self.sub_report():
|
if self.sub_report():
|
||||||
self.systemconfig.set(SystemConfigKey.SubscribeReport, "1")
|
self.systemconfig.set(SystemConfigKey.SubscribeReport, "1")
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=20, ttl=1800))
|
@cached(maxsize=20, ttl=1800)
|
||||||
def get_statistic(self, stype: str, page: int = 1, count: int = 30) -> List[dict]:
|
def get_statistic(self, stype: str, page: int = 1, count: int = 30) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
获取订阅统计数据
|
获取订阅统计数据
|
||||||
@@ -129,6 +128,7 @@ class SubscribeHelper(metaclass=Singleton):
|
|||||||
return False, "订阅不存在"
|
return False, "订阅不存在"
|
||||||
subscribe_dict = subscribe.to_dict()
|
subscribe_dict = subscribe.to_dict()
|
||||||
subscribe_dict.pop("id")
|
subscribe_dict.pop("id")
|
||||||
|
cache_backend.clear(region=self._shares_cache_region)
|
||||||
res = RequestUtils(proxies=settings.PROXY, content_type="application/json",
|
res = RequestUtils(proxies=settings.PROXY, content_type="application/json",
|
||||||
timeout=10).post(self._sub_share,
|
timeout=10).post(self._sub_share,
|
||||||
json={
|
json={
|
||||||
@@ -142,7 +142,7 @@ class SubscribeHelper(metaclass=Singleton):
|
|||||||
return False, "连接MoviePilot服务器失败"
|
return False, "连接MoviePilot服务器失败"
|
||||||
if res.ok:
|
if res.ok:
|
||||||
# 清除 get_shares 的缓存,以便实时看到结果
|
# 清除 get_shares 的缓存,以便实时看到结果
|
||||||
self._shares_cache.clear()
|
cache_backend.clear(region=self._shares_cache_region)
|
||||||
return True, ""
|
return True, ""
|
||||||
else:
|
else:
|
||||||
return False, res.json().get("message")
|
return False, res.json().get("message")
|
||||||
@@ -160,7 +160,7 @@ class SubscribeHelper(metaclass=Singleton):
|
|||||||
return False, "连接MoviePilot服务器失败"
|
return False, "连接MoviePilot服务器失败"
|
||||||
if res.ok:
|
if res.ok:
|
||||||
# 清除 get_shares 的缓存,以便实时看到结果
|
# 清除 get_shares 的缓存,以便实时看到结果
|
||||||
self._shares_cache.clear()
|
cache_backend.clear(region=self._shares_cache_region)
|
||||||
return True, ""
|
return True, ""
|
||||||
else:
|
else:
|
||||||
return False, res.json().get("message")
|
return False, res.json().get("message")
|
||||||
@@ -181,8 +181,8 @@ class SubscribeHelper(metaclass=Singleton):
|
|||||||
else:
|
else:
|
||||||
return False, res.json().get("message")
|
return False, res.json().get("message")
|
||||||
|
|
||||||
@cached(cache=_shares_cache)
|
@cached(region=_shares_cache_region)
|
||||||
def get_shares(self, name: str, page: int = 1, count: int = 30) -> List[dict]:
|
def get_shares(self, name: str = None, page: int = 1, count: int = 30) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
获取订阅分享数据
|
获取订阅分享数据
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -165,3 +165,12 @@ class BangumiModule(_ModuleBase):
|
|||||||
if credits_info:
|
if credits_info:
|
||||||
return [MediaInfo(bangumi_info=credit) for credit in credits_info]
|
return [MediaInfo(bangumi_info=credit) for credit in credits_info]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def bangumi_discover(self, **kwargs) -> Optional[List[MediaInfo]]:
|
||||||
|
"""
|
||||||
|
发现Bangumi番剧
|
||||||
|
"""
|
||||||
|
infos = self.bangumiapi.discover(**kwargs)
|
||||||
|
if infos:
|
||||||
|
return [MediaInfo(bangumi_info=info) for info in infos]
|
||||||
|
return []
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from cachetools import TTLCache, cached
|
|
||||||
|
|
||||||
|
from app.core.cache import cached
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ class BangumiApi(object):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
_urls = {
|
_urls = {
|
||||||
|
"discover": "v0/subjects",
|
||||||
"search": "search/subjects/%s?type=2",
|
"search": "search/subjects/%s?type=2",
|
||||||
"calendar": "calendar",
|
"calendar": "calendar",
|
||||||
"detail": "v0/subjects/%s",
|
"detail": "v0/subjects/%s",
|
||||||
@@ -29,15 +30,18 @@ class BangumiApi(object):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["bangumi"], ttl=settings.CACHE_CONF["meta"]))
|
@cached(maxsize=settings.CACHE_CONF["bangumi"], ttl=settings.CACHE_CONF["meta"])
|
||||||
def __invoke(cls, url, **kwargs):
|
def __invoke(cls, url, key: str = None, **kwargs):
|
||||||
req_url = cls._base_url + url
|
req_url = cls._base_url + url
|
||||||
params = {}
|
params = {}
|
||||||
if kwargs:
|
if kwargs:
|
||||||
params.update(kwargs)
|
params.update(kwargs)
|
||||||
resp = cls._req.get_res(url=req_url, params=params)
|
resp = cls._req.get_res(url=req_url, params=params)
|
||||||
try:
|
try:
|
||||||
return resp.json() if resp else None
|
if not resp:
|
||||||
|
return None
|
||||||
|
result = resp.json()
|
||||||
|
return result.get(key) if key else result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
print(e)
|
||||||
return None
|
return None
|
||||||
@@ -188,8 +192,17 @@ class BangumiApi(object):
|
|||||||
获取人物参演作品
|
获取人物参演作品
|
||||||
"""
|
"""
|
||||||
ret_list = []
|
ret_list = []
|
||||||
result = self.__invoke(self._urls["person_credits"] % person_id, _ts=datetime.strftime(datetime.now(), '%Y%m%d'))
|
result = self.__invoke(self._urls["person_credits"] % person_id,
|
||||||
|
_ts=datetime.strftime(datetime.now(), '%Y%m%d'))
|
||||||
if result:
|
if result:
|
||||||
for item in result:
|
for item in result:
|
||||||
ret_list.append(item)
|
ret_list.append(item)
|
||||||
return ret_list
|
return ret_list
|
||||||
|
|
||||||
|
def discover(self, **kwargs):
|
||||||
|
"""
|
||||||
|
发现
|
||||||
|
"""
|
||||||
|
return self.__invoke(self._urls["discover"],
|
||||||
|
key="data",
|
||||||
|
_ts=datetime.strftime(datetime.now(), '%Y%m%d'), **kwargs)
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ from random import choice
|
|||||||
from urllib import parse
|
from urllib import parse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from cachetools import TTLCache, cached
|
|
||||||
|
|
||||||
|
from app.core.cache import cached
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
@@ -174,14 +174,14 @@ class DoubanApi(metaclass=Singleton):
|
|||||||
).digest()
|
).digest()
|
||||||
).decode()
|
).decode()
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["douban"], ttl=settings.CACHE_CONF["meta"]))
|
@cached(maxsize=settings.CACHE_CONF["douban"], ttl=settings.CACHE_CONF["meta"])
|
||||||
def __invoke_recommend(self, url: str, **kwargs) -> dict:
|
def __invoke_recommend(self, url: str, **kwargs) -> dict:
|
||||||
"""
|
"""
|
||||||
推荐/发现类API
|
推荐/发现类API
|
||||||
"""
|
"""
|
||||||
return self.__invoke(url, **kwargs)
|
return self.__invoke(url, **kwargs)
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["douban"], ttl=settings.CACHE_CONF["meta"]))
|
@cached(maxsize=settings.CACHE_CONF["douban"], ttl=settings.CACHE_CONF["meta"])
|
||||||
def __invoke_search(self, url: str, **kwargs) -> dict:
|
def __invoke_search(self, url: str, **kwargs) -> dict:
|
||||||
"""
|
"""
|
||||||
搜索类API
|
搜索类API
|
||||||
@@ -216,7 +216,7 @@ class DoubanApi(metaclass=Singleton):
|
|||||||
return resp.json()
|
return resp.json()
|
||||||
return resp.json() if resp else {}
|
return resp.json() if resp else {}
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["douban"], ttl=settings.CACHE_CONF["meta"]))
|
@cached(maxsize=settings.CACHE_CONF["douban"], ttl=settings.CACHE_CONF["meta"])
|
||||||
def __post(self, url: str, **kwargs) -> dict:
|
def __post(self, url: str, **kwargs) -> dict:
|
||||||
"""
|
"""
|
||||||
POST请求
|
POST请求
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import re
|
import re
|
||||||
from typing import Optional, Tuple, Union
|
from typing import Optional, Tuple, Union
|
||||||
|
|
||||||
from cachetools import TTLCache, cached
|
from app.core.cache import cached
|
||||||
|
|
||||||
from app.core.context import MediaInfo, settings
|
from app.core.context import MediaInfo, settings
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.modules import _ModuleBase
|
from app.modules import _ModuleBase
|
||||||
@@ -11,7 +10,6 @@ from app.utils.http import RequestUtils
|
|||||||
|
|
||||||
|
|
||||||
class FanartModule(_ModuleBase):
|
class FanartModule(_ModuleBase):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
"name": "The Wheel of Time",
|
"name": "The Wheel of Time",
|
||||||
@@ -384,7 +382,7 @@ class FanartModule(_ModuleBase):
|
|||||||
continue
|
continue
|
||||||
if not isinstance(images, list):
|
if not isinstance(images, list):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 图片属性xx_path
|
# 图片属性xx_path
|
||||||
image_name = self.__name(name)
|
image_name = self.__name(name)
|
||||||
if image_name.startswith("season"):
|
if image_name.startswith("season"):
|
||||||
@@ -422,7 +420,7 @@ class FanartModule(_ModuleBase):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["fanart"], ttl=settings.CACHE_CONF["meta"]))
|
@cached(maxsize=settings.CACHE_CONF["fanart"], ttl=settings.CACHE_CONF["meta"], skip_none=False)
|
||||||
def __request_fanart(cls, media_type: MediaType, queryid: Union[str, int]) -> Optional[dict]:
|
def __request_fanart(cls, media_type: MediaType, queryid: Union[str, int]) -> Optional[dict]:
|
||||||
if media_type == MediaType.MOVIE:
|
if media_type == MediaType.MOVIE:
|
||||||
image_url = cls._movie_url % queryid
|
image_url = cls._movie_url % queryid
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ from app.helper.module import ModuleHelper
|
|||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.modules import _ModuleBase
|
from app.modules import _ModuleBase
|
||||||
from app.modules.filemanager.storages import StorageBase
|
from app.modules.filemanager.storages import StorageBase
|
||||||
from app.schemas import TransferInfo, ExistMediaInfo, TmdbEpisode, TransferDirectoryConf, FileItem, StorageUsage, TransferRenameEventData
|
from app.schemas import TransferInfo, ExistMediaInfo, TmdbEpisode, TransferDirectoryConf, FileItem, StorageUsage, \
|
||||||
|
TransferRenameEventData, TransferInterceptEventData
|
||||||
from app.schemas.types import MediaType, ModuleType, ChainEventType, OtherModulesType
|
from app.schemas.types import MediaType, ModuleType, ChainEventType, OtherModulesType
|
||||||
from app.utils.system import SystemUtils
|
from app.utils.system import SystemUtils
|
||||||
|
|
||||||
@@ -368,7 +369,7 @@ class FileManagerModule(_ModuleBase):
|
|||||||
# 覆盖模式
|
# 覆盖模式
|
||||||
overwrite_mode = target_directory.overwrite_mode
|
overwrite_mode = target_directory.overwrite_mode
|
||||||
# 是否需要刮削
|
# 是否需要刮削
|
||||||
need_scrape = scrape or target_directory.scraping
|
need_scrape = target_directory.scraping if scrape is None else scrape
|
||||||
# 目标存储类型
|
# 目标存储类型
|
||||||
if not target_storage:
|
if not target_storage:
|
||||||
target_storage = target_directory.library_storage
|
target_storage = target_directory.library_storage
|
||||||
@@ -763,6 +764,21 @@ class FileManagerModule(_ModuleBase):
|
|||||||
target_item = target_oper.get_folder(target_path)
|
target_item = target_oper.get_folder(target_path)
|
||||||
if not target_item:
|
if not target_item:
|
||||||
return None, f"获取目标目录失败:{target_path}"
|
return None, f"获取目标目录失败:{target_path}"
|
||||||
|
event_data = TransferInterceptEventData(
|
||||||
|
fileitem=fileitem,
|
||||||
|
target_storage=target_storage,
|
||||||
|
target_path=target_path,
|
||||||
|
transfer_type=transfer_type
|
||||||
|
)
|
||||||
|
event = eventmanager.send_event(ChainEventType.TransferRename, event_data)
|
||||||
|
if event and event.event_data:
|
||||||
|
event_data = event.event_data
|
||||||
|
# 如果事件被取消,跳过文件整理
|
||||||
|
if event_data.cancel:
|
||||||
|
logger.debug(
|
||||||
|
f"Transfer dir canceled by event: {event_data.source},"
|
||||||
|
f"Reason: {event_data.reason}")
|
||||||
|
return None, event_data.reason
|
||||||
# 处理所有文件
|
# 处理所有文件
|
||||||
state, errmsg = self.__transfer_dir_files(fileitem=fileitem,
|
state, errmsg = self.__transfer_dir_files(fileitem=fileitem,
|
||||||
target_storage=target_storage,
|
target_storage=target_storage,
|
||||||
@@ -830,6 +846,24 @@ class FileManagerModule(_ModuleBase):
|
|||||||
target_file.unlink()
|
target_file.unlink()
|
||||||
logger.info(f"正在整理文件:【{fileitem.storage}】{fileitem.path} 到 【{target_storage}】{target_file},"
|
logger.info(f"正在整理文件:【{fileitem.storage}】{fileitem.path} 到 【{target_storage}】{target_file},"
|
||||||
f"操作类型:{transfer_type}")
|
f"操作类型:{transfer_type}")
|
||||||
|
event_data = TransferInterceptEventData(
|
||||||
|
fileitem=fileitem,
|
||||||
|
target_storage=target_storage,
|
||||||
|
target_path=target_file,
|
||||||
|
transfer_type=transfer_type,
|
||||||
|
options={
|
||||||
|
"over_flag": over_flag
|
||||||
|
}
|
||||||
|
)
|
||||||
|
event = eventmanager.send_event(ChainEventType.TransferRename, event_data)
|
||||||
|
if event and event.event_data:
|
||||||
|
event_data = event.event_data
|
||||||
|
# 如果事件被取消,跳过文件整理
|
||||||
|
if event_data.cancel:
|
||||||
|
logger.debug(
|
||||||
|
f"Transfer file canceled by event: {event_data.source},"
|
||||||
|
f"Reason: {event_data.reason}")
|
||||||
|
return None, event_data.reason
|
||||||
new_item, errmsg = self.__transfer_command(fileitem=fileitem,
|
new_item, errmsg = self.__transfer_command(fileitem=fileitem,
|
||||||
target_storage=target_storage,
|
target_storage=target_storage,
|
||||||
target_file=target_file,
|
target_file=target_file,
|
||||||
@@ -920,18 +954,6 @@ class FileManagerModule(_ModuleBase):
|
|||||||
rename_format = settings.TV_RENAME_FORMAT \
|
rename_format = settings.TV_RENAME_FORMAT \
|
||||||
if mediainfo.type == MediaType.TV else settings.MOVIE_RENAME_FORMAT
|
if mediainfo.type == MediaType.TV else settings.MOVIE_RENAME_FORMAT
|
||||||
|
|
||||||
# 计算重命名中的文件夹层数
|
|
||||||
rename_format_level = len(rename_format.split("/")) - 1
|
|
||||||
|
|
||||||
if rename_format_level < 1:
|
|
||||||
# 重命名格式不合法
|
|
||||||
logger.error(f"重命名格式不合法:{rename_format}")
|
|
||||||
return TransferInfo(success=False,
|
|
||||||
message=f"重命名格式不合法",
|
|
||||||
fileitem=fileitem,
|
|
||||||
transfer_type=transfer_type,
|
|
||||||
need_notify=need_notify)
|
|
||||||
|
|
||||||
# 判断是否为文件夹
|
# 判断是否为文件夹
|
||||||
if fileitem.type == "dir":
|
if fileitem.type == "dir":
|
||||||
# 整理整个目录,一般为蓝光原盘
|
# 整理整个目录,一般为蓝光原盘
|
||||||
@@ -1011,12 +1033,15 @@ class FileManagerModule(_ModuleBase):
|
|||||||
overflag = False
|
overflag = False
|
||||||
# 目的操作对象
|
# 目的操作对象
|
||||||
target_oper: StorageBase = self.__get_storage_oper(target_storage)
|
target_oper: StorageBase = self.__get_storage_oper(target_storage)
|
||||||
|
# 计算重命名中的文件夹层级
|
||||||
|
rename_format_level = len(rename_format.split("/")) - 1
|
||||||
|
folder_path = new_file.parents[rename_format_level - 1]
|
||||||
# 目标目录
|
# 目标目录
|
||||||
target_diritem = target_oper.get_folder(new_file.parents[rename_format_level - 1])
|
target_diritem = target_oper.get_folder(folder_path)
|
||||||
if not target_diritem:
|
if not target_diritem:
|
||||||
logger.error(f"目标目录 {new_file.parents[rename_format_level - 1]} 获取失败")
|
logger.error(f"目标目录 {folder_path} 获取失败")
|
||||||
return TransferInfo(success=False,
|
return TransferInfo(success=False,
|
||||||
message=f"目标目录 {new_file.parents[rename_format_level - 1]} 获取失败",
|
message=f"目标目录 {folder_path} 获取失败",
|
||||||
fileitem=fileitem,
|
fileitem=fileitem,
|
||||||
fail_list=[fileitem.path],
|
fail_list=[fileitem.path],
|
||||||
transfer_type=transfer_type,
|
transfer_type=transfer_type,
|
||||||
@@ -1136,7 +1161,7 @@ class FileManagerModule(_ModuleBase):
|
|||||||
if episode.episode_number == meta.begin_episode:
|
if episode.episode_number == meta.begin_episode:
|
||||||
episode_date = episode.air_date
|
episode_date = episode.air_date
|
||||||
break
|
break
|
||||||
|
|
||||||
return {
|
return {
|
||||||
# 标题
|
# 标题
|
||||||
"title": __convert_invalid_characters(mediainfo.title),
|
"title": __convert_invalid_characters(mediainfo.title),
|
||||||
@@ -1256,10 +1281,6 @@ class FileManagerModule(_ModuleBase):
|
|||||||
# 重命名格式
|
# 重命名格式
|
||||||
rename_format = settings.TV_RENAME_FORMAT \
|
rename_format = settings.TV_RENAME_FORMAT \
|
||||||
if mediainfo.type == MediaType.TV else settings.MOVIE_RENAME_FORMAT
|
if mediainfo.type == MediaType.TV else settings.MOVIE_RENAME_FORMAT
|
||||||
# 计算重命名中的文件夹层数
|
|
||||||
rename_format_level = len(rename_format.split("/")) - 1
|
|
||||||
if rename_format_level < 1:
|
|
||||||
continue
|
|
||||||
# 获取路径(重命名路径)
|
# 获取路径(重命名路径)
|
||||||
target_path = self.get_rename_path(
|
target_path = self.get_rename_path(
|
||||||
path=dir_path,
|
path=dir_path,
|
||||||
@@ -1267,13 +1288,19 @@ class FileManagerModule(_ModuleBase):
|
|||||||
rename_dict=self.__get_naming_dict(meta=MetaInfo(mediainfo.title),
|
rename_dict=self.__get_naming_dict(meta=MetaInfo(mediainfo.title),
|
||||||
mediainfo=mediainfo)
|
mediainfo=mediainfo)
|
||||||
)
|
)
|
||||||
|
# 计算重命名中的文件夹层数
|
||||||
|
rename_format_level = len(rename_format.split("/")) - 1
|
||||||
# 取相对路径的第1层目录
|
# 取相对路径的第1层目录
|
||||||
media_path = target_path.parents[rename_format_level - 1]
|
media_path = target_path.parents[rename_format_level - 1]
|
||||||
# 检索媒体文件
|
# 检索媒体文件
|
||||||
fileitem = storage_oper.get_item(media_path)
|
fileitem = storage_oper.get_item(media_path)
|
||||||
if not fileitem:
|
if not fileitem:
|
||||||
continue
|
continue
|
||||||
media_files = self.list_files(fileitem, True)
|
try:
|
||||||
|
media_files = self.list_files(fileitem, True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"获取媒体文件列表失败:{str(e)}")
|
||||||
|
continue
|
||||||
if media_files:
|
if media_files:
|
||||||
for media_file in media_files:
|
for media_file in media_files:
|
||||||
if f".{media_file.extension.lower()}" in settings.RMT_MEDIAEXT:
|
if f".{media_file.extension.lower()}" in settings.RMT_MEDIAEXT:
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ class StorageBase(metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
获取父目录
|
获取父目录
|
||||||
"""
|
"""
|
||||||
return self.get_folder(Path(fileitem.path).parent)
|
return self.get_item(Path(fileitem.path).parent)
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def delete(self, fileitem: schemas.FileItem) -> bool:
|
def delete(self, fileitem: schemas.FileItem) -> bool:
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, List, Dict
|
from typing import Optional, List, Dict
|
||||||
|
|
||||||
from cachetools import cached, TTLCache
|
|
||||||
from requests import Response
|
from requests import Response
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.core.cache import cached
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.modules.filemanager.storages import StorageBase
|
from app.modules.filemanager.storages import StorageBase
|
||||||
@@ -67,13 +67,16 @@ class Alist(StorageBase, metaclass=Singleton):
|
|||||||
return self.__generate_token
|
return self.__generate_token
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@cached(cache=TTLCache(maxsize=1, ttl=60 * 60 * 24 * 2 - 60 * 5))
|
@cached(maxsize=1, ttl=60 * 60 * 24 * 2 - 60 * 5)
|
||||||
def __generate_token(self) -> str:
|
def __generate_token(self) -> str:
|
||||||
"""
|
"""
|
||||||
使用账号密码生成一个临时token
|
如果设置永久令牌则返回永久令牌,否则使用账号密码生成一个临时 token
|
||||||
缓存2天,提前5分钟更新
|
缓存2天,提前5分钟更新
|
||||||
"""
|
"""
|
||||||
conf = self.get_conf()
|
conf = self.get_conf()
|
||||||
|
token = conf.get("token")
|
||||||
|
if token:
|
||||||
|
return str(token)
|
||||||
resp: Response = RequestUtils(headers={
|
resp: Response = RequestUtils(headers={
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}).post_res(
|
}).post_res(
|
||||||
|
|||||||
@@ -208,9 +208,16 @@ class NexusPhpSiteUserInfo(SiteParserBase):
|
|||||||
# 是否存在下页数据
|
# 是否存在下页数据
|
||||||
next_page = None
|
next_page = None
|
||||||
next_page_text = html.xpath('//a[contains(.//text(), "下一页") or contains(.//text(), "下一頁") or contains(.//text(), ">")]/@href')
|
next_page_text = html.xpath('//a[contains(.//text(), "下一页") or contains(.//text(), "下一頁") or contains(.//text(), ">")]/@href')
|
||||||
if next_page_text:
|
|
||||||
next_page = next_page_text[-1].strip()
|
#防止识别到详情页
|
||||||
# fix up page url
|
while next_page_text:
|
||||||
|
next_page = next_page_text.pop().strip()
|
||||||
|
if not next_page.startswith('details.php'):
|
||||||
|
break;
|
||||||
|
next_page = None
|
||||||
|
|
||||||
|
# fix up page url
|
||||||
|
if next_page:
|
||||||
if self.userid not in next_page:
|
if self.userid not in next_page:
|
||||||
next_page = f'{next_page}&userid={self.userid}&type=seeding'
|
next_page = f'{next_page}&userid={self.userid}&type=seeding'
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ from pathlib import Path
|
|||||||
from typing import List, Optional, Dict, Tuple, Generator, Any, Union
|
from typing import List, Optional, Dict, Tuple, Generator, Any, Union
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
from cachetools import TTLCache, cached
|
|
||||||
from plexapi import media
|
from plexapi import media
|
||||||
from plexapi.myplex import MyPlexAccount
|
from plexapi.myplex import MyPlexAccount
|
||||||
from plexapi.server import PlexServer
|
from plexapi.server import PlexServer
|
||||||
from requests import Response, Session
|
from requests import Response, Session
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.core.cache import cached
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas import MediaType
|
from app.schemas import MediaType
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
@@ -83,7 +83,7 @@ class Plex:
|
|||||||
logger.error(f"Authentication failed: {e}")
|
logger.error(f"Authentication failed: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=100, ttl=86400))
|
@cached(maxsize=100, ttl=86400)
|
||||||
def __get_library_images(self, library_key: str, mtype: int) -> Optional[List[str]]:
|
def __get_library_images(self, library_key: str, mtype: int) -> Optional[List[str]]:
|
||||||
"""
|
"""
|
||||||
获取媒体服务器最近添加的媒体的图片列表
|
获取媒体服务器最近添加的媒体的图片列表
|
||||||
@@ -293,7 +293,7 @@ class Plex:
|
|||||||
season_episodes[episode.seasonNumber].append(episode.index)
|
season_episodes[episode.seasonNumber].append(episode.index)
|
||||||
return videos.key, season_episodes
|
return videos.key, season_episodes
|
||||||
|
|
||||||
def get_remote_image_by_id(self,
|
def get_remote_image_by_id(self,
|
||||||
item_id: str,
|
item_id: str,
|
||||||
image_type: str,
|
image_type: str,
|
||||||
depth: int = 0,
|
depth: int = 0,
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ class Slack:
|
|||||||
return ""
|
return ""
|
||||||
conversation_id = ""
|
conversation_id = ""
|
||||||
try:
|
try:
|
||||||
for result in self._client.conversations_list():
|
for result in self._client.conversations_list(types="public_channel,private_channel"):
|
||||||
if conversation_id:
|
if conversation_id:
|
||||||
break
|
break
|
||||||
for channel in result["channels"]:
|
for channel in result["channels"]:
|
||||||
|
|||||||
@@ -356,26 +356,52 @@ class TheMovieDbModule(_ModuleBase):
|
|||||||
return None
|
return None
|
||||||
return self.scraper.get_metadata_img(mediainfo=mediainfo, season=season, episode=episode)
|
return self.scraper.get_metadata_img(mediainfo=mediainfo, season=season, episode=episode)
|
||||||
|
|
||||||
def tmdb_discover(self, mtype: MediaType, sort_by: str, with_genres: str, with_original_language: str,
|
def tmdb_discover(self, mtype: MediaType, sort_by: str,
|
||||||
|
with_genres: str,
|
||||||
|
with_original_language: str,
|
||||||
|
with_keywords: str,
|
||||||
|
with_watch_providers: str,
|
||||||
|
vote_average: float,
|
||||||
|
vote_count: int,
|
||||||
|
release_date: str,
|
||||||
page: int = 1) -> Optional[List[MediaInfo]]:
|
page: int = 1) -> Optional[List[MediaInfo]]:
|
||||||
"""
|
"""
|
||||||
:param mtype: 媒体类型
|
:param mtype: 媒体类型
|
||||||
:param sort_by: 排序方式
|
:param sort_by: 排序方式
|
||||||
:param with_genres: 类型
|
:param with_genres: 类型
|
||||||
:param with_original_language: 语言
|
:param with_original_language: 语言
|
||||||
|
:param with_keywords: 关键字
|
||||||
|
:param with_watch_providers: 提供商
|
||||||
|
:param vote_average: 评分
|
||||||
|
:param vote_count: 评分人数
|
||||||
|
:param release_date: 发布日期
|
||||||
:param page: 页码
|
:param page: 页码
|
||||||
:return: 媒体信息列表
|
:return: 媒体信息列表
|
||||||
"""
|
"""
|
||||||
if mtype == MediaType.MOVIE:
|
if mtype == MediaType.MOVIE:
|
||||||
infos = self.tmdb.discover_movies(sort_by=sort_by,
|
infos = self.tmdb.discover_movies({
|
||||||
with_genres=with_genres,
|
"sort_by": sort_by,
|
||||||
with_original_language=with_original_language,
|
"with_genres": with_genres,
|
||||||
page=page)
|
"with_original_language": with_original_language,
|
||||||
|
"with_keywords": with_keywords,
|
||||||
|
"with_watch_providers": with_watch_providers,
|
||||||
|
"vote_average.gte": vote_average,
|
||||||
|
"vote_count.gte": vote_count,
|
||||||
|
"release_date.gte": release_date,
|
||||||
|
"page": page
|
||||||
|
})
|
||||||
elif mtype == MediaType.TV:
|
elif mtype == MediaType.TV:
|
||||||
infos = self.tmdb.discover_tvs(sort_by=sort_by,
|
infos = self.tmdb.discover_tvs({
|
||||||
with_genres=with_genres,
|
"sort_by": sort_by,
|
||||||
with_original_language=with_original_language,
|
"with_genres": with_genres,
|
||||||
page=page)
|
"with_original_language": with_original_language,
|
||||||
|
"with_keywords": with_keywords,
|
||||||
|
"with_watch_providers": with_watch_providers,
|
||||||
|
"vote_average.gte": vote_average,
|
||||||
|
"vote_count.gte": vote_count,
|
||||||
|
"first_air_date.gte": release_date,
|
||||||
|
"page": page
|
||||||
|
})
|
||||||
else:
|
else:
|
||||||
return []
|
return []
|
||||||
if infos:
|
if infos:
|
||||||
|
|||||||
@@ -170,6 +170,9 @@ class TmdbScraper:
|
|||||||
DomUtils.add_node(doc, root, "genre", genre.get("name") or "")
|
DomUtils.add_node(doc, root, "genre", genre.get("name") or "")
|
||||||
# 评分
|
# 评分
|
||||||
DomUtils.add_node(doc, root, "rating", mediainfo.vote_average or "0")
|
DomUtils.add_node(doc, root, "rating", mediainfo.vote_average or "0")
|
||||||
|
# 内容分级
|
||||||
|
if content_rating := mediainfo.content_rating:
|
||||||
|
DomUtils.add_node(doc, root, "mpaa", content_rating)
|
||||||
|
|
||||||
return doc
|
return doc
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ from typing import Optional, List
|
|||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
import zhconv
|
import zhconv
|
||||||
from cachetools import TTLCache, cached
|
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
|
from app.core.cache import cached
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
|
from app.schemas import APIRateLimitException
|
||||||
from app.schemas.types import MediaType
|
from app.schemas.types import MediaType
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
|
from app.utils.limit import rate_limit_exponential
|
||||||
from app.utils.string import StringUtils
|
from app.utils.string import StringUtils
|
||||||
from .tmdbv3api import TMDb, Search, Movie, TV, Season, Episode, Discover, Trending, Person, Collection
|
from .tmdbv3api import TMDb, Search, Movie, TV, Season, Episode, Discover, Trending, Person, Collection
|
||||||
from .tmdbv3api.exceptions import TMDbException
|
from .tmdbv3api.exceptions import TMDbException
|
||||||
@@ -491,7 +493,8 @@ class TmdbApi:
|
|||||||
|
|
||||||
return ret_info
|
return ret_info
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["tmdb"], ttl=settings.CACHE_CONF["meta"]))
|
@cached(maxsize=settings.CACHE_CONF["tmdb"], ttl=settings.CACHE_CONF["meta"])
|
||||||
|
@rate_limit_exponential(source="match_tmdb_web", base_wait=5, max_wait=1800, enable_logging=True)
|
||||||
def match_web(self, name: str, mtype: MediaType) -> Optional[dict]:
|
def match_web(self, name: str, mtype: MediaType) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
搜索TMDB网站,直接抓取结果,结果只有一条时才返回
|
搜索TMDB网站,直接抓取结果,结果只有一条时才返回
|
||||||
@@ -504,51 +507,56 @@ class TmdbApi:
|
|||||||
return {}
|
return {}
|
||||||
logger.info("正在从TheDbMovie网站查询:%s ..." % name)
|
logger.info("正在从TheDbMovie网站查询:%s ..." % name)
|
||||||
tmdb_url = "https://www.themoviedb.org/search?query=%s" % quote(name)
|
tmdb_url = "https://www.themoviedb.org/search?query=%s" % quote(name)
|
||||||
res = RequestUtils(timeout=5, ua=settings.USER_AGENT).get_res(url=tmdb_url)
|
res = RequestUtils(timeout=5, ua=settings.USER_AGENT, proxies=settings.PROXY).get_res(url=tmdb_url)
|
||||||
if res and res.status_code == 200:
|
if res is None:
|
||||||
html_text = res.text
|
return None
|
||||||
if not html_text:
|
if res.status_code == 429:
|
||||||
return None
|
raise APIRateLimitException("触发TheDbMovie网站限流,获取媒体信息失败")
|
||||||
try:
|
if res.status_code != 200:
|
||||||
tmdb_links = []
|
return {}
|
||||||
html = etree.HTML(html_text)
|
html_text = res.text
|
||||||
if mtype == MediaType.TV:
|
if not html_text:
|
||||||
links = html.xpath("//a[@data-id and @data-media-type='tv']/@href")
|
return {}
|
||||||
else:
|
try:
|
||||||
links = html.xpath("//a[@data-id]/@href")
|
tmdb_links = []
|
||||||
for link in links:
|
html = etree.HTML(html_text)
|
||||||
if not link or (not link.startswith("/tv") and not link.startswith("/movie")):
|
if mtype == MediaType.TV:
|
||||||
continue
|
links = html.xpath("//a[@data-id and @data-media-type='tv']/@href")
|
||||||
if link not in tmdb_links:
|
else:
|
||||||
tmdb_links.append(link)
|
links = html.xpath("//a[@data-id]/@href")
|
||||||
if len(tmdb_links) == 1:
|
for link in links:
|
||||||
tmdbinfo = self.get_info(
|
if not link or (not link.startswith("/tv") and not link.startswith("/movie")):
|
||||||
mtype=MediaType.TV if tmdb_links[0].startswith("/tv") else MediaType.MOVIE,
|
continue
|
||||||
tmdbid=tmdb_links[0].split("/")[-1])
|
if link not in tmdb_links:
|
||||||
if tmdbinfo:
|
tmdb_links.append(link)
|
||||||
if mtype == MediaType.TV and tmdbinfo.get('media_type') != MediaType.TV:
|
if len(tmdb_links) == 1:
|
||||||
return {}
|
tmdbinfo = self.get_info(
|
||||||
if tmdbinfo.get('media_type') == MediaType.MOVIE:
|
mtype=MediaType.TV if tmdb_links[0].startswith("/tv") else MediaType.MOVIE,
|
||||||
logger.info("%s 从WEB识别到 电影:TMDBID=%s, 名称=%s, 上映日期=%s" % (
|
tmdbid=tmdb_links[0].split("/")[-1])
|
||||||
name,
|
if tmdbinfo:
|
||||||
tmdbinfo.get('id'),
|
if mtype == MediaType.TV and tmdbinfo.get('media_type') != MediaType.TV:
|
||||||
tmdbinfo.get('title'),
|
return {}
|
||||||
tmdbinfo.get('release_date')))
|
if tmdbinfo.get('media_type') == MediaType.MOVIE:
|
||||||
else:
|
logger.info("%s 从WEB识别到 电影:TMDBID=%s, 名称=%s, 上映日期=%s" % (
|
||||||
logger.info("%s 从WEB识别到 电视剧:TMDBID=%s, 名称=%s, 首播日期=%s" % (
|
name,
|
||||||
name,
|
tmdbinfo.get('id'),
|
||||||
tmdbinfo.get('id'),
|
tmdbinfo.get('title'),
|
||||||
tmdbinfo.get('name'),
|
tmdbinfo.get('release_date')))
|
||||||
tmdbinfo.get('first_air_date')))
|
else:
|
||||||
return tmdbinfo
|
logger.info("%s 从WEB识别到 电视剧:TMDBID=%s, 名称=%s, 首播日期=%s" % (
|
||||||
elif len(tmdb_links) > 1:
|
name,
|
||||||
logger.info("%s TMDB网站返回数据过多:%s" % (name, len(tmdb_links)))
|
tmdbinfo.get('id'),
|
||||||
else:
|
tmdbinfo.get('name'),
|
||||||
logger.info("%s TMDB网站未查询到媒体信息!" % name)
|
tmdbinfo.get('first_air_date')))
|
||||||
except Exception as err:
|
return tmdbinfo
|
||||||
logger.error(f"从TheDbMovie网站查询出错:{str(err)}")
|
elif len(tmdb_links) > 1:
|
||||||
return None
|
logger.info("%s TMDB网站返回数据过多:%s" % (name, len(tmdb_links)))
|
||||||
return None
|
else:
|
||||||
|
logger.info("%s TMDB网站未查询到媒体信息!" % name)
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"从TheDbMovie网站查询出错:{str(err)}")
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
def get_info(self,
|
def get_info(self,
|
||||||
mtype: MediaType,
|
mtype: MediaType,
|
||||||
@@ -593,6 +601,8 @@ class TmdbApi:
|
|||||||
tmdb_info['genre_ids'] = __get_genre_ids(tmdb_info.get('genres'))
|
tmdb_info['genre_ids'] = __get_genre_ids(tmdb_info.get('genres'))
|
||||||
# 别名和译名
|
# 别名和译名
|
||||||
tmdb_info['names'] = self.__get_names(tmdb_info)
|
tmdb_info['names'] = self.__get_names(tmdb_info)
|
||||||
|
# 内容分级
|
||||||
|
tmdb_info['content_rating'] = self.__get_content_rating(tmdb_info)
|
||||||
# 转换多语种标题
|
# 转换多语种标题
|
||||||
self.__update_tmdbinfo_extra_title(tmdb_info)
|
self.__update_tmdbinfo_extra_title(tmdb_info)
|
||||||
# 转换中文标题
|
# 转换中文标题
|
||||||
@@ -600,6 +610,68 @@ class TmdbApi:
|
|||||||
|
|
||||||
return tmdb_info
|
return tmdb_info
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __get_content_rating(tmdb_info: dict) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
获得tmdb中的内容评级
|
||||||
|
:param tmdb_info: TMDB信息
|
||||||
|
:return: 内容评级
|
||||||
|
"""
|
||||||
|
if not tmdb_info:
|
||||||
|
return None
|
||||||
|
# dict[地区:分级]
|
||||||
|
ratings = {}
|
||||||
|
if results := (tmdb_info.get("release_dates") or {}).get("results"):
|
||||||
|
"""
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"iso_3166_1": "AR",
|
||||||
|
"release_dates": [
|
||||||
|
{
|
||||||
|
"certification": "+13",
|
||||||
|
"descriptors": [],
|
||||||
|
"iso_639_1": "",
|
||||||
|
"note": "",
|
||||||
|
"release_date": "2025-01-23T00:00:00.000Z",
|
||||||
|
"type": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
for item in results:
|
||||||
|
iso_3166_1 = item.get("iso_3166_1")
|
||||||
|
if not iso_3166_1:
|
||||||
|
continue
|
||||||
|
dates = item.get("release_dates")
|
||||||
|
if not dates:
|
||||||
|
continue
|
||||||
|
certification = dates[0].get("certification")
|
||||||
|
if not certification:
|
||||||
|
continue
|
||||||
|
ratings[iso_3166_1] = certification
|
||||||
|
elif results := (tmdb_info.get("content_ratings") or {}).get("results"):
|
||||||
|
"""
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"descriptors": [],
|
||||||
|
"iso_3166_1": "US",
|
||||||
|
"rating": "TV-MA"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
for item in results:
|
||||||
|
iso_3166_1 = item.get("iso_3166_1")
|
||||||
|
if not iso_3166_1:
|
||||||
|
continue
|
||||||
|
rating = item.get("rating")
|
||||||
|
if not rating:
|
||||||
|
continue
|
||||||
|
ratings[iso_3166_1] = rating
|
||||||
|
if not ratings:
|
||||||
|
return None
|
||||||
|
return ratings.get("CN") or ratings.get("US")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __update_tmdbinfo_cn_title(tmdb_info: dict):
|
def __update_tmdbinfo_cn_title(tmdb_info: dict):
|
||||||
"""
|
"""
|
||||||
@@ -678,20 +750,21 @@ class TmdbApi:
|
|||||||
else:
|
else:
|
||||||
en_title = __get_tmdb_lang_title(tmdb_info, "US")
|
en_title = __get_tmdb_lang_title(tmdb_info, "US")
|
||||||
tmdb_info['en_title'] = en_title or org_title
|
tmdb_info['en_title'] = en_title or org_title
|
||||||
|
|
||||||
# 查找香港台湾译名
|
# 查找香港台湾译名
|
||||||
tmdb_info['hk_title'] = __get_tmdb_lang_title(tmdb_info, "HK")
|
tmdb_info['hk_title'] = __get_tmdb_lang_title(tmdb_info, "HK")
|
||||||
tmdb_info['tw_title'] = __get_tmdb_lang_title(tmdb_info, "TW")
|
tmdb_info['tw_title'] = __get_tmdb_lang_title(tmdb_info, "TW")
|
||||||
|
|
||||||
# 查找新加坡名(用于替代中文名)
|
# 查找新加坡名(用于替代中文名)
|
||||||
tmdb_info['sg_title'] = __get_tmdb_lang_title(tmdb_info, "SG") or org_title
|
tmdb_info['sg_title'] = __get_tmdb_lang_title(tmdb_info, "SG") or org_title
|
||||||
|
|
||||||
def __get_movie_detail(self,
|
def __get_movie_detail(self,
|
||||||
tmdbid: int,
|
tmdbid: int,
|
||||||
append_to_response: str = "images,"
|
append_to_response: str = "images,"
|
||||||
"credits,"
|
"credits,"
|
||||||
"alternative_titles,"
|
"alternative_titles,"
|
||||||
"translations,"
|
"translations,"
|
||||||
|
"release_dates,"
|
||||||
"external_ids") -> Optional[dict]:
|
"external_ids") -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
获取电影的详情
|
获取电影的详情
|
||||||
@@ -804,6 +877,7 @@ class TmdbApi:
|
|||||||
"credits,"
|
"credits,"
|
||||||
"alternative_titles,"
|
"alternative_titles,"
|
||||||
"translations,"
|
"translations,"
|
||||||
|
"content_ratings,"
|
||||||
"external_ids") -> Optional[dict]:
|
"external_ids") -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
获取电视剧的详情
|
获取电视剧的详情
|
||||||
@@ -1072,18 +1146,17 @@ class TmdbApi:
|
|||||||
logger.error(str(e))
|
logger.error(str(e))
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def discover_movies(self, **kwargs) -> List[dict]:
|
def discover_movies(self, params: dict) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
发现电影
|
发现电影
|
||||||
:param kwargs:
|
:param params: 参数
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
if not self.discover:
|
if not self.discover:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
logger.debug(f"正在发现电影:{kwargs}...")
|
logger.debug(f"正在发现电影:{params}...")
|
||||||
params_tuple = tuple(kwargs.items())
|
tmdbinfo = self.discover.discover_movies(tuple(params.items()))
|
||||||
tmdbinfo = self.discover.discover_movies(params_tuple)
|
|
||||||
if tmdbinfo:
|
if tmdbinfo:
|
||||||
for info in tmdbinfo:
|
for info in tmdbinfo:
|
||||||
info['media_type'] = MediaType.MOVIE
|
info['media_type'] = MediaType.MOVIE
|
||||||
@@ -1092,18 +1165,17 @@ class TmdbApi:
|
|||||||
logger.error(str(e))
|
logger.error(str(e))
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def discover_tvs(self, **kwargs) -> List[dict]:
|
def discover_tvs(self, params: dict) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
发现电视剧
|
发现电视剧
|
||||||
:param kwargs:
|
:param params: 参数
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
if not self.discover:
|
if not self.discover:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
logger.debug(f"正在发现电视剧:{kwargs}...")
|
logger.debug(f"正在发现电视剧:{params}...")
|
||||||
params_tuple = tuple(kwargs.items())
|
tmdbinfo = self.discover.discover_tv_shows(tuple(params.items()))
|
||||||
tmdbinfo = self.discover.discover_tv_shows(params_tuple)
|
|
||||||
if tmdbinfo:
|
if tmdbinfo:
|
||||||
for info in tmdbinfo:
|
for info in tmdbinfo:
|
||||||
info['media_type'] = MediaType.TV
|
info['media_type'] = MediaType.TV
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
from app.core.cache import cached
|
||||||
from ..tmdb import TMDb
|
from ..tmdb import TMDb
|
||||||
from cachetools import cached, TTLCache
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from urllib import urlencode
|
from urllib import urlencode
|
||||||
@@ -13,7 +13,7 @@ class Discover(TMDb):
|
|||||||
"tv": "/discover/tv"
|
"tv": "/discover/tv"
|
||||||
}
|
}
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=1, ttl=43200))
|
@cached(maxsize=1, ttl=43200)
|
||||||
def discover_movies(self, params_tuple):
|
def discover_movies(self, params_tuple):
|
||||||
"""
|
"""
|
||||||
Discover movies by different types of data like average rating, number of votes, genres and certifications.
|
Discover movies by different types of data like average rating, number of votes, genres and certifications.
|
||||||
@@ -23,7 +23,7 @@ class Discover(TMDb):
|
|||||||
params = dict(params_tuple)
|
params = dict(params_tuple)
|
||||||
return self._request_obj(self._urls["movies"], urlencode(params), key="results", call_cached=False)
|
return self._request_obj(self._urls["movies"], urlencode(params), key="results", call_cached=False)
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=1, ttl=43200))
|
@cached(maxsize=1, ttl=43200)
|
||||||
def discover_tv_shows(self, params_tuple):
|
def discover_tv_shows(self, params_tuple):
|
||||||
"""
|
"""
|
||||||
Discover TV shows by different types of data like average rating, number of votes, genres,
|
Discover TV shows by different types of data like average rating, number of votes, genres,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from cachetools import cached, TTLCache
|
from app.core.cache import cached
|
||||||
|
|
||||||
from ..tmdb import TMDb
|
from ..tmdb import TMDb
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ from ..tmdb import TMDb
|
|||||||
class Trending(TMDb):
|
class Trending(TMDb):
|
||||||
_urls = {"trending": "/trending/%s/%s"}
|
_urls = {"trending": "/trending/%s/%s"}
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=1, ttl=43200))
|
@cached(maxsize=1024, ttl=43200)
|
||||||
def _trending(self, media_type="all", time_window="day", page=1):
|
def _trending(self, media_type="all", time_window="day", page=1):
|
||||||
"""
|
"""
|
||||||
Get trending, TTLCache 12 hours
|
Get trending, TTLCache 12 hours
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ from datetime import datetime
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
import requests.exceptions
|
import requests.exceptions
|
||||||
from cachetools import TTLCache, cached
|
|
||||||
|
|
||||||
|
from app.core.cache import cached
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
from .exceptions import TMDbException
|
from .exceptions import TMDbException
|
||||||
@@ -137,7 +137,7 @@ class TMDb(object):
|
|||||||
def cache(self, cache):
|
def cache(self, cache):
|
||||||
os.environ[self.TMDB_CACHE_ENABLED] = str(cache)
|
os.environ[self.TMDB_CACHE_ENABLED] = str(cache)
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["tmdb"], ttl=settings.CACHE_CONF["meta"]))
|
@cached(maxsize=settings.CACHE_CONF["tmdb"], ttl=settings.CACHE_CONF["meta"])
|
||||||
def cached_request(self, method, url, data, json,
|
def cached_request(self, method, url, data, json,
|
||||||
_ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
_ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class WebPushModule(_ModuleBase, _MessageBase):
|
|||||||
webpush_users = conf.config.get("WEBPUSH_USERNAME") or ""
|
webpush_users = conf.config.get("WEBPUSH_USERNAME") or ""
|
||||||
if webpush_users:
|
if webpush_users:
|
||||||
# 设定了接收用户时,非该用户的消息不接收
|
# 设定了接收用户时,非该用户的消息不接收
|
||||||
if not message.userid or message.userid not in webpush_users.split(","):
|
if not message.username or message.username not in webpush_users.split(","):
|
||||||
continue
|
continue
|
||||||
if not message.title and not message.text:
|
if not message.title and not message.text:
|
||||||
logger.warn("标题和内容不能同时为空")
|
logger.warn("标题和内容不能同时为空")
|
||||||
|
|||||||
@@ -225,7 +225,8 @@ class _PluginBase(metaclass=ABCMeta):
|
|||||||
return self.plugindata.del_data(plugin_id, key)
|
return self.plugindata.del_data(plugin_id, key)
|
||||||
|
|
||||||
def post_message(self, channel: MessageChannel = None, mtype: NotificationType = None, title: str = None,
|
def post_message(self, channel: MessageChannel = None, mtype: NotificationType = None, title: str = None,
|
||||||
text: str = None, image: str = None, link: str = None, userid: str = None, username: str = None):
|
text: str = None, image: str = None, link: str = None, userid: str = None, username: str = None,
|
||||||
|
**kwargs):
|
||||||
"""
|
"""
|
||||||
发送消息
|
发送消息
|
||||||
"""
|
"""
|
||||||
@@ -233,7 +234,7 @@ class _PluginBase(metaclass=ABCMeta):
|
|||||||
link = settings.MP_DOMAIN(f"#/plugins?tab=installed&id={self.__class__.__name__}")
|
link = settings.MP_DOMAIN(f"#/plugins?tab=installed&id={self.__class__.__name__}")
|
||||||
self.chain.post_message(Notification(
|
self.chain.post_message(Notification(
|
||||||
channel=channel, mtype=mtype, title=title, text=text,
|
channel=channel, mtype=mtype, title=title, text=text,
|
||||||
image=image, link=link, userid=userid, username=username
|
image=image, link=link, userid=userid, username=username, **kwargs
|
||||||
))
|
))
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ from app.chain.recommend import RecommendChain
|
|||||||
from app.chain.site import SiteChain
|
from app.chain.site import SiteChain
|
||||||
from app.chain.subscribe import SubscribeChain
|
from app.chain.subscribe import SubscribeChain
|
||||||
from app.chain.tmdb import TmdbChain
|
from app.chain.tmdb import TmdbChain
|
||||||
from app.chain.torrents import TorrentsChain
|
|
||||||
from app.chain.transfer import TransferChain
|
from app.chain.transfer import TransferChain
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.event import EventManager
|
from app.core.event import EventManager
|
||||||
@@ -93,6 +92,11 @@ class Scheduler(metaclass=Singleton):
|
|||||||
"func": SubscribeChain().refresh,
|
"func": SubscribeChain().refresh,
|
||||||
"running": False,
|
"running": False,
|
||||||
},
|
},
|
||||||
|
"subscribe_follow": {
|
||||||
|
"name": "关注的订阅分享",
|
||||||
|
"func": SubscribeChain().follow,
|
||||||
|
"running": False,
|
||||||
|
},
|
||||||
"transfer": {
|
"transfer": {
|
||||||
"name": "下载文件整理",
|
"name": "下载文件整理",
|
||||||
"func": TransferChain().process,
|
"func": TransferChain().process,
|
||||||
@@ -242,6 +246,18 @@ class Scheduler(metaclass=Singleton):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 关注订阅分享(每1小时)
|
||||||
|
self._scheduler.add_job(
|
||||||
|
self.start,
|
||||||
|
"interval",
|
||||||
|
id="subscribe_follow",
|
||||||
|
name="关注的订阅分享",
|
||||||
|
hours=1,
|
||||||
|
kwargs={
|
||||||
|
'job_id': 'subscribe_follow'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# 下载器文件转移(每5分钟)
|
# 下载器文件转移(每5分钟)
|
||||||
self._scheduler.add_job(
|
self._scheduler.add_job(
|
||||||
self.start,
|
self.start,
|
||||||
@@ -549,7 +565,6 @@ class Scheduler(metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
清理缓存
|
清理缓存
|
||||||
"""
|
"""
|
||||||
TorrentsChain().clear_cache()
|
|
||||||
SchedulerChain().clear_cache()
|
SchedulerChain().clear_cache()
|
||||||
|
|
||||||
def user_auth(self):
|
def user_auth(self):
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from typing import Optional, Dict, Any, List, Set
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field, root_validator
|
from pydantic import BaseModel, Field, root_validator
|
||||||
|
|
||||||
from app.schemas import MessageChannel
|
from app.schemas import MessageChannel, FileItem
|
||||||
|
|
||||||
|
|
||||||
class BaseEventData(BaseModel):
|
class BaseEventData(BaseModel):
|
||||||
@@ -50,7 +50,7 @@ class AuthCredentials(ChainEventData):
|
|||||||
service: Optional[str] = Field(default=None, description="服务名称")
|
service: Optional[str] = Field(default=None, description="服务名称")
|
||||||
|
|
||||||
@root_validator(pre=True)
|
@root_validator(pre=True)
|
||||||
def check_fields_based_on_grant_type(cls, values): # noqa
|
def check_fields_based_on_grant_type(cls, values): # noqa
|
||||||
grant_type = values.get("grant_type")
|
grant_type = values.get("grant_type")
|
||||||
if not grant_type:
|
if not grant_type:
|
||||||
values["grant_type"] = "password"
|
values["grant_type"] = "password"
|
||||||
@@ -202,3 +202,33 @@ class ResourceDownloadEventData(ChainEventData):
|
|||||||
cancel: bool = Field(default=False, description="是否取消下载")
|
cancel: bool = Field(default=False, description="是否取消下载")
|
||||||
source: str = Field(default="未知拦截源", description="拦截源")
|
source: str = Field(default="未知拦截源", description="拦截源")
|
||||||
reason: str = Field(default="", description="拦截原因")
|
reason: str = Field(default="", description="拦截原因")
|
||||||
|
|
||||||
|
|
||||||
|
class TransferInterceptEventData(ChainEventData):
|
||||||
|
"""
|
||||||
|
TransferIntercept 事件的数据模型
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
# 输入参数
|
||||||
|
fileitem (FileItem): 源文件
|
||||||
|
target_storage (str): 目标存储
|
||||||
|
target_path (Path): 目标路径
|
||||||
|
transfer_type (str): 整理方式(copy、move、link、softlink等)
|
||||||
|
options (dict): 其他参数
|
||||||
|
|
||||||
|
# 输出参数
|
||||||
|
cancel (bool): 是否取消下载,默认值为 False
|
||||||
|
source (str): 拦截源,默认值为 "未知拦截源"
|
||||||
|
reason (str): 拦截原因,描述拦截的具体原因
|
||||||
|
"""
|
||||||
|
# 输入参数
|
||||||
|
fileitem: FileItem = Field(..., description="源文件")
|
||||||
|
target_storage: str = Field(..., description="目标存储")
|
||||||
|
target_path: Path = Field(..., description="目标路径")
|
||||||
|
transfer_type: str = Field(..., description="整理方式")
|
||||||
|
options: Optional[dict] = Field(None, description="其他参数")
|
||||||
|
|
||||||
|
# 输出参数
|
||||||
|
cancel: bool = Field(default=False, description="是否取消整理")
|
||||||
|
source: str = Field(default="未知拦截源", description="拦截源")
|
||||||
|
reason: str = Field(default="", description="拦截原因")
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ class ChainEventType(Enum):
|
|||||||
CommandRegister = "command.register"
|
CommandRegister = "command.register"
|
||||||
# 整理重命名
|
# 整理重命名
|
||||||
TransferRename = "transfer.rename"
|
TransferRename = "transfer.rename"
|
||||||
|
# 整理拦截
|
||||||
|
TransferIntercept = "transfer.intercept"
|
||||||
# 资源选择
|
# 资源选择
|
||||||
ResourceSelection = "resource.selection"
|
ResourceSelection = "resource.selection"
|
||||||
# 资源下载
|
# 资源下载
|
||||||
@@ -135,6 +137,8 @@ class SystemConfigKey(Enum):
|
|||||||
DefaultTvSubscribeConfig = "DefaultTvSubscribeConfig"
|
DefaultTvSubscribeConfig = "DefaultTvSubscribeConfig"
|
||||||
# 用户站点认证参数
|
# 用户站点认证参数
|
||||||
UserSiteAuthParams = "UserSiteAuthParams"
|
UserSiteAuthParams = "UserSiteAuthParams"
|
||||||
|
# Follow订阅分享者
|
||||||
|
FollowSubscribers = "FollowSubscribers"
|
||||||
|
|
||||||
|
|
||||||
# 处理进度Key字典
|
# 处理进度Key字典
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import sys
|
|||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from app.core.cache import close_cache
|
||||||
from app.core.config import global_vars, settings
|
from app.core.config import global_vars, settings
|
||||||
from app.core.module import ModuleManager
|
from app.core.module import ModuleManager
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
@@ -129,6 +130,8 @@ def shutdown_modules(_: FastAPI):
|
|||||||
Monitor().stop()
|
Monitor().stop()
|
||||||
# 停止线程池
|
# 停止线程池
|
||||||
ThreadHelper().shutdown()
|
ThreadHelper().shutdown()
|
||||||
|
# 停止缓存连接
|
||||||
|
close_cache()
|
||||||
# 停止数据库连接
|
# 停止数据库连接
|
||||||
close_database()
|
close_database()
|
||||||
# 停止前端服务
|
# 停止前端服务
|
||||||
|
|||||||
@@ -156,7 +156,8 @@ class ExponentialBackoffRateLimiter(BaseRateLimiter):
|
|||||||
with self.lock:
|
with self.lock:
|
||||||
self.next_allowed_time = current_time + self.current_wait
|
self.next_allowed_time = current_time + self.current_wait
|
||||||
self.current_wait = min(self.current_wait * self.backoff_factor, self.max_wait)
|
self.current_wait = min(self.current_wait * self.backoff_factor, self.max_wait)
|
||||||
self.log_warning(f"触发限流,将在 {self.current_wait} 秒后允许继续调用")
|
wait_time = self.next_allowed_time - current_time
|
||||||
|
self.log_warning(f"触发限流,将在 {wait_time:.2f} 秒后允许继续调用")
|
||||||
|
|
||||||
|
|
||||||
# 时间窗口限流器
|
# 时间窗口限流器
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
from cachetools import TTLCache, cached
|
from app.core.cache import cached
|
||||||
|
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ class WebUtils:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@cached(cache=TTLCache(maxsize=1, ttl=3600))
|
@cached(maxsize=1, ttl=3600)
|
||||||
def get_bing_wallpaper() -> Optional[str]:
|
def get_bing_wallpaper() -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
获取Bing每日壁纸
|
获取Bing每日壁纸
|
||||||
@@ -93,7 +93,7 @@ class WebUtils:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@cached(cache=TTLCache(maxsize=1, ttl=3600))
|
@cached(maxsize=1, ttl=3600)
|
||||||
def get_bing_wallpapers(num: int = 7) -> List[str]:
|
def get_bing_wallpapers(num: int = 7) -> List[str]:
|
||||||
"""
|
"""
|
||||||
获取7天的Bing每日壁纸
|
获取7天的Bing每日壁纸
|
||||||
|
|||||||
@@ -64,4 +64,5 @@ python-cookietools==0.0.2.1
|
|||||||
aligo~=6.2.4
|
aligo~=6.2.4
|
||||||
aiofiles~=24.1.0
|
aiofiles~=24.1.0
|
||||||
jieba~=0.42.1
|
jieba~=0.42.1
|
||||||
rsa~=4.9
|
rsa~=4.9
|
||||||
|
redis~=5.2.1
|
||||||
@@ -968,7 +968,7 @@ meta_cases = [{
|
|||||||
"year": "2023",
|
"year": "2023",
|
||||||
"part": "",
|
"part": "",
|
||||||
"season": "S02",
|
"season": "S02",
|
||||||
"episode": "",
|
"episode": "E01-E08",
|
||||||
"restype": "WEB-DL",
|
"restype": "WEB-DL",
|
||||||
"pix": "2160p",
|
"pix": "2160p",
|
||||||
"video_codec": "H265",
|
"video_codec": "H265",
|
||||||
@@ -1016,7 +1016,7 @@ meta_cases = [{
|
|||||||
"year": "2019",
|
"year": "2019",
|
||||||
"part": "",
|
"part": "",
|
||||||
"season": "S01",
|
"season": "S01",
|
||||||
"episode": "",
|
"episode": "E01-E36",
|
||||||
"restype": "WEB-DL",
|
"restype": "WEB-DL",
|
||||||
"pix": "2160p",
|
"pix": "2160p",
|
||||||
"video_codec": "H265",
|
"video_codec": "H265",
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
APP_VERSION = 'v2.2.2'
|
APP_VERSION = 'v2.2.6'
|
||||||
FRONTEND_VERSION = 'v2.2.2'
|
FRONTEND_VERSION = 'v2.2.6'
|
||||||
|
|||||||
Reference in New Issue
Block a user