From af67f4cd6ee53f30ca7c6446b0c1ff3732fa4d01 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:26:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/media.go | 115 +++++++++++-------- internal/handler/playback_extra.go | 29 ++++- internal/handler/playback_extra_test.go | 84 ++++++++++++-- internal/handler/routes_admin.go | 5 +- internal/handler/streaming.go | 15 ++- internal/handler/strm.go | 33 ++++++ internal/handler/strm_routes_test.go | 5 +- internal/handler/subtitles.go | 7 +- internal/repository/strm_repository.go | 44 +++++++ internal/service/emby_remote.go | 63 ++++++++-- internal/service/emby_remote_playurl_test.go | 51 +++++++- internal/service/emby_remote_web.go | 98 +++++++++++++++- internal/service/service_builder.go | 2 +- internal/service/stream_file.go | 8 +- internal/service/strm_queue.go | 15 +++ internal/service/strm_queue_test.go | 60 ++++++++++ web/src/api/strm.ts | 9 ++ web/src/pages/MediaDetailPageSections.tsx | 4 +- web/src/pages/PlayerPage.tsx | 34 +++++- web/src/pages/PlayerTopBar.tsx | 13 ++- web/src/pages/StrmQueuePage.tsx | 77 +++++++------ web/src/pages/playerPageModel.ts | 21 +++- web/src/pages/useLibraryData.ts | 10 +- 23 files changed, 671 insertions(+), 131 deletions(-) diff --git a/internal/handler/media.go b/internal/handler/media.go index 9a46e51..9086d51 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -7,6 +7,7 @@ import ( "net/http" "strconv" "strings" + "sync" "github.com/gin-gonic/gin" @@ -16,22 +17,22 @@ import ( ) type createLibraryReq struct { - Name string `json:"name"` - Path string `json:"path"` - Paths []string `json:"paths"` - Roots []service.LibraryRootInput `json:"roots"` - Type string `json:"type"` - CoverURL string `json:"cover_url"` - CreatePerSubfolder bool `json:"create_per_subfolder"` + Name string `json:"name"` + Path string `json:"path"` + Paths []string `json:"paths"` + Roots []service.LibraryRootInput `json:"roots"` + Type string `json:"type"` + CoverURL string `json:"cover_url"` + CreatePerSubfolder bool `json:"create_per_subfolder"` } // webLibraryPayload 是 /api/libraries 返回的库条目:本地库与远程 Emby 挂载库 // 统一结构(远程库附加 is_remote_emby / remote_source 只读标记)。 type webLibraryPayload struct { model.Library - IsRemoteEmby bool `json:"is_remote_emby,omitempty"` - RemoteSource string `json:"remote_source,omitempty"` - Total int64 `json:"total,omitempty"` + IsRemoteEmby bool `json:"is_remote_emby,omitempty"` + RemoteSource string `json:"remote_source,omitempty"` + Total int64 `json:"total,omitempty"` Cards []service.SeriesCard `json:"cards,omitempty"` } @@ -96,22 +97,42 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { // 远程 Emby 挂载库追加在本地库之后。 if svc.EmbyRemote != nil { if views, err := svc.EmbyRemote.RemoteLibraries(ctx); err == nil { - for _, v := range views { - wl := webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName} - if withPreview { - if acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID); acct != nil { + remotePayloads := make([]webLibraryPayload, len(views)) + for i, v := range views { + remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName} + } + if withPreview && len(views) > 0 { + const maxRemotePreviewWorkers = 6 + sem := make(chan struct{}, maxRemotePreviewWorkers) + var wg sync.WaitGroup + for i, v := range views { + i, v := i, v + wg.Add(1) + go func() { + defer wg.Done() + select { + case sem <- struct{}{}: + defer func() { <-sem }() + case <-ctx.Done(): + return + } + acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID) + if acct == nil { + return + } tmpMount := &model.EmbyMount{Base: model.Base{ID: v.MountID}} itemTypes := remoteLibraryItemTypes(v.CollectionType) if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil { - wl.Total = total + remotePayloads[i].Total = total } if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil { - wl.Cards = cards + remotePayloads[i].Cards = cards } - } + }() } - out = append(out, wl) + wg.Wait() } + out = append(out, remotePayloads...) } } c.JSON(http.StatusOK, out) @@ -171,38 +192,38 @@ func createLibraryHandler(svc *service.Container) gin.HandlerFunc { } } if len(roots) == 0 && strings.TrimSpace(req.Path) != "" { -roots = append(roots, service.LibraryRootInput{Path: req.Path}) - } - var l *model.Library - if req.CreatePerSubfolder { - parent := "" - if len(roots) > 0 { - parent = roots[0].Path - } else if strings.TrimSpace(req.Path) != "" { - parent = req.Path + roots = append(roots, service.LibraryRootInput{Path: req.Path}) } - created, err := svc.Media.CreateLibrariesPerSubfolder(c.Request.Context(), parent, req.Type, req.CoverURL) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - uid, _ := c.Get("ctx_user_id") - for i := range created { - lib := &created[i] - svc.Audit.Record(c.Request.Context(), toString(uid), "library.create", lib.ID, c.ClientIP(), lib.Path) - if svc.Watcher != nil { - go func() { _ = svc.Watcher.Refresh(context.Background()) }() + var l *model.Library + if req.CreatePerSubfolder { + parent := "" + if len(roots) > 0 { + parent = roots[0].Path + } else if strings.TrimSpace(req.Path) != "" { + parent = req.Path } - for _, root := range lib.Roots { - if root.Enabled { - queueLibraryRootScan(svc, lib.ID, root.ID) + created, err := svc.Media.CreateLibrariesPerSubfolder(c.Request.Context(), parent, req.Type, req.CoverURL) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + uid, _ := c.Get("ctx_user_id") + for i := range created { + lib := &created[i] + svc.Audit.Record(c.Request.Context(), toString(uid), "library.create", lib.ID, c.ClientIP(), lib.Path) + if svc.Watcher != nil { + go func() { _ = svc.Watcher.Refresh(context.Background()) }() + } + for _, root := range lib.Roots { + if root.Enabled { + queueLibraryRootScan(svc, lib.ID, root.ID) + } } } + c.JSON(http.StatusCreated, gin.H{"libraries": created}) + return } - c.JSON(http.StatusCreated, gin.H{"libraries": created}) - return - } - l, err := svc.Media.CreateLibraryWithRootsAndCover(c.Request.Context(), req.Name, req.Type, req.CoverURL, roots) + l, err := svc.Media.CreateLibraryWithRootsAndCover(c.Request.Context(), req.Name, req.Type, req.CoverURL, roots) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -490,6 +511,9 @@ func streamHandler(svc *service.Container) gin.HandlerFunc { id := c.Param("id") // 远程 Emby 条目:按挂载代理配置分流——代理走 MMTL 反代,否则 302 直连。 if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) { + if !enforceScopedPlaybackToken(c, id) { + return + } mountID, remoteID, _ := service.DecodeEmbyRemoteID(id) mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID) if mount == nil || acct == nil { @@ -509,6 +533,7 @@ func streamHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } + setRedirectNoStoreHeaders(c) c.Redirect(http.StatusFound, target) return } diff --git a/internal/handler/playback_extra.go b/internal/handler/playback_extra.go index 3b012c0..a646634 100644 --- a/internal/handler/playback_extra.go +++ b/internal/handler/playback_extra.go @@ -19,21 +19,40 @@ import ( "github.com/ShukeBta/MMTL/internal/service" ) +func findMediaForPlaybackEndpoint(c *gin.Context, svc *service.Container, id string) (*model.Media, error) { + ctx := c.Request.Context() + if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) { + mountID, remoteID, _ := service.DecodeEmbyRemoteID(id) + mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID) + if mount == nil || acct == nil { + return nil, nil + } + return svc.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, remoteID) + } + return svc.Repo.Media.FindByID(ctx, id) +} + // playbackInfoHandler returns the media row + a `stream_url` the React // player can hit. Mirrors the Python project's surface. func playbackInfoHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id")) + id := c.Param("id") + m, err := findMediaForPlaybackEndpoint(c, svc, id) if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) { c.JSON(http.StatusNotFound, gin.H{"error": "media not found"}) return } token := externalPlaybackToken(c, svc, m.ID, m.DurationSec) profileQuery := externalProfileQuery(c) + hlsURL := "/api/hls/" + m.ID + "/index.m3u8?token=" + url.QueryEscape(token) + profileQuery + if service.IsEmbyRemoteID(m.ID) || service.IsStrmMediaRow(m) { + // Emby 远程挂载与 STRM 媒体一样,默认直连播放,不提供转码地址 + hlsURL = "" + } c.JSON(http.StatusOK, gin.H{ "media": m, "stream_url": "/api/stream/" + m.ID + "?token=" + url.QueryEscape(token) + profileQuery, - "hls_url": "/api/hls/" + m.ID + "/index.m3u8?token=" + url.QueryEscape(token) + profileQuery, + "hls_url": hlsURL, }) } } @@ -68,7 +87,8 @@ func playbackProgressHandler(svc *service.Container) gin.HandlerFunc { // produce the per-player launch URL. func externalPlayersHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id")) + id := c.Param("id") + m, err := findMediaForPlaybackEndpoint(c, svc, id) if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) { c.JSON(http.StatusNotFound, gin.H{"error": "media not found"}) return @@ -93,7 +113,8 @@ func externalPlayersHandler(svc *service.Container) gin.HandlerFunc { // token query string the external player needs. func externalURLHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id")) + id := c.Param("id") + m, err := findMediaForPlaybackEndpoint(c, svc, id) if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) { c.JSON(http.StatusNotFound, gin.H{"error": "media not found"}) return diff --git a/internal/handler/playback_extra_test.go b/internal/handler/playback_extra_test.go index c135d64..235100b 100644 --- a/internal/handler/playback_extra_test.go +++ b/internal/handler/playback_extra_test.go @@ -401,11 +401,81 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container, t.Fatal(err) } - router := gin.New() - api := router.Group("/api") - api.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret)) - api.GET("/playback/:id/external-url", externalURLHandler(svc)) - api.GET("/playback/:id/external-players", externalPlayersHandler(svc)) - api.GET("/stream/:id", streamHandler(svc)) - return router, svc, cfg.Secrets.JWTSecret + router := gin.New() + api := router.Group("/api") + api.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret)) + api.GET("/playback/:id/info", playbackInfoHandler(svc)) + api.GET("/playback/:id/external-url", externalURLHandler(svc)) + api.GET("/playback/:id/external-players", externalPlayersHandler(svc)) + api.GET("/stream/:id", streamHandler(svc)) + api.GET("/hls/:id/index.m3u8", hlsPlaylistHandler(svc)) + api.GET("/media/:id/subtitles", listSubtitlesHandler(svc)) + return router, svc, cfg.Secrets.JWTSecret + } + +func TestPlaybackInfoForSTRMMediaDisablesHLS(t *testing.T) { + router, _, secret := newPlaybackScopeTestRouter(t) + loginToken := signedTestToken(t, secret) + + req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/playback/media-1/info", nil) + req.Header.Set("Authorization", "Bearer "+loginToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) + } + var payload struct { + StreamURL string `json:"stream_url"` + HlsURL string `json:"hls_url"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode: %v", err) + } + if payload.StreamURL == "" { + t.Fatalf("expected non-empty stream_url") + } + if payload.HlsURL != "" { + t.Fatalf("expected empty hls_url for STRM media, got %q", payload.HlsURL) + } } + +func TestHLSPlaylistForRemoteEmbyMediaDisabled(t *testing.T) { + router, svc, secret := newPlaybackScopeTestRouter(t) + svc.EmbyRemote = &service.EmbyRemoteService{} + loginToken := signedTestToken(t, secret) + + req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/hls/embyremote~acct1~item1/index.m3u8", nil) + req.Header.Set("Authorization", "Bearer "+loginToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusConflict { + t.Fatalf("status = %d, want %d (409 StatusConflict)", w.Code, http.StatusConflict) + } +} + +func TestListSubtitlesForRemoteEmbyMediaReturnsEmptyTracks(t *testing.T) { + router, svc, secret := newPlaybackScopeTestRouter(t) + svc.EmbyRemote = &service.EmbyRemoteService{} + loginToken := signedTestToken(t, secret) + + req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/media/embyremote~acct1~item1/subtitles", nil) + req.Header.Set("Authorization", "Bearer "+loginToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 OK", w.Code) + } + var payload struct { + Tracks []any `json:"tracks"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode: %v", err) + } + if payload.Tracks == nil || len(payload.Tracks) != 0 { + t.Fatalf("expected empty tracks array, got %v", payload.Tracks) + } +} + diff --git a/internal/handler/routes_admin.go b/internal/handler/routes_admin.go index de2a76b..1e7e4d3 100644 --- a/internal/handler/routes_admin.go +++ b/internal/handler/routes_admin.go @@ -93,8 +93,11 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) { admin.POST("/strm/uploads/:id/retry", retryStrmUploadHandler(svc)) admin.DELETE("/strm/uploads/:id", deleteStrmUploadHandler(svc)) admin.POST("/strm/uploads/batch", batchActionUploadsHandler(svc)) - admin.POST("/strm/uploads/cancel-pending", cancelPendingUploadsHandler(svc)) + admin.POST("/strm/uploads/clear-done", clearDoneUploadsHandler(svc)) + admin.POST("/strm/uploads/clear-finished", clearFinishedUploadsHandler(svc)) admin.POST("/strm/uploads/clear-canceled", clearCanceledUploadsHandler(svc)) + admin.POST("/strm/uploads/retry-failed", retryAllFailedUploadsHandler(svc)) + admin.POST("/strm/uploads/cancel-pending", cancelPendingUploadsHandler(svc)) } func registerAdminUserRoutes(admin *gin.RouterGroup, svc *service.Container) { diff --git a/internal/handler/streaming.go b/internal/handler/streaming.go index 23ba58c..1b7cfc0 100644 --- a/internal/handler/streaming.go +++ b/internal/handler/streaming.go @@ -13,7 +13,13 @@ import ( func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id")) + id := c.Param("id") + // 远程 Emby 挂载媒体与 STRM 一样,默认直连播放,不进行转码。 + if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) { + c.JSON(http.StatusConflict, gin.H{"error": "transcode disabled"}) + return + } + m, err := svc.Media.GetMedia(c.Request.Context(), id) if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return @@ -43,7 +49,12 @@ func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc { func hlsSegmentHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id")) + id := c.Param("id") + if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) { + c.JSON(http.StatusConflict, gin.H{"error": "transcode disabled"}) + return + } + m, err := svc.Media.GetMedia(c.Request.Context(), id) if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return diff --git a/internal/handler/strm.go b/internal/handler/strm.go index ead666f..01ac802 100644 --- a/internal/handler/strm.go +++ b/internal/handler/strm.go @@ -504,6 +504,28 @@ func clearCanceledUploadsHandler(svc *service.Container) gin.HandlerFunc { } } +func clearDoneUploadsHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + n, err := svc.Strm.ClearDoneUploadTasks(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"deleted": n}) + } +} + +func clearFinishedUploadsHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + n, err := svc.Strm.ClearFinishedUploadTasks(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"deleted": n}) + } +} + func retryAllFailedDownloadsHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { n, err := svc.Strm.RetryAllFailedDownloadTasks(c.Request.Context()) @@ -515,6 +537,17 @@ func retryAllFailedDownloadsHandler(svc *service.Container) gin.HandlerFunc { } } +func retryAllFailedUploadsHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + n, err := svc.Strm.RetryAllFailedUploadTasks(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"retried": n}) + } +} + func cancelPendingDownloadsHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { n, err := svc.Strm.CancelPendingDownloadTasks(c.Request.Context()) diff --git a/internal/handler/strm_routes_test.go b/internal/handler/strm_routes_test.go index ff7eed0..2775e85 100644 --- a/internal/handler/strm_routes_test.go +++ b/internal/handler/strm_routes_test.go @@ -57,8 +57,11 @@ func TestStrmAdminRoutesAreRegistered(t *testing.T) { "GET /api/admin/strm/uploads", "POST /api/admin/strm/uploads/:id/cancel", "POST /api/admin/strm/uploads/:id/retry", - "POST /api/admin/strm/uploads/cancel-pending", + "POST /api/admin/strm/uploads/clear-done", + "POST /api/admin/strm/uploads/clear-finished", "POST /api/admin/strm/uploads/clear-canceled", + "POST /api/admin/strm/uploads/retry-failed", + "POST /api/admin/strm/uploads/cancel-pending", "GET /api/strm/play/:provider/:file", } { if !routes[want] { diff --git a/internal/handler/subtitles.go b/internal/handler/subtitles.go index fff5b33..bae6d11 100644 --- a/internal/handler/subtitles.go +++ b/internal/handler/subtitles.go @@ -11,7 +11,12 @@ import ( func listSubtitlesHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - tracks, err := svc.Subtitle.Discover(c.Request.Context(), c.Param("id")) + id := c.Param("id") + if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) { + c.JSON(http.StatusOK, gin.H{"tracks": []service.SubtitleTrack{}}) + return + } + tracks, err := svc.Subtitle.Discover(c.Request.Context(), id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return diff --git a/internal/repository/strm_repository.go b/internal/repository/strm_repository.go index bdfacb2..29e5852 100644 --- a/internal/repository/strm_repository.go +++ b/internal/repository/strm_repository.go @@ -675,6 +675,29 @@ func (r *StrmUploadTaskRepository) CancelBatch(ctx context.Context, ids []string return count, err } +// ClearDone 清空全部已完成上传任务。 +func (r *StrmUploadTaskRepository) ClearDone(ctx context.Context) (int64, error) { + var count int64 + err := withSQLiteBusyRetry(ctx, func() error { + res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.StrmTaskDone).Delete(&model.StrmUploadTask{}) + count = res.RowsAffected + return res.Error + }) + return count, err +} + +// ClearFinished 清空全部已完成与失败上传任务(包括已完成、失败及取消)。 +func (r *StrmUploadTaskRepository) ClearFinished(ctx context.Context) (int64, error) { + var count int64 + err := withSQLiteBusyRetry(ctx, func() error { + res := r.db.WithContext(ctx).Unscoped().Where("status IN ?", []string{model.StrmTaskDone, model.StrmTaskFailed, model.StrmTaskCanceled}). + Delete(&model.StrmUploadTask{}) + count = res.RowsAffected + return res.Error + }) + return count, err +} + // ClearCanceled 清空全部已取消上传任务。 func (r *StrmUploadTaskRepository) ClearCanceled(ctx context.Context) (int64, error) { var count int64 @@ -686,6 +709,27 @@ func (r *StrmUploadTaskRepository) ClearCanceled(ctx context.Context) (int64, er return count, err } +// RetryAllFailed 把所有失败任务重置回待处理,清空错误与重试计数。 +func (r *StrmUploadTaskRepository) RetryAllFailed(ctx context.Context) (int64, error) { + var count int64 + err := withSQLiteBusyRetry(ctx, func() error { + res := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}). + Where("status = ?", model.StrmTaskFailed). + Updates(map[string]any{ + "status": model.StrmTaskPending, + "error": "", + "retry_count": 0, + "next_try_at": nil, + "started_at": nil, + "finished_at": nil, + "updated_at": time.Now(), + }) + count = res.RowsAffected + return res.Error + }) + return count, err +} + // CancelPending 批量取消所有排队中和进行中的任务。 func (r *StrmUploadTaskRepository) CancelPending(ctx context.Context) (int64, error) { now := time.Now() diff --git a/internal/service/emby_remote.go b/internal/service/emby_remote.go index 31424fa..3e3afe6 100644 --- a/internal/service/emby_remote.go +++ b/internal/service/emby_remote.go @@ -15,6 +15,8 @@ package service import ( "context" + "crypto/sha1" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -69,6 +71,7 @@ type EmbyRemoteService struct { repo *repository.Container crypto *CryptoService http *http.Client + cache *RuntimeCacheService } // NewEmbyRemoteService 构造远程 Emby 聚合服务。 @@ -85,6 +88,32 @@ func NewEmbyRemoteService(cfg *config.Config, log *zap.Logger, repo *repository. } } +func (r *EmbyRemoteService) SetRuntimeCache(cache *RuntimeCacheService) *EmbyRemoteService { + if r != nil { + r.cache = cache + } + return r +} + +func (r *EmbyRemoteService) remoteMediaCacheTTL() time.Duration { + seconds := 15 + if r != nil && r.cfg != nil && r.cfg.Cache.MediaTTLSeconds > 0 { + seconds = r.cfg.Cache.MediaTTLSeconds + } + return time.Duration(seconds) * time.Second +} + +func (r *EmbyRemoteService) remoteCacheKey(parts ...string) string { + sum := sha1.Sum([]byte(strings.Join(parts, "|"))) + return "media:embyremote:" + hex.EncodeToString(sum[:]) +} + +func (r *EmbyRemoteService) invalidateRemoteMediaCache(ctx context.Context) { + if r != nil && r.cache != nil { + r.cache.DeletePrefix(ctx, "media:embyremote:") + } +} + // ListAccounts 返回全部启用的远程 Emby 挂载账号。 func (r *EmbyRemoteService) ListAccounts(ctx context.Context) ([]model.StrmAccount, error) { accounts, err := r.repo.StrmAccount.List(ctx) @@ -143,6 +172,7 @@ func (r *EmbyRemoteService) CreateMount(ctx context.Context, m *model.EmbyMount) if err := r.repo.EmbyMount.Create(ctx, m); err != nil { return nil, err } + r.invalidateRemoteMediaCache(ctx) return m, nil } @@ -172,6 +202,7 @@ func (r *EmbyRemoteService) CreateMounts(ctx context.Context, mounts []*model.Em if err := r.repo.EmbyMount.CreateInBatches(ctx, fresh, 50); err != nil { return 0, err } + r.invalidateRemoteMediaCache(ctx) return len(fresh), nil } @@ -187,12 +218,17 @@ func (r *EmbyRemoteService) UpdateMount(ctx context.Context, id string, m *model if err := r.repo.EmbyMount.Update(ctx, existing); err != nil { return nil, err } + r.invalidateRemoteMediaCache(ctx) return existing, nil } // DeleteMount 删除挂载。 func (r *EmbyRemoteService) DeleteMount(ctx context.Context, id string) error { - return r.repo.EmbyMount.Delete(ctx, id) + err := r.repo.EmbyMount.Delete(ctx, id) + if err == nil { + r.invalidateRemoteMediaCache(ctx) + } + return err } // FullMountAccount 把账号的全部远程媒体库(View)挂载进来(幂等,已存在跳过)。 @@ -407,12 +443,12 @@ func (r *EmbyRemoteService) doGet(ctx context.Context, acct *model.StrmAccount, // token 失效:清空后重认证重试一次。 cfg.Token = "" if acct != nil { -raw := map[string]string{} - _ = json.Unmarshal([]byte(acct.Config), &raw) - delete(raw, "api_key") - enc, _ := json.Marshal(raw) - acct.Config = string(enc) - _ = r.repo.StrmAccount.Update(ctx, acct) + raw := map[string]string{} + _ = json.Unmarshal([]byte(acct.Config), &raw) + delete(raw, "api_key") + enc, _ := json.Marshal(raw) + acct.Config = string(enc) + _ = r.repo.StrmAccount.Update(ctx, acct) } continue } @@ -457,6 +493,11 @@ func (r *EmbyRemoteService) RemoteViews(ctx context.Context, acct *model.StrmAcc if err != nil { return nil, err } + cacheKey := r.remoteCacheKey("views", acct.ID, r.remoteUserID(cfg)) + var cached []map[string]any + if r.cache != nil && r.cache.GetJSON(ctx, cacheKey, &cached) { + return cached, nil + } q := url.Values{"api_key": {cfg.Token}} var body struct { Items []map[string]any `json:"Items"` @@ -464,6 +505,12 @@ func (r *EmbyRemoteService) RemoteViews(ctx context.Context, acct *model.StrmAcc if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Views", q, &body); err != nil { return nil, err } + if body.Items == nil { + body.Items = []map[string]any{} + } + if r.cache != nil { + r.cache.SetJSON(ctx, cacheKey, body.Items, r.remoteMediaCacheTTL()) + } return body.Items, nil } @@ -859,4 +906,4 @@ func (r *EmbyRemoteService) doMutate(ctx context.Context, acct *model.StrmAccoun return fmt.Errorf("远程 Emby 状态同步失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data))) } return nil -} \ No newline at end of file +} diff --git a/internal/service/emby_remote_playurl_test.go b/internal/service/emby_remote_playurl_test.go index 5b377e1..0c5e453 100644 --- a/internal/service/emby_remote_playurl_test.go +++ b/internal/service/emby_remote_playurl_test.go @@ -2,6 +2,8 @@ package service import ( "testing" + + "github.com/ShukeBta/MMTL/internal/model" ) // rewriteSubtitleDeliveryURLs 只应改动字幕轨道的 DeliveryUrl,其余媒体流不动。 @@ -47,8 +49,51 @@ func TestRewriteSubtitleDeliveryURLsFallsBackIndexOne(t *testing.T) { } rewriteSubtitleDeliveryURLs(src, "/Videos/embyremote~acct-1~item-1", &EmbyRemoteConfig{}) streams := src["MediaStreams"].([]any) - want := "/Videos/embyremote~acct-1~item-1/Subtitles/1/Stream" - if got := streams[0].(map[string]any)["DeliveryUrl"]; got != want { - t.Fatalf("subtitle DeliveryUrl = %v, want %v", got, want) + want := "/Videos/embyremote~acct-1~item-1/Subtitles/1/Stream" + if got := streams[0].(map[string]any)["DeliveryUrl"]; got != want { + t.Fatalf("subtitle DeliveryUrl = %v, want %v", got, want) + } + } + +func TestMapRemoteItemToMediaExtractsCodecsAndContainer(t *testing.T) { + r := &EmbyRemoteService{} + item := map[string]any{ + "Id": "item-100", + "Name": "Test Movie", + "Container": "mkv", + "MediaStreams": []any{ + map[string]any{ + "Type": "Video", + "Codec": "h264", + "Width": 1920, + "Height": 1080, + }, + map[string]any{ + "Type": "Audio", + "Codec": "aac", + }, + }, + "MediaSources": []any{ + map[string]any{ + "Container": "mkv", + "Size": int64(104857600), + }, + }, + } + media := r.MapRemoteItemToMedia(t.Context(), nil, &model.StrmAccount{Base: model.Base{ID: "acct-1"}}, &EmbyRemoteConfig{}, item) + if media.Container != "mkv" { + t.Fatalf("media.Container = %v, want mkv", media.Container) + } + if media.VideoCodec != "h264" { + t.Fatalf("media.VideoCodec = %v, want h264", media.VideoCodec) + } + if media.AudioCodec != "aac" { + t.Fatalf("media.AudioCodec = %v, want aac", media.AudioCodec) + } + if media.Width != 1920 || media.Height != 1080 { + t.Fatalf("resolution = %dx%d, want 1920x1080", media.Width, media.Height) + } + if media.SizeBytes != 104857600 { + t.Fatalf("size = %d, want 104857600", media.SizeBytes) } } \ No newline at end of file diff --git a/internal/service/emby_remote_web.go b/internal/service/emby_remote_web.go index 301f96d..299e0c6 100644 --- a/internal/service/emby_remote_web.go +++ b/internal/service/emby_remote_web.go @@ -191,6 +191,59 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod media.DoubanID = v } } + media.Container = remoteItemString(item, "Container") + media.Width = remoteItemInt(item, "Width") + media.Height = remoteItemInt(item, "Height") + + extractStreamInfo := func(streams []any) { + for _, s := range streams { + sm, ok := s.(map[string]any) + if !ok { + continue + } + typ := remoteItemString(sm, "Type") + if strings.EqualFold(typ, "Video") { + if media.VideoCodec == "" { + media.VideoCodec = remoteItemString(sm, "Codec") + } + if media.Width == 0 { + media.Width = remoteItemInt(sm, "Width") + } + if media.Height == 0 { + media.Height = remoteItemInt(sm, "Height") + } + } else if strings.EqualFold(typ, "Audio") { + if media.AudioCodec == "" { + media.AudioCodec = remoteItemString(sm, "Codec") + } + } + } + } + + if streams, ok := item["MediaStreams"].([]any); ok { + extractStreamInfo(streams) + } else if streams, ok := item["MediaStreams"].([]map[string]any); ok { + anyStreams := make([]any, len(streams)) + for i, v := range streams { + anyStreams[i] = v + } + extractStreamInfo(anyStreams) + } + + if sources, ok := item["MediaSources"].([]any); ok && len(sources) > 0 { + if sourceMap, ok := sources[0].(map[string]any); ok { + if media.Container == "" { + media.Container = remoteItemString(sourceMap, "Container") + } + if media.SizeBytes == 0 { + media.SizeBytes = remoteItemInt64(sourceMap, "Size") + } + if streams, ok := sourceMap["MediaStreams"].([]any); ok && (media.VideoCodec == "" || media.AudioCodec == "") { + extractStreamInfo(streams) + } + } + } + switch remoteItemString(item, "Type") { case "Episode": media.SeasonNum = remoteItemInt(item, "ParentIndexNumber") @@ -221,13 +274,21 @@ func (r *EmbyRemoteService) RemoteLibraryMedia(ctx context.Context, mount *model if itemTypes == "" { itemTypes = "Movie,Series" // 未知类型时两者都取(前端自行按 episode-like 分组) } + cacheKey := r.remoteCacheKey("library-media", acct.ID, mount.ID, remoteViewID, itemTypes, strconv.Itoa(offset), strconv.Itoa(limit)) + var cached struct { + Items []model.Media `json:"items"` + TotalRecordCount int64 `json:"total_record_count"` + } + if r.cache != nil && r.cache.GetJSON(ctx, cacheKey, &cached) { + return cached.Items, cached.TotalRecordCount, nil + } q := url.Values{} q.Set("ParentId", remoteViewID) q.Set("IncludeItemTypes", itemTypes) q.Set("Recursive", "false") q.Set("StartIndex", strconv.Itoa(offset)) q.Set("Limit", strconv.Itoa(limit)) - q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage") + q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage,MediaStreams,MediaSources") var body struct { Items []map[string]any `json:"Items"` TotalRecordCount int64 `json:"TotalRecordCount"` @@ -240,6 +301,12 @@ func (r *EmbyRemoteService) RemoteLibraryMedia(ctx context.Context, mount *model RewriteEmbyRemoteIDs(it, mount.ID) // 嵌套/关联 ID 一并伪装 items = append(items, r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)) } + if r.cache != nil { + r.cache.SetJSON(ctx, cacheKey, struct { + Items []model.Media `json:"items"` + TotalRecordCount int64 `json:"total_record_count"` + }{Items: items, TotalRecordCount: body.TotalRecordCount}, r.remoteMediaCacheTTL()) + } return items, body.TotalRecordCount, nil } @@ -250,7 +317,7 @@ func (r *EmbyRemoteService) RemoteMediaDetail(ctx context.Context, mount *model. return nil, err } path := "/Users/" + url.PathEscape(r.remoteUserID(cfg)) + "/Items/" + url.PathEscape(remoteID) - path += "?Fields=Overview,Genres,ProviderIds,People,Studios,Path" + path += "?Fields=Overview,Genres,ProviderIds,People,Studios,Path,MediaStreams,MediaSources" var out map[string]any if err := r.doGet(ctx, acct, cfg, path, nil, &out); err != nil { return nil, err @@ -311,7 +378,7 @@ func (r *EmbyRemoteService) remoteEpisodesOf(ctx context.Context, mount *model.E q.Set("Recursive", "true") q.Set("StartIndex", "0") q.Set("Limit", "500") - q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage") + q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage,MediaStreams,MediaSources") var body struct { Items []map[string]any `json:"Items"` TotalRecordCount int64 `json:"TotalRecordCount"` @@ -334,6 +401,11 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model. if err != nil { return nil, err } + cacheKey := r.remoteCacheKey("series-cards", acct.ID, mount.ID, remoteViewID) + var cached []SeriesCard + if r.cache != nil && r.cache.GetJSON(ctx, cacheKey, &cached) { + return cached, nil + } q := url.Values{} q.Set("ParentId", remoteViewID) q.Set("IncludeItemTypes", "Series") @@ -361,6 +433,9 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model. } cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count}) } + if r.cache != nil { + r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL()) + } return cards, nil } @@ -370,6 +445,11 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model. if err != nil { return nil, err } + cacheKey := r.remoteCacheKey("latest-cards", acct.ID, mount.ID, remoteViewID, strconv.Itoa(limit)) + var cached []SeriesCard + if r.cache != nil && r.cache.GetJSON(ctx, cacheKey, &cached) { + return cached, nil + } items, err := r.RemoteLatest(ctx, mount, acct, remoteViewID, limit) if err != nil { return nil, err @@ -379,6 +459,9 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model. m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it) cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: 0}) } + if r.cache != nil { + r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL()) + } return cards, nil } @@ -449,6 +532,8 @@ func remoteItemInt(item map[string]any, key string) int { return int(v) case int: return v + case int64: + return int(v) case string: n, _ := strconv.Atoi(v) return n @@ -463,6 +548,8 @@ func remoteItemInt64(item map[string]any, key string) int64 { switch v := item[key].(type) { case float64: return int64(v) + case int64: + return v case int: return int64(v) case string: @@ -572,6 +659,7 @@ func remoteItemTypeOf(m *model.Media) string { } return "Movie" } + // ─── 供 handler 层使用的远程 View 条目取值(导出薄封装) ────────────────────── // RemoteItemIDString 提取远程 View 条目的 Id。 @@ -581,7 +669,9 @@ func RemoteItemIDString(item map[string]any) string { return remoteItemString(it func RemoteItemNameString(item map[string]any) string { return remoteItemString(item, "Name") } // RemoteItemCollectionType 提取远程 View 条目的 CollectionType。 -func RemoteItemCollectionType(item map[string]any) string { return remoteItemString(item, "CollectionType") } +func RemoteItemCollectionType(item map[string]any) string { + return remoteItemString(item, "CollectionType") +} // RemoteItemChildCount 提取远程 View 条目的 ChildCount。 func RemoteItemChildCount(item map[string]any) int { return remoteItemInt(item, "ChildCount") } diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index c5be37e..04d0587 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -103,7 +103,7 @@ func (b *serviceContainerBuilder) initContentServices() { b.c.DLNA = NewDLNAService(b.log) b.c.Storage = NewStorageService(b.log, b.repos) b.c.Emby = NewEmbyService(b.cfg, b.log, b.repos) - b.c.EmbyRemote = NewEmbyRemoteService(b.cfg, b.log, b.repos, b.c.Crypto) + b.c.EmbyRemote = NewEmbyRemoteService(b.cfg, b.log, b.repos, b.c.Crypto).SetRuntimeCache(b.c.Cache) b.c.Emby.SetEmbyRemote(b.c.EmbyRemote) b.c.Backup = NewBackupService(b.cfg, b.log, b.repos.DB) b.c.Media = NewMediaService(b.cfg, b.log, b.repos).SetRuntimeCache(b.c.Cache) diff --git a/internal/service/stream_file.go b/internal/service/stream_file.go index 1529646..2576fdc 100644 --- a/internal/service/stream_file.go +++ b/internal/service/stream_file.go @@ -86,9 +86,9 @@ func playableSTRMTarget(ctx context.Context, repo *repository.Container, raw str return STRMPlaybackEnabled(ctx, repo) } -// isStrmMediaRow 判断媒体行是否为 .strm(远程直链)媒体:STRMURL 非空、 +// IsStrmMediaRow 判断媒体行是否为 .strm(远程直链)媒体:STRMURL 非空、 // container=strm 或路径以 .strm 结尾。strm 媒体只能直连播放,禁止转码。 -func isStrmMediaRow(m *model.Media) bool { +func IsStrmMediaRow(m *model.Media) bool { if m == nil { return false } @@ -101,6 +101,10 @@ func isStrmMediaRow(m *model.Media) bool { return strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm") } +func isStrmMediaRow(m *model.Media) bool { + return IsStrmMediaRow(m) +} + func isHTTPPlaybackTarget(raw string) bool { u, err := url.Parse(strings.TrimSpace(raw)) if err != nil || u == nil || !u.IsAbs() { diff --git a/internal/service/strm_queue.go b/internal/service/strm_queue.go index 10148b5..6288af5 100644 --- a/internal/service/strm_queue.go +++ b/internal/service/strm_queue.go @@ -609,11 +609,26 @@ func (s *StrmService) ClearCanceledUploadTasks(ctx context.Context) (int64, erro return s.repo.StrmUpload.ClearCanceled(ctx) } +// ClearDoneUploadTasks 清空全部已完成上传记录,返回删除数量。 +func (s *StrmService) ClearDoneUploadTasks(ctx context.Context) (int64, error) { + return s.repo.StrmUpload.ClearDone(ctx) +} + +// ClearFinishedUploadTasks 清空全部已完成与失败的上传记录,返回删除数量。 +func (s *StrmService) ClearFinishedUploadTasks(ctx context.Context) (int64, error) { + return s.repo.StrmUpload.ClearFinished(ctx) +} + // RetryAllFailedDownloadTasks 批量重试所有失败下载任务,返回重新入队数量。 func (s *StrmService) RetryAllFailedDownloadTasks(ctx context.Context) (int64, error) { return s.repo.StrmDownload.RetryAllFailed(ctx) } +// RetryAllFailedUploadTasks 批量重试所有失败上传任务,返回重新入队数量。 +func (s *StrmService) RetryAllFailedUploadTasks(ctx context.Context) (int64, error) { + return s.repo.StrmUpload.RetryAllFailed(ctx) +} + // CancelPendingDownloadTasks 批量取消所有排队下载任务,返回取消数量。 func (s *StrmService) CancelPendingDownloadTasks(ctx context.Context) (int64, error) { return s.repo.StrmDownload.CancelPending(ctx) diff --git a/internal/service/strm_queue_test.go b/internal/service/strm_queue_test.go index 498d57a..d142b72 100644 --- a/internal/service/strm_queue_test.go +++ b/internal/service/strm_queue_test.go @@ -1,8 +1,13 @@ package service import ( + "context" "errors" "testing" + + "github.com/ShukeBta/MMTL/internal/model" + "github.com/ShukeBta/MMTL/internal/repository" + "go.uber.org/zap" ) func TestIs115Blocked(t *testing.T) { @@ -44,3 +49,58 @@ func TestIsHTTPDownloadFailure(t *testing.T) { } } } + +func TestStrmUploadTasksClearAndRetry(t *testing.T) { + db := newServiceTestDB(t, &model.StrmUploadTask{}) + repos := repository.New(db) + svc := NewStrmService(nil, zap.NewNop(), repos, nil) + ctx := context.Background() + + tasks := []*model.StrmUploadTask{ + {Base: model.Base{ID: "task-pending"}, Status: model.StrmTaskPending, FileName: "1.nfo"}, + {Base: model.Base{ID: "task-running"}, Status: model.StrmTaskRunning, FileName: "2.nfo"}, + {Base: model.Base{ID: "task-done"}, Status: model.StrmTaskDone, FileName: "3.nfo"}, + {Base: model.Base{ID: "task-failed"}, Status: model.StrmTaskFailed, FileName: "4.nfo", Error: "some error", RetryCount: 3}, + {Base: model.Base{ID: "task-canceled"}, Status: model.StrmTaskCanceled, FileName: "5.nfo"}, + } + for _, task := range tasks { + if err := db.Create(task).Error; err != nil { + t.Fatalf("failed to insert task: %v", err) + } + } + + // 1. RetryAllFailedUploadTasks + retried, err := svc.RetryAllFailedUploadTasks(ctx) + if err != nil { + t.Fatalf("RetryAllFailedUploadTasks failed: %v", err) + } + if retried != 1 { + t.Fatalf("expected 1 retried task, got %d", retried) + } + var failedTask model.StrmUploadTask + if err := db.First(&failedTask, "id = ?", "task-failed").Error; err != nil { + t.Fatalf("failed to get task-failed: %v", err) + } + if failedTask.Status != model.StrmTaskPending || failedTask.Error != "" || failedTask.RetryCount != 0 { + t.Fatalf("task-failed was not reset properly: %+v", failedTask) + } + + // 再次改为 failed 以便测试 ClearFinished + db.Model(&model.StrmUploadTask{}).Where("id = ?", "task-failed").Updates(map[string]any{"status": model.StrmTaskFailed}) + + // 2. ClearFinishedUploadTasks 应删除 done, failed, canceled 三条历史记录 + deleted, err := svc.ClearFinishedUploadTasks(ctx) + if err != nil { + t.Fatalf("ClearFinishedUploadTasks failed: %v", err) + } + if deleted != 3 { + t.Fatalf("expected 3 deleted tasks (done, failed, canceled), got %d", deleted) + } + + // 验证剩余的任务只有 pending 和 running + var count int64 + db.Model(&model.StrmUploadTask{}).Count(&count) + if count != 2 { + t.Fatalf("expected 2 remaining tasks, got %d", count) + } +} diff --git a/web/src/api/strm.ts b/web/src/api/strm.ts index e29bda9..46680bb 100644 --- a/web/src/api/strm.ts +++ b/web/src/api/strm.ts @@ -187,6 +187,15 @@ export const strmAPI = { cancelPendingUploads: () => api.post<{ canceled: number }>('/admin/strm/uploads/cancel-pending').then((r) => r.data), + clearDoneUploads: () => + api.post<{ deleted: number }>('/admin/strm/uploads/clear-done').then((r) => r.data), + + clearFinishedUploads: () => + api.post<{ deleted: number }>('/admin/strm/uploads/clear-finished').then((r) => r.data), + clearCanceledUploads: () => api.post<{ deleted: number }>('/admin/strm/uploads/clear-canceled').then((r) => r.data), + + retryFailedUploads: () => + api.post<{ retried: number }>('/admin/strm/uploads/retry-failed').then((r) => r.data), } \ No newline at end of file diff --git a/web/src/pages/MediaDetailPageSections.tsx b/web/src/pages/MediaDetailPageSections.tsx index 5aca477..d7bdb6a 100644 --- a/web/src/pages/MediaDetailPageSections.tsx +++ b/web/src/pages/MediaDetailPageSections.tsx @@ -1,4 +1,3 @@ -import { isRemoteEmbyID } from '../utils/remoteEmby' import { ArrowLeft, Heart, Play, RefreshCw } from 'lucide-react' import { Link } from 'react-router-dom' @@ -7,6 +6,7 @@ import { ManualScrapeDialog } from '../components/ManualScrapeDialog' import { MetadataEditDialog } from '../components/MetadataEditDialog' import { OrganizeMediaDialog } from '../components/OrganizeMediaDialog' import type { Media } from '../types' +import { isDirectStreamMedia } from './playerPageModel' import { MediaDetailAdminPanel } from './MediaDetailAdminPanel' import { MediaDetailPoster } from './MediaDetailArtwork' import { MediaDetailMetadata } from './MediaDetailMetadata' @@ -96,7 +96,7 @@ export function MediaDetailPlaybackActions({ 立即播放 - {!isRemoteEmbyID(media.id) && ( + {!isDirectStreamMedia(media) && ( { setMedia(m) + const isDirect = isDirectStreamMedia(m) const forced = params.get('mode') as PlayerMode | null const auto = pickPlayerMode(m) - // 直连解码模式下忽略 ?mode=hls 与自动判定,始终 direct play。 - setMode(directOnly ? 'direct' : (forced ?? auto)) + // 直连解码模式以及 STRM / Emby 挂载等直连媒体,忽略 ?mode=hls,始终 direct play。 + setMode(directOnly || isDirect ? 'direct' : (forced ?? auto)) setPlayerError('') }) subtitlesAPI @@ -429,12 +431,18 @@ export function PlayerPage() { return () => window.removeEventListener('keydown', onKey) }, [goBack, prevEpisode, nextEpisode, handlePrevEpisode, handleNextEpisode, playlistOpen, danmakuOpen]) + const isDirectStream = isDirectStreamMedia(media) + const toggleMode = useCallback(() => { + if (isDirectStream) { + toast('该媒体为直连播放,无需且不支持转码') + return + } const next = mode === 'hls' ? 'direct' : 'hls' setMode(next) params.set('mode', next) setParams(params, { replace: true }) - }, [mode, params, setParams]) + }, [isDirectStream, mode, params, setParams]) // 用户切换字幕轨道:-1=关闭;记忆偏好,下次播放默认沿用。 const selectSubtitle = useCallback((index: number) => { @@ -450,7 +458,13 @@ export function PlayerPage() { // 浏览器对