fix emby clients and cloud playback controls

This commit is contained in:
ShukeBta
2026-06-13 12:08:57 +08:00
parent ba43117a57
commit a177dc61dd
28 changed files with 2031 additions and 138 deletions
+4
View File
@@ -0,0 +1,4 @@
name: MediaStationGo CodeQL
paths-ignore:
- internal/service/fileid_other.go
+70
View File
@@ -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 }}
+53
View File
@@ -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`
+390 -44
View File
@@ -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))
}
+473
View File
@@ -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{})
+5 -1
View File
@@ -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
+1
View File
@@ -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.
+15
View File
@@ -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
+4 -4
View File
@@ -18,8 +18,8 @@ const EmbyCtxUserID = "emby_user_id"
// 按优先级尝试以下认证方式:
// 1. X-Emby-Token / X-MediaBrowser-Token 请求头
// 2. Authorization: Bearer <token> / MediaBrowser Token="<token>" 请求头
// 3. X-Emby-Authorization: MediaBrowser Token="<token>"
// 4. ?token= / ?api_key= / ?apiKey= URL 参数
// 3. X-Emby-Authorization / X-MediaBrowser-Authorization: MediaBrowser Token="<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
}
+3
View File
@@ -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 {
+61 -4
View File
@@ -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" {
+70 -8
View File
@@ -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 == "." {
+2 -4
View File
@@ -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
+3 -4
View File
@@ -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,
+174 -33
View File
@@ -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
+154 -2
View File
@@ -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 {
+36 -6
View File
@@ -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)
}
+15
View File
@@ -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 {
+46
View File
@@ -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.
@@ -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)
}
}
+148 -6
View File
@@ -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))
+71 -1
View File
@@ -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")
+9 -1
View File
@@ -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
}
}
+63
View File
@@ -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)
}
}
+5
View File
@@ -80,6 +80,11 @@ export const storageAPI = {
.put<StorageConfig>(`/admin/storage/${type}`, { type, config, enabled })
.then((r) => r.data),
logout: (type: StorageType) =>
api
.post<StorageConfig>(`/admin/storage/${type}/logout`)
.then((r) => r.data),
test: (type: StorageType, config: Record<string, string>) =>
api
.post<{ ok: boolean; error?: string }>(`/admin/storage/${type}/test`, {
+2 -2
View File
@@ -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<Library[]>([])
@@ -372,7 +372,7 @@ export function FileManagerPage() {
<header>
<h1 className="font-display text-3xl font-bold text-ink-600">文件管理</h1>
<p className="text-sm text-ink-50">
像 MoviePilot 一样:在下载目录中选择文件夹或视频,直接设置目标并整理入库。
在下载目录中选择文件夹或视频,直接设置目标并整理入库。
</p>
</header>
+36 -6
View File
@@ -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<StorageType, { key: string; label: string; secret?: boo
{ key: 'username', label: 'WebDAV 用户名' },
{ key: 'password', label: 'WebDAV 密码', secret: true },
{ key: 'timeout_seconds', label: '请求超时秒数', placeholder: '120' },
{ key: 'force_302', label: '强制 302 直链(true/false,默认自动优先直链)' },
],
webdav: [
{ key: 'url', label: 'URL', placeholder: 'https://example.com/dav/' },
@@ -137,11 +136,9 @@ const FIELD_DEFS: Record<StorageType, { key: string; label: string; secret?: boo
],
cloud115: [
{ key: 'cookie', label: 'Cookie(UID / CID / SEID,或扫码登录自动填充)', secret: true, placeholder: 'UID=...; CID=...; SEID=...' },
{ key: 'force_proxy', label: '强制反代(true/false,默认 302 直链)' },
],
quark: [
{ key: 'cookie', label: 'Cookie(从 pan.quark.cn 复制整段)', secret: true, placeholder: '__pus=...; __kp=...; kps=...' },
{ key: 'force_302', label: '强制 302 直链(true/false,默认反代)' },
],
clouddrive2: [
{ key: 'url', label: 'CloudDrive2 WebDAV URL', placeholder: 'http://host.docker.internal:19798/dav 或 http://NAS-IP:19798/dav' },
@@ -149,7 +146,6 @@ const FIELD_DEFS: Record<StorageType, { key: string; label: string; secret?: boo
{ key: 'password', label: '密码 / Token', secret: true },
{ key: 'token', label: 'Authorization Token(可选)', secret: true, placeholder: 'Bearer ... 或 Basic ...' },
{ key: 'timeout_seconds', label: '请求超时秒数', placeholder: '120' },
{ key: 'force_302', label: '强制 302 直链(true/false,默认反代)' },
],
}
@@ -160,6 +156,7 @@ function StorageForm({ type }: { type: StorageType }) {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [loggingOut, setLoggingOut] = useState(false)
const refresh = async () => {
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 (
<div className="flex justify-center py-12 text-ink-50">
@@ -262,6 +281,17 @@ function StorageForm({ type }: { type: StorageType }) {
启用
</label>
<div className="flex justify-end gap-2 pt-2">
{isCloud(type) && (
<button
type="button"
onClick={onLogout}
disabled={loggingOut}
className="rounded-lg border border-red-200 px-4 py-2 text-sm text-red-500 hover:bg-red-50 disabled:opacity-50"
>
{loggingOut ? <Loader2 size={14} className="inline animate-spin" /> : <LogOut size={14} className="inline" />}
{' '}退出登录
</button>
)}
<button
type="button"
onClick={onTest}
@@ -647,7 +677,7 @@ function CloudBrowser({ type }: { type: StorageType }) {
}
return (
<div className="mt-2 rounded-lg border border-gray-200 p-3" onClick={(e) => e.preventDefault()}>
<div className="mt-2 rounded-lg border border-gray-200 p-3">
<div className="mb-3 rounded border border-emerald-100 bg-emerald-50/60 p-2">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div>
+79 -12
View File
@@ -8,6 +8,8 @@ import { strmAPI, type GenerateSTRMResult } from '../api/strm'
import { confirmAction } from '../components/ConfirmDialog'
import type { Library, Media } from '../types'
type CloudPlaybackMode = 'strm' | 'redirect_proxy'
// StrmPage exposes the URL-as-file admin tooling backed by the Go server:
// - import a brand-new media row directly from a (library, title, url)
// tuple — useful for streaming-only entries with no on-disk file.
@@ -21,7 +23,9 @@ export function StrmPage() {
const [generateLibraryID, setGenerateLibraryID] = useState('')
const [baseURL, setBaseURL] = useState('')
const [outputDir, setOutputDir] = useState('')
const [strmEnabled, setStrmEnabled] = useState(true)
const [cloudPlaybackMode, setCloudPlaybackMode] = useState<CloudPlaybackMode>('redirect_proxy')
const [strmPlaybackEnabled, setStrmPlaybackEnabled] = useState(false)
const [redirectProxyEnabled, setRedirectProxyEnabled] = useState(true)
const [autoGenerate, setAutoGenerate] = useState(false)
const [savingSettings, setSavingSettings] = useState(false)
const [overwrite, setOverwrite] = useState(false)
@@ -48,7 +52,20 @@ export function StrmPage() {
const settings = Object.fromEntries(rows.map((row) => [row.key, row.value]))
setBaseURL(settings['app.server_url'] || settings['strm.base_url'] || '')
setOutputDir(settings['strm.output_dir'] || '')
setStrmEnabled(settings['strm.enabled'] !== 'false')
const mode = settings['cloud.playback_mode']
const nextMode =
mode === 'strm' || mode === 'redirect_proxy'
? mode
: settings['strm.enabled'] === 'true'
? 'strm'
: 'redirect_proxy'
setCloudPlaybackMode(nextMode)
setStrmPlaybackEnabled(
settings['cloud.playback_strm_enabled'] != null
? settings['cloud.playback_strm_enabled'] === 'true'
: settings['strm.enabled'] === 'true' || nextMode === 'strm',
)
setRedirectProxyEnabled(settings['cloud.playback_redirect_proxy_enabled'] !== 'false')
setAutoGenerate(settings['strm.auto_generate_enabled'] === 'true')
})
.catch(() => undefined)
@@ -93,11 +110,25 @@ export function StrmPage() {
const saveSTRMSettings = async () => {
setSavingSettings(true)
try {
const effectiveMode: CloudPlaybackMode =
strmPlaybackEnabled && !redirectProxyEnabled
? 'strm'
: !strmPlaybackEnabled && redirectProxyEnabled
? 'redirect_proxy'
: cloudPlaybackMode
await Promise.all([
adminAPI.updateSetting('strm.enabled', String(strmEnabled)),
adminAPI.updateSetting('cloud.playback_mode', effectiveMode),
adminAPI.updateSetting('cloud.playback_strm_enabled', String(strmPlaybackEnabled)),
adminAPI.updateSetting('cloud.playback_redirect_proxy_enabled', String(redirectProxyEnabled)),
adminAPI.updateSetting('strm.enabled', String(strmPlaybackEnabled)),
adminAPI.updateSetting('strm.auto_generate_enabled', String(autoGenerate)),
])
toast.success(strmEnabled ? 'STRM 播放已启用' : 'STRM 播放已关闭')
setCloudPlaybackMode(effectiveMode)
toast.success(
strmPlaybackEnabled || redirectProxyEnabled
? `播放设置已保存:优先 ${effectiveMode === 'strm' ? 'STRMURL' : '302/反代'}`
: '播放设置已保存:云盘第三方播放已关闭',
)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
@@ -186,6 +217,15 @@ export function StrmPage() {
}
}
const playbackStatus =
strmPlaybackEnabled && redirectProxyEnabled
? `两者开启 · 优先${cloudPlaybackMode === 'strm' ? 'STRMURL' : '302/反代'}`
: strmPlaybackEnabled
? '仅 STRMURL'
: redirectProxyEnabled
? '仅 302/反代'
: '云盘第三方播放关闭'
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
@@ -209,26 +249,53 @@ export function StrmPage() {
</p>
</div>
<span className={`rounded-full border px-3 py-1 text-xs font-semibold ${
strmEnabled
strmPlaybackEnabled || redirectProxyEnabled
? 'border-emerald-300/40 bg-emerald-400/10 text-emerald-500'
: 'border-red-300/40 bg-red-400/10 text-red-500'
}`}>
{strmEnabled ? 'STRM 播放已启用' : 'STRM 播放已关闭'}
{playbackStatus}
</span>
</div>
<div className="grid gap-3 rounded-2xl border border-gray-200 bg-white/70 p-4 md:grid-cols-[1fr_1fr_auto]">
<div className="grid gap-3 rounded-2xl border border-gray-200 bg-white/70 p-4 md:grid-cols-2">
<label className="flex items-start gap-3 text-sm text-ink-100">
<input
type="checkbox"
className="mt-1 h-4 w-4 accent-primary-400"
checked={strmEnabled}
onChange={(e) => setStrmEnabled(e.target.checked)}
checked={strmPlaybackEnabled}
onChange={(e) => setStrmPlaybackEnabled(e.target.checked)}
/>
<span>
<span className="block font-medium text-ink-600">启用 STRM 播放</span>
<span className="text-xs text-ink-50">开启后第三方播放优先走 STRM 入口;关闭后使用 302/反代播放入口。</span>
<span className="block font-medium text-ink-600">启用 STRMURL 播放</span>
<span className="text-xs text-ink-50">第三方客户端可拿到 /api/stream/媒体ID 入口,适合 STRM 管理和自动生成方案。</span>
</span>
</label>
<label className="flex items-start gap-3 text-sm text-ink-100">
<input
type="checkbox"
className="mt-1 h-4 w-4 accent-primary-400"
checked={redirectProxyEnabled}
onChange={(e) => setRedirectProxyEnabled(e.target.checked)}
/>
<span>
<span className="block font-medium text-ink-600">启用 302/反代播放</span>
<span className="text-xs text-ink-50">第三方客户端可拿到 /Videos/媒体ID/stream 入口,由服务端解析后 302 或必要时反代。</span>
</span>
</label>
</div>
<div className="grid gap-3 rounded-2xl border border-gray-200 bg-white/70 p-4 md:grid-cols-[1fr_1fr_auto]">
<label className="text-sm text-ink-100">
<span className="mb-1 block font-medium text-ink-600">两者都开启时优先</span>
<select
className="input-base"
value={cloudPlaybackMode}
onChange={(e) => setCloudPlaybackMode(e.target.value as CloudPlaybackMode)}
disabled={!strmPlaybackEnabled || !redirectProxyEnabled}
>
<option value="strm">优先 STRMURL</option>
<option value="redirect_proxy">优先 302/反代</option>
</select>
<span className="mt-1 block text-xs text-ink-50">只开启一个时自动使用已开启的播放方式;两个都关闭时云盘媒体不向第三方提供播放。</span>
</label>
<label className="flex items-start gap-3 text-sm text-ink-100">
<input
type="checkbox"
@@ -289,7 +356,7 @@ export function StrmPage() {
</button>
</form>
<p className="text-xs text-sand-500">
生成内容为 <code>域名 + /api/stream/媒体ID?token=...</code>;开启 STRM 播放时第三方客户端优先使用该入口,关闭后回到 302/反代播放。域名会同步保存到系统设置中的「公开访问域名 / STRM 域名」。
生成内容为 <code>域名 + /api/stream/媒体ID?token=...</code>;第三方客户端播放优先方式由上方「STRMURL / 302反代」模式决定。域名会同步保存到系统设置中的「公开访问域名 / STRM 域名」。
</p>
{generateResult && (
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 text-sm text-ink-50">