mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 11:46:36 +08:00
完善应用功能与界面交互
This commit is contained in:
@@ -3,6 +3,7 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -14,7 +15,13 @@ import (
|
||||
// specific danmaku library chosen by the user after a disambiguation.
|
||||
func getDanmakuHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
res, err := svc.Danmaku.Fetch(c.Request.Context(), c.Param("id"), c.Query("kw"), c.Query("episodeId"))
|
||||
uid := currentUserID(c)
|
||||
// 弹幕合并偏好按用户存储:这里读取后作为本次抓取的选项传入。
|
||||
opts := service.DanmakuFetchOptions{
|
||||
MergeSources: svc.Danmaku.MergeSourcesEnabled(c.Request.Context(), uid),
|
||||
}
|
||||
res, err := svc.Danmaku.FetchWithOptions(
|
||||
c.Request.Context(), c.Param("id"), c.Query("kw"), c.Query("episodeId"), opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -28,6 +35,34 @@ func getDanmakuHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// admin privileges.
|
||||
func getDanmakuConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, svc.Danmaku.Config(c.Request.Context()))
|
||||
c.JSON(http.StatusOK, svc.Danmaku.ConfigForUser(c.Request.Context(), currentUserID(c)))
|
||||
}
|
||||
}
|
||||
|
||||
// updateDanmakuSettingsHandler 持久化当前用户的弹幕偏好。目前只有合并开关,
|
||||
// 落在 user 表上(与字幕简繁偏好同样按用户存储)。
|
||||
func updateDanmakuSettingsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := currentUserID(c)
|
||||
if strings.TrimSpace(uid) == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
MergeSources *bool `json:"merge_sources"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
if req.MergeSources == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "merge_sources is required"})
|
||||
return
|
||||
}
|
||||
if err := svc.Danmaku.SetMergeSources(c.Request.Context(), uid, *req.MergeSources); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"merge_sources": *req.MergeSources})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// newDanmakuSettingsContext 构造带登录用户的最小 gin 上下文。
|
||||
func newDanmakuSettingsContext(t *testing.T, svc *service.Container, method, path, body string, userID string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
if userID != "" {
|
||||
c.Set(middleware.CtxUserID, userID)
|
||||
}
|
||||
return c, w
|
||||
}
|
||||
|
||||
func newDanmakuSettingsService(t *testing.T) *service.Container {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Setting{}, &model.Media{}, &model.User{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
user := model.User{Username: "settings-user", PasswordHash: "x", Role: "user", IsActive: true}
|
||||
user.ID = "user-1"
|
||||
if err := repos.User.Create(t.Context(), &user); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
return &service.Container{
|
||||
Repo: repos,
|
||||
Danmaku: service.NewDanmakuService(zap.NewNop(), repos),
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsPersistsMergeSources(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||
`{"merge_sources":true}`, "user-1")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
MergeSources bool `json:"merge_sources"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !resp.MergeSources {
|
||||
t.Fatal("response should echo merge_sources=true")
|
||||
}
|
||||
// 落库校验:重新读取应为 true。
|
||||
if !svc.Danmaku.MergeSourcesEnabled(t.Context(), "user-1") {
|
||||
t.Fatal("merge preference was not persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsRejectsMissingField(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", `{}`, "user-1")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsRequiresAuthentication(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||
`{"merge_sources":true}`, "")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// config 接口应把当前用户的合并偏好带出去,供面板初始化。
|
||||
func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodGet, "/danmaku/config", "", "user-1")
|
||||
getDanmakuConfigHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var cfg struct {
|
||||
MergeSources bool `json:"merge_sources"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if cfg.MergeSources {
|
||||
t.Fatal("default merge preference should be false")
|
||||
}
|
||||
|
||||
if err := svc.Danmaku.SetMergeSources(t.Context(), "user-1", true); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
c2, w2 := newDanmakuSettingsContext(t, svc, http.MethodGet, "/danmaku/config", "", "user-1")
|
||||
getDanmakuConfigHandler(svc)(c2)
|
||||
if err := json.Unmarshal(w2.Body.Bytes(), &cfg); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !cfg.MergeSources {
|
||||
t.Fatal("config should reflect the persisted merge preference")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// newEmbyCompatTestRouter 构造一个挂载了完整 Emby 路由表的测试引擎。
|
||||
func newEmbyCompatTestRouter(t *testing.T, secret string) *gin.Engine {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
})
|
||||
return router
|
||||
}
|
||||
|
||||
func TestEmbyAdditionalPartsReturnsEmptyArray(t *testing.T) {
|
||||
const secret = "test-secret"
|
||||
router := newEmbyCompatTestRouter(t, secret)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/Videos/msgo-series-abc/AdditionalParts", nil)
|
||||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
// 必须命中 AdditionalParts 静态路由并返回空数组,而不是被 /Videos/:id/:seg
|
||||
// 的 HLS 兜底路由接走返回空 404。
|
||||
var payload []any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode body %q: %v", w.Body.String(), err)
|
||||
}
|
||||
if len(payload) != 0 {
|
||||
t.Fatalf("expected an empty array, got %v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyItemImagesWithoutTypeReturnsArray(t *testing.T) {
|
||||
router := newEmbyCompatTestRouter(t, "test-secret")
|
||||
|
||||
// 不带 Type 的图片清单接口是公开路由,不要求 token,与带 Type 的
|
||||
// 图片字节流一致(客户端缓存 URL 时会丢 token)。
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/Items/unknown-item/Images", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct == "" || ct[:16] != "application/json" {
|
||||
t.Fatalf("content type = %q, want application/json", ct)
|
||||
}
|
||||
var payload []any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode body %q: %v", w.Body.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyItemImagesLowerCaseRouteIsRegistered(t *testing.T) {
|
||||
router := newEmbyCompatTestRouter(t, "test-secret")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/items/unknown-item/images", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyUserImageWithoutAvatarReturnsCacheableNotFound(t *testing.T) {
|
||||
router := newEmbyCompatTestRouter(t, "test-secret")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/Users/user-1/Images/Primary", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// 用户没有头像时 Emby 同样返回 404,但响应必须可缓存,否则客户端会
|
||||
// 在每次进入设置页时重复请求(线上曾观察到每分钟一次的重试)。
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if cc := w.Header().Get("Cache-Control"); cc != "public, max-age=86400" {
|
||||
t.Fatalf("Cache-Control = %q, want the cacheable directive", cc)
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,49 @@ func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// embyItemImagesHandler 处理不带 Type 的 GET /Items/{Id}/Images,返回图片
|
||||
// 清单(Emby 的 ImageInfo 数组)。客户端据此决定详情页加载哪些图。
|
||||
func embyItemImagesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
if svc == nil || svc.Emby == nil || id == "" {
|
||||
c.JSON(http.StatusOK, []any{})
|
||||
return
|
||||
}
|
||||
infos := svc.Emby.ImageInfos(c.Request.Context(), id)
|
||||
if infos == nil {
|
||||
infos = []map[string]any{}
|
||||
}
|
||||
c.JSON(http.StatusOK, infos)
|
||||
}
|
||||
}
|
||||
|
||||
// embyUserImageHandler 处理 /Users/{UserId}/Images/{Type}。Emby 对未设置
|
||||
// 头像的用户同样返回 404,但响应必须带缓存头,否则客户端每次进入设置页
|
||||
// 都会重复请求同一个空头像(日志中曾观察到每分钟重试)。
|
||||
func embyUserImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := strings.TrimSpace(c.Param("userId"))
|
||||
raw := ""
|
||||
if svc != nil && svc.Emby != nil && uid != "" {
|
||||
raw = svc.Emby.UserAvatarURL(c.Request.Context(), uid)
|
||||
}
|
||||
if raw == "" || svc == nil || svc.ImageProxy == nil {
|
||||
embyMissingAvatar(c)
|
||||
return
|
||||
}
|
||||
if err := svc.ImageProxy.Serve(c.Request.Context(), c.Writer, c.Request, raw); err != nil {
|
||||
embyMissingAvatar(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// embyMissingAvatar 以 Emby 语义返回"该用户没有头像",并允许客户端长期缓存。
|
||||
func embyMissingAvatar(c *gin.Context) {
|
||||
c.Header("Cache-Control", "public, max-age=86400")
|
||||
c.Status(http.StatusNotFound)
|
||||
}
|
||||
|
||||
func clearEmbyImageNoStoreHeaders(c *gin.Context) {
|
||||
c.Writer.Header().Del("Cache-Control")
|
||||
c.Writer.Header().Del("Pragma")
|
||||
|
||||
@@ -115,12 +115,24 @@ func registerEmbyPublicClientRoutes(grp *gin.RouterGroup, jwtSecret string, svc
|
||||
|
||||
func registerEmbyPublicImageRoutes(grp *gin.RouterGroup, svc *service.Container) {
|
||||
// 图片公开(Infuse 缓存 URL 时会丢 token)
|
||||
//
|
||||
// 不带 Type 的 /Items/{Id}/Images 返回图片清单(ImageInfo 数组),与下面
|
||||
// 带 Type 的图片字节流是不同接口,必须单独注册,否则会落到 NoRoute 并
|
||||
// 返回 text/plain 的 404。
|
||||
grp.GET("/Items/:id/Images", embyItemImagesHandler(svc))
|
||||
grp.HEAD("/Items/:id/Images", embyItemImagesHandler(svc))
|
||||
grp.GET("/items/:id/images", embyItemImagesHandler(svc))
|
||||
grp.GET("/Items/:id/Images/:type", embyItemImageHandler(svc))
|
||||
grp.GET("/Items/:id/Images/:type/:index", embyItemImageHandler(svc))
|
||||
grp.HEAD("/Items/:id/Images/:type", embyItemImageHandler(svc))
|
||||
grp.GET("/items/:id/images/:type", embyItemImageHandler(svc))
|
||||
grp.GET("/items/:id/images/:type/:index", embyItemImageHandler(svc))
|
||||
grp.HEAD("/items/:id/images/:type", embyItemImageHandler(svc))
|
||||
// 用户头像。没有头像时返回带缓存头的 404,避免客户端反复重试。
|
||||
grp.GET("/Users/:userId/Images/:type", embyUserImageHandler(svc))
|
||||
grp.HEAD("/Users/:userId/Images/:type", embyUserImageHandler(svc))
|
||||
grp.GET("/users/:userId/images/:type", embyUserImageHandler(svc))
|
||||
grp.HEAD("/users/:userId/images/:type", embyUserImageHandler(svc))
|
||||
}
|
||||
|
||||
func registerEmbyGetRoutes(grp *gin.RouterGroup, svc *service.Container, paths []string, factory embyRouteHandlerFactory) {
|
||||
@@ -202,6 +214,12 @@ func registerEmbyAuthenticatedPlaybackRoutes(auth *gin.RouterGroup, prefix strin
|
||||
auth.POST("/Users/:userId/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
|
||||
|
||||
registerEmbyVideoStreamRoutes(auth, svc, "/Videos")
|
||||
// Emby 官方接口:附加片段清单。MeBox 不提供附加片段,但必须返回空数组
|
||||
// 而不是 404 —— 部分客户端(RodelPlayer)在详情页无条件请求它,404 会
|
||||
// 让它们把条目判定为不完整。必须注册成静态段,否则会被
|
||||
// /Videos/:id/:seg 的 HLS 兜底路由抢先匹配并返回空 404。
|
||||
auth.GET("/Videos/:id/AdditionalParts", embyEmptyArrayHandler(svc))
|
||||
auth.HEAD("/Videos/:id/AdditionalParts", embyEmptyArrayHandler(svc))
|
||||
auth.GET("/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
|
||||
auth.HEAD("/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
|
||||
auth.GET("/Users/:userId/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
|
||||
|
||||
@@ -12,6 +12,7 @@ func registerAuthedUISurfaceRoutes(authed *gin.RouterGroup, svc *service.Contain
|
||||
|
||||
authed.GET("/danmaku/:id", getDanmakuHandler(svc))
|
||||
authed.GET("/danmaku/config", getDanmakuConfigHandler(svc))
|
||||
authed.PUT("/danmaku/settings", updateDanmakuSettingsHandler(svc))
|
||||
|
||||
authed.GET("/watch-history", historyListHandler(svc))
|
||||
authed.GET("/watch-history/stats", historyStatsHandler(svc))
|
||||
|
||||
Reference in New Issue
Block a user