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_* 临时文件排除规则
This commit is contained in:
ShukeBta
2026-05-26 16:09:13 +08:00
parent 5d1ae91419
commit 3a2db6bdd9
32 changed files with 2263 additions and 465 deletions
+1
View File
@@ -51,3 +51,4 @@ config.yaml
# Editor backups # Editor backups
*~ *~
.tmp_*
+10 -2
View File
@@ -5,15 +5,22 @@ import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/service" "github.com/ShukeBta/MediaStationGo/internal/service"
) )
// trendingHandler 返回 TMDb 当日热门列表。
//
// 当本机无法连接 TMDb(GFW / 代理未配 / API key 无效)时,TMDb 调用会
// 在 15 秒后超时;这种情况下不应该让首页显示 500 错误,而是把空列表
// 直接返回——前端按 items.length === 0 渲染"暂无推荐"即可。
func trendingHandler(svc *service.Container) gin.HandlerFunc { func trendingHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
items, err := svc.Discover.Trending(c.Request.Context()) items, err := svc.Discover.Trending(c.Request.Context())
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) svc.Log.Warn("discover trending failed (returning empty list)", zap.Error(err))
c.JSON(http.StatusOK, gin.H{"items": []service.Match{}, "error": err.Error()})
return return
} }
if items == nil { if items == nil {
@@ -27,7 +34,8 @@ func popularHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
items, err := svc.Discover.Popular(c.Request.Context()) items, err := svc.Discover.Popular(c.Request.Context())
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) svc.Log.Warn("discover popular failed (returning empty list)", zap.Error(err))
c.JSON(http.StatusOK, gin.H{"items": []service.Match{}, "error": err.Error()})
return return
} }
if items == nil { if items == nil {
+5
View File
@@ -37,6 +37,9 @@ func createDownloadClientHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
// 让真正发起下载的 DownloadService 立刻读到新的 qb 配置,
// 避免保存后还要重启进程才能生效。
_ = svc.Downloads.ReloadConfig(c.Request.Context())
c.JSON(http.StatusOK, row) c.JSON(http.StatusOK, row)
} }
} }
@@ -53,6 +56,7 @@ func updateDownloadClientHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
_ = svc.Downloads.ReloadConfig(c.Request.Context())
c.JSON(http.StatusOK, row) c.JSON(http.StatusOK, row)
} }
} }
@@ -63,6 +67,7 @@ func deleteDownloadClientHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
_ = svc.Downloads.ReloadConfig(c.Request.Context())
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
} }
} }
+98 -2
View File
@@ -2,11 +2,17 @@
package handler package handler
import ( import (
"context"
"net/http" "net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/middleware" "github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/service" "github.com/ShukeBta/MediaStationGo/internal/service"
) )
@@ -15,6 +21,94 @@ type addDownloadReq struct {
SavePath string `json:"save_path"` 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 { func addDownloadHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
var req addDownloadReq var req addDownloadReq
@@ -23,12 +117,14 @@ func addDownloadHandler(svc *service.Container) gin.HandlerFunc {
return return
} }
uid, _ := c.Get(middleware.CtxUserID) uid, _ := c.Get(middleware.CtxUserID)
t, err := svc.Downloads.AddDownload(c.Request.Context(), uid.(string), req.URL, req.SavePath) // 把站点搜索 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 { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
svc.Audit.Record(c.Request.Context(), uid.(string), "download.add", req.URL, c.ClientIP(), "") svc.Audit.Record(c.Request.Context(), uid.(string), "download.add", realURL, c.ClientIP(), "")
c.JSON(http.StatusOK, t) c.JSON(http.StatusOK, t)
} }
} }
+457 -9
View File
@@ -1,26 +1,134 @@
// Package handler — Emby/Jellyfin compatibility shim. // Package handler — Emby/Jellyfin compatibility shim.
// //
// Routes are mounted under /emby/* so existing Emby-aware clients // 路由挂在 /emby/* 和根路径下双前缀。Infuse / Yamby / Hills /
// (Infuse / VidHub / Kodi) point at MediaStationGo and discover the // Senplayer / Kodi 这类客户端会自动尝试 /System/Info 与 /emby/System/Info
// library through their familiar API. We do not implement write paths; // 两种 URL,我们都接住。
// the React UI stays the canonical control plane.
package handler package handler
import ( import (
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/service" "github.com/ShukeBta/MediaStationGo/internal/service"
) )
// embyError 返回 Emby 风格的错误(顶层 Code/Message)。
func embyError(c *gin.Context, status int, msg string) {
c.JSON(status, gin.H{"Code": status, "Message": msg})
}
// embyUserID 从中间件中获取 user id。Emby auth middleware 写入 CtxUserID。
func embyUserID(c *gin.Context) string {
if uid, ok := c.Get(middleware.CtxUserID); ok {
if s, ok := uid.(string); ok {
return s
}
}
return ""
}
// ─── System ──────────────────────────────────────────────────────────────────
func embySystemInfoHandler(svc *service.Container) gin.HandlerFunc { func embySystemInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
c.JSON(http.StatusOK, svc.Emby.SystemInfo()) c.JSON(http.StatusOK, svc.Emby.SystemInfo())
} }
} }
func embySystemInfoPublicHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, svc.Emby.SystemInfoPublic())
}
}
func embySystemEndpointHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"IsLocal": true,
"IsInNetwork": true,
})
}
}
func embyPingHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
// Emby/Jellyfin 期望 plain text "Emby Server"
c.String(http.StatusOK, "Emby Server")
}
}
// ─── Users / Auth ────────────────────────────────────────────────────────────
type embyAuthByNameReq struct {
Username string `json:"Username"`
Pw string `json:"Pw"`
Password string `json:"Password"`
}
// embyAuthByNameHandler 处理 POST /Users/AuthenticateByName。
//
// 这是 Emby 客户端登录的唯一入口(Infuse / Yamby / Hills 等都走这里)。
// 用户名+密码 → 调用我们已有的 AuthService.Login → 返回 AccessToken + User。
func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req embyAuthByNameReq
if err := c.ShouldBindJSON(&req); err != nil {
embyError(c, http.StatusBadRequest, "invalid body")
return
}
password := req.Pw
if password == "" {
password = req.Password
}
resp, err := svc.Auth.Login(c.Request.Context(), req.Username, password)
if err != nil {
embyError(c, http.StatusUnauthorized, err.Error())
return
}
userPayload, _ := svc.Emby.FindUser(c.Request.Context(), resp.User.ID)
c.JSON(http.StatusOK, gin.H{
"AccessToken": resp.Tokens.AccessToken,
"ServerId": "mediastation-go-001",
"User": userPayload,
"SessionInfo": gin.H{
"Id": resp.User.ID,
"UserId": resp.User.ID,
"UserName": resp.User.Username,
"Client": c.GetHeader("X-Emby-Client"),
"DeviceId": c.GetHeader("X-Emby-Device-Id"),
"DeviceName": c.GetHeader("X-Emby-Device-Name"),
},
})
}
}
func embyPublicUsersHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
// 公开用户列表(Emby Web 客户端登录页拉这个,列出可见用户)。
users, err := svc.Emby.ListUsers(c.Request.Context())
if err != nil {
c.JSON(http.StatusOK, []any{})
return
}
// 公开版本只暴露 Id + Name,不包含 Policy。
out := make([]map[string]any, 0, len(users))
for _, u := range users {
out = append(out, map[string]any{
"Id": u["Id"],
"Name": u["Name"],
"ServerId": u["ServerId"],
"HasPassword": true,
})
}
c.JSON(http.StatusOK, out)
}
}
func embyListUsersHandler(svc *service.Container) gin.HandlerFunc { func embyListUsersHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
users, err := svc.Emby.ListUsers(c.Request.Context()) users, err := svc.Emby.ListUsers(c.Request.Context())
@@ -32,6 +140,35 @@ func embyListUsersHandler(svc *service.Container) gin.HandlerFunc {
} }
} }
func embyMeHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := embyUserID(c)
if uid == "" {
embyError(c, http.StatusUnauthorized, "not authenticated")
return
}
u, err := svc.Emby.FindUser(c.Request.Context(), uid)
if err != nil || u == nil {
embyError(c, http.StatusNotFound, "user not found")
return
}
c.JSON(http.StatusOK, u)
}
}
func embyGetUserByIDHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
u, err := svc.Emby.FindUser(c.Request.Context(), c.Param("userId"))
if err != nil || u == nil {
embyError(c, http.StatusNotFound, "user not found")
return
}
c.JSON(http.StatusOK, u)
}
}
// ─── Views / MediaFolders ────────────────────────────────────────────────────
func embyViewsHandler(svc *service.Container) gin.HandlerFunc { func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
out, err := svc.Emby.Views(c.Request.Context()) out, err := svc.Emby.Views(c.Request.Context())
@@ -43,12 +180,46 @@ func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
} }
} }
// ─── Items ───────────────────────────────────────────────────────────────────
func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
limit, _ := strconv.Atoi(c.DefaultQuery("Limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("StartIndex", "0"))
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
splitOpt := func(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
return service.ItemsParams{
UserID: uid,
ParentID: c.Query("ParentId"),
IDs: splitOpt(c.Query("Ids")),
SearchTerm: c.Query("SearchTerm"),
IncludeItemTypes: splitOpt(c.Query("IncludeItemTypes")),
Recursive: strings.EqualFold(c.Query("Recursive"), "true"),
SortBy: c.Query("SortBy"),
SortOrder: c.Query("SortOrder"),
Limit: limit,
StartIndex: offset,
}
}
func embyItemsHandler(svc *service.Container) gin.HandlerFunc { func embyItemsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
libraryID := c.Query("ParentId") out, err := svc.Emby.Items(c.Request.Context(), parseEmbyItemsParams(c))
limit, _ := strconv.Atoi(c.DefaultQuery("Limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("StartIndex", "0"))
out, err := svc.Emby.Items(c.Request.Context(), libraryID, limit, offset)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -57,6 +228,93 @@ func embyItemsHandler(svc *service.Container) gin.HandlerFunc {
} }
} }
func embyItemByIDHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
out, err := svc.Emby.Item(c.Request.Context(), id, uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if out == nil {
embyError(c, http.StatusNotFound, "item not found")
return
}
c.JSON(http.StatusOK, out)
}
}
func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
limit, _ := strconv.Atoi(c.DefaultQuery("Limit", "20"))
out, err := svc.Emby.LatestItems(c.Request.Context(), uid, c.Query("ParentId"), limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, out)
}
}
func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
limit, _ := strconv.Atoi(c.DefaultQuery("Limit", "20"))
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, out)
}
}
// ─── Images ──────────────────────────────────────────────────────────────────
// embyItemImageHandler 把 /Items/{id}/Images/Primary 等请求重定向到
// 我们的 /api/img 代理。Emby 客户端会自动追加 ?api_key=... 或 ?tag=...
// 我们只关心 id+type,从 media row 拉到 PosterURL/BackdropURL 后转成
// /api/img?url=... 重定向。
func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
imgType := strings.ToLower(c.Param("type"))
m, err := svc.Repo.Media.FindByID(c.Request.Context(), id)
if err != nil || m == nil {
c.Status(http.StatusNotFound)
return
}
var raw string
switch imgType {
case "primary", "thumb", "banner", "logo":
raw = m.PosterURL
case "backdrop", "art":
raw = m.BackdropURL
default:
raw = m.PosterURL
}
if raw == "" {
c.Status(http.StatusNotFound)
return
}
// 直接重定向到 /api/img;image proxy 自己缓存 + 兜底 1×1 PNG。
c.Redirect(http.StatusFound, "/api/img?url="+url.QueryEscape(raw))
}
}
// ─── Playback ────────────────────────────────────────────────────────────────
func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc { func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
out, err := svc.Emby.PlaybackInfo(c.Request.Context(), c.Param("id")) out, err := svc.Emby.PlaybackInfo(c.Request.Context(), c.Param("id"))
@@ -65,9 +323,199 @@ func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
return return
} }
if out == nil { if out == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) embyError(c, http.StatusNotFound, "not found")
return return
} }
c.JSON(http.StatusOK, out) c.JSON(http.StatusOK, out)
} }
} }
// embyVideoStreamHandler 是 GET /Videos/{id}/stream 的入口,
// 直接代理到我们的 /api/stream/{id}(同一个 ServeFile)。
func embyVideoStreamHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
// 直接调用 Stream service 写入 response
err := svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id"))
if err != nil {
c.Status(http.StatusNotFound)
}
}
}
// ─── 播放进度 / 收藏 / 已看 ────────────────────────────────────────────────
type embyPlayingReq struct {
ItemId string `json:"ItemId"`
PositionTicks int64 `json:"PositionTicks"`
RunTimeTicks int64 `json:"RunTimeTicks"`
}
func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := embyUserID(c)
if uid == "" {
c.Status(http.StatusUnauthorized)
return
}
var req embyPlayingReq
_ = c.ShouldBindJSON(&req)
// 兼容 query 形式(一些客户端在 /Sessions/Playing/* 用 query)
if req.ItemId == "" {
req.ItemId = c.Query("ItemId")
}
if req.PositionTicks == 0 {
req.PositionTicks, _ = strconv.ParseInt(c.Query("PositionTicks"), 10, 64)
}
if req.RunTimeTicks == 0 {
req.RunTimeTicks, _ = strconv.ParseInt(c.Query("RunTimeTicks"), 10, 64)
}
if req.ItemId == "" {
c.Status(http.StatusOK) // Emby 期望 2xx;不是关键操作
return
}
_ = svc.Emby.RecordProgress(c.Request.Context(), uid, req.ItemId, req.PositionTicks, req.RunTimeTicks)
c.Status(http.StatusNoContent)
}
}
func embyFavoriteHandler(svc *service.Container, fav bool) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
mid := c.Param("itemId")
if uid == "" || mid == "" {
c.Status(http.StatusBadRequest)
return
}
if err := svc.Emby.SetFavorite(c.Request.Context(), uid, mid, fav); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Emby 期望返回 UserItemDataDto;最小可工作版本:echo Item 即可。
out, _ := svc.Emby.Item(c.Request.Context(), mid, uid)
if out != nil {
c.JSON(http.StatusOK, out["UserData"])
return
}
c.JSON(http.StatusOK, gin.H{"IsFavorite": fav})
}
}
func embyMarkPlayedHandler(svc *service.Container, played bool) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
mid := c.Param("itemId")
if uid == "" || mid == "" {
c.Status(http.StatusBadRequest)
return
}
if err := svc.Emby.MarkPlayed(c.Request.Context(), uid, mid, played); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
out, _ := svc.Emby.Item(c.Request.Context(), mid, uid)
if out != nil {
c.JSON(http.StatusOK, out["UserData"])
return
}
c.JSON(http.StatusOK, gin.H{"Played": played})
}
}
// ─── Sessions / Branding 占位 ────────────────────────────────────────────────
func embySessionsHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, []any{})
}
}
func embyBrandingConfigHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"LoginDisclaimer": "",
"CustomCss": "",
"SplashscreenEnabled": false,
})
}
}
func embyLocalizationOptionsHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, []map[string]any{
{"Name": "简体中文", "Value": "zh-CN"},
{"Name": "English", "Value": "en-US"},
})
}
}
// registerEmbyRoutes 在 r 上挂双前缀("" + "/emby")的 Emby 兼容路由。
func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container) {
for _, prefix := range []string{"/emby", ""} {
grp := r.Group(prefix)
// 公开端点
grp.GET("/System/Info/Public", embySystemInfoPublicHandler(svc))
grp.GET("/System/Ping", embyPingHandler(svc))
grp.HEAD("/System/Ping", embyPingHandler(svc))
grp.POST("/System/Ping", embyPingHandler(svc))
grp.POST("/Users/AuthenticateByName", embyAuthByNameHandler(svc))
grp.GET("/Users/Public", embyPublicUsersHandler(svc))
grp.GET("/Branding/Configuration", embyBrandingConfigHandler(svc))
grp.GET("/Localization/Options", embyLocalizationOptionsHandler(svc))
// 图片公开(Infuse 缓存 URL 时会丢 token)
grp.GET("/Items/:id/Images/:type", embyItemImageHandler(svc))
grp.GET("/Items/:id/Images/:type/:index", embyItemImageHandler(svc))
grp.HEAD("/Items/:id/Images/:type", embyItemImageHandler(svc))
// 鉴权后端点
auth := grp.Group("", middleware.AuthRequired(jwtSecret))
auth.GET("/System/Info", embySystemInfoHandler(svc))
auth.GET("/System/Endpoint", embySystemEndpointHandler(svc))
auth.GET("/Users/Me", embyMeHandler(svc))
auth.GET("/Users", embyListUsersHandler(svc))
auth.GET("/Users/:userId", embyGetUserByIDHandler(svc))
auth.GET("/Users/:userId/Views", embyViewsHandler(svc))
auth.GET("/Library/MediaFolders", embyViewsHandler(svc))
auth.GET("/Items", embyItemsHandler(svc))
auth.GET("/Users/:userId/Items", embyItemsHandler(svc))
auth.GET("/Items/:id", embyItemByIDHandler(svc))
auth.GET("/Users/:userId/Items/Latest", embyLatestItemsHandler(svc))
auth.GET("/Users/:userId/Items/Resume", embyResumeItemsHandler(svc))
auth.GET("/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
auth.POST("/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
auth.GET("/Videos/:id/stream", embyVideoStreamHandler(svc))
auth.HEAD("/Videos/:id/stream", embyVideoStreamHandler(svc))
auth.GET("/Videos/:id/stream.:container", embyVideoStreamHandler(svc))
auth.HEAD("/Videos/:id/stream.:container", embyVideoStreamHandler(svc))
auth.GET("/Videos/:id/original", embyVideoStreamHandler(svc))
auth.GET("/Videos/:id/original.:container", embyVideoStreamHandler(svc))
auth.POST("/Sessions/Playing", embyPlayingProgressHandler(svc))
auth.POST("/Sessions/Playing/Progress", embyPlayingProgressHandler(svc))
auth.POST("/Sessions/Playing/Stopped", embyPlayingProgressHandler(svc))
auth.POST("/Users/:userId/FavoriteItems/:itemId", embyFavoriteHandler(svc, true))
auth.DELETE("/Users/:userId/FavoriteItems/:itemId", embyFavoriteHandler(svc, false))
auth.POST("/Users/:userId/PlayedItems/:itemId", embyMarkPlayedHandler(svc, true))
auth.DELETE("/Users/:userId/PlayedItems/:itemId", embyMarkPlayedHandler(svc, false))
auth.GET("/Sessions", embySessionsHandler(svc))
auth.GET("/DisplayPreferences/:id", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"Id": c.Param("id"), "CustomPrefs": gin.H{}})
})
auth.POST("/DisplayPreferences/:id", func(c *gin.Context) {
c.Status(http.StatusNoContent)
})
}
}
+8 -12
View File
@@ -25,7 +25,11 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
{ {
auth.POST("/login", loginHandler(svc)) auth.POST("/login", loginHandler(svc))
auth.POST("/register", registerHandler(svc)) auth.POST("/register", registerHandler(svc))
auth.POST("/refresh", refreshHandler(svc)) // /auth/refresh 用 RefreshHandler.RefreshToken:它从 body 读
// refresh_token 并签发新 access/refresh 对。旧的 refreshHandler
// 依赖 AuthRequired 中间件,永远 401,因此弃用。
refreshHd := NewRefreshHandler(svc, log)
auth.POST("/refresh", refreshHd.RefreshToken)
} }
// Authenticated endpoints. // Authenticated endpoints.
@@ -336,19 +340,11 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
apiConfig.POST("/:provider/test", testApiConfigHandler(svc)) apiConfig.POST("/:provider/test", testApiConfigHandler(svc))
} }
// Emby/Jellyfin compatibility shim (read-only). // Emby/Jellyfin compatibility shim — routes mounted at /emby/* AND
// Mounted at /emby/* (NOT /api/*) to mirror the upstream surface. // the root path so Infuse / Yamby / Hills / Senplayer 都能自动连接。
} }
emby := r.Group("/emby") registerEmbyRoutes(r, cfg.Secrets.JWTSecret, svc)
emby.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret))
{
emby.GET("/System/Info", embySystemInfoHandler(svc))
emby.GET("/Users", embyListUsersHandler(svc))
emby.GET("/Users/:userId/Views", embyViewsHandler(svc))
emby.GET("/Users/:userId/Items", embyItemsHandler(svc))
emby.GET("/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
}
} }
func healthCheck(c *gin.Context) { func healthCheck(c *gin.Context) {
+4 -1
View File
@@ -45,7 +45,10 @@ func stopTranscodeHandler(svc *service.Container) gin.HandlerFunc {
func imageProxyHandler(svc *service.Container) gin.HandlerFunc { func imageProxyHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
raw := c.Query("url") raw := c.Query("url")
if err := svc.ImageProxy.Serve(c.Request.Context(), c.Writer, raw); err != nil { // Serve handles upstream errors internally by returning a 1×1 PNG
// placeholder, so the only error we can get back here is a malformed
// URL. In that case we still return 400 to make the misuse visible.
if err := svc.ImageProxy.Serve(c.Request.Context(), c.Writer, c.Request, raw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
+56 -23
View File
@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
@@ -14,6 +15,34 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
// NewSiteHTTPClient builds an http.Client honoring per-site policies:
// - timeout (seconds, defaults to 15)
// - proxy via HTTP(S)_PROXY environment variables when site.UseProxy is on
//
// When useProxy is false, the client is created without proxy plumbing so
// the request goes out direct, matching the user's checkbox intent.
func NewSiteHTTPClient(timeoutSeconds int, useProxy bool) *http.Client {
if timeoutSeconds <= 0 {
timeoutSeconds = 15
}
tr := &http.Transport{
MaxIdleConns: 16,
MaxIdleConnsPerHost: 4,
IdleConnTimeout: 60 * time.Second,
TLSHandshakeTimeout: 15 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
if useProxy {
tr.Proxy = func(r *http.Request) (*url.URL, error) {
return http.ProxyFromEnvironment(r)
}
}
return &http.Client{
Timeout: time.Duration(timeoutSeconds) * time.Second,
Transport: tr,
}
}
// HTTPHeaderPresets returns a map of realistic browser HTTP headers. // HTTPHeaderPresets returns a map of realistic browser HTTP headers.
// These mimic a real Chrome browser to avoid WAF/bot detection. // These mimic a real Chrome browser to avoid WAF/bot detection.
func HTTPHeaderPresets() map[string]string { func HTTPHeaderPresets() map[string]string {
@@ -193,11 +222,15 @@ func IsCloudflareChallenge(html string) bool {
// ─── Site Connectivity Test ───────────────────────────────────────────── // ─── Site Connectivity Test ─────────────────────────────────────────────
// TestSiteConnectivity performs a site connectivity test with browser-like headers. // TestSiteConnectivity performs a site connectivity test with browser-like headers.
// If flareSolverrURL is non-empty, it will attempt to use FlareSolverr first. // If flareSolverrURL is non-empty AND the site has BrowserEmulation turned on,
// it will attempt to use FlareSolverr first.
// Returns (ok, message, error). // Returns (ok, message, error).
func TestSiteConnectivity(site *model.Site, flareSolverrURL string, timeout int, log *zap.Logger) (bool, string, error) { func TestSiteConnectivity(site *model.Site, flareSolverrURL string, timeout int, log *zap.Logger) (bool, string, error) {
// Try FlareSolverr first if configured // Try FlareSolverr first when (a) globally enabled and (b) the site
if flareSolverrURL != "" { // asked for browser emulation. This matches the contract used by the
// search path (see service.SiteService.siteModelToConfig).
useFlare := flareSolverrURL != "" && site.BrowserEmulation
if useFlare {
log.Info("Trying FlareSolverr for site test", zap.String("url", site.URL)) log.Info("Trying FlareSolverr for site test", zap.String("url", site.URL))
body, err := FetchURLWithFlareSolverr(flareSolverrURL, site.URL, site.Cookie, timeout, "", log) body, err := FetchURLWithFlareSolverr(flareSolverrURL, site.URL, site.Cookie, timeout, "", log)
if err == nil { if err == nil {
@@ -211,15 +244,15 @@ func TestSiteConnectivity(site *model.Site, flareSolverrURL string, timeout int,
// Fall through to direct request // Fall through to direct request
} }
// Direct HTTP request with browser-like headers // Direct HTTP request with browser-like headers. Honors HTTP(S)_PROXY
client := &http.Client{ // when the site has UseProxy enabled — this makes the "use proxy"
Timeout: time.Duration(timeout) * time.Second, // checkbox in the UI actually do something.
CheckRedirect: func(req *http.Request, via []*http.Request) error { client := NewSiteHTTPClient(timeout, site.UseProxy)
if len(via) >= 10 { client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return fmt.Errorf("too many redirects") if len(via) >= 10 {
} return fmt.Errorf("too many redirects")
return nil }
}, return nil
} }
req, err := http.NewRequest("GET", site.URL, nil) req, err := http.NewRequest("GET", site.URL, nil)
@@ -253,7 +286,9 @@ func TestSiteConnectivity(site *model.Site, flareSolverrURL string, timeout int,
return false, "站点被 Cloudflare/WAF 拦截,请配置 FlareSolverr 或浏览器模拟", nil return false, "站点被 Cloudflare/WAF 拦截,请配置 FlareSolverr 或浏览器模拟", nil
} }
// Evaluate status code (same logic as before) // Evaluate status code (mirror the reference Python project's semantics:
// 200 → success, 3xx → success/redirect-to-self, 401/403 → failure with
// hint to check credentials, 4xx/5xx → failure with raw status text).
switch { switch {
case resp.StatusCode >= 200 && resp.StatusCode < 300: case resp.StatusCode >= 200 && resp.StatusCode < 300:
return true, fmt.Sprintf("连接成功 (%s)", resp.Status), nil return true, fmt.Sprintf("连接成功 (%s)", resp.Status), nil
@@ -262,18 +297,18 @@ func TestSiteConnectivity(site *model.Site, flareSolverrURL string, timeout int,
if loc == "" { if loc == "" {
loc = "(unknown)" loc = "(unknown)"
} }
return true, fmt.Sprintf("站点可达,但返回重定向至 %s", loc), nil // Most PT sites redirect logged-out users to login; treat as failure.
return false, fmt.Sprintf("未登录或 Cookie 失效(重定向至 %s)", loc), nil
case resp.StatusCode == 401: case resp.StatusCode == 401:
return true, "站点可达,需要认证 (HTTP 401)", nil return false, "未授权(HTTP 401),请检查 API Key / Cookie", nil
case resp.StatusCode == 403: case resp.StatusCode == 403:
return true, "站点可达,但访问被拒绝 — 可能被 Cloudflare/WAF 拦截 (HTTP 403)", nil return false, "认证失败(HTTP 403),请检查 Cookie / API Key 或站点是否需要浏览器模拟", nil
case resp.StatusCode == 429: case resp.StatusCode == 429:
return true, "站点可达,但被限流 (HTTP 429)", nil return false, "请求被限流(HTTP 429),请稍后再试", nil
case resp.StatusCode == 503: case resp.StatusCode == 503:
return true, "站点可达,服务暂时不可用 (HTTP 503)", nil return false, "服务暂时不可用(HTTP 503)", nil
default: default:
ok := resp.StatusCode >= 400 && resp.StatusCode < 500 return false, resp.Status, nil
return ok, resp.Status, nil
} }
} }
@@ -303,9 +338,7 @@ func ApplySiteAuthHeaders(req *http.Request, site *model.Site) {
// GetPageSource fetches a page with browser-like headers. // GetPageSource fetches a page with browser-like headers.
// Returns (pageSource, cookies, error). // Returns (pageSource, cookies, error).
func GetPageSource(url string, site *model.Site, timeout int, log *zap.Logger) (string, string, error) { func GetPageSource(url string, site *model.Site, timeout int, log *zap.Logger) (string, string, error) {
client := &http.Client{ client := NewSiteHTTPClient(timeout, site.UseProxy)
Timeout: time.Duration(timeout) * time.Second,
}
req, err := http.NewRequest("GET", url, nil) req, err := http.NewRequest("GET", url, nil)
if err != nil { if err != nil {
+61 -2
View File
@@ -182,9 +182,68 @@ func (r *LibraryRepository) Delete(ctx context.Context, id string) error {
type MediaRepository struct{ db *gorm.DB } type MediaRepository struct{ db *gorm.DB }
// Upsert inserts or updates a media row keyed by Path (unique index). // Upsert inserts or updates a media row keyed by Path (unique index).
//
// 重要:当一条行已经存在时,scanner 重扫只应该刷新文件级元数据
// (时长、宽高、编码、容器、大小),不能把刮削器维护的字段(标题改写、
// 海报、TMDb/Bangumi ID、scrape_status 等)覆盖回零值。
//
// 之前用 Assign(*m).FirstOrCreate(m) 会把整张零值结构体写回,导致:
// 1. scrape_status 从 'matched' / 'no_match' 被清空成 '';
// 2. 新建行使 GORM `default:pending` 也得不到应用(因为 zero value 被
// 显式写入)。这两个问题都让 EnrichLibrary(WHERE scrape_status='pending')
// 永远捞不到数据。
func (r *MediaRepository) Upsert(ctx context.Context, m *model.Media) error { func (r *MediaRepository) Upsert(ctx context.Context, m *model.Media) error {
return r.db.WithContext(ctx).Where("path = ?", m.Path). var existing model.Media
Assign(*m).FirstOrCreate(m).Error err := r.db.WithContext(ctx).Where("path = ?", m.Path).First(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// 新行:保证 scrape_status 走 GORM default:pending(即留空让数据库填)。
if m.ScrapeStatus == "" {
m.ScrapeStatus = "pending"
}
return r.db.WithContext(ctx).Create(m).Error
}
if err != nil {
return err
}
// 已存在:仅刷新文件层面的字段。
updates := map[string]any{
"size_bytes": m.SizeBytes,
"duration_sec": m.DurationSec,
"width": m.Width,
"height": m.Height,
"video_codec": m.VideoCodec,
"audio_codec": m.AudioCodec,
"container": m.Container,
}
if m.Title != "" {
// scanner 给出的标题只是从路径推导,刮削后 title 已被替换为
// 真实剧名。仅在 existing 还停留在 'pending'/'' 时回填扫描标题,
// 避免覆盖刮削结果。
if existing.ScrapeStatus == "pending" || existing.ScrapeStatus == "" || existing.ScrapeStatus == "no_match" {
updates["title"] = m.Title
if m.Year > 0 {
updates["year"] = m.Year
}
}
}
if lib := m.LibraryID; lib != "" && lib != existing.LibraryID {
updates["library_id"] = m.LibraryID
}
if m.SeasonNum > 0 && existing.SeasonNum != m.SeasonNum {
updates["season_num"] = m.SeasonNum
}
if m.EpisodeNum > 0 && existing.EpisodeNum != m.EpisodeNum {
updates["episode_num"] = m.EpisodeNum
}
if err := r.db.WithContext(ctx).Model(&model.Media{}).
Where("id = ?", existing.ID).Updates(updates).Error; err != nil {
return err
}
// 回写 ID / 不可变字段,让 caller 拿到完整的现有行。
*m = existing
return nil
} }
// FindByID returns the media row or (nil, nil). // FindByID returns the media row or (nil, nil).
+18 -1
View File
@@ -8,6 +8,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@@ -129,23 +130,39 @@ func (s *DownloadClientService) Test(ctx context.Context, id string) error {
} }
switch c.Type { switch c.Type {
case "qbittorrent": case "qbittorrent":
host := strings.TrimRight(c.Host, "/")
body := url.Values{} body := url.Values{}
body.Set("username", c.Username) body.Set("username", c.Username)
body.Set("password", c.Password) body.Set("password", c.Password)
req, _ := http.NewRequestWithContext( req, _ := http.NewRequestWithContext(
ctx, http.MethodPost, ctx, http.MethodPost,
strings.TrimRight(c.Host, "/")+"/api/v2/auth/login", host+"/api/v2/auth/login",
strings.NewReader(body.Encode()), strings.NewReader(body.Encode()),
) )
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// qBittorrent v4.6+ 默认开启 "host header validation" 并要求
// Referer 与 Host 同源,否则即使账户正确也会拒绝登录。
req.Header.Set("Referer", host)
req.Header.Set("Origin", host)
req.Header.Set("User-Agent", "MediaStationGo/0.1")
resp, err := s.client.Do(req) resp, err := s.client.Do(req)
if err != nil { if err != nil {
return err return err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode == http.StatusForbidden {
return errors.New("qbittorrent: 403 — 用户名/密码错误,或 WebUI 启用了 IP 封禁")
}
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
return fmt.Errorf("qbittorrent returned %d", resp.StatusCode) return fmt.Errorf("qbittorrent returned %d", resp.StatusCode)
} }
// 即使返回 200,body 内容仍可能是 "Fails." 表示登录失败。
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
text := strings.TrimSpace(string(raw))
if text == "Fails." {
return errors.New("qbittorrent: 用户名/密码错误")
}
// 正确响应是 "Ok." — 但部分版本会重定向或返回空体,不强校验。
return nil return nil
case "aria2", "transmission": case "aria2", "transmission":
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, c.Host, nil) req, _ := http.NewRequestWithContext(ctx, http.MethodGet, c.Host, nil)
+37 -13
View File
@@ -18,6 +18,7 @@ package service
import ( import (
"context" "context"
"errors" "errors"
"strings"
"sync" "sync"
"time" "time"
@@ -67,19 +68,41 @@ func (d *DownloadService) Stop() {
close(d.stopCh) close(d.stopCh)
} }
// ReloadConfig rebuilds the qBittorrent client from the system settings. // ReloadConfig rebuilds the qBittorrent client from the configured
// download clients (preferred) or the legacy Setting table (fallback).
//
// 配置来源优先级:
//
// 1. download_clients 表中 type=qbittorrent 且 is_default=true 且 enabled=true
// 的行(侧边栏「下载器」页面写入的数据)。
// 2. system Setting 表中的 qbittorrent.url / username / password
// (旧版「系统设置」表单写入的数据;保留作向后兼容)。
//
// 这避免了两套配置各跑各的:之前操作员明明已经在「下载器」页面填好
// 默认 qb,但实际下载链路读的还是 Setting 表,导致一直连不上。
func (d *DownloadService) ReloadConfig(ctx context.Context) error { func (d *DownloadService) ReloadConfig(ctx context.Context) error {
cfg := QBitConfig{} cfg := QBitConfig{}
for _, key := range []struct{ from, into *string }{} {
_ = key // Path 1: download_clients 表
if d.repo.DownloadClient != nil {
if c, err := d.repo.DownloadClient.FindDefault(ctx); err == nil && c != nil && c.Type == "qbittorrent" {
cfg.BaseURL = strings.TrimRight(c.Host, "/")
cfg.Username = c.Username
cfg.Password = c.Password
}
} }
get := func(k string) string {
v, _ := d.repo.Setting.Get(ctx, k) // Path 2: legacy Setting 表(仅在 client 表未配置时回退)
return v if cfg.BaseURL == "" {
get := func(k string) string {
v, _ := d.repo.Setting.Get(ctx, k)
return v
}
cfg.BaseURL = get("qbittorrent.url")
cfg.Username = get("qbittorrent.username")
cfg.Password = get("qbittorrent.password")
} }
cfg.BaseURL = get("qbittorrent.url")
cfg.Username = get("qbittorrent.username")
cfg.Password = get("qbittorrent.password")
d.qb.Configure(cfg) d.qb.Configure(cfg)
return nil return nil
} }
@@ -171,11 +194,12 @@ func (d *DownloadService) onTorrentComplete(ctx context.Context, hash string, sa
if d.organizer == nil || savePath == "" { if d.organizer == nil || savePath == "" {
return return
} }
// Check if auto-organize after download is enabled // 仅当显式开启 organizer.auto_after_download 时才在下载完成后整理。
autoOrganize := d.organizer.isSmartClassifyEnabled(ctx) // 之前的代码错误地把 organizer.smart_classify 也当成"自动整理"开关,
// Also check dedicated config key // 让操作员只想启用"分类子目录"就被动触发了文件 move。
autoOrganize := false
if v, err := d.repo.Setting.Get(ctx, "organizer.auto_after_download"); err == nil { if v, err := d.repo.Setting.Get(ctx, "organizer.auto_after_download"); err == nil {
autoOrganize = autoOrganize || v == "true" || v == "1" || v == "on" autoOrganize = v == "true" || v == "1" || v == "on"
} }
if !autoOrganize { if !autoOrganize {
d.log.Info("download completed, auto-organize disabled", zap.String("hash", hash)) d.log.Info("download completed, auto-organize disabled", zap.String("hash", hash))
+504 -77
View File
@@ -1,33 +1,36 @@
// Package service — minimal Emby/Jellyfin compatibility shim. // Package service — Emby/Jellyfin compatibility shim.
// //
// EmbyService produces JSON envelopes shaped like the most-consumed // EmbyService produces JSON envelopes shaped like the most-consumed
// Emby-API endpoints so existing players (Infuse / Kodi NextPVR // Emby-API endpoints so existing players (Infuse / Yamby / Hills /
// extension / iOS native clients) can talk to MediaStationGo without a // Senplayer / Kodi NextPVR extension / iOS native clients) can talk to
// custom plugin. // MediaStationGo without a custom plugin.
// //
// Implemented surface (matches what nowen-video exposes): // The shim is read-mostly: items, images, playback are fully covered;
// //播放进度上报 / 收藏切换 是写路径但走我们自己的 PlaybackHistory /
// GET /emby/System/Info server identity // Favorite 表,所以 Emby 客户端的"标记已看 / 收藏"也会反向同步到
// GET /emby/Users list of users (admin only field) // 我们自己的 React UI。
// GET /emby/Users/{userId}/Views virtual root: one entry per library
// GET /emby/Users/{userId}/Items paginated media listing
// GET /emby/Items/{id} single item
// GET /emby/Items/{id}/PlaybackInfo stream URL (delegates to /api/stream)
//
// The shim is read-only — Emby write operations (mark watched, etc.) are
// not implemented; the React UI stays the canonical control plane.
package service package service
import ( import (
"context" "context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/config" "github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository" "github.com/ShukeBta/MediaStationGo/internal/repository"
) )
// 用一个固定的 ServerId 字符串。Emby 客户端会缓存这个 id,第一次见到
// 该 id 后会把所有派生数据(cookie/收藏/历史)和它绑定。
const embyServerID = "mediastation-go-001"
// EmbyService produces Emby-shaped JSON. // EmbyService produces Emby-shaped JSON.
type EmbyService struct { type EmbyService struct {
cfg *config.Config cfg *config.Config
@@ -40,17 +43,46 @@ func NewEmbyService(cfg *config.Config, log *zap.Logger, repo *repository.Contai
return &EmbyService{cfg: cfg, log: log, repo: repo} return &EmbyService{cfg: cfg, log: log, repo: repo}
} }
// SystemInfo returns the Emby identity payload. // ─── System ──────────────────────────────────────────────────────────────────
// SystemInfo returns the full Emby identity payload.
func (e *EmbyService) SystemInfo() map[string]any { func (e *EmbyService) SystemInfo() map[string]any {
return map[string]any{ return map[string]any{
"ServerName": "MediaStationGo", "Id": embyServerID,
"Version": "0.1.0", "ServerName": "MediaStationGo",
"Id": "mediastation-go", "Version": "10.8.13",
"OperatingSystem": "Linux", "ProductName": "MediaStationGo",
"ProductName": "MediaStationGo", "OperatingSystem": "Linux",
"Architecture": "X64",
"LocalAddress": "",
"WanAddress": "",
"HasPendingRestart": false,
"IsShuttingDown": false,
"SupportsLibraryMonitor": true,
"SupportsHttps": false,
"SupportsAutoDiscovery": true,
"WebSocketPortNumber": e.cfg.App.Port,
"CompletedInstallations": []any{},
"CanSelfRestart": false,
"CanLaunchWebBrowser": false,
"CanRestart": false,
} }
} }
// SystemInfoPublic 是不需要认证的精简版(Emby Web 客户端登陆前会拉)。
func (e *EmbyService) SystemInfoPublic() map[string]any {
return map[string]any{
"Id": embyServerID,
"ServerName": "MediaStationGo",
"Version": "10.8.13",
"ProductName": "MediaStationGo",
"OperatingSystem": "Linux",
"StartupWizardCompleted": true,
}
}
// ─── Users ───────────────────────────────────────────────────────────────────
// ListUsers returns Emby-shaped users. // ListUsers returns Emby-shaped users.
func (e *EmbyService) ListUsers(ctx context.Context) ([]map[string]any, error) { func (e *EmbyService) ListUsers(ctx context.Context) ([]map[string]any, error) {
users, err := e.repo.User.List(ctx) users, err := e.repo.User.List(ctx)
@@ -64,23 +96,62 @@ func (e *EmbyService) ListUsers(ctx context.Context) ([]map[string]any, error) {
return out, nil return out, nil
} }
// FindUser 用 ID 查用户,用于 /Users/Me 与 /Users/{id}。
func (e *EmbyService) FindUser(ctx context.Context, id string) (map[string]any, error) {
u, err := e.repo.User.FindByID(ctx, id)
if err != nil || u == nil {
return nil, err
}
return e.userPayload(u), nil
}
func (e *EmbyService) userPayload(u *model.User) map[string]any { func (e *EmbyService) userPayload(u *model.User) map[string]any {
return map[string]any{ return map[string]any{
"Id": u.ID, "Id": u.ID,
"Name": u.Username, "Name": u.Username,
"ServerId": "mediastation-go", "ServerId": embyServerID,
"HasPassword": true, "ServerName": "MediaStationGo",
"HasPassword": true,
"HasConfiguredPassword": true,
"HasConfiguredEasyPassword": false, "HasConfiguredEasyPassword": false,
"EnableAutoLogin": false,
"LastLoginDate": u.LastLoginAt,
"LastActivityDate": u.UpdatedAt,
"Configuration": map[string]any{
"PlayDefaultAudioTrack": true,
"DisplayCollectionsView": true,
"DisplayMissingEpisodes": false,
"SubtitleMode": "Default",
"EnableNextEpisodeAutoPlay": true,
"AudioLanguagePreference": "",
"SubtitleLanguagePreference": "",
},
"Policy": map[string]any{ "Policy": map[string]any{
"IsAdministrator": u.Role == "admin", "IsAdministrator": u.Role == "admin",
"IsHidden": false, "IsHidden": false,
"IsDisabled": false, "IsDisabled": !u.IsActive,
"EnableUserPreferenceAccess": true, "EnableUserPreferenceAccess": true,
"EnableRemoteAccess": true,
"EnableMediaPlayback": true,
"EnableAudioPlaybackTranscoding": true,
"EnableVideoPlaybackTranscoding": true,
"EnablePlaybackRemuxing": true,
"EnableLiveTvAccess": false,
"EnableContentDownloading": true,
"EnableSyncTranscoding": true,
"EnableMediaConversion": true,
"EnableAllChannels": true,
"EnableAllFolders": true,
"EnableAllDevices": true,
"AuthenticationProviderId": "Emby.Server.Implementations.LocalAuthenticationProvider",
"PasswordResetProviderId": "Emby.Server.Implementations.LocalPasswordResetProvider",
}, },
} }
} }
// Views (Emby's name for libraries). // ─── Views / MediaFolders ────────────────────────────────────────────────────
// Views 返回 Emby 中"虚拟根目录"——每个 library 一个条目。
func (e *EmbyService) Views(ctx context.Context) (map[string]any, error) { func (e *EmbyService) Views(ctx context.Context) (map[string]any, error) {
libs, err := e.repo.Library.List(ctx) libs, err := e.repo.Library.List(ctx)
if err != nil { if err != nil {
@@ -88,98 +159,454 @@ func (e *EmbyService) Views(ctx context.Context) (map[string]any, error) {
} }
items := make([]map[string]any, 0, len(libs)) items := make([]map[string]any, 0, len(libs))
for _, l := range libs { for _, l := range libs {
collectionType := "movies" items = append(items, e.libraryAsView(&l))
if l.Type == "tv" {
collectionType = "tvshows"
} else if l.Type == "music" {
collectionType = "music"
}
items = append(items, map[string]any{
"Id": l.ID,
"Name": l.Name,
"CollectionType": collectionType,
"ServerId": "mediastation-go",
"Type": "CollectionFolder",
})
} }
return map[string]any{"Items": items, "TotalRecordCount": len(items)}, nil return map[string]any{"Items": items, "TotalRecordCount": len(items)}, nil
} }
// Items paginates media in Emby's flat shape. func (e *EmbyService) libraryAsView(l *model.Library) map[string]any {
func (e *EmbyService) Items(ctx context.Context, libraryID string, limit, offset int) (map[string]any, error) { collectionType := "movies"
if limit <= 0 || limit > 200 { switch l.Type {
limit = 50 case "tv":
collectionType = "tvshows"
case "anime":
collectionType = "tvshows" // Emby 没有专门的 anime CollectionType
case "music":
collectionType = "music"
} }
if offset < 0 { return map[string]any{
offset = 0 "Id": l.ID,
"Name": l.Name,
"CollectionType": collectionType,
"ServerId": embyServerID,
"Type": "CollectionFolder",
"IsFolder": true,
"ImageTags": map[string]string{},
"BackdropImageTags": []string{},
"UserData": map[string]any{
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"UnplayedItemCount": 0,
},
}
}
// ─── Items ───────────────────────────────────────────────────────────────────
// ItemsParams 是 /Items 与 /Users/{uid}/Items 共用的查询参数。
type ItemsParams struct {
UserID string
ParentID string
IDs []string
SearchTerm string
IncludeItemTypes []string
Recursive bool
SortBy string
SortOrder string
Limit int
StartIndex int
}
// Items paginates media in Emby's flat shape.
func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any, error) {
if p.Limit <= 0 || p.Limit > 500 {
p.Limit = 50
}
if p.StartIndex < 0 {
p.StartIndex = 0
} }
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL") q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
if libraryID != "" { if p.ParentID != "" {
q = q.Where("library_id = ?", libraryID) // ParentID 既可能是 library_id 也可能是 series_id(剧集详情下钻)
q = q.Where("library_id = ? OR series_id = ?", p.ParentID, p.ParentID)
} }
if len(p.IDs) > 0 {
q = q.Where("id IN ?", p.IDs)
}
if p.SearchTerm != "" {
q = q.Where("title LIKE ?", "%"+p.SearchTerm+"%")
}
var total int64 var total int64
if err := q.Count(&total).Error; err != nil { if err := q.Count(&total).Error; err != nil {
return nil, err return nil, err
} }
// 排序
order := "created_at desc"
switch strings.ToLower(p.SortBy) {
case "sortname", "name":
order = "title"
case "premieredate", "productionyear":
order = "year"
case "datecreated":
order = "created_at"
case "communityrating":
order = "rating"
}
if strings.EqualFold(p.SortOrder, "Descending") {
if !strings.HasSuffix(order, " desc") {
order = order + " desc"
}
}
var rows []model.Media var rows []model.Media
if err := q.Order("created_at desc").Offset(offset).Limit(limit).Find(&rows).Error; err != nil { if err := q.Order(order).Offset(p.StartIndex).Limit(p.Limit).Find(&rows).Error; err != nil {
return nil, err return nil, err
} }
// User-data: 收藏 + 进度
userFavs := map[string]bool{}
userPos := map[string]int64{}
if p.UserID != "" {
var favs []model.Favorite
_ = e.repo.DB.WithContext(ctx).Where("user_id = ?", p.UserID).Find(&favs).Error
for _, f := range favs {
userFavs[f.MediaID] = true
}
var hist []model.PlaybackHistory
_ = e.repo.DB.WithContext(ctx).Where("user_id = ?", p.UserID).Find(&hist).Error
for _, h := range hist {
userPos[h.MediaID] = h.PositionMs
}
}
items := make([]map[string]any, 0, len(rows)) items := make([]map[string]any, 0, len(rows))
for _, m := range rows { for _, m := range rows {
items = append(items, e.itemPayload(&m)) items = append(items, e.itemPayload(&m, userFavs[m.ID], userPos[m.ID]))
} }
return map[string]any{ return map[string]any{
"Items": items, "Items": items,
"TotalRecordCount": total, "TotalRecordCount": total,
"StartIndex": offset, "StartIndex": p.StartIndex,
}, nil }, nil
} }
func (e *EmbyService) itemPayload(m *model.Media) map[string]any { // Item 单条目详情。
func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[string]any, error) {
m, err := e.repo.Media.FindByID(ctx, mediaID)
if err != nil {
return nil, err
}
if m == nil {
return nil, nil
}
fav := false
pos := int64(0)
if userID != "" {
var f model.Favorite
ferr := e.repo.DB.WithContext(ctx).Where("user_id = ? AND media_id = ?", userID, mediaID).First(&f).Error
if ferr == nil {
fav = true
}
var h model.PlaybackHistory
herr := e.repo.DB.WithContext(ctx).Where("user_id = ? AND media_id = ?", userID, mediaID).
Order("watched_at desc").First(&h).Error
if herr == nil {
pos = h.PositionMs
}
}
return e.itemPayload(m, fav, pos), nil
}
// LatestItems 最近添加,全库或指定库。
func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string, limit int) ([]map[string]any, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
if parentID != "" {
q = q.Where("library_id = ?", parentID)
}
var rows []model.Media
if err := q.Order("created_at desc").Limit(limit).Find(&rows).Error; err != nil {
return nil, err
}
favs := map[string]bool{}
if userID != "" {
var fr []model.Favorite
_ = e.repo.DB.WithContext(ctx).Where("user_id = ?", userID).Find(&fr).Error
for _, f := range fr {
favs[f.MediaID] = true
}
}
out := make([]map[string]any, 0, len(rows))
for _, m := range rows {
out = append(out, e.itemPayload(&m, favs[m.ID], 0))
}
return out, nil
}
// ResumeItems 列出有未完成播放进度的媒体。
func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int) (map[string]any, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
type row struct {
MediaID string
PositionMs int64
DurationMs int64
}
var hist []model.PlaybackHistory
if err := e.repo.DB.WithContext(ctx).
Where("user_id = ? AND completed = ? AND position_ms > 0", userID, false).
Order("watched_at desc").Limit(limit).Find(&hist).Error; err != nil {
return nil, err
}
if len(hist) == 0 {
return map[string]any{"Items": []any{}, "TotalRecordCount": 0}, nil
}
ids := make([]string, 0, len(hist))
posByID := map[string]int64{}
for _, h := range hist {
ids = append(ids, h.MediaID)
posByID[h.MediaID] = h.PositionMs
}
var medias []model.Media
if err := e.repo.DB.WithContext(ctx).Where("id IN ?", ids).Find(&medias).Error; err != nil {
return nil, err
}
// 维持时间倒序
byID := map[string]*model.Media{}
for i := range medias {
byID[medias[i].ID] = &medias[i]
}
items := make([]map[string]any, 0, len(hist))
for _, h := range hist {
if m, ok := byID[h.MediaID]; ok {
items = append(items, e.itemPayload(m, false, posByID[h.MediaID]))
}
}
return map[string]any{"Items": items, "TotalRecordCount": len(items)}, nil
}
func (e *EmbyService) itemPayload(m *model.Media, fav bool, posMs int64) map[string]any {
itemType := "Movie" itemType := "Movie"
if m.SeasonNum > 0 || m.EpisodeNum > 0 { if m.SeasonNum > 0 || m.EpisodeNum > 0 {
itemType = "Episode" itemType = "Episode"
} }
imageTags := map[string]string{}
backdropTags := []string{}
if m.PosterURL != "" {
imageTags["Primary"] = m.ID
}
if m.BackdropURL != "" {
backdropTags = append(backdropTags, m.ID+"-bd")
}
runTimeTicks := int64(m.DurationSec) * 10_000_000
durationMs := int64(m.DurationSec) * 1000
played := posMs > 0 && durationMs > 0 && posMs >= durationMs*9/10
pct := 0.0
if durationMs > 0 {
pct = float64(posMs) / float64(durationMs) * 100
}
return map[string]any{ return map[string]any{
"Id": m.ID, "Id": m.ID,
"Name": m.Title, "Name": m.Title,
"ServerId": "mediastation-go", "OriginalTitle": m.OriginalName,
"ServerId": embyServerID,
"Type": itemType, "Type": itemType,
"MediaType": "Video",
"IsFolder": false,
"ProductionYear": m.Year, "ProductionYear": m.Year,
"ParentIndexNumber": m.SeasonNum, "ParentIndexNumber": m.SeasonNum,
"IndexNumber": m.EpisodeNum, "IndexNumber": m.EpisodeNum,
"Overview": m.Overview, "Overview": m.Overview,
"RunTimeTicks": int64(m.DurationSec) * 10_000_000, "RunTimeTicks": runTimeTicks,
"CommunityRating": m.Rating, "CommunityRating": m.Rating,
"MediaSources": []map[string]any{{ "Container": m.Container,
"Id": m.ID, "Width": m.Width,
"Path": m.Path, "Height": m.Height,
"Container": m.Container, "DateCreated": m.CreatedAt,
"Size": m.SizeBytes, "Path": m.Path,
}}, "ParentId": m.LibraryID,
"SeriesId": m.SeriesID,
"ImageTags": imageTags,
"BackdropImageTags": backdropTags,
"Genres": splitCSV(m.Genres),
"ProviderIds": map[string]string{
"Tmdb": intToStr(m.TMDbID),
"Bangumi": intToStr(m.BangumiID),
},
"UserData": map[string]any{
"PlaybackPositionTicks": posMs * 10_000,
"PlayCount": 0,
"IsFavorite": fav,
"Played": played,
"PlayedPercentage": pct,
},
"MediaSources": []map[string]any{e.mediaSource(m, true)},
} }
} }
// PlaybackInfo returns the stream URL (caller must append ?token=). // ─── Playback ────────────────────────────────────────────────────────────────
// PlaybackInfo returns a PlaybackInfoResponse usable by Emby clients.
func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID string) (map[string]any, error) { func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID string) (map[string]any, error) {
m, err := e.repo.Media.FindByID(ctx, mediaID) m, err := e.repo.Media.FindByID(ctx, mediaID)
if err != nil || m == nil { if err != nil || m == nil {
return nil, err return nil, err
} }
url := "/api/stream/" + m.ID
if m.STRMURL != "" {
url = m.STRMURL
}
return map[string]any{ return map[string]any{
"MediaSources": []map[string]any{{ "MediaSources": []map[string]any{e.mediaSource(m, false)},
"Id": m.ID, "PlaySessionId": fmt.Sprintf("%s-%d", m.ID, time.Now().Unix()),
"Path": url,
"Protocol": "Http",
"DirectStreamUrl": url,
"Container": m.Container,
"Size": m.SizeBytes,
}},
"PlaySessionId": m.ID,
}, nil }, nil
} }
// mediaSource 是 /Items 与 /PlaybackInfo 共享的 MediaSource 结构。
//
// asEmbedded=true:嵌在 /Items 列表里,不包含完整 stream URL(避免暴露
// 直链给搜索接口)。/PlaybackInfo 走 false 路径,URL 完整指向
// /api/stream/{id}(Emby 客户端会自动 append ?api_key=token)。
func (e *EmbyService) mediaSource(m *model.Media, asEmbedded bool) map[string]any {
src := map[string]any{
"Id": m.ID,
"Name": m.Title,
"Path": m.Path,
"Container": m.Container,
"Size": m.SizeBytes,
"Protocol": "Http",
"Type": "Default",
"IsRemote": false,
"SupportsTranscoding": true,
"SupportsDirectStream": true,
"SupportsDirectPlay": true,
"SupportsProbing": true,
"RunTimeTicks": int64(m.DurationSec) * 10_000_000,
"MediaStreams": e.mediaStreams(m),
}
if !asEmbedded {
// 完整 URL,让 Infuse 直接 GET。Emby 客户端会自动加 ?api_key=token。
src["DirectStreamUrl"] = "/api/stream/" + m.ID
}
if strings.TrimSpace(m.STRMURL) != "" {
// STRM 重定向:客户端直接拉远端,跳过我们这一层。
src["IsRemote"] = true
src["DirectStreamUrl"] = m.STRMURL
src["Path"] = m.STRMURL
}
return src
}
func (e *EmbyService) mediaStreams(m *model.Media) []map[string]any {
streams := []map[string]any{}
if m.VideoCodec != "" || m.Width > 0 {
streams = append(streams, map[string]any{
"Codec": m.VideoCodec,
"Type": "Video",
"Index": 0,
"Width": m.Width,
"Height": m.Height,
"AspectRatio": "",
"IsDefault": true,
"IsForced": false,
"IsExternal": false,
"DisplayTitle": fmt.Sprintf("%dx%d %s", m.Width, m.Height, m.VideoCodec),
})
}
if m.AudioCodec != "" {
streams = append(streams, map[string]any{
"Codec": m.AudioCodec,
"Type": "Audio",
"Index": 1,
"IsDefault": true,
"IsForced": false,
"IsExternal": false,
})
}
return streams
}
// ─── 收藏 / 已看(Emby 客户端写路径) ──────────────────────────────────────
// SetFavorite 把 mediaID 标为 userID 的收藏。
func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, favorite bool) error {
if favorite {
var f model.Favorite
err := e.repo.DB.WithContext(ctx).
Where("user_id = ? AND media_id = ?", userID, mediaID).First(&f).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return e.repo.DB.WithContext(ctx).Create(&model.Favorite{
UserID: userID, MediaID: mediaID,
}).Error
}
return err
}
return e.repo.DB.WithContext(ctx).
Where("user_id = ? AND media_id = ?", userID, mediaID).
Delete(&model.Favorite{}).Error
}
// MarkPlayed 把 mediaID 标为已看(写一个 100% 进度的 history 行)。
func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, played bool) error {
if !played {
return e.repo.DB.WithContext(ctx).
Where("user_id = ? AND media_id = ?", userID, mediaID).
Delete(&model.PlaybackHistory{}).Error
}
m, err := e.repo.Media.FindByID(ctx, mediaID)
if err != nil || m == nil {
return errors.New("media not found")
}
dur := int64(m.DurationSec) * 1000
if dur <= 0 {
dur = 1
}
return e.repo.History.Upsert(ctx, &model.PlaybackHistory{
UserID: userID,
MediaID: mediaID,
PositionMs: dur,
DurationMs: dur,
WatchedAt: time.Now(),
Completed: true,
})
}
// RecordProgress 记录播放进度(来自 Emby 客户端的 /Sessions/Playing/Progress)。
func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string, positionTicks, runtimeTicks int64) error {
pos := positionTicks / 10_000
dur := runtimeTicks / 10_000
if dur <= 0 {
// runtimeTicks 缺失时回退到 media.DurationSec
if m, _ := e.repo.Media.FindByID(ctx, mediaID); m != nil {
dur = int64(m.DurationSec) * 1000
}
}
completed := dur > 0 && pos >= dur*9/10
return e.repo.History.Upsert(ctx, &model.PlaybackHistory{
UserID: userID,
MediaID: mediaID,
PositionMs: pos,
DurationMs: dur,
WatchedAt: time.Now(),
Completed: completed,
})
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
func splitCSV(s string) []string {
if strings.TrimSpace(s) == "" {
return []string{}
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func intToStr(v int) string {
if v == 0 {
return ""
}
return strconv.Itoa(v)
}
+171 -92
View File
@@ -4,13 +4,16 @@
// networks). ImageProxy fronts a remote image URL so the browser only ever // networks). ImageProxy fronts a remote image URL so the browser only ever
// talks to the MediaStationGo origin. The proxy: // talks to the MediaStationGo origin. The proxy:
// //
// - validates the URL belongs to a small allow-list of trusted hosts, // - validates the URL scheme is http/https,
// - streams bytes through with a small disk cache under cache/images, // - streams bytes through with a small disk cache under cache/images,
// - falls back to a transparent 1×1 PNG on upstream failure so the UI // - falls back to a transparent 1×1 PNG on upstream failure so the UI
// never breaks layout. // never breaks layout,
// - honors HTTP(S)_PROXY environment variables so users behind GFW can
// route image fetches through their proxy.
package service package service
import ( import (
"bytes"
"context" "context"
"crypto/sha1" "crypto/sha1"
"encoding/hex" "encoding/hex"
@@ -29,52 +32,113 @@ import (
"github.com/ShukeBta/MediaStationGo/internal/config" "github.com/ShukeBta/MediaStationGo/internal/config"
) )
// transparent1x1PNG is a baseline 67-byte PNG used as a fallback when the
// upstream image cannot be retrieved, so browser layouts never collapse.
var transparent1x1PNG = []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41,
0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00,
0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
0x42, 0x60, 0x82,
}
// knownImageHosts are hosts we explicitly recognize. The list is no longer
// a hard allow-list — it only short-circuits cases where we can be 100%
// sure the destination is a public image CDN. Other hosts are accepted as
// long as the scheme is http/https; this is required so users behind GFW
// can configure their own TMDb mirror via secrets.tmdb_image_proxy.
var knownImageHosts = map[string]struct{}{
"image.tmdb.org": {},
"www.themoviedb.org": {},
"lain.bgm.tv": {},
"img.bgm.tv": {},
"webdav.bgm.tv": {},
"img1.doubanio.com": {},
"img2.doubanio.com": {},
"img3.doubanio.com": {},
"img9.doubanio.com": {},
"assets.fanart.tv": {},
"artworks.thetvdb.com": {},
}
// ImageProxy fetches and caches remote images on behalf of the browser. // ImageProxy fetches and caches remote images on behalf of the browser.
type ImageProxy struct { type ImageProxy struct {
cfg *config.Config cfg *config.Config
log *zap.Logger log *zap.Logger
client *http.Client client *http.Client
cacheDir string cacheDir string
allowHost map[string]struct{} mu sync.Mutex
mu sync.Mutex
} }
// NewImageProxy is the constructor. // NewImageProxy is the constructor.
func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy { func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
// Honor HTTP(S)_PROXY env vars so deployments behind GFW can pull
// from image.tmdb.org via their HTTP proxy without extra config.
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
MaxIdleConns: 32,
MaxIdleConnsPerHost: 8,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 15 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
return &ImageProxy{ return &ImageProxy{
cfg: cfg, cfg: cfg,
log: log, log: log,
cacheDir: filepath.Join(cfg.Cache.CacheDir, "images"), cacheDir: filepath.Join(cfg.Cache.CacheDir, "images"),
client: &http.Client{Timeout: 20 * time.Second}, client: &http.Client{Timeout: 30 * time.Second, Transport: transport},
allowHost: map[string]struct{}{
"image.tmdb.org": {},
"www.themoviedb.org": {},
"lain.bgm.tv": {},
"img.bgm.tv": {},
"webdav.bgm.tv": {},
"img1.doubanio.com": {},
"img2.doubanio.com": {},
"img3.doubanio.com": {},
"img9.doubanio.com": {},
"assets.fanart.tv": {},
"artworks.thetvdb.com": {},
},
} }
} }
// validateURL parses raw and ensures the scheme is http/https. The host
// allow-list is now advisory — every reachable URL is accepted so users
// can freely configure mirror domains via tmdb_image_proxy.
func (p *ImageProxy) validateURL(raw string) (*url.URL, error) {
if raw == "" {
return nil, errors.New("missing url")
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return nil, errors.New("invalid url")
}
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" {
return nil, errors.New("unsupported scheme")
}
return u, nil
}
// detectContentType returns the MIME type of data using the first 512 bytes.
func detectContentType(data []byte) string {
if len(data) > 512 {
return http.DetectContentType(data[:512])
}
return http.DetectContentType(data)
}
// servePlaceholder writes a 1×1 transparent PNG to w. Used as a fallback
// when upstream fetch fails so the browser layout stays intact.
func servePlaceholder(w http.ResponseWriter) {
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(transparent1x1PNG)
}
// Serve writes the requested image to w. Caller is expected to validate // Serve writes the requested image to w. Caller is expected to validate
// the JWT before invoking it. // the JWT before invoking it.
func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, raw string) error { func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.Request, raw string) error {
if raw == "" { u, err := p.validateURL(raw)
return errors.New("missing url") if err != nil {
} // Bad URL is the only request-side error; everything else falls
u, err := url.Parse(raw) // through to the placeholder so the UI stays clean.
if err != nil || u.Scheme == "" || u.Host == "" { return err
return errors.New("invalid url")
}
if _, ok := p.allowHost[strings.ToLower(u.Host)]; !ok {
return errors.New("host not allowed")
} }
host := strings.ToLower(u.Host)
// Cache key = sha1(url) // Cache key = sha1(url)
sum := sha1.Sum([]byte(raw)) sum := sha1.Sum([]byte(raw))
@@ -82,77 +146,96 @@ func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, raw strin
cachePath := filepath.Join(p.cacheDir, key) cachePath := filepath.Join(p.cacheDir, key)
// Cache hit. // Cache hit.
if f, err := os.Open(cachePath); err == nil { if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 {
defer f.Close() w.Header().Set("Content-Type", detectContentType(data))
stat, _ := f.Stat()
w.Header().Set("Cache-Control", "public, max-age=604800") w.Header().Set("Cache-Control", "public, max-age=604800")
http.ServeContent(w, &http.Request{}, key, stat.ModTime(), f) stat, _ := os.Stat(cachePath)
modTime := time.Now()
if stat != nil {
modTime = stat.ModTime()
}
http.ServeContent(w, r, key, modTime, bytes.NewReader(data))
return nil return nil
} }
// Cache miss → fetch upstream. // Cache miss → fetch upstream.
if err := os.MkdirAll(p.cacheDir, 0o755); err != nil { if err := os.MkdirAll(p.cacheDir, 0o755); err != nil {
return err p.log.Warn("imageproxy: mkdir failed", zap.String("dir", p.cacheDir), zap.Error(err))
servePlaceholder(w)
return nil
} }
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil { if err != nil {
return err p.log.Warn("imageproxy: build request failed", zap.String("url", raw), zap.Error(err))
servePlaceholder(w)
return nil
} }
req.Header.Set("User-Agent", "MediaStationGo/0.1") req.Header.Set("User-Agent", "MediaStationGo/0.1")
resp, err := p.client.Do(req) resp, err := p.client.Do(req)
if err != nil { if err != nil {
return err p.log.Warn("imageproxy: upstream fetch failed",
zap.String("host", host), zap.Error(err))
servePlaceholder(w)
return nil
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
return errors.New("upstream returned " + resp.Status) p.log.Warn("imageproxy: upstream returned non-OK",
zap.String("host", host), zap.String("status", resp.Status))
servePlaceholder(w)
return nil
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) // 32 MiB cap
if err != nil || len(data) == 0 {
p.log.Warn("imageproxy: read upstream body failed",
zap.String("host", host), zap.Error(err))
servePlaceholder(w)
return nil
} }
// Write to a temp file then rename for atomicity. // Write to a temp file then rename for atomicity.
tmp, err := os.CreateTemp(p.cacheDir, "img-*.tmp") p.mu.Lock()
if err != nil { tmp, tmpErr := os.CreateTemp(p.cacheDir, "img-*.tmp")
return err if tmpErr == nil {
} if _, werr := tmp.Write(data); werr == nil {
if _, err := io.Copy(tmp, resp.Body); err != nil { tmp.Close()
tmp.Close() if rerr := os.Rename(tmp.Name(), cachePath); rerr != nil {
os.Remove(tmp.Name()) _ = os.Remove(tmp.Name())
return err }
} } else {
tmp.Close() tmp.Close()
if err := os.Rename(tmp.Name(), cachePath); err != nil { _ = os.Remove(tmp.Name())
os.Remove(tmp.Name())
}
// Now serve the freshly cached file.
f, err := os.Open(cachePath)
if err != nil {
return err
}
defer f.Close()
stat, _ := f.Stat()
for _, h := range []string{"Content-Type", "Content-Length", "ETag", "Last-Modified"} {
if v := resp.Header.Get(h); v != "" {
w.Header().Set(h, v)
} }
} }
p.mu.Unlock()
ctype := resp.Header.Get("Content-Type")
if ctype == "" {
ctype = detectContentType(data)
}
w.Header().Set("Content-Type", ctype)
if v := resp.Header.Get("Content-Length"); v != "" {
w.Header().Set("Content-Length", v)
}
if v := resp.Header.Get("ETag"); v != "" {
w.Header().Set("ETag", v)
}
if v := resp.Header.Get("Last-Modified"); v != "" {
w.Header().Set("Last-Modified", v)
}
w.Header().Set("Cache-Control", "public, max-age=604800") w.Header().Set("Cache-Control", "public, max-age=604800")
http.ServeContent(w, &http.Request{}, key, stat.ModTime(), f) http.ServeContent(w, r, key, time.Now(), bytes.NewReader(data))
return nil return nil
} }
// Fetch 拉取远程图片并返回字节和 Content-Type(带缓存)。 // Fetch 拉取远程图片并返回字节和 Content-Type(带缓存)。
func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, error) { func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, error) {
if raw == "" { u, err := p.validateURL(raw)
return nil, "", errors.New("missing url") if err != nil {
} return nil, "", err
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return nil, "", errors.New("invalid url")
}
if _, ok := p.allowHost[strings.ToLower(u.Host)]; !ok {
return nil, "", errors.New("host not allowed")
} }
// Cache lookup // Cache lookup
@@ -160,10 +243,8 @@ func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, err
key := hex.EncodeToString(sum[:]) key := hex.EncodeToString(sum[:])
cachePath := filepath.Join(p.cacheDir, key) cachePath := filepath.Join(p.cacheDir, key)
if data, err := os.ReadFile(cachePath); err == nil { if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 {
// Content-Type from file extension or upstream headers — use a simple detect return data, detectContentType(data), nil
ctype := detectContentType(data)
return data, ctype, nil
} }
// Fetch upstream // Fetch upstream
@@ -186,34 +267,32 @@ func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, err
return nil, "", errors.New("upstream returned " + resp.Status) return nil, "", errors.New("upstream returned " + resp.Status)
} }
data, err := io.ReadAll(resp.Body) data, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
// Write to cache // Write to cache
tmp, err := os.CreateTemp(p.cacheDir, "img-*.tmp") p.mu.Lock()
if err == nil { tmp, terr := os.CreateTemp(p.cacheDir, "img-*.tmp")
if _, err := tmp.Write(data); err == nil { if terr == nil {
if _, werr := tmp.Write(data); werr == nil {
tmp.Close() tmp.Close()
os.Rename(tmp.Name(), cachePath) if rerr := os.Rename(tmp.Name(), cachePath); rerr != nil {
_ = os.Remove(tmp.Name())
}
} else { } else {
tmp.Close() tmp.Close()
os.Remove(tmp.Name()) _ = os.Remove(tmp.Name())
} }
} }
p.mu.Unlock()
ctype := resp.Header.Get("Content-Type") ctype := resp.Header.Get("Content-Type")
if ctype == "" { if ctype == "" {
ctype = detectContentType(data) ctype = detectContentType(data)
} }
// host is unused here but referenced for log clarity in the future.
_ = u
return data, ctype, nil return data, ctype, nil
} }
// detectContentType 通过前 512 字节检测 MIME 类型。
func detectContentType(data []byte) string {
if len(data) > 512 {
return http.DetectContentType(data[:512])
}
return http.DetectContentType(data)
}
+28 -1
View File
@@ -96,6 +96,17 @@ func (o *OrganizerService) OrganizeMedia(ctx context.Context, mediaID string) (s
return dst, nil return dst, nil
} }
// Refuse to overwrite an existing different file. 当多个 release(如
// 不同字幕组、不同源)刮削后被统一改名,原本不重复的文件会被映射到
// 同一个目标路径,盲目 move 会导致后者覆盖前者,造成数据丢失。
if _, err := os.Stat(dst); err == nil {
o.log.Warn("organize skipped: destination already exists",
zap.String("media", m.ID),
zap.String("from", m.Path),
zap.String("to", dst))
return dst, nil
}
// Create directories. // Create directories.
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return "", err return "", err
@@ -149,7 +160,13 @@ func (o *OrganizerService) OrganizeLibrary(ctx context.Context, libraryID string
// moveFile tries os.Rename first (instant on same fs), then falls back // moveFile tries os.Rename first (instant on same fs), then falls back
// to copy + remove for cross-device moves. // to copy + remove for cross-device moves.
//
// 重要:如果 dst 已经存在,moveFile 会直接报错而不是覆盖。OrganizeMedia
// 已经在调用前做过 stat 检查,这里是第二道防线。
func moveFile(src, dst string) error { func moveFile(src, dst string) error {
if _, err := os.Stat(dst); err == nil {
return fmt.Errorf("destination already exists: %s", dst)
}
if err := os.Rename(src, dst); err == nil { if err := os.Rename(src, dst); err == nil {
return nil return nil
} }
@@ -158,9 +175,19 @@ func moveFile(src, dst string) error {
if err != nil { if err != nil {
return err return err
} }
if err := os.WriteFile(dst, data, 0o644); err != nil { // O_EXCL 保证不会覆盖已存在的目标。
f, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
return err return err
} }
if _, werr := f.Write(data); werr != nil {
f.Close()
os.Remove(dst)
return werr
}
if cerr := f.Close(); cerr != nil {
return cerr
}
return os.Remove(src) return os.Remove(src)
} }
+16 -2
View File
@@ -114,6 +114,10 @@ func (q *QBitClient) Login(ctx context.Context) error {
} }
// AddTorrent submits a magnet URL or HTTP(S) URL to qBittorrent. // AddTorrent submits a magnet URL or HTTP(S) URL to qBittorrent.
//
// qBittorrent 的 /api/v2/torrents/add 在很多失败场景下仍然返回 HTTP 200
// 但 body 里写 "Fails."。我们把这些情况也识别为错误并返回,避免
// "API 返回 200 → 我们告诉前端成功 → qb 中却没下载" 这种迷惑性失败。
func (q *QBitClient) AddTorrent(ctx context.Context, magnetOrURL, savePath string) error { func (q *QBitClient) AddTorrent(ctx context.Context, magnetOrURL, savePath string) error {
q.mu.Lock() q.mu.Lock()
defer q.mu.Unlock() defer q.mu.Unlock()
@@ -137,16 +141,26 @@ func (q *QBitClient) AddTorrent(ctx context.Context, magnetOrURL, savePath strin
} }
req.Header.Set("Content-Type", w.FormDataContentType()) req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Referer", q.cfg.BaseURL) req.Header.Set("Referer", q.cfg.BaseURL)
req.Header.Set("Origin", q.cfg.BaseURL)
resp, err := q.client.Do(req) resp, err := q.client.Do(req)
if err != nil { if err != nil {
return err return err
} }
defer resp.Body.Close() defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
bodyText := strings.TrimSpace(string(raw))
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
raw, _ := io.ReadAll(resp.Body) return fmt.Errorf("qbittorrent add: HTTP %d: %s", resp.StatusCode, bodyText)
return fmt.Errorf("qbittorrent add: %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
} }
// qb 的成功响应是 "Ok." 或空体;任何 "Fails." 视为失败。
if strings.EqualFold(bodyText, "Fails.") {
return fmt.Errorf("qbittorrent add: 拒绝任务 (检查 URL 是否需要认证或 savePath 是否可写)")
}
q.log.Info("qbittorrent: torrent added",
zap.String("url", magnetOrURL),
zap.String("save_path", savePath),
zap.String("body", bodyText))
return nil return nil
} }
+59 -7
View File
@@ -60,12 +60,26 @@ var yearPattern = regexp.MustCompile(`(?:^|[^\d])(19\d{2}|20\d{2})(?:[^\d]|$)`)
// noiseTokens are stripped before search. // noiseTokens are stripped before search.
var noiseTokens = []string{ var noiseTokens = []string{
// 视频规格
"1080p", "2160p", "4k", "720p", "480p", "1080p", "2160p", "4k", "720p", "480p",
"hdrip", "bluray", "blu-ray", "webrip", "web-dl", "web", "hdrip", "bluray", "blu-ray", "webrip", "web-dl", "web",
"x264", "x265", "h264", "h265", "hevc", "avc", "x264", "x265", "h264", "h265", "hevc", "avc",
"hdr", "sdr", "dts", "ddp", "atmos", "aac", "ac3", "flac", "hdr", "sdr", "dts", "ddp", "atmos", "aac", "ac3", "flac",
"remux", "extended", "uncut", "directors-cut", "directors_cut", "remux", "extended", "uncut", "directors-cut", "directors_cut",
"hkfree", "yify", "rarbg", "ettv", "fgt", "hkfree", "yify", "rarbg", "ettv", "fgt",
// 流媒体平台 / 字幕组 / 国家版本(动漫常见)
"netflix", "nf", "amzn", "hulu", "disney", "max", "hbo",
"linetv", "ourtv", "iqiyi", "youku", "bilibili", "qiyi", "krj",
"crunchyroll", "funimation", "anidb", "horriblesubs", "subsplease",
"erai-raws", "judas", "asw", "smcat", "leopard-raws", "ohys-raws",
// 中文字幕标记
"zm", "zw", "ch", "chs", "cht", "cn", "tc", "sc",
"中字", "繁字", "简中", "繁中", "国语", "粤语", "日语",
// 季数前缀残留 — ParseEpisode 已抽取过
"season",
} }
// bracketedTag matches "[anything]" or "(anything)" segments. // bracketedTag matches "[anything]" or "(anything)" segments.
@@ -94,12 +108,23 @@ func CleanQuery(raw string) (title string, year int) {
for _, t := range noiseTokens { for _, t := range noiseTokens {
lower = strings.ReplaceAll(lower, t, " ") lower = strings.ReplaceAll(lower, t, " ")
} }
for _, sep := range []string{".", "_", "-", "[", "]", "(", ")"} { for _, sep := range []string{".", "_", "-", "[", "]", "(", ")", "×", "x"} {
lower = strings.ReplaceAll(lower, sep, " ") lower = strings.ReplaceAll(lower, sep, " ")
} }
fields := strings.Fields(lower) // 拆分后丢掉过短(≤1)且全为 ASCII 数字 / 字母的"碎片",避免
title = strings.Join(fields, " ") // 「2」「0」「v」之类残留干扰 TMDb 搜索。中文字符不算碎片。
return strings.TrimSpace(title), year out := make([]string, 0, 8)
for _, w := range strings.Fields(lower) {
if len(w) <= 1 {
r := []rune(w)
if len(r) == 1 && r[0] < 128 {
continue
}
}
out = append(out, w)
}
title = strings.TrimSpace(strings.Join(out, " "))
return title, year
} }
// EnrichOne runs the provider chain for a single media row. // EnrichOne runs the provider chain for a single media row.
@@ -126,7 +151,6 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
Update("scrape_status", "no_match").Error Update("scrape_status", "no_match").Error
return nil return nil
} }
// Optional Fanart upgrade. // Optional Fanart upgrade.
if s.fanart != nil && s.fanart.Enabled() && match.TMDbID > 0 { if s.fanart != nil && s.fanart.Enabled() && match.TMDbID > 0 {
if a, err := s.fanart.MovieArtwork(ctx, match.TMDbID); err == nil && a != nil { if a, err := s.fanart.MovieArtwork(ctx, match.TMDbID); err == nil && a != nil {
@@ -197,6 +221,16 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
// lookup runs the provider chain. When the library is missing we fall // lookup runs the provider chain. When the library is missing we fall
// back to TMDb only. // back to TMDb only.
//
// 库类型决定首选 provider:
//
// anime -> Bangumi -> TMDb /search/tv -> TMDb /search/movie
// tv -> TheTVDB -> TMDb /search/tv -> TMDb /search/movie
// movie -> TMDb /search/movie
// (空) -> TMDb /search/movie
//
// 任何 provider 错误都不会中止链式查询;只要返回 nil/err,就继续走下一个
// provider。这避免了 Bangumi token 未配置时 anime 库整体失败的问题。
func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, query string, year int) *Match { func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, query string, year int) *Match {
kind := "" kind := ""
if lib != nil { if lib != nil {
@@ -204,21 +238,35 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, query s
} }
switch kind { switch kind {
case "anime": case "anime":
if s.bangumi != nil { if s.bangumi != nil && s.bangumi.Enabled() {
if m, err := s.bangumi.Search(ctx, query); err == nil && m != nil { if m, err := s.bangumi.Search(ctx, query); err == nil && m != nil {
return m return m
} else if err != nil {
s.log.Debug("bangumi search failed", zap.String("query", query), zap.Error(err))
} }
} }
case "tv": case "tv":
if s.thetvdb != nil && s.thetvdb.Enabled() { if s.thetvdb != nil && s.thetvdb.Enabled() {
if m, err := s.thetvdb.SearchSeries(ctx, query); err == nil && m != nil { if m, err := s.thetvdb.SearchSeries(ctx, query); err == nil && m != nil {
return m return m
} else if err != nil {
s.log.Debug("thetvdb search failed", zap.String("query", query), zap.Error(err))
} }
} }
} }
if s.tmdb != nil && s.tmdb.Enabled() { if s.tmdb != nil && s.tmdb.Enabled() {
// anime / tv 先用 TMDb /search/tv(剧名通常是 TV 类目)。
if kind == "anime" || kind == "tv" {
if m, err := s.tmdb.SearchTV(ctx, query, year); err == nil && m != nil {
return m
} else if err != nil {
s.log.Debug("tmdb tv search failed", zap.String("query", query), zap.Error(err))
}
}
if m, err := s.tmdb.SearchMovie(ctx, query, year); err == nil && m != nil { if m, err := s.tmdb.SearchMovie(ctx, query, year); err == nil && m != nil {
return m return m
} else if err != nil {
s.log.Debug("tmdb movie search failed", zap.String("query", query), zap.Error(err))
} }
} }
return nil return nil
@@ -226,9 +274,13 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, query s
// EnrichLibrary runs the provider chain for every "pending" media in a // EnrichLibrary runs the provider chain for every "pending" media in a
// library. It throttles to 4 RPS and publishes a summary event when done. // library. It throttles to 4 RPS and publishes a summary event when done.
//
// Pending status includes both the canonical "pending" string and the
// empty / NULL values, because MediaRepository.Upsert can wipe the GORM
// default when re-running a scan over an already-existing row.
func (s *ScraperService) EnrichLibrary(ctx context.Context, libraryID string) (int, error) { func (s *ScraperService) EnrichLibrary(ctx context.Context, libraryID string) (int, error) {
var rows []model.Media var rows []model.Media
q := s.repo.DB.Where("scrape_status = ?", "pending") q := s.repo.DB.Where("scrape_status IS NULL OR scrape_status = '' OR scrape_status = ?", "pending")
if libraryID != "" { if libraryID != "" {
q = q.Where("library_id = ?", libraryID) q = q.Where("library_id = ?", libraryID)
} }
+53 -8
View File
@@ -81,8 +81,16 @@ func (s *SiteService) Delete(ctx context.Context, id string) error {
// TestConnection tries to reach the site's base URL with the configured // TestConnection tries to reach the site's base URL with the configured
// credentials and reports success/failure. // credentials and reports success/failure.
// Now uses helper.TestSiteConnectivity with browser-like headers //
// and optional FlareSolverr support. // 测试逻辑(与参考项目 ShukeBta/MediaStation 对齐):
//
// 1. 优先调用对应站点适配器的 Authenticate(),让 PT 站点(M-Team / UNIT3D /
// Gazelle 等)使用各自的开放 API 验证,而不是去拉首页 HTML——后者通常
// 被 Cloudflare 直接 403 但 API 能正常访问。
// 2. 适配器不可用或站点类型未知时,回退到 helper.TestSiteConnectivity 的
// 通用浏览器头 GET 方案。
// 3. helper.TestSiteConnectivity 在全局 FlareSolverr 启用且站点开启了
// BrowserEmulation 时,会自动走 FlareSolverr。
func (s *SiteService) TestConnection(ctx context.Context, id string) (bool, string, error) { func (s *SiteService) TestConnection(ctx context.Context, id string) (bool, string, error) {
site, err := s.FindByID(ctx, id) site, err := s.FindByID(ctx, id)
if err != nil || site == nil { if err != nil || site == nil {
@@ -94,25 +102,58 @@ func (s *SiteService) TestConnection(ctx context.Context, id string) (bool, stri
if timeout <= 0 { if timeout <= 0 {
timeout = 15 timeout = 15
} }
// Use configured FlareSolverr URL (may be empty if not configured)
flareSolverrURL := s.flareSolverrURL flareSolverrURL := s.flareSolverrURL
// ── Path 1: site-aware adapter Authenticate ────────────────────────
// custom_rss 没有真适配器,跳过;其它类型先尝试针对性认证端点。
if adapter := NewSiteAdapter(site); adapter != nil && site.Type != "" && site.Type != "custom_rss" {
cfg := s.siteModelToConfig(site)
actx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
defer cancel()
if authErr := adapter.Authenticate(actx, cfg); authErr == nil {
now := time.Now()
_ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id).
Updates(map[string]any{
"login_status": "ok",
"last_error": "",
"last_check_at": &now,
}).Error
return true, "连接成功", nil
} else {
s.log.Warn("site adapter authenticate failed, falling back to generic test",
zap.String("site", site.Name),
zap.String("type", site.Type),
zap.Error(authErr))
// 回退到通用 GET 测试 — 给 Cookie/RSS 类站点一个机会
}
}
// ── Path 2: generic GET with browser headers / FlareSolverr ───────
ok, msg, err := helper.TestSiteConnectivity(site, flareSolverrURL, timeout, s.log) ok, msg, err := helper.TestSiteConnectivity(site, flareSolverrURL, timeout, s.log)
if err != nil { if err != nil {
now := time.Now() now := time.Now()
_ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id). _ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id).
Updates(map[string]any{"last_error": err.Error(), "last_check_at": &now}).Error Updates(map[string]any{
"login_status": "fail",
"last_error": err.Error(),
"last_check_at": &now,
}).Error
return false, err.Error(), nil return false, err.Error(), nil
} }
loginStatus := "ok" loginStatus := "ok"
storedError := ""
if !ok { if !ok {
loginStatus = "fail" loginStatus = "fail"
storedError = msg
} }
now := time.Now() now := time.Now()
_ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id). _ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id).
Updates(map[string]any{"last_error": loginStatus, "last_check_at": &now}).Error Updates(map[string]any{
"login_status": loginStatus,
"last_error": storedError,
"last_check_at": &now,
}).Error
return ok, msg, nil return ok, msg, nil
} }
@@ -172,8 +213,11 @@ func (s *SiteService) Search(ctx context.Context, keyword string) ([]SearchResul
result, err := adapter.Search(ctxWithTimeout, cfg, keyword, 1) result, err := adapter.Search(ctxWithTimeout, cfg, keyword, 1)
if err != nil { if err != nil {
s.log.Debug("site search failed", s.log.Warn("site search failed",
zap.String("site", site.Name), zap.Error(err)) zap.String("site", site.Name),
zap.String("type", site.Type),
zap.String("url", site.URL),
zap.Error(err))
return return
} }
if result == nil { if result == nil {
@@ -249,5 +293,6 @@ func (svc *SiteService) siteModelToConfig(s *model.Site) SiteConfig {
Timeout: timeout, Timeout: timeout,
Extra: extra, Extra: extra,
FlareSolverrURL: flareSolverrURL, FlareSolverrURL: flareSolverrURL,
UseProxy: s.UseProxy,
} }
} }
+206 -53
View File
@@ -8,6 +8,7 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"os"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@@ -30,6 +31,7 @@ type SiteConfig struct {
Timeout time.Duration // 请求超时 Timeout time.Duration // 请求超时
Extra map[string]string // JSON 扩展配置 Extra map[string]string // JSON 扩展配置
FlareSolverrURL string // FlareSolverr 服务地址(用于浏览器模拟绕过 Cloudflare/WAF) FlareSolverrURL string // FlareSolverr 服务地址(用于浏览器模拟绕过 Cloudflare/WAF)
UseProxy bool // 通过 HTTP(S)_PROXY 环境变量出站
} }
// SiteSearchResult 站点搜索结果(按站点分组的批量搜索结果)。 // SiteSearchResult 站点搜索结果(按站点分组的批量搜索结果)。
@@ -97,8 +99,14 @@ type SiteAdapter interface {
} }
// newHTTPClient 创建带有认证头的 HTTP 客户端。 // newHTTPClient 创建带有认证头的 HTTP 客户端。
// 当 cfg.UseProxy 为 true 时,会读取 HTTP(S)_PROXY 环境变量;
// 否则忽略环境变量直连。
func newHTTPClient(cfg SiteConfig, timeout time.Duration) *http.Client { func newHTTPClient(cfg SiteConfig, timeout time.Duration) *http.Client {
return &http.Client{Timeout: timeout} secs := int(timeout.Seconds())
if secs <= 0 {
secs = 30
}
return helper.NewSiteHTTPClient(secs, cfg.UseProxy)
} }
// buildRequest 构建带认证的 HTTP 请求。 // buildRequest 构建带认证的 HTTP 请求。
@@ -114,13 +122,11 @@ func buildRequest(ctx context.Context, method, rawURL string, cfg SiteConfig, bo
req.Header.Set("Cookie", cfg.Cookie) req.Header.Set("Cookie", cfg.Cookie)
} }
case "api_key": case "api_key":
// MTeam 使用 Authorization: Bearer 格式 // 与参考项目(ShukeBta/MediaStation)的 ApplySiteAuthHeaders 对齐:
if cfg.Type == "mteam" { // M-Team / UNIT3D 等开放 API 的 PT 站点都使用 `x-api-key` 头部,
if cfg.APIKey != "" { // 不要再为 mteam 单独走 Authorization: Bearer,否则服务端会 401。
req.Header.Set("Authorization", "Bearer "+cfg.APIKey) if cfg.APIKey != "" {
} req.Header.Set("x-api-key", cfg.APIKey)
} else if cfg.APIKey != "" {
req.Header.Set("X-API-Key", cfg.APIKey)
} }
case "auth_header": case "auth_header":
if cfg.AuthHeader != "" { if cfg.AuthHeader != "" {
@@ -169,7 +175,14 @@ func doRequest(ctx context.Context, client *http.Client, method, rawURL string,
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
} }
resp, err := client.Do(req) // 当站点开启了「使用代理」开关时,使用本次请求专用的、读取 HTTP(S)_PROXY
// 的 client;否则沿用适配器持有的全局 client。这与前端勾选行为对齐。
httpClient := client
if cfg.UseProxy {
httpClient = newHTTPClient(cfg, cfg.Timeout)
}
resp, err := httpClient.Do(req)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -197,33 +210,32 @@ func NewNexusPHPAdapter() *NexusPHPAdapter {
} }
func (a *NexusPHPAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error { func (a *NexusPHPAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
resp, err := buildRequest(ctx, "GET", cfg.URL+"/index.php", cfg, nil) // 走 doRequest 以便复用代理 / FlareSolverr / 浏览器头。
if err != nil { data, status, err := doRequest(ctx, a.client, "GET", cfg.URL+"/index.php", cfg, nil)
return fmt.Errorf("build request: %w", err)
}
httpResp, err := a.client.Do(resp)
if err != nil { if err != nil {
return fmt.Errorf("request failed: %w", err) return fmt.Errorf("request failed: %w", err)
} }
defer httpResp.Body.Close()
if httpResp.StatusCode == http.StatusFound { if status == http.StatusFound {
return fmt.Errorf("authentication failed: redirected to login page") return fmt.Errorf("authentication failed: redirected to login page")
} }
if httpResp.StatusCode != http.StatusOK { if status == http.StatusUnauthorized || status == http.StatusForbidden {
return fmt.Errorf("authentication failed: status %d", httpResp.StatusCode) return fmt.Errorf("authentication failed: status %d", status)
}
if status >= 400 {
return fmt.Errorf("authentication failed: status %d", status)
} }
body, _ := io.ReadAll(httpResp.Body) body := string(data)
bodyStr := string(body) // NexusPHP 登录后页面通常包含 logout 或 userdetails;
// NexusPHP 登录页面通常包含 logout 或 userdetails // 仅当二者都不存在且明确显示登录表单时才判失败。
if strings.Contains(bodyStr, "userdetails") || strings.Contains(bodyStr, "logout") { if strings.Contains(body, "userdetails") || strings.Contains(body, "logout") || strings.Contains(body, "退出") {
return nil return nil
} }
// Check for common login indicators if strings.Contains(body, "takelogin.php") || strings.Contains(body, "id=\"loginform\"") {
if strings.Contains(bodyStr, "login") && !strings.Contains(bodyStr, "userdetails") {
return fmt.Errorf("authentication failed: not logged in") return fmt.Errorf("authentication failed: not logged in")
} }
// 状态码 OK 但页面不含明显标记时不再武断判失败。
return nil return nil
} }
@@ -843,33 +855,68 @@ func NewMTeamAdapter() *MTeamAdapter {
} }
func (a *MTeamAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error { func (a *MTeamAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
// 与 ShukeBta/MediaStation 参考实现对齐:
// 用 camelCase 参数(pageNumber / pageSize),同时接受 code 为字符串 "0"
// 或数值 0;兼容 M-Team v3 API 不同版本的返回。
u := cfg.URL + "/api/torrent/search" u := cfg.URL + "/api/torrent/search"
payload := `{"mode":"search","keyword":"","page":1,"pageSize":1}` payload := `{"pageNumber":1,"pageSize":1,"keyword":"test"}`
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte(payload)) data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte(payload))
// 调试开关:MEDIASTATION_DEBUG_MTEAM=1 时把请求/响应详情写入 stderr。
if os.Getenv("MEDIASTATION_DEBUG_MTEAM") == "1" {
preview := string(data)
if len(preview) > 800 {
preview = preview[:800] + "..."
}
fmt.Fprintf(os.Stderr,
"[DEBUG mteam.Authenticate] url=%s status=%d err=%v body=%s\n",
u, status, err, preview)
}
if err != nil { if err != nil {
return fmt.Errorf("authenticate: %w", err) return fmt.Errorf("authenticate: %w", err)
} }
if status == http.StatusUnauthorized { preview := string(data)
return fmt.Errorf("authentication failed: unauthorized") if len(preview) > 400 {
preview = preview[:400] + "..."
}
if status == http.StatusUnauthorized || status == http.StatusForbidden {
return fmt.Errorf("authentication failed: status %d, body=%s", status, preview)
}
if status >= 300 && status < 400 {
return fmt.Errorf("authentication failed: HTTP %d (API Key 无效或未登录), body=%s", status, preview)
} }
if status != http.StatusOK { if status != http.StatusOK {
return fmt.Errorf("authenticate failed: status %d", status) return fmt.Errorf("authenticate failed: status %d, body=%s", status, preview)
} }
var resp map[string]interface{} var resp map[string]interface{}
if err := json.Unmarshal(data, &resp); err == nil { if err := json.Unmarshal(data, &resp); err != nil {
if code, ok := resp["code"].(float64); ok && code != 0 { return fmt.Errorf("parse response: %w (body=%s)", err, preview)
return fmt.Errorf("authentication failed: code %v", code)
}
} }
return nil codeStr := ""
switch v := resp["code"].(type) {
case string:
codeStr = v
case float64:
codeStr = strconv.Itoa(int(v))
}
if codeStr == "0" || codeStr == "200" {
return nil
}
msg, _ := resp["message"].(string)
if msg == "" {
msg = fmt.Sprintf("code=%s", codeStr)
}
return fmt.Errorf("authentication failed: %s (body=%s)", msg, preview)
} }
func (a *MTeamAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) { func (a *MTeamAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
// 与参考项目对齐:使用 camelCase 字段名,page 从 1 开始。
if page <= 0 {
page = 1
}
payload := map[string]interface{}{ payload := map[string]interface{}{
"mode": "search", "keyword": keyword,
"keyword": keyword, "pageNumber": page,
"page": page, "pageSize": 50,
"pageSize": 50,
} }
body, _ := json.Marshal(payload) body, _ := json.Marshal(payload)
@@ -886,11 +933,16 @@ func (a *MTeamAdapter) Search(ctx context.Context, cfg SiteConfig, keyword strin
} }
func (a *MTeamAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) { func (a *MTeamAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
if page <= 0 {
page = 1
}
payload := map[string]interface{}{ payload := map[string]interface{}{
"mode": "browse", "keyword": "",
"category": category, "pageNumber": page,
"page": page, "pageSize": 50,
"pageSize": 50, }
if category != "" {
payload["categories"] = []string{category}
} }
body, _ := json.Marshal(payload) body, _ := json.Marshal(payload)
@@ -964,30 +1016,122 @@ func (a *MTeamAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string)
return detail, nil return detail, nil
} }
// GetDownloadURL 解析 M-Team 种子的真实下载链接。
//
// M-Team v3 流程:
//
// POST /api/torrent/genDlToken?id={tid} (带 x-api-key)
// → {"code":"0","data":"https://api.m-team.cc/api/rss/dlv2?sign=..."}
//
// 拿到的 sign URL 可被任何下载客户端无认证地直接 GET。这是参考项目
// (ShukeBta/MediaStation) 的 _download_torrent_file 方法的子集。
func (a *MTeamAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) { func (a *MTeamAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) {
return cfg.URL + "/api/torrent/detail?id=" + id, nil u := cfg.URL + "/api/torrent/genDlToken?id=" + id
// genDlToken 是 POST 但参数走 query string;body 留空。
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte("{}"))
if err != nil {
return "", fmt.Errorf("genDlToken: %w", err)
}
if status >= 300 {
return "", fmt.Errorf("genDlToken: HTTP %d", status)
}
var resp map[string]interface{}
if err := json.Unmarshal(data, &resp); err != nil {
return "", fmt.Errorf("genDlToken parse: %w", err)
}
codeStr := ""
switch v := resp["code"].(type) {
case string:
codeStr = v
case float64:
codeStr = strconv.Itoa(int(v))
}
if codeStr != "0" && codeStr != "200" {
msg, _ := resp["message"].(string)
if msg == "" {
msg = "unknown error"
}
return "", fmt.Errorf("genDlToken: %s", msg)
}
dl, _ := resp["data"].(string)
if dl == "" {
return "", fmt.Errorf("genDlToken: empty data field")
}
return dl, nil
} }
// parseMTeamJSON 解析 MTeam JSON 响应。 // parseMTeamJSON 解析 MTeam v3 JSON 响应。
//
// 响应结构(与 ShukeBta/MediaStation 参考项目一致):
//
// {
// "code": "0", // 字符串 "0" 表示成功
// "message": "SUCCESS",
// "data": {
// "total": "123",
// "data": [ ... ] // 旧字段名 "lists" 已被替换为 "data"
// }
// }
func parseMTeamJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, error) { func parseMTeamJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, error) {
var resp struct { // 用 map 反序列化以兼容 code/total 既可能是字符串又可能是数字。
Code int `json:"code"` var raw map[string]interface{}
Data struct { if err := json.Unmarshal(data, &raw); err != nil {
Total int `json:"total"`
Lists []map[string]interface{} `json:"lists"`
} `json:"data"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return nil, fmt.Errorf("parse JSON: %w", err) return nil, fmt.Errorf("parse JSON: %w", err)
} }
// code 兼容字符串与数字。
codeStr := ""
switch v := raw["code"].(type) {
case string:
codeStr = v
case float64:
codeStr = strconv.Itoa(int(v))
}
if codeStr != "" && codeStr != "0" && codeStr != "200" {
msg, _ := raw["message"].(string)
if msg == "" {
msg = fmt.Sprintf("code=%s", codeStr)
}
return nil, fmt.Errorf("mteam: %s", msg)
}
dataField, _ := raw["data"].(map[string]interface{})
if dataField == nil {
return &SiteSearchResult{SiteName: siteName, Items: []TorrentItem{}}, nil
}
// total 兼容字符串与数字。
total := 0
switch v := dataField["total"].(type) {
case string:
total, _ = strconv.Atoi(v)
case float64:
total = int(v)
}
// data.data(v3)优先;兜底兼容旧的 data.lists。
var rows []interface{}
switch v := dataField["data"].(type) {
case []interface{}:
rows = v
}
if rows == nil {
if v, ok := dataField["lists"].([]interface{}); ok {
rows = v
}
}
result := &SiteSearchResult{ result := &SiteSearchResult{
SiteName: siteName, SiteName: siteName,
Items: []TorrentItem{}, Items: []TorrentItem{},
Total: resp.Data.Total, Total: total,
} }
for _, t := range resp.Data.Lists { for _, rawT := range rows {
t, ok := rawT.(map[string]interface{})
if !ok {
continue
}
item := TorrentItem{} item := TorrentItem{}
if v, ok := t["id"].(string); ok { if v, ok := t["id"].(string); ok {
item.ID = v item.ID = v
@@ -1007,6 +1151,11 @@ func parseMTeamJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, e
} }
if v, ok := t["size"].(float64); ok { if v, ok := t["size"].(float64); ok {
item.Size = int64(v) item.Size = int64(v)
} else if v, ok := t["size"].(string); ok {
// v3 API 把 size 序列化成字符串。
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
item.Size = n
}
} }
if v, ok := t["status"].(map[string]interface{}); ok { if v, ok := t["status"].(map[string]interface{}); ok {
if seeders, ok := v["seeders"].(float64); ok { if seeders, ok := v["seeders"].(float64); ok {
@@ -1027,6 +1176,10 @@ func parseMTeamJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, e
} }
item.DetailURL = baseURL + "/detail/" + item.ID item.DetailURL = baseURL + "/detail/" + item.ID
// 标记 download_url 指向 genDlToken;真正的下载链接由 handler 层
// 在用户点"下载"时通过 MTeamAdapter.GetDownloadURL 解析。
// 这样前端 SiteSearchPage 才知道这一行有可用的下载入口。
item.DownloadURL = baseURL + "/api/torrent/genDlToken?id=" + item.ID
result.Items = append(result.Items, item) result.Items = append(result.Items, item)
} }
+80 -1
View File
@@ -58,7 +58,20 @@ func NewTMDbProvider(cfg *config.Config, log *zap.Logger, apiConfig *APIConfigSe
apiConfig: apiConfig, apiConfig: apiConfig,
base: base, base: base,
imgCDN: img, imgCDN: img,
client: &http.Client{Timeout: 15 * time.Second}, // 默认 8s 超时:首页同时调 trending + popular,15s 太久会让用户感觉
// 卡死。如果 TMDb 真有问题,handler 层会快速降级返回空列表。
// 同时让 client 显式读 HTTP(S)_PROXY 环境变量——这是 GFW 内部署最低
// 成本能拉到 TMDb 的方式。
client: &http.Client{
Timeout: 8 * time.Second,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
MaxIdleConns: 16,
IdleConnTimeout: 60 * time.Second,
TLSHandshakeTimeout: 8 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
},
},
} }
} }
@@ -197,6 +210,72 @@ func (t *TMDbProvider) SearchMovie(ctx context.Context, query string, year int)
return m, nil return m, nil
} }
// SearchTV issues `/search/tv` and returns the best match. Used by anime /
// tv libraries before falling back to SearchMovie.
func (t *TMDbProvider) SearchTV(ctx context.Context, query string, year int) (*Match, error) {
if query == "" {
return nil, errors.New("empty query")
}
apiKey := t.resolveAPIKey(ctx)
if apiKey == "" {
return nil, nil
}
base := t.resolveBaseURL(ctx)
q := url.Values{}
q.Set("api_key", apiKey)
q.Set("query", query)
q.Set("language", "zh-CN")
q.Set("include_adult", "false")
if year > 0 {
q.Set("first_air_date_year", fmt.Sprintf("%d", year))
}
u := base + "/search/tv?" + q.Encode()
type result struct {
ID int `json:"id"`
Name string `json:"name"`
OriginalName string `json:"original_name"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"`
FirstAirDate string `json:"first_air_date"`
VoteAverage float32 `json:"vote_average"`
}
type page struct {
Results []result `json:"results"`
}
var p page
if err := t.getJSON(ctx, u, &p); err != nil {
return nil, err
}
if len(p.Results) == 0 {
return nil, nil
}
r := p.Results[0]
m := &Match{
TMDbID: r.ID,
Title: r.Name,
Overview: r.Overview,
Rating: r.VoteAverage,
}
if m.Title == "" {
m.Title = r.OriginalName
}
if r.PosterPath != "" {
m.PosterURL = t.imgCDN + "/w500" + r.PosterPath
}
if r.BackdropPath != "" {
m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath
}
if len(r.FirstAirDate) >= 4 {
fmt.Sscanf(r.FirstAirDate[:4], "%d", &m.Year)
}
return m, nil
}
func (t *TMDbProvider) getJSON(ctx context.Context, url string, out any) error { func (t *TMDbProvider) getJSON(ctx context.Context, url string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { if err != nil {
+19 -5
View File
@@ -6,16 +6,30 @@ import type { Media } from '../types'
export interface DiscoverItem extends Partial<Media> { export interface DiscoverItem extends Partial<Media> {
tmdb_id: number tmdb_id: number
title: string title: string
overview: string poster_url?: string
poster_url: string backdrop_url?: string
backdrop_url: string overview?: string
year: number year: number
rating: number rating: number
} }
// 后端在 TMDb 不可达 / API key 缺失时统一返回 { items: [], error: "..." }
// 200 状态码——前端必须能区分这两种情况,不能简单用 items.length === 0
// 推断"未配置 API key"。
export interface DiscoverResp {
items: DiscoverItem[]
error?: string
}
export const discoverAPI = { export const discoverAPI = {
trending: () => trending: () =>
api.get<{ items: DiscoverItem[] }>('/discover/trending').then((r) => r.data.items ?? []), api.get<DiscoverResp>('/discover/trending').then((r) => ({
items: r.data.items ?? [],
error: r.data.error,
})),
popular: () => popular: () =>
api.get<{ items: DiscoverItem[] }>('/discover/popular').then((r) => r.data.items ?? []), api.get<DiscoverResp>('/discover/popular').then((r) => ({
items: r.data.items ?? [],
error: r.data.error,
})),
} }
+4 -4
View File
@@ -2,13 +2,14 @@ import { api } from './client'
export type DownloadClientType = 'qbittorrent' | 'aria2' | 'transmission' export type DownloadClientType = 'qbittorrent' | 'aria2' | 'transmission'
// 与后端 model.DownloadClient 字段对齐(json:"host")。
// 之前前端用的 `url` / `save_path` 字段会被后端忽略并触发 400 binding 错误。
export interface DownloadClient { export interface DownloadClient {
id: string id: string
name: string name: string
type: DownloadClientType type: DownloadClientType
url: string host: string
username?: string username?: string
save_path?: string
is_default: boolean is_default: boolean
enabled: boolean enabled: boolean
created_at: string created_at: string
@@ -18,10 +19,9 @@ export interface DownloadClient {
export interface DownloadClientInput { export interface DownloadClientInput {
name: string name: string
type: DownloadClientType type: DownloadClientType
url: string host: string
username?: string username?: string
password?: string password?: string
save_path?: string
is_default: boolean is_default: boolean
enabled: boolean enabled: boolean
} }
+15 -5
View File
@@ -13,12 +13,22 @@ export interface RefreshTokenResponse {
token_type: string token_type: string
} }
// 刷新访问令牌 // 刷新访问令牌。
//
// 后端响应封装在 { code, message, data } 里,需解包 .data。
// /auth/login 的响应是直接展开的({tokens:..., user:...}),
// /auth/refresh 的响应是包装过的 — 这里负责拉平成前端使用的 shape。
export async function refreshToken(refreshToken: string): Promise<RefreshTokenResponse> { export async function refreshToken(refreshToken: string): Promise<RefreshTokenResponse> {
const resp = await api.post<RefreshTokenResponse>('/auth/refresh', { const resp = await api.post<{
refresh_token: refreshToken, code: number
}) message: string
return resp.data as unknown as RefreshTokenResponse data: RefreshTokenResponse
}>('/auth/refresh', { refresh_token: refreshToken })
const body = resp.data
if (!body || !body.data || !body.data.token) {
throw new Error(body?.message || 'refresh failed')
}
return body.data
} }
// 登出 // 登出
+13 -8
View File
@@ -4,7 +4,12 @@ import { Film, Play } from 'lucide-react'
import { imageURL } from '../api/client' import { imageURL } from '../api/client'
import type { Media } from '../types' import type { Media } from '../types'
/** Compact poster tile — used by library, search, favourites, poster wall, etc. */ /**
* Compact poster tile — used by library, search, favourites, poster wall, etc.
*
* 视觉密度调小:之前字体偏大、卡片整体偏宽,导致每页只能塞下 5 个左右;
* 现在文字行 truncate + 字号缩到 12px,让 8 列网格不再"挤"。
*/
export function MediaCard({ export function MediaCard({
media, media,
progress, progress,
@@ -29,14 +34,14 @@ export function MediaCard({
/> />
) : ( ) : (
<div className="flex h-full w-full items-center justify-center text-cream-900/30"> <div className="flex h-full w-full items-center justify-center text-cream-900/30">
<Film size={48} /> <Film size={36} />
</div> </div>
)} )}
{/* Hover overlay */} {/* Hover overlay */}
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 via-transparent to-transparent p-3 opacity-0 transition-opacity group-hover:opacity-100"> <div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 via-transparent to-transparent p-2 opacity-0 transition-opacity group-hover:opacity-100">
<span className="flex items-center gap-1.5 text-xs text-white/90"> <span className="flex items-center gap-1 text-[11px] text-white/90">
<Play size={12} /> <Play size={11} />
播放 播放
</span> </span>
</div> </div>
@@ -53,12 +58,12 @@ export function MediaCard({
</div> </div>
{/* Info */} {/* Info */}
<div className="px-3 py-2.5"> <div className="px-2 py-2">
<p className="truncate text-sm font-medium text-cream-200 group-hover:text-cream-100"> <p className="truncate text-xs font-medium text-cream-200 group-hover:text-cream-100">
{media.title} {media.title}
</p> </p>
{media.year > 0 && ( {media.year > 0 && (
<p className="mt-0.5 text-xs text-cream-500">{media.year}</p> <p className="mt-0.5 text-[11px] text-cream-500">{media.year}</p>
)} )}
</div> </div>
</Link> </Link>
+86 -50
View File
@@ -1,42 +1,64 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Sparkles, AlertTriangle, ExternalLink } from 'lucide-react' import { Sparkles, AlertTriangle, ExternalLink, Wifi } from 'lucide-react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { discoverAPI, type DiscoverItem } from '../api/discover' import { discoverAPI, type DiscoverItem } from '../api/discover'
import { imageURL } from '../api/client' import { imageURL } from '../api/client'
// 判断后端返回的错误是不是"未配置 API key"。其它(网络超时/被墙/上游 5xx)
// 都归为"网络/上游故障",避免误导用户去再配一次 key。
function isMissingKey(err?: string): boolean {
if (!err) return false
const low = err.toLowerCase()
return low.includes('api key') || low.includes('apikey') || low.includes('not configured')
}
function isNetworkError(err?: string): boolean {
if (!err) return false
const low = err.toLowerCase()
return (
low.includes('deadline exceeded') ||
low.includes('timeout') ||
low.includes('no such host') ||
low.includes('connection refused') ||
low.includes('eof') ||
low.includes('tls') ||
low.includes('reset')
)
}
export function DiscoverPage() { export function DiscoverPage() {
const [trending, setTrending] = useState<DiscoverItem[]>([]) const [trending, setTrending] = useState<DiscoverItem[]>([])
const [popular, setPopular] = useState<DiscoverItem[]>([]) const [popular, setPopular] = useState<DiscoverItem[]>([])
const [trendingErr, setTrendingErr] = useState<string | undefined>()
const [popularErr, setPopularErr] = useState<string | undefined>()
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
setLoading(true) setLoading(true)
setError(null)
Promise.all([ Promise.all([
discoverAPI.trending().catch((err) => { discoverAPI.trending().catch((err) => ({
console.error('Failed to fetch trending:', err) items: [] as DiscoverItem[],
return [] as DiscoverItem[] error: err instanceof Error ? err.message : String(err),
}), })),
discoverAPI.popular().catch((err) => { discoverAPI.popular().catch((err) => ({
console.error('Failed to fetch popular:', err) items: [] as DiscoverItem[],
return [] as DiscoverItem[] error: err instanceof Error ? err.message : String(err),
}), })),
]) ])
.then(([t, p]) => { .then(([t, p]) => {
setTrending(t) setTrending(t.items)
setPopular(p) setTrendingErr(t.error)
}) setPopular(p.items)
.catch((err) => { setPopularErr(p.error)
setError(err instanceof Error ? err.message : 'Failed to load discover data')
}) })
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, []) }, [])
// Check if TMDB API key is likely not configured const anyErr = trendingErr || popularErr
const isTMDBMissing = !loading && trending.length === 0 && popular.length === 0 && !error const missingKey = isMissingKey(anyErr)
const networkErr = !missingKey && isNetworkError(anyErr)
const otherErr = !missingKey && !networkErr && anyErr
return ( return (
<div className="space-y-8 px-4 py-6 max-w-7xl mx-auto"> <div className="space-y-8 px-4 py-6 max-w-7xl mx-auto">
@@ -53,26 +75,17 @@ export function DiscoverPage() {
</div> </div>
</header> </header>
{/* Error Alert */}
{error && (
<div className="rounded-2xl bg-red-500/10 border border-red-500/20 p-4 flex items-center gap-3">
<AlertTriangle className="h-5 w-5 text-red-400 flex-shrink-0" />
<p className="text-red-300">{error}</p>
</div>
)}
{/* Loading State */}
{loading && <DiscoverSkeleton />} {loading && <DiscoverSkeleton />}
{/* TMDB API Key Missing */} {/* TMDb API Key 未配置 */}
{isTMDBMissing && ( {!loading && missingKey && (
<div className="rounded-2xl bg-amber-500/10 border border-amber-500/20 p-6 text-center space-y-4"> <div className="rounded-2xl bg-amber-500/10 border border-amber-500/20 p-6 text-center space-y-4">
<div className="mx-auto w-16 h-16 rounded-full bg-amber-500/10 flex items-center justify-center"> <div className="mx-auto w-16 h-16 rounded-full bg-amber-500/10 flex items-center justify-center">
<AlertTriangle className="h-8 w-8 text-amber-400" /> <AlertTriangle className="h-8 w-8 text-amber-400" />
</div> </div>
<h3 className="text-lg font-semibold text-white">TMDb API Key 未配置</h3> <h3 className="text-lg font-semibold text-white">TMDb API Key 未配置</h3>
<p className="text-sm text-slate-400 max-w-md mx-auto"> <p className="text-sm text-slate-400 max-w-md mx-auto">
您需要配置 TMDb API Key 才能查看发现内容。请前往管理后台配置。 您需要在管理后台填入 TMDb API Key 才能查看发现内容。
</p> </p>
<Link <Link
to="/admin" to="/admin"
@@ -84,14 +97,42 @@ export function DiscoverPage() {
</div> </div>
)} )}
{/* 网络无法访问 TMDb */}
{!loading && networkErr && (
<div className="rounded-2xl bg-orange-500/10 border border-orange-500/20 p-6 text-center space-y-4">
<div className="mx-auto w-16 h-16 rounded-full bg-orange-500/10 flex items-center justify-center">
<Wifi className="h-8 w-8 text-orange-400" />
</div>
<h3 className="text-lg font-semibold text-white">无法连接到 TMDb</h3>
<p className="text-sm text-slate-400 max-w-lg mx-auto">
服务器到 <code className="font-mono text-orange-300">api.themoviedb.org</code> 的连接超时。
通常是因为部署机器没有走代理。可以在系统环境变量里设置
<code className="font-mono text-orange-300 mx-1">HTTPS_PROXY</code>,
或在「外部 API」配置里填写自建反代地址(tmdb_api_proxy / tmdb_image_proxy)。
</p>
<details className="text-xs text-slate-500 max-w-lg mx-auto text-left">
<summary className="cursor-pointer hover:text-slate-400">查看原始错误</summary>
<pre className="mt-2 p-2 rounded bg-black/40 overflow-x-auto whitespace-pre-wrap">{anyErr}</pre>
</details>
</div>
)}
{/* 其它错误 */}
{!loading && otherErr && (
<div className="rounded-2xl bg-red-500/10 border border-red-500/20 p-4 flex items-center gap-3">
<AlertTriangle className="h-5 w-5 text-red-400 flex-shrink-0" />
<p className="text-red-300">{otherErr}</p>
</div>
)}
{/* Content Rows */} {/* Content Rows */}
{!loading && !isTMDBMissing && ( {!loading && !missingKey && (
<div className="space-y-10"> <div className="space-y-10">
{trending.length > 0 && <ContentRow title="今日趋势" items={trending} />} {trending.length > 0 && <ContentRow title="今日趋势" items={trending} />}
{popular.length > 0 && <ContentRow title="热门电影" items={popular} />} {popular.length > 0 && <ContentRow title="热门电影" items={popular} />}
{/* Empty State (TMDB configured but no data) */} {/* TMDB 配置 OK 但本次没拿到任何条目(极少见) */}
{trending.length === 0 && popular.length === 0 && ( {!networkErr && trending.length === 0 && popular.length === 0 && !otherErr && (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-slate-500">暂无发现内容</p> <p className="text-slate-500">暂无发现内容</p>
</div> </div>
@@ -106,7 +147,7 @@ function ContentRow({ title, items }: { title: string; items: DiscoverItem[] })
return ( return (
<section className="space-y-4"> <section className="space-y-4">
<h2 className="font-display text-2xl font-semibold text-white pl-1">{title}</h2> <h2 className="font-display text-2xl font-semibold text-white pl-1">{title}</h2>
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"> <div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
{items.map((item) => ( {items.map((item) => (
<DiscoverCard key={item.tmdb_id} item={item} /> <DiscoverCard key={item.tmdb_id} item={item} />
))} ))}
@@ -117,8 +158,7 @@ function ContentRow({ title, items }: { title: string; items: DiscoverItem[] })
function DiscoverCard({ item }: { item: DiscoverItem }) { function DiscoverCard({ item }: { item: DiscoverItem }) {
return ( return (
<div className="group relative overflow-hidden rounded-2xl border border-white/5 bg-surface-800/60 hover:border-primary-500/30 transition-all duration-300 hover:shadow-2xl hover:shadow-primary-500/10 hover:-translate-y-1"> <div className="group relative overflow-hidden rounded-xl border border-white/5 bg-surface-800/60 hover:border-primary-500/30 transition-all duration-300">
{/* Poster */}
<div className="aspect-[2/3] w-full bg-surface-900 relative overflow-hidden"> <div className="aspect-[2/3] w-full bg-surface-900 relative overflow-hidden">
{item.poster_url ? ( {item.poster_url ? (
<img <img
@@ -129,26 +169,22 @@ function DiscoverCard({ item }: { item: DiscoverItem }) {
className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-500" className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-500"
/> />
) : ( ) : (
<div className="flex h-full w-full items-center justify-center text-slate-600"> <div className="flex h-full w-full items-center justify-center text-slate-600 text-xs">
无海报 无海报
</div> </div>
)} )}
{/* Rating Badge */}
{item.rating > 0 && ( {item.rating > 0 && (
<div className="absolute top-3 right-3 rounded-lg bg-black/70 backdrop-blur-sm px-2 py-1 text-sm font-semibold text-yellow-400 border border-yellow-400/30"> <div className="absolute top-1.5 right-1.5 rounded-md bg-black/70 backdrop-blur-sm px-1.5 py-0.5 text-[11px] font-semibold text-yellow-400 border border-yellow-400/30">
★ {item.rating.toFixed(1)} ★ {item.rating.toFixed(1)}
</div> </div>
)} )}
</div> </div>
<div className="px-2.5 py-2 space-y-0.5">
{/* Info */} <p className="text-xs font-medium text-white truncate group-hover:text-primary-400 transition-colors">
<div className="p-4 space-y-1">
<p className="font-medium text-white truncate group-hover:text-primary-400 transition-colors">
{item.title} {item.title}
</p> </p>
{item.year > 0 && ( {item.year > 0 && (
<p className="text-sm text-slate-500">{item.year}</p> <p className="text-[11px] text-slate-500">{item.year}</p>
)} )}
</div> </div>
</div> </div>
@@ -161,9 +197,9 @@ function DiscoverSkeleton() {
{[1, 2].map((section) => ( {[1, 2].map((section) => (
<section key={section} className="space-y-4"> <section key={section} className="space-y-4">
<div className="h-8 w-48 rounded-lg bg-surface-800 animate-pulse" /> <div className="h-8 w-48 rounded-lg bg-surface-800 animate-pulse" />
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"> <div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
{[1, 2, 3, 4, 5, 6].map((i) => ( {[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
<div key={i} className="aspect-[2/3] rounded-2xl bg-surface-800 animate-pulse" /> <div key={i} className="aspect-[2/3] rounded-xl bg-surface-800 animate-pulse" />
))} ))}
</div> </div>
</section> </section>
+4 -13
View File
@@ -118,9 +118,8 @@ export function DownloadClientsPage() {
)} )}
</div> </div>
<div className="mt-1 truncate text-xs text-slate-400"> <div className="mt-1 truncate text-xs text-slate-400">
{c.url} {c.host}
{c.username && ` · ${c.username}`} {c.username && ` · ${c.username}`}
{c.save_path && ` · ${c.save_path}`}
</div> </div>
</div> </div>
<div className="flex shrink-0 gap-2"> <div className="flex shrink-0 gap-2">
@@ -177,10 +176,9 @@ function ClientFormModal({
const [form, setForm] = useState<DownloadClientInput>(() => ({ const [form, setForm] = useState<DownloadClientInput>(() => ({
name: editing?.name ?? '', name: editing?.name ?? '',
type: editing?.type ?? 'qbittorrent', type: editing?.type ?? 'qbittorrent',
url: editing?.url ?? '', host: editing?.host ?? '',
username: editing?.username ?? '', username: editing?.username ?? '',
password: '', password: '',
save_path: editing?.save_path ?? '',
is_default: editing?.is_default ?? false, is_default: editing?.is_default ?? false,
enabled: editing?.enabled ?? true, enabled: editing?.enabled ?? true,
})) }))
@@ -246,8 +244,8 @@ function ClientFormModal({
required required
className="input-base" className="input-base"
placeholder={placeholder} placeholder={placeholder}
value={form.url} value={form.host}
onChange={(e) => update('url', e.target.value)} onChange={(e) => update('host', e.target.value)}
/> />
</Field> </Field>
{form.type !== 'aria2' && ( {form.type !== 'aria2' && (
@@ -279,13 +277,6 @@ function ClientFormModal({
/> />
</Field> </Field>
)} )}
<Field label="默认保存路径">
<input
className="input-base"
value={form.save_path ?? ''}
onChange={(e) => update('save_path', e.target.value)}
/>
</Field>
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2 text-sm text-slate-300"> <label className="flex items-center gap-2 text-sm text-slate-300">
<input <input
+7 -1
View File
@@ -84,7 +84,13 @@ export function DownloadsPage() {
<section className="glass-panel"> <section className="glass-panel">
<h2 className="mb-3 font-display text-lg font-semibold text-white">实时状态</h2> <h2 className="mb-3 font-display text-lg font-semibold text-white">实时状态</h2>
{torrents === null && ( {torrents === null && (
<p className="text-slate-500">尚未连接到 qBittorrent — 请在管理后台配置 qbittorrent.url / username / password。</p> <p className="text-slate-500">
尚未连接到下载器 — 请到{' '}
<a href="/download-clients" className="text-primary-400 hover:underline">
下载器
</a>{' '}
页面添加并测试连接(qBittorrent / Aria2 / Transmission)。
</p>
)} )}
{torrents && torrents.length === 0 && <p className="text-slate-500">暂无运行中任务。</p>} {torrents && torrents.length === 0 && <p className="text-slate-500">暂无运行中任务。</p>}
{torrents && torrents.length > 0 && ( {torrents && torrents.length > 0 && (
+155 -48
View File
@@ -1,30 +1,105 @@
import { useEffect, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { ArrowRight, Play, Clock, Film, Compass, Sparkles } from 'lucide-react' import {
ArrowRight,
Play,
Clock,
Film,
Compass,
Sparkles,
Layers,
Library as LibraryIcon,
Tv,
Music,
PlayCircle,
} from 'lucide-react'
import { mediaAPI } from '../api/library' import { libraryAPI, mediaAPI } from '../api/library'
import { playbackAPI, type HistoryItem } from '../api/playback' import { playbackAPI, type HistoryItem } from '../api/playback'
import { imageURL } from '../api/client' import { imageURL } from '../api/client'
import type { Media } from '../types' import type { Library, Media } from '../types'
import { groupSeries } from '../utils/groupSeries'
// LibraryRow 把单个媒体库的最近添加聚合成代表卡片列表。
type LibraryRow = {
library: Library
cards: ReturnType<typeof groupSeries>
}
const TYPE_LABELS: Record<string, string> = {
movie: '电影',
tv: '电视剧',
anime: '动漫',
music: '音乐',
}
const TYPE_ICONS: Record<string, React.ReactNode> = {
movie: <Film size={16} />,
tv: <Tv size={16} />,
anime: <PlayCircle size={16} />,
music: <Music size={16} />,
}
export function HomePage() { export function HomePage() {
const [recent, setRecent] = useState<Media[]>([]) const [libraries, setLibraries] = useState<Library[]>([])
const [rows, setRows] = useState<LibraryRow[]>([])
const [history, setHistory] = useState<HistoryItem[]>([]) const [history, setHistory] = useState<HistoryItem[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
Promise.all([ let cancelled = false
mediaAPI.search('', 24).then((d) => d.items),
playbackAPI.recentHistory().catch(() => [] as HistoryItem[]), async function load() {
]) setLoading(true)
.then(([items, hist]) => { try {
setRecent(items) // 1) 拉所有库 + 历史
const [libs, hist] = await Promise.all([
libraryAPI.list().catch(() => [] as Library[]),
playbackAPI.recentHistory().catch(() => [] as HistoryItem[]),
])
if (cancelled) return
setLibraries(libs)
setHistory(hist.filter((h) => !h.completed && !!h.media)) setHistory(hist.filter((h) => !h.completed && !!h.media))
})
.finally(() => setLoading(false)) // 2) 并行为每个库拉最近 60 条 media,前端折叠成剧集卡片
const perLib = await Promise.all(
libs.map(async (lib) => {
try {
const page = await libraryAPI.listMedia(lib.id, 1, 60)
const cards = groupSeries(page.items).slice(0, 12)
return { library: lib, cards } as LibraryRow
} catch {
return { library: lib, cards: [] } as LibraryRow
}
}),
)
if (cancelled) return
setRows(perLib)
} finally {
if (!cancelled) setLoading(false)
}
}
load()
return () => {
cancelled = true
}
}, []) }, [])
const empty = !loading && recent.length === 0 && history.length === 0 // 兜底:如果一个库都没有,但 /api/media 有内容(旧数据),回退展示一个总卡片墙。
const [fallback, setFallback] = useState<Media[]>([])
useEffect(() => {
if (loading) return
if (libraries.length > 0) return
mediaAPI.search('', 60).then((d) => setFallback(d.items)).catch(() => undefined)
}, [loading, libraries.length])
const fallbackCards = useMemo(() => groupSeries(fallback).slice(0, 16), [fallback])
const empty =
!loading &&
history.length === 0 &&
rows.every((r) => r.cards.length === 0) &&
fallbackCards.length === 0
return ( return (
<div className="animate-fade-in space-y-12"> <div className="animate-fade-in space-y-12">
@@ -50,7 +125,7 @@ export function HomePage() {
)} )}
</header> </header>
{/* ── Loading ── */} {/* Loading */}
{loading && ( {loading && (
<div className="flex items-center gap-3 py-16"> <div className="flex items-center gap-3 py-16">
<div className="h-1.5 w-1.5 animate-pulse rounded-full bg-brand-500/60" /> <div className="h-1.5 w-1.5 animate-pulse rounded-full bg-brand-500/60" />
@@ -58,7 +133,7 @@ export function HomePage() {
</div> </div>
)} )}
{/* ── Empty state ── */} {/* Empty */}
{empty && ( {empty && (
<div className="surface-card flex flex-col items-center gap-4 py-16 text-center"> <div className="surface-card flex flex-col items-center gap-4 py-16 text-center">
<Film className="h-12 w-12 text-cream-900/40" /> <Film className="h-12 w-12 text-cream-900/40" />
@@ -75,7 +150,7 @@ export function HomePage() {
</div> </div>
)} )}
{/* ── Continue Watching — wide horizontal stripe ── */} {/* Continue Watching */}
{history.length > 0 && ( {history.length > 0 && (
<section> <section>
<SectionHeading icon={<Play size={16} />} label="继续观看" /> <SectionHeading icon={<Play size={16} />} label="继续观看" />
@@ -89,27 +164,27 @@ export function HomePage() {
</section> </section>
)} )}
{/* ── Recently Added — bento grid ── */} {/* 每个媒体库一行 */}
{recent.length > 0 && ( {!loading &&
rows
.filter((r) => r.cards.length > 0)
.map((row) => (
<LibrarySection key={row.library.id} row={row} />
))}
{/* 旧数据兜底 */}
{!loading && libraries.length === 0 && fallbackCards.length > 0 && (
<section> <section>
<SectionHeading icon={<Clock size={16} />} label="最近添加" /> <SectionHeading icon={<Clock size={16} />} label="最近添加" />
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"> <div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
{recent.slice(0, 12).map((m, i) => { {fallbackCards.map((s) => (
// First 2 items get double-column span on large screens <PosterCard key={s.rep.id} media={s.rep} count={s.count} />
const isHero = i < 2 ))}
return (
<PosterCard
key={m.id}
media={m}
className={isHero ? 'lg:col-span-2 lg:row-span-2' : ''}
/>
)
})}
</div> </div>
</section> </section>
)} )}
{/* ── Quick Links ── */} {/* Quick Links */}
{!empty && ( {!empty && (
<section> <section>
<SectionHeading icon={<Sparkles size={16} />} label="快捷入口" /> <SectionHeading icon={<Sparkles size={16} />} label="快捷入口" />
@@ -125,6 +200,39 @@ export function HomePage() {
) )
} }
/* ─── Library 行 ─── */
function LibrarySection({ row }: { row: LibraryRow }) {
const lib = row.library
const typeIcon = TYPE_ICONS[lib.type] ?? <LibraryIcon size={16} />
const typeLabel = TYPE_LABELS[lib.type] ?? lib.type
return (
<section>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-cream-400">{typeIcon}</span>
<h2 className="font-display text-lg font-semibold tracking-tight text-cream-200">
{lib.name}
</h2>
<span className="text-xs text-cream-500">·</span>
<span className="text-xs text-cream-500">{typeLabel}</span>
</div>
<Link
to={`/library/${lib.id}`}
className="flex items-center gap-1.5 text-xs text-cream-400 transition-colors hover:text-brand-400"
>
查看全部
<ArrowRight size={12} />
</Link>
</div>
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
{row.cards.map((s) => (
<PosterCard key={s.rep.id} media={s.rep} count={s.count} />
))}
</div>
</section>
)
}
/* ─── Section Heading ─── */ /* ─── Section Heading ─── */
function SectionHeading({ icon, label }: { icon: React.ReactNode; label: string }) { function SectionHeading({ icon, label }: { icon: React.ReactNode; label: string }) {
return ( return (
@@ -144,7 +252,6 @@ function WideContinueCard({ media, progress }: { media: Media; progress: number
to={`/media/${media.id}`} to={`/media/${media.id}`}
className="group flex shrink-0 w-72 items-center gap-4 rounded-lg border border-cream-900/15 bg-surface-400 p-3 transition-all hover:border-brand-500/30 hover:bg-surface-300" className="group flex shrink-0 w-72 items-center gap-4 rounded-lg border border-cream-900/15 bg-surface-400 p-3 transition-all hover:border-brand-500/30 hover:bg-surface-300"
> >
{/* Poster thumbnail */}
<div className="relative h-20 w-14 shrink-0 overflow-hidden rounded-md bg-surface-600"> <div className="relative h-20 w-14 shrink-0 overflow-hidden rounded-md bg-surface-600">
{media.poster_url ? ( {media.poster_url ? (
<img <img
@@ -160,8 +267,6 @@ function WideContinueCard({ media, progress }: { media: Media; progress: number
</div> </div>
)} )}
</div> </div>
{/* Info */}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-cream-200 group-hover:text-cream-100"> <p className="truncate text-sm font-medium text-cream-200 group-hover:text-cream-100">
{media.title} {media.title}
@@ -169,8 +274,6 @@ function WideContinueCard({ media, progress }: { media: Media; progress: number
{media.year > 0 && ( {media.year > 0 && (
<p className="mt-0.5 text-xs text-cream-500">{media.year}</p> <p className="mt-0.5 text-xs text-cream-500">{media.year}</p>
)} )}
{/* Progress bar */}
{progress > 0 && ( {progress > 0 && (
<div className="mt-2 h-1 w-full overflow-hidden rounded-full bg-cream-900/20"> <div className="mt-2 h-1 w-full overflow-hidden rounded-full bg-cream-900/20">
<div <div
@@ -180,7 +283,6 @@ function WideContinueCard({ media, progress }: { media: Media; progress: number
</div> </div>
)} )}
</div> </div>
<Play <Play
size={14} size={14}
className="shrink-0 text-cream-600 opacity-0 transition-all group-hover:opacity-100 group-hover:text-brand-400" className="shrink-0 text-cream-600 opacity-0 transition-all group-hover:opacity-100 group-hover:text-brand-400"
@@ -190,11 +292,11 @@ function WideContinueCard({ media, progress }: { media: Media; progress: number
} }
/* ─── Poster Card (grid item) ─── */ /* ─── Poster Card (grid item) ─── */
function PosterCard({ media, className = '' }: { media: Media; className?: string }) { function PosterCard({ media, count = 1 }: { media: Media; count?: number }) {
return ( return (
<Link <Link
to={`/media/${media.id}`} to={`/media/${media.id}`}
className={`group block overflow-hidden rounded-lg border border-cream-900/15 bg-surface-400 transition-all hover:border-brand-500/30 hover:bg-surface-300 ${className}`} className="group block overflow-hidden rounded-lg border border-cream-900/15 bg-surface-400 transition-all hover:border-brand-500/30 hover:bg-surface-300"
> >
<div className="relative aspect-[2/3] w-full overflow-hidden bg-surface-600"> <div className="relative aspect-[2/3] w-full overflow-hidden bg-surface-600">
{media.poster_url ? ( {media.poster_url ? (
@@ -207,23 +309,28 @@ function PosterCard({ media, className = '' }: { media: Media; className?: strin
/> />
) : ( ) : (
<div className="flex h-full w-full items-center justify-center text-cream-900/30"> <div className="flex h-full w-full items-center justify-center text-cream-900/30">
<Film size={48} /> <Film size={36} />
</div> </div>
)} )}
{/* Overlay on hover */} {count > 1 && (
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 via-transparent to-transparent p-3 opacity-0 transition-opacity group-hover:opacity-100"> <span className="absolute right-1.5 top-1.5 inline-flex items-center gap-0.5 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm">
<span className="flex items-center gap-1.5 text-xs text-white/90"> <Layers size={10} />
<Play size={12} /> {count}
</span>
)}
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 via-transparent to-transparent p-2.5 opacity-0 transition-opacity group-hover:opacity-100">
<span className="flex items-center gap-1 text-[11px] text-white/90">
<Play size={11} />
播放 播放
</span> </span>
</div> </div>
</div> </div>
<div className="px-3 py-2.5"> <div className="px-2.5 py-2">
<p className="truncate text-sm font-medium text-cream-200 group-hover:text-cream-100"> <p className="truncate text-xs font-medium text-cream-200 group-hover:text-cream-100">
{media.title} {media.title}
</p> </p>
{media.year > 0 && ( {media.year > 0 && (
<p className="mt-0.5 text-xs text-cream-500">{media.year}</p> <p className="mt-0.5 text-[11px] text-cream-500">{media.year}</p>
)} )}
</div> </div>
</Link> </Link>
+2 -2
View File
@@ -118,7 +118,7 @@ export function LibraryPage() {
{loading && <p className="text-slate-500">加载中…</p>} {loading && <p className="text-slate-500">加载中…</p>}
{!isSeries && ( {!isSeries && (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"> <div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
{items.map((m) => ( {items.map((m) => (
<MediaCard key={m.id} media={m} /> <MediaCard key={m.id} media={m} />
))} ))}
@@ -131,7 +131,7 @@ export function LibraryPage() {
<h2 className="font-display text-xl font-semibold text-white"> <h2 className="font-display text-xl font-semibold text-white">
{s.season > 0 ? `第 ${s.season} 季` : '未分季'} ({s.episodes.length}) {s.season > 0 ? `第 ${s.season} 季` : '未分季'} ({s.episodes.length})
</h2> </h2>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"> <div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
{s.episodes.map((e) => ( {s.episodes.map((e) => (
<div key={e.id} className="space-y-1"> <div key={e.id} className="space-y-1">
<MediaCard media={e} /> <MediaCard media={e} />
+13 -1
View File
@@ -162,8 +162,20 @@ export function PlayerPage() {
ref={ref} ref={ref}
controls controls
autoPlay autoPlay
crossOrigin="use-credentials" playsInline
className="max-h-screen w-full max-w-[1600px] bg-black" className="max-h-screen w-full max-w-[1600px] bg-black"
onError={() => {
// 浏览器对 <video src> 的错误描述非常有限,把详细原因
// 转给开发者控制台 + 一条 toast;常见原因是 codec 不支持。
if (mode === 'direct') {
toast.error('直接播放失败,切换到 HLS 转码')
setMode('hls')
params.set('mode', 'hls')
setParams(params, { replace: true })
} else {
toast.error('视频播放失败,请检查文件是否存在')
}
}}
> >
{subs.map((t, i) => ( {subs.map((t, i) => (
<track <track
+23 -11
View File
@@ -1,24 +1,28 @@
import { useEffect, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { GalleryHorizontalEnd } from 'lucide-react' import { GalleryHorizontalEnd, Layers } from 'lucide-react'
import { mediaAPI } from '../api/library' import { mediaAPI } from '../api/library'
import { MediaCard } from '../components/MediaCard' import { MediaCard } from '../components/MediaCard'
import { groupSeries } from '../utils/groupSeries'
import type { Media } from '../types' import type { Media } from '../types'
// PosterWallPage shows a dense poster grid of all media across every // PosterWallPage 把所有媒体的代表海报聚合到同一面墙,便于一目了然
// library — a visual "poster wall" that matches the original MediaStation's // 浏览整个站点的内容。所有 episode 行会按剧集折叠,避免同一海报刷屏。
// PosterWallView.vue. Loads the first 200 items sorted by rating.
export function PosterWallPage() { export function PosterWallPage() {
const [items, setItems] = useState<Media[]>([]) const [items, setItems] = useState<Media[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
// 拉得多一点(500),折叠后才能凑够展示量;后端 page_size 上限可能
// 需要后续观察,暂以 500 试探。
mediaAPI mediaAPI
.search('', 200) .search('', 500)
.then((d) => setItems(d.items)) .then((d) => setItems(d.items))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, []) }, [])
const cards = useMemo(() => groupSeries(items).slice(0, 240), [items])
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<header className="flex items-center gap-3"> <header className="flex items-center gap-3">
@@ -26,18 +30,26 @@ export function PosterWallPage() {
<div> <div>
<h1 className="font-display text-3xl font-bold text-white">海报墙</h1> <h1 className="font-display text-3xl font-bold text-white">海报墙</h1>
<p className="text-sm text-slate-400"> <p className="text-sm text-slate-400">
全部媒体的海报展示(最多 200 项)。 按剧集聚合 · 共 {cards.length} 个条目
</p> </p>
</div> </div>
</header> </header>
{loading && <p className="text-slate-500">加载中…</p>} {loading && <p className="text-slate-500">加载中…</p>}
{!loading && items.length === 0 && ( {!loading && cards.length === 0 && (
<p className="text-slate-400">暂无媒体。请先添加媒体库并扫描。</p> <p className="text-slate-400">暂无媒体。请先添加媒体库并扫描。</p>
)} )}
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8"> <div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10">
{items.map((m) => ( {cards.map((s) => (
<MediaCard key={m.id} media={m} /> <div key={s.rep.id} className="relative">
<MediaCard media={s.rep} />
{s.count > 1 && (
<span className="pointer-events-none absolute right-1.5 top-1.5 inline-flex items-center gap-0.5 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm">
<Layers size={10} />
{s.count}
</span>
)}
</div>
))} ))}
</div> </div>
</div> </div>
+3 -11
View File
@@ -171,17 +171,9 @@ const GROUPS: SettingGroup[] = [
}, },
], ],
}, },
{ // qBittorrent 配置已迁移到独立的「下载器」页面(侧边栏 → 下载器),
key: 'qbittorrent', // 该页面支持多客户端 + 连接测试。这里不再重复暴露入口,避免与
label: 'qBittorrent', // /api/admin/download/clients 写入的数据来源冲突。
description: '默认下载器配置',
items: [
{ key: 'qbittorrent.url', label: 'WebUI URL', type: 'text', placeholder: 'http://127.0.0.1:8080' },
{ key: 'qbittorrent.username', label: '用户名', type: 'text' },
{ key: 'qbittorrent.password', label: '密码', type: 'text' },
{ key: 'qbittorrent.savepath', label: '默认保存目录', type: 'text' },
],
},
] ]
const ALL_KEYS = new Set(GROUPS.flatMap((g) => g.items.map((i) => i.key))) const ALL_KEYS = new Set(GROUPS.flatMap((g) => g.items.map((i) => i.key)))
+47
View File
@@ -0,0 +1,47 @@
import type { Media } from '../types'
/**
* 把若干 Episode/Movie 行折叠成"剧集卡片"。
*
* 后端的 /api/media 等接口默认返回 episode 级行——同一部剧的每一集都
* 是一行。在「最近添加」「海报墙」「收藏墙」这种以剧集为单位展示的页面
* 我们要把它们合成一张代表卡片,避免同一海报刷屏。
*
* 折叠键优先级(命中第一个就分组):
*
* 1. tmdb_id (刮削匹配后最稳定)
* 2. bangumi_id (番剧)
* 3. series_id (后端某些场景下会预先聚合)
* 4. library_id + title (fallback:同库同名视为同一剧)
*
* 同一组内取最早 created_at 的那条作为代表卡片,并带 count 表示集数。
*/
export type SeriesCard = { rep: Media; count: number }
export function groupSeries(items: Media[]): SeriesCard[] {
const groups = new Map<string, SeriesCard>()
for (const m of items) {
let key = ''
if (m.tmdb_id && m.tmdb_id > 0) key = `tmdb:${m.tmdb_id}`
else if (m.bangumi_id && m.bangumi_id > 0) key = `bgm:${m.bangumi_id}`
else if (m.series_id) key = `series:${m.series_id}`
else key = `lib:${m.library_id}|${(m.title ?? '').toLowerCase().trim()}`
const g = groups.get(key)
if (!g) {
groups.set(key, { rep: m, count: 1 })
} else {
g.count += 1
const repHasPoster = !!g.rep.poster_url
const curHasPoster = !!m.poster_url
if (!repHasPoster && curHasPoster) {
g.rep = m
} else if (repHasPoster === curHasPoster) {
const cur = (m.season_num ?? 0) * 10000 + (m.episode_num ?? 0)
const rep = (g.rep.season_num ?? 0) * 10000 + (g.rep.episode_num ?? 0)
if (cur > 0 && (rep === 0 || cur < rep)) g.rep = m
}
}
}
return Array.from(groups.values())
}