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 }
+}