fix: honor STRM playback mode for cloud clients

This commit is contained in:
ShukeBta
2026-06-13 00:17:39 +08:00
parent 5d942f2c42
commit ba43117a57
9 changed files with 344 additions and 29 deletions
+68 -3
View File
@@ -375,11 +375,15 @@ func TestCloudDrive2WebDAVListAndResolve(t *testing.T) {
func TestOpenListWebDAVListAndResolve(t *testing.T) {
var gotPath, gotDepth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/fs/get" {
http.NotFound(w, r)
return
}
if r.Method != "PROPFIND" || r.URL.Path != "/dav" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
gotPath = r.URL.Path
gotDepth = r.Header.Get("Depth")
if r.Method != "PROPFIND" {
t.Fatalf("unexpected method %s", r.Method)
}
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusMultiStatus)
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
@@ -428,6 +432,67 @@ func TestOpenListWebDAVListAndResolve(t *testing.T) {
}
}
func TestOpenListResolveUsesAPIRawURLFor302Playback(t *testing.T) {
var gotPath, gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
if r.Method != http.MethodPost || r.URL.Path != "/api/fs/get" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"https://cdn.example.test/movie.mkv?sign=1"}}`))
}))
defer srv.Close()
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, 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 gotPath != "/api/fs/get" {
t.Fatalf("api path = %q, want /api/fs/get", gotPath)
}
if gotAuth != "alist-token" {
t.Fatalf("Authorization = %q, want token", gotAuth)
}
if link.URL != "https://cdn.example.test/movie.mkv?sign=1" {
t.Fatalf("url = %q", link.URL)
}
if link.Proxy {
t.Fatalf("openlist raw_url without required headers should be 302 playback")
}
}
func TestOpenListResolveFallsBackToProxyWhenAPIRawURLNeedsHeaders(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/fs/get" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"/dav/Cloud/Movie.mkv","header":{"Cookie":"sid=abc"}}}`))
}))
defer srv.Close()
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, 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 link.URL != srv.URL+"/dav/Cloud/Movie.mkv" {
t.Fatalf("url = %q", link.URL)
}
if !link.Proxy || link.Headers["Cookie"] != "sid=abc" {
t.Fatalf("link should keep proxy mode with required headers: %#v", link)
}
}
func TestOpenListRootURLDefaultsToDAV(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+139
View File
@@ -211,6 +211,11 @@ func (p *cloudDrive2Provider) Resolve(ctx context.Context, fileRef string) (*Dir
if ref == "/" {
return nil, fmt.Errorf("%s: file reference required", p.name)
}
if p.typ == TypeOpenList && p.apiBase != nil && isOpenListDirectPlaybackCandidate(ref) {
if link, err := p.resolveOpenListAPIDirect(ctx, ref); err == nil && link != nil {
return link, nil
}
}
headers := map[string]string{
"User-Agent": p.ua,
}
@@ -222,6 +227,130 @@ func (p *cloudDrive2Provider) Resolve(ctx context.Context, fileRef string) (*Dir
return &DirectLink{URL: p.urlFor(ref), Headers: headers, Proxy: p.proxy}, nil
}
func (p *cloudDrive2Provider) resolveOpenListAPIDirect(ctx context.Context, fileRef string) (*DirectLink, error) {
payload, _ := json.Marshal(map[string]string{"path": normalizeCloudDAVPath(fileRef), "password": ""})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL("/api/fs/get"), bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", p.ua)
if p.token != "" {
req.Header.Set("Authorization", p.token)
}
resp, err := p.client.Do(req)
if err != nil {
return nil, decorateDAVTransportError(p.name, p.openListAPIURL("/api/fs/get"), err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%s: api get %s returned http %d", p.name, fileRef, resp.StatusCode)
}
var decoded openListGetResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&decoded); err != nil {
return nil, fmt.Errorf("%s: decode api get: %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 nil, fmt.Errorf("%s: api get %s failed: %s", p.name, fileRef, msg)
}
raw := firstNonEmpty(decoded.Data.RawURL, decoded.Data.URL)
if raw == "" {
return nil, fmt.Errorf("%s: api get %s returned empty raw_url", p.name, fileRef)
}
resolved, err := p.resolveOpenListPlaybackURL(raw)
if err != nil {
return nil, err
}
headers := normalizeOpenListPlaybackHeaders(decoded.Data.Header)
proxy := p.proxy && len(headers) > 0
if !proxy {
headers = nil
}
return &DirectLink{URL: resolved, Headers: headers, Proxy: proxy}, nil
}
func (p *cloudDrive2Provider) resolveOpenListPlaybackURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", fmt.Errorf("%s: empty playback URL", p.name)
}
if strings.HasPrefix(raw, "//") {
if p.apiBase == nil || p.apiBase.Scheme == "" {
return "", fmt.Errorf("%s: protocol-relative playback URL without API base", p.name)
}
raw = p.apiBase.Scheme + ":" + raw
}
u, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("%s: invalid playback URL: %w", p.name, err)
}
if u.IsAbs() {
if u.Scheme != "http" && u.Scheme != "https" {
return "", fmt.Errorf("%s: unsupported playback URL scheme %q", p.name, u.Scheme)
}
return u.String(), nil
}
if p.apiBase == nil {
return "", fmt.Errorf("%s: relative playback URL without API base", p.name)
}
base := *p.apiBase
base.RawPath = ""
base.RawQuery = ""
base.Fragment = ""
return base.ResolveReference(u).String(), nil
}
func normalizeOpenListPlaybackHeaders(raw json.RawMessage) map[string]string {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
var obj map[string]any
if err := json.Unmarshal(raw, &obj); err != nil {
return nil
}
out := make(map[string]string, len(obj))
for k, v := range obj {
key := strings.TrimSpace(k)
if key == "" {
continue
}
switch value := v.(type) {
case string:
if strings.TrimSpace(value) != "" {
out[key] = strings.TrimSpace(value)
}
case []any:
parts := make([]string, 0, len(value))
for _, item := range value {
if s, ok := item.(string); ok && strings.TrimSpace(s) != "" {
parts = append(parts, strings.TrimSpace(s))
}
}
if len(parts) > 0 {
out[key] = strings.Join(parts, ", ")
}
}
}
if len(out) == 0 {
return nil
}
return out
}
func isOpenListDirectPlaybackCandidate(fileRef string) bool {
switch strings.ToLower(path.Ext(strings.TrimSpace(fileRef))) {
case ".mkv", ".mp4", ".m4v", ".avi", ".mov", ".webm", ".ts", ".rmvb", ".rm", ".3gp", ".mpg", ".mpeg":
return true
default:
return false
}
}
func (p *cloudDrive2Provider) validate() error {
if p.base == nil || p.base.Scheme == "" || p.base.Host == "" {
return fmt.Errorf("%s: missing WebDAV URL", p.name)
@@ -464,6 +593,16 @@ type openListListItem struct {
IsDir bool `json:"is_dir"`
}
type openListGetResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
RawURL string `json:"raw_url"`
URL string `json:"url"`
Header json.RawMessage `json:"header"`
} `json:"data"`
}
func normalizeCloudDAVPath(p string) string {
p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/")
if p == "" || p == "." {
+22 -15
View File
@@ -511,7 +511,7 @@ func (e *EmbyService) payloadsForMedia(ctx context.Context, rows []model.Media,
items := make([]map[string]any, 0, len(rows))
for _, m := range rows {
items = append(items, e.itemPayload(&m, userFavs[m.ID], userPos[m.ID]))
items = append(items, e.itemPayload(ctx, &m, userFavs[m.ID], userPos[m.ID]))
}
return items, nil
}
@@ -562,7 +562,7 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
pos = h.PositionMs
}
}
return e.itemPayload(m, fav, pos), nil
return e.itemPayload(ctx, m, fav, pos), nil
}
// LatestItems 最近添加,全库或指定库。
@@ -601,7 +601,7 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
}
out := make([]map[string]any, 0, len(rows))
for _, m := range rows {
out = append(out, e.itemPayload(&m, favs[m.ID], 0))
out = append(out, e.itemPayload(ctx, &m, favs[m.ID], 0))
}
return out, nil
}
@@ -675,13 +675,13 @@ func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int)
items := make([]map[string]any, 0, len(hist))
for _, h := range hist {
if m, ok := byID[h.MediaID]; ok {
items = append(items, e.itemPayload(m, false, posByID[h.MediaID]))
items = append(items, e.itemPayload(ctx, m, false, posByID[h.MediaID]))
}
}
return map[string]any{"Items": items, "TotalRecordCount": len(items)}, nil
}
func (e *EmbyService) itemPayload(m *model.Media, fav bool, posMs int64) map[string]any {
func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, posMs int64) map[string]any {
itemType := "Movie"
name := m.Title
parentID := m.LibraryID
@@ -756,7 +756,7 @@ func (e *EmbyService) itemPayload(m *model.Media, fav bool, posMs int64) map[str
"Played": played,
"PlayedPercentage": pct,
},
"MediaSources": []map[string]any{e.mediaSource(m, true, false)},
"MediaSources": []map[string]any{e.mediaSource(ctx, m, true, false)},
}
}
@@ -1474,7 +1474,7 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string)
}
e.ensureCloudTrackMetadata(ctx, m)
return map[string]any{
"MediaSources": []map[string]any{e.mediaSource(m, false, e.directPlayOnly(ctx))},
"MediaSources": []map[string]any{e.mediaSource(ctx, m, false, e.directPlayOnly(ctx))},
"PlaySessionId": fmt.Sprintf("%s-%d", m.ID, time.Now().Unix()),
}, nil
}
@@ -1629,7 +1629,7 @@ func (e *EmbyService) playableMedia(ctx context.Context, id, userID string) (*mo
// asEmbedded=true:嵌在 /Items 列表里,不包含完整 stream URL(避免暴露
// 直链给搜索接口)。/PlaybackInfo 走 false 路径,URL 指向 Emby 兼容
// /Videos/{id}/stream(客户端会继续携带 X-Emby-Token 或 append api_key)。
func (e *EmbyService) mediaSource(m *model.Media, asEmbedded, directOnly bool) map[string]any {
func (e *EmbyService) mediaSource(ctx context.Context, m *model.Media, asEmbedded, directOnly bool) map[string]any {
container := strings.Trim(strings.ToLower(m.Container), ". ")
if container == "" {
container = strings.TrimPrefix(strings.ToLower(filepath.Ext(m.Path)), ".")
@@ -1638,6 +1638,10 @@ func (e *EmbyService) mediaSource(m *model.Media, asEmbedded, directOnly bool) m
container = "strm"
}
isCloud := strings.TrimSpace(m.STRMURL) != ""
playURL := embyDirectStreamURL(m.ID, container)
if isCloud && STRMPlaybackEnabled(ctx, e.repo) {
playURL = embySTRMStreamURL(m.ID)
}
if isCloud {
// Cloud/WebDAV media is already a direct/proxy stream. Advertising HLS
// transcoding makes some Emby clients pick /master.m3u8, forcing this
@@ -1670,8 +1674,7 @@ func (e *EmbyService) mediaSource(m *model.Media, asEmbedded, directOnly bool) m
"MediaStreams": e.mediaStreams(m),
}
if !asEmbedded {
streamURL := embyDirectStreamURL(m.ID, container)
src["DirectStreamUrl"] = streamURL
src["DirectStreamUrl"] = playURL
// 直连解码模式下不下发 TranscodingUrl,迫使客户端本地解码直连,
// 宿主机不参与转码。
if !directOnly {
@@ -1679,16 +1682,20 @@ func (e *EmbyService) mediaSource(m *model.Media, asEmbedded, directOnly bool) m
}
}
if strings.TrimSpace(m.STRMURL) != "" {
// STRM / cloud:// media plays through /Videos/{id}/stream. Some
// third-party Emby clients still prefer MediaSource.Path even when
// SupportsDirectPlay=false; pointing Path at the token-aware stream
// endpoint keeps those clients away from naked /api/cloud/play URLs.
// STRM / cloud:// media must stay behind a token-aware endpoint. When
// STRM playback is enabled we expose /api/stream so third-party clients
// follow the same STRM entry as generated .strm files; when disabled we
// expose /Videos/{id}/stream so playback uses the Emby 302/proxy path.
src["IsRemote"] = true
src["Path"] = embyDirectStreamURL(m.ID, container)
src["Path"] = playURL
}
return src
}
func embySTRMStreamURL(mediaID string) string {
return "/api/stream/" + url.PathEscape(strings.TrimSpace(mediaID))
}
func embyDirectStreamURL(mediaID, container string) string {
mediaID = strings.TrimSpace(mediaID)
container = strings.Trim(strings.ToLower(container), ". ")
+38 -4
View File
@@ -360,11 +360,11 @@ func TestEmbyPlaybackInfoKeepsSTRMBehindStreamEndpoint(t *testing.T) {
if src["IsRemote"] != true {
t.Fatalf("strm media should be marked remote: %#v", src)
}
if src["DirectStreamUrl"] != "/Videos/cloud-1/stream" {
t.Fatalf("strm playback must stay behind token-aware stream endpoint: %#v", src)
if src["DirectStreamUrl"] != "/api/stream/cloud-1" {
t.Fatalf("strm playback should prefer /api/stream when enabled: %#v", src)
}
if src["Path"] != "/Videos/cloud-1/stream" {
t.Fatalf("path should use token-aware stream endpoint: %#v", src)
if src["Path"] != "/api/stream/cloud-1" {
t.Fatalf("path should prefer /api/stream when enabled: %#v", src)
}
streams := src["MediaStreams"].([]map[string]any)
if len(streams) == 0 || streams[0]["Type"] != "Video" {
@@ -372,6 +372,40 @@ func TestEmbyPlaybackInfoKeepsSTRMBehindStreamEndpoint(t *testing.T) {
}
}
func TestEmbyPlaybackInfoUsesVideoStreamWhenSTRMDisabled(t *testing.T) {
svc := newTestEmbyService(t)
if err := svc.repo.Setting.Set(t.Context(), STRMEnabledSettingKey, "false"); err != nil {
t.Fatalf("set strm disabled: %v", err)
}
lib := model.Library{Name: "OpenList", Path: `cloud://openlist/Movies`, Type: "movie", Enabled: true}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
media := model.Media{
Base: model.Base{ID: "cloud-302"},
LibraryID: lib.ID,
Title: "Cloud 302 Movie",
Path: `cloud://openlist/Movies/Movie.mkv`,
STRMURL: `/api/cloud/play/openlist?ref=%2FMovies%2FMovie.mkv`,
Container: "mkv",
}
if err := svc.repo.DB.Create(&media).Error; err != nil {
t.Fatalf("create media: %v", err)
}
pb, err := svc.PlaybackInfo(t.Context(), "cloud-302", "user-1")
if err != nil {
t.Fatalf("playback info: %v", err)
}
src := pb["MediaSources"].([]map[string]any)[0]
if src["DirectStreamUrl"] != "/Videos/cloud-302/stream.mkv" {
t.Fatalf("302/proxy mode should use Emby video stream URL: %#v", src)
}
if src["Path"] != "/Videos/cloud-302/stream.mkv" {
t.Fatalf("302/proxy mode path should use Emby video stream URL: %#v", src)
}
}
func TestEmbyPlaybackInfoProbesMissingCloudTrackMetadata(t *testing.T) {
svc := newTestEmbyService(t)
lib := model.Library{Name: "OpenList", Path: `cloud://openlist/Movies`, Type: "movie", Enabled: true}
+2 -2
View File
@@ -115,7 +115,7 @@ func TestServeFileRedirectUsesForwardedTunnelHost(t *testing.T) {
}
}
func TestServeFileRedirectsCloudMediaWhenSTRMPlaybackDisabled(t *testing.T) {
func TestServeFileRedirectsCloudMediaForVideoStreamMode(t *testing.T) {
repos := newStreamTestRepo(t)
if err := repos.Setting.Set(t.Context(), STRMEnabledSettingKey, "false"); err != nil {
t.Fatal(err)
@@ -134,7 +134,7 @@ func TestServeFileRedirectsCloudMediaWhenSTRMPlaybackDisabled(t *testing.T) {
err := svc.ServeFile(w, req, "cloud-1")
if err != nil {
t.Fatalf("cloud playback should ignore local STRM toggle: %v", err)
t.Fatalf("video stream mode should still reach cloud playback endpoint: %v", err)
}
if w.Code != http.StatusFound {
t.Fatalf("status = %d, want 302", w.Code)