mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
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:
@@ -51,3 +51,4 @@ config.yaml
|
|||||||
|
|
||||||
# Editor backups
|
# Editor backups
|
||||||
*~
|
*~
|
||||||
|
.tmp_*
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,26 +1,134 @@
|
|||||||
// Package handler — Emby/Jellyfin compatibility shim.
|
// Package handler — Emby/Jellyfin compatibility shim.
|
||||||
//
|
//
|
||||||
// Routes are mounted under /emby/* so existing Emby-aware clients
|
// 路由挂在 /emby/* 和根路径下双前缀。Infuse / Yamby / Hills /
|
||||||
// (Infuse / VidHub / Kodi) point at MediaStationGo and discover the
|
// Senplayer / Kodi 这类客户端会自动尝试 /System/Info 与 /emby/System/Info
|
||||||
// library through their familiar API. We do not implement write paths;
|
// 两种 URL,我们都接住。
|
||||||
// the React UI stays the canonical control plane.
|
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/middleware"
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// embyError 返回 Emby 风格的错误(顶层 Code/Message)。
|
||||||
|
func embyError(c *gin.Context, status int, msg string) {
|
||||||
|
c.JSON(status, gin.H{"Code": status, "Message": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// embyUserID 从中间件中获取 user id。Emby auth middleware 写入 CtxUserID。
|
||||||
|
func embyUserID(c *gin.Context) string {
|
||||||
|
if uid, ok := c.Get(middleware.CtxUserID); ok {
|
||||||
|
if s, ok := uid.(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── System ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func embySystemInfoHandler(svc *service.Container) gin.HandlerFunc {
|
func embySystemInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, svc.Emby.SystemInfo())
|
c.JSON(http.StatusOK, svc.Emby.SystemInfo())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func embySystemInfoPublicHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, svc.Emby.SystemInfoPublic())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func embySystemEndpointHandler(_ *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"IsLocal": true,
|
||||||
|
"IsInNetwork": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func embyPingHandler(_ *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// Emby/Jellyfin 期望 plain text "Emby Server"
|
||||||
|
c.String(http.StatusOK, "Emby Server")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Users / Auth ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type embyAuthByNameReq struct {
|
||||||
|
Username string `json:"Username"`
|
||||||
|
Pw string `json:"Pw"`
|
||||||
|
Password string `json:"Password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// embyAuthByNameHandler 处理 POST /Users/AuthenticateByName。
|
||||||
|
//
|
||||||
|
// 这是 Emby 客户端登录的唯一入口(Infuse / Yamby / Hills 等都走这里)。
|
||||||
|
// 用户名+密码 → 调用我们已有的 AuthService.Login → 返回 AccessToken + User。
|
||||||
|
func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req embyAuthByNameReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
embyError(c, http.StatusBadRequest, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
password := req.Pw
|
||||||
|
if password == "" {
|
||||||
|
password = req.Password
|
||||||
|
}
|
||||||
|
resp, err := svc.Auth.Login(c.Request.Context(), req.Username, password)
|
||||||
|
if err != nil {
|
||||||
|
embyError(c, http.StatusUnauthorized, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userPayload, _ := svc.Emby.FindUser(c.Request.Context(), resp.User.ID)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"AccessToken": resp.Tokens.AccessToken,
|
||||||
|
"ServerId": "mediastation-go-001",
|
||||||
|
"User": userPayload,
|
||||||
|
"SessionInfo": gin.H{
|
||||||
|
"Id": resp.User.ID,
|
||||||
|
"UserId": resp.User.ID,
|
||||||
|
"UserName": resp.User.Username,
|
||||||
|
"Client": c.GetHeader("X-Emby-Client"),
|
||||||
|
"DeviceId": c.GetHeader("X-Emby-Device-Id"),
|
||||||
|
"DeviceName": c.GetHeader("X-Emby-Device-Name"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func embyPublicUsersHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// 公开用户列表(Emby Web 客户端登录页拉这个,列出可见用户)。
|
||||||
|
users, err := svc.Emby.ListUsers(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, []any{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 公开版本只暴露 Id + Name,不包含 Policy。
|
||||||
|
out := make([]map[string]any, 0, len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
out = append(out, map[string]any{
|
||||||
|
"Id": u["Id"],
|
||||||
|
"Name": u["Name"],
|
||||||
|
"ServerId": u["ServerId"],
|
||||||
|
"HasPassword": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func embyListUsersHandler(svc *service.Container) gin.HandlerFunc {
|
func embyListUsersHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
users, err := svc.Emby.ListUsers(c.Request.Context())
|
users, err := svc.Emby.ListUsers(c.Request.Context())
|
||||||
@@ -32,6 +140,35 @@ func embyListUsersHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func embyMeHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
uid := embyUserID(c)
|
||||||
|
if uid == "" {
|
||||||
|
embyError(c, http.StatusUnauthorized, "not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, err := svc.Emby.FindUser(c.Request.Context(), uid)
|
||||||
|
if err != nil || u == nil {
|
||||||
|
embyError(c, http.StatusNotFound, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func embyGetUserByIDHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
u, err := svc.Emby.FindUser(c.Request.Context(), c.Param("userId"))
|
||||||
|
if err != nil || u == nil {
|
||||||
|
embyError(c, http.StatusNotFound, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Views / MediaFolders ────────────────────────────────────────────────────
|
||||||
|
|
||||||
func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
|
func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
out, err := svc.Emby.Views(c.Request.Context())
|
out, err := svc.Emby.Views(c.Request.Context())
|
||||||
@@ -43,12 +180,46 @@ func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Items ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("Limit", "50"))
|
||||||
|
offset, _ := strconv.Atoi(c.DefaultQuery("StartIndex", "0"))
|
||||||
|
uid := c.Param("userId")
|
||||||
|
if uid == "" {
|
||||||
|
uid = embyUserID(c)
|
||||||
|
}
|
||||||
|
splitOpt := func(s string) []string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(s, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return service.ItemsParams{
|
||||||
|
UserID: uid,
|
||||||
|
ParentID: c.Query("ParentId"),
|
||||||
|
IDs: splitOpt(c.Query("Ids")),
|
||||||
|
SearchTerm: c.Query("SearchTerm"),
|
||||||
|
IncludeItemTypes: splitOpt(c.Query("IncludeItemTypes")),
|
||||||
|
Recursive: strings.EqualFold(c.Query("Recursive"), "true"),
|
||||||
|
SortBy: c.Query("SortBy"),
|
||||||
|
SortOrder: c.Query("SortOrder"),
|
||||||
|
Limit: limit,
|
||||||
|
StartIndex: offset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func embyItemsHandler(svc *service.Container) gin.HandlerFunc {
|
func embyItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
libraryID := c.Query("ParentId")
|
out, err := svc.Emby.Items(c.Request.Context(), parseEmbyItemsParams(c))
|
||||||
limit, _ := strconv.Atoi(c.DefaultQuery("Limit", "50"))
|
|
||||||
offset, _ := strconv.Atoi(c.DefaultQuery("StartIndex", "0"))
|
|
||||||
out, err := svc.Emby.Items(c.Request.Context(), libraryID, limit, offset)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -57,6 +228,93 @@ func embyItemsHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func embyItemByIDHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
uid := c.Param("userId")
|
||||||
|
if uid == "" {
|
||||||
|
uid = embyUserID(c)
|
||||||
|
}
|
||||||
|
out, err := svc.Emby.Item(c.Request.Context(), id, uid)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
embyError(c, http.StatusNotFound, "item not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
uid := c.Param("userId")
|
||||||
|
if uid == "" {
|
||||||
|
uid = embyUserID(c)
|
||||||
|
}
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("Limit", "20"))
|
||||||
|
out, err := svc.Emby.LatestItems(c.Request.Context(), uid, c.Query("ParentId"), limit)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
uid := c.Param("userId")
|
||||||
|
if uid == "" {
|
||||||
|
uid = embyUserID(c)
|
||||||
|
}
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("Limit", "20"))
|
||||||
|
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, limit)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Images ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// embyItemImageHandler 把 /Items/{id}/Images/Primary 等请求重定向到
|
||||||
|
// 我们的 /api/img 代理。Emby 客户端会自动追加 ?api_key=... 或 ?tag=...
|
||||||
|
// 我们只关心 id+type,从 media row 拉到 PosterURL/BackdropURL 后转成
|
||||||
|
// /api/img?url=... 重定向。
|
||||||
|
func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
imgType := strings.ToLower(c.Param("type"))
|
||||||
|
m, err := svc.Repo.Media.FindByID(c.Request.Context(), id)
|
||||||
|
if err != nil || m == nil {
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var raw string
|
||||||
|
switch imgType {
|
||||||
|
case "primary", "thumb", "banner", "logo":
|
||||||
|
raw = m.PosterURL
|
||||||
|
case "backdrop", "art":
|
||||||
|
raw = m.BackdropURL
|
||||||
|
default:
|
||||||
|
raw = m.PosterURL
|
||||||
|
}
|
||||||
|
if raw == "" {
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 直接重定向到 /api/img;image proxy 自己缓存 + 兜底 1×1 PNG。
|
||||||
|
c.Redirect(http.StatusFound, "/api/img?url="+url.QueryEscape(raw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Playback ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
out, err := svc.Emby.PlaybackInfo(c.Request.Context(), c.Param("id"))
|
out, err := svc.Emby.PlaybackInfo(c.Request.Context(), c.Param("id"))
|
||||||
@@ -65,9 +323,199 @@ func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if out == nil {
|
if out == nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
embyError(c, http.StatusNotFound, "not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, out)
|
c.JSON(http.StatusOK, out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// embyVideoStreamHandler 是 GET /Videos/{id}/stream 的入口,
|
||||||
|
// 直接代理到我们的 /api/stream/{id}(同一个 ServeFile)。
|
||||||
|
func embyVideoStreamHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// 直接调用 Stream service 写入 response
|
||||||
|
err := svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 播放进度 / 收藏 / 已看 ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type embyPlayingReq struct {
|
||||||
|
ItemId string `json:"ItemId"`
|
||||||
|
PositionTicks int64 `json:"PositionTicks"`
|
||||||
|
RunTimeTicks int64 `json:"RunTimeTicks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
uid := embyUserID(c)
|
||||||
|
if uid == "" {
|
||||||
|
c.Status(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req embyPlayingReq
|
||||||
|
_ = c.ShouldBindJSON(&req)
|
||||||
|
// 兼容 query 形式(一些客户端在 /Sessions/Playing/* 用 query)
|
||||||
|
if req.ItemId == "" {
|
||||||
|
req.ItemId = c.Query("ItemId")
|
||||||
|
}
|
||||||
|
if req.PositionTicks == 0 {
|
||||||
|
req.PositionTicks, _ = strconv.ParseInt(c.Query("PositionTicks"), 10, 64)
|
||||||
|
}
|
||||||
|
if req.RunTimeTicks == 0 {
|
||||||
|
req.RunTimeTicks, _ = strconv.ParseInt(c.Query("RunTimeTicks"), 10, 64)
|
||||||
|
}
|
||||||
|
if req.ItemId == "" {
|
||||||
|
c.Status(http.StatusOK) // Emby 期望 2xx;不是关键操作
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = svc.Emby.RecordProgress(c.Request.Context(), uid, req.ItemId, req.PositionTicks, req.RunTimeTicks)
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func embyFavoriteHandler(svc *service.Container, fav bool) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
uid := c.Param("userId")
|
||||||
|
if uid == "" {
|
||||||
|
uid = embyUserID(c)
|
||||||
|
}
|
||||||
|
mid := c.Param("itemId")
|
||||||
|
if uid == "" || mid == "" {
|
||||||
|
c.Status(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svc.Emby.SetFavorite(c.Request.Context(), uid, mid, fav); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Emby 期望返回 UserItemDataDto;最小可工作版本:echo Item 即可。
|
||||||
|
out, _ := svc.Emby.Item(c.Request.Context(), mid, uid)
|
||||||
|
if out != nil {
|
||||||
|
c.JSON(http.StatusOK, out["UserData"])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"IsFavorite": fav})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func embyMarkPlayedHandler(svc *service.Container, played bool) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
uid := c.Param("userId")
|
||||||
|
if uid == "" {
|
||||||
|
uid = embyUserID(c)
|
||||||
|
}
|
||||||
|
mid := c.Param("itemId")
|
||||||
|
if uid == "" || mid == "" {
|
||||||
|
c.Status(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svc.Emby.MarkPlayed(c.Request.Context(), uid, mid, played); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, _ := svc.Emby.Item(c.Request.Context(), mid, uid)
|
||||||
|
if out != nil {
|
||||||
|
c.JSON(http.StatusOK, out["UserData"])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"Played": played})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sessions / Branding 占位 ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func embySessionsHandler(_ *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, []any{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func embyBrandingConfigHandler(_ *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"LoginDisclaimer": "",
|
||||||
|
"CustomCss": "",
|
||||||
|
"SplashscreenEnabled": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func embyLocalizationOptionsHandler(_ *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, []map[string]any{
|
||||||
|
{"Name": "简体中文", "Value": "zh-CN"},
|
||||||
|
{"Name": "English", "Value": "en-US"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerEmbyRoutes 在 r 上挂双前缀("" + "/emby")的 Emby 兼容路由。
|
||||||
|
func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container) {
|
||||||
|
for _, prefix := range []string{"/emby", ""} {
|
||||||
|
grp := r.Group(prefix)
|
||||||
|
|
||||||
|
// 公开端点
|
||||||
|
grp.GET("/System/Info/Public", embySystemInfoPublicHandler(svc))
|
||||||
|
grp.GET("/System/Ping", embyPingHandler(svc))
|
||||||
|
grp.HEAD("/System/Ping", embyPingHandler(svc))
|
||||||
|
grp.POST("/System/Ping", embyPingHandler(svc))
|
||||||
|
grp.POST("/Users/AuthenticateByName", embyAuthByNameHandler(svc))
|
||||||
|
grp.GET("/Users/Public", embyPublicUsersHandler(svc))
|
||||||
|
grp.GET("/Branding/Configuration", embyBrandingConfigHandler(svc))
|
||||||
|
grp.GET("/Localization/Options", embyLocalizationOptionsHandler(svc))
|
||||||
|
|
||||||
|
// 图片公开(Infuse 缓存 URL 时会丢 token)
|
||||||
|
grp.GET("/Items/:id/Images/:type", embyItemImageHandler(svc))
|
||||||
|
grp.GET("/Items/:id/Images/:type/:index", embyItemImageHandler(svc))
|
||||||
|
grp.HEAD("/Items/:id/Images/:type", embyItemImageHandler(svc))
|
||||||
|
|
||||||
|
// 鉴权后端点
|
||||||
|
auth := grp.Group("", middleware.AuthRequired(jwtSecret))
|
||||||
|
auth.GET("/System/Info", embySystemInfoHandler(svc))
|
||||||
|
auth.GET("/System/Endpoint", embySystemEndpointHandler(svc))
|
||||||
|
|
||||||
|
auth.GET("/Users/Me", embyMeHandler(svc))
|
||||||
|
auth.GET("/Users", embyListUsersHandler(svc))
|
||||||
|
auth.GET("/Users/:userId", embyGetUserByIDHandler(svc))
|
||||||
|
auth.GET("/Users/:userId/Views", embyViewsHandler(svc))
|
||||||
|
auth.GET("/Library/MediaFolders", embyViewsHandler(svc))
|
||||||
|
|
||||||
|
auth.GET("/Items", embyItemsHandler(svc))
|
||||||
|
auth.GET("/Users/:userId/Items", embyItemsHandler(svc))
|
||||||
|
auth.GET("/Items/:id", embyItemByIDHandler(svc))
|
||||||
|
auth.GET("/Users/:userId/Items/Latest", embyLatestItemsHandler(svc))
|
||||||
|
auth.GET("/Users/:userId/Items/Resume", embyResumeItemsHandler(svc))
|
||||||
|
|
||||||
|
auth.GET("/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
|
||||||
|
auth.POST("/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
|
||||||
|
|
||||||
|
auth.GET("/Videos/:id/stream", embyVideoStreamHandler(svc))
|
||||||
|
auth.HEAD("/Videos/:id/stream", embyVideoStreamHandler(svc))
|
||||||
|
auth.GET("/Videos/:id/stream.:container", embyVideoStreamHandler(svc))
|
||||||
|
auth.HEAD("/Videos/:id/stream.:container", embyVideoStreamHandler(svc))
|
||||||
|
auth.GET("/Videos/:id/original", embyVideoStreamHandler(svc))
|
||||||
|
auth.GET("/Videos/:id/original.:container", embyVideoStreamHandler(svc))
|
||||||
|
|
||||||
|
auth.POST("/Sessions/Playing", embyPlayingProgressHandler(svc))
|
||||||
|
auth.POST("/Sessions/Playing/Progress", embyPlayingProgressHandler(svc))
|
||||||
|
auth.POST("/Sessions/Playing/Stopped", embyPlayingProgressHandler(svc))
|
||||||
|
|
||||||
|
auth.POST("/Users/:userId/FavoriteItems/:itemId", embyFavoriteHandler(svc, true))
|
||||||
|
auth.DELETE("/Users/:userId/FavoriteItems/:itemId", embyFavoriteHandler(svc, false))
|
||||||
|
auth.POST("/Users/:userId/PlayedItems/:itemId", embyMarkPlayedHandler(svc, true))
|
||||||
|
auth.DELETE("/Users/:userId/PlayedItems/:itemId", embyMarkPlayedHandler(svc, false))
|
||||||
|
|
||||||
|
auth.GET("/Sessions", embySessionsHandler(svc))
|
||||||
|
auth.GET("/DisplayPreferences/:id", func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"Id": c.Param("id"), "CustomPrefs": gin.H{}})
|
||||||
|
})
|
||||||
|
auth.POST("/DisplayPreferences/:id", func(c *gin.Context) {
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ func stopTranscodeHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
func imageProxyHandler(svc *service.Container) gin.HandlerFunc {
|
func imageProxyHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
raw := c.Query("url")
|
raw := c.Query("url")
|
||||||
if err := svc.ImageProxy.Serve(c.Request.Context(), c.Writer, raw); err != nil {
|
// Serve handles upstream errors internally by returning a 1×1 PNG
|
||||||
|
// placeholder, so the only error we can get back here is a malformed
|
||||||
|
// URL. In that case we still return 400 to make the misuse visible.
|
||||||
|
if err := svc.ImageProxy.Serve(c.Request.Context(), c.Writer, c.Request, raw); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-23
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,6 +15,34 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// NewSiteHTTPClient builds an http.Client honoring per-site policies:
|
||||||
|
// - timeout (seconds, defaults to 15)
|
||||||
|
// - proxy via HTTP(S)_PROXY environment variables when site.UseProxy is on
|
||||||
|
//
|
||||||
|
// When useProxy is false, the client is created without proxy plumbing so
|
||||||
|
// the request goes out direct, matching the user's checkbox intent.
|
||||||
|
func NewSiteHTTPClient(timeoutSeconds int, useProxy bool) *http.Client {
|
||||||
|
if timeoutSeconds <= 0 {
|
||||||
|
timeoutSeconds = 15
|
||||||
|
}
|
||||||
|
tr := &http.Transport{
|
||||||
|
MaxIdleConns: 16,
|
||||||
|
MaxIdleConnsPerHost: 4,
|
||||||
|
IdleConnTimeout: 60 * time.Second,
|
||||||
|
TLSHandshakeTimeout: 15 * time.Second,
|
||||||
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
|
}
|
||||||
|
if useProxy {
|
||||||
|
tr.Proxy = func(r *http.Request) (*url.URL, error) {
|
||||||
|
return http.ProxyFromEnvironment(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &http.Client{
|
||||||
|
Timeout: time.Duration(timeoutSeconds) * time.Second,
|
||||||
|
Transport: tr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// HTTPHeaderPresets returns a map of realistic browser HTTP headers.
|
// HTTPHeaderPresets returns a map of realistic browser HTTP headers.
|
||||||
// These mimic a real Chrome browser to avoid WAF/bot detection.
|
// These mimic a real Chrome browser to avoid WAF/bot detection.
|
||||||
func HTTPHeaderPresets() map[string]string {
|
func HTTPHeaderPresets() map[string]string {
|
||||||
@@ -193,11 +222,15 @@ func IsCloudflareChallenge(html string) bool {
|
|||||||
// ─── Site Connectivity Test ─────────────────────────────────────────────
|
// ─── Site Connectivity Test ─────────────────────────────────────────────
|
||||||
|
|
||||||
// TestSiteConnectivity performs a site connectivity test with browser-like headers.
|
// TestSiteConnectivity performs a site connectivity test with browser-like headers.
|
||||||
// If flareSolverrURL is non-empty, it will attempt to use FlareSolverr first.
|
// If flareSolverrURL is non-empty AND the site has BrowserEmulation turned on,
|
||||||
|
// it will attempt to use FlareSolverr first.
|
||||||
// Returns (ok, message, error).
|
// Returns (ok, message, error).
|
||||||
func TestSiteConnectivity(site *model.Site, flareSolverrURL string, timeout int, log *zap.Logger) (bool, string, error) {
|
func TestSiteConnectivity(site *model.Site, flareSolverrURL string, timeout int, log *zap.Logger) (bool, string, error) {
|
||||||
// Try FlareSolverr first if configured
|
// Try FlareSolverr first when (a) globally enabled and (b) the site
|
||||||
if flareSolverrURL != "" {
|
// asked for browser emulation. This matches the contract used by the
|
||||||
|
// search path (see service.SiteService.siteModelToConfig).
|
||||||
|
useFlare := flareSolverrURL != "" && site.BrowserEmulation
|
||||||
|
if useFlare {
|
||||||
log.Info("Trying FlareSolverr for site test", zap.String("url", site.URL))
|
log.Info("Trying FlareSolverr for site test", zap.String("url", site.URL))
|
||||||
body, err := FetchURLWithFlareSolverr(flareSolverrURL, site.URL, site.Cookie, timeout, "", log)
|
body, err := FetchURLWithFlareSolverr(flareSolverrURL, site.URL, site.Cookie, timeout, "", log)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -211,15 +244,15 @@ func TestSiteConnectivity(site *model.Site, flareSolverrURL string, timeout int,
|
|||||||
// Fall through to direct request
|
// Fall through to direct request
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct HTTP request with browser-like headers
|
// Direct HTTP request with browser-like headers. Honors HTTP(S)_PROXY
|
||||||
client := &http.Client{
|
// when the site has UseProxy enabled — this makes the "use proxy"
|
||||||
Timeout: time.Duration(timeout) * time.Second,
|
// checkbox in the UI actually do something.
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
client := NewSiteHTTPClient(timeout, site.UseProxy)
|
||||||
if len(via) >= 10 {
|
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||||
return fmt.Errorf("too many redirects")
|
if len(via) >= 10 {
|
||||||
}
|
return fmt.Errorf("too many redirects")
|
||||||
return nil
|
}
|
||||||
},
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", site.URL, nil)
|
req, err := http.NewRequest("GET", site.URL, nil)
|
||||||
@@ -253,7 +286,9 @@ func TestSiteConnectivity(site *model.Site, flareSolverrURL string, timeout int,
|
|||||||
return false, "站点被 Cloudflare/WAF 拦截,请配置 FlareSolverr 或浏览器模拟", nil
|
return false, "站点被 Cloudflare/WAF 拦截,请配置 FlareSolverr 或浏览器模拟", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Evaluate status code (same logic as before)
|
// Evaluate status code (mirror the reference Python project's semantics:
|
||||||
|
// 200 → success, 3xx → success/redirect-to-self, 401/403 → failure with
|
||||||
|
// hint to check credentials, 4xx/5xx → failure with raw status text).
|
||||||
switch {
|
switch {
|
||||||
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
||||||
return true, fmt.Sprintf("连接成功 (%s)", resp.Status), nil
|
return true, fmt.Sprintf("连接成功 (%s)", resp.Status), nil
|
||||||
@@ -262,18 +297,18 @@ func TestSiteConnectivity(site *model.Site, flareSolverrURL string, timeout int,
|
|||||||
if loc == "" {
|
if loc == "" {
|
||||||
loc = "(unknown)"
|
loc = "(unknown)"
|
||||||
}
|
}
|
||||||
return true, fmt.Sprintf("站点可达,但返回重定向至 %s", loc), nil
|
// Most PT sites redirect logged-out users to login; treat as failure.
|
||||||
|
return false, fmt.Sprintf("未登录或 Cookie 失效(重定向至 %s)", loc), nil
|
||||||
case resp.StatusCode == 401:
|
case resp.StatusCode == 401:
|
||||||
return true, "站点可达,需要认证 (HTTP 401)", nil
|
return false, "未授权(HTTP 401),请检查 API Key / Cookie", nil
|
||||||
case resp.StatusCode == 403:
|
case resp.StatusCode == 403:
|
||||||
return true, "站点可达,但访问被拒绝 — 可能被 Cloudflare/WAF 拦截 (HTTP 403)", nil
|
return false, "认证失败(HTTP 403),请检查 Cookie / API Key 或站点是否需要浏览器模拟", nil
|
||||||
case resp.StatusCode == 429:
|
case resp.StatusCode == 429:
|
||||||
return true, "站点可达,但被限流 (HTTP 429)", nil
|
return false, "请求被限流(HTTP 429),请稍后再试", nil
|
||||||
case resp.StatusCode == 503:
|
case resp.StatusCode == 503:
|
||||||
return true, "站点可达,服务暂时不可用 (HTTP 503)", nil
|
return false, "服务暂时不可用(HTTP 503)", nil
|
||||||
default:
|
default:
|
||||||
ok := resp.StatusCode >= 400 && resp.StatusCode < 500
|
return false, resp.Status, nil
|
||||||
return ok, resp.Status, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,9 +338,7 @@ func ApplySiteAuthHeaders(req *http.Request, site *model.Site) {
|
|||||||
// GetPageSource fetches a page with browser-like headers.
|
// GetPageSource fetches a page with browser-like headers.
|
||||||
// Returns (pageSource, cookies, error).
|
// Returns (pageSource, cookies, error).
|
||||||
func GetPageSource(url string, site *model.Site, timeout int, log *zap.Logger) (string, string, error) {
|
func GetPageSource(url string, site *model.Site, timeout int, log *zap.Logger) (string, string, error) {
|
||||||
client := &http.Client{
|
client := NewSiteHTTPClient(timeout, site.UseProxy)
|
||||||
Timeout: time.Duration(timeout) * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -67,19 +68,41 @@ func (d *DownloadService) Stop() {
|
|||||||
close(d.stopCh)
|
close(d.stopCh)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReloadConfig rebuilds the qBittorrent client from the system settings.
|
// ReloadConfig rebuilds the qBittorrent client from the configured
|
||||||
|
// download clients (preferred) or the legacy Setting table (fallback).
|
||||||
|
//
|
||||||
|
// 配置来源优先级:
|
||||||
|
//
|
||||||
|
// 1. download_clients 表中 type=qbittorrent 且 is_default=true 且 enabled=true
|
||||||
|
// 的行(侧边栏「下载器」页面写入的数据)。
|
||||||
|
// 2. system Setting 表中的 qbittorrent.url / username / password
|
||||||
|
// (旧版「系统设置」表单写入的数据;保留作向后兼容)。
|
||||||
|
//
|
||||||
|
// 这避免了两套配置各跑各的:之前操作员明明已经在「下载器」页面填好
|
||||||
|
// 默认 qb,但实际下载链路读的还是 Setting 表,导致一直连不上。
|
||||||
func (d *DownloadService) ReloadConfig(ctx context.Context) error {
|
func (d *DownloadService) ReloadConfig(ctx context.Context) error {
|
||||||
cfg := QBitConfig{}
|
cfg := QBitConfig{}
|
||||||
for _, key := range []struct{ from, into *string }{} {
|
|
||||||
_ = key
|
// Path 1: download_clients 表
|
||||||
|
if d.repo.DownloadClient != nil {
|
||||||
|
if c, err := d.repo.DownloadClient.FindDefault(ctx); err == nil && c != nil && c.Type == "qbittorrent" {
|
||||||
|
cfg.BaseURL = strings.TrimRight(c.Host, "/")
|
||||||
|
cfg.Username = c.Username
|
||||||
|
cfg.Password = c.Password
|
||||||
|
}
|
||||||
}
|
}
|
||||||
get := func(k string) string {
|
|
||||||
v, _ := d.repo.Setting.Get(ctx, k)
|
// Path 2: legacy Setting 表(仅在 client 表未配置时回退)
|
||||||
return v
|
if cfg.BaseURL == "" {
|
||||||
|
get := func(k string) string {
|
||||||
|
v, _ := d.repo.Setting.Get(ctx, k)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
cfg.BaseURL = get("qbittorrent.url")
|
||||||
|
cfg.Username = get("qbittorrent.username")
|
||||||
|
cfg.Password = get("qbittorrent.password")
|
||||||
}
|
}
|
||||||
cfg.BaseURL = get("qbittorrent.url")
|
|
||||||
cfg.Username = get("qbittorrent.username")
|
|
||||||
cfg.Password = get("qbittorrent.password")
|
|
||||||
d.qb.Configure(cfg)
|
d.qb.Configure(cfg)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -171,11 +194,12 @@ func (d *DownloadService) onTorrentComplete(ctx context.Context, hash string, sa
|
|||||||
if d.organizer == nil || savePath == "" {
|
if d.organizer == nil || savePath == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Check if auto-organize after download is enabled
|
// 仅当显式开启 organizer.auto_after_download 时才在下载完成后整理。
|
||||||
autoOrganize := d.organizer.isSmartClassifyEnabled(ctx)
|
// 之前的代码错误地把 organizer.smart_classify 也当成"自动整理"开关,
|
||||||
// Also check dedicated config key
|
// 让操作员只想启用"分类子目录"就被动触发了文件 move。
|
||||||
|
autoOrganize := false
|
||||||
if v, err := d.repo.Setting.Get(ctx, "organizer.auto_after_download"); err == nil {
|
if v, err := d.repo.Setting.Get(ctx, "organizer.auto_after_download"); err == nil {
|
||||||
autoOrganize = autoOrganize || v == "true" || v == "1" || v == "on"
|
autoOrganize = v == "true" || v == "1" || v == "on"
|
||||||
}
|
}
|
||||||
if !autoOrganize {
|
if !autoOrganize {
|
||||||
d.log.Info("download completed, auto-organize disabled", zap.String("hash", hash))
|
d.log.Info("download completed, auto-organize disabled", zap.String("hash", hash))
|
||||||
|
|||||||
+504
-77
@@ -1,33 +1,36 @@
|
|||||||
// Package service — minimal Emby/Jellyfin compatibility shim.
|
// Package service — Emby/Jellyfin compatibility shim.
|
||||||
//
|
//
|
||||||
// EmbyService produces JSON envelopes shaped like the most-consumed
|
// EmbyService produces JSON envelopes shaped like the most-consumed
|
||||||
// Emby-API endpoints so existing players (Infuse / Kodi NextPVR
|
// Emby-API endpoints so existing players (Infuse / Yamby / Hills /
|
||||||
// extension / iOS native clients) can talk to MediaStationGo without a
|
// Senplayer / Kodi NextPVR extension / iOS native clients) can talk to
|
||||||
// custom plugin.
|
// MediaStationGo without a custom plugin.
|
||||||
//
|
//
|
||||||
// Implemented surface (matches what nowen-video exposes):
|
// The shim is read-mostly: items, images, playback are fully covered;
|
||||||
//
|
//播放进度上报 / 收藏切换 是写路径但走我们自己的 PlaybackHistory /
|
||||||
// GET /emby/System/Info server identity
|
// Favorite 表,所以 Emby 客户端的"标记已看 / 收藏"也会反向同步到
|
||||||
// GET /emby/Users list of users (admin only field)
|
// 我们自己的 React UI。
|
||||||
// GET /emby/Users/{userId}/Views virtual root: one entry per library
|
|
||||||
// GET /emby/Users/{userId}/Items paginated media listing
|
|
||||||
// GET /emby/Items/{id} single item
|
|
||||||
// GET /emby/Items/{id}/PlaybackInfo stream URL (delegates to /api/stream)
|
|
||||||
//
|
|
||||||
// The shim is read-only — Emby write operations (mark watched, etc.) are
|
|
||||||
// not implemented; the React UI stays the canonical control plane.
|
|
||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 用一个固定的 ServerId 字符串。Emby 客户端会缓存这个 id,第一次见到
|
||||||
|
// 该 id 后会把所有派生数据(cookie/收藏/历史)和它绑定。
|
||||||
|
const embyServerID = "mediastation-go-001"
|
||||||
|
|
||||||
// EmbyService produces Emby-shaped JSON.
|
// EmbyService produces Emby-shaped JSON.
|
||||||
type EmbyService struct {
|
type EmbyService struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
@@ -40,17 +43,46 @@ func NewEmbyService(cfg *config.Config, log *zap.Logger, repo *repository.Contai
|
|||||||
return &EmbyService{cfg: cfg, log: log, repo: repo}
|
return &EmbyService{cfg: cfg, log: log, repo: repo}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SystemInfo returns the Emby identity payload.
|
// ─── System ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// SystemInfo returns the full Emby identity payload.
|
||||||
func (e *EmbyService) SystemInfo() map[string]any {
|
func (e *EmbyService) SystemInfo() map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"ServerName": "MediaStationGo",
|
"Id": embyServerID,
|
||||||
"Version": "0.1.0",
|
"ServerName": "MediaStationGo",
|
||||||
"Id": "mediastation-go",
|
"Version": "10.8.13",
|
||||||
"OperatingSystem": "Linux",
|
"ProductName": "MediaStationGo",
|
||||||
"ProductName": "MediaStationGo",
|
"OperatingSystem": "Linux",
|
||||||
|
"Architecture": "X64",
|
||||||
|
"LocalAddress": "",
|
||||||
|
"WanAddress": "",
|
||||||
|
"HasPendingRestart": false,
|
||||||
|
"IsShuttingDown": false,
|
||||||
|
"SupportsLibraryMonitor": true,
|
||||||
|
"SupportsHttps": false,
|
||||||
|
"SupportsAutoDiscovery": true,
|
||||||
|
"WebSocketPortNumber": e.cfg.App.Port,
|
||||||
|
"CompletedInstallations": []any{},
|
||||||
|
"CanSelfRestart": false,
|
||||||
|
"CanLaunchWebBrowser": false,
|
||||||
|
"CanRestart": false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SystemInfoPublic 是不需要认证的精简版(Emby Web 客户端登陆前会拉)。
|
||||||
|
func (e *EmbyService) SystemInfoPublic() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"Id": embyServerID,
|
||||||
|
"ServerName": "MediaStationGo",
|
||||||
|
"Version": "10.8.13",
|
||||||
|
"ProductName": "MediaStationGo",
|
||||||
|
"OperatingSystem": "Linux",
|
||||||
|
"StartupWizardCompleted": true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Users ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// ListUsers returns Emby-shaped users.
|
// ListUsers returns Emby-shaped users.
|
||||||
func (e *EmbyService) ListUsers(ctx context.Context) ([]map[string]any, error) {
|
func (e *EmbyService) ListUsers(ctx context.Context) ([]map[string]any, error) {
|
||||||
users, err := e.repo.User.List(ctx)
|
users, err := e.repo.User.List(ctx)
|
||||||
@@ -64,23 +96,62 @@ func (e *EmbyService) ListUsers(ctx context.Context) ([]map[string]any, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindUser 用 ID 查用户,用于 /Users/Me 与 /Users/{id}。
|
||||||
|
func (e *EmbyService) FindUser(ctx context.Context, id string) (map[string]any, error) {
|
||||||
|
u, err := e.repo.User.FindByID(ctx, id)
|
||||||
|
if err != nil || u == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return e.userPayload(u), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (e *EmbyService) userPayload(u *model.User) map[string]any {
|
func (e *EmbyService) userPayload(u *model.User) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"Id": u.ID,
|
"Id": u.ID,
|
||||||
"Name": u.Username,
|
"Name": u.Username,
|
||||||
"ServerId": "mediastation-go",
|
"ServerId": embyServerID,
|
||||||
"HasPassword": true,
|
"ServerName": "MediaStationGo",
|
||||||
|
"HasPassword": true,
|
||||||
|
"HasConfiguredPassword": true,
|
||||||
"HasConfiguredEasyPassword": false,
|
"HasConfiguredEasyPassword": false,
|
||||||
|
"EnableAutoLogin": false,
|
||||||
|
"LastLoginDate": u.LastLoginAt,
|
||||||
|
"LastActivityDate": u.UpdatedAt,
|
||||||
|
"Configuration": map[string]any{
|
||||||
|
"PlayDefaultAudioTrack": true,
|
||||||
|
"DisplayCollectionsView": true,
|
||||||
|
"DisplayMissingEpisodes": false,
|
||||||
|
"SubtitleMode": "Default",
|
||||||
|
"EnableNextEpisodeAutoPlay": true,
|
||||||
|
"AudioLanguagePreference": "",
|
||||||
|
"SubtitleLanguagePreference": "",
|
||||||
|
},
|
||||||
"Policy": map[string]any{
|
"Policy": map[string]any{
|
||||||
"IsAdministrator": u.Role == "admin",
|
"IsAdministrator": u.Role == "admin",
|
||||||
"IsHidden": false,
|
"IsHidden": false,
|
||||||
"IsDisabled": false,
|
"IsDisabled": !u.IsActive,
|
||||||
"EnableUserPreferenceAccess": true,
|
"EnableUserPreferenceAccess": true,
|
||||||
|
"EnableRemoteAccess": true,
|
||||||
|
"EnableMediaPlayback": true,
|
||||||
|
"EnableAudioPlaybackTranscoding": true,
|
||||||
|
"EnableVideoPlaybackTranscoding": true,
|
||||||
|
"EnablePlaybackRemuxing": true,
|
||||||
|
"EnableLiveTvAccess": false,
|
||||||
|
"EnableContentDownloading": true,
|
||||||
|
"EnableSyncTranscoding": true,
|
||||||
|
"EnableMediaConversion": true,
|
||||||
|
"EnableAllChannels": true,
|
||||||
|
"EnableAllFolders": true,
|
||||||
|
"EnableAllDevices": true,
|
||||||
|
"AuthenticationProviderId": "Emby.Server.Implementations.LocalAuthenticationProvider",
|
||||||
|
"PasswordResetProviderId": "Emby.Server.Implementations.LocalPasswordResetProvider",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Views (Emby's name for libraries).
|
// ─── Views / MediaFolders ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Views 返回 Emby 中"虚拟根目录"——每个 library 一个条目。
|
||||||
func (e *EmbyService) Views(ctx context.Context) (map[string]any, error) {
|
func (e *EmbyService) Views(ctx context.Context) (map[string]any, error) {
|
||||||
libs, err := e.repo.Library.List(ctx)
|
libs, err := e.repo.Library.List(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -88,98 +159,454 @@ func (e *EmbyService) Views(ctx context.Context) (map[string]any, error) {
|
|||||||
}
|
}
|
||||||
items := make([]map[string]any, 0, len(libs))
|
items := make([]map[string]any, 0, len(libs))
|
||||||
for _, l := range libs {
|
for _, l := range libs {
|
||||||
collectionType := "movies"
|
items = append(items, e.libraryAsView(&l))
|
||||||
if l.Type == "tv" {
|
|
||||||
collectionType = "tvshows"
|
|
||||||
} else if l.Type == "music" {
|
|
||||||
collectionType = "music"
|
|
||||||
}
|
|
||||||
items = append(items, map[string]any{
|
|
||||||
"Id": l.ID,
|
|
||||||
"Name": l.Name,
|
|
||||||
"CollectionType": collectionType,
|
|
||||||
"ServerId": "mediastation-go",
|
|
||||||
"Type": "CollectionFolder",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return map[string]any{"Items": items, "TotalRecordCount": len(items)}, nil
|
return map[string]any{"Items": items, "TotalRecordCount": len(items)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Items paginates media in Emby's flat shape.
|
func (e *EmbyService) libraryAsView(l *model.Library) map[string]any {
|
||||||
func (e *EmbyService) Items(ctx context.Context, libraryID string, limit, offset int) (map[string]any, error) {
|
collectionType := "movies"
|
||||||
if limit <= 0 || limit > 200 {
|
switch l.Type {
|
||||||
limit = 50
|
case "tv":
|
||||||
|
collectionType = "tvshows"
|
||||||
|
case "anime":
|
||||||
|
collectionType = "tvshows" // Emby 没有专门的 anime CollectionType
|
||||||
|
case "music":
|
||||||
|
collectionType = "music"
|
||||||
}
|
}
|
||||||
if offset < 0 {
|
return map[string]any{
|
||||||
offset = 0
|
"Id": l.ID,
|
||||||
|
"Name": l.Name,
|
||||||
|
"CollectionType": collectionType,
|
||||||
|
"ServerId": embyServerID,
|
||||||
|
"Type": "CollectionFolder",
|
||||||
|
"IsFolder": true,
|
||||||
|
"ImageTags": map[string]string{},
|
||||||
|
"BackdropImageTags": []string{},
|
||||||
|
"UserData": map[string]any{
|
||||||
|
"PlaybackPositionTicks": 0,
|
||||||
|
"PlayCount": 0,
|
||||||
|
"IsFavorite": false,
|
||||||
|
"Played": false,
|
||||||
|
"UnplayedItemCount": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Items ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ItemsParams 是 /Items 与 /Users/{uid}/Items 共用的查询参数。
|
||||||
|
type ItemsParams struct {
|
||||||
|
UserID string
|
||||||
|
ParentID string
|
||||||
|
IDs []string
|
||||||
|
SearchTerm string
|
||||||
|
IncludeItemTypes []string
|
||||||
|
Recursive bool
|
||||||
|
SortBy string
|
||||||
|
SortOrder string
|
||||||
|
Limit int
|
||||||
|
StartIndex int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Items paginates media in Emby's flat shape.
|
||||||
|
func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any, error) {
|
||||||
|
if p.Limit <= 0 || p.Limit > 500 {
|
||||||
|
p.Limit = 50
|
||||||
|
}
|
||||||
|
if p.StartIndex < 0 {
|
||||||
|
p.StartIndex = 0
|
||||||
}
|
}
|
||||||
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
|
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
|
||||||
if libraryID != "" {
|
if p.ParentID != "" {
|
||||||
q = q.Where("library_id = ?", libraryID)
|
// ParentID 既可能是 library_id 也可能是 series_id(剧集详情下钻)
|
||||||
|
q = q.Where("library_id = ? OR series_id = ?", p.ParentID, p.ParentID)
|
||||||
}
|
}
|
||||||
|
if len(p.IDs) > 0 {
|
||||||
|
q = q.Where("id IN ?", p.IDs)
|
||||||
|
}
|
||||||
|
if p.SearchTerm != "" {
|
||||||
|
q = q.Where("title LIKE ?", "%"+p.SearchTerm+"%")
|
||||||
|
}
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
if err := q.Count(&total).Error; err != nil {
|
if err := q.Count(&total).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 排序
|
||||||
|
order := "created_at desc"
|
||||||
|
switch strings.ToLower(p.SortBy) {
|
||||||
|
case "sortname", "name":
|
||||||
|
order = "title"
|
||||||
|
case "premieredate", "productionyear":
|
||||||
|
order = "year"
|
||||||
|
case "datecreated":
|
||||||
|
order = "created_at"
|
||||||
|
case "communityrating":
|
||||||
|
order = "rating"
|
||||||
|
}
|
||||||
|
if strings.EqualFold(p.SortOrder, "Descending") {
|
||||||
|
if !strings.HasSuffix(order, " desc") {
|
||||||
|
order = order + " desc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var rows []model.Media
|
var rows []model.Media
|
||||||
if err := q.Order("created_at desc").Offset(offset).Limit(limit).Find(&rows).Error; err != nil {
|
if err := q.Order(order).Offset(p.StartIndex).Limit(p.Limit).Find(&rows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User-data: 收藏 + 进度
|
||||||
|
userFavs := map[string]bool{}
|
||||||
|
userPos := map[string]int64{}
|
||||||
|
if p.UserID != "" {
|
||||||
|
var favs []model.Favorite
|
||||||
|
_ = e.repo.DB.WithContext(ctx).Where("user_id = ?", p.UserID).Find(&favs).Error
|
||||||
|
for _, f := range favs {
|
||||||
|
userFavs[f.MediaID] = true
|
||||||
|
}
|
||||||
|
var hist []model.PlaybackHistory
|
||||||
|
_ = e.repo.DB.WithContext(ctx).Where("user_id = ?", p.UserID).Find(&hist).Error
|
||||||
|
for _, h := range hist {
|
||||||
|
userPos[h.MediaID] = h.PositionMs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
items := make([]map[string]any, 0, len(rows))
|
items := make([]map[string]any, 0, len(rows))
|
||||||
for _, m := range rows {
|
for _, m := range rows {
|
||||||
items = append(items, e.itemPayload(&m))
|
items = append(items, e.itemPayload(&m, userFavs[m.ID], userPos[m.ID]))
|
||||||
}
|
}
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"Items": items,
|
"Items": items,
|
||||||
"TotalRecordCount": total,
|
"TotalRecordCount": total,
|
||||||
"StartIndex": offset,
|
"StartIndex": p.StartIndex,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *EmbyService) itemPayload(m *model.Media) map[string]any {
|
// Item 单条目详情。
|
||||||
|
func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[string]any, error) {
|
||||||
|
m, err := e.repo.Media.FindByID(ctx, mediaID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if m == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
fav := false
|
||||||
|
pos := int64(0)
|
||||||
|
if userID != "" {
|
||||||
|
var f model.Favorite
|
||||||
|
ferr := e.repo.DB.WithContext(ctx).Where("user_id = ? AND media_id = ?", userID, mediaID).First(&f).Error
|
||||||
|
if ferr == nil {
|
||||||
|
fav = true
|
||||||
|
}
|
||||||
|
var h model.PlaybackHistory
|
||||||
|
herr := e.repo.DB.WithContext(ctx).Where("user_id = ? AND media_id = ?", userID, mediaID).
|
||||||
|
Order("watched_at desc").First(&h).Error
|
||||||
|
if herr == nil {
|
||||||
|
pos = h.PositionMs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return e.itemPayload(m, fav, pos), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatestItems 最近添加,全库或指定库。
|
||||||
|
func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string, limit int) ([]map[string]any, error) {
|
||||||
|
if limit <= 0 || limit > 100 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
|
||||||
|
if parentID != "" {
|
||||||
|
q = q.Where("library_id = ?", parentID)
|
||||||
|
}
|
||||||
|
var rows []model.Media
|
||||||
|
if err := q.Order("created_at desc").Limit(limit).Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
favs := map[string]bool{}
|
||||||
|
if userID != "" {
|
||||||
|
var fr []model.Favorite
|
||||||
|
_ = e.repo.DB.WithContext(ctx).Where("user_id = ?", userID).Find(&fr).Error
|
||||||
|
for _, f := range fr {
|
||||||
|
favs[f.MediaID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]map[string]any, 0, len(rows))
|
||||||
|
for _, m := range rows {
|
||||||
|
out = append(out, e.itemPayload(&m, favs[m.ID], 0))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResumeItems 列出有未完成播放进度的媒体。
|
||||||
|
func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int) (map[string]any, error) {
|
||||||
|
if limit <= 0 || limit > 100 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
type row struct {
|
||||||
|
MediaID string
|
||||||
|
PositionMs int64
|
||||||
|
DurationMs int64
|
||||||
|
}
|
||||||
|
var hist []model.PlaybackHistory
|
||||||
|
if err := e.repo.DB.WithContext(ctx).
|
||||||
|
Where("user_id = ? AND completed = ? AND position_ms > 0", userID, false).
|
||||||
|
Order("watched_at desc").Limit(limit).Find(&hist).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(hist) == 0 {
|
||||||
|
return map[string]any{"Items": []any{}, "TotalRecordCount": 0}, nil
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(hist))
|
||||||
|
posByID := map[string]int64{}
|
||||||
|
for _, h := range hist {
|
||||||
|
ids = append(ids, h.MediaID)
|
||||||
|
posByID[h.MediaID] = h.PositionMs
|
||||||
|
}
|
||||||
|
var medias []model.Media
|
||||||
|
if err := e.repo.DB.WithContext(ctx).Where("id IN ?", ids).Find(&medias).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 维持时间倒序
|
||||||
|
byID := map[string]*model.Media{}
|
||||||
|
for i := range medias {
|
||||||
|
byID[medias[i].ID] = &medias[i]
|
||||||
|
}
|
||||||
|
items := make([]map[string]any, 0, len(hist))
|
||||||
|
for _, h := range hist {
|
||||||
|
if m, ok := byID[h.MediaID]; ok {
|
||||||
|
items = append(items, e.itemPayload(m, false, posByID[h.MediaID]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string]any{"Items": items, "TotalRecordCount": len(items)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EmbyService) itemPayload(m *model.Media, fav bool, posMs int64) map[string]any {
|
||||||
itemType := "Movie"
|
itemType := "Movie"
|
||||||
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
|
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
|
||||||
itemType = "Episode"
|
itemType = "Episode"
|
||||||
}
|
}
|
||||||
|
imageTags := map[string]string{}
|
||||||
|
backdropTags := []string{}
|
||||||
|
if m.PosterURL != "" {
|
||||||
|
imageTags["Primary"] = m.ID
|
||||||
|
}
|
||||||
|
if m.BackdropURL != "" {
|
||||||
|
backdropTags = append(backdropTags, m.ID+"-bd")
|
||||||
|
}
|
||||||
|
|
||||||
|
runTimeTicks := int64(m.DurationSec) * 10_000_000
|
||||||
|
durationMs := int64(m.DurationSec) * 1000
|
||||||
|
played := posMs > 0 && durationMs > 0 && posMs >= durationMs*9/10
|
||||||
|
pct := 0.0
|
||||||
|
if durationMs > 0 {
|
||||||
|
pct = float64(posMs) / float64(durationMs) * 100
|
||||||
|
}
|
||||||
|
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"Id": m.ID,
|
"Id": m.ID,
|
||||||
"Name": m.Title,
|
"Name": m.Title,
|
||||||
"ServerId": "mediastation-go",
|
"OriginalTitle": m.OriginalName,
|
||||||
|
"ServerId": embyServerID,
|
||||||
"Type": itemType,
|
"Type": itemType,
|
||||||
|
"MediaType": "Video",
|
||||||
|
"IsFolder": false,
|
||||||
"ProductionYear": m.Year,
|
"ProductionYear": m.Year,
|
||||||
"ParentIndexNumber": m.SeasonNum,
|
"ParentIndexNumber": m.SeasonNum,
|
||||||
"IndexNumber": m.EpisodeNum,
|
"IndexNumber": m.EpisodeNum,
|
||||||
"Overview": m.Overview,
|
"Overview": m.Overview,
|
||||||
"RunTimeTicks": int64(m.DurationSec) * 10_000_000,
|
"RunTimeTicks": runTimeTicks,
|
||||||
"CommunityRating": m.Rating,
|
"CommunityRating": m.Rating,
|
||||||
"MediaSources": []map[string]any{{
|
"Container": m.Container,
|
||||||
"Id": m.ID,
|
"Width": m.Width,
|
||||||
"Path": m.Path,
|
"Height": m.Height,
|
||||||
"Container": m.Container,
|
"DateCreated": m.CreatedAt,
|
||||||
"Size": m.SizeBytes,
|
"Path": m.Path,
|
||||||
}},
|
"ParentId": m.LibraryID,
|
||||||
|
"SeriesId": m.SeriesID,
|
||||||
|
"ImageTags": imageTags,
|
||||||
|
"BackdropImageTags": backdropTags,
|
||||||
|
"Genres": splitCSV(m.Genres),
|
||||||
|
"ProviderIds": map[string]string{
|
||||||
|
"Tmdb": intToStr(m.TMDbID),
|
||||||
|
"Bangumi": intToStr(m.BangumiID),
|
||||||
|
},
|
||||||
|
"UserData": map[string]any{
|
||||||
|
"PlaybackPositionTicks": posMs * 10_000,
|
||||||
|
"PlayCount": 0,
|
||||||
|
"IsFavorite": fav,
|
||||||
|
"Played": played,
|
||||||
|
"PlayedPercentage": pct,
|
||||||
|
},
|
||||||
|
"MediaSources": []map[string]any{e.mediaSource(m, true)},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlaybackInfo returns the stream URL (caller must append ?token=).
|
// ─── Playback ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// PlaybackInfo returns a PlaybackInfoResponse usable by Emby clients.
|
||||||
func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID string) (map[string]any, error) {
|
func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID string) (map[string]any, error) {
|
||||||
m, err := e.repo.Media.FindByID(ctx, mediaID)
|
m, err := e.repo.Media.FindByID(ctx, mediaID)
|
||||||
if err != nil || m == nil {
|
if err != nil || m == nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
url := "/api/stream/" + m.ID
|
|
||||||
if m.STRMURL != "" {
|
|
||||||
url = m.STRMURL
|
|
||||||
}
|
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"MediaSources": []map[string]any{{
|
"MediaSources": []map[string]any{e.mediaSource(m, false)},
|
||||||
"Id": m.ID,
|
"PlaySessionId": fmt.Sprintf("%s-%d", m.ID, time.Now().Unix()),
|
||||||
"Path": url,
|
|
||||||
"Protocol": "Http",
|
|
||||||
"DirectStreamUrl": url,
|
|
||||||
"Container": m.Container,
|
|
||||||
"Size": m.SizeBytes,
|
|
||||||
}},
|
|
||||||
"PlaySessionId": m.ID,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mediaSource 是 /Items 与 /PlaybackInfo 共享的 MediaSource 结构。
|
||||||
|
//
|
||||||
|
// asEmbedded=true:嵌在 /Items 列表里,不包含完整 stream URL(避免暴露
|
||||||
|
// 直链给搜索接口)。/PlaybackInfo 走 false 路径,URL 完整指向
|
||||||
|
// /api/stream/{id}(Emby 客户端会自动 append ?api_key=token)。
|
||||||
|
func (e *EmbyService) mediaSource(m *model.Media, asEmbedded bool) map[string]any {
|
||||||
|
src := map[string]any{
|
||||||
|
"Id": m.ID,
|
||||||
|
"Name": m.Title,
|
||||||
|
"Path": m.Path,
|
||||||
|
"Container": m.Container,
|
||||||
|
"Size": m.SizeBytes,
|
||||||
|
"Protocol": "Http",
|
||||||
|
"Type": "Default",
|
||||||
|
"IsRemote": false,
|
||||||
|
"SupportsTranscoding": true,
|
||||||
|
"SupportsDirectStream": true,
|
||||||
|
"SupportsDirectPlay": true,
|
||||||
|
"SupportsProbing": true,
|
||||||
|
"RunTimeTicks": int64(m.DurationSec) * 10_000_000,
|
||||||
|
"MediaStreams": e.mediaStreams(m),
|
||||||
|
}
|
||||||
|
if !asEmbedded {
|
||||||
|
// 完整 URL,让 Infuse 直接 GET。Emby 客户端会自动加 ?api_key=token。
|
||||||
|
src["DirectStreamUrl"] = "/api/stream/" + m.ID
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(m.STRMURL) != "" {
|
||||||
|
// STRM 重定向:客户端直接拉远端,跳过我们这一层。
|
||||||
|
src["IsRemote"] = true
|
||||||
|
src["DirectStreamUrl"] = m.STRMURL
|
||||||
|
src["Path"] = m.STRMURL
|
||||||
|
}
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EmbyService) mediaStreams(m *model.Media) []map[string]any {
|
||||||
|
streams := []map[string]any{}
|
||||||
|
if m.VideoCodec != "" || m.Width > 0 {
|
||||||
|
streams = append(streams, map[string]any{
|
||||||
|
"Codec": m.VideoCodec,
|
||||||
|
"Type": "Video",
|
||||||
|
"Index": 0,
|
||||||
|
"Width": m.Width,
|
||||||
|
"Height": m.Height,
|
||||||
|
"AspectRatio": "",
|
||||||
|
"IsDefault": true,
|
||||||
|
"IsForced": false,
|
||||||
|
"IsExternal": false,
|
||||||
|
"DisplayTitle": fmt.Sprintf("%dx%d %s", m.Width, m.Height, m.VideoCodec),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if m.AudioCodec != "" {
|
||||||
|
streams = append(streams, map[string]any{
|
||||||
|
"Codec": m.AudioCodec,
|
||||||
|
"Type": "Audio",
|
||||||
|
"Index": 1,
|
||||||
|
"IsDefault": true,
|
||||||
|
"IsForced": false,
|
||||||
|
"IsExternal": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return streams
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 收藏 / 已看(Emby 客户端写路径) ──────────────────────────────────────
|
||||||
|
|
||||||
|
// SetFavorite 把 mediaID 标为 userID 的收藏。
|
||||||
|
func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, favorite bool) error {
|
||||||
|
if favorite {
|
||||||
|
var f model.Favorite
|
||||||
|
err := e.repo.DB.WithContext(ctx).
|
||||||
|
Where("user_id = ? AND media_id = ?", userID, mediaID).First(&f).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return e.repo.DB.WithContext(ctx).Create(&model.Favorite{
|
||||||
|
UserID: userID, MediaID: mediaID,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.repo.DB.WithContext(ctx).
|
||||||
|
Where("user_id = ? AND media_id = ?", userID, mediaID).
|
||||||
|
Delete(&model.Favorite{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkPlayed 把 mediaID 标为已看(写一个 100% 进度的 history 行)。
|
||||||
|
func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, played bool) error {
|
||||||
|
if !played {
|
||||||
|
return e.repo.DB.WithContext(ctx).
|
||||||
|
Where("user_id = ? AND media_id = ?", userID, mediaID).
|
||||||
|
Delete(&model.PlaybackHistory{}).Error
|
||||||
|
}
|
||||||
|
m, err := e.repo.Media.FindByID(ctx, mediaID)
|
||||||
|
if err != nil || m == nil {
|
||||||
|
return errors.New("media not found")
|
||||||
|
}
|
||||||
|
dur := int64(m.DurationSec) * 1000
|
||||||
|
if dur <= 0 {
|
||||||
|
dur = 1
|
||||||
|
}
|
||||||
|
return e.repo.History.Upsert(ctx, &model.PlaybackHistory{
|
||||||
|
UserID: userID,
|
||||||
|
MediaID: mediaID,
|
||||||
|
PositionMs: dur,
|
||||||
|
DurationMs: dur,
|
||||||
|
WatchedAt: time.Now(),
|
||||||
|
Completed: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordProgress 记录播放进度(来自 Emby 客户端的 /Sessions/Playing/Progress)。
|
||||||
|
func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string, positionTicks, runtimeTicks int64) error {
|
||||||
|
pos := positionTicks / 10_000
|
||||||
|
dur := runtimeTicks / 10_000
|
||||||
|
if dur <= 0 {
|
||||||
|
// runtimeTicks 缺失时回退到 media.DurationSec
|
||||||
|
if m, _ := e.repo.Media.FindByID(ctx, mediaID); m != nil {
|
||||||
|
dur = int64(m.DurationSec) * 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
completed := dur > 0 && pos >= dur*9/10
|
||||||
|
return e.repo.History.Upsert(ctx, &model.PlaybackHistory{
|
||||||
|
UserID: userID,
|
||||||
|
MediaID: mediaID,
|
||||||
|
PositionMs: pos,
|
||||||
|
DurationMs: dur,
|
||||||
|
WatchedAt: time.Now(),
|
||||||
|
Completed: completed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func splitCSV(s string) []string {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
parts := strings.Split(s, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func intToStr(v int) string {
|
||||||
|
if v == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strconv.Itoa(v)
|
||||||
|
}
|
||||||
|
|||||||
+171
-92
@@ -4,13 +4,16 @@
|
|||||||
// networks). ImageProxy fronts a remote image URL so the browser only ever
|
// networks). ImageProxy fronts a remote image URL so the browser only ever
|
||||||
// talks to the MediaStationGo origin. The proxy:
|
// talks to the MediaStationGo origin. The proxy:
|
||||||
//
|
//
|
||||||
// - validates the URL belongs to a small allow-list of trusted hosts,
|
// - validates the URL scheme is http/https,
|
||||||
// - streams bytes through with a small disk cache under cache/images,
|
// - streams bytes through with a small disk cache under cache/images,
|
||||||
// - falls back to a transparent 1×1 PNG on upstream failure so the UI
|
// - falls back to a transparent 1×1 PNG on upstream failure so the UI
|
||||||
// never breaks layout.
|
// never breaks layout,
|
||||||
|
// - honors HTTP(S)_PROXY environment variables so users behind GFW can
|
||||||
|
// route image fetches through their proxy.
|
||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -29,52 +32,113 @@ import (
|
|||||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// transparent1x1PNG is a baseline 67-byte PNG used as a fallback when the
|
||||||
|
// upstream image cannot be retrieved, so browser layouts never collapse.
|
||||||
|
var transparent1x1PNG = []byte{
|
||||||
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||||
|
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||||
|
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||||
|
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
||||||
|
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41,
|
||||||
|
0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
|
||||||
|
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
|
||||||
|
0x42, 0x60, 0x82,
|
||||||
|
}
|
||||||
|
|
||||||
|
// knownImageHosts are hosts we explicitly recognize. The list is no longer
|
||||||
|
// a hard allow-list — it only short-circuits cases where we can be 100%
|
||||||
|
// sure the destination is a public image CDN. Other hosts are accepted as
|
||||||
|
// long as the scheme is http/https; this is required so users behind GFW
|
||||||
|
// can configure their own TMDb mirror via secrets.tmdb_image_proxy.
|
||||||
|
var knownImageHosts = map[string]struct{}{
|
||||||
|
"image.tmdb.org": {},
|
||||||
|
"www.themoviedb.org": {},
|
||||||
|
"lain.bgm.tv": {},
|
||||||
|
"img.bgm.tv": {},
|
||||||
|
"webdav.bgm.tv": {},
|
||||||
|
"img1.doubanio.com": {},
|
||||||
|
"img2.doubanio.com": {},
|
||||||
|
"img3.doubanio.com": {},
|
||||||
|
"img9.doubanio.com": {},
|
||||||
|
"assets.fanart.tv": {},
|
||||||
|
"artworks.thetvdb.com": {},
|
||||||
|
}
|
||||||
|
|
||||||
// ImageProxy fetches and caches remote images on behalf of the browser.
|
// ImageProxy fetches and caches remote images on behalf of the browser.
|
||||||
type ImageProxy struct {
|
type ImageProxy struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
client *http.Client
|
client *http.Client
|
||||||
cacheDir string
|
cacheDir string
|
||||||
allowHost map[string]struct{}
|
mu sync.Mutex
|
||||||
mu sync.Mutex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewImageProxy is the constructor.
|
// NewImageProxy is the constructor.
|
||||||
func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
|
func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
|
||||||
|
// Honor HTTP(S)_PROXY env vars so deployments behind GFW can pull
|
||||||
|
// from image.tmdb.org via their HTTP proxy without extra config.
|
||||||
|
transport := &http.Transport{
|
||||||
|
Proxy: http.ProxyFromEnvironment,
|
||||||
|
MaxIdleConns: 32,
|
||||||
|
MaxIdleConnsPerHost: 8,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
TLSHandshakeTimeout: 15 * time.Second,
|
||||||
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
|
}
|
||||||
return &ImageProxy{
|
return &ImageProxy{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
log: log,
|
log: log,
|
||||||
cacheDir: filepath.Join(cfg.Cache.CacheDir, "images"),
|
cacheDir: filepath.Join(cfg.Cache.CacheDir, "images"),
|
||||||
client: &http.Client{Timeout: 20 * time.Second},
|
client: &http.Client{Timeout: 30 * time.Second, Transport: transport},
|
||||||
allowHost: map[string]struct{}{
|
|
||||||
"image.tmdb.org": {},
|
|
||||||
"www.themoviedb.org": {},
|
|
||||||
"lain.bgm.tv": {},
|
|
||||||
"img.bgm.tv": {},
|
|
||||||
"webdav.bgm.tv": {},
|
|
||||||
"img1.doubanio.com": {},
|
|
||||||
"img2.doubanio.com": {},
|
|
||||||
"img3.doubanio.com": {},
|
|
||||||
"img9.doubanio.com": {},
|
|
||||||
"assets.fanart.tv": {},
|
|
||||||
"artworks.thetvdb.com": {},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateURL parses raw and ensures the scheme is http/https. The host
|
||||||
|
// allow-list is now advisory — every reachable URL is accepted so users
|
||||||
|
// can freely configure mirror domains via tmdb_image_proxy.
|
||||||
|
func (p *ImageProxy) validateURL(raw string) (*url.URL, error) {
|
||||||
|
if raw == "" {
|
||||||
|
return nil, errors.New("missing url")
|
||||||
|
}
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil || u.Host == "" {
|
||||||
|
return nil, errors.New("invalid url")
|
||||||
|
}
|
||||||
|
scheme := strings.ToLower(u.Scheme)
|
||||||
|
if scheme != "http" && scheme != "https" {
|
||||||
|
return nil, errors.New("unsupported scheme")
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectContentType returns the MIME type of data using the first 512 bytes.
|
||||||
|
func detectContentType(data []byte) string {
|
||||||
|
if len(data) > 512 {
|
||||||
|
return http.DetectContentType(data[:512])
|
||||||
|
}
|
||||||
|
return http.DetectContentType(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// servePlaceholder writes a 1×1 transparent PNG to w. Used as a fallback
|
||||||
|
// when upstream fetch fails so the browser layout stays intact.
|
||||||
|
func servePlaceholder(w http.ResponseWriter) {
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(transparent1x1PNG)
|
||||||
|
}
|
||||||
|
|
||||||
// Serve writes the requested image to w. Caller is expected to validate
|
// Serve writes the requested image to w. Caller is expected to validate
|
||||||
// the JWT before invoking it.
|
// the JWT before invoking it.
|
||||||
func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, raw string) error {
|
func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.Request, raw string) error {
|
||||||
if raw == "" {
|
u, err := p.validateURL(raw)
|
||||||
return errors.New("missing url")
|
if err != nil {
|
||||||
}
|
// Bad URL is the only request-side error; everything else falls
|
||||||
u, err := url.Parse(raw)
|
// through to the placeholder so the UI stays clean.
|
||||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
return err
|
||||||
return errors.New("invalid url")
|
|
||||||
}
|
|
||||||
if _, ok := p.allowHost[strings.ToLower(u.Host)]; !ok {
|
|
||||||
return errors.New("host not allowed")
|
|
||||||
}
|
}
|
||||||
|
host := strings.ToLower(u.Host)
|
||||||
|
|
||||||
// Cache key = sha1(url)
|
// Cache key = sha1(url)
|
||||||
sum := sha1.Sum([]byte(raw))
|
sum := sha1.Sum([]byte(raw))
|
||||||
@@ -82,77 +146,96 @@ func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, raw strin
|
|||||||
cachePath := filepath.Join(p.cacheDir, key)
|
cachePath := filepath.Join(p.cacheDir, key)
|
||||||
|
|
||||||
// Cache hit.
|
// Cache hit.
|
||||||
if f, err := os.Open(cachePath); err == nil {
|
if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 {
|
||||||
defer f.Close()
|
w.Header().Set("Content-Type", detectContentType(data))
|
||||||
stat, _ := f.Stat()
|
|
||||||
w.Header().Set("Cache-Control", "public, max-age=604800")
|
w.Header().Set("Cache-Control", "public, max-age=604800")
|
||||||
http.ServeContent(w, &http.Request{}, key, stat.ModTime(), f)
|
stat, _ := os.Stat(cachePath)
|
||||||
|
modTime := time.Now()
|
||||||
|
if stat != nil {
|
||||||
|
modTime = stat.ModTime()
|
||||||
|
}
|
||||||
|
http.ServeContent(w, r, key, modTime, bytes.NewReader(data))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache miss → fetch upstream.
|
// Cache miss → fetch upstream.
|
||||||
if err := os.MkdirAll(p.cacheDir, 0o755); err != nil {
|
if err := os.MkdirAll(p.cacheDir, 0o755); err != nil {
|
||||||
return err
|
p.log.Warn("imageproxy: mkdir failed", zap.String("dir", p.cacheDir), zap.Error(err))
|
||||||
|
servePlaceholder(w)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
p.log.Warn("imageproxy: build request failed", zap.String("url", raw), zap.Error(err))
|
||||||
|
servePlaceholder(w)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
req.Header.Set("User-Agent", "MediaStationGo/0.1")
|
req.Header.Set("User-Agent", "MediaStationGo/0.1")
|
||||||
|
|
||||||
resp, err := p.client.Do(req)
|
resp, err := p.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
p.log.Warn("imageproxy: upstream fetch failed",
|
||||||
|
zap.String("host", host), zap.Error(err))
|
||||||
|
servePlaceholder(w)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
return errors.New("upstream returned " + resp.Status)
|
p.log.Warn("imageproxy: upstream returned non-OK",
|
||||||
|
zap.String("host", host), zap.String("status", resp.Status))
|
||||||
|
servePlaceholder(w)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) // 32 MiB cap
|
||||||
|
if err != nil || len(data) == 0 {
|
||||||
|
p.log.Warn("imageproxy: read upstream body failed",
|
||||||
|
zap.String("host", host), zap.Error(err))
|
||||||
|
servePlaceholder(w)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to a temp file then rename for atomicity.
|
// Write to a temp file then rename for atomicity.
|
||||||
tmp, err := os.CreateTemp(p.cacheDir, "img-*.tmp")
|
p.mu.Lock()
|
||||||
if err != nil {
|
tmp, tmpErr := os.CreateTemp(p.cacheDir, "img-*.tmp")
|
||||||
return err
|
if tmpErr == nil {
|
||||||
}
|
if _, werr := tmp.Write(data); werr == nil {
|
||||||
if _, err := io.Copy(tmp, resp.Body); err != nil {
|
tmp.Close()
|
||||||
tmp.Close()
|
if rerr := os.Rename(tmp.Name(), cachePath); rerr != nil {
|
||||||
os.Remove(tmp.Name())
|
_ = os.Remove(tmp.Name())
|
||||||
return err
|
}
|
||||||
}
|
} else {
|
||||||
tmp.Close()
|
tmp.Close()
|
||||||
if err := os.Rename(tmp.Name(), cachePath); err != nil {
|
_ = os.Remove(tmp.Name())
|
||||||
os.Remove(tmp.Name())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now serve the freshly cached file.
|
|
||||||
f, err := os.Open(cachePath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
stat, _ := f.Stat()
|
|
||||||
for _, h := range []string{"Content-Type", "Content-Length", "ETag", "Last-Modified"} {
|
|
||||||
if v := resp.Header.Get(h); v != "" {
|
|
||||||
w.Header().Set(h, v)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
ctype := resp.Header.Get("Content-Type")
|
||||||
|
if ctype == "" {
|
||||||
|
ctype = detectContentType(data)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", ctype)
|
||||||
|
if v := resp.Header.Get("Content-Length"); v != "" {
|
||||||
|
w.Header().Set("Content-Length", v)
|
||||||
|
}
|
||||||
|
if v := resp.Header.Get("ETag"); v != "" {
|
||||||
|
w.Header().Set("ETag", v)
|
||||||
|
}
|
||||||
|
if v := resp.Header.Get("Last-Modified"); v != "" {
|
||||||
|
w.Header().Set("Last-Modified", v)
|
||||||
|
}
|
||||||
w.Header().Set("Cache-Control", "public, max-age=604800")
|
w.Header().Set("Cache-Control", "public, max-age=604800")
|
||||||
http.ServeContent(w, &http.Request{}, key, stat.ModTime(), f)
|
http.ServeContent(w, r, key, time.Now(), bytes.NewReader(data))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch 拉取远程图片并返回字节和 Content-Type(带缓存)。
|
// Fetch 拉取远程图片并返回字节和 Content-Type(带缓存)。
|
||||||
func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, error) {
|
func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, error) {
|
||||||
if raw == "" {
|
u, err := p.validateURL(raw)
|
||||||
return nil, "", errors.New("missing url")
|
if err != nil {
|
||||||
}
|
return nil, "", err
|
||||||
u, err := url.Parse(raw)
|
|
||||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
|
||||||
return nil, "", errors.New("invalid url")
|
|
||||||
}
|
|
||||||
if _, ok := p.allowHost[strings.ToLower(u.Host)]; !ok {
|
|
||||||
return nil, "", errors.New("host not allowed")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache lookup
|
// Cache lookup
|
||||||
@@ -160,10 +243,8 @@ func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, err
|
|||||||
key := hex.EncodeToString(sum[:])
|
key := hex.EncodeToString(sum[:])
|
||||||
cachePath := filepath.Join(p.cacheDir, key)
|
cachePath := filepath.Join(p.cacheDir, key)
|
||||||
|
|
||||||
if data, err := os.ReadFile(cachePath); err == nil {
|
if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 {
|
||||||
// Content-Type from file extension or upstream headers — use a simple detect
|
return data, detectContentType(data), nil
|
||||||
ctype := detectContentType(data)
|
|
||||||
return data, ctype, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch upstream
|
// Fetch upstream
|
||||||
@@ -186,34 +267,32 @@ func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, err
|
|||||||
return nil, "", errors.New("upstream returned " + resp.Status)
|
return nil, "", errors.New("upstream returned " + resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
data, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to cache
|
// Write to cache
|
||||||
tmp, err := os.CreateTemp(p.cacheDir, "img-*.tmp")
|
p.mu.Lock()
|
||||||
if err == nil {
|
tmp, terr := os.CreateTemp(p.cacheDir, "img-*.tmp")
|
||||||
if _, err := tmp.Write(data); err == nil {
|
if terr == nil {
|
||||||
|
if _, werr := tmp.Write(data); werr == nil {
|
||||||
tmp.Close()
|
tmp.Close()
|
||||||
os.Rename(tmp.Name(), cachePath)
|
if rerr := os.Rename(tmp.Name(), cachePath); rerr != nil {
|
||||||
|
_ = os.Remove(tmp.Name())
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
tmp.Close()
|
tmp.Close()
|
||||||
os.Remove(tmp.Name())
|
_ = os.Remove(tmp.Name())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
ctype := resp.Header.Get("Content-Type")
|
ctype := resp.Header.Get("Content-Type")
|
||||||
if ctype == "" {
|
if ctype == "" {
|
||||||
ctype = detectContentType(data)
|
ctype = detectContentType(data)
|
||||||
}
|
}
|
||||||
|
// host is unused here but referenced for log clarity in the future.
|
||||||
|
_ = u
|
||||||
return data, ctype, nil
|
return data, ctype, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectContentType 通过前 512 字节检测 MIME 类型。
|
|
||||||
func detectContentType(data []byte) string {
|
|
||||||
if len(data) > 512 {
|
|
||||||
return http.DetectContentType(data[:512])
|
|
||||||
}
|
|
||||||
return http.DetectContentType(data)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,10 @@ func (q *QBitClient) Login(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AddTorrent submits a magnet URL or HTTP(S) URL to qBittorrent.
|
// AddTorrent submits a magnet URL or HTTP(S) URL to qBittorrent.
|
||||||
|
//
|
||||||
|
// qBittorrent 的 /api/v2/torrents/add 在很多失败场景下仍然返回 HTTP 200
|
||||||
|
// 但 body 里写 "Fails."。我们把这些情况也识别为错误并返回,避免
|
||||||
|
// "API 返回 200 → 我们告诉前端成功 → qb 中却没下载" 这种迷惑性失败。
|
||||||
func (q *QBitClient) AddTorrent(ctx context.Context, magnetOrURL, savePath string) error {
|
func (q *QBitClient) AddTorrent(ctx context.Context, magnetOrURL, savePath string) error {
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
defer q.mu.Unlock()
|
defer q.mu.Unlock()
|
||||||
@@ -137,16 +141,26 @@ func (q *QBitClient) AddTorrent(ctx context.Context, magnetOrURL, savePath strin
|
|||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||||
req.Header.Set("Referer", q.cfg.BaseURL)
|
req.Header.Set("Referer", q.cfg.BaseURL)
|
||||||
|
req.Header.Set("Origin", q.cfg.BaseURL)
|
||||||
|
|
||||||
resp, err := q.client.Do(req)
|
resp, err := q.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
bodyText := strings.TrimSpace(string(raw))
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
raw, _ := io.ReadAll(resp.Body)
|
return fmt.Errorf("qbittorrent add: HTTP %d: %s", resp.StatusCode, bodyText)
|
||||||
return fmt.Errorf("qbittorrent add: %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
|
||||||
}
|
}
|
||||||
|
// qb 的成功响应是 "Ok." 或空体;任何 "Fails." 视为失败。
|
||||||
|
if strings.EqualFold(bodyText, "Fails.") {
|
||||||
|
return fmt.Errorf("qbittorrent add: 拒绝任务 (检查 URL 是否需要认证或 savePath 是否可写)")
|
||||||
|
}
|
||||||
|
q.log.Info("qbittorrent: torrent added",
|
||||||
|
zap.String("url", magnetOrURL),
|
||||||
|
zap.String("save_path", savePath),
|
||||||
|
zap.String("body", bodyText))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -30,6 +31,7 @@ type SiteConfig struct {
|
|||||||
Timeout time.Duration // 请求超时
|
Timeout time.Duration // 请求超时
|
||||||
Extra map[string]string // JSON 扩展配置
|
Extra map[string]string // JSON 扩展配置
|
||||||
FlareSolverrURL string // FlareSolverr 服务地址(用于浏览器模拟绕过 Cloudflare/WAF)
|
FlareSolverrURL string // FlareSolverr 服务地址(用于浏览器模拟绕过 Cloudflare/WAF)
|
||||||
|
UseProxy bool // 通过 HTTP(S)_PROXY 环境变量出站
|
||||||
}
|
}
|
||||||
|
|
||||||
// SiteSearchResult 站点搜索结果(按站点分组的批量搜索结果)。
|
// SiteSearchResult 站点搜索结果(按站点分组的批量搜索结果)。
|
||||||
@@ -97,8 +99,14 @@ type SiteAdapter interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// newHTTPClient 创建带有认证头的 HTTP 客户端。
|
// newHTTPClient 创建带有认证头的 HTTP 客户端。
|
||||||
|
// 当 cfg.UseProxy 为 true 时,会读取 HTTP(S)_PROXY 环境变量;
|
||||||
|
// 否则忽略环境变量直连。
|
||||||
func newHTTPClient(cfg SiteConfig, timeout time.Duration) *http.Client {
|
func newHTTPClient(cfg SiteConfig, timeout time.Duration) *http.Client {
|
||||||
return &http.Client{Timeout: timeout}
|
secs := int(timeout.Seconds())
|
||||||
|
if secs <= 0 {
|
||||||
|
secs = 30
|
||||||
|
}
|
||||||
|
return helper.NewSiteHTTPClient(secs, cfg.UseProxy)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildRequest 构建带认证的 HTTP 请求。
|
// buildRequest 构建带认证的 HTTP 请求。
|
||||||
@@ -114,13 +122,11 @@ func buildRequest(ctx context.Context, method, rawURL string, cfg SiteConfig, bo
|
|||||||
req.Header.Set("Cookie", cfg.Cookie)
|
req.Header.Set("Cookie", cfg.Cookie)
|
||||||
}
|
}
|
||||||
case "api_key":
|
case "api_key":
|
||||||
// MTeam 使用 Authorization: Bearer 格式
|
// 与参考项目(ShukeBta/MediaStation)的 ApplySiteAuthHeaders 对齐:
|
||||||
if cfg.Type == "mteam" {
|
// M-Team / UNIT3D 等开放 API 的 PT 站点都使用 `x-api-key` 头部,
|
||||||
if cfg.APIKey != "" {
|
// 不要再为 mteam 单独走 Authorization: Bearer,否则服务端会 401。
|
||||||
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
if cfg.APIKey != "" {
|
||||||
}
|
req.Header.Set("x-api-key", cfg.APIKey)
|
||||||
} else if cfg.APIKey != "" {
|
|
||||||
req.Header.Set("X-API-Key", cfg.APIKey)
|
|
||||||
}
|
}
|
||||||
case "auth_header":
|
case "auth_header":
|
||||||
if cfg.AuthHeader != "" {
|
if cfg.AuthHeader != "" {
|
||||||
@@ -169,7 +175,14 @@ func doRequest(ctx context.Context, client *http.Client, method, rawURL string,
|
|||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
// 当站点开启了「使用代理」开关时,使用本次请求专用的、读取 HTTP(S)_PROXY
|
||||||
|
// 的 client;否则沿用适配器持有的全局 client。这与前端勾选行为对齐。
|
||||||
|
httpClient := client
|
||||||
|
if cfg.UseProxy {
|
||||||
|
httpClient = newHTTPClient(cfg, cfg.Timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
@@ -197,33 +210,32 @@ func NewNexusPHPAdapter() *NexusPHPAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *NexusPHPAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
|
func (a *NexusPHPAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
|
||||||
resp, err := buildRequest(ctx, "GET", cfg.URL+"/index.php", cfg, nil)
|
// 走 doRequest 以便复用代理 / FlareSolverr / 浏览器头。
|
||||||
if err != nil {
|
data, status, err := doRequest(ctx, a.client, "GET", cfg.URL+"/index.php", cfg, nil)
|
||||||
return fmt.Errorf("build request: %w", err)
|
|
||||||
}
|
|
||||||
httpResp, err := a.client.Do(resp)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("request failed: %w", err)
|
return fmt.Errorf("request failed: %w", err)
|
||||||
}
|
}
|
||||||
defer httpResp.Body.Close()
|
|
||||||
|
|
||||||
if httpResp.StatusCode == http.StatusFound {
|
if status == http.StatusFound {
|
||||||
return fmt.Errorf("authentication failed: redirected to login page")
|
return fmt.Errorf("authentication failed: redirected to login page")
|
||||||
}
|
}
|
||||||
if httpResp.StatusCode != http.StatusOK {
|
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||||||
return fmt.Errorf("authentication failed: status %d", httpResp.StatusCode)
|
return fmt.Errorf("authentication failed: status %d", status)
|
||||||
|
}
|
||||||
|
if status >= 400 {
|
||||||
|
return fmt.Errorf("authentication failed: status %d", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
body, _ := io.ReadAll(httpResp.Body)
|
body := string(data)
|
||||||
bodyStr := string(body)
|
// NexusPHP 登录后页面通常包含 logout 或 userdetails;
|
||||||
// NexusPHP 登录页面通常包含 logout 或 userdetails
|
// 仅当二者都不存在且明确显示登录表单时才判失败。
|
||||||
if strings.Contains(bodyStr, "userdetails") || strings.Contains(bodyStr, "logout") {
|
if strings.Contains(body, "userdetails") || strings.Contains(body, "logout") || strings.Contains(body, "退出") {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Check for common login indicators
|
if strings.Contains(body, "takelogin.php") || strings.Contains(body, "id=\"loginform\"") {
|
||||||
if strings.Contains(bodyStr, "login") && !strings.Contains(bodyStr, "userdetails") {
|
|
||||||
return fmt.Errorf("authentication failed: not logged in")
|
return fmt.Errorf("authentication failed: not logged in")
|
||||||
}
|
}
|
||||||
|
// 状态码 OK 但页面不含明显标记时不再武断判失败。
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -843,33 +855,68 @@ func NewMTeamAdapter() *MTeamAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *MTeamAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
|
func (a *MTeamAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
|
||||||
|
// 与 ShukeBta/MediaStation 参考实现对齐:
|
||||||
|
// 用 camelCase 参数(pageNumber / pageSize),同时接受 code 为字符串 "0"
|
||||||
|
// 或数值 0;兼容 M-Team v3 API 不同版本的返回。
|
||||||
u := cfg.URL + "/api/torrent/search"
|
u := cfg.URL + "/api/torrent/search"
|
||||||
payload := `{"mode":"search","keyword":"","page":1,"pageSize":1}`
|
payload := `{"pageNumber":1,"pageSize":1,"keyword":"test"}`
|
||||||
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte(payload))
|
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte(payload))
|
||||||
|
// 调试开关:MEDIASTATION_DEBUG_MTEAM=1 时把请求/响应详情写入 stderr。
|
||||||
|
if os.Getenv("MEDIASTATION_DEBUG_MTEAM") == "1" {
|
||||||
|
preview := string(data)
|
||||||
|
if len(preview) > 800 {
|
||||||
|
preview = preview[:800] + "..."
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr,
|
||||||
|
"[DEBUG mteam.Authenticate] url=%s status=%d err=%v body=%s\n",
|
||||||
|
u, status, err, preview)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("authenticate: %w", err)
|
return fmt.Errorf("authenticate: %w", err)
|
||||||
}
|
}
|
||||||
if status == http.StatusUnauthorized {
|
preview := string(data)
|
||||||
return fmt.Errorf("authentication failed: unauthorized")
|
if len(preview) > 400 {
|
||||||
|
preview = preview[:400] + "..."
|
||||||
|
}
|
||||||
|
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||||||
|
return fmt.Errorf("authentication failed: status %d, body=%s", status, preview)
|
||||||
|
}
|
||||||
|
if status >= 300 && status < 400 {
|
||||||
|
return fmt.Errorf("authentication failed: HTTP %d (API Key 无效或未登录), body=%s", status, preview)
|
||||||
}
|
}
|
||||||
if status != http.StatusOK {
|
if status != http.StatusOK {
|
||||||
return fmt.Errorf("authenticate failed: status %d", status)
|
return fmt.Errorf("authenticate failed: status %d, body=%s", status, preview)
|
||||||
}
|
}
|
||||||
var resp map[string]interface{}
|
var resp map[string]interface{}
|
||||||
if err := json.Unmarshal(data, &resp); err == nil {
|
if err := json.Unmarshal(data, &resp); err != nil {
|
||||||
if code, ok := resp["code"].(float64); ok && code != 0 {
|
return fmt.Errorf("parse response: %w (body=%s)", err, preview)
|
||||||
return fmt.Errorf("authentication failed: code %v", code)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
codeStr := ""
|
||||||
|
switch v := resp["code"].(type) {
|
||||||
|
case string:
|
||||||
|
codeStr = v
|
||||||
|
case float64:
|
||||||
|
codeStr = strconv.Itoa(int(v))
|
||||||
|
}
|
||||||
|
if codeStr == "0" || codeStr == "200" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
msg, _ := resp["message"].(string)
|
||||||
|
if msg == "" {
|
||||||
|
msg = fmt.Sprintf("code=%s", codeStr)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("authentication failed: %s (body=%s)", msg, preview)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *MTeamAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
|
func (a *MTeamAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
|
||||||
|
// 与参考项目对齐:使用 camelCase 字段名,page 从 1 开始。
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"mode": "search",
|
"keyword": keyword,
|
||||||
"keyword": keyword,
|
"pageNumber": page,
|
||||||
"page": page,
|
"pageSize": 50,
|
||||||
"pageSize": 50,
|
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(payload)
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
@@ -886,11 +933,16 @@ func (a *MTeamAdapter) Search(ctx context.Context, cfg SiteConfig, keyword strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *MTeamAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
|
func (a *MTeamAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"mode": "browse",
|
"keyword": "",
|
||||||
"category": category,
|
"pageNumber": page,
|
||||||
"page": page,
|
"pageSize": 50,
|
||||||
"pageSize": 50,
|
}
|
||||||
|
if category != "" {
|
||||||
|
payload["categories"] = []string{category}
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(payload)
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
@@ -964,30 +1016,122 @@ func (a *MTeamAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string)
|
|||||||
return detail, nil
|
return detail, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDownloadURL 解析 M-Team 种子的真实下载链接。
|
||||||
|
//
|
||||||
|
// M-Team v3 流程:
|
||||||
|
//
|
||||||
|
// POST /api/torrent/genDlToken?id={tid} (带 x-api-key)
|
||||||
|
// → {"code":"0","data":"https://api.m-team.cc/api/rss/dlv2?sign=..."}
|
||||||
|
//
|
||||||
|
// 拿到的 sign URL 可被任何下载客户端无认证地直接 GET。这是参考项目
|
||||||
|
// (ShukeBta/MediaStation) 的 _download_torrent_file 方法的子集。
|
||||||
func (a *MTeamAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) {
|
func (a *MTeamAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) {
|
||||||
return cfg.URL + "/api/torrent/detail?id=" + id, nil
|
u := cfg.URL + "/api/torrent/genDlToken?id=" + id
|
||||||
|
// genDlToken 是 POST 但参数走 query string;body 留空。
|
||||||
|
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte("{}"))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("genDlToken: %w", err)
|
||||||
|
}
|
||||||
|
if status >= 300 {
|
||||||
|
return "", fmt.Errorf("genDlToken: HTTP %d", status)
|
||||||
|
}
|
||||||
|
var resp map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &resp); err != nil {
|
||||||
|
return "", fmt.Errorf("genDlToken parse: %w", err)
|
||||||
|
}
|
||||||
|
codeStr := ""
|
||||||
|
switch v := resp["code"].(type) {
|
||||||
|
case string:
|
||||||
|
codeStr = v
|
||||||
|
case float64:
|
||||||
|
codeStr = strconv.Itoa(int(v))
|
||||||
|
}
|
||||||
|
if codeStr != "0" && codeStr != "200" {
|
||||||
|
msg, _ := resp["message"].(string)
|
||||||
|
if msg == "" {
|
||||||
|
msg = "unknown error"
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("genDlToken: %s", msg)
|
||||||
|
}
|
||||||
|
dl, _ := resp["data"].(string)
|
||||||
|
if dl == "" {
|
||||||
|
return "", fmt.Errorf("genDlToken: empty data field")
|
||||||
|
}
|
||||||
|
return dl, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseMTeamJSON 解析 MTeam JSON 响应。
|
// parseMTeamJSON 解析 MTeam v3 JSON 响应。
|
||||||
|
//
|
||||||
|
// 响应结构(与 ShukeBta/MediaStation 参考项目一致):
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// "code": "0", // 字符串 "0" 表示成功
|
||||||
|
// "message": "SUCCESS",
|
||||||
|
// "data": {
|
||||||
|
// "total": "123",
|
||||||
|
// "data": [ ... ] // 旧字段名 "lists" 已被替换为 "data"
|
||||||
|
// }
|
||||||
|
// }
|
||||||
func parseMTeamJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, error) {
|
func parseMTeamJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, error) {
|
||||||
var resp struct {
|
// 用 map 反序列化以兼容 code/total 既可能是字符串又可能是数字。
|
||||||
Code int `json:"code"`
|
var raw map[string]interface{}
|
||||||
Data struct {
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
Total int `json:"total"`
|
|
||||||
Lists []map[string]interface{} `json:"lists"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(data, &resp); err != nil {
|
|
||||||
return nil, fmt.Errorf("parse JSON: %w", err)
|
return nil, fmt.Errorf("parse JSON: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// code 兼容字符串与数字。
|
||||||
|
codeStr := ""
|
||||||
|
switch v := raw["code"].(type) {
|
||||||
|
case string:
|
||||||
|
codeStr = v
|
||||||
|
case float64:
|
||||||
|
codeStr = strconv.Itoa(int(v))
|
||||||
|
}
|
||||||
|
if codeStr != "" && codeStr != "0" && codeStr != "200" {
|
||||||
|
msg, _ := raw["message"].(string)
|
||||||
|
if msg == "" {
|
||||||
|
msg = fmt.Sprintf("code=%s", codeStr)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("mteam: %s", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
dataField, _ := raw["data"].(map[string]interface{})
|
||||||
|
if dataField == nil {
|
||||||
|
return &SiteSearchResult{SiteName: siteName, Items: []TorrentItem{}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// total 兼容字符串与数字。
|
||||||
|
total := 0
|
||||||
|
switch v := dataField["total"].(type) {
|
||||||
|
case string:
|
||||||
|
total, _ = strconv.Atoi(v)
|
||||||
|
case float64:
|
||||||
|
total = int(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// data.data(v3)优先;兜底兼容旧的 data.lists。
|
||||||
|
var rows []interface{}
|
||||||
|
switch v := dataField["data"].(type) {
|
||||||
|
case []interface{}:
|
||||||
|
rows = v
|
||||||
|
}
|
||||||
|
if rows == nil {
|
||||||
|
if v, ok := dataField["lists"].([]interface{}); ok {
|
||||||
|
rows = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
result := &SiteSearchResult{
|
result := &SiteSearchResult{
|
||||||
SiteName: siteName,
|
SiteName: siteName,
|
||||||
Items: []TorrentItem{},
|
Items: []TorrentItem{},
|
||||||
Total: resp.Data.Total,
|
Total: total,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, t := range resp.Data.Lists {
|
for _, rawT := range rows {
|
||||||
|
t, ok := rawT.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
item := TorrentItem{}
|
item := TorrentItem{}
|
||||||
if v, ok := t["id"].(string); ok {
|
if v, ok := t["id"].(string); ok {
|
||||||
item.ID = v
|
item.ID = v
|
||||||
@@ -1007,6 +1151,11 @@ func parseMTeamJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, e
|
|||||||
}
|
}
|
||||||
if v, ok := t["size"].(float64); ok {
|
if v, ok := t["size"].(float64); ok {
|
||||||
item.Size = int64(v)
|
item.Size = int64(v)
|
||||||
|
} else if v, ok := t["size"].(string); ok {
|
||||||
|
// v3 API 把 size 序列化成字符串。
|
||||||
|
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||||
|
item.Size = n
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if v, ok := t["status"].(map[string]interface{}); ok {
|
if v, ok := t["status"].(map[string]interface{}); ok {
|
||||||
if seeders, ok := v["seeders"].(float64); ok {
|
if seeders, ok := v["seeders"].(float64); ok {
|
||||||
@@ -1027,6 +1176,10 @@ func parseMTeamJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
item.DetailURL = baseURL + "/detail/" + item.ID
|
item.DetailURL = baseURL + "/detail/" + item.ID
|
||||||
|
// 标记 download_url 指向 genDlToken;真正的下载链接由 handler 层
|
||||||
|
// 在用户点"下载"时通过 MTeamAdapter.GetDownloadURL 解析。
|
||||||
|
// 这样前端 SiteSearchPage 才知道这一行有可用的下载入口。
|
||||||
|
item.DownloadURL = baseURL + "/api/torrent/genDlToken?id=" + item.ID
|
||||||
result.Items = append(result.Items, item)
|
result.Items = append(result.Items, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
})),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
// 登出
|
// 登出
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -84,7 +84,13 @@ export function DownloadsPage() {
|
|||||||
<section className="glass-panel">
|
<section className="glass-panel">
|
||||||
<h2 className="mb-3 font-display text-lg font-semibold text-white">实时状态</h2>
|
<h2 className="mb-3 font-display text-lg font-semibold text-white">实时状态</h2>
|
||||||
{torrents === null && (
|
{torrents === null && (
|
||||||
<p className="text-slate-500">尚未连接到 qBittorrent — 请在管理后台配置 qbittorrent.url / username / password。</p>
|
<p className="text-slate-500">
|
||||||
|
尚未连接到下载器 — 请到{' '}
|
||||||
|
<a href="/download-clients" className="text-primary-400 hover:underline">
|
||||||
|
下载器
|
||||||
|
</a>{' '}
|
||||||
|
页面添加并测试连接(qBittorrent / Aria2 / Transmission)。
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
{torrents && torrents.length === 0 && <p className="text-slate-500">暂无运行中任务。</p>}
|
{torrents && torrents.length === 0 && <p className="text-slate-500">暂无运行中任务。</p>}
|
||||||
{torrents && torrents.length > 0 && (
|
{torrents && torrents.length > 0 && (
|
||||||
|
|||||||
+155
-48
@@ -1,30 +1,105 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { ArrowRight, Play, Clock, Film, Compass, Sparkles } from 'lucide-react'
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Play,
|
||||||
|
Clock,
|
||||||
|
Film,
|
||||||
|
Compass,
|
||||||
|
Sparkles,
|
||||||
|
Layers,
|
||||||
|
Library as LibraryIcon,
|
||||||
|
Tv,
|
||||||
|
Music,
|
||||||
|
PlayCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
import { mediaAPI } from '../api/library'
|
import { libraryAPI, mediaAPI } from '../api/library'
|
||||||
import { playbackAPI, type HistoryItem } from '../api/playback'
|
import { playbackAPI, type HistoryItem } from '../api/playback'
|
||||||
import { imageURL } from '../api/client'
|
import { imageURL } from '../api/client'
|
||||||
import type { Media } from '../types'
|
import type { Library, Media } from '../types'
|
||||||
|
import { groupSeries } from '../utils/groupSeries'
|
||||||
|
|
||||||
|
// LibraryRow 把单个媒体库的最近添加聚合成代表卡片列表。
|
||||||
|
type LibraryRow = {
|
||||||
|
library: Library
|
||||||
|
cards: ReturnType<typeof groupSeries>
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
|
movie: '电影',
|
||||||
|
tv: '电视剧',
|
||||||
|
anime: '动漫',
|
||||||
|
music: '音乐',
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
movie: <Film size={16} />,
|
||||||
|
tv: <Tv size={16} />,
|
||||||
|
anime: <PlayCircle size={16} />,
|
||||||
|
music: <Music size={16} />,
|
||||||
|
}
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const [recent, setRecent] = useState<Media[]>([])
|
const [libraries, setLibraries] = useState<Library[]>([])
|
||||||
|
const [rows, setRows] = useState<LibraryRow[]>([])
|
||||||
const [history, setHistory] = useState<HistoryItem[]>([])
|
const [history, setHistory] = useState<HistoryItem[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
let cancelled = false
|
||||||
mediaAPI.search('', 24).then((d) => d.items),
|
|
||||||
playbackAPI.recentHistory().catch(() => [] as HistoryItem[]),
|
async function load() {
|
||||||
])
|
setLoading(true)
|
||||||
.then(([items, hist]) => {
|
try {
|
||||||
setRecent(items)
|
// 1) 拉所有库 + 历史
|
||||||
|
const [libs, hist] = await Promise.all([
|
||||||
|
libraryAPI.list().catch(() => [] as Library[]),
|
||||||
|
playbackAPI.recentHistory().catch(() => [] as HistoryItem[]),
|
||||||
|
])
|
||||||
|
if (cancelled) return
|
||||||
|
setLibraries(libs)
|
||||||
setHistory(hist.filter((h) => !h.completed && !!h.media))
|
setHistory(hist.filter((h) => !h.completed && !!h.media))
|
||||||
})
|
|
||||||
.finally(() => setLoading(false))
|
// 2) 并行为每个库拉最近 60 条 media,前端折叠成剧集卡片
|
||||||
|
const perLib = await Promise.all(
|
||||||
|
libs.map(async (lib) => {
|
||||||
|
try {
|
||||||
|
const page = await libraryAPI.listMedia(lib.id, 1, 60)
|
||||||
|
const cards = groupSeries(page.items).slice(0, 12)
|
||||||
|
return { library: lib, cards } as LibraryRow
|
||||||
|
} catch {
|
||||||
|
return { library: lib, cards: [] } as LibraryRow
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (cancelled) return
|
||||||
|
setRows(perLib)
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const empty = !loading && recent.length === 0 && history.length === 0
|
// 兜底:如果一个库都没有,但 /api/media 有内容(旧数据),回退展示一个总卡片墙。
|
||||||
|
const [fallback, setFallback] = useState<Media[]>([])
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return
|
||||||
|
if (libraries.length > 0) return
|
||||||
|
mediaAPI.search('', 60).then((d) => setFallback(d.items)).catch(() => undefined)
|
||||||
|
}, [loading, libraries.length])
|
||||||
|
|
||||||
|
const fallbackCards = useMemo(() => groupSeries(fallback).slice(0, 16), [fallback])
|
||||||
|
const empty =
|
||||||
|
!loading &&
|
||||||
|
history.length === 0 &&
|
||||||
|
rows.every((r) => r.cards.length === 0) &&
|
||||||
|
fallbackCards.length === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in space-y-12">
|
<div className="animate-fade-in space-y-12">
|
||||||
@@ -50,7 +125,7 @@ export function HomePage() {
|
|||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* ── Loading ── */}
|
{/* Loading */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-center gap-3 py-16">
|
<div className="flex items-center gap-3 py-16">
|
||||||
<div className="h-1.5 w-1.5 animate-pulse rounded-full bg-brand-500/60" />
|
<div className="h-1.5 w-1.5 animate-pulse rounded-full bg-brand-500/60" />
|
||||||
@@ -58,7 +133,7 @@ export function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Empty state ── */}
|
{/* Empty */}
|
||||||
{empty && (
|
{empty && (
|
||||||
<div className="surface-card flex flex-col items-center gap-4 py-16 text-center">
|
<div className="surface-card flex flex-col items-center gap-4 py-16 text-center">
|
||||||
<Film className="h-12 w-12 text-cream-900/40" />
|
<Film className="h-12 w-12 text-cream-900/40" />
|
||||||
@@ -75,7 +150,7 @@ export function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Continue Watching — wide horizontal stripe ── */}
|
{/* Continue Watching */}
|
||||||
{history.length > 0 && (
|
{history.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<SectionHeading icon={<Play size={16} />} label="继续观看" />
|
<SectionHeading icon={<Play size={16} />} label="继续观看" />
|
||||||
@@ -89,27 +164,27 @@ export function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Recently Added — bento grid ── */}
|
{/* 每个媒体库一行 */}
|
||||||
{recent.length > 0 && (
|
{!loading &&
|
||||||
|
rows
|
||||||
|
.filter((r) => r.cards.length > 0)
|
||||||
|
.map((row) => (
|
||||||
|
<LibrarySection key={row.library.id} row={row} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 旧数据兜底 */}
|
||||||
|
{!loading && libraries.length === 0 && fallbackCards.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<SectionHeading icon={<Clock size={16} />} label="最近添加" />
|
<SectionHeading icon={<Clock size={16} />} label="最近添加" />
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||||
{recent.slice(0, 12).map((m, i) => {
|
{fallbackCards.map((s) => (
|
||||||
// First 2 items get double-column span on large screens
|
<PosterCard key={s.rep.id} media={s.rep} count={s.count} />
|
||||||
const isHero = i < 2
|
))}
|
||||||
return (
|
|
||||||
<PosterCard
|
|
||||||
key={m.id}
|
|
||||||
media={m}
|
|
||||||
className={isHero ? 'lg:col-span-2 lg:row-span-2' : ''}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Quick Links ── */}
|
{/* Quick Links */}
|
||||||
{!empty && (
|
{!empty && (
|
||||||
<section>
|
<section>
|
||||||
<SectionHeading icon={<Sparkles size={16} />} label="快捷入口" />
|
<SectionHeading icon={<Sparkles size={16} />} label="快捷入口" />
|
||||||
@@ -125,6 +200,39 @@ export function HomePage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Library 行 ─── */
|
||||||
|
function LibrarySection({ row }: { row: LibraryRow }) {
|
||||||
|
const lib = row.library
|
||||||
|
const typeIcon = TYPE_ICONS[lib.type] ?? <LibraryIcon size={16} />
|
||||||
|
const typeLabel = TYPE_LABELS[lib.type] ?? lib.type
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-cream-400">{typeIcon}</span>
|
||||||
|
<h2 className="font-display text-lg font-semibold tracking-tight text-cream-200">
|
||||||
|
{lib.name}
|
||||||
|
</h2>
|
||||||
|
<span className="text-xs text-cream-500">·</span>
|
||||||
|
<span className="text-xs text-cream-500">{typeLabel}</span>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to={`/library/${lib.id}`}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-cream-400 transition-colors hover:text-brand-400"
|
||||||
|
>
|
||||||
|
查看全部
|
||||||
|
<ArrowRight size={12} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||||
|
{row.cards.map((s) => (
|
||||||
|
<PosterCard key={s.rep.id} media={s.rep} count={s.count} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Section Heading ─── */
|
/* ─── Section Heading ─── */
|
||||||
function SectionHeading({ icon, label }: { icon: React.ReactNode; label: string }) {
|
function SectionHeading({ icon, label }: { icon: React.ReactNode; label: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -144,7 +252,6 @@ function WideContinueCard({ media, progress }: { media: Media; progress: number
|
|||||||
to={`/media/${media.id}`}
|
to={`/media/${media.id}`}
|
||||||
className="group flex shrink-0 w-72 items-center gap-4 rounded-lg border border-cream-900/15 bg-surface-400 p-3 transition-all hover:border-brand-500/30 hover:bg-surface-300"
|
className="group flex shrink-0 w-72 items-center gap-4 rounded-lg border border-cream-900/15 bg-surface-400 p-3 transition-all hover:border-brand-500/30 hover:bg-surface-300"
|
||||||
>
|
>
|
||||||
{/* Poster thumbnail */}
|
|
||||||
<div className="relative h-20 w-14 shrink-0 overflow-hidden rounded-md bg-surface-600">
|
<div className="relative h-20 w-14 shrink-0 overflow-hidden rounded-md bg-surface-600">
|
||||||
{media.poster_url ? (
|
{media.poster_url ? (
|
||||||
<img
|
<img
|
||||||
@@ -160,8 +267,6 @@ function WideContinueCard({ media, progress }: { media: Media; progress: number
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-medium text-cream-200 group-hover:text-cream-100">
|
<p className="truncate text-sm font-medium text-cream-200 group-hover:text-cream-100">
|
||||||
{media.title}
|
{media.title}
|
||||||
@@ -169,8 +274,6 @@ function WideContinueCard({ media, progress }: { media: Media; progress: number
|
|||||||
{media.year > 0 && (
|
{media.year > 0 && (
|
||||||
<p className="mt-0.5 text-xs text-cream-500">{media.year}</p>
|
<p className="mt-0.5 text-xs text-cream-500">{media.year}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Progress bar */}
|
|
||||||
{progress > 0 && (
|
{progress > 0 && (
|
||||||
<div className="mt-2 h-1 w-full overflow-hidden rounded-full bg-cream-900/20">
|
<div className="mt-2 h-1 w-full overflow-hidden rounded-full bg-cream-900/20">
|
||||||
<div
|
<div
|
||||||
@@ -180,7 +283,6 @@ function WideContinueCard({ media, progress }: { media: Media; progress: number
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Play
|
<Play
|
||||||
size={14}
|
size={14}
|
||||||
className="shrink-0 text-cream-600 opacity-0 transition-all group-hover:opacity-100 group-hover:text-brand-400"
|
className="shrink-0 text-cream-600 opacity-0 transition-all group-hover:opacity-100 group-hover:text-brand-400"
|
||||||
@@ -190,11 +292,11 @@ function WideContinueCard({ media, progress }: { media: Media; progress: number
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Poster Card (grid item) ─── */
|
/* ─── Poster Card (grid item) ─── */
|
||||||
function PosterCard({ media, className = '' }: { media: Media; className?: string }) {
|
function PosterCard({ media, count = 1 }: { media: Media; count?: number }) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={`/media/${media.id}`}
|
to={`/media/${media.id}`}
|
||||||
className={`group block overflow-hidden rounded-lg border border-cream-900/15 bg-surface-400 transition-all hover:border-brand-500/30 hover:bg-surface-300 ${className}`}
|
className="group block overflow-hidden rounded-lg border border-cream-900/15 bg-surface-400 transition-all hover:border-brand-500/30 hover:bg-surface-300"
|
||||||
>
|
>
|
||||||
<div className="relative aspect-[2/3] w-full overflow-hidden bg-surface-600">
|
<div className="relative aspect-[2/3] w-full overflow-hidden bg-surface-600">
|
||||||
{media.poster_url ? (
|
{media.poster_url ? (
|
||||||
@@ -207,23 +309,28 @@ function PosterCard({ media, className = '' }: { media: Media; className?: strin
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center text-cream-900/30">
|
<div className="flex h-full w-full items-center justify-center text-cream-900/30">
|
||||||
<Film size={48} />
|
<Film size={36} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Overlay on hover */}
|
{count > 1 && (
|
||||||
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 via-transparent to-transparent p-3 opacity-0 transition-opacity group-hover:opacity-100">
|
<span className="absolute right-1.5 top-1.5 inline-flex items-center gap-0.5 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm">
|
||||||
<span className="flex items-center gap-1.5 text-xs text-white/90">
|
<Layers size={10} />
|
||||||
<Play size={12} />
|
{count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 via-transparent to-transparent p-2.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<span className="flex items-center gap-1 text-[11px] text-white/90">
|
||||||
|
<Play size={11} />
|
||||||
播放
|
播放
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-2.5">
|
<div className="px-2.5 py-2">
|
||||||
<p className="truncate text-sm font-medium text-cream-200 group-hover:text-cream-100">
|
<p className="truncate text-xs font-medium text-cream-200 group-hover:text-cream-100">
|
||||||
{media.title}
|
{media.title}
|
||||||
</p>
|
</p>
|
||||||
{media.year > 0 && (
|
{media.year > 0 && (
|
||||||
<p className="mt-0.5 text-xs text-cream-500">{media.year}</p>
|
<p className="mt-0.5 text-[11px] text-cream-500">{media.year}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user