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