mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 065652d758 | |||
| e273b5e87d | |||
| c9c612df1b |
@@ -0,0 +1,36 @@
|
||||
# Segment Coverage Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** IntroDB prewarm + same-season intro propagation + shorter miss TTL + playback-only duration probe (option C).
|
||||
|
||||
**Architecture:** Extend `MediaSegmentService` for merge/propagate/prewarm; add scheduler job `segment_prewarm`; repository queries for candidates and season siblings.
|
||||
|
||||
**Tech Stack:** Go, GORM/SQLite, existing SchedulerService + IntroDB client.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Negative cache TTL + ledgerFresh tests
|
||||
|
||||
**Files:** `internal/service/media_segment.go`, `internal/service/media_segment_test.go`
|
||||
|
||||
- Change `segmentMissingTTL` to 24h; update tests for fresh/stale miss.
|
||||
|
||||
### Task 2: Playback merge + propagation
|
||||
|
||||
**Files:** `media_segment.go`, `media_segment_repository.go`, `media_repository.go`, tests
|
||||
|
||||
- Constant `PropagatedSource = "propagated"`.
|
||||
- `ListForPlayback` merges sources with priority manual > theintrodb > propagated.
|
||||
- After successful IntroDB refresh with intro, propagate to same-season siblings (shared `tm_db_id` or `series_id`).
|
||||
|
||||
### Task 3: Prewarm API + scheduler job
|
||||
|
||||
**Files:** `media_segment.go`, `media_segment_repository.go`, `scheduler.go`, `scheduler_segment_jobs.go`, `service_builder.go`, tests
|
||||
|
||||
- `Prewarm(ctx, limit)` rate-limited refreshes.
|
||||
- Job `segment_prewarm` every 6h.
|
||||
|
||||
### Task 4: Verify
|
||||
|
||||
- `go test` for affected packages.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Segment Coverage Improvements (1–4, option C)
|
||||
|
||||
## Goal
|
||||
|
||||
Raise intro/outro skip availability without Chromaprint or full-library STRM probing.
|
||||
|
||||
## Scope (approved)
|
||||
|
||||
1. **IntroDB prewarm** — background job for all queryable media (TMDb + season/episode).
|
||||
2. **Duration** — playback-path only (no full STRM scan); keep `ListForPlayback` → `EnsureAsync`.
|
||||
3. **Same-season intro propagation** — copy intro to sibling episodes after a hit.
|
||||
4. **Negative cache** — miss TTL 24h; 403/429/timeout must not write miss ledger.
|
||||
|
||||
## Non-goals
|
||||
|
||||
Chromaprint, manual segment UI, library-name filters, batch STRM duration scan.
|
||||
|
||||
## Behavior
|
||||
|
||||
### Prewarm (`segment_prewarm`)
|
||||
|
||||
- Scheduler job, interval 6h, initial delay 6h (avoid restart spike).
|
||||
- **Opt-in via `segment.prewarm_enabled` (default off)** — system settings toggle「后台预热 IntroDB 片头片段」.
|
||||
- Candidates: `tm_db_id > 0`, season/episode or movie, ledger missing or stale.
|
||||
- Order: recently played first, then others.
|
||||
- Rate: ~1 req/s, stop on context cancel; respect IntroDB 429 retry already in client.
|
||||
- Reuse `MediaSegmentService` refresh + propagation.
|
||||
- Manual `RunNow` can bypass the toggle (same pattern as organize).
|
||||
|
||||
### Propagation
|
||||
|
||||
- After TheIntroDB refresh finds at least one `intro` span, write the same intro window to same-season siblings that lack a `theintrodb` intro.
|
||||
- Source tag: `propagated`.
|
||||
- Do not propagate credits/preview/recap.
|
||||
- Playback merge priority per kind: `manual` > `theintrodb` > `propagated`.
|
||||
|
||||
### Negative cache
|
||||
|
||||
- `segmentMissingTTL`: 24h (was 7d).
|
||||
- `segmentFoundTTL`: 30d (unchanged).
|
||||
- Provider errors (non-404) continue to skip ledger writes.
|
||||
|
||||
### Duration (C)
|
||||
|
||||
- No new batch probe job.
|
||||
- Existing `defer ensureMediaProbe` on playback remains the only STRM duration path.
|
||||
|
||||
## Success criteria
|
||||
|
||||
- Prewarm increases `media_segment_fetches` over time without blocking play.
|
||||
- One IntroDB intro hit can surface skip on sibling episodes via `propagated`.
|
||||
- Misses are retried within ~24h (sooner if recently played and prewarm runs).
|
||||
@@ -112,8 +112,7 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var cfg struct {
|
||||
MergeSources bool `json:"merge_sources"`
|
||||
PlaybackRate float64 `json:"playback_rate"`
|
||||
MergeSources bool `json:"merge_sources"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
@@ -121,9 +120,6 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
|
||||
if cfg.MergeSources {
|
||||
t.Fatal("default merge preference should be false")
|
||||
}
|
||||
if cfg.PlaybackRate != 1 {
|
||||
t.Fatalf("default playback rate = %v, want 1", cfg.PlaybackRate)
|
||||
}
|
||||
|
||||
if err := svc.Danmaku.SetMergeSources(t.Context(), "user-1", true); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
@@ -141,7 +137,7 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
|
||||
func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
body := "{\"enabled\":false,\"opacity\":0.6,\"font_size\":32,\"area\":0.7,\"merge_sources\":true,\"volume\":0.35,\"playback_rate\":1.5,\"source\":\"https://dm.example/base/\",\"app_id\":\"my-app-id\",\"app_key\":\"my-app-secret\"}"
|
||||
body := "{\"enabled\":false,\"opacity\":0.6,\"font_size\":32,\"area\":0.7,\"merge_sources\":true,\"volume\":0.35,\"source\":\"https://dm.example/base/\",\"app_id\":\"my-app-id\",\"app_key\":\"my-app-secret\"}"
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", body, "user-1")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
|
||||
@@ -158,7 +154,7 @@ func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
|
||||
if cfg.Enabled || cfg.Opacity != "0.6" || cfg.FontSize != "32" || cfg.Area != "0.7" {
|
||||
t.Fatalf("unexpected render config: %+v", cfg)
|
||||
}
|
||||
if !cfg.MergeSources || cfg.Volume != 0.35 || cfg.PlaybackRate != 1.5 {
|
||||
if !cfg.MergeSources || cfg.Volume != 0.35 {
|
||||
t.Fatalf("unexpected user preferences: %+v", cfg)
|
||||
}
|
||||
if cfg.Source != "https://dm.example/base" || cfg.AppID != "my-app-id" || !cfg.AppKeyConfigured {
|
||||
@@ -169,7 +165,7 @@ func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("read persisted user: %v", err)
|
||||
}
|
||||
if user.DanmakuAppKey != "my-app-secret" || user.PlayerVolume != 0.35 || user.PlayerPlaybackRate != 1.5 || user.DanmakuSource != "https://dm.example/base" {
|
||||
if user.DanmakuAppKey != "my-app-secret" || user.PlayerVolume != 0.35 || user.DanmakuSource != "https://dm.example/base" {
|
||||
t.Fatalf("preferences not persisted: %+v", user)
|
||||
}
|
||||
}
|
||||
@@ -207,22 +203,36 @@ func TestUpdateDanmakuSettingsPersistsVr360GuideSeen(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsRejectsInvalidPlaybackRate(t *testing.T) {
|
||||
// 播放倍速只作用于当前视频:接口既不下发也不保存它。老客户端仍可能带上
|
||||
// playback_rate,服务端应当忽略而不是报错。
|
||||
func TestDanmakuSettingsIgnorePlaybackRate(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||
`{"playback_rate":4}`, "user-1")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodGet, "/danmaku/config", "", "user-1")
|
||||
getDanmakuConfigHandler(svc)(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if bytes.Contains(w.Body.Bytes(), []byte("playback_rate")) {
|
||||
t.Fatalf("config must not expose playback rate: %s", w.Body.String())
|
||||
}
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 (body=%s)", w.Code, w.Body.String())
|
||||
c2, w2 := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||
`{"volume":0.5,"playback_rate":1.5}`, "user-1")
|
||||
updateDanmakuSettingsHandler(svc)(c2)
|
||||
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w2.Code, w2.Body.String())
|
||||
}
|
||||
if bytes.Contains(w2.Body.Bytes(), []byte("playback_rate")) {
|
||||
t.Fatalf("settings response must not expose playback rate: %s", w2.Body.String())
|
||||
}
|
||||
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("read user: %v", err)
|
||||
}
|
||||
if user.PlayerPlaybackRate != 1 {
|
||||
t.Fatalf("invalid playback rate was persisted: %v", user.PlayerPlaybackRate)
|
||||
if user.PlayerVolume != 0.5 {
|
||||
t.Fatalf("volume not persisted: %v", user.PlayerVolume)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -105,8 +105,8 @@ func TestEmbyNextUpReturnsNextEpisode(t *testing.T) {
|
||||
}
|
||||
if watched {
|
||||
h := &model.PlaybackHistory{
|
||||
UserID: userID, MediaID: m.ID, PositionMs: 1000, DurationMs: 2000,
|
||||
WatchedAt: watchedAt, Completed: false,
|
||||
UserID: userID, MediaID: m.ID, PositionMs: 2000, DurationMs: 2000,
|
||||
WatchedAt: watchedAt, Completed: true, // 第 1 集已看完
|
||||
}
|
||||
if err := svc.Repo.DB.Create(h).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -127,6 +127,49 @@ func TestEmbyNextUpReturnsNextEpisode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 回归:用户在剧集详情页点播放、只看了几秒就退出(历史行 completed=false)后,
|
||||
// Yamby 再次进入详情页带的 NextUp 仍要指向那一集本身,否则「继续播放」会跳到下一集。
|
||||
func TestEmbyNextUpKeepsPartiallyWatchedEpisode(t *testing.T) {
|
||||
router, svc, userID := newEmbyDiscoveryEnv(t)
|
||||
libID := seedEmbyLibrary(t, svc, "tv")
|
||||
|
||||
episodeIDs := map[int]string{}
|
||||
for ep := 1; ep <= 3; ep++ {
|
||||
m := &model.Media{
|
||||
LibraryID: libID, SeriesID: "series-1", Title: "剧一",
|
||||
SeasonNum: 1, EpisodeNum: ep,
|
||||
Path: "/media/tv/S1E" + strconv.Itoa(ep) + ".mkv",
|
||||
}
|
||||
if err := svc.Repo.DB.Create(m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
episodeIDs[ep] = m.ID
|
||||
}
|
||||
|
||||
// 第 2 集播放了 3 秒后退出:有进度、未标记看完。
|
||||
if err := svc.Repo.DB.Create(&model.PlaybackHistory{
|
||||
UserID: userID, MediaID: episodeIDs[2], PositionMs: 3582, DurationMs: 1440064,
|
||||
WatchedAt: time.Now(), Completed: false,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := embyGet(t, router, "/emby/Shows/NextUp?SeriesId=series-1&Limit=1", 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 id, _ := items[0]["Id"].(string); id != episodeIDs[2] {
|
||||
t.Fatalf("Id = %q, want the partially watched episode %q (body=%s)", id, episodeIDs[2], 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)
|
||||
@@ -267,8 +310,8 @@ func TestEmbyNextUpFiltersBySeriesID(t *testing.T) {
|
||||
case 1:
|
||||
watchedID = m.ID
|
||||
h := &model.PlaybackHistory{
|
||||
UserID: userID, MediaID: m.ID, PositionMs: 1000, DurationMs: 2000,
|
||||
WatchedAt: watchedAt, Completed: false,
|
||||
UserID: userID, MediaID: m.ID, PositionMs: 2000, DurationMs: 2000,
|
||||
WatchedAt: watchedAt, Completed: true, // 第 1 集已看完,下一集是 S1E2
|
||||
}
|
||||
if err := svc.Repo.DB.Create(h).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -37,8 +37,8 @@ type User struct {
|
||||
// original / simplified / traditional。
|
||||
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
|
||||
// 网页播放器偏好按用户存储,切换媒体对象后继续沿用。
|
||||
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
|
||||
PlayerPlaybackRate float64 `gorm:"not null;default:1" json:"player_playback_rate"`
|
||||
// 播放倍速不在此列:它只作用于当前视频,不进数据库。
|
||||
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
|
||||
// PlayerVr360GuideSeen 记录用户是否已经看过 VR 全景播放的首次操作说明,
|
||||
// 按用户保存:看过一次之后不再弹出。
|
||||
PlayerVr360GuideSeen bool `gorm:"not null;default:false" json:"player_vr360_guide_seen"`
|
||||
|
||||
@@ -232,6 +232,31 @@ func (r *MediaRepository) ExistsSiblingWithTMDbID(ctx context.Context, m *model.
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// ListSeasonSiblings returns other episodes in the same season as m.
|
||||
// Prefers series_id; falls back to shared library+title+tm_db_id (anime scrape path).
|
||||
func (r *MediaRepository) ListSeasonSiblings(ctx context.Context, m *model.Media) ([]model.Media, error) {
|
||||
if r == nil || m == nil || m.SeasonNum <= 0 || m.ID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
query := r.db.WithContext(ctx).Model(&model.Media{}).
|
||||
Where("season_num = ? AND id <> ?", m.SeasonNum, m.ID)
|
||||
if seriesID := strings.TrimSpace(m.SeriesID); seriesID != "" {
|
||||
query = query.Where("series_id = ?", seriesID)
|
||||
} else {
|
||||
libraryID := strings.TrimSpace(m.LibraryID)
|
||||
title := strings.TrimSpace(m.Title)
|
||||
if libraryID == "" || title == "" || m.TMDbID <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
query = query.Where("library_id = ? AND title = ? AND tm_db_id = ?", libraryID, title, m.TMDbID)
|
||||
}
|
||||
var rows []model.Media
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ListByLibrary returns paginated media items for a library.
|
||||
func (r *MediaRepository) ListByLibrary(ctx context.Context, libraryID string, offset, limit int) ([]model.Media, int64, error) {
|
||||
return r.ListByLibraryFiltered(ctx, libraryID, offset, limit, MediaQueryFilter{IncludeNSFW: true})
|
||||
|
||||
@@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
@@ -84,3 +85,45 @@ func (r *MediaSegmentRepository) UpsertFetch(ctx context.Context, row *model.Med
|
||||
}
|
||||
return r.db.WithContext(ctx).Clauses(onConflict).Create(row).Error
|
||||
}
|
||||
|
||||
// ListPrewarmCandidates returns media that should be refreshed from source:
|
||||
// no ledger, expired miss (fetched_at < missBefore), or expired hit (fetched_at < hitBefore).
|
||||
// Recently played items come first so hot titles recover coverage sooner.
|
||||
func (r *MediaSegmentRepository) ListPrewarmCandidates(
|
||||
ctx context.Context,
|
||||
source string,
|
||||
missBefore, hitBefore time.Time,
|
||||
limit int,
|
||||
) ([]model.Media, error) {
|
||||
if r == nil || limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows := make([]model.Media, 0, limit)
|
||||
// 可查询:有 TMDb,且是剧集(有季集)或电影(无季集)。
|
||||
err := r.db.WithContext(ctx).Raw(`
|
||||
SELECT m.*
|
||||
FROM media m
|
||||
LEFT JOIN media_segment_fetches f
|
||||
ON f.media_id = m.id AND f.source = ? AND f.deleted_at IS NULL
|
||||
LEFT JOIN (
|
||||
SELECT media_id, MAX(updated_at) AS last_played
|
||||
FROM playback_histories
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY media_id
|
||||
) ph ON ph.media_id = m.id
|
||||
WHERE m.deleted_at IS NULL
|
||||
AND m.tm_db_id > 0
|
||||
AND (
|
||||
(m.season_num > 0 AND m.episode_num > 0)
|
||||
OR (COALESCE(m.season_num, 0) = 0 AND COALESCE(m.episode_num, 0) = 0)
|
||||
)
|
||||
AND (
|
||||
f.id IS NULL
|
||||
OR (f.found = 0 AND f.fetched_at < ?)
|
||||
OR (f.found = 1 AND f.fetched_at < ?)
|
||||
)
|
||||
ORDER BY ph.last_played DESC
|
||||
LIMIT ?
|
||||
`, source, missBefore, hitBefore, limit).Scan(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
@@ -152,3 +152,56 @@ func TestUpsertFetchKeepsOneRowPerMediaAndSource(t *testing.T) {
|
||||
t.Fatalf("ledger should be updated in place, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSeasonSiblingsSharesLibraryTitleTMDb(t *testing.T) {
|
||||
repos := newSegmentTestRepos(t)
|
||||
ctx := t.Context()
|
||||
eps := []*model.Media{
|
||||
{Base: model.Base{ID: "a"}, LibraryID: "lib", Title: "Show", Path: "/a", SeasonNum: 1, EpisodeNum: 1, TMDbID: 99},
|
||||
{Base: model.Base{ID: "b"}, LibraryID: "lib", Title: "Show", Path: "/b", SeasonNum: 1, EpisodeNum: 2, TMDbID: 99},
|
||||
{Base: model.Base{ID: "c"}, LibraryID: "lib", Title: "Show", Path: "/c", SeasonNum: 2, EpisodeNum: 1, TMDbID: 99},
|
||||
}
|
||||
for _, ep := range eps {
|
||||
if err := repos.DB.Create(ep).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := repos.Media.ListSeasonSiblings(ctx, eps[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != "b" {
|
||||
t.Fatalf("siblings = %#v, want only same-season ep b", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPrewarmCandidatesOrdersRecentPlaysFirst(t *testing.T) {
|
||||
repos := newSegmentTestRepos(t)
|
||||
ctx := t.Context()
|
||||
now := time.Now()
|
||||
for _, m := range []*model.Media{
|
||||
{Base: model.Base{ID: "cold"}, Path: "/cold", TMDbID: 1},
|
||||
{Base: model.Base{ID: "hot"}, Path: "/hot", TMDbID: 2},
|
||||
} {
|
||||
if err := repos.DB.Create(m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := repos.DB.Create(&model.PlaybackHistory{
|
||||
Base: model.Base{ID: "ph1"}, UserID: "u", MediaID: "hot",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := repos.MediaSegment.ListPrewarmCandidates(
|
||||
ctx, "theintrodb", now.Add(-time.Hour), now.Add(-time.Hour), 10,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) < 2 {
|
||||
t.Fatalf("candidates = %#v, want both", got)
|
||||
}
|
||||
if got[0].ID != "hot" {
|
||||
t.Fatalf("first = %s, want hot (recently played)", got[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,12 +49,6 @@ const (
|
||||
// dandanplay protocol (search/episodes + comment/{episodeId}) may be used.
|
||||
const DanmakuDefaultSource = "https://api.dandanplay.net"
|
||||
|
||||
const (
|
||||
minPlayerPlaybackRate = 0.5
|
||||
maxPlayerPlaybackRate = 3.0
|
||||
defaultPlayerPlaybackRate = 1.0
|
||||
)
|
||||
|
||||
// danmakuOfficialBase is where identification (/api/v2/match) and the
|
||||
// comment/search fallback always go, regardless of the configured source.
|
||||
// A package var (not a const) so tests can point it at a local server.
|
||||
@@ -69,8 +63,6 @@ type DanmakuRenderConfig struct {
|
||||
FontSize string `json:"font_size"`
|
||||
Area string `json:"area"`
|
||||
Volume float64 `json:"volume"`
|
||||
// PlaybackRate 是当前用户的播放倍速偏好(按用户存储)。
|
||||
PlaybackRate float64 `json:"playback_rate"`
|
||||
// Vr360GuideSeen 表示当前用户是否已经看过 VR 全景播放的首次操作说明。
|
||||
Vr360GuideSeen bool `json:"vr360_guide_seen"`
|
||||
// MergeSources 是当前用户的弹幕合并偏好(按用户存储)。
|
||||
@@ -99,7 +91,6 @@ type DanmakuSettingsPatch struct {
|
||||
Area *float64 `json:"area"`
|
||||
MergeSources *bool `json:"merge_sources"`
|
||||
Volume *float64 `json:"volume"`
|
||||
PlaybackRate *float64 `json:"playback_rate"`
|
||||
// Vr360GuideSeen 标记 VR 全景播放的首次操作说明是否已经看过。
|
||||
Vr360GuideSeen *bool `json:"vr360_guide_seen"`
|
||||
}
|
||||
@@ -358,11 +349,10 @@ func cloneDanmakuAnimeList(in []DanmakuAnime) []DanmakuAnime {
|
||||
// Config reads danmaku settings from the runtime settings table.
|
||||
func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
|
||||
cfg := DanmakuRenderConfig{
|
||||
Opacity: "1",
|
||||
FontSize: "24",
|
||||
Area: "1",
|
||||
Volume: 1,
|
||||
PlaybackRate: defaultPlayerPlaybackRate,
|
||||
Opacity: "1",
|
||||
FontSize: "24",
|
||||
Area: "1",
|
||||
Volume: 1,
|
||||
}
|
||||
if s == nil || s.repo == nil || s.repo.Setting == nil {
|
||||
return cfg
|
||||
@@ -414,12 +404,11 @@ func (s *DanmakuService) findUser(ctx context.Context, userID string) (*model.Us
|
||||
func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
|
||||
if user == nil {
|
||||
return DanmakuRenderConfig{
|
||||
Enabled: true,
|
||||
Opacity: "1",
|
||||
FontSize: "24",
|
||||
Area: "1",
|
||||
Volume: 1,
|
||||
PlaybackRate: defaultPlayerPlaybackRate,
|
||||
Enabled: true,
|
||||
Opacity: "1",
|
||||
FontSize: "24",
|
||||
Area: "1",
|
||||
Volume: 1,
|
||||
}
|
||||
}
|
||||
opacity := user.DanmakuOpacity
|
||||
@@ -438,10 +427,6 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
|
||||
if volume < 0 || volume > 1 {
|
||||
volume = 1
|
||||
}
|
||||
playbackRate := user.PlayerPlaybackRate
|
||||
if playbackRate < minPlayerPlaybackRate || playbackRate > maxPlayerPlaybackRate {
|
||||
playbackRate = defaultPlayerPlaybackRate
|
||||
}
|
||||
return DanmakuRenderConfig{
|
||||
Enabled: user.DanmakuEnabled,
|
||||
Source: strings.TrimSpace(user.DanmakuSource),
|
||||
@@ -450,7 +435,6 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
|
||||
FontSize: strconv.Itoa(fontSize),
|
||||
Area: strconv.FormatFloat(area, 'f', -1, 64),
|
||||
Volume: volume,
|
||||
PlaybackRate: playbackRate,
|
||||
Vr360GuideSeen: user.PlayerVr360GuideSeen,
|
||||
MergeSources: user.DanmakuMergeSources,
|
||||
AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "",
|
||||
@@ -522,17 +506,6 @@ func (s *DanmakuService) UpdateUserSettings(ctx context.Context, userID string,
|
||||
}
|
||||
updates["player_volume"] = *patch.Volume
|
||||
}
|
||||
if patch.PlaybackRate != nil {
|
||||
if *patch.PlaybackRate < minPlayerPlaybackRate || *patch.PlaybackRate > maxPlayerPlaybackRate {
|
||||
return DanmakuRenderConfig{}, fmt.Errorf(
|
||||
"%w: playback_rate must be between %.2f and %.2f",
|
||||
ErrInvalidDanmakuSettings,
|
||||
minPlayerPlaybackRate,
|
||||
maxPlayerPlaybackRate,
|
||||
)
|
||||
}
|
||||
updates["player_playback_rate"] = *patch.PlaybackRate
|
||||
}
|
||||
if patch.Vr360GuideSeen != nil {
|
||||
updates["player_vr360_guide_seen"] = *patch.Vr360GuideSeen
|
||||
}
|
||||
|
||||
@@ -422,7 +422,6 @@ func TestDanmakuFetchUsesPerUserSourceAndCredentials(t *testing.T) {
|
||||
user.DanmakuFontSize = 30
|
||||
user.DanmakuArea = 0.7
|
||||
user.PlayerVolume = 0.42
|
||||
user.PlayerPlaybackRate = 1.25
|
||||
require.NoError(t, svc.repo.User.Create(ctx, &user))
|
||||
seedDanmakuMedia(t, svc, "per-user-media", "测试动画", "", 0)
|
||||
|
||||
@@ -431,7 +430,6 @@ func TestDanmakuFetchUsesPerUserSourceAndCredentials(t *testing.T) {
|
||||
require.Contains(t, res.Raw, "弹幕A")
|
||||
require.Equal(t, srv.URL(), res.Source)
|
||||
require.Equal(t, 0.42, res.Volume)
|
||||
require.Equal(t, 1.25, res.PlaybackRate)
|
||||
require.Equal(t, "0.65", res.Opacity)
|
||||
require.Equal(t, "30", res.FontSize)
|
||||
require.Equal(t, srv.lastHeaders.Get("X-AppId"), "user-app-id")
|
||||
|
||||
@@ -143,9 +143,11 @@ func (e *EmbyService) nextUpForRemoteSeries(ctx context.Context, userID, seriesI
|
||||
}
|
||||
|
||||
var current *model.Media
|
||||
currentCompleted := false
|
||||
for i := range hist {
|
||||
if m := epByID[hist[i].MediaID]; m != nil {
|
||||
current = m
|
||||
currentCompleted = hist[i].Completed
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -167,7 +169,7 @@ func (e *EmbyService) nextUpForRemoteSeries(ctx context.Context, userID, seriesI
|
||||
}
|
||||
}
|
||||
|
||||
next, ok := pickNextEpisode(episodes, current, completed)
|
||||
next, ok := pickNextEpisode(episodes, current, currentCompleted, completed)
|
||||
if !ok {
|
||||
return emptyItemsEnvelope(0), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// 回归:挂载的远程 Emby 剧集里,只看了几秒就退出的那一集必须仍是 NextUp 的结果。
|
||||
// 客户端(Yamby 等)剧集详情页的「继续播放」直接取 NextUp 第一条,跳集会播错集。
|
||||
func TestMountedRemoteNextUpKeepsPartiallyWatchedEpisode(t *testing.T) {
|
||||
episode := func(id string, index int) map[string]any {
|
||||
return map[string]any{
|
||||
"Id": id,
|
||||
"Name": "第" + strconv.Itoa(index) + "集",
|
||||
"Type": "Episode",
|
||||
"SeriesId": "series-100",
|
||||
"ParentIndexNumber": 1,
|
||||
"IndexNumber": index,
|
||||
"RunTimeTicks": 14400640000,
|
||||
}
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/emby/Users/uid-1/Items/series-100":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"Id": "series-100", "Name": "剧一", "Type": "Series"})
|
||||
case "/emby/Users/uid-1/Items/ep-2":
|
||||
_ = json.NewEncoder(w).Encode(episode("ep-2", 2))
|
||||
case "/emby/Users/uid-1/Items":
|
||||
q := r.URL.Query()
|
||||
if q.Get("IncludeItemTypes") != "Episode" || q.Get("ParentId") != "series-100" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"TotalRecordCount": 3,
|
||||
"Items": []map[string]any{
|
||||
episode("ep-1", 1),
|
||||
episode("ep-2", 2),
|
||||
episode("ep-3", 3),
|
||||
},
|
||||
})
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{}, &model.PlaybackHistory{}, &model.User{})
|
||||
repos := repository.New(db)
|
||||
|
||||
cfg := &config.Config{}
|
||||
remote := NewEmbyRemoteService(cfg, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||
svc := NewEmbyService(cfg, zap.NewNop(), repos).SetEmbyRemote(remote)
|
||||
|
||||
rawConfig, _ := json.Marshal(map[string]string{
|
||||
"url": server.URL,
|
||||
"api_key": "test-api-key",
|
||||
"remote_user_id": "uid-1",
|
||||
})
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-1"},
|
||||
Name: "远程 Emby",
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig),
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
mount := &model.EmbyMount{
|
||||
Base: model.Base{ID: "mount-1"},
|
||||
AccountID: acct.ID,
|
||||
RemoteViewID: "view-1",
|
||||
RemoteViewName: "新番连载",
|
||||
CollectionType: "tvshows",
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
|
||||
t.Fatalf("create mount: %v", err)
|
||||
}
|
||||
user := &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "viewer",
|
||||
PasswordHash: "x",
|
||||
Role: "user",
|
||||
Tier: "free",
|
||||
IsActive: true,
|
||||
}
|
||||
if err := repos.User.Create(t.Context(), user); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
// 第 2 集只播了 3.5 秒就退出:有进度、未标记看完。
|
||||
if err := repos.DB.Create(&model.PlaybackHistory{
|
||||
UserID: user.ID,
|
||||
MediaID: EncodeEmbyRemoteID(mount.ID, "ep-2"),
|
||||
PositionMs: 3582,
|
||||
DurationMs: 1440064,
|
||||
WatchedAt: time.Now(),
|
||||
Completed: false,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create history: %v", err)
|
||||
}
|
||||
|
||||
envelope, err := svc.NextUp(t.Context(), user.ID, EncodeEmbyRemoteID(mount.ID, "series-100"), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("NextUp: %v", err)
|
||||
}
|
||||
items, _ := envelope["Items"].([]map[string]any)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items = %d, want 1 (%#v)", len(items), envelope)
|
||||
}
|
||||
wantID := EncodeEmbyRemoteID(mount.ID, "ep-2")
|
||||
if id, _ := items[0]["Id"].(string); id != wantID {
|
||||
t.Fatalf("Id = %q, want %q (未看完的那一集不能被跳过)", id, wantID)
|
||||
}
|
||||
userData, _ := items[0]["UserData"].(map[string]any)
|
||||
if ticks, _ := userData["PlaybackPositionTicks"].(int64); ticks != 35820000 {
|
||||
t.Fatalf("PlaybackPositionTicks = %#v, want 35820000 (详情页要能续播到 3.5 秒)", userData["PlaybackPositionTicks"])
|
||||
}
|
||||
}
|
||||
|
||||
// 远程剧集已看完当前一集时,NextUp 仍要指向下一集。
|
||||
func TestMountedRemoteNextUpAfterCompletedEpisode(t *testing.T) {
|
||||
episode := func(id string, index int) map[string]any {
|
||||
return map[string]any{
|
||||
"Id": id,
|
||||
"Name": "第" + strconv.Itoa(index) + "集",
|
||||
"Type": "Episode",
|
||||
"SeriesId": "series-100",
|
||||
"ParentIndexNumber": 1,
|
||||
"IndexNumber": index,
|
||||
"RunTimeTicks": 14400640000,
|
||||
}
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/emby/Users/uid-1/Items/series-100":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"Id": "series-100", "Name": "剧一", "Type": "Series"})
|
||||
case "/emby/Users/uid-1/Items/ep-3":
|
||||
_ = json.NewEncoder(w).Encode(episode("ep-3", 3))
|
||||
case "/emby/Users/uid-1/Items":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"TotalRecordCount": 3,
|
||||
"Items": []map[string]any{
|
||||
episode("ep-1", 1),
|
||||
episode("ep-2", 2),
|
||||
episode("ep-3", 3),
|
||||
},
|
||||
})
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{}, &model.PlaybackHistory{}, &model.User{})
|
||||
repos := repository.New(db)
|
||||
|
||||
cfg := &config.Config{}
|
||||
remote := NewEmbyRemoteService(cfg, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||
svc := NewEmbyService(cfg, zap.NewNop(), repos).SetEmbyRemote(remote)
|
||||
|
||||
rawConfig, _ := json.Marshal(map[string]string{
|
||||
"url": server.URL,
|
||||
"api_key": "test-api-key",
|
||||
"remote_user_id": "uid-1",
|
||||
})
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-1"},
|
||||
Name: "远程 Emby",
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig),
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
mount := &model.EmbyMount{
|
||||
Base: model.Base{ID: "mount-1"},
|
||||
AccountID: acct.ID,
|
||||
RemoteViewID: "view-1",
|
||||
RemoteViewName: "新番连载",
|
||||
CollectionType: "tvshows",
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
|
||||
t.Fatalf("create mount: %v", err)
|
||||
}
|
||||
user := &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "viewer",
|
||||
PasswordHash: "x",
|
||||
Role: "user",
|
||||
Tier: "free",
|
||||
IsActive: true,
|
||||
}
|
||||
if err := repos.User.Create(t.Context(), user); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
// 第 2 集已整集看完。
|
||||
if err := repos.DB.Create(&model.PlaybackHistory{
|
||||
UserID: user.ID,
|
||||
MediaID: EncodeEmbyRemoteID(mount.ID, "ep-2"),
|
||||
PositionMs: 1440064,
|
||||
DurationMs: 1440064,
|
||||
WatchedAt: time.Now(),
|
||||
Completed: true,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create history: %v", err)
|
||||
}
|
||||
|
||||
envelope, err := svc.NextUp(context.Background(), user.ID, EncodeEmbyRemoteID(mount.ID, "series-100"), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("NextUp: %v", err)
|
||||
}
|
||||
items, _ := envelope["Items"].([]map[string]any)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items = %d, want 1 (%#v)", len(items), envelope)
|
||||
}
|
||||
if id, _ := items[0]["Id"].(string); id != EncodeEmbyRemoteID(mount.ID, "ep-3") {
|
||||
t.Fatalf("Id = %q, want ep-3", id)
|
||||
}
|
||||
}
|
||||
@@ -212,10 +212,11 @@ func (s *MediaDiscoveryService) NextUpCandidates(ctx context.Context, userID str
|
||||
byID[watchedRows[i].ID] = &watchedRows[i]
|
||||
}
|
||||
|
||||
// 按最近观看顺序归并到「剧」维度,同时记住该剧最近的已看集。
|
||||
// 按最近观看顺序归并到「剧」维度,同时记住该剧最近看的那一集以及它是否看完。
|
||||
type seriesState struct {
|
||||
key string
|
||||
current *model.Media
|
||||
key string
|
||||
current *model.Media
|
||||
completed bool
|
||||
}
|
||||
states := make([]seriesState, 0, len(histories))
|
||||
seen := make(map[string]bool, len(histories))
|
||||
@@ -229,7 +230,7 @@ func (s *MediaDiscoveryService) NextUpCandidates(ctx context.Context, userID str
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
states = append(states, seriesState{key: key, current: m})
|
||||
states = append(states, seriesState{key: key, current: m, completed: h.Completed})
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return nil, nil
|
||||
@@ -293,7 +294,7 @@ func (s *MediaDiscoveryService) NextUpCandidates(ctx context.Context, userID str
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
next, ok := pickNextEpisode(bySeries[st.key], st.current, completed)
|
||||
next, ok := pickNextEpisode(bySeries[st.key], st.current, st.completed, completed)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@@ -341,16 +342,27 @@ func (s *MediaDiscoveryService) completedMediaIDs(ctx context.Context, userID st
|
||||
return out
|
||||
}
|
||||
|
||||
// pickNextEpisode 在候选集中选出严格晚于 current 的、编号最小的一集。
|
||||
// 比较顺序为 (季, 集),因此跨季时自然落到下一季第一集。
|
||||
func pickNextEpisode(candidates []model.Media, current *model.Media, completed map[string]bool) (model.Media, bool) {
|
||||
// pickNextEpisode 选出这部剧「接下来该看的那一集」。
|
||||
//
|
||||
// anchor 是这部剧最近一次播放的那一集,anchorCompleted 表示那一集是否已看完:
|
||||
// - 没看完(只播了几秒就退出、或中途暂停)时,接下来该看的仍是这一集本身。
|
||||
// 否则详情页的「继续播放」会直接跳到下一集,用户刚看的那一集被静默跳过。
|
||||
// - 已看完时,才在候选集里取严格晚于它的、编号最小的一集;比较顺序为
|
||||
// (季, 集),因此跨季时自然落到下一季第一集。
|
||||
func pickNextEpisode(candidates []model.Media, anchor *model.Media, anchorCompleted bool, completed map[string]bool) (model.Media, bool) {
|
||||
if anchor == nil {
|
||||
return model.Media{}, false
|
||||
}
|
||||
if !anchorCompleted {
|
||||
return *anchor, true
|
||||
}
|
||||
var best model.Media
|
||||
found := false
|
||||
for _, candidate := range candidates {
|
||||
if candidate.ID == current.ID || completed[candidate.ID] {
|
||||
if candidate.ID == anchor.ID || completed[candidate.ID] {
|
||||
continue
|
||||
}
|
||||
if !episodeAfter(candidate, *current) {
|
||||
if !episodeAfter(candidate, *anchor) {
|
||||
continue
|
||||
}
|
||||
if !found || episodeBefore(candidate, best) {
|
||||
|
||||
@@ -13,6 +13,9 @@ import (
|
||||
)
|
||||
|
||||
// seedEpisode 插入一集,并把播放历史指向 `watched`(nil 表示没有历史)。
|
||||
//
|
||||
// position_ms / duration_ms 只是占位值,NextUp 只看历史行的 completed 字段:
|
||||
// 未看完的那一集本身就是「接下来该看的一集」。
|
||||
func seedEpisode(
|
||||
t *testing.T,
|
||||
repos *repository.Container,
|
||||
@@ -78,7 +81,7 @@ func TestNextUpPicksNextEpisode(t *testing.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, 1, &watchedAt, true) // 第 1 集已看完
|
||||
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 2, nil, false)
|
||||
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 3, nil, false)
|
||||
|
||||
@@ -89,6 +92,40 @@ func TestNextUpPicksNextEpisode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 回归:只播了几秒就退出(未看完)时,「接下来该看的一集」仍是这一集本身。
|
||||
// 客户端(Yamby 等)剧集详情页的「继续播放」直接取 NextUp 第一条,跳集会播错集。
|
||||
func TestNextUpKeepsPartiallyWatchedEpisode(t *testing.T) {
|
||||
repos := newDiscoveryTestDB(t)
|
||||
libID := seedDiscoveryLibrary(t, repos, "tv")
|
||||
watchedAt := time.Now().Add(-time.Minute)
|
||||
|
||||
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 1, nil, true)
|
||||
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 2, &watchedAt, false) // 第 2 集只看了几秒
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 未看完的是这部剧的最后一集时也要返回它,不能因为「后面没有集了」而返回空。
|
||||
func TestNextUpKeepsPartiallyWatchedFinalEpisode(t *testing.T) {
|
||||
repos := newDiscoveryTestDB(t)
|
||||
libID := seedDiscoveryLibrary(t, repos, "tv")
|
||||
watchedAt := time.Now().Add(-time.Minute)
|
||||
|
||||
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 1, nil, true)
|
||||
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 2, &watchedAt, 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)
|
||||
@@ -104,6 +141,10 @@ func TestNextUpSkipsMovies(t *testing.T) {
|
||||
}
|
||||
|
||||
// 同一部剧有多条未看完历史时,只能出一条,且指向最靠后的已看集的下一集。
|
||||
// 同一部剧有多条未看完历史时只能出一条,且指向最近看过的那一集。
|
||||
//
|
||||
// 最近那一集(S1E2)本身还没看完,所以它就是「接下来该看的一集」;
|
||||
// S1E1 只是更早的中间进度,不能据此跳到 S1E3。
|
||||
func TestNextUpOneEntryPerSeries(t *testing.T) {
|
||||
repos := newDiscoveryTestDB(t)
|
||||
libID := seedDiscoveryLibrary(t, repos, "tv")
|
||||
@@ -116,8 +157,8 @@ func TestNextUpOneEntryPerSeries(t *testing.T) {
|
||||
|
||||
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)
|
||||
if len(got) != 1 || got[0] != "S1E2" {
|
||||
t.Fatalf("next up = %v, want [S1E2]", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +168,7 @@ func TestNextUpCrossesSeason(t *testing.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", "剧一", 1, 12, &watchedAt, true) // 第 1 季最后一集已看完
|
||||
seedEpisode(t, repos, libID, "series-1", "剧一", 2, 1, nil, false)
|
||||
seedEpisode(t, repos, libID, "series-1", "剧一", 2, 2, nil, false)
|
||||
|
||||
@@ -144,8 +185,8 @@ func TestNextUpSkipsCompletedEpisode(t *testing.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, 1, &watchedAt, true) // 已看完,下一集是 S1E3
|
||||
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)
|
||||
@@ -155,13 +196,13 @@ func TestNextUpSkipsCompletedEpisode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 追到最后一集时没有下一集,结果为空而不是重复返回最后一集。
|
||||
// 追到最后一集且已看完时没有下一集,结果为空而不是重复返回最后一集。
|
||||
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)
|
||||
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 3, &watchedAt, true)
|
||||
|
||||
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||
if got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true}); len(got) != 0 {
|
||||
@@ -176,7 +217,7 @@ func TestNextUpRespectsVisibility(t *testing.T) {
|
||||
hiddenLib := seedDiscoveryLibrary(t, repos, "tv")
|
||||
watchedAt := time.Now().Add(-time.Hour)
|
||||
|
||||
seedEpisode(t, repos, visibleLib, "series-1", "剧一", 1, 1, &watchedAt, false)
|
||||
seedEpisode(t, repos, visibleLib, "series-1", "剧一", 1, 1, &watchedAt, true)
|
||||
seedEpisode(t, repos, hiddenLib, "series-1", "剧一", 1, 2, nil, false)
|
||||
|
||||
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||
|
||||
@@ -18,9 +18,21 @@ import (
|
||||
// 片段数据的缓存时长。命中过说明社区库里已有记录、数据很少变动,可以放很久;
|
||||
// 未命中说明这部片还没人贡献,隔一段时间再试一次即可——负缓存是必须的,否则
|
||||
// 每次播放一部没有片段数据的影片都会打一次外网。
|
||||
//
|
||||
// 未命中 TTL 刻意短于命中:社区库在持续补充,尤其是热门剧,24h 重试一次比
|
||||
// 锁死 7 天更能跟上贡献节奏;预热任务也会优先扫最近播放过的 miss。
|
||||
const (
|
||||
segmentFoundTTL = 30 * 24 * time.Hour
|
||||
segmentMissingTTL = 7 * 24 * time.Hour
|
||||
segmentMissingTTL = 24 * time.Hour
|
||||
|
||||
// SegmentSourceManual / Propagated 与 IntroDBSource 并列,播放时按优先级合并。
|
||||
SegmentSourceManual = "manual"
|
||||
SegmentSourcePropagated = "propagated"
|
||||
|
||||
// segmentPrewarmDefaultLimit 是一次预热任务最多处理的媒体数,避免单次跑太久。
|
||||
segmentPrewarmDefaultLimit = 200
|
||||
// segmentPrewarmInterval 是预热请求之间的间隔,压低对 TheIntroDB 的 429。
|
||||
segmentPrewarmInterval = time.Second
|
||||
)
|
||||
|
||||
// SegmentView 是播放器消费的最小片段结构,避免把库内字段(source 等)暴露给前端。
|
||||
@@ -44,14 +56,29 @@ type MediaSegmentService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
introdb *IntroDBService
|
||||
// probe 负责从文件内嵌章节里提取片头/片尾(异步、落库)。未注入时只用
|
||||
// TheIntroDB。
|
||||
// probe 负责异步补齐媒体信息(主要是 STRM 时长)。未注入时只用 TheIntroDB。
|
||||
probe *MediaProbeService
|
||||
// prewarmGap 是预热两次 IntroDB 请求之间的间隔;测试可设为 0。
|
||||
prewarmGap time.Duration
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewMediaSegmentService is the constructor.
|
||||
func NewMediaSegmentService(log *zap.Logger, repo *repository.Container) *MediaSegmentService {
|
||||
return &MediaSegmentService{log: log, repo: repo}
|
||||
return &MediaSegmentService{
|
||||
log: log,
|
||||
repo: repo,
|
||||
prewarmGap: segmentPrewarmInterval,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// SetPrewarmGap overrides the delay between prewarm fetches (tests use 0).
|
||||
func (s *MediaSegmentService) SetPrewarmGap(gap time.Duration) *MediaSegmentService {
|
||||
if s != nil {
|
||||
s.prewarmGap = gap
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SetIntroDB wires the provider. Without it the service only reads cached rows.
|
||||
@@ -62,8 +89,8 @@ func (s *MediaSegmentService) SetIntroDB(p *IntroDBService) *MediaSegmentService
|
||||
return s
|
||||
}
|
||||
|
||||
// SetProbe wires the in-file chapter extractor. Without it the ffprobe source
|
||||
// simply yields nothing and auto falls back to TheIntroDB.
|
||||
// SetProbe wires the async media probe (duration backfill for STRM). Without it
|
||||
// open-ended credits still work once duration is known from elsewhere.
|
||||
func (s *MediaSegmentService) SetProbe(p *MediaProbeService) *MediaSegmentService {
|
||||
if s != nil {
|
||||
s.probe = p
|
||||
@@ -121,28 +148,73 @@ func mediaProbeSettled(row *model.MediaProbe) bool {
|
||||
}
|
||||
|
||||
// introDBSegments 读社区库的片段,缓存过期时按调用方的预算抓一次并落库。
|
||||
// 返回值是按来源优先级合并后的结果(manual > theintrodb > propagated)。
|
||||
func (s *MediaSegmentService) introDBSegments(ctx context.Context, m *model.Media) ([]model.MediaSegment, error) {
|
||||
cached, err := s.repo.MediaSegment.ListByMediaSource(ctx, m.ID, IntroDBSource)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ledger, err := s.repo.MediaSegment.GetFetch(ctx, m.ID, IntroDBSource)
|
||||
if err != nil {
|
||||
// 调用方预算耗尽时仍读本地缓存,绝不能让播放接口报错。
|
||||
if ctx.Err() != nil {
|
||||
return s.mergeForPlayback(context.WithoutCancel(ctx), m.ID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if ledger != nil && ledgerFresh(ledger) {
|
||||
return cached, nil
|
||||
if ledger == nil || !ledgerFresh(ledger) {
|
||||
if _, _, err := s.refresh(ctx, m); err != nil {
|
||||
// 社区库不可达或返回异常:沿用已有缓存,不影响播放;也不写负缓存。
|
||||
logIntroDBFailure(s.log, 0, err)
|
||||
}
|
||||
}
|
||||
refreshed, attempted, err := s.refresh(ctx, m)
|
||||
// 合并读库只碰本地,脱离调用方 deadline,避免外网抓取耗尽预算后读缓存也失败。
|
||||
return s.mergeForPlayback(context.WithoutCancel(ctx), m.ID)
|
||||
}
|
||||
|
||||
// mergeForPlayback 合并同一媒体上多来源片段。同 kind 只保留优先级最高的来源。
|
||||
func (s *MediaSegmentService) mergeForPlayback(ctx context.Context, mediaID string) ([]model.MediaSegment, error) {
|
||||
rows, err := s.repo.MediaSegment.ListByMedia(ctx, mediaID)
|
||||
if err != nil {
|
||||
// 社区库不可达或返回异常:沿用已有缓存,不影响播放。
|
||||
logIntroDBFailure(s.log, 0, err)
|
||||
return cached, nil
|
||||
return nil, err
|
||||
}
|
||||
if !attempted {
|
||||
return cached, nil
|
||||
return preferSegmentsBySource(rows), nil
|
||||
}
|
||||
|
||||
// segmentSourcePriority 数值越大越优先。未知来源视为最低,避免挡住已知源。
|
||||
func segmentSourcePriority(source string) int {
|
||||
switch source {
|
||||
case SegmentSourceManual:
|
||||
return 3
|
||||
case IntroDBSource:
|
||||
return 2
|
||||
case SegmentSourcePropagated:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
// preferSegmentsBySource 按 kind 选取最高优先级来源的全部区间。
|
||||
func preferSegmentsBySource(rows []model.MediaSegment) []model.MediaSegment {
|
||||
bestPri := make(map[string]int, 4)
|
||||
byKind := make(map[string][]model.MediaSegment, 4)
|
||||
for _, row := range rows {
|
||||
pri := segmentSourcePriority(row.Source)
|
||||
cur, seen := bestPri[row.Kind]
|
||||
if !seen || pri > cur {
|
||||
bestPri[row.Kind] = pri
|
||||
byKind[row.Kind] = []model.MediaSegment{row}
|
||||
continue
|
||||
}
|
||||
if pri == cur {
|
||||
byKind[row.Kind] = append(byKind[row.Kind], row)
|
||||
}
|
||||
}
|
||||
out := make([]model.MediaSegment, 0, len(rows))
|
||||
for _, kind := range []string{
|
||||
model.SegmentKindIntro, model.SegmentKindRecap,
|
||||
model.SegmentKindCredits, model.SegmentKindPreview,
|
||||
} {
|
||||
out = append(out, byKind[kind]...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *MediaSegmentService) debug(message, mediaID string, err error) {
|
||||
@@ -210,9 +282,111 @@ func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]mo
|
||||
}); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
s.propagateIntroToSeason(fetchCtx, m, rows)
|
||||
return rows, true, nil
|
||||
}
|
||||
|
||||
// propagateIntroToSeason 把本集 IntroDB 命中的 intro 复制到同季还没有
|
||||
// theintrodb intro 的兄弟集。片尾/预告不传播:各集时长与片尾位置经常不同。
|
||||
func (s *MediaSegmentService) propagateIntroToSeason(ctx context.Context, m *model.Media, rows []model.MediaSegment) {
|
||||
if s == nil || s.repo == nil || m == nil || m.SeasonNum <= 0 {
|
||||
return
|
||||
}
|
||||
intros := introSpansFrom(rows)
|
||||
if len(intros) == 0 {
|
||||
return
|
||||
}
|
||||
siblings, err := s.repo.Media.ListSeasonSiblings(ctx, m)
|
||||
if err != nil {
|
||||
s.debug("list season siblings failed", m.ID, err)
|
||||
return
|
||||
}
|
||||
for i := range siblings {
|
||||
sib := &siblings[i]
|
||||
existing, err := s.repo.MediaSegment.ListByMediaSource(ctx, sib.ID, IntroDBSource)
|
||||
if err != nil {
|
||||
s.debug("list sibling introdb segments failed", sib.ID, err)
|
||||
continue
|
||||
}
|
||||
if hasSegmentKind(existing, model.SegmentKindIntro) {
|
||||
continue
|
||||
}
|
||||
copied := make([]model.MediaSegment, 0, len(intros))
|
||||
for _, intro := range intros {
|
||||
copied = append(copied, model.MediaSegment{
|
||||
MediaID: sib.ID,
|
||||
SeriesID: sib.SeriesID,
|
||||
Kind: model.SegmentKindIntro,
|
||||
StartMs: intro.StartMs,
|
||||
EndMs: intro.EndMs,
|
||||
Source: SegmentSourcePropagated,
|
||||
})
|
||||
}
|
||||
if err := s.repo.MediaSegment.ReplaceForMedia(ctx, sib.ID, SegmentSourcePropagated, copied); err != nil {
|
||||
s.debug("propagate intro failed", sib.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func introSpansFrom(rows []model.MediaSegment) []model.MediaSegment {
|
||||
out := make([]model.MediaSegment, 0, 1)
|
||||
for _, row := range rows {
|
||||
if row.Kind == model.SegmentKindIntro {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasSegmentKind(rows []model.MediaSegment, kind string) bool {
|
||||
for _, row := range rows {
|
||||
if row.Kind == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Prewarm 批量向 TheIntroDB 补齐过期/缺失账本的可查询媒体。返回实际发起过查询的数量。
|
||||
func (s *MediaSegmentService) Prewarm(ctx context.Context, limit int) (int, error) {
|
||||
if s == nil || s.repo == nil || s.introdb == nil {
|
||||
return 0, nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = segmentPrewarmDefaultLimit
|
||||
}
|
||||
now := time.Now()
|
||||
if s.now != nil {
|
||||
now = s.now()
|
||||
}
|
||||
candidates, err := s.repo.MediaSegment.ListPrewarmCandidates(
|
||||
ctx, IntroDBSource, now.Add(-segmentMissingTTL), now.Add(-segmentFoundTTL), limit,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
attempted := 0
|
||||
for i := range candidates {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return attempted, err
|
||||
}
|
||||
m := &candidates[i]
|
||||
_, did, err := s.refresh(ctx, m)
|
||||
if err != nil {
|
||||
logIntroDBFailure(s.log, m.TMDbID, err)
|
||||
}
|
||||
if did {
|
||||
attempted++
|
||||
}
|
||||
if i+1 < len(candidates) && s.prewarmGap > 0 {
|
||||
if !waitForIntroDBRetry(ctx, s.prewarmGap) {
|
||||
return attempted, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
return attempted, nil
|
||||
}
|
||||
|
||||
// queryIDs resolves the provider query key. Movies use their own TMDb id.
|
||||
//
|
||||
// 剧集需要「剧集级」TMDb id 加季/集。优先取 Series.TMDbID;但有些刮削路径
|
||||
|
||||
@@ -385,6 +385,123 @@ func TestListForPlaybackRespectsCallerDeadline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferSegmentsBySourcePrefersManualThenIntroDBThenPropagated(t *testing.T) {
|
||||
rows := []model.MediaSegment{
|
||||
{Kind: model.SegmentKindIntro, StartMs: 1, EndMs: 2, Source: SegmentSourcePropagated},
|
||||
{Kind: model.SegmentKindIntro, StartMs: 10, EndMs: 20, Source: IntroDBSource},
|
||||
{Kind: model.SegmentKindIntro, StartMs: 100, EndMs: 200, Source: SegmentSourceManual},
|
||||
{Kind: model.SegmentKindCredits, StartMs: 1000, EndMs: 0, Source: SegmentSourcePropagated},
|
||||
{Kind: model.SegmentKindCredits, StartMs: 2000, EndMs: 0, Source: IntroDBSource},
|
||||
}
|
||||
got := preferSegmentsBySource(rows)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %#v, want manual intro + introdb credits", got)
|
||||
}
|
||||
if got[0].Source != SegmentSourceManual || got[0].StartMs != 100 {
|
||||
t.Fatalf("intro = %#v, want manual", got[0])
|
||||
}
|
||||
if got[1].Source != IntroDBSource || got[1].StartMs != 2000 {
|
||||
t.Fatalf("credits = %#v, want theintrodb", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestListForPlaybackPropagatesIntroToSeasonSiblings(t *testing.T) {
|
||||
svc, repos, _ := newSegmentServiceFixture(t, writeJSONBody(introDBTVPayload))
|
||||
svc.SetPrewarmGap(0)
|
||||
ctx := t.Context()
|
||||
episodes := []*model.Media{
|
||||
{Base: model.Base{ID: "ep-1"}, LibraryID: "lib-anime", Title: "便·当",
|
||||
Path: "/anime/S01E01.mkv", SeasonNum: 1, EpisodeNum: 1, TMDbID: 61970},
|
||||
{Base: model.Base{ID: "ep-2"}, LibraryID: "lib-anime", Title: "便·当",
|
||||
Path: "/anime/S01E02.mkv", SeasonNum: 1, EpisodeNum: 2, TMDbID: 61970},
|
||||
}
|
||||
for _, ep := range episodes {
|
||||
if err := repos.DB.Create(ep).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := svc.ListForPlayback(ctx, episodes[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("ep1 rows = %#v, want intro+credits from provider", rows)
|
||||
}
|
||||
|
||||
sib, err := repos.MediaSegment.ListByMediaSource(ctx, "ep-2", SegmentSourcePropagated)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sib) != 1 || sib[0].Kind != model.SegmentKindIntro {
|
||||
t.Fatalf("sibling propagated = %#v, want intro only", sib)
|
||||
}
|
||||
if sib[0].StartMs != 228_664 || sib[0].EndMs != 246_143 {
|
||||
t.Fatalf("propagated window = %#v", sib[0])
|
||||
}
|
||||
|
||||
// 兄弟集播放时应直接看到传播来的 intro(即使自己还没打过 IntroDB)。
|
||||
if err := repos.MediaSegment.UpsertFetch(ctx, &model.MediaSegmentFetch{
|
||||
MediaID: "ep-2", Source: IntroDBSource, FetchedAt: time.Now(), Found: false,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
merged, err := svc.ListForPlayback(ctx, episodes[1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(merged) != 1 || merged[0].Kind != model.SegmentKindIntro || merged[0].Source != SegmentSourcePropagated {
|
||||
t.Fatalf("ep2 playback = %#v, want propagated intro", merged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrewarmFetchesStaleMissesPreferringRecentPlays(t *testing.T) {
|
||||
svc, repos, calls := newSegmentServiceFixture(t, writeJSONBody(introDBMoviePayload))
|
||||
svc.SetPrewarmGap(0)
|
||||
ctx := t.Context()
|
||||
|
||||
old := &model.Media{Base: model.Base{ID: "mv-old"}, Path: "/a.mkv", TMDbID: 111}
|
||||
hot := &model.Media{Base: model.Base{ID: "mv-hot"}, Path: "/b.mkv", TMDbID: 27205}
|
||||
for _, m := range []*model.Media{old, hot} {
|
||||
if err := repos.DB.Create(m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// 两条都是过期 miss,热播的应优先被预热。
|
||||
stale := time.Now().Add(-segmentMissingTTL - time.Hour)
|
||||
for _, id := range []string{"mv-old", "mv-hot"} {
|
||||
if err := repos.MediaSegment.UpsertFetch(ctx, &model.MediaSegmentFetch{
|
||||
MediaID: id, Source: IntroDBSource, FetchedAt: stale, Found: false,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := repos.DB.Create(&model.PlaybackHistory{
|
||||
Base: model.Base{ID: "h-1"}, UserID: "u1", MediaID: "mv-hot",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
n, err := svc.Prewarm(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("attempted = %d, want 1", n)
|
||||
}
|
||||
if got := atomic.LoadInt32(calls); got != 1 {
|
||||
t.Fatalf("provider calls = %d, want 1", got)
|
||||
}
|
||||
ledger, err := repos.MediaSegment.GetFetch(ctx, "mv-hot", IntroDBSource)
|
||||
if err != nil || ledger == nil || !ledger.Found {
|
||||
t.Fatalf("hot ledger = %#v err=%v, want found", ledger, err)
|
||||
}
|
||||
oldLedger, err := repos.MediaSegment.GetFetch(ctx, "mv-old", IntroDBSource)
|
||||
if err != nil || oldLedger == nil || oldLedger.Found {
|
||||
t.Fatalf("old ledger should remain a miss, got %#v err=%v", oldLedger, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLedgerFreshUsesLongerTTLWhenDataWasFound(t *testing.T) {
|
||||
now := time.Now()
|
||||
found := &model.MediaSegmentFetch{FetchedAt: now.Add(-segmentMissingTTL), Found: true}
|
||||
@@ -399,3 +516,4 @@ func TestLedgerFreshUsesLongerTTLWhenDataWasFound(t *testing.T) {
|
||||
t.Fatal("a missing ledger must not be considered fresh")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
// organize_source opt-in — organize the configured staging folder.
|
||||
// transcode_cleanup every 24 h — purge HLS transcode artefacts
|
||||
// older than 24 h.
|
||||
// segment_prewarm every 6 h — fill IntroDB skip segments for queryable media.
|
||||
//
|
||||
// Each job runs at most once at a time (an in-flight run blocks the
|
||||
// next tick). All work happens on a long-lived background context so
|
||||
@@ -42,6 +43,8 @@ type SchedulerService struct {
|
||||
|
||||
imagesPolicyProvider func() ImageCachePolicy
|
||||
|
||||
segments *MediaSegmentService
|
||||
|
||||
mu sync.Mutex
|
||||
stopCh chan struct{}
|
||||
jobs []*scheduledJob
|
||||
@@ -145,6 +148,14 @@ func (s *SchedulerService) Start(ctx context.Context) {
|
||||
run: s.jobCleanImageCache,
|
||||
},
|
||||
}
|
||||
// 片头预热只在注入了 Segments 时注册,避免测试跑无转外网任务。
|
||||
if s.segments != nil {
|
||||
s.jobs = append(s.jobs, &scheduledJob{
|
||||
name: "segment_prewarm",
|
||||
interval: segmentPrewarmJobInterval,
|
||||
run: s.jobSegmentPrewarm,
|
||||
})
|
||||
}
|
||||
// 到期提醒只在配置了巡检器时注册,避免测试与未启用通知的部署跑空转任务。
|
||||
if s.expiryWatcher != nil {
|
||||
s.jobs = append(s.jobs, &scheduledJob{
|
||||
@@ -155,8 +166,8 @@ func (s *SchedulerService) Start(ctx context.Context) {
|
||||
}
|
||||
for _, j := range s.jobs {
|
||||
initialDelay := 15 * time.Second
|
||||
if j.name == "library_scan" || j.name == "organize_source" {
|
||||
// 重启后不立即整库重扫/整理下载目录:更新窗口恰是登录高峰,
|
||||
if j.name == "library_scan" || j.name == "organize_source" || j.name == "segment_prewarm" {
|
||||
// 重启后不立即整库重扫/整理/预热:更新窗口恰是登录高峰,
|
||||
// 15 秒即全量 walk + ffprobe 曾把 CPU/磁盘打满导致无法登录。
|
||||
// 首轮等满一个完整周期再跑,平时节奏不变。
|
||||
initialDelay = j.interval
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
segmentPrewarmJobInterval = 6 * time.Hour
|
||||
// SegmentPrewarmEnabledKey 控制是否后台预热 TheIntroDB 片段;默认关闭。
|
||||
SegmentPrewarmEnabledKey = "segment.prewarm_enabled"
|
||||
)
|
||||
|
||||
// SetSegments wires the IntroDB prewarm job. Without it the job is not registered.
|
||||
func (s *SchedulerService) SetSegments(segments *MediaSegmentService) {
|
||||
if s != nil {
|
||||
s.segments = segments
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SchedulerService) jobSegmentPrewarm(ctx context.Context) error {
|
||||
if s == nil || s.segments == nil {
|
||||
return nil
|
||||
}
|
||||
manual, _ := ctx.Value(schedulerManualRunKey{}).(bool)
|
||||
if !manual && !s.segmentPrewarmEnabled(ctx) {
|
||||
return nil
|
||||
}
|
||||
n, err := s.segments.Prewarm(ctx, segmentPrewarmDefaultLimit)
|
||||
if s.log != nil {
|
||||
s.log.Info("segment prewarm finished",
|
||||
zap.Int("attempted", n),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// segmentPrewarmEnabled reports whether the operator opted into background
|
||||
// IntroDB prewarm. Defaults to false so playback-on-demand remains the only path.
|
||||
func (s *SchedulerService) segmentPrewarmEnabled(ctx context.Context) bool {
|
||||
if s.repo == nil || s.repo.Setting == nil {
|
||||
return false
|
||||
}
|
||||
v, err := s.repo.Setting.Get(ctx, SegmentPrewarmEnabledKey)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return parseBoolSetting(v, false)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func TestSegmentPrewarmDefaultsOff(t *testing.T) {
|
||||
repos := repository.New(newServiceTestDB(t, &model.Setting{}))
|
||||
scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, nil, nil, "")
|
||||
if scheduler.segmentPrewarmEnabled(t.Context()) {
|
||||
t.Fatal("prewarm must default to off")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobSegmentPrewarmSkippedWhenDisabled(t *testing.T) {
|
||||
var calls int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
repos := repository.New(newServiceTestDB(t))
|
||||
if err := repos.DB.Create(&model.Media{
|
||||
Base: model.Base{ID: "mv-1"}, Path: "/a.mkv", TMDbID: 1,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
segments := NewMediaSegmentService(zap.NewNop(), repos).
|
||||
SetIntroDB(NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL)).
|
||||
SetPrewarmGap(0)
|
||||
scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, nil, nil, "")
|
||||
scheduler.SetSegments(segments)
|
||||
|
||||
if err := scheduler.jobSegmentPrewarm(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 0 {
|
||||
t.Fatalf("provider calls = %d, want 0 when prewarm is disabled", got)
|
||||
}
|
||||
|
||||
if err := repos.Setting.Set(t.Context(), SegmentPrewarmEnabledKey, "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := scheduler.jobSegmentPrewarm(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("provider calls = %d, want 1 after enabling prewarm", got)
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,7 @@ func (b *serviceContainerBuilder) initAccessAndStorageServices() {
|
||||
)
|
||||
b.c.Scheduler.SetTaskTracker(b.c.Tasks)
|
||||
b.c.Scheduler.SetOrganizePipeline(b.c.OrganizePipeline)
|
||||
b.c.Scheduler.SetSegments(b.c.Segments)
|
||||
b.c.Scheduler.SetImageCachePolicyProvider(func() ImageCachePolicy {
|
||||
if b.cfg == nil {
|
||||
return ImageCachePolicy{}
|
||||
|
||||
@@ -71,7 +71,6 @@ export interface DanmakuConfig {
|
||||
font_size: string
|
||||
area: string
|
||||
volume: number
|
||||
playback_rate: number
|
||||
/** 当前用户是否已经看过 VR 全景播放的首次操作说明(按用户存储)。 */
|
||||
vr360_guide_seen: boolean
|
||||
/** Per-user preference: merge the same episode's multiple sources. */
|
||||
@@ -90,7 +89,6 @@ export interface DanmakuSettingsPatch {
|
||||
area?: number
|
||||
merge_sources?: boolean
|
||||
volume?: number
|
||||
playback_rate?: number
|
||||
/** 看过 VR 操作说明后置为 true,之后不再弹出。 */
|
||||
vr360_guide_seen?: boolean
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export function ExternalPlayerButton({
|
||||
className={
|
||||
compact
|
||||
? 'shrink-0 inline-flex items-center rounded-lg border border-primary-400/35 bg-white px-2 py-1 text-xs font-semibold text-brand-500 hover:bg-primary-400/10 disabled:opacity-50 transition-colors whitespace-nowrap'
|
||||
: 'btn-outline border-brand-500/30 px-5 text-[#c9954a] hover:border-brand-500 hover:bg-brand-50'
|
||||
: 'btn-outline h-11 border-brand-500/30 px-5 text-[#c9954a] hover:border-brand-500 hover:bg-brand-50'
|
||||
}
|
||||
>
|
||||
<PlaySquare size={compact ? 13 : 14} className="mr-1 inline" />
|
||||
|
||||
@@ -133,10 +133,9 @@ type PlayerControlsProps = {
|
||||
volume?: number
|
||||
onVolumeChange?: (volume: number) => void
|
||||
onVolumeCommit?: (volume: number) => void
|
||||
/** 用户级播放倍速,由播放页从数据库读取。 */
|
||||
/** 播放倍速(1x = 原速)。只作用于当前视频,不落库。 */
|
||||
playbackRate?: number
|
||||
onPlaybackRateChange?: (rate: number) => void
|
||||
onPlaybackRateCommit?: (rate: number) => void
|
||||
uiVisible: boolean
|
||||
onUiVisibleChange: (visible: boolean) => void
|
||||
/** 外部浮层(VR 工具条/设置面板)正被鼠标悬停:此时控制栏不自动隐藏。 */
|
||||
@@ -213,7 +212,6 @@ export function PlayerControls({
|
||||
onVolumeCommit,
|
||||
playbackRate: playbackRateProp = 1,
|
||||
onPlaybackRateChange,
|
||||
onPlaybackRateCommit,
|
||||
uiVisible,
|
||||
onUiVisibleChange,
|
||||
uiHold = false,
|
||||
@@ -381,7 +379,7 @@ export function PlayerControls({
|
||||
setMuted(next === 0)
|
||||
}, [video, volumeProp])
|
||||
|
||||
// 倍速由播放页按用户持久化;配置加载、切换剧集或切换播放源后同步到当前 video。
|
||||
// 倍速只影响当前视频:挂载、切换剧集或切换播放源后同步到当前 video。
|
||||
useEffect(() => {
|
||||
const el = video()
|
||||
if (!el) return
|
||||
@@ -626,15 +624,9 @@ export function PlayerControls({
|
||||
}, PLAYBACK_RATE_HINT_MS)
|
||||
}, [onPlaybackRateChange])
|
||||
|
||||
const commitPlaybackRate = useCallback(() => {
|
||||
onPlaybackRateCommit?.(playbackRateRef.current)
|
||||
}, [onPlaybackRateCommit])
|
||||
|
||||
const selectPlaybackRate = useCallback((next: number) => {
|
||||
const normalized = normalizePlaybackRate(next)
|
||||
changePlaybackRate(normalized)
|
||||
onPlaybackRateCommit?.(normalized)
|
||||
}, [changePlaybackRate, onPlaybackRateCommit])
|
||||
changePlaybackRate(normalizePlaybackRate(next))
|
||||
}, [changePlaybackRate])
|
||||
|
||||
const applyAbsoluteSeek = useCallback(
|
||||
(absolute: number) => {
|
||||
@@ -787,18 +779,6 @@ export function PlayerControls({
|
||||
return () => window.removeEventListener('keydown', onKey, true)
|
||||
}, [changePlaybackRate, queueRelativeSeek])
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return
|
||||
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return
|
||||
if (shouldIgnorePlayerSeekShortcut(e.target)) return
|
||||
e.preventDefault()
|
||||
commitPlaybackRate()
|
||||
}
|
||||
window.addEventListener('keyup', onKeyUp, true)
|
||||
return () => window.removeEventListener('keyup', onKeyUp, true)
|
||||
}, [commitPlaybackRate])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (seekApplyTimerRef.current) clearTimeout(seekApplyTimerRef.current)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Database, FileText, FolderInput, Pencil, Search, Sparkles, Trash2 } from 'lucide-react'
|
||||
import { Database, FileText, FolderInput, Pencil, Search, ShieldAlert, Sparkles, Trash2 } from 'lucide-react'
|
||||
|
||||
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
|
||||
import type { Media } from '../types'
|
||||
@@ -34,50 +34,56 @@ export function MediaDetailAdminPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50/50 p-5 space-y-3">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-[#c9954a]">系统后台高级控制面板</p>
|
||||
{isEpisodeArtworkTarget(media) && (
|
||||
<EpisodeArtworkToggle
|
||||
checked={scrapeEpisodeArtwork}
|
||||
onChange={onScrapeEpisodeArtworkChange}
|
||||
title="关闭后仍会获取每集简介、评分和时长,只跳过单集图片"
|
||||
className="h-10"
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={onSmartScrape} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<section className="overflow-hidden rounded-2xl border border-gray-200 bg-gray-50/50">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-gray-200/60 px-4 py-3 sm:px-5">
|
||||
<p className="inline-flex items-center gap-2 text-2xs font-bold uppercase tracking-[0.18em] text-gray-500">
|
||||
<ShieldAlert size={14} className="text-brand-500" />
|
||||
系统后台高级控制面板
|
||||
</p>
|
||||
{isEpisodeArtworkTarget(media) && (
|
||||
<EpisodeArtworkToggle
|
||||
checked={scrapeEpisodeArtwork}
|
||||
onChange={onScrapeEpisodeArtworkChange}
|
||||
title="关闭后仍会获取每集简介、评分和时长,只跳过单集图片"
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 px-4 py-4 sm:px-5">
|
||||
<button onClick={onSmartScrape} className="btn-outline h-9 rounded-lg px-3 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<Sparkles size={13} className="text-[#c9954a]" />
|
||||
<span>智能刮削 (TMDB)</span>
|
||||
</button>
|
||||
<button onClick={onManualScrape} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<button onClick={onManualScrape} className="btn-outline h-9 rounded-lg px-3 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<Search size={13} className="text-[#c9954a]" />
|
||||
<span>手动匹配刮削</span>
|
||||
</button>
|
||||
<button onClick={onMetadataEdit} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<Pencil size={13} className="text-gray-600" />
|
||||
<button onClick={onMetadataEdit} className="btn-outline h-9 rounded-lg px-3 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<Pencil size={13} className="text-brand-500" />
|
||||
<span>编辑元数据</span>
|
||||
</button>
|
||||
<button onClick={onOrganize} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<button onClick={onOrganize} className="btn-outline h-9 rounded-lg px-3 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<FolderInput size={13} className="text-[#c9954a]" />
|
||||
<span>整理入库</span>
|
||||
</button>
|
||||
<button onClick={onProbe} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<Database size={13} className="text-gray-600" />
|
||||
<button onClick={onProbe} className="btn-outline h-9 rounded-lg px-3 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<Database size={13} className="text-brand-500" />
|
||||
<span>探测媒体轨 (ffprobe)</span>
|
||||
</button>
|
||||
<button onClick={onExportNFO} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<FileText size={13} />
|
||||
<button onClick={onExportNFO} className="btn-outline h-9 rounded-lg px-3 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<FileText size={13} className="text-brand-500" />
|
||||
<span>写出本地 NFO 属性</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="btn-outline py-2 px-3.5 text-xs gap-1.5 !border-red-100 !text-red-500 hover:!bg-red-50 hover:!border-red-200"
|
||||
className="btn-outline ml-auto h-9 rounded-lg px-3 text-xs gap-1.5 !border-red-100 !text-red-500 hover:!bg-red-50 hover:!border-red-200"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
<Trash2 size={13} className="text-red-500" />
|
||||
<span>删除</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ type MediaDetailMetadataProps = {
|
||||
export function MediaDetailMetadata({ media }: MediaDetailMetadataProps) {
|
||||
const heading = media.episode_title?.trim() || media.title
|
||||
const showTitleContext = Boolean(media.episode_title?.trim() && media.title && media.title !== heading)
|
||||
const genres = parseCSV(media.genres)
|
||||
const languages = parseCSV(media.languages)
|
||||
const countries = parseCSV(media.countries)
|
||||
const hasTaxonomy = genres.length > 0 || languages.length > 0 || countries.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -21,26 +25,26 @@ export function MediaDetailMetadata({ media }: MediaDetailMetadataProps) {
|
||||
{media.title}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2.5 text-xs text-gray-500 font-bold tracking-wide uppercase">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{media.year > 0 && (
|
||||
<span className="inline-flex items-center gap-1 bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700">
|
||||
<span className="inline-flex h-7 items-center rounded-lg border border-gray-200/60 bg-gray-100 px-2.5 text-2xs font-bold uppercase tracking-wide gap-1.5 text-gray-700">
|
||||
<Calendar size={13} className="text-brand-500" />
|
||||
<span>{media.year} 年</span>
|
||||
</span>
|
||||
)}
|
||||
{media.width > 0 && (
|
||||
<span className="inline-flex items-center gap-1 bg-brand-50 text-brand-700 border border-brand-100/50 px-2.5 py-1 rounded-xl">
|
||||
{media.width > 0 && media.height > 0 && (
|
||||
<span className="inline-flex h-7 items-center rounded-lg border border-brand-100/50 bg-brand-50 px-2.5 text-2xs font-bold uppercase tracking-wide text-brand-700">
|
||||
<span>{media.width} × {media.height}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700">
|
||||
{fmtSize(media.size_bytes)}
|
||||
</span>
|
||||
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700">
|
||||
{fmtDuration(media.duration_sec)}
|
||||
</span>
|
||||
{media.size_bytes > 0 && (
|
||||
<span className="inline-flex h-7 items-center rounded-lg border border-gray-200/60 bg-gray-100 px-2.5 text-2xs font-bold uppercase tracking-wide text-gray-700">{fmtSize(media.size_bytes)}</span>
|
||||
)}
|
||||
{media.duration_sec > 0 && (
|
||||
<span className="inline-flex h-7 items-center rounded-lg border border-gray-200/60 bg-gray-100 px-2.5 text-2xs font-bold uppercase tracking-wide text-gray-700">{fmtDuration(media.duration_sec)}</span>
|
||||
)}
|
||||
{media.container && (
|
||||
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700 font-mono">
|
||||
<span className="inline-flex h-7 items-center rounded-lg border border-gray-200/60 bg-gray-100 px-2.5 text-2xs font-bold uppercase tracking-wide text-gray-700 font-mono">
|
||||
{media.container}
|
||||
</span>
|
||||
)}
|
||||
@@ -56,24 +60,44 @@ export function MediaDetailMetadata({ media }: MediaDetailMetadataProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<MetadataTags label="类型流派" values={parseCSV(media.genres)} primary />
|
||||
<MetadataTags label="语言" values={parseCSV(media.languages)} />
|
||||
<MetadataTags label="国家/地区" values={parseCSV(media.countries)} />
|
||||
</div>
|
||||
{hasTaxonomy && (
|
||||
<div className="space-y-3">
|
||||
<MetadataRow label="类型流派" values={parseCSV(media.genres)} tone="brand" />
|
||||
<MetadataRow label="语言" values={parseCSV(media.languages)} />
|
||||
<MetadataRow label="国家/地区" values={parseCSV(media.countries)} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MetadataTags({ label, values, primary = false }: { label: string; values: string[]; primary?: boolean }) {
|
||||
/**
|
||||
* One "label - values" row of the taxonomy block.
|
||||
*
|
||||
* All rows share the same label gutter, so every value column starts on the
|
||||
* same vertical line. Pills keep one geometry and differ by tone only.
|
||||
*/
|
||||
function MetadataRow({
|
||||
label,
|
||||
values,
|
||||
tone = 'neutral',
|
||||
}: {
|
||||
label: string
|
||||
values: string[]
|
||||
tone?: 'neutral' | 'brand'
|
||||
}) {
|
||||
if (values.length === 0) return null
|
||||
const tagClass = primary
|
||||
? 'rounded-full bg-brand-50 text-brand-700 border border-brand-100/30 px-3 py-1 text-2xs font-bold uppercase tracking-wider'
|
||||
: 'rounded-xl bg-gray-100 text-gray-600 border border-gray-200/40 px-2.5 py-1 text-2xs font-semibold'
|
||||
const tagClass = [
|
||||
'inline-flex items-center rounded-lg border px-2.5 py-1 text-2xs font-bold uppercase tracking-wide',
|
||||
tone === 'brand'
|
||||
? 'border-brand-100/50 bg-brand-50 text-brand-700'
|
||||
: 'border-gray-200/60 bg-gray-100 text-gray-600',
|
||||
].join(' ')
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-xs font-bold text-gray-500 w-16 uppercase tracking-wider">{label}</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="grid grid-cols-[4.5rem_1fr] items-start gap-2 sm:grid-cols-[5.5rem_1fr]">
|
||||
<span className="pt-1 text-xs font-semibold text-gray-500">{label}</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{values.map((value) => (
|
||||
<span key={value} className={tagClass}>
|
||||
{value}
|
||||
|
||||
@@ -99,19 +99,23 @@ export function MediaDetailPlaybackActions({
|
||||
}: MediaDetailPlaybackActionsProps) {
|
||||
const targetId = media.id || playTargetId || ''
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link to={`/play/${targetId}`} state={{ from: `/media/${media.id}` }} className="btn-primary px-6 py-3.5 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<Link
|
||||
to={`/play/${targetId}`}
|
||||
state={{ from: `/media/${media.id}` }}
|
||||
className="btn-primary h-11 px-6 shadow-sm"
|
||||
>
|
||||
<Play size={16} fill="currentColor" />
|
||||
<span>立即播放</span>
|
||||
</Link>
|
||||
|
||||
{!isDirectStreamMedia(media) && (
|
||||
<Link
|
||||
to={`/play/${targetId}?mode=hls`}
|
||||
state={{ from: `/media/${media.id}` }}
|
||||
className="btn-outline border-brand-500/30 hover:border-brand-500 text-[#c9954a] hover:bg-brand-50 px-5"
|
||||
>
|
||||
<RefreshCw size={14} className="animate-spin-slow" />
|
||||
to={`/play/${targetId}?mode=hls`}
|
||||
state={{ from: `/media/${media.id}` }}
|
||||
className="btn-outline h-11 border-brand-500/30 px-5 text-[#c9954a] hover:border-brand-500 hover:bg-brand-50"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
<span>HLS 兼容转码播放</span>
|
||||
</Link>
|
||||
)}
|
||||
@@ -121,7 +125,7 @@ export function MediaDetailPlaybackActions({
|
||||
<button
|
||||
onClick={onToggleFavourite}
|
||||
className={
|
||||
'btn-outline gap-2 ' +
|
||||
'btn-outline h-11 gap-2 ' +
|
||||
(favourite
|
||||
? '!border-red-200 !bg-red-50 !text-red-600 hover:!bg-red-100/50'
|
||||
: 'hover:border-red-200 hover:text-red-600 hover:bg-red-50/50')
|
||||
@@ -154,10 +158,10 @@ export function MediaDetailMainContent({
|
||||
<div className="relative z-10 p-6 sm:p-10 flex flex-col md:flex-row gap-8 lg:gap-12">
|
||||
<MediaDetailPoster media={media} />
|
||||
|
||||
<div className="flex-1 space-y-6">
|
||||
<div className="min-w-0 flex-1 space-y-5">
|
||||
<MediaDetailMetadata media={media} />
|
||||
<div className="divider border-gray-200/60" />
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="divider" />
|
||||
<div className="space-y-4">
|
||||
<MediaDetailPlaybackActions
|
||||
media={media}
|
||||
favourite={favourite}
|
||||
@@ -165,21 +169,21 @@ export function MediaDetailMainContent({
|
||||
playTargetId={playTargetId}
|
||||
/>
|
||||
<MediaVersionSwitcher media={media} />
|
||||
{isAdmin && (
|
||||
<MediaDetailAdminPanel
|
||||
media={media}
|
||||
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
|
||||
onScrapeEpisodeArtworkChange={onScrapeEpisodeArtworkChange}
|
||||
onSmartScrape={onSmartScrape}
|
||||
onManualScrape={onManualScrape}
|
||||
onMetadataEdit={onMetadataEdit}
|
||||
onOrganize={onOrganize}
|
||||
onProbe={onProbe}
|
||||
onExportNFO={onExportNFO}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<MediaDetailAdminPanel
|
||||
media={media}
|
||||
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
|
||||
onScrapeEpisodeArtworkChange={onScrapeEpisodeArtworkChange}
|
||||
onSmartScrape={onSmartScrape}
|
||||
onManualScrape={onManualScrape}
|
||||
onMetadataEdit={onMetadataEdit}
|
||||
onOrganize={onOrganize}
|
||||
onProbe={onProbe}
|
||||
onExportNFO={onExportNFO}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useAuthStore } from '../stores/auth'
|
||||
import type { Media, PlaybackInfo, PlaybackQuality, PlaybackSegment, PlaybackSegmentKind } from '../types'
|
||||
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
|
||||
import { mediaVersionMatches, mediaVersionsOf } from '../utils/mediaVersion'
|
||||
import { normalizePlaybackRate } from '../utils/playbackRate'
|
||||
import { DEFAULT_PLAYBACK_RATE, normalizePlaybackRate } from '../utils/playbackRate'
|
||||
import { resolveActiveSkip, skippedNoticeText, toSkipSegments, type SkipPrompt } from '../utils/skipSegments'
|
||||
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||
import {
|
||||
@@ -139,16 +139,13 @@ export function PlayerPage() {
|
||||
const [playerVolume, setPlayerVolume] = useState(() =>
|
||||
normalizePlayerVolume(authUser?.player_volume),
|
||||
)
|
||||
const [playerPlaybackRate, setPlayerPlaybackRate] = useState(() =>
|
||||
normalizePlaybackRate(authUser?.player_playback_rate),
|
||||
)
|
||||
// 播放倍速只作用于当前视频:不入库、不跨视频沿用(换集/换片会重置回 1x)。
|
||||
const [playerPlaybackRate, setPlayerPlaybackRate] = useState(() => DEFAULT_PLAYBACK_RATE)
|
||||
const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode)
|
||||
// VR 全景播放的首次操作说明是否已经看过:null = 服务端配置还没读回来,
|
||||
// 这时不弹说明,避免给老用户闪一下。
|
||||
const [vr360GuideSeen, setVr360GuideSeen] = useState<boolean | null>(null)
|
||||
const playerVolumeTouchedRef = useRef(false)
|
||||
const playerPlaybackRateTouchedRef = useRef(false)
|
||||
const playerPlaybackRateSaveSeqRef = useRef(0)
|
||||
const subtitlePreferenceTouchedRef = useRef(false)
|
||||
const subtitlePreferenceSaveQueueRef = useRef<Promise<void>>(Promise.resolve())
|
||||
const [hlsUnavailable, setHlsUnavailable] = useState(false)
|
||||
@@ -285,10 +282,6 @@ export function PlayerPage() {
|
||||
if (!playerVolumeTouchedRef.current) {
|
||||
setPlayerVolume(volume)
|
||||
}
|
||||
const playbackRate = normalizePlaybackRate(cfg.playback_rate)
|
||||
if (!playerPlaybackRateTouchedRef.current) {
|
||||
setPlayerPlaybackRate(playbackRate)
|
||||
}
|
||||
setDanmakuEnabled(cfg.enabled)
|
||||
setDanmakuOpacity(Number(cfg.opacity) || 1)
|
||||
setDanmakuFontSize(Number(cfg.font_size) || 24)
|
||||
@@ -300,10 +293,6 @@ export function PlayerPage() {
|
||||
const current = useAuthStore.getState().user
|
||||
if (current) setAuthUser({ ...current, player_volume: volume })
|
||||
}
|
||||
if (!playerPlaybackRateTouchedRef.current) {
|
||||
const current = useAuthStore.getState().user
|
||||
if (current) setAuthUser({ ...current, player_playback_rate: playbackRate })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 配置读取失败时保持本地默认值,不影响播放。
|
||||
@@ -338,34 +327,11 @@ export function PlayerPage() {
|
||||
[setAuthUser],
|
||||
)
|
||||
|
||||
// 倍速只改本机播放状态:不写回服务端,也不跨视频沿用。
|
||||
const changePlayerPlaybackRate = useCallback((next: number) => {
|
||||
playerPlaybackRateTouchedRef.current = true
|
||||
setPlayerPlaybackRate(normalizePlaybackRate(next))
|
||||
}, [])
|
||||
|
||||
const commitPlayerPlaybackRate = useCallback(
|
||||
(next: number) => {
|
||||
playerPlaybackRateTouchedRef.current = true
|
||||
const playbackRate = normalizePlaybackRate(next)
|
||||
setPlayerPlaybackRate(playbackRate)
|
||||
const saveSeq = ++playerPlaybackRateSaveSeqRef.current
|
||||
void danmakuAPI
|
||||
.updateSettings({ playback_rate: playbackRate })
|
||||
.then((cfg) => {
|
||||
if (saveSeq !== playerPlaybackRateSaveSeqRef.current) return
|
||||
const saved = normalizePlaybackRate(cfg.playback_rate)
|
||||
setPlayerPlaybackRate(saved)
|
||||
const current = useAuthStore.getState().user
|
||||
if (current) setAuthUser({ ...current, player_playback_rate: saved })
|
||||
})
|
||||
.catch(() => {
|
||||
if (saveSeq !== playerPlaybackRateSaveSeqRef.current) return
|
||||
toast.error('倍速保存失败,请重试')
|
||||
})
|
||||
},
|
||||
[setAuthUser],
|
||||
)
|
||||
|
||||
const saveDanmakuAdvanced = useCallback(
|
||||
async (values: { source: string; appId: string; appKey: string; clearAppKey: boolean }) => {
|
||||
const patch: DanmakuSettingsPatch = {
|
||||
@@ -674,6 +640,12 @@ export function PlayerPage() {
|
||||
const mediaId = media?.id
|
||||
const playbackProvider = playbackInfo?.provider
|
||||
|
||||
// 倍速只影响当前视频:换到另一集/另一部片时回到 1x,避免上一个视频的
|
||||
// 倍速被顺手带到下一集。
|
||||
useEffect(() => {
|
||||
setPlayerPlaybackRate(DEFAULT_PLAYBACK_RATE)
|
||||
}, [mediaId])
|
||||
|
||||
// 加载统一播放能力:115 云端清晰度 + 本地 HLS 清晰度。
|
||||
useEffect(() => {
|
||||
if (!mediaId) return
|
||||
@@ -1907,7 +1879,6 @@ export function PlayerPage() {
|
||||
onPlayerVolumeCommit={commitPlayerVolume}
|
||||
playerPlaybackRate={playerPlaybackRate}
|
||||
onPlayerPlaybackRateChange={changePlayerPlaybackRate}
|
||||
onPlayerPlaybackRateCommit={commitPlayerPlaybackRate}
|
||||
danmakuEnabled={danmakuEnabled}
|
||||
danmakuOpacity={danmakuOpacity}
|
||||
danmakuFontSize={danmakuFontSize}
|
||||
|
||||
@@ -184,7 +184,6 @@ type PlayerVideoStageProps = {
|
||||
onPlayerVolumeCommit: (volume: number) => void
|
||||
playerPlaybackRate: number
|
||||
onPlayerPlaybackRateChange: (rate: number) => void
|
||||
onPlayerPlaybackRateCommit: (rate: number) => void
|
||||
danmakuEnabled: boolean
|
||||
danmakuOpacity: number
|
||||
danmakuFontSize: number
|
||||
@@ -268,7 +267,6 @@ export function PlayerVideoStage({
|
||||
onPlayerVolumeCommit,
|
||||
playerPlaybackRate,
|
||||
onPlayerPlaybackRateChange,
|
||||
onPlayerPlaybackRateCommit,
|
||||
danmakuEnabled,
|
||||
danmakuOpacity,
|
||||
danmakuFontSize,
|
||||
@@ -892,7 +890,6 @@ export function PlayerVideoStage({
|
||||
onVolumeCommit={onPlayerVolumeCommit}
|
||||
playbackRate={playerPlaybackRate}
|
||||
onPlaybackRateChange={onPlayerPlaybackRateChange}
|
||||
onPlaybackRateCommit={onPlayerPlaybackRateCommit}
|
||||
uiVisible={controlsVisible && !locked}
|
||||
onUiVisibleChange={setControlsVisible}
|
||||
uiHold={vrUiHovered}
|
||||
|
||||
@@ -23,6 +23,13 @@ export const generalSettingsGroup: SettingGroup = {
|
||||
hint: '默认关闭。开启后宿主机不再进行任何 FFmpeg 转码,所有播放交给第三方客户端(Infuse / VLC / Emby 客户端等)或浏览器本地解码直连(direct play / 302 直链),大幅降低宿主机 CPU 占用。若客户端不支持源编码可能无法播放。',
|
||||
defaultValue: 'false',
|
||||
},
|
||||
{
|
||||
key: 'segment.prewarm_enabled',
|
||||
label: '后台预热 IntroDB 片头片段',
|
||||
type: 'toggle',
|
||||
hint: '默认关闭。开启后每 6 小时在后台向 TheIntroDB 拉取可识别媒体的片头/片尾区间并写入本地;关闭时仅在播放时按需请求。',
|
||||
defaultValue: 'false',
|
||||
},
|
||||
{
|
||||
key: 'transcode.enabled',
|
||||
label: '启用转码',
|
||||
|
||||
@@ -12,8 +12,6 @@ export interface User {
|
||||
subtitle_chinese_mode?: 'original' | 'simplified' | 'traditional'
|
||||
/** 本地缓存的播放器音量,服务端配置接口会刷新。 */
|
||||
player_volume?: number
|
||||
/** 本地缓存的播放倍速,服务端配置接口会刷新。 */
|
||||
player_playback_rate?: number
|
||||
force_password_reset: boolean
|
||||
is_active: boolean
|
||||
allowed_library_ids?: string[]
|
||||
|
||||
@@ -3,6 +3,9 @@ export const PLAYBACK_RATE_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3]
|
||||
export const MIN_PLAYBACK_RATE = PLAYBACK_RATE_OPTIONS[0]
|
||||
export const MAX_PLAYBACK_RATE = PLAYBACK_RATE_OPTIONS[PLAYBACK_RATE_OPTIONS.length - 1]
|
||||
|
||||
/** 默认倍速(1x)。倍速不落库,每个视频都从这个值开始。 */
|
||||
export const DEFAULT_PLAYBACK_RATE = 1
|
||||
|
||||
export function normalizePlaybackRate(value: unknown): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return 1
|
||||
|
||||
Reference in New Issue
Block a user