From 9ffac320c4ebea00d76f99f458bcafc7376b29a8 Mon Sep 17 00:00:00 2001 From: truewhile <779943132@qq.com> Date: Wed, 4 Nov 2026 10:16:12 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/watch_history.go | 24 ++- internal/handler/watch_history_test.go | 74 ++++++++ internal/service/media_cache.go | 16 ++ internal/service/media_listing.go | 30 ++++ internal/service/media_versions_test.go | 73 ++++++++ internal/service/playback.go | 73 ++++++++ internal/service/playback_test.go | 62 +++++++ web/src/api/history.ts | 12 ++ web/src/pages/LibraryPage.tsx | 10 ++ web/src/pages/LibrarySeriesDetailHeader.tsx | 32 +++- web/src/pages/LibrarySeriesDetailSection.tsx | 10 ++ web/src/pages/LibrarySeriesEpisodes.tsx | 31 ++++ web/src/pages/MediaDetailPage.tsx | 2 + web/src/pages/MediaDetailPageSections.tsx | 23 ++- web/src/pages/PlayerPage.tsx | 20 ++- web/src/pages/mediaDetailResumeModel.test.ts | 122 ++++++++++++++ web/src/pages/mediaDetailResumeModel.ts | 168 +++++++++++++++++++ web/src/pages/useMediaDetailPageState.ts | 37 +++- web/src/pages/useSeriesEpisodeResume.ts | 56 +++++++ 19 files changed, 856 insertions(+), 19 deletions(-) create mode 100644 web/src/pages/mediaDetailResumeModel.test.ts create mode 100644 web/src/pages/mediaDetailResumeModel.ts create mode 100644 web/src/pages/useSeriesEpisodeResume.ts diff --git a/internal/handler/watch_history.go b/internal/handler/watch_history.go index f4019ff..495a9a1 100644 --- a/internal/handler/watch_history.go +++ b/internal/handler/watch_history.go @@ -30,10 +30,12 @@ import ( // // include_media=0 只返回历史行本身。客户端判断「继续播放」指向哪一集时只 // 需要 media_id/position_ms;带上整条 media 会让 300 条的响应从 ~40KB 涨到 -// ~420KB,手机链路上光传输就要十几秒,详情页的续播解析会被它拖住。 +// ~420KB。更重要的是服务端不再 hydrate:不查整条 media、也不为挂载条目 +// 逐条请求上游 Emby(见 PlaybackService.RecentHistoryWithoutMedia)。 func historyListHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { uid, _ := c.Get(middleware.CtxUserID) + userID := toString(uid) limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) if limit <= 0 || limit > 500 { limit = 50 @@ -44,19 +46,27 @@ func historyListHandler(svc *service.Container) gin.HandlerFunc { includeMedia = parsed } } - items, err := svc.Playback.RecentHistory(c.Request.Context(), toString(uid), limit) + ctx := c.Request.Context() + visibility := mediaVisibilityForRequest(c, svc) + if !includeMedia { + // 可见性由服务层用窄列查询判定,handler 不再需要 media。 + items, err := svc.Playback.RecentHistoryWithoutMedia(ctx, userID, limit, visibility) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, items) + return + } + items, err := svc.Playback.RecentHistory(ctx, userID, limit) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - visibility := mediaVisibilityForRequest(c, svc) filtered := make([]service.HistoryItem, 0, len(items)) for _, item := range items { - // 可见性仍然按 media 判断,只是决定要不要把它写进响应。 + // 可见性按 media 判断:媒体已删除/远端取不到时仍然保留这条历史。 if item.Media == nil || visibility.Allows(item.Media) { - if !includeMedia { - item.Media = nil - } filtered = append(filtered, item) } } diff --git a/internal/handler/watch_history_test.go b/internal/handler/watch_history_test.go index 41ebf1f..fb94669 100644 --- a/internal/handler/watch_history_test.go +++ b/internal/handler/watch_history_test.go @@ -175,3 +175,77 @@ func TestHistoryListCanOmitEmbeddedMedia(t *testing.T) { t.Fatalf("include_media=0 must not embed media: %s", lean.Body.String()) } } + +// TestHistoryListIncludeMediaFalseSkipsHydration 保证 include_media=0 时服务端 +// 真的不 hydrate media:响应里只有历史行(客户端按 media_id 匹配续播目标)。 +func TestHistoryListIncludeMediaFalseSkipsHydration(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) + } + if err := db.Create(&model.Media{ + Base: model.Base{ID: "media-1"}, + Title: "影片", + Path: "/media/1.mkv", + }).Error; err != nil { + t.Fatal(err) + } + now := time.Now() + for i, mediaID := range []string{"media-1", "media-deleted"} { + if err := db.Create(&model.PlaybackHistory{ + Base: model.Base{ID: "history-" + mediaID}, + UserID: "user-1", + MediaID: mediaID, + PositionMs: 30_000, + DurationMs: 120_000, + WatchedAt: now.Add(-time.Duration(i) * time.Minute), + }).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", historyListHandler(svc)) + + for _, path := range []string{ + "/watch-history?limit=10&include_media=0", + "/watch-history?limit=10&include_media=false", + } { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("%s status = %d, body = %s", path, rec.Code, rec.Body.String()) + } + var rows []map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &rows); err != nil { + t.Fatalf("%s decode: %v", path, err) + } + // 媒体已删除的历史也保留:客户端只按 media_id 判断有没有记录。 + if len(rows) != 2 { + t.Fatalf("%s rows = %d, want 2 (%s)", path, len(rows), rec.Body.String()) + } + for _, row := range rows { + if _, ok := row["media"]; ok { + t.Fatalf("%s should not embed media: %v", path, row) + } + if row["media_id"] == "" || row["media_id"] == nil { + t.Fatalf("%s missing media_id: %v", path, row) + } + } + } +} diff --git a/internal/service/media_cache.go b/internal/service/media_cache.go index 52731e9..388f1e2 100644 --- a/internal/service/media_cache.go +++ b/internal/service/media_cache.go @@ -179,6 +179,22 @@ func (s *MediaService) libraryCardsObjectKey(libraryID string, visibility MediaV return s.seriesCardsCacheKey(libraryID, visibility) + ":obj" } +// 版本分组对象的缓存 key。 +// +// 详情页的「同片多版本」以前每次都现算:条目没有 series_id / tmdb / douban / +// thetvdb 时走不到收窄分支,会直接把这个库最多 5000 行整行读出来再逐条算版本 +// 键(实测 5000 行的库单次 ~180ms,且没有缓存)。同一个版本组的结果只随扫描/ +// 刮削变化,所以按「合并库集合 + 版本组键」缓存,配合 media: 前缀一起被 +// invalidateMediaCache 清掉。 +func (s *MediaService) versionSiblingsCacheKey(libraryIDs []string, groupKey string) string { + libs := append([]string(nil), libraryIDs...) + sort.Strings(libs) + return "media:obj:version-siblings:" + hashObjectCacheKey([]string{ + strings.Join(libs, ","), + groupKey, + }) +} + func (s *MediaService) invalidateMediaCache(ctx context.Context) { if s != nil && s.cache != nil { s.cache.DeletePrefix(ctx, "media:") diff --git a/internal/service/media_listing.go b/internal/service/media_listing.go index cb20361..62e0acc 100644 --- a/internal/service/media_listing.go +++ b/internal/service/media_listing.go @@ -255,6 +255,35 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med if len(libraryIDs) == 0 { libraryIDs = []string{media.LibraryID} } + // 同一个版本组的结果对组内任一成员都成立,而且只随扫描/刮削变化:缓存它 + // 才能让「逐部点开详情」的浏览不再每次都重扫该库最多 5000 行。 + cacheKey := s.versionSiblingsCacheKey(libraryIDs, key) + if s.cache != nil { + if obj, ok := s.cache.GetObject(cacheKey); ok { + if cached, ok := obj.([]model.Media); ok { + // GetObject 返回的对象此后视为不可变,调用方(详情页)可能重排/追加 + // 切片,所以拷贝一份再交出去。 + out := make([]model.Media, len(cached)) + copy(out, cached) + return out, nil + } + } + } + matched, err := s.loadVersionSiblings(ctx, media, libraryIDs, key) + if err != nil { + return nil, err + } + if s.cache != nil { + s.cache.SetObject(cacheKey, matched, s.derivedReadCacheTTL()) + } + out := make([]model.Media, len(matched)) + copy(out, matched) + return out, nil +} + +// loadVersionSiblings 是版本分组的慢路径(可能读全库),结果由 +// listVersionSiblings 缓存。 +func (s *MediaService) loadVersionSiblings(ctx context.Context, media *model.Media, libraryIDs []string, key string) ([]model.Media, error) { filter := repository.MediaQueryFilter{IncludeNSFW: true} // 成人条目按番号分组,而番号常常只存在于路径/标题里:本地 NFO 来源的分片 // 没有 douban_id/thetvdb_id(MetaTube 刮削出来的那几个才有)。若仍按外部 @@ -263,6 +292,7 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med // 直接在该库范围内比对版本键。未刮削但文件名带番号的分片同理:同一部片的 // 各分片元数据来源不一致(刮削 vs pending),收窄后标题也对不上。 var candidates []model.Media + var err error narrowed := false hasAdultCode := mediaAdultGroupCode(*media) != "" || canonicalAdultGroupCode(AdultCodeFromMediaPath(media.Path)) != "" diff --git a/internal/service/media_versions_test.go b/internal/service/media_versions_test.go index 2ed6228..d050069 100644 --- a/internal/service/media_versions_test.go +++ b/internal/service/media_versions_test.go @@ -787,3 +787,76 @@ func TestMediaVersionGroupKeyDistinguishesHalfEpisode(t *testing.T) { t.Fatalf("plain episode key = %q, want suffix 1:11", plainKey) } } + +// TestGetMediaItemCachesVersionSiblings 覆盖详情页最贵的一次查询: +// 条目没有外部 ID 时会整库读最多 5000 行来找同片版本,而结果只随扫描/刮削 +// 变化。这里断言第二次打开详情走缓存(删掉候选行后仍能拿到同一组版本), +// 并且调用方修改返回切片不会污染缓存。 +func TestGetMediaItemCachesVersionSiblings(t *testing.T) { + db := newServiceTestDB(t, &model.Library{}, &model.Media{}) + repos := repository.New(db) + lib := model.Library{Name: "电影", Path: "/movies", Type: "movie"} + if err := db.Create(&lib).Error; err != nil { + t.Fatal(err) + } + now := time.Now() + rows := []model.Media{ + { + Base: model.Base{ID: "v-1080", CreatedAt: now, UpdatedAt: now}, + LibraryID: lib.ID, + Title: "影片 2024 1080p", + Path: "/movies/影片.2024.1080p.mkv", + Width: 1920, + Height: 1080, + Year: 2024, + }, + { + Base: model.Base{ID: "v-2160", CreatedAt: now.Add(time.Hour), UpdatedAt: now.Add(time.Hour)}, + LibraryID: lib.ID, + Title: "影片 2024 2160p", + Path: "/movies/影片.2024.2160p.mkv", + Width: 3840, + Height: 2160, + Year: 2024, + }, + } + if err := db.Create(&rows).Error; err != nil { + t.Fatal(err) + } + + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos). + SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop())) + t.Cleanup(func() { _ = svc.cache.Close() }) + + item, err := svc.GetMediaItem(t.Context(), "v-1080") + if err != nil { + t.Fatal(err) + } + if len(item.Versions) != 2 { + t.Fatalf("versions = %d, want 2: %#v", len(item.Versions), item.Versions) + } + + // 把数据删掉:如果第二次还去查库,就只会剩下代表条目自己。 + if err := db.Where("id = ?", "v-2160").Delete(&model.Media{}).Error; err != nil { + t.Fatal(err) + } + second, err := svc.GetMediaItem(t.Context(), "v-1080") + if err != nil { + t.Fatal(err) + } + if len(second.Versions) != 2 { + t.Fatalf("cached versions = %d, want 2", len(second.Versions)) + } + + // 调用方乱改返回的切片不能影响缓存内容。 + second.Versions[0] = model.Media{Base: model.Base{ID: "hacked"}} + third, err := svc.GetMediaItem(t.Context(), "v-1080") + if err != nil { + t.Fatal(err) + } + for _, v := range third.Versions { + if v.ID == "hacked" { + t.Fatal("caller mutation leaked into the cached version list") + } + } +} diff --git a/internal/service/playback.go b/internal/service/playback.go index 486a8cc..21268b8 100644 --- a/internal/service/playback.go +++ b/internal/service/playback.go @@ -193,6 +193,79 @@ func (p *PlaybackService) RecentHistory(ctx context.Context, userID string, limi return items, nil } +// RecentHistoryWithoutMedia 返回最近观看记录,但完全不 join media。 +// +// 详情页解析「继续播放」只需要 media_id/position_ms,带整条 media 会让 300 条 +// 响应大一个数量级。旧实现是先用 RecentHistory 把 media 全部查出来(挂载条目 +// 还会逐条串行请求上游 Emby),最后才在 handler 里把 media 置空 —— 也就是 +// 响应变小了,服务端一点没省。这里直接跳过 hydrate: +// +// - 本地条目:用 (id, library_id, nsfw) 窄列查询判断可见性,不读 overview / +// path / 海报这些大字段; +// - 挂载条目:按挂载媒体库的 ACL 判断,不再发上游请求(上游慢或挂了时, +// 详情页的续播解析不会被它拖住); +// - 记录还在但 media 已删除:保留。与旧行为一致 —— 旧实现 hydrate 失败时 +// 同样保留这条历史,客户端只拿 media_id 判断续播目标。 +func (p *PlaybackService) RecentHistoryWithoutMedia(ctx context.Context, userID string, limit int, visibility MediaVisibility) ([]HistoryItem, error) { + rows, err := p.repo.History.ListByUser(ctx, userID, limit) + if err != nil { + return nil, err + } + if len(rows) == 0 { + return []HistoryItem{}, nil + } + + mediaIDs := make([]string, 0, len(rows)) + for i := range rows { + if rows[i].MediaID != "" { + mediaIDs = append(mediaIDs, rows[i].MediaID) + } + } + // 只取可见性判定真正需要的三列。Select 到 model.Media 上是本仓库既有写法 + // (见 database/backfillLibraryRootMedia),未选中的字段保持零值。 + visibleByID := make(map[string]bool, len(mediaIDs)) + if len(mediaIDs) > 0 { + var lean []model.Media + if err := p.repo.DB.WithContext(ctx).Model(&model.Media{}). + Select("id", "library_id", "nsfw"). + Where("id IN ?", mediaIDs). + Find(&lean).Error; err != nil { + return nil, err + } + for i := range lean { + visibleByID[lean[i].ID] = visibility.Allows(&lean[i]) + } + } + + // 挂载库 ACL 每个挂载只解析一次:ResolveMount 只读本地表,不发网络请求。 + mountAllowed := make(map[string]bool, 2) + items := make([]HistoryItem, 0, len(rows)) + for i := range rows { + mediaID := rows[i].MediaID + if allowed, known := visibleByID[mediaID]; known { + if allowed { + items = append(items, HistoryItem{PlaybackHistory: rows[i]}) + } + continue + } + if p.remote != nil && IsEmbyRemoteID(mediaID) { + mountID, _, _ := DecodeEmbyRemoteID(mediaID) + allowed, resolved := mountAllowed[mountID] + if !resolved { + mount, _, resolveErr := p.remote.ResolveMount(ctx, mountID) + // 挂载解析不出来时无法判定,沿用旧行为保留这条历史。 + allowed = resolveErr != nil || mount == nil || EmbyMountLibraryAllowed(visibility, mount) + mountAllowed[mountID] = allowed + } + if !allowed { + continue + } + } + items = append(items, HistoryItem{PlaybackHistory: rows[i]}) + } + return items, nil +} + // ─── Favourites ───────────────────────────────────────────────────────────── // ToggleFavourite flips the favourite flag and reports the new state. diff --git a/internal/service/playback_test.go b/internal/service/playback_test.go index 0fb8f8e..c636924 100644 --- a/internal/service/playback_test.go +++ b/internal/service/playback_test.go @@ -113,3 +113,65 @@ func TestPlaybackProgressCompletedThreshold(t *testing.T) { }) } } + +// TestRecentHistoryWithoutMediaSkipsHydration 覆盖详情页用的轻量历史路径: +// 不返回 media,但可见性过滤必须与带 media 的版本一致。 +func TestRecentHistoryWithoutMediaSkipsHydration(t *testing.T) { + db := newServiceTestDB(t, &model.PlaybackHistory{}, &model.Media{}, &model.Library{}) + repos := repository.New(db) + svc := NewPlaybackService(zap.NewNop(), repos) + + seed := func(id, libraryID string, nsfw bool) { + t.Helper() + m := model.Media{ + Base: model.Base{ID: id}, + LibraryID: libraryID, + Title: "标题 " + id, + Path: "/media/" + id + ".mkv", + NSFW: nsfw, + } + if err := db.Create(&m).Error; err != nil { + t.Fatalf("seed media %s: %v", id, err) + } + } + seed("visible", "lib-ok", false) + seed("adult", "lib-ok", true) + seed("hidden-lib", "lib-hidden", false) + + now := time.Now() + for i, mediaID := range []string{"visible", "adult", "hidden-lib", "gone"} { + if err := db.Create(&model.PlaybackHistory{ + Base: model.Base{ID: "hist-" + mediaID}, + UserID: "user-1", + MediaID: mediaID, + PositionMs: 10_000, + DurationMs: 60_000, + WatchedAt: now.Add(-time.Duration(i) * time.Minute), + }).Error; err != nil { + t.Fatalf("seed history %s: %v", mediaID, err) + } + } + + items, err := svc.RecentHistoryWithoutMedia(context.Background(), "user-1", 50, MediaVisibility{ + HiddenLibraryIDs: []string{"lib-hidden"}, + }) + if err != nil { + t.Fatalf("RecentHistoryWithoutMedia: %v", err) + } + got := make([]string, 0, len(items)) + for _, item := range items { + if item.Media != nil { + t.Fatalf("expected no media hydration, got %#v", item.Media) + } + got = append(got, item.MediaID) + } + want := []string{"visible", "gone"} + if len(got) != len(want) { + t.Fatalf("media ids = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("media ids = %v, want %v", got, want) + } + } +} diff --git a/web/src/api/history.ts b/web/src/api/history.ts index 4cff061..d6d0722 100644 --- a/web/src/api/history.ts +++ b/web/src/api/history.ts @@ -10,6 +10,18 @@ export const historyAPI = { .get('/watch-history', { params: { limit } }) .then((r) => r.data), + /** + * 只要历史行、不要内嵌的 media。 + * + * 媒体详情页解析「继续播放」只需要 media_id/position_ms;带上 media 会让 + * 300 条响应大一个数量级,而且服务端还得为每条去查 media(挂载条目逐条 + * 回源)。服务端已经支持 include_media=0 跳过 hydrate。 + */ + listRows: (limit = 300) => + api + .get('/watch-history', { params: { limit, include_media: false } }) + .then((r) => r.data), + stats: () => api.get('/watch-history/stats').then((r) => r.data), continueWatching: (limit = 10) => diff --git a/web/src/pages/LibraryPage.tsx b/web/src/pages/LibraryPage.tsx index 90c3239..6bf40a7 100644 --- a/web/src/pages/LibraryPage.tsx +++ b/web/src/pages/LibraryPage.tsx @@ -29,6 +29,7 @@ import { LibrarySeriesDetailSection } from './LibrarySeriesDetailSection' import { useLibraryData } from './useLibraryData' import { useLibraryScanStatus } from './useLibraryScanStatus' import { useLibrarySeriesSelection } from './useLibrarySeriesSelection' +import { useSeriesEpisodeResume } from './useSeriesEpisodeResume' import { useLibraryAdminActions } from './useLibraryAdminActions' import { usePermission } from '../hooks/usePermission' import { useFavourites } from '../hooks/useFavourites' @@ -214,6 +215,13 @@ export function LibraryPage() { setSelectedSeason, onClearSeriesState: () => setSeriesMetadataEditOpen(false), }) + // 抽屉里的「继续播放」:在整剧所有集里挑最近看过的一条(代表条目可能是 OVA)。 + const { resume: seriesResume, progressById: seriesEpisodeProgress } = useSeriesEpisodeResume( + selectedSeries?.key ?? '', + selectedSeries?.rep ?? null, + selectedSeriesAllEpisodes, + ) + const selectedSeriesScrapeMedia = selectedSeriesEpisodes.find((media) => !isTheatricalFeature(media)) ?? selectedSeries?.rep ?? null @@ -350,6 +358,8 @@ export function LibraryPage() { selectedSeason={selectedSeason} visibleEpisodes={visibleEpisodes} allEpisodes={selectedSeriesEpisodes} + resume={seriesResume} + episodeProgress={seriesEpisodeProgress} loadingEpisodes={loadingSeriesEpisodes} playbackFrom={`${location.pathname}${location.search}`} isAdmin={role === 'admin'} diff --git a/web/src/pages/LibrarySeriesDetailHeader.tsx b/web/src/pages/LibrarySeriesDetailHeader.tsx index 0592f82..e56207d 100644 --- a/web/src/pages/LibrarySeriesDetailHeader.tsx +++ b/web/src/pages/LibrarySeriesDetailHeader.tsx @@ -8,10 +8,12 @@ import { MediaFavouriteButton } from '../components/MediaFavouriteButton' import type { Media } from '../types' import { isTheatricalFeature, seriesTitle, type SeriesCard } from '../utils/groupSeries' import { episodeNumberValue } from '../utils/episodeNumber' +import { canContinuePlaying, continueButtonLabel, type MediaDetailResume } from './mediaDetailResumeModel' type LibrarySeriesDetailHeaderProps = { series: SeriesCard allEpisodes: Media[] + resume: MediaDetailResume | null playbackFrom: string isAdmin: boolean canFavorite: boolean @@ -32,6 +34,7 @@ type LibrarySeriesDetailHeaderProps = { export function LibrarySeriesDetailHeader({ series, allEpisodes, + resume, playbackFrom, isAdmin, canFavorite, @@ -51,6 +54,9 @@ export function LibrarySeriesDetailHeader({ const tvEpisodes = allEpisodes.filter((media) => !isTheatricalFeature(media)) const theatricalCount = allEpisodes.length - tvEpisodes.length const firstEpisode = firstPlayableEpisode(tvEpisodes) + // 看过的剧先给「继续播放 · 第N集 · mm:ss」,指向真正看的那一集; + // 没有可用进度时保持原来的「从第一集开始播放」。 + const resumeTarget = resume && canContinuePlaying(resume) ? resume : null return ( <> @@ -90,14 +96,32 @@ export function LibrarySeriesDetailHeader({

- {firstEpisode && ( + {resumeTarget ? ( <> - + - 从第一集开始播放 + {continueButtonLabel(resumeTarget)} - + + 从头播放 + + + ) : ( + firstEpisode && ( + <> + + + 从第一集开始播放 + + + + ) )} {canFavorite && ( loadingEpisodes: boolean playbackFrom: string isAdmin: boolean @@ -42,6 +45,8 @@ export function LibrarySeriesDetailSection({ selectedSeason, visibleEpisodes, allEpisodes, + resume, + episodeProgress, loadingEpisodes, playbackFrom, isAdmin, @@ -73,6 +78,7 @@ export function LibrarySeriesDetailSection({ playbackFrom: string isAdmin: boolean onSeasonChange: (season: number) => void @@ -122,6 +130,7 @@ function LibrarySeriesEpisodesPanel({ selectedEpisodes, selectedSeason, visibleEpisodes, + progressById, playbackFrom, isAdmin, onSeasonChange, @@ -134,6 +143,7 @@ function LibrarySeriesEpisodesPanel({ selectedEpisodes={selectedEpisodes} selectedSeason={selectedSeason} visibleEpisodes={visibleEpisodes} + progressById={progressById} playbackFrom={playbackFrom} isAdmin={isAdmin} onSeasonChange={onSeasonChange} diff --git a/web/src/pages/LibrarySeriesEpisodes.tsx b/web/src/pages/LibrarySeriesEpisodes.tsx index 598a32d..17320e6 100644 --- a/web/src/pages/LibrarySeriesEpisodes.tsx +++ b/web/src/pages/LibrarySeriesEpisodes.tsx @@ -12,6 +12,11 @@ import { } from '../utils/groupSeries' import { formatSize } from './libraryPageModel' import { formatEpisodeLabel, formatEpisodeNumber } from '../utils/episodeNumber' +import { + formatResumeClock, + progressPercent, + type EpisodeProgress, +} from './mediaDetailResumeModel' type SeasonGroup = { season: number @@ -24,6 +29,8 @@ type LibrarySeriesEpisodesProps = { selectedSeason: number | null visibleEpisodes: Media[] playbackFrom: string + /** 每集(按条目 id)的续播进度,用于卡片上的进度条。 */ + progressById?: Map isAdmin?: boolean onSeasonChange: (season: number) => void onManualScrape?: (media: Media) => void @@ -35,6 +42,7 @@ export function LibrarySeriesEpisodes({ selectedSeason, visibleEpisodes, playbackFrom, + progressById, isAdmin = false, onSeasonChange, onManualScrape, @@ -76,6 +84,7 @@ export function LibrarySeriesEpisodes({ {visibleEpisodes.map((ep) => { const displayTitle = episodeDisplayTitle(ep, visibleEpisodes) const versionCount = ep.versions?.length ?? 1 + const progress = progressById?.get(ep.id) return (
1 ? ` · ${versionCount} 版本` : ''}

+ {progress && ( +
+
+
+
+ + {progress.completed + ? '已看完' + : `看到 ${formatResumeClock(progress.positionMs)}`} + +
+ )}
@@ -142,6 +166,13 @@ export function LibrarySeriesEpisodes({ ) } +function progressTitle(progress: EpisodeProgress): string { + if (progress.completed) return '已看完' + const position = formatResumeClock(progress.positionMs) + const duration = formatResumeClock(progress.durationMs) + return duration ? `看到 ${position} / ${duration}` : `看到 ${position}` +} + function episodeDisplayTitle(ep: Media, siblings: Media[]): string { let mainTitle = '' const epTitle = ep.episode_title?.trim() diff --git a/web/src/pages/MediaDetailPage.tsx b/web/src/pages/MediaDetailPage.tsx index dada52b..4d58d70 100644 --- a/web/src/pages/MediaDetailPage.tsx +++ b/web/src/pages/MediaDetailPage.tsx @@ -33,6 +33,7 @@ export function MediaDetailPage() { isAdmin={role === 'admin'} favourite={detail.favourite} playTargetId={media.id || detail.firstPlayableEpisode?.id} + resume={detail.resume} scrapeEpisodeArtwork={detail.scrapeEpisodeArtwork} onToggleFavourite={detail.toggleFavourite} onScrapeEpisodeArtworkChange={detail.setScrapeEpisodeArtwork} @@ -53,6 +54,7 @@ export function MediaDetailPage() { selectedEpisodes={detail.seasonGroups} selectedSeason={detail.selectedSeason} visibleEpisodes={detail.visibleEpisodes} + progressById={detail.episodeProgress} playbackFrom={`/media/${media.id}`} onSeasonChange={detail.setSelectedSeason} /> diff --git a/web/src/pages/MediaDetailPageSections.tsx b/web/src/pages/MediaDetailPageSections.tsx index 7de4cb9..150a8c1 100644 --- a/web/src/pages/MediaDetailPageSections.tsx +++ b/web/src/pages/MediaDetailPageSections.tsx @@ -10,6 +10,7 @@ import { MetadataEditDialog } from '../components/MetadataEditDialog' import { OrganizeMediaDialog } from '../components/OrganizeMediaDialog' import type { Media } from '../types' import { isDirectStreamMedia } from './playerPageModel' +import { canContinuePlaying, continueButtonLabel, type MediaDetailResume } from './mediaDetailResumeModel' import { MediaDetailAdminPanel } from './MediaDetailAdminPanel' import { MediaDetailPoster } from './MediaDetailArtwork' import { MediaDetailMetadata } from './MediaDetailMetadata' @@ -20,6 +21,7 @@ interface MediaDetailPlaybackActionsProps { favourite: boolean onToggleFavourite: () => void playTargetId?: string + resume?: MediaDetailResume | null } interface MediaDetailMainContentProps extends MediaDetailPlaybackActionsProps { @@ -96,8 +98,12 @@ export function MediaDetailPlaybackActions({ favourite, onToggleFavourite, playTargetId, + resume, }: MediaDetailPlaybackActionsProps) { - const targetId = media.id || playTargetId || '' + // 代表条目可能是 OVA/特别篇:有续播记录时优先打开历史命中的那一集(或那集的 + // 其它版本),并直接把「看到第几秒」写在按钮上。播放器仍会自己读 /resume。 + const resumeTarget = resume && canContinuePlaying(resume) ? resume : null + const targetId = resumeTarget?.mediaId || media.id || playTargetId || '' return (
- 立即播放 + {resumeTarget ? continueButtonLabel(resumeTarget) : '立即播放'} + {resumeTarget && ( + + 从头播放 + + )} + {!isDirectStreamMedia(media) && (
diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx index 353a772..6d3d505 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -64,6 +64,7 @@ import { // // ?mode=hls force HLS even when direct play would work // ?mode=direct force direct play (default for browser-friendly codecs) +// ?restart=1 忽略服务端续播进度,从 0 开始播(详情页的「从头播放」入口) // // ?mode= 只在进入某个媒体时读取一次(媒体详情页的「HLS 兼容转码播放」入口依赖它); // 播放模式不再写回 URL——写 URL 会让 search 变化触发依赖 setSearchParams 的 effect @@ -102,6 +103,17 @@ function formatMarkTime(totalSec: number): string { const CLOUD_HLS_RELOAD_MAX = 3 const CLOUD_HLS_RELOAD_WINDOW_MS = 10 * 60 * 1000 +/** + * 逐集切换时保留 ?mode=(HLS/直连是整段会话的选择),但丢掉 ?restart=1: + * 「从头播放」只对用户点的那一集生效,下一集仍按自己的续播进度起播。 + */ +function searchWithoutRestart(search: string): string { + const params = new URLSearchParams(search) + params.delete('restart') + const next = params.toString() + return next ? `?${next}` : '' +} + function normalizePlayerVolume(value: unknown): number { const parsed = Number(value) if (!Number.isFinite(parsed)) return 1 @@ -1590,6 +1602,8 @@ export function PlayerPage() { if (!id) return setResumePosition(0) setInitialSeekDone(false) + // 详情页的「从头播放」显式要求忽略续播:不再去读 /resume,直接从 0 起播。 + if (new URLSearchParams(window.location.search).get('restart') === '1') return const controller = new AbortController() playbackAPI .getResume(id, controller.signal) @@ -1855,7 +1869,7 @@ export function PlayerPage() { const target = autoPlayEpisodeFor(media, id, playlistEpisodes) if (!target) return navigate( - { pathname: `/play/${target.id}`, search: location.search }, + { pathname: `/play/${target.id}`, search: searchWithoutRestart(location.search) }, { replace: true, state: location.state }, ) }, [media, id, playlistEpisodes, navigate, location.search, location.state]) @@ -1937,7 +1951,9 @@ export function PlayerPage() { navigate( { pathname: `/play/${target.id}`, - search: location.search, + // 逐集切换保留 ?mode=,但丢掉 ?restart=1:「从头播放」只对用户点的那一集 + // 生效,下一集仍按各自的续播进度起播。 + search: searchWithoutRestart(location.search), }, { state: location.state }, ) diff --git a/web/src/pages/mediaDetailResumeModel.test.ts b/web/src/pages/mediaDetailResumeModel.test.ts new file mode 100644 index 0000000..35669f1 --- /dev/null +++ b/web/src/pages/mediaDetailResumeModel.test.ts @@ -0,0 +1,122 @@ +import { + canContinuePlaying, + continueButtonLabel, + episodeProgressById, + formatResumeClock, + progressPercent, + resolveMediaDetailResume, +} from './mediaDetailResumeModel.ts' +import type { HistoryItem, Media } from '../types' + +function check(name: string, cond: boolean) { + if (!cond) throw new Error(`mediaDetailResumeModel: ${name}`) +} + +function media(partial: Partial & { id: string }): Media { + return { + library_id: 'lib-1', + title: '标题', + path: `/media/${partial.id}.mkv`, + size_bytes: 0, + duration_sec: 0, + width: 0, + height: 0, + rating: 0, + year: 2024, + season_num: 0, + episode_num: 0, + scrape_status: 'matched', + tmdb_id: 0, + bangumi_id: 0, + nsfw: false, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + ...partial, + } as Media +} + +function historyRow( + mediaID: string, + positionMs: number, + watchedAt: string, + extra: Partial = {}, +): HistoryItem { + return { + id: `h-${mediaID}`, + user_id: 'u1', + media_id: mediaID, + position_ms: positionMs, + duration_ms: 1_440_000, + watched_at: watchedAt, + completed: false, + ...extra, + } as HistoryItem +} + +// ── 电影:直接用本条的进度 ──────────────────────────────────────────────── +const movie = media({ id: 'movie-1' }) +const movieResume = resolveMediaDetailResume(movie, [], [historyRow('movie-1', 754_000, '2024-05-01T10:00:00Z')]) +check('movie resume found', movieResume?.mediaId === 'movie-1') +check('movie label', continueButtonLabel(movieResume!) === '继续播放 · 12:34') +check('movie can continue', canContinuePlaying(movieResume)) + +// ── 剧集:代表条目是 OVA,续播要指向真正看过的那一集 ────────────────────── +const ova = media({ id: 'ova', episode_num: 0, title: '特别篇' }) +const ep1 = media({ id: 'ep1', season_num: 1, episode_num: 1 }) +const ep2 = media({ id: 'ep2', season_num: 1, episode_num: 2 }) +const ep3 = media({ id: 'ep3', season_num: 1, episode_num: 3 }) +const episodes = [ep1, ep2, ep3] +const seriesHistory = [ + historyRow('ep1', 600_000, '2024-05-01T09:00:00Z', { completed: true }), + historyRow('ep2', 300_000, '2024-05-02T09:00:00Z'), + historyRow('ep3', 0, '2024-05-03T09:00:00Z'), + historyRow('other-show', 900_000, '2024-05-04T09:00:00Z'), +] +const seriesResume = resolveMediaDetailResume(ova, episodes, seriesHistory) +check('series picks newest watched episode', seriesResume?.mediaId === 'ep2') +check('series label', continueButtonLabel(seriesResume!) === '继续播放 · 第 2 集 · 5:00') + +// 命中的是某一集的版本时,打开版本、但集号仍按它属于哪一集显示。 +const ep2Uhd = media({ id: 'ep2-uhd', season_num: 1, episode_num: 2 }) +const versionedEpisodes = [ep1, { ...ep2, versions: [ep2Uhd] }, ep3] +const versionResume = resolveMediaDetailResume(ova, versionedEpisodes, [ + historyRow('ep2-uhd', 120_000, '2024-05-02T09:00:00Z'), +]) +check('version resume target', versionResume?.mediaId === 'ep2-uhd') +check('version resume label uses owning episode', versionResume?.media.episode_num === 2) + +// ── 不值得续播的情况 ────────────────────────────────────────────────────── +check( + 'near-start is not a resume', + !canContinuePlaying(resolveMediaDetailResume(movie, [], [historyRow('movie-1', 1_500, '2024-05-01T10:00:00Z')])), +) +check( + 'completed is not a resume', + !canContinuePlaying( + resolveMediaDetailResume(movie, [], [ + historyRow('movie-1', 1_400_000, '2024-05-01T10:00:00Z', { completed: true }), + ]), + ), +) +check('no history', resolveMediaDetailResume(movie, [], []) === null) +check( + 'unrelated history is ignored', + resolveMediaDetailResume(movie, [], [historyRow('other', 900_000, '2024-05-01T10:00:00Z')]) === null, +) + +// ── 时间格式 ────────────────────────────────────────────────────────────── +check('clock minutes', formatResumeClock(754_000) === '12:34') +check('clock hours', formatResumeClock(3_723_000) === '1:02:03') +check('clock invalid', formatResumeClock(0) === '' && formatResumeClock(Number.NaN) === '') + +// ── 每集进度(剧集卡片上的进度条) ──────────────────────────────────────── +const progress = episodeProgressById(versionedEpisodes, [ + historyRow('ep1', 700_000, '2024-05-01T09:00:00Z', { completed: true }), + historyRow('ep2', 200_000, '2024-05-01T09:10:00Z'), + historyRow('ep2-uhd', 400_000, '2024-05-01T09:20:00Z'), +]) +check('episode progress skips untouched episodes', !progress.has('ep3')) +check('episode progress prefers newest version row', progress.get('ep2')?.positionMs === 400_000) +check('completed episode is full', progressPercent(progress.get('ep1')!) === 100) +check('partial episode is capped', progressPercent({ positionMs: 1_430_000, durationMs: 1_440_000, completed: false }) === 99) +check('unknown duration still visible', progressPercent({ positionMs: 10_000, durationMs: 0, completed: false }) === 5) diff --git a/web/src/pages/mediaDetailResumeModel.ts b/web/src/pages/mediaDetailResumeModel.ts new file mode 100644 index 0000000..80a638f --- /dev/null +++ b/web/src/pages/mediaDetailResumeModel.ts @@ -0,0 +1,168 @@ +import type { HistoryItem, Media } from '../types' +import { formatEpisodeLabel } from '../utils/episodeNumber.ts' + +/** + * 媒体详情页的「继续播放」解析。 + * + * 服务端存的是「(user, media) → 最近位置」的续播行。详情页要回答两个问题: + * 1. 这部剧最新看到哪一集?(代表条目可能是 OVA/特别篇,不能直接用它自己的进度) + * 2. 那一集看到第几秒? + * + * 因此这里拿到的是「整库最近 N 条历史」,在「本条目 + 全部剧集(含各自的版本)」 + * 这个集合里挑 watched_at 最新的一条。历史请求用 include_media=false,服务端 + * 不需要 join media,也不会为挂载条目逐条回源。 + */ + +/** 播放器只在「>2 秒且未看完」时才真正续播,详情页用同一阈值决定按钮文案。 */ +export const RESUME_MIN_POSITION_MS = 2000 + +/** 详情页续播扫描的条数(服务端上限 500)。 */ +export const RESUME_SCAN_LIMIT = 300 + +export interface MediaDetailResume { + /** 实际要打开的媒体 id:命中的那条历史,可能是某集的某个版本。 */ + mediaId: string + /** 用来显示集号/标题的条目(命中的那集;电影就是本体)。 */ + media: Media + positionMs: number + durationMs: number + completed: boolean +} + +export interface EpisodeProgress { + positionMs: number + durationMs: number + completed: boolean +} + +function versionIDs(media: Media | null | undefined): string[] { + return (media?.versions ?? []).map((v) => v.id).filter(Boolean) +} + +/** 「本条 + 各集 + 各集版本」的 id 集合:续播候选范围。 */ +export function mediaDetailScopeIDs(media: Media | null, episodes: Media[]): Set { + const ids = new Set() + const add = (item: Media | null | undefined) => { + if (!item?.id) return + ids.add(item.id) + for (const id of versionIDs(item)) ids.add(id) + } + add(media) + for (const ep of episodes) add(ep) + return ids +} + +/** 命中的 history.media_id 属于哪个条目:集优先,命中的是版本时归到它那一集。 */ +function ownerOf(mediaID: string, media: Media | null, episodes: Media[]): Media | null { + for (const ep of episodes) { + if (ep.id === mediaID) return ep + if (versionIDs(ep).includes(mediaID)) return ep + } + if (media) { + if (media.id === mediaID) return media + if (versionIDs(media).includes(mediaID)) return media + } + return null +} + +function watchedAtScore(row: HistoryItem): number { + const at = Date.parse(row.watched_at) + return Number.isFinite(at) ? at : 0 +} + +function newestRow(rows: HistoryItem[]): HistoryItem | null { + let best: HistoryItem | null = null + for (const row of rows) { + if (!row || !row.media_id) continue + if (best == null || watchedAtScore(row) > watchedAtScore(best)) best = row + } + return best +} + +/** + * 详情页的续播目标;没有可用历史时返回 null(按钮退回「立即播放」)。 + */ +export function resolveMediaDetailResume( + media: Media | null, + episodes: Media[], + history: HistoryItem[], +): MediaDetailResume | null { + if (!media) return null + const ids = mediaDetailScopeIDs(media, episodes) + if (ids.size === 0) return null + const rows = history.filter((row) => row?.media_id && ids.has(row.media_id) && row.position_ms > 0) + const best = newestRow(rows) + if (!best) return null + return { + mediaId: best.media_id, + media: ownerOf(best.media_id, media, episodes) ?? media, + positionMs: best.position_ms, + durationMs: best.duration_ms, + completed: Boolean(best.completed), + } +} + +/** 是否该显示「继续播放」(与播放器真正会续播的条件保持一致)。 */ +export function canContinuePlaying(resume: MediaDetailResume | null): boolean { + if (!resume) return false + return !resume.completed && resume.positionMs > RESUME_MIN_POSITION_MS +} + +/** 12:34 / 1:02:03;非法输入返回空串。 */ +export function formatResumeClock(positionMs: number): string { + if (!Number.isFinite(positionMs) || positionMs <= 0) return '' + const totalSec = Math.floor(positionMs / 1000) + const hours = Math.floor(totalSec / 3600) + const minutes = Math.floor((totalSec % 3600) / 60) + const seconds = totalSec % 60 + const pad = (value: number) => String(value).padStart(2, '0') + return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}` +} + +/** 「继续播放 · 第 3 集 · 12:34」;电影没有集号时只带位置。 */ +export function continueButtonLabel(resume: MediaDetailResume): string { + const parts = ['继续播放'] + const episode = formatEpisodeLabel(resume.media) + if (episode) parts.push(episode) + const clock = formatResumeClock(resume.positionMs) + if (clock) parts.push(clock) + return parts.join(' · ') +} + +/** 每集(含版本)的续播进度,用于剧集卡片上的进度条。 */ +export function episodeProgressById( + episodes: Media[], + history: HistoryItem[], +): Map { + const map = new Map() + if (episodes.length === 0 || history.length === 0) return map + const byMediaID = new Map() + for (const row of history) { + if (row?.media_id && row.position_ms > 0) byMediaID.set(row.media_id, row) + } + for (const ep of episodes) { + const rows: HistoryItem[] = [] + for (const id of [ep.id, ...versionIDs(ep)]) { + const row = byMediaID.get(id) + if (row) rows.push(row) + } + const best = newestRow(rows) + if (!best) continue + map.set(ep.id, { + positionMs: best.position_ms, + durationMs: best.duration_ms, + completed: Boolean(best.completed), + }) + } + return map +} + +/** 进度条宽度(0-100)。时长未知时按「看过」显示一小段,避免整条空着。 */ +export function progressPercent(progress: EpisodeProgress): number { + if (progress.completed) return 100 + if (progress.durationMs > 0) { + const ratio = (progress.positionMs / progress.durationMs) * 100 + return Math.min(99, Math.max(2, ratio)) + } + return 5 +} diff --git a/web/src/pages/useMediaDetailPageState.ts b/web/src/pages/useMediaDetailPageState.ts index 6c2874a..7a008c7 100644 --- a/web/src/pages/useMediaDetailPageState.ts +++ b/web/src/pages/useMediaDetailPageState.ts @@ -3,13 +3,19 @@ import { useLocation, type NavigateFunction } from 'react-router-dom' import toast from 'react-hot-toast' import { api } from '../api/client' +import { historyAPI } from '../api/history' import { mediaAPI } from '../api/library' import { playbackAPI } from '../api/playback' import { confirmActionResult } from '../components/confirmAction' import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference' -import type { Media } from '../types' +import type { HistoryItem, Media } from '../types' import { seasonSortOrder } from '../utils/groupSeries' import { episodeNumberValue } from '../utils/episodeNumber' +import { + episodeProgressById, + resolveMediaDetailResume, + RESUME_SCAN_LIMIT, +} from './mediaDetailResumeModel' import { mediaLibraryBackTarget } from './MediaDetailPageModel' type MediaDetailLocationState = { from?: string } | null @@ -26,6 +32,7 @@ interface MediaDetailRefreshParams { setLoading: Dispatch> setEpisodes: Dispatch> setLoadingEpisodes: Dispatch> + setHistory: Dispatch> } interface MediaDetailActionsParams { @@ -43,6 +50,7 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa const [loading, setLoading] = useState(true) const [episodes, setEpisodes] = useState([]) const [loadingEpisodes, setLoadingEpisodes] = useState(false) + const [history, setHistory] = useState([]) const [selectedSeason, setSelectedSeason] = useState(null) const [manualScrapeOpen, setManualScrapeOpen] = useState(false) @@ -57,6 +65,7 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa setLoading, setEpisodes, setLoadingEpisodes, + setHistory, }) const location = useLocation() const backFrom = (location.state as MediaDetailLocationState)?.from @@ -108,6 +117,17 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa return (visibleEpisodes.length > 0 ? visibleEpisodes[0] : episodes[0]) || null }, [episodes, visibleEpisodes]) + // 「继续播放」:在「本条目 + 全部剧集(含版本)」里找最近看过的一条。 + // 详情页的代表条目可能是 OVA/特别篇,所以不能只看它自己的进度。 + const resume = useMemo( + () => resolveMediaDetailResume(media, episodes, history), + [episodes, history, media], + ) + const episodeProgress = useMemo( + () => episodeProgressById(episodes, history), + [episodes, history], + ) + const handleMetadataSaved = useCallback(async (next: Media) => { setMedia(next) await refresh() @@ -124,6 +144,8 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa visibleEpisodes, hasEpisodes, firstPlayableEpisode, + resume, + episodeProgress, setSelectedSeason, manualScrapeOpen, metadataEditOpen, @@ -146,20 +168,26 @@ function useMediaDetailRefresh({ setLoading, setEpisodes, setLoadingEpisodes, + setHistory, }: MediaDetailRefreshParams): () => Promise { return useCallback(async () => { if (!id) return setLoading(true) setLoadingEpisodes(true) try { - // 三个请求并行发出;详情一到就解锁整页渲染,收藏状态与分集列表 - // 到达后各自补齐(原先完全串行,首屏要排队等满三个往返)。 + // 四个请求并行发出;详情一到就解锁整页渲染,收藏、分集列表、续播历史 + // 到达后各自补齐(原先完全串行,首屏要排队等满所有往返)。 const nextMediaPromise = mediaAPI.get(id) const favouritePromise = playbackAPI.favouriteStatus(id).catch(() => false) const episodesPromise = mediaAPI .getEpisodes(id) .then((r) => r.items ?? []) .catch(() => []) + // 续播扫描只要历史行(include_media=false):服务端不 join media, + // 300 条也只有几十 KB。 + const historyPromise = historyAPI + .listRows(RESUME_SCAN_LIMIT) + .catch(() => [] as HistoryItem[]) const nextMedia = await nextMediaPromise setMedia(nextMedia) @@ -169,11 +197,12 @@ function useMediaDetailRefresh({ const episodes = await episodesPromise setEpisodes(episodes) + setHistory(await historyPromise) } finally { setLoading(false) setLoadingEpisodes(false) } - }, [id, setFavourite, setLoading, setMedia, setEpisodes, setLoadingEpisodes]) + }, [id, setFavourite, setHistory, setLoading, setMedia, setEpisodes, setLoadingEpisodes]) } function useMediaDetailActions({ diff --git a/web/src/pages/useSeriesEpisodeResume.ts b/web/src/pages/useSeriesEpisodeResume.ts new file mode 100644 index 0000000..fcd7855 --- /dev/null +++ b/web/src/pages/useSeriesEpisodeResume.ts @@ -0,0 +1,56 @@ +import { useEffect, useMemo, useState } from 'react' + +import { historyAPI } from '../api/history' +import type { HistoryItem, Media } from '../types' +import { + episodeProgressById, + resolveMediaDetailResume, + RESUME_SCAN_LIMIT, + type EpisodeProgress, + type MediaDetailResume, +} from './mediaDetailResumeModel' + +interface SeriesEpisodeResume { + resume: MediaDetailResume | null + progressById: Map +} + +/** + * 剧集面板(媒体库抽屉 / 媒体详情)用的续播数据。 + * + * 代表条目可能是 OVA/特别篇,因此续播目标必须在「整剧所有集」里挑最近看过 + * 的一条;打开抽屉时扫一次整库最近历史即可,只取历史行(include_media=false)。 + */ +export function useSeriesEpisodeResume( + seriesID: string, + rep: Media | null, + episodes: Media[], +): SeriesEpisodeResume { + const [history, setHistory] = useState([]) + + useEffect(() => { + if (!seriesID) { + setHistory([]) + return + } + let cancelled = false + historyAPI + .listRows(RESUME_SCAN_LIMIT) + .then((rows) => { + if (!cancelled) setHistory(rows) + }) + .catch(() => { + if (!cancelled) setHistory([]) + }) + return () => { + cancelled = true + } + }, [seriesID]) + + const resume = useMemo( + () => resolveMediaDetailResume(rep, episodes, history), + [episodes, history, rep], + ) + const progressById = useMemo(() => episodeProgressById(episodes, history), [episodes, history]) + return { resume, progressById } +}