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 {
} }
} }
func embyItemsHandler(svc *service.Container) gin.HandlerFunc { // ─── Items ───────────────────────────────────────────────────────────────────
return func(c *gin.Context) {
libraryID := c.Query("ParentId") func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
limit, _ := strconv.Atoi(c.DefaultQuery("Limit", "50")) limit, _ := strconv.Atoi(c.DefaultQuery("Limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("StartIndex", "0")) offset, _ := strconv.Atoi(c.DefaultQuery("StartIndex", "0"))
out, err := svc.Emby.Items(c.Request.Context(), libraryID, limit, offset) 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 {
return func(c *gin.Context) {
out, err := svc.Emby.Items(c.Request.Context(), parseEmbyItemsParams(c))
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
} }
+52 -19
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)
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 { if len(via) >= 10 {
return fmt.Errorf("too many redirects") 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)
+31 -7
View File
@@ -18,6 +18,7 @@ package service
import ( import (
"context" "context"
"errors" "errors"
"strings"
"sync" "sync"
"time" "time"
@@ -67,12 +68,32 @@ 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
} }
}
// Path 2: legacy Setting 表(仅在 client 表未配置时回退)
if cfg.BaseURL == "" {
get := func(k string) string { get := func(k string) string {
v, _ := d.repo.Setting.Get(ctx, k) v, _ := d.repo.Setting.Get(ctx, k)
return v return v
@@ -80,6 +101,8 @@ func (d *DownloadService) ReloadConfig(ctx context.Context) error {
cfg.BaseURL = get("qbittorrent.url") cfg.BaseURL = get("qbittorrent.url")
cfg.Username = get("qbittorrent.username") cfg.Username = get("qbittorrent.username")
cfg.Password = get("qbittorrent.password") 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))
+495 -68
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{
"Id": embyServerID,
"ServerName": "MediaStationGo", "ServerName": "MediaStationGo",
"Version": "0.1.0", "Version": "10.8.13",
"Id": "mediastation-go",
"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,
"ServerName": "MediaStationGo",
"HasPassword": true, "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{{
"Id": m.ID,
"Path": m.Path,
"Container": m.Container, "Container": m.Container,
"Size": m.SizeBytes, "Width": m.Width,
}}, "Height": m.Height,
"DateCreated": m.CreatedAt,
"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)
}
+162 -83
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,24 +32,26 @@ import (
"github.com/ShukeBta/MediaStationGo/internal/config" "github.com/ShukeBta/MediaStationGo/internal/config"
) )
// ImageProxy fetches and caches remote images on behalf of the browser. // transparent1x1PNG is a baseline 67-byte PNG used as a fallback when the
type ImageProxy struct { // upstream image cannot be retrieved, so browser layouts never collapse.
cfg *config.Config var transparent1x1PNG = []byte{
log *zap.Logger 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
client *http.Client 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
cacheDir string 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
allowHost map[string]struct{} 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
mu sync.Mutex 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,
} }
// NewImageProxy is the constructor. // knownImageHosts are hosts we explicitly recognize. The list is no longer
func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy { // a hard allow-list — it only short-circuits cases where we can be 100%
return &ImageProxy{ // sure the destination is a public image CDN. Other hosts are accepted as
cfg: cfg, // long as the scheme is http/https; this is required so users behind GFW
log: log, // can configure their own TMDb mirror via secrets.tmdb_image_proxy.
cacheDir: filepath.Join(cfg.Cache.CacheDir, "images"), var knownImageHosts = map[string]struct{}{
client: &http.Client{Timeout: 20 * time.Second},
allowHost: map[string]struct{}{
"image.tmdb.org": {}, "image.tmdb.org": {},
"www.themoviedb.org": {}, "www.themoviedb.org": {},
"lain.bgm.tv": {}, "lain.bgm.tv": {},
@@ -58,23 +63,82 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
"img9.doubanio.com": {}, "img9.doubanio.com": {},
"assets.fanart.tv": {}, "assets.fanart.tv": {},
"artworks.thetvdb.com": {}, "artworks.thetvdb.com": {},
},
} }
// ImageProxy fetches and caches remote images on behalf of the browser.
type ImageProxy struct {
cfg *config.Config
log *zap.Logger
client *http.Client
cacheDir string
mu sync.Mutex
}
// NewImageProxy is the constructor.
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{
cfg: cfg,
log: log,
cacheDir: filepath.Join(cfg.Cache.CacheDir, "images"),
client: &http.Client{Timeout: 30 * time.Second, Transport: transport},
}
}
// 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()
os.Remove(tmp.Name()) if rerr := os.Rename(tmp.Name(), cachePath); rerr != nil {
return err _ = os.Remove(tmp.Name())
} }
} else {
tmp.Close() tmp.Close()
if err := os.Rename(tmp.Name(), cachePath); err != nil { _ = os.Remove(tmp.Name())
os.Remove(tmp.Name())
} }
}
p.mu.Unlock()
// Now serve the freshly cached file. ctype := resp.Header.Get("Content-Type")
f, err := os.Open(cachePath) if ctype == "" {
if err != nil { ctype = detectContentType(data)
return err
} }
defer f.Close() w.Header().Set("Content-Type", ctype)
stat, _ := f.Stat() if v := resp.Header.Get("Content-Length"); v != "" {
for _, h := range []string{"Content-Type", "Content-Length", "ETag", "Last-Modified"} { w.Header().Set("Content-Length", v)
if v := resp.Header.Get(h); v != "" {
w.Header().Set(h, 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()
if resp.StatusCode >= 400 {
raw, _ := io.ReadAll(resp.Body) raw, _ := io.ReadAll(resp.Body)
return fmt.Errorf("qbittorrent add: %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) bodyText := strings.TrimSpace(string(raw))
if resp.StatusCode >= 400 {
return fmt.Errorf("qbittorrent add: HTTP %d: %s", resp.StatusCode, bodyText)
} }
// 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,
} }
} }
+200 -47
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` 头部,
// 不要再为 mteam 单独走 Authorization: Bearer,否则服务端会 401。
if cfg.APIKey != "" { if cfg.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+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,32 +855,67 @@ 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)
} }
codeStr := ""
switch v := resp["code"].(type) {
case string:
codeStr = v
case float64:
codeStr = strconv.Itoa(int(v))
} }
if codeStr == "0" || codeStr == "200" {
return nil 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,
"page": page, "pageNumber": page,
"pageSize": 50, "pageSize": 50,
} }
body, _ := json.Marshal(payload) body, _ := json.Marshal(payload)
@@ -886,12 +933,17 @@ 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)
u := cfg.URL + "/api/torrent/search" u := cfg.URL + "/api/torrent/search"
@@ -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>
+85 -49
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 && (
+153 -46
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),
async function load() {
setLoading(true)
try {
// 1) 拉所有库 + 历史
const [libs, hist] = await Promise.all([
libraryAPI.list().catch(() => [] as Library[]),
playbackAPI.recentHistory().catch(() => [] as HistoryItem[]), playbackAPI.recentHistory().catch(() => [] as HistoryItem[]),
]) ])
.then(([items, hist]) => { if (cancelled) return
setRecent(items) 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())
}