添加新功能,完善项目

This commit is contained in:
truewhile
2026-09-22 16:22:29 +08:00
parent 47899284a5
commit b5ce4646cf
85 changed files with 7103 additions and 982 deletions
+22
View File
@@ -20,6 +20,12 @@ type settingReq struct {
Value string `json:"value"`
}
// maskedSettingKeys 里的设置值绝不能被完整下发:它们是可用于对外操作的凭据。
// 下发脱敏值,保存时再靠 isMaskedSettingValue 还原为「保持原值」。
var maskedSettingKeys = map[string]bool{
service.SettingTelegramBotToken: true,
}
func listSettingsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
settings, err := svc.Repo.Setting.All(c.Request.Context())
@@ -27,10 +33,21 @@ func listSettingsHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
for i := range settings {
if maskedSettingKeys[settings[i].Key] {
settings[i].Value = service.MaskSecret(settings[i].Value)
}
}
c.JSON(http.StatusOK, settings)
}
}
// isMaskedSettingValue 识别「前端把脱敏值原样提交回来」的情况。此时必须保留
// 已存的真实值,否则一次保存就会把凭据覆盖成 ***。
func isMaskedSettingValue(value string) bool {
return strings.Contains(value, "***")
}
func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req settingReq
@@ -38,6 +55,11 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 脱敏值回传 == 用户没改这个凭据,保留库里已存的真实值。
if maskedSettingKeys[req.Key] && isMaskedSettingValue(req.Value) {
c.Status(http.StatusNoContent)
return
}
oldValue := ""
if req.Key == service.AdultLibraryIDsSettingKey {
oldValue, _ = svc.Repo.Setting.Get(c.Request.Context(), req.Key)
+173
View File
@@ -0,0 +1,173 @@
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
// 设备管理接口。
//
// 普通用户只能操作自己的设备(路由挂在 /me 下,用户 ID 始终取自会话);
// 管理员通过 /admin/users/:id/devices 代管任意用户。两组接口共用同一份
// DeviceService,因此「谁上线过、谁被踢掉」只有一处事实来源。
// deviceListPayload 是设备列表的下发形状。Fingerprint 不外发:它是防共享
// 判定用的内部标识,暴露出去只会方便伪造。
type deviceListPayload struct {
Devices []devicePayload `json:"devices"`
}
type devicePayload struct {
ID string `json:"id"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name,omitempty"`
Client string `json:"client,omitempty"`
LastIP string `json:"last_ip,omitempty"`
LastSeenAt string `json:"last_seen_at,omitempty"`
LastPlayAt string `json:"last_play_at,omitempty"`
Kicked bool `json:"kicked"`
Online bool `json:"online"`
Playing bool `json:"playing"`
Warnings int `json:"warnings"`
}
func toDevicePayload(d model.UserDevice) devicePayload {
out := devicePayload{
ID: d.ID,
DeviceID: d.DeviceID,
DeviceName: d.DeviceName,
Client: d.Client,
LastIP: d.LastIP,
Kicked: d.Kicked,
Online: d.Online,
Playing: d.Playing,
Warnings: d.Warnings,
}
if !d.LastSeenAt.IsZero() {
out.LastSeenAt = d.LastSeenAt.Format("2006-01-02T15:04:05Z07:00")
}
if d.LastPlayAt != nil && !d.LastPlayAt.IsZero() {
out.LastPlayAt = d.LastPlayAt.Format("2006-01-02T15:04:05Z07:00")
}
return out
}
func deviceListResponse(devices []model.UserDevice) deviceListPayload {
items := make([]devicePayload, 0, len(devices))
for _, d := range devices {
items = append(items, toDevicePayload(d))
}
return deviceListPayload{Devices: items}
}
// myDevicesHandler 返回当前会话用户的设备列表。
func myDevicesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := sessionUserID(c)
devices, err := svc.Device.ListDevices(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, deviceListResponse(devices))
}
}
// myKickDeviceHandler 踢掉当前用户的一台设备。
func myKickDeviceHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := sessionUserID(c)
deviceID := strings.TrimSpace(c.Param("deviceID"))
if deviceID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "device id required"})
return
}
if err := svc.Device.KickDevice(c.Request.Context(), userID, deviceID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
// myKickAllDevicesHandler 踢掉当前用户的全部设备。
func myKickAllDevicesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := sessionUserID(c)
if err := svc.Device.KickAllDevices(c.Request.Context(), userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
// adminUserDevicesHandler 返回指定用户的设备列表。
func adminUserDevicesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := strings.TrimSpace(c.Param("id"))
// FindByID 对「不存在」返回 (nil, nil),必须判空而不是判 error,
// 否则「用户不存在」会伪装成「该用户没有设备」的空列表。
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
}
devices, err := svc.Device.ListDevices(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, deviceListResponse(devices))
}
}
// adminKickUserDeviceHandler 由管理员踢掉指定用户的一台设备。
func adminKickUserDeviceHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := strings.TrimSpace(c.Param("id"))
deviceID := strings.TrimSpace(c.Param("deviceID"))
if deviceID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "device id required"})
return
}
if err := svc.Device.KickDevice(c.Request.Context(), userID, deviceID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
// adminKickAllUserDevicesHandler 由管理员踢掉指定用户的全部设备。
func adminKickAllUserDevicesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := strings.TrimSpace(c.Param("id"))
if err := svc.Device.KickAllDevices(c.Request.Context(), userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
// sessionUserID 读取会话用户 ID。调用方路由都挂在鉴权中间件之后,因此这里
// 只做类型断言兜底,不做权限判断。
func sessionUserID(c *gin.Context) string {
if v, ok := c.Get(middleware.CtxUserID); ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
+232
View File
@@ -0,0 +1,232 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"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"
)
// newDeviceTestEnv 搭一个只挂设备/Telegram 路由的最小环境,并预置两个用户,
// 用于验证「只能操作自己的设备」这条边界。
func newDeviceTestEnv(t *testing.T) (*gin.Engine, *service.Container) {
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.User{}, &model.UserDevice{}, &model.Setting{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
svc := &service.Container{Repo: repos, Log: zap.NewNop()}
svc.Device = service.NewDeviceService(zap.NewNop(), repos)
svc.Device.SetSessionTracker(service.NewSessionTrackerService(zap.NewNop()))
svc.Telegram = service.NewTelegramService(zap.NewNop(), repos)
const secret = "test-secret"
router := gin.New()
authed := router.Group("/api", func(c *gin.Context) {
// 测试里直接注入会话身份,绕开真实 JWT 解析。
if uid := c.GetHeader("X-Test-User"); uid != "" {
c.Set(middleware.CtxUserID, uid)
c.Set(middleware.CtxUserRole, c.GetHeader("X-Test-Role"))
}
c.Next()
})
authed.GET("/me/devices", myDevicesHandler(svc))
authed.POST("/me/devices/kick-all", myKickAllDevicesHandler(svc))
authed.POST("/me/devices/:deviceID/kick", myKickDeviceHandler(svc))
authed.GET("/me/telegram", getTelegramStatusHandler(svc))
authed.POST("/me/telegram/bind-code", startTelegramBindHandler(svc))
authed.DELETE("/me/telegram", unbindTelegramHandler(svc))
authed.GET("/admin/users/:id/devices", adminUserDevicesHandler(svc))
authed.POST("/admin/users/:id/devices/:deviceID/kick", adminKickUserDeviceHandler(svc))
_ = secret
return router, svc
}
func seedDeviceUsers(t *testing.T, svc *service.Container) {
t.Helper()
ctx := context.Background()
for _, id := range []string{"user-a", "user-b"} {
if err := svc.Repo.User.Create(ctx, &model.User{
Base: model.Base{ID: id}, Username: id, PasswordHash: "x", Role: "user", IsActive: true,
}); err != nil {
t.Fatal(err)
}
}
}
func doJSON(t *testing.T, router *gin.Engine, method, path, userID, role string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, nil)
req.Header.Set("X-Test-User", userID)
if role != "" {
req.Header.Set("X-Test-Role", role)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
// /me/devices 只能返回调用者自己的设备。
func TestMyDevicesScopedToCaller(t *testing.T) {
router, svc := newDeviceTestEnv(t)
seedDeviceUsers(t, svc)
ctx := context.Background()
svc.Device.RecordLogin(ctx, "user-a", "dev-a", "A-Phone", "Infuse", "1.1.1.1")
svc.Device.RecordLogin(ctx, "user-b", "dev-b", "B-Phone", "Infuse", "2.2.2.2")
w := doJSON(t, router, http.MethodGet, "/api/me/devices", "user-a", "user")
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
var payload struct {
Devices []struct {
DeviceID string `json:"device_id"`
} `json:"devices"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if len(payload.Devices) != 1 || payload.Devices[0].DeviceID != "dev-a" {
t.Fatalf("devices = %+v, want only dev-a", payload.Devices)
}
// 设备指纹属于内部判定标识,不能下发。
if strings.Contains(w.Body.String(), "fingerprint") {
t.Fatalf("response must not expose fingerprint: %s", w.Body.String())
}
}
// 踢别人的设备必须失败:/me 路由用会话身份,deviceID 属于他人时查不到。
func TestKickForeignDeviceFails(t *testing.T) {
router, svc := newDeviceTestEnv(t)
seedDeviceUsers(t, svc)
svc.Device.RecordLogin(context.Background(), "user-b", "dev-b", "B-Phone", "Infuse", "2.2.2.2")
w := doJSON(t, router, http.MethodPost, "/api/me/devices/dev-b/kick", "user-a", "user")
if w.Code == http.StatusNoContent {
t.Fatal("user-a must not be able to kick user-b's device")
}
}
func TestMyKickOwnDeviceSucceeds(t *testing.T) {
router, svc := newDeviceTestEnv(t)
seedDeviceUsers(t, svc)
svc.Device.RecordLogin(context.Background(), "user-a", "dev-a", "A-Phone", "Infuse", "1.1.1.1")
w := doJSON(t, router, http.MethodPost, "/api/me/devices/dev-a/kick", "user-a", "user")
if w.Code != http.StatusNoContent {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
}
// 管理员接口对不存在的用户返回 404,避免把「用户不存在」和「用户没有设备」
// 混成同一个空列表。
func TestAdminDevicesUnknownUserReturns404(t *testing.T) {
router, svc := newDeviceTestEnv(t)
seedDeviceUsers(t, svc)
w := doJSON(t, router, http.MethodGet, "/api/admin/users/nope/devices", "admin-1", "admin")
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", w.Code)
}
}
func TestTelegramStatusAndBindCode(t *testing.T) {
router, svc := newDeviceTestEnv(t)
seedDeviceUsers(t, svc)
w := doJSON(t, router, http.MethodGet, "/api/me/telegram", "user-a", "user")
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
if !strings.Contains(w.Body.String(), `"bound":false`) {
t.Fatalf("body = %s, want bound=false", w.Body.String())
}
w = doJSON(t, router, http.MethodPost, "/api/me/telegram/bind-code", "user-a", "user")
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
var code struct {
Code string `json:"code"`
ExpiresIn int `json:"expires_in_seconds"`
}
if err := json.Unmarshal(w.Body.Bytes(), &code); err != nil {
t.Fatal(err)
}
if len(code.Code) != 6 {
t.Fatalf("code = %q, want 6 chars", code.Code)
}
if code.ExpiresIn <= 0 {
t.Fatalf("expires_in_seconds = %d, want > 0", code.ExpiresIn)
}
}
func TestAdminSettingsMasksBotToken(t *testing.T) {
router, svc := newDeviceTestEnv(t)
ctx := context.Background()
if err := svc.Repo.Setting.Set(ctx, service.SettingTelegramBotToken, "123456:AAHsecretTOKEN"); err != nil {
t.Fatal(err)
}
router.GET("/api/admin/settings", listSettingsHandler(svc))
router.PUT("/api/admin/settings", updateSettingHandler(svc))
w := doJSON(t, router, http.MethodGet, "/api/admin/settings", "admin-1", "admin")
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
if strings.Contains(w.Body.String(), "AAHsecretTOKEN") {
t.Fatalf("bot token leaked: %s", w.Body.String())
}
if !strings.Contains(w.Body.String(), "***") {
t.Fatalf("bot token should be masked: %s", w.Body.String())
}
}
// 把脱敏值原样提交回来时,必须保留库里真实 Token —— 否则一次保存就把凭据毁掉。
func TestSavingMaskedTokenKeepsRealValue(t *testing.T) {
router, svc := newDeviceTestEnv(t)
ctx := context.Background()
const real = "123456:AAHsecretTOKEN"
if err := svc.Repo.Setting.Set(ctx, service.SettingTelegramBotToken, real); err != nil {
t.Fatal(err)
}
router.PUT("/api/admin/settings", updateSettingHandler(svc))
body := `{"key":"telegram.bot_token","value":"12***EN"}`
req := httptest.NewRequest(http.MethodPut, "/api/admin/settings", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Test-User", "admin-1")
req.Header.Set("X-Test-Role", "admin")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
got, err := svc.Repo.Setting.Get(ctx, service.SettingTelegramBotToken)
if err != nil {
t.Fatal(err)
}
if got != real {
t.Fatalf("stored token = %q, want the original value preserved", got)
}
}
+87
View File
@@ -0,0 +1,87 @@
package handler
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
// Emby 发现类接口的 handler:NextUp / Similar / Genres。
//
// 这三个接口此前返回空列表,导致第三方客户端首页「接下来播放」、详情页
// 「相似推荐」、按类型浏览全部为空白。它们必须始终返回 200 + 合法信封,
// 因为客户端在首页刷新时会并发请求,任何 4xx/5xx 都会被判定为服务端异常。
func embyNextUpHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := embyScopedUserID(c)
if userID == "" {
c.JSON(http.StatusOK, embyEmptyItemsPayload())
return
}
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), ""))
out, err := svc.Emby.NextUp(c.Request.Context(), userID, limit)
if err != nil {
c.JSON(http.StatusOK, embyEmptyItemsPayload())
return
}
embyAttachRequestTokenToMediaSources(c, out)
c.JSON(http.StatusOK, out)
}
}
func embySimilarHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
mediaID := strings.TrimSpace(c.Param("id"))
if mediaID == "" {
c.JSON(http.StatusOK, embyEmptyItemsPayload())
return
}
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), ""))
out, err := svc.Emby.SimilarItems(c.Request.Context(), mediaID, embyEffectiveUserID(c), limit)
if err != nil {
c.JSON(http.StatusOK, embyEmptyItemsPayload())
return
}
embyAttachRequestTokenToMediaSources(c, out)
c.JSON(http.StatusOK, out)
}
}
func embyGenresHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
parentID := firstQueryValue(c, "ParentId", "parentId", "parentid")
out, err := svc.Emby.Genres(c.Request.Context(), embyEffectiveUserID(c), parentID)
if err != nil {
c.JSON(http.StatusOK, embyEmptyItemsPayload())
return
}
c.JSON(http.StatusOK, out)
}
}
// embyScopedUserID 解析「按用户请求」的 Emby 接口的生效用户。
//
// 路由上带 :userId 时(/Users/{uid}/Shows/NextUp),只允许查询自己:客户端
// 偶尔会带着别人的 id 请求,直接采信等于开放他人观看历史的读取。管理员同样
// 按自己处理,避免出现一条无人使用的越权路径。
func embyScopedUserID(c *gin.Context) string {
caller := embyEffectiveUserID(c)
requested := strings.TrimSpace(c.Param("userId"))
if requested == "" {
return caller
}
if requested == caller {
return caller
}
return ""
}
// embyEmptyItemsPayload 与 embyEmptyItemsHandler 保持同一形状。
func embyEmptyItemsPayload() gin.H {
return gin.H{"Items": []any{}, "TotalRecordCount": 0}
}
+244
View File
@@ -0,0 +1,244 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"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"
)
// newEmbyDiscoveryEnv 搭一个跑在内存库上的 Emby 路由环境。
func newEmbyDiscoveryEnv(t *testing.T) (*gin.Engine, *service.Container, string) {
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.User{}, &model.Library{}, &model.Media{}, &model.PlaybackHistory{},
&model.Setting{}, &model.Favorite{}, &model.UserDevice{},
); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "test-secret"
svc := &service.Container{Repo: repos, Log: zap.NewNop()}
svc.Emby = service.NewEmbyService(cfg, zap.NewNop(), repos).
SetDiscovery(service.NewMediaDiscoveryService(zap.NewNop(), repos))
const userID = "user-1"
if err := repos.User.Create(context.Background(), &model.User{
Base: model.Base{ID: userID}, Username: "tester", PasswordHash: "x",
Role: "user", IsActive: true,
}); err != nil {
t.Fatal(err)
}
router := gin.New()
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, svc)
return router, svc, userID
}
func seedEmbyLibrary(t *testing.T, svc *service.Container, typ string) string {
t.Helper()
lib := &model.Library{Name: "库-" + typ, Path: "/media/" + typ, Type: typ, Enabled: true}
if err := svc.Repo.Library.Create(context.Background(), lib); err != nil {
t.Fatal(err)
}
return lib.ID
}
func embyGet(t *testing.T, router *gin.Engine, path, token string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
if token != "" {
req.Header.Set("X-Emby-Token", token)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
func decodeItemsEnvelope(t *testing.T, body []byte) []map[string]any {
t.Helper()
var payload struct {
Items []map[string]any `json:"Items"`
TotalRecordCount int64 `json:"TotalRecordCount"`
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("decode %s: %v", string(body), err)
}
return payload.Items
}
// NextUp 必须真的返回下一集,而不是空数组。
func TestEmbyNextUpReturnsNextEpisode(t *testing.T) {
router, svc, userID := newEmbyDiscoveryEnv(t)
libID := seedEmbyLibrary(t, svc, "tv")
watchedAt := time.Now().Add(-time.Hour)
for episode, watched := range map[int]bool{1: true, 2: false, 3: false} {
m := &model.Media{
LibraryID: libID, SeriesID: "series-1", Title: "剧一",
SeasonNum: 1, EpisodeNum: episode,
Path: "/media/tv/S1E" + string(rune('0'+episode)) + ".mkv",
}
if err := svc.Repo.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
if watched {
h := &model.PlaybackHistory{
UserID: userID, MediaID: m.ID, PositionMs: 1000, DurationMs: 2000,
WatchedAt: watchedAt, Completed: false,
}
if err := svc.Repo.DB.Create(h).Error; err != nil {
t.Fatal(err)
}
}
}
w := embyGet(t, router, "/emby/Shows/NextUp", signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
items := decodeItemsEnvelope(t, w.Body.Bytes())
if len(items) != 1 {
t.Fatalf("items = %d, want 1 (body=%s)", len(items), w.Body.String())
}
if index, ok := items[0]["IndexNumber"].(float64); !ok || int(index) != 2 {
t.Fatalf("IndexNumber = %v, want 2 (body=%s)", items[0]["IndexNumber"], w.Body.String())
}
}
// 没有历史时必须返回合法空信封,不能 404/500。
func TestEmbyNextUpEmptyWithoutHistory(t *testing.T) {
router, _, _ := newEmbyDiscoveryEnv(t)
w := embyGet(t, router, "/emby/Shows/NextUp", signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
if items := decodeItemsEnvelope(t, w.Body.Bytes()); len(items) != 0 {
t.Fatalf("items = %d, want 0", len(items))
}
}
// 小写别名路由同样要走到真实实现(客户端路径大小写并不统一)。
func TestEmbyNextUpLowercaseAlias(t *testing.T) {
router, svc, _ := newEmbyDiscoveryEnv(t)
_ = seedEmbyLibrary(t, svc, "tv")
w := embyGet(t, router, "/emby/shows/nextup", signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
}
// Similar 对不存在的条目返回空列表(客户端详情页会无条件请求)。
func TestEmbySimilarUnknownItemReturnsEmpty(t *testing.T) {
router, _, _ := newEmbyDiscoveryEnv(t)
w := embyGet(t, router, "/emby/Items/does-not-exist/Similar", signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
if items := decodeItemsEnvelope(t, w.Body.Bytes()); len(items) != 0 {
t.Fatalf("items = %d, want 0", len(items))
}
}
func TestEmbySimilarReturnsCandidates(t *testing.T) {
router, svc, _ := newEmbyDiscoveryEnv(t)
libID := seedEmbyLibrary(t, svc, "movie")
source := &model.Media{
LibraryID: libID, Title: "源片", Genres: "Action", Year: 2010, Rating: 8,
Path: "/media/movie/source.mkv",
}
if err := svc.Repo.DB.Create(source).Error; err != nil {
t.Fatal(err)
}
other := &model.Media{
LibraryID: libID, Title: "同类片", Genres: "Action", Year: 2011, Rating: 8,
Path: "/media/movie/other.mkv",
}
if err := svc.Repo.DB.Create(other).Error; err != nil {
t.Fatal(err)
}
w := embyGet(t, router, "/emby/Items/"+source.ID+"/Similar", signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
items := decodeItemsEnvelope(t, w.Body.Bytes())
if len(items) != 1 {
t.Fatalf("items = %d, want 1 (body=%s)", len(items), w.Body.String())
}
if name, _ := items[0]["Name"].(string); name != "同类片" {
t.Fatalf("Name = %q, want 同类片", name)
}
}
// Genres 必须返回真实类型与计数。
func TestEmbyGenresReturnsCounts(t *testing.T) {
router, svc, _ := newEmbyDiscoveryEnv(t)
libID := seedEmbyLibrary(t, svc, "movie")
for i, genres := range []string{"Action,Drama", "Action"} {
m := &model.Media{
LibraryID: libID, Title: "片" + string(rune('A'+i)), Genres: genres,
Path: "/media/movie/m" + string(rune('0'+i)) + ".mkv",
}
if err := svc.Repo.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
}
w := embyGet(t, router, "/emby/Genres", signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
items := decodeItemsEnvelope(t, w.Body.Bytes())
if len(items) != 2 {
t.Fatalf("items = %d, want 2 (body=%s)", len(items), w.Body.String())
}
// 排序按计数降序:Action(2) 在前。
if name, _ := items[0]["Name"].(string); name != "Action" {
t.Fatalf("first Name = %q, want Action", name)
}
if count, ok := items[0]["ItemCount"].(float64); !ok || int(count) != 2 {
t.Fatalf("ItemCount = %v, want 2", items[0]["ItemCount"])
}
if id, _ := items[0]["Id"].(string); len(id) == 0 {
t.Fatal("genre item must carry a stable Id")
}
}
// 按别人的 userId 请求 NextUp 不允许泄露他人历史。
func TestEmbyNextUpRejectsForeignUserID(t *testing.T) {
router, _, _ := newEmbyDiscoveryEnv(t)
w := embyGet(t, router, "/emby/Users/someone-else/Shows/NextUp", signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
if items := decodeItemsEnvelope(t, w.Body.Bytes()); len(items) != 0 {
t.Fatalf("items = %d, want 0", len(items))
}
}
+4 -4
View File
@@ -196,15 +196,15 @@ func registerEmbyAuthenticatedItemRoutes(auth *gin.RouterGroup, svc *service.Con
auth.GET("/Shows/:id/Episodes", embyShowEpisodesHandler(svc))
auth.GET("/Users/:userId/Shows/:id/Seasons", embyShowSeasonsHandler(svc))
auth.GET("/Users/:userId/Shows/:id/Episodes", embyShowEpisodesHandler(svc))
auth.GET("/Shows/NextUp", embyEmptyItemsHandler(svc))
auth.GET("/Users/:userId/Shows/NextUp", embyEmptyItemsHandler(svc))
auth.GET("/Shows/NextUp", embyNextUpHandler(svc))
auth.GET("/Users/:userId/Shows/NextUp", embyNextUpHandler(svc))
auth.GET("/MediaSegments/:id", embyEmptyItemsHandler(svc))
auth.GET("/Artists", embyEmptyItemsHandler(svc))
auth.GET("/Persons", embyEmptyItemsHandler(svc))
auth.GET("/Genres", embyEmptyItemsHandler(svc))
auth.GET("/Genres", embyGenresHandler(svc))
auth.GET("/Shows/Upcoming", embyEmptyItemsHandler(svc))
auth.GET("/Users/:userId/Shows/Upcoming", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/Similar", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/Similar", embySimilarHandler(svc))
auth.GET("/Items/:id/ThumbnailSet", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/ThemeMedia", embyThemeMediaHandler(svc))
auth.GET("/Users/:userId/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc))
+4 -4
View File
@@ -40,15 +40,15 @@ func registerLowercaseEmbyItemRoutes(auth *gin.RouterGroup, svc *service.Contain
auth.GET("/shows/:id/episodes", embyShowEpisodesHandler(svc))
auth.GET("/users/:userId/shows/:id/seasons", embyShowSeasonsHandler(svc))
auth.GET("/users/:userId/shows/:id/episodes", embyShowEpisodesHandler(svc))
auth.GET("/shows/nextup", embyEmptyItemsHandler(svc))
auth.GET("/users/:userId/shows/nextup", embyEmptyItemsHandler(svc))
auth.GET("/shows/nextup", embyNextUpHandler(svc))
auth.GET("/users/:userId/shows/nextup", embyNextUpHandler(svc))
auth.GET("/mediasegments/:id", embyEmptyItemsHandler(svc))
auth.GET("/artists", embyEmptyItemsHandler(svc))
auth.GET("/persons", embyEmptyItemsHandler(svc))
auth.GET("/genres", embyEmptyItemsHandler(svc))
auth.GET("/genres", embyGenresHandler(svc))
auth.GET("/shows/upcoming", embyEmptyItemsHandler(svc))
auth.GET("/users/:userId/shows/upcoming", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/similar", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/similar", embySimilarHandler(svc))
auth.GET("/items/:id/thumbnailset", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/thememedia", embyThemeMediaHandler(svc))
auth.GET("/users/:userId/items/:id/specialfeatures", embyEmptyItemsHandler(svc))
+55
View File
@@ -0,0 +1,55 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
// 媒体库筛选面板接口:facets 提供可选项,random 提供「随便看看」。
//
// 两者都走与列表完全相同的可见性判定(mediaVisibilityForRequest)与筛选解析
// (parseLibraryFilters),因此不会出现「列表里有、facets 里没有」或「随机跳
// 到了筛选条件之外的条目」这类不一致。
func libraryFacetsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
libraryID := c.Param("id")
facets, err := svc.Media.LibraryFacets(
c.Request.Context(),
libraryID,
mediaVisibilityForRequest(c, svc),
svc.Discovery,
)
if err != nil {
writeInternalOrCanceled(c, err)
return
}
c.JSON(http.StatusOK, facets)
}
}
func libraryRandomHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
libraryID := c.Param("id")
filters := parseLibraryFilters(c)
// 随机只取一条,因此不带分页参数;未观看筛选仍需要会话用户。
media, err := svc.Media.RandomMedia(
c.Request.Context(),
libraryID,
mediaVisibilityForRequest(c, svc),
filters,
)
if err != nil {
writeInternalOrCanceled(c, err)
return
}
if media == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no media matches the current filters"})
return
}
c.JSON(http.StatusOK, media)
}
}
+283
View File
@@ -0,0 +1,283 @@
package handler
import (
"context"
"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"
)
func newLibraryFilterEnv(t *testing.T) (*gin.Engine, *service.Container, string, string) {
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.User{}, &model.Library{}, &model.Media{}, &model.PlaybackHistory{}, &model.Setting{},
); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
svc := &service.Container{Repo: repos, Log: zap.NewNop()}
svc.Media = service.NewMediaService(nil, zap.NewNop(), repos)
svc.Discovery = service.NewMediaDiscoveryService(zap.NewNop(), repos)
const userID = "user-1"
if err := repos.User.Create(context.Background(), &model.User{
Base: model.Base{ID: userID}, Username: "tester", PasswordHash: "x", Role: "user", IsActive: true,
}); err != nil {
t.Fatal(err)
}
lib := &model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
if err := repos.Library.Create(context.Background(), lib); err != nil {
t.Fatal(err)
}
router := gin.New()
authed := router.Group("/api", func(c *gin.Context) {
c.Set(middleware.CtxUserID, userID)
c.Set(middleware.CtxUserRole, "user")
c.Next()
})
authed.GET("/libraries/:id/media", listMediaHandler(svc))
authed.GET("/libraries/:id/facets", libraryFacetsHandler(svc))
authed.GET("/libraries/:id/random", libraryRandomHandler(svc))
return router, svc, userID, lib.ID
}
func seedLibraryMedia(t *testing.T, svc *service.Container, rows ...*model.Media) {
t.Helper()
for _, row := range rows {
if err := svc.Repo.DB.Create(row).Error; err != nil {
t.Fatal(err)
}
}
}
func getJSON(t *testing.T, router *gin.Engine, path string) (int, []byte) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w.Code, w.Body.Bytes()
}
func TestLibraryFacetsReturnGenresAndYears(t *testing.T) {
router, svc, _, libID := newLibraryFilterEnv(t)
seedLibraryMedia(t, svc,
&model.Media{LibraryID: libID, Title: "A", Genres: "Action,Drama", Year: 1999, Path: "/a.mkv"},
&model.Media{LibraryID: libID, Title: "B", Genres: "Action", Year: 2021, Path: "/b.mkv"},
)
code, body := getJSON(t, router, "/api/libraries/"+libID+"/facets")
if code != http.StatusOK {
t.Fatalf("status = %d body=%s", code, body)
}
var facets struct {
Genres []struct {
Name string `json:"name"`
Count int `json:"count"`
} `json:"genres"`
YearMin int `json:"year_min"`
YearMax int `json:"year_max"`
}
if err := json.Unmarshal(body, &facets); err != nil {
t.Fatalf("decode %s: %v", body, err)
}
if facets.YearMin != 1999 || facets.YearMax != 2021 {
t.Fatalf("year range = %d..%d, want 1999..2021", facets.YearMin, facets.YearMax)
}
if len(facets.Genres) != 2 {
t.Fatalf("genres = %+v, want 2 entries", facets.Genres)
}
if facets.Genres[0].Name != "Action" || facets.Genres[0].Count != 2 {
t.Fatalf("first genre = %+v, want Action:2", facets.Genres[0])
}
}
// 空库时 facets 必须返回空数组而不是 null,前端无需额外判空。
func TestLibraryFacetsEmptyLibrary(t *testing.T) {
router, _, _, libID := newLibraryFilterEnv(t)
code, body := getJSON(t, router, "/api/libraries/"+libID+"/facets")
if code != http.StatusOK {
t.Fatalf("status = %d body=%s", code, body)
}
if !containsSubstring(string(body), `"genres":[]`) {
t.Fatalf("body = %s, want genres:[]", body)
}
}
// 列表筛选:按类型过滤后只返回命中的条目。
func TestListMediaAppliesGenreFilter(t *testing.T) {
router, svc, _, libID := newLibraryFilterEnv(t)
seedLibraryMedia(t, svc,
&model.Media{LibraryID: libID, Title: "动作", Genres: "Action", Path: "/a.mkv"},
&model.Media{LibraryID: libID, Title: "喜剧", Genres: "Comedy", Path: "/b.mkv"},
)
code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0&genre=Action")
if code != http.StatusOK {
t.Fatalf("status = %d body=%s", code, body)
}
var payload struct {
Items []model.Media `json:"items"`
Total int64 `json:"total"`
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("decode %s: %v", body, err)
}
if payload.Total != 1 || len(payload.Items) != 1 || payload.Items[0].Title != "动作" {
t.Fatalf("payload = %+v, want only 动作", payload)
}
}
// 筛选条件必须进入缓存键:先请求未筛选列表、再筛选时不能命中旧缓存。
func TestListMediaFilterBypassesUnfilteredCache(t *testing.T) {
router, svc, _, libID := newLibraryFilterEnv(t)
seedLibraryMedia(t, svc,
&model.Media{LibraryID: libID, Title: "动作", Genres: "Action", Path: "/a.mkv"},
&model.Media{LibraryID: libID, Title: "喜剧", Genres: "Comedy", Path: "/b.mkv"},
)
// 先拉全量(可能写缓存),再拉筛选结果。
if code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0"); code != http.StatusOK {
t.Fatalf("unfiltered status = %d body=%s", code, body)
}
code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0&genre=Comedy")
if code != http.StatusOK {
t.Fatalf("filtered status = %d body=%s", code, body)
}
var payload struct {
Total int64 `json:"total"`
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatal(err)
}
if payload.Total != 1 {
t.Fatalf("total = %d, want 1 (filtered response must not be served from the unfiltered cache)", payload.Total)
}
}
// 未观看筛选:已看完的不出现,看了一半的仍出现。
func TestListMediaUnwatchedFilter(t *testing.T) {
router, svc, userID, libID := newLibraryFilterEnv(t)
seedLibraryMedia(t, svc,
&model.Media{Base: model.Base{ID: "m-done"}, LibraryID: libID, Title: "看完", Path: "/a.mkv"},
&model.Media{Base: model.Base{ID: "m-half"}, LibraryID: libID, Title: "看一半", Path: "/b.mkv"},
)
for _, h := range []*model.PlaybackHistory{
{UserID: userID, MediaID: "m-done", Completed: true},
{UserID: userID, MediaID: "m-half", Completed: false},
} {
if err := svc.Repo.DB.Create(h).Error; err != nil {
t.Fatal(err)
}
}
code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0&unwatched=1")
if code != http.StatusOK {
t.Fatalf("status = %d body=%s", code, body)
}
var payload struct {
Items []model.Media `json:"items"`
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatal(err)
}
if len(payload.Items) != 1 || payload.Items[0].Title != "看一半" {
t.Fatalf("items = %+v, want only 看一半", payload.Items)
}
}
func TestLibraryRandomReturnsMedia(t *testing.T) {
router, svc, _, libID := newLibraryFilterEnv(t)
seedLibraryMedia(t, svc,
&model.Media{LibraryID: libID, Title: "唯一", Genres: "Action", Path: "/a.mkv"},
)
code, body := getJSON(t, router, "/api/libraries/"+libID+"/random")
if code != http.StatusOK {
t.Fatalf("status = %d body=%s", code, body)
}
var media model.Media
if err := json.Unmarshal(body, &media); err != nil {
t.Fatalf("decode %s: %v", body, err)
}
if media.Title != "唯一" {
t.Fatalf("title = %q, want 唯一", media.Title)
}
}
// 筛选后没有命中时返回 404,前端据此提示「没有符合条件的媒体」。
func TestLibraryRandomEmptyResultIs404(t *testing.T) {
router, svc, _, libID := newLibraryFilterEnv(t)
seedLibraryMedia(t, svc,
&model.Media{LibraryID: libID, Title: "动作", Genres: "Action", Path: "/a.mkv"},
)
code, body := getJSON(t, router, "/api/libraries/"+libID+"/random?genre=Nonexistent")
if code != http.StatusNotFound {
t.Fatalf("status = %d body=%s, want 404", code, body)
}
}
// axios 默认把数组序列化为 genre[]=Action 格式;后端必须把它当作 genre=Action 处理。
func TestListMediaAcceptsBracketGenreParam(t *testing.T) {
router, svc, _, libID := newLibraryFilterEnv(t)
seedLibraryMedia(t, svc,
&model.Media{LibraryID: libID, Title: "动作", Genres: "Action", Path: "/a.mkv"},
&model.Media{LibraryID: libID, Title: "喜剧", Genres: "Comedy", Path: "/b.mkv"},
)
// genre[]=Action — axios bracket format without custom paramsSerializer
code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0&genre[]=Action")
if code != http.StatusOK {
t.Fatalf("status = %d body=%s", code, body)
}
var payload struct {
Items []model.Media `json:"items"`
Total int64 `json:"total"`
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("decode %s: %v", body, err)
}
if payload.Total != 1 || len(payload.Items) != 1 || payload.Items[0].Title != "动作" {
t.Fatalf("payload = %+v, want only 动作 for genre[]=Action", payload)
}
}
// 随机也遵守筛选:只命中 Action 时,带 Comedy 筛选必须 404。
func TestLibraryRandomHonoursFilters(t *testing.T) {
router, svc, _, libID := newLibraryFilterEnv(t)
seedLibraryMedia(t, svc,
&model.Media{LibraryID: libID, Title: "A", Genres: "Action", Year: 2001, Path: "/a.mkv"},
&model.Media{LibraryID: libID, Title: "B", Genres: "Comedy", Year: 2002, Path: "/b.mkv"},
)
code, body := getJSON(t, router, "/api/libraries/"+libID+"/random?genre=Comedy&year_min=2002")
if code != http.StatusOK {
t.Fatalf("status = %d body=%s", code, body)
}
var media model.Media
if err := json.Unmarshal(body, &media); err != nil {
t.Fatal(err)
}
if media.Title != "B" {
t.Fatalf("title = %q, want B", media.Title)
}
}
+90 -2
View File
@@ -4,6 +4,7 @@ package handler
import (
"context"
"errors"
"math"
"net/http"
"strconv"
"strings"
@@ -407,6 +408,92 @@ func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
}
}
// parseLibraryFilters 解析媒体库列表的筛选查询参数。
//
// 全部参数都是可选的:缺省时返回零值,`MediaListFilters.empty()` 为真,列表
// 行为与此前完全一致(不引入任何默认筛选)。
//
// 参数约定:
// - genre=Action&genre=Comedy 类型多选(或关系,整词匹配)
// - year_min / year_max 年份区间,0 或非法值表示不限
// - rating_min 评分下限(浮点)
// - unwatched=1 仅显示未看完;用户 ID 取自会话
func parseLibraryFilters(c *gin.Context) service.MediaListFilters {
filters := service.MediaListFilters{
Genres: parseRepeatedQueryValues(c, "genre"),
YearMin: parseNonNegativeInt(firstQueryValue(c, "year_min", "yearMin")),
YearMax: parseNonNegativeInt(firstQueryValue(c, "year_max", "yearMax")),
RatingMin: parseNonNegativeFloat(firstQueryValue(c, "rating_min", "ratingMin")),
}
if isTruthyQuery(firstQueryValue(c, "unwatched", "unwatched_only", "unwatchedOnly")) {
filters.Unwatched = true
filters.UserID = toString(mustSessionUserID(c))
}
return filters
}
// parseRepeatedQueryValues 读取可重复出现的查询参数,去重并丢弃空值。
// 同时接受 key[] 括号格式(axios 1.x 默认序列化方式)作为向后兼容回退,
// 在前端 paramsSerializer 未正确配置时不会静默返回空结果。
func parseRepeatedQueryValues(c *gin.Context, key string) []string {
raw := c.QueryArray(key)
if len(raw) == 0 {
// fallback: axios bracket format (e.g. genre[]=Action&genre[]=Comedy)
raw = c.QueryArray(key + "[]")
}
if len(raw) == 0 {
return nil
}
seen := make(map[string]struct{}, len(raw))
out := make([]string, 0, len(raw))
for _, value := range raw {
// 客户端可能把多值拼成一次逗号分隔,两种形式都要接受。
for _, part := range strings.Split(value, ",") {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
out = append(out, trimmed)
}
}
return out
}
func parseNonNegativeInt(raw string) int {
value, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || value < 0 {
return 0
}
return value
}
func parseNonNegativeFloat(raw string) float64 {
value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
if err != nil || value < 0 || math.IsNaN(value) {
return 0
}
return value
}
func isTruthyQuery(raw string) bool {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
// mustSessionUserID 取会话用户 ID,缺失时返回空串(筛选逻辑会忽略它)。
func mustSessionUserID(c *gin.Context) any {
uid, _ := c.Get(middleware.CtxUserID)
return uid
}
func listMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
@@ -450,9 +537,10 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
if sortSpec.Field == "last_played" {
history = mediaHistoryMap(c, svc)
}
filters := parseLibraryFilters(c)
groupVersions := c.DefaultQuery("group_versions", "1") != "0"
if !groupVersions {
items, total, err := svc.Media.ListMediaVisible(ctx, id, page, size, mediaVisibilityForRequest(c, svc))
items, total, err := svc.Media.ListMediaVisibleFiltered(ctx, id, page, size, mediaVisibilityForRequest(c, svc), filters)
if err != nil {
writeInternalOrCanceled(c, err)
return
@@ -468,7 +556,7 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
})
return
}
grouped, err := svc.Media.GroupedMediaVisible(ctx, id, mediaVisibilityForRequest(c, svc))
grouped, err := svc.Media.GroupedMediaVisibleFiltered(ctx, id, mediaVisibilityForRequest(c, svc), filters)
if err != nil {
writeInternalOrCanceled(c, err)
return
+5
View File
@@ -116,8 +116,13 @@ func registerAdminUserRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.PATCH("/users/:id/role", adminUpdateRoleHandler(svc))
admin.PATCH("/users/:id/libraries", updateUserLibrariesHandler(svc))
admin.DELETE("/users/:id", deleteUserHandler(svc))
// 设备代管:管理员可查看并踢掉任意用户的设备。
admin.GET("/users/:id/devices", adminUserDevicesHandler(svc))
admin.POST("/users/:id/devices/kick-all", adminKickAllUserDevicesHandler(svc))
admin.POST("/users/:id/devices/:deviceID/kick", adminKickUserDeviceHandler(svc))
admin.GET("/settings", listSettingsHandler(svc))
admin.PUT("/settings", updateSettingHandler(svc))
admin.POST("/telegram/test", testTelegramHandler(svc))
admin.POST("/adult/test-scraper", testAdultScraperHandler(svc))
admin.GET("/logs", recentLogsHandler(svc))
}
@@ -19,6 +19,16 @@ func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Co
authed.GET("/me/temporary-password", temporaryPasswordHandler(svc))
authed.POST("/me/temporary-password", temporaryPasswordHandler(svc))
// 设备管理:路由挂在 /me 下,用户 ID 一律取自会话,天然只能管自己的设备。
authed.GET("/me/devices", myDevicesHandler(svc))
authed.POST("/me/devices/kick-all", myKickAllDevicesHandler(svc))
authed.POST("/me/devices/:deviceID/kick", myKickDeviceHandler(svc))
// Telegram 通知绑定:一次性码 + Bot /bind <code>。
authed.GET("/me/telegram", getTelegramStatusHandler(svc))
authed.POST("/me/telegram/bind-code", startTelegramBindHandler(svc))
authed.DELETE("/me/telegram", unbindTelegramHandler(svc))
authed.GET("/auth/permissions", getMyPermissionsHandler(svc))
}
@@ -38,6 +48,8 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container
authed.POST("/libraries/:id/scrape", middleware.AdminRequired(), scrapeLibraryHandler(svc))
authed.GET("/libraries/:id/media", listMediaHandler(svc))
authed.GET("/libraries/:id/facets", libraryFacetsHandler(svc))
authed.GET("/libraries/:id/random", libraryRandomHandler(svc))
authed.GET("/libraries/:id/series", listLibrarySeriesHandler(svc))
authed.GET("/libraries/:id/series/episodes", listLibrarySeriesEpisodesHandler(svc))
authed.GET("/libraries/:id/seasons", listSeasonsHandler(svc))
@@ -15,7 +15,7 @@ func registerAuthedUISurfaceRoutes(authed *gin.RouterGroup, svc *service.Contain
authed.PUT("/danmaku/settings", updateDanmakuSettingsHandler(svc))
authed.GET("/watch-history", historyListHandler(svc))
authed.GET("/watch-history/stats", historyStatsHandler(svc))
authed.GET("/watch-history/stats", requirePermission(svc, "can_view_history"), historyStatsHandler(svc))
authed.GET("/watch-history/continue", historyContinueHandler(svc))
authed.DELETE("/watch-history", historyDeleteHandler(svc))
authed.DELETE("/watch-history/:id", historyDeleteOneHandler(svc))
+3 -1
View File
@@ -124,7 +124,9 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
return
}
}
items, total, err := svc.Media.ListLibrarySeriesCards(c.Request.Context(), libID, mediaVisibilityForRequest(c, svc))
items, total, err := svc.Media.ListLibrarySeriesCardsFiltered(
c.Request.Context(), libID, mediaVisibilityForRequest(c, svc), parseLibraryFilters(c),
)
if err != nil {
writeInternalOrCanceled(c, err)
return
+89
View File
@@ -0,0 +1,89 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
// Telegram 绑定与测试接口。
//
// 绑定刻意做成「网页生成一次性码 → 用户在 Bot 里发 /bind <码>」:服务端不需要
// 用户手工填写 chat id,也不需要站点暴露 Bot 命令以外任何能力。
type telegramBindCodePayload struct {
Code string `json:"code"`
ExpiresIn int `json:"expires_in_seconds"`
}
// startTelegramBindHandler 生成一次性绑定码。同一用户重复调用时旧码作废。
func startTelegramBindHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc.Telegram == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "telegram service unavailable"})
return
}
userID := sessionUserID(c)
code, err := svc.Telegram.StartBind(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, telegramBindCodePayload{Code: code, ExpiresIn: 300})
}
}
// getTelegramStatusHandler 返回绑定状态与脱敏会话 ID。
func getTelegramStatusHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc.Telegram == nil {
c.JSON(http.StatusOK, gin.H{"bound": false})
return
}
bound, masked := svc.Telegram.Status(c.Request.Context(), sessionUserID(c))
payload := gin.H{"bound": bound}
if bound {
payload["chat_id_masked"] = masked
}
c.JSON(http.StatusOK, payload)
}
}
// unbindTelegramHandler 解除当前用户的 Telegram 绑定。
func unbindTelegramHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc.Telegram == nil {
c.Status(http.StatusNoContent)
return
}
if err := svc.Telegram.Unbind(c.Request.Context(), sessionUserID(c)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
// testTelegramHandler 向管理员会话发送一条测试消息,用于验证 Token/会话 ID。
func testTelegramHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc.Telegram == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "telegram service unavailable"})
return
}
if !svc.Telegram.Configured(c.Request.Context()) {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": "请先启用 Telegram 通知并填写 Bot Token 与管理员 Chat ID",
})
return
}
if err := svc.Telegram.SendToAdminChecked(c.Request.Context(), "✅ MeBox 测试消息:通知通道工作正常。"); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
}
+194 -6
View File
@@ -11,8 +11,11 @@
package handler
import (
"context"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -48,11 +51,16 @@ func historyListHandler(svc *service.Container) gin.HandlerFunc {
}
// historyStatsHandler returns aggregate watch time + completion counts
// for the caller. Used by the WatchHistoryPage hero card.
// for the caller. Used by the WatchHistoryPage hero card and the dedicated
// personal statistics page.
//
// 统计口径全部来自 PlaybackHistory 本身,不新增统计表:position_ms 是「已看
// 时长」的近似值,足以支撑趋势图;精确到秒的播放时长另有会话统计负责。
func historyStatsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
userID := toString(uid)
ctx := c.Request.Context()
var total int64
_ = svc.Repo.DB.Model(&model.PlaybackHistory{}).
@@ -77,16 +85,196 @@ func historyStatsHandler(svc *service.Container) gin.HandlerFunc {
last = &lastT
}
visibility := mediaVisibilityForRequest(c, svc)
daily, byType, recent := historyStatsBreakdowns(ctx, svc, userID, visibility)
inProgress := total - completed
if inProgress < 0 {
inProgress = 0
}
c.JSON(http.StatusOK, gin.H{
"total": total,
"completed": completed,
"watched_ms": watchedMs,
"watched_hours": float64(watchedMs) / 1000.0 / 3600.0,
"last_watched": last,
"total": total,
"completed": completed,
"in_progress": inProgress,
"watched_ms": watchedMs,
"watched_hours": float64(watchedMs) / 1000.0 / 3600.0,
"last_watched": last,
"daily": daily,
"by_library_type": byType,
"recent": recent,
})
}
}
// historyStatsDailyDays 是趋势图回看的天数。
const historyStatsDailyDays = 30
// historyStatsRecentLimit 是「最近看过」返回的条数。
const historyStatsRecentLimit = 8
type historyDailyStat struct {
Day string `json:"day"`
WatchMs int64 `json:"watch_ms"`
Plays int64 `json:"plays"`
}
type historyTypeStat struct {
Type string `json:"type"`
WatchMs int64 `json:"watch_ms"`
Count int64 `json:"count"`
}
// historyStatsBreakdowns 产出每日趋势、按媒体库类型分布与最近记录。
//
// 分桶在 Go 里做而不是用 SQL 的日期函数:SQLite 的 strftime 与 PostgreSQL 的
// to_char 语法不同,写两份 SQL 会在方言差异上长期出错,而历史行数受用户规模
// 约束(每人一行一部媒体),一次全量读取是可以接受的。
//
// visibility 控制哪些媒体对调用者可见(播放档案、成人锁等)。
func historyStatsBreakdowns(ctx context.Context, svc *service.Container, userID string, visibility service.MediaVisibility) ([]historyDailyStat, []historyTypeStat, []map[string]any) {
daily := make([]historyDailyStat, 0, historyStatsDailyDays)
byType := make([]historyTypeStat, 0)
recent := make([]map[string]any, 0, historyStatsRecentLimit)
var rows []model.PlaybackHistory
if err := svc.Repo.DB.WithContext(ctx).
Where("user_id = ?", userID).
Order("watched_at desc").
Find(&rows).Error; err != nil || len(rows) == 0 {
return daily, byType, recent
}
// 每日趋势:只回看最近 N 天,且按「本地日」分桶,避免跨时区偏移。
now := time.Now()
cutoff := now.AddDate(0, 0, -(historyStatsDailyDays - 1))
startOfDay := func(t time.Time) time.Time {
local := t.In(time.Local)
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, time.Local)
}
buckets := make(map[string]*historyDailyStat, historyStatsDailyDays)
for i := 0; i < historyStatsDailyDays; i++ {
day := startOfDay(cutoff).AddDate(0, 0, i).Format("2006-01-02")
buckets[day] = &historyDailyStat{Day: day}
}
for _, r := range rows {
watched := r.WatchedAt.In(time.Local)
if watched.Before(startOfDay(cutoff)) {
continue
}
if bucket, ok := buckets[watched.Format("2006-01-02")]; ok {
bucket.WatchMs += r.PositionMs
bucket.Plays++
}
}
for i := 0; i < historyStatsDailyDays; i++ {
day := startOfDay(cutoff).AddDate(0, 0, i).Format("2006-01-02")
if bucket, ok := buckets[day]; ok && bucket.Plays > 0 {
daily = append(daily, *bucket)
}
}
mediaIDs := make([]string, 0, len(rows))
for _, r := range rows {
mediaIDs = append(mediaIDs, r.MediaID)
}
var medias []model.Media
_ = svc.Repo.DB.WithContext(ctx).Where("id IN ?", mediaIDs).Find(&medias).Error
mediaByID := make(map[string]*model.Media, len(medias))
for i := range medias {
mediaByID[medias[i].ID] = &medias[i]
}
libraryTypes := make(map[string]string)
var libraries []model.Library
if svc.Repo.Library != nil {
if libs, err := svc.Repo.Library.List(ctx); err == nil {
libraries = libs
}
}
for _, lib := range libraries {
libraryTypes[lib.ID] = lib.Type
}
typeAcc := make(map[string]*historyTypeStat)
order := make([]string, 0, 4)
for _, r := range rows {
media := mediaByID[r.MediaID]
var key string
if media == nil {
// 媒体记录已删除(含 Emby 远程缓存失效):计入 "other" 桶而非丢弃,
// 这样类型分布总数才能与播放历史总数吻合。
key = "other"
} else {
// 如果调用者的可见性策略排除了该媒体,则跳过统计(visibility leak fix)。
if !visibility.Allows(media) {
continue
}
key = strings.TrimSpace(libraryTypes[media.LibraryID])
if key == "" {
key = "other"
}
}
acc, ok := typeAcc[key]
if !ok {
acc = &historyTypeStat{Type: key}
typeAcc[key] = acc
order = append(order, key)
}
acc.WatchMs += r.PositionMs
acc.Count++
}
// 顺序按观看时长降序,让「我主要在看什么」一眼可见。
for _, key := range order {
byType = append(byType, *typeAcc[key])
}
sort.SliceStable(byType, func(i, j int) bool {
if byType[i].WatchMs != byType[j].WatchMs {
return byType[i].WatchMs > byType[j].WatchMs
}
return byType[i].Type < byType[j].Type
})
for _, r := range rows {
if len(recent) >= historyStatsRecentLimit {
break
}
entry := map[string]any{"history": r}
if media := mediaByID[r.MediaID]; media != nil {
// 可见性检查:隐藏库或受档案限制的媒体不进入最近记录(visibility leak fix)。
if !visibility.Allows(media) {
continue
}
entry["media"] = media
} else if svc.EmbyRemote != nil && service.IsEmbyRemoteID(r.MediaID) {
// 尝试从 Emby 远端补全媒体详情,与 historyContinueHandler 保持相同策略。
mountID, remoteID, _ := service.DecodeEmbyRemoteID(r.MediaID)
mount, acct, resolveErr := svc.EmbyRemote.ResolveMount(ctx, mountID)
if resolveErr == nil && mount != nil && acct != nil {
remoteMedia, detailErr := svc.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, remoteID)
if detailErr == nil && remoteMedia != nil {
if !visibility.Allows(remoteMedia) {
continue
}
entry["media"] = *remoteMedia
} else {
// 无法获取 Emby 媒体详情,跳过此条记录。
continue
}
} else {
// 挂载不可用,跳过。
continue
}
} else {
// 媒体记录不存在且无法 Emby 补全,跳过。
continue
}
recent = append(recent, entry)
}
return daily, byType, recent
}
// historyContinueHandler returns "Continue Watching" rows: incomplete
// items, most recent first.
func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
@@ -0,0 +1,349 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"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"
)
func newHistoryStatsEnv(t *testing.T) (*gin.Engine, *service.Container, string) {
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.User{}, &model.Library{}, &model.Media{}, &model.PlaybackHistory{}, &model.UserPermission{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
svc := &service.Container{Repo: repos, Log: zap.NewNop()}
svc.Permissions = service.NewPermissionService(zap.NewNop(), repos)
const userID = "user-1"
if err := repos.User.Create(context.Background(), &model.User{
Base: model.Base{ID: userID}, Username: "tester", PasswordHash: "x", Role: "user", IsActive: true,
}); err != nil {
t.Fatal(err)
}
router := gin.New()
authed := router.Group("/api", func(c *gin.Context) {
c.Set(middleware.CtxUserID, userID)
c.Next()
})
authed.GET("/watch-history/stats", historyStatsHandler(svc))
return router, svc, userID
}
type historyStatsPayload struct {
Total int64 `json:"total"`
Completed int64 `json:"completed"`
InProgress int64 `json:"in_progress"`
WatchedMs int64 `json:"watched_ms"`
WatchedHours float64 `json:"watched_hours"`
Daily []struct {
Day string `json:"day"`
WatchMs int64 `json:"watch_ms"`
Plays int64 `json:"plays"`
} `json:"daily"`
ByLibraryType []struct {
Type string `json:"type"`
WatchMs int64 `json:"watch_ms"`
Count int64 `json:"count"`
} `json:"by_library_type"`
Recent []struct {
Media *model.Media `json:"media"`
} `json:"recent"`
}
func fetchHistoryStats(t *testing.T, router *gin.Engine) historyStatsPayload {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/watch-history/stats", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
var payload historyStatsPayload
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode %s: %v", w.Body.String(), err)
}
return payload
}
// 新字段必须提供每日聚合、库类型分布与在看数量,供个人统计页绘图。
func TestHistoryStatsIncludesDailyAndTypes(t *testing.T) {
router, svc, userID := newHistoryStatsEnv(t)
ctx := context.Background()
movieLib := &model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
if err := svc.Repo.Library.Create(ctx, movieLib); err != nil {
t.Fatal(err)
}
tvLib := &model.Library{Name: "剧集", Path: "/media/tv", Type: "tv", Enabled: true}
if err := svc.Repo.Library.Create(ctx, tvLib); err != nil {
t.Fatal(err)
}
yesterday := time.Now().Add(-24 * time.Hour)
today := time.Now().Add(-time.Hour)
rows := []struct {
media *model.Media
watchedAt time.Time
position int64
completed bool
}{
{
media: &model.Media{LibraryID: movieLib.ID, Title: "电影A", Path: "/media/movies/a.mkv"},
watchedAt: yesterday, position: 60000, completed: true,
},
{
media: &model.Media{LibraryID: tvLib.ID, Title: "剧B", Path: "/media/tv/b.mkv"},
watchedAt: today, position: 30000, completed: false,
},
}
for _, row := range rows {
if err := svc.Repo.DB.Create(row.media).Error; err != nil {
t.Fatal(err)
}
h := &model.PlaybackHistory{
UserID: userID, MediaID: row.media.ID, PositionMs: row.position,
DurationMs: 120000, WatchedAt: row.watchedAt, Completed: row.completed,
}
if err := svc.Repo.DB.Create(h).Error; err != nil {
t.Fatal(err)
}
}
payload := fetchHistoryStats(t, router)
if payload.Total != 2 {
t.Fatalf("total = %d, want 2", payload.Total)
}
if payload.Completed != 1 {
t.Fatalf("completed = %d, want 1", payload.Completed)
}
if payload.InProgress != 1 {
t.Fatalf("in_progress = %d, want 1", payload.InProgress)
}
if payload.WatchedMs != 90000 {
t.Fatalf("watched_ms = %d, want 90000", payload.WatchedMs)
}
if len(payload.Daily) != 2 {
t.Fatalf("daily = %+v, want 2 days", payload.Daily)
}
if len(payload.ByLibraryType) != 2 {
t.Fatalf("by_library_type = %+v, want 2 entries", payload.ByLibraryType)
}
if len(payload.Recent) != 2 {
t.Fatalf("recent = %d entries, want 2", len(payload.Recent))
}
if payload.Recent[0].Media == nil || payload.Recent[0].Media.Title != "剧B" {
t.Fatalf("recent[0] = %+v, want the most recent entry (剧B)", payload.Recent[0])
}
}
// 没有任何播放记录时,新字段要返回空数组而不是 null,前端无需额外判空。
func TestHistoryStatsEmptyProvidesEmptyArrays(t *testing.T) {
router, _, _ := newHistoryStatsEnv(t)
req := httptest.NewRequest(http.MethodGet, "/api/watch-history/stats", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
body := w.Body.String()
for _, field := range []string{`"daily":[]`, `"by_library_type":[]`, `"recent":[]`} {
if !containsSubstring(body, field) {
t.Fatalf("body = %s, want %s", body, field)
}
}
}
func containsSubstring(haystack, needle string) bool {
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return true
}
}
return false
}
// TestHistoryStatsBreakdownsVisibilityFilter validates that historyStatsBreakdowns
// respects the caller's MediaVisibility: media in a hidden library must be absent
// from both the recent list and the by_library_type buckets.
func TestHistoryStatsBreakdownsVisibilityFilter(t *testing.T) {
_, svc, userID := newHistoryStatsEnv(t)
ctx := context.Background()
allowedLib := &model.Library{Name: "允许库", Path: "/media/allowed", Type: "movie", Enabled: true}
hiddenLib := &model.Library{Name: "隐藏库", Path: "/media/hidden", Type: "tv", Enabled: true}
if err := svc.Repo.Library.Create(ctx, allowedLib); err != nil {
t.Fatal(err)
}
if err := svc.Repo.Library.Create(ctx, hiddenLib); err != nil {
t.Fatal(err)
}
allowedMedia := &model.Media{LibraryID: allowedLib.ID, Title: "允许媒体", Path: "/media/allowed/a.mkv"}
hiddenMedia := &model.Media{LibraryID: hiddenLib.ID, Title: "隐藏媒体", Path: "/media/hidden/b.mkv"}
if err := svc.Repo.DB.Create(allowedMedia).Error; err != nil {
t.Fatal(err)
}
if err := svc.Repo.DB.Create(hiddenMedia).Error; err != nil {
t.Fatal(err)
}
now := time.Now()
for _, mid := range []string{allowedMedia.ID, hiddenMedia.ID} {
h := &model.PlaybackHistory{
UserID: userID, MediaID: mid, PositionMs: 10000,
DurationMs: 100000, WatchedAt: now, Completed: false,
}
if err := svc.Repo.DB.Create(h).Error; err != nil {
t.Fatal(err)
}
}
// visibility that hides hiddenLib
vis := service.MediaVisibility{
HiddenLibraryIDs: []string{hiddenLib.ID},
}
_, byType, recent := historyStatsBreakdowns(ctx, svc, userID, vis)
// recent must contain only the allowed media
for _, entry := range recent {
m, ok := entry["media"]
if !ok {
t.Fatal("recent entry missing media field")
}
switch med := m.(type) {
case *model.Media:
if med.LibraryID == hiddenLib.ID {
t.Fatalf("hidden media appeared in recent: %s", med.Title)
}
case model.Media:
if med.LibraryID == hiddenLib.ID {
t.Fatalf("hidden media appeared in recent: %s", med.Title)
}
}
}
if len(recent) != 1 {
t.Fatalf("recent length = %d, want 1 (hidden entry must be excluded)", len(recent))
}
// by_library_type must not contain the hidden library's type ("tv")
for _, bt := range byType {
if bt.Type == "tv" {
t.Fatalf("hidden library type 'tv' appeared in by_library_type (count=%d)", bt.Count)
}
}
}
// TestHistoryStatsBreakdownsNilMediaCountsAsOther confirms that a history row
// whose media has been deleted (nil lookup) is counted under the "other" type
// bucket rather than silently dropped.
func TestHistoryStatsBreakdownsNilMediaCountsAsOther(t *testing.T) {
_, svc, userID := newHistoryStatsEnv(t)
ctx := context.Background()
// Insert a history row whose media_id does not correspond to any Media row.
ghost := &model.PlaybackHistory{
UserID: userID,
MediaID: "ghost-media-id",
PositionMs: 5000,
DurationMs: 50000,
WatchedAt: time.Now(),
Completed: false,
}
if err := svc.Repo.DB.Create(ghost).Error; err != nil {
t.Fatal(err)
}
vis := service.MediaVisibility{} // unrestricted
_, byType, _ := historyStatsBreakdowns(ctx, svc, userID, vis)
var otherEntry *historyTypeStat
for i := range byType {
if byType[i].Type == "other" {
otherEntry = &byType[i]
break
}
}
if otherEntry == nil {
t.Fatalf("expected 'other' bucket for nil-media history row, got %+v", byType)
}
if otherEntry.Count != 1 {
t.Fatalf("other.Count = %d, want 1", otherEntry.Count)
}
}
// TestHistoryStatsPermissionDeny checks that a user without can_view_history
// receives HTTP 403 from the gated route.
func TestHistoryStatsPermissionDeny(t *testing.T) {
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.User{}, &model.Library{}, &model.Media{},
&model.PlaybackHistory{}, &model.UserPermission{},
); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
svc := &service.Container{Repo: repos, Log: zap.NewNop()}
svc.Permissions = service.NewPermissionService(zap.NewNop(), repos)
const userID = "user-noperm"
if err := repos.User.Create(context.Background(), &model.User{
Base: model.Base{ID: userID}, Username: "noperm", PasswordHash: "x", Role: "user", IsActive: true,
}); err != nil {
t.Fatal(err)
}
// Explicitly deny can_view_history for this user.
// First seed defaults (Effective will create the row with defaults), then
// update to deny via Save which uses an explicit map update path in the repo.
if _, err := svc.Permissions.Effective(context.Background(), userID); err != nil {
t.Fatalf("seed permissions: %v", err)
}
denyPerm := &model.UserPermission{UserID: userID, CanViewHistory: false}
if err := svc.Permissions.Save(context.Background(), userID, denyPerm); err != nil {
t.Fatalf("save permission: %v", err)
}
router := gin.New()
authed := router.Group("/api", func(c *gin.Context) {
c.Set(middleware.CtxUserID, userID)
c.Set(middleware.CtxUserRole, "user")
c.Next()
})
authed.GET("/watch-history/stats", requirePermission(svc, "can_view_history"), historyStatsHandler(svc))
req := httptest.NewRequest(http.MethodGet, "/api/watch-history/stats", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 for user without can_view_history", w.Code)
}
}
+3
View File
@@ -55,6 +55,9 @@ type User struct {
// expires. When set and in the past, the account is treated as expired
// (login blocked) until an admin or a redemption code renews it.
ExpiredAt *time.Time `json:"expired_at,omitempty"`
// TelegramChatID 是用户绑定的 Telegram 会话 ID,用于接收账号与设备通知。
// 为空表示未绑定;绑定走个人资料页生成的一次性码 + Bot /bind 命令。
TelegramChatID string `gorm:"size:64" json:"telegram_chat_id,omitempty"`
// ShareWarnings counts anti-account-sharing warnings, mainly device
// fingerprint mismatches. Once it exceeds the configured threshold a
// re-offence disables the account until an admin re-enables it.
+24
View File
@@ -0,0 +1,24 @@
package model
import (
"sync"
"testing"
"gorm.io/gorm/schema"
)
// TelegramChatID 是 Telegram 通知的绑定目标:Size 必须容得下真实 chat id
// (群/频道 id 为负数且位数更长),因此下限设为 64。
func TestUserTelegramChatIDFieldSize(t *testing.T) {
parsed, err := schema.Parse(&User{}, &sync.Map{}, schema.NamingStrategy{})
if err != nil {
t.Fatal(err)
}
field := parsed.LookUpField("TelegramChatID")
if field == nil {
t.Fatal("TelegramChatID field not found")
}
if field.Size < 64 {
t.Fatalf("TelegramChatID size = %d, want at least 64", field.Size)
}
}
+174
View File
@@ -0,0 +1,174 @@
package repository
import (
"context"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/truewhile/MeBox/internal/model"
)
func newMediaFilterTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Media{}, &model.PlaybackHistory{}); err != nil {
t.Fatal(err)
}
return db
}
func seedFilterMedia(t *testing.T, db *gorm.DB, rows ...*model.Media) {
t.Helper()
for _, row := range rows {
if err := db.WithContext(context.Background()).Create(row).Error; err != nil {
t.Fatal(err)
}
}
}
func listFiltered(t *testing.T, db *gorm.DB, filter MediaQueryFilter) []string {
t.Helper()
var rows []model.Media
q := db.WithContext(context.Background()).Model(&model.Media{})
q = applyMediaQueryFilter(q, filter)
if err := q.Order("title asc").Find(&rows).Error; err != nil {
t.Fatal(err)
}
out := make([]string, 0, len(rows))
for _, row := range rows {
out = append(out, row.Title)
}
return out
}
func hasTitle(items []string, want string) bool {
for _, item := range items {
if item == want {
return true
}
}
return false
}
// 多个类型之间是「或」:勾选 Action 与 Comedy 应同时命中两类。
func TestFilterByGenreOR(t *testing.T) {
db := newMediaFilterTestDB(t)
seedFilterMedia(t, db,
&model.Media{Title: "动作", Genres: "Action", Path: "/a.mkv", LibraryID: "lib-1"},
&model.Media{Title: "喜剧", Genres: "Comedy", Path: "/b.mkv", LibraryID: "lib-1"},
&model.Media{Title: "剧情", Genres: "Drama", Path: "/c.mkv", LibraryID: "lib-1"},
)
got := listFiltered(t, db, MediaQueryFilter{IncludeNSFW: true, Genres: []string{"Action", "Comedy"}})
if !hasTitle(got, "动作") || !hasTitle(got, "喜剧") {
t.Fatalf("result = %v, want both 动作 and 喜剧", got)
}
if hasTitle(got, "剧情") {
t.Fatalf("result = %v, must not contain 剧情", got)
}
}
// 类型匹配必须是整词匹配:搜 "Action" 不能命中 "ActionComedy" 这类拼接值。
func TestFilterByGenreDoesNotMatchSubstring(t *testing.T) {
db := newMediaFilterTestDB(t)
seedFilterMedia(t, db,
&model.Media{Title: "精确", Genres: "Action,Drama", Path: "/a.mkv", LibraryID: "lib-1"},
&model.Media{Title: "拼接", Genres: "ActionComedy", Path: "/b.mkv", LibraryID: "lib-1"},
)
got := listFiltered(t, db, MediaQueryFilter{IncludeNSFW: true, Genres: []string{"Action"}})
if !hasTitle(got, "精确") {
t.Fatalf("result = %v, want 精确", got)
}
if hasTitle(got, "拼接") {
t.Fatalf("result = %v, must not match ActionComedy for Action", got)
}
}
func TestFilterYearAndRating(t *testing.T) {
db := newMediaFilterTestDB(t)
seedFilterMedia(t, db,
&model.Media{Title: "老片", Year: 1995, Rating: 9, Path: "/a.mkv", LibraryID: "lib-1"},
&model.Media{Title: "中年", Year: 2010, Rating: 5, Path: "/b.mkv", LibraryID: "lib-1"},
&model.Media{Title: "新片", Year: 2023, Rating: 8, Path: "/c.mkv", LibraryID: "lib-1"},
)
got := listFiltered(t, db, MediaQueryFilter{IncludeNSFW: true, YearMin: 2000, YearMax: 2020})
if len(got) != 1 || got[0] != "中年" {
t.Fatalf("year filter result = %v, want [中年]", got)
}
got = listFiltered(t, db, MediaQueryFilter{IncludeNSFW: true, RatingMin: 8})
if len(got) != 2 {
t.Fatalf("rating filter result = %v, want 2 entries", got)
}
}
// 「未观看」的语义是「没有标记看完的记录」:看了一半的仍应出现。
func TestFilterUnwatchedExcludesCompleted(t *testing.T) {
db := newMediaFilterTestDB(t)
seedFilterMedia(t, db,
&model.Media{Base: model.Base{ID: "m-done"}, Title: "看完", Path: "/a.mkv", LibraryID: "lib-1"},
&model.Media{Base: model.Base{ID: "m-half"}, Title: "看一半", Path: "/b.mkv", LibraryID: "lib-1"},
&model.Media{Base: model.Base{ID: "m-new"}, Title: "没看过", Path: "/c.mkv", LibraryID: "lib-1"},
)
ctx := context.Background()
for _, h := range []*model.PlaybackHistory{
{UserID: "u1", MediaID: "m-done", Completed: true},
{UserID: "u1", MediaID: "m-half", Completed: false},
// 别人的完播记录不应影响本人筛选。
{UserID: "u2", MediaID: "m-new", Completed: true},
} {
if err := db.WithContext(ctx).Create(h).Error; err != nil {
t.Fatal(err)
}
}
got := listFiltered(t, db, MediaQueryFilter{
IncludeNSFW: true, UnwatchedOnly: true, UnwatchedUserID: "u1",
})
if hasTitle(got, "看完") {
t.Fatalf("result = %v, must exclude completed media", got)
}
if !hasTitle(got, "看一半") || !hasTitle(got, "没看过") {
t.Fatalf("result = %v, want both 看一半 and 没看过", got)
}
}
// 多词类型(如 "Science Fiction"):列侧 SQL 会 REPLACE 掉空格,参数侧也必须同步
// 去掉空格,两侧对称才能命中。
func TestFilterByGenreMultiWordStripsSpaces(t *testing.T) {
db := newMediaFilterTestDB(t)
seedFilterMedia(t, db,
&model.Media{Title: "科幻", Genres: "Science Fiction,Drama", Path: "/a.mkv", LibraryID: "lib-1"},
&model.Media{Title: "动作", Genres: "Action", Path: "/b.mkv", LibraryID: "lib-1"},
)
got := listFiltered(t, db, MediaQueryFilter{IncludeNSFW: true, Genres: []string{"Science Fiction"}})
if !hasTitle(got, "科幻") {
t.Fatalf("result = %v, want 科幻 (multi-word genre must match after space stripping)", got)
}
if hasTitle(got, "动作") {
t.Fatalf("result = %v, must not contain 动作", got)
}
}
// UnwatchedOnly 缺省 userID 时必须忽略该条件,而不是返回空结果。
func TestFilterUnwatchedWithoutUserIsIgnored(t *testing.T) {
db := newMediaFilterTestDB(t)
seedFilterMedia(t, db, &model.Media{Title: "片", Path: "/a.mkv", LibraryID: "lib-1"})
got := listFiltered(t, db, MediaQueryFilter{IncludeNSFW: true, UnwatchedOnly: true})
if len(got) != 1 {
t.Fatalf("result = %v, want the row to be returned", got)
}
}
+116
View File
@@ -44,6 +44,21 @@ type MediaQueryFilter struct {
AllowedLibraryIDs []string
HiddenLibraryIDs []string
SeriesID string
// LibraryID 是精确匹配的单个库过滤,用于库内场景(例如媒体库页筛选)。
// 它与 AllowedLibraryIDs 是「与」关系:可见性仍由后者兜底,避免越权。
LibraryID string
// Genres 是类型多选,之间为「或」。按整词匹配(见 genreMatchClause)。
Genres []string
// YearMin / YearMax 为 0 表示该端不限。
YearMin int
YearMax int
// RatingMin 为 0 表示不限。
RatingMin float64
// UnwatchedOnly 排除 UnwatchedUserID 已标记看完的条目。
// 「未观看」定义为「没有 completed=true 的记录」:看到一半的仍会出现,
// 与「继续观看」互补而不是重复。
UnwatchedOnly bool
UnwatchedUserID string
}
func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
@@ -56,12 +71,113 @@ func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
if len(filter.AllowedLibraryIDs) > 0 {
q = q.Where("library_id IN ?", filter.AllowedLibraryIDs)
}
if libraryID := strings.TrimSpace(filter.LibraryID); libraryID != "" {
q = q.Where("library_id = ?", libraryID)
}
if seriesID := strings.TrimSpace(filter.SeriesID); seriesID != "" {
q = q.Where("series_id = ?", seriesID)
}
if len(filter.Genres) > 0 {
q = q.Where(genreMatchClause(filter.Genres), genreMatchArgs(filter.Genres)...)
}
if filter.YearMin > 0 {
q = q.Where("year >= ?", filter.YearMin)
}
if filter.YearMax > 0 {
q = q.Where("year <= ?", filter.YearMax)
}
if filter.RatingMin > 0 {
q = q.Where("rating >= ?", filter.RatingMin)
}
if filter.UnwatchedOnly {
userID := strings.TrimSpace(filter.UnwatchedUserID)
// 没有用户上下文时忽略该条件:否则会把整个库筛成空,看起来像「坏了」。
if userID != "" {
q = q.Where(
"id NOT IN (SELECT media_id FROM playback_histories WHERE user_id = ? AND completed = ?)",
userID, true,
)
}
}
return q
}
// genreMatchClause 生成类型整词匹配条件。
//
// genres 列是逗号分隔字符串,直接 LIKE '%Action%' 会把 "ActionComedy" 也命中。
// 这里统一补上首尾逗号(并用空格容错)后再按 "%,Action,%" 匹配,实现整词语义;
// 该写法在 SQLite 与 PostgreSQL 上行为一致,因此不需要方言分支。
//
// 注意写法:参数本身带上首尾逗号,SQL 里只做一次 REPLACE 来保证列值两端也有
// 分隔符,避免 OR 链里重复拼接列表达式。
func genreMatchClause(genres []string) string {
clauses := make([]string, 0, len(genres))
for range genres {
clauses = append(clauses, "',' || REPLACE(REPLACE(TRIM(genres), ' ', ''), ',', ',') || ',' LIKE ?")
}
return "(" + strings.Join(clauses, " OR ") + ")"
}
// genreMatchArgs 生成与 genreMatchClause 对应的参数,形如 "%,Action,%"。
//
// 必须与 genreMatchClause 的列端处理完全对称:
// - TRIM → TrimSpace
// - REPLACE(…, ' ', '') → ReplaceAll(…, " ", "") ← 多词类型(如 "Science Fiction")
// - REPLACE(…, ',', ',') → ReplaceAll(…, ",", ",")
func genreMatchArgs(genres []string) []any {
args := make([]any, 0, len(genres))
for _, genre := range genres {
name := strings.ReplaceAll(strings.TrimSpace(genre), ",", ",")
name = strings.ReplaceAll(name, " ", "") // mirror REPLACE(…,' ','') in genreMatchClause
if name == "" {
name = "\x00" // 空类型不会命中任何行
}
args = append(args, "%,"+name+",%")
}
return args
}
// ListGenreValues 返回符合过滤条件的 media.genres 原始值(逗号分隔字符串)。
//
// 只取单列:类型聚合不需要整行 media,而一台大库的整行扫描会把海报 URL、
// 简介等大字段一起读进内存。切分与去重交给调用方,SQL 层保持方言无关。
func (r *MediaRepository) ListGenreValues(ctx context.Context, filter MediaQueryFilter) ([]string, error) {
var values []string
q := r.db.WithContext(ctx).
Model(&model.Media{}).
Where("genres IS NOT NULL AND genres <> ''")
q = applyMediaQueryFilter(q, filter)
if err := q.Pluck("genres", &values).Error; err != nil {
return nil, err
}
return values, nil
}
// YearRange 返回符合过滤条件的年份区间(两端都为 0 表示没有可用年份)。
// 供媒体库筛选面板生成年份上下限,避免前端硬编码或先取全量再自己算。
func (r *MediaRepository) YearRange(ctx context.Context, filter MediaQueryFilter) (int, int, error) {
var bounds struct {
MinYear *int
MaxYear *int
}
q := r.db.WithContext(ctx).
Model(&model.Media{}).
Where("year > 0").
Select("MIN(year) AS min_year, MAX(year) AS max_year")
q = applyMediaQueryFilter(q, filter)
if err := q.Scan(&bounds).Error; err != nil {
return 0, 0, err
}
min, max := 0, 0
if bounds.MinYear != nil {
min = *bounds.MinYear
}
if bounds.MaxYear != nil {
max = *bounds.MaxYear
}
return min, max, nil
}
func (r *MediaRepository) indexMediaBestEffort(ctx context.Context, media model.Media) {
backend, ok := r.searchBackend.(MediaSearchSyncBackend)
if !ok {
+13 -3
View File
@@ -21,14 +21,24 @@ func (s *DeviceService) KickDevice(ctx context.Context, userID, deviceID string)
return fmt.Errorf("device not found")
}
if fp := strings.TrimSpace(d.Fingerprint); fp != "" {
return s.repo.UserDevice.SetKickedByFingerprint(ctx, userID, fp, true)
err = s.repo.UserDevice.SetKickedByFingerprint(ctx, userID, fp, true)
} else {
err = s.repo.UserDevice.SetKicked(ctx, d.ID, true)
}
return s.repo.UserDevice.SetKicked(ctx, d.ID, true)
if err != nil {
return err
}
s.notify(ctx, userID, fmt.Sprintf("🔌 设备已下线:<b>%s</b>\n该终端需要重新登录后才能继续使用。", deviceLabel(d.DeviceName, d.Client)))
return nil
}
// KickAllDevices marks all devices for a user as kicked.
func (s *DeviceService) KickAllDevices(ctx context.Context, userID string) error {
return s.repo.UserDevice.SetKickedByUser(ctx, userID, true)
if err := s.repo.UserDevice.SetKickedByUser(ctx, userID, true); err != nil {
return err
}
s.notify(ctx, userID, "🔌 你名下的全部设备已下线,需要重新登录后才能继续使用。")
return nil
}
// ListDevices returns the device sessions for a user.
+155
View File
@@ -0,0 +1,155 @@
package service
import (
"context"
"strings"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
func newDeviceServiceForTest(t *testing.T) (*DeviceService, *repository.Container, string) {
t.Helper()
repos := repository.New(newServiceTestDB(t))
const userID = "user-1"
if err := repos.User.Create(context.Background(), &model.User{
Base: model.Base{ID: userID},
Username: "tester",
PasswordHash: "x",
Role: "user",
IsActive: true,
}); err != nil {
t.Fatal(err)
}
svc := NewDeviceService(zap.NewNop(), repos)
svc.SetSessionTracker(NewSessionTrackerService(zap.NewNop()))
return svc, repos, userID
}
// 新终端首次登录必须通知用户,否则「谁在用我的账号」永远无从察觉。
func TestRecordLoginNotifiesOnNewDevice(t *testing.T) {
svc, _, userID := newDeviceServiceForTest(t)
type call struct{ userID, text string }
var calls []call
svc.SetNotifier(func(_ context.Context, uid, text string) {
calls = append(calls, call{uid, text})
})
svc.RecordLogin(context.Background(), userID, "dev-1", "Phone", "Infuse", "1.2.3.4")
if len(calls) != 1 {
t.Fatalf("notifier calls = %d, want 1", len(calls))
}
if calls[0].userID != userID {
t.Fatalf("notifier user = %q, want %q", calls[0].userID, userID)
}
if !strings.Contains(calls[0].text, "新设备") {
t.Fatalf("notifier text = %q, want it to mention 新设备", calls[0].text)
}
}
// 已知终端重复登录不应刷屏:只在首次建档时通知。
func TestRecordLoginDoesNotNotifyOnKnownDevice(t *testing.T) {
svc, _, userID := newDeviceServiceForTest(t)
var count int
svc.SetNotifier(func(context.Context, string, string) { count++ })
svc.RecordLogin(context.Background(), userID, "dev-1", "Phone", "Infuse", "1.2.3.4")
svc.RecordLogin(context.Background(), userID, "dev-1", "Phone", "Infuse", "1.2.3.4")
if count != 1 {
t.Fatalf("notifier calls = %d, want exactly 1", count)
}
}
// 一键踢下线后必须告知用户,否则只会表现为「播放莫名失败」。
func TestKickDeviceNotifiesUser(t *testing.T) {
svc, _, userID := newDeviceServiceForTest(t)
svc.RecordLogin(context.Background(), userID, "dev-1", "Phone", "Infuse", "1.2.3.4")
var kickText string
svc.SetNotifier(func(_ context.Context, _, text string) { kickText = text })
if err := svc.KickAllDevices(context.Background(), userID); err != nil {
t.Fatal(err)
}
if kickText == "" {
t.Fatal("expected a notification after kicking devices")
}
if !strings.Contains(kickText, "已下线") && !strings.Contains(kickText, "踢") {
t.Fatalf("kick notification text = %q, want it to describe the kick", kickText)
}
}
// 未接线 notifier 时(例如测试环境或 Bot 未配置),所有路径必须保持可用。
func TestDeviceServiceWorksWithoutNotifier(t *testing.T) {
svc, _, userID := newDeviceServiceForTest(t)
svc.RecordLogin(context.Background(), userID, "dev-1", "Phone", "Infuse", "1.2.3.4")
if err := svc.KickAllDevices(context.Background(), userID); err != nil {
t.Fatal(err)
}
}
// 设备指纹警告除通知用户外,管理员也必须同步收到告警。
func TestFingerprintWarnNotifiesAdmin(t *testing.T) {
svc, repos, userID := newDeviceServiceForTest(t)
// 启用防共享策略
if err := repos.Setting.Set(context.Background(), SettingAntiShareEnabled, "true"); err != nil {
t.Fatal(err)
}
type call struct{ text string }
var adminCalls []call
svc.SetAdminNotifier(func(_ context.Context, text string) {
adminCalls = append(adminCalls, call{text})
})
svc.SetNotifier(func(context.Context, string, string) {}) // 用户通知静默接收
// 第一次登录注册设备
svc.RecordLogin(context.Background(), userID, "dev-1", "Phone-A", "Infuse", "1.2.3.4")
// 同设备 ID 换设备名 → 触发指纹变更警告
svc.RecordLogin(context.Background(), userID, "dev-1", "Phone-B", "Infuse", "1.2.3.4")
if len(adminCalls) == 0 {
t.Fatal("admin must be notified on fingerprint warn")
}
}
// 账号因设备策略被禁用时,管理员也必须收到告警。
func TestPolicyDisableNotifiesAdmin(t *testing.T) {
svc, repos, userID := newDeviceServiceForTest(t)
// 启用防共享策略,设置最大并发客户端为 1
for _, kv := range [][2]string{
{SettingAntiShareEnabled, "true"},
{SettingMaxLoggedClients, "1"},
{SettingClientActiveDays, "30"},
} {
if err := repos.Setting.Set(context.Background(), kv[0], kv[1]); err != nil {
t.Fatal(err)
}
}
var adminCalls []string
svc.SetAdminNotifier(func(_ context.Context, text string) {
adminCalls = append(adminCalls, text)
})
svc.SetNotifier(func(context.Context, string, string) {})
// 两台不同设备登录,超出上限 → 触发禁用
svc.RecordLogin(context.Background(), userID, "dev-1", "Phone", "Infuse", "1.2.3.4")
svc.RecordLogin(context.Background(), userID, "dev-2", "TV", "Emby", "1.2.3.5")
if len(adminCalls) == 0 {
t.Fatal("admin must be notified when account is disabled by policy")
}
if !strings.Contains(adminCalls[0], "禁用") {
t.Fatalf("admin notification = %q, want it to mention 禁用", adminCalls[0])
}
}
+26 -2
View File
@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"encoding/hex"
"fmt"
"html"
"strings"
"time"
@@ -34,6 +35,10 @@ type DeviceService struct {
// notifyUser sends a Telegram message to the local user (resolved to their
// Telegram binding). Wired by the bot service; nil disables notifications.
notifyUser func(ctx context.Context, userID, text string)
// notifyAdmin sends a Telegram message to all admin accounts.
// Wired by the bot service; nil disables notifications.
notifyAdmin func(ctx context.Context, text string)
}
// NewDeviceService constructs a DeviceService.
@@ -46,6 +51,11 @@ func (s *DeviceService) SetNotifier(fn func(ctx context.Context, userID, text st
s.notifyUser = fn
}
// SetAdminNotifier wires the admin-broadcast Telegram notification callback.
func (s *DeviceService) SetAdminNotifier(fn func(ctx context.Context, text string)) {
s.notifyAdmin = fn
}
func (s *DeviceService) SetSessionTracker(tracker *SessionTrackerService) {
s.sessions = tracker
}
@@ -102,6 +112,8 @@ func (s *DeviceService) RecordLogin(ctx context.Context, userID, deviceID, devic
FirstSeenAt: now,
LastSeenAt: now,
})
// 新终端首次登录才通知:已知设备重复登录不刷屏。
s.notify(ctx, userID, fmt.Sprintf("🔔 新设备登录:<b>%s</b>\n如果这不是你本人,请到「个人资料 → 我的设备」踢下线并修改密码。", deviceLabel(deviceName, client)))
} else {
if existing.Fingerprint != "" && existing.Fingerprint != fp {
mismatch = true
@@ -236,7 +248,10 @@ func (s *DeviceService) registerFingerprintWarning(ctx context.Context, userID,
"last_share_warn_at": &now,
})
left := cfg.WarnThreshold + 1 - warnings
s.notify(ctx, userID, fmt.Sprintf("⚠️ 账号 <b>%s</b> 触发设备指纹警告:%s\n这是第 <b>%d</b> 次警告,再异常 <b>%d</b> 次将禁用账号。请使用 Bot 的「我的设备」踢下线异常设备。", u.Username, reason, warnings, left))
warnText := fmt.Sprintf("⚠️ 账号 <b>%s</b> 触发设备指纹警告:%s\n这是第 <b>%d</b> 次警告,再异常 <b>%d</b> 次将禁用账号。请到「个人资料 → 我的设备」踢下线异常设备。",
html.EscapeString(u.Username), html.EscapeString(reason), warnings, left)
s.notify(ctx, userID, warnText)
s.notifyAdminMsg(ctx, warnText)
s.log.Info("anti-share: warning issued", zap.String("user", u.Username), zap.Int("warnings", warnings), zap.String("reason", reason))
}
@@ -255,7 +270,10 @@ func (s *DeviceService) disableForPolicy(ctx context.Context, userID, reason str
"last_share_warn_at": &now,
})
_ = s.repo.UserDevice.SetKickedByUser(ctx, userID, true)
s.notify(ctx, userID, fmt.Sprintf("⛔️ 账号 <b>%s</b> 因触发设备规则已被禁用:%s\n请联系管理员解除禁用,或通过「我的设备」踢下线多余设备后再申请恢复。", u.Username, reason))
disableText := fmt.Sprintf("⛔️ 账号 <b>%s</b> 因触发设备规则已被禁用:%s\n请联系管理员解除禁用,或通过「个人资料 → 我的设备」踢下线多余设备后再申请恢复。",
html.EscapeString(u.Username), html.EscapeString(reason))
s.notify(ctx, userID, disableText)
s.notifyAdminMsg(ctx, disableText)
s.log.Warn("device policy: disabled account", zap.String("user", u.Username), zap.String("reason", reason))
}
@@ -276,6 +294,12 @@ func (s *DeviceService) notify(ctx context.Context, userID, text string) {
}
}
func (s *DeviceService) notifyAdminMsg(ctx context.Context, text string) {
if s.notifyAdmin != nil {
s.notifyAdmin(ctx, text)
}
}
func deviceLabel(name, client string) string {
name = strings.TrimSpace(name)
client = strings.TrimSpace(client)
+24
View File
@@ -76,6 +76,10 @@ type EmbyService struct {
adult *AdultProvider
personImageMu sync.RWMutex
personImages map[string]string
// discovery 提供 NextUp / Similar / Genres 的候选集。它只选候选,
// DTO 形状仍由本服务统一产出,避免同一部剧在不同接口上长得不一样。
discovery *MediaDiscoveryService
}
// NewEmbyService is the constructor.
@@ -91,6 +95,26 @@ func (e *EmbyService) SetEmbyRemote(remote *EmbyRemoteService) *EmbyService {
return e
}
// SetDiscovery 注入发现类查询服务(NextUp / Similar / Genres)。
func (e *EmbyService) SetDiscovery(discovery *MediaDiscoveryService) *EmbyService {
if e != nil {
e.discovery = discovery
}
return e
}
// discoveryService 返回发现服务;未注入时按需构造,保证 Emby 接口在任何
// 组装顺序下都不会因为缺少注入而返回空结果。
func (e *EmbyService) discoveryService() *MediaDiscoveryService {
if e == nil {
return nil
}
if e.discovery == nil {
e.discovery = NewMediaDiscoveryService(e.log, e.repo)
}
return e.discovery
}
// SetTMDbProvider wires the TMDb client used for detail-time cast/crew lookup.
func (e *EmbyService) SetTMDbProvider(tmdb *TMDbProvider) *EmbyService {
if e != nil {
+132
View File
@@ -1,6 +1,8 @@
package service
import (
"context"
"strings"
"testing"
"time"
@@ -65,3 +67,133 @@ func TestEmbyLatestItemsOrderByReleaseDate(t *testing.T) {
t.Fatalf("latest item should expose PremiereDate for Emby clients: %#v", items[0])
}
}
// SimilarItems with a real series_id (not a media table row ID) must return
// results instead of an empty list. Before bug-2 fix, Media.FindByID returned
// nil for any ID that wasn't a primary-key match in the media table (including
// series_id values and virtual msgo-series-* IDs), so SimilarItems always
// returned empty for series detail pages.
func TestEmbyServiceSimilarItemsSeriesID(t *testing.T) {
svc := newTestEmbyService(t)
svc.SetDiscovery(NewMediaDiscoveryService(zap.NewNop(), svc.repo))
lib := model.Library{Name: "TV", Path: "/media/tv", Type: "tv", Enabled: true}
if err := svc.repo.Library.Create(context.Background(), &lib); err != nil {
t.Fatal(err)
}
// Two series with the same genre so they score > 0 for similarity.
ep1 := model.Media{
LibraryID: lib.ID,
SeriesID: "real-series-1",
Title: "剧一",
Genres: "Action",
SeasonNum: 1,
EpisodeNum: 1,
Path: "/media/tv/s1e1.mkv",
}
ep2 := model.Media{
LibraryID: lib.ID,
SeriesID: "real-series-2",
Title: "剧二",
Genres: "Action",
SeasonNum: 1,
EpisodeNum: 1,
Path: "/media/tv/s2e1.mkv",
}
for _, m := range []*model.Media{&ep1, &ep2} {
if err := svc.repo.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
}
// "real-series-1" is the series_id stored in the media row but is NOT a
// primary key in the media table, so Media.FindByID("real-series-1") returns
// nil. SimilarItems must fall back to findSeriesGroup and still return
// results.
result, err := svc.SimilarItems(context.Background(), "real-series-1", "", 12)
if err != nil {
t.Fatalf("SimilarItems with series_id: %v", err)
}
similar, _ := result["Items"].([]map[string]any)
if similar == nil {
t.Fatal("SimilarItems returned nil items for a series_id that resolves via findSeriesGroup")
}
// Should contain 剧二 (the only other episodic content with the same genre).
found := false
for _, item := range similar {
if name, _ := item["Name"].(string); strings.Contains(name, "剧二") || strings.Contains(name, "第 1 集") {
found = true
break
}
}
if !found && len(similar) == 0 {
t.Fatalf("SimilarItems returned no results; want at least 剧二 for series real-series-1")
}
}
// SimilarItems with a virtual series ID (msgo-series-*) must also work.
// Virtual IDs are generated for episodes that have no series_id set.
func TestEmbyServiceSimilarItemsVirtualSeriesID(t *testing.T) {
svc := newTestEmbyService(t)
svc.SetDiscovery(NewMediaDiscoveryService(zap.NewNop(), svc.repo))
lib := model.Library{Name: "TV2", Path: "/media/tv2", Type: "tv", Enabled: true}
if err := svc.repo.Library.Create(context.Background(), &lib); err != nil {
t.Fatal(err)
}
// Episodes WITHOUT SeriesID → series group gets a virtual msgo-series-* ID.
ep1 := model.Media{
LibraryID: lib.ID,
Title: "虚拟剧一",
Genres: "Drama",
SeasonNum: 1,
EpisodeNum: 1,
Path: "/media/tv2/virtual1/S01E01.mkv",
}
ep2 := model.Media{
LibraryID: lib.ID,
Title: "虚拟剧二",
Genres: "Drama",
SeasonNum: 1,
EpisodeNum: 1,
Path: "/media/tv2/virtual2/S01E01.mkv",
}
for _, m := range []*model.Media{&ep1, &ep2} {
if err := svc.repo.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
}
// Fetch series items to get the virtual ID for ep1's series.
seriesItems, err := svc.Items(context.Background(), ItemsParams{
IncludeItemTypes: []string{"Series"},
Recursive: true,
})
if err != nil {
t.Fatal(err)
}
items, _ := seriesItems["Items"].([]map[string]any)
var virtualID string
for _, item := range items {
id, _ := item["Id"].(string)
name, _ := item["Name"].(string)
if strings.Contains(name, "虚拟剧一") && strings.HasPrefix(id, embyVirtualSeriesPrefix) {
virtualID = id
break
}
}
if virtualID == "" {
t.Skip("virtual series ID not generated for episodes without series_id in this build")
}
result, err := svc.SimilarItems(context.Background(), virtualID, "", 12)
if err != nil {
t.Fatalf("SimilarItems with virtual series ID: %v", err)
}
similar, _ := result["Items"].([]map[string]any)
if similar == nil {
t.Fatalf("SimilarItems returned nil items for virtual series ID %q", virtualID)
}
}
+178
View File
@@ -0,0 +1,178 @@
package service
import (
"context"
"crypto/sha1"
"encoding/hex"
"strings"
)
// Emby 发现类接口:NextUp / Similar / Genres。
//
// 候选集的选取交给 MediaDiscoveryService(纯查询、无 DTO 概念),本文件只做
// 「候选 → Emby DTO」的映射,复用 itemPayload 以保证与 /Items 的形状一致。
const (
embyNextUpDefaultLimit = 20
embySimilarDefaultLimit = 12
embyNextUpMaxLimit = 100
embySimilarMaxLimit = 50
)
// NextUp 返回「每部在看的剧的下一集」,即 Emby 客户端首页「接下来播放」的数据源。
func (e *EmbyService) NextUp(ctx context.Context, userID string, limit int) (map[string]any, error) {
if limit <= 0 {
limit = embyNextUpDefaultLimit
}
if limit > embyNextUpMaxLimit {
limit = embyNextUpMaxLimit
}
if strings.TrimSpace(userID) == "" {
return emptyItemsEnvelope(0), nil
}
discovery := e.discoveryService()
if discovery == nil {
return emptyItemsEnvelope(0), nil
}
rows, err := discovery.NextUpCandidates(ctx, userID, limit, e.mediaVisibility(ctx, userID))
if err != nil {
return nil, err
}
// Bug 3 fix: use payloadsForMedia which attaches the request-scoped payload
// cache (withPayloadCache + prefetchPayloadCache) to avoid N+1 DB queries.
items, err := e.payloadsForMedia(ctx, rows, userID)
if err != nil {
return nil, err
}
return map[string]any{
"Items": items,
"TotalRecordCount": int64(len(items)),
}, nil
}
// SimilarItems 返回与指定条目相似的本地媒体。
//
// 找不到条目(或该条目对当前用户不可见)时返回空列表而不是错误:客户端会在
// 详情页无条件请求它,404/500 会让客户端把条目判定为不完整。
func (e *EmbyService) SimilarItems(ctx context.Context, mediaID, userID string, limit int) (map[string]any, error) {
if limit <= 0 {
limit = embySimilarDefaultLimit
}
if limit > embySimilarMaxLimit {
limit = embySimilarMaxLimit
}
discovery := e.discoveryService()
if discovery == nil || strings.TrimSpace(mediaID) == "" {
return emptyItemsEnvelope(0), nil
}
// Bug 2 fix: resolve virtual series IDs (msgo-series-*) and real series
// table IDs to a representative episode so SimilarCandidates (which calls
// Media.FindByID) can seed similarity from concrete media metadata.
resolvedID := mediaID
if strings.HasPrefix(mediaID, embyVirtualSeriesPrefix) {
series, ok, err := e.findSeriesGroup(ctx, mediaID, userID)
if err != nil {
return nil, err
}
if !ok || len(series.Episodes) == 0 {
return emptyItemsEnvelope(0), nil
}
resolvedID = series.Episodes[0].ID
} else if e.repo != nil && e.repo.Media != nil {
// For non-virtual IDs that are series-level (not in media table), also
// resolve via findSeriesGroup so the seed row can be found.
m, err := e.repo.Media.FindByID(ctx, mediaID)
if err != nil {
return nil, err
}
if m == nil {
series, ok, err := e.findSeriesGroup(ctx, mediaID, userID)
if err != nil {
return nil, err
}
if !ok || len(series.Episodes) == 0 {
return emptyItemsEnvelope(0), nil
}
resolvedID = series.Episodes[0].ID
}
}
rows, err := discovery.SimilarCandidates(ctx, resolvedID, limit, e.mediaVisibility(ctx, userID))
if err != nil {
return nil, err
}
// Bug 3 fix: use payloadsForMedia which attaches the request-scoped payload
// cache (withPayloadCache + prefetchPayloadCache) to avoid N+1 DB queries.
items, err := e.payloadsForMedia(ctx, rows, userID)
if err != nil {
return nil, err
}
return map[string]any{
"Items": items,
"TotalRecordCount": int64(len(items)),
}, nil
}
// Genres 返回类型清单。parentID 非空时(客户端按媒体库浏览类型)只统计该库。
func (e *EmbyService) Genres(ctx context.Context, userID, parentID string) (map[string]any, error) {
discovery := e.discoveryService()
if discovery == nil {
return emptyItemsEnvelope(0), nil
}
libraryID := ""
if trimmed := strings.TrimSpace(parentID); trimmed != "" {
// 只有本地的真实库 ID 才能用于收窄;虚拟视图 ID(Emby 客户端自己的
// 视图标识)收窄后会得到空结果,因此识别不出来时按全库统计。
if e.libraryExists(ctx, trimmed) {
libraryID = trimmed
}
}
genres, err := discovery.AggregateGenres(ctx, e.mediaVisibility(ctx, userID), libraryID)
if err != nil {
return nil, err
}
items := make([]map[string]any, 0, len(genres))
for _, genre := range genres {
items = append(items, map[string]any{
"Id": embyGenreID(genre.Name),
"Name": genre.Name,
"ItemCount": genre.Count,
"Type": "Genre",
"ServerId": embyServerID,
"IsFolder": false,
"CanDelete": false,
"CanDownload": false,
"ImageTags": map[string]any{},
"BackdropImageTags": []any{},
})
}
return map[string]any{
"Items": items,
"TotalRecordCount": int64(len(items)),
}, nil
}
// libraryExists 判断 ID 是否对应本地媒体库。
func (e *EmbyService) libraryExists(ctx context.Context, id string) bool {
if e == nil || e.repo == nil || e.repo.Library == nil {
return false
}
lib, err := e.repo.Library.FindByID(ctx, id)
if err != nil {
return false
}
return lib != nil
}
// embyGenreID 为类型生成稳定的虚拟 ID。
//
// 客户端会把 Id 当作条目去请求图片/详情,直接用类型名会带上空格与非 ASCII
// 字符,因此用固定前缀 + 名称哈希;同一个名称永远得到同一个 ID。
func embyGenreID(name string) string {
sum := sha1.Sum([]byte(strings.ToLower(strings.TrimSpace(name))))
return "msgo-genre-" + hex.EncodeToString(sum[:])[:16]
}
+17
View File
@@ -32,10 +32,26 @@ func (s *MediaService) mediaListCacheKey(libraryID string, libraryIDs []string,
strings.Join(allowed, ","),
strings.Join(hidden, ","),
filter.SeriesID,
filterFingerprint(filter),
}, "|")))
return "media:list:" + hex.EncodeToString(sum[:])
}
// filterFingerprint 把影响结果的筛选维度序列化成稳定字符串。
//
// 缓存键必须覆盖所有会改变结果的过滤条件:漏一个就会出现「先打开未筛选列表,
// 再筛选时命中旧缓存」这类脏读(返回不带筛选的数据)。新增过滤字段时只改这里。
func filterFingerprint(filter repository.MediaQueryFilter) string {
genres := append([]string(nil), filter.Genres...)
sort.Strings(genres)
return strings.Join([]string{
"genres=" + strings.Join(genres, ","),
fmt.Sprintf("year=%d-%d", filter.YearMin, filter.YearMax),
fmt.Sprintf("rating=%.2f", filter.RatingMin),
fmt.Sprintf("unwatched=%t:%s", filter.UnwatchedOnly, filter.UnwatchedUserID),
}, "&")
}
func (s *MediaService) libraryPreviewCacheKey(libraries []model.Library, cardLimit int, filter repository.MediaQueryFilter, includeCounts bool) string {
libIDs := make([]string, len(libraries))
for i, lib := range libraries {
@@ -138,6 +154,7 @@ func (s *MediaService) groupedItemsCacheKey(libraryID string, libraryIDs []strin
strings.Join(allowed, ","),
strings.Join(hidden, ","),
filter.SeriesID,
filterFingerprint(filter),
})
}
+570
View File
@@ -0,0 +1,570 @@
package service
import (
"context"
"sort"
"strings"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// MediaDiscoveryService 提供「发现类」查询:类型聚合、下一集、相似内容。
//
// 它只负责候选集的选取,不产出 Emby DTO —— DTO 形状必须由 EmbyService 统一
// 提供,否则同一部剧在 /Items 与 /Shows/NextUp 上会长得不一样。同理,这里
// 只接受调用方传入的 MediaVisibility,不自己解析用户权限。
type MediaDiscoveryService struct {
log *zap.Logger
repo *repository.Container
}
// GenreCount 是类型聚合结果。Name 保留首次出现时的写法(大小写与全半角
// 均按原样展示),计数则不区分大小写。
type GenreCount struct {
Name string `json:"name"`
Count int `json:"count"`
}
// NewMediaDiscoveryService 构建发现服务。
func NewMediaDiscoveryService(log *zap.Logger, repo *repository.Container) *MediaDiscoveryService {
return &MediaDiscoveryService{log: log, repo: repo}
}
// mediaFilterFromVisibility 把可见性翻译成仓储过滤条件。所有发现类查询都必须
// 经过这里,避免某一处忘记过滤 NSFW 或受限媒体库。
func mediaFilterFromVisibility(visibility MediaVisibility) repository.MediaQueryFilter {
return repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
}
}
// AggregateGenres 统计可见媒体的类型分布。libraryID 非空时只统计该库,
// 供媒体库页的筛选项与 Emby /Genres 共用。
func (s *MediaDiscoveryService) AggregateGenres(ctx context.Context, visibility MediaVisibility, libraryID string) ([]GenreCount, error) {
if s == nil || s.repo == nil || s.repo.Media == nil {
return nil, nil
}
filter := mediaFilterFromVisibility(visibility)
filter.LibraryID = strings.TrimSpace(libraryID)
raw, err := s.repo.Media.ListGenreValues(ctx, filter)
if err != nil {
return nil, err
}
return countGenres(raw), nil
}
// countGenres 切分并计数。大小写不同的同名类型合并计数,展示名取首次出现的
// 写法;结果按 count 降序、同数按名称升序,保证输出稳定可测。
func countGenres(values []string) []GenreCount {
counts := make(map[string]int)
display := make(map[string]string)
for _, value := range values {
for _, name := range SplitGenreList(value) {
key := strings.ToLower(name)
if _, seen := display[key]; !seen {
display[key] = name
}
counts[key]++
}
}
out := make([]GenreCount, 0, len(counts))
for key, count := range counts {
out = append(out, GenreCount{Name: display[key], Count: count})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Count != out[j].Count {
return out[i].Count > out[j].Count
}
return out[i].Name < out[j].Name
})
return out
}
// SplitGenreList 切分逗号分隔的类型字段。
//
// 刮削来源既有英文逗号也有中文全角逗号,且常见首尾空格,因此三种分隔符都要
// 处理,并丢弃空段。
func SplitGenreList(value string) []string {
if strings.TrimSpace(value) == "" {
return nil
}
parts := strings.FieldsFunc(value, func(r rune) bool {
return r == ',' || r == ',' || r == '、' || r == ';' || r == ';'
})
out := make([]string, 0, len(parts))
for _, part := range parts {
if trimmed := strings.TrimSpace(part); trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
// genreSet 把类型字段转成小写集合,用于相似度计算。
func genreSet(value string) map[string]struct{} {
names := SplitGenreList(value)
if len(names) == 0 {
return nil
}
out := make(map[string]struct{}, len(names))
for _, name := range names {
out[strings.ToLower(name)] = struct{}{}
}
return out
}
// genreOverlap 返回两个类型集合的交集大小。
func genreOverlap(a, b map[string]struct{}) int {
if len(a) == 0 || len(b) == 0 {
return 0
}
// 遍历较小的集合,减少比较次数。
if len(b) < len(a) {
a, b = b, a
}
count := 0
for name := range a {
if _, ok := b[name]; ok {
count++
}
}
return count
}
// mediaIsEpisode 判断一行 media 是否属于「剧集」维度。
//
// 与 Emby 的判定保持一致(季号或集号大于 0),但不依赖 library type:同一个
// 库既可能放电影也可能放剧集,用编号判断更贴近实际数据。
func mediaIsEpisode(m *model.Media) bool {
return m != nil && (m.SeasonNum > 0 || m.EpisodeNum > 0)
}
// seriesGroupKey 是「同一部剧」的归并键。SeriesID 优先;缺省时退回
// (库, 标题),这样未刮削的剧集也能归到一组而不是每条历史各算一部剧。
func seriesGroupKey(m *model.Media) string {
if m == nil {
return ""
}
if key := strings.TrimSpace(m.SeriesID); key != "" {
return "sid:" + key
}
if !mediaIsEpisode(m) {
return ""
}
return "lib:" + m.LibraryID + "|title:" + strings.ToLower(strings.TrimSpace(m.Title))
}
// nextUpHistoryScanLimit 是扫描播放历史的上限。历史按最近观看倒序取,
// 因此截断只会丢掉「很久没看且排在很后面」的剧,不会影响首页前排。
const nextUpHistoryScanLimit = 100
// NextUpCandidates 返回「每部在看的剧的下一个待看集」,按最近观看时间排序。
//
// 语义要点:
// - 只处理剧集,电影由 Resume 接口负责,避免两个接口内容重复。
// - 同一部剧最多一条:取最近看过的集合之后、编号最小的那集。
// - 已标记看完的集跳过;追到最后一集则该剧不出现在结果里。
func (s *MediaDiscoveryService) NextUpCandidates(ctx context.Context, userID string, limit int, visibility MediaVisibility) ([]model.Media, error) {
if s == nil || s.repo == nil || s.repo.Media == nil {
return nil, nil
}
if strings.TrimSpace(userID) == "" {
return nil, nil
}
if limit <= 0 {
limit = 20
}
// Bug 1 fix: include completed histories as anchors so a finished episode
// still anchors its series and the next unwatched episode is picked.
var histories []model.PlaybackHistory
if err := s.repo.DB.WithContext(ctx).
Where("user_id = ? AND position_ms > 0", userID).
Order("watched_at desc").
Limit(nextUpHistoryScanLimit).
Find(&histories).Error; err != nil {
return nil, err
}
if len(histories) == 0 {
return nil, nil
}
mediaIDs := make([]string, 0, len(histories))
for _, h := range histories {
mediaIDs = append(mediaIDs, h.MediaID)
}
filter := mediaFilterFromVisibility(visibility)
var watchedRows []model.Media
q := s.repo.DB.WithContext(ctx).Where("id IN ?", mediaIDs)
q = applyDiscoveryVisibility(q, filter)
if err := q.Find(&watchedRows).Error; err != nil {
return nil, err
}
byID := make(map[string]*model.Media, len(watchedRows))
for i := range watchedRows {
byID[watchedRows[i].ID] = &watchedRows[i]
}
// 按最近观看顺序归并到「剧」维度,同时记住该剧最近的已看集。
type seriesState struct {
key string
current *model.Media
}
states := make([]seriesState, 0, len(histories))
seen := make(map[string]bool, len(histories))
for _, h := range histories {
m := byID[h.MediaID]
if m == nil || !mediaIsEpisode(m) {
continue
}
key := seriesGroupKey(m)
if key == "" || seen[key] {
continue
}
seen[key] = true
states = append(states, seriesState{key: key, current: m})
}
if len(states) == 0 {
return nil, nil
}
// 一次性把涉及的剧集全部取回,避免按剧逐条查询。
//
// Bug 4 fix: group by library_id when fetching by series_id so episodes
// from a different library with the same series_id don't bleed in.
// Bug 5 fix: batch unscraped (library_id, title) lookups per library
// instead of one query per series.
byLibSeries := make(map[string][]string) // libID -> []seriesID
var fallback []seriesState
for _, st := range states {
if id := strings.TrimSpace(st.current.SeriesID); id != "" {
byLibSeries[st.current.LibraryID] = append(byLibSeries[st.current.LibraryID], id)
} else {
fallback = append(fallback, st)
}
}
episodes := make([]model.Media, 0, len(states)*8)
// 按库批量加载刮削剧集,避免跨库混入同名 series_id 的剧集。
for libID, sids := range byLibSeries {
var rows []model.Media
eq := s.repo.DB.WithContext(ctx).Where("series_id IN ? AND library_id = ?", sids, libID)
eq = applyDiscoveryVisibility(eq, filter)
if err := eq.Find(&rows).Error; err != nil {
return nil, err
}
episodes = append(episodes, rows...)
}
// 未刮削剧集按 (库, 标题) 批量兜底查询,每库一条 SQL 避免 N+1。
byLibTitles := make(map[string][]string) // libID -> []title
for _, st := range fallback {
byLibTitles[st.current.LibraryID] = append(byLibTitles[st.current.LibraryID], st.current.Title)
}
for libID, titles := range byLibTitles {
var rows []model.Media
fq := s.repo.DB.WithContext(ctx).Where("library_id = ? AND title IN ?", libID, titles)
fq = applyDiscoveryVisibility(fq, filter)
if err := fq.Find(&rows).Error; err != nil {
return nil, err
}
episodes = append(episodes, rows...)
}
// 按剧归并候选集,便于 O(1) 查找下一集。
bySeries := make(map[string][]model.Media, len(states))
for _, row := range episodes {
key := seriesGroupKey(&row)
if key == "" {
continue
}
bySeries[key] = append(bySeries[key], row)
}
completed := s.completedMediaIDs(ctx, userID, episodes)
out := make([]model.Media, 0, limit)
for _, st := range states {
if len(out) >= limit {
break
}
next, ok := pickNextEpisode(bySeries[st.key], st.current, completed)
if !ok {
continue
}
out = append(out, next)
}
return out, nil
}
// applyDiscoveryVisibility 把可见性过滤应用到查询上。
func applyDiscoveryVisibility(q *gorm.DB, filter repository.MediaQueryFilter) *gorm.DB {
if !filter.IncludeNSFW {
q = q.Where("nsfw = ?", false)
}
if len(filter.HiddenLibraryIDs) > 0 {
q = q.Where("library_id NOT IN ?", filter.HiddenLibraryIDs)
}
if len(filter.AllowedLibraryIDs) > 0 {
q = q.Where("library_id IN ?", filter.AllowedLibraryIDs)
}
if libraryID := strings.TrimSpace(filter.LibraryID); libraryID != "" {
q = q.Where("library_id = ?", libraryID)
}
return q
}
// completedMediaIDs 找出这些候选里该用户已标记看完的集。
func (s *MediaDiscoveryService) completedMediaIDs(ctx context.Context, userID string, rows []model.Media) map[string]bool {
out := make(map[string]bool)
if len(rows) == 0 {
return out
}
ids := make([]string, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.ID)
}
var done []model.PlaybackHistory
if err := s.repo.DB.WithContext(ctx).
Where("user_id = ? AND completed = ? AND media_id IN ?", userID, true, ids).
Find(&done).Error; err != nil {
return out
}
for _, h := range done {
out[h.MediaID] = true
}
return out
}
// pickNextEpisode 在候选集中选出严格晚于 current 的、编号最小的一集。
// 比较顺序为 (季, 集),因此跨季时自然落到下一季第一集。
func pickNextEpisode(candidates []model.Media, current *model.Media, completed map[string]bool) (model.Media, bool) {
var best model.Media
found := false
for _, candidate := range candidates {
if candidate.ID == current.ID || completed[candidate.ID] {
continue
}
if !episodeAfter(candidate, *current) {
continue
}
if !found || episodeBefore(candidate, best) {
best = candidate
found = true
}
}
return best, found
}
// episodeAfter 报告 a 是否严格晚于 b。
func episodeAfter(a, b model.Media) bool {
if a.SeasonNum != b.SeasonNum {
return a.SeasonNum > b.SeasonNum
}
return a.EpisodeNum > b.EpisodeNum
}
// episodeBefore 报告 a 是否严格早于 b。
func episodeBefore(a, b model.Media) bool {
if a.SeasonNum != b.SeasonNum {
return a.SeasonNum < b.SeasonNum
}
return a.EpisodeNum < b.EpisodeNum
}
// similarCandidateLimit 是每个来源池(同库 / 同类型其他库)的候选上限。
//
// 相似度需要在内存里按类型/年份/评分算分,因此不能把整库拉出来;按评分倒序
// 取前 N 条是「好的片子更可能被推荐」与「查询有界」之间的折中。
const similarCandidateLimit = 400
// SimilarCandidates 返回与源条目相似的本地媒体。
//
// 打分口径(不依赖任何外部 API,离线可用):
// - 类型重合数 × 10:最强信号,同类内容通常才谈得上相似;
// - 年份接近度:相差 5 年内给分,差得越远越低;
// - 评分接近度:同为高分片算加分,避免「8 分片旁边推 3 分片」。
//
// 同库优先;不足时才从同类型的其它可见库里补齐。同剧其它集与自身一律排除。
func (s *MediaDiscoveryService) SimilarCandidates(ctx context.Context, mediaID string, limit int, visibility MediaVisibility) ([]model.Media, error) {
if s == nil || s.repo == nil || s.repo.Media == nil {
return nil, nil
}
if limit <= 0 {
limit = 12
}
source, err := s.repo.Media.FindByID(ctx, mediaID)
if err != nil {
return nil, err
}
if source == nil || !visibility.Allows(source) {
return nil, nil
}
filter := mediaFilterFromVisibility(visibility)
pool, err := s.similarPool(ctx, filter, source, false)
if err != nil {
return nil, err
}
if len(pool) < limit {
// 同库不够时再扩到同类型库,保持「电影配电影、剧集配剧集」的直觉。
more, err := s.similarPool(ctx, filter, source, true)
if err != nil {
return nil, err
}
pool = append(pool, more...)
}
return rankSimilar(source, pool, limit), nil
}
// similarPool 取一批候选。expand=true 时排除源所在的库(用于补齐阶段),
// 否则只取源所在的库(首选阶段)。
func (s *MediaDiscoveryService) similarPool(ctx context.Context, filter repository.MediaQueryFilter, source *model.Media, expand bool) ([]model.Media, error) {
q := s.repo.DB.WithContext(ctx).Model(&model.Media{})
q = applyDiscoveryVisibility(q, filter)
q = q.Where("id <> ?", source.ID)
libraryIDs := []string{source.LibraryID}
if expand {
ids, err := s.compatibleLibraryIDs(ctx, source)
if err != nil {
return nil, err
}
filtered := make([]string, 0, len(ids))
for _, id := range ids {
if id != source.LibraryID {
filtered = append(filtered, id)
}
}
if len(filtered) == 0 {
return nil, nil
}
libraryIDs = filtered
}
q = q.Where("library_id IN ?", libraryIDs)
// 电影与剧集不互相推荐:用集号判定,和 NextUp 保持同一套口径。
if mediaIsEpisode(source) {
q = q.Where("(season_num > 0 OR episode_num > 0)")
} else {
q = q.Where("season_num = 0 AND episode_num = 0")
}
var rows []model.Media
if err := q.Order("rating desc, updated_at desc").Limit(similarCandidateLimit).Find(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
// compatibleLibraryIDs 返回与源条目同类型的库 ID(可能包含源库自身)。
// 查不到类型时退回源库,保证补齐阶段不会跨类型乱推。
func (s *MediaDiscoveryService) compatibleLibraryIDs(ctx context.Context, source *model.Media) ([]string, error) {
if s.repo.Library == nil {
return []string{source.LibraryID}, nil
}
libs, err := s.repo.Library.List(ctx)
if err != nil {
return nil, err
}
sourceType := ""
for _, lib := range libs {
if lib.ID == source.LibraryID {
sourceType = strings.ToLower(strings.TrimSpace(lib.Type))
break
}
}
if sourceType == "" {
return []string{source.LibraryID}, nil
}
out := make([]string, 0, len(libs))
for _, lib := range libs {
if strings.ToLower(strings.TrimSpace(lib.Type)) == sourceType {
out = append(out, lib.ID)
}
}
return out, nil
}
// rankSimilar 按相似度排序并截断。
func rankSimilar(source *model.Media, pool []model.Media, limit int) []model.Media {
if len(pool) == 0 {
return nil
}
sourceGenres := genreSet(source.Genres)
sourceKey := seriesGroupKey(source)
type scored struct {
media model.Media
score float64
}
scoredRows := make([]scored, 0, len(pool))
seen := make(map[string]bool, len(pool))
for _, candidate := range pool {
if candidate.ID == source.ID || seen[candidate.ID] {
continue
}
// 同剧其它集不参与:「相似」不是在推荐本剧的下一集。
if key := seriesGroupKey(&candidate); key != "" && key == sourceKey {
continue
}
seen[candidate.ID] = true
scoredRows = append(scoredRows, scored{
media: candidate,
score: similarScore(source, &candidate, sourceGenres),
})
}
sort.SliceStable(scoredRows, func(i, j int) bool {
if scoredRows[i].score != scoredRows[j].score {
return scoredRows[i].score > scoredRows[j].score
}
if scoredRows[i].media.Rating != scoredRows[j].media.Rating {
return scoredRows[i].media.Rating > scoredRows[j].media.Rating
}
return scoredRows[i].media.Title < scoredRows[j].media.Title
})
if len(scoredRows) > limit {
scoredRows = scoredRows[:limit]
}
out := make([]model.Media, 0, len(scoredRows))
for _, row := range scoredRows {
out = append(out, row.media)
}
return out
}
// similarScore 计算单个候选的相似度。
func similarScore(source, candidate *model.Media, sourceGenres map[string]struct{}) float64 {
score := float64(genreOverlap(sourceGenres, genreSet(candidate.Genres))) * 10
if source.Year > 0 && candidate.Year > 0 {
diff := source.Year - candidate.Year
if diff < 0 {
diff = -diff
}
if diff <= 5 {
score += float64(5 - diff)
}
}
if source.Rating > 0 && candidate.Rating > 0 {
diff := float64(source.Rating - candidate.Rating)
if diff < 0 {
diff = -diff
}
// 评分差 2 分以内才给分,最多 3 分。
if diff < 2 {
score += 3 * (2 - diff) / 2
}
}
return score
}
@@ -0,0 +1,246 @@
package service
import (
"context"
"strconv"
"testing"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// seedEpisode 插入一集,并把播放历史指向 `watched`(nil 表示没有历史)。
func seedEpisode(
t *testing.T,
repos *repository.Container,
libID, seriesID, title string,
season, episode int,
watchedAt *time.Time,
completed bool,
) *model.Media {
t.Helper()
m := &model.Media{
LibraryID: libID,
SeriesID: seriesID,
Title: title,
SeasonNum: season,
EpisodeNum: episode,
Path: "/media/" + seriesID + "/S" + strconv.Itoa(season) + "E" + strconv.Itoa(episode) + ".mkv",
}
if err := repos.DB.WithContext(context.Background()).Create(m).Error; err != nil {
t.Fatal(err)
}
if watchedAt != nil || completed {
// 标记「已看完」也会产生一条历史行,因此 completed 为真时同样要写历史,
// 否则夹具与真实数据不一致(真实库里已看完一定有行)。
watched := time.Now().Add(-time.Hour)
if watchedAt != nil {
watched = *watchedAt
}
h := &model.PlaybackHistory{
UserID: "user-1",
MediaID: m.ID,
PositionMs: 1000,
DurationMs: 2000,
WatchedAt: watched,
Completed: completed,
}
if err := repos.DB.WithContext(context.Background()).Create(h).Error; err != nil {
t.Fatal(err)
}
}
return m
}
func nextUpIDs(t *testing.T, svc *MediaDiscoveryService, visibility MediaVisibility) []string {
t.Helper()
rows, err := svc.NextUpCandidates(context.Background(), "user-1", 20, visibility)
if err != nil {
t.Fatal(err)
}
out := make([]string, 0, len(rows))
for _, row := range rows {
out = append(out, episodeLabel(row))
}
return out
}
// episodeLabel 把一集渲染成 "S1E2",方便断言。
func episodeLabel(m model.Media) string {
return "S" + strconv.Itoa(m.SeasonNum) + "E" + strconv.Itoa(m.EpisodeNum)
}
func TestNextUpPicksNextEpisode(t *testing.T) {
repos := newDiscoveryTestDB(t)
libID := seedDiscoveryLibrary(t, repos, "tv")
watchedAt := time.Now().Add(-time.Hour)
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 1, &watchedAt, false)
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 2, nil, false)
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 3, nil, false)
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true})
if len(got) != 1 || got[0] != "S1E2" {
t.Fatalf("next up = %v, want [S1E2]", got)
}
}
// 电影不进 NextUp:NextUp 的语义是「下一集」,电影由 Resume 接口负责。
func TestNextUpSkipsMovies(t *testing.T) {
repos := newDiscoveryTestDB(t)
libID := seedDiscoveryLibrary(t, repos, "movie")
watchedAt := time.Now().Add(-time.Hour)
seedEpisode(t, repos, libID, "", "电影", 0, 0, &watchedAt, false)
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
if got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true}); len(got) != 0 {
t.Fatalf("next up = %v, want empty", got)
}
}
// 同一部剧有多条未看完历史时,只能出一条,且指向最靠后的已看集的下一集。
func TestNextUpOneEntryPerSeries(t *testing.T) {
repos := newDiscoveryTestDB(t)
libID := seedDiscoveryLibrary(t, repos, "tv")
older := time.Now().Add(-48 * time.Hour)
newer := time.Now().Add(-2 * time.Hour)
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 1, &older, false)
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 2, &newer, false)
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 3, nil, false)
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true})
if len(got) != 1 || got[0] != "S1E3" {
t.Fatalf("next up = %v, want [S1E3]", got)
}
}
// 跨季时下一集应是下一季的第一集。
func TestNextUpCrossesSeason(t *testing.T) {
repos := newDiscoveryTestDB(t)
libID := seedDiscoveryLibrary(t, repos, "tv")
watchedAt := time.Now().Add(-time.Hour)
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 12, &watchedAt, false)
seedEpisode(t, repos, libID, "series-1", "剧一", 2, 1, nil, false)
seedEpisode(t, repos, libID, "series-1", "剧一", 2, 2, nil, false)
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true})
if len(got) != 1 || got[0] != "S2E1" {
t.Fatalf("next up = %v, want [S2E1]", got)
}
}
// 已标记看完的下一集要跳过。
func TestNextUpSkipsCompletedEpisode(t *testing.T) {
repos := newDiscoveryTestDB(t)
libID := seedDiscoveryLibrary(t, repos, "tv")
watchedAt := time.Now().Add(-time.Hour)
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 1, &watchedAt, false)
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 2, nil, true) // 已看完
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 3, nil, false)
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true})
if len(got) != 1 || got[0] != "S1E3" {
t.Fatalf("next up = %v, want [S1E3]", got)
}
}
// 追到最后一集时没有下一集,结果为空而不是重复返回最后一集。
func TestNextUpEmptyAtSeriesEnd(t *testing.T) {
repos := newDiscoveryTestDB(t)
libID := seedDiscoveryLibrary(t, repos, "tv")
watchedAt := time.Now().Add(-time.Hour)
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 3, &watchedAt, false)
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
if got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true}); len(got) != 0 {
t.Fatalf("next up = %v, want empty", got)
}
}
// 不可见媒体库里的下一集不能被推荐出去。
func TestNextUpRespectsVisibility(t *testing.T) {
repos := newDiscoveryTestDB(t)
visibleLib := seedDiscoveryLibrary(t, repos, "tv")
hiddenLib := seedDiscoveryLibrary(t, repos, "tv")
watchedAt := time.Now().Add(-time.Hour)
seedEpisode(t, repos, visibleLib, "series-1", "剧一", 1, 1, &watchedAt, false)
seedEpisode(t, repos, hiddenLib, "series-1", "剧一", 1, 2, nil, false)
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true, AllowedLibraryIDs: []string{visibleLib}})
if len(got) != 0 {
t.Fatalf("next up = %v, want empty (hidden library)", got)
}
}
// 最后一集看完(completed=true)后,下一部剧仍应出现在 NextUp 中,
// 而不是因为没有 completed=false 的历史而消失(bug 1 回归测试)。
func TestNextUpAfterCompletedEpisode(t *testing.T) {
repos := newDiscoveryTestDB(t)
libID := seedDiscoveryLibrary(t, repos, "tv")
watchedAt := time.Now().Add(-time.Hour)
// S1E1 已看完,S1E2 尚未开始。
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 1, &watchedAt, true)
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 2, nil, false)
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 3, nil, false)
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true})
if len(got) != 1 || got[0] != "S1E2" {
t.Fatalf("next up after completed S1E1 = %v, want [S1E2]", got)
}
}
// 整部剧看完(所有集都 completed=true)时不应出现在 NextUp(没有下一集)。
func TestNextUpSeriesFullyWatchedIsEmpty(t *testing.T) {
repos := newDiscoveryTestDB(t)
libID := seedDiscoveryLibrary(t, repos, "tv")
watchedAt := time.Now().Add(-time.Hour)
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 1, nil, true)
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 2, &watchedAt, true) // 最近看完的最后一集
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
if got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true}); len(got) != 0 {
t.Fatalf("next up for fully-watched series = %v, want empty", got)
}
}
// 多部剧时按最近观看时间排序。
func TestNextUpOrdersByRecency(t *testing.T) {
repos := newDiscoveryTestDB(t)
libID := seedDiscoveryLibrary(t, repos, "tv")
older := time.Now().Add(-24 * time.Hour)
newer := time.Now().Add(-1 * time.Hour)
seedEpisode(t, repos, libID, "series-a", "剧A", 1, 1, &older, false)
seedEpisode(t, repos, libID, "series-a", "剧A", 1, 2, nil, false)
seedEpisode(t, repos, libID, "series-b", "剧B", 1, 1, &newer, false)
seedEpisode(t, repos, libID, "series-b", "剧B", 1, 2, nil, false)
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
rows, err := svc.NextUpCandidates(context.Background(), "user-1", 20, MediaVisibility{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
if len(rows) != 2 {
t.Fatalf("rows = %d, want 2", len(rows))
}
if rows[0].SeriesID != "series-b" {
t.Fatalf("first row series = %q, want series-b (most recently watched)", rows[0].SeriesID)
}
}
@@ -0,0 +1,167 @@
package service
import (
"context"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
func seedSimilarMedia(t *testing.T, repos *repository.Container, libID, title, genres string, year int, rating float32) *model.Media {
t.Helper()
m := &model.Media{
LibraryID: libID,
Title: title,
Genres: genres,
Year: year,
Rating: rating,
Path: "/media/" + libID + "/" + title + ".mkv",
}
if err := repos.DB.WithContext(context.Background()).Create(m).Error; err != nil {
t.Fatal(err)
}
return m
}
func similarTitles(t *testing.T, svc *MediaDiscoveryService, sourceID string, visibility MediaVisibility) []string {
t.Helper()
rows, err := svc.SimilarCandidates(context.Background(), sourceID, 12, visibility)
if err != nil {
t.Fatal(err)
}
out := make([]string, 0, len(rows))
for _, row := range rows {
out = append(out, row.Title)
}
return out
}
func containsTitle(items []string, want string) bool {
for _, item := range items {
if item == want {
return true
}
}
return false
}
// 相似推荐必须排除自己,也要排除同剧的其他集(否则详情页会推荐本剧的其它集)。
func TestSimilarExcludesSelfAndSameSeries(t *testing.T) {
repos := newDiscoveryTestDB(t)
libID := seedDiscoveryLibrary(t, repos, "tv")
source := seedSimilarMedia(t, repos, libID, "剧一 E01", "Action", 2020, 8)
source.SeriesID = "series-1"
source.SeasonNum, source.EpisodeNum = 1, 1
if err := repos.DB.WithContext(context.Background()).Save(source).Error; err != nil {
t.Fatal(err)
}
sibling := seedSimilarMedia(t, repos, libID, "剧一 E02", "Action", 2020, 8)
sibling.SeriesID = "series-1"
sibling.SeasonNum, sibling.EpisodeNum = 1, 2
if err := repos.DB.WithContext(context.Background()).Save(sibling).Error; err != nil {
t.Fatal(err)
}
// 同库另一部剧的第 1 集:这才是剧集详情页该推荐的内容。
other := seedSimilarMedia(t, repos, libID, "另一部动作剧", "Action", 2021, 7)
other.SeriesID = "series-2"
other.SeasonNum, other.EpisodeNum = 1, 1
if err := repos.DB.WithContext(context.Background()).Save(other).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
got := similarTitles(t, svc, source.ID, MediaVisibility{IncludeNSFW: true})
if containsTitle(got, "剧一 E01") {
t.Fatalf("result must exclude the source itself: %v", got)
}
if containsTitle(got, "剧一 E02") {
t.Fatalf("result must exclude other episodes of the same series: %v", got)
}
if !containsTitle(got, "另一部动作剧") {
t.Fatalf("result = %v, want it to contain 另一部动作剧", got)
}
}
// 类型重合度高的条目要排在前面。
func TestSimilarPrefersGenreOverlap(t *testing.T) {
repos := newDiscoveryTestDB(t)
libID := seedDiscoveryLibrary(t, repos, "movie")
source := seedSimilarMedia(t, repos, libID, "源片", "Action,Adventure", 2010, 7)
seedSimilarMedia(t, repos, libID, "同类型", "Action,Adventure", 2010, 7)
seedSimilarMedia(t, repos, libID, "弱相关", "Comedy", 2010, 7)
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
got := similarTitles(t, svc, source.ID, MediaVisibility{IncludeNSFW: true})
if len(got) < 2 {
t.Fatalf("result = %v, want at least 2 entries", got)
}
if got[0] != "同类型" {
t.Fatalf("result = %v, want 同类型 ranked first", got)
}
}
// 不可见媒体库的条目不能被推荐。
func TestSimilarRespectsVisibility(t *testing.T) {
repos := newDiscoveryTestDB(t)
sourceLib := seedDiscoveryLibrary(t, repos, "movie")
hiddenLib := seedDiscoveryLibrary(t, repos, "movie")
source := seedSimilarMedia(t, repos, sourceLib, "源片", "Action", 2010, 7)
seedSimilarMedia(t, repos, hiddenLib, "隐藏片", "Action", 2010, 7)
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
got := similarTitles(t, svc, source.ID, MediaVisibility{IncludeNSFW: true, AllowedLibraryIDs: []string{sourceLib}})
if containsTitle(got, "隐藏片") {
t.Fatalf("hidden library leaked into similar: %v", got)
}
}
// 源条目不可见或不存在时返回空,不报错:客户端不该因此看到 500。
func TestSimilarUnknownSourceReturnsEmpty(t *testing.T) {
repos := newDiscoveryTestDB(t)
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
rows, err := svc.SimilarCandidates(context.Background(), "missing", 12, MediaVisibility{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
if len(rows) != 0 {
t.Fatalf("rows = %v, want empty", rows)
}
}
// limit 生效,且不返回重复条目。
func TestSimilarHonoursLimit(t *testing.T) {
repos := newDiscoveryTestDB(t)
libID := seedDiscoveryLibrary(t, repos, "movie")
source := seedSimilarMedia(t, repos, libID, "源片", "Action", 2010, 7)
for i := 0; i < 5; i++ {
seedSimilarMedia(t, repos, libID, "候选"+string(rune('A'+i)), "Action", 2010, 7)
}
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
rows, err := svc.SimilarCandidates(context.Background(), source.ID, 3, MediaVisibility{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
if len(rows) != 3 {
t.Fatalf("rows = %d, want 3", len(rows))
}
seen := map[string]bool{}
for _, row := range rows {
if seen[row.ID] {
t.Fatalf("duplicate row %q in result", row.Title)
}
seen[row.ID] = true
}
}
+136
View File
@@ -0,0 +1,136 @@
package service
import (
"context"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// newDiscoveryTestDB 建好内存库并返回 repository 容器。
func newDiscoveryTestDB(t *testing.T) *repository.Container {
t.Helper()
return repository.New(newServiceTestDB(t))
}
func seedDiscoveryLibrary(t *testing.T, repos *repository.Container, typ string) string {
t.Helper()
lib := &model.Library{Name: "库-" + typ, Path: "/media/" + typ, Type: typ, Enabled: true}
if err := repos.Library.Create(context.Background(), lib); err != nil {
t.Fatal(err)
}
return lib.ID
}
func seedDiscoveryMedia(t *testing.T, repos *repository.Container, m *model.Media) {
t.Helper()
if err := repos.DB.WithContext(context.Background()).Create(m).Error; err != nil {
t.Fatal(err)
}
}
func findGenre(genres []GenreCount, name string) int {
for _, g := range genres {
if g.Name == name {
return g.Count
}
}
return -1
}
func TestAggregateGenresSplitsAndCounts(t *testing.T) {
repos := newDiscoveryTestDB(t)
libID := seedDiscoveryLibrary(t, repos, "movie")
seedDiscoveryMedia(t, repos, &model.Media{
LibraryID: libID, Title: "A", Path: "/media/movie/a.mkv", Genres: "Action,Drama",
})
seedDiscoveryMedia(t, repos, &model.Media{
LibraryID: libID, Title: "B", Path: "/media/movie/b.mkv", Genres: "action",
})
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
genres, err := svc.AggregateGenres(context.Background(), MediaVisibility{IncludeNSFW: true}, "")
if err != nil {
t.Fatal(err)
}
// 大小写不同视为同一类型,展示名保留首次出现的写法。
if got := findGenre(genres, "Action"); got != 2 {
t.Fatalf("Action count = %d, want 2 (genres=%+v)", got, genres)
}
if got := findGenre(genres, "Drama"); got != 1 {
t.Fatalf("Drama count = %d, want 1 (genres=%+v)", got, genres)
}
}
// 中文全角逗号在刮削结果里同样常见,必须一并切分。
func TestAggregateGenresHandlesFullWidthComma(t *testing.T) {
repos := newDiscoveryTestDB(t)
libID := seedDiscoveryLibrary(t, repos, "movie")
seedDiscoveryMedia(t, repos, &model.Media{
LibraryID: libID, Title: "A", Path: "/media/movie/a.mkv", Genres: "科幻,悬疑",
})
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
genres, err := svc.AggregateGenres(context.Background(), MediaVisibility{IncludeNSFW: true}, "")
if err != nil {
t.Fatal(err)
}
if findGenre(genres, "科幻") != 1 || findGenre(genres, "悬疑") != 1 {
t.Fatalf("genres = %+v, want 科幻:1 and 悬疑:1", genres)
}
}
// libraryID 非空时只统计该库,供媒体库页面的筛选项使用。
func TestAggregateGenresScopedToLibrary(t *testing.T) {
repos := newDiscoveryTestDB(t)
movieLib := seedDiscoveryLibrary(t, repos, "movie")
tvLib := seedDiscoveryLibrary(t, repos, "tv")
seedDiscoveryMedia(t, repos, &model.Media{
LibraryID: movieLib, Title: "A", Path: "/media/movie/a.mkv", Genres: "Action",
})
seedDiscoveryMedia(t, repos, &model.Media{
LibraryID: tvLib, Title: "B", Path: "/media/tv/b.mkv", Genres: "Comedy",
})
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
genres, err := svc.AggregateGenres(context.Background(), MediaVisibility{IncludeNSFW: true}, movieLib)
if err != nil {
t.Fatal(err)
}
if findGenre(genres, "Comedy") != -1 {
t.Fatalf("Comedy must not appear when scoped to the movie library: %+v", genres)
}
if findGenre(genres, "Action") != 1 {
t.Fatalf("Action count = %d, want 1", findGenre(genres, "Action"))
}
}
// 可见性过滤必须生效:不可见的库不应泄漏类型统计。
func TestAggregateGenresRespectsVisibility(t *testing.T) {
repos := newDiscoveryTestDB(t)
allowedLib := seedDiscoveryLibrary(t, repos, "movie")
otherLib := seedDiscoveryLibrary(t, repos, "movie")
seedDiscoveryMedia(t, repos, &model.Media{
LibraryID: allowedLib, Title: "A", Path: "/media/a/a.mkv", Genres: "Action",
})
seedDiscoveryMedia(t, repos, &model.Media{
LibraryID: otherLib, Title: "B", Path: "/media/b/b.mkv", Genres: "Secret",
})
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
genres, err := svc.AggregateGenres(context.Background(),
MediaVisibility{IncludeNSFW: true, AllowedLibraryIDs: []string{allowedLib}}, "")
if err != nil {
t.Fatal(err)
}
if findGenre(genres, "Secret") != -1 {
t.Fatalf("hidden library leaked into genres: %+v", genres)
}
}
+152
View File
@@ -0,0 +1,152 @@
package service
import (
"context"
"math/rand"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// LibraryFacets 是媒体库筛选面板需要的元数据:可选类型与年份区间。
type LibraryFacets struct {
Genres []GenreCount `json:"genres"`
YearMin int `json:"year_min"`
YearMax int `json:"year_max"`
}
// libraryFilterFrom 组装「可见性 + 用户筛选」的最终仓储条件,
// 与列表查询保持完全一致的语义。
func libraryFilterFrom(visibility MediaVisibility, filters MediaListFilters) repository.MediaQueryFilter {
return filters.apply(repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
})
}
// LibraryFacets 返回某个库的筛选项。类型统计复用 MediaDiscoveryService,
// 避免在筛选面板与 Emby /Genres 之间出现两套口径。
//
// 作用范围必须与列表查询一致:列表用的是「合并网盘库」后的库 ID 集合,因此这
// 里也把可见性收窄到同一集合,否则筛选项会漏掉列表里真实存在的条目。
func (s *MediaService) LibraryFacets(
ctx context.Context,
libraryID string,
visibility MediaVisibility,
discovery *MediaDiscoveryService,
) (LibraryFacets, error) {
var facets LibraryFacets
if s == nil || s.repo == nil || s.repo.Media == nil {
return facets, nil
}
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
merged, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID)
if err != nil {
return facets, err
}
scoped, ok := scopeVisibilityToLibraries(visibility, merged)
if !ok {
facets.Genres = []GenreCount{}
return facets, nil
}
yearMin, yearMax, err := s.repo.Media.YearRange(ctx, libraryFilterFrom(scoped, MediaListFilters{}))
if err != nil {
return facets, err
}
facets.YearMin = yearMin
facets.YearMax = yearMax
if discovery != nil {
genres, err := discovery.AggregateGenres(ctx, scoped, "")
if err != nil {
return facets, err
}
facets.Genres = genres
}
if facets.Genres == nil {
facets.Genres = []GenreCount{}
}
return facets, nil
}
// scopeVisibilityToLibraries 把可见性收窄到给定库集合。
//
// 返回 ok=false 表示「这些库与用户的可见性没有交集」——此时必须返回空结果,
// 而不是退化成不过滤,否则受限用户会看到别人的库。
func scopeVisibilityToLibraries(visibility MediaVisibility, libraryIDs []string) (MediaVisibility, bool) {
allowed := make(map[string]struct{}, len(visibility.AllowedLibraryIDs))
for _, id := range visibility.AllowedLibraryIDs {
allowed[id] = struct{}{}
}
out := make([]string, 0, len(libraryIDs))
for _, id := range libraryIDs {
if len(allowed) > 0 {
if _, ok := allowed[id]; !ok {
continue
}
}
out = append(out, id)
}
if len(out) == 0 {
return visibility, false
}
visibility.AllowedLibraryIDs = out
return visibility, true
}
// RandomMedia 在「可见性 + 筛选」的结果集里随机取一条。
//
// 实现是「先 COUNT 再随机 offset」的两段查询:SQLite 没有 TABLESAMPLE,
// ORDER BY RANDOM() 又会对整库排序(大库上会拖垮磁盘),因此用等价的
// 偏移量取法,且两种数据库方言完全一致。
//
// 返回 (nil, nil) 表示结果集为空,由调用方决定响应码。
func (s *MediaService) RandomMedia(
ctx context.Context,
libraryID string,
visibility MediaVisibility,
filters MediaListFilters,
) (*model.Media, error) {
if s == nil || s.repo == nil || s.repo.Media == nil {
return nil, nil
}
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID)
if err != nil {
return nil, err
}
filter := libraryFilterFrom(visibility, filters)
_, total, err := s.repo.Media.ListByLibrariesFiltered(ctx, libraryIDs, 0, 1, filter)
if err != nil {
return nil, err
}
if total <= 0 {
return nil, nil
}
// 超大结果集直接对全部记录随机 OFFSET 会让数据库扫描海量行(SQLite 逐行计数)。
// 当总量超过阈值时,收窄到按更新时间最新的前 N 条(列表排序首列是
// release_date / updated_at,前 randomPoolCap 行即为"最新"子集),
// 偏移量取其中随机位置,兼顾性能与覆盖面。
const randomPoolCap = 5000
effectiveTotal := total
if effectiveTotal > randomPoolCap {
effectiveTotal = randomPoolCap
}
offset := 0
if effectiveTotal > 1 {
offset = rand.Intn(int(effectiveTotal))
}
items, err := s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, offset, 1, filter)
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, nil
}
s.attachLibraryMetadata(ctx, items)
return &items[0], nil
}
+88
View File
@@ -0,0 +1,88 @@
package service
import (
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// 系列路径的筛选在内存里复现 SQL 语义,必须与仓储侧一致,否则会出现
// 「电影库能筛、剧集库筛不动」的行为差异。
func TestMediaRowMatchesFilters(t *testing.T) {
row := &model.Media{
Base: model.Base{ID: "m1"},
Title: "片",
Genres: "Action,Drama",
Year: 2015,
Rating: 7.5,
}
cases := []struct {
name string
filters MediaListFilters
completed map[string]bool
want bool
}{
{name: "no filters", filters: MediaListFilters{}, want: true},
{name: "genre hit", filters: MediaListFilters{Genres: []string{"Action"}}, want: true},
{name: "genre miss", filters: MediaListFilters{Genres: []string{"Comedy"}}, want: false},
{name: "year in range", filters: MediaListFilters{YearMin: 2010, YearMax: 2020}, want: true},
{name: "year below min", filters: MediaListFilters{YearMin: 2016}, want: false},
{name: "year above max", filters: MediaListFilters{YearMax: 2014}, want: false},
{name: "rating ok", filters: MediaListFilters{RatingMin: 7}, want: true},
{name: "rating too high", filters: MediaListFilters{RatingMin: 8}, want: false},
{name: "unwatched passes", filters: MediaListFilters{Unwatched: true}, want: true},
{
name: "unwatched excludes completed",
filters: MediaListFilters{Unwatched: true},
completed: map[string]bool{"m1": true},
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := mediaRowMatchesFilters(row, tc.filters, tc.completed); got != tc.want {
t.Fatalf("matched = %t, want %t", got, tc.want)
}
})
}
}
// 多词类型(如 "Science Fiction")在内存筛选中必须整词命中。
func TestMediaRowMatchesFiltersMultiWordGenre(t *testing.T) {
row := &model.Media{
Base: model.Base{ID: "m2"},
Title: "科幻片",
Genres: "Science Fiction,Drama",
}
cases := []struct {
name string
filters MediaListFilters
want bool
}{
{name: "multi-word hit", filters: MediaListFilters{Genres: []string{"Science Fiction"}}, want: true},
{name: "partial word miss", filters: MediaListFilters{Genres: []string{"Science"}}, want: false},
{name: "case insensitive hit", filters: MediaListFilters{Genres: []string{"science fiction"}}, want: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := mediaRowMatchesFilters(row, tc.filters, nil); got != tc.want {
t.Fatalf("matched = %t, want %t", got, tc.want)
}
})
}
}
// 无筛选时 empty() 为真,调用方据此走带缓存的原路径。
func TestMediaListFiltersEmpty(t *testing.T) {
if !(MediaListFilters{}).empty() {
t.Fatal("zero filters must be reported as empty")
}
if (MediaListFilters{Genres: []string{"Action"}}).empty() {
t.Fatal("genre filter must not be reported as empty")
}
if (MediaListFilters{Unwatched: true}).empty() {
t.Fatal("unwatched filter must not be reported as empty")
}
}
+74 -11
View File
@@ -12,11 +12,63 @@ import (
)
// ListMedia paginates media items inside a library.
// MediaListFilters 是列表接口的可选筛选条件(来自查询串或库内筛选面板)。
//
// 与 MediaVisibility 分开:可见性是权限约束(服务端强制),这些是用户主动
// 选择的浏览条件,两者在 SQL 层是「与」关系。
type MediaListFilters struct {
Genres []string
YearMin int
YearMax int
RatingMin float64
Unwatched bool
// UserID 是「未观看」判定所需的账号;为空时 Unwatched 被忽略。
UserID string
}
// empty 报告是否没有任何筛选条件。调用方据此走缓存友好的默认路径。
func (f MediaListFilters) empty() bool {
return len(f.Genres) == 0 && f.YearMin <= 0 && f.YearMax <= 0 &&
f.RatingMin <= 0 && !f.Unwatched
}
// apply 把筛选条件叠加到仓储过滤条件上。
func (f MediaListFilters) apply(filter repository.MediaQueryFilter) repository.MediaQueryFilter {
if len(f.Genres) > 0 {
filter.Genres = f.Genres
}
if f.YearMin > 0 {
filter.YearMin = f.YearMin
}
if f.YearMax > 0 {
filter.YearMax = f.YearMax
}
if f.RatingMin > 0 {
filter.RatingMin = f.RatingMin
}
if f.Unwatched && strings.TrimSpace(f.UserID) != "" {
filter.UnwatchedOnly = true
filter.UnwatchedUserID = f.UserID
}
return filter
}
func (s *MediaService) ListMedia(ctx context.Context, libraryID string, page, pageSize int) ([]model.Media, int64, error) {
return s.ListMediaVisible(ctx, libraryID, page, pageSize, MediaVisibility{IncludeNSFW: true})
}
func (s *MediaService) ListMediaVisible(ctx context.Context, libraryID string, page, pageSize int, visibility MediaVisibility) ([]model.Media, int64, error) {
return s.ListMediaVisibleFiltered(ctx, libraryID, page, pageSize, visibility, MediaListFilters{})
}
// ListMediaVisibleFiltered 在可见性之上叠加用户筛选条件。
func (s *MediaService) ListMediaVisibleFiltered(
ctx context.Context,
libraryID string,
page, pageSize int,
visibility MediaVisibility,
filters MediaListFilters,
) ([]model.Media, int64, error) {
if pageSize <= 0 {
pageSize = 50
}
@@ -31,11 +83,11 @@ func (s *MediaService) ListMediaVisible(ctx context.Context, libraryID string, p
if err != nil {
return nil, 0, err
}
filter := repository.MediaQueryFilter{
filter := filters.apply(repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
}
})
cacheKey := s.mediaListCacheKey(libraryID, libraryIDs, page, pageSize, filter)
var cached mediaListCacheValue
if s.cache != nil && s.cache.GetJSON(ctx, cacheKey, &cached) {
@@ -54,7 +106,6 @@ func (s *MediaService) ListMediaVisible(ctx context.Context, libraryID string, p
}
func (s *MediaService) ListMediaVisibleGrouped(ctx context.Context, libraryID string, page, pageSize int, visibility MediaVisibility) ([]MediaItem, int64, error) {
page, pageSize = normalizeGroupedMediaPage(page, pageSize)
grouped, err := s.GroupedMediaVisible(ctx, libraryID, visibility)
if err != nil {
return nil, 0, err
@@ -65,16 +116,29 @@ func (s *MediaService) ListMediaVisibleGrouped(ctx context.Context, libraryID st
// GroupedMediaVisible returns the complete version-grouped media list before pagination.
// The result is cached as an immutable slice; sort/pagination callers must copy it before mutating.
func (s *MediaService) GroupedMediaVisible(ctx context.Context, libraryID string, visibility MediaVisibility) ([]MediaItem, error) {
return s.GroupedMediaVisibleFiltered(ctx, libraryID, visibility, MediaListFilters{})
}
// GroupedMediaVisibleFiltered 在可见性之上叠加用户筛选条件。
//
// 版本分组的筛选必须作用在原始行上(先筛后分组):否则「按类型筛选」会把
// 同一部作品的不同版本拆到不同筛选结果里,出现重复卡片。
func (s *MediaService) GroupedMediaVisibleFiltered(
ctx context.Context,
libraryID string,
visibility MediaVisibility,
filters MediaListFilters,
) ([]MediaItem, error) {
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID)
if err != nil {
return nil, err
}
filter := repository.MediaQueryFilter{
filter := filters.apply(repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
}
})
itemsCacheKey := s.groupedItemsCacheKey(libraryID, libraryIDs, filter)
value, err, _ := s.groupedMediaFlight.Do(itemsCacheKey, func() (any, error) {
if s.cache != nil {
@@ -86,7 +150,7 @@ func (s *MediaService) GroupedMediaVisible(ctx context.Context, libraryID string
}
loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
defer cancel()
items, err := s.listMediaVisibleForGrouping(loadCtx, libraryID, visibility)
items, err := s.listMediaVisibleForGrouping(loadCtx, libraryID, visibility, filters)
if err != nil {
return nil, err
}
@@ -105,17 +169,17 @@ func (s *MediaService) GroupedMediaVisible(ctx context.Context, libraryID string
return nil, nil
}
func (s *MediaService) listMediaVisibleForGrouping(ctx context.Context, libraryID string, visibility MediaVisibility) ([]model.Media, error) {
func (s *MediaService) listMediaVisibleForGrouping(ctx context.Context, libraryID string, visibility MediaVisibility, filters MediaListFilters) ([]model.Media, error) {
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID)
if err != nil {
return nil, err
}
filter := repository.MediaQueryFilter{
filter := filters.apply(repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
}
})
// 版本分组的 URL 分页发生在 Go 进程内,响应里的 total 是分组后的数量,
// 不需要数据库再为原始行做一次 COUNT(*)。全量 COUNT 在超大媒体库上
// 会重复扫描整个 library_id 范围,而这里只关心是否存在截断风险。
@@ -133,8 +197,7 @@ func (s *MediaService) listMediaVisibleForGrouping(ctx context.Context, libraryI
}
// GetMedia returns a single media row.
func (s *MediaService) GetMedia(ctx context.Context, id string) (*model.Media, error) {
media, err := s.repo.Media.FindByID(ctx, id)
func (s *MediaService) GetMedia(ctx context.Context, id string) (*model.Media, error) { media, err := s.repo.Media.FindByID(ctx, id)
if err != nil || media == nil {
return media, err
}
+92
View File
@@ -147,6 +147,98 @@ func (s *MediaService) ListLibrarySeriesCards(ctx context.Context, libraryID str
return cards, total, nil
}
// ListLibrarySeriesCardsFiltered 在系列卡片上应用筛选。
//
// 语义:先对原始剧集行做筛选,再分组 —— 于是「剧里任意一集命中条件」即可保留
// 该剧。这比只筛代表行更符合直觉(用户勾选「动作」是想要动作剧,而不是
// 「第一集恰好是动作的剧」)。
//
// 无筛选时直接走带缓存的原路径,避免平白多一次全库分组。
func (s *MediaService) ListLibrarySeriesCardsFiltered(
ctx context.Context,
libraryID string,
visibility MediaVisibility,
filters MediaListFilters,
) ([]SeriesCard, int64, error) {
if filters.empty() {
return s.ListLibrarySeriesCards(ctx, libraryID, visibility)
}
rows, err := s.libraryRowsWithIndex(ctx, libraryID, visibility)
if err != nil {
return nil, 0, err
}
completed := s.completedMediaIDSet(ctx, filters)
kept := make([]model.Media, 0, len(rows.Rows))
for _, row := range rows.Rows {
if !mediaRowMatchesFilters(&row, filters, completed) {
continue
}
kept = append(kept, row)
}
cards := groupMediaSeriesCards(kept)
if cards == nil {
cards = []SeriesCard{}
}
return cards, int64(len(cards)), nil
}
// mediaRowMatchesFilters 在内存里复现 SQL 层的筛选语义。
//
// 系列路径无法直接复用仓储过滤(它基于整库共享缓存),因此这里必须与
// applyMediaQueryFilter 保持同一套判定,否则会出现「电影库能筛、剧集库不同」
// 的行为差异。
func mediaRowMatchesFilters(row *model.Media, filters MediaListFilters, completed map[string]bool) bool {
if row == nil {
return false
}
if filters.YearMin > 0 && row.Year < filters.YearMin {
return false
}
if filters.YearMax > 0 && row.Year > filters.YearMax {
return false
}
if filters.RatingMin > 0 && float64(row.Rating) < filters.RatingMin {
return false
}
if filters.Unwatched && completed[row.ID] {
return false
}
if len(filters.Genres) > 0 {
rowGenres := genreSet(row.Genres)
matched := false
for _, want := range filters.Genres {
if _, ok := rowGenres[strings.ToLower(strings.TrimSpace(want))]; ok {
matched = true
break
}
}
if !matched {
return false
}
}
return true
}
// completedMediaIDSet 返回该用户已标记看完的媒体 ID 集合(未启用未观看筛选时
// 返回 nil,避免无谓查询)。
func (s *MediaService) completedMediaIDSet(ctx context.Context, filters MediaListFilters) map[string]bool {
userID := strings.TrimSpace(filters.UserID)
if !filters.Unwatched || userID == "" {
return nil
}
var rows []model.PlaybackHistory
if err := s.repo.DB.WithContext(ctx).
Where("user_id = ? AND completed = ?", userID, true).
Find(&rows).Error; err != nil {
return nil
}
out := make(map[string]bool, len(rows))
for _, row := range rows {
out[row.MediaID] = true
}
return out
}
func (s *MediaService) ListRecentSeriesCards(ctx context.Context, limit int, visibility MediaVisibility) ([]SeriesCard, error) {
if limit <= 0 {
limit = 24
+14
View File
@@ -36,6 +36,7 @@ type SchedulerService struct {
organizePipeline *OrganizePipelineService
hub *Hub
tasks *TaskTrackerService
expiryWatcher *TelegramExpiryWatcher
cacheDir string
now func() time.Time
@@ -59,6 +60,11 @@ func (s *SchedulerService) SetOrganizePipeline(pipeline *OrganizePipelineService
s.organizePipeline = pipeline
}
// SetExpiryWatcher 注入账号到期巡检。未注入时(例如测试)该任务不注册。
func (s *SchedulerService) SetExpiryWatcher(watcher *TelegramExpiryWatcher) {
s.expiryWatcher = watcher
}
// ImageCachePolicy 是一次图片缓存清理要用的策略(全部为 0 表示不做任何清理)。
type ImageCachePolicy struct {
TotalBytes int64
@@ -139,6 +145,14 @@ func (s *SchedulerService) Start(ctx context.Context) {
run: s.jobCleanImageCache,
},
}
// 到期提醒只在配置了巡检器时注册,避免测试与未启用通知的部署跑空转任务。
if s.expiryWatcher != nil {
s.jobs = append(s.jobs, &scheduledJob{
name: "telegram_expiry_warning",
interval: 24 * time.Hour,
run: s.jobTelegramExpiryWarning,
})
}
for _, j := range s.jobs {
initialDelay := 15 * time.Second
if j.name == "library_scan" || j.name == "organize_source" {
+8
View File
@@ -169,6 +169,14 @@ func (s *SchedulerService) organizeSourceInterval(ctx context.Context) time.Dura
return time.Duration(seconds) * time.Second
}
// jobTelegramExpiryWarning 每日巡检即将到期的账号并提醒用户。
func (s *SchedulerService) jobTelegramExpiryWarning(ctx context.Context) error {
if s.expiryWatcher == nil {
return nil
}
return s.expiryWatcher.RunOnce(ctx)
}
// jobCleanTranscodeCache deletes HLS artefacts older than 24h.
func (s *SchedulerService) jobCleanTranscodeCache(ctx context.Context) error {
if s.cacheDir == "" {
+8
View File
@@ -59,6 +59,9 @@ type Container struct {
Token *TokenService
ApiConfig *ApiConfigService
Device *DeviceService
Telegram *TelegramService
TelegramExpiry *TelegramExpiryWatcher
Discovery *MediaDiscoveryService
Cache *RuntimeCacheService
Sessions *SessionTrackerService
RecognitionWords *RecognitionWordsService
@@ -103,6 +106,11 @@ func (c *Container) Boot() {
// 启动调度器定时任务
c.Scheduler.Start(c.stopCtx)
// Telegram 通知轮询(未启用或未配置 Token 时直接返回)
if c.Telegram != nil {
c.Telegram.Start(c.stopCtx)
}
// 远程 Emby 挂载兼容迁移:清理已删账号的残留挂载;旧账号无挂载时自动全量挂载
if c.EmbyRemote != nil {
c.EmbyRemote.CleanupOrphanMounts(c.stopCtx)
+24
View File
@@ -42,11 +42,22 @@ func newServiceContainer(cfg *config.Config, log *zap.Logger, repos *repository.
builder.initContentServices()
builder.initAccessAndStorageServices()
builder.initIdentityServices()
// Telegram 在 initIdentityServices 里构建,这里把失败告警接到任务状态机上。
builder.wireTaskNotifications()
builder.initImageProxy()
builder.attachRuntimeContext()
return builder.c
}
// wireTaskNotifications 把任务失败通知接到 Telegram。必须在 Tasks 与 Telegram
// 都已构建之后调用:早于两者其一会静默漏接。
func (b *serviceContainerBuilder) wireTaskNotifications() {
if b.c.Tasks == nil || b.c.Telegram == nil {
return
}
b.c.Tasks.SetFailureNotifier(b.c.Telegram.SendToAdmin)
}
func (b *serviceContainerBuilder) startRealtimeServices() {
b.c.WSHub = NewHub(b.log)
helper.Go(b.log, "ws.hub", b.c.WSHub.Run)
@@ -110,6 +121,9 @@ func (b *serviceContainerBuilder) initContentServices() {
b.c.DLNA = NewDLNAService(b.log)
b.c.Storage = NewStorageService(b.log, b.repos)
b.c.Emby = NewEmbyService(b.cfg, b.log, b.repos).SetTMDbProvider(b.c.TMDb).SetAdultProvider(b.c.Scraper.adult)
// 发现类查询(NextUp / Similar / Genres):Emby 兼容层与媒体库筛选共用。
b.c.Discovery = NewMediaDiscoveryService(b.log, b.repos)
b.c.Emby.SetDiscovery(b.c.Discovery)
b.c.EmbyRemote = NewEmbyRemoteService(b.cfg, b.log, b.repos, b.c.Crypto).SetRuntimeCache(b.c.Cache)
b.c.Emby.SetEmbyRemote(b.c.EmbyRemote)
b.c.Backup = NewBackupService(b.cfg, b.log, b.repos.DB)
@@ -161,6 +175,7 @@ func (b *serviceContainerBuilder) initAccessAndStorageServices() {
b.c.Database = NewDatabaseAdminService(b.cfg, b.log, b.repos, b.repos.DB)
b.c.Emby.SetRuntimeCache(b.c.Cache)
b.c.Emby.SetSubtitleService(b.c.Subtitle)
b.c.Emby.SetDiscovery(b.c.Discovery)
b.c.Scheduler = NewSchedulerService(
b.log, b.repos, b.c.Scan, b.c.Transcoder,
b.c.Organizer, b.c.WSHub, b.cfg.Cache.CacheDir,
@@ -190,6 +205,15 @@ func (b *serviceContainerBuilder) initIdentityServices() {
b.c.Sessions = NewSessionTrackerService(b.log)
b.c.Device = NewDeviceService(b.log, b.repos)
b.c.Device.SetSessionTracker(b.c.Sessions)
// Telegram 通知:未启用时 Start 不会占用 goroutine,SendToUser 静默跳过。
b.c.Telegram = NewTelegramService(b.log, b.repos)
b.c.Device.SetNotifier(b.c.Telegram.SendToUser)
b.c.Device.SetAdminNotifier(b.c.Telegram.SendToAdmin)
// 账号到期巡检:只负责发现与去重,发送复用同一个 Telegram 通道。
b.c.TelegramExpiry = NewTelegramExpiryWatcher(b.log, b.repos)
b.c.TelegramExpiry.SetUserNotifier(b.c.Telegram.SendToUser)
// SetExpiryWatcher must run AFTER TelegramExpiry is constructed.
b.c.Scheduler.SetExpiryWatcher(b.c.TelegramExpiry)
b.c.ApiConfig = NewApiConfigService(b.cfg, b.log, b.repos, b.c.Crypto)
}
+46
View File
@@ -1,6 +1,9 @@
package service
import (
"context"
"html"
"strings"
"sync"
"time"
@@ -59,6 +62,9 @@ type TaskTrackerService struct {
log *zap.Logger
hub *Hub
// failureNotifier 在任务以失败收尾时通知管理员(Telegram)。nil 时静默。
failureNotifier func(ctx context.Context, text string)
mu sync.Mutex
active map[string]*BackgroundTask
recent []BackgroundTask
@@ -179,8 +185,48 @@ func (t *TaskTrackerService) finish(id string, err error, update TaskUpdate) {
if len(t.recent) > t.maxRecent {
t.recent = t.recent[:t.maxRecent]
}
notifier := t.failureNotifier
t.mu.Unlock()
t.publish(snapshot)
// 失败通知放在锁外发送:网络请求绝不能阻塞任务状态机。
if err != nil && notifier != nil {
notifier(context.Background(), formatTaskFailureAlert(snapshot))
}
}
// SetFailureNotifier 注入任务失败时的管理员通知回调。
func (t *TaskTrackerService) SetFailureNotifier(fn func(ctx context.Context, text string)) {
if t == nil {
return
}
t.mu.Lock()
t.failureNotifier = fn
t.mu.Unlock()
}
// formatTaskFailureAlert 生成面向管理员的失败摘要。错误信息可能很长
// (ffmpeg 输出等),这里截断,避免超出 Telegram 单条消息长度。
func formatTaskFailureAlert(task BackgroundTask) string {
const maxErrRunes = 500
text := strings.TrimSpace(task.Error)
runes := []rune(text)
if len(runes) > maxErrRunes {
text = string(runes[:maxErrRunes]) + "…"
}
var b strings.Builder
b.WriteString("⚠️ 任务失败:<b>")
b.WriteString(html.EscapeString(task.Name))
b.WriteString("</b>")
if task.SourcePath != "" {
b.WriteString("\n来源:")
b.WriteString(html.EscapeString(task.SourcePath))
}
if text != "" {
b.WriteString("\n错误:")
b.WriteString(html.EscapeString(text))
}
return b.String()
}
func (t *TaskTrackerService) currentTime() time.Time {
+513
View File
@@ -0,0 +1,513 @@
package service
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/repository"
)
// Telegram 通知相关的设置键。全部存在 settings 表,由管理员在「设备与通知」
// 设置分组里维护;带安全默认值:未启用时所有发送静默跳过。
const (
SettingTelegramEnabled = "telegram.enabled" // 总开关(默认关)
SettingTelegramBotToken = "telegram.bot_token" // Bot API Token
SettingTelegramAdminChatID = "telegram.admin_chat_id" // 管理员接收运维通知的会话 ID
)
// telegramBindAlphabet 去掉容易看错的 0/O/1/I,降低手抄绑定码时的出错率。
const telegramBindAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
const (
// telegramBindTTL 是绑定码的有效期。到期后必须重新生成。
telegramBindTTL = 5 * time.Minute
// telegramAPITimeout 覆盖单次 sendMessage 请求。
telegramAPITimeout = 10 * time.Second
// telegramPollClientTimeout 是长轮询专用 HTTP 客户端的超时,必须 > telegramPollTimeout。
telegramPollClientTimeout = 35 * time.Second
// telegramPollTimeout 是 getUpdates 的长轮询等待秒数。
telegramPollTimeout = 30
// telegramPollBackoff 是长轮询失败后的重试间隔,避免打爆 API。
telegramPollBackoff = 5 * time.Second
// telegramDisabledCheckInterval 是禁用状态下检查配置变化的间隔。
telegramDisabledCheckInterval = 10 * time.Second
)
var (
// ErrTelegramBindInvalid 表示绑定码不存在(不存在、已被使用,或已被新码取代)。
ErrTelegramBindInvalid = errors.New("telegram bind code not found")
// ErrTelegramBindExpired 表示绑定码已过期。
ErrTelegramBindExpired = errors.New("telegram bind code expired")
)
// telegramBindPending 记录一个待消费的绑定码。
type telegramBindPending struct {
UserID string
ExpiresAt time.Time
}
// TelegramService 负责把通知发到 Telegram,并提供账号绑定。
//
// 范围被刻意收窄:Bot 只处理 /bind 与 /start 两条命令,不做开注、签到、
// 兑换码等命令体系。绑定走「网页生成一次性码 → Bot 端 /bind <code>」,
// 这样服务端不需要用户手工填写 chat id。
type TelegramService struct {
log *zap.Logger
repo *repository.Container
mu sync.Mutex
pending map[string]telegramBindPending // code -> pending
byUser map[string]string // userID -> code(同一用户只保留最新码)
// apiBase 允许测试指向本地假服务;生产固定为 Telegram 官方地址。
apiBase string
// lastUpdateID 是 getUpdates 的增量水位,避免重复处理同一条命令。
lastUpdateID int64
// client 可注入,便于测试替换传输层(sendMessage 等短请求)。
client *http.Client
// pollClient 专用于 getUpdates 长轮询,超时须 > telegramPollTimeout。
pollClient *http.Client
}
// NewTelegramService 构建 TelegramService。
func NewTelegramService(log *zap.Logger, repo *repository.Container) *TelegramService {
return &TelegramService{
log: log,
repo: repo,
pending: make(map[string]telegramBindPending),
byUser: make(map[string]string),
apiBase: "https://api.telegram.org",
client: &http.Client{Timeout: telegramAPITimeout},
pollClient: &http.Client{Timeout: telegramPollClientTimeout},
}
}
// Start 在后台运行 Bot 命令长轮询。goroutine 始终启动;
// 未启用或未配置 Token 时 pollLoop 内部休眠等待配置就绪,ctx 结束时停止。
func (s *TelegramService) Start(ctx context.Context) {
if s == nil || s.repo == nil {
return
}
helper.Go(s.log, "service.telegramBotPoll", func() { s.pollLoop(ctx) })
if s.log != nil {
s.log.Info("telegram poller started")
}
}
type telegramConfig struct {
Enabled bool
BotToken string
AdminID string
}
func (s *TelegramService) config(ctx context.Context) telegramConfig {
return telegramConfig{
Enabled: s.enabled(ctx),
BotToken: s.setting(ctx, SettingTelegramBotToken),
AdminID: s.setting(ctx, SettingTelegramAdminChatID),
}
}
func (s *TelegramService) setting(ctx context.Context, key string) string {
if s == nil || s.repo == nil || s.repo.Setting == nil {
return ""
}
v, err := s.repo.Setting.Get(ctx, key)
if err != nil {
return ""
}
return strings.TrimSpace(v)
}
func (s *TelegramService) enabled(ctx context.Context) bool {
return parseBoolSetting(s.setting(ctx, SettingTelegramEnabled), false)
}
// SendToUser 把消息发给用户绑定的 Telegram 会话。未绑定、未启用或发送失败
// 都只记日志并返回:通知永远不能影响触发它的业务流程。
func (s *TelegramService) SendToUser(ctx context.Context, userID, htmlText string) {
if s == nil || s.repo == nil || userID == "" || strings.TrimSpace(htmlText) == "" {
return
}
u, err := s.repo.User.FindByID(ctx, userID)
if err != nil || u == nil {
return
}
chatID := strings.TrimSpace(u.TelegramChatID)
if chatID == "" {
return
}
if err := s.sendMessage(ctx, chatID, htmlText); err != nil {
s.warn("telegram send to user failed", userID, err)
}
}
// SendToAdmin 把消息发给管理员会话,用于运维类事件(任务失败等)。
func (s *TelegramService) SendToAdmin(ctx context.Context, htmlText string) {
if s == nil || s.repo == nil || strings.TrimSpace(htmlText) == "" {
return
}
chatID := s.setting(ctx, SettingTelegramAdminChatID)
if chatID == "" {
return
}
if err := s.sendMessage(ctx, chatID, htmlText); err != nil {
s.warn("telegram send to admin failed", chatID, err)
}
}
func (s *TelegramService) warn(msg, target string, err error) {
if s.log == nil {
return
}
s.log.Warn(msg, zap.String("target", target), zap.Error(err))
}
// Configured 报告通知是否已具备发送条件:启用 + Token + 管理员会话都齐了。
func (s *TelegramService) Configured(ctx context.Context) bool {
if s == nil {
return false
}
cfg := s.config(ctx)
return cfg.Enabled && cfg.BotToken != "" && cfg.AdminID != ""
}
// SendToAdminChecked 是需要把失败反馈给管理员的场景(例如设置页的测试按钮)。
// 它配置未就绪时返回错误;普通事件通知仍应使用静默的 SendToAdmin。
func (s *TelegramService) SendToAdminChecked(ctx context.Context, htmlText string) error {
if s == nil || s.repo == nil {
return errors.New("telegram service unavailable")
}
chatID := s.setting(ctx, SettingTelegramAdminChatID)
if chatID == "" {
return errors.New("未配置管理员 Chat ID")
}
if !s.enabled(ctx) {
return errors.New("Telegram 通知未启用")
}
if s.setting(ctx, SettingTelegramBotToken) == "" {
return errors.New("未配置 Bot Token")
}
return s.sendMessage(ctx, chatID, htmlText)
}
// sendMessage 是唯一的出网点。所有前置条件在这里统一校验。
func (s *TelegramService) sendMessage(ctx context.Context, chatID, htmlText string) error {
cfg := s.config(ctx)
if !cfg.Enabled || cfg.BotToken == "" {
return nil
}
payload := map[string]any{
"chat_id": chatID,
"text": htmlText,
"parse_mode": "HTML",
}
return s.call(ctx, cfg.BotToken, "sendMessage", payload, nil)
}
// call 发起一次 Bot API 调用(使用短请求客户端)。
func (s *TelegramService) call(ctx context.Context, token, method string, payload map[string]any, out any) error {
return s.callWithClient(ctx, s.client, token, method, payload, out)
}
// callWithClient 发起一次 Bot API 调用,允许注入自定义 HTTP 客户端(例如长轮询专用)。
// 失败时返回 Telegram 的 description,便于排查是 Token 错误、chat 不存在还是被限流。
func (s *TelegramService) callWithClient(ctx context.Context, client *http.Client, token, method string, payload map[string]any, out any) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
url := fmt.Sprintf("%s/bot%s/%s", s.apiBase, token, method)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if client == nil {
client = &http.Client{Timeout: telegramAPITimeout}
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return err
}
var envelope struct {
OK bool `json:"ok"`
Description string `json:"description"`
Result json.RawMessage `json:"result"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
return fmt.Errorf("telegram %s: decode response: %w", method, err)
}
if !envelope.OK {
if envelope.Description == "" {
envelope.Description = fmt.Sprintf("http %d", resp.StatusCode)
}
return fmt.Errorf("telegram %s: %s", method, envelope.Description)
}
if out != nil && len(envelope.Result) > 0 {
return json.Unmarshal(envelope.Result, out)
}
return nil
}
// StartBind 生成一次性绑定码。同一用户重复调用时旧码立即失效。
func (s *TelegramService) StartBind(ctx context.Context, userID string) (string, error) {
if s == nil || strings.TrimSpace(userID) == "" {
return "", errors.New("telegram bind: empty user id")
}
code, err := randomBindCode()
if err != nil {
return "", err
}
s.mu.Lock()
defer s.mu.Unlock()
if prev, ok := s.byUser[userID]; ok {
delete(s.pending, prev)
}
s.pending[code] = telegramBindPending{UserID: userID, ExpiresAt: time.Now().Add(telegramBindTTL)}
s.byUser[userID] = code
return code, nil
}
// CompleteBind 消费绑定码并写入用户的 TelegramChatID。
// 只在数据库写入成功后才删除 pending 中的绑定码;失败时码保留以便重试。
func (s *TelegramService) CompleteBind(ctx context.Context, code, chatID string) error {
if s == nil || s.repo == nil {
return ErrTelegramBindInvalid
}
code = strings.ToUpper(strings.TrimSpace(code))
chatID = strings.TrimSpace(chatID)
if code == "" || chatID == "" {
return ErrTelegramBindInvalid
}
// 先读取,不删除——只有在操作成功后才消费绑定码。
s.mu.Lock()
pending, ok := s.pending[code]
s.mu.Unlock()
if !ok {
return ErrTelegramBindInvalid
}
if time.Now().After(pending.ExpiresAt) {
// 过期码:删除并返回,不再保留。
s.mu.Lock()
delete(s.pending, code)
if s.byUser[pending.UserID] == code {
delete(s.byUser, pending.UserID)
}
s.mu.Unlock()
return ErrTelegramBindExpired
}
if err := s.repo.User.UpdateFields(ctx, pending.UserID, map[string]any{"telegram_chat_id": chatID}); err != nil {
// DB 失败时保留 pending,调用方可重试。
return err
}
// 写入成功后再消费绑定码。
s.mu.Lock()
delete(s.pending, code)
if s.byUser[pending.UserID] == code {
delete(s.byUser, pending.UserID)
}
s.mu.Unlock()
return nil
}
// Unbind 清空用户的 Telegram 绑定。
func (s *TelegramService) Unbind(ctx context.Context, userID string) error {
if s == nil || s.repo == nil {
return nil
}
s.mu.Lock()
if code, ok := s.byUser[userID]; ok {
delete(s.pending, code)
delete(s.byUser, userID)
}
s.mu.Unlock()
return s.repo.User.UpdateFields(ctx, userID, map[string]any{"telegram_chat_id": ""})
}
// Status 返回绑定状态与脱敏后的会话 ID,供个人资料页展示。
func (s *TelegramService) Status(ctx context.Context, userID string) (bool, string) {
if s == nil || s.repo == nil || userID == "" {
return false, ""
}
u, err := s.repo.User.FindByID(ctx, userID)
if err != nil || u == nil || strings.TrimSpace(u.TelegramChatID) == "" {
return false, ""
}
return true, MaskSecret(u.TelegramChatID)
}
// MaskSecret 保留首尾各两个字符,中间以 *** 取代。用于 Token 与会话 ID
// 这类「界面需要辨识、但不应完整下发」的凭据。
func MaskSecret(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
r := []rune(value)
if len(r) <= 4 {
return "***"
}
return string(r[:2]) + "***" + string(r[len(r)-2:])
}
func randomBindCode() (string, error) {
buf := make([]byte, 6)
if _, err := rand.Read(buf); err != nil {
return "", err
}
out := make([]byte, 6)
for i, b := range buf {
out[i] = telegramBindAlphabet[int(b)%len(telegramBindAlphabet)]
}
return string(out), nil
}
var telegramBindCommandPattern = regexp.MustCompile(`(?i)^/bind(?:@\w+)?\s+(\S+)\s*$`)
// pollLoop 长轮询 Bot 更新,只处理绑定相关的两条命令。
// 当 Telegram 未启用或 Token 未配置时,休眠后继续循环以便配置变更后自动激活。
func (s *TelegramService) pollLoop(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
}
cfg := s.config(ctx)
if !cfg.Enabled || cfg.BotToken == "" {
if !sleepCtx(ctx, telegramDisabledCheckInterval) {
return
}
continue
}
updates, err := s.fetchUpdates(ctx, cfg.BotToken)
if err != nil {
if s.log != nil && ctx.Err() == nil {
s.log.Warn("telegram getUpdates failed", zap.Error(err))
}
if !sleepCtx(ctx, telegramPollBackoff) {
return
}
continue
}
for _, u := range updates {
if u.UpdateID >= s.lastUpdateID {
s.lastUpdateID = u.UpdateID + 1
}
s.handleUpdate(ctx, cfg.BotToken, u)
}
}
}
type telegramUpdate struct {
UpdateID int64 `json:"update_id"`
Message *struct {
Text string `json:"text"`
Chat struct {
ID int64 `json:"id"`
} `json:"chat"`
} `json:"message"`
}
func (s *TelegramService) fetchUpdates(ctx context.Context, token string) ([]telegramUpdate, error) {
s.mu.Lock()
offset := s.lastUpdateID
s.mu.Unlock()
payload := map[string]any{
"timeout": telegramPollTimeout,
// 只取消息更新,避免把频道/回调查询也塞进来。
"allowed_updates": []string{"message"},
}
if offset > 0 {
payload["offset"] = offset
}
var updates []telegramUpdate
if err := s.callWithClient(ctx, s.pollClient, token, "getUpdates", payload, &updates); err != nil {
return nil, err
}
return updates, nil
}
func (s *TelegramService) handleUpdate(ctx context.Context, token string, u telegramUpdate) {
if u.Message == nil {
return
}
text := strings.TrimSpace(u.Message.Text)
chatID := fmt.Sprintf("%d", u.Message.Chat.ID)
if chatID == "0" || text == "" {
return
}
if match := telegramBindCommandPattern.FindStringSubmatch(text); match != nil {
s.replyBind(ctx, token, chatID, match[1])
return
}
if strings.HasPrefix(strings.ToLower(text), "/start") {
if err := s.call(ctx, token, "sendMessage", map[string]any{
"chat_id": chatID,
"text": "MeBox 通知绑定:在网页「个人资料 → Telegram 通知」生成 6 位绑定码,然后发送 <code>/bind 绑定码</code>。",
"parse_mode": "HTML",
}, nil); err != nil {
s.warn("telegram /start reply failed", chatID, err)
}
}
}
func (s *TelegramService) replyBind(ctx context.Context, token, chatID, code string) {
message := ""
switch err := s.CompleteBind(ctx, code, chatID); {
case err == nil:
message = "✅ 绑定成功,之后 MeBox 的账号与设备通知会发到这里。"
case errors.Is(err, ErrTelegramBindExpired):
message = "⌛️ 绑定码已过期,请在网页重新生成。"
case errors.Is(err, ErrTelegramBindInvalid):
message = "❌ 绑定码无效,请在网页重新生成后再试。"
default:
message = "⚠️ 绑定失败,请稍后重试或查看服务日志。"
}
if err := s.call(ctx, token, "sendMessage", map[string]any{
"chat_id": chatID,
"text": message,
}, nil); err != nil {
s.warn("telegram bind reply failed", chatID, err)
}
}
// sleepCtx 等待指定时长,ctx 结束则提前返回 false。
func sleepCtx(ctx context.Context, d time.Duration) bool {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}
+211
View File
@@ -0,0 +1,211 @@
package service
import (
"context"
"errors"
"strings"
"testing"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// 任务失败必须通知管理员:否则只会停留在任务队列里等人自己发现。
func TestTaskFailureNotifiesAdmin(t *testing.T) {
repos := repository.New(newServiceTestDB(t))
tracker := NewTaskTrackerService(zap.NewNop(), nil)
type call struct{ text string }
var adminCalls []call
tracker.SetFailureNotifier(func(_ context.Context, text string) {
adminCalls = append(adminCalls, call{text})
})
h := tracker.Start(TaskKindOrganize, "自动整理", TaskUpdate{})
h.Finish(errors.New("disk full"), TaskUpdate{})
if len(adminCalls) != 1 {
t.Fatalf("admin notifications = %d, want 1", len(adminCalls))
}
if !strings.Contains(adminCalls[0].text, "自动整理") {
t.Fatalf("notification = %q, want it to name the task", adminCalls[0].text)
}
if !strings.Contains(adminCalls[0].text, "disk full") {
t.Fatalf("notification = %q, want it to carry the error", adminCalls[0].text)
}
_ = repos
}
// 动态字段须 HTML 转义,避免路径/错误里的 <>& 破坏 parse_mode。
func TestTaskFailureAlertEscapesHTML(t *testing.T) {
got := formatTaskFailureAlert(BackgroundTask{
Name: "整理 <script>",
SourcePath: "C:\\a&b>c",
Error: "fail <b>now</b>",
})
for _, bad := range []string{"<script>", "a&b>c", "<b>now</b>"} {
if strings.Contains(got, bad) {
t.Fatalf("alert still contains raw %q: %s", bad, got)
}
}
for _, want := range []string{"整理 &lt;script&gt;", "a&amp;b&gt;c", "fail &lt;b&gt;now&lt;/b&gt;"} {
if !strings.Contains(got, want) {
t.Fatalf("alert missing escaped %q: %s", want, got)
}
}
}
// 成功结束的任务不应触发失败通知。
func TestTaskSuccessDoesNotNotifyAdmin(t *testing.T) {
tracker := NewTaskTrackerService(zap.NewNop(), nil)
var count int
tracker.SetFailureNotifier(func(context.Context, string) { count++ })
h := tracker.Start(TaskKindOrganize, "自动整理", TaskUpdate{})
h.Finish(nil, TaskUpdate{})
if count != 0 {
t.Fatalf("admin notifications = %d, want 0", count)
}
}
// 未接线通知时,任务路径必须照常完成。
func TestTaskTrackerWorksWithoutNotifier(t *testing.T) {
tracker := NewTaskTrackerService(zap.NewNop(), nil)
h := tracker.Start(TaskKindScan, "扫描", TaskUpdate{})
h.Finish(errors.New("boom"), TaskUpdate{})
}
func TestExpiryWarningsTargetDueUsersOnly(t *testing.T) {
repos := repository.New(newServiceTestDB(t))
now := time.Date(2026, 9, 22, 12, 0, 0, 0, time.Local)
ctx := context.Background()
// 48h 内到期(落在 "3d" 桶:1-3 天)→ 应该提醒
soon := now.Add(48 * time.Hour)
// 三十天后到期 → 不应提醒
far := now.Add(30 * 24 * time.Hour)
for _, u := range []*model.User{
{Base: model.Base{ID: "u-soon"}, Username: "soon", PasswordHash: "x", Role: "user", IsActive: true, ExpiredAt: &soon, TelegramChatID: "111"},
{Base: model.Base{ID: "u-far"}, Username: "far", PasswordHash: "x", Role: "user", IsActive: true, ExpiredAt: &far, TelegramChatID: "222"},
{Base: model.Base{ID: "u-never"}, Username: "never", PasswordHash: "x", Role: "user", IsActive: true, TelegramChatID: "333"},
} {
if err := repos.User.Create(ctx, u); err != nil {
t.Fatal(err)
}
}
svc := NewTelegramExpiryWatcher(zap.NewNop(), repos)
svc.now = func() time.Time { return now }
var notified []string
svc.SetUserNotifier(func(_ context.Context, userID, _ string) { notified = append(notified, userID) })
if err := svc.RunOnce(ctx); err != nil {
t.Fatal(err)
}
if len(notified) != 1 || notified[0] != "u-soon" {
t.Fatalf("notified = %v, want [u-soon]", notified)
}
// 同一桶重复运行不得重复打扰。
notified = nil
if err := svc.RunOnce(ctx); err != nil {
t.Fatal(err)
}
if len(notified) != 0 {
t.Fatalf("second run notified %v, want none", notified)
}
}
// 未绑定 Telegram 的到期用户不应被通知,且不应写入标记键。
func TestExpirySkipsUnboundUsers(t *testing.T) {
repos := repository.New(newServiceTestDB(t))
now := time.Date(2026, 9, 22, 12, 0, 0, 0, time.Local)
ctx := context.Background()
soon := now.Add(48 * time.Hour)
// 未绑定(TelegramChatID 为空)
unbound := &model.User{Base: model.Base{ID: "u-unbound"}, Username: "unbound", PasswordHash: "x", Role: "user", IsActive: true, ExpiredAt: &soon}
// 已绑定
bound := &model.User{Base: model.Base{ID: "u-bound"}, Username: "bound", PasswordHash: "x", Role: "user", IsActive: true, ExpiredAt: &soon, TelegramChatID: "999"}
for _, u := range []*model.User{unbound, bound} {
if err := repos.User.Create(ctx, u); err != nil {
t.Fatal(err)
}
}
svc := NewTelegramExpiryWatcher(zap.NewNop(), repos)
svc.now = func() time.Time { return now }
var notified []string
svc.SetUserNotifier(func(_ context.Context, userID, _ string) { notified = append(notified, userID) })
if err := svc.RunOnce(ctx); err != nil {
t.Fatal(err)
}
for _, id := range notified {
if id == "u-unbound" {
t.Fatal("unbound user must not be notified")
}
}
found := false
for _, id := range notified {
if id == "u-bound" {
found = true
}
}
if !found {
t.Fatal("bound user must be notified")
}
// 未绑定用户不应写标记键:再次跑时仍然跳过(不重复通知)。
notified = nil
if err := svc.RunOnce(ctx); err != nil {
t.Fatal(err)
}
for _, id := range notified {
if id == "u-unbound" {
t.Fatal("unbound user notified on second run")
}
}
}
// 两个提醒桶(3d / 1d)各触发一次,互不干扰。
func TestExpiryTwoBuckets(t *testing.T) {
repos := repository.New(newServiceTestDB(t))
ctx := context.Background()
now := time.Date(2026, 9, 22, 12, 0, 0, 0, time.Local)
// 用户的到期时间在 "1d" 桶内
oneDay := now.Add(12 * time.Hour)
u := &model.User{Base: model.Base{ID: "u1"}, Username: "alice", PasswordHash: "x", Role: "user", IsActive: true, ExpiredAt: &oneDay, TelegramChatID: "555"}
if err := repos.User.Create(ctx, u); err != nil {
t.Fatal(err)
}
svc := NewTelegramExpiryWatcher(zap.NewNop(), repos)
svc.now = func() time.Time { return now }
var count int
svc.SetUserNotifier(func(context.Context, string, string) { count++ })
// 第一次运行:1d 桶触发
if err := svc.RunOnce(ctx); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("first run: count = %d, want 1", count)
}
// 第二次运行:1d 桶已标记,不再触发
if err := svc.RunOnce(ctx); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("second run: count = %d, want still 1", count)
}
}
+117
View File
@@ -0,0 +1,117 @@
package service
import (
"context"
"fmt"
"html"
"strings"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// expiryNotifiedKeyPrefix 记录「某个用户在某个提醒桶已经提醒过」,防止重复打扰。
// 键格式:telegram.expiry_notified.{userID}.{bucket},bucket 为 "3d" 或 "1d"。
const expiryNotifiedKeyPrefix = "telegram.expiry_notified."
// expiryWarnWindow 是「即将到期」的判定总窗口(所有提醒桶中最大的上限)。
const expiryWarnWindow = 72 * time.Hour
// expiryBucket 描述一个提醒时间窗口。
type expiryBucket struct {
name string
from time.Duration // 剩余时间下限(不含)
to time.Duration // 剩余时间上限(含)
}
// expiryBuckets 定义两次提醒:约 3 天前 / 约 1 天前,各提醒一次。
var expiryBuckets = []expiryBucket{
{"3d", 24 * time.Hour, 72 * time.Hour}, // 1–3 天
{"1d", 0, 24 * time.Hour}, // 0–1 天
}
// TelegramExpiryWatcher 每日巡检即将到期的账号并提醒用户。
//
// 只负责「发现 + 通知 + 去重」,发送本身交给注入的 notifier,因此没有配置
// Telegram 时整条链路静默跳过。
type TelegramExpiryWatcher struct {
log *zap.Logger
repo *repository.Container
now func() time.Time
userNotifier func(ctx context.Context, userID, text string)
}
// NewTelegramExpiryWatcher 构建巡检器。
func NewTelegramExpiryWatcher(log *zap.Logger, repo *repository.Container) *TelegramExpiryWatcher {
return &TelegramExpiryWatcher{log: log, repo: repo, now: time.Now}
}
// SetUserNotifier 注入用户通知回调(通常是 TelegramService.SendToUser)。
func (w *TelegramExpiryWatcher) SetUserNotifier(fn func(ctx context.Context, userID, text string)) {
if w == nil {
return
}
w.userNotifier = fn
}
// RunOnce 执行一次巡检。每个用户每个提醒桶("3d" / "1d")最多触发一次,
// 未绑定 Telegram 的用户跳过且不写标记键。
func (w *TelegramExpiryWatcher) RunOnce(ctx context.Context) error {
if w == nil || w.repo == nil || w.repo.User == nil || w.userNotifier == nil {
return nil
}
now := w.now()
for _, bucket := range expiryBuckets {
from := now.Add(bucket.from) // expired_at > from
to := now.Add(bucket.to) // expired_at <= to
users, err := w.dueUsers(ctx, from, to)
if err != nil {
return err
}
for _, u := range users {
if u.ExpiredAt == nil {
continue
}
// 跳过未绑定 Telegram 的用户,且不消耗标记键。
if strings.TrimSpace(u.TelegramChatID) == "" {
continue
}
markKey := expiryNotifiedKeyPrefix + u.ID + "." + bucket.name
if seen, err := w.repo.Setting.Get(ctx, markKey); err == nil && strings.TrimSpace(seen) != "" {
continue
}
w.userNotifier(ctx, u.ID, fmt.Sprintf(
"⏳ 账号 <b>%s</b> 将于 %s 到期,请及时续期,避免到期后无法登录。",
html.EscapeString(u.Username), u.ExpiredAt.In(time.Local).Format("2006-01-02 15:04"),
))
// 仅在发送(尝试)后才写标记键。
if err := w.repo.Setting.Set(ctx, markKey, "1"); err != nil && w.log != nil {
w.log.Warn("telegram expiry mark failed", zap.String("user_id", u.ID), zap.Error(err))
}
}
}
return nil
}
// dueUsers 返回 (now, deadline] 内到期且仍处于启用状态的账号。
func (w *TelegramExpiryWatcher) dueUsers(ctx context.Context, now, deadline time.Time) ([]*model.User, error) {
var users []model.User
err := w.repo.DB.WithContext(ctx).
Where("expired_at IS NOT NULL AND expired_at > ? AND expired_at <= ?", now, deadline).
Where("is_active = ?", true).
Find(&users).Error
if err != nil {
return nil, err
}
out := make([]*model.User, 0, len(users))
for i := range users {
out = append(out, &users[i])
}
return out, nil
}
+210
View File
@@ -0,0 +1,210 @@
package service
import (
"context"
"strings"
"testing"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
func newTelegramTestService(t *testing.T) (*TelegramService, *repository.Container) {
t.Helper()
repos := repository.New(newServiceTestDB(t))
return NewTelegramService(zap.NewNop(), repos), repos
}
func seedTelegramTestUser(t *testing.T, repos *repository.Container, id, chatID string) {
t.Helper()
if err := repos.User.Create(context.Background(), &model.User{
Base: model.Base{ID: id},
Username: "tg-" + id,
PasswordHash: "x",
Role: "user",
IsActive: true,
TelegramChatID: chatID,
}); err != nil {
t.Fatal(err)
}
}
func TestStartBindReturnsSixCharCode(t *testing.T) {
s, _ := newTelegramTestService(t)
code, err := s.StartBind(context.Background(), "u1")
if err != nil {
t.Fatal(err)
}
if len(code) != 6 {
t.Fatalf("code = %q, want 6 chars", code)
}
for _, r := range code {
if !strings.ContainsRune(telegramBindAlphabet, r) {
t.Fatalf("code %q contains unexpected rune %q", code, r)
}
}
}
// 同一用户重复申请绑定码时,旧码必须立即失效,避免多个有效码并存。
func TestStartBindReplacesPreviousCode(t *testing.T) {
s, _ := newTelegramTestService(t)
first, err := s.StartBind(context.Background(), "u1")
if err != nil {
t.Fatal(err)
}
second, err := s.StartBind(context.Background(), "u1")
if err != nil {
t.Fatal(err)
}
if first == second {
t.Skip("random collision, rerun")
}
if err := s.CompleteBind(context.Background(), first, "111"); err == nil {
t.Fatal("expected the superseded code to be rejected")
}
}
func TestCompleteBindRejectsExpiredCode(t *testing.T) {
s, _ := newTelegramTestService(t)
code, err := s.StartBind(context.Background(), "u1")
if err != nil {
t.Fatal(err)
}
s.mu.Lock()
s.pending[code] = telegramBindPending{UserID: "u1", ExpiresAt: time.Now().Add(-time.Second)}
s.mu.Unlock()
if err := s.CompleteBind(context.Background(), code, "999"); err == nil {
t.Fatal("expected expired code to be rejected")
}
}
func TestCompleteBindWritesChatID(t *testing.T) {
s, repos := newTelegramTestService(t)
seedTelegramTestUser(t, repos, "u1", "")
code, err := s.StartBind(context.Background(), "u1")
if err != nil {
t.Fatal(err)
}
if err := s.CompleteBind(context.Background(), code, "987654321"); err != nil {
t.Fatal(err)
}
u, err := repos.User.FindByID(context.Background(), "u1")
if err != nil {
t.Fatal(err)
}
if u.TelegramChatID != "987654321" {
t.Fatalf("chat id = %q, want 987654321", u.TelegramChatID)
}
// 绑定成功后码必须被消费,不能重复使用。
if err := s.CompleteBind(context.Background(), code, "987654321"); err == nil {
t.Fatal("expected the consumed code to be rejected")
}
}
func TestStatusMasksChatID(t *testing.T) {
s, repos := newTelegramTestService(t)
seedTelegramTestUser(t, repos, "u1", "1234567890")
bound, masked := s.Status(context.Background(), "u1")
if !bound {
t.Fatal("expected bound user")
}
if strings.Contains(masked, "1234567890") {
t.Fatalf("chat id must not be exposed verbatim, got %q", masked)
}
if !strings.Contains(masked, "***") {
t.Fatalf("masked value should carry a *** marker, got %q", masked)
}
}
func TestUnbindClearsChatID(t *testing.T) {
s, repos := newTelegramTestService(t)
seedTelegramTestUser(t, repos, "u1", "555")
if err := s.Unbind(context.Background(), "u1"); err != nil {
t.Fatal(err)
}
u, err := repos.User.FindByID(context.Background(), "u1")
if err != nil {
t.Fatal(err)
}
if u.TelegramChatID != "" {
t.Fatalf("chat id = %q, want empty", u.TelegramChatID)
}
}
// 未启用 / 未配置 token 时,发送必须静默返回,绝不能发起网络请求或以 panic 收场。
func TestSendNoopWhenDisabled(t *testing.T) {
s, repos := newTelegramTestService(t)
seedTelegramTestUser(t, repos, "u1", "1234567890")
s.SendToUser(context.Background(), "u1", "hello")
s.SendToAdmin(context.Background(), "hello")
}
// 未绑定 Telegram 的用户仍可能触发通知(例如首次登录),此时必须静默跳过。
func TestSendNoopWhenUserUnbound(t *testing.T) {
s, _ := newTelegramTestService(t)
s.SendToUser(context.Background(), "missing-user", "hello")
}
// 数据库写入失败时绑定码必须保留,让用户可以重试。
func TestCompleteBindKeepsPendingOnDBFailure(t *testing.T) {
db := newServiceTestDB(t)
repos := repository.New(db)
s := NewTelegramService(zap.NewNop(), repos)
code, err := s.StartBind(context.Background(), "u1")
if err != nil {
t.Fatal(err)
}
// 关闭底层数据库连接,强制 UpdateFields 失败。
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.Close()
bindErr := s.CompleteBind(context.Background(), code, "999")
if bindErr == nil {
t.Fatal("expected an error from closed DB")
}
// 码必须还在 pending 里,以便重试。
s.mu.Lock()
_, stillPresent := s.pending[code]
s.mu.Unlock()
if !stillPresent {
t.Fatal("bind code must be retained when DB write fails, so the user can retry")
}
}
// pollLoop 在 Telegram 禁用状态下不应退出,而应持续等待配置开启。
func TestPollLoopContinuesWhenDisabled(t *testing.T) {
repos := repository.New(newServiceTestDB(t))
s := NewTelegramService(zap.NewNop(), repos)
// 使用一个立即取消的 context 验证 goroutine 能正常退出(不阻塞)。
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
s.pollLoop(ctx)
}()
// 给 goroutine 启动时间,然后取消 context。
time.Sleep(20 * time.Millisecond)
cancel()
select {
case <-done:
// ok: goroutine 正常退出
case <-time.After(2 * time.Second):
t.Fatal("pollLoop did not exit after ctx cancellation")
}
}