Files
MeBox/internal/handler/downloads.go
T
ShukeBta 3a2db6bdd9 feat: Emby 兼容层完整实现 + 多模块功能增强
## Emby/Jellyfin 兼容层 (emby_compat.go / emby.go)
- 新增 SystemInfoPublic、FindUser、Items、Item、LatestItems、ResumeItems
- 新增 SetFavorite、MarkPlayed、RecordProgress 用户播放状态同步
- 新增 itemPayload、mediaSource、mediaStreams 媒体信息组装
- 双前缀路由 /emby/* 和 / 根路径,兼容 Infuse/Yamby/Senplayer/Kodi
- 新增 Ping、SystemEndpoint、AuthByName 端点
- emby.go 扩展对应 handler 函数

## 站点适配器 (site_adapter.go / site.go)
- SiteConfig 扩展 UserAgent/Timeout/Extra/FlareSolverrURL 字段
- doRequest() GET 请求支持 FlareSolverr 代理绕过 Cloudflare/WAF
- MTeam api_key 认证改为 Authorization: Bearer 格式
- Search() 重构为 sync.WaitGroup 并发执行,提升多站搜索性能
- siteModelToConfig() 改为 SiteService 方法,按 BrowserEmulation 填充 FlareSolverrURL

## 图片代理 (image_proxy.go)
- 重构图片代理服务,支持更多来源和缓存策略

## 下载管理 (downloads.go / download_clients.go / qbittorrent.go)
- 下载任务增强:状态管理、进度追踪优化
- qBittorrent 客户端连接稳定性改进

## 刮削与数据库 (scraper.go / tmdb.go / repository.go)
- 刮削器增强 TMDB 集成,补全元数据字段
- repository 扩展查询方法

## 前端 (web/src/)
- HomePage: 首页布局重构,按媒体库分组展示,系列聚合优化
- DiscoverPage: 发现页增强,错误处理改进(API key 缺失/网络错误分离)
- PosterWallPage: 海报墙优化,系列聚合展示
- MediaCard: 媒体卡片优化
- PlayerPage: 播放器改进
- 新增 utils/groupSeries.ts: 系列聚合工具函数
- .gitignore: 添加 .tmp_* 临时文件排除规则
2026-05-26 16:09:13 +08:00

167 lines
4.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package handler — download manager endpoints.
package handler
import (
"context"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
type addDownloadReq struct {
URL string `json:"url" binding:"required"`
SavePath string `json:"save_path"`
}
// resolvePTDownloadURL 把站点搜索结果里的"详情/获取签名"URL 解析成 qb 能直接
// 拉到 .torrent 文件的真实下载 URL。
//
// 链路:
//
// 1. 拿 URL 的 host,到 sites 表里找 base_url 同源的站点。
// 2. 如果站点的 type 是已知 PT 框架(mteam/nexusphp/unit3d/...),
// 就用对应适配器的 GetDownloadURL,传入从 URL 里 parse 出来的 id。
// 3. 任一步失败都直接返回原 URL,让 qb 自己去拉(保持向后兼容)。
//
// 这一步存在的意义:M-Team 等站点的搜索结果里 download_url 是
// /api/torrent/genDlToken?id=xxx,需要带 x-api-key 才能调用,qb 自己
// 是没法识别这种 PT 专属端点的。
func resolvePTDownloadURL(ctx context.Context, svc *service.Container, raw string, log *zap.Logger) string {
if raw == "" || svc == nil || svc.Site == nil {
return raw
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return raw
}
host := strings.ToLower(u.Host)
// 找一个 host 匹配的已配置站点。
sites, err := svc.Site.List(ctx)
if err != nil || len(sites) == 0 {
return raw
}
var matched *model.Site
for i := range sites {
su, err := url.Parse(sites[i].URL)
if err != nil || su.Host == "" {
continue
}
if strings.EqualFold(su.Host, host) || strings.HasSuffix(host, "."+strings.ToLower(su.Host)) {
matched = &sites[i]
break
}
}
if matched == nil {
return raw
}
// 抽取 torrent id:?id=xxx 是 PT 站普遍写法。
id := u.Query().Get("id")
if id == "" {
return raw
}
adapter := service.GetAdapterForType(matched.Type)
if adapter == nil {
return raw
}
cfg := service.SiteConfig{
Name: matched.Name,
Type: matched.Type,
URL: strings.TrimRight(matched.URL, "/"),
AuthType: matched.AuthType,
Cookie: matched.Cookie,
APIKey: matched.APIKey,
AuthHeader: matched.AuthHeader,
UserAgent: matched.UserAgent,
Timeout: time.Duration(matched.Timeout) * time.Second,
UseProxy: matched.UseProxy,
}
if cfg.Timeout <= 0 {
cfg.Timeout = 15 * time.Second
}
resolveCtx, cancel := context.WithTimeout(ctx, cfg.Timeout)
defer cancel()
resolved, err := adapter.GetDownloadURL(resolveCtx, cfg, id)
if err != nil || resolved == "" {
log.Warn("resolve PT download URL failed (using raw URL)",
zap.String("site", matched.Name),
zap.String("type", matched.Type),
zap.String("raw", raw),
zap.Error(err))
return raw
}
log.Info("resolved PT download URL",
zap.String("site", matched.Name),
zap.String("from", raw),
zap.String("to", resolved))
return resolved
}
func addDownloadHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req addDownloadReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
uid, _ := c.Get(middleware.CtxUserID)
// 把站点搜索 URL 转换成真实可下载 URL(M-Team 走 genDlToken 等)。
realURL := resolvePTDownloadURL(c.Request.Context(), svc, req.URL, svc.Log)
t, err := svc.Downloads.AddDownload(c.Request.Context(), uid.(string), realURL, req.SavePath)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
svc.Audit.Record(c.Request.Context(), uid.(string), "download.add", realURL, c.ClientIP(), "")
c.JSON(http.StatusOK, t)
}
}
func listDownloadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
rows, live, err := svc.Downloads.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"tasks": rows,
"torrents": live,
})
}
}
func deleteDownloadHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
hash := c.Param("hash")
withFiles := c.Query("delete_files") == "true"
if err := svc.Downloads.Delete(c.Request.Context(), hash, withFiles); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
func reloadDownloadConfigHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Downloads.ReloadConfig(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}