优化续播

This commit is contained in:
truewhile
2026-09-01 23:16:11 +08:00
parent 3fe37e050b
commit b503fdee7a
57 changed files with 739 additions and 410 deletions
+6 -6
View File
@@ -44,11 +44,11 @@ type TranscoderConfig struct {
// AppConfig 保存运行时应用参数。 // AppConfig 保存运行时应用参数。
type AppConfig struct { type AppConfig struct {
Port int `mapstructure:"port"` Port int `mapstructure:"port"`
Debug bool `mapstructure:"debug"` Debug bool `mapstructure:"debug"`
Env string `mapstructure:"env"` Env string `mapstructure:"env"`
DataDir string `mapstructure:"data_dir"` DataDir string `mapstructure:"data_dir"`
WebDir string `mapstructure:"web_dir"` WebDir string `mapstructure:"web_dir"`
// HTTPSEnabled 是否仅通过 HTTPS 提供访问。启用时必须同时配置 // HTTPSEnabled 是否仅通过 HTTPS 提供访问。启用时必须同时配置
// SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS。 // SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS。
HTTPSEnabled bool `mapstructure:"https_enabled"` HTTPSEnabled bool `mapstructure:"https_enabled"`
@@ -59,7 +59,7 @@ type AppConfig struct {
// SSLCertPath 是 SSL 证书文件路径;非空时优先于 SSLCert 从文件读取。 // SSLCertPath 是 SSL 证书文件路径;非空时优先于 SSLCert 从文件读取。
SSLCertPath string `mapstructure:"ssl_cert_path"` SSLCertPath string `mapstructure:"ssl_cert_path"`
// SSLKeyPath 是 SSL 私钥文件路径;非空时优先于 SSLKey 从文件读取。 // SSLKeyPath 是 SSL 私钥文件路径;非空时优先于 SSLKey 从文件读取。
SSLKeyPath string `mapstructure:"ssl_key_path"` SSLKeyPath string `mapstructure:"ssl_key_path"`
FFmpegPath string `mapstructure:"ffmpeg_path"` FFmpegPath string `mapstructure:"ffmpeg_path"`
FFprobePath string `mapstructure:"ffprobe_path"` FFprobePath string `mapstructure:"ffprobe_path"`
// FFprobeMaxConcurrent limits concurrent ffprobe/ffmpeg metadata probes. // FFprobeMaxConcurrent limits concurrent ffprobe/ffmpeg metadata probes.
+7 -7
View File
@@ -408,13 +408,13 @@ func TestOpenSQLiteMigrationSourceUsesFallbackSourcePath(t *testing.T) {
_ = sqlDB2.Close() _ = sqlDB2.Close()
} }
}() }()
_, copied, err := copyModelTables(src2, dst, 2) _, copied, err := copyModelTables(src2, dst, 2)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if copied != 2 { if copied != 2 {
t.Fatalf("copied rows = %d, want 2", copied) t.Fatalf("copied rows = %d, want 2", copied)
} }
var userCount int64 var userCount int64
if err := dst.Model(&model.User{}).Where("username = ?", "real-admin").Count(&userCount).Error; err != nil { if err := dst.Model(&model.User{}).Where("username = ?", "real-admin").Count(&userCount).Error; err != nil {
t.Fatal(err) t.Fatal(err)
+61 -61
View File
@@ -192,77 +192,77 @@ func updateUserStatusHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
if req.IsActive { if req.IsActive {
_ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, false) _ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, false)
} else { } else {
_ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, true) _ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, true)
}
updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
updated.PopulateComputedFields()
c.JSON(http.StatusOK, updated)
} }
updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
updated.PopulateComputedFields()
c.JSON(http.StatusOK, updated)
} }
}
type adminUpdateUserLibrariesReq struct { type adminUpdateUserLibrariesReq struct {
AllowedLibraryIDs *[]string `json:"allowed_library_ids"` AllowedLibraryIDs *[]string `json:"allowed_library_ids"`
} }
func updateUserLibrariesHandler(svc *service.Container) gin.HandlerFunc { func updateUserLibrariesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
var req adminUpdateUserLibrariesReq var req adminUpdateUserLibrariesReq
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
userID := c.Param("id") userID := c.Param("id")
user, err := svc.Repo.User.FindByID(c.Request.Context(), userID) user, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
if user == nil { if user == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return return
} }
var rawJSON string var rawJSON string
if req.AllowedLibraryIDs != nil && len(*req.AllowedLibraryIDs) > 0 { if req.AllowedLibraryIDs != nil && len(*req.AllowedLibraryIDs) > 0 {
var cleanIDs []string var cleanIDs []string
for _, id := range *req.AllowedLibraryIDs { for _, id := range *req.AllowedLibraryIDs {
trimmed := strings.TrimSpace(id) trimmed := strings.TrimSpace(id)
if trimmed != "" { if trimmed != "" {
cleanIDs = append(cleanIDs, trimmed) cleanIDs = append(cleanIDs, trimmed)
}
}
if len(cleanIDs) > 0 {
data, err := json.Marshal(cleanIDs)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
rawJSON = string(data)
} }
} }
if len(cleanIDs) > 0 {
updates := map[string]any{"allowed_library_ids": rawJSON} data, err := json.Marshal(cleanIDs)
if err := svc.Repo.User.UpdateFields(c.Request.Context(), userID, updates); err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
}
rawJSON = string(data)
} }
updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
if err != nil || updated == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload user"})
return
}
updated.PopulateComputedFields()
c.JSON(http.StatusOK, updated)
} }
updates := map[string]any{"allowed_library_ids": rawJSON}
if err := svc.Repo.User.UpdateFields(c.Request.Context(), userID, updates); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
if err != nil || updated == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload user"})
return
}
updated.PopulateComputedFields()
c.JSON(http.StatusOK, updated)
} }
}
func annotateProtectedUsers(ctx context.Context, svc *service.Container, users []model.User) error { func annotateProtectedUsers(ctx context.Context, svc *service.Container, users []model.User) error {
firstAdmin, err := svc.Repo.User.FirstAdmin(ctx) firstAdmin, err := svc.Repo.User.FirstAdmin(ctx)
+3 -3
View File
@@ -44,10 +44,10 @@ func TestDeleteUserRefusesRecentRealtimeSession(t *testing.T) {
if w.Code != http.StatusConflict { if w.Code != http.StatusConflict {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
} }
if found, _ := repos.User.FindByID(t.Context(), viewer.ID); found == nil { if found, _ := repos.User.FindByID(t.Context(), viewer.ID); found == nil {
t.Fatal("recent realtime user should not be deleted") t.Fatal("recent realtime user should not be deleted")
}
} }
}
func TestUpdateUserLibraries(t *testing.T) { func TestUpdateUserLibraries(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
@@ -0,0 +1,148 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/golang-jwt/jwt/v5"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/middleware"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service"
)
func TestMountedEmbyPlayingProgressAndResumePipeline(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
user := &model.User{
Base: model.Base{ID: "user-1"},
Username: "test_viewer",
PasswordHash: "x",
Role: "user",
Tier: "free",
IsActive: true,
}
if err := repos.User.Create(t.Context(), user); err != nil {
t.Fatalf("create user: %v", err)
}
cfg := &config.Config{}
logger := zap.NewNop()
svc := &service.Container{
Repo: repos,
Emby: service.NewEmbyService(cfg, logger, repos),
Sessions: service.NewSessionTrackerService(logger),
Playback: service.NewPlaybackService(logger, repos),
}
router := gin.New()
// 注册带认证的路由,模拟已登录用户
router.Use(func(c *gin.Context) {
c.Set(middleware.CtxUserID, user.ID)
c.Next()
})
router.POST("/Sessions/Playing/Progress", embyPlayingProgressHandler(svc))
router.GET("/Items", embyItemsHandler(svc))
router.GET("/Users/:userId/Items/Resume", embyResumeItemsHandler(svc))
router.GET("/Sessions", embySessionsHandler(svc))
remoteMediaID := service.EncodeEmbyRemoteID("mount-1", "remote-item-123")
// 1. 测试上报进度:客户端使用小写 query 参数 itemId / positionTicks
progressReq := httptest.NewRequest(
http.MethodPost,
"/Sessions/Playing/Progress?itemId="+remoteMediaID+"&positionTicks=300000000&runTimeTicks=1000000000",
nil,
)
wProgress := httptest.NewRecorder()
router.ServeHTTP(wProgress, progressReq)
if wProgress.Code != http.StatusNoContent {
t.Fatalf("progress status = %d, body = %s", wProgress.Code, wProgress.Body.String())
}
// 验证已持久化到 PlaybackHistory
var hist model.PlaybackHistory
if err := db.Where("user_id = ? AND media_id = ?", user.ID, remoteMediaID).First(&hist).Error; err != nil {
t.Fatalf("playback history not saved: %v", err)
}
if hist.PositionMs != 30000 {
t.Fatalf("expected position_ms = 30000, got %d", hist.PositionMs)
}
// 2. 测试 Filters=IsResumable 能够包含该远程条目
resumableReq := httptest.NewRequest(
http.MethodGet,
"/Items?Filters=IsResumable",
nil,
)
wResumable := httptest.NewRecorder()
router.ServeHTTP(wResumable, resumableReq)
if wResumable.Code != http.StatusOK {
t.Fatalf("items resumable status = %d, body = %s", wResumable.Code, wResumable.Body.String())
}
var resumableEnvelope map[string]any
if err := json.Unmarshal(wResumable.Body.Bytes(), &resumableEnvelope); err != nil {
t.Fatalf("decode resumable: %v", err)
}
// 因为没有配置真实的远程客户端连接,该远程条目在当前离线测试中不会 panic 崩溃,并且正常响应 Envelope
if resumableEnvelope["TotalRecordCount"] == nil {
t.Fatalf("missing TotalRecordCount in resumable envelope")
}
// 3. 测试 /Users/:userId/Items/Resume 别名路由
resumeAliasReq := httptest.NewRequest(
http.MethodGet,
"/Users/"+user.ID+"/Items/Resume",
nil,
)
wResumeAlias := httptest.NewRecorder()
router.ServeHTTP(wResumeAlias, resumeAliasReq)
if wResumeAlias.Code != http.StatusOK {
t.Fatalf("resume alias status = %d, body = %s", wResumeAlias.Code, wResumeAlias.Body.String())
}
// 4. 测试 /Sessions 返回 NowPlayingItem
sessionsReq := httptest.NewRequest(http.MethodGet, "/Sessions", nil)
wSessions := httptest.NewRecorder()
router.ServeHTTP(wSessions, sessionsReq)
if wSessions.Code != http.StatusOK {
t.Fatalf("sessions status = %d, body = %s", wSessions.Code, wSessions.Body.String())
}
var sessionsList []map[string]any
if err := json.Unmarshal(wSessions.Body.Bytes(), &sessionsList); err != nil {
t.Fatalf("decode sessions: %v", err)
}
if len(sessionsList) == 0 {
t.Fatalf("expected at least 1 session")
}
nowPlaying, ok := sessionsList[0]["NowPlayingItem"].(map[string]any)
if !ok || nowPlaying["Id"] != remoteMediaID {
t.Fatalf("expected NowPlayingItem with id %q, got %#v", remoteMediaID, sessionsList[0]["NowPlayingItem"])
}
}
func signMockToken(secret, userID string) string {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": userID,
"exp": time.Now().Add(time.Hour).Unix(),
})
s, _ := token.SignedString([]byte(secret))
return s
}
+25 -11
View File
@@ -12,8 +12,13 @@ import (
type embyPlayingReq struct { type embyPlayingReq struct {
ItemId string `json:"ItemId"` ItemId string `json:"ItemId"`
ItemIDLower string `json:"itemId"`
ID string `json:"Id"`
IDLower string `json:"id"`
PositionTicks int64 `json:"PositionTicks"` PositionTicks int64 `json:"PositionTicks"`
PositionLower int64 `json:"positionTicks"`
RunTimeTicks int64 `json:"RunTimeTicks"` RunTimeTicks int64 `json:"RunTimeTicks"`
RunTimeLower int64 `json:"runTimeTicks"`
} }
func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc { func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
@@ -25,16 +30,25 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
} }
var req embyPlayingReq var req embyPlayingReq
_ = c.ShouldBindJSON(&req) _ = c.ShouldBindJSON(&req)
if req.ItemId == "" { itemID := embyFirstNonEmptyString(req.ItemId, req.ItemIDLower, req.ID, req.IDLower)
req.ItemId = c.Query("ItemId") if itemID == "" {
itemID = embyFirstNonEmptyString(firstQueryValue(c, "ItemId", "itemId", "Id", "id"))
} }
if req.PositionTicks == 0 { pos := req.PositionTicks
req.PositionTicks, _ = strconv.ParseInt(c.Query("PositionTicks"), 10, 64) if pos == 0 {
pos = req.PositionLower
} }
if req.RunTimeTicks == 0 { if pos == 0 {
req.RunTimeTicks, _ = strconv.ParseInt(c.Query("RunTimeTicks"), 10, 64) pos, _ = strconv.ParseInt(firstQueryValue(c, "PositionTicks", "positionTicks"), 10, 64)
} }
if req.ItemId == "" { runTime := req.RunTimeTicks
if runTime == 0 {
runTime = req.RunTimeLower
}
if runTime == 0 {
runTime, _ = strconv.ParseInt(firstQueryValue(c, "RunTimeTicks", "runTimeTicks"), 10, 64)
}
if itemID == "" {
c.Status(http.StatusOK) c.Status(http.StatusOK)
return return
} }
@@ -43,7 +57,7 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
c.Status(http.StatusUnauthorized) c.Status(http.StatusUnauthorized)
return return
} }
if err := svc.Emby.RecordProgress(c.Request.Context(), uid, req.ItemId, req.PositionTicks, req.RunTimeTicks); err != nil { if err := svc.Emby.RecordProgress(c.Request.Context(), uid, itemID, pos, runTime); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -54,9 +68,9 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
clientInfo.DeviceName, clientInfo.DeviceName,
clientInfo.Client, clientInfo.Client,
c.ClientIP(), c.ClientIP(),
req.ItemId, itemID,
req.PositionTicks, pos,
req.RunTimeTicks, runTime,
stopped) stopped)
} }
if svc.Device != nil && !stopped { if svc.Device != nil && !stopped {
+2
View File
@@ -161,6 +161,8 @@ func registerEmbyAuthenticatedItemRoutes(auth *gin.RouterGroup, svc *service.Con
auth.GET("/Users/:userId/Items/Counts", embyItemsCountsHandler(svc)) auth.GET("/Users/:userId/Items/Counts", embyItemsCountsHandler(svc))
auth.GET("/Items/Latest", embyLatestItemsHandler(svc)) auth.GET("/Items/Latest", embyLatestItemsHandler(svc))
auth.GET("/Items/Resume", embyResumeItemsHandler(svc)) auth.GET("/Items/Resume", embyResumeItemsHandler(svc))
auth.GET("/Users/:userId/Items/Resume", embyResumeItemsHandler(svc))
auth.GET("/UserItems/Resume", embyResumeItemsHandler(svc))
auth.GET("/Items/:id", embyItemByIDHandler(svc)) auth.GET("/Items/:id", embyItemByIDHandler(svc))
auth.GET("/Users/:userId/Items/:id", embyUserItemByIDHandler(svc)) auth.GET("/Users/:userId/Items/:id", embyUserItemByIDHandler(svc))
auth.GET("/Shows/:id/Seasons", embyShowSeasonsHandler(svc)) auth.GET("/Shows/:id/Seasons", embyShowSeasonsHandler(svc))
@@ -32,6 +32,8 @@ func registerLowercaseEmbyItemRoutes(auth *gin.RouterGroup, svc *service.Contain
auth.GET("/users/:userId/items/counts", embyItemsCountsHandler(svc)) auth.GET("/users/:userId/items/counts", embyItemsCountsHandler(svc))
auth.GET("/items/latest", embyLatestItemsHandler(svc)) auth.GET("/items/latest", embyLatestItemsHandler(svc))
auth.GET("/items/resume", embyResumeItemsHandler(svc)) auth.GET("/items/resume", embyResumeItemsHandler(svc))
auth.GET("/users/:userId/items/resume", embyResumeItemsHandler(svc))
auth.GET("/useritems/resume", embyResumeItemsHandler(svc))
auth.GET("/items/:id", embyItemByIDHandler(svc)) auth.GET("/items/:id", embyItemByIDHandler(svc))
auth.GET("/users/:userId/items/:id", embyUserItemByIDHandler(svc)) auth.GET("/users/:userId/items/:id", embyUserItemByIDHandler(svc))
auth.GET("/shows/:id/seasons", embyShowSeasonsHandler(svc)) auth.GET("/shows/:id/seasons", embyShowSeasonsHandler(svc))
+11 -1
View File
@@ -41,7 +41,17 @@ func embySessionsHandler(svc *service.Container) gin.HandlerFunc {
"SupportsRemoteControl": true, "SupportsRemoteControl": true,
} }
if itemID != "" && sess.IsPlaying { if itemID != "" && sess.IsPlaying {
row["NowPlayingItem"] = gin.H{"Id": itemID} nowPlaying := gin.H{"Id": itemID}
if svc.Emby != nil {
if item, _ := svc.Emby.Item(c.Request.Context(), itemID, sess.UserID); item != nil {
for _, key := range []string{"Name", "Type", "RunTimeTicks", "PrimaryImageItemId", "ImageTags", "SeriesName", "SeasonName", "IndexNumber", "ParentIndexNumber"} {
if val, ok := item[key]; ok && val != nil {
nowPlaying[key] = val
}
}
}
}
row["NowPlayingItem"] = nowPlaying
} }
out = append(out, row) out = append(out, row)
} }
+11 -12
View File
@@ -401,17 +401,17 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
t.Fatal(err) t.Fatal(err)
} }
router := gin.New() router := gin.New()
api := router.Group("/api") api := router.Group("/api")
api.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret)) api.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret))
api.GET("/playback/:id/info", playbackInfoHandler(svc)) api.GET("/playback/:id/info", playbackInfoHandler(svc))
api.GET("/playback/:id/external-url", externalURLHandler(svc)) api.GET("/playback/:id/external-url", externalURLHandler(svc))
api.GET("/playback/:id/external-players", externalPlayersHandler(svc)) api.GET("/playback/:id/external-players", externalPlayersHandler(svc))
api.GET("/stream/:id", streamHandler(svc)) api.GET("/stream/:id", streamHandler(svc))
api.GET("/hls/:id/index.m3u8", hlsPlaylistHandler(svc)) api.GET("/hls/:id/index.m3u8", hlsPlaylistHandler(svc))
api.GET("/media/:id/subtitles", listSubtitlesHandler(svc)) api.GET("/media/:id/subtitles", listSubtitlesHandler(svc))
return router, svc, cfg.Secrets.JWTSecret return router, svc, cfg.Secrets.JWTSecret
} }
func TestPlaybackInfoForSTRMMediaDisablesHLS(t *testing.T) { func TestPlaybackInfoForSTRMMediaDisablesHLS(t *testing.T) {
router, _, secret := newPlaybackScopeTestRouter(t) router, _, secret := newPlaybackScopeTestRouter(t)
@@ -478,4 +478,3 @@ func TestListSubtitlesForRemoteEmbyMediaReturnsEmptyTracks(t *testing.T) {
t.Fatalf("expected empty tracks array, got %v", payload.Tracks) t.Fatalf("expected empty tracks array, got %v", payload.Tracks)
} }
} }
+5 -5
View File
@@ -49,11 +49,11 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
// Emby 挂载管理:远程 Emby 媒体库挂载(账号复用 strm/accounts) // Emby 挂载管理:远程 Emby 媒体库挂载(账号复用 strm/accounts)
admin.GET("/emby/accounts/:id/views", embyAccountViewsHandler(svc)) admin.GET("/emby/accounts/:id/views", embyAccountViewsHandler(svc))
admin.POST("/emby/accounts/:id/full-mount", fullMountEmbyAccountHandler(svc)) admin.POST("/emby/accounts/:id/full-mount", fullMountEmbyAccountHandler(svc))
admin.GET("/emby/mounts", listEmbyMountsHandler(svc)) admin.GET("/emby/mounts", listEmbyMountsHandler(svc))
admin.POST("/emby/mounts", createEmbyMountsHandler(svc)) admin.POST("/emby/mounts", createEmbyMountsHandler(svc))
admin.PUT("/emby/mounts/reorder", reorderEmbyMountsHandler(svc)) admin.PUT("/emby/mounts/reorder", reorderEmbyMountsHandler(svc))
admin.PUT("/emby/mounts/:id", updateEmbyMountHandler(svc)) admin.PUT("/emby/mounts/:id", updateEmbyMountHandler(svc))
admin.DELETE("/emby/mounts/:id", deleteEmbyMountHandler(svc)) admin.DELETE("/emby/mounts/:id", deleteEmbyMountHandler(svc))
admin.GET("/strm/accounts", listStrmAccountsHandler(svc)) admin.GET("/strm/accounts", listStrmAccountsHandler(svc))
admin.POST("/strm/accounts", createStrmAccountHandler(svc)) admin.POST("/strm/accounts", createStrmAccountHandler(svc))
+13 -13
View File
@@ -50,19 +50,19 @@ func TestStrmAdminRoutesAreRegistered(t *testing.T) {
"GET /api/admin/strm/downloads", "GET /api/admin/strm/downloads",
"POST /api/admin/strm/downloads/:id/cancel", "POST /api/admin/strm/downloads/:id/cancel",
"POST /api/admin/strm/downloads/:id/retry", "POST /api/admin/strm/downloads/:id/retry",
"POST /api/admin/strm/downloads/clear-finished", "POST /api/admin/strm/downloads/clear-finished",
"POST /api/admin/strm/downloads/clear-canceled", "POST /api/admin/strm/downloads/clear-canceled",
"POST /api/admin/strm/downloads/retry-failed", "POST /api/admin/strm/downloads/retry-failed",
"POST /api/admin/strm/downloads/cancel-pending", "POST /api/admin/strm/downloads/cancel-pending",
"GET /api/admin/strm/uploads", "GET /api/admin/strm/uploads",
"POST /api/admin/strm/uploads/:id/cancel", "POST /api/admin/strm/uploads/:id/cancel",
"POST /api/admin/strm/uploads/:id/retry", "POST /api/admin/strm/uploads/:id/retry",
"POST /api/admin/strm/uploads/clear-done", "POST /api/admin/strm/uploads/clear-done",
"POST /api/admin/strm/uploads/clear-finished", "POST /api/admin/strm/uploads/clear-finished",
"POST /api/admin/strm/uploads/clear-canceled", "POST /api/admin/strm/uploads/clear-canceled",
"POST /api/admin/strm/uploads/retry-failed", "POST /api/admin/strm/uploads/retry-failed",
"POST /api/admin/strm/uploads/cancel-pending", "POST /api/admin/strm/uploads/cancel-pending",
"GET /api/strm/play/:provider/:file", "GET /api/strm/play/:provider/:file",
} { } {
if !routes[want] { if !routes[want] {
t.Fatalf("%s route is not registered", want) t.Fatalf("%s route is not registered", want)
+13 -1
View File
@@ -125,6 +125,18 @@ func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
for _, r := range rows { for _, r := range rows {
m, ok := mIdx[r.MediaID] m, ok := mIdx[r.MediaID]
if !ok { if !ok {
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(r.MediaID) {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(r.MediaID)
if mount, acct, _ := svc.EmbyRemote.ResolveMount(c.Request.Context(), mountID); mount != nil && acct != nil {
if rm, err := svc.EmbyRemote.RemoteMediaDetail(c.Request.Context(), mount, acct, remoteID); err == nil && rm != nil {
out = append(out, gin.H{
"history": r,
"media": *rm,
})
continue
}
}
}
continue continue
} }
out = append(out, gin.H{ out = append(out, gin.H{
@@ -163,7 +175,7 @@ func historyDeleteHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "status must be completed or incomplete"}) c.JSON(http.StatusBadRequest, gin.H{"error": "status must be completed or incomplete"})
return return
} }
res := q.Unscoped().Delete(&model.PlaybackHistory{}) res := q.Unscoped().Delete(&model.PlaybackHistory{})
if err := res.Error; err != nil { if err := res.Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
+8 -8
View File
@@ -9,12 +9,12 @@ package model
// EmbyMount 是远程 Emby 服务器上一个媒体库(View)的挂载配置。 // EmbyMount 是远程 Emby 服务器上一个媒体库(View)的挂载配置。
type EmbyMount struct { type EmbyMount struct {
Base Base
AccountID string `gorm:"size:36;index" json:"account_id"` // StrmAccount.ID(provider=emby_remote) AccountID string `gorm:"size:36;index" json:"account_id"` // StrmAccount.ID(provider=emby_remote)
RemoteViewID string `gorm:"size:128" json:"remote_view_id"` // 远程 Emby 的 View Id RemoteViewID string `gorm:"size:128" json:"remote_view_id"` // 远程 Emby 的 View Id
RemoteViewName string `gorm:"size:255" json:"remote_view_name"` // 远程媒体库原名(展示冗余) RemoteViewName string `gorm:"size:255" json:"remote_view_name"` // 远程媒体库原名(展示冗余)
CollectionType string `gorm:"size:32" json:"collection_type"` // movies / tvshows / music ... CollectionType string `gorm:"size:32" json:"collection_type"` // movies / tvshows / music ...
Name string `gorm:"size:255" json:"name,omitempty"` // 覆盖显示名(可选,默认「账号 · 库名」) Name string `gorm:"size:255" json:"name,omitempty"` // 覆盖显示名(可选,默认「账号 · 库名」)
SortOrder int `gorm:"default:0;index" json:"sort_order"` // 手动排序用,越小越靠前 SortOrder int `gorm:"default:0;index" json:"sort_order"` // 手动排序用,越小越靠前
ProxyPlay bool `gorm:"default:false" json:"proxy_play"` // 该挂载播放流量是否经 MMTL 反向代理 ProxyPlay bool `gorm:"default:false" json:"proxy_play"` // 该挂载播放流量是否经 MMTL 反向代理
Enabled bool `gorm:"default:true" json:"enabled"` // 是否在媒体库中展示 Enabled bool `gorm:"default:true" json:"enabled"` // 是否在媒体库中展示
} }
+2 -2
View File
@@ -8,8 +8,8 @@ type Library struct {
Type string `gorm:"size:16;not null;default:movie" json:"type"` // movie / tv / anime / music Type string `gorm:"size:16;not null;default:movie" json:"type"` // movie / tv / anime / music
CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"` CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"`
Enabled bool `gorm:"default:true" json:"enabled"` Enabled bool `gorm:"default:true" json:"enabled"`
SortOrder int `gorm:"index;default:0" json:"sort_order"` // 手动拖拽排序用,越小越靠前 SortOrder int `gorm:"index;default:0" json:"sort_order"` // 手动拖拽排序用,越小越靠前
CarouselEnabled bool `gorm:"default:false" json:"carousel_enabled"` // 是否参与首页海报轮播(默认不参与) CarouselEnabled bool `gorm:"default:false" json:"carousel_enabled"` // 是否参与首页海报轮播(默认不参与)
Roots []LibraryRoot `gorm:"foreignKey:LibraryID" json:"roots,omitempty"` Roots []LibraryRoot `gorm:"foreignKey:LibraryID" json:"roots,omitempty"`
} }
+6 -6
View File
@@ -54,10 +54,10 @@ func AllModels() []interface{} {
&StrmAccount{}, &StrmAccount{},
&StrmSyncPath{}, &StrmSyncPath{},
&StrmSyncRecord{}, &StrmSyncRecord{},
&StrmDownloadTask{}, &StrmDownloadTask{},
&StrmUploadTask{}, &StrmUploadTask{},
&StrmDirCache{}, &StrmDirCache{},
&ScrapeTask{}, &ScrapeTask{},
&EmbyMount{}, &EmbyMount{},
} }
} }
+2 -2
View File
@@ -18,8 +18,8 @@ type ScrapeTask struct {
LibraryName string `gorm:"size:128" json:"library_name"` LibraryName string `gorm:"size:128" json:"library_name"`
MediaTitle string `gorm:"size:255;not null" json:"media_title"` MediaTitle string `gorm:"size:255;not null" json:"media_title"`
MediaPath string `gorm:"size:1024;not null" json:"media_path"` MediaPath string `gorm:"size:1024;not null" json:"media_path"`
MediaType string `gorm:"size:16" json:"media_type"` // movie / tv / anime / adult MediaType string `gorm:"size:16" json:"media_type"` // movie / tv / anime / adult
Provider string `gorm:"size:32" json:"provider"` // tmdb / douban / bangumi / thetvdb / metatube Provider string `gorm:"size:32" json:"provider"` // tmdb / douban / bangumi / thetvdb / metatube
MatchedTitle string `gorm:"size:255" json:"matched_title"` MatchedTitle string `gorm:"size:255" json:"matched_title"`
MatchedYear int `json:"matched_year"` MatchedYear int `json:"matched_year"`
PosterURL string `gorm:"size:1024" json:"poster_url"` PosterURL string `gorm:"size:1024" json:"poster_url"`
+11 -12
View File
@@ -38,17 +38,17 @@ type StrmSyncPath struct {
RemotePath string `gorm:"size:1024" json:"remote_path"` // 远端目录:115=目录ID,OpenList/CD2=路径,local=源目录 RemotePath string `gorm:"size:1024" json:"remote_path"` // 远端目录:115=目录ID,OpenList/CD2=路径,local=源目录
LocalPath string `gorm:"size:1024" json:"local_path"` // STRM/元数据本地输出目录 LocalPath string `gorm:"size:1024" json:"local_path"` // STRM/元数据本地输出目录
// STRM 链接配置(空值继承全局 strm.* 设置) // STRM 链接配置(空值继承全局 strm.* 设置)
StrmBaseURL string `gorm:"size:512" json:"strm_base_url"` // 覆盖 strm.base_url StrmBaseURL string `gorm:"size:512" json:"strm_base_url"` // 覆盖 strm.base_url
VideoExt string `gorm:"size:512" json:"video_ext"` // 逗号分隔,覆盖 strm.video_ext VideoExt string `gorm:"size:512" json:"video_ext"` // 逗号分隔,覆盖 strm.video_ext
MetaExt string `gorm:"size:512" json:"meta_ext"` // 逗号分隔,覆盖 strm.meta_ext MetaExt string `gorm:"size:512" json:"meta_ext"` // 逗号分隔,覆盖 strm.meta_ext
ExcludeName string `gorm:"size:512" json:"exclude_name"` // 逗号分隔,文件名包含即跳过 ExcludeName string `gorm:"size:512" json:"exclude_name"` // 逗号分隔,文件名包含即跳过
MinVideoSizeMB int64 `json:"min_video_size_mb"` // 小于该大小(MB)的视频不生成 STRM MinVideoSizeMB int64 `json:"min_video_size_mb"` // 小于该大小(MB)的视频不生成 STRM
AddPath int `json:"add_path"` // STRM 链接 path 参数:1=完整远端路径 2=仅文件名 3=不带 AddPath int `json:"add_path"` // STRM 链接 path 参数:1=完整远端路径 2=仅文件名 3=不带
DownloadMeta bool `gorm:"default:true" json:"download_meta"` // 同步时下载元数据文件(nfo/图片/字幕) DownloadMeta bool `gorm:"default:true" json:"download_meta"` // 同步时下载元数据文件(nfo/图片/字幕)
UploadMeta bool `json:"upload_meta"` // 同步时把本地元数据上传到远端 UploadMeta bool `json:"upload_meta"` // 同步时把本地元数据上传到远端
DeleteDir bool `json:"delete_dir"` // 清理多余文件时删除空目录 DeleteDir bool `json:"delete_dir"` // 清理多余文件时删除空目录
Cron string `gorm:"size:128" json:"cron"` // 5 段 cron 表达式(可选) Cron string `gorm:"size:128" json:"cron"` // 5 段 cron 表达式(可选)
EnableCron bool `json:"enable_cron"` // 是否按 Cron 定时同步 EnableCron bool `json:"enable_cron"` // 是否按 Cron 定时同步
SyncMode string `gorm:"size:32;default:'incremental'" json:"sync_mode"` // 默认同步模式:incremental / full SyncMode string `gorm:"size:32;default:'incremental'" json:"sync_mode"` // 默认同步模式:incremental / full
Enabled bool `gorm:"default:true" json:"enabled"` Enabled bool `gorm:"default:true" json:"enabled"`
LastSyncAt *time.Time `json:"last_sync_at"` LastSyncAt *time.Time `json:"last_sync_at"`
@@ -140,4 +140,3 @@ type StrmDirCache struct {
DirID string `gorm:"size:128;index:idx_strm_dir_cache,priority:2" json:"dir_id"` DirID string `gorm:"size:128;index:idx_strm_dir_cache,priority:2" json:"dir_id"`
Path string `gorm:"size:1024" json:"path"` // 相对根目录的路径 Path string `gorm:"size:1024" json:"path"` // 相对根目录的路径
} }
+2 -2
View File
@@ -23,8 +23,8 @@ type User struct {
LastLoginAt *time.Time `json:"last_login_at,omitempty"` LastLoginAt *time.Time `json:"last_login_at,omitempty"`
// AllowedLibraryIDs 存储管理员为该用户指定的受限可访问媒体库 ID 列表(JSON 字符串)。 // AllowedLibraryIDs 存储管理员为该用户指定的受限可访问媒体库 ID 列表(JSON 字符串)。
// 为空时代表不限制(全库可访问)。 // 为空时代表不限制(全库可访问)。
AllowedLibraryIDs string `gorm:"type:text" json:"-"` AllowedLibraryIDs string `gorm:"type:text" json:"-"`
AllowedLibraryList []string `gorm:"-" json:"allowed_library_ids,omitempty"` AllowedLibraryList []string `gorm:"-" json:"allowed_library_ids,omitempty"`
// ExpiredAt is the account expiry time. Nil means the account never // ExpiredAt is the account expiry time. Nil means the account never
// expires. When set and in the past, the account is treated as expired // expires. When set and in the past, the account is treated as expired
// (login blocked) until an admin or a redemption code renews it. // (login blocked) until an admin or a redemption code renews it.
+1 -1
View File
@@ -23,7 +23,7 @@ func (r *FavoriteRepository) Toggle(ctx context.Context, userID, mediaID string)
if err != nil { if err != nil {
return false, err return false, err
} }
return false, r.db.WithContext(ctx).Unscoped().Delete(&f).Error return false, r.db.WithContext(ctx).Unscoped().Delete(&f).Error
} }
// ListByUser returns all favourite media IDs for a user. // ListByUser returns all favourite media IDs for a user.
+4 -4
View File
@@ -30,11 +30,11 @@ type Container struct {
StrmSyncPath *StrmSyncPathRepository StrmSyncPath *StrmSyncPathRepository
StrmSyncRecord *StrmSyncRecordRepository StrmSyncRecord *StrmSyncRecordRepository
StrmDownload *StrmDownloadTaskRepository StrmDownload *StrmDownloadTaskRepository
StrmUpload *StrmUploadTaskRepository StrmUpload *StrmUploadTaskRepository
StrmDirCache *StrmDirCacheRepository StrmDirCache *StrmDirCacheRepository
ScrapeTask *ScrapeTaskRepository ScrapeTask *ScrapeTaskRepository
EmbyMount *EmbyMountRepository EmbyMount *EmbyMountRepository
} }
// New 将每个 repository 连接到单个 *gorm.DB。 // New 将每个 repository 连接到单个 *gorm.DB。
func New(db *gorm.DB) *Container { func New(db *gorm.DB) *Container {
-2
View File
@@ -821,5 +821,3 @@ func (r *StrmDirCacheRepository) DeleteBySyncPathID(ctx context.Context, syncPat
return r.db.WithContext(ctx).Unscoped().Where("sync_path_id = ?", syncPathID).Delete(&model.StrmDirCache{}).Error return r.db.WithContext(ctx).Unscoped().Where("sync_path_id = ?", syncPathID).Delete(&model.StrmDirCache{}).Error
}) })
} }
-1
View File
@@ -138,4 +138,3 @@ func PruneImageCache(imagesDir string, maxSizeBytes int64) (PruneImageCacheResul
return result, nil return result, nil
} }
+5 -5
View File
@@ -38,11 +38,11 @@ var ErrUnsupported = errors.New("unsupported cloud provider")
// FileEntry is one item in a cloud directory listing. // FileEntry is one item in a cloud directory listing.
type FileEntry struct { type FileEntry struct {
ID string `json:"id"` // provider-native file id ID string `json:"id"` // provider-native file id
Name string `json:"name"` Name string `json:"name"`
IsDir bool `json:"is_dir"` IsDir bool `json:"is_dir"`
Size int64 `json:"size"` Size int64 `json:"size"`
MTime int64 `json:"mtime,omitempty"` MTime int64 `json:"mtime,omitempty"`
// PickCode is 115-specific; other providers use ID directly. // PickCode is 115-specific; other providers use ID directly.
PickCode string `json:"pick_code,omitempty"` PickCode string `json:"pick_code,omitempty"`
} }
+6 -6
View File
@@ -184,12 +184,12 @@ func (p *embyProvider) ensureToken(ctx context.Context) (string, error) {
// embyItemSummary 目录浏览所需的最小 Emby 条目字段。 // embyItemSummary 目录浏览所需的最小 Emby 条目字段。
type embyItemSummary struct { type embyItemSummary struct {
Id string `json:"Id"` Id string `json:"Id"`
Name string `json:"Name"` Name string `json:"Name"`
Type string `json:"Type"` Type string `json:"Type"`
IsFolder bool `json:"IsFolder"` IsFolder bool `json:"IsFolder"`
ChildCount int `json:"ChildCount"` ChildCount int `json:"ChildCount"`
RunTimeTicks int64 `json:"RunTimeTicks"` RunTimeTicks int64 `json:"RunTimeTicks"`
} }
type embyItemListResponse struct { type embyItemListResponse struct {
+7 -7
View File
@@ -59,8 +59,8 @@ func TestEmbyProviderListViewsAndChildren(t *testing.T) {
defer srv.Close() defer srv.Close()
p, err := New(TypeEmbyRemote, map[string]any{ p, err := New(TypeEmbyRemote, map[string]any{
"url": srv.URL, "url": srv.URL,
"api_key": "fixed-token", "api_key": "fixed-token",
"remote_user_id": "user-9", "remote_user_id": "user-9",
}, srv.Client()) }, srv.Client())
if err != nil { if err != nil {
@@ -87,8 +87,8 @@ func TestEmbyProviderResolveDirectURLByDefault(t *testing.T) {
defer srv.Close() defer srv.Close()
p, err := New(TypeEmbyRemote, map[string]any{ p, err := New(TypeEmbyRemote, map[string]any{
"url": srv.URL, "url": srv.URL,
"api_key": "fixed-token", "api_key": "fixed-token",
"remote_user_id": "user-9", "remote_user_id": "user-9",
}, srv.Client()) }, srv.Client())
if err != nil { if err != nil {
@@ -115,10 +115,10 @@ func TestEmbyProviderResolveProxyWhenConfigured(t *testing.T) {
defer srv.Close() defer srv.Close()
p, err := New(TypeEmbyRemote, map[string]any{ p, err := New(TypeEmbyRemote, map[string]any{
"url": srv.URL, "url": srv.URL,
"api_key": "fixed-token", "api_key": "fixed-token",
"remote_user_id": "user-9", "remote_user_id": "user-9",
"proxy_play": "true", "proxy_play": "true",
}, srv.Client()) }, srv.Client())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+60 -60
View File
@@ -278,72 +278,72 @@ func TestDanmakuSameBase(t *testing.T) {
require.False(t, sameDanmakuBase("", "https://api.dandanplay.net")) require.False(t, sameDanmakuBase("", "https://api.dandanplay.net"))
} }
// fetchCommentWithFallback:配置源与官方同源时不重复请求; // fetchCommentWithFallback:配置源与官方同源时不重复请求;
// 全失败时带出最后一跳错误。 // 全失败时带出最后一跳错误。
func TestDanmakuFetchCommentWithFallback(t *testing.T) { func TestDanmakuFetchCommentWithFallback(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
})) }))
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
svc := newDanmakuTestService(t) svc := newDanmakuTestService(t)
ctx := context.Background() ctx := context.Background()
raw, st, err := svc.fetchCommentWithFallback(ctx, srv.URL, srv.URL, "25484") raw, st, err := svc.fetchCommentWithFallback(ctx, srv.URL, srv.URL, "25484")
require.Error(t, err) require.Error(t, err)
require.Empty(t, raw) require.Empty(t, raw)
require.Equal(t, "auto", st) require.Equal(t, "auto", st)
} }
// 视频即便能命中 Hash 自动识别,当用户传入手动搜索关键词时应跳过 Hash 匹配,走关键词搜索。 // 视频即便能命中 Hash 自动识别,当用户传入手动搜索关键词时应跳过 Hash 匹配,走关键词搜索。
func TestDanmakuFetchHashMatchSkippedOnManualKeyword(t *testing.T) { func TestDanmakuFetchHashMatchSkippedOnManualKeyword(t *testing.T) {
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv") videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
// 官方服务同时提供 match 和 search: // 官方服务同时提供 match 和 search:
// match 会返回 episodeId=25484(动画A) // match 会返回 episodeId=25484(动画A)
// search 会根据关键词返回 episodeId=99999(动画B) // search 会根据关键词返回 episodeId=99999(动画B)
mux := http.NewServeMux() mux := http.NewServeMux()
var matchCalled bool var matchCalled bool
mux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
matchCalled = true matchCalled = true
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"自动识别动画A","episodeTitle":"第1话"}]}`) fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"自动识别动画A","episodeTitle":"第1话"}]}`)
}) })
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":2002,"animeTitle":"手动搜索动画B","episodes":[{"episodeId":99999,"episodeTitle":"第1话"}]}]}`) fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":2002,"animeTitle":"手动搜索动画B","episodes":[{"episodeId":99999,"episodeTitle":"第1话"}]}]}`)
}) })
mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml") w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">自动识别弹幕</d></i>`) fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">自动识别弹幕</d></i>`)
}) })
mux.HandleFunc("/api/v2/comment/99999", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/v2/comment/99999", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml") w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user2">手动搜索弹幕</d></i>`) fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user2">手动搜索弹幕</d></i>`)
}) })
official := httptest.NewServer(mux) official := httptest.NewServer(mux)
t.Cleanup(official.Close) t.Cleanup(official.Close)
overrideDanmakuOfficialBase(t, official.URL) overrideDanmakuOfficialBase(t, official.URL)
svc := newDanmakuTestService(t) svc := newDanmakuTestService(t)
ctx := context.Background() ctx := context.Background()
seedDanmakuVideoMedia(t, svc, "mManual", "自动识别动画A", videoPath, 32000, 1) seedDanmakuVideoMedia(t, svc, "mManual", "自动识别动画A", videoPath, 32000, 1)
// 1) 默认自动识别:命中 Hash 识别 // 1) 默认自动识别:命中 Hash 识别
resAuto, err := svc.Fetch(ctx, "mManual", "", "") resAuto, err := svc.Fetch(ctx, "mManual", "", "")
require.NoError(t, err) require.NoError(t, err)
require.True(t, matchCalled) require.True(t, matchCalled)
require.Equal(t, "hash", resAuto.MatchMode) require.Equal(t, "hash", resAuto.MatchMode)
require.Equal(t, int64(25484), resAuto.EpisodeID) require.Equal(t, int64(25484), resAuto.EpisodeID)
require.Contains(t, resAuto.Raw, "自动识别弹幕") require.Contains(t, resAuto.Raw, "自动识别弹幕")
// 2) 用户传入手动搜索关键词:跳过 Hash 识别,命中搜索结果动画B // 2) 用户传入手动搜索关键词:跳过 Hash 识别,命中搜索结果动画B
resManual, err := svc.Fetch(ctx, "mManual", "手动搜索动画B", "") resManual, err := svc.Fetch(ctx, "mManual", "手动搜索动画B", "")
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "search", resManual.MatchMode) require.Equal(t, "search", resManual.MatchMode)
require.Equal(t, int64(99999), resManual.EpisodeID) require.Equal(t, int64(99999), resManual.EpisodeID)
require.Equal(t, "手动搜索动画B", resManual.AnimeTitle) require.Equal(t, "手动搜索动画B", resManual.AnimeTitle)
require.Contains(t, resManual.Raw, "手动搜索弹幕") require.Contains(t, resManual.Raw, "手动搜索弹幕")
} }
// Emby 远程挂载条目:通过伪装 ID 解析出流直链,通过 Range 提取 16MB 前缀计算 hash 并匹配弹幕。 // Emby 远程挂载条目:通过伪装 ID 解析出流直链,通过 Range 提取 16MB 前缀计算 hash 并匹配弹幕。
func TestDanmakuFetchEmbyRemoteHashViaDirectLink(t *testing.T) { func TestDanmakuFetchEmbyRemoteHashViaDirectLink(t *testing.T) {
+82 -82
View File
@@ -206,94 +206,94 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
configured := strings.TrimRight(strings.TrimSpace(res.Source), "/") configured := strings.TrimRight(strings.TrimSpace(res.Source), "/")
official := danmakuOfficialBase official := danmakuOfficialBase
// 手动指定弹幕库:跳过识别,直接拉取该库(自定义源失败回退官方)。 // 手动指定弹幕库:跳过识别,直接拉取该库(自定义源失败回退官方)。
if target := strings.TrimSpace(episodeID); target != "" { if target := strings.TrimSpace(episodeID); target != "" {
raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target) raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target)
if err != nil {
s.log.Warn("danmaku comment fetch failed", zap.String("media_id", mediaID), zap.String("episode_id", target), zap.Error(err))
return res, err
}
res.Raw, res.SourceType = raw, st
if id, parseErr := strconv.ParseInt(target, 10, 64); parseErr == nil {
res.EpisodeID = id
}
res.MatchMode = "manual"
return res, nil
}
term, media, err := s.searchTerms(ctx, mediaID)
if err != nil { if err != nil {
s.log.Warn("danmaku comment fetch failed", zap.String("media_id", mediaID), zap.String("episode_id", target), zap.Error(err))
return res, err return res, err
} }
manualKeyword := strings.TrimSpace(keyword) != "" res.Raw, res.SourceType = raw, st
if kw := strings.TrimSpace(keyword); kw != "" { if id, parseErr := strconv.ParseInt(target, 10, 64); parseErr == nil {
term.name = kw res.EpisodeID = id
} }
if strings.TrimSpace(term.name) == "" { res.MatchMode = "manual"
return res, nil
}
term, media, err := s.searchTerms(ctx, mediaID)
if err != nil {
return res, err
}
manualKeyword := strings.TrimSpace(keyword) != ""
if kw := strings.TrimSpace(keyword); kw != "" {
term.name = kw
}
if strings.TrimSpace(term.name) == "" {
return res, nil
}
target := ""
// 1) hash 识别:始终走官方 /api/v2/match(keyword 手动覆盖时跳过,直接走第 3 层)。
if target == "" && !manualKeyword && media != nil && (media.Path != "" || IsEmbyRemoteID(media.ID)) {
if hash, ok := s.mediaHash(ctx, media); ok {
fileSize := media.SizeBytes
if media.Path != "" && strings.EqualFold(filepath.Ext(media.Path), ".strm") {
fileSize = 0 // strm 行的 SizeBytes 是文本大小,不是视频大小
}
matchName := danmakuMatchFileName(media.Path)
if matchName == "" {
matchName = term.name
}
matches, err := s.matchOfficial(ctx, matchName, hash, fileSize, media.DurationSec)
if err != nil {
s.log.Warn("danmaku hash match failed", zap.String("media_id", mediaID), zap.Error(err))
} else if len(matches) > 0 {
target = fmt.Sprintf("%d", matches[0].EpisodeID)
res.AnimeTitle = matches[0].AnimeTitle
res.EpisodeTitle = matches[0].EpisodeTitle
res.EpisodeID = matches[0].EpisodeID
res.MatchMode = "hash"
}
}
}
// 2) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 3 层)。
if target == "" && !manualKeyword && media != nil && media.Path != "" {
if fileName := danmakuMatchFileName(media.Path); fileName != "" && fileName != term.name {
if candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, fileName, term.episode); err == nil &&
len(candidates) == 1 && len(candidates[0].Episodes) > 0 {
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
res.AnimeTitle = candidates[0].AnimeTitle
res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle
res.EpisodeID = candidates[0].Episodes[0].EpisodeID
res.MatchMode = "filename"
}
}
}
// 3) 现有自动识别:标题层级(original_name → title → 文件名)+ 集数,
// 多结果返回候选列表交给播放器(歧义处理)。
if target == "" {
candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, term.name, term.episode)
if err != nil {
s.log.Warn("danmaku search failed", zap.String("media_id", mediaID), zap.String("name", term.name), zap.String("episode", term.episode), zap.Error(err))
return res, err
}
if len(candidates) != 1 {
res.Candidates = candidates
return res, nil return res, nil
} }
if len(candidates[0].Episodes) == 0 {
target := "" return res, errors.New("no danmaku library found for this video")
// 1) hash 识别:始终走官方 /api/v2/match(keyword 手动覆盖时跳过,直接走第 3 层)。
if target == "" && !manualKeyword && media != nil && (media.Path != "" || IsEmbyRemoteID(media.ID)) {
if hash, ok := s.mediaHash(ctx, media); ok {
fileSize := media.SizeBytes
if media.Path != "" && strings.EqualFold(filepath.Ext(media.Path), ".strm") {
fileSize = 0 // strm 行的 SizeBytes 是文本大小,不是视频大小
}
matchName := danmakuMatchFileName(media.Path)
if matchName == "" {
matchName = term.name
}
matches, err := s.matchOfficial(ctx, matchName, hash, fileSize, media.DurationSec)
if err != nil {
s.log.Warn("danmaku hash match failed", zap.String("media_id", mediaID), zap.Error(err))
} else if len(matches) > 0 {
target = fmt.Sprintf("%d", matches[0].EpisodeID)
res.AnimeTitle = matches[0].AnimeTitle
res.EpisodeTitle = matches[0].EpisodeTitle
res.EpisodeID = matches[0].EpisodeID
res.MatchMode = "hash"
}
}
}
// 2) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 3 层)。
if target == "" && !manualKeyword && media != nil && media.Path != "" {
if fileName := danmakuMatchFileName(media.Path); fileName != "" && fileName != term.name {
if candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, fileName, term.episode); err == nil &&
len(candidates) == 1 && len(candidates[0].Episodes) > 0 {
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
res.AnimeTitle = candidates[0].AnimeTitle
res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle
res.EpisodeID = candidates[0].Episodes[0].EpisodeID
res.MatchMode = "filename"
}
}
}
// 3) 现有自动识别:标题层级(original_name → title → 文件名)+ 集数,
// 多结果返回候选列表交给播放器(歧义处理)。
if target == "" {
candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, term.name, term.episode)
if err != nil {
s.log.Warn("danmaku search failed", zap.String("media_id", mediaID), zap.String("name", term.name), zap.String("episode", term.episode), zap.Error(err))
return res, err
}
if len(candidates) != 1 {
res.Candidates = candidates
return res, nil
}
if len(candidates[0].Episodes) == 0 {
return res, errors.New("no danmaku library found for this video")
}
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
res.AnimeTitle = candidates[0].AnimeTitle
res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle
res.EpisodeID = candidates[0].Episodes[0].EpisodeID
res.MatchMode = "search"
} }
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
res.AnimeTitle = candidates[0].AnimeTitle
res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle
res.EpisodeID = candidates[0].Episodes[0].EpisodeID
res.MatchMode = "search"
}
raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target) raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target)
if err != nil { if err != nil {
@@ -787,7 +787,7 @@ func (s *DanmakuService) matchOfficial(ctx context.Context, fileName, fileHash s
return nil, fmt.Errorf("danmaku match returned HTTP %d", resp.StatusCode) return nil, fmt.Errorf("danmaku match returned HTTP %d", resp.StatusCode)
} }
var out struct { var out struct {
Success bool `json:"success"` Success bool `json:"success"`
Matches []danmakuMatch `json:"matches"` Matches []danmakuMatch `json:"matches"`
} }
if err := json.Unmarshal(raw, &out); err != nil { if err := json.Unmarshal(raw, &out); err != nil {
+4
View File
@@ -168,6 +168,10 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
} }
} }
if containsEmbyFilter(p.Filters, "IsResumable") {
return e.resumableItems(ctx, p)
}
if len(p.IDs) > 0 { if len(p.IDs) > 0 {
items := make([]map[string]any, 0, len(p.IDs)) items := make([]map[string]any, 0, len(p.IDs))
for _, id := range p.IDs { for _, id := range p.IDs {
+38 -7
View File
@@ -11,6 +11,9 @@ import (
// Item 单条目详情。 // Item 单条目详情。
func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[string]any, error) { func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[string]any, error) {
if e == nil {
return nil, nil
}
// 远程 Emby 条目:不查本地库,直接向远程转发(保持远程最新元数据)。 // 远程 Emby 条目:不查本地库,直接向远程转发(保持远程最新元数据)。
if e.remote != nil && IsEmbyRemoteID(mediaID) { if e.remote != nil && IsEmbyRemoteID(mediaID) {
mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID) mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
@@ -174,17 +177,29 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l
// ResumeItems 列出有未完成播放进度的媒体。 // ResumeItems 列出有未完成播放进度的媒体。
func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int) (map[string]any, error) { func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int) (map[string]any, error) {
if limit <= 0 || limit > 100 { return e.resumableItems(ctx, ItemsParams{UserID: userID, Limit: limit})
limit = 20 }
// resumableItems 返回未完成播放进度的媒体(包含本地媒体与挂载的远程媒体),支持分页。
func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
if p.Limit <= 0 || p.Limit > 100 {
p.Limit = 50
} }
if p.StartIndex < 0 {
p.StartIndex = 0
}
if strings.TrimSpace(p.UserID) == "" {
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
}
var hist []model.PlaybackHistory var hist []model.PlaybackHistory
if err := e.repo.DB.WithContext(ctx). if err := e.repo.DB.WithContext(ctx).
Where("user_id = ? AND completed = ? AND position_ms > 0", userID, false). Where("user_id = ? AND completed = ? AND position_ms > 0", p.UserID, false).
Order("watched_at desc").Limit(limit).Find(&hist).Error; err != nil { Order("watched_at desc").Find(&hist).Error; err != nil {
return nil, err return nil, err
} }
if len(hist) == 0 { if len(hist) == 0 {
return map[string]any{"Items": []any{}, "TotalRecordCount": 0}, nil return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
} }
localIDs := make([]string, 0, len(hist)) localIDs := make([]string, 0, len(hist))
@@ -197,7 +212,7 @@ func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int)
if len(localIDs) > 0 { if len(localIDs) > 0 {
var medias []model.Media var medias []model.Media
q := e.repo.DB.WithContext(ctx).Where("id IN ?", localIDs) q := e.repo.DB.WithContext(ctx).Where("id IN ?", localIDs)
q = e.applyUserMediaVisibility(ctx, q, userID) q = e.applyUserMediaVisibility(ctx, q, p.UserID)
if err := q.Find(&medias).Error; err != nil { if err := q.Find(&medias).Error; err != nil {
return nil, err return nil, err
} }
@@ -209,6 +224,9 @@ func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int)
items := make([]map[string]any, 0, len(hist)) items := make([]map[string]any, 0, len(hist))
for _, h := range hist { for _, h := range hist {
if m, ok := byID[h.MediaID]; ok { if m, ok := byID[h.MediaID]; ok {
if p.ParentID != "" && m.LibraryID != p.ParentID && m.SeriesID != p.ParentID {
continue
}
items = append(items, e.itemPayload(ctx, m, false, h.PositionMs)) items = append(items, e.itemPayload(ctx, m, false, h.PositionMs))
continue continue
} }
@@ -224,10 +242,23 @@ func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int)
if err != nil || item == nil { if err != nil || item == nil {
continue continue
} }
if p.ParentID != "" {
parentID, _ := item["ParentId"].(string)
seriesID, _ := item["SeriesId"].(string)
if parentID != p.ParentID && seriesID != p.ParentID && mountID != p.ParentID {
continue
}
}
item["UserData"] = mergedRemoteUserData(item["UserData"], &h) item["UserData"] = mergedRemoteUserData(item["UserData"], &h)
items = append(items, item) items = append(items, item)
} }
return map[string]any{"Items": items, "TotalRecordCount": len(items)}, nil
total := int64(len(items))
if p.StartIndex >= len(items) {
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
}
end := minInt(p.StartIndex+p.Limit, len(items))
return map[string]any{"Items": items[p.StartIndex:end], "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
} }
func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, posMs int64) map[string]any { func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, posMs int64) map[string]any {
+7 -7
View File
@@ -42,11 +42,11 @@ func TestDecodeEmbyRemoteIDRejectsLocalUUIDs(t *testing.T) {
func TestRewriteEmbyRemoteIDs(t *testing.T) { func TestRewriteEmbyRemoteIDs(t *testing.T) {
payload := map[string]any{ payload := map[string]any{
"Id": "item-1", "Id": "item-1",
"ParentId": "folder-1", "ParentId": "folder-1",
"SeriesId": "series-1", "SeriesId": "series-1",
"SeasonId": "season-1", "SeasonId": "season-1",
"PrimaryImageItemId": "item-1", "PrimaryImageItemId": "item-1",
"DisplayPreferencesId": "folder-1", "DisplayPreferencesId": "folder-1",
"ImageTags": map[string]any{ "ImageTags": map[string]any{
"Primary": "item-1", "Primary": "item-1",
@@ -58,8 +58,8 @@ func TestRewriteEmbyRemoteIDs(t *testing.T) {
// MediaSource 的 Id 保持原样(客户端仅作为 MediaSourceId 查询参数)。 // MediaSource 的 Id 保持原样(客户端仅作为 MediaSourceId 查询参数)。
"MediaSources": []any{ "MediaSources": []any{
map[string]any{ map[string]any{
"Id": "ms-9", "Id": "ms-9",
"DirectStreamUrl": "/Videos/item-1/stream", "DirectStreamUrl": "/Videos/item-1/stream",
"MediaStreams": []any{ "MediaStreams": []any{
map[string]any{"Type": "Subtitle", "DeliveryUrl": "/Videos/item-1/Subtitles/2/Stream.srt"}, map[string]any{"Type": "Subtitle", "DeliveryUrl": "/Videos/item-1/Subtitles/2/Stream.srt"},
}, },
+4 -4
View File
@@ -49,11 +49,11 @@ func TestRewriteSubtitleDeliveryURLsFallsBackIndexOne(t *testing.T) {
} }
rewriteSubtitleDeliveryURLs(src, "/Videos/embyremote~acct-1~item-1", &EmbyRemoteConfig{}) rewriteSubtitleDeliveryURLs(src, "/Videos/embyremote~acct-1~item-1", &EmbyRemoteConfig{})
streams := src["MediaStreams"].([]any) streams := src["MediaStreams"].([]any)
want := "/Videos/embyremote~acct-1~item-1/Subtitles/1/Stream" want := "/Videos/embyremote~acct-1~item-1/Subtitles/1/Stream"
if got := streams[0].(map[string]any)["DeliveryUrl"]; got != want { if got := streams[0].(map[string]any)["DeliveryUrl"]; got != want {
t.Fatalf("subtitle DeliveryUrl = %v, want %v", got, want) t.Fatalf("subtitle DeliveryUrl = %v, want %v", got, want)
}
} }
}
func TestMapRemoteItemToMediaExtractsCodecsAndContainer(t *testing.T) { func TestMapRemoteItemToMediaExtractsCodecsAndContainer(t *testing.T) {
r := &EmbyRemoteService{} r := &EmbyRemoteService{}
+17
View File
@@ -87,6 +87,23 @@ func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string
// runtimeTicks 缺失时回退到 media.DurationSec // runtimeTicks 缺失时回退到 media.DurationSec
if m, _ := e.repo.Media.FindByID(ctx, mediaID); m != nil { if m, _ := e.repo.Media.FindByID(ctx, mediaID); m != nil {
dur = int64(m.DurationSec) * 1000 dur = int64(m.DurationSec) * 1000
} else if IsEmbyRemoteID(mediaID) {
// 远程挂载条目:尝试从既有历史记录或远程详情补齐时长
var oldHist model.PlaybackHistory
if err := e.repo.DB.WithContext(ctx).Where("user_id = ? AND media_id = ?", userID, mediaID).First(&oldHist).Error; err == nil && oldHist.DurationMs > 0 {
dur = oldHist.DurationMs
} else if e.remote != nil {
mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
if mount, acct, _ := e.remote.ResolveMount(ctx, mountID); mount != nil && acct != nil {
if item, _ := e.remote.RemoteItem(ctx, mount, acct, remoteID); item != nil {
if ticks, ok := item["RunTimeTicks"].(float64); ok && ticks > 0 {
dur = int64(ticks) / 10_000
} else if ticks, ok := item["RunTimeTicks"].(int64); ok && ticks > 0 {
dur = ticks / 10_000
}
}
}
}
} }
} }
completed := dur > 0 && pos >= dur*9/10 completed := dur > 0 && pos >= dur*9/10
+29
View File
@@ -79,3 +79,32 @@ func TestRemoteItemMapsFindsEnvelopeItems(t *testing.T) {
t.Fatalf("first item ID = %#v, want %q", items[0]["Id"], remoteID) t.Fatalf("first item ID = %#v, want %q", items[0]["Id"], remoteID)
} }
} }
func TestRecordProgressFallbacksToExistingHistoryDuration(t *testing.T) {
svc := newTestEmbyService(t)
remoteID := EncodeEmbyRemoteID("mount-test", "item-999")
user := &model.User{Username: "resume_test_user", Role: "user", Tier: "free", IsActive: true}
if err := svc.repo.User.Create(t.Context(), user); err != nil {
t.Fatalf("create user: %v", err)
}
// 先以有 runtimeTicks 写入首次进度
if err := svc.RecordProgress(t.Context(), user.ID, remoteID, 10_000_000, 100_000_000); err != nil {
t.Fatalf("first record progress: %v", err)
}
// 再次上报,但某些客户端此时发了 0 runtimeTicks
if err := svc.RecordProgress(t.Context(), user.ID, remoteID, 95_000_000, 0); err != nil {
t.Fatalf("second record progress: %v", err)
}
var hist model.PlaybackHistory
if err := svc.repo.DB.Where("user_id = ? AND media_id = ?", user.ID, remoteID).First(&hist).Error; err != nil {
t.Fatalf("find hist: %v", err)
}
if hist.DurationMs != 10_000 {
t.Fatalf("expected duration 10000ms, got %d", hist.DurationMs)
}
if !hist.Completed {
t.Fatalf("expected 95%% progress to be completed")
}
}
@@ -111,4 +111,3 @@ func safeZipTarget(destRoot, name string) (string, error) {
} }
return targetAbs, nil return targetAbs, nil
} }
-1
View File
@@ -78,4 +78,3 @@ func TestStartInstallRejectsConcurrent(t *testing.T) {
svc.running = false svc.running = false
svc.mu.Unlock() svc.mu.Unlock()
} }
+4 -4
View File
@@ -50,11 +50,11 @@ func TestResolveSSLMaterial(t *testing.T) {
} }
cases := []struct { cases := []struct {
name string name string
content string content string
path string path string
want string want string
err bool err bool
}{ }{
{name: "content only", content: certPEM, want: certPEM}, {name: "content only", content: certPEM, want: certPEM},
{name: "path only", path: path, want: certPEM}, {name: "path only", path: path, want: certPEM},
-1
View File
@@ -93,4 +93,3 @@ func (p *ImageProxy) Prune() (PruneImageCacheResult, error) {
maxBytes := int64(p.cfg.Cache.ImagesMaxSizeMB) * 1024 * 1024 maxBytes := int64(p.cfg.Cache.ImagesMaxSizeMB) * 1024 * 1024
return PruneImageCache(p.cacheDir, maxBytes) return PruneImageCache(p.cacheDir, maxBytes)
} }
+3 -3
View File
@@ -179,9 +179,9 @@ func TestManualSearchFallsBackToMovieFolderForGenericQuery(t *testing.T) {
if len(results) != 1 || results[0].TMDbID != 27205 { if len(results) != 1 || results[0].TMDbID != 27205 {
t.Fatalf("manual search results=%#v, want folder fallback candidate; queries=%v", results, queries) t.Fatalf("manual search results=%#v, want folder fallback candidate; queries=%v", results, queries)
} }
if len(queries) < 2 || queries[0] != "00000" || queries[len(queries)-1] != "inception" { if len(queries) < 2 || queries[0] != "00000" || queries[len(queries)-1] != "inception" {
t.Fatalf("manual search queries=%v, want explicit query then folder fallback", queries) t.Fatalf("manual search queries=%v, want explicit query then folder fallback", queries)
} }
} }
func TestManualSearchReturnsMovieFallbackForTVTypedTMDbSearch(t *testing.T) { func TestManualSearchReturnsMovieFallbackForTVTypedTMDbSearch(t *testing.T) {
+4 -4
View File
@@ -117,10 +117,10 @@ func (s *MediaService) DeleteLibrary(ctx context.Context, id string) error {
if err := tx.Unscoped().Where("library_id = ?", id).Delete(&model.Media{}).Error; err != nil { if err := tx.Unscoped().Where("library_id = ?", id).Delete(&model.Media{}).Error; err != nil {
return err return err
} }
if err := hardDeleteLibraryRoots(ctx, tx, id); err != nil { if err := hardDeleteLibraryRoots(ctx, tx, id); err != nil {
return err return err
} }
return tx.Unscoped().Delete(&model.Library{}, "id = ?", id).Error return tx.Unscoped().Delete(&model.Library{}, "id = ?", id).Error
}) })
if err == nil { if err == nil {
s.invalidateMediaCache(ctx) s.invalidateMediaCache(ctx)
@@ -229,9 +229,9 @@ func (o *OrganizerService) replaceVersions(ctx context.Context, src string, exis
o.log.Warn("organize replace remove existing failed", o.log.Warn("organize replace remove existing failed",
zap.String("path", e), zap.Error(err)) zap.String("path", e), zap.Error(err))
} }
if o.repo != nil && o.repo.DB != nil { if o.repo != nil && o.repo.DB != nil {
_ = o.repo.DB.WithContext(ctx).Unscoped().Where("path = ?", e).Delete(&model.Media{}).Error _ = o.repo.DB.WithContext(ctx).Unscoped().Where("path = ?", e).Delete(&model.Media{}).Error
} }
} }
// Move staged file + sidecars into the final path. // Move staged file + sidecars into the final path.
if err := os.Rename(stage, dst); err != nil { if err := os.Rename(stage, dst); err != nil {
+23 -4
View File
@@ -19,8 +19,9 @@ import (
// PlaybackService bundles history / favourite / playlist business logic. // PlaybackService bundles history / favourite / playlist business logic.
type PlaybackService struct { type PlaybackService struct {
log *zap.Logger log *zap.Logger
repo *repository.Container repo *repository.Container
remote *EmbyRemoteService
} }
// NewPlaybackService is the constructor. // NewPlaybackService is the constructor.
@@ -28,6 +29,14 @@ func NewPlaybackService(log *zap.Logger, repo *repository.Container) *PlaybackSe
return &PlaybackService{log: log, repo: repo} return &PlaybackService{log: log, repo: repo}
} }
// SetEmbyRemote wires the remote Emby service for hydrating mounted remote items.
func (p *PlaybackService) SetEmbyRemote(remote *EmbyRemoteService) *PlaybackService {
if p != nil {
p.remote = remote
}
return p
}
// ─── History ──────────────────────────────────────────────────────────────── // ─── History ────────────────────────────────────────────────────────────────
// RecordProgress upserts the resume position for a (user, media) pair. A // RecordProgress upserts the resume position for a (user, media) pair. A
@@ -84,9 +93,19 @@ func (p *PlaybackService) RecentHistory(ctx context.Context, userID string, limi
if m, ok := mediaByID[rows[i].MediaID]; ok { if m, ok := mediaByID[rows[i].MediaID]; ok {
media := m media := m
items = append(items, HistoryItem{PlaybackHistory: rows[i], Media: &media}) items = append(items, HistoryItem{PlaybackHistory: rows[i], Media: &media})
} else { continue
items = append(items, HistoryItem{PlaybackHistory: rows[i]})
} }
if p.remote != nil && IsEmbyRemoteID(rows[i].MediaID) {
mountID, remoteID, _ := DecodeEmbyRemoteID(rows[i].MediaID)
if mount, acct, _ := p.remote.ResolveMount(ctx, mountID); mount != nil && acct != nil {
if rm, err := p.remote.RemoteMediaDetail(ctx, mount, acct, remoteID); err == nil && rm != nil {
media := *rm
items = append(items, HistoryItem{PlaybackHistory: rows[i], Media: &media})
continue
}
}
}
items = append(items, HistoryItem{PlaybackHistory: rows[i]})
} }
return items, nil return items, nil
} }
+10 -10
View File
@@ -106,18 +106,18 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) {
cfg.App.SSLCert = value cfg.App.SSLCert = value
case "https.key": case "https.key":
cfg.App.SSLKey = value cfg.App.SSLKey = value
case "https.cert_path": case "https.cert_path":
cfg.App.SSLCertPath = strings.TrimSpace(value) cfg.App.SSLCertPath = strings.TrimSpace(value)
case "https.key_path": case "https.key_path":
cfg.App.SSLKeyPath = strings.TrimSpace(value) cfg.App.SSLKeyPath = strings.TrimSpace(value)
case "cache.images_max_size_mb": case "cache.images_max_size_mb":
if n, err := strconv.Atoi(value); err == nil { if n, err := strconv.Atoi(value); err == nil {
if n < 0 { if n < 0 {
n = 0 n = 0
}
cfg.Cache.ImagesMaxSizeMB = n
} }
cfg.Cache.ImagesMaxSizeMB = n
} }
}
} }
// ParseBoolSetting is the exported variant of parseBoolSetting for handlers // ParseBoolSetting is the exported variant of parseBoolSetting for handlers
-1
View File
@@ -231,4 +231,3 @@ func (s *SchedulerService) jobCleanImageCache(ctx context.Context) error {
} }
return nil return nil
} }
+11 -11
View File
@@ -59,13 +59,13 @@ type Container struct {
Device *DeviceService Device *DeviceService
Cache *RuntimeCacheService Cache *RuntimeCacheService
Sessions *SessionTrackerService Sessions *SessionTrackerService
RecognitionWords *RecognitionWordsService RecognitionWords *RecognitionWordsService
Danmaku *DanmakuService Danmaku *DanmakuService
Strm *StrmService Strm *StrmService
Database *DatabaseAdminService Database *DatabaseAdminService
FFTools *FFmpegToolsService FFTools *FFmpegToolsService
stopCtx context.Context stopCtx context.Context
stopCancel context.CancelFunc stopCancel context.CancelFunc
// ReloadHTTPServer 由 cmd/server 注入。HTTPS 相关设置保存后,handler // ReloadHTTPServer 由 cmd/server 注入。HTTPS 相关设置保存后,handler
@@ -110,12 +110,12 @@ func (c *Container) Boot() {
c.Strm.Start(c.stopCtx) c.Strm.Start(c.stopCtx)
} }
// 启动刮削队列后台消费者 // 启动刮削队列后台消费者
if c.Scraper != nil { if c.Scraper != nil {
c.Scraper.Start(c.stopCtx) c.Scraper.Start(c.stopCtx)
} }
// Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。 // Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。
// 每天触发一次评估;规则里的窗口可随机,不固定。 // 每天触发一次评估;规则里的窗口可随机,不固定。
if c.Device != nil { if c.Device != nil {
go c.runInactivitySweeper(c.stopCtx) go c.runInactivitySweeper(c.stopCtx)
+1 -1
View File
@@ -110,7 +110,7 @@ func (b *serviceContainerBuilder) initContentServices() {
b.c.Backup = NewBackupService(b.cfg, b.log, b.repos.DB) 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) b.c.Media = NewMediaService(b.cfg, b.log, b.repos).SetRuntimeCache(b.c.Cache)
b.c.Stream = NewStreamService(b.cfg, b.log, b.repos, b.c.Transcoder) b.c.Stream = NewStreamService(b.cfg, b.log, b.repos, b.c.Transcoder)
b.c.Playback = NewPlaybackService(b.log, b.repos) b.c.Playback = NewPlaybackService(b.log, b.repos).SetEmbyRemote(b.c.EmbyRemote)
b.c.Subtitle = NewSubtitleService(b.cfg, b.log, b.repos) b.c.Subtitle = NewSubtitleService(b.cfg, b.log, b.repos)
b.c.Profile = NewProfileService(b.log, b.repos) b.c.Profile = NewProfileService(b.log, b.repos)
b.c.Audit = NewAuditService(b.log, b.repos) b.c.Audit = NewAuditService(b.log, b.repos)
+10 -10
View File
@@ -22,18 +22,18 @@ func TestNormalizeCloudPlayTarget(t *testing.T) {
if parsed.IsAbs() || parsed.Host != "" { if parsed.IsAbs() || parsed.Host != "" {
t.Fatalf("normalized target should be relative, got %q", got) t.Fatalf("normalized target should be relative, got %q", got)
} }
if parsed.Query().Get("ref") != ref { if parsed.Query().Get("ref") != ref {
t.Fatalf("ref round-trip failed: %q", parsed.Query().Get("ref")) t.Fatalf("ref round-trip failed: %q", parsed.Query().Get("ref"))
} }
strmStale := "http://bwg.linkmy.fun:1314/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123" strmStale := "http://bwg.linkmy.fun:1314/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123"
gotStrm := normalizeCloudPlayTarget(strmStale) gotStrm := normalizeCloudPlayTarget(strmStale)
wantStrm := "/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123" wantStrm := "/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123"
if gotStrm != wantStrm { if gotStrm != wantStrm {
t.Fatalf("normalizeCloudPlayTarget(strm) = %q, want %q", gotStrm, wantStrm) t.Fatalf("normalizeCloudPlayTarget(strm) = %q, want %q", gotStrm, wantStrm)
} }
// 非云盘播放 URL 保持原样(WebDAV/直链等)。 // 非云盘播放 URL 保持原样(WebDAV/直链等)。
passthrough := "https://dav.example.com/media/file.mkv" passthrough := "https://dav.example.com/media/file.mkv"
if got := normalizeCloudPlayTarget(passthrough); got != passthrough { if got := normalizeCloudPlayTarget(passthrough); got != passthrough {
t.Fatalf("non-cloud target should pass through, got %q", got) t.Fatalf("non-cloud target should pass through, got %q", got)
+1 -1
View File
@@ -91,7 +91,7 @@ type StrmService struct {
oauthSessions map[string]*strm115AuthSession oauthSessions map[string]*strm115AuthSession
wafUntil time.Time // 115 风控/限流熔断截止时间(由 mu 保护) wafUntil time.Time // 115 风控/限流熔断截止时间(由 mu 保护)
downloadSem chan struct{} // 全局下载并发信号量:限制整个进程同时进行「换直链+下载」的并发数 downloadSem chan struct{} // 全局下载并发信号量:限制整个进程同时进行「换直链+下载」的并发数
downloadSemOnce sync.Once downloadSemOnce sync.Once
} }
+2 -1
View File
@@ -575,7 +575,8 @@ export function ContinueWatchingSection({ history }: { history: HistoryItem[] })
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }} style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
> >
{history.slice(0, 12).map((h) => { {history.slice(0, 12).map((h) => {
const media = h.media! const media = h.media
if (!media) return null
const progress = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0 const progress = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0
return ( return (
<div key={h.id} className="w-64 sm:w-72 shrink-0"> <div key={h.id} className="w-64 sm:w-72 shrink-0">
+45 -1
View File
@@ -64,6 +64,8 @@ export function PlayerPage() {
const [playerError, setPlayerError] = useState('') const [playerError, setPlayerError] = useState('')
// 「客户端直连解码」模式:宿主机不转码,播放器强制 direct play、隐藏 HLS 切换。 // 「客户端直连解码」模式:宿主机不转码,播放器强制 direct play、隐藏 HLS 切换。
const [directOnly, setDirectOnly] = useState(false) const [directOnly, setDirectOnly] = useState(false)
const [resumePosition, setResumePosition] = useState(0)
const [initialSeekDone, setInitialSeekDone] = useState(false)
// 弹幕控制:状态来自 /api/danmaku/config 初始值,用户在面板里实时调整。 // 弹幕控制:状态来自 /api/danmaku/config 初始值,用户在面板里实时调整。
const [danmakuOpen, setDanmakuOpen] = useState(false) const [danmakuOpen, setDanmakuOpen] = useState(false)
@@ -254,7 +256,44 @@ export function PlayerPage() {
return () => teardownHls(media.id, mode === 'hls') return () => teardownHls(media.id, mode === 'hls')
}, [hlsUnavailable, media, mode, params, setParams, teardownHls]) }, [hlsUnavailable, media, mode, params, setParams, teardownHls])
// Persist resume position every 10 seconds while playing. // 自动拉取已有的播放进度并恢复播放位置
useEffect(() => {
if (!id) return
setResumePosition(0)
setInitialSeekDone(false)
playbackAPI
.recentHistory()
.then((items) => {
const found = items.find((h) => h.media_id === id)
if (found && found.position_ms > 2000 && !found.completed) {
setResumePosition(found.position_ms / 1000)
}
})
.catch(() => undefined)
}, [id])
useEffect(() => {
const video = ref.current
if (!video || !resumePosition || initialSeekDone) return
const applyResume = () => {
if (resumePosition > 0 && Math.abs(video.currentTime - resumePosition) > 2) {
video.currentTime = resumePosition
setInitialSeekDone(true)
const m = Math.floor(resumePosition / 60)
const s = Math.floor(resumePosition % 60)
const timeStr = `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
toast.success(`已恢复上次播放进度至 ${timeStr}`, { duration: 2500 })
}
}
if (video.readyState >= 1) {
applyResume()
} else {
video.addEventListener('loadedmetadata', applyResume, { once: true })
return () => video.removeEventListener('loadedmetadata', applyResume)
}
}, [resumePosition, initialSeekDone])
// Persist resume position every 10 seconds while playing, and immediately upon pause/unmount.
useEffect(() => { useEffect(() => {
if (!media || !ref.current) return if (!media || !ref.current) return
const video = ref.current const video = ref.current
@@ -273,6 +312,11 @@ export function PlayerPage() {
return () => { return () => {
video.removeEventListener('timeupdate', handler) video.removeEventListener('timeupdate', handler)
video.removeEventListener('pause', handler) video.removeEventListener('pause', handler)
const positionMs = Math.floor(video.currentTime * 1000)
const durationMs = Math.floor((video.duration || 0) * 1000)
if (positionMs > 0 && media) {
playbackAPI.recordProgress(media.id, positionMs, durationMs).catch(() => undefined)
}
} }
}, [media]) }, [media])
+8 -3
View File
@@ -6,7 +6,8 @@ import toast from 'react-hot-toast'
import { historyAPI } from '../api/history' import { historyAPI } from '../api/history'
import { imageURL } from '../api/client' import { imageURL } from '../api/client'
import { confirmAction } from '../components/confirmAction' import { confirmAction } from '../components/confirmAction'
import type { HistoryItem } from '../types' import { isRemoteEmbyID } from '../utils/remoteEmby'
import type { HistoryItem, Media } from '../types'
function fmtDuration(ms: number): string { function fmtDuration(ms: number): string {
if (!ms || ms <= 0) return '—' if (!ms || ms <= 0) return '—'
@@ -90,8 +91,12 @@ export function WatchHistoryPage() {
<div className="space-y-3"> <div className="space-y-3">
{items.map((h) => { {items.map((h) => {
const m = h.media const m: Media = h.media || ({
if (!m) return null id: h.media_id,
title: isRemoteEmbyID(h.media_id) ? '远程媒体' : h.media_id,
poster_url: '',
updated_at: h.watched_at,
} as Media)
const progress = const progress =
h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0 h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0
return ( return (