diff --git a/internal/config/types.go b/internal/config/types.go
index a8cdee5..2bfe35e 100644
--- a/internal/config/types.go
+++ b/internal/config/types.go
@@ -44,11 +44,11 @@ type TranscoderConfig struct {
// AppConfig 保存运行时应用参数。
type AppConfig struct {
- Port int `mapstructure:"port"`
- Debug bool `mapstructure:"debug"`
- Env string `mapstructure:"env"`
- DataDir string `mapstructure:"data_dir"`
- WebDir string `mapstructure:"web_dir"`
+ Port int `mapstructure:"port"`
+ Debug bool `mapstructure:"debug"`
+ Env string `mapstructure:"env"`
+ DataDir string `mapstructure:"data_dir"`
+ WebDir string `mapstructure:"web_dir"`
// HTTPSEnabled 是否仅通过 HTTPS 提供访问。启用时必须同时配置
// SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS。
HTTPSEnabled bool `mapstructure:"https_enabled"`
@@ -59,7 +59,7 @@ type AppConfig struct {
// SSLCertPath 是 SSL 证书文件路径;非空时优先于 SSLCert 从文件读取。
SSLCertPath string `mapstructure:"ssl_cert_path"`
// SSLKeyPath 是 SSL 私钥文件路径;非空时优先于 SSLKey 从文件读取。
- SSLKeyPath string `mapstructure:"ssl_key_path"`
+ SSLKeyPath string `mapstructure:"ssl_key_path"`
FFmpegPath string `mapstructure:"ffmpeg_path"`
FFprobePath string `mapstructure:"ffprobe_path"`
// FFprobeMaxConcurrent limits concurrent ffprobe/ffmpeg metadata probes.
diff --git a/internal/database/database_test.go b/internal/database/database_test.go
index 73fb690..db3cc44 100644
--- a/internal/database/database_test.go
+++ b/internal/database/database_test.go
@@ -408,13 +408,13 @@ func TestOpenSQLiteMigrationSourceUsesFallbackSourcePath(t *testing.T) {
_ = sqlDB2.Close()
}
}()
- _, copied, err := copyModelTables(src2, dst, 2)
- if err != nil {
- t.Fatal(err)
- }
- if copied != 2 {
- t.Fatalf("copied rows = %d, want 2", copied)
- }
+ _, copied, err := copyModelTables(src2, dst, 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if copied != 2 {
+ t.Fatalf("copied rows = %d, want 2", copied)
+ }
var userCount int64
if err := dst.Model(&model.User{}).Where("username = ?", "real-admin").Count(&userCount).Error; err != nil {
t.Fatal(err)
diff --git a/internal/handler/admin.go b/internal/handler/admin.go
index 5aa66fe..e61837a 100644
--- a/internal/handler/admin.go
+++ b/internal/handler/admin.go
@@ -192,77 +192,77 @@ func updateUserStatusHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
- if req.IsActive {
- _ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, false)
- } else {
- _ = 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)
+ if req.IsActive {
+ _ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, false)
+ } else {
+ _ = 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)
}
+}
- type adminUpdateUserLibrariesReq struct {
- AllowedLibraryIDs *[]string `json:"allowed_library_ids"`
- }
+type adminUpdateUserLibrariesReq struct {
+ AllowedLibraryIDs *[]string `json:"allowed_library_ids"`
+}
- func updateUserLibrariesHandler(svc *service.Container) gin.HandlerFunc {
- return func(c *gin.Context) {
- var req adminUpdateUserLibrariesReq
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
- userID := c.Param("id")
- user, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- if user == nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
- return
- }
+func updateUserLibrariesHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var req adminUpdateUserLibrariesReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ userID := c.Param("id")
+ user, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ if user == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
+ return
+ }
- var rawJSON string
- if req.AllowedLibraryIDs != nil && len(*req.AllowedLibraryIDs) > 0 {
- var cleanIDs []string
- for _, id := range *req.AllowedLibraryIDs {
- trimmed := strings.TrimSpace(id)
- if 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)
+ var rawJSON string
+ if req.AllowedLibraryIDs != nil && len(*req.AllowedLibraryIDs) > 0 {
+ var cleanIDs []string
+ for _, id := range *req.AllowedLibraryIDs {
+ trimmed := strings.TrimSpace(id)
+ if trimmed != "" {
+ cleanIDs = append(cleanIDs, trimmed)
}
}
-
- 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
+ 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)
}
-
- 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 {
firstAdmin, err := svc.Repo.User.FirstAdmin(ctx)
diff --git a/internal/handler/admin_test.go b/internal/handler/admin_test.go
index 0690001..ecdeb4d 100644
--- a/internal/handler/admin_test.go
+++ b/internal/handler/admin_test.go
@@ -44,10 +44,10 @@ func TestDeleteUserRefusesRecentRealtimeSession(t *testing.T) {
if w.Code != http.StatusConflict {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
- if found, _ := repos.User.FindByID(t.Context(), viewer.ID); found == nil {
- t.Fatal("recent realtime user should not be deleted")
- }
+ if found, _ := repos.User.FindByID(t.Context(), viewer.ID); found == nil {
+ t.Fatal("recent realtime user should not be deleted")
}
+}
func TestUpdateUserLibraries(t *testing.T) {
gin.SetMode(gin.TestMode)
diff --git a/internal/handler/emby_mounted_resume_test.go b/internal/handler/emby_mounted_resume_test.go
new file mode 100644
index 0000000..2194e51
--- /dev/null
+++ b/internal/handler/emby_mounted_resume_test.go
@@ -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
+}
diff --git a/internal/handler/emby_mounts.go b/internal/handler/emby_mounts.go
index 937c8cd..8b2f0f3 100644
--- a/internal/handler/emby_mounts.go
+++ b/internal/handler/emby_mounts.go
@@ -219,4 +219,4 @@ func reorderEmbyMountsHandler(svc *service.Container) gin.HandlerFunc {
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
-}
\ No newline at end of file
+}
diff --git a/internal/handler/emby_playstate_handlers.go b/internal/handler/emby_playstate_handlers.go
index d8e528c..a0d283c 100644
--- a/internal/handler/emby_playstate_handlers.go
+++ b/internal/handler/emby_playstate_handlers.go
@@ -12,8 +12,13 @@ import (
type embyPlayingReq struct {
ItemId string `json:"ItemId"`
+ ItemIDLower string `json:"itemId"`
+ ID string `json:"Id"`
+ IDLower string `json:"id"`
PositionTicks int64 `json:"PositionTicks"`
+ PositionLower int64 `json:"positionTicks"`
RunTimeTicks int64 `json:"RunTimeTicks"`
+ RunTimeLower int64 `json:"runTimeTicks"`
}
func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
@@ -25,16 +30,25 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
}
var req embyPlayingReq
_ = c.ShouldBindJSON(&req)
- if req.ItemId == "" {
- req.ItemId = c.Query("ItemId")
+ itemID := embyFirstNonEmptyString(req.ItemId, req.ItemIDLower, req.ID, req.IDLower)
+ if itemID == "" {
+ itemID = embyFirstNonEmptyString(firstQueryValue(c, "ItemId", "itemId", "Id", "id"))
}
- if req.PositionTicks == 0 {
- req.PositionTicks, _ = strconv.ParseInt(c.Query("PositionTicks"), 10, 64)
+ pos := req.PositionTicks
+ if pos == 0 {
+ pos = req.PositionLower
}
- if req.RunTimeTicks == 0 {
- req.RunTimeTicks, _ = strconv.ParseInt(c.Query("RunTimeTicks"), 10, 64)
+ if pos == 0 {
+ 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)
return
}
@@ -43,7 +57,7 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
c.Status(http.StatusUnauthorized)
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()})
return
}
@@ -54,9 +68,9 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
clientInfo.DeviceName,
clientInfo.Client,
c.ClientIP(),
- req.ItemId,
- req.PositionTicks,
- req.RunTimeTicks,
+ itemID,
+ pos,
+ runTime,
stopped)
}
if svc.Device != nil && !stopped {
diff --git a/internal/handler/emby_routes.go b/internal/handler/emby_routes.go
index f29590e..b6091c5 100644
--- a/internal/handler/emby_routes.go
+++ b/internal/handler/emby_routes.go
@@ -161,6 +161,8 @@ func registerEmbyAuthenticatedItemRoutes(auth *gin.RouterGroup, svc *service.Con
auth.GET("/Users/:userId/Items/Counts", embyItemsCountsHandler(svc))
auth.GET("/Items/Latest", embyLatestItemsHandler(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("/Users/:userId/Items/:id", embyUserItemByIDHandler(svc))
auth.GET("/Shows/:id/Seasons", embyShowSeasonsHandler(svc))
diff --git a/internal/handler/emby_routes_lowercase.go b/internal/handler/emby_routes_lowercase.go
index 9246b25..60beee8 100644
--- a/internal/handler/emby_routes_lowercase.go
+++ b/internal/handler/emby_routes_lowercase.go
@@ -32,6 +32,8 @@ func registerLowercaseEmbyItemRoutes(auth *gin.RouterGroup, svc *service.Contain
auth.GET("/users/:userId/items/counts", embyItemsCountsHandler(svc))
auth.GET("/items/latest", embyLatestItemsHandler(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("/users/:userId/items/:id", embyUserItemByIDHandler(svc))
auth.GET("/shows/:id/seasons", embyShowSeasonsHandler(svc))
diff --git a/internal/handler/emby_sessions.go b/internal/handler/emby_sessions.go
index d36ca8a..befa6fb 100644
--- a/internal/handler/emby_sessions.go
+++ b/internal/handler/emby_sessions.go
@@ -41,7 +41,17 @@ func embySessionsHandler(svc *service.Container) gin.HandlerFunc {
"SupportsRemoteControl": true,
}
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)
}
diff --git a/internal/handler/playback_extra_test.go b/internal/handler/playback_extra_test.go
index 235100b..cabe5d2 100644
--- a/internal/handler/playback_extra_test.go
+++ b/internal/handler/playback_extra_test.go
@@ -401,17 +401,17 @@ 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/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
- }
+ 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)
@@ -478,4 +478,3 @@ func TestListSubtitlesForRemoteEmbyMediaReturnsEmptyTracks(t *testing.T) {
t.Fatalf("expected empty tracks array, got %v", payload.Tracks)
}
}
-
diff --git a/internal/handler/routes_admin.go b/internal/handler/routes_admin.go
index c8df559..2f349a8 100644
--- a/internal/handler/routes_admin.go
+++ b/internal/handler/routes_admin.go
@@ -49,11 +49,11 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
// Emby 挂载管理:远程 Emby 媒体库挂载(账号复用 strm/accounts)
admin.GET("/emby/accounts/:id/views", embyAccountViewsHandler(svc))
admin.POST("/emby/accounts/:id/full-mount", fullMountEmbyAccountHandler(svc))
- admin.GET("/emby/mounts", listEmbyMountsHandler(svc))
- admin.POST("/emby/mounts", createEmbyMountsHandler(svc))
- admin.PUT("/emby/mounts/reorder", reorderEmbyMountsHandler(svc))
- admin.PUT("/emby/mounts/:id", updateEmbyMountHandler(svc))
- admin.DELETE("/emby/mounts/:id", deleteEmbyMountHandler(svc))
+ admin.GET("/emby/mounts", listEmbyMountsHandler(svc))
+ admin.POST("/emby/mounts", createEmbyMountsHandler(svc))
+ admin.PUT("/emby/mounts/reorder", reorderEmbyMountsHandler(svc))
+ admin.PUT("/emby/mounts/:id", updateEmbyMountHandler(svc))
+ admin.DELETE("/emby/mounts/:id", deleteEmbyMountHandler(svc))
admin.GET("/strm/accounts", listStrmAccountsHandler(svc))
admin.POST("/strm/accounts", createStrmAccountHandler(svc))
diff --git a/internal/handler/strm_routes_test.go b/internal/handler/strm_routes_test.go
index 2775e85..5b4af18 100644
--- a/internal/handler/strm_routes_test.go
+++ b/internal/handler/strm_routes_test.go
@@ -50,19 +50,19 @@ func TestStrmAdminRoutesAreRegistered(t *testing.T) {
"GET /api/admin/strm/downloads",
"POST /api/admin/strm/downloads/:id/cancel",
"POST /api/admin/strm/downloads/:id/retry",
- "POST /api/admin/strm/downloads/clear-finished",
- "POST /api/admin/strm/downloads/clear-canceled",
- "POST /api/admin/strm/downloads/retry-failed",
- "POST /api/admin/strm/downloads/cancel-pending",
- "GET /api/admin/strm/uploads",
- "POST /api/admin/strm/uploads/:id/cancel",
- "POST /api/admin/strm/uploads/:id/retry",
- "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",
+ "POST /api/admin/strm/downloads/clear-finished",
+ "POST /api/admin/strm/downloads/clear-canceled",
+ "POST /api/admin/strm/downloads/retry-failed",
+ "POST /api/admin/strm/downloads/cancel-pending",
+ "GET /api/admin/strm/uploads",
+ "POST /api/admin/strm/uploads/:id/cancel",
+ "POST /api/admin/strm/uploads/:id/retry",
+ "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] {
t.Fatalf("%s route is not registered", want)
diff --git a/internal/handler/watch_history.go b/internal/handler/watch_history.go
index a5e3e08..918df9a 100644
--- a/internal/handler/watch_history.go
+++ b/internal/handler/watch_history.go
@@ -125,6 +125,18 @@ func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
for _, r := range rows {
m, ok := mIdx[r.MediaID]
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
}
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"})
return
}
- res := q.Unscoped().Delete(&model.PlaybackHistory{})
+ res := q.Unscoped().Delete(&model.PlaybackHistory{})
if err := res.Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
diff --git a/internal/model/emby_mount.go b/internal/model/emby_mount.go
index 5fc6e1a..d957698 100644
--- a/internal/model/emby_mount.go
+++ b/internal/model/emby_mount.go
@@ -9,12 +9,12 @@ package model
// EmbyMount 是远程 Emby 服务器上一个媒体库(View)的挂载配置。
type EmbyMount struct {
Base
- 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
- RemoteViewName string `gorm:"size:255" json:"remote_view_name"` // 远程媒体库原名(展示冗余)
- CollectionType string `gorm:"size:32" json:"collection_type"` // movies / tvshows / music ...
- Name string `gorm:"size:255" json:"name,omitempty"` // 覆盖显示名(可选,默认「账号 · 库名」)
- SortOrder int `gorm:"default:0;index" json:"sort_order"` // 手动排序用,越小越靠前
- ProxyPlay bool `gorm:"default:false" json:"proxy_play"` // 该挂载播放流量是否经 MMTL 反向代理
- Enabled bool `gorm:"default:true" json:"enabled"` // 是否在媒体库中展示
-}
\ No newline at end of file
+ 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
+ RemoteViewName string `gorm:"size:255" json:"remote_view_name"` // 远程媒体库原名(展示冗余)
+ CollectionType string `gorm:"size:32" json:"collection_type"` // movies / tvshows / music ...
+ Name string `gorm:"size:255" json:"name,omitempty"` // 覆盖显示名(可选,默认「账号 · 库名」)
+ SortOrder int `gorm:"default:0;index" json:"sort_order"` // 手动排序用,越小越靠前
+ ProxyPlay bool `gorm:"default:false" json:"proxy_play"` // 该挂载播放流量是否经 MMTL 反向代理
+ Enabled bool `gorm:"default:true" json:"enabled"` // 是否在媒体库中展示
+}
diff --git a/internal/model/library_media.go b/internal/model/library_media.go
index 677325d..de46ec7 100644
--- a/internal/model/library_media.go
+++ b/internal/model/library_media.go
@@ -8,8 +8,8 @@ type Library struct {
Type string `gorm:"size:16;not null;default:movie" json:"type"` // movie / tv / anime / music
CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"`
Enabled bool `gorm:"default:true" json:"enabled"`
- SortOrder int `gorm:"index;default:0" json:"sort_order"` // 手动拖拽排序用,越小越靠前
- CarouselEnabled bool `gorm:"default:false" json:"carousel_enabled"` // 是否参与首页海报轮播(默认不参与)
+ SortOrder int `gorm:"index;default:0" json:"sort_order"` // 手动拖拽排序用,越小越靠前
+ CarouselEnabled bool `gorm:"default:false" json:"carousel_enabled"` // 是否参与首页海报轮播(默认不参与)
Roots []LibraryRoot `gorm:"foreignKey:LibraryID" json:"roots,omitempty"`
}
diff --git a/internal/model/model.go b/internal/model/model.go
index 36c02aa..4b3cf9f 100644
--- a/internal/model/model.go
+++ b/internal/model/model.go
@@ -54,10 +54,10 @@ func AllModels() []interface{} {
&StrmAccount{},
&StrmSyncPath{},
&StrmSyncRecord{},
- &StrmDownloadTask{},
- &StrmUploadTask{},
- &StrmDirCache{},
- &ScrapeTask{},
- &EmbyMount{},
- }
+ &StrmDownloadTask{},
+ &StrmUploadTask{},
+ &StrmDirCache{},
+ &ScrapeTask{},
+ &EmbyMount{},
+ }
}
diff --git a/internal/model/scrape_task.go b/internal/model/scrape_task.go
index 9df33e8..8c829e8 100644
--- a/internal/model/scrape_task.go
+++ b/internal/model/scrape_task.go
@@ -18,8 +18,8 @@ type ScrapeTask struct {
LibraryName string `gorm:"size:128" json:"library_name"`
MediaTitle string `gorm:"size:255;not null" json:"media_title"`
MediaPath string `gorm:"size:1024;not null" json:"media_path"`
- MediaType string `gorm:"size:16" json:"media_type"` // movie / tv / anime / adult
- Provider string `gorm:"size:32" json:"provider"` // tmdb / douban / bangumi / thetvdb / metatube
+ MediaType string `gorm:"size:16" json:"media_type"` // movie / tv / anime / adult
+ Provider string `gorm:"size:32" json:"provider"` // tmdb / douban / bangumi / thetvdb / metatube
MatchedTitle string `gorm:"size:255" json:"matched_title"`
MatchedYear int `json:"matched_year"`
PosterURL string `gorm:"size:1024" json:"poster_url"`
diff --git a/internal/model/strm.go b/internal/model/strm.go
index add4d67..67e90ad 100644
--- a/internal/model/strm.go
+++ b/internal/model/strm.go
@@ -38,17 +38,17 @@ type StrmSyncPath struct {
RemotePath string `gorm:"size:1024" json:"remote_path"` // 远端目录:115=目录ID,OpenList/CD2=路径,local=源目录
LocalPath string `gorm:"size:1024" json:"local_path"` // STRM/元数据本地输出目录
// STRM 链接配置(空值继承全局 strm.* 设置)
- StrmBaseURL string `gorm:"size:512" json:"strm_base_url"` // 覆盖 strm.base_url
- VideoExt string `gorm:"size:512" json:"video_ext"` // 逗号分隔,覆盖 strm.video_ext
- MetaExt string `gorm:"size:512" json:"meta_ext"` // 逗号分隔,覆盖 strm.meta_ext
- ExcludeName string `gorm:"size:512" json:"exclude_name"` // 逗号分隔,文件名包含即跳过
- MinVideoSizeMB int64 `json:"min_video_size_mb"` // 小于该大小(MB)的视频不生成 STRM
- AddPath int `json:"add_path"` // STRM 链接 path 参数:1=完整远端路径 2=仅文件名 3=不带
- DownloadMeta bool `gorm:"default:true" json:"download_meta"` // 同步时下载元数据文件(nfo/图片/字幕)
- UploadMeta bool `json:"upload_meta"` // 同步时把本地元数据上传到远端
- DeleteDir bool `json:"delete_dir"` // 清理多余文件时删除空目录
- Cron string `gorm:"size:128" json:"cron"` // 5 段 cron 表达式(可选)
- EnableCron bool `json:"enable_cron"` // 是否按 Cron 定时同步
+ StrmBaseURL string `gorm:"size:512" json:"strm_base_url"` // 覆盖 strm.base_url
+ VideoExt string `gorm:"size:512" json:"video_ext"` // 逗号分隔,覆盖 strm.video_ext
+ MetaExt string `gorm:"size:512" json:"meta_ext"` // 逗号分隔,覆盖 strm.meta_ext
+ ExcludeName string `gorm:"size:512" json:"exclude_name"` // 逗号分隔,文件名包含即跳过
+ MinVideoSizeMB int64 `json:"min_video_size_mb"` // 小于该大小(MB)的视频不生成 STRM
+ AddPath int `json:"add_path"` // STRM 链接 path 参数:1=完整远端路径 2=仅文件名 3=不带
+ DownloadMeta bool `gorm:"default:true" json:"download_meta"` // 同步时下载元数据文件(nfo/图片/字幕)
+ UploadMeta bool `json:"upload_meta"` // 同步时把本地元数据上传到远端
+ DeleteDir bool `json:"delete_dir"` // 清理多余文件时删除空目录
+ Cron string `gorm:"size:128" json:"cron"` // 5 段 cron 表达式(可选)
+ EnableCron bool `json:"enable_cron"` // 是否按 Cron 定时同步
SyncMode string `gorm:"size:32;default:'incremental'" json:"sync_mode"` // 默认同步模式:incremental / full
Enabled bool `gorm:"default:true" json:"enabled"`
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"`
Path string `gorm:"size:1024" json:"path"` // 相对根目录的路径
}
-
diff --git a/internal/model/user.go b/internal/model/user.go
index f5084f0..a24861c 100644
--- a/internal/model/user.go
+++ b/internal/model/user.go
@@ -23,8 +23,8 @@ type User struct {
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
// AllowedLibraryIDs 存储管理员为该用户指定的受限可访问媒体库 ID 列表(JSON 字符串)。
// 为空时代表不限制(全库可访问)。
- AllowedLibraryIDs string `gorm:"type:text" json:"-"`
- AllowedLibraryList []string `gorm:"-" json:"allowed_library_ids,omitempty"`
+ AllowedLibraryIDs string `gorm:"type:text" json:"-"`
+ AllowedLibraryList []string `gorm:"-" json:"allowed_library_ids,omitempty"`
// ExpiredAt is the account expiry time. Nil means the account never
// expires. When set and in the past, the account is treated as expired
// (login blocked) until an admin or a redemption code renews it.
diff --git a/internal/repository/emby_mount_repository.go b/internal/repository/emby_mount_repository.go
index 0a1eac2..f80af31 100644
--- a/internal/repository/emby_mount_repository.go
+++ b/internal/repository/emby_mount_repository.go
@@ -124,4 +124,4 @@ func (r *EmbyMountRepository) DeleteByAccountID(ctx context.Context, accountID s
return res.Error
})
return count, err
-}
\ No newline at end of file
+}
diff --git a/internal/repository/favorite_repository.go b/internal/repository/favorite_repository.go
index 40862ad..44ca84d 100644
--- a/internal/repository/favorite_repository.go
+++ b/internal/repository/favorite_repository.go
@@ -23,7 +23,7 @@ func (r *FavoriteRepository) Toggle(ctx context.Context, userID, mediaID string)
if err != nil {
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.
diff --git a/internal/repository/repository.go b/internal/repository/repository.go
index cc8d738..0cf8b79 100644
--- a/internal/repository/repository.go
+++ b/internal/repository/repository.go
@@ -30,11 +30,11 @@ type Container struct {
StrmSyncPath *StrmSyncPathRepository
StrmSyncRecord *StrmSyncRecordRepository
StrmDownload *StrmDownloadTaskRepository
- StrmUpload *StrmUploadTaskRepository
- StrmDirCache *StrmDirCacheRepository
- ScrapeTask *ScrapeTaskRepository
+ StrmUpload *StrmUploadTaskRepository
+ StrmDirCache *StrmDirCacheRepository
+ ScrapeTask *ScrapeTaskRepository
EmbyMount *EmbyMountRepository
- }
+}
// New 将每个 repository 连接到单个 *gorm.DB。
func New(db *gorm.DB) *Container {
diff --git a/internal/repository/strm_repository.go b/internal/repository/strm_repository.go
index 29e5852..5b7f381 100644
--- a/internal/repository/strm_repository.go
+++ b/internal/repository/strm_repository.go
@@ -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
})
}
-
-
diff --git a/internal/service/cache_cleanup.go b/internal/service/cache_cleanup.go
index 8119177..bf83ec9 100644
--- a/internal/service/cache_cleanup.go
+++ b/internal/service/cache_cleanup.go
@@ -138,4 +138,3 @@ func PruneImageCache(imagesDir string, maxSizeBytes int64) (PruneImageCacheResul
return result, nil
}
-
diff --git a/internal/service/cloud/cloud.go b/internal/service/cloud/cloud.go
index 9c4cdd1..8c30b5b 100644
--- a/internal/service/cloud/cloud.go
+++ b/internal/service/cloud/cloud.go
@@ -38,11 +38,11 @@ var ErrUnsupported = errors.New("unsupported cloud provider")
// FileEntry is one item in a cloud directory listing.
type FileEntry struct {
- ID string `json:"id"` // provider-native file id
- Name string `json:"name"`
- IsDir bool `json:"is_dir"`
- Size int64 `json:"size"`
- MTime int64 `json:"mtime,omitempty"`
+ ID string `json:"id"` // provider-native file id
+ Name string `json:"name"`
+ IsDir bool `json:"is_dir"`
+ Size int64 `json:"size"`
+ MTime int64 `json:"mtime,omitempty"`
// PickCode is 115-specific; other providers use ID directly.
PickCode string `json:"pick_code,omitempty"`
}
diff --git a/internal/service/cloud/emby.go b/internal/service/cloud/emby.go
index b2677ee..fb9cbc6 100644
--- a/internal/service/cloud/emby.go
+++ b/internal/service/cloud/emby.go
@@ -184,12 +184,12 @@ func (p *embyProvider) ensureToken(ctx context.Context) (string, error) {
// embyItemSummary 目录浏览所需的最小 Emby 条目字段。
type embyItemSummary struct {
- Id string `json:"Id"`
- Name string `json:"Name"`
- Type string `json:"Type"`
- IsFolder bool `json:"IsFolder"`
- ChildCount int `json:"ChildCount"`
- RunTimeTicks int64 `json:"RunTimeTicks"`
+ Id string `json:"Id"`
+ Name string `json:"Name"`
+ Type string `json:"Type"`
+ IsFolder bool `json:"IsFolder"`
+ ChildCount int `json:"ChildCount"`
+ RunTimeTicks int64 `json:"RunTimeTicks"`
}
type embyItemListResponse struct {
@@ -243,4 +243,4 @@ func (p *embyProvider) Resolve(ctx context.Context, fileRef string) (*DirectLink
u := p.apiBase() + "/Videos/" + url.PathEscape(fileRef) + "/stream"
u += "?api_key=" + url.QueryEscape(token) + "&Static=true&MediaSourceId=" + url.QueryEscape(fileRef)
return &DirectLink{URL: u, Headers: map[string]string{"X-Emby-Token": token}, Proxy: p.proxyPlay}, nil
-}
\ No newline at end of file
+}
diff --git a/internal/service/cloud/emby_test.go b/internal/service/cloud/emby_test.go
index f17e693..3d15a4f 100644
--- a/internal/service/cloud/emby_test.go
+++ b/internal/service/cloud/emby_test.go
@@ -59,8 +59,8 @@ func TestEmbyProviderListViewsAndChildren(t *testing.T) {
defer srv.Close()
p, err := New(TypeEmbyRemote, map[string]any{
- "url": srv.URL,
- "api_key": "fixed-token",
+ "url": srv.URL,
+ "api_key": "fixed-token",
"remote_user_id": "user-9",
}, srv.Client())
if err != nil {
@@ -87,8 +87,8 @@ func TestEmbyProviderResolveDirectURLByDefault(t *testing.T) {
defer srv.Close()
p, err := New(TypeEmbyRemote, map[string]any{
- "url": srv.URL,
- "api_key": "fixed-token",
+ "url": srv.URL,
+ "api_key": "fixed-token",
"remote_user_id": "user-9",
}, srv.Client())
if err != nil {
@@ -115,10 +115,10 @@ func TestEmbyProviderResolveProxyWhenConfigured(t *testing.T) {
defer srv.Close()
p, err := New(TypeEmbyRemote, map[string]any{
- "url": srv.URL,
- "api_key": "fixed-token",
+ "url": srv.URL,
+ "api_key": "fixed-token",
"remote_user_id": "user-9",
- "proxy_play": "true",
+ "proxy_play": "true",
}, srv.Client())
if err != nil {
t.Fatal(err)
@@ -133,4 +133,4 @@ func TestEmbyProviderResolveProxyWhenConfigured(t *testing.T) {
if link.URL == "" {
t.Fatal("proxy link must still carry the remote URL")
}
-}
\ No newline at end of file
+}
diff --git a/internal/service/danmaku_credentials.go b/internal/service/danmaku_credentials.go
index 6b66984..f7adbe5 100644
--- a/internal/service/danmaku_credentials.go
+++ b/internal/service/danmaku_credentials.go
@@ -62,4 +62,4 @@ func xorDecode(hexStr string) string {
func dandanplaySignature(appID, appSecret string, ts int64, path string) string {
sum := sha256.Sum256([]byte(appID + strconv.FormatInt(ts, 10) + path + appSecret))
return base64.StdEncoding.EncodeToString(sum[:])
-}
\ No newline at end of file
+}
diff --git a/internal/service/danmaku_credentials_test.go b/internal/service/danmaku_credentials_test.go
index 05eca79..e5ed8e1 100644
--- a/internal/service/danmaku_credentials_test.go
+++ b/internal/service/danmaku_credentials_test.go
@@ -81,4 +81,4 @@ func TestDanmakuCredentialsSelection(t *testing.T) {
require.False(t, ok)
require.Empty(t, id)
require.Empty(t, key)
-}
\ No newline at end of file
+}
diff --git a/internal/service/danmaku_match_test.go b/internal/service/danmaku_match_test.go
index 7ca3448..4356da6 100644
--- a/internal/service/danmaku_match_test.go
+++ b/internal/service/danmaku_match_test.go
@@ -278,72 +278,72 @@ func TestDanmakuSameBase(t *testing.T) {
require.False(t, sameDanmakuBase("", "https://api.dandanplay.net"))
}
- // fetchCommentWithFallback:配置源与官方同源时不重复请求;
- // 全失败时带出最后一跳错误。
- func TestDanmakuFetchCommentWithFallback(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusInternalServerError)
- }))
- t.Cleanup(srv.Close)
+// fetchCommentWithFallback:配置源与官方同源时不重复请求;
+// 全失败时带出最后一跳错误。
+func TestDanmakuFetchCommentWithFallback(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ }))
+ t.Cleanup(srv.Close)
- svc := newDanmakuTestService(t)
- ctx := context.Background()
- raw, st, err := svc.fetchCommentWithFallback(ctx, srv.URL, srv.URL, "25484")
- require.Error(t, err)
- require.Empty(t, raw)
- require.Equal(t, "auto", st)
- }
+ svc := newDanmakuTestService(t)
+ ctx := context.Background()
+ raw, st, err := svc.fetchCommentWithFallback(ctx, srv.URL, srv.URL, "25484")
+ require.Error(t, err)
+ require.Empty(t, raw)
+ require.Equal(t, "auto", st)
+}
- // 视频即便能命中 Hash 自动识别,当用户传入手动搜索关键词时应跳过 Hash 匹配,走关键词搜索。
- func TestDanmakuFetchHashMatchSkippedOnManualKeyword(t *testing.T) {
- videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
+// 视频即便能命中 Hash 自动识别,当用户传入手动搜索关键词时应跳过 Hash 匹配,走关键词搜索。
+func TestDanmakuFetchHashMatchSkippedOnManualKeyword(t *testing.T) {
+ videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
- // 官方服务同时提供 match 和 search:
- // match 会返回 episodeId=25484(动画A)
- // search 会根据关键词返回 episodeId=99999(动画B)
- mux := http.NewServeMux()
- var matchCalled bool
- mux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
- matchCalled = true
- w.Header().Set("Content-Type", "application/json")
- 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) {
- w.Header().Set("Content-Type", "application/json")
- 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) {
- w.Header().Set("Content-Type", "application/xml")
- fmt.Fprint(w, `