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