diff --git a/$null b/$null new file mode 100644 index 0000000..ba3427b --- /dev/null +++ b/$null @@ -0,0 +1,2 @@ +'Get-Content' is not recognized as an internal or external command, +operable program or batch file. diff --git a/internal/handler/watch_history.go b/internal/handler/watch_history.go index 44ecdfa..2068ff8 100644 --- a/internal/handler/watch_history.go +++ b/internal/handler/watch_history.go @@ -92,13 +92,14 @@ func historyStatsHandler(svc *service.Container) gin.HandlerFunc { func historyContinueHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { uid, _ := c.Get(middleware.CtxUserID) + userID := toString(uid) limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) if limit <= 0 || limit > 50 { limit = 10 } var rows []model.PlaybackHistory if err := svc.Repo.DB. - Where("user_id = ? AND completed = ?", toString(uid), false). + Where("user_id = ? AND completed = ?", userID, false). Order("watched_at desc"). Limit(limit). Find(&rows).Error; err != nil { @@ -116,61 +117,52 @@ func historyContinueHandler(svc *service.Container) gin.HandlerFunc { } mIdx := make(map[string]model.Media, len(media)) for _, m := range media { - if !mediaVisibleForRequest(c, svc, &m) { - continue - } mIdx[m.ID] = m } out := make([]gin.H, 0, len(rows)) + staleIDs := make([]string, 0) for _, r := range rows { m, ok := mIdx[r.MediaID] - if !ok { - if svc.EmbyRemote != nil && service.IsEmbyRemoteID(r.MediaID) { - mountID, remoteID, _ := service.DecodeEmbyRemoteID(r.MediaID) - if mount, acct, _ := svc.EmbyRemote.ResolveMount(c.Request.Context(), mountID); mount != nil && acct != nil { - if rm, err := svc.EmbyRemote.RemoteMediaDetail(c.Request.Context(), mount, acct, remoteID); err == nil && rm != nil { - if mediaVisibleForRequest(c, svc, rm) { - out = append(out, gin.H{ - "history": r, - "media": *rm, - }) - } - continue - } - } - } - fallback := fallbackHistoryMedia(r.MediaID) - if fallback != nil { + if ok { + if mediaVisibleForRequest(c, svc, &m) { out = append(out, gin.H{ "history": r, - "media": *fallback, + "media": m, }) } continue } - out = append(out, gin.H{ - "history": r, - "media": m, - }) + + if svc.EmbyRemote != nil && service.IsEmbyRemoteID(r.MediaID) { + mountID, remoteID, _ := service.DecodeEmbyRemoteID(r.MediaID) + mount, acct, resolveErr := svc.EmbyRemote.ResolveMount(c.Request.Context(), mountID) + if resolveErr == nil && mount != nil && acct != nil { + remoteMedia, detailErr := svc.EmbyRemote.RemoteMediaDetail(c.Request.Context(), mount, acct, remoteID) + if detailErr == nil && remoteMedia != nil { + if mediaVisibleForRequest(c, svc, remoteMedia) { + out = append(out, gin.H{ + "history": r, + "media": *remoteMedia, + }) + } + continue + } + } + } + + // 媒体记录已不存在。继续返回占位卡只会让用户点击后遇到 404, + // 因此清理这条失效播放记录,不再占用继续观看列表。 + staleIDs = append(staleIDs, r.MediaID) + } + if len(staleIDs) > 0 { + _ = svc.Repo.DB.WithContext(c.Request.Context()).Unscoped(). + Where("user_id = ? AND media_id IN ?", userID, staleIDs). + Delete(&model.PlaybackHistory{}).Error } c.JSON(http.StatusOK, out) } } -func fallbackHistoryMedia(mediaID string) *model.Media { - if mediaID == "" { - return nil - } - title := "媒体" - if service.IsEmbyRemoteID(mediaID) { - title = "远程媒体" - } - return &model.Media{ - Base: model.Base{ID: mediaID}, - Title: title, - } -} - // historyDeleteHandler removes one or all history rows for the caller. // // DELETE /api/watch-history?media_id=xxx → delete just that media's row diff --git a/internal/handler/watch_history_test.go b/internal/handler/watch_history_test.go new file mode 100644 index 0000000..cf0d06a --- /dev/null +++ b/internal/handler/watch_history_test.go @@ -0,0 +1,105 @@ +package handler + +import ( + "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 TestHistoryContinueRemovesMissingMedia(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.PlaybackHistory{}, &model.Media{}); err != nil { + t.Fatal(err) + } + + valid := model.Media{ + Base: model.Base{ID: "media-valid"}, + Title: "有效影片", + Path: "/media/valid.mkv", + } + if err := db.Create(&valid).Error; err != nil { + t.Fatal(err) + } + + now := time.Now() + if err := db.Create(&model.PlaybackHistory{ + Base: model.Base{ID: "history-valid"}, + UserID: "user-1", + MediaID: valid.ID, + PositionMs: 30_000, + DurationMs: 120_000, + WatchedAt: now.Add(-time.Minute), + }).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.PlaybackHistory{ + Base: model.Base{ID: "history-stale"}, + UserID: "user-1", + MediaID: "media-deleted", + PositionMs: 60_000, + DurationMs: 120_000, + WatchedAt: now, + }).Error; err != nil { + t.Fatal(err) + } + + repos := repository.New(db) + svc := &service.Container{ + Log: zap.NewNop(), + Repo: repos, + Playback: service.NewPlaybackService(zap.NewNop(), repos), + } + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(middleware.CtxUserID, "user-1") + c.Next() + }) + router.GET("/watch-history/continue", historyContinueHandler(svc)) + + req := httptest.NewRequest(http.MethodGet, "/watch-history/continue?limit=10", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + + var body []struct { + History model.PlaybackHistory `json:"history"` + Media model.Media `json:"media"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if len(body) != 1 { + t.Fatalf("continue watching rows = %d, want 1: %s", len(body), rec.Body.String()) + } + if body[0].Media.ID != valid.ID || body[0].History.ID != "history-valid" { + t.Fatalf("unexpected row: %#v", body[0]) + } + + var staleCount int64 + if err := db.Unscoped().Model(&model.PlaybackHistory{}). + Where("user_id = ? AND media_id = ?", "user-1", "media-deleted"). + Count(&staleCount).Error; err != nil { + t.Fatal(err) + } + if staleCount != 0 { + t.Fatalf("stale history rows remaining = %d, want 0", staleCount) + } +}