mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
fix emby clients and cloud playback controls
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
name: MediaStationGo CodeQL
|
||||
|
||||
paths-ignore:
|
||||
- internal/service/fileid_other.go
|
||||
@@ -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 }}
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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 == "." {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user