Compare commits

...

6 Commits

Author SHA1 Message Date
truewhile 065652d758 优化 2026-09-24 22:27:09 +08:00
truewhile e273b5e87d 优化 2026-09-24 11:43:17 +08:00
truewhile c9c612df1b bug处理 2026-09-23 23:07:01 +08:00
truewhile e2cd32af53 处理yamby继续播放bug 2026-09-23 22:31:09 +08:00
truewhile 28485ed429 优化 2026-09-23 17:01:08 +08:00
truewhile 34ccf14cda 优化视频头尾跳过功能 2026-09-23 16:07:28 +08:00
47 changed files with 2900 additions and 242 deletions
@@ -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).
+26 -16
View File
@@ -112,8 +112,7 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
t.Fatalf("status = %d, want 200", w.Code) t.Fatalf("status = %d, want 200", w.Code)
} }
var cfg struct { var cfg struct {
MergeSources bool `json:"merge_sources"` MergeSources bool `json:"merge_sources"`
PlaybackRate float64 `json:"playback_rate"`
} }
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil { if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
t.Fatalf("decode: %v", err) t.Fatalf("decode: %v", err)
@@ -121,9 +120,6 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
if cfg.MergeSources { if cfg.MergeSources {
t.Fatal("default merge preference should be false") 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 { if err := svc.Danmaku.SetMergeSources(t.Context(), "user-1", true); err != nil {
t.Fatalf("set: %v", err) t.Fatalf("set: %v", err)
@@ -141,7 +137,7 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) { func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
svc := newDanmakuSettingsService(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") c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", body, "user-1")
updateDanmakuSettingsHandler(svc)(c) 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" { if cfg.Enabled || cfg.Opacity != "0.6" || cfg.FontSize != "32" || cfg.Area != "0.7" {
t.Fatalf("unexpected render config: %+v", cfg) 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) t.Fatalf("unexpected user preferences: %+v", cfg)
} }
if cfg.Source != "https://dm.example/base" || cfg.AppID != "my-app-id" || !cfg.AppKeyConfigured { 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 { if err != nil || user == nil {
t.Fatalf("read persisted user: %v", err) 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) 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) svc := newDanmakuSettingsService(t)
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", c, w := newDanmakuSettingsContext(t, svc, http.MethodGet, "/danmaku/config", "", "user-1")
`{"playback_rate":4}`, "user-1") getDanmakuConfigHandler(svc)(c)
updateDanmakuSettingsHandler(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 { c2, w2 := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
t.Fatalf("status = %d, want 400 (body=%s)", w.Code, w.Body.String()) `{"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") user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
if err != nil || user == nil { if err != nil || user == nil {
t.Fatalf("read user: %v", err) t.Fatalf("read user: %v", err)
} }
if user.PlayerPlaybackRate != 1 { if user.PlayerVolume != 0.5 {
t.Fatalf("invalid playback rate was persisted: %v", user.PlayerPlaybackRate) t.Fatalf("volume not persisted: %v", user.PlayerVolume)
} }
} }
+36 -1
View File
@@ -24,7 +24,42 @@ func embyNextUpHandler(svc *service.Container) gin.HandlerFunc {
return return
} }
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "")) limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), ""))
out, err := svc.Emby.NextUp(c.Request.Context(), userID, limit) // YamBy / Emby 进剧集详情会带 SeriesId 请求「本剧下一集」。
// 忽略该参数会把全站 NextUp 第一条塞进详情页「继续播放」。
// 注意:不要把普通 ParentId(媒体库)当成 SeriesId,否则首页 NextUp 会被滤空。
seriesID := firstQueryValue(c, "SeriesId", "seriesId", "seriesid")
if seriesID == "" {
if parentID := firstQueryValue(c, "ParentId", "parentId", "parentid"); parentID != "" {
if strings.HasPrefix(parentID, "msgo-series-") || service.IsEmbyRemoteID(parentID) {
seriesID = parentID
}
}
}
out, err := svc.Emby.NextUp(c.Request.Context(), userID, seriesID, limit)
if err != nil {
c.JSON(http.StatusOK, embyEmptyItemsPayload())
return
}
embyAttachRequestTokenToMediaSources(c, out)
c.JSON(http.StatusOK, out)
}
}
// embyShowNextUpHandler 处理 /Shows/{id}/NextUp:把路径上的剧集 ID 当作 SeriesId。
func embyShowNextUpHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := embyScopedUserID(c)
if userID == "" {
c.JSON(http.StatusOK, embyEmptyItemsPayload())
return
}
seriesID := strings.TrimSpace(c.Param("id"))
if seriesID == "" || strings.EqualFold(seriesID, "NextUp") {
c.JSON(http.StatusOK, embyEmptyItemsPayload())
return
}
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), ""))
out, err := svc.Emby.NextUp(c.Request.Context(), userID, seriesID, limit)
if err != nil { if err != nil {
c.JSON(http.StatusOK, embyEmptyItemsPayload()) c.JSON(http.StatusOK, embyEmptyItemsPayload())
return return
+130 -2
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strconv"
"testing" "testing"
"time" "time"
@@ -104,8 +105,8 @@ func TestEmbyNextUpReturnsNextEpisode(t *testing.T) {
} }
if watched { if watched {
h := &model.PlaybackHistory{ h := &model.PlaybackHistory{
UserID: userID, MediaID: m.ID, PositionMs: 1000, DurationMs: 2000, UserID: userID, MediaID: m.ID, PositionMs: 2000, DurationMs: 2000,
WatchedAt: watchedAt, Completed: false, WatchedAt: watchedAt, Completed: true, // 第 1 集已看完
} }
if err := svc.Repo.DB.Create(h).Error; err != nil { if err := svc.Repo.DB.Create(h).Error; err != nil {
t.Fatal(err) t.Fatal(err)
@@ -126,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。 // 没有历史时必须返回合法空信封,不能 404/500。
func TestEmbyNextUpEmptyWithoutHistory(t *testing.T) { func TestEmbyNextUpEmptyWithoutHistory(t *testing.T) {
router, _, _ := newEmbyDiscoveryEnv(t) router, _, _ := newEmbyDiscoveryEnv(t)
@@ -242,3 +286,87 @@ func TestEmbyNextUpRejectsForeignUserID(t *testing.T) {
t.Fatalf("items = %d, want 0", len(items)) t.Fatalf("items = %d, want 0", len(items))
} }
} }
// YamBy 等客户端进入剧集详情会带 SeriesId 调 NextUp;必须只返回该剧的下一集,
// 不能回落成全站「继续观看」第一条,否则详情页播放会串到别的片子。
func TestEmbyNextUpFiltersBySeriesID(t *testing.T) {
router, svc, userID := newEmbyDiscoveryEnv(t)
libID := seedEmbyLibrary(t, svc, "tv")
recent := time.Now().Add(-time.Minute)
older := time.Now().Add(-2 * time.Hour)
seedSeries := func(seriesID, title string, watchedAt time.Time) (watchedID, nextID string) {
t.Helper()
for ep := 1; ep <= 3; ep++ {
m := &model.Media{
LibraryID: libID, SeriesID: seriesID, Title: title,
SeasonNum: 1, EpisodeNum: ep,
Path: "/media/tv/" + seriesID + "/S1E" + strconv.Itoa(ep) + ".mkv",
}
if err := svc.Repo.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
switch ep {
case 1:
watchedID = m.ID
h := &model.PlaybackHistory{
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)
}
case 2:
nextID = m.ID
}
}
return watchedID, nextID
}
_, _ = seedSeries("series-hot", "热门剧", recent)
_, wantNext := seedSeries("series-cold", "目标剧", older)
token := signedTestToken(t, "test-secret")
global := embyGet(t, router, "/emby/Shows/NextUp?Limit=10", token)
if global.Code != http.StatusOK {
t.Fatalf("global status = %d body=%s", global.Code, global.Body.String())
}
if items := decodeItemsEnvelope(t, global.Body.Bytes()); len(items) < 2 {
t.Fatalf("global items = %d, want >= 2 (body=%s)", len(items), global.Body.String())
}
scoped := embyGet(t, router, "/emby/Shows/NextUp?SeriesId=series-cold&Limit=10", token)
if scoped.Code != http.StatusOK {
t.Fatalf("scoped status = %d body=%s", scoped.Code, scoped.Body.String())
}
items := decodeItemsEnvelope(t, scoped.Body.Bytes())
if len(items) != 1 {
t.Fatalf("scoped items = %d, want 1 (body=%s)", len(items), scoped.Body.String())
}
if id, _ := items[0]["Id"].(string); id != wantNext {
t.Fatalf("scoped Id = %q, want %q (body=%s)", id, wantNext, scoped.Body.String())
}
if seriesID, _ := items[0]["SeriesId"].(string); seriesID != "series-cold" {
t.Fatalf("scoped SeriesId = %q, want series-cold", seriesID)
}
empty := embyGet(t, router, "/emby/Shows/NextUp?SeriesId=series-never-watched", token)
if empty.Code != http.StatusOK {
t.Fatalf("empty status = %d body=%s", empty.Code, empty.Body.String())
}
if items := decodeItemsEnvelope(t, empty.Body.Bytes()); len(items) != 0 {
t.Fatalf("never-watched items = %d, want 0 (body=%s)", len(items), empty.Body.String())
}
pathScoped := embyGet(t, router, "/emby/Shows/series-cold/NextUp?Limit=10", token)
if pathScoped.Code != http.StatusOK {
t.Fatalf("path scoped status = %d body=%s", pathScoped.Code, pathScoped.Body.String())
}
pathItems := decodeItemsEnvelope(t, pathScoped.Body.Bytes())
if len(pathItems) != 1 {
t.Fatalf("path scoped items = %d, want 1 (body=%s)", len(pathItems), pathScoped.Body.String())
}
if id, _ := pathItems[0]["Id"].(string); id != wantNext {
t.Fatalf("path scoped Id = %q, want %q", id, wantNext)
}
}
+4 -1
View File
@@ -132,7 +132,10 @@ func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
uid := embyEffectiveUserID(c) uid := embyEffectiveUserID(c)
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20")) limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, limit) startIndex, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
// ParentId / SeriesId 收窄到当前库或当前剧,避免详情页继续播放串到全站历史。
parentID := firstQueryValue(c, "ParentId", "parentId", "parentid", "SeriesId", "seriesId", "seriesid")
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, parentID, limit, startIndex)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
+3
View File
@@ -198,6 +198,9 @@ func registerEmbyAuthenticatedItemRoutes(auth *gin.RouterGroup, svc *service.Con
auth.GET("/Users/:userId/Shows/:id/Episodes", embyShowEpisodesHandler(svc)) auth.GET("/Users/:userId/Shows/:id/Episodes", embyShowEpisodesHandler(svc))
auth.GET("/Shows/NextUp", embyNextUpHandler(svc)) auth.GET("/Shows/NextUp", embyNextUpHandler(svc))
auth.GET("/Users/:userId/Shows/NextUp", embyNextUpHandler(svc)) auth.GET("/Users/:userId/Shows/NextUp", embyNextUpHandler(svc))
// 部分客户端用路径形式 /Shows/{seriesId}/NextUp,而不是 query SeriesId。
auth.GET("/Shows/:id/NextUp", embyShowNextUpHandler(svc))
auth.GET("/Users/:userId/Shows/:id/NextUp", embyShowNextUpHandler(svc))
auth.GET("/MediaSegments/:id", embyMediaSegmentsHandler(svc)) auth.GET("/MediaSegments/:id", embyMediaSegmentsHandler(svc))
auth.GET("/Items/:id/MediaSegments", embyMediaSegmentsHandler(svc)) auth.GET("/Items/:id/MediaSegments", embyMediaSegmentsHandler(svc))
auth.GET("/Users/:userId/Items/:id/MediaSegments", embyMediaSegmentsHandler(svc)) auth.GET("/Users/:userId/Items/:id/MediaSegments", embyMediaSegmentsHandler(svc))
@@ -42,6 +42,8 @@ func registerLowercaseEmbyItemRoutes(auth *gin.RouterGroup, svc *service.Contain
auth.GET("/users/:userId/shows/:id/episodes", embyShowEpisodesHandler(svc)) auth.GET("/users/:userId/shows/:id/episodes", embyShowEpisodesHandler(svc))
auth.GET("/shows/nextup", embyNextUpHandler(svc)) auth.GET("/shows/nextup", embyNextUpHandler(svc))
auth.GET("/users/:userId/shows/nextup", embyNextUpHandler(svc)) auth.GET("/users/:userId/shows/nextup", embyNextUpHandler(svc))
auth.GET("/shows/:id/nextup", embyShowNextUpHandler(svc))
auth.GET("/users/:userId/shows/:id/nextup", embyShowNextUpHandler(svc))
auth.GET("/mediasegments/:id", embyMediaSegmentsHandler(svc)) auth.GET("/mediasegments/:id", embyMediaSegmentsHandler(svc))
auth.GET("/items/:id/mediasegments", embyMediaSegmentsHandler(svc)) auth.GET("/items/:id/mediasegments", embyMediaSegmentsHandler(svc))
auth.GET("/users/:userId/items/:id/mediasegments", embyMediaSegmentsHandler(svc)) auth.GET("/users/:userId/items/:id/mediasegments", embyMediaSegmentsHandler(svc))
+42
View File
@@ -0,0 +1,42 @@
package model
import "time"
// MediaProbe 是一次 ffprobe 全量探测(容器 / 轨道 / 内嵌章节)的结果缓存。
//
// 它存在的理由:探测一次要 3~4 秒——远端直链更慢,因为要跨洋跑三次 HTTP
// 事务。播放链路绝不能等它,所以第一次播放只起后台任务,结果落库后由后续请求
// 与「跳过片头」的章节数据直接读库。
//
// Payload 刻意只保存裁剪后的字段:ffprobe 原始输出里的 format.filename 是解析
// 后的播放直链(带网盘签名与 pickcode),原样落库等于把可直接下载的链接留在
// 数据库里,所以只保留与技术信息有关的字段。
type MediaProbe struct {
Base
MediaID string `gorm:"uniqueIndex;size:128;not null" json:"media_id"`
// Signature 是「探的是哪个文件」的指纹(哈希):本地文件取路径 + 大小 +
// 修改时间,STRM / 云盘取固化的播放目标。文件换了就说明缓存不再对应当前
// 内容,需要重探。
Signature string `gorm:"size:64" json:"signature,omitempty"`
// Source 记录输入形态:local | strm。
Source string `gorm:"size:16" json:"source,omitempty"`
Container string `gorm:"size:64" json:"container,omitempty"`
DurationSec int `json:"duration_sec"`
BitRate int64 `json:"bit_rate,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
VideoCodec string `gorm:"size:64" json:"video_codec,omitempty"`
AudioCodec string `gorm:"size:64" json:"audio_codec,omitempty"`
VideoStreams int `json:"video_streams"`
AudioStreams int `json:"audio_streams"`
SubtitleStreams int `json:"subtitle_streams"`
ChapterCount int `json:"chapter_count"`
// Payload 是供详情页展示的裁剪后 JSON(容器 + 每路轨道 + 章节)。
Payload string `gorm:"type:text" json:"payload,omitempty"`
// ProbedAt 是最近一次探测的时刻。LastError 非空表示这次探测失败;失败只更新
// 这两个字段,不会覆盖此前成功的 Payload 与已经落库的章节片段。
ProbedAt time.Time `json:"probed_at"`
LastError string `gorm:"size:512" json:"last_error,omitempty"`
}
+1
View File
@@ -40,6 +40,7 @@ func AllModels() []interface{} {
&PlaybackHistory{}, &PlaybackHistory{},
&MediaSegment{}, &MediaSegment{},
&MediaSegmentFetch{}, &MediaSegmentFetch{},
&MediaProbe{},
&Favorite{}, &Favorite{},
&Playlist{}, &Playlist{},
&PlaylistItem{}, &PlaylistItem{},
+2 -2
View File
@@ -37,8 +37,8 @@ type User struct {
// original / simplified / traditional。 // original / simplified / traditional。
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"` 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 记录用户是否已经看过 VR 全景播放的首次操作说明,
// 按用户保存:看过一次之后不再弹出。 // 按用户保存:看过一次之后不再弹出。
PlayerVr360GuideSeen bool `gorm:"not null;default:false" json:"player_vr360_guide_seen"` PlayerVr360GuideSeen bool `gorm:"not null;default:false" json:"player_vr360_guide_seen"`
@@ -0,0 +1,61 @@
package repository
import (
"context"
"errors"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/truewhile/MeBox/internal/model"
)
// MediaProbeRepository 持久化 ffprobe 全量探测的结果缓存。
type MediaProbeRepository struct{ db *gorm.DB }
// Get 返回某媒体的探测缓存,未探测过时返回 (nil, nil)。
func (r *MediaProbeRepository) Get(ctx context.Context, mediaID string) (*model.MediaProbe, error) {
var row model.MediaProbe
err := r.db.WithContext(ctx).
Where("media_id = ?", mediaID).
First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
// Upsert 写入一次成功的探测结果(含裁剪后的媒体信息)。
func (r *MediaProbeRepository) Upsert(ctx context.Context, row *model.MediaProbe) error {
onConflict := clause.OnConflict{
Columns: []clause.Column{{Name: "media_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"signature", "source",
"container", "duration_sec", "bit_rate",
"width", "height", "video_codec", "audio_codec",
"video_streams", "audio_streams", "subtitle_streams", "chapter_count",
"payload", "probed_at", "last_error",
"deleted_at",
}),
}
return r.db.WithContext(ctx).Clauses(onConflict).Create(row).Error
}
// MarkFailure 记录一次失败的探测。它只更新「时间 + 错误信息」,刻意不碰
// payload 与其它字段:一次失败的重探不该把上一次成功拿到的媒体信息抹掉。
func (r *MediaProbeRepository) MarkFailure(ctx context.Context, mediaID, message string, probedAt time.Time) error {
row := model.MediaProbe{MediaID: mediaID, ProbedAt: probedAt, LastError: message}
onConflict := clause.OnConflict{
Columns: []clause.Column{{Name: "media_id"}},
DoUpdates: clause.Assignments(map[string]any{
"probed_at": probedAt,
"last_error": message,
"deleted_at": nil,
}),
}
return r.db.WithContext(ctx).Clauses(onConflict).Create(&row).Error
}
@@ -0,0 +1,105 @@
package repository
import (
"testing"
"time"
"github.com/truewhile/MeBox/internal/model"
)
func TestMediaProbeUpsertReplacesAndGetReturnsLatest(t *testing.T) {
repos := newSegmentTestRepos(t)
ctx := t.Context()
first := &model.MediaProbe{
MediaID: "m-1", Signature: "sig-1", Source: "local",
Container: "matroska,webm", DurationSec: 1451, ChapterCount: 2,
Payload: `{"container":"matroska,webm"}`, ProbedAt: time.Now(),
}
if err := repos.MediaProbe.Upsert(ctx, first); err != nil {
t.Fatalf("upsert #1: %v", err)
}
second := &model.MediaProbe{
MediaID: "m-1", Signature: "sig-2", Source: "strm",
Container: "mp4", DurationSec: 900, ChapterCount: 0,
Payload: `{"container":"mp4"}`, ProbedAt: time.Now(),
}
if err := repos.MediaProbe.Upsert(ctx, second); err != nil {
t.Fatalf("upsert #2: %v", err)
}
got, err := repos.MediaProbe.Get(ctx, "m-1")
if err != nil {
t.Fatal(err)
}
if got == nil {
t.Fatal("probe row missing")
}
if got.Container != "mp4" || got.DurationSec != 900 || got.Signature != "sig-2" || got.Payload != `{"container":"mp4"}` {
t.Fatalf("probe = %#v, want the second upsert to win", got)
}
// 重复探测不能累积重复行:media_id 是唯一索引。
var count int64
if err := repos.DB.Model(&model.MediaProbe{}).Where("media_id = ?", "m-1").Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("rows = %d, want 1", count)
}
}
func TestMediaProbeGetReturnsNilWhenMissing(t *testing.T) {
repos := newSegmentTestRepos(t)
got, err := repos.MediaProbe.Get(t.Context(), "nope")
if err != nil {
t.Fatalf("Get: %v", err)
}
if got != nil {
t.Fatalf("probe = %#v, want nil", got)
}
}
func TestMediaProbeMarkFailureKeepsPreviousPayload(t *testing.T) {
repos := newSegmentTestRepos(t)
ctx := t.Context()
if err := repos.MediaProbe.Upsert(ctx, &model.MediaProbe{
MediaID: "m-1", Container: "matroska,webm", DurationSec: 1451,
ChapterCount: 2, Payload: `{"container":"matroska,webm"}`, ProbedAt: time.Now(),
}); err != nil {
t.Fatal(err)
}
if err := repos.MediaProbe.MarkFailure(ctx, "m-1", "ffprobe full: exit status 1", time.Now()); err != nil {
t.Fatal(err)
}
got, err := repos.MediaProbe.Get(ctx, "m-1")
if err != nil {
t.Fatal(err)
}
if got == nil || got.LastError != "ffprobe full: exit status 1" {
t.Fatalf("probe = %#v, want the recorded failure", got)
}
// 一次失败的重探不该把上一次成功拿到的媒体信息抹掉。
if got.Container != "matroska,webm" || got.DurationSec != 1451 || got.Payload == "" || got.ChapterCount != 2 {
t.Fatalf("a failed re-probe wiped the previous summary: %#v", got)
}
}
func TestMediaProbeMarkFailureInsertsRowWhenAbsent(t *testing.T) {
repos := newSegmentTestRepos(t)
ctx := t.Context()
// 没有历史成功记录时,失败也必须落一行:否则冷却期没有时间戳,
// 每次播放都会重跑一次注定失败的探测。
if err := repos.MediaProbe.MarkFailure(ctx, "m-2", "boom", time.Now()); err != nil {
t.Fatal(err)
}
got, err := repos.MediaProbe.Get(ctx, "m-2")
if err != nil {
t.Fatal(err)
}
if got == nil || got.LastError != "boom" || got.ProbedAt.IsZero() {
t.Fatalf("probe = %#v, want a failure row with a timestamp", got)
}
}
+25
View File
@@ -232,6 +232,31 @@ func (r *MediaRepository) ExistsSiblingWithTMDbID(ctx context.Context, m *model.
return count > 0 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. // ListByLibrary returns paginated media items for a library.
func (r *MediaRepository) ListByLibrary(ctx context.Context, libraryID string, offset, limit int) ([]model.Media, int64, error) { 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}) return r.ListByLibraryFiltered(ctx, libraryID, offset, limit, MediaQueryFilter{IncludeNSFW: true})
@@ -3,6 +3,7 @@ package repository
import ( import (
"context" "context"
"errors" "errors"
"time"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -23,6 +24,18 @@ func (r *MediaSegmentRepository) ListByMedia(ctx context.Context, mediaID string
return rows, err return rows, err
} }
// ListByMediaSource returns the segments contributed by one source only, ordered
// by start. Used to read the ffprobe-extracted chapters independently of the
// community-database rows, so the player can pick between them.
func (r *MediaSegmentRepository) ListByMediaSource(ctx context.Context, mediaID, source string) ([]model.MediaSegment, error) {
rows := make([]model.MediaSegment, 0, 4)
err := r.db.WithContext(ctx).
Where("media_id = ? AND source = ?", mediaID, source).
Order("start_ms asc").
Find(&rows).Error
return rows, err
}
// ReplaceForMedia swaps the segments contributed by one source in a single // ReplaceForMedia swaps the segments contributed by one source in a single
// transaction, so a provider refresh can never leave a half-updated set. // transaction, so a provider refresh can never leave a half-updated set.
// //
@@ -72,3 +85,45 @@ func (r *MediaSegmentRepository) UpsertFetch(ctx context.Context, row *model.Med
} }
return r.db.WithContext(ctx).Clauses(onConflict).Create(row).Error 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) 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)
}
}
+2
View File
@@ -16,6 +16,7 @@ type Container struct {
Series *SeriesRepository Series *SeriesRepository
History *HistoryRepository History *HistoryRepository
MediaSegment *MediaSegmentRepository MediaSegment *MediaSegmentRepository
MediaProbe *MediaProbeRepository
Favorite *FavoriteRepository Favorite *FavoriteRepository
Playlist *PlaylistRepository Playlist *PlaylistRepository
Setting *SettingRepository Setting *SettingRepository
@@ -47,6 +48,7 @@ func New(db *gorm.DB) *Container {
Series: &SeriesRepository{db: db}, Series: &SeriesRepository{db: db},
History: &HistoryRepository{db: db}, History: &HistoryRepository{db: db},
MediaSegment: &MediaSegmentRepository{db: db}, MediaSegment: &MediaSegmentRepository{db: db},
MediaProbe: &MediaProbeRepository{db: db},
Favorite: &FavoriteRepository{db: db}, Favorite: &FavoriteRepository{db: db},
Playlist: &PlaylistRepository{db: db}, Playlist: &PlaylistRepository{db: db},
Setting: &SettingRepository{db: db}, Setting: &SettingRepository{db: db},
+9 -36
View File
@@ -49,12 +49,6 @@ const (
// dandanplay protocol (search/episodes + comment/{episodeId}) may be used. // dandanplay protocol (search/episodes + comment/{episodeId}) may be used.
const DanmakuDefaultSource = "https://api.dandanplay.net" 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 // danmakuOfficialBase is where identification (/api/v2/match) and the
// comment/search fallback always go, regardless of the configured source. // 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. // 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"` FontSize string `json:"font_size"`
Area string `json:"area"` Area string `json:"area"`
Volume float64 `json:"volume"` Volume float64 `json:"volume"`
// PlaybackRate 是当前用户的播放倍速偏好(按用户存储)。
PlaybackRate float64 `json:"playback_rate"`
// Vr360GuideSeen 表示当前用户是否已经看过 VR 全景播放的首次操作说明。 // Vr360GuideSeen 表示当前用户是否已经看过 VR 全景播放的首次操作说明。
Vr360GuideSeen bool `json:"vr360_guide_seen"` Vr360GuideSeen bool `json:"vr360_guide_seen"`
// MergeSources 是当前用户的弹幕合并偏好(按用户存储)。 // MergeSources 是当前用户的弹幕合并偏好(按用户存储)。
@@ -99,7 +91,6 @@ type DanmakuSettingsPatch struct {
Area *float64 `json:"area"` Area *float64 `json:"area"`
MergeSources *bool `json:"merge_sources"` MergeSources *bool `json:"merge_sources"`
Volume *float64 `json:"volume"` Volume *float64 `json:"volume"`
PlaybackRate *float64 `json:"playback_rate"`
// Vr360GuideSeen 标记 VR 全景播放的首次操作说明是否已经看过。 // Vr360GuideSeen 标记 VR 全景播放的首次操作说明是否已经看过。
Vr360GuideSeen *bool `json:"vr360_guide_seen"` Vr360GuideSeen *bool `json:"vr360_guide_seen"`
} }
@@ -358,11 +349,10 @@ func cloneDanmakuAnimeList(in []DanmakuAnime) []DanmakuAnime {
// Config reads danmaku settings from the runtime settings table. // Config reads danmaku settings from the runtime settings table.
func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig { func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
cfg := DanmakuRenderConfig{ cfg := DanmakuRenderConfig{
Opacity: "1", Opacity: "1",
FontSize: "24", FontSize: "24",
Area: "1", Area: "1",
Volume: 1, Volume: 1,
PlaybackRate: defaultPlayerPlaybackRate,
} }
if s == nil || s.repo == nil || s.repo.Setting == nil { if s == nil || s.repo == nil || s.repo.Setting == nil {
return cfg return cfg
@@ -414,12 +404,11 @@ func (s *DanmakuService) findUser(ctx context.Context, userID string) (*model.Us
func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig { func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
if user == nil { if user == nil {
return DanmakuRenderConfig{ return DanmakuRenderConfig{
Enabled: true, Enabled: true,
Opacity: "1", Opacity: "1",
FontSize: "24", FontSize: "24",
Area: "1", Area: "1",
Volume: 1, Volume: 1,
PlaybackRate: defaultPlayerPlaybackRate,
} }
} }
opacity := user.DanmakuOpacity opacity := user.DanmakuOpacity
@@ -438,10 +427,6 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
if volume < 0 || volume > 1 { if volume < 0 || volume > 1 {
volume = 1 volume = 1
} }
playbackRate := user.PlayerPlaybackRate
if playbackRate < minPlayerPlaybackRate || playbackRate > maxPlayerPlaybackRate {
playbackRate = defaultPlayerPlaybackRate
}
return DanmakuRenderConfig{ return DanmakuRenderConfig{
Enabled: user.DanmakuEnabled, Enabled: user.DanmakuEnabled,
Source: strings.TrimSpace(user.DanmakuSource), Source: strings.TrimSpace(user.DanmakuSource),
@@ -450,7 +435,6 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
FontSize: strconv.Itoa(fontSize), FontSize: strconv.Itoa(fontSize),
Area: strconv.FormatFloat(area, 'f', -1, 64), Area: strconv.FormatFloat(area, 'f', -1, 64),
Volume: volume, Volume: volume,
PlaybackRate: playbackRate,
Vr360GuideSeen: user.PlayerVr360GuideSeen, Vr360GuideSeen: user.PlayerVr360GuideSeen,
MergeSources: user.DanmakuMergeSources, MergeSources: user.DanmakuMergeSources,
AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "", AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "",
@@ -522,17 +506,6 @@ func (s *DanmakuService) UpdateUserSettings(ctx context.Context, userID string,
} }
updates["player_volume"] = *patch.Volume 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 { if patch.Vr360GuideSeen != nil {
updates["player_vr360_guide_seen"] = *patch.Vr360GuideSeen updates["player_vr360_guide_seen"] = *patch.Vr360GuideSeen
} }
-2
View File
@@ -422,7 +422,6 @@ func TestDanmakuFetchUsesPerUserSourceAndCredentials(t *testing.T) {
user.DanmakuFontSize = 30 user.DanmakuFontSize = 30
user.DanmakuArea = 0.7 user.DanmakuArea = 0.7
user.PlayerVolume = 0.42 user.PlayerVolume = 0.42
user.PlayerPlaybackRate = 1.25
require.NoError(t, svc.repo.User.Create(ctx, &user)) require.NoError(t, svc.repo.User.Create(ctx, &user))
seedDanmakuMedia(t, svc, "per-user-media", "测试动画", "", 0) seedDanmakuMedia(t, svc, "per-user-media", "测试动画", "", 0)
@@ -431,7 +430,6 @@ func TestDanmakuFetchUsesPerUserSourceAndCredentials(t *testing.T) {
require.Contains(t, res.Raw, "弹幕A") require.Contains(t, res.Raw, "弹幕A")
require.Equal(t, srv.URL(), res.Source) require.Equal(t, srv.URL(), res.Source)
require.Equal(t, 0.42, res.Volume) require.Equal(t, 0.42, res.Volume)
require.Equal(t, 1.25, res.PlaybackRate)
require.Equal(t, "0.65", res.Opacity) require.Equal(t, "0.65", res.Opacity)
require.Equal(t, "30", res.FontSize) require.Equal(t, "30", res.FontSize)
require.Equal(t, srv.lastHeaders.Get("X-AppId"), "user-app-id") require.Equal(t, srv.lastHeaders.Get("X-AppId"), "user-app-id")
+143 -1
View File
@@ -6,6 +6,8 @@ import (
"encoding/hex" "encoding/hex"
"strings" "strings"
"time" "time"
"github.com/truewhile/MeBox/internal/model"
) )
// Emby 发现类接口:NextUp / Similar / Genres。 // Emby 发现类接口:NextUp / Similar / Genres。
@@ -21,7 +23,8 @@ const (
) )
// NextUp 返回「每部在看的剧的下一集」,即 Emby 客户端首页「接下来播放」的数据源。 // NextUp 返回「每部在看的剧的下一集」,即 Emby 客户端首页「接下来播放」的数据源。
func (e *EmbyService) NextUp(ctx context.Context, userID string, limit int) (map[string]any, error) { // seriesID 非空时只返回该剧的下一集(剧集详情页「继续播放」);空则返回全站列表。
func (e *EmbyService) NextUp(ctx context.Context, userID, seriesID string, limit int) (map[string]any, error) {
if limit <= 0 { if limit <= 0 {
limit = embyNextUpDefaultLimit limit = embyNextUpDefaultLimit
} }
@@ -31,11 +34,16 @@ func (e *EmbyService) NextUp(ctx context.Context, userID string, limit int) (map
if strings.TrimSpace(userID) == "" { if strings.TrimSpace(userID) == "" {
return emptyItemsEnvelope(0), nil return emptyItemsEnvelope(0), nil
} }
seriesID = strings.TrimSpace(seriesID)
discovery := e.discoveryService() discovery := e.discoveryService()
if discovery == nil { if discovery == nil {
return emptyItemsEnvelope(0), nil return emptyItemsEnvelope(0), nil
} }
if seriesID != "" {
return e.nextUpForSeries(ctx, userID, seriesID, limit)
}
rows, err := discovery.NextUpCandidates(ctx, userID, limit, e.mediaVisibility(ctx, userID)) rows, err := discovery.NextUpCandidates(ctx, userID, limit, e.mediaVisibility(ctx, userID))
if err != nil { if err != nil {
return nil, err return nil, err
@@ -52,6 +60,140 @@ func (e *EmbyService) NextUp(ctx context.Context, userID string, limit int) (map
}, nil }, nil
} }
// nextUpForSeries 只解析指定剧的下一集。远程挂载剧集按本机播放历史 + 远程
// 分集列表计算,避免把其它本地剧的 NextUp 塞进详情页继续播放按钮。
func (e *EmbyService) nextUpForSeries(ctx context.Context, userID, seriesID string, limit int) (map[string]any, error) {
if IsEmbyRemoteID(seriesID) {
return e.nextUpForRemoteSeries(ctx, userID, seriesID, limit)
}
discovery := e.discoveryService()
if discovery == nil {
return emptyItemsEnvelope(0), nil
}
// 多取候选再按 SeriesId 精确过滤,避免「全站 TopN」把目标剧挤掉。
scanLimit := embyNextUpMaxLimit
if limit > scanLimit {
scanLimit = limit
}
rows, err := discovery.NextUpCandidates(ctx, userID, scanLimit, e.mediaVisibility(ctx, userID))
if err != nil {
return nil, err
}
items, err := e.payloadsForMedia(ctx, rows, userID)
if err != nil {
return nil, err
}
filtered := make([]map[string]any, 0, 1)
for _, item := range items {
itemSeries, _ := item["SeriesId"].(string)
if itemSeries == seriesID {
filtered = append(filtered, item)
if len(filtered) >= limit {
break
}
}
}
return map[string]any{
"Items": filtered,
"TotalRecordCount": int64(len(filtered)),
}, nil
}
// nextUpForRemoteSeries 用 MeBox 本地播放历史在远程剧的分集里找「下一集」。
// 不透传远程账号的 NextUp,避免多用户共用挂载账号时串进度。
func (e *EmbyService) nextUpForRemoteSeries(ctx context.Context, userID, seriesID string, limit int) (map[string]any, error) {
if e == nil || e.remote == nil || strings.TrimSpace(userID) == "" {
return emptyItemsEnvelope(0), nil
}
if limit <= 0 {
limit = 1
}
mountID, remoteSeriesID, ok := DecodeEmbyRemoteID(seriesID)
if !ok {
return emptyItemsEnvelope(0), nil
}
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
if err != nil || mount == nil || acct == nil {
return emptyItemsEnvelope(0), nil
}
if !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
return emptyItemsEnvelope(0), nil
}
prefix := EmbyRemoteIDPrefix + mountID + "~"
var hist []model.PlaybackHistory
if err := e.repo.DB.WithContext(ctx).
Where("user_id = ? AND position_ms > 0 AND media_id LIKE ?", userID, prefix+"%").
Order("watched_at desc").
Limit(nextUpHistoryScanLimit).
Find(&hist).Error; err != nil {
return nil, err
}
if len(hist) == 0 {
return emptyItemsEnvelope(0), nil
}
episodes, err := e.remote.RemoteEpisodes(ctx, mount, acct, remoteSeriesID)
if err != nil || len(episodes) == 0 {
return emptyItemsEnvelope(0), nil
}
epByID := make(map[string]*model.Media, len(episodes))
for i := range episodes {
epByID[episodes[i].ID] = &episodes[i]
}
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
}
}
if current == nil {
return emptyItemsEnvelope(0), nil
}
completed := map[string]bool{}
epIDs := make([]string, 0, len(episodes))
for i := range episodes {
epIDs = append(epIDs, episodes[i].ID)
}
var done []model.PlaybackHistory
if err := e.repo.DB.WithContext(ctx).
Where("user_id = ? AND completed = ? AND media_id IN ?", userID, true, epIDs).
Find(&done).Error; err == nil {
for _, h := range done {
completed[h.MediaID] = true
}
}
next, ok := pickNextEpisode(episodes, current, currentCompleted, completed)
if !ok {
return emptyItemsEnvelope(0), nil
}
_, remoteEpID, ok := DecodeEmbyRemoteID(next.ID)
if !ok {
return emptyItemsEnvelope(0), nil
}
item, err := e.remote.RemoteItem(ctx, mount, acct, remoteEpID)
if err != nil || item == nil {
return emptyItemsEnvelope(0), nil
}
if err := e.mergeRemoteUserData(ctx, userID, item); err != nil {
return nil, err
}
items := []map[string]any{item}
if limit < len(items) {
items = items[:limit]
}
return map[string]any{
"Items": items,
"TotalRecordCount": int64(len(items)),
}, nil
}
// SimilarItems 返回与指定条目相似的本地媒体。 // SimilarItems 返回与指定条目相似的本地媒体。
// //
// 找不到条目(或该条目对当前用户不可见)时返回空列表而不是错误:客户端会在 // 找不到条目(或该条目对当前用户不可见)时返回空列表而不是错误:客户端会在
+33 -3
View File
@@ -299,8 +299,14 @@ func embyLatestSeriesRowLimit(limit int) int {
} }
// ResumeItems 列出有未完成播放进度的媒体。 // ResumeItems 列出有未完成播放进度的媒体。
func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int) (map[string]any, error) { // parentID 非空时收窄到该库 / 该剧(含虚拟 msgo-series-* ID)。
return e.resumableItems(ctx, ItemsParams{UserID: userID, Limit: limit}) func (e *EmbyService) ResumeItems(ctx context.Context, userID, parentID string, limit, startIndex int) (map[string]any, error) {
return e.resumableItems(ctx, ItemsParams{
UserID: userID,
ParentID: strings.TrimSpace(parentID),
Limit: limit,
StartIndex: startIndex,
})
} }
// favoriteItems returns favourited media for Emby clients, including mounted // favoriteItems returns favourited media for Emby clients, including mounted
@@ -434,6 +440,30 @@ func favoriteMatchesParent(ctx context.Context, e *EmbyService, parentID, mediaI
return wantMountID != "" && gotMountID == wantMountID return wantMountID != "" && gotMountID == wantMountID
} }
// resumeMatchesParent 判断续播条目是否属于 ParentId / SeriesId 作用域。
// 本地剧集的 series_id 常为空,实际对外 ID 是 msgo-series-* 虚拟 ID,必须用
// seriesIDForMedia 对齐,否则按剧收窄永远匹配不上。
func resumeMatchesParent(ctx context.Context, e *EmbyService, parentID, libraryID, seriesID string, m *model.Media) bool {
if parentID == "" {
return true
}
if libraryID == parentID || seriesID == parentID {
return true
}
if m != nil && e.seriesIDForMedia(ctx, m) == parentID {
return true
}
if m != nil && e.seasonIDForMedia(ctx, m) == parentID {
return true
}
for _, id := range e.mergedLibraryIDs(ctx, parentID) {
if id == libraryID {
return true
}
}
return false
}
// resumableItems 返回未完成播放进度的媒体(包含本地媒体与挂载的远程媒体),支持分页。 // resumableItems 返回未完成播放进度的媒体(包含本地媒体与挂载的远程媒体),支持分页。
func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[string]any, error) { func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
if p.Limit <= 0 || p.Limit > 100 { if p.Limit <= 0 || p.Limit > 100 {
@@ -500,7 +530,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
localTotal, remoteTotal := 0, 0 localTotal, remoteTotal := 0, 0
for _, h := range hist { for _, h := range hist {
if m, ok := byID[h.MediaID]; ok { if m, ok := byID[h.MediaID]; ok {
if p.ParentID != "" && m.LibraryID != p.ParentID && m.SeriesID != p.ParentID { if p.ParentID != "" && !resumeMatchesParent(ctx, e, p.ParentID, m.LibraryID, m.SeriesID, m) {
continue continue
} }
localTotal++ localTotal++
+240
View File
@@ -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)
}
}
+283
View File
@@ -0,0 +1,283 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"os/exec"
"strconv"
"strings"
"time"
)
// ffprobeFullTimeout 是一次全量探测(含章节)的超时。远端直链实测 3~4 秒,
// 留足余量的同时不能让一个坏源把工作协程永久占住。
const ffprobeFullTimeout = 60 * time.Second
// ProbeInput 是喂给 ffprobe 的输入:本地文件给路径,STRM / 云盘给已解析的最终
// 直链加绑定请求头。解析由调用方负责(复用播放链路的换链逻辑),否则会踩到
// 115 CDN 的防盗链 403。
type ProbeInput struct {
Source string
Headers map[string]string
}
// ProbeStream 是一路轨道的关键字段,供详情页展示。
type ProbeStream struct {
Index int `json:"index"`
Type string `json:"type"`
Codec string `json:"codec,omitempty"`
Profile string `json:"profile,omitempty"`
Language string `json:"language,omitempty"`
Title string `json:"title,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
PixFmt string `json:"pix_fmt,omitempty"`
FrameRate string `json:"frame_rate,omitempty"`
Channels int `json:"channels,omitempty"`
ChannelLayout string `json:"channel_layout,omitempty"`
SampleRate int `json:"sample_rate,omitempty"`
BitRate int64 `json:"bit_rate,omitempty"`
Default bool `json:"default,omitempty"`
Forced bool `json:"forced,omitempty"`
}
// ProbeChapter 是一个内嵌章节区间。
type ProbeChapter struct {
Index int `json:"index"`
StartMs int64 `json:"start_ms"`
EndMs int64 `json:"end_ms"`
Title string `json:"title,omitempty"`
}
// FullProbeResult 是一次全量探测的裁剪结果。
type FullProbeResult struct {
Container string
DurationSec int
BitRate int64
Streams []ProbeStream
Chapters []ProbeChapter
}
// mediaProbePayload 是落库的媒体信息结构。它只包含白名单字段——刻意不接受
// ffprobe 的原始 JSON,因为其中的 format.filename 是播放直链(含签名与
// pickcode),原样保存会外泄。
type mediaProbePayload struct {
Container string `json:"container,omitempty"`
DurationSec int `json:"duration_sec"`
BitRate int64 `json:"bit_rate,omitempty"`
Streams []ProbeStream `json:"streams"`
Chapters []ProbeChapter `json:"chapters,omitempty"`
}
// StreamsOfType 返回指定类型的轨道,供详情页分组展示。
func (r *FullProbeResult) StreamsOfType(kind string) []ProbeStream {
if r == nil {
return nil
}
out := make([]ProbeStream, 0, len(r.Streams))
for _, s := range r.Streams {
if s.Type == kind {
out = append(out, s)
}
}
return out
}
// PayloadJSON 序列化落库用的媒体信息。
func (r *FullProbeResult) PayloadJSON() (string, error) {
if r == nil {
return "", nil
}
streams := r.Streams
if streams == nil {
streams = []ProbeStream{}
}
body, err := json.Marshal(mediaProbePayload{
Container: r.Container,
DurationSec: r.DurationSec,
BitRate: r.BitRate,
Streams: streams,
Chapters: r.Chapters,
})
if err != nil {
return "", err
}
return string(body), nil
}
// ProbeFull 跑一次全量探测:容器信息 + 全部轨道 + 内嵌章节。
//
// 与 Probe 的区别:Probe 只取扫描需要的几个字段、对着媒体行的本地路径跑;
// ProbeFull 接受调用方解析好的输入(远端直链 + 绑定请求头),并额外抓章节。
func (f *FFprobeService) ProbeFull(ctx context.Context, input ProbeInput) (*FullProbeResult, error) {
if f == nil || f.cfg == nil {
return nil, errors.New("ffprobe service nil")
}
source := strings.TrimSpace(input.Source)
if source == "" {
return nil, errors.New("empty probe source")
}
token, err := f.acquire(ctx)
if err != nil {
return nil, err
}
defer f.release(token)
bin, err := resolveLocalExecutable(f.cfg.App.FFprobePath, "ffprobe")
if err != nil {
return nil, fmt.Errorf("ffprobe unavailable: %w", err)
}
probeCtx, cancel := context.WithTimeout(ctx, ffprobeFullTimeout)
defer cancel()
args := []string{"-v", "error"}
if headerText := ffmpegHeaderText(input.Headers); headerText != "" {
args = append(args, "-headers", headerText)
}
args = append(args,
"-print_format", "json",
"-show_format",
"-show_streams",
"-show_chapters",
source,
)
out, err := exec.CommandContext(probeCtx, bin, args...).Output() // #nosec G204 -- bin is resolved by resolveLocalExecutable before execution.
if err != nil {
return nil, fmt.Errorf("ffprobe full: %w", err)
}
return parseFullProbeJSON(out)
}
// probeNumber 兼容 ffprobe 把数值输出成字符串或裸数字两种形态
// (duration 是字符串,chapter 的 start_time 也可能是数字)。解析不出来就保持
// 零值:这些字段都只是展示用,不该因为一个格式差异让整次探测失败。
type probeNumber float64
func (p *probeNumber) UnmarshalJSON(data []byte) error {
text := strings.TrimSpace(strings.Trim(string(data), `"`))
if text == "" || text == "null" {
return nil
}
value, err := strconv.ParseFloat(text, 64)
if err != nil {
return nil
}
*p = probeNumber(value)
return nil
}
func (p probeNumber) float() float64 { return float64(p) }
func (p probeNumber) int() int { return int(float64(p)) }
func (p probeNumber) int64() int64 { return int64(float64(p)) }
// rawFullProbe 镜像 ffprobe -show_format -show_streams -show_chapters 的输出。
// 只声明用得到的字段;format.filename 刻意不声明,避免它进入任何落库路径。
type rawFullProbe struct {
Format struct {
FormatName string `json:"format_name"`
Duration probeNumber `json:"duration"`
BitRate probeNumber `json:"bit_rate"`
} `json:"format"`
Streams []struct {
Index int `json:"index"`
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
Profile string `json:"profile"`
Width int `json:"width"`
Height int `json:"height"`
PixFmt string `json:"pix_fmt"`
AvgFrameRate string `json:"avg_frame_rate"`
Channels int `json:"channels"`
ChannelLayout string `json:"channel_layout"`
SampleRate probeNumber `json:"sample_rate"`
BitRate probeNumber `json:"bit_rate"`
Tags struct {
Language string `json:"language"`
Title string `json:"title"`
} `json:"tags"`
Disposition struct {
Default int `json:"default"`
Forced int `json:"forced"`
} `json:"disposition"`
} `json:"streams"`
Chapters []struct {
StartTime probeNumber `json:"start_time"`
EndTime probeNumber `json:"end_time"`
Tags struct {
Title string `json:"title"`
} `json:"tags"`
} `json:"chapters"`
}
func parseFullProbeJSON(data []byte) (*FullProbeResult, error) {
var raw rawFullProbe
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parse ffprobe json: %w", err)
}
result := &FullProbeResult{
Container: strings.TrimSpace(raw.Format.FormatName),
DurationSec: raw.Format.Duration.int(),
BitRate: raw.Format.BitRate.int64(),
Streams: make([]ProbeStream, 0, len(raw.Streams)),
Chapters: make([]ProbeChapter, 0, len(raw.Chapters)),
}
for _, s := range raw.Streams {
stream := ProbeStream{
Index: s.Index,
Type: s.CodecType,
Codec: s.CodecName,
Profile: s.Profile,
Language: strings.TrimSpace(s.Tags.Language),
Title: strings.TrimSpace(s.Tags.Title),
Width: s.Width,
Height: s.Height,
PixFmt: s.PixFmt,
FrameRate: normalizeFrameRate(s.AvgFrameRate),
Channels: s.Channels,
ChannelLayout: s.ChannelLayout,
SampleRate: s.SampleRate.int(),
BitRate: s.BitRate.int64(),
Default: s.Disposition.Default != 0,
Forced: s.Disposition.Forced != 0,
}
result.Streams = append(result.Streams, stream)
}
for index, chapter := range raw.Chapters {
result.Chapters = append(result.Chapters, ProbeChapter{
Index: index,
StartMs: secondsToMillis(chapter.StartTime.float()),
EndMs: secondsToMillis(chapter.EndTime.float()),
Title: strings.TrimSpace(chapter.Tags.Title),
})
}
return result, nil
}
// secondsToMillis 把 ffprobe 的秒(浮点)转成毫秒整数。
func secondsToMillis(seconds float64) int64 {
if seconds <= 0 {
return 0
}
return int64(seconds*1000 + 0.5)
}
// normalizeFrameRate 把 "24000/1001" 这类分数帧率换算成可读形式;"0/0"
// (未知)返回空串。
func normalizeFrameRate(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
parts := strings.SplitN(raw, "/", 2)
if len(parts) != 2 {
return raw
}
num, errNum := strconv.ParseFloat(parts[0], 64)
den, errDen := strconv.ParseFloat(parts[1], 64)
if errNum != nil || errDen != nil || den == 0 || num <= 0 {
return ""
}
return strconv.FormatFloat(num/den, 'f', 3, 64)
}
+149
View File
@@ -0,0 +1,149 @@
package service
import (
"strings"
"testing"
)
const fullProbeFixture = `{
"format": {
"format_name": "matroska,webm",
"duration": "1451.024000",
"bit_rate": "8000000",
"filename": "https://cdn.example.com/secret/movie.mkv?d=vip-abc-pickcode&token=xyz"
},
"streams": [
{"index": 0, "codec_type": "video", "codec_name": "hevc", "profile": "Main 10",
"width": 3840, "height": 2160, "pix_fmt": "yuv420p10le", "avg_frame_rate": "24000/1001",
"bit_rate": "7800000", "disposition": {"default": 1}},
{"index": 1, "codec_type": "audio", "codec_name": "eac3", "channels": 6,
"channel_layout": "5.1(side)", "sample_rate": "48000",
"tags": {"language": "eng", "title": "Surround"}, "disposition": {"default": 1}},
{"index": 2, "codec_type": "subtitle", "codec_name": "ass",
"tags": {"language": "chi"}, "disposition": {"forced": 1}}
],
"chapters": [
{"start_time": "0.000000", "end_time": "95.000000", "tags": {"title": "Chapter 01"}},
{"start_time": "228.664000", "end_time": "246.143000", "tags": {"title": "Opening"}}
]
}`
func TestParseFullProbeJSONExtractsStreamsAndChapters(t *testing.T) {
got, err := parseFullProbeJSON([]byte(fullProbeFixture))
if err != nil {
t.Fatalf("parseFullProbeJSON: %v", err)
}
if got.Container != "matroska,webm" || got.DurationSec != 1451 || got.BitRate != 8_000_000 {
t.Fatalf("container/duration/bitrate = %q/%d/%d", got.Container, got.DurationSec, got.BitRate)
}
if len(got.Streams) != 3 {
t.Fatalf("streams = %#v, want 3", got.Streams)
}
video := got.Streams[0]
if video.Type != "video" || video.Codec != "hevc" || video.Width != 3840 || video.Height != 2160 {
t.Fatalf("video stream = %#v", video)
}
if video.FrameRate != "23.976" {
t.Fatalf("frame rate = %q, want 23.976 (converted from 24000/1001)", video.FrameRate)
}
if !video.Default {
t.Fatal("video stream should be flagged default")
}
audio := got.Streams[1]
if audio.Codec != "eac3" || audio.Language != "eng" || audio.Title != "Surround" || audio.Channels != 6 || audio.SampleRate != 48000 {
t.Fatalf("audio stream = %#v", audio)
}
sub := got.Streams[2]
if sub.Type != "subtitle" || sub.Language != "chi" || !sub.Forced {
t.Fatalf("subtitle stream = %#v", sub)
}
if len(got.Chapters) != 2 {
t.Fatalf("chapters = %#v, want 2", got.Chapters)
}
if got.Chapters[1].StartMs != 228_664 || got.Chapters[1].EndMs != 246_143 || got.Chapters[1].Title != "Opening" {
t.Fatalf("chapter[1] = %#v", got.Chapters[1])
}
if videoCount := len(got.StreamsOfType("video")); videoCount != 1 {
t.Fatalf("StreamsOfType(video) = %d, want 1", videoCount)
}
if audioCount := len(got.StreamsOfType("audio")); audioCount != 1 {
t.Fatalf("StreamsOfType(audio) = %d, want 1", audioCount)
}
}
// 落库的 payload 绝不能带 ffprobe 的 format.filename:那是解析后的播放直链,
// 里面是网盘签名和 pickcode,存进数据库等于把可直接下载的链接留下来。
func TestFullProbePayloadNeverLeaksSourceURL(t *testing.T) {
got, err := parseFullProbeJSON([]byte(fullProbeFixture))
if err != nil {
t.Fatalf("parseFullProbeJSON: %v", err)
}
payload, err := got.PayloadJSON()
if err != nil {
t.Fatalf("PayloadJSON: %v", err)
}
for _, needle := range []string{"filename", "cdn.example.com", "pickcode", "token=", "http"} {
if strings.Contains(payload, needle) {
t.Fatalf("payload 泄漏了 %q:\n%s", needle, payload)
}
}
// 技术信息本身必须保留。
for _, needle := range []string{"matroska,webm", "hevc", "3840", "Opening"} {
if !strings.Contains(payload, needle) {
t.Fatalf("payload 缺少 %q:\n%s", needle, payload)
}
}
}
// ffprobe 对 duration / start_time 有时输出字符串、有时输出裸数字,两种都要能读。
func TestParseFullProbeJSONAcceptsNumericAndStringTimes(t *testing.T) {
got, err := parseFullProbeJSON([]byte(`{
"format": {"format_name": "mp4", "duration": 125.5},
"streams": [],
"chapters": [{"start_time": 12.5, "end_time": 20}]
}`))
if err != nil {
t.Fatalf("parseFullProbeJSON: %v", err)
}
if got.DurationSec != 125 {
t.Fatalf("duration = %d, want 125", got.DurationSec)
}
if len(got.Chapters) != 1 || got.Chapters[0].StartMs != 12_500 || got.Chapters[0].EndMs != 20_000 {
t.Fatalf("chapters = %#v", got.Chapters)
}
}
func TestParseFullProbeJSONToleratesMissingSections(t *testing.T) {
got, err := parseFullProbeJSON([]byte(`{}`))
if err != nil {
t.Fatalf("parseFullProbeJSON: %v", err)
}
if got.DurationSec != 0 || len(got.Streams) != 0 || len(got.Chapters) != 0 {
t.Fatalf("result = %#v, want empty", got)
}
payload, err := got.PayloadJSON()
if err != nil {
t.Fatalf("PayloadJSON: %v", err)
}
// 空轨道必须序列化成 [],不能是 null——详情页前端按数组消费。
if !strings.Contains(payload, `"streams":[]`) {
t.Fatalf("payload = %s, want an empty streams array", payload)
}
}
func TestNormalizeFrameRate(t *testing.T) {
cases := map[string]string{
"24000/1001": "23.976",
"25/1": "25.000",
"0/0": "",
"": "",
"25": "25",
}
for input, want := range cases {
if got := normalizeFrameRate(input); got != want {
t.Errorf("normalizeFrameRate(%q) = %q, want %q", input, got, want)
}
}
}
+22 -10
View File
@@ -212,10 +212,11 @@ func (s *MediaDiscoveryService) NextUpCandidates(ctx context.Context, userID str
byID[watchedRows[i].ID] = &watchedRows[i] byID[watchedRows[i].ID] = &watchedRows[i]
} }
// 按最近观看顺序归并到「剧」维度,同时记住该剧最近的已看集。 // 按最近观看顺序归并到「剧」维度,同时记住该剧最近看的那一集以及它是否看完。
type seriesState struct { type seriesState struct {
key string key string
current *model.Media current *model.Media
completed bool
} }
states := make([]seriesState, 0, len(histories)) states := make([]seriesState, 0, len(histories))
seen := make(map[string]bool, len(histories)) seen := make(map[string]bool, len(histories))
@@ -229,7 +230,7 @@ func (s *MediaDiscoveryService) NextUpCandidates(ctx context.Context, userID str
continue continue
} }
seen[key] = true 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 { if len(states) == 0 {
return nil, nil return nil, nil
@@ -293,7 +294,7 @@ func (s *MediaDiscoveryService) NextUpCandidates(ctx context.Context, userID str
if len(out) >= limit { if len(out) >= limit {
break break
} }
next, ok := pickNextEpisode(bySeries[st.key], st.current, completed) next, ok := pickNextEpisode(bySeries[st.key], st.current, st.completed, completed)
if !ok { if !ok {
continue continue
} }
@@ -341,16 +342,27 @@ func (s *MediaDiscoveryService) completedMediaIDs(ctx context.Context, userID st
return out return out
} }
// pickNextEpisode 在候选集中选出严格晚于 current 的、编号最小的一集。 // pickNextEpisode 选出这部剧「接下来该看的那一集」。
// 比较顺序为 (季, 集),因此跨季时自然落到下一季第一集。 //
func pickNextEpisode(candidates []model.Media, current *model.Media, completed map[string]bool) (model.Media, bool) { // 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 var best model.Media
found := false found := false
for _, candidate := range candidates { for _, candidate := range candidates {
if candidate.ID == current.ID || completed[candidate.ID] { if candidate.ID == anchor.ID || completed[candidate.ID] {
continue continue
} }
if !episodeAfter(candidate, *current) { if !episodeAfter(candidate, *anchor) {
continue continue
} }
if !found || episodeBefore(candidate, best) { if !found || episodeBefore(candidate, best) {
@@ -13,6 +13,9 @@ import (
) )
// seedEpisode 插入一集,并把播放历史指向 `watched`(nil 表示没有历史)。 // seedEpisode 插入一集,并把播放历史指向 `watched`(nil 表示没有历史)。
//
// position_ms / duration_ms 只是占位值,NextUp 只看历史行的 completed 字段:
// 未看完的那一集本身就是「接下来该看的一集」。
func seedEpisode( func seedEpisode(
t *testing.T, t *testing.T,
repos *repository.Container, repos *repository.Container,
@@ -78,7 +81,7 @@ func TestNextUpPicksNextEpisode(t *testing.T) {
libID := seedDiscoveryLibrary(t, repos, "tv") libID := seedDiscoveryLibrary(t, repos, "tv")
watchedAt := time.Now().Add(-time.Hour) 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, 2, nil, false)
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 3, 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 接口负责。 // 电影不进 NextUp:NextUp 的语义是「下一集」,电影由 Resume 接口负责。
func TestNextUpSkipsMovies(t *testing.T) { func TestNextUpSkipsMovies(t *testing.T) {
repos := newDiscoveryTestDB(t) repos := newDiscoveryTestDB(t)
@@ -104,6 +141,10 @@ func TestNextUpSkipsMovies(t *testing.T) {
} }
// 同一部剧有多条未看完历史时,只能出一条,且指向最靠后的已看集的下一集。 // 同一部剧有多条未看完历史时,只能出一条,且指向最靠后的已看集的下一集。
// 同一部剧有多条未看完历史时只能出一条,且指向最近看过的那一集。
//
// 最近那一集(S1E2)本身还没看完,所以它就是「接下来该看的一集」;
// S1E1 只是更早的中间进度,不能据此跳到 S1E3。
func TestNextUpOneEntryPerSeries(t *testing.T) { func TestNextUpOneEntryPerSeries(t *testing.T) {
repos := newDiscoveryTestDB(t) repos := newDiscoveryTestDB(t)
libID := seedDiscoveryLibrary(t, repos, "tv") libID := seedDiscoveryLibrary(t, repos, "tv")
@@ -116,8 +157,8 @@ func TestNextUpOneEntryPerSeries(t *testing.T) {
svc := NewMediaDiscoveryService(zap.NewNop(), repos) svc := NewMediaDiscoveryService(zap.NewNop(), repos)
got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true}) got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true})
if len(got) != 1 || got[0] != "S1E3" { if len(got) != 1 || got[0] != "S1E2" {
t.Fatalf("next up = %v, want [S1E3]", got) t.Fatalf("next up = %v, want [S1E2]", got)
} }
} }
@@ -127,7 +168,7 @@ func TestNextUpCrossesSeason(t *testing.T) {
libID := seedDiscoveryLibrary(t, repos, "tv") libID := seedDiscoveryLibrary(t, repos, "tv")
watchedAt := time.Now().Add(-time.Hour) 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, 1, nil, false)
seedEpisode(t, repos, libID, "series-1", "剧一", 2, 2, 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") libID := seedDiscoveryLibrary(t, repos, "tv")
watchedAt := time.Now().Add(-time.Hour) 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) // 已看完,下一集是 S1E3
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 2, nil, true) // 已看完 seedEpisode(t, repos, libID, "series-1", "剧一", 1, 2, nil, true) // 已看完
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 3, nil, false) seedEpisode(t, repos, libID, "series-1", "剧一", 1, 3, nil, false)
svc := NewMediaDiscoveryService(zap.NewNop(), repos) svc := NewMediaDiscoveryService(zap.NewNop(), repos)
@@ -155,13 +196,13 @@ func TestNextUpSkipsCompletedEpisode(t *testing.T) {
} }
} }
// 追到最后一集时没有下一集,结果为空而不是重复返回最后一集。 // 追到最后一集且已看完时没有下一集,结果为空而不是重复返回最后一集。
func TestNextUpEmptyAtSeriesEnd(t *testing.T) { func TestNextUpEmptyAtSeriesEnd(t *testing.T) {
repos := newDiscoveryTestDB(t) repos := newDiscoveryTestDB(t)
libID := seedDiscoveryLibrary(t, repos, "tv") libID := seedDiscoveryLibrary(t, repos, "tv")
watchedAt := time.Now().Add(-time.Hour) 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) svc := NewMediaDiscoveryService(zap.NewNop(), repos)
if got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true}); len(got) != 0 { 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") hiddenLib := seedDiscoveryLibrary(t, repos, "tv")
watchedAt := time.Now().Add(-time.Hour) 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) seedEpisode(t, repos, hiddenLib, "series-1", "剧一", 1, 2, nil, false)
svc := NewMediaDiscoveryService(zap.NewNop(), repos) svc := NewMediaDiscoveryService(zap.NewNop(), repos)
+321
View File
@@ -0,0 +1,321 @@
package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// 探测结果的缓存与预算策略。
const (
// mediaProbeTimeout 是一次后台探测的总预算(含把 strm 目标解析成直链)。
mediaProbeTimeout = 90 * time.Second
// mediaProbeFailureRetry 是探测失败后允许重新探测的间隔。失败的结果也会落库,
// 否则每次播放都会为一个坏源重跑一次。
mediaProbeFailureRetry = 6 * time.Hour
// mediaProbeErrorLimit 限制落库的错误信息长度(列宽 512 字节,错误里可能
// 带 URL,截断同时避免超长)。
mediaProbeErrorLimit = 200
)
// mediaProber 是 MediaProbeService 需要的探测能力。抽成接口是为了在测试里注入
// 桩,避免依赖真实 ffprobe 二进制。
type mediaProber interface {
ProbeFull(ctx context.Context, input ProbeInput) (*FullProbeResult, error)
}
// MediaProbeService 用 ffprobe 提取媒体的基础信息(容器、每路轨道、内嵌章节)
// 并落库缓存。
//
// 定位是「播放时顺带补齐媒体信息」:STRM / 云盘媒体在扫描阶段拿不到时长,而
// 播放链路要用缓存里的时长来换算「延续到片尾」这类区间,详情页将来也直接读这份
// 媒体信息。
//
// 它**不参与片头/片尾判定**:章节标题绝大多数没有语义(生产库实测抽样 64 个
// 文件,命中 0 个),拿它去猜跳过点只会给出错误的位置,时间轴数据仍然只信
// TheIntroDB。
//
// 核心约束:一次探测要 2~4.5 秒(远端直链要跨洋跑几次 HTTP 事务),所以只允许
// 异步跑,播放链路永远只读缓存。
type MediaProbeService struct {
log *zap.Logger
repo *repository.Container
probe mediaProber
// resolve 把 strm 播放目标解析成最终直链(含绑定 UA 的请求头)。与转码、
// 内嵌字幕发现走同一条换链路径,否则会踩到网盘 CDN 的防盗链 403。
resolve func(ctx context.Context, raw, userAgent string) (*StrmPlayResult, error)
mu sync.Mutex
inFlight map[string]struct{}
}
// NewMediaProbeService is the constructor.
func NewMediaProbeService(log *zap.Logger, repo *repository.Container, probe *FFprobeService) *MediaProbeService {
svc := &MediaProbeService{
log: log,
repo: repo,
inFlight: make(map[string]struct{}),
}
if probe != nil {
svc.probe = probe
}
return svc
}
// SetPlayTargetResolver injects the strm → direct-link resolver.
func (s *MediaProbeService) SetPlayTargetResolver(resolve func(ctx context.Context, raw, userAgent string) (*StrmPlayResult, error)) *MediaProbeService {
if s != nil {
s.resolve = resolve
}
return s
}
// EnsureAsync 保证这部媒体的探测已排上队,并立刻返回。
//
// 已经有同一条媒体的探测在跑、或服务未配置好时返回 false;这次新排上一条返回
// true——调用方据此告诉客户端「稍后再拉一次」。
func (s *MediaProbeService) EnsureAsync(m *model.Media) bool {
if s == nil || s.probe == nil || s.repo == nil || m == nil || strings.TrimSpace(m.ID) == "" {
return false
}
if !s.reserve(m.ID) {
return false
}
// 复制一份媒体行:调用方的对象可能属于请求作用域,后台协程不该继续引用它。
snapshot := *m
helper.Go(s.log, "service.mediaProbe", func() {
defer s.release(snapshot.ID)
s.run(context.Background(), &snapshot)
})
return true
}
func (s *MediaProbeService) reserve(mediaID string) bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.inFlight == nil {
s.inFlight = make(map[string]struct{})
}
if _, running := s.inFlight[mediaID]; running {
return false
}
s.inFlight[mediaID] = struct{}{}
return true
}
func (s *MediaProbeService) release(mediaID string) {
s.mu.Lock()
delete(s.inFlight, mediaID)
s.mu.Unlock()
}
// run 执行一次探测并落库。它跑在后台,没有调用方能接收错误,所以任何失败都只
// 记录、不外抛。
func (s *MediaProbeService) run(ctx context.Context, m *model.Media) {
ctx, cancel := context.WithTimeout(ctx, mediaProbeTimeout)
defer cancel()
input, err := s.probeInput(ctx, m)
if err != nil {
s.markFailure(ctx, m.ID, err)
return
}
result, err := s.probe.ProbeFull(ctx, input)
if err != nil {
s.markFailure(ctx, m.ID, err)
return
}
if err := s.persistProbe(ctx, m, result); err != nil {
s.markFailure(ctx, m.ID, err)
}
}
// probeInput 把媒体行解析成 ffprobe 能直接打开的输入。
//
// 本地文件给路径;STRM / 云盘先解析成最终直链并带上绑定的请求头——直链与 UA
// 必须配套,用错会被 CDN 拒绝。
func (s *MediaProbeService) probeInput(ctx context.Context, m *model.Media) (ProbeInput, error) {
if m == nil {
return ProbeInput{}, ErrMediaNotFound
}
if !isStrmMediaRow(m) {
if _, err := os.Stat(m.Path); err != nil {
return ProbeInput{}, ErrMediaNotFound
}
return ProbeInput{Source: m.Path}, nil
}
raw := strings.TrimSpace(m.STRMURL)
if raw == "" && strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm") {
parsed, err := readLocalSTRMTarget(m.Path)
if err == nil {
raw = strings.TrimSpace(parsed)
}
}
if raw == "" {
return ProbeInput{}, errors.New("strm play target missing")
}
if s.resolve != nil {
resolved, err := s.resolve(ctx, raw, "")
if err != nil {
return ProbeInput{}, err
}
in, err := transcodeInputFromPlayResult(resolved)
if err != nil {
return ProbeInput{}, err
}
return ProbeInput{Source: in.Source, Headers: in.Headers}, nil
}
if isHTTPPlaybackTarget(raw) {
return ProbeInput{Source: raw}, nil
}
return ProbeInput{}, errors.New("strm probe source unavailable")
}
// persistProbe 把一次成功的探测落库。
func (s *MediaProbeService) persistProbe(ctx context.Context, m *model.Media, result *FullProbeResult) error {
if s.repo == nil || s.repo.MediaProbe == nil {
return errors.New("media probe repository not wired")
}
payload, err := result.PayloadJSON()
if err != nil {
return err
}
row := &model.MediaProbe{
MediaID: m.ID,
Signature: mediaProbeSignature(m),
Source: mediaProbeInputKind(m),
Container: result.Container,
DurationSec: result.DurationSec,
BitRate: result.BitRate,
Width: firstStreamDimension(result, "video", true),
Height: firstStreamDimension(result, "video", false),
VideoCodec: firstStreamCodec(result, "video"),
AudioCodec: firstStreamCodec(result, "audio"),
VideoStreams: len(result.StreamsOfType("video")),
AudioStreams: len(result.StreamsOfType("audio")),
SubtitleStreams: len(result.StreamsOfType("subtitle")),
ChapterCount: len(result.Chapters),
Payload: payload,
ProbedAt: time.Now(),
}
if err := s.repo.MediaProbe.Upsert(ctx, row); err != nil {
return err
}
s.backfillDuration(ctx, m, result.DurationSec)
return nil
}
// backfillDuration 把探测到的时长补进 media.duration_sec。STRM / 云盘媒体在扫描
// 阶段拿不到时长,而末段区间(end_ms = 0)要靠它才能换算出真实结束时间。
func (s *MediaProbeService) backfillDuration(ctx context.Context, m *model.Media, durationSec int) {
if s.repo == nil || s.repo.DB == nil || m == nil || durationSec <= 0 || m.DurationSec > 0 {
return
}
err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).
Where("id = ? AND duration_sec <= 0", m.ID).
Update("duration_sec", durationSec).Error
if err != nil && s.log != nil {
s.log.Debug("backfill probed duration failed", zap.String("media_id", m.ID), zap.Error(err))
}
}
func (s *MediaProbeService) markFailure(ctx context.Context, mediaID string, probeErr error) {
if s == nil || s.repo == nil || probeErr == nil {
return
}
message := truncateProbeError(probeErr)
if err := s.repo.MediaProbe.MarkFailure(ctx, mediaID, message, time.Now()); err != nil && s.log != nil {
s.log.Debug("record media probe failure failed", zap.String("media_id", mediaID), zap.Error(err))
}
if s.log != nil {
s.log.Debug("media probe failed", zap.String("media_id", mediaID), zap.Error(probeErr))
}
}
// truncateProbeError 限制错误信息长度。错误里可能带被拒绝的直链,落库时截断,
// 避免超长并减少敏感内容。
func truncateProbeError(err error) string {
message := strings.TrimSpace(err.Error())
runes := []rune(message)
if len(runes) > mediaProbeErrorLimit {
return string(runes[:mediaProbeErrorLimit])
}
return message
}
// mediaProbeInputKind 记录输入形态,供详情页判断「这个时长是本地读的还是远端读的」。
func mediaProbeInputKind(m *model.Media) string {
if m == nil {
return ""
}
if isStrmMediaRow(m) {
return "strm"
}
return "local"
}
// mediaProbeSignature 是「探的是哪个文件」的指纹。
//
// 本地文件用路径 + 大小 + 修改时间;STRM / 云盘没有本地文件,用固化的播放目标,
// 且绝不能用解析后的直链(每次签名都不同,缓存会永远失效)。整体做哈希:
// 既固定长度,也不把路径或 pickcode 再抄一份进数据库。
func mediaProbeSignature(m *model.Media) string {
if m == nil {
return ""
}
var base string
if isStrmMediaRow(m) {
target := strings.TrimSpace(m.STRMURL)
if target == "" {
target = strings.TrimSpace(m.Path)
}
base = "strm|" + target
} else if info, err := os.Stat(m.Path); err == nil {
base = fmt.Sprintf("local|%s|%d|%d", m.Path, info.Size(), info.ModTime().Unix())
} else {
base = "local|" + m.Path
}
sum := sha256.Sum256([]byte(base))
return hex.EncodeToString(sum[:16])
}
func firstStreamCodec(result *FullProbeResult, kind string) string {
if result == nil {
return ""
}
for _, stream := range result.Streams {
if stream.Type == kind && stream.Codec != "" {
return stream.Codec
}
}
return ""
}
// firstStreamDimension 取第一路指定类型轨道的宽(width=true)或高。
func firstStreamDimension(result *FullProbeResult, kind string, width bool) int {
if result == nil {
return 0
}
for _, stream := range result.Streams {
if stream.Type != kind {
continue
}
if width {
return stream.Width
}
return stream.Height
}
return 0
}
+308
View File
@@ -0,0 +1,308 @@
package service
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// stubProber 是 mediaProber 的测试桩:可以拦住探测(gate)用来验证并发去重,
// 也可以直接返回预置结果或错误。
type stubProber struct {
gate chan struct{}
result *FullProbeResult
err error
mu sync.Mutex
calls int
last ProbeInput
}
func (s *stubProber) ProbeFull(_ context.Context, input ProbeInput) (*FullProbeResult, error) {
s.mu.Lock()
s.calls++
s.last = input
gate := s.gate
result := s.result
err := s.err
s.mu.Unlock()
if gate != nil {
<-gate
}
return result, err
}
func (s *stubProber) callCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.calls
}
func (s *stubProber) lastInput() ProbeInput {
s.mu.Lock()
defer s.mu.Unlock()
return s.last
}
func chapterProbeResult() *FullProbeResult {
return &FullProbeResult{
Container: "matroska,webm",
DurationSec: 1451,
BitRate: 8_000_000,
Streams: []ProbeStream{
{Index: 0, Type: "video", Codec: "hevc", Width: 3840, Height: 2160},
{Index: 1, Type: "audio", Codec: "eac3"},
{Index: 2, Type: "subtitle", Codec: "ass"},
},
Chapters: []ProbeChapter{
{Index: 0, StartMs: 0, EndMs: 95_000, Title: "Chapter 01"},
{Index: 1, StartMs: 228_664, EndMs: 246_143, Title: "Opening"},
{Index: 2, StartMs: 3_431_000, EndMs: 0, Title: "End Credits"},
},
}
}
// newProbeFixture 建一条本地媒体(真实落在临时目录里,因为 probeInput 会 stat
// 它)和一个可注入桩的 MediaProbeService。
func newProbeFixture(t *testing.T) (*MediaProbeService, *repository.Container, *model.Media) {
t.Helper()
repos := repository.New(newServiceTestDB(t))
dir := t.TempDir()
path := filepath.Join(dir, "S01E01.mkv")
if err := os.WriteFile(path, []byte("not-really-a-video"), 0o600); err != nil {
t.Fatal(err)
}
m := &model.Media{
Base: model.Base{ID: "ep-1"},
LibraryID: "lib-anime",
SeriesID: "s-1",
Title: "某剧",
Path: path,
SeasonNum: 1,
EpisodeNum: 1,
}
if err := repos.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaProbeService(zap.NewNop(), repos, nil)
return svc, repos, m
}
func waitForCondition(t *testing.T, timeout time.Duration, cond func() bool) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("condition was not met before the deadline")
}
func TestMediaProbeRunPersistsProbeRow(t *testing.T) {
svc, repos, m := newProbeFixture(t)
prober := &stubProber{result: chapterProbeResult()}
svc.probe = prober
// 直接调 run(而不是 EnsureAsync)以获得确定性的断言:这里要验证的是落库,
// 不是调度。
svc.run(context.Background(), m)
if got := prober.lastInput().Source; got != m.Path {
t.Fatalf("probe source = %q, want the local path %q", got, m.Path)
}
probe, err := repos.MediaProbe.Get(t.Context(), m.ID)
if err != nil {
t.Fatal(err)
}
if probe == nil {
t.Fatal("probe row missing")
}
if probe.Container != "matroska,webm" || probe.DurationSec != 1451 || probe.ChapterCount != 3 {
t.Fatalf("probe summary = %#v", probe)
}
if probe.VideoStreams != 1 || probe.AudioStreams != 1 || probe.SubtitleStreams != 1 {
t.Fatalf("stream counts = %d/%d/%d", probe.VideoStreams, probe.AudioStreams, probe.SubtitleStreams)
}
if probe.Width != 3840 || probe.Height != 2160 || probe.VideoCodec != "hevc" || probe.AudioCodec != "eac3" {
t.Fatalf("probe primaries = %#v", probe)
}
if probe.Source != "local" || probe.Signature == "" {
t.Fatalf("probe source/signature = %q/%q", probe.Source, probe.Signature)
}
if probe.LastError != "" {
t.Fatalf("last error = %q, want empty", probe.LastError)
}
if !strings.Contains(probe.Payload, "hevc") {
t.Fatalf("payload should carry the parsed streams: %s", probe.Payload)
}
// 时长回填:STRM / 云盘媒体扫描时拿不到时长,末段区间要靠它换算结束时间。
var refreshed model.Media
if err := repos.DB.First(&refreshed, "id = ?", m.ID).Error; err != nil {
t.Fatal(err)
}
if refreshed.DurationSec != 1451 {
t.Fatalf("media duration = %d, want the probed 1451", refreshed.DurationSec)
}
}
func TestMediaProbeFailureKeepsPreviousSummary(t *testing.T) {
svc, repos, m := newProbeFixture(t)
prober := &stubProber{result: chapterProbeResult()}
svc.probe = prober
svc.run(context.Background(), m)
prober.err = errors.New("ffprobe full: exit status 1")
svc.run(context.Background(), m)
probe, err := repos.MediaProbe.Get(t.Context(), m.ID)
if err != nil {
t.Fatal(err)
}
if probe == nil || probe.LastError == "" {
t.Fatal("a failed re-probe must record the error")
}
// 关键:失败只更新时间与错误信息,上一次成功的媒体信息必须留着——
// 否则一次失败的重探会把已经拿到的时长(片尾区间换算要用)抹掉。
if probe.DurationSec != 1451 || probe.Container != "matroska,webm" || probe.Payload == "" {
t.Fatalf("a failed re-probe wiped the previous summary: %#v", probe)
}
}
// 探测有结论后就不再重探;失败要等冷却期过去才允许重试。
func TestMediaProbeSettledSkipsReprobeButStaleFailureRetries(t *testing.T) {
if !mediaProbeSettled(&model.MediaProbe{ProbedAt: time.Now()}) {
t.Fatal("a completed probe is settled")
}
if !mediaProbeSettled(&model.MediaProbe{ProbedAt: time.Now(), LastError: "boom"}) {
t.Fatal("a recent failure is settled: it must not be retried on every play")
}
if mediaProbeSettled(&model.MediaProbe{ProbedAt: time.Now().Add(-mediaProbeFailureRetry - time.Minute), LastError: "boom"}) {
t.Fatal("a failure past the cooldown should be retried")
}
if mediaProbeSettled(nil) {
t.Fatal("a missing probe row is not settled")
}
}
func TestMediaProbeEnsureAsyncDedupesInFlightProbes(t *testing.T) {
svc, _, m := newProbeFixture(t)
gate := make(chan struct{})
prober := &stubProber{result: chapterProbeResult(), gate: gate}
svc.probe = prober
if !svc.EnsureAsync(m) {
t.Fatal("first EnsureAsync should enqueue a probe")
}
// 同一条媒体在跑的时候不能重复排队:否则每次播放请求都会再起一次 3 秒探测。
if svc.EnsureAsync(m) {
t.Fatal("second EnsureAsync must report that a probe is already running")
}
close(gate)
waitForCondition(t, 5*time.Second, func() bool {
svc.mu.Lock()
defer svc.mu.Unlock()
return len(svc.inFlight) == 0
})
if got := prober.callCount(); got != 1 {
t.Fatalf("probe calls = %d, want 1", got)
}
// 跑完之后允许再次排队(例如失败冷却期到了之后的重试)。
if !svc.EnsureAsync(m) {
t.Fatal("EnsureAsync should enqueue again once the previous run finished")
}
waitForCondition(t, 5*time.Second, func() bool { return prober.callCount() == 2 })
}
func TestMediaProbeEnsureAsyncWithoutProberIsNotPending(t *testing.T) {
svc, _, m := newProbeFixture(t)
// probe 未注入(ffprobe 不可用时就是这样):不能谎报「提取中」,否则客户端
// 会白轮询一轮。
if svc.EnsureAsync(m) {
t.Fatal("EnsureAsync must return false without a prober")
}
}
func TestMediaProbeInputRejectsMissingLocalFile(t *testing.T) {
svc, _, _ := newProbeFixture(t)
_, err := svc.probeInput(t.Context(), &model.Media{
Base: model.Base{ID: "missing"}, Path: filepath.Join(t.TempDir(), "nope.mkv"),
})
if !errors.Is(err, ErrMediaNotFound) {
t.Fatalf("err = %v, want ErrMediaNotFound", err)
}
}
func TestMediaProbeInputResolvesStrmWithHeaders(t *testing.T) {
svc := &MediaProbeService{}
svc.SetPlayTargetResolver(func(_ context.Context, raw, userAgent string) (*StrmPlayResult, error) {
if raw != "https://pan.example.com/api/strm/play/115?v=1" {
t.Fatalf("resolver got raw = %q", raw)
}
if userAgent != "" {
t.Fatalf("userAgent = %q, want empty for a background probe", userAgent)
}
return &StrmPlayResult{RedirectURL: "https://cdn.example.com/a.mkv"}, nil
})
input, err := svc.probeInput(t.Context(), &model.Media{
Base: model.Base{ID: "strm-1"},
Path: "/media/a.mkv.strm",
STRMURL: "https://pan.example.com/api/strm/play/115?v=1",
})
if err != nil {
t.Fatalf("probeInput: %v", err)
}
if input.Source != "https://cdn.example.com/a.mkv" {
t.Fatalf("source = %q, want the resolved direct link", input.Source)
}
}
// 签名不能把播放目标(含 pickcode)再抄一份进数据库,所以整体做哈希。
func TestMediaProbeSignatureHidesPlayTargetAndTracksFileChanges(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "movie.mkv")
if err := os.WriteFile(path, []byte("v1"), 0o600); err != nil {
t.Fatal(err)
}
local := &model.Media{Base: model.Base{ID: "l-1"}, Path: path}
first := mediaProbeSignature(local)
if len(first) != 32 {
t.Fatalf("signature = %q, want a 32-char hex digest", first)
}
if strings.Contains(first, "movie.mkv") {
t.Fatalf("signature leaked the path: %q", first)
}
if err := os.WriteFile(path, []byte("v2-changed-size"), 0o600); err != nil {
t.Fatal(err)
}
if mediaProbeSignature(local) == first {
t.Fatal("the signature must change when the file changes")
}
strm := &model.Media{
Base: model.Base{ID: "s-1"},
Path: "/media/movie.mkv.strm",
STRMURL: "https://pan.example.com/api/strm/play/115?pickcode=secret-pickcode",
}
strmSig := mediaProbeSignature(strm)
if strings.Contains(strmSig, "secret-pickcode") || strings.Contains(strmSig, "pan.example.com") {
t.Fatalf("strm signature leaked the play target: %q", strmSig)
}
if strmSig == "" {
t.Fatal("strm signature must not be empty")
}
}
+249 -14
View File
@@ -18,9 +18,21 @@ import (
// 片段数据的缓存时长。命中过说明社区库里已有记录、数据很少变动,可以放很久; // 片段数据的缓存时长。命中过说明社区库里已有记录、数据很少变动,可以放很久;
// 未命中说明这部片还没人贡献,隔一段时间再试一次即可——负缓存是必须的,否则 // 未命中说明这部片还没人贡献,隔一段时间再试一次即可——负缓存是必须的,否则
// 每次播放一部没有片段数据的影片都会打一次外网。 // 每次播放一部没有片段数据的影片都会打一次外网。
//
// 未命中 TTL 刻意短于命中:社区库在持续补充,尤其是热门剧,24h 重试一次比
// 锁死 7 天更能跟上贡献节奏;预热任务也会优先扫最近播放过的 miss。
const ( const (
segmentFoundTTL = 30 * 24 * time.Hour 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 等)暴露给前端。 // SegmentView 是播放器消费的最小片段结构,避免把库内字段(source 等)暴露给前端。
@@ -44,11 +56,29 @@ type MediaSegmentService struct {
log *zap.Logger log *zap.Logger
repo *repository.Container repo *repository.Container
introdb *IntroDBService introdb *IntroDBService
// probe 负责异步补齐媒体信息(主要是 STRM 时长)。未注入时只用 TheIntroDB。
probe *MediaProbeService
// prewarmGap 是预热两次 IntroDB 请求之间的间隔;测试可设为 0。
prewarmGap time.Duration
now func() time.Time
} }
// NewMediaSegmentService is the constructor. // NewMediaSegmentService is the constructor.
func NewMediaSegmentService(log *zap.Logger, repo *repository.Container) *MediaSegmentService { 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. // SetIntroDB wires the provider. Without it the service only reads cached rows.
@@ -59,36 +89,139 @@ func (s *MediaSegmentService) SetIntroDB(p *IntroDBService) *MediaSegmentService
return s return s
} }
// 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
}
return s
}
// ListForPlayback returns the segments known for a media item, refreshing from // ListForPlayback returns the segments known for a media item, refreshing from
// the provider when the cache is stale. // the provider when the cache is stale.
// //
// 它不做任何阻塞起播的事情——调用方是在播放已经开始之后用一次独立请求进来的, // 它不做任何阻塞起播的事情——调用方是在播放已经开始之后用一次独立请求进来的,
// 抓取失败也只是少一个「跳过片头」按钮,绝不能让播放报错。 // 抓取失败也只是少一个「跳过片头」按钮,绝不能让播放报错。
//
// 时间轴数据只来自 TheIntroDB。顺带在返回前起一次异步探测补齐媒体信息(主要是
// STRM 媒体的时长),但探测结果不参与片段判定,详见 MediaProbeService 的说明。
func (s *MediaSegmentService) ListForPlayback(ctx context.Context, m *model.Media) ([]model.MediaSegment, error) { func (s *MediaSegmentService) ListForPlayback(ctx context.Context, m *model.Media) ([]model.MediaSegment, error) {
if s == nil || s.repo == nil || m == nil || m.ID == "" { if s == nil || s.repo == nil || m == nil || m.ID == "" {
return nil, nil return nil, nil
} }
cached, err := s.repo.MediaSegment.ListByMedia(ctx, m.ID) // 用 defer 保证探测一定在本次请求所有数据库读写之后才启动:后台探测自己也要
if err != nil { // 写库,若在本次写事务还没结束时启动,两个写事务会抢同一把锁(SQLite 下就是
return nil, err // SQLITE_BUSY,实测能直接把社区库的落库打失败)。
defer s.ensureMediaProbe(ctx, m)
return s.introDBSegments(ctx, m)
}
// ensureMediaProbe 在还没探过(或上次失败已过冷却期)时起一次异步探测。
//
// 它只为「补齐媒体信息」服务:失败只是拿不到时长,不影响播放,也不影响片段。
func (s *MediaSegmentService) ensureMediaProbe(ctx context.Context, m *model.Media) {
if s == nil || s.probe == nil || m == nil {
return
} }
cached, err := s.repo.MediaProbe.Get(ctx, m.ID)
if err != nil {
s.debug("get media probe failed", m.ID, err)
return
}
if mediaProbeSettled(cached) {
return
}
s.probe.EnsureAsync(m)
}
// mediaProbeSettled 判断这部媒体的探测是否已经「有结论」——成功过,或者失败但还在
// 冷却期内。有结论就不必再探;失败且已过冷却期时返回 false,让下一次播放重试。
func mediaProbeSettled(row *model.MediaProbe) bool {
if row == nil {
return false
}
if strings.TrimSpace(row.LastError) == "" {
return true
}
return time.Since(row.ProbedAt) < mediaProbeFailureRetry
}
// introDBSegments 读社区库的片段,缓存过期时按调用方的预算抓一次并落库。
// 返回值是按来源优先级合并后的结果(manual > theintrodb > propagated)。
func (s *MediaSegmentService) introDBSegments(ctx context.Context, m *model.Media) ([]model.MediaSegment, error) {
ledger, err := s.repo.MediaSegment.GetFetch(ctx, m.ID, IntroDBSource) ledger, err := s.repo.MediaSegment.GetFetch(ctx, m.ID, IntroDBSource)
if err != nil { if err != nil {
// 调用方预算耗尽时仍读本地缓存,绝不能让播放接口报错。
if ctx.Err() != nil {
return s.mergeForPlayback(context.WithoutCancel(ctx), m.ID)
}
return nil, err return nil, err
} }
if ledger != nil && ledgerFresh(ledger) { if ledger == nil || !ledgerFresh(ledger) {
return cached, nil 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 { if err != nil {
// 社区库不可达或返回异常:沿用已有缓存,不影响播放。 return nil, err
logIntroDBFailure(s.log, 0, err)
return cached, nil
} }
if !attempted { return preferSegmentsBySource(rows), nil
return cached, 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) {
if s == nil || s.log == nil {
return
}
s.log.Debug(message, zap.String("media_id", mediaID), zap.Error(err))
} }
// refresh 向提供方查询并落库,返回 (rows, 是否真的发起过查询, error)。 // refresh 向提供方查询并落库,返回 (rows, 是否真的发起过查询, error)。
@@ -149,9 +282,111 @@ func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]mo
}); err != nil { }); err != nil {
return nil, true, err return nil, true, err
} }
s.propagateIntroToSeason(fetchCtx, m, rows)
return rows, true, nil 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. // queryIDs resolves the provider query key. Movies use their own TMDb id.
// //
// 剧集需要「剧集级」TMDb id 加季/集。优先取 Series.TMDbID;但有些刮削路径 // 剧集需要「剧集级」TMDb id 加季/集。优先取 Series.TMDbID;但有些刮削路径
@@ -0,0 +1,77 @@
package service
import (
"testing"
"time"
"go.uber.org/zap"
)
// 播放请求要顺带把媒体信息(主要是 STRM 媒体的时长)补齐,但探测不能影响片段:
// ListForPlayback 只读社区库,探测在返回前才异步排上队。
func TestListForPlaybackTriggersMediaProbe(t *testing.T) {
probeSvc, repos, m := newProbeFixture(t)
prober := &stubProber{result: chapterProbeResult()}
probeSvc.probe = prober
segments := NewMediaSegmentService(zap.NewNop(), repos).SetProbe(probeSvc)
rows, err := segments.ListForPlayback(t.Context(), m)
if err != nil {
t.Fatalf("ListForPlayback: %v", err)
}
if len(rows) != 0 {
t.Fatalf("rows = %#v, want none without a provider", rows)
}
// 探测是异步的:等它跑完并落库。
waitForCondition(t, 5*time.Second, func() bool { return prober.callCount() >= 1 })
probeRow, err := repos.MediaProbe.Get(t.Context(), m.ID)
if err != nil {
t.Fatal(err)
}
if probeRow == nil {
t.Fatal("the playback path should have probed the media info")
}
// 已经有结论的媒体不该被反复探测:每次播放重跑一次 2~4.5 秒的远端读取太贵。
if _, err := segments.ListForPlayback(t.Context(), m); err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
if got := prober.callCount(); got != 1 {
t.Fatalf("probe calls = %d, want 1 (a settled probe must not repeat)", got)
}
}
// 探测失败也要有结论:冷却期内不再重探。
func TestListForPlaybackDoesNotReprobeWithinFailureCooldown(t *testing.T) {
probeSvc, repos, m := newProbeFixture(t)
prober := &stubProber{result: chapterProbeResult()}
probeSvc.probe = prober
if err := repos.MediaProbe.MarkFailure(t.Context(), m.ID, "boom", time.Now()); err != nil {
t.Fatal(err)
}
segments := NewMediaSegmentService(zap.NewNop(), repos).SetProbe(probeSvc)
if _, err := segments.ListForPlayback(t.Context(), m); err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
if got := prober.callCount(); got != 0 {
t.Fatalf("probe calls = %d, want 0 inside the cooldown", got)
}
}
// 没注入探测服务时(ffprobe 不可用的精简部署)播放链路必须照常工作。
func TestListForPlaybackWithoutProbeStillWorks(t *testing.T) {
_, repos, m := newProbeFixture(t)
segments := NewMediaSegmentService(zap.NewNop(), repos)
rows, err := segments.ListForPlayback(t.Context(), m)
if err != nil {
t.Fatalf("ListForPlayback: %v", err)
}
if len(rows) != 0 {
t.Fatalf("rows = %#v, want none", rows)
}
}
+118
View File
@@ -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) { func TestLedgerFreshUsesLongerTTLWhenDataWasFound(t *testing.T) {
now := time.Now() now := time.Now()
found := &model.MediaSegmentFetch{FetchedAt: now.Add(-segmentMissingTTL), Found: true} 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") t.Fatal("a missing ledger must not be considered fresh")
} }
} }
+13 -2
View File
@@ -8,6 +8,7 @@
// organize_source opt-in — organize the configured staging folder. // organize_source opt-in — organize the configured staging folder.
// transcode_cleanup every 24 h — purge HLS transcode artefacts // transcode_cleanup every 24 h — purge HLS transcode artefacts
// older than 24 h. // 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 // 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 // next tick). All work happens on a long-lived background context so
@@ -42,6 +43,8 @@ type SchedulerService struct {
imagesPolicyProvider func() ImageCachePolicy imagesPolicyProvider func() ImageCachePolicy
segments *MediaSegmentService
mu sync.Mutex mu sync.Mutex
stopCh chan struct{} stopCh chan struct{}
jobs []*scheduledJob jobs []*scheduledJob
@@ -145,6 +148,14 @@ func (s *SchedulerService) Start(ctx context.Context) {
run: s.jobCleanImageCache, 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 { if s.expiryWatcher != nil {
s.jobs = append(s.jobs, &scheduledJob{ s.jobs = append(s.jobs, &scheduledJob{
@@ -155,8 +166,8 @@ func (s *SchedulerService) Start(ctx context.Context) {
} }
for _, j := range s.jobs { for _, j := range s.jobs {
initialDelay := 15 * time.Second 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/磁盘打满导致无法登录。 // 15 秒即全量 walk + ffprobe 曾把 CPU/磁盘打满导致无法登录。
// 首轮等满一个完整周期再跑,平时节奏不变。 // 首轮等满一个完整周期再跑,平时节奏不变。
initialDelay = j.interval 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)
}
}
+1
View File
@@ -36,6 +36,7 @@ type Container struct {
Scraper *ScraperService Scraper *ScraperService
Playback *PlaybackService Playback *PlaybackService
Segments *MediaSegmentService Segments *MediaSegmentService
MediaProbe *MediaProbeService
ImageProxy *ImageProxy ImageProxy *ImageProxy
Watcher *WatcherService Watcher *WatcherService
Subtitle *SubtitleService Subtitle *SubtitleService
+6
View File
@@ -147,6 +147,11 @@ func (b *serviceContainerBuilder) initContentServices() {
b.c.Transcoder.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget) b.c.Transcoder.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget)
b.c.Transcoder.SetProbe(b.c.FFprobe) b.c.Transcoder.SetProbe(b.c.FFprobe)
b.c.Subtitle.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget) b.c.Subtitle.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget)
// 播放时的媒体信息提取(ffprobe 章节 → 跳过片头/片尾):完全异步,播放链路
// 只读缓存。换链复用与转码/字幕同一条路径,避免 CDN 防盗链 403。
b.c.MediaProbe = NewMediaProbeService(b.log, b.repos, b.c.FFprobe).
SetPlayTargetResolver(b.c.Strm.ResolvePlayTargetWithUA)
b.c.Segments.SetProbe(b.c.MediaProbe)
// 播放链路:/Videos/{id}/stream 与 /api/stream/{id} 在服务端完成换链后直接 // 播放链路:/Videos/{id}/stream 与 /api/stream/{id} 在服务端完成换链后直接
// 302 到最终直链,客户端少跟随一次 302(高延迟线路上省一个往返)。 // 302 到最终直链,客户端少跟随一次 302(高延迟线路上省一个往返)。
b.c.Stream.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTargetWithUA) b.c.Stream.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTargetWithUA)
@@ -187,6 +192,7 @@ func (b *serviceContainerBuilder) initAccessAndStorageServices() {
) )
b.c.Scheduler.SetTaskTracker(b.c.Tasks) b.c.Scheduler.SetTaskTracker(b.c.Tasks)
b.c.Scheduler.SetOrganizePipeline(b.c.OrganizePipeline) b.c.Scheduler.SetOrganizePipeline(b.c.OrganizePipeline)
b.c.Scheduler.SetSegments(b.c.Segments)
b.c.Scheduler.SetImageCachePolicyProvider(func() ImageCachePolicy { b.c.Scheduler.SetImageCachePolicyProvider(func() ImageCachePolicy {
if b.cfg == nil { if b.cfg == nil {
return ImageCachePolicy{} return ImageCachePolicy{}
-2
View File
@@ -71,7 +71,6 @@ export interface DanmakuConfig {
font_size: string font_size: string
area: string area: string
volume: number volume: number
playback_rate: number
/** 当前用户是否已经看过 VR 全景播放的首次操作说明(按用户存储)。 */ /** 当前用户是否已经看过 VR 全景播放的首次操作说明(按用户存储)。 */
vr360_guide_seen: boolean vr360_guide_seen: boolean
/** Per-user preference: merge the same episode's multiple sources. */ /** Per-user preference: merge the same episode's multiple sources. */
@@ -90,7 +89,6 @@ export interface DanmakuSettingsPatch {
area?: number area?: number
merge_sources?: boolean merge_sources?: boolean
volume?: number volume?: number
playback_rate?: number
/** 看过 VR 操作说明后置为 true,之后不再弹出。 */ /** 看过 VR 操作说明后置为 true,之后不再弹出。 */
vr360_guide_seen?: boolean vr360_guide_seen?: boolean
} }
+1 -1
View File
@@ -52,7 +52,7 @@ export function ExternalPlayerButton({
className={ className={
compact 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' ? '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" /> <PlaySquare size={compact ? 13 : 14} className="mr-1 inline" />
+4 -24
View File
@@ -133,10 +133,9 @@ type PlayerControlsProps = {
volume?: number volume?: number
onVolumeChange?: (volume: number) => void onVolumeChange?: (volume: number) => void
onVolumeCommit?: (volume: number) => void onVolumeCommit?: (volume: number) => void
/** 用户级播放倍速,由播放页从数据库读取。 */ /** 播放倍速(1x = 原速)。只作用于当前视频,不落库。 */
playbackRate?: number playbackRate?: number
onPlaybackRateChange?: (rate: number) => void onPlaybackRateChange?: (rate: number) => void
onPlaybackRateCommit?: (rate: number) => void
uiVisible: boolean uiVisible: boolean
onUiVisibleChange: (visible: boolean) => void onUiVisibleChange: (visible: boolean) => void
/** 外部浮层(VR 工具条/设置面板)正被鼠标悬停:此时控制栏不自动隐藏。 */ /** 外部浮层(VR 工具条/设置面板)正被鼠标悬停:此时控制栏不自动隐藏。 */
@@ -213,7 +212,6 @@ export function PlayerControls({
onVolumeCommit, onVolumeCommit,
playbackRate: playbackRateProp = 1, playbackRate: playbackRateProp = 1,
onPlaybackRateChange, onPlaybackRateChange,
onPlaybackRateCommit,
uiVisible, uiVisible,
onUiVisibleChange, onUiVisibleChange,
uiHold = false, uiHold = false,
@@ -381,7 +379,7 @@ export function PlayerControls({
setMuted(next === 0) setMuted(next === 0)
}, [video, volumeProp]) }, [video, volumeProp])
// 倍速由播放页按用户持久化;配置加载、切换剧集或切换播放源后同步到当前 video。 // 倍速只影响当前视频:挂载、切换剧集或切换播放源后同步到当前 video。
useEffect(() => { useEffect(() => {
const el = video() const el = video()
if (!el) return if (!el) return
@@ -626,15 +624,9 @@ export function PlayerControls({
}, PLAYBACK_RATE_HINT_MS) }, PLAYBACK_RATE_HINT_MS)
}, [onPlaybackRateChange]) }, [onPlaybackRateChange])
const commitPlaybackRate = useCallback(() => {
onPlaybackRateCommit?.(playbackRateRef.current)
}, [onPlaybackRateCommit])
const selectPlaybackRate = useCallback((next: number) => { const selectPlaybackRate = useCallback((next: number) => {
const normalized = normalizePlaybackRate(next) changePlaybackRate(normalizePlaybackRate(next))
changePlaybackRate(normalized) }, [changePlaybackRate])
onPlaybackRateCommit?.(normalized)
}, [changePlaybackRate, onPlaybackRateCommit])
const applyAbsoluteSeek = useCallback( const applyAbsoluteSeek = useCallback(
(absolute: number) => { (absolute: number) => {
@@ -787,18 +779,6 @@ export function PlayerControls({
return () => window.removeEventListener('keydown', onKey, true) return () => window.removeEventListener('keydown', onKey, true)
}, [changePlaybackRate, queueRelativeSeek]) }, [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(() => { useEffect(() => {
return () => { return () => {
if (seekApplyTimerRef.current) clearTimeout(seekApplyTimerRef.current) if (seekApplyTimerRef.current) clearTimeout(seekApplyTimerRef.current)
+30 -24
View File
@@ -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 { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
import type { Media } from '../types' import type { Media } from '../types'
@@ -34,50 +34,56 @@ export function MediaDetailAdminPanel({
} }
return ( return (
<div className="rounded-2xl border border-gray-200 bg-gray-50/50 p-5 space-y-3"> <section className="overflow-hidden rounded-2xl border border-gray-200 bg-gray-50/50">
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-[#c9954a]">系统后台高级控制面板</p> <div className="flex flex-wrap items-center justify-between gap-3 border-b border-gray-200/60 px-4 py-3 sm:px-5">
{isEpisodeArtworkTarget(media) && ( <p className="inline-flex items-center gap-2 text-2xs font-bold uppercase tracking-[0.18em] text-gray-500">
<EpisodeArtworkToggle <ShieldAlert size={14} className="text-brand-500" />
checked={scrapeEpisodeArtwork} 系统后台高级控制面板
onChange={onScrapeEpisodeArtworkChange} </p>
title="关闭后仍会获取每集简介、评分和时长,只跳过单集图片" {isEpisodeArtworkTarget(media) && (
className="h-10" <EpisodeArtworkToggle
/> checked={scrapeEpisodeArtwork}
)} onChange={onScrapeEpisodeArtworkChange}
<div className="flex flex-wrap gap-2"> title="关闭后仍会获取每集简介、评分和时长,只跳过单集图片"
<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"> 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]" /> <Sparkles size={13} className="text-[#c9954a]" />
<span>智能刮削 (TMDB)</span> <span>智能刮削 (TMDB)</span>
</button> </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]" /> <Search size={13} className="text-[#c9954a]" />
<span>手动匹配刮削</span> <span>手动匹配刮削</span>
</button> </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"> <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-gray-600" /> <Pencil size={13} className="text-brand-500" />
<span>编辑元数据</span> <span>编辑元数据</span>
</button> </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]" /> <FolderInput size={13} className="text-[#c9954a]" />
<span>整理入库</span> <span>整理入库</span>
</button> </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"> <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-gray-600" /> <Database size={13} className="text-brand-500" />
<span>探测媒体轨 (ffprobe)</span> <span>探测媒体轨 (ffprobe)</span>
</button> </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"> <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} /> <FileText size={13} className="text-brand-500" />
<span>写出本地 NFO 属性</span> <span>写出本地 NFO 属性</span>
</button> </button>
<button <button
onClick={onDelete} 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> <span>删除</span>
</button> </button>
</div> </div>
</div> </section>
) )
} }
+47 -23
View File
@@ -9,6 +9,10 @@ type MediaDetailMetadataProps = {
export function MediaDetailMetadata({ media }: MediaDetailMetadataProps) { export function MediaDetailMetadata({ media }: MediaDetailMetadataProps) {
const heading = media.episode_title?.trim() || media.title const heading = media.episode_title?.trim() || media.title
const showTitleContext = Boolean(media.episode_title?.trim() && media.title && media.title !== heading) 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 ( return (
<> <>
@@ -21,26 +25,26 @@ export function MediaDetailMetadata({ media }: MediaDetailMetadataProps) {
{media.title} {media.title}
</p> </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 && ( {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" /> <Calendar size={13} className="text-brand-500" />
<span>{media.year} 年</span> <span>{media.year} 年</span>
</span> </span>
)} )}
{media.width > 0 && ( {media.width > 0 && media.height > 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"> <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>{media.width} × {media.height}</span>
</span> </span>
)} )}
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700"> {media.size_bytes > 0 && (
{fmtSize(media.size_bytes)} <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>
</span> )}
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700"> {media.duration_sec > 0 && (
{fmtDuration(media.duration_sec)} <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>
</span> )}
{media.container && ( {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} {media.container}
</span> </span>
)} )}
@@ -56,24 +60,44 @@ export function MediaDetailMetadata({ media }: MediaDetailMetadataProps) {
</div> </div>
)} )}
<div className="space-y-4"> {hasTaxonomy && (
<MetadataTags label="类型流派" values={parseCSV(media.genres)} primary /> <div className="space-y-3">
<MetadataTags label="语言" values={parseCSV(media.languages)} /> <MetadataRow label="类型流派" values={parseCSV(media.genres)} tone="brand" />
<MetadataTags label="国家/地区" values={parseCSV(media.countries)} /> <MetadataRow label="语言" values={parseCSV(media.languages)} />
</div> <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 if (values.length === 0) return null
const tagClass = primary const tagClass = [
? 'rounded-full bg-brand-50 text-brand-700 border border-brand-100/30 px-3 py-1 text-2xs font-bold uppercase tracking-wider' 'inline-flex items-center rounded-lg border px-2.5 py-1 text-2xs font-bold uppercase tracking-wide',
: 'rounded-xl bg-gray-100 text-gray-600 border border-gray-200/40 px-2.5 py-1 text-2xs font-semibold' tone === 'brand'
? 'border-brand-100/50 bg-brand-50 text-brand-700'
: 'border-gray-200/60 bg-gray-100 text-gray-600',
].join(' ')
return ( return (
<div className="flex flex-wrap items-center gap-3"> <div className="grid grid-cols-[4.5rem_1fr] items-start gap-2 sm:grid-cols-[5.5rem_1fr]">
<span className="text-xs font-bold text-gray-500 w-16 uppercase tracking-wider">{label}</span> <span className="pt-1 text-xs font-semibold text-gray-500">{label}</span>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-1.5">
{values.map((value) => ( {values.map((value) => (
<span key={value} className={tagClass}> <span key={value} className={tagClass}>
{value} {value}
+29 -25
View File
@@ -99,19 +99,23 @@ export function MediaDetailPlaybackActions({
}: MediaDetailPlaybackActionsProps) { }: MediaDetailPlaybackActionsProps) {
const targetId = media.id || playTargetId || '' const targetId = media.id || playTargetId || ''
return ( return (
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap items-center gap-2.5">
<Link to={`/play/${targetId}`} state={{ from: `/media/${media.id}` }} className="btn-primary px-6 py-3.5 shadow-sm"> <Link
to={`/play/${targetId}`}
state={{ from: `/media/${media.id}` }}
className="btn-primary h-11 px-6 shadow-sm"
>
<Play size={16} fill="currentColor" /> <Play size={16} fill="currentColor" />
<span>立即播放</span> <span>立即播放</span>
</Link> </Link>
{!isDirectStreamMedia(media) && ( {!isDirectStreamMedia(media) && (
<Link <Link
to={`/play/${targetId}?mode=hls`} to={`/play/${targetId}?mode=hls`}
state={{ from: `/media/${media.id}` }} state={{ from: `/media/${media.id}` }}
className="btn-outline border-brand-500/30 hover:border-brand-500 text-[#c9954a] hover:bg-brand-50 px-5" className="btn-outline h-11 border-brand-500/30 px-5 text-[#c9954a] hover:border-brand-500 hover:bg-brand-50"
> >
<RefreshCw size={14} className="animate-spin-slow" /> <RefreshCw size={14} />
<span>HLS 兼容转码播放</span> <span>HLS 兼容转码播放</span>
</Link> </Link>
)} )}
@@ -121,7 +125,7 @@ export function MediaDetailPlaybackActions({
<button <button
onClick={onToggleFavourite} onClick={onToggleFavourite}
className={ className={
'btn-outline gap-2 ' + 'btn-outline h-11 gap-2 ' +
(favourite (favourite
? '!border-red-200 !bg-red-50 !text-red-600 hover:!bg-red-100/50' ? '!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') : '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"> <div className="relative z-10 p-6 sm:p-10 flex flex-col md:flex-row gap-8 lg:gap-12">
<MediaDetailPoster media={media} /> <MediaDetailPoster media={media} />
<div className="flex-1 space-y-6"> <div className="min-w-0 flex-1 space-y-5">
<MediaDetailMetadata media={media} /> <MediaDetailMetadata media={media} />
<div className="divider border-gray-200/60" /> <div className="divider" />
<div className="flex flex-col gap-5"> <div className="space-y-4">
<MediaDetailPlaybackActions <MediaDetailPlaybackActions
media={media} media={media}
favourite={favourite} favourite={favourite}
@@ -165,21 +169,21 @@ export function MediaDetailMainContent({
playTargetId={playTargetId} playTargetId={playTargetId}
/> />
<MediaVersionSwitcher media={media} /> <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> </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>
</div> </div>
) )
+10 -39
View File
@@ -20,7 +20,7 @@ import { useAuthStore } from '../stores/auth'
import type { Media, PlaybackInfo, PlaybackQuality, PlaybackSegment, PlaybackSegmentKind } from '../types' import type { Media, PlaybackInfo, PlaybackQuality, PlaybackSegment, PlaybackSegmentKind } from '../types'
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries' import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
import { mediaVersionMatches, mediaVersionsOf } from '../utils/mediaVersion' 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 { resolveActiveSkip, skippedNoticeText, toSkipSegments, type SkipPrompt } from '../utils/skipSegments'
import { isRemoteEmbyID } from '../utils/remoteEmby' import { isRemoteEmbyID } from '../utils/remoteEmby'
import { import {
@@ -139,16 +139,13 @@ export function PlayerPage() {
const [playerVolume, setPlayerVolume] = useState(() => const [playerVolume, setPlayerVolume] = useState(() =>
normalizePlayerVolume(authUser?.player_volume), normalizePlayerVolume(authUser?.player_volume),
) )
const [playerPlaybackRate, setPlayerPlaybackRate] = useState(() => // 播放倍速只作用于当前视频:不入库、不跨视频沿用(换集/换片会重置回 1x)。
normalizePlaybackRate(authUser?.player_playback_rate), const [playerPlaybackRate, setPlayerPlaybackRate] = useState(() => DEFAULT_PLAYBACK_RATE)
)
const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode) const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode)
// VR 全景播放的首次操作说明是否已经看过:null = 服务端配置还没读回来, // VR 全景播放的首次操作说明是否已经看过:null = 服务端配置还没读回来,
// 这时不弹说明,避免给老用户闪一下。 // 这时不弹说明,避免给老用户闪一下。
const [vr360GuideSeen, setVr360GuideSeen] = useState<boolean | null>(null) const [vr360GuideSeen, setVr360GuideSeen] = useState<boolean | null>(null)
const playerVolumeTouchedRef = useRef(false) const playerVolumeTouchedRef = useRef(false)
const playerPlaybackRateTouchedRef = useRef(false)
const playerPlaybackRateSaveSeqRef = useRef(0)
const subtitlePreferenceTouchedRef = useRef(false) const subtitlePreferenceTouchedRef = useRef(false)
const subtitlePreferenceSaveQueueRef = useRef<Promise<void>>(Promise.resolve()) const subtitlePreferenceSaveQueueRef = useRef<Promise<void>>(Promise.resolve())
const [hlsUnavailable, setHlsUnavailable] = useState(false) const [hlsUnavailable, setHlsUnavailable] = useState(false)
@@ -285,10 +282,6 @@ export function PlayerPage() {
if (!playerVolumeTouchedRef.current) { if (!playerVolumeTouchedRef.current) {
setPlayerVolume(volume) setPlayerVolume(volume)
} }
const playbackRate = normalizePlaybackRate(cfg.playback_rate)
if (!playerPlaybackRateTouchedRef.current) {
setPlayerPlaybackRate(playbackRate)
}
setDanmakuEnabled(cfg.enabled) setDanmakuEnabled(cfg.enabled)
setDanmakuOpacity(Number(cfg.opacity) || 1) setDanmakuOpacity(Number(cfg.opacity) || 1)
setDanmakuFontSize(Number(cfg.font_size) || 24) setDanmakuFontSize(Number(cfg.font_size) || 24)
@@ -300,10 +293,6 @@ export function PlayerPage() {
const current = useAuthStore.getState().user const current = useAuthStore.getState().user
if (current) setAuthUser({ ...current, player_volume: volume }) if (current) setAuthUser({ ...current, player_volume: volume })
} }
if (!playerPlaybackRateTouchedRef.current) {
const current = useAuthStore.getState().user
if (current) setAuthUser({ ...current, player_playback_rate: playbackRate })
}
}) })
.catch(() => { .catch(() => {
// 配置读取失败时保持本地默认值,不影响播放。 // 配置读取失败时保持本地默认值,不影响播放。
@@ -338,34 +327,11 @@ export function PlayerPage() {
[setAuthUser], [setAuthUser],
) )
// 倍速只改本机播放状态:不写回服务端,也不跨视频沿用。
const changePlayerPlaybackRate = useCallback((next: number) => { const changePlayerPlaybackRate = useCallback((next: number) => {
playerPlaybackRateTouchedRef.current = true
setPlayerPlaybackRate(normalizePlaybackRate(next)) 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( const saveDanmakuAdvanced = useCallback(
async (values: { source: string; appId: string; appKey: string; clearAppKey: boolean }) => { async (values: { source: string; appId: string; appKey: string; clearAppKey: boolean }) => {
const patch: DanmakuSettingsPatch = { const patch: DanmakuSettingsPatch = {
@@ -674,6 +640,12 @@ export function PlayerPage() {
const mediaId = media?.id const mediaId = media?.id
const playbackProvider = playbackInfo?.provider const playbackProvider = playbackInfo?.provider
// 倍速只影响当前视频:换到另一集/另一部片时回到 1x,避免上一个视频的
// 倍速被顺手带到下一集。
useEffect(() => {
setPlayerPlaybackRate(DEFAULT_PLAYBACK_RATE)
}, [mediaId])
// 加载统一播放能力:115 云端清晰度 + 本地 HLS 清晰度。 // 加载统一播放能力:115 云端清晰度 + 本地 HLS 清晰度。
useEffect(() => { useEffect(() => {
if (!mediaId) return if (!mediaId) return
@@ -1907,7 +1879,6 @@ export function PlayerPage() {
onPlayerVolumeCommit={commitPlayerVolume} onPlayerVolumeCommit={commitPlayerVolume}
playerPlaybackRate={playerPlaybackRate} playerPlaybackRate={playerPlaybackRate}
onPlayerPlaybackRateChange={changePlayerPlaybackRate} onPlayerPlaybackRateChange={changePlayerPlaybackRate}
onPlayerPlaybackRateCommit={commitPlayerPlaybackRate}
danmakuEnabled={danmakuEnabled} danmakuEnabled={danmakuEnabled}
danmakuOpacity={danmakuOpacity} danmakuOpacity={danmakuOpacity}
danmakuFontSize={danmakuFontSize} danmakuFontSize={danmakuFontSize}
-3
View File
@@ -184,7 +184,6 @@ type PlayerVideoStageProps = {
onPlayerVolumeCommit: (volume: number) => void onPlayerVolumeCommit: (volume: number) => void
playerPlaybackRate: number playerPlaybackRate: number
onPlayerPlaybackRateChange: (rate: number) => void onPlayerPlaybackRateChange: (rate: number) => void
onPlayerPlaybackRateCommit: (rate: number) => void
danmakuEnabled: boolean danmakuEnabled: boolean
danmakuOpacity: number danmakuOpacity: number
danmakuFontSize: number danmakuFontSize: number
@@ -268,7 +267,6 @@ export function PlayerVideoStage({
onPlayerVolumeCommit, onPlayerVolumeCommit,
playerPlaybackRate, playerPlaybackRate,
onPlayerPlaybackRateChange, onPlayerPlaybackRateChange,
onPlayerPlaybackRateCommit,
danmakuEnabled, danmakuEnabled,
danmakuOpacity, danmakuOpacity,
danmakuFontSize, danmakuFontSize,
@@ -892,7 +890,6 @@ export function PlayerVideoStage({
onVolumeCommit={onPlayerVolumeCommit} onVolumeCommit={onPlayerVolumeCommit}
playbackRate={playerPlaybackRate} playbackRate={playerPlaybackRate}
onPlaybackRateChange={onPlayerPlaybackRateChange} onPlaybackRateChange={onPlayerPlaybackRateChange}
onPlaybackRateCommit={onPlayerPlaybackRateCommit}
uiVisible={controlsVisible && !locked} uiVisible={controlsVisible && !locked}
onUiVisibleChange={setControlsVisible} onUiVisibleChange={setControlsVisible}
uiHold={vrUiHovered} uiHold={vrUiHovered}
+7
View File
@@ -23,6 +23,13 @@ export const generalSettingsGroup: SettingGroup = {
hint: '默认关闭。开启后宿主机不再进行任何 FFmpeg 转码,所有播放交给第三方客户端(Infuse / VLC / Emby 客户端等)或浏览器本地解码直连(direct play / 302 直链),大幅降低宿主机 CPU 占用。若客户端不支持源编码可能无法播放。', hint: '默认关闭。开启后宿主机不再进行任何 FFmpeg 转码,所有播放交给第三方客户端(Infuse / VLC / Emby 客户端等)或浏览器本地解码直连(direct play / 302 直链),大幅降低宿主机 CPU 占用。若客户端不支持源编码可能无法播放。',
defaultValue: 'false', defaultValue: 'false',
}, },
{
key: 'segment.prewarm_enabled',
label: '后台预热 IntroDB 片头片段',
type: 'toggle',
hint: '默认关闭。开启后每 6 小时在后台向 TheIntroDB 拉取可识别媒体的片头/片尾区间并写入本地;关闭时仅在播放时按需请求。',
defaultValue: 'false',
},
{ {
key: 'transcode.enabled', key: 'transcode.enabled',
label: '启用转码', label: '启用转码',
-2
View File
@@ -12,8 +12,6 @@ export interface User {
subtitle_chinese_mode?: 'original' | 'simplified' | 'traditional' subtitle_chinese_mode?: 'original' | 'simplified' | 'traditional'
/** 本地缓存的播放器音量,服务端配置接口会刷新。 */ /** 本地缓存的播放器音量,服务端配置接口会刷新。 */
player_volume?: number player_volume?: number
/** 本地缓存的播放倍速,服务端配置接口会刷新。 */
player_playback_rate?: number
force_password_reset: boolean force_password_reset: boolean
is_active: boolean is_active: boolean
allowed_library_ids?: string[] allowed_library_ids?: string[]
+3
View File
@@ -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 MIN_PLAYBACK_RATE = PLAYBACK_RATE_OPTIONS[0]
export const MAX_PLAYBACK_RATE = PLAYBACK_RATE_OPTIONS[PLAYBACK_RATE_OPTIONS.length - 1] 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 { export function normalizePlaybackRate(value: unknown): number {
const parsed = Number(value) const parsed = Number(value)
if (!Number.isFinite(parsed)) return 1 if (!Number.isFinite(parsed)) return 1