fix emby clients and cloud playback controls

This commit is contained in:
ShukeBta
2026-06-13 12:08:57 +08:00
parent ba43117a57
commit a177dc61dd
28 changed files with 2031 additions and 138 deletions
+61 -4
View File
@@ -3,6 +3,7 @@ package cloud
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
@@ -105,10 +106,18 @@ func quarkPagePayload(page int) string {
return strings.Join(items, ",")
}
func TestQuarkForce302(t *testing.T) {
p := newQuark(map[string]any{"cookie": "c", "force_302": "true"}, http.DefaultClient)
if p.proxy {
t.Fatalf("force_302 should disable proxy mode")
func TestDeprecatedProviderPlaybackOverrideKeysAreIgnored(t *testing.T) {
quark := newQuark(map[string]any{"cookie": "c", "force_302": "true"}, http.DefaultClient)
if !quark.proxy {
t.Fatalf("quark should keep safe proxy mode; force_302 is deprecated")
}
pan115 := new115(map[string]any{"cookie": "UID=1; CID=2", "force_proxy": "true"}, http.DefaultClient)
if pan115.proxy {
t.Fatalf("115 should keep safe direct mode; force_proxy is deprecated")
}
cd2 := newCloudDrive2(map[string]any{"url": "http://example.test/dav", "force_302": "true"}, http.DefaultClient)
if !cd2.proxy {
t.Fatalf("clouddrive2 should keep safe proxy mode; force_302 is deprecated")
}
}
@@ -375,6 +384,10 @@ func TestCloudDrive2WebDAVListAndResolve(t *testing.T) {
func TestOpenListWebDAVListAndResolve(t *testing.T) {
var gotPath, gotDepth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/auth/login" {
http.NotFound(w, r)
return
}
if r.URL.Path == "/api/fs/get" {
http.NotFound(w, r)
return
@@ -467,6 +480,50 @@ func TestOpenListResolveUsesAPIRawURLFor302Playback(t *testing.T) {
}
}
func TestOpenListResolveLogsInWithUsernamePasswordForAPIRawURL(t *testing.T) {
var loginSeen bool
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/auth/login":
loginSeen = true
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode login body: %v", err)
}
if body["username"] != "alice" || body["password"] != "secret" {
t.Fatalf("login body = %#v", body)
}
_, _ = w.Write([]byte(`{"code":200,"data":{"token":"api-token"}}`))
case "/api/fs/get":
gotAuth = r.Header.Get("Authorization")
_, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"https://cdn.example.test/movie.mkv?sign=1"}}`))
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "username": "alice", "password": "secret"}, srv.Client())
if err != nil {
t.Fatal(err)
}
link, err := p.Resolve(context.Background(), "/Cloud/Movie.mkv")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if !loginSeen {
t.Fatalf("expected api login before fs/get")
}
if gotAuth != "api-token" {
t.Fatalf("Authorization = %q, want api-token", gotAuth)
}
if link.URL != "https://cdn.example.test/movie.mkv?sign=1" || link.Proxy {
t.Fatalf("link = %#v, want raw_url 302 playback", link)
}
}
func TestOpenListResolveFallsBackToProxyWhenAPIRawURLNeedsHeaders(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/fs/get" {
+70 -8
View File
@@ -54,9 +54,6 @@ func newCloudDAVProvider(typ, name string, cfg map[string]any, client *http.Clie
ua = defaultUA
}
proxy := true
if _, ok := cfg["force_302"]; ok && boolish(cfg["force_302"]) {
proxy = false
}
return &cloudDrive2Provider{
typ: typ,
name: name,
@@ -82,7 +79,7 @@ func (p *cloudDrive2Provider) List(ctx context.Context, dir string) ([]FileEntry
if err := p.validate(); err != nil {
return nil, err
}
if p.typ == TypeOpenList && p.apiBase != nil && strings.TrimSpace(p.token) != "" {
if p.typ == TypeOpenList && p.apiBase != nil && p.hasOpenListAPICredentials() {
if entries, err := p.listOpenListAPI(ctx, dir); err == nil {
return entries, nil
}
@@ -135,6 +132,10 @@ func (p *cloudDrive2Provider) List(ctx context.Context, dir string) ([]FileEntry
}
func (p *cloudDrive2Provider) listOpenListAPI(ctx context.Context, dir string) ([]FileEntry, error) {
token, err := p.openListAPIToken(ctx)
if err != nil {
return nil, err
}
const pageSize = 500
target := normalizeCloudDAVPath(dir)
out := make([]FileEntry, 0, pageSize)
@@ -154,8 +155,8 @@ func (p *cloudDrive2Provider) listOpenListAPI(ctx context.Context, dir string) (
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", p.ua)
if p.token != "" {
req.Header.Set("Authorization", p.token)
if token != "" {
req.Header.Set("Authorization", token)
}
resp, err := p.client.Do(req)
if err != nil {
@@ -228,6 +229,10 @@ func (p *cloudDrive2Provider) Resolve(ctx context.Context, fileRef string) (*Dir
}
func (p *cloudDrive2Provider) resolveOpenListAPIDirect(ctx context.Context, fileRef string) (*DirectLink, error) {
token, err := p.openListAPIToken(ctx)
if err != nil {
return nil, err
}
payload, _ := json.Marshal(map[string]string{"path": normalizeCloudDAVPath(fileRef), "password": ""})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL("/api/fs/get"), bytes.NewReader(payload))
if err != nil {
@@ -236,8 +241,8 @@ func (p *cloudDrive2Provider) resolveOpenListAPIDirect(ctx context.Context, file
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", p.ua)
if p.token != "" {
req.Header.Set("Authorization", p.token)
if token != "" {
req.Header.Set("Authorization", token)
}
resp, err := p.client.Do(req)
if err != nil {
@@ -274,6 +279,55 @@ func (p *cloudDrive2Provider) resolveOpenListAPIDirect(ctx context.Context, file
return &DirectLink{URL: resolved, Headers: headers, Proxy: proxy}, nil
}
func (p *cloudDrive2Provider) hasOpenListAPICredentials() bool {
return strings.TrimSpace(p.token) != "" || (strings.TrimSpace(p.username) != "" && p.password != "")
}
func (p *cloudDrive2Provider) openListAPIToken(ctx context.Context) (string, error) {
if token := strings.TrimSpace(p.token); token != "" {
return token, nil
}
if strings.TrimSpace(p.username) == "" || p.password == "" {
return "", nil
}
payload, _ := json.Marshal(map[string]string{
"username": p.username,
"password": p.password,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL("/api/auth/login"), bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", p.ua)
resp, err := p.client.Do(req)
if err != nil {
return "", decorateDAVTransportError(p.name, p.openListAPIURL("/api/auth/login"), err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("%s: api login returned http %d", p.name, resp.StatusCode)
}
var decoded openListLoginResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&decoded); err != nil {
return "", fmt.Errorf("%s: decode api login: %w", p.name, err)
}
if decoded.Code != 0 && decoded.Code != 200 {
msg := strings.TrimSpace(decoded.Message)
if msg == "" {
msg = fmt.Sprintf("code %d", decoded.Code)
}
return "", fmt.Errorf("%s: api login failed: %s", p.name, msg)
}
token := strings.TrimSpace(decoded.Data.Token)
if token == "" {
return "", fmt.Errorf("%s: api login returned empty token", p.name)
}
p.token = token
return token, nil
}
func (p *cloudDrive2Provider) resolveOpenListPlaybackURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
@@ -603,6 +657,14 @@ type openListGetResponse struct {
} `json:"data"`
}
type openListLoginResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
Token string `json:"token"`
} `json:"data"`
}
func normalizeCloudDAVPath(p string) string {
p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/")
if p == "" || p == "." {
+2 -4
View File
@@ -45,11 +45,9 @@ func new115(cfg map[string]any, client *http.Client) *pan115Provider {
ua = defaultUA
}
// 115 CDN download URLs work with a plain 302 (Alist's recommended mode),
// so offload by default; admin can force proxy mode if their network needs it.
// so offload by default. The global cloud playback setting decides whether
// clients receive a STRMURL entry or a /Videos stream entry.
proxy := false
if _, ok := cfg["force_proxy"]; ok && boolish(cfg["force_proxy"]) {
proxy = true
}
pro := str(cfg["pro_base"])
if pro == "" {
pro = pan115ProBase
+3 -4
View File
@@ -36,11 +36,10 @@ func newQuark(cfg map[string]any, client *http.Client) *quarkProvider {
ua = defaultUA
}
// Quark download links require the session cookie + UA, so the host must
// reverse-proxy unless the admin explicitly opts into raw 302.
// reverse-proxy. The global cloud playback setting decides whether clients
// receive a STRMURL entry or a /Videos stream entry; this provider only
// reports whether the resolved upstream URL itself is safe for raw 302.
proxy := true
if _, ok := cfg["force_302"]; ok && boolish(cfg["force_302"]) {
proxy = false
}
return &quarkProvider{
cookie: str(cfg["cookie"]),
ua: ua,
+174 -33
View File
@@ -232,7 +232,7 @@ func (e *EmbyService) Views(ctx context.Context, userID string) (map[string]any,
}
items = append(items, e.libraryAsView(&l))
}
return map[string]any{"Items": items, "TotalRecordCount": len(items)}, nil
return map[string]any{"Items": items, "TotalRecordCount": len(items), "StartIndex": 0}, nil
}
func (e *EmbyService) libraryAsView(l *model.Library) map[string]any {
@@ -248,14 +248,31 @@ func (e *EmbyService) libraryAsView(l *model.Library) map[string]any {
collectionType = "music"
}
return map[string]any{
"Id": l.ID,
"Name": l.Name,
"CollectionType": collectionType,
"ServerId": embyServerID,
"Type": "CollectionFolder",
"IsFolder": true,
"ImageTags": map[string]string{},
"BackdropImageTags": []string{},
"Id": l.ID,
"Name": l.Name,
"CollectionType": collectionType,
"ServerId": embyServerID,
"Type": "CollectionFolder",
"IsFolder": true,
"Path": l.Path,
"SortName": strings.ToLower(l.Name),
"DateCreated": l.CreatedAt.UTC().Format(time.RFC3339),
"CanDelete": false,
"CanDownload": false,
"DisplayPreferencesId": l.ID,
"PrimaryImageItemId": l.ID,
"PrimaryImageAspectRatio": 1.7777777777777777,
"RecursiveItemCount": 0,
"ChildCount": 0,
"SpecialFeatureCount": 0,
"EnableMediaSourceDisplay": true,
"PlayAccess": "Full",
"ExternalUrls": []any{},
"ProviderIds": map[string]string{},
"Genres": []string{},
"Tags": []string{},
"ImageTags": map[string]string{},
"BackdropImageTags": []string{},
"UserData": map[string]any{
"PlaybackPositionTicks": 0,
"PlayCount": 0,
@@ -275,6 +292,7 @@ type ItemsParams struct {
IDs []string
SearchTerm string
IncludeItemTypes []string
Filters []string
Recursive bool
SortBy string
SortOrder string
@@ -352,6 +370,9 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
if p.StartIndex < 0 {
p.StartIndex = 0
}
if len(p.IncludeItemTypes) > 0 && !containsSupportedEmbyItemType(p.IncludeItemTypes) {
return emptyItemsEnvelope(p.StartIndex), nil
}
if len(p.IDs) > 0 {
items := make([]map[string]any, 0, len(p.IDs))
@@ -367,7 +388,19 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
return map[string]any{"Items": items, "TotalRecordCount": len(items), "StartIndex": 0}, nil
}
if p.ParentID == "" && p.SearchTerm == "" && !p.Recursive && len(p.IncludeItemTypes) == 0 {
if containsOnlyFolderItemTypes(p.IncludeItemTypes) {
if p.ParentID == "" {
return e.Views(ctx, p.UserID)
}
if episodic, err := e.libraryIsEpisodic(ctx, p.ParentID); err != nil {
return nil, err
} else if episodic {
return e.seriesItemsForLibrary(ctx, p.ParentID, p)
}
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": 0, "StartIndex": p.StartIndex}, nil
}
if p.ParentID == "" && p.SearchTerm == "" && !p.Recursive && len(p.IncludeItemTypes) == 0 && len(p.Filters) == 0 {
return e.Views(ctx, p.UserID)
}
@@ -414,6 +447,24 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string
if p.SearchTerm != "" {
q = q.Where("title LIKE ? OR original_name LIKE ?", "%"+p.SearchTerm+"%", "%"+p.SearchTerm+"%")
}
if containsEmbyFilter(p.Filters, "IsFavorite") {
if strings.TrimSpace(p.UserID) == "" {
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
}
q = q.Joins("JOIN favorites ON favorites.media_id = media.id AND favorites.user_id = ? AND favorites.deleted_at IS NULL", p.UserID)
}
resumeFilter := containsEmbyFilter(p.Filters, "IsResumable")
if resumeFilter {
if strings.TrimSpace(p.UserID) == "" {
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
}
q = q.Joins(`JOIN (
SELECT media_id, MAX(watched_at) AS watched_at
FROM playback_histories
WHERE user_id = ? AND completed = ? AND position_ms > 0
GROUP BY media_id
) AS resume ON resume.media_id = media.id`, p.UserID, false)
}
if containsItemType(p.IncludeItemTypes, "Movie") && !containsItemType(p.IncludeItemTypes, "Episode") {
q = q.Where("season_num = 0 AND episode_num = 0")
}
@@ -425,18 +476,20 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string
if err := q.Count(&total).Error; err != nil {
return nil, err
}
order := "created_at desc"
switch strings.ToLower(p.SortBy) {
order := "media.created_at desc"
switch primarySupportedEmbySort(p.SortBy, resumeFilter) {
case "sortname", "name":
order = "title"
order = "media.title"
case "premieredate", "productionyear":
order = "year"
order = "media.year"
case "datecreated":
order = "created_at"
order = "media.created_at"
case "dateplayed":
order = "resume.watched_at"
case "communityrating":
order = "rating"
order = "media.rating"
}
if strings.EqualFold(p.SortOrder, "Descending") {
if strings.EqualFold(firstCSVValue(p.SortOrder), "Descending") {
if !strings.HasSuffix(order, " desc") {
order = order + " desc"
}
@@ -518,6 +571,19 @@ func (e *EmbyService) payloadsForMedia(ctx context.Context, rows []model.Media,
// Item 单条目详情。
func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[string]any, error) {
if lib, err := e.repo.Library.FindByID(ctx, mediaID); err != nil {
return nil, err
} else if lib != nil {
libs := FilterDisplayCloudLibraries(ctx, e.repo, []model.Library{*lib})
if len(libs) == 0 {
return nil, nil
}
visibility := e.mediaVisibility(ctx, userID)
if !e.libraryVisibleFromCachedVisibility(libs[0], visibility) {
return nil, nil
}
return e.libraryAsView(&libs[0]), nil
}
if strings.HasPrefix(mediaID, embyVirtualSeasonPrefix) {
if season, ok, err := e.findSeasonGroup(ctx, mediaID, userID); err != nil {
return nil, err
@@ -579,7 +645,7 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
q = q.Where("library_id = ?", parentID)
}
var rows []model.Media
if err := q.Order("created_at desc").Limit(limit).Find(&rows).Error; err != nil {
if err := q.Order("media.created_at desc").Limit(limit).Find(&rows).Error; err != nil {
return nil, err
}
favs := map[string]bool{}
@@ -621,7 +687,7 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l
Where("library_id IN ? AND (season_num > 0 OR episode_num > 0)", e.mergedLibraryIDs(ctx, libraryID))
q = e.applyUserMediaVisibility(ctx, q, userID)
var rows []model.Media
if err := q.Order("created_at desc").Limit(rowLimit).Find(&rows).Error; err != nil {
if err := q.Order("media.created_at desc").Limit(rowLimit).Find(&rows).Error; err != nil {
return nil, err
}
groups := e.seriesGroupsFromMedia(rows)
@@ -769,6 +835,12 @@ func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID strin
if p.SearchTerm != "" {
q = q.Where("title LIKE ? OR original_name LIKE ?", "%"+p.SearchTerm+"%", "%"+p.SearchTerm+"%")
}
if containsEmbyFilter(p.Filters, "IsFavorite") {
if strings.TrimSpace(p.UserID) == "" {
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": 0, "StartIndex": p.StartIndex}, nil
}
q = q.Joins("JOIN favorites ON favorites.media_id = media.id AND favorites.user_id = ? AND favorites.deleted_at IS NULL", p.UserID)
}
rowLimit := p.StartIndex + maxInt(p.Limit*40, 1000)
if rowLimit < p.Limit {
rowLimit = p.Limit
@@ -777,7 +849,7 @@ func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID strin
rowLimit = embySeriesGroupingLimit
}
var rows []model.Media
if err := q.Order("created_at desc").Limit(rowLimit).Find(&rows).Error; err != nil {
if err := q.Order("media.created_at desc").Limit(rowLimit).Find(&rows).Error; err != nil {
return nil, err
}
groups := e.seriesGroupsFromMedia(rows)
@@ -939,7 +1011,7 @@ func (e *EmbyService) findSeriesGroup(ctx context.Context, id, userID string) (e
if !strings.HasPrefix(id, embyVirtualSeriesPrefix) {
q = q.Where("series_id = ?", id)
}
if err := q.Order("season_num asc, episode_num asc, created_at asc").Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
if err := q.Order("media.season_num asc, media.episode_num asc, media.created_at asc").Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
return embySeriesGroup{}, false, err
}
for _, group := range e.seriesGroupsFromMedia(rows) {
@@ -982,7 +1054,7 @@ func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (e
Where("season_num > 0 OR episode_num > 0")
q = e.applyUserMediaVisibility(ctx, q, userID)
if err := q.
Order("season_num asc, episode_num asc, created_at asc").
Order("media.season_num asc, media.episode_num asc, media.created_at asc").
Limit(embySeriesGroupingLimit).
Find(&rows).Error; err != nil {
return embySeasonGroup{}, false, err
@@ -1284,6 +1356,69 @@ func containsItemType(types []string, want string) bool {
return false
}
func containsSupportedEmbyItemType(types []string) bool {
for _, itemType := range types {
switch strings.ToLower(strings.TrimSpace(itemType)) {
case "movie", "series", "season", "episode", "video", "folder", "collectionfolder":
return true
}
}
return false
}
func containsOnlyFolderItemTypes(types []string) bool {
if len(types) == 0 {
return false
}
for _, itemType := range types {
switch strings.ToLower(strings.TrimSpace(itemType)) {
case "folder", "collectionfolder":
default:
return false
}
}
return true
}
func emptyItemsEnvelope(startIndex int) map[string]any {
return map[string]any{
"Items": []map[string]any{},
"TotalRecordCount": int64(0),
"StartIndex": startIndex,
}
}
func containsEmbyFilter(filters []string, want string) bool {
for _, filter := range filters {
if strings.EqualFold(strings.TrimSpace(filter), want) {
return true
}
}
return false
}
func firstCSVValue(value string) string {
if i := strings.Index(value, ","); i >= 0 {
value = value[:i]
}
return strings.TrimSpace(value)
}
func primarySupportedEmbySort(sortBy string, resumeFilter bool) string {
for _, part := range strings.Split(sortBy, ",") {
key := strings.ToLower(strings.TrimSpace(part))
switch key {
case "sortname", "name", "premieredate", "productionyear", "datecreated", "communityrating":
return key
case "dateplayed":
if resumeFilter {
return key
}
}
}
return strings.ToLower(strings.TrimSpace(firstCSVValue(sortBy)))
}
func pageSlice[T any](items []T, start, limit int) []T {
if start < 0 {
start = 0
@@ -1639,8 +1774,15 @@ func (e *EmbyService) mediaSource(ctx context.Context, m *model.Media, asEmbedde
}
isCloud := strings.TrimSpace(m.STRMURL) != ""
playURL := embyDirectStreamURL(m.ID, container)
if isCloud && STRMPlaybackEnabled(ctx, e.repo) {
playURL = embySTRMStreamURL(m.ID)
if isCloud {
switch CloudPlaybackMode(ctx, e.repo) {
case CloudPlaybackModeSTRM:
playURL = embySTRMStreamURL(m.ID)
case CloudPlaybackModeRedirectProxy:
playURL = embyDirectStreamURL(m.ID, container)
default:
playURL = ""
}
}
if isCloud {
// Cloud/WebDAV media is already a direct/proxy stream. Advertising HLS
@@ -1662,18 +1804,17 @@ func (e *EmbyService) mediaSource(ctx context.Context, m *model.Media, asEmbedde
"RequiresClosing": false,
"ReadAtNativeFramerate": false,
"SupportsTranscoding": !directOnly,
// 云盘媒体禁用 DirectPlay:DirectPlay 语义是「客户端直接访问
// Path」,而云盘媒体的 Path 是不带鉴权 token 的内部 /api/cloud/play
// 路径,Infuse/VidHub 等播放器直接请求会得到 401/404。强制它们走
// DirectStream(/Videos/{id}/stream?api_key=...),由服务端校验后
// 302 到云盘直链。
"SupportsDirectStream": true,
"SupportsDirectPlay": !isCloud,
// 云盘媒体的 Path 在 PlaybackInfo 阶段会被补上 api_key,且最终
// 302 到云盘直链。Infuse/Emby 官方客户端会优先挑选 DirectPlay
// 源;如果这里标 false,即使 DirectStreamUrl 可用,也可能被判定
// 为“没有可播放媒体源”。
"SupportsDirectStream": !isCloud || playURL != "",
"SupportsDirectPlay": !isCloud || playURL != "",
"SupportsProbing": true,
"RunTimeTicks": int64(m.DurationSec) * 10_000_000,
"MediaStreams": e.mediaStreams(m),
}
if !asEmbedded {
if !asEmbedded && playURL != "" {
src["DirectStreamUrl"] = playURL
// 直连解码模式下不下发 TranscodingUrl,迫使客户端本地解码直连,
// 宿主机不参与转码。
@@ -1681,7 +1822,7 @@ func (e *EmbyService) mediaSource(ctx context.Context, m *model.Media, asEmbedde
src["TranscodingUrl"] = "/Videos/" + m.ID + "/master.m3u8"
}
}
if strings.TrimSpace(m.STRMURL) != "" {
if strings.TrimSpace(m.STRMURL) != "" && playURL != "" {
// STRM / cloud:// media must stay behind a token-aware endpoint. When
// STRM playback is enabled we expose /api/stream so third-party clients
// follow the same STRM entry as generated .strm files; when disabled we
+154 -2
View File
@@ -213,6 +213,155 @@ func TestEmbyRootItemsExposeLibraries(t *testing.T) {
}
}
func TestEmbyFolderItemQueryExposesLibrariesForHome(t *testing.T) {
svc := newTestEmbyService(t)
lib := model.Library{Name: "电影", Path: `/media/movies`, Type: "movie", Enabled: true}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
if err := svc.repo.DB.Create(&model.Media{Base: model.Base{ID: "movie-1"}, LibraryID: lib.ID, Title: "不应出现在文件夹查询", Path: `/media/movies/a.mkv`}).Error; err != nil {
t.Fatalf("create media: %v", err)
}
out, err := svc.Items(t.Context(), ItemsParams{
IncludeItemTypes: []string{"Folder", "CollectionFolder"},
Limit: 50,
})
if err != nil {
t.Fatalf("folder items: %v", err)
}
items := out["Items"].([]map[string]any)
if len(items) != 1 {
t.Fatalf("expected one library folder, got %#v", items)
}
if items[0]["Type"] != "CollectionFolder" || items[0]["IsFolder"] != true {
t.Fatalf("folder query should return collection folders, got %#v", items[0])
}
}
func TestEmbyUnsupportedItemTypesDoNotLeakAllMedia(t *testing.T) {
svc := newTestEmbyService(t)
lib := model.Library{Name: "电影", Path: `/media/movies`, Type: "movie", Enabled: true}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
if err := svc.repo.DB.Create(&model.Media{Base: model.Base{ID: "movie-1"}, LibraryID: lib.ID, Title: "普通电影", Path: `/media/movies/a.mkv`}).Error; err != nil {
t.Fatalf("create media: %v", err)
}
for _, includeType := range []string{"BoxSet", "Game", "Book", "Audio", "MusicAlbum", "Playlist", "TvChannel"} {
out, err := svc.Items(t.Context(), ItemsParams{
IncludeItemTypes: []string{includeType},
Recursive: true,
Limit: 50,
})
if err != nil {
t.Fatalf("%s items: %v", includeType, err)
}
if out["TotalRecordCount"] != int64(0) {
t.Fatalf("%s should not return media rows, got %#v", includeType, out)
}
items := out["Items"].([]map[string]any)
if len(items) != 0 {
t.Fatalf("%s should return an empty list, got %#v", includeType, items)
}
}
}
func TestEmbyItemsFiltersFavorites(t *testing.T) {
svc := newTestEmbyService(t)
viewer := &model.User{Base: model.Base{ID: "user-1"}, Username: "viewer", Role: "user", Tier: "free", IsActive: true}
if err := svc.repo.User.Create(t.Context(), viewer); err != nil {
t.Fatalf("create viewer: %v", err)
}
lib := model.Library{Name: "电影", Path: `/media/movies`, Type: "movie", Enabled: true}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
favorite := model.Media{Base: model.Base{ID: "fav-1"}, LibraryID: lib.ID, Title: "收藏电影", Path: `/media/movies/fav.mkv`}
normal := model.Media{Base: model.Base{ID: "normal-1"}, LibraryID: lib.ID, Title: "普通电影", Path: `/media/movies/normal.mkv`}
if err := svc.repo.DB.Create(&favorite).Error; err != nil {
t.Fatalf("create favorite media: %v", err)
}
if err := svc.repo.DB.Create(&normal).Error; err != nil {
t.Fatalf("create normal media: %v", err)
}
if err := svc.repo.DB.Create(&model.Favorite{UserID: viewer.ID, MediaID: favorite.ID}).Error; err != nil {
t.Fatalf("create favorite: %v", err)
}
out, err := svc.Items(t.Context(), ItemsParams{
UserID: viewer.ID,
Filters: []string{"IsFavorite"},
Recursive: true,
Limit: 50,
})
if err != nil {
t.Fatalf("favorite items: %v", err)
}
if out["TotalRecordCount"] != int64(1) {
t.Fatalf("expected one favorite, got %#v", out)
}
items := out["Items"].([]map[string]any)
if len(items) != 1 || items[0]["Id"] != favorite.ID {
t.Fatalf("favorite filter returned wrong items: %#v", items)
}
userData := items[0]["UserData"].(map[string]any)
if userData["IsFavorite"] != true {
t.Fatalf("favorite payload should carry IsFavorite=true: %#v", userData)
}
}
func TestEmbyItemsFiltersResumableForHome(t *testing.T) {
svc := newTestEmbyService(t)
viewer := &model.User{Base: model.Base{ID: "user-1"}, Username: "viewer", Role: "user", Tier: "free", IsActive: true}
if err := svc.repo.User.Create(t.Context(), viewer); err != nil {
t.Fatalf("create viewer: %v", err)
}
lib := model.Library{Name: "电影", Path: `/media/movies`, Type: "movie", Enabled: true}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
resumable := model.Media{Base: model.Base{ID: "resume-1"}, LibraryID: lib.ID, Title: "继续观看", Path: `/media/movies/resume.mkv`, DurationSec: 120}
normal := model.Media{Base: model.Base{ID: "normal-1"}, LibraryID: lib.ID, Title: "普通电影", Path: `/media/movies/normal.mkv`, DurationSec: 120}
if err := svc.repo.DB.Create(&resumable).Error; err != nil {
t.Fatalf("create resumable media: %v", err)
}
if err := svc.repo.DB.Create(&normal).Error; err != nil {
t.Fatalf("create normal media: %v", err)
}
if err := svc.repo.DB.Create(&model.PlaybackHistory{
UserID: viewer.ID,
MediaID: resumable.ID,
PositionMs: 30_000,
DurationMs: 120_000,
WatchedAt: time.Now(),
Completed: false,
}).Error; err != nil {
t.Fatalf("create playback history: %v", err)
}
out, err := svc.Items(t.Context(), ItemsParams{
UserID: viewer.ID,
Filters: []string{"IsResumable"},
Recursive: true,
SortBy: "DatePlayed",
SortOrder: "Descending",
Limit: 50,
StartIndex: 0,
})
if err != nil {
t.Fatalf("resumable items: %v", err)
}
if out["TotalRecordCount"] != int64(1) {
t.Fatalf("expected one resumable item, got %#v", out)
}
items := out["Items"].([]map[string]any)
if len(items) != 1 || items[0]["Id"] != resumable.ID {
t.Fatalf("resumable filter returned wrong items: %#v", items)
}
}
func TestEmbyUserPolicyDisablesDownloadsForViewers(t *testing.T) {
svc := newTestEmbyService(t)
viewer := &model.User{Username: "viewer", Role: "user", Tier: "free", IsActive: true}
@@ -337,6 +486,9 @@ func TestEmbyPlaybackInfoRespectsDirectPlayOnly(t *testing.T) {
func TestEmbyPlaybackInfoKeepsSTRMBehindStreamEndpoint(t *testing.T) {
svc := newTestEmbyService(t)
if err := svc.repo.Setting.Set(t.Context(), CloudPlaybackModeSettingKey, CloudPlaybackModeSTRM); err != nil {
t.Fatalf("set cloud playback mode: %v", err)
}
lib := model.Library{Name: "夸克网盘", Path: `cloud://quark/0`, Type: "movie", Enabled: true}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
@@ -374,8 +526,8 @@ func TestEmbyPlaybackInfoKeepsSTRMBehindStreamEndpoint(t *testing.T) {
func TestEmbyPlaybackInfoUsesVideoStreamWhenSTRMDisabled(t *testing.T) {
svc := newTestEmbyService(t)
if err := svc.repo.Setting.Set(t.Context(), STRMEnabledSettingKey, "false"); err != nil {
t.Fatalf("set strm disabled: %v", err)
if err := svc.repo.Setting.Set(t.Context(), CloudPlaybackModeSettingKey, CloudPlaybackModeRedirectProxy); err != nil {
t.Fatalf("set cloud playback mode: %v", err)
}
lib := model.Library{Name: "OpenList", Path: `cloud://openlist/Movies`, Type: "movie", Enabled: true}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
+36 -6
View File
@@ -147,6 +147,9 @@ func mappedPathCandidates(input string) []string {
}
clean := filepath.Clean(input)
add(clean)
if slashClean := cleanPathForVolumeMapping(input); slashClean != "" {
add(slashClean)
}
for _, candidate := range dockerVolumePathCandidates(clean) {
add(candidate)
}
@@ -165,7 +168,7 @@ func isAccessibleDir(path string) bool {
}
func dockerVolumePathCandidates(path string) []string {
normalized := filepath.ToSlash(filepath.Clean(path))
normalized := cleanPathForVolumeMapping(path)
var candidates []string
addCandidate := func(candidate string) {
candidate = filepath.Clean(filepath.FromSlash(candidate))
@@ -184,7 +187,7 @@ func dockerVolumePathCandidates(path string) []string {
{env: "MEDIASTATION_MEDIA_DIR", container: envOrDefault("MEDIASTATION_MEDIA_CONTAINER_DIR", "/media")},
{env: "MEDIASTATION_DOWNLOAD_DIR", container: envOrDefault("MEDIASTATION_DOWNLOAD_CONTAINER_DIR", "/downloads")},
} {
host := filepath.ToSlash(filepath.Clean(os.Getenv(mapping.env)))
host := cleanPathForVolumeMapping(os.Getenv(mapping.env))
if host == "." || host == "" || strings.HasPrefix(host, ".") {
continue
}
@@ -201,17 +204,44 @@ func dockerVolumePathCandidates(path string) []string {
part string
container string
}{
{part: "/media/", container: "/media/"},
{part: "/downloads/", container: "/downloads/"},
{part: "/media", container: envOrDefault("MEDIASTATION_MEDIA_CONTAINER_DIR", "/media")},
{part: "/downloads", container: envOrDefault("MEDIASTATION_DOWNLOAD_CONTAINER_DIR", "/downloads")},
} {
if idx := strings.Index(normalized, marker.part); idx >= 0 {
addCandidate(marker.container + strings.TrimPrefix(normalized[idx+len(marker.part):], "/"))
part := strings.TrimRight(marker.part, "/")
container := strings.TrimRight(filepath.ToSlash(marker.container), "/")
markerPath := pathAfterWindowsDrivePrefix(normalized)
if markerPath == part {
addCandidate(container)
continue
}
if strings.HasPrefix(markerPath, part+"/") {
addCandidate(container + strings.TrimPrefix(markerPath, part))
}
}
return candidates
}
func cleanPathForVolumeMapping(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
path = strings.ReplaceAll(path, "\\", "/")
return filepath.ToSlash(filepath.Clean(filepath.FromSlash(path)))
}
func pathAfterWindowsDrivePrefix(path string) string {
if len(path) >= 3 && path[1] == ':' && path[2] == '/' && isASCIIAlpha(path[0]) {
return path[2:]
}
return path
}
func isASCIIAlpha(ch byte) bool {
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')
}
func sameLibraryPath(a, b string) bool {
return filepath.Clean(a) == filepath.Clean(b)
}
+15
View File
@@ -49,6 +49,21 @@ func TestResolveAccessibleLibraryPathKeepsAccessibleContainerPath(t *testing.T)
}
}
func TestMappedPathCandidatesMapWindowsDriveDownloadMarker(t *testing.T) {
root := t.TempDir()
containerDownloads := filepath.Join(root, "container", "downloads")
containerLibrary := filepath.Join(containerDownloads, "国产剧")
t.Setenv("MEDIASTATION_DOWNLOAD_CONTAINER_DIR", containerDownloads)
want := filepath.Clean(containerLibrary)
for _, got := range mappedPathCandidates(`F:\downloads\国产剧`) {
if got == want {
return
}
}
t.Fatalf("mappedPathCandidates() missing %q", want)
}
func TestDeleteCloudLibraryPurgesMountWithoutRecycleBin(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
+46
View File
@@ -147,6 +147,52 @@ func (s *StorageConfigService) Save(ctx context.Context, in StorageInput) (*Stor
return s.Get(ctx, in.Type)
}
// Logout clears saved cloud login credentials and disables the storage backend.
// It intentionally keeps non-secret connection hints such as server / WebDAV
// URL / timeout so the admin can log in again without rebuilding the form.
func (s *StorageConfigService) Logout(ctx context.Context, typ string) (*StorageView, error) {
if !validStorageType(typ) {
return nil, fmt.Errorf("unsupported storage type %q", typ)
}
if !cloud.IsCloudType(typ) {
return nil, fmt.Errorf("not a cloud provider: %q", typ)
}
view, err := s.Get(ctx, typ)
if err != nil {
return nil, err
}
if view == nil {
return nil, fmt.Errorf("%s storage not configured", typ)
}
cfg := make(map[string]any, len(view.Config))
for k, v := range view.Config {
if isStorageLoginSecretKey(k) || isDeprecatedStoragePlaybackKey(k) {
continue
}
cfg[k] = v
}
enabled := false
return s.Save(ctx, StorageInput{Type: typ, Config: cfg, Enabled: &enabled})
}
func isStorageLoginSecretKey(key string) bool {
switch strings.ToLower(strings.TrimSpace(key)) {
case "cookie", "token", "username", "password", "access_key", "secret_key":
return true
default:
return false
}
}
func isDeprecatedStoragePlaybackKey(key string) bool {
switch strings.ToLower(strings.TrimSpace(key)) {
case "force_302", "force_proxy":
return true
default:
return false
}
}
// Test runs a connection probe against the supplied (un-saved) config.
// The implementation is best-effort: it issues a single HEAD/PROPFIND
// to verify reachability, not full functionality.
@@ -0,0 +1,39 @@
package service
import "testing"
func TestStorageConfigLogoutClearsCloudCredentialsOnly(t *testing.T) {
_, storage := newStorageUploadTestService(t)
enabled := true
if _, err := storage.Save(t.Context(), StorageInput{
Type: "openlist",
Config: map[string]any{
"server": "http://openlist.test",
"url": "http://openlist.test/dav/",
"username": "user",
"password": "pass",
"token": "token",
"timeout_seconds": "120",
"force_302": "true",
},
Enabled: &enabled,
}); err != nil {
t.Fatalf("save storage: %v", err)
}
view, err := storage.Logout(t.Context(), "openlist")
if err != nil {
t.Fatalf("logout: %v", err)
}
if view.Enabled {
t.Fatal("storage should be disabled after logout")
}
for _, key := range []string{"username", "password", "token", "force_302", "force_proxy"} {
if _, ok := view.Config[key]; ok {
t.Fatalf("logout should clear %s, config = %#v", key, view.Config)
}
}
if view.Config["server"] != "http://openlist.test" || view.Config["url"] != "http://openlist.test/dav/" || view.Config["timeout_seconds"] != "120" {
t.Fatalf("logout should keep non-secret connection hints, config = %#v", view.Config)
}
}
+148 -6
View File
@@ -33,7 +33,21 @@ import (
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
const STRMEnabledSettingKey = "strm.enabled"
const (
STRMEnabledSettingKey = "strm.enabled"
CloudPlaybackModeSettingKey = "cloud.playback_mode"
CloudPlaybackSTRMEnabledSettingKey = "cloud.playback_strm_enabled"
CloudPlaybackRedirectEnabledSettingKey = "cloud.playback_redirect_proxy_enabled"
CloudPlaybackModeSTRM = "strm"
CloudPlaybackModeRedirectProxy = "redirect_proxy"
)
type CloudPlaybackOptions struct {
STRMEnabled bool
RedirectProxyEnabled bool
PreferredMode string
}
// StreamService serves media files with proper Range support so browsers can
// seek into the stream.
@@ -62,6 +76,8 @@ var ErrMediaNotFound = errors.New("media not found")
// 调用方应把它与「媒体不存在」区分开,避免把配置类故障当成 404 返回给播放器。
var ErrCloudPlaybackUnavailable = errors.New("cloud media playback unavailable: media missing play url; re-scan the library")
var ErrCloudPlaybackDisabled = errors.New("cloud media playback disabled by admin settings")
// normalizeCloudPlayTarget 把存库的云盘播放 URL 规范化为相对路径。
//
// STRMURL 是扫描时根据当时的 server_url/请求地址生成并固化进数据库的。
@@ -201,6 +217,14 @@ func requestToken(r *http.Request) string {
return v
}
}
for _, hk := range []string{"X-Emby-Authorization", "X-MediaBrowser-Authorization"} {
if token := streamTokenFromAuthHeader(r.Header.Get(hk)); token != "" {
return token
}
}
if token := streamTokenFromAuthHeader(r.Header.Get("Authorization")); token != "" {
return token
}
for _, k := range []string{"token", "api_key", "apiKey", "ApiKey"} {
if v := strings.TrimSpace(r.URL.Query().Get(k)); v != "" {
return v
@@ -209,15 +233,126 @@ func requestToken(r *http.Request) string {
return ""
}
func STRMPlaybackEnabled(ctx context.Context, repo *repository.Container) bool {
func streamTokenFromAuthHeader(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
for _, prefix := range []string{"Bearer ", "Emby "} {
if strings.HasPrefix(value, prefix) {
return strings.TrimSpace(strings.TrimPrefix(value, prefix))
}
}
if strings.HasPrefix(value, "MediaBrowser ") || strings.Contains(value, "Token=") {
for _, part := range strings.Split(value, ",") {
part = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(part), "MediaBrowser "))
if !strings.HasPrefix(part, "Token=") {
continue
}
token := strings.TrimSpace(strings.TrimPrefix(part, "Token="))
return strings.Trim(token, `"`)
}
return ""
}
return value
}
func CloudPlaybackSettings(ctx context.Context, repo *repository.Container) CloudPlaybackOptions {
opts := CloudPlaybackOptions{
STRMEnabled: false,
RedirectProxyEnabled: true,
PreferredMode: CloudPlaybackModeRedirectProxy,
}
if repo == nil || repo.Setting == nil {
return true
return opts
}
v, err := repo.Setting.Get(ctx, STRMEnabledSettingKey)
modeRaw, hasMode := settingValue(ctx, repo, CloudPlaybackModeSettingKey)
if mode := normalizeCloudPlaybackMode(modeRaw); mode != "" {
opts.PreferredMode = mode
}
legacySTRM, hasLegacySTRM := settingValue(ctx, repo, STRMEnabledSettingKey)
legacySTRMEnabled := hasLegacySTRM && parseBoolSetting(legacySTRM, false)
if !hasMode && legacySTRMEnabled {
opts.PreferredMode = CloudPlaybackModeSTRM
}
if raw, ok := settingValue(ctx, repo, CloudPlaybackSTRMEnabledSettingKey); ok {
opts.STRMEnabled = parseBoolSetting(raw, false)
} else if hasLegacySTRM {
opts.STRMEnabled = legacySTRMEnabled
} else if hasMode && opts.PreferredMode == CloudPlaybackModeSTRM {
opts.STRMEnabled = true
}
if raw, ok := settingValue(ctx, repo, CloudPlaybackRedirectEnabledSettingKey); ok {
opts.RedirectProxyEnabled = parseBoolSetting(raw, true)
} else if hasMode && opts.PreferredMode == CloudPlaybackModeRedirectProxy {
opts.RedirectProxyEnabled = true
}
if opts.PreferredMode == CloudPlaybackModeSTRM && !opts.STRMEnabled && opts.RedirectProxyEnabled {
opts.PreferredMode = CloudPlaybackModeRedirectProxy
}
if opts.PreferredMode == CloudPlaybackModeRedirectProxy && !opts.RedirectProxyEnabled && opts.STRMEnabled {
opts.PreferredMode = CloudPlaybackModeSTRM
}
return opts
}
func CloudPlaybackMode(ctx context.Context, repo *repository.Container) string {
opts := CloudPlaybackSettings(ctx, repo)
switch opts.PreferredMode {
case CloudPlaybackModeSTRM:
if opts.STRMEnabled {
return CloudPlaybackModeSTRM
}
if opts.RedirectProxyEnabled {
return CloudPlaybackModeRedirectProxy
}
case CloudPlaybackModeRedirectProxy:
if opts.RedirectProxyEnabled {
return CloudPlaybackModeRedirectProxy
}
if opts.STRMEnabled {
return CloudPlaybackModeSTRM
}
}
return ""
}
func STRMPlaybackEnabled(ctx context.Context, repo *repository.Container) bool {
return CloudPlaybackSettings(ctx, repo).STRMEnabled
}
func cloudPlaybackModeEnabled(ctx context.Context, repo *repository.Container, mode string) bool {
opts := CloudPlaybackSettings(ctx, repo)
switch normalizeCloudPlaybackMode(mode) {
case CloudPlaybackModeSTRM:
return opts.STRMEnabled
case CloudPlaybackModeRedirectProxy:
return opts.RedirectProxyEnabled
default:
return opts.STRMEnabled || opts.RedirectProxyEnabled
}
}
func settingValue(ctx context.Context, repo *repository.Container, key string) (string, bool) {
if repo == nil || repo.Setting == nil {
return "", false
}
v, err := repo.Setting.Get(ctx, key)
if err != nil || strings.TrimSpace(v) == "" {
return true
return "", false
}
return v, true
}
func normalizeCloudPlaybackMode(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "strm", "strmurl", "strm_url", "api_stream", "api-stream":
return CloudPlaybackModeSTRM
case "302", "proxy", "reverse_proxy", "redirect", "redirect_proxy", "302_proxy", "302-proxy", "cloud":
return CloudPlaybackModeRedirectProxy
default:
return ""
}
return parseBoolSetting(v, true)
}
// ServeFile streams the file backing the given media ID using
@@ -227,6 +362,10 @@ func STRMPlaybackEnabled(ctx context.Context, repo *repository.Container) bool {
// instead of opening a local file. This lets WebDAV / Alist / S3 / HTTP
// direct links flow through the rest of the player UI unchanged.
func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaID string) error {
return s.ServeFileWithCloudMode(w, r, mediaID, "")
}
func (s *StreamService) ServeFileWithCloudMode(w http.ResponseWriter, r *http.Request, mediaID, cloudMode string) error {
m, err := s.repo.Media.FindByID(r.Context(), mediaID)
if err != nil {
return err
@@ -235,6 +374,9 @@ func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaI
return ErrMediaNotFound
}
if strmURL := strings.TrimSpace(m.STRMURL); strmURL != "" && (isCloudPlaybackTarget(strmURL) || STRMPlaybackEnabled(r.Context(), s.repo)) {
if !cloudPlaybackModeEnabled(r.Context(), s.repo, cloudMode) {
return ErrCloudPlaybackDisabled
}
// 云盘播放 URL 先规范化为相对路径,免疫扫描时固化的旧 host。
target := normalizeCloudPlayTarget(strmURL)
target = withAuthTokenForInternalRedirect(target, r, PublicServerURL(r.Context(), s.repo, s.cfg))
+71 -1
View File
@@ -1,6 +1,7 @@
package service
import (
"errors"
"net/http"
"net/http/httptest"
"net/url"
@@ -117,7 +118,7 @@ func TestServeFileRedirectUsesForwardedTunnelHost(t *testing.T) {
func TestServeFileRedirectsCloudMediaForVideoStreamMode(t *testing.T) {
repos := newStreamTestRepo(t)
if err := repos.Setting.Set(t.Context(), STRMEnabledSettingKey, "false"); err != nil {
if err := repos.Setting.Set(t.Context(), CloudPlaybackModeSettingKey, CloudPlaybackModeRedirectProxy); err != nil {
t.Fatal(err)
}
if err := repos.DB.Create(&model.Media{
@@ -145,6 +146,66 @@ func TestServeFileRedirectsCloudMediaForVideoStreamMode(t *testing.T) {
}
}
func TestCloudPlaybackModeUsesExplicitModeBeforeLegacySTRMFlag(t *testing.T) {
repos := newStreamTestRepo(t)
if got := CloudPlaybackMode(t.Context(), repos); got != CloudPlaybackModeRedirectProxy {
t.Fatalf("default mode = %q, want %q", got, CloudPlaybackModeRedirectProxy)
}
if err := repos.Setting.Set(t.Context(), STRMEnabledSettingKey, "true"); err != nil {
t.Fatal(err)
}
if got := CloudPlaybackMode(t.Context(), repos); got != CloudPlaybackModeSTRM {
t.Fatalf("legacy strm.enabled=true mode = %q, want %q", got, CloudPlaybackModeSTRM)
}
if err := repos.Setting.Set(t.Context(), CloudPlaybackModeSettingKey, CloudPlaybackModeRedirectProxy); err != nil {
t.Fatal(err)
}
if got := CloudPlaybackMode(t.Context(), repos); got != CloudPlaybackModeRedirectProxy {
t.Fatalf("explicit mode should override legacy flag, got %q", got)
}
if err := repos.Setting.Set(t.Context(), CloudPlaybackModeSettingKey, CloudPlaybackModeSTRM); err != nil {
t.Fatal(err)
}
if got := CloudPlaybackMode(t.Context(), repos); got != CloudPlaybackModeSTRM {
t.Fatalf("explicit strm mode = %q, want %q", got, CloudPlaybackModeSTRM)
}
if err := repos.Setting.Set(t.Context(), CloudPlaybackSTRMEnabledSettingKey, "false"); err != nil {
t.Fatal(err)
}
if err := repos.Setting.Set(t.Context(), CloudPlaybackRedirectEnabledSettingKey, "false"); err != nil {
t.Fatal(err)
}
if got := CloudPlaybackMode(t.Context(), repos); got != "" {
t.Fatalf("both disabled mode = %q, want empty", got)
}
}
func TestServeFileRejectsCloudMediaWhenSelectedModeDisabled(t *testing.T) {
repos := newStreamTestRepo(t)
if err := repos.Setting.Set(t.Context(), CloudPlaybackSTRMEnabledSettingKey, "false"); err != nil {
t.Fatal(err)
}
if err := repos.Setting.Set(t.Context(), CloudPlaybackRedirectEnabledSettingKey, "false"); err != nil {
t.Fatal(err)
}
if err := repos.DB.Create(&model.Media{
Base: model.Base{ID: "cloud-1"},
Title: "Cloud",
Path: "cloud://openlist/Movie.mkv",
STRMURL: "/api/cloud/play/openlist?ref=movie",
}).Error; err != nil {
t.Fatal(err)
}
svc := NewStreamService(&config.Config{}, zap.NewNop(), repos, nil)
req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/cloud-1?api_key=jwt123", nil)
w := httptest.NewRecorder()
err := svc.ServeFileWithCloudMode(w, req, "cloud-1", CloudPlaybackModeSTRM)
if !errors.Is(err, ErrCloudPlaybackDisabled) {
t.Fatalf("error = %v, want ErrCloudPlaybackDisabled", err)
}
}
func newStreamTestRepo(t *testing.T) *repository.Container {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
@@ -166,6 +227,15 @@ func TestRequestTokenFromBearerHeader(t *testing.T) {
}
}
func TestRequestTokenFromMediaBrowserAuthorizationHeader(t *testing.T) {
h := http.Header{}
h.Set("X-MediaBrowser-Authorization", `MediaBrowser Client="Infuse", Device="PC", Token="mbtok"`)
r := &http.Request{Header: h, URL: &url.URL{}}
if got := requestToken(r); got != "mbtok" {
t.Fatalf("MediaBrowser token not extracted: %q", got)
}
}
func TestAppendQueryToHLSSegments(t *testing.T) {
in := "#EXTM3U\n#EXTINF:4.0,\nseg_00000.ts\n#EXTINF:4.0,\nseg_00001.ts?old=1\n"
got := appendQueryToHLSSegments(in, "token=abc")
+9 -1
View File
@@ -102,7 +102,15 @@ func (w *WatcherService) Refresh(ctx context.Context) error {
if _, _, ok := parseCloudLibraryPath(l.Path); ok {
continue
}
for _, dir := range listDirsForWatch(l.Path) {
watchRoot, info, err := resolveAccessibleMappedPath(l.Path)
if err != nil || !info.IsDir() {
w.log.Warn("watch path inaccessible",
zap.String("path", l.Path),
zap.String("library_id", l.ID),
zap.Error(err))
continue
}
for _, dir := range listDirsForWatch(watchRoot) {
current[dir] = l.ID
}
}
+63
View File
@@ -0,0 +1,63 @@
package service
import (
"os"
"path/filepath"
"testing"
"github.com/fsnotify/fsnotify"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
func TestWatcherRefreshMapsHostLibraryPathToContainerPath(t *testing.T) {
root := t.TempDir()
hostMedia := filepath.Join(root, "nas-host", "media")
containerMedia := filepath.Join(root, "container", "media")
containerLibrary := filepath.Join(containerMedia, "电视剧", "国产剧")
if err := os.MkdirAll(containerLibrary, 0o755); err != nil {
t.Fatalf("mkdir container library: %v", err)
}
t.Setenv("MEDIASTATION_MEDIA_DIR", hostMedia)
t.Setenv("MEDIASTATION_MEDIA_CONTAINER_DIR", containerMedia)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.Library{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
lib := model.Library{
Base: model.Base{ID: "lib-tv"},
Name: "国产剧",
Path: filepath.Join(hostMedia, "电视剧", "国产剧"),
Type: "tv",
Enabled: true,
}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
fw, err := fsnotify.NewWatcher()
if err != nil {
t.Fatalf("new watcher: %v", err)
}
defer fw.Close()
watcher := NewWatcherService(zap.NewNop(), repos, nil)
watcher.watcher = fw
if err := watcher.Refresh(t.Context()); err != nil {
t.Fatalf("refresh: %v", err)
}
if _, ok := watcher.watched[filepath.Clean(containerLibrary)]; !ok {
t.Fatalf("expected mapped container path watched, got %#v", watcher.watched)
}
if _, ok := watcher.watched[filepath.Clean(lib.Path)]; ok {
t.Fatalf("host path should not be watched inside container: %#v", watcher.watched)
}
}