This commit is contained in:
jxxghp
2025-05-29 13:35:01 +08:00
parent 77a4c271ae
commit a8e00e9f0f
5 changed files with 230 additions and 430 deletions

View File

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

View File

@@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
from starlette.background import BackgroundTasks
from app import schemas
from app.api.endpoints.plugin import register_plugin_api
from app.chain.site import SiteChain
from app.chain.torrents import TorrentsChain
from app.command import Command
@@ -17,13 +18,13 @@ from app.db.models.site import Site
from app.db.models.siteicon import SiteIcon
from app.db.models.sitestatistic import SiteStatistic
from app.db.models.siteuserdata import SiteUserData
from app.db.site_oper import SiteOper
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import get_current_active_superuser
from app.helper.sites import SitesHelper
from app.scheduler import Scheduler
from app.schemas.types import SystemConfigKey, EventType
from app.utils.string import StringUtils
from startup.plugins_initializer import register_plugin_api
router = APIRouter()
@@ -395,6 +396,21 @@ def auth_site(
return schemas.Response(success=status, message=msg)
@router.get("/mapping", summary="获取站点域名到名称的映射", response_model=schemas.Response)
def site_mapping(_: User = Depends(get_current_active_superuser)):
"""
获取站点域名到名称的映射关系
"""
try:
sites = SiteOper().list()
mapping = {}
for site in sites:
mapping[site.domain] = site.name
return schemas.Response(success=True, data=mapping)
except Exception as e:
return schemas.Response(success=False, message=f"获取映射失败:{str(e)}")
@router.get("/{site_id}", summary="站点详情", response_model=schemas.Site)
def read_site(
site_id: int,

View File

@@ -5,14 +5,13 @@ import tempfile
from collections import deque
from datetime import datetime
from pathlib import Path
from typing import Optional, Union, Annotated, List
from typing import Optional, Union, Annotated
import aiofiles
import pillow_avif # noqa 用于自动注册AVIF支持
from PIL import Image
from fastapi import APIRouter, Depends, HTTPException, Header, Request, Response
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app import schemas
from app.chain.search import SearchChain
@@ -30,15 +29,15 @@ from app.helper.progress import ProgressHelper
from app.helper.rule import RuleHelper
from app.helper.sites import SitesHelper
from app.helper.subscribe import SubscribeHelper
from app.helper.system import SystemHelper
from app.log import logger
from app.monitor import Monitor
from app.scheduler import Scheduler
from app.schemas.types import SystemConfigKey, MediaType
from app.schemas.types import SystemConfigKey
from app.utils.crypto import HashUtils
from app.utils.http import RequestUtils
from app.utils.security import SecurityUtils
from app.utils.url import UrlUtils
from app.helper.system import SystemHelper
from version import APP_VERSION
router = APIRouter()
@@ -519,427 +518,3 @@ def run_scheduler2(jobid: str,
Scheduler().start(jobid)
return schemas.Response(success=True)
@router.get("/sites/mapping", summary="获取站点域名到名称的映射", response_model=schemas.Response)
def get_sites_mapping(_: User = Depends(get_current_active_superuser)):
"""
获取站点域名到名称的映射关系
"""
try:
from app.db.site_oper import SiteOper
site_oper = SiteOper()
sites = site_oper.list()
mapping = {}
for site in sites:
mapping[site.domain] = site.name
return schemas.Response(success=True, data=mapping)
except Exception as e:
logger.error(f"获取站点映射失败:{str(e)}")
return schemas.Response(success=False, message=f"获取映射失败:{str(e)}")
@router.get("/cache/torrents", summary="获取种子缓存", response_model=schemas.Response)
def get_torrents_cache(_: User = Depends(get_current_active_superuser)):
"""
获取当前种子缓存数据
"""
from app.chain.torrents import TorrentsChain
torrents_chain = TorrentsChain()
# 获取spider和rss两种缓存
spider_cache = torrents_chain.get_torrents("spider")
rss_cache = torrents_chain.get_torrents("rss")
# 统计信息
spider_count = sum(len(torrents) for torrents in spider_cache.values())
rss_count = sum(len(torrents) for torrents in rss_cache.values())
# 转换为前端需要的格式
spider_data = []
for domain, contexts in spider_cache.items():
for context in contexts:
torrent_hash = HashUtils.md5(f"{context.torrent_info.title}{context.torrent_info.description}")
spider_data.append({
"hash": torrent_hash,
"domain": domain,
"title": context.torrent_info.title,
"description": context.torrent_info.description,
"size": context.torrent_info.size,
"pubdate": context.torrent_info.pubdate,
"site_name": context.torrent_info.site_name,
"media_name": context.media_info.title if context.media_info else "",
"media_year": context.media_info.year if context.media_info else "",
"media_type": context.media_info.type if context.media_info else "",
"season_episode": context.meta_info.season_episode if context.meta_info else "",
"resource_term": context.meta_info.resource_term if context.meta_info else "",
"enclosure": context.torrent_info.enclosure,
"page_url": context.torrent_info.page_url,
"poster_path": context.media_info.get_poster_image() if context.media_info else "",
"backdrop_path": context.media_info.get_backdrop_image() if context.media_info else ""
})
rss_data = []
for domain, contexts in rss_cache.items():
for context in contexts:
torrent_hash = HashUtils.md5(f"{context.torrent_info.title}{context.torrent_info.description}")
rss_data.append({
"hash": torrent_hash,
"domain": domain,
"title": context.torrent_info.title,
"description": context.torrent_info.description,
"size": context.torrent_info.size,
"pubdate": context.torrent_info.pubdate,
"site_name": context.torrent_info.site_name,
"media_name": context.media_info.title if context.media_info else "",
"media_year": context.media_info.year if context.media_info else "",
"media_type": context.media_info.type if context.media_info else "",
"season_episode": context.meta_info.season_episode if context.meta_info else "",
"resource_term": context.meta_info.resource_term if context.meta_info else "",
"enclosure": context.torrent_info.enclosure,
"page_url": context.torrent_info.page_url,
"poster_path": context.media_info.get_poster_image() if context.media_info else "",
"backdrop_path": context.media_info.get_backdrop_image() if context.media_info else ""
})
return schemas.Response(success=True, data={
"spider": {
"count": spider_count,
"sites": len(spider_cache),
"data": spider_data
},
"rss": {
"count": rss_count,
"sites": len(rss_cache),
"data": rss_data
}
})
@router.post("/cache/torrents/refresh", summary="刷新种子缓存", response_model=schemas.Response)
def refresh_torrents_cache(cache_type: str = "auto", _: User = Depends(get_current_active_superuser)):
"""
刷新种子缓存
:param cache_type: 缓存类型 auto/spider/rss
"""
from app.chain.torrents import TorrentsChain
torrents_chain = TorrentsChain()
try:
if cache_type == "auto":
cache_type = None
result = torrents_chain.refresh(stype=cache_type, sites=None)
# 统计刷新结果
total_count = sum(len(torrents) for torrents in result.values())
sites_count = len(result)
return schemas.Response(success=True, message=f"缓存刷新完成,共刷新 {sites_count} 个站点,{total_count} 个种子")
except Exception as e:
logger.error(f"刷新种子缓存失败:{str(e)}")
return schemas.Response(success=False, message=f"刷新失败:{str(e)}")
@router.delete("/cache/torrents", summary="清理种子缓存", response_model=schemas.Response)
def clear_torrents_cache(_: User = Depends(get_current_active_superuser)):
"""
清理所有种子缓存
"""
from app.chain.torrents import TorrentsChain
torrents_chain = TorrentsChain()
try:
torrents_chain.clear_torrents()
return schemas.Response(success=True, message="种子缓存清理完成")
except Exception as e:
logger.error(f"清理种子缓存失败:{str(e)}")
return schemas.Response(success=False, message=f"清理失败:{str(e)}")
@router.get("/cache/torrents/stats", summary="获取种子缓存统计", response_model=schemas.Response)
def get_torrents_cache_stats(_: User = Depends(get_current_active_superuser)):
"""
获取种子缓存统计信息
"""
from app.chain.torrents import TorrentsChain
torrents_chain = TorrentsChain()
# 获取缓存配置
cache_limit = settings.CACHE_CONF.get("torrents", 100)
refresh_limit = settings.CACHE_CONF.get("refresh", 30)
# 获取缓存数据
spider_cache = torrents_chain.get_torrents("spider")
rss_cache = torrents_chain.get_torrents("rss")
# 统计各站点缓存情况
spider_stats = []
for domain, contexts in spider_cache.items():
spider_stats.append({
"domain": domain,
"count": len(contexts),
"latest_date": max([ctx.torrent_info.pubdate for ctx in contexts if ctx.torrent_info.pubdate], default="")
})
rss_stats = []
for domain, contexts in rss_cache.items():
rss_stats.append({
"domain": domain,
"count": len(contexts),
"latest_date": max([ctx.torrent_info.pubdate for ctx in contexts if ctx.torrent_info.pubdate], default="")
})
return schemas.Response(success=True, data={
"config": {
"cache_limit": cache_limit,
"refresh_limit": refresh_limit,
"current_mode": settings.SUBSCRIBE_MODE
},
"spider": {
"total_count": sum(len(torrents) for torrents in spider_cache.values()),
"sites_count": len(spider_cache),
"sites": spider_stats
},
"rss": {
"total_count": sum(len(torrents) for torrents in rss_cache.values()),
"sites_count": len(rss_cache),
"sites": rss_stats
}
})
@router.delete("/cache/torrents/{cache_type}/{domain}/{torrent_hash}", summary="删除指定种子缓存", response_model=schemas.Response)
def delete_torrent_cache(cache_type: str, domain: str, torrent_hash: str,
_: User = Depends(get_current_active_superuser)):
"""
删除指定的种子缓存
:param cache_type: 缓存类型 spider/rss
:param domain: 站点域名
:param torrent_hash: 种子hash使用title+description的md5
"""
from app.chain.torrents import TorrentsChain
from app.utils.crypto import HashUtils
torrents_chain = TorrentsChain()
try:
# 获取当前缓存
cache_data = torrents_chain.get_torrents(cache_type)
if domain not in cache_data:
return schemas.Response(success=False, message=f"站点 {domain} 缓存不存在")
# 查找并删除指定种子
original_count = len(cache_data[domain])
cache_data[domain] = [
context for context in cache_data[domain]
if HashUtils.md5(f"{context.torrent_info.title}{context.torrent_info.description}") != torrent_hash
]
if len(cache_data[domain]) == original_count:
return schemas.Response(success=False, message="未找到指定的种子")
# 保存更新后的缓存
if cache_type == "spider":
torrents_chain.save_cache(cache_data, torrents_chain._spider_file)
else:
torrents_chain.save_cache(cache_data, torrents_chain._rss_file)
return schemas.Response(success=True, message="种子删除成功")
except Exception as e:
logger.error(f"删除种子缓存失败:{str(e)}")
return schemas.Response(success=False, message=f"删除失败:{str(e)}")
@router.post("/cache/torrents/{cache_type}/{domain}/{torrent_hash}/reidentify", summary="重新识别种子", response_model=schemas.Response)
def reidentify_torrent_cache(cache_type: str, domain: str, torrent_hash: str,
tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
_: User = Depends(get_current_active_superuser)):
"""
重新识别指定的种子
:param cache_type: 缓存类型 spider/rss
:param domain: 站点域名
:param torrent_hash: 种子hash使用title+description的md5
:param tmdbid: 手动指定的TMDB ID
:param doubanid: 手动指定的豆瓣ID
"""
from app.chain.torrents import TorrentsChain
from app.chain.media import MediaChain
from app.core.metainfo import MetaInfo
from app.core.context import MediaInfo
from app.utils.crypto import HashUtils
from app.schemas.types import MediaType
torrents_chain = TorrentsChain()
media_chain = MediaChain()
try:
# 获取当前缓存
cache_data = torrents_chain.get_torrents(cache_type)
if domain not in cache_data:
return schemas.Response(success=False, message=f"站点 {domain} 缓存不存在")
# 查找指定种子
target_context = None
for context in cache_data[domain]:
if HashUtils.md5(f"{context.torrent_info.title}{context.torrent_info.description}") == torrent_hash:
target_context = context
break
if not target_context:
return schemas.Response(success=False, message="未找到指定的种子")
# 重新识别
if tmdbid or doubanid:
# 手动指定媒体信息
if tmdbid:
# 先尝试电影类型
tmdbinfo = media_chain.tmdb_info(tmdbid=tmdbid, mtype=MediaType.MOVIE)
if not tmdbinfo:
# 再尝试电视剧类型
tmdbinfo = media_chain.tmdb_info(tmdbid=tmdbid, mtype=MediaType.TV)
if tmdbinfo:
mediainfo = MediaInfo()
mediainfo.set_tmdb_info(tmdbinfo)
else:
mediainfo = None
else:
# 先尝试电影类型
doubaninfo = media_chain.douban_info(doubanid=doubanid, mtype=MediaType.MOVIE)
if not doubaninfo:
# 再尝试电视剧类型
doubaninfo = media_chain.douban_info(doubanid=doubanid, mtype=MediaType.TV)
if doubaninfo:
mediainfo = MediaInfo()
mediainfo.set_douban_info(doubaninfo)
else:
mediainfo = None
else:
# 自动重新识别
meta = MetaInfo(title=target_context.torrent_info.title,
subtitle=target_context.torrent_info.description)
mediainfo = media_chain.recognize_by_meta(meta)
if not mediainfo:
# 创建空的媒体信息
mediainfo = MediaInfo()
else:
# 清理多余数据
mediainfo.clear()
# 更新上下文中的媒体信息
target_context.media_info = mediainfo
# 保存更新后的缓存
if cache_type == "spider":
torrents_chain.save_cache(cache_data, torrents_chain._spider_file)
else:
torrents_chain.save_cache(cache_data, torrents_chain._rss_file)
return schemas.Response(success=True, message="重新识别完成", data={
"media_name": mediainfo.title if mediainfo else "",
"media_year": mediainfo.year if mediainfo else "",
"media_type": mediainfo.type.value if mediainfo and mediainfo.type else ""
})
except Exception as e:
logger.error(f"重新识别种子失败:{str(e)}")
return schemas.Response(success=False, message=f"重新识别失败:{str(e)}")
@router.get("/cache/images/stats", summary="获取图片缓存统计", response_model=schemas.Response)
def get_images_cache_stats(_: User = Depends(get_current_active_superuser)):
"""
获取图片缓存统计信息
"""
import os
from pathlib import Path
try:
images_cache_path = settings.CACHE_PATH / "images"
if not images_cache_path.exists():
return schemas.Response(success=True, data={
"total_files": 0,
"total_size": 0,
"cache_enabled": settings.GLOBAL_IMAGE_CACHE
})
total_files = 0
total_size = 0
# 递归统计所有图片文件
for root, dirs, files in os.walk(images_cache_path):
for file in files:
file_path = Path(root) / file
if file_path.suffix.lower() in settings.SECURITY_IMAGE_SUFFIXES:
total_files += 1
try:
total_size += file_path.stat().st_size
except (OSError, IOError):
continue
return schemas.Response(success=True, data={
"total_files": total_files,
"total_size": total_size,
"cache_enabled": settings.GLOBAL_IMAGE_CACHE,
"cache_path": str(images_cache_path)
})
except Exception as e:
logger.error(f"获取图片缓存统计失败:{str(e)}")
return schemas.Response(success=False, message=f"获取统计失败:{str(e)}")
@router.delete("/cache/images", summary="清理图片缓存", response_model=schemas.Response)
def clear_images_cache(_: User = Depends(get_current_active_superuser)):
"""
清理所有图片缓存
"""
try:
from app.utils.system import SystemUtils
images_cache_path = settings.CACHE_PATH / "images"
if not images_cache_path.exists():
return schemas.Response(success=True, message="图片缓存目录不存在")
# 清理图片缓存目录
cleared_count = SystemUtils.clear(images_cache_path, days=0)
return schemas.Response(success=True, message=f"图片缓存清理完成,清理了 {cleared_count} 个文件")
except Exception as e:
logger.error(f"清理图片缓存失败:{str(e)}")
return schemas.Response(success=False, message=f"清理失败:{str(e)}")
@router.post("/cache/images/clean", summary="清理过期图片缓存", response_model=schemas.Response)
def clean_expired_images_cache(days: int = 7, _: User = Depends(get_current_active_superuser)):
"""
清理过期的图片缓存
:param days: 保留天数默认7天
"""
try:
from app.utils.system import SystemUtils
images_cache_path = settings.CACHE_PATH / "images"
if not images_cache_path.exists():
return schemas.Response(success=True, message="图片缓存目录不存在")
# 清理过期图片缓存
cleared_count = SystemUtils.clear(images_cache_path, days=days)
return schemas.Response(success=True, message=f"过期图片缓存清理完成,清理了 {cleared_count} 个文件")
except Exception as e:
logger.error(f"清理过期图片缓存失败:{str(e)}")
return schemas.Response(success=False, message=f"清理失败:{str(e)}")

View File

@@ -0,0 +1,199 @@
from typing import Optional
from fastapi import APIRouter, Depends
from app import schemas
from app.chain.media import MediaChain
from app.chain.torrents import TorrentsChain
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.metainfo import MetaInfo
from app.db.models import User
from app.db.user_oper import get_current_active_superuser
from app.utils.crypto import HashUtils
router = APIRouter()
@router.get("/cache", summary="获取种子缓存", response_model=schemas.Response)
def torrents_cache(_: User = Depends(get_current_active_superuser)):
"""
获取当前种子缓存数据
"""
torrents_chain = TorrentsChain()
# 获取spider和rss两种缓存
if settings.SUBSCRIBE_MODE == "rss":
cache_info = torrents_chain.get_torrents("rss")
else:
cache_info = torrents_chain.get_torrents("spider")
# 统计信息
torrent_count = sum(len(torrents) for torrents in cache_info.values())
# 转换为前端需要的格式
torrent_data = []
for domain, contexts in cache_info.items():
for context in contexts:
torrent_hash = HashUtils.md5(f"{context.torrent_info.title}{context.torrent_info.description}")
torrent_data.append({
"hash": torrent_hash,
"domain": domain,
"title": context.torrent_info.title,
"description": context.torrent_info.description,
"size": context.torrent_info.size,
"pubdate": context.torrent_info.pubdate,
"site_name": context.torrent_info.site_name,
"media_name": context.media_info.title if context.media_info else "",
"media_year": context.media_info.year if context.media_info else "",
"media_type": context.media_info.type if context.media_info else "",
"season_episode": context.meta_info.season_episode if context.meta_info else "",
"resource_term": context.meta_info.resource_term if context.meta_info else "",
"enclosure": context.torrent_info.enclosure,
"page_url": context.torrent_info.page_url,
"poster_path": context.media_info.get_poster_image() if context.media_info else "",
"backdrop_path": context.media_info.get_backdrop_image() if context.media_info else ""
})
return schemas.Response(success=True, data={
"count": torrent_count,
"sites": len(cache_info),
"data": torrent_data
})
@router.delete("/cache/{domain}/{torrent_hash}", summary="删除指定种子缓存",
response_model=schemas.Response)
def delete_cache(domain: str, torrent_hash: str, _: User = Depends(get_current_active_superuser)):
"""
删除指定的种子缓存
:param domain: 站点域名
:param torrent_hash: 种子hash使用title+description的md5
:param _: 当前用户,必须是超级用户
"""
torrents_chain = TorrentsChain()
try:
# 获取当前缓存
cache_data = torrents_chain.get_torrents()
if domain not in cache_data:
return schemas.Response(success=False, message=f"站点 {domain} 缓存不存在")
# 查找并删除指定种子
original_count = len(cache_data[domain])
cache_data[domain] = [
context for context in cache_data[domain]
if HashUtils.md5(f"{context.torrent_info.title}{context.torrent_info.description}") != torrent_hash
]
if len(cache_data[domain]) == original_count:
return schemas.Response(success=False, message="未找到指定的种子")
# 保存更新后的缓存
torrents_chain.save_cache(cache_data, torrents_chain.cache_file)
return schemas.Response(success=True, message="种子删除成功")
except Exception as e:
return schemas.Response(success=False, message=f"删除失败:{str(e)}")
@router.delete("/cache", summary="清理种子缓存", response_model=schemas.Response)
def clear_cache(_: User = Depends(get_current_active_superuser)):
"""
清理所有种子缓存
"""
torrents_chain = TorrentsChain()
try:
torrents_chain.clear_torrents()
return schemas.Response(success=True, message="种子缓存清理完成")
except Exception as e:
return schemas.Response(success=False, message=f"清理失败:{str(e)}")
@router.post("/cache/refresh", summary="刷新种子缓存", response_model=schemas.Response)
def refresh_cache(_: User = Depends(get_current_active_superuser)):
"""
刷新种子缓存
"""
from app.chain.torrents import TorrentsChain
torrents_chain = TorrentsChain()
try:
result = torrents_chain.refresh()
# 统计刷新结果
total_count = sum(len(torrents) for torrents in result.values())
sites_count = len(result)
return schemas.Response(success=True, message=f"缓存刷新完成,共刷新 {sites_count} 个站点,{total_count} 个种子")
except Exception as e:
return schemas.Response(success=False, message=f"刷新失败:{str(e)}")
@router.post("/cache/reidentify/{domain}/{torrent_hash}", summary="重新识别种子", response_model=schemas.Response)
def reidentify_cache(domain: str, torrent_hash: str,
tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
_: User = Depends(get_current_active_superuser)):
"""
重新识别指定的种子
:param domain: 站点域名
:param torrent_hash: 种子hash使用title+description的md5
:param tmdbid: 手动指定的TMDB ID
:param doubanid: 手动指定的豆瓣ID
:param _: 当前用户,必须是超级用户
"""
torrents_chain = TorrentsChain()
media_chain = MediaChain()
try:
# 获取当前缓存
cache_data = torrents_chain.get_torrents()
if domain not in cache_data:
return schemas.Response(success=False, message=f"站点 {domain} 缓存不存在")
# 查找指定种子
target_context = None
for context in cache_data[domain]:
if HashUtils.md5(f"{context.torrent_info.title}{context.torrent_info.description}") == torrent_hash:
target_context = context
break
if not target_context:
return schemas.Response(success=False, message="未找到指定的种子")
# 重新识别
meta = MetaInfo(title=target_context.torrent_info.title,
subtitle=target_context.torrent_info.description)
if tmdbid or doubanid:
# 手动指定媒体信息
mediainfo = MediaChain().recognize_media(meta=meta, tmdbid=tmdbid, doubanid=doubanid)
else:
# 自动重新识别
mediainfo = media_chain.recognize_by_meta(meta)
if not mediainfo:
# 创建空的媒体信息
mediainfo = MediaInfo()
else:
# 清理多余数据
mediainfo.clear()
# 更新上下文中的媒体信息
target_context.media_info = mediainfo
# 保存更新后的缓存
torrents_chain.save_cache(cache_data, TorrentsChain().cache_file)
return schemas.Response(success=True, message="重新识别完成", data={
"media_name": mediainfo.title if mediainfo else "",
"media_year": mediainfo.year if mediainfo else "",
"media_type": mediainfo.type.value if mediainfo and mediainfo.type else ""
})
except Exception as e:
return schemas.Response(success=False, message=f"重新识别失败:{str(e)}")