mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-09 06:46:37 +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"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/middleware"
|
"github.com/ShukeBta/MediaStationGo/internal/middleware"
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||||
@@ -38,6 +40,104 @@ func embyUserID(c *gin.Context) string {
|
|||||||
return ""
|
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 ──────────────────────────────────────────────────────────────────
|
// ─── System ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func embySystemInfoHandler(svc *service.Container) gin.HandlerFunc {
|
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 != "" {
|
if longLived, err := svc.Auth.IssueEmbyToken(resp.User); err == nil && longLived != "" {
|
||||||
accessToken = longLived
|
accessToken = longLived
|
||||||
}
|
}
|
||||||
|
embyRememberCompatSession(c, accessToken)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"AccessToken": accessToken,
|
"AccessToken": accessToken,
|
||||||
"ServerId": "mediastation-go-001",
|
"ServerId": "mediastation-go-001",
|
||||||
@@ -458,6 +559,7 @@ func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
embyAttachRequestTokenToMediaSources(c, out)
|
||||||
c.JSON(http.StatusOK, 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"))
|
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "50"))
|
||||||
offset, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
|
offset, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
|
||||||
uid := c.Param("userId")
|
uid := c.Param("userId")
|
||||||
|
if uid == "" {
|
||||||
|
uid = firstQueryValue(c, "UserId", "userId", "userid")
|
||||||
|
}
|
||||||
if uid == "" {
|
if uid == "" {
|
||||||
uid = embyUserID(c)
|
uid = embyUserID(c)
|
||||||
}
|
}
|
||||||
@@ -529,6 +634,7 @@ func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
|
|||||||
IDs: splitOpt(firstQueryValue(c, "Ids", "ids")),
|
IDs: splitOpt(firstQueryValue(c, "Ids", "ids")),
|
||||||
SearchTerm: firstQueryValue(c, "SearchTerm", "searchTerm", "searchterm"),
|
SearchTerm: firstQueryValue(c, "SearchTerm", "searchTerm", "searchterm"),
|
||||||
IncludeItemTypes: splitOpt(firstQueryValue(c, "IncludeItemTypes", "includeItemTypes", "includeitemtypes")),
|
IncludeItemTypes: splitOpt(firstQueryValue(c, "IncludeItemTypes", "includeItemTypes", "includeitemtypes")),
|
||||||
|
Filters: splitOpt(firstQueryValue(c, "Filters", "filters")),
|
||||||
Recursive: strings.EqualFold(firstQueryValue(c, "Recursive", "recursive"), "true"),
|
Recursive: strings.EqualFold(firstQueryValue(c, "Recursive", "recursive"), "true"),
|
||||||
SortBy: firstQueryValue(c, "SortBy", "sortBy", "sortby"),
|
SortBy: firstQueryValue(c, "SortBy", "sortBy", "sortby"),
|
||||||
SortOrder: firstQueryValue(c, "SortOrder", "sortOrder", "sortorder"),
|
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()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
embyAttachRequestTokenToMediaSources(c, out)
|
||||||
c.JSON(http.StatusOK, out)
|
c.JSON(http.StatusOK, out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -573,7 +680,7 @@ func embyItemByIDHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
embyError(c, http.StatusNotFound, "item not found")
|
embyError(c, http.StatusNotFound, "item not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
embyAttachRequestTokenToPlaybackInfo(c, out)
|
embyAttachRequestTokenToMediaSources(c, out)
|
||||||
c.JSON(http.StatusOK, out)
|
c.JSON(http.StatusOK, out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -594,15 +701,19 @@ func embyUserItemByIDHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
|
func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
uid := c.Param("userId")
|
uid := c.Param("userId")
|
||||||
|
if uid == "" {
|
||||||
|
uid = firstQueryValue(c, "UserId", "userId", "userid")
|
||||||
|
}
|
||||||
if uid == "" {
|
if uid == "" {
|
||||||
uid = embyUserID(c)
|
uid = embyUserID(c)
|
||||||
}
|
}
|
||||||
limit, _ := strconv.Atoi(c.DefaultQuery("Limit", "20"))
|
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
|
||||||
out, err := svc.Emby.LatestItems(c.Request.Context(), uid, c.Query("ParentId"), limit)
|
out, err := svc.Emby.LatestItems(c.Request.Context(), uid, firstQueryValue(c, "ParentId", "parentId", "parentid"), limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
embyAttachRequestTokenToMediaSources(c, out)
|
||||||
c.JSON(http.StatusOK, out)
|
c.JSON(http.StatusOK, out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -610,15 +721,19 @@ func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
|
func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
uid := c.Param("userId")
|
uid := c.Param("userId")
|
||||||
|
if uid == "" {
|
||||||
|
uid = firstQueryValue(c, "UserId", "userId", "userid")
|
||||||
|
}
|
||||||
if uid == "" {
|
if uid == "" {
|
||||||
uid = embyUserID(c)
|
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)
|
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
embyAttachRequestTokenToMediaSources(c, out)
|
||||||
c.JSON(http.StatusOK, 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 ──────────────────────────────────────────────────────────────────
|
// ─── 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 等请求直接输出为图片。
|
// embyItemImageHandler 把 /Items/{id}/Images/Primary 等请求直接输出为图片。
|
||||||
// Emby 客户端缓存图片 URL 时经常不会继续携带 token;如果重定向到受保护的
|
// Emby 客户端缓存图片 URL 时经常不会继续携带 token;如果重定向到受保护的
|
||||||
// /api/img 会变成 401,所以这里复用 ImageProxy 但不再走 /api 路由。
|
// /api/img 会变成 401,所以这里复用 ImageProxy 但不再走 /api 路由。
|
||||||
@@ -648,7 +810,7 @@ func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
imgType := strings.ToLower(c.Param("type"))
|
imgType := strings.ToLower(c.Param("type"))
|
||||||
raw, err := svc.Emby.ImageURL(ctx, id, imgType)
|
raw, err := svc.Emby.ImageURL(ctx, id, imgType)
|
||||||
if err != nil || raw == "" {
|
if err != nil || raw == "" {
|
||||||
c.Status(http.StatusNotFound)
|
embyServeTransparentImage(c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if typ, ref, ok := parseCloudPlayImageURL(raw); ok {
|
if typ, ref, ok := parseCloudPlayImageURL(raw); ok {
|
||||||
@@ -657,15 +819,26 @@ func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if svc.ImageProxy == nil {
|
if svc.ImageProxy == nil {
|
||||||
c.Status(http.StatusNotFound)
|
embyServeTransparentImage(c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := svc.ImageProxy.Serve(ctx, c.Writer, req, raw); err != nil {
|
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) {
|
func parseCloudPlayImageURL(raw string) (string, string, bool) {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
@@ -700,6 +873,7 @@ func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
embyAttachRequestTokenToMediaSources(c, out)
|
||||||
c.JSON(http.StatusOK, 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()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
embyAttachRequestTokenToMediaSources(c, out)
|
||||||
c.JSON(http.StatusOK, out)
|
c.JSON(http.StatusOK, out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -743,20 +918,55 @@ func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
embyError(c, http.StatusNotFound, "not found")
|
embyError(c, http.StatusNotFound, "not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
embyAttachRequestTokenToPlaybackInfo(c, out)
|
embyAttachRequestTokenToMediaSources(c, out)
|
||||||
c.JSON(http.StatusOK, 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)
|
token := embyRequestToken(c)
|
||||||
if token == "" || out == nil {
|
if token == "" || out == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sources, ok := out["MediaSources"].([]map[string]any)
|
embyAttachTokenToMediaSourcesValue(out, token)
|
||||||
if !ok {
|
}
|
||||||
|
|
||||||
|
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
|
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 _, source := range sources {
|
||||||
for _, key := range []string{"DirectStreamUrl", "TranscodingUrl", "Path"} {
|
for _, key := range []string{"DirectStreamUrl", "TranscodingUrl", "Path"} {
|
||||||
raw, ok := source[key].(string)
|
raw, ok := source[key].(string)
|
||||||
@@ -772,7 +982,7 @@ func embyRequestToken(c *gin.Context) string {
|
|||||||
if c == nil {
|
if c == nil {
|
||||||
return ""
|
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 != "" {
|
if value := strings.TrimSpace(c.Query(key)); value != "" {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
@@ -782,7 +992,7 @@ func embyRequestToken(c *gin.Context) string {
|
|||||||
return value
|
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 != "" {
|
if token := embyTokenFromAuthHeader(c.GetHeader(header)); token != "" {
|
||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
@@ -837,7 +1047,7 @@ func embyAppendAPIKey(raw, token string) string {
|
|||||||
|
|
||||||
// embyVideoStreamHandler 是 GET /Videos/{id}/stream 的入口,
|
// embyVideoStreamHandler 是 GET /Videos/{id}/stream 的入口,
|
||||||
// 直接代理到我们的 /api/stream/{id}(同一个 ServeFile)。
|
// 直接代理到我们的 /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) {
|
return func(c *gin.Context) {
|
||||||
uid := embyUserID(c)
|
uid := embyUserID(c)
|
||||||
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
|
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 过期、直链解析失败、
|
// 此前这里把所有错误一律吞成 404:云盘 Cookie 过期、直链解析失败、
|
||||||
// STRM 播放被关闭……在第三方播放器上全部表现为「404 不存在」,
|
// STRM 播放被关闭……在第三方播放器上全部表现为「404 不存在」,
|
||||||
// 无法排查。现在区分:行不存在→404;云盘播放不可用/上游故障→502+原因。
|
// 无法排查。现在区分:行不存在→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 {
|
switch {
|
||||||
case err == nil:
|
case err == nil:
|
||||||
case errors.Is(err, service.ErrMediaNotFound):
|
case errors.Is(err, service.ErrMediaNotFound):
|
||||||
c.Status(http.StatusNotFound)
|
c.Status(http.StatusNotFound)
|
||||||
|
case errors.Is(err, service.ErrCloudPlaybackDisabled):
|
||||||
|
if !c.Writer.Written() {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
if !c.Writer.Written() {
|
if !c.Writer.Written() {
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
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 {
|
func embyServerConfigurationHandler(_ *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
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 {
|
func embyServerDomainsHandler(_ *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, []any{})
|
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"} {
|
for _, path := range []string{"/Localization/Options", "/localization/options"} {
|
||||||
grp.GET(path, embyLocalizationOptionsHandler(svc))
|
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)
|
// 图片公开(Infuse 缓存 URL 时会丢 token)
|
||||||
grp.GET("/Items/:id/Images/:type", embyItemImageHandler(svc))
|
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))
|
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/Me", embyMeHandler(svc))
|
||||||
auth.GET("/Users", embyListUsersHandler(svc))
|
auth.GET("/Users", embyListUsersHandler(svc))
|
||||||
auth.GET("/Users/:userId", embyGetUserByIDHandler(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("/Users/:userId/Items", embyItemsHandler(svc))
|
||||||
auth.GET("/Items/Counts", embyItemsCountsHandler(svc))
|
auth.GET("/Items/Counts", embyItemsCountsHandler(svc))
|
||||||
auth.GET("/Users/:userId/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("/Items/:id", embyItemByIDHandler(svc))
|
||||||
auth.GET("/Users/:userId/Items/:id", embyUserItemByIDHandler(svc))
|
auth.GET("/Users/:userId/Items/:id", embyUserItemByIDHandler(svc))
|
||||||
auth.GET("/Shows/:id/Seasons", embyShowSeasonsHandler(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("/Shows/NextUp", embyEmptyItemsHandler(svc))
|
||||||
auth.GET("/Users/:userId/Shows/NextUp", embyEmptyItemsHandler(svc))
|
auth.GET("/Users/:userId/Shows/NextUp", embyEmptyItemsHandler(svc))
|
||||||
auth.GET("/MediaSegments/:id", 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/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("/api/danmu/:id/raw", embyDanmuRawHandler(svc))
|
||||||
|
|
||||||
auth.GET("/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(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.GET("/Users/:userId/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
|
||||||
auth.POST("/Users/:userId/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
|
auth.POST("/Users/:userId/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
|
||||||
|
|
||||||
auth.GET("/Videos/:id/stream", embyVideoStreamHandler(svc))
|
auth.GET("/Videos/:id/stream", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy))
|
||||||
auth.HEAD("/Videos/:id/stream", embyVideoStreamHandler(svc))
|
auth.HEAD("/Videos/:id/stream", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy))
|
||||||
auth.GET("/Videos/:id/stream.:container", embyVideoStreamHandler(svc))
|
auth.GET("/Videos/:id/stream.:container", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy))
|
||||||
auth.HEAD("/Videos/:id/stream.:container", embyVideoStreamHandler(svc))
|
auth.HEAD("/Videos/:id/stream.:container", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy))
|
||||||
auth.GET("/Videos/:id/original", embyVideoStreamHandler(svc))
|
auth.GET("/Videos/:id/original", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy))
|
||||||
auth.HEAD("/Videos/:id/original", embyVideoStreamHandler(svc))
|
auth.HEAD("/Videos/:id/original", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy))
|
||||||
auth.GET("/Videos/:id/original.:container", embyVideoStreamHandler(svc))
|
auth.GET("/Videos/:id/original.:container", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy))
|
||||||
auth.HEAD("/Videos/:id/original.:container", embyVideoStreamHandler(svc))
|
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.GET("/Videos/:id/master.m3u8", embyVideoHLSPlaylistHandler(svc))
|
||||||
auth.HEAD("/Videos/:id/master.m3u8", embyVideoHLSPlaylistHandler(svc))
|
auth.HEAD("/Videos/:id/master.m3u8", embyVideoHLSPlaylistHandler(svc))
|
||||||
auth.GET("/Videos/:id/main.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("/Sessions", embySessionsHandler(svc))
|
||||||
auth.GET("/System/Configuration", embyServerConfigurationHandler(svc))
|
auth.GET("/System/Configuration", embyServerConfigurationHandler(svc))
|
||||||
auth.GET("/DisplayPreferences/:id", func(c *gin.Context) {
|
auth.GET("/System/WakeOnLanInfo", embyEmptyArrayHandler(svc))
|
||||||
c.JSON(http.StatusOK, gin.H{"Id": c.Param("id"), "CustomPrefs": gin.H{}})
|
auth.GET("/ScheduledTasks", embyEmptyArrayHandler(svc))
|
||||||
})
|
auth.GET("/LiveTv/Recordings", embyEmptyItemsHandler(svc))
|
||||||
auth.POST("/DisplayPreferences/:id", func(c *gin.Context) {
|
auth.GET("/System/ActivityLog/Entries", embyEmptyItemsHandler(svc))
|
||||||
c.Status(http.StatusNoContent)
|
auth.GET("/Web/ConfigurationPages", embyEmptyArrayHandler(svc))
|
||||||
})
|
auth.POST("/Users/:userId/Configuration", embyNoContentHandler(svc))
|
||||||
|
|
||||||
registerLowercaseEmbyAuthRoutes(auth, 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("/users/:userId/items", embyItemsHandler(svc))
|
||||||
auth.GET("/items/counts", embyItemsCountsHandler(svc))
|
auth.GET("/items/counts", embyItemsCountsHandler(svc))
|
||||||
auth.GET("/users/:userId/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("/items/:id", embyItemByIDHandler(svc))
|
||||||
auth.GET("/users/:userId/items/:id", embyUserItemByIDHandler(svc))
|
auth.GET("/users/:userId/items/:id", embyUserItemByIDHandler(svc))
|
||||||
auth.GET("/shows/:id/seasons", embyShowSeasonsHandler(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("/shows/nextup", embyEmptyItemsHandler(svc))
|
||||||
auth.GET("/users/:userId/shows/nextup", embyEmptyItemsHandler(svc))
|
auth.GET("/users/:userId/shows/nextup", embyEmptyItemsHandler(svc))
|
||||||
auth.GET("/mediasegments/:id", 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/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.GET("/items/:id/playbackinfo", embyPlaybackInfoHandler(svc))
|
||||||
auth.POST("/items/:id/playbackinfo", embyPlaybackInfoHandler(svc))
|
auth.POST("/items/:id/playbackinfo", embyPlaybackInfoHandler(svc))
|
||||||
auth.GET("/users/:userId/items/:id/playbackinfo", embyPlaybackInfoHandler(svc))
|
auth.GET("/users/:userId/items/:id/playbackinfo", embyPlaybackInfoHandler(svc))
|
||||||
auth.POST("/users/:userId/items/:id/playbackinfo", embyPlaybackInfoHandler(svc))
|
auth.POST("/users/:userId/items/:id/playbackinfo", embyPlaybackInfoHandler(svc))
|
||||||
|
|
||||||
auth.GET("/videos/:id/stream", embyVideoStreamHandler(svc))
|
auth.GET("/videos/:id/stream", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy))
|
||||||
auth.HEAD("/videos/:id/stream", embyVideoStreamHandler(svc))
|
auth.HEAD("/videos/:id/stream", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy))
|
||||||
auth.GET("/videos/:id/stream.:container", embyVideoStreamHandler(svc))
|
auth.GET("/videos/:id/stream.:container", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy))
|
||||||
auth.HEAD("/videos/:id/stream.:container", embyVideoStreamHandler(svc))
|
auth.HEAD("/videos/:id/stream.:container", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy))
|
||||||
auth.GET("/videos/:id/original", embyVideoStreamHandler(svc))
|
auth.GET("/videos/:id/original", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy))
|
||||||
auth.HEAD("/videos/:id/original", embyVideoStreamHandler(svc))
|
auth.HEAD("/videos/:id/original", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy))
|
||||||
auth.GET("/videos/:id/original.:container", embyVideoStreamHandler(svc))
|
auth.GET("/videos/:id/original.:container", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy))
|
||||||
auth.HEAD("/videos/:id/original.:container", embyVideoStreamHandler(svc))
|
auth.HEAD("/videos/:id/original.:container", embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy))
|
||||||
auth.GET("/videos/:id/master.m3u8", embyVideoHLSPlaylistHandler(svc))
|
auth.GET("/videos/:id/master.m3u8", embyVideoHLSPlaylistHandler(svc))
|
||||||
auth.HEAD("/videos/:id/master.m3u8", embyVideoHLSPlaylistHandler(svc))
|
auth.HEAD("/videos/:id/master.m3u8", embyVideoHLSPlaylistHandler(svc))
|
||||||
auth.GET("/videos/:id/main.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("/sessions", embySessionsHandler(svc))
|
||||||
auth.GET("/system/configuration", embyServerConfigurationHandler(svc))
|
auth.GET("/system/configuration", embyServerConfigurationHandler(svc))
|
||||||
auth.GET("/displaypreferences/:id", func(c *gin.Context) {
|
auth.GET("/system/wakeonlaninfo", embyEmptyArrayHandler(svc))
|
||||||
c.JSON(http.StatusOK, gin.H{"Id": c.Param("id"), "CustomPrefs": gin.H{}})
|
auth.GET("/scheduledtasks", embyEmptyArrayHandler(svc))
|
||||||
})
|
auth.GET("/livetv/recordings", embyEmptyItemsHandler(svc))
|
||||||
auth.POST("/displaypreferences/:id", func(c *gin.Context) {
|
auth.GET("/system/activitylog/entries", embyEmptyItemsHandler(svc))
|
||||||
c.Status(http.StatusNoContent)
|
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/gin-gonic/gin"
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"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) {
|
func TestEmbyWithRequestAddressUsesHost(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
w := httptest.NewRecorder()
|
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) {
|
func TestEmbySenPlayerDiscoveryRoutesReturnProtocolResponses(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
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) {
|
func TestEmbyItemImageServesWithoutAPIAuth(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
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) {
|
func TestEmbyUserItemByIDRouteReturnsJSON(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
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) {
|
func TestEmbyLowercasePlaybackInfoRouteReturnsJSON(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
@@ -649,6 +928,9 @@ func TestEmbyPlaybackInfoTokenizesCloudPath(t *testing.T) {
|
|||||||
t.Fatalf("migrate: %v", err)
|
t.Fatalf("migrate: %v", err)
|
||||||
}
|
}
|
||||||
repos := repository.New(db)
|
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{
|
if err := repos.User.Create(t.Context(), &model.User{
|
||||||
Base: model.Base{ID: "user-1"},
|
Base: model.Base{ID: "user-1"},
|
||||||
Username: "tester",
|
Username: "tester",
|
||||||
@@ -705,6 +987,80 @@ func TestEmbyPlaybackInfoTokenizesCloudPath(t *testing.T) {
|
|||||||
if !strings.HasPrefix(directURL, "/api/stream/cloud-1") || !strings.Contains(directURL, "api_key=") {
|
if !strings.HasPrefix(directURL, "/api/stream/cloud-1") || !strings.Contains(directURL, "api_key=") {
|
||||||
t.Fatalf("DirectStreamUrl should stay tokenized: %#v", source)
|
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) {
|
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) {
|
func TestEmbyLowercaseOriginalHeadRouteServesHeaders(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
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"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
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) {
|
if errors.Is(err, service.ErrMediaNotFound) {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, service.ErrCloudPlaybackDisabled) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.
|
|||||||
admin.GET("/storage/:type", getStorageConfigHandler(svc))
|
admin.GET("/storage/:type", getStorageConfigHandler(svc))
|
||||||
admin.PUT("/storage/:type", saveStorageConfigHandler(svc))
|
admin.PUT("/storage/:type", saveStorageConfigHandler(svc))
|
||||||
admin.POST("/storage/:type/test", testStorageConfigHandler(svc))
|
admin.POST("/storage/:type/test", testStorageConfigHandler(svc))
|
||||||
|
admin.POST("/storage/:type/logout", logoutStorageConfigHandler(svc))
|
||||||
admin.POST("/storage/:type/upload-local", storageUploadLocalHandler(svc))
|
admin.POST("/storage/:type/upload-local", storageUploadLocalHandler(svc))
|
||||||
|
|
||||||
// Cloud disk (115 / 夸克) browsing, QR login and 302 import.
|
// 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 {
|
func storageUploadLocalHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var req service.CloudUploadInput
|
var req service.CloudUploadInput
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ const EmbyCtxUserID = "emby_user_id"
|
|||||||
// 按优先级尝试以下认证方式:
|
// 按优先级尝试以下认证方式:
|
||||||
// 1. X-Emby-Token / X-MediaBrowser-Token 请求头
|
// 1. X-Emby-Token / X-MediaBrowser-Token 请求头
|
||||||
// 2. Authorization: Bearer <token> / MediaBrowser Token="<token>" 请求头
|
// 2. Authorization: Bearer <token> / MediaBrowser Token="<token>" 请求头
|
||||||
// 3. X-Emby-Authorization: MediaBrowser Token="<token>"
|
// 3. X-Emby-Authorization / X-MediaBrowser-Authorization: MediaBrowser Token="<token>"
|
||||||
// 4. ?token= / ?api_key= / ?apiKey= URL 参数
|
// 4. ?token= / ?api_key= / ?apiKey= / ?X-Emby-Token= URL 参数
|
||||||
func EmbyAuthRequired(secret string) gin.HandlerFunc {
|
func EmbyAuthRequired(secret string) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
token := extractEmbyToken(c)
|
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 value := strings.TrimSpace(c.GetHeader(header)); value != "" {
|
||||||
if token := tokenFromAuthHeader(value); token != "" {
|
if token := tokenFromAuthHeader(value); token != "" {
|
||||||
return 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 != "" {
|
if value := strings.TrimSpace(c.Query(key)); value != "" {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ func TestEmbyAuthRequiredAcceptsEmbyClientTokenFormats(t *testing.T) {
|
|||||||
{name: "x mediabrowser token", headerKey: "X-MediaBrowser-Token", headerVal: token},
|
{name: "x mediabrowser token", headerKey: "X-MediaBrowser-Token", headerVal: token},
|
||||||
{name: "authorization mediabrowser token", headerKey: "Authorization", headerVal: `MediaBrowser Client="Infuse", Token="` + 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 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 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 {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package cloud
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -105,10 +106,18 @@ func quarkPagePayload(page int) string {
|
|||||||
return strings.Join(items, ",")
|
return strings.Join(items, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQuarkForce302(t *testing.T) {
|
func TestDeprecatedProviderPlaybackOverrideKeysAreIgnored(t *testing.T) {
|
||||||
p := newQuark(map[string]any{"cookie": "c", "force_302": "true"}, http.DefaultClient)
|
quark := newQuark(map[string]any{"cookie": "c", "force_302": "true"}, http.DefaultClient)
|
||||||
if p.proxy {
|
if !quark.proxy {
|
||||||
t.Fatalf("force_302 should disable proxy mode")
|
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) {
|
func TestOpenListWebDAVListAndResolve(t *testing.T) {
|
||||||
var gotPath, gotDepth string
|
var gotPath, gotDepth string
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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" {
|
if r.URL.Path == "/api/fs/get" {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
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) {
|
func TestOpenListResolveFallsBackToProxyWhenAPIRawURLNeedsHeaders(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/api/fs/get" {
|
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
|
ua = defaultUA
|
||||||
}
|
}
|
||||||
proxy := true
|
proxy := true
|
||||||
if _, ok := cfg["force_302"]; ok && boolish(cfg["force_302"]) {
|
|
||||||
proxy = false
|
|
||||||
}
|
|
||||||
return &cloudDrive2Provider{
|
return &cloudDrive2Provider{
|
||||||
typ: typ,
|
typ: typ,
|
||||||
name: name,
|
name: name,
|
||||||
@@ -82,7 +79,7 @@ func (p *cloudDrive2Provider) List(ctx context.Context, dir string) ([]FileEntry
|
|||||||
if err := p.validate(); err != nil {
|
if err := p.validate(); err != nil {
|
||||||
return nil, err
|
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 {
|
if entries, err := p.listOpenListAPI(ctx, dir); err == nil {
|
||||||
return entries, 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) {
|
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
|
const pageSize = 500
|
||||||
target := normalizeCloudDAVPath(dir)
|
target := normalizeCloudDAVPath(dir)
|
||||||
out := make([]FileEntry, 0, pageSize)
|
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("Content-Type", "application/json")
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req.Header.Set("User-Agent", p.ua)
|
req.Header.Set("User-Agent", p.ua)
|
||||||
if p.token != "" {
|
if token != "" {
|
||||||
req.Header.Set("Authorization", p.token)
|
req.Header.Set("Authorization", token)
|
||||||
}
|
}
|
||||||
resp, err := p.client.Do(req)
|
resp, err := p.client.Do(req)
|
||||||
if err != nil {
|
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) {
|
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": ""})
|
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))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL("/api/fs/get"), bytes.NewReader(payload))
|
||||||
if err != nil {
|
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("Content-Type", "application/json")
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req.Header.Set("User-Agent", p.ua)
|
req.Header.Set("User-Agent", p.ua)
|
||||||
if p.token != "" {
|
if token != "" {
|
||||||
req.Header.Set("Authorization", p.token)
|
req.Header.Set("Authorization", token)
|
||||||
}
|
}
|
||||||
resp, err := p.client.Do(req)
|
resp, err := p.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -274,6 +279,55 @@ func (p *cloudDrive2Provider) resolveOpenListAPIDirect(ctx context.Context, file
|
|||||||
return &DirectLink{URL: resolved, Headers: headers, Proxy: proxy}, nil
|
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) {
|
func (p *cloudDrive2Provider) resolveOpenListPlaybackURL(raw string) (string, error) {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
@@ -603,6 +657,14 @@ type openListGetResponse struct {
|
|||||||
} `json:"data"`
|
} `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 {
|
func normalizeCloudDAVPath(p string) string {
|
||||||
p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/")
|
p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/")
|
||||||
if p == "" || p == "." {
|
if p == "" || p == "." {
|
||||||
|
|||||||
@@ -45,11 +45,9 @@ func new115(cfg map[string]any, client *http.Client) *pan115Provider {
|
|||||||
ua = defaultUA
|
ua = defaultUA
|
||||||
}
|
}
|
||||||
// 115 CDN download URLs work with a plain 302 (Alist's recommended mode),
|
// 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
|
proxy := false
|
||||||
if _, ok := cfg["force_proxy"]; ok && boolish(cfg["force_proxy"]) {
|
|
||||||
proxy = true
|
|
||||||
}
|
|
||||||
pro := str(cfg["pro_base"])
|
pro := str(cfg["pro_base"])
|
||||||
if pro == "" {
|
if pro == "" {
|
||||||
pro = pan115ProBase
|
pro = pan115ProBase
|
||||||
|
|||||||
@@ -36,11 +36,10 @@ func newQuark(cfg map[string]any, client *http.Client) *quarkProvider {
|
|||||||
ua = defaultUA
|
ua = defaultUA
|
||||||
}
|
}
|
||||||
// Quark download links require the session cookie + UA, so the host must
|
// 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
|
proxy := true
|
||||||
if _, ok := cfg["force_302"]; ok && boolish(cfg["force_302"]) {
|
|
||||||
proxy = false
|
|
||||||
}
|
|
||||||
return &quarkProvider{
|
return &quarkProvider{
|
||||||
cookie: str(cfg["cookie"]),
|
cookie: str(cfg["cookie"]),
|
||||||
ua: ua,
|
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))
|
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 {
|
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"
|
collectionType = "music"
|
||||||
}
|
}
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"Id": l.ID,
|
"Id": l.ID,
|
||||||
"Name": l.Name,
|
"Name": l.Name,
|
||||||
"CollectionType": collectionType,
|
"CollectionType": collectionType,
|
||||||
"ServerId": embyServerID,
|
"ServerId": embyServerID,
|
||||||
"Type": "CollectionFolder",
|
"Type": "CollectionFolder",
|
||||||
"IsFolder": true,
|
"IsFolder": true,
|
||||||
"ImageTags": map[string]string{},
|
"Path": l.Path,
|
||||||
"BackdropImageTags": []string{},
|
"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{
|
"UserData": map[string]any{
|
||||||
"PlaybackPositionTicks": 0,
|
"PlaybackPositionTicks": 0,
|
||||||
"PlayCount": 0,
|
"PlayCount": 0,
|
||||||
@@ -275,6 +292,7 @@ type ItemsParams struct {
|
|||||||
IDs []string
|
IDs []string
|
||||||
SearchTerm string
|
SearchTerm string
|
||||||
IncludeItemTypes []string
|
IncludeItemTypes []string
|
||||||
|
Filters []string
|
||||||
Recursive bool
|
Recursive bool
|
||||||
SortBy string
|
SortBy string
|
||||||
SortOrder string
|
SortOrder string
|
||||||
@@ -352,6 +370,9 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
|
|||||||
if p.StartIndex < 0 {
|
if p.StartIndex < 0 {
|
||||||
p.StartIndex = 0
|
p.StartIndex = 0
|
||||||
}
|
}
|
||||||
|
if len(p.IncludeItemTypes) > 0 && !containsSupportedEmbyItemType(p.IncludeItemTypes) {
|
||||||
|
return emptyItemsEnvelope(p.StartIndex), nil
|
||||||
|
}
|
||||||
|
|
||||||
if len(p.IDs) > 0 {
|
if len(p.IDs) > 0 {
|
||||||
items := make([]map[string]any, 0, len(p.IDs))
|
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
|
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)
|
return e.Views(ctx, p.UserID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,6 +447,24 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string
|
|||||||
if p.SearchTerm != "" {
|
if p.SearchTerm != "" {
|
||||||
q = q.Where("title LIKE ? OR original_name LIKE ?", "%"+p.SearchTerm+"%", "%"+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") {
|
if containsItemType(p.IncludeItemTypes, "Movie") && !containsItemType(p.IncludeItemTypes, "Episode") {
|
||||||
q = q.Where("season_num = 0 AND episode_num = 0")
|
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 {
|
if err := q.Count(&total).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
order := "created_at desc"
|
order := "media.created_at desc"
|
||||||
switch strings.ToLower(p.SortBy) {
|
switch primarySupportedEmbySort(p.SortBy, resumeFilter) {
|
||||||
case "sortname", "name":
|
case "sortname", "name":
|
||||||
order = "title"
|
order = "media.title"
|
||||||
case "premieredate", "productionyear":
|
case "premieredate", "productionyear":
|
||||||
order = "year"
|
order = "media.year"
|
||||||
case "datecreated":
|
case "datecreated":
|
||||||
order = "created_at"
|
order = "media.created_at"
|
||||||
|
case "dateplayed":
|
||||||
|
order = "resume.watched_at"
|
||||||
case "communityrating":
|
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") {
|
if !strings.HasSuffix(order, " desc") {
|
||||||
order = order + " desc"
|
order = order + " desc"
|
||||||
}
|
}
|
||||||
@@ -518,6 +571,19 @@ func (e *EmbyService) payloadsForMedia(ctx context.Context, rows []model.Media,
|
|||||||
|
|
||||||
// Item 单条目详情。
|
// Item 单条目详情。
|
||||||
func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[string]any, error) {
|
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 strings.HasPrefix(mediaID, embyVirtualSeasonPrefix) {
|
||||||
if season, ok, err := e.findSeasonGroup(ctx, mediaID, userID); err != nil {
|
if season, ok, err := e.findSeasonGroup(ctx, mediaID, userID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -579,7 +645,7 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
|
|||||||
q = q.Where("library_id = ?", parentID)
|
q = q.Where("library_id = ?", parentID)
|
||||||
}
|
}
|
||||||
var rows []model.Media
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
favs := map[string]bool{}
|
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))
|
Where("library_id IN ? AND (season_num > 0 OR episode_num > 0)", e.mergedLibraryIDs(ctx, libraryID))
|
||||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||||
var rows []model.Media
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
groups := e.seriesGroupsFromMedia(rows)
|
groups := e.seriesGroupsFromMedia(rows)
|
||||||
@@ -769,6 +835,12 @@ func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID strin
|
|||||||
if p.SearchTerm != "" {
|
if p.SearchTerm != "" {
|
||||||
q = q.Where("title LIKE ? OR original_name LIKE ?", "%"+p.SearchTerm+"%", "%"+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)
|
rowLimit := p.StartIndex + maxInt(p.Limit*40, 1000)
|
||||||
if rowLimit < p.Limit {
|
if rowLimit < p.Limit {
|
||||||
rowLimit = p.Limit
|
rowLimit = p.Limit
|
||||||
@@ -777,7 +849,7 @@ func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID strin
|
|||||||
rowLimit = embySeriesGroupingLimit
|
rowLimit = embySeriesGroupingLimit
|
||||||
}
|
}
|
||||||
var rows []model.Media
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
groups := e.seriesGroupsFromMedia(rows)
|
groups := e.seriesGroupsFromMedia(rows)
|
||||||
@@ -939,7 +1011,7 @@ func (e *EmbyService) findSeriesGroup(ctx context.Context, id, userID string) (e
|
|||||||
if !strings.HasPrefix(id, embyVirtualSeriesPrefix) {
|
if !strings.HasPrefix(id, embyVirtualSeriesPrefix) {
|
||||||
q = q.Where("series_id = ?", id)
|
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
|
return embySeriesGroup{}, false, err
|
||||||
}
|
}
|
||||||
for _, group := range e.seriesGroupsFromMedia(rows) {
|
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")
|
Where("season_num > 0 OR episode_num > 0")
|
||||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||||
if err := q.
|
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).
|
Limit(embySeriesGroupingLimit).
|
||||||
Find(&rows).Error; err != nil {
|
Find(&rows).Error; err != nil {
|
||||||
return embySeasonGroup{}, false, err
|
return embySeasonGroup{}, false, err
|
||||||
@@ -1284,6 +1356,69 @@ func containsItemType(types []string, want string) bool {
|
|||||||
return false
|
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 {
|
func pageSlice[T any](items []T, start, limit int) []T {
|
||||||
if start < 0 {
|
if start < 0 {
|
||||||
start = 0
|
start = 0
|
||||||
@@ -1639,8 +1774,15 @@ func (e *EmbyService) mediaSource(ctx context.Context, m *model.Media, asEmbedde
|
|||||||
}
|
}
|
||||||
isCloud := strings.TrimSpace(m.STRMURL) != ""
|
isCloud := strings.TrimSpace(m.STRMURL) != ""
|
||||||
playURL := embyDirectStreamURL(m.ID, container)
|
playURL := embyDirectStreamURL(m.ID, container)
|
||||||
if isCloud && STRMPlaybackEnabled(ctx, e.repo) {
|
if isCloud {
|
||||||
playURL = embySTRMStreamURL(m.ID)
|
switch CloudPlaybackMode(ctx, e.repo) {
|
||||||
|
case CloudPlaybackModeSTRM:
|
||||||
|
playURL = embySTRMStreamURL(m.ID)
|
||||||
|
case CloudPlaybackModeRedirectProxy:
|
||||||
|
playURL = embyDirectStreamURL(m.ID, container)
|
||||||
|
default:
|
||||||
|
playURL = ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if isCloud {
|
if isCloud {
|
||||||
// Cloud/WebDAV media is already a direct/proxy stream. Advertising HLS
|
// 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,
|
"RequiresClosing": false,
|
||||||
"ReadAtNativeFramerate": false,
|
"ReadAtNativeFramerate": false,
|
||||||
"SupportsTranscoding": !directOnly,
|
"SupportsTranscoding": !directOnly,
|
||||||
// 云盘媒体禁用 DirectPlay:DirectPlay 语义是「客户端直接访问
|
// 云盘媒体的 Path 在 PlaybackInfo 阶段会被补上 api_key,且最终
|
||||||
// Path」,而云盘媒体的 Path 是不带鉴权 token 的内部 /api/cloud/play
|
// 302 到云盘直链。Infuse/Emby 官方客户端会优先挑选 DirectPlay
|
||||||
// 路径,Infuse/VidHub 等播放器直接请求会得到 401/404。强制它们走
|
// 源;如果这里标 false,即使 DirectStreamUrl 可用,也可能被判定
|
||||||
// DirectStream(/Videos/{id}/stream?api_key=...),由服务端校验后
|
// 为“没有可播放媒体源”。
|
||||||
// 302 到云盘直链。
|
"SupportsDirectStream": !isCloud || playURL != "",
|
||||||
"SupportsDirectStream": true,
|
"SupportsDirectPlay": !isCloud || playURL != "",
|
||||||
"SupportsDirectPlay": !isCloud,
|
|
||||||
"SupportsProbing": true,
|
"SupportsProbing": true,
|
||||||
"RunTimeTicks": int64(m.DurationSec) * 10_000_000,
|
"RunTimeTicks": int64(m.DurationSec) * 10_000_000,
|
||||||
"MediaStreams": e.mediaStreams(m),
|
"MediaStreams": e.mediaStreams(m),
|
||||||
}
|
}
|
||||||
if !asEmbedded {
|
if !asEmbedded && playURL != "" {
|
||||||
src["DirectStreamUrl"] = playURL
|
src["DirectStreamUrl"] = playURL
|
||||||
// 直连解码模式下不下发 TranscodingUrl,迫使客户端本地解码直连,
|
// 直连解码模式下不下发 TranscodingUrl,迫使客户端本地解码直连,
|
||||||
// 宿主机不参与转码。
|
// 宿主机不参与转码。
|
||||||
@@ -1681,7 +1822,7 @@ func (e *EmbyService) mediaSource(ctx context.Context, m *model.Media, asEmbedde
|
|||||||
src["TranscodingUrl"] = "/Videos/" + m.ID + "/master.m3u8"
|
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 / cloud:// media must stay behind a token-aware endpoint. When
|
||||||
// STRM playback is enabled we expose /api/stream so third-party clients
|
// STRM playback is enabled we expose /api/stream so third-party clients
|
||||||
// follow the same STRM entry as generated .strm files; when disabled we
|
// 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) {
|
func TestEmbyUserPolicyDisablesDownloadsForViewers(t *testing.T) {
|
||||||
svc := newTestEmbyService(t)
|
svc := newTestEmbyService(t)
|
||||||
viewer := &model.User{Username: "viewer", Role: "user", Tier: "free", IsActive: true}
|
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) {
|
func TestEmbyPlaybackInfoKeepsSTRMBehindStreamEndpoint(t *testing.T) {
|
||||||
svc := newTestEmbyService(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}
|
lib := model.Library{Name: "夸克网盘", Path: `cloud://quark/0`, Type: "movie", Enabled: true}
|
||||||
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
|
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
|
||||||
t.Fatalf("create library: %v", err)
|
t.Fatalf("create library: %v", err)
|
||||||
@@ -374,8 +526,8 @@ func TestEmbyPlaybackInfoKeepsSTRMBehindStreamEndpoint(t *testing.T) {
|
|||||||
|
|
||||||
func TestEmbyPlaybackInfoUsesVideoStreamWhenSTRMDisabled(t *testing.T) {
|
func TestEmbyPlaybackInfoUsesVideoStreamWhenSTRMDisabled(t *testing.T) {
|
||||||
svc := newTestEmbyService(t)
|
svc := newTestEmbyService(t)
|
||||||
if err := svc.repo.Setting.Set(t.Context(), STRMEnabledSettingKey, "false"); err != nil {
|
if err := svc.repo.Setting.Set(t.Context(), CloudPlaybackModeSettingKey, CloudPlaybackModeRedirectProxy); err != nil {
|
||||||
t.Fatalf("set strm disabled: %v", err)
|
t.Fatalf("set cloud playback mode: %v", err)
|
||||||
}
|
}
|
||||||
lib := model.Library{Name: "OpenList", Path: `cloud://openlist/Movies`, Type: "movie", Enabled: true}
|
lib := model.Library{Name: "OpenList", Path: `cloud://openlist/Movies`, Type: "movie", Enabled: true}
|
||||||
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
|
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ func mappedPathCandidates(input string) []string {
|
|||||||
}
|
}
|
||||||
clean := filepath.Clean(input)
|
clean := filepath.Clean(input)
|
||||||
add(clean)
|
add(clean)
|
||||||
|
if slashClean := cleanPathForVolumeMapping(input); slashClean != "" {
|
||||||
|
add(slashClean)
|
||||||
|
}
|
||||||
for _, candidate := range dockerVolumePathCandidates(clean) {
|
for _, candidate := range dockerVolumePathCandidates(clean) {
|
||||||
add(candidate)
|
add(candidate)
|
||||||
}
|
}
|
||||||
@@ -165,7 +168,7 @@ func isAccessibleDir(path string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func dockerVolumePathCandidates(path string) []string {
|
func dockerVolumePathCandidates(path string) []string {
|
||||||
normalized := filepath.ToSlash(filepath.Clean(path))
|
normalized := cleanPathForVolumeMapping(path)
|
||||||
var candidates []string
|
var candidates []string
|
||||||
addCandidate := func(candidate string) {
|
addCandidate := func(candidate string) {
|
||||||
candidate = filepath.Clean(filepath.FromSlash(candidate))
|
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_MEDIA_DIR", container: envOrDefault("MEDIASTATION_MEDIA_CONTAINER_DIR", "/media")},
|
||||||
{env: "MEDIASTATION_DOWNLOAD_DIR", container: envOrDefault("MEDIASTATION_DOWNLOAD_CONTAINER_DIR", "/downloads")},
|
{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, ".") {
|
if host == "." || host == "" || strings.HasPrefix(host, ".") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -201,17 +204,44 @@ func dockerVolumePathCandidates(path string) []string {
|
|||||||
part string
|
part string
|
||||||
container string
|
container string
|
||||||
}{
|
}{
|
||||||
{part: "/media/", container: "/media/"},
|
{part: "/media", container: envOrDefault("MEDIASTATION_MEDIA_CONTAINER_DIR", "/media")},
|
||||||
{part: "/downloads/", container: "/downloads/"},
|
{part: "/downloads", container: envOrDefault("MEDIASTATION_DOWNLOAD_CONTAINER_DIR", "/downloads")},
|
||||||
} {
|
} {
|
||||||
if idx := strings.Index(normalized, marker.part); idx >= 0 {
|
part := strings.TrimRight(marker.part, "/")
|
||||||
addCandidate(marker.container + strings.TrimPrefix(normalized[idx+len(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
|
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 {
|
func sameLibraryPath(a, b string) bool {
|
||||||
return filepath.Clean(a) == filepath.Clean(b)
|
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) {
|
func TestDeleteCloudLibraryPurgesMountWithoutRecycleBin(t *testing.T) {
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -147,6 +147,52 @@ func (s *StorageConfigService) Save(ctx context.Context, in StorageInput) (*Stor
|
|||||||
return s.Get(ctx, in.Type)
|
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.
|
// Test runs a connection probe against the supplied (un-saved) config.
|
||||||
// The implementation is best-effort: it issues a single HEAD/PROPFIND
|
// The implementation is best-effort: it issues a single HEAD/PROPFIND
|
||||||
// to verify reachability, not full functionality.
|
// 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"
|
"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
|
// StreamService serves media files with proper Range support so browsers can
|
||||||
// seek into the stream.
|
// seek into the stream.
|
||||||
@@ -62,6 +76,8 @@ var ErrMediaNotFound = errors.New("media not found")
|
|||||||
// 调用方应把它与「媒体不存在」区分开,避免把配置类故障当成 404 返回给播放器。
|
// 调用方应把它与「媒体不存在」区分开,避免把配置类故障当成 404 返回给播放器。
|
||||||
var ErrCloudPlaybackUnavailable = errors.New("cloud media playback unavailable: media missing play url; re-scan the library")
|
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 规范化为相对路径。
|
// normalizeCloudPlayTarget 把存库的云盘播放 URL 规范化为相对路径。
|
||||||
//
|
//
|
||||||
// STRMURL 是扫描时根据当时的 server_url/请求地址生成并固化进数据库的。
|
// STRMURL 是扫描时根据当时的 server_url/请求地址生成并固化进数据库的。
|
||||||
@@ -201,6 +217,14 @@ func requestToken(r *http.Request) string {
|
|||||||
return v
|
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"} {
|
for _, k := range []string{"token", "api_key", "apiKey", "ApiKey"} {
|
||||||
if v := strings.TrimSpace(r.URL.Query().Get(k)); v != "" {
|
if v := strings.TrimSpace(r.URL.Query().Get(k)); v != "" {
|
||||||
return v
|
return v
|
||||||
@@ -209,15 +233,126 @@ func requestToken(r *http.Request) string {
|
|||||||
return ""
|
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 {
|
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) == "" {
|
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
|
// 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
|
// instead of opening a local file. This lets WebDAV / Alist / S3 / HTTP
|
||||||
// direct links flow through the rest of the player UI unchanged.
|
// direct links flow through the rest of the player UI unchanged.
|
||||||
func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaID string) error {
|
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)
|
m, err := s.repo.Media.FindByID(r.Context(), mediaID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -235,6 +374,9 @@ func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaI
|
|||||||
return ErrMediaNotFound
|
return ErrMediaNotFound
|
||||||
}
|
}
|
||||||
if strmURL := strings.TrimSpace(m.STRMURL); strmURL != "" && (isCloudPlaybackTarget(strmURL) || STRMPlaybackEnabled(r.Context(), s.repo)) {
|
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。
|
// 云盘播放 URL 先规范化为相对路径,免疫扫描时固化的旧 host。
|
||||||
target := normalizeCloudPlayTarget(strmURL)
|
target := normalizeCloudPlayTarget(strmURL)
|
||||||
target = withAuthTokenForInternalRedirect(target, r, PublicServerURL(r.Context(), s.repo, s.cfg))
|
target = withAuthTokenForInternalRedirect(target, r, PublicServerURL(r.Context(), s.repo, s.cfg))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -117,7 +118,7 @@ func TestServeFileRedirectUsesForwardedTunnelHost(t *testing.T) {
|
|||||||
|
|
||||||
func TestServeFileRedirectsCloudMediaForVideoStreamMode(t *testing.T) {
|
func TestServeFileRedirectsCloudMediaForVideoStreamMode(t *testing.T) {
|
||||||
repos := newStreamTestRepo(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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := repos.DB.Create(&model.Media{
|
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 {
|
func newStreamTestRepo(t *testing.T) *repository.Container {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
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) {
|
func TestAppendQueryToHLSSegments(t *testing.T) {
|
||||||
in := "#EXTM3U\n#EXTINF:4.0,\nseg_00000.ts\n#EXTINF:4.0,\nseg_00001.ts?old=1\n"
|
in := "#EXTM3U\n#EXTINF:4.0,\nseg_00000.ts\n#EXTINF:4.0,\nseg_00001.ts?old=1\n"
|
||||||
got := appendQueryToHLSSegments(in, "token=abc")
|
got := appendQueryToHLSSegments(in, "token=abc")
|
||||||
|
|||||||
@@ -102,7 +102,15 @@ func (w *WatcherService) Refresh(ctx context.Context) error {
|
|||||||
if _, _, ok := parseCloudLibraryPath(l.Path); ok {
|
if _, _, ok := parseCloudLibraryPath(l.Path); ok {
|
||||||
continue
|
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
|
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 })
|
.put<StorageConfig>(`/admin/storage/${type}`, { type, config, enabled })
|
||||||
.then((r) => r.data),
|
.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>) =>
|
test: (type: StorageType, config: Record<string, string>) =>
|
||||||
api
|
api
|
||||||
.post<{ ok: boolean; error?: string }>(`/admin/storage/${type}/test`, {
|
.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())
|
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.
|
// browse allowed roots, optionally recurse, and perform safe local operations.
|
||||||
export function FileManagerPage() {
|
export function FileManagerPage() {
|
||||||
const [libraries, setLibraries] = useState<Library[]>([])
|
const [libraries, setLibraries] = useState<Library[]>([])
|
||||||
@@ -372,7 +372,7 @@ export function FileManagerPage() {
|
|||||||
<header>
|
<header>
|
||||||
<h1 className="font-display text-3xl font-bold text-ink-600">文件管理</h1>
|
<h1 className="font-display text-3xl font-bold text-ink-600">文件管理</h1>
|
||||||
<p className="text-sm text-ink-50">
|
<p className="text-sm text-ink-50">
|
||||||
像 MoviePilot 一样:在下载目录中选择文件夹或视频,直接设置目标并整理入库。
|
在下载目录中选择文件夹或视频,直接设置目标并整理入库。
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FormEvent, useEffect, useMemo, useState } from 'react'
|
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 toast from 'react-hot-toast'
|
||||||
|
|
||||||
import { libraryAPI } from '../api/library'
|
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: 'username', label: 'WebDAV 用户名' },
|
||||||
{ key: 'password', label: 'WebDAV 密码', secret: true },
|
{ key: 'password', label: 'WebDAV 密码', secret: true },
|
||||||
{ key: 'timeout_seconds', label: '请求超时秒数', placeholder: '120' },
|
{ key: 'timeout_seconds', label: '请求超时秒数', placeholder: '120' },
|
||||||
{ key: 'force_302', label: '强制 302 直链(true/false,默认自动优先直链)' },
|
|
||||||
],
|
],
|
||||||
webdav: [
|
webdav: [
|
||||||
{ key: 'url', label: 'URL', placeholder: 'https://example.com/dav/' },
|
{ 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: [
|
cloud115: [
|
||||||
{ key: 'cookie', label: 'Cookie(UID / CID / SEID,或扫码登录自动填充)', secret: true, placeholder: 'UID=...; CID=...; SEID=...' },
|
{ key: 'cookie', label: 'Cookie(UID / CID / SEID,或扫码登录自动填充)', secret: true, placeholder: 'UID=...; CID=...; SEID=...' },
|
||||||
{ key: 'force_proxy', label: '强制反代(true/false,默认 302 直链)' },
|
|
||||||
],
|
],
|
||||||
quark: [
|
quark: [
|
||||||
{ key: 'cookie', label: 'Cookie(从 pan.quark.cn 复制整段)', secret: true, placeholder: '__pus=...; __kp=...; kps=...' },
|
{ key: 'cookie', label: 'Cookie(从 pan.quark.cn 复制整段)', secret: true, placeholder: '__pus=...; __kp=...; kps=...' },
|
||||||
{ key: 'force_302', label: '强制 302 直链(true/false,默认反代)' },
|
|
||||||
],
|
],
|
||||||
clouddrive2: [
|
clouddrive2: [
|
||||||
{ key: 'url', label: 'CloudDrive2 WebDAV URL', placeholder: 'http://host.docker.internal:19798/dav 或 http://NAS-IP:19798/dav' },
|
{ 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: 'password', label: '密码 / Token', secret: true },
|
||||||
{ key: 'token', label: 'Authorization Token(可选)', secret: true, placeholder: 'Bearer ... 或 Basic ...' },
|
{ key: 'token', label: 'Authorization Token(可选)', secret: true, placeholder: 'Bearer ... 或 Basic ...' },
|
||||||
{ key: 'timeout_seconds', label: '请求超时秒数', placeholder: '120' },
|
{ 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 [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [testing, setTesting] = useState(false)
|
const [testing, setTesting] = useState(false)
|
||||||
|
const [loggingOut, setLoggingOut] = useState(false)
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
setLoading(true)
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center py-12 text-ink-50">
|
<div className="flex justify-center py-12 text-ink-50">
|
||||||
@@ -262,6 +281,17 @@ function StorageForm({ type }: { type: StorageType }) {
|
|||||||
启用
|
启用
|
||||||
</label>
|
</label>
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onTest}
|
onClick={onTest}
|
||||||
@@ -647,7 +677,7 @@ function CloudBrowser({ type }: { type: StorageType }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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-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 className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
+79
-12
@@ -8,6 +8,8 @@ import { strmAPI, type GenerateSTRMResult } from '../api/strm'
|
|||||||
import { confirmAction } from '../components/ConfirmDialog'
|
import { confirmAction } from '../components/ConfirmDialog'
|
||||||
import type { Library, Media } from '../types'
|
import type { Library, Media } from '../types'
|
||||||
|
|
||||||
|
type CloudPlaybackMode = 'strm' | 'redirect_proxy'
|
||||||
|
|
||||||
// StrmPage exposes the URL-as-file admin tooling backed by the Go server:
|
// 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)
|
// - import a brand-new media row directly from a (library, title, url)
|
||||||
// tuple — useful for streaming-only entries with no on-disk file.
|
// tuple — useful for streaming-only entries with no on-disk file.
|
||||||
@@ -21,7 +23,9 @@ export function StrmPage() {
|
|||||||
const [generateLibraryID, setGenerateLibraryID] = useState('')
|
const [generateLibraryID, setGenerateLibraryID] = useState('')
|
||||||
const [baseURL, setBaseURL] = useState('')
|
const [baseURL, setBaseURL] = useState('')
|
||||||
const [outputDir, setOutputDir] = 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 [autoGenerate, setAutoGenerate] = useState(false)
|
||||||
const [savingSettings, setSavingSettings] = useState(false)
|
const [savingSettings, setSavingSettings] = useState(false)
|
||||||
const [overwrite, setOverwrite] = 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]))
|
const settings = Object.fromEntries(rows.map((row) => [row.key, row.value]))
|
||||||
setBaseURL(settings['app.server_url'] || settings['strm.base_url'] || '')
|
setBaseURL(settings['app.server_url'] || settings['strm.base_url'] || '')
|
||||||
setOutputDir(settings['strm.output_dir'] || '')
|
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')
|
setAutoGenerate(settings['strm.auto_generate_enabled'] === 'true')
|
||||||
})
|
})
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
@@ -93,11 +110,25 @@ export function StrmPage() {
|
|||||||
const saveSTRMSettings = async () => {
|
const saveSTRMSettings = async () => {
|
||||||
setSavingSettings(true)
|
setSavingSettings(true)
|
||||||
try {
|
try {
|
||||||
|
const effectiveMode: CloudPlaybackMode =
|
||||||
|
strmPlaybackEnabled && !redirectProxyEnabled
|
||||||
|
? 'strm'
|
||||||
|
: !strmPlaybackEnabled && redirectProxyEnabled
|
||||||
|
? 'redirect_proxy'
|
||||||
|
: cloudPlaybackMode
|
||||||
await Promise.all([
|
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)),
|
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) {
|
} catch (err: unknown) {
|
||||||
const msg =
|
const msg =
|
||||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
(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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -209,26 +249,53 @@ export function StrmPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className={`rounded-full border px-3 py-1 text-xs font-semibold ${
|
<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-emerald-300/40 bg-emerald-400/10 text-emerald-500'
|
||||||
: 'border-red-300/40 bg-red-400/10 text-red-500'
|
: 'border-red-300/40 bg-red-400/10 text-red-500'
|
||||||
}`}>
|
}`}>
|
||||||
{strmEnabled ? 'STRM 播放已启用' : 'STRM 播放已关闭'}
|
{playbackStatus}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<label className="flex items-start gap-3 text-sm text-ink-100">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="mt-1 h-4 w-4 accent-primary-400"
|
className="mt-1 h-4 w-4 accent-primary-400"
|
||||||
checked={strmEnabled}
|
checked={strmPlaybackEnabled}
|
||||||
onChange={(e) => setStrmEnabled(e.target.checked)}
|
onChange={(e) => setStrmPlaybackEnabled(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
<span className="block font-medium text-ink-600">启用 STRM 播放</span>
|
<span className="block font-medium text-ink-600">启用 STRMURL 播放</span>
|
||||||
<span className="text-xs text-ink-50">开启后第三方播放优先走 STRM 入口;关闭后使用 302/反代播放入口。</span>
|
<span className="text-xs text-ink-50">第三方客户端可拿到 /api/stream/媒体ID 入口,适合 STRM 管理和自动生成方案。</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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">
|
<label className="flex items-start gap-3 text-sm text-ink-100">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -289,7 +356,7 @@ export function StrmPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<p className="text-xs text-sand-500">
|
<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>
|
</p>
|
||||||
{generateResult && (
|
{generateResult && (
|
||||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 text-sm text-ink-50">
|
<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