From a177dc61dde60663d5af976173e0a78db710e031 Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Sat, 13 Jun 2026 12:08:57 +0800 Subject: [PATCH] fix emby clients and cloud playback controls --- .github/codeql/codeql-config.yml | 4 + .github/workflows/codeql.yml | 70 +++ PR_DESCRIPTION.md | 53 ++ internal/handler/emby.go | 434 ++++++++++++++-- internal/handler/emby_test.go | 473 ++++++++++++++++++ internal/handler/media.go | 6 +- internal/handler/routes_admin.go | 1 + internal/handler/storage_config.go | 15 + internal/middleware/emby_auth.go | 8 +- internal/middleware/emby_auth_test.go | 3 + internal/service/cloud/cloud_test.go | 65 ++- internal/service/cloud/clouddrive2.go | 78 ++- internal/service/cloud/pan115.go | 6 +- internal/service/cloud/quark.go | 7 +- internal/service/emby_compat.go | 207 ++++++-- internal/service/emby_compat_test.go | 156 +++++- internal/service/media.go | 42 +- internal/service/media_test.go | 15 + internal/service/storage_config.go | 46 ++ .../service/storage_config_logout_test.go | 39 ++ internal/service/stream.go | 154 +++++- internal/service/stream_test.go | 72 ++- internal/service/watcher.go | 10 +- internal/service/watcher_test.go | 63 +++ web/src/api/storage_config.ts | 5 + web/src/pages/FileManagerPage.tsx | 4 +- web/src/pages/StorageConfigPage.tsx | 42 +- web/src/pages/StrmPage.tsx | 91 +++- 28 files changed, 2031 insertions(+), 138 deletions(-) create mode 100644 .github/codeql/codeql-config.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 PR_DESCRIPTION.md create mode 100644 internal/service/storage_config_logout_test.go create mode 100644 internal/service/watcher_test.go diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000..e05584d --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,4 @@ +name: MediaStationGo CodeQL + +paths-ignore: + - internal/service/fileid_other.go diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..7ab5708 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,70 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '22 19 * * 1' + +permissions: + contents: read + security-events: write + +jobs: + analyze-scripted: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + language: [actions, javascript-typescript] + steps: + - uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + config-file: ./.github/codeql/codeql-config.yml + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: /language:${{ matrix.language }} + + analyze-go: + name: Analyze Go (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + category: /language:go-linux-tests + - os: windows-latest + category: /language:go-windows-tests + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: go + build-mode: manual + config-file: ./.github/codeql/codeql-config.yml + + - name: Compile packages and tests for extraction + shell: bash + run: go test -run '^$' ./... + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: ${{ matrix.category }} diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md new file mode 100644 index 0000000..33cd243 --- /dev/null +++ b/PR_DESCRIPTION.md @@ -0,0 +1,53 @@ +# fix: 资源占用 / 登录稳定性 / QB 整理入库 / 第三方播放 404 综合修复 + +> 分支:`fix/resource-login-organize-playback`(2 个提交) +> 推送后本文件可删除,内容用于创建 PR。 + +## 一、资源占用(Docker 部署 CPU/内存长期居高) + +| 问题 | 修复 | +|---|---| +| 云盘探测预算只在「成功入队」时扣减,队列满时对每个文件反复尝试入队,实测刷出 **41165 条** `cloud media probe queue full` WARN | 预算改为按「尝试」扣减(`scanner.go`);队列满给文件挂 30 分钟退避;告警限速为每分钟最多 1 条 | +| 云盘扫描对**每个文件**同步下载海报/背景图(单张最长 20s 超时),大库扫描变成持续数小时的串行下载 | 改走后台预取队列(原有 worker),扫描只入队不下载 | +| `PlaybackInfo` 同步执行 CloudResolve+ffprobe(HTTP),最长 8 秒,每次起播/点开详情都可能触发云盘下载 | 改为异步探测 + 单飞去重,结果落库后下次请求自然带上 | +| `logging.level/format` 配置完全没生效(固定 NewProduction);每个请求都打 INFO,几小时几十 MB,Docker json-file 无上限 | 日志配置真正生效;访问日志跳过 `/api/health`、`/assets/*`;compose 增加 `max-size: 10m, max-file: 3` | + +## 二、登录稳定性(经常登录报错) + +登录时 refresh token 因 SQLite 写压力「尽力写库」失败后仅在内存补写,客户端 1 小时后刷新令牌时因 token 从未落库而被判无效 → 被踢回登录页。 + +修复(`token_svc.go`):刷新请求可识别「待落库令牌」(按哈希索引,带签发信息与过期时间);轮换/登出后取消后台补写,防止已替换的旧令牌复活。附 2 个新测试。 + +## 三、QB 下载 PT 资源无法整理入库 + +| 断点 | 修复 | +|---|---| +| QB 容器路径→本程序路径映射是**写死的 3 条猜测**,对不上静默失败 | 新增 `download.path_mappings` 设置(每行 `客户端路径=本地路径`,支持 `=>`/`:` 分隔、`#` 注释);并复用 compose 注入的 `MEDIASTATION_DOWNLOAD_DIR`↔`/downloads` 环境映射规则 | +| 首次轮询把已完成种子标记「已见过」,**应用重启期间下完的种子永远不整理** | 启动后补整理最近 24h 内完成的种子(读 qB `completion_on`;仍受 `organize.auto` 开关约束,幂等) | +| 应用启动时 QB 未就绪 → 下载客户端初始化失败后**永不重连** | 初始化失败仍注册适配器,依赖其按需重新登录机制自愈(容器启动顺序免疫) | +| 硬链接跨 bind mount 必失败(EXDEV),整理静默中断 | 自动降级为复制,保种语义不变 | + +## 四、第三方播放器播放网盘媒体 404 + +| 成因 | 修复 | +|---|---| +| `/Videos/{id}/stream` 把**所有**错误吞成 404(Cookie 过期、直链解析失败、STRM 播放被关…) | 区分:媒体不存在→404;云盘播放不可用/上游故障→502+原因(`ErrCloudPlaybackUnavailable`) | +| 云盘媒体播放 URL 在**扫描时**按当时地址生成并固化进库,换部署环境(Windows 开发机→Docker/换 IP)后 302 指向旧地址 | `normalizeCloudPlayTarget`:能解析出 provider+ref 就重建为相对 `/api/cloud/play` 路径,按当前请求补全 host,对历史脏数据免疫 | +| 云盘媒体 `SupportsDirectPlay=true` 且 Path 是不带 token 的内部路径,Infuse/VidHub DirectPlay 直接请求 → 401/404 | 云盘媒体 `SupportsDirectPlay=false`,强制走带鉴权的 DirectStream(`/Videos/{id}/stream?api_key=…`) | + +## 五、清理 + +- go.mod 与 web/package.json 依赖**全部在用**,无可删项(资源问题在行为,不在依赖) +- 删除仓库目录约 60MB 未跟踪垃圾(.tmp_* 诊断脚本/日志、旧二进制、临时部署目录) +- .gitignore 补充 `.tmp-*`、`downloads/`、`media/`、`*.pid`、`.tmp-live-backups/` + +## 验证 + +- `go build ./...` 通过;`go test ./internal/...` 全绿(含 6 个新增测试) +- `tsc -b && vite build` 前端构建通过 +- 本机冒烟:健康检查/登录/refresh 轮换/Emby AuthenticateByName/Views/不存在媒体 404 语义/健康检查日志静默 —— 全部符合预期 + +## 部署提示 + +- 升级后建议重扫一次云盘媒体库,刷新存库的播放 URL(旧数据也已被运行时规范化兜底) +- QB 在不同容器/主机时,在设置表配置 `download.path_mappings`,如:`/var/lib/qbittorrent/downloads=/downloads` diff --git a/internal/handler/emby.go b/internal/handler/emby.go index 1889bca..0df426a 100644 --- a/internal/handler/emby.go +++ b/internal/handler/emby.go @@ -15,9 +15,11 @@ import ( "net/url" "strconv" "strings" + "sync" "time" "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" "github.com/ShukeBta/MediaStationGo/internal/middleware" "github.com/ShukeBta/MediaStationGo/internal/service" @@ -38,6 +40,104 @@ func embyUserID(c *gin.Context) string { return "" } +const embyCompatSessionTTL = 30 * time.Minute + +type embyCompatSession struct { + token string + expiresAt time.Time +} + +var embyCompatSessions = struct { + sync.RWMutex + items map[string]embyCompatSession +}{items: map[string]embyCompatSession{}} + +func embyAuthRequiredWithSessionFallback(secret string) gin.HandlerFunc { + required := middleware.EmbyAuthRequired(secret) + return func(c *gin.Context) { + if embyRequestToken(c) == "" { + if token := embyCompatSessionToken(c); token != "" { + c.Request.Header.Set("X-Emby-Token", token) + } + } + required(c) + } +} + +func embyRememberCompatSession(c *gin.Context, token string) { + token = strings.TrimSpace(token) + if token == "" { + return + } + keys := embyCompatSessionKeys(c) + if len(keys) == 0 { + return + } + expiresAt := time.Now().Add(embyCompatSessionTTL) + embyCompatSessions.Lock() + defer embyCompatSessions.Unlock() + if len(embyCompatSessions.items) > 1000 { + now := time.Now() + for key, session := range embyCompatSessions.items { + if now.After(session.expiresAt) { + delete(embyCompatSessions.items, key) + } + } + if len(embyCompatSessions.items) > 1000 { + embyCompatSessions.items = map[string]embyCompatSession{} + } + } + for _, key := range keys { + embyCompatSessions.items[key] = embyCompatSession{token: token, expiresAt: expiresAt} + } +} + +func embyCompatSessionToken(c *gin.Context) string { + keys := embyCompatSessionKeys(c) + if len(keys) == 0 { + return "" + } + now := time.Now() + embyCompatSessions.RLock() + defer embyCompatSessions.RUnlock() + for _, key := range keys { + session, ok := embyCompatSessions.items[key] + if ok && now.Before(session.expiresAt) { + return session.token + } + } + return "" +} + +func embyCompatSessionKeys(c *gin.Context) []string { + if c == nil { + return nil + } + ip := strings.TrimSpace(c.ClientIP()) + if ip == "" { + return nil + } + keys := []string{} + add := func(kind, value string) { + value = strings.TrimSpace(value) + if value != "" { + keys = append(keys, ip+"\x00"+kind+"\x00"+value) + } + } + add("device", firstHeaderValue(c, "X-Emby-Device-Id", "X-Emby-DeviceId", "X-MediaBrowser-Device-Id", "X-MediaBrowser-DeviceId")) + add("ua", c.GetHeader("User-Agent")) + return keys +} + +func firstHeaderValue(c *gin.Context, names ...string) string { + for _, name := range names { + if value := strings.TrimSpace(c.GetHeader(name)); value != "" { + return value + } + } + return "" +} + // ─── System ────────────────────────────────────────────────────────────────── func embySystemInfoHandler(svc *service.Container) gin.HandlerFunc { @@ -337,6 +437,7 @@ func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc { if longLived, err := svc.Auth.IssueEmbyToken(resp.User); err == nil && longLived != "" { accessToken = longLived } + embyRememberCompatSession(c, accessToken) c.JSON(http.StatusOK, gin.H{ "AccessToken": accessToken, "ServerId": "mediastation-go-001", @@ -458,6 +559,7 @@ func embyViewsHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + embyAttachRequestTokenToMediaSources(c, out) c.JSON(http.StatusOK, out) } } @@ -506,6 +608,9 @@ func parseEmbyItemsParams(c *gin.Context) service.ItemsParams { limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "50")) offset, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0")) uid := c.Param("userId") + if uid == "" { + uid = firstQueryValue(c, "UserId", "userId", "userid") + } if uid == "" { uid = embyUserID(c) } @@ -529,6 +634,7 @@ func parseEmbyItemsParams(c *gin.Context) service.ItemsParams { IDs: splitOpt(firstQueryValue(c, "Ids", "ids")), SearchTerm: firstQueryValue(c, "SearchTerm", "searchTerm", "searchterm"), IncludeItemTypes: splitOpt(firstQueryValue(c, "IncludeItemTypes", "includeItemTypes", "includeitemtypes")), + Filters: splitOpt(firstQueryValue(c, "Filters", "filters")), Recursive: strings.EqualFold(firstQueryValue(c, "Recursive", "recursive"), "true"), SortBy: firstQueryValue(c, "SortBy", "sortBy", "sortby"), SortOrder: firstQueryValue(c, "SortOrder", "sortOrder", "sortorder"), @@ -553,6 +659,7 @@ func embyItemsHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + embyAttachRequestTokenToMediaSources(c, out) c.JSON(http.StatusOK, out) } } @@ -573,7 +680,7 @@ func embyItemByIDHandler(svc *service.Container) gin.HandlerFunc { embyError(c, http.StatusNotFound, "item not found") return } - embyAttachRequestTokenToPlaybackInfo(c, out) + embyAttachRequestTokenToMediaSources(c, out) c.JSON(http.StatusOK, out) } } @@ -594,15 +701,19 @@ func embyUserItemByIDHandler(svc *service.Container) gin.HandlerFunc { func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { uid := c.Param("userId") + if uid == "" { + uid = firstQueryValue(c, "UserId", "userId", "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) + limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20")) + out, err := svc.Emby.LatestItems(c.Request.Context(), uid, firstQueryValue(c, "ParentId", "parentId", "parentid"), limit) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + embyAttachRequestTokenToMediaSources(c, out) c.JSON(http.StatusOK, out) } } @@ -610,15 +721,19 @@ func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc { func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { uid := c.Param("userId") + if uid == "" { + uid = firstQueryValue(c, "UserId", "userId", "userid") + } if uid == "" { uid = embyUserID(c) } - limit, _ := strconv.Atoi(c.DefaultQuery("Limit", "20")) + limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "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 } + embyAttachRequestTokenToMediaSources(c, out) c.JSON(http.StatusOK, out) } } @@ -634,8 +749,55 @@ func embyItemsCountsHandler(_ *service.Container) gin.HandlerFunc { } } +func embyDisplayPreferencesHandler(_ *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "Id": c.Param("id"), + "ViewType": "Poster", + "SortBy": "SortName", + "SortOrder": "Ascending", + "IndexBy": "SortName", + "RememberIndexing": false, + "PrimaryImageHeight": 250, + "PrimaryImageWidth": 250, + "ScrollDirection": "Horizontal", + "ShowSidebar": true, + "CustomPrefs": gin.H{ + "homeexploresection": "1", + "homesection0": "smalllibrarytiles", + "homesection1": "resume", + "homesection2": "latestmedia", + "homesection3": "nextup", + "homesection4": "none", + "homesection5": "none", + "homesection6": "none", + "latestItems": "true", + "landing-livetv": "false", + }, + }) + } +} + +func embySaveDisplayPreferencesHandler(_ *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + c.Status(http.StatusNoContent) + } +} + // ─── Images ────────────────────────────────────────────────────────────────── +var embyTransparentPNG = []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, +} + // embyItemImageHandler 把 /Items/{id}/Images/Primary 等请求直接输出为图片。 // Emby 客户端缓存图片 URL 时经常不会继续携带 token;如果重定向到受保护的 // /api/img 会变成 401,所以这里复用 ImageProxy 但不再走 /api 路由。 @@ -648,7 +810,7 @@ func embyItemImageHandler(svc *service.Container) gin.HandlerFunc { imgType := strings.ToLower(c.Param("type")) raw, err := svc.Emby.ImageURL(ctx, id, imgType) if err != nil || raw == "" { - c.Status(http.StatusNotFound) + embyServeTransparentImage(c) return } if typ, ref, ok := parseCloudPlayImageURL(raw); ok { @@ -657,15 +819,26 @@ func embyItemImageHandler(svc *service.Container) gin.HandlerFunc { return } if svc.ImageProxy == nil { - c.Status(http.StatusNotFound) + embyServeTransparentImage(c) return } if err := svc.ImageProxy.Serve(ctx, c.Writer, req, raw); err != nil { - c.Status(http.StatusNotFound) + embyServeTransparentImage(c) } } } +func embyServeTransparentImage(c *gin.Context) { + c.Header("Content-Type", "image/png") + c.Header("Cache-Control", "public, max-age=3600") + c.Header("Content-Length", strconv.Itoa(len(embyTransparentPNG))) + if c.Request.Method == http.MethodHead { + c.Status(http.StatusOK) + return + } + c.Data(http.StatusOK, "image/png", embyTransparentPNG) +} + func parseCloudPlayImageURL(raw string) (string, string, bool) { raw = strings.TrimSpace(raw) if raw == "" { @@ -700,6 +873,7 @@ func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + embyAttachRequestTokenToMediaSources(c, out) c.JSON(http.StatusOK, out) } } @@ -722,6 +896,7 @@ func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + embyAttachRequestTokenToMediaSources(c, out) c.JSON(http.StatusOK, out) } } @@ -743,20 +918,55 @@ func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc { embyError(c, http.StatusNotFound, "not found") return } - embyAttachRequestTokenToPlaybackInfo(c, out) + embyAttachRequestTokenToMediaSources(c, out) c.JSON(http.StatusOK, out) } } -func embyAttachRequestTokenToPlaybackInfo(c *gin.Context, out map[string]any) { +func embyAttachRequestTokenToMediaSources(c *gin.Context, out any) { token := embyRequestToken(c) if token == "" || out == nil { return } - sources, ok := out["MediaSources"].([]map[string]any) - if !ok { + embyAttachTokenToMediaSourcesValue(out, token) +} + +func embyAttachTokenToMediaSourcesValue(value any, token string) { + switch typed := value.(type) { + case map[string]any: + embyAttachTokenToMediaSourcesMap(typed, token) + case gin.H: + embyAttachTokenToMediaSourcesMap(map[string]any(typed), token) + case []map[string]any: + for _, item := range typed { + embyAttachTokenToMediaSourcesMap(item, token) + } + case []any: + for _, item := range typed { + embyAttachTokenToMediaSourcesValue(item, token) + } + } +} + +func embyAttachTokenToMediaSourcesMap(out map[string]any, token string) { + if out == nil { return } + if sources, ok := out["MediaSources"].([]map[string]any); ok { + embyAttachTokenToMediaSources(sources, token) + } else if sources, ok := out["MediaSources"].([]any); ok { + for _, source := range sources { + if sourceMap, ok := source.(map[string]any); ok { + embyAttachTokenToMediaSources([]map[string]any{sourceMap}, token) + } + } + } + if items, ok := out["Items"]; ok { + embyAttachTokenToMediaSourcesValue(items, token) + } +} + +func embyAttachTokenToMediaSources(sources []map[string]any, token string) { for _, source := range sources { for _, key := range []string{"DirectStreamUrl", "TranscodingUrl", "Path"} { raw, ok := source[key].(string) @@ -772,7 +982,7 @@ func embyRequestToken(c *gin.Context) string { if c == nil { return "" } - for _, key := range []string{"api_key", "apiKey", "ApiKey", "token"} { + for _, key := range []string{"api_key", "apiKey", "ApiKey", "token", "X-Emby-Token", "X-MediaBrowser-Token"} { if value := strings.TrimSpace(c.Query(key)); value != "" { return value } @@ -782,7 +992,7 @@ func embyRequestToken(c *gin.Context) string { return value } } - for _, header := range []string{"Authorization", "X-Emby-Authorization"} { + for _, header := range []string{"Authorization", "X-Emby-Authorization", "X-MediaBrowser-Authorization"} { if token := embyTokenFromAuthHeader(c.GetHeader(header)); token != "" { return token } @@ -837,7 +1047,7 @@ func embyAppendAPIKey(raw, token string) string { // embyVideoStreamHandler 是 GET /Videos/{id}/stream 的入口, // 直接代理到我们的 /api/stream/{id}(同一个 ServeFile)。 -func embyVideoStreamHandler(svc *service.Container) gin.HandlerFunc { +func embyVideoStreamHandler(svc *service.Container, cloudMode string) gin.HandlerFunc { return func(c *gin.Context) { uid := embyUserID(c) item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid) @@ -853,11 +1063,15 @@ func embyVideoStreamHandler(svc *service.Container) gin.HandlerFunc { // 此前这里把所有错误一律吞成 404:云盘 Cookie 过期、直链解析失败、 // STRM 播放被关闭……在第三方播放器上全部表现为「404 不存在」, // 无法排查。现在区分:行不存在→404;云盘播放不可用/上游故障→502+原因。 - err = svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id")) + err = svc.Stream.ServeFileWithCloudMode(c.Writer, c.Request, c.Param("id"), cloudMode) switch { case err == nil: case errors.Is(err, service.ErrMediaNotFound): c.Status(http.StatusNotFound) + case errors.Is(err, service.ErrCloudPlaybackDisabled): + if !c.Writer.Written() { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + } default: if !c.Writer.Written() { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) @@ -1014,6 +1228,44 @@ func embyNoContentHandler(_ *service.Container) gin.HandlerFunc { } } +func embyWebSocketHandler(_ *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + if !websocket.IsWebSocketUpgrade(c.Request) { + c.Status(http.StatusNoContent) + return + } + conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + return + } + defer conn.Close() + + done := make(chan struct{}) + go func() { + defer close(done) + for { + if _, _, err := conn.NextReader(); err != nil { + return + } + } + }() + + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-done: + return + case <-ticker.C: + _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } + } +} + func embyServerConfigurationHandler(_ *service.Container) gin.HandlerFunc { return func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ @@ -1065,6 +1317,52 @@ func embyEmptyItemsHandler(_ *service.Container) gin.HandlerFunc { } } +func embyEmptyArrayHandler(_ *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + c.JSON(http.StatusOK, []any{}) + } +} + +func embyCustomCSSJSScriptsHandler(_ *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + c.Data(http.StatusOK, "application/javascript; charset=utf-8", nil) + } +} + +func embyLocalizationCulturesHandler(_ *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + c.JSON(http.StatusOK, []gin.H{ + { + "DisplayName": "简体中文", + "Name": "zh-CN", + "ThreeLetterISOLanguageName": "zho", + "TwoLetterISOLanguageName": "zh", + "ThreeLetterISOLanguageNames": []string{"zho", "chi"}, + "IsRightToLeft": false, + }, + { + "DisplayName": "English", + "Name": "en-US", + "ThreeLetterISOLanguageName": "eng", + "TwoLetterISOLanguageName": "en", + "ThreeLetterISOLanguageNames": []string{"eng"}, + "IsRightToLeft": false, + }, + }) + } +} + +func embyThemeMediaHandler(_ *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + empty := gin.H{"Items": []any{}, "TotalRecordCount": 0} + c.JSON(http.StatusOK, gin.H{ + "ThemeVideosResult": empty, + "ThemeSongsResult": empty, + "SoundtrackSongsResult": empty, + }) + } +} + func embyServerDomainsHandler(_ *service.Container) gin.HandlerFunc { return func(c *gin.Context) { c.JSON(http.StatusOK, []any{}) @@ -1178,6 +1476,24 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container) for _, path := range []string{"/Localization/Options", "/localization/options"} { grp.GET(path, embyLocalizationOptionsHandler(svc)) } + for _, path := range []string{"/Localization/Cultures", "/Localization/cultures", "/localization/cultures"} { + grp.GET(path, embyLocalizationCulturesHandler(svc)) + } + for _, path := range []string{"/CustomCssJS/Scripts", "/customcssjs/scripts"} { + grp.GET(path, embyCustomCSSJSScriptsHandler(svc)) + grp.HEAD(path, embyCustomCSSJSScriptsHandler(svc)) + } + for _, path := range []string{"/embywebsocket", "/EmbyWebSocket"} { + grp.GET(path, embyWebSocketHandler(svc)) + grp.HEAD(path, embyNoContentHandler(svc)) + } + for _, path := range []string{"/Sessions/Logout", "/sessions/logout"} { + grp.POST(path, embyNoContentHandler(svc)) + } + grp.GET("/DisplayPreferences/:id", embyDisplayPreferencesHandler(svc)) + grp.POST("/DisplayPreferences/:id", embySaveDisplayPreferencesHandler(svc)) + grp.GET("/displaypreferences/:id", embyDisplayPreferencesHandler(svc)) + grp.POST("/displaypreferences/:id", embySaveDisplayPreferencesHandler(svc)) // 图片公开(Infuse 缓存 URL 时会丢 token) grp.GET("/Items/:id/Images/:type", embyItemImageHandler(svc)) @@ -1188,7 +1504,7 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container) grp.HEAD("/items/:id/images/:type", embyItemImageHandler(svc)) // 鉴权后端点 - auth := grp.Group("", middleware.EmbyAuthRequired(jwtSecret), activeEmbyUserRequired(svc)) + auth := grp.Group("", embyAuthRequiredWithSessionFallback(jwtSecret), activeEmbyUserRequired(svc)) auth.GET("/Users/Me", embyMeHandler(svc)) auth.GET("/Users", embyListUsersHandler(svc)) auth.GET("/Users/:userId", embyGetUserByIDHandler(svc)) @@ -1201,6 +1517,8 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container) auth.GET("/Users/:userId/Items", embyItemsHandler(svc)) auth.GET("/Items/Counts", embyItemsCountsHandler(svc)) auth.GET("/Users/:userId/Items/Counts", embyItemsCountsHandler(svc)) + auth.GET("/Items/Latest", embyLatestItemsHandler(svc)) + auth.GET("/Items/Resume", embyResumeItemsHandler(svc)) auth.GET("/Items/:id", embyItemByIDHandler(svc)) auth.GET("/Users/:userId/Items/:id", embyUserItemByIDHandler(svc)) auth.GET("/Shows/:id/Seasons", embyShowSeasonsHandler(svc)) @@ -1210,7 +1528,18 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container) auth.GET("/Shows/NextUp", embyEmptyItemsHandler(svc)) auth.GET("/Users/:userId/Shows/NextUp", embyEmptyItemsHandler(svc)) auth.GET("/MediaSegments/:id", embyEmptyItemsHandler(svc)) + auth.GET("/Artists", embyEmptyItemsHandler(svc)) + auth.GET("/Persons", embyEmptyItemsHandler(svc)) + auth.GET("/Genres", embyEmptyItemsHandler(svc)) + auth.GET("/Shows/Upcoming", embyEmptyItemsHandler(svc)) + auth.GET("/Users/:userId/Shows/Upcoming", embyEmptyItemsHandler(svc)) auth.GET("/Items/:id/Similar", embyEmptyItemsHandler(svc)) + auth.GET("/Items/:id/ThumbnailSet", embyEmptyItemsHandler(svc)) + auth.GET("/Items/:id/ThemeMedia", embyThemeMediaHandler(svc)) + auth.GET("/Users/:userId/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc)) + auth.GET("/Users/:userId/Items/:id/Intros", embyEmptyItemsHandler(svc)) + auth.GET("/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc)) + auth.GET("/Items/:id/Intros", embyEmptyItemsHandler(svc)) auth.GET("/api/danmu/:id/raw", embyDanmuRawHandler(svc)) auth.GET("/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc)) @@ -1218,14 +1547,18 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container) auth.GET("/Users/:userId/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc)) auth.POST("/Users/:userId/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.HEAD("/Videos/:id/original", embyVideoStreamHandler(svc)) - auth.GET("/Videos/:id/original.:container", embyVideoStreamHandler(svc)) - auth.HEAD("/Videos/:id/original.:container", embyVideoStreamHandler(svc)) + auth.GET("/Videos/:id/stream", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy)) + auth.HEAD("/Videos/:id/stream", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy)) + auth.GET("/Videos/:id/stream.:container", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy)) + auth.HEAD("/Videos/:id/stream.:container", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy)) + auth.GET("/Videos/:id/original", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy)) + auth.HEAD("/Videos/:id/original", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy)) + auth.GET("/Videos/:id/original.:container", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy)) + auth.HEAD("/Videos/:id/original.:container", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy)) + if prefix == "/emby" { + auth.GET("/api/stream/:id", embyVideoStreamHandler(svc, service.CloudPlaybackModeSTRM)) + auth.HEAD("/api/stream/:id", embyVideoStreamHandler(svc, service.CloudPlaybackModeSTRM)) + } auth.GET("/Videos/:id/master.m3u8", embyVideoHLSPlaylistHandler(svc)) auth.HEAD("/Videos/:id/master.m3u8", embyVideoHLSPlaylistHandler(svc)) auth.GET("/Videos/:id/main.m3u8", embyVideoHLSPlaylistHandler(svc)) @@ -1243,12 +1576,12 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container) auth.GET("/Sessions", embySessionsHandler(svc)) auth.GET("/System/Configuration", embyServerConfigurationHandler(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) - }) + auth.GET("/System/WakeOnLanInfo", embyEmptyArrayHandler(svc)) + auth.GET("/ScheduledTasks", embyEmptyArrayHandler(svc)) + auth.GET("/LiveTv/Recordings", embyEmptyItemsHandler(svc)) + auth.GET("/System/ActivityLog/Entries", embyEmptyItemsHandler(svc)) + auth.GET("/Web/ConfigurationPages", embyEmptyArrayHandler(svc)) + auth.POST("/Users/:userId/Configuration", embyNoContentHandler(svc)) registerLowercaseEmbyAuthRoutes(auth, svc) } @@ -1267,6 +1600,8 @@ func registerLowercaseEmbyAuthRoutes(auth *gin.RouterGroup, svc *service.Contain auth.GET("/users/:userId/items", embyItemsHandler(svc)) auth.GET("/items/counts", embyItemsCountsHandler(svc)) auth.GET("/users/:userId/items/counts", embyItemsCountsHandler(svc)) + auth.GET("/items/latest", embyLatestItemsHandler(svc)) + auth.GET("/items/resume", embyResumeItemsHandler(svc)) auth.GET("/items/:id", embyItemByIDHandler(svc)) auth.GET("/users/:userId/items/:id", embyUserItemByIDHandler(svc)) auth.GET("/shows/:id/seasons", embyShowSeasonsHandler(svc)) @@ -1276,21 +1611,32 @@ func registerLowercaseEmbyAuthRoutes(auth *gin.RouterGroup, svc *service.Contain auth.GET("/shows/nextup", embyEmptyItemsHandler(svc)) auth.GET("/users/:userId/shows/nextup", embyEmptyItemsHandler(svc)) auth.GET("/mediasegments/:id", embyEmptyItemsHandler(svc)) + auth.GET("/artists", embyEmptyItemsHandler(svc)) + auth.GET("/persons", embyEmptyItemsHandler(svc)) + auth.GET("/genres", embyEmptyItemsHandler(svc)) + auth.GET("/shows/upcoming", embyEmptyItemsHandler(svc)) + auth.GET("/users/:userId/shows/upcoming", embyEmptyItemsHandler(svc)) auth.GET("/items/:id/similar", embyEmptyItemsHandler(svc)) + auth.GET("/items/:id/thumbnailset", embyEmptyItemsHandler(svc)) + auth.GET("/items/:id/thememedia", embyThemeMediaHandler(svc)) + auth.GET("/users/:userId/items/:id/specialfeatures", embyEmptyItemsHandler(svc)) + auth.GET("/users/:userId/items/:id/intros", embyEmptyItemsHandler(svc)) + auth.GET("/items/:id/specialfeatures", embyEmptyItemsHandler(svc)) + auth.GET("/items/:id/intros", embyEmptyItemsHandler(svc)) auth.GET("/items/:id/playbackinfo", embyPlaybackInfoHandler(svc)) auth.POST("/items/:id/playbackinfo", embyPlaybackInfoHandler(svc)) auth.GET("/users/:userId/items/:id/playbackinfo", embyPlaybackInfoHandler(svc)) auth.POST("/users/:userId/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.HEAD("/videos/:id/original", embyVideoStreamHandler(svc)) - auth.GET("/videos/:id/original.:container", embyVideoStreamHandler(svc)) - auth.HEAD("/videos/:id/original.:container", embyVideoStreamHandler(svc)) + auth.GET("/videos/:id/stream", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy)) + auth.HEAD("/videos/:id/stream", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy)) + auth.GET("/videos/:id/stream.:container", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy)) + auth.HEAD("/videos/:id/stream.:container", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy)) + auth.GET("/videos/:id/original", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy)) + auth.HEAD("/videos/:id/original", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy)) + auth.GET("/videos/:id/original.:container", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy)) + auth.HEAD("/videos/:id/original.:container", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy)) auth.GET("/videos/:id/master.m3u8", embyVideoHLSPlaylistHandler(svc)) auth.HEAD("/videos/:id/master.m3u8", embyVideoHLSPlaylistHandler(svc)) auth.GET("/videos/:id/main.m3u8", embyVideoHLSPlaylistHandler(svc)) @@ -1308,10 +1654,10 @@ func registerLowercaseEmbyAuthRoutes(auth *gin.RouterGroup, svc *service.Contain auth.GET("/sessions", embySessionsHandler(svc)) auth.GET("/system/configuration", embyServerConfigurationHandler(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) - }) + auth.GET("/system/wakeonlaninfo", embyEmptyArrayHandler(svc)) + auth.GET("/scheduledtasks", embyEmptyArrayHandler(svc)) + auth.GET("/livetv/recordings", embyEmptyItemsHandler(svc)) + auth.GET("/system/activitylog/entries", embyEmptyItemsHandler(svc)) + auth.GET("/web/configurationpages", embyEmptyArrayHandler(svc)) + auth.POST("/users/:userId/configuration", embyNoContentHandler(svc)) } diff --git a/internal/handler/emby_test.go b/internal/handler/emby_test.go index c73192a..60c2de4 100644 --- a/internal/handler/emby_test.go +++ b/internal/handler/emby_test.go @@ -14,6 +14,7 @@ import ( "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "github.com/golang-jwt/jwt/v5" + "github.com/gorilla/websocket" "go.uber.org/zap" "gorm.io/gorm" @@ -115,6 +116,75 @@ func TestEmbyAuthenticateByNameAcceptsCaseVariantUsernameAndPath(t *testing.T) { } } +func TestEmbyCompatSessionAllowsSameClientRequestsWithoutToken(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open db: %v", err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("get sql db: %v", err) + } + sqlDB.SetMaxOpenConns(1) + if err := db.AutoMigrate(model.AllModels()...); err != nil { + t.Fatalf("migrate: %v", err) + } + repos := repository.New(db) + cfg := &config.Config{} + cfg.Secrets.JWTSecret = "test-secret" + log := zap.NewNop() + permissions := service.NewPermissionService(log, repos) + auth := service.NewAuthService(cfg, log, repos, service.NewTokenService(cfg, log, repos), permissions) + user, _, err := auth.Register(context.Background(), "viewer", "secret-pass") + if err != nil { + t.Fatalf("register: %v", err) + } + if err := repos.Library.Create(t.Context(), &model.Library{Name: "Movies", Path: "D:\\media", Type: "movie", Enabled: true}); err != nil { + t.Fatalf("create library: %v", err) + } + + embyCompatSessions.Lock() + embyCompatSessions.items = map[string]embyCompatSession{} + embyCompatSessions.Unlock() + + router := gin.New() + registerEmbyRoutes(router, cfg.Secrets.JWTSecret, &service.Container{ + Repo: repos, + Auth: auth, + Emby: service.NewEmbyService(cfg, log, repos), + Audit: service.NewAuditService(log, repos), + }) + + req := httptest.NewRequest(http.MethodPost, "/emby/Users/authenticatebyname", strings.NewReader(`{"Username":"viewer","Pw":"secret-pass"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "Emby Theater") + req.Header.Set("X-Emby-Device-Id", "pc-device") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("login status: %d body=%s", w.Code, w.Body.String()) + } + + req = httptest.NewRequest(http.MethodGet, "/emby/Users/"+user.ID+"/Views", nil) + req.Header.Set("User-Agent", "Emby Theater") + req.Header.Set("X-Emby-Device-Id", "pc-device") + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("views status: %d body=%s", w.Code, w.Body.String()) + } + var payload map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode views: %v", err) + } + if _, ok := payload["Items"]; !ok { + t.Fatalf("missing Items: %#v", payload) + } +} + func TestEmbyWithRequestAddressUsesHost(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() @@ -229,6 +299,75 @@ func TestEmbyMobileCompatibilityRoutesAvoidPlaybackBlocking404s(t *testing.T) { } } +func TestEmbyOfficialClientProbeRoutesAvoidHomepageBlocking404s(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.AutoMigrate(model.AllModels()...); err != nil { + t.Fatalf("migrate: %v", err) + } + repos := repository.New(db) + if err := repos.User.Create(t.Context(), &model.User{ + Base: model.Base{ID: "user-1"}, + Username: "tester", + PasswordHash: "x", + Role: "admin", + Tier: "plus", + IsActive: true, + }); err != nil { + t.Fatalf("create user: %v", err) + } + + const secret = "test-secret" + router := gin.New() + registerEmbyRoutes(router, secret, &service.Container{ + Repo: repos, + Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos), + }) + token := signedTestToken(t, secret) + tests := []struct { + method string + path string + auth bool + }{ + {method: http.MethodGet, path: "/emby/CustomCssJS/Scripts"}, + {method: http.MethodGet, path: "/emby/Localization/cultures"}, + {method: http.MethodPost, path: "/emby/Sessions/Logout"}, + {method: http.MethodGet, path: "/emby/System/WakeOnLanInfo", auth: true}, + {method: http.MethodGet, path: "/emby/ScheduledTasks", auth: true}, + {method: http.MethodGet, path: "/emby/LiveTv/Recordings", auth: true}, + {method: http.MethodGet, path: "/emby/System/ActivityLog/Entries", auth: true}, + {method: http.MethodGet, path: "/emby/web/configurationpages", auth: true}, + {method: http.MethodPost, path: "/emby/Users/user-1/Configuration", auth: true}, + {method: http.MethodGet, path: "/emby/Items/Latest?UserId=user-1", auth: true}, + {method: http.MethodGet, path: "/emby/Items/Resume?UserId=user-1", auth: true}, + {method: http.MethodGet, path: "/emby/Genres", auth: true}, + {method: http.MethodGet, path: "/emby/Shows/Upcoming", auth: true}, + {method: http.MethodGet, path: "/emby/Items/item-1/ThumbnailSet", auth: true}, + {method: http.MethodGet, path: "/emby/Items/item-1/ThemeMedia", auth: true}, + {method: http.MethodGet, path: "/emby/Users/user-1/Items/item-1/SpecialFeatures", auth: true}, + {method: http.MethodGet, path: "/emby/Users/user-1/Items/item-1/Intros", auth: true}, + } + for _, tt := range tests { + t.Run(tt.method+" "+tt.path, func(t *testing.T) { + req := httptest.NewRequest(tt.method, tt.path, nil) + if tt.auth { + req.Header.Set("X-Emby-Token", token) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code == http.StatusNotFound { + t.Fatalf("route returned 404 body=%s", w.Body.String()) + } + if w.Code >= 500 { + t.Fatalf("route returned %d body=%s", w.Code, w.Body.String()) + } + }) + } +} + func TestEmbySenPlayerDiscoveryRoutesReturnProtocolResponses(t *testing.T) { gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) @@ -443,6 +582,64 @@ func TestEmbyItemsCountsRouteReturnsJSON(t *testing.T) { } } +func TestEmbyDisplayPreferencesAllowsAnonymousCompatibility(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + registerEmbyRoutes(router, "secret", &service.Container{}) + + req := httptest.NewRequest(http.MethodGet, "/emby/DisplayPreferences/usersettings", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("unexpected GET status: %d body=%s", w.Code, w.Body.String()) + } + var body map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode display preferences: %v", err) + } + if body["Id"] != "usersettings" { + t.Fatalf("unexpected preferences payload: %#v", body) + } + customPrefs, ok := body["CustomPrefs"].(map[string]any) + if !ok { + t.Fatalf("missing CustomPrefs: %#v", body) + } + if customPrefs["homesection0"] != "smalllibrarytiles" || customPrefs["homesection2"] != "latestmedia" { + t.Fatalf("homepage sections should expose library tiles and latest media: %#v", customPrefs) + } + + req = httptest.NewRequest(http.MethodPost, "/emby/displaypreferences/usersettings", strings.NewReader(`{}`)) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("unexpected POST status: %d body=%s", w.Code, w.Body.String()) + } +} + +func TestEmbyWebSocketRouteUpgradesForOfficialClients(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + registerEmbyRoutes(router, "secret", &service.Container{}) + server := httptest.NewServer(router) + defer server.Close() + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/embywebsocket?api_key=test-token&deviceId=device-1" + conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + status := 0 + if resp != nil { + status = resp.StatusCode + } + t.Fatalf("websocket dial failed status=%d err=%v", status, err) + } + defer conn.Close() + if resp == nil || resp.StatusCode != http.StatusSwitchingProtocols { + t.Fatalf("expected websocket upgrade, got resp=%#v", resp) + } +} + func TestEmbyItemImageServesWithoutAPIAuth(t *testing.T) { gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) @@ -501,6 +698,39 @@ func TestEmbyItemImageServesWithoutAPIAuth(t *testing.T) { } } +func TestEmbyMissingItemImageReturnsTransparentPlaceholder(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.AutoMigrate(model.AllModels()...); err != nil { + t.Fatalf("migrate: %v", err) + } + repos := repository.New(db) + cfg := &config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}} + router := gin.New() + registerEmbyRoutes(router, "test-secret", &service.Container{ + Repo: repos, + Emby: service.NewEmbyService(cfg, zap.NewNop(), repos), + ImageProxy: service.NewImageProxy(cfg, zap.NewNop()), + }) + + req := httptest.NewRequest(http.MethodHead, "/Items/missing/Images/Primary", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected placeholder status 200, got %d body=%s", w.Code, w.Body.String()) + } + if contentType := w.Header().Get("Content-Type"); !strings.Contains(contentType, "image/png") { + t.Fatalf("expected png content type, got %q", contentType) + } + if length := w.Header().Get("Content-Length"); length == "" || length == "0" { + t.Fatalf("expected placeholder content length, got %q", length) + } +} + func TestEmbyUserItemByIDRouteReturnsJSON(t *testing.T) { gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) @@ -565,6 +795,55 @@ func TestEmbyUserItemByIDRouteReturnsJSON(t *testing.T) { } } +func TestEmbyUserItemByIDRouteReturnsLibraryView(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.AutoMigrate(model.AllModels()...); err != nil { + t.Fatalf("migrate: %v", err) + } + repos := repository.New(db) + if err := repos.User.Create(t.Context(), &model.User{ + Base: model.Base{ID: "user-1"}, + Username: "tester", + PasswordHash: "x", + Role: "admin", + Tier: "plus", + IsActive: true, + }); err != nil { + t.Fatalf("create user: %v", err) + } + lib := model.Library{Base: model.Base{ID: "lib-tv"}, Name: "剧集", Path: "D:\\media\\tv", Type: "tv", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + + const secret = "test-secret" + router := gin.New() + registerEmbyRoutes(router, secret, &service.Container{ + Repo: repos, + Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos), + }) + + req := httptest.NewRequest(http.MethodGet, "/Users/user-1/Items/lib-tv", nil) + req.Header.Set("X-Emby-Token", signedTestToken(t, secret)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String()) + } + var item map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &item); err != nil { + t.Fatalf("decode item: %v", err) + } + if item["Id"] != "lib-tv" || item["Type"] != "CollectionFolder" || item["CollectionType"] != "tvshows" { + t.Fatalf("unexpected library payload: %#v", item) + } +} + func TestEmbyLowercasePlaybackInfoRouteReturnsJSON(t *testing.T) { gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) @@ -649,6 +928,9 @@ func TestEmbyPlaybackInfoTokenizesCloudPath(t *testing.T) { t.Fatalf("migrate: %v", err) } repos := repository.New(db) + if err := repos.Setting.Set(t.Context(), service.CloudPlaybackModeSettingKey, service.CloudPlaybackModeSTRM); err != nil { + t.Fatalf("set cloud playback mode: %v", err) + } if err := repos.User.Create(t.Context(), &model.User{ Base: model.Base{ID: "user-1"}, Username: "tester", @@ -705,6 +987,80 @@ func TestEmbyPlaybackInfoTokenizesCloudPath(t *testing.T) { if !strings.HasPrefix(directURL, "/api/stream/cloud-1") || !strings.Contains(directURL, "api_key=") { t.Fatalf("DirectStreamUrl should stay tokenized: %#v", source) } + if source["SupportsDirectPlay"] != true { + t.Fatalf("cloud media should advertise DirectPlay when tokenized Path is playable: %#v", source) + } + if source["SupportsTranscoding"] != false { + t.Fatalf("cloud media should not advertise host transcoding: %#v", source) + } +} + +func TestEmbyItemsTokenizesEmbeddedCloudMediaSources(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.AutoMigrate(model.AllModels()...); err != nil { + t.Fatalf("migrate: %v", err) + } + repos := repository.New(db) + if err := repos.Setting.Set(t.Context(), service.CloudPlaybackModeSettingKey, service.CloudPlaybackModeSTRM); err != nil { + t.Fatalf("set cloud playback mode: %v", err) + } + if err := repos.User.Create(t.Context(), &model.User{ + Base: model.Base{ID: "user-1"}, + Username: "tester", + PasswordHash: "x", + Role: "admin", + Tier: "plus", + IsActive: true, + }); err != nil { + t.Fatalf("create user: %v", err) + } + lib := model.Library{Name: "OpenList", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + if err := db.Create(&model.Media{ + Base: model.Base{ID: "cloud-1"}, + LibraryID: lib.ID, + Title: "Cloud Movie", + Path: "cloud://openlist/Movies/Movie.mkv", + STRMURL: "/api/cloud/play/openlist?ref=%2FMovies%2FMovie.mkv", + Container: "mkv", + }).Error; err != nil { + t.Fatalf("create media: %v", err) + } + + const secret = "test-secret" + token := signedTestToken(t, secret) + router := gin.New() + registerEmbyRoutes(router, secret, &service.Container{ + Repo: repos, + Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos), + }) + + req := httptest.NewRequest(http.MethodGet, "/emby/Users/user-1/Items?IncludeItemTypes=Movie&Recursive=true&Limit=5&X-Emby-Token="+token, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String()) + } + var body map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode items: %v", err) + } + items := body["Items"].([]any) + if len(items) != 1 { + t.Fatalf("unexpected items: %#v", body["Items"]) + } + source := items[0].(map[string]any)["MediaSources"].([]any)[0].(map[string]any) + pathURL, _ := source["Path"].(string) + if !strings.HasPrefix(pathURL, "/api/stream/cloud-1") || !strings.Contains(pathURL, "api_key=") { + t.Fatalf("embedded cloud Path should be tokenized stream URL, got %#v", source) + } } func TestEmbyLowercaseVideoStreamRouteServesMedia(t *testing.T) { @@ -766,6 +1122,123 @@ func TestEmbyLowercaseVideoStreamRouteServesMedia(t *testing.T) { } } +func TestEmbyPrefixedAPIStreamRouteServesMedia(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.AutoMigrate(model.AllModels()...); err != nil { + t.Fatalf("migrate: %v", err) + } + repos := repository.New(db) + if err := repos.User.Create(t.Context(), &model.User{ + Base: model.Base{ID: "user-1"}, + Username: "tester", + PasswordHash: "x", + Role: "admin", + Tier: "plus", + IsActive: true, + }); err != nil { + t.Fatalf("create user: %v", err) + } + dir := t.TempDir() + mediaPath := filepath.Join(dir, "sample.mp4") + if err := os.WriteFile(mediaPath, []byte("fake-video-bytes"), 0o644); err != nil { + t.Fatalf("write media: %v", err) + } + lib := model.Library{Name: "电影", Path: dir, Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + if err := db.Create(&model.Media{ + Base: model.Base{ID: "media-1"}, + LibraryID: lib.ID, + Title: "Prefixed API Stream", + Path: mediaPath, + Container: "mp4", + }).Error; err != nil { + t.Fatalf("create media: %v", err) + } + + const secret = "test-secret" + router := gin.New() + registerEmbyRoutes(router, secret, &service.Container{ + Repo: repos, + Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos), + Stream: service.NewStreamService(&config.Config{}, zap.NewNop(), repos, nil), + }) + + req := httptest.NewRequest(http.MethodGet, "/emby/api/stream/media-1?api_key="+signedTestToken(t, secret), nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String()) + } + if got := w.Body.String(); got != "fake-video-bytes" { + t.Fatalf("unexpected stream body: %q", got) + } +} + +func TestEmbyVideoStreamRedirectKeepsMediaBrowserAuthorizationToken(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.AutoMigrate(model.AllModels()...); err != nil { + t.Fatalf("migrate: %v", err) + } + repos := repository.New(db) + if err := repos.User.Create(t.Context(), &model.User{ + Base: model.Base{ID: "user-1"}, + Username: "tester", + PasswordHash: "x", + Role: "admin", + Tier: "plus", + IsActive: true, + }); err != nil { + t.Fatalf("create user: %v", err) + } + lib := model.Library{Name: "OpenList", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + if err := db.Create(&model.Media{ + Base: model.Base{ID: "cloud-1"}, + LibraryID: lib.ID, + Title: "Cloud Movie", + Path: "cloud://openlist/Movies/Movie.mkv", + STRMURL: "/api/cloud/play/openlist?ref=%2FMovies%2FMovie.mkv", + Container: "mkv", + }).Error; err != nil { + t.Fatalf("create media: %v", err) + } + + const secret = "test-secret" + router := gin.New() + registerEmbyRoutes(router, secret, &service.Container{ + Repo: repos, + Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos), + Stream: service.NewStreamService(&config.Config{}, zap.NewNop(), repos, nil), + }) + + token := signedTestToken(t, secret) + req := httptest.NewRequest(http.MethodGet, "/videos/cloud-1/stream", nil) + req.Header.Set("X-MediaBrowser-Authorization", `MediaBrowser Client="Infuse", Device="PC", Token="`+token+`"`) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusFound { + t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String()) + } + loc := w.Header().Get("Location") + if !strings.Contains(loc, "/api/cloud/play/openlist?") || !strings.Contains(loc, "token=") { + t.Fatalf("redirect Location should target tokenized cloud play endpoint, got %q", loc) + } +} + func TestEmbyLowercaseOriginalHeadRouteServesHeaders(t *testing.T) { gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) diff --git a/internal/handler/media.go b/internal/handler/media.go index 1cf8266..8173f47 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -204,11 +204,15 @@ func streamHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } - err = svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id")) + err = svc.Stream.ServeFileWithCloudMode(c.Writer, c.Request, c.Param("id"), service.CloudPlaybackModeSTRM) if errors.Is(err, service.ErrMediaNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } + if errors.Is(err, service.ErrCloudPlaybackDisabled) { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/internal/handler/routes_admin.go b/internal/handler/routes_admin.go index d0c5fa0..9850812 100644 --- a/internal/handler/routes_admin.go +++ b/internal/handler/routes_admin.go @@ -35,6 +35,7 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service. admin.GET("/storage/:type", getStorageConfigHandler(svc)) admin.PUT("/storage/:type", saveStorageConfigHandler(svc)) admin.POST("/storage/:type/test", testStorageConfigHandler(svc)) + admin.POST("/storage/:type/logout", logoutStorageConfigHandler(svc)) admin.POST("/storage/:type/upload-local", storageUploadLocalHandler(svc)) // Cloud disk (115 / 夸克) browsing, QR login and 302 import. diff --git a/internal/handler/storage_config.go b/internal/handler/storage_config.go index b595632..08231dc 100644 --- a/internal/handler/storage_config.go +++ b/internal/handler/storage_config.go @@ -77,6 +77,21 @@ func testStorageConfigHandler(svc *service.Container) gin.HandlerFunc { } } +func logoutStorageConfigHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + typ := c.Param("type") + row, err := svc.StorageCfg.Logout(c.Request.Context(), typ) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if svc.Scan != nil { + _ = svc.Scan.CancelCloudScansForProvider(typ) + } + c.JSON(http.StatusOK, row) + } +} + func storageUploadLocalHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { var req service.CloudUploadInput diff --git a/internal/middleware/emby_auth.go b/internal/middleware/emby_auth.go index 792212f..8d17bab 100644 --- a/internal/middleware/emby_auth.go +++ b/internal/middleware/emby_auth.go @@ -18,8 +18,8 @@ const EmbyCtxUserID = "emby_user_id" // 按优先级尝试以下认证方式: // 1. X-Emby-Token / X-MediaBrowser-Token 请求头 // 2. Authorization: Bearer / MediaBrowser Token="" 请求头 -// 3. X-Emby-Authorization: MediaBrowser Token="" -// 4. ?token= / ?api_key= / ?apiKey= URL 参数 +// 3. X-Emby-Authorization / X-MediaBrowser-Authorization: MediaBrowser Token="" +// 4. ?token= / ?api_key= / ?apiKey= / ?X-Emby-Token= URL 参数 func EmbyAuthRequired(secret string) gin.HandlerFunc { return func(c *gin.Context) { token := extractEmbyToken(c) @@ -66,7 +66,7 @@ func extractEmbyToken(c *gin.Context) string { } } - for _, header := range []string{"Authorization", "X-Emby-Authorization"} { + for _, header := range []string{"Authorization", "X-Emby-Authorization", "X-MediaBrowser-Authorization"} { if value := strings.TrimSpace(c.GetHeader(header)); value != "" { if token := tokenFromAuthHeader(value); token != "" { return token @@ -74,7 +74,7 @@ func extractEmbyToken(c *gin.Context) string { } } - for _, key := range []string{"token", "api_key", "apiKey", "ApiKey"} { + for _, key := range []string{"token", "api_key", "apiKey", "ApiKey", "X-Emby-Token", "X-MediaBrowser-Token"} { if value := strings.TrimSpace(c.Query(key)); value != "" { return value } diff --git a/internal/middleware/emby_auth_test.go b/internal/middleware/emby_auth_test.go index c08f5df..9777558 100644 --- a/internal/middleware/emby_auth_test.go +++ b/internal/middleware/emby_auth_test.go @@ -25,7 +25,10 @@ func TestEmbyAuthRequiredAcceptsEmbyClientTokenFormats(t *testing.T) { {name: "x mediabrowser token", headerKey: "X-MediaBrowser-Token", headerVal: token}, {name: "authorization mediabrowser token", headerKey: "Authorization", headerVal: `MediaBrowser Client="Infuse", Token="` + token + `"`}, {name: "x emby authorization", headerKey: "X-Emby-Authorization", headerVal: `MediaBrowser Client="VidHub", Token="` + token + `"`}, + {name: "x mediabrowser authorization", headerKey: "X-MediaBrowser-Authorization", headerVal: `MediaBrowser Client="Emby Theater", Token="` + token + `"`}, {name: "query api key", query: "?api_key=" + token}, + {name: "query x emby token", query: "?X-Emby-Token=" + token}, + {name: "query x mediabrowser token", query: "?X-MediaBrowser-Token=" + token}, } for _, tt := range tests { diff --git a/internal/service/cloud/cloud_test.go b/internal/service/cloud/cloud_test.go index 54fe735..ed9d655 100644 --- a/internal/service/cloud/cloud_test.go +++ b/internal/service/cloud/cloud_test.go @@ -3,6 +3,7 @@ package cloud import ( "context" "encoding/base64" + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -105,10 +106,18 @@ func quarkPagePayload(page int) string { return strings.Join(items, ",") } -func TestQuarkForce302(t *testing.T) { - p := newQuark(map[string]any{"cookie": "c", "force_302": "true"}, http.DefaultClient) - if p.proxy { - t.Fatalf("force_302 should disable proxy mode") +func TestDeprecatedProviderPlaybackOverrideKeysAreIgnored(t *testing.T) { + quark := newQuark(map[string]any{"cookie": "c", "force_302": "true"}, http.DefaultClient) + if !quark.proxy { + t.Fatalf("quark should keep safe proxy mode; force_302 is deprecated") + } + pan115 := new115(map[string]any{"cookie": "UID=1; CID=2", "force_proxy": "true"}, http.DefaultClient) + if pan115.proxy { + t.Fatalf("115 should keep safe direct mode; force_proxy is deprecated") + } + cd2 := newCloudDrive2(map[string]any{"url": "http://example.test/dav", "force_302": "true"}, http.DefaultClient) + if !cd2.proxy { + t.Fatalf("clouddrive2 should keep safe proxy mode; force_302 is deprecated") } } @@ -375,6 +384,10 @@ func TestCloudDrive2WebDAVListAndResolve(t *testing.T) { func TestOpenListWebDAVListAndResolve(t *testing.T) { var gotPath, gotDepth string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/auth/login" { + http.NotFound(w, r) + return + } if r.URL.Path == "/api/fs/get" { http.NotFound(w, r) return @@ -467,6 +480,50 @@ func TestOpenListResolveUsesAPIRawURLFor302Playback(t *testing.T) { } } +func TestOpenListResolveLogsInWithUsernamePasswordForAPIRawURL(t *testing.T) { + var loginSeen bool + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/auth/login": + loginSeen = true + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode login body: %v", err) + } + if body["username"] != "alice" || body["password"] != "secret" { + t.Fatalf("login body = %#v", body) + } + _, _ = w.Write([]byte(`{"code":200,"data":{"token":"api-token"}}`)) + case "/api/fs/get": + gotAuth = r.Header.Get("Authorization") + _, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"https://cdn.example.test/movie.mkv?sign=1"}}`)) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer srv.Close() + + p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "username": "alice", "password": "secret"}, srv.Client()) + if err != nil { + t.Fatal(err) + } + link, err := p.Resolve(context.Background(), "/Cloud/Movie.mkv") + if err != nil { + t.Fatalf("resolve: %v", err) + } + if !loginSeen { + t.Fatalf("expected api login before fs/get") + } + if gotAuth != "api-token" { + t.Fatalf("Authorization = %q, want api-token", gotAuth) + } + if link.URL != "https://cdn.example.test/movie.mkv?sign=1" || link.Proxy { + t.Fatalf("link = %#v, want raw_url 302 playback", link) + } +} + func TestOpenListResolveFallsBackToProxyWhenAPIRawURLNeedsHeaders(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/fs/get" { diff --git a/internal/service/cloud/clouddrive2.go b/internal/service/cloud/clouddrive2.go index 5de8edc..838229c 100644 --- a/internal/service/cloud/clouddrive2.go +++ b/internal/service/cloud/clouddrive2.go @@ -54,9 +54,6 @@ func newCloudDAVProvider(typ, name string, cfg map[string]any, client *http.Clie ua = defaultUA } proxy := true - if _, ok := cfg["force_302"]; ok && boolish(cfg["force_302"]) { - proxy = false - } return &cloudDrive2Provider{ typ: typ, name: name, @@ -82,7 +79,7 @@ func (p *cloudDrive2Provider) List(ctx context.Context, dir string) ([]FileEntry if err := p.validate(); err != nil { return nil, err } - if p.typ == TypeOpenList && p.apiBase != nil && strings.TrimSpace(p.token) != "" { + if p.typ == TypeOpenList && p.apiBase != nil && p.hasOpenListAPICredentials() { if entries, err := p.listOpenListAPI(ctx, dir); err == nil { return entries, nil } @@ -135,6 +132,10 @@ func (p *cloudDrive2Provider) List(ctx context.Context, dir string) ([]FileEntry } func (p *cloudDrive2Provider) listOpenListAPI(ctx context.Context, dir string) ([]FileEntry, error) { + token, err := p.openListAPIToken(ctx) + if err != nil { + return nil, err + } const pageSize = 500 target := normalizeCloudDAVPath(dir) out := make([]FileEntry, 0, pageSize) @@ -154,8 +155,8 @@ func (p *cloudDrive2Provider) listOpenListAPI(ctx context.Context, dir string) ( req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", p.ua) - if p.token != "" { - req.Header.Set("Authorization", p.token) + if token != "" { + req.Header.Set("Authorization", token) } resp, err := p.client.Do(req) if err != nil { @@ -228,6 +229,10 @@ func (p *cloudDrive2Provider) Resolve(ctx context.Context, fileRef string) (*Dir } func (p *cloudDrive2Provider) resolveOpenListAPIDirect(ctx context.Context, fileRef string) (*DirectLink, error) { + token, err := p.openListAPIToken(ctx) + if err != nil { + return nil, err + } payload, _ := json.Marshal(map[string]string{"path": normalizeCloudDAVPath(fileRef), "password": ""}) req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL("/api/fs/get"), bytes.NewReader(payload)) if err != nil { @@ -236,8 +241,8 @@ func (p *cloudDrive2Provider) resolveOpenListAPIDirect(ctx context.Context, file req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", p.ua) - if p.token != "" { - req.Header.Set("Authorization", p.token) + if token != "" { + req.Header.Set("Authorization", token) } resp, err := p.client.Do(req) if err != nil { @@ -274,6 +279,55 @@ func (p *cloudDrive2Provider) resolveOpenListAPIDirect(ctx context.Context, file return &DirectLink{URL: resolved, Headers: headers, Proxy: proxy}, nil } +func (p *cloudDrive2Provider) hasOpenListAPICredentials() bool { + return strings.TrimSpace(p.token) != "" || (strings.TrimSpace(p.username) != "" && p.password != "") +} + +func (p *cloudDrive2Provider) openListAPIToken(ctx context.Context) (string, error) { + if token := strings.TrimSpace(p.token); token != "" { + return token, nil + } + if strings.TrimSpace(p.username) == "" || p.password == "" { + return "", nil + } + payload, _ := json.Marshal(map[string]string{ + "username": p.username, + "password": p.password, + }) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL("/api/auth/login"), bytes.NewReader(payload)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", p.ua) + resp, err := p.client.Do(req) + if err != nil { + return "", decorateDAVTransportError(p.name, p.openListAPIURL("/api/auth/login"), err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("%s: api login returned http %d", p.name, resp.StatusCode) + } + var decoded openListLoginResponse + if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&decoded); err != nil { + return "", fmt.Errorf("%s: decode api login: %w", p.name, err) + } + if decoded.Code != 0 && decoded.Code != 200 { + msg := strings.TrimSpace(decoded.Message) + if msg == "" { + msg = fmt.Sprintf("code %d", decoded.Code) + } + return "", fmt.Errorf("%s: api login failed: %s", p.name, msg) + } + token := strings.TrimSpace(decoded.Data.Token) + if token == "" { + return "", fmt.Errorf("%s: api login returned empty token", p.name) + } + p.token = token + return token, nil +} + func (p *cloudDrive2Provider) resolveOpenListPlaybackURL(raw string) (string, error) { raw = strings.TrimSpace(raw) if raw == "" { @@ -603,6 +657,14 @@ type openListGetResponse struct { } `json:"data"` } +type openListLoginResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + Token string `json:"token"` + } `json:"data"` +} + func normalizeCloudDAVPath(p string) string { p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/") if p == "" || p == "." { diff --git a/internal/service/cloud/pan115.go b/internal/service/cloud/pan115.go index 117a45d..95833fa 100644 --- a/internal/service/cloud/pan115.go +++ b/internal/service/cloud/pan115.go @@ -45,11 +45,9 @@ func new115(cfg map[string]any, client *http.Client) *pan115Provider { ua = defaultUA } // 115 CDN download URLs work with a plain 302 (Alist's recommended mode), - // so offload by default; admin can force proxy mode if their network needs it. + // so offload by default. The global cloud playback setting decides whether + // clients receive a STRMURL entry or a /Videos stream entry. proxy := false - if _, ok := cfg["force_proxy"]; ok && boolish(cfg["force_proxy"]) { - proxy = true - } pro := str(cfg["pro_base"]) if pro == "" { pro = pan115ProBase diff --git a/internal/service/cloud/quark.go b/internal/service/cloud/quark.go index aa37392..fbb3bf0 100644 --- a/internal/service/cloud/quark.go +++ b/internal/service/cloud/quark.go @@ -36,11 +36,10 @@ func newQuark(cfg map[string]any, client *http.Client) *quarkProvider { ua = defaultUA } // Quark download links require the session cookie + UA, so the host must - // reverse-proxy unless the admin explicitly opts into raw 302. + // reverse-proxy. The global cloud playback setting decides whether clients + // receive a STRMURL entry or a /Videos stream entry; this provider only + // reports whether the resolved upstream URL itself is safe for raw 302. proxy := true - if _, ok := cfg["force_302"]; ok && boolish(cfg["force_302"]) { - proxy = false - } return &quarkProvider{ cookie: str(cfg["cookie"]), ua: ua, diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index ab15f07..482fc25 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -232,7 +232,7 @@ func (e *EmbyService) Views(ctx context.Context, userID string) (map[string]any, } items = append(items, e.libraryAsView(&l)) } - return map[string]any{"Items": items, "TotalRecordCount": len(items)}, nil + return map[string]any{"Items": items, "TotalRecordCount": len(items), "StartIndex": 0}, nil } func (e *EmbyService) libraryAsView(l *model.Library) map[string]any { @@ -248,14 +248,31 @@ func (e *EmbyService) libraryAsView(l *model.Library) map[string]any { collectionType = "music" } return map[string]any{ - "Id": l.ID, - "Name": l.Name, - "CollectionType": collectionType, - "ServerId": embyServerID, - "Type": "CollectionFolder", - "IsFolder": true, - "ImageTags": map[string]string{}, - "BackdropImageTags": []string{}, + "Id": l.ID, + "Name": l.Name, + "CollectionType": collectionType, + "ServerId": embyServerID, + "Type": "CollectionFolder", + "IsFolder": true, + "Path": l.Path, + "SortName": strings.ToLower(l.Name), + "DateCreated": l.CreatedAt.UTC().Format(time.RFC3339), + "CanDelete": false, + "CanDownload": false, + "DisplayPreferencesId": l.ID, + "PrimaryImageItemId": l.ID, + "PrimaryImageAspectRatio": 1.7777777777777777, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SpecialFeatureCount": 0, + "EnableMediaSourceDisplay": true, + "PlayAccess": "Full", + "ExternalUrls": []any{}, + "ProviderIds": map[string]string{}, + "Genres": []string{}, + "Tags": []string{}, + "ImageTags": map[string]string{}, + "BackdropImageTags": []string{}, "UserData": map[string]any{ "PlaybackPositionTicks": 0, "PlayCount": 0, @@ -275,6 +292,7 @@ type ItemsParams struct { IDs []string SearchTerm string IncludeItemTypes []string + Filters []string Recursive bool SortBy string SortOrder string @@ -352,6 +370,9 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any, if p.StartIndex < 0 { p.StartIndex = 0 } + if len(p.IncludeItemTypes) > 0 && !containsSupportedEmbyItemType(p.IncludeItemTypes) { + return emptyItemsEnvelope(p.StartIndex), nil + } if len(p.IDs) > 0 { items := make([]map[string]any, 0, len(p.IDs)) @@ -367,7 +388,19 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any, return map[string]any{"Items": items, "TotalRecordCount": len(items), "StartIndex": 0}, nil } - if p.ParentID == "" && p.SearchTerm == "" && !p.Recursive && len(p.IncludeItemTypes) == 0 { + if containsOnlyFolderItemTypes(p.IncludeItemTypes) { + if p.ParentID == "" { + return e.Views(ctx, p.UserID) + } + if episodic, err := e.libraryIsEpisodic(ctx, p.ParentID); err != nil { + return nil, err + } else if episodic { + return e.seriesItemsForLibrary(ctx, p.ParentID, p) + } + return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": 0, "StartIndex": p.StartIndex}, nil + } + + if p.ParentID == "" && p.SearchTerm == "" && !p.Recursive && len(p.IncludeItemTypes) == 0 && len(p.Filters) == 0 { return e.Views(ctx, p.UserID) } @@ -414,6 +447,24 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string if p.SearchTerm != "" { q = q.Where("title LIKE ? OR original_name LIKE ?", "%"+p.SearchTerm+"%", "%"+p.SearchTerm+"%") } + if containsEmbyFilter(p.Filters, "IsFavorite") { + if strings.TrimSpace(p.UserID) == "" { + return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil + } + q = q.Joins("JOIN favorites ON favorites.media_id = media.id AND favorites.user_id = ? AND favorites.deleted_at IS NULL", p.UserID) + } + resumeFilter := containsEmbyFilter(p.Filters, "IsResumable") + if resumeFilter { + if strings.TrimSpace(p.UserID) == "" { + return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil + } + q = q.Joins(`JOIN ( + SELECT media_id, MAX(watched_at) AS watched_at + FROM playback_histories + WHERE user_id = ? AND completed = ? AND position_ms > 0 + GROUP BY media_id + ) AS resume ON resume.media_id = media.id`, p.UserID, false) + } if containsItemType(p.IncludeItemTypes, "Movie") && !containsItemType(p.IncludeItemTypes, "Episode") { q = q.Where("season_num = 0 AND episode_num = 0") } @@ -425,18 +476,20 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string if err := q.Count(&total).Error; err != nil { return nil, err } - order := "created_at desc" - switch strings.ToLower(p.SortBy) { + order := "media.created_at desc" + switch primarySupportedEmbySort(p.SortBy, resumeFilter) { case "sortname", "name": - order = "title" + order = "media.title" case "premieredate", "productionyear": - order = "year" + order = "media.year" case "datecreated": - order = "created_at" + order = "media.created_at" + case "dateplayed": + order = "resume.watched_at" case "communityrating": - order = "rating" + order = "media.rating" } - if strings.EqualFold(p.SortOrder, "Descending") { + if strings.EqualFold(firstCSVValue(p.SortOrder), "Descending") { if !strings.HasSuffix(order, " desc") { order = order + " desc" } @@ -518,6 +571,19 @@ func (e *EmbyService) payloadsForMedia(ctx context.Context, rows []model.Media, // Item 单条目详情。 func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[string]any, error) { + if lib, err := e.repo.Library.FindByID(ctx, mediaID); err != nil { + return nil, err + } else if lib != nil { + libs := FilterDisplayCloudLibraries(ctx, e.repo, []model.Library{*lib}) + if len(libs) == 0 { + return nil, nil + } + visibility := e.mediaVisibility(ctx, userID) + if !e.libraryVisibleFromCachedVisibility(libs[0], visibility) { + return nil, nil + } + return e.libraryAsView(&libs[0]), nil + } if strings.HasPrefix(mediaID, embyVirtualSeasonPrefix) { if season, ok, err := e.findSeasonGroup(ctx, mediaID, userID); err != nil { return nil, err @@ -579,7 +645,7 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string, q = q.Where("library_id = ?", parentID) } var rows []model.Media - if err := q.Order("created_at desc").Limit(limit).Find(&rows).Error; err != nil { + if err := q.Order("media.created_at desc").Limit(limit).Find(&rows).Error; err != nil { return nil, err } favs := map[string]bool{} @@ -621,7 +687,7 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l Where("library_id IN ? AND (season_num > 0 OR episode_num > 0)", e.mergedLibraryIDs(ctx, libraryID)) q = e.applyUserMediaVisibility(ctx, q, userID) var rows []model.Media - if err := q.Order("created_at desc").Limit(rowLimit).Find(&rows).Error; err != nil { + if err := q.Order("media.created_at desc").Limit(rowLimit).Find(&rows).Error; err != nil { return nil, err } groups := e.seriesGroupsFromMedia(rows) @@ -769,6 +835,12 @@ func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID strin if p.SearchTerm != "" { q = q.Where("title LIKE ? OR original_name LIKE ?", "%"+p.SearchTerm+"%", "%"+p.SearchTerm+"%") } + if containsEmbyFilter(p.Filters, "IsFavorite") { + if strings.TrimSpace(p.UserID) == "" { + return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": 0, "StartIndex": p.StartIndex}, nil + } + q = q.Joins("JOIN favorites ON favorites.media_id = media.id AND favorites.user_id = ? AND favorites.deleted_at IS NULL", p.UserID) + } rowLimit := p.StartIndex + maxInt(p.Limit*40, 1000) if rowLimit < p.Limit { rowLimit = p.Limit @@ -777,7 +849,7 @@ func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID strin rowLimit = embySeriesGroupingLimit } var rows []model.Media - if err := q.Order("created_at desc").Limit(rowLimit).Find(&rows).Error; err != nil { + if err := q.Order("media.created_at desc").Limit(rowLimit).Find(&rows).Error; err != nil { return nil, err } groups := e.seriesGroupsFromMedia(rows) @@ -939,7 +1011,7 @@ func (e *EmbyService) findSeriesGroup(ctx context.Context, id, userID string) (e if !strings.HasPrefix(id, embyVirtualSeriesPrefix) { q = q.Where("series_id = ?", id) } - if err := q.Order("season_num asc, episode_num asc, created_at asc").Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil { + if err := q.Order("media.season_num asc, media.episode_num asc, media.created_at asc").Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil { return embySeriesGroup{}, false, err } for _, group := range e.seriesGroupsFromMedia(rows) { @@ -982,7 +1054,7 @@ func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (e Where("season_num > 0 OR episode_num > 0") q = e.applyUserMediaVisibility(ctx, q, userID) if err := q. - Order("season_num asc, episode_num asc, created_at asc"). + Order("media.season_num asc, media.episode_num asc, media.created_at asc"). Limit(embySeriesGroupingLimit). Find(&rows).Error; err != nil { return embySeasonGroup{}, false, err @@ -1284,6 +1356,69 @@ func containsItemType(types []string, want string) bool { return false } +func containsSupportedEmbyItemType(types []string) bool { + for _, itemType := range types { + switch strings.ToLower(strings.TrimSpace(itemType)) { + case "movie", "series", "season", "episode", "video", "folder", "collectionfolder": + return true + } + } + return false +} + +func containsOnlyFolderItemTypes(types []string) bool { + if len(types) == 0 { + return false + } + for _, itemType := range types { + switch strings.ToLower(strings.TrimSpace(itemType)) { + case "folder", "collectionfolder": + default: + return false + } + } + return true +} + +func emptyItemsEnvelope(startIndex int) map[string]any { + return map[string]any{ + "Items": []map[string]any{}, + "TotalRecordCount": int64(0), + "StartIndex": startIndex, + } +} + +func containsEmbyFilter(filters []string, want string) bool { + for _, filter := range filters { + if strings.EqualFold(strings.TrimSpace(filter), want) { + return true + } + } + return false +} + +func firstCSVValue(value string) string { + if i := strings.Index(value, ","); i >= 0 { + value = value[:i] + } + return strings.TrimSpace(value) +} + +func primarySupportedEmbySort(sortBy string, resumeFilter bool) string { + for _, part := range strings.Split(sortBy, ",") { + key := strings.ToLower(strings.TrimSpace(part)) + switch key { + case "sortname", "name", "premieredate", "productionyear", "datecreated", "communityrating": + return key + case "dateplayed": + if resumeFilter { + return key + } + } + } + return strings.ToLower(strings.TrimSpace(firstCSVValue(sortBy))) +} + func pageSlice[T any](items []T, start, limit int) []T { if start < 0 { start = 0 @@ -1639,8 +1774,15 @@ func (e *EmbyService) mediaSource(ctx context.Context, m *model.Media, asEmbedde } isCloud := strings.TrimSpace(m.STRMURL) != "" playURL := embyDirectStreamURL(m.ID, container) - if isCloud && STRMPlaybackEnabled(ctx, e.repo) { - playURL = embySTRMStreamURL(m.ID) + if isCloud { + switch CloudPlaybackMode(ctx, e.repo) { + case CloudPlaybackModeSTRM: + playURL = embySTRMStreamURL(m.ID) + case CloudPlaybackModeRedirectProxy: + playURL = embyDirectStreamURL(m.ID, container) + default: + playURL = "" + } } if isCloud { // Cloud/WebDAV media is already a direct/proxy stream. Advertising HLS @@ -1662,18 +1804,17 @@ func (e *EmbyService) mediaSource(ctx context.Context, m *model.Media, asEmbedde "RequiresClosing": false, "ReadAtNativeFramerate": false, "SupportsTranscoding": !directOnly, - // 云盘媒体禁用 DirectPlay:DirectPlay 语义是「客户端直接访问 - // Path」,而云盘媒体的 Path 是不带鉴权 token 的内部 /api/cloud/play - // 路径,Infuse/VidHub 等播放器直接请求会得到 401/404。强制它们走 - // DirectStream(/Videos/{id}/stream?api_key=...),由服务端校验后 - // 302 到云盘直链。 - "SupportsDirectStream": true, - "SupportsDirectPlay": !isCloud, + // 云盘媒体的 Path 在 PlaybackInfo 阶段会被补上 api_key,且最终 + // 302 到云盘直链。Infuse/Emby 官方客户端会优先挑选 DirectPlay + // 源;如果这里标 false,即使 DirectStreamUrl 可用,也可能被判定 + // 为“没有可播放媒体源”。 + "SupportsDirectStream": !isCloud || playURL != "", + "SupportsDirectPlay": !isCloud || playURL != "", "SupportsProbing": true, "RunTimeTicks": int64(m.DurationSec) * 10_000_000, "MediaStreams": e.mediaStreams(m), } - if !asEmbedded { + if !asEmbedded && playURL != "" { src["DirectStreamUrl"] = playURL // 直连解码模式下不下发 TranscodingUrl,迫使客户端本地解码直连, // 宿主机不参与转码。 @@ -1681,7 +1822,7 @@ func (e *EmbyService) mediaSource(ctx context.Context, m *model.Media, asEmbedde src["TranscodingUrl"] = "/Videos/" + m.ID + "/master.m3u8" } } - if strings.TrimSpace(m.STRMURL) != "" { + if strings.TrimSpace(m.STRMURL) != "" && playURL != "" { // STRM / cloud:// media must stay behind a token-aware endpoint. When // STRM playback is enabled we expose /api/stream so third-party clients // follow the same STRM entry as generated .strm files; when disabled we diff --git a/internal/service/emby_compat_test.go b/internal/service/emby_compat_test.go index e438afe..8777670 100644 --- a/internal/service/emby_compat_test.go +++ b/internal/service/emby_compat_test.go @@ -213,6 +213,155 @@ func TestEmbyRootItemsExposeLibraries(t *testing.T) { } } +func TestEmbyFolderItemQueryExposesLibrariesForHome(t *testing.T) { + svc := newTestEmbyService(t) + lib := model.Library{Name: "电影", Path: `/media/movies`, Type: "movie", Enabled: true} + if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + if err := svc.repo.DB.Create(&model.Media{Base: model.Base{ID: "movie-1"}, LibraryID: lib.ID, Title: "不应出现在文件夹查询", Path: `/media/movies/a.mkv`}).Error; err != nil { + t.Fatalf("create media: %v", err) + } + + out, err := svc.Items(t.Context(), ItemsParams{ + IncludeItemTypes: []string{"Folder", "CollectionFolder"}, + Limit: 50, + }) + if err != nil { + t.Fatalf("folder items: %v", err) + } + items := out["Items"].([]map[string]any) + if len(items) != 1 { + t.Fatalf("expected one library folder, got %#v", items) + } + if items[0]["Type"] != "CollectionFolder" || items[0]["IsFolder"] != true { + t.Fatalf("folder query should return collection folders, got %#v", items[0]) + } +} + +func TestEmbyUnsupportedItemTypesDoNotLeakAllMedia(t *testing.T) { + svc := newTestEmbyService(t) + lib := model.Library{Name: "电影", Path: `/media/movies`, Type: "movie", Enabled: true} + if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + if err := svc.repo.DB.Create(&model.Media{Base: model.Base{ID: "movie-1"}, LibraryID: lib.ID, Title: "普通电影", Path: `/media/movies/a.mkv`}).Error; err != nil { + t.Fatalf("create media: %v", err) + } + + for _, includeType := range []string{"BoxSet", "Game", "Book", "Audio", "MusicAlbum", "Playlist", "TvChannel"} { + out, err := svc.Items(t.Context(), ItemsParams{ + IncludeItemTypes: []string{includeType}, + Recursive: true, + Limit: 50, + }) + if err != nil { + t.Fatalf("%s items: %v", includeType, err) + } + if out["TotalRecordCount"] != int64(0) { + t.Fatalf("%s should not return media rows, got %#v", includeType, out) + } + items := out["Items"].([]map[string]any) + if len(items) != 0 { + t.Fatalf("%s should return an empty list, got %#v", includeType, items) + } + } +} + +func TestEmbyItemsFiltersFavorites(t *testing.T) { + svc := newTestEmbyService(t) + viewer := &model.User{Base: model.Base{ID: "user-1"}, Username: "viewer", Role: "user", Tier: "free", IsActive: true} + if err := svc.repo.User.Create(t.Context(), viewer); err != nil { + t.Fatalf("create viewer: %v", err) + } + lib := model.Library{Name: "电影", Path: `/media/movies`, Type: "movie", Enabled: true} + if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + favorite := model.Media{Base: model.Base{ID: "fav-1"}, LibraryID: lib.ID, Title: "收藏电影", Path: `/media/movies/fav.mkv`} + normal := model.Media{Base: model.Base{ID: "normal-1"}, LibraryID: lib.ID, Title: "普通电影", Path: `/media/movies/normal.mkv`} + if err := svc.repo.DB.Create(&favorite).Error; err != nil { + t.Fatalf("create favorite media: %v", err) + } + if err := svc.repo.DB.Create(&normal).Error; err != nil { + t.Fatalf("create normal media: %v", err) + } + if err := svc.repo.DB.Create(&model.Favorite{UserID: viewer.ID, MediaID: favorite.ID}).Error; err != nil { + t.Fatalf("create favorite: %v", err) + } + + out, err := svc.Items(t.Context(), ItemsParams{ + UserID: viewer.ID, + Filters: []string{"IsFavorite"}, + Recursive: true, + Limit: 50, + }) + if err != nil { + t.Fatalf("favorite items: %v", err) + } + if out["TotalRecordCount"] != int64(1) { + t.Fatalf("expected one favorite, got %#v", out) + } + items := out["Items"].([]map[string]any) + if len(items) != 1 || items[0]["Id"] != favorite.ID { + t.Fatalf("favorite filter returned wrong items: %#v", items) + } + userData := items[0]["UserData"].(map[string]any) + if userData["IsFavorite"] != true { + t.Fatalf("favorite payload should carry IsFavorite=true: %#v", userData) + } +} + +func TestEmbyItemsFiltersResumableForHome(t *testing.T) { + svc := newTestEmbyService(t) + viewer := &model.User{Base: model.Base{ID: "user-1"}, Username: "viewer", Role: "user", Tier: "free", IsActive: true} + if err := svc.repo.User.Create(t.Context(), viewer); err != nil { + t.Fatalf("create viewer: %v", err) + } + lib := model.Library{Name: "电影", Path: `/media/movies`, Type: "movie", Enabled: true} + if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + resumable := model.Media{Base: model.Base{ID: "resume-1"}, LibraryID: lib.ID, Title: "继续观看", Path: `/media/movies/resume.mkv`, DurationSec: 120} + normal := model.Media{Base: model.Base{ID: "normal-1"}, LibraryID: lib.ID, Title: "普通电影", Path: `/media/movies/normal.mkv`, DurationSec: 120} + if err := svc.repo.DB.Create(&resumable).Error; err != nil { + t.Fatalf("create resumable media: %v", err) + } + if err := svc.repo.DB.Create(&normal).Error; err != nil { + t.Fatalf("create normal media: %v", err) + } + if err := svc.repo.DB.Create(&model.PlaybackHistory{ + UserID: viewer.ID, + MediaID: resumable.ID, + PositionMs: 30_000, + DurationMs: 120_000, + WatchedAt: time.Now(), + Completed: false, + }).Error; err != nil { + t.Fatalf("create playback history: %v", err) + } + + out, err := svc.Items(t.Context(), ItemsParams{ + UserID: viewer.ID, + Filters: []string{"IsResumable"}, + Recursive: true, + SortBy: "DatePlayed", + SortOrder: "Descending", + Limit: 50, + StartIndex: 0, + }) + if err != nil { + t.Fatalf("resumable items: %v", err) + } + if out["TotalRecordCount"] != int64(1) { + t.Fatalf("expected one resumable item, got %#v", out) + } + items := out["Items"].([]map[string]any) + if len(items) != 1 || items[0]["Id"] != resumable.ID { + t.Fatalf("resumable filter returned wrong items: %#v", items) + } +} + func TestEmbyUserPolicyDisablesDownloadsForViewers(t *testing.T) { svc := newTestEmbyService(t) viewer := &model.User{Username: "viewer", Role: "user", Tier: "free", IsActive: true} @@ -337,6 +486,9 @@ func TestEmbyPlaybackInfoRespectsDirectPlayOnly(t *testing.T) { func TestEmbyPlaybackInfoKeepsSTRMBehindStreamEndpoint(t *testing.T) { svc := newTestEmbyService(t) + if err := svc.repo.Setting.Set(t.Context(), CloudPlaybackModeSettingKey, CloudPlaybackModeSTRM); err != nil { + t.Fatalf("set cloud playback mode: %v", err) + } lib := model.Library{Name: "夸克网盘", Path: `cloud://quark/0`, Type: "movie", Enabled: true} if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { t.Fatalf("create library: %v", err) @@ -374,8 +526,8 @@ func TestEmbyPlaybackInfoKeepsSTRMBehindStreamEndpoint(t *testing.T) { func TestEmbyPlaybackInfoUsesVideoStreamWhenSTRMDisabled(t *testing.T) { svc := newTestEmbyService(t) - if err := svc.repo.Setting.Set(t.Context(), STRMEnabledSettingKey, "false"); err != nil { - t.Fatalf("set strm disabled: %v", err) + if err := svc.repo.Setting.Set(t.Context(), CloudPlaybackModeSettingKey, CloudPlaybackModeRedirectProxy); err != nil { + t.Fatalf("set cloud playback mode: %v", err) } lib := model.Library{Name: "OpenList", Path: `cloud://openlist/Movies`, Type: "movie", Enabled: true} if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { diff --git a/internal/service/media.go b/internal/service/media.go index 8f08321..1cd181e 100644 --- a/internal/service/media.go +++ b/internal/service/media.go @@ -147,6 +147,9 @@ func mappedPathCandidates(input string) []string { } clean := filepath.Clean(input) add(clean) + if slashClean := cleanPathForVolumeMapping(input); slashClean != "" { + add(slashClean) + } for _, candidate := range dockerVolumePathCandidates(clean) { add(candidate) } @@ -165,7 +168,7 @@ func isAccessibleDir(path string) bool { } func dockerVolumePathCandidates(path string) []string { - normalized := filepath.ToSlash(filepath.Clean(path)) + normalized := cleanPathForVolumeMapping(path) var candidates []string addCandidate := func(candidate string) { candidate = filepath.Clean(filepath.FromSlash(candidate)) @@ -184,7 +187,7 @@ func dockerVolumePathCandidates(path string) []string { {env: "MEDIASTATION_MEDIA_DIR", container: envOrDefault("MEDIASTATION_MEDIA_CONTAINER_DIR", "/media")}, {env: "MEDIASTATION_DOWNLOAD_DIR", container: envOrDefault("MEDIASTATION_DOWNLOAD_CONTAINER_DIR", "/downloads")}, } { - host := filepath.ToSlash(filepath.Clean(os.Getenv(mapping.env))) + host := cleanPathForVolumeMapping(os.Getenv(mapping.env)) if host == "." || host == "" || strings.HasPrefix(host, ".") { continue } @@ -201,17 +204,44 @@ func dockerVolumePathCandidates(path string) []string { part string container string }{ - {part: "/media/", container: "/media/"}, - {part: "/downloads/", container: "/downloads/"}, + {part: "/media", container: envOrDefault("MEDIASTATION_MEDIA_CONTAINER_DIR", "/media")}, + {part: "/downloads", container: envOrDefault("MEDIASTATION_DOWNLOAD_CONTAINER_DIR", "/downloads")}, } { - if idx := strings.Index(normalized, marker.part); idx >= 0 { - addCandidate(marker.container + strings.TrimPrefix(normalized[idx+len(marker.part):], "/")) + part := strings.TrimRight(marker.part, "/") + container := strings.TrimRight(filepath.ToSlash(marker.container), "/") + markerPath := pathAfterWindowsDrivePrefix(normalized) + if markerPath == part { + addCandidate(container) + continue + } + if strings.HasPrefix(markerPath, part+"/") { + addCandidate(container + strings.TrimPrefix(markerPath, part)) } } return candidates } +func cleanPathForVolumeMapping(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + path = strings.ReplaceAll(path, "\\", "/") + return filepath.ToSlash(filepath.Clean(filepath.FromSlash(path))) +} + +func pathAfterWindowsDrivePrefix(path string) string { + if len(path) >= 3 && path[1] == ':' && path[2] == '/' && isASCIIAlpha(path[0]) { + return path[2:] + } + return path +} + +func isASCIIAlpha(ch byte) bool { + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') +} + func sameLibraryPath(a, b string) bool { return filepath.Clean(a) == filepath.Clean(b) } diff --git a/internal/service/media_test.go b/internal/service/media_test.go index 8fbbacd..bebabe2 100644 --- a/internal/service/media_test.go +++ b/internal/service/media_test.go @@ -49,6 +49,21 @@ func TestResolveAccessibleLibraryPathKeepsAccessibleContainerPath(t *testing.T) } } +func TestMappedPathCandidatesMapWindowsDriveDownloadMarker(t *testing.T) { + root := t.TempDir() + containerDownloads := filepath.Join(root, "container", "downloads") + containerLibrary := filepath.Join(containerDownloads, "国产剧") + t.Setenv("MEDIASTATION_DOWNLOAD_CONTAINER_DIR", containerDownloads) + + want := filepath.Clean(containerLibrary) + for _, got := range mappedPathCandidates(`F:\downloads\国产剧`) { + if got == want { + return + } + } + t.Fatalf("mappedPathCandidates() missing %q", want) +} + func TestDeleteCloudLibraryPurgesMountWithoutRecycleBin(t *testing.T) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { diff --git a/internal/service/storage_config.go b/internal/service/storage_config.go index f49f330..069e20d 100644 --- a/internal/service/storage_config.go +++ b/internal/service/storage_config.go @@ -147,6 +147,52 @@ func (s *StorageConfigService) Save(ctx context.Context, in StorageInput) (*Stor return s.Get(ctx, in.Type) } +// Logout clears saved cloud login credentials and disables the storage backend. +// It intentionally keeps non-secret connection hints such as server / WebDAV +// URL / timeout so the admin can log in again without rebuilding the form. +func (s *StorageConfigService) Logout(ctx context.Context, typ string) (*StorageView, error) { + if !validStorageType(typ) { + return nil, fmt.Errorf("unsupported storage type %q", typ) + } + if !cloud.IsCloudType(typ) { + return nil, fmt.Errorf("not a cloud provider: %q", typ) + } + view, err := s.Get(ctx, typ) + if err != nil { + return nil, err + } + if view == nil { + return nil, fmt.Errorf("%s storage not configured", typ) + } + cfg := make(map[string]any, len(view.Config)) + for k, v := range view.Config { + if isStorageLoginSecretKey(k) || isDeprecatedStoragePlaybackKey(k) { + continue + } + cfg[k] = v + } + enabled := false + return s.Save(ctx, StorageInput{Type: typ, Config: cfg, Enabled: &enabled}) +} + +func isStorageLoginSecretKey(key string) bool { + switch strings.ToLower(strings.TrimSpace(key)) { + case "cookie", "token", "username", "password", "access_key", "secret_key": + return true + default: + return false + } +} + +func isDeprecatedStoragePlaybackKey(key string) bool { + switch strings.ToLower(strings.TrimSpace(key)) { + case "force_302", "force_proxy": + return true + default: + return false + } +} + // Test runs a connection probe against the supplied (un-saved) config. // The implementation is best-effort: it issues a single HEAD/PROPFIND // to verify reachability, not full functionality. diff --git a/internal/service/storage_config_logout_test.go b/internal/service/storage_config_logout_test.go new file mode 100644 index 0000000..84301ed --- /dev/null +++ b/internal/service/storage_config_logout_test.go @@ -0,0 +1,39 @@ +package service + +import "testing" + +func TestStorageConfigLogoutClearsCloudCredentialsOnly(t *testing.T) { + _, storage := newStorageUploadTestService(t) + enabled := true + if _, err := storage.Save(t.Context(), StorageInput{ + Type: "openlist", + Config: map[string]any{ + "server": "http://openlist.test", + "url": "http://openlist.test/dav/", + "username": "user", + "password": "pass", + "token": "token", + "timeout_seconds": "120", + "force_302": "true", + }, + Enabled: &enabled, + }); err != nil { + t.Fatalf("save storage: %v", err) + } + + view, err := storage.Logout(t.Context(), "openlist") + if err != nil { + t.Fatalf("logout: %v", err) + } + if view.Enabled { + t.Fatal("storage should be disabled after logout") + } + for _, key := range []string{"username", "password", "token", "force_302", "force_proxy"} { + if _, ok := view.Config[key]; ok { + t.Fatalf("logout should clear %s, config = %#v", key, view.Config) + } + } + if view.Config["server"] != "http://openlist.test" || view.Config["url"] != "http://openlist.test/dav/" || view.Config["timeout_seconds"] != "120" { + t.Fatalf("logout should keep non-secret connection hints, config = %#v", view.Config) + } +} diff --git a/internal/service/stream.go b/internal/service/stream.go index b94bfdb..ace5558 100644 --- a/internal/service/stream.go +++ b/internal/service/stream.go @@ -33,7 +33,21 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/repository" ) -const STRMEnabledSettingKey = "strm.enabled" +const ( + STRMEnabledSettingKey = "strm.enabled" + CloudPlaybackModeSettingKey = "cloud.playback_mode" + CloudPlaybackSTRMEnabledSettingKey = "cloud.playback_strm_enabled" + CloudPlaybackRedirectEnabledSettingKey = "cloud.playback_redirect_proxy_enabled" + + CloudPlaybackModeSTRM = "strm" + CloudPlaybackModeRedirectProxy = "redirect_proxy" +) + +type CloudPlaybackOptions struct { + STRMEnabled bool + RedirectProxyEnabled bool + PreferredMode string +} // StreamService serves media files with proper Range support so browsers can // seek into the stream. @@ -62,6 +76,8 @@ var ErrMediaNotFound = errors.New("media not found") // 调用方应把它与「媒体不存在」区分开,避免把配置类故障当成 404 返回给播放器。 var ErrCloudPlaybackUnavailable = errors.New("cloud media playback unavailable: media missing play url; re-scan the library") +var ErrCloudPlaybackDisabled = errors.New("cloud media playback disabled by admin settings") + // normalizeCloudPlayTarget 把存库的云盘播放 URL 规范化为相对路径。 // // STRMURL 是扫描时根据当时的 server_url/请求地址生成并固化进数据库的。 @@ -201,6 +217,14 @@ func requestToken(r *http.Request) string { return v } } + for _, hk := range []string{"X-Emby-Authorization", "X-MediaBrowser-Authorization"} { + if token := streamTokenFromAuthHeader(r.Header.Get(hk)); token != "" { + return token + } + } + if token := streamTokenFromAuthHeader(r.Header.Get("Authorization")); token != "" { + return token + } for _, k := range []string{"token", "api_key", "apiKey", "ApiKey"} { if v := strings.TrimSpace(r.URL.Query().Get(k)); v != "" { return v @@ -209,15 +233,126 @@ func requestToken(r *http.Request) string { return "" } -func STRMPlaybackEnabled(ctx context.Context, repo *repository.Container) bool { +func streamTokenFromAuthHeader(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + for _, prefix := range []string{"Bearer ", "Emby "} { + if strings.HasPrefix(value, prefix) { + return strings.TrimSpace(strings.TrimPrefix(value, prefix)) + } + } + if strings.HasPrefix(value, "MediaBrowser ") || strings.Contains(value, "Token=") { + for _, part := range strings.Split(value, ",") { + part = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(part), "MediaBrowser ")) + if !strings.HasPrefix(part, "Token=") { + continue + } + token := strings.TrimSpace(strings.TrimPrefix(part, "Token=")) + return strings.Trim(token, `"`) + } + return "" + } + return value +} + +func CloudPlaybackSettings(ctx context.Context, repo *repository.Container) CloudPlaybackOptions { + opts := CloudPlaybackOptions{ + STRMEnabled: false, + RedirectProxyEnabled: true, + PreferredMode: CloudPlaybackModeRedirectProxy, + } if repo == nil || repo.Setting == nil { - return true + return opts } - v, err := repo.Setting.Get(ctx, STRMEnabledSettingKey) + modeRaw, hasMode := settingValue(ctx, repo, CloudPlaybackModeSettingKey) + if mode := normalizeCloudPlaybackMode(modeRaw); mode != "" { + opts.PreferredMode = mode + } + legacySTRM, hasLegacySTRM := settingValue(ctx, repo, STRMEnabledSettingKey) + legacySTRMEnabled := hasLegacySTRM && parseBoolSetting(legacySTRM, false) + if !hasMode && legacySTRMEnabled { + opts.PreferredMode = CloudPlaybackModeSTRM + } + if raw, ok := settingValue(ctx, repo, CloudPlaybackSTRMEnabledSettingKey); ok { + opts.STRMEnabled = parseBoolSetting(raw, false) + } else if hasLegacySTRM { + opts.STRMEnabled = legacySTRMEnabled + } else if hasMode && opts.PreferredMode == CloudPlaybackModeSTRM { + opts.STRMEnabled = true + } + if raw, ok := settingValue(ctx, repo, CloudPlaybackRedirectEnabledSettingKey); ok { + opts.RedirectProxyEnabled = parseBoolSetting(raw, true) + } else if hasMode && opts.PreferredMode == CloudPlaybackModeRedirectProxy { + opts.RedirectProxyEnabled = true + } + if opts.PreferredMode == CloudPlaybackModeSTRM && !opts.STRMEnabled && opts.RedirectProxyEnabled { + opts.PreferredMode = CloudPlaybackModeRedirectProxy + } + if opts.PreferredMode == CloudPlaybackModeRedirectProxy && !opts.RedirectProxyEnabled && opts.STRMEnabled { + opts.PreferredMode = CloudPlaybackModeSTRM + } + return opts +} + +func CloudPlaybackMode(ctx context.Context, repo *repository.Container) string { + opts := CloudPlaybackSettings(ctx, repo) + switch opts.PreferredMode { + case CloudPlaybackModeSTRM: + if opts.STRMEnabled { + return CloudPlaybackModeSTRM + } + if opts.RedirectProxyEnabled { + return CloudPlaybackModeRedirectProxy + } + case CloudPlaybackModeRedirectProxy: + if opts.RedirectProxyEnabled { + return CloudPlaybackModeRedirectProxy + } + if opts.STRMEnabled { + return CloudPlaybackModeSTRM + } + } + return "" +} + +func STRMPlaybackEnabled(ctx context.Context, repo *repository.Container) bool { + return CloudPlaybackSettings(ctx, repo).STRMEnabled +} + +func cloudPlaybackModeEnabled(ctx context.Context, repo *repository.Container, mode string) bool { + opts := CloudPlaybackSettings(ctx, repo) + switch normalizeCloudPlaybackMode(mode) { + case CloudPlaybackModeSTRM: + return opts.STRMEnabled + case CloudPlaybackModeRedirectProxy: + return opts.RedirectProxyEnabled + default: + return opts.STRMEnabled || opts.RedirectProxyEnabled + } +} + +func settingValue(ctx context.Context, repo *repository.Container, key string) (string, bool) { + if repo == nil || repo.Setting == nil { + return "", false + } + v, err := repo.Setting.Get(ctx, key) if err != nil || strings.TrimSpace(v) == "" { - return true + return "", false + } + return v, true +} + +func normalizeCloudPlaybackMode(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "strm", "strmurl", "strm_url", "api_stream", "api-stream": + return CloudPlaybackModeSTRM + case "302", "proxy", "reverse_proxy", "redirect", "redirect_proxy", "302_proxy", "302-proxy", "cloud": + return CloudPlaybackModeRedirectProxy + default: + return "" } - return parseBoolSetting(v, true) } // ServeFile streams the file backing the given media ID using @@ -227,6 +362,10 @@ func STRMPlaybackEnabled(ctx context.Context, repo *repository.Container) bool { // instead of opening a local file. This lets WebDAV / Alist / S3 / HTTP // direct links flow through the rest of the player UI unchanged. func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaID string) error { + return s.ServeFileWithCloudMode(w, r, mediaID, "") +} + +func (s *StreamService) ServeFileWithCloudMode(w http.ResponseWriter, r *http.Request, mediaID, cloudMode string) error { m, err := s.repo.Media.FindByID(r.Context(), mediaID) if err != nil { return err @@ -235,6 +374,9 @@ func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaI return ErrMediaNotFound } if strmURL := strings.TrimSpace(m.STRMURL); strmURL != "" && (isCloudPlaybackTarget(strmURL) || STRMPlaybackEnabled(r.Context(), s.repo)) { + if !cloudPlaybackModeEnabled(r.Context(), s.repo, cloudMode) { + return ErrCloudPlaybackDisabled + } // 云盘播放 URL 先规范化为相对路径,免疫扫描时固化的旧 host。 target := normalizeCloudPlayTarget(strmURL) target = withAuthTokenForInternalRedirect(target, r, PublicServerURL(r.Context(), s.repo, s.cfg)) diff --git a/internal/service/stream_test.go b/internal/service/stream_test.go index 84b3dde..510c747 100644 --- a/internal/service/stream_test.go +++ b/internal/service/stream_test.go @@ -1,6 +1,7 @@ package service import ( + "errors" "net/http" "net/http/httptest" "net/url" @@ -117,7 +118,7 @@ func TestServeFileRedirectUsesForwardedTunnelHost(t *testing.T) { func TestServeFileRedirectsCloudMediaForVideoStreamMode(t *testing.T) { repos := newStreamTestRepo(t) - if err := repos.Setting.Set(t.Context(), STRMEnabledSettingKey, "false"); err != nil { + if err := repos.Setting.Set(t.Context(), CloudPlaybackModeSettingKey, CloudPlaybackModeRedirectProxy); err != nil { t.Fatal(err) } if err := repos.DB.Create(&model.Media{ @@ -145,6 +146,66 @@ func TestServeFileRedirectsCloudMediaForVideoStreamMode(t *testing.T) { } } +func TestCloudPlaybackModeUsesExplicitModeBeforeLegacySTRMFlag(t *testing.T) { + repos := newStreamTestRepo(t) + if got := CloudPlaybackMode(t.Context(), repos); got != CloudPlaybackModeRedirectProxy { + t.Fatalf("default mode = %q, want %q", got, CloudPlaybackModeRedirectProxy) + } + if err := repos.Setting.Set(t.Context(), STRMEnabledSettingKey, "true"); err != nil { + t.Fatal(err) + } + if got := CloudPlaybackMode(t.Context(), repos); got != CloudPlaybackModeSTRM { + t.Fatalf("legacy strm.enabled=true mode = %q, want %q", got, CloudPlaybackModeSTRM) + } + if err := repos.Setting.Set(t.Context(), CloudPlaybackModeSettingKey, CloudPlaybackModeRedirectProxy); err != nil { + t.Fatal(err) + } + if got := CloudPlaybackMode(t.Context(), repos); got != CloudPlaybackModeRedirectProxy { + t.Fatalf("explicit mode should override legacy flag, got %q", got) + } + if err := repos.Setting.Set(t.Context(), CloudPlaybackModeSettingKey, CloudPlaybackModeSTRM); err != nil { + t.Fatal(err) + } + if got := CloudPlaybackMode(t.Context(), repos); got != CloudPlaybackModeSTRM { + t.Fatalf("explicit strm mode = %q, want %q", got, CloudPlaybackModeSTRM) + } + if err := repos.Setting.Set(t.Context(), CloudPlaybackSTRMEnabledSettingKey, "false"); err != nil { + t.Fatal(err) + } + if err := repos.Setting.Set(t.Context(), CloudPlaybackRedirectEnabledSettingKey, "false"); err != nil { + t.Fatal(err) + } + if got := CloudPlaybackMode(t.Context(), repos); got != "" { + t.Fatalf("both disabled mode = %q, want empty", got) + } +} + +func TestServeFileRejectsCloudMediaWhenSelectedModeDisabled(t *testing.T) { + repos := newStreamTestRepo(t) + if err := repos.Setting.Set(t.Context(), CloudPlaybackSTRMEnabledSettingKey, "false"); err != nil { + t.Fatal(err) + } + if err := repos.Setting.Set(t.Context(), CloudPlaybackRedirectEnabledSettingKey, "false"); err != nil { + t.Fatal(err) + } + if err := repos.DB.Create(&model.Media{ + Base: model.Base{ID: "cloud-1"}, + Title: "Cloud", + Path: "cloud://openlist/Movie.mkv", + STRMURL: "/api/cloud/play/openlist?ref=movie", + }).Error; err != nil { + t.Fatal(err) + } + svc := NewStreamService(&config.Config{}, zap.NewNop(), repos, nil) + req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/cloud-1?api_key=jwt123", nil) + w := httptest.NewRecorder() + + err := svc.ServeFileWithCloudMode(w, req, "cloud-1", CloudPlaybackModeSTRM) + if !errors.Is(err, ErrCloudPlaybackDisabled) { + t.Fatalf("error = %v, want ErrCloudPlaybackDisabled", err) + } +} + func newStreamTestRepo(t *testing.T) *repository.Container { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) @@ -166,6 +227,15 @@ func TestRequestTokenFromBearerHeader(t *testing.T) { } } +func TestRequestTokenFromMediaBrowserAuthorizationHeader(t *testing.T) { + h := http.Header{} + h.Set("X-MediaBrowser-Authorization", `MediaBrowser Client="Infuse", Device="PC", Token="mbtok"`) + r := &http.Request{Header: h, URL: &url.URL{}} + if got := requestToken(r); got != "mbtok" { + t.Fatalf("MediaBrowser token not extracted: %q", got) + } +} + func TestAppendQueryToHLSSegments(t *testing.T) { in := "#EXTM3U\n#EXTINF:4.0,\nseg_00000.ts\n#EXTINF:4.0,\nseg_00001.ts?old=1\n" got := appendQueryToHLSSegments(in, "token=abc") diff --git a/internal/service/watcher.go b/internal/service/watcher.go index 1b1e22a..80b433b 100644 --- a/internal/service/watcher.go +++ b/internal/service/watcher.go @@ -102,7 +102,15 @@ func (w *WatcherService) Refresh(ctx context.Context) error { if _, _, ok := parseCloudLibraryPath(l.Path); ok { continue } - for _, dir := range listDirsForWatch(l.Path) { + watchRoot, info, err := resolveAccessibleMappedPath(l.Path) + if err != nil || !info.IsDir() { + w.log.Warn("watch path inaccessible", + zap.String("path", l.Path), + zap.String("library_id", l.ID), + zap.Error(err)) + continue + } + for _, dir := range listDirsForWatch(watchRoot) { current[dir] = l.ID } } diff --git a/internal/service/watcher_test.go b/internal/service/watcher_test.go new file mode 100644 index 0000000..c36d8b7 --- /dev/null +++ b/internal/service/watcher_test.go @@ -0,0 +1,63 @@ +package service + +import ( + "os" + "path/filepath" + "testing" + + "github.com/fsnotify/fsnotify" + "github.com/glebarez/sqlite" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +func TestWatcherRefreshMapsHostLibraryPathToContainerPath(t *testing.T) { + root := t.TempDir() + hostMedia := filepath.Join(root, "nas-host", "media") + containerMedia := filepath.Join(root, "container", "media") + containerLibrary := filepath.Join(containerMedia, "电视剧", "国产剧") + if err := os.MkdirAll(containerLibrary, 0o755); err != nil { + t.Fatalf("mkdir container library: %v", err) + } + t.Setenv("MEDIASTATION_MEDIA_DIR", hostMedia) + t.Setenv("MEDIASTATION_MEDIA_CONTAINER_DIR", containerMedia) + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.AutoMigrate(&model.Library{}); err != nil { + t.Fatalf("migrate: %v", err) + } + repos := repository.New(db) + lib := model.Library{ + Base: model.Base{ID: "lib-tv"}, + Name: "国产剧", + Path: filepath.Join(hostMedia, "电视剧", "国产剧"), + Type: "tv", + Enabled: true, + } + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + fw, err := fsnotify.NewWatcher() + if err != nil { + t.Fatalf("new watcher: %v", err) + } + defer fw.Close() + watcher := NewWatcherService(zap.NewNop(), repos, nil) + watcher.watcher = fw + + if err := watcher.Refresh(t.Context()); err != nil { + t.Fatalf("refresh: %v", err) + } + if _, ok := watcher.watched[filepath.Clean(containerLibrary)]; !ok { + t.Fatalf("expected mapped container path watched, got %#v", watcher.watched) + } + if _, ok := watcher.watched[filepath.Clean(lib.Path)]; ok { + t.Fatalf("host path should not be watched inside container: %#v", watcher.watched) + } +} diff --git a/web/src/api/storage_config.ts b/web/src/api/storage_config.ts index 8408bdb..b8a2ae9 100644 --- a/web/src/api/storage_config.ts +++ b/web/src/api/storage_config.ts @@ -80,6 +80,11 @@ export const storageAPI = { .put(`/admin/storage/${type}`, { type, config, enabled }) .then((r) => r.data), + logout: (type: StorageType) => + api + .post(`/admin/storage/${type}/logout`) + .then((r) => r.data), + test: (type: StorageType, config: Record) => api .post<{ ok: boolean; error?: string }>(`/admin/storage/${type}/test`, { diff --git a/web/src/pages/FileManagerPage.tsx b/web/src/pages/FileManagerPage.tsx index cfc2ded..58a7604 100644 --- a/web/src/pages/FileManagerPage.tsx +++ b/web/src/pages/FileManagerPage.tsx @@ -107,7 +107,7 @@ function settingOn(value: string): boolean { return ['1', 'true', 'yes', 'on', 'enabled', '启用', '开启'].includes(value.trim().toLowerCase()) } -// FileManagerPage mirrors MoviePilot's local Storage view at a smaller scope: +// FileManagerPage provides a focused local storage view: // browse allowed roots, optionally recurse, and perform safe local operations. export function FileManagerPage() { const [libraries, setLibraries] = useState([]) @@ -372,7 +372,7 @@ export function FileManagerPage() {

文件管理

- 像 MoviePilot 一样:在下载目录中选择文件夹或视频,直接设置目标并整理入库。 + 在下载目录中选择文件夹或视频,直接设置目标并整理入库。

diff --git a/web/src/pages/StorageConfigPage.tsx b/web/src/pages/StorageConfigPage.tsx index 359afe2..2f3595e 100644 --- a/web/src/pages/StorageConfigPage.tsx +++ b/web/src/pages/StorageConfigPage.tsx @@ -1,5 +1,5 @@ import { FormEvent, useEffect, useMemo, useState } from 'react' -import { Cloud, FileVideo, Folder, Loader2, PauseCircle, QrCode, RefreshCw, Save, Send, Trash2, Upload } from 'lucide-react' +import { Cloud, FileVideo, Folder, Loader2, LogOut, PauseCircle, QrCode, RefreshCw, Save, Send, Trash2, Upload } from 'lucide-react' import toast from 'react-hot-toast' import { libraryAPI } from '../api/library' @@ -119,7 +119,6 @@ const FIELD_DEFS: Record { setLoading(true) @@ -221,6 +218,28 @@ function StorageForm({ type }: { type: StorageType }) { } } + const onLogout = async () => { + const ok = await confirmAction({ + title: '退出云盘登录', + message: `将清空「${TYPE_LABEL[type] ?? type}」保存的 Cookie / Token / 密码并停用该外部存储;不会删除网盘文件,也不会删除已挂载媒体库。`, + confirmText: '退出登录', + }) + if (!ok) return + setLoggingOut(true) + try { + await storageAPI.logout(type) + toast.success('已退出云盘登录并停用该存储') + await refresh() + } catch (err: unknown) { + const msg = + (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? + '退出登录失败' + toast.error(msg) + } finally { + setLoggingOut(false) + } + } + if (loading) { return (
@@ -262,6 +281,17 @@ function StorageForm({ type }: { type: StorageType }) { 启用
+ {isCloud(type) && ( + + )}