This commit is contained in:
truewhile
2026-09-01 16:26:25 +08:00
parent 73de139d1f
commit af67f4cd6e
23 changed files with 671 additions and 131 deletions
+70 -45
View File
@@ -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
}
+25 -4
View File
@@ -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
+77 -7
View File
@@ -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)
}
}
+4 -1
View File
@@ -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) {
+13 -2
View File
@@ -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
+33
View File
@@ -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())
+4 -1
View File
@@ -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] {
+6 -1
View File
@@ -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
+44
View File
@@ -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()
+55 -8
View File
@@ -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
}
}
+48 -3
View File
@@ -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)
}
}
+94 -4
View File
@@ -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") }
+1 -1
View File
@@ -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)
+6 -2
View File
@@ -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() {
+15
View File
@@ -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)
+60
View File
@@ -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)
}
}
+9
View File
@@ -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),
}
+2 -2
View File
@@ -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({
<span>立即播放</span>
</Link>
{!isRemoteEmbyID(media.id) && (
{!isDirectStreamMedia(media) && (
<Link
to={`/play/${media.id}?mode=hls`}
state={{ from: `/media/${media.id}` }}
+28 -6
View File
@@ -11,7 +11,8 @@ import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
import { systemAPI } from '../api/system'
import type { Media } from '../types'
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
import { pickPlayerMode, needsTranscodeForBrowser, type PlayerMode } from './playerPageModel'
import { isRemoteEmbyID } from '../utils/remoteEmby'
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, type PlayerMode } from './playerPageModel'
import { PlayerTopBar } from './PlayerTopBar'
import { PlayerVideoStage } from './PlayerVideoStage'
import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
@@ -186,10 +187,11 @@ export function PlayerPage() {
if (!id) return
mediaAPI.get(id).then((m) => {
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() {
// 浏览器对 <video src> 的错误描述非常有限,把详细原因
// 转给开发者控制台 + 一条 toast;常见原因是 codec 不支持。
if (mode === 'direct') {
if (directOnly) {
if (isRemoteEmbyID(media?.id)) {
setPlayerError('直接播放失败。该媒体为远程 Emby 挂载直连播放(不进行转码);当前浏览器可能不支持该视频编码或音频格式,建议使用外部播放器(如 PotPlayer / VLC / IINA)播放。')
toast.error('直接播放失败,建议使用外部播放器')
} else if (isDirectStreamMedia(media)) {
setPlayerError('直接播放失败。该媒体为 STRM 远程直连播放(不进行转码);当前浏览器可能不支持该视频编码或音频格式,建议使用外部播放器播放。')
toast.error('直接播放失败,建议使用外部播放器')
} else if (directOnly) {
setPlayerError('直接播放失败。当前为「客户端直连解码」模式,宿主机不转码;请使用支持该编码/封装的播放器(如 Infuse / VLC / Emby 客户端)播放,或关闭直连解码模式。')
toast.error('直接播放失败(客户端直连解码模式)')
} else if (hlsUnavailable) {
@@ -467,12 +481,20 @@ export function PlayerPage() {
setPlayerError('视频播放失败,请检查文件是否存在,或确认 ffmpeg 已正确配置。')
toast.error('视频播放失败,请检查文件是否存在')
}, [directOnly, hlsUnavailable, mode, params, setParams])
}, [directOnly, hlsUnavailable, media, mode, params, setParams])
return (
<div className="relative flex h-full w-full flex-1 flex-col overflow-hidden bg-black">
<PlayerTopBar
directOnly={directOnly}
isDirectStream={isDirectStream}
directStreamLabel={
isRemoteEmbyID(media?.id)
? 'Emby 直连播放'
: isDirectStream
? 'STRM 直连播放'
: undefined
}
mode={mode}
onBack={goBack}
onToggleMode={toggleMode}
+12 -1
View File
@@ -4,6 +4,8 @@ import type { PlayerMode } from './playerPageModel'
type PlayerTopBarProps = {
directOnly: boolean
isDirectStream?: boolean
directStreamLabel?: string
mode: PlayerMode
onBack: () => void
onToggleMode: () => void
@@ -11,6 +13,8 @@ type PlayerTopBarProps = {
export function PlayerTopBar({
directOnly,
isDirectStream,
directStreamLabel,
mode,
onBack,
onToggleMode,
@@ -25,7 +29,14 @@ export function PlayerTopBar({
</button>
<div className="pointer-events-auto flex items-center gap-2">
{directOnly ? (
{isDirectStream ? (
<span
className="flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur"
title={directStreamLabel ? `${directStreamLabel},默认直连播放,不进行转码` : '远程直连播放,不进行转码'}
>
<Sparkles size={14} /> {directStreamLabel || '直连播放'}
</span>
) : directOnly ? (
<span
className="flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur"
title="宿主机不转码,由客户端本地解码直连"
+42 -35
View File
@@ -309,7 +309,10 @@ export function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) {
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(
() => strmAPI.retryFailedDownloads(),
() =>
isDownload
? strmAPI.retryFailedDownloads()
: strmAPI.retryFailedUploads(),
'确定重新入队所有失败任务?',
)
}}
@@ -340,23 +343,26 @@ export function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) {
</button>
)}
<div className="my-1 border-t border-gray-100" />
{isDownload && (
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(
() => strmAPI.clearDoneDownloads(),
'确定清空所有已完成的下载记录?',
)
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-ink-100 hover:bg-gray-50"
>
<CheckCircle2 size={13} className="text-emerald-500" />
<span>清空已完成记录</span>
</button>
)}
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(
() =>
isDownload
? strmAPI.clearDoneDownloads()
: strmAPI.clearDoneUploads(),
isDownload
? '确定清空所有已完成的下载记录?'
: '确定清空所有已完成的上传记录?',
)
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-ink-100 hover:bg-gray-50"
>
<CheckCircle2 size={13} className="text-emerald-500" />
<span>清空已完成记录</span>
</button>
<button
type="button"
disabled={batchBusy}
@@ -375,23 +381,24 @@ export function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) {
<Ban size={13} className="text-amber-500" />
<span>清空已取消记录</span>
</button>
{isDownload && (
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(
() => strmAPI.clearFinishedDownloads(),
'确定清空所有已完成、失败及取消的历史记录?',
)
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-rose-500 hover:bg-rose-50"
>
<Trash2 size={13} />
<span>清空全部历史记录</span>
</button>
)}
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(
() =>
isDownload
? strmAPI.clearFinishedDownloads()
: strmAPI.clearFinishedUploads(),
'确定清空所有已完成、失败及取消的历史记录?',
)
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-rose-500 hover:bg-rose-50"
>
<Trash2 size={13} />
<span>清空全部历史记录</span>
</button>
</div>
</details>
</div>
+17 -4
View File
@@ -1,4 +1,5 @@
import type { Media } from '../types'
import { isRemoteEmbyID } from '../utils/remoteEmby'
export type PlayerMode = 'direct' | 'hls'
@@ -6,16 +7,27 @@ const directContainers = ['mp4', 'webm', 'm4v']
const directVideoCodecs = ['h264', 'avc', 'avc1']
const directAudioCodecs = ['aac', 'mp3', 'opus']
/**
* 判断媒体是否为远程直链或挂载直连流媒体(STRM 或 Emby 远程挂载)。
* 这类媒体服务端 302 重定向到直链或进行原生流中继,本地不具备原始文件,
* 无法也不应该进行 ffmpeg 转码,恒走直连播放。
*/
export function isDirectStreamMedia(media?: Media | null): boolean {
if (!media) return false
if (isRemoteEmbyID(media.id)) return true
const container = (media.container ?? '').toLowerCase()
return container.includes('strm') || String(media.strm_url ?? '').trim() !== ''
}
export function pickPlayerMode(media: Media): PlayerMode {
return needsTranscodeForBrowser(media) ? 'hls' : 'direct'
}
export function needsTranscodeForBrowser(media: Media): boolean {
// Emby 远程挂载与 .strm 媒体一样,均为直连流,无法进行本地转码,恒走 direct play。
if (isDirectStreamMedia(media)) return false
const container = (media.container ?? '').toLowerCase()
// .strm 媒体内容是远程直链(服务端 302 到播放 CDN 或反向代理),
// 浏览器直接播放该远程流即可,转码无意义且必然失败(ffmpeg 无法读取
// 文本 strm),恒走 direct play。
if (container.includes('strm') || String(media.strm_url ?? '').trim() !== '') return false
const videoCodec = (media.video_codec ?? '').toLowerCase()
const audioCodec = (media.audio_codec ?? '').toLowerCase()
const containerOK = directContainers.some((item) => container.includes(item))
@@ -23,3 +35,4 @@ export function needsTranscodeForBrowser(media: Media): boolean {
const audioOK = !audioCodec || directAudioCodecs.some((item) => audioCodec.includes(item))
return !(containerOK && videoOK && audioOK)
}
+6 -4
View File
@@ -58,7 +58,7 @@ export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | n
const loadAll = async () => {
if (isSeriesLibrary) {
const collected = await loadAllSeriesCards(libraryID, (next) => {
const collected = await loadAllSeriesCards(libraryID, library.is_remote_emby, (next) => {
if (cancelled) return
setTotal(next.total)
if (next.firstPage) {
@@ -70,7 +70,7 @@ export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | n
return
}
const collected = await loadAllMedia(libraryID, (next) => {
const collected = await loadAllMedia(libraryID, library.is_remote_emby, (next) => {
if (cancelled) return
setTotal(next.total)
if (next.firstPage) {
@@ -147,9 +147,10 @@ function isSeriesLibraryType(type?: string) {
async function loadAllSeriesCards(
libraryID: string,
isRemoteEmby: boolean | undefined,
onPage: (state: { items: SeriesCard[]; total: number; firstPage: boolean }) => void,
) {
const pageSize = 500
const pageSize = isRemoteEmby ? 100 : 500
let page = 1
let collected: SeriesCard[] = []
for (;;) {
@@ -166,9 +167,10 @@ async function loadAllSeriesCards(
async function loadAllMedia(
libraryID: string,
isRemoteEmby: boolean | undefined,
onPage: (state: { items: Media[]; total: number; firstPage: boolean }) => void,
) {
const pageSize = 2000
const pageSize = isRemoteEmby ? 100 : 2000
let page = 1
let collected: Media[] = []
for (;;) {