mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
Compare commits
7 Commits
mebox-v0.1.147
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bad07f7add | |||
| cb539afd2a | |||
| 594166f696 | |||
| e0b07492a5 | |||
| 3be6978e70 | |||
| 065652d758 | |||
| e273b5e87d |
@@ -0,0 +1,36 @@
|
||||
# Segment Coverage Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** IntroDB prewarm + same-season intro propagation + shorter miss TTL + playback-only duration probe (option C).
|
||||
|
||||
**Architecture:** Extend `MediaSegmentService` for merge/propagate/prewarm; add scheduler job `segment_prewarm`; repository queries for candidates and season siblings.
|
||||
|
||||
**Tech Stack:** Go, GORM/SQLite, existing SchedulerService + IntroDB client.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Negative cache TTL + ledgerFresh tests
|
||||
|
||||
**Files:** `internal/service/media_segment.go`, `internal/service/media_segment_test.go`
|
||||
|
||||
- Change `segmentMissingTTL` to 24h; update tests for fresh/stale miss.
|
||||
|
||||
### Task 2: Playback merge + propagation
|
||||
|
||||
**Files:** `media_segment.go`, `media_segment_repository.go`, `media_repository.go`, tests
|
||||
|
||||
- Constant `PropagatedSource = "propagated"`.
|
||||
- `ListForPlayback` merges sources with priority manual > theintrodb > propagated.
|
||||
- After successful IntroDB refresh with intro, propagate to same-season siblings (shared `tm_db_id` or `series_id`).
|
||||
|
||||
### Task 3: Prewarm API + scheduler job
|
||||
|
||||
**Files:** `media_segment.go`, `media_segment_repository.go`, `scheduler.go`, `scheduler_segment_jobs.go`, `service_builder.go`, tests
|
||||
|
||||
- `Prewarm(ctx, limit)` rate-limited refreshes.
|
||||
- Job `segment_prewarm` every 6h.
|
||||
|
||||
### Task 4: Verify
|
||||
|
||||
- `go test` for affected packages.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Segment Coverage Improvements (1–4, option C)
|
||||
|
||||
## Goal
|
||||
|
||||
Raise intro/outro skip availability without Chromaprint or full-library STRM probing.
|
||||
|
||||
## Scope (approved)
|
||||
|
||||
1. **IntroDB prewarm** — background job for all queryable media (TMDb + season/episode).
|
||||
2. **Duration** — playback-path only (no full STRM scan); keep `ListForPlayback` → `EnsureAsync`.
|
||||
3. **Same-season intro propagation** — copy intro to sibling episodes after a hit.
|
||||
4. **Negative cache** — miss TTL 24h; 403/429/timeout must not write miss ledger.
|
||||
|
||||
## Non-goals
|
||||
|
||||
Chromaprint, manual segment UI, library-name filters, batch STRM duration scan.
|
||||
|
||||
## Behavior
|
||||
|
||||
### Prewarm (`segment_prewarm`)
|
||||
|
||||
- Scheduler job, interval 6h, initial delay 6h (avoid restart spike).
|
||||
- **Opt-in via `segment.prewarm_enabled` (default off)** — system settings toggle「后台预热 IntroDB 片头片段」.
|
||||
- Candidates: `tm_db_id > 0`, season/episode or movie, ledger missing or stale.
|
||||
- Order: recently played first, then others.
|
||||
- Rate: ~1 req/s, stop on context cancel; respect IntroDB 429 retry already in client.
|
||||
- Reuse `MediaSegmentService` refresh + propagation.
|
||||
- Manual `RunNow` can bypass the toggle (same pattern as organize).
|
||||
|
||||
### Propagation
|
||||
|
||||
- After TheIntroDB refresh finds at least one `intro` span, write the same intro window to same-season siblings that lack a `theintrodb` intro.
|
||||
- Source tag: `propagated`.
|
||||
- Do not propagate credits/preview/recap.
|
||||
- Playback merge priority per kind: `manual` > `theintrodb` > `propagated`.
|
||||
|
||||
### Negative cache
|
||||
|
||||
- `segmentMissingTTL`: 24h (was 7d).
|
||||
- `segmentFoundTTL`: 30d (unchanged).
|
||||
- Provider errors (non-404) continue to skip ledger writes.
|
||||
|
||||
### Duration (C)
|
||||
|
||||
- No new batch probe job.
|
||||
- Existing `defer ensureMediaProbe` on playback remains the only STRM duration path.
|
||||
|
||||
## Success criteria
|
||||
|
||||
- Prewarm increases `media_segment_fetches` over time without blocking play.
|
||||
- One IntroDB intro hit can surface skip on sibling episodes via `propagated`.
|
||||
- Misses are retried within ~24h (sooner if recently played and prewarm runs).
|
||||
@@ -112,8 +112,7 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var cfg struct {
|
||||
MergeSources bool `json:"merge_sources"`
|
||||
PlaybackRate float64 `json:"playback_rate"`
|
||||
MergeSources bool `json:"merge_sources"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
@@ -121,9 +120,6 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
|
||||
if cfg.MergeSources {
|
||||
t.Fatal("default merge preference should be false")
|
||||
}
|
||||
if cfg.PlaybackRate != 1 {
|
||||
t.Fatalf("default playback rate = %v, want 1", cfg.PlaybackRate)
|
||||
}
|
||||
|
||||
if err := svc.Danmaku.SetMergeSources(t.Context(), "user-1", true); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
@@ -141,7 +137,7 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
|
||||
func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
body := "{\"enabled\":false,\"opacity\":0.6,\"font_size\":32,\"area\":0.7,\"merge_sources\":true,\"volume\":0.35,\"playback_rate\":1.5,\"source\":\"https://dm.example/base/\",\"app_id\":\"my-app-id\",\"app_key\":\"my-app-secret\"}"
|
||||
body := "{\"enabled\":false,\"opacity\":0.6,\"font_size\":32,\"area\":0.7,\"merge_sources\":true,\"volume\":0.35,\"source\":\"https://dm.example/base/\",\"app_id\":\"my-app-id\",\"app_key\":\"my-app-secret\"}"
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", body, "user-1")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
|
||||
@@ -158,7 +154,7 @@ func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
|
||||
if cfg.Enabled || cfg.Opacity != "0.6" || cfg.FontSize != "32" || cfg.Area != "0.7" {
|
||||
t.Fatalf("unexpected render config: %+v", cfg)
|
||||
}
|
||||
if !cfg.MergeSources || cfg.Volume != 0.35 || cfg.PlaybackRate != 1.5 {
|
||||
if !cfg.MergeSources || cfg.Volume != 0.35 {
|
||||
t.Fatalf("unexpected user preferences: %+v", cfg)
|
||||
}
|
||||
if cfg.Source != "https://dm.example/base" || cfg.AppID != "my-app-id" || !cfg.AppKeyConfigured {
|
||||
@@ -169,7 +165,7 @@ func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("read persisted user: %v", err)
|
||||
}
|
||||
if user.DanmakuAppKey != "my-app-secret" || user.PlayerVolume != 0.35 || user.PlayerPlaybackRate != 1.5 || user.DanmakuSource != "https://dm.example/base" {
|
||||
if user.DanmakuAppKey != "my-app-secret" || user.PlayerVolume != 0.35 || user.DanmakuSource != "https://dm.example/base" {
|
||||
t.Fatalf("preferences not persisted: %+v", user)
|
||||
}
|
||||
}
|
||||
@@ -207,22 +203,36 @@ func TestUpdateDanmakuSettingsPersistsVr360GuideSeen(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsRejectsInvalidPlaybackRate(t *testing.T) {
|
||||
// 播放倍速只作用于当前视频:接口既不下发也不保存它。老客户端仍可能带上
|
||||
// playback_rate,服务端应当忽略而不是报错。
|
||||
func TestDanmakuSettingsIgnorePlaybackRate(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||
`{"playback_rate":4}`, "user-1")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodGet, "/danmaku/config", "", "user-1")
|
||||
getDanmakuConfigHandler(svc)(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if bytes.Contains(w.Body.Bytes(), []byte("playback_rate")) {
|
||||
t.Fatalf("config must not expose playback rate: %s", w.Body.String())
|
||||
}
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 (body=%s)", w.Code, w.Body.String())
|
||||
c2, w2 := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||
`{"volume":0.5,"playback_rate":1.5}`, "user-1")
|
||||
updateDanmakuSettingsHandler(svc)(c2)
|
||||
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w2.Code, w2.Body.String())
|
||||
}
|
||||
if bytes.Contains(w2.Body.Bytes(), []byte("playback_rate")) {
|
||||
t.Fatalf("settings response must not expose playback rate: %s", w2.Body.String())
|
||||
}
|
||||
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("read user: %v", err)
|
||||
}
|
||||
if user.PlayerPlaybackRate != 1 {
|
||||
t.Fatalf("invalid playback rate was persisted: %v", user.PlayerPlaybackRate)
|
||||
if user.PlayerVolume != 0.5 {
|
||||
t.Fatalf("volume not persisted: %v", user.PlayerVolume)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -231,12 +231,24 @@ func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// 不一致时把结果过滤成空集。
|
||||
seasonIndex = nil
|
||||
}
|
||||
// Emby 客户端按 StartIndex/Limit 分页拉取分集,并在已收条目数小于
|
||||
// TotalRecordCount 时继续请求下一页。忽略这两个参数会让客户端永远
|
||||
// 停在第一页、反复重发同一请求(现象是分集列表一直加载不出来)。
|
||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "500"))
|
||||
if limit <= 0 {
|
||||
limit = 500
|
||||
}
|
||||
startIndex, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
|
||||
if startIndex < 0 {
|
||||
startIndex = 0
|
||||
}
|
||||
params := service.ItemsParams{
|
||||
UserID: embyEffectiveUserID(c),
|
||||
ParentID: parentID,
|
||||
IncludeItemTypes: []string{"Episode"},
|
||||
Recursive: true,
|
||||
Limit: 500,
|
||||
Limit: limit,
|
||||
StartIndex: startIndex,
|
||||
SeasonIndex: seasonIndex,
|
||||
}
|
||||
out, err := svc.Emby.Items(c.Request.Context(), params)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -21,6 +22,31 @@ import (
|
||||
// two seasons, so /Shows/{id}/Episodes can be exercised with the query forms
|
||||
// real clients send.
|
||||
func embySeasonEpisodesRouter(t *testing.T) (*gin.Engine, string, string) {
|
||||
t.Helper()
|
||||
return embySeriesRouter(t, []model.Media{
|
||||
{
|
||||
Base: model.Base{ID: "s1e1"},
|
||||
Title: "Test Show",
|
||||
Path: "D:\\media\\tv\\Test Show\\Season 01\\Test Show - S01E01.mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
Container: "mkv",
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "s2e1"},
|
||||
Title: "Test Show",
|
||||
Path: "D:\\media\\tv\\Test Show\\Season 02\\Test Show - S02E01.mkv",
|
||||
SeasonNum: 2,
|
||||
EpisodeNum: 1,
|
||||
Container: "mkv",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// embySeriesRouter builds an Emby-compatible router with a single tv library and
|
||||
// one series made of the given episode rows. Callers omit LibraryID: it is filled
|
||||
// in here once the library exists.
|
||||
func embySeriesRouter(t *testing.T, rows []model.Media) (*gin.Engine, string, string) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
@@ -48,27 +74,9 @@ func embySeasonEpisodesRouter(t *testing.T) (*gin.Engine, string, string) {
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
for _, m := range []model.Media{
|
||||
{
|
||||
Base: model.Base{ID: "s1e1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Test Show",
|
||||
Path: "D:\\media\\tv\\Test Show\\Season 01\\Test Show - S01E01.mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
Container: "mkv",
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "s2e1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Test Show",
|
||||
Path: "D:\\media\\tv\\Test Show\\Season 02\\Test Show - S02E01.mkv",
|
||||
SeasonNum: 2,
|
||||
EpisodeNum: 1,
|
||||
Container: "mkv",
|
||||
},
|
||||
} {
|
||||
if err := db.Create(&m).Error; err != nil {
|
||||
for i := range rows {
|
||||
rows[i].LibraryID = lib.ID
|
||||
if err := db.Create(&rows[i]).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -104,7 +112,34 @@ func embySeasonEpisodesRouter(t *testing.T) (*gin.Engine, string, string) {
|
||||
return router, secret, seriesID
|
||||
}
|
||||
|
||||
func fetchEpisodeIDs(t *testing.T, router *gin.Engine, secret, path string) ([]string, int) {
|
||||
func fetchSeasonID(t *testing.T, router *gin.Engine, secret, seriesID string, index int) string {
|
||||
t.Helper()
|
||||
path := "/Shows/" + seriesID + "/Seasons"
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("seasons status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var seasons struct {
|
||||
Items []map[string]any `json:"Items"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &seasons); err != nil {
|
||||
t.Fatalf("decode seasons: %v", err)
|
||||
}
|
||||
for _, s := range seasons.Items {
|
||||
if got, ok := s["IndexNumber"].(float64); ok && int(got) == index {
|
||||
if id, _ := s["Id"].(string); id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("season %d not found in %#v", index, seasons.Items)
|
||||
return ""
|
||||
}
|
||||
|
||||
func fetchEpisodeItems(t *testing.T, router *gin.Engine, secret, path string) ([]map[string]any, int) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||
@@ -120,12 +155,18 @@ func fetchEpisodeIDs(t *testing.T, router *gin.Engine, secret, path string) ([]s
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode %s: %v", path, err)
|
||||
}
|
||||
ids := make([]string, 0, len(payload.Items))
|
||||
for _, item := range payload.Items {
|
||||
return payload.Items, int(payload.TotalRecordCount)
|
||||
}
|
||||
|
||||
func fetchEpisodeIDs(t *testing.T, router *gin.Engine, secret, path string) ([]string, int) {
|
||||
t.Helper()
|
||||
items, total := fetchEpisodeItems(t, router, secret, path)
|
||||
ids := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
id, _ := item["Id"].(string)
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, int(payload.TotalRecordCount)
|
||||
return ids, total
|
||||
}
|
||||
|
||||
// TestEmbyShowEpisodesHonoursSeasonQueryParam is the client-facing regression
|
||||
@@ -174,29 +215,7 @@ func TestEmbyShowEpisodesHonoursSeasonQueryParam(t *testing.T) {
|
||||
func TestEmbyShowEpisodesSeasonIdStillWins(t *testing.T) {
|
||||
router, secret, seriesID := embySeasonEpisodesRouter(t)
|
||||
|
||||
seasonsPath := "/Shows/" + seriesID + "/Seasons"
|
||||
req := httptest.NewRequest(http.MethodGet, seasonsPath, nil)
|
||||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("seasons status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var seasons struct {
|
||||
Items []map[string]any `json:"Items"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &seasons); err != nil {
|
||||
t.Fatalf("decode seasons: %v", err)
|
||||
}
|
||||
var season1ID string
|
||||
for _, s := range seasons.Items {
|
||||
if index, ok := s["IndexNumber"].(float64); ok && int(index) == 1 {
|
||||
season1ID, _ = s["Id"].(string)
|
||||
}
|
||||
}
|
||||
if season1ID == "" {
|
||||
t.Fatalf("season 1 not found in %#v", seasons.Items)
|
||||
}
|
||||
season1ID := fetchSeasonID(t, router, secret, seriesID, 1)
|
||||
|
||||
ids, _ := fetchEpisodeIDs(t, router, secret, "/Shows/"+seriesID+"/Episodes?SeasonId="+season1ID)
|
||||
if len(ids) != 1 || ids[0] != "s1e1" {
|
||||
@@ -209,3 +228,145 @@ func TestEmbyShowEpisodesSeasonIdStillWins(t *testing.T) {
|
||||
t.Fatalf("SeasonId+Season episodes = %#v, want [s1e1]", ids)
|
||||
}
|
||||
}
|
||||
|
||||
// foldedDuplicateSeriesRows models a season where two files resolve to the same
|
||||
// episode number — 3月的狮子 S01E11 together with S01E11.5, which the episode
|
||||
// parser reads as S01E11 and stores as a second row for season 1 episode 2 here.
|
||||
func foldedDuplicateSeriesRows() []model.Media {
|
||||
return []model.Media{
|
||||
{
|
||||
Base: model.Base{ID: "e1"},
|
||||
Title: "三月的狮子",
|
||||
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E01.mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
Container: "mkv",
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "e2"},
|
||||
Title: "三月的狮子",
|
||||
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E02.mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 2,
|
||||
Container: "mkv",
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "e2half"},
|
||||
Title: "三月的狮子",
|
||||
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E02.5.mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 2,
|
||||
Container: "mkv",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbyShowEpisodesFoldedDuplicateKeepsTotalsConsistent is the regression test
|
||||
// for the "第一季分集加载不出来" lock-up: two rows sharing one season/episode are
|
||||
// folded into a single Emby item with two MediaSources. TotalRecordCount used to
|
||||
// be computed before that fold, so clients that page until they have
|
||||
// TotalRecordCount items kept re-requesting the same page and never rendered the
|
||||
// season.
|
||||
func TestEmbyShowEpisodesFoldedDuplicateKeepsTotalsConsistent(t *testing.T) {
|
||||
router, secret, seriesID := embySeriesRouter(t, foldedDuplicateSeriesRows())
|
||||
seasonID := fetchSeasonID(t, router, secret, seriesID, 1)
|
||||
|
||||
items, total := fetchEpisodeItems(t, router, secret, "/Shows/"+seriesID+"/Episodes?SeasonId="+seasonID)
|
||||
if total != len(items) {
|
||||
t.Fatalf("TotalRecordCount = %d but %d items returned, want them equal", total, len(items))
|
||||
}
|
||||
if total != 2 {
|
||||
t.Fatalf("TotalRecordCount = %d, want 2 distinct episodes", total)
|
||||
}
|
||||
// The folded episode must still carry both files so clients can switch version.
|
||||
versioned := 0
|
||||
for _, item := range items {
|
||||
if sources, ok := item["MediaSources"].([]any); ok && len(sources) > 1 {
|
||||
versioned++
|
||||
}
|
||||
}
|
||||
if versioned != 1 {
|
||||
t.Fatalf("items with multiple MediaSources = %d, want 1 (%#v)", versioned, items)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbyShowEpisodesPagingAdvances pins that the client's StartIndex/Limit reach
|
||||
// the episode list. Ignoring them made every page identical, so a client paging
|
||||
// until it has TotalRecordCount items could never finish loading the season.
|
||||
func TestEmbyShowEpisodesPagingAdvances(t *testing.T) {
|
||||
router, secret, seriesID := embySeriesRouter(t, foldedDuplicateSeriesRows())
|
||||
base := "/Shows/" + seriesID + "/Episodes?Season=1"
|
||||
|
||||
first, total := fetchEpisodeIDs(t, router, secret, base+"&StartIndex=0&Limit=1")
|
||||
if total != 2 || len(first) != 1 {
|
||||
t.Fatalf("page 1 = %#v (total=%d), want one item of two", first, total)
|
||||
}
|
||||
second, _ := fetchEpisodeIDs(t, router, secret, base+"&StartIndex=1&Limit=1")
|
||||
if len(second) != 1 || second[0] == first[0] {
|
||||
t.Fatalf("page 2 = %#v, want the remaining episode (page 1 = %#v)", second, first)
|
||||
}
|
||||
third, _ := fetchEpisodeIDs(t, router, secret, base+"&StartIndex=2&Limit=1")
|
||||
if len(third) != 0 {
|
||||
t.Fatalf("page 3 = %#v, want an empty page so clients stop paging", third)
|
||||
}
|
||||
}
|
||||
|
||||
// 半集(S01E11.5)在 Emby 侧必须和第 11 集并列返回:一旦被折成一个条目,
|
||||
// TotalRecordCount 就会比 Items 多一条,按总数翻页的客户端会一直重发同一页
|
||||
// (这就是「第一季分集加载不出来」的根因)。Emby 的 IndexNumber 只能是整数,
|
||||
// 所以集号仍占 11,小数通过条目名体现。
|
||||
func TestEmbyShowEpisodesKeepsHalfEpisodeVisible(t *testing.T) {
|
||||
router, secret, seriesID := embySeriesRouter(t, []model.Media{
|
||||
{
|
||||
Base: model.Base{ID: "e11"},
|
||||
Title: "三月的狮子",
|
||||
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E11.mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 11,
|
||||
Container: "mkv",
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "e11half"},
|
||||
Title: "三月的狮子",
|
||||
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E11.5.mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 11,
|
||||
EpisodeFraction: 0.5,
|
||||
Container: "mkv",
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "e12"},
|
||||
Title: "三月的狮子",
|
||||
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E12.mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 12,
|
||||
Container: "mkv",
|
||||
},
|
||||
})
|
||||
|
||||
items, total := fetchEpisodeItems(t, router, secret, "/Shows/"+seriesID+"/Episodes?Season=1")
|
||||
if total != 3 || len(items) != 3 {
|
||||
t.Fatalf("TotalRecordCount = %d, items = %d, want 3/3", total, len(items))
|
||||
}
|
||||
wantOrder := []string{"e11", "e11half", "e12"}
|
||||
for i, want := range wantOrder {
|
||||
id, _ := items[i]["Id"].(string)
|
||||
if id != want {
|
||||
t.Fatalf("items[%d] = %q, want %q (order %#v)", i, id, want, items)
|
||||
}
|
||||
}
|
||||
for _, item := range items {
|
||||
sources, _ := item["MediaSources"].([]any)
|
||||
if len(sources) != 1 {
|
||||
t.Fatalf("item %v folded %d sources, want 1", item["Id"], len(sources))
|
||||
}
|
||||
}
|
||||
|
||||
halfName, _ := items[1]["Name"].(string)
|
||||
if !strings.HasPrefix(halfName, "第 11.5 集") {
|
||||
t.Fatalf("half episode name = %q, want prefix 第 11.5 集", halfName)
|
||||
}
|
||||
if idx, _ := items[1]["IndexNumber"].(float64); int(idx) != 11 {
|
||||
t.Fatalf("half episode IndexNumber = %v, want 11 (Emby only supports integers)", items[1]["IndexNumber"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,38 +26,39 @@ type LibraryRoot struct {
|
||||
// Media 是单个可播放项。剧集链接到 SeriesID;电影 SeriesID == ""。
|
||||
type Media struct {
|
||||
Base
|
||||
LibraryID string `gorm:"index;size:36;index:idx_media_library_release,priority:1" json:"library_id"`
|
||||
LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"`
|
||||
SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
|
||||
Title string `gorm:"size:255;not null" json:"title"`
|
||||
OriginalName string `gorm:"size:255" json:"original_name,omitempty"`
|
||||
EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"`
|
||||
Path string `gorm:"uniqueIndex;size:1024;not null" json:"path"`
|
||||
RelativePath string `gorm:"size:1024" json:"relative_path,omitempty"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
DurationSec int `json:"duration_sec"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
VideoCodec string `gorm:"size:32" json:"video_codec,omitempty"`
|
||||
AudioCodec string `gorm:"size:32" json:"audio_codec,omitempty"`
|
||||
Container string `gorm:"size:128" json:"container,omitempty"`
|
||||
PosterURL string `gorm:"size:1024" json:"poster_url,omitempty"`
|
||||
BackdropURL string `gorm:"size:1024" json:"backdrop_url,omitempty"`
|
||||
Overview string `gorm:"type:text" json:"overview,omitempty"`
|
||||
Rating float32 `json:"rating"`
|
||||
Year int `json:"year"`
|
||||
ReleaseDate string `gorm:"size:10;index:idx_media_library_release,priority:2" json:"release_date,omitempty"`
|
||||
SeasonNum int `json:"season_num"`
|
||||
EpisodeNum int `json:"episode_num"`
|
||||
ScrapeStatus string `gorm:"size:16;default:pending" json:"scrape_status"`
|
||||
TMDbID int `json:"tmdb_id"`
|
||||
BangumiID int `json:"bangumi_id"`
|
||||
DoubanID string `gorm:"column:douban_id;size:32" json:"douban_id,omitempty"`
|
||||
TheTVDBID string `gorm:"column:thetvdb_id;size:64" json:"thetvdb_id,omitempty"`
|
||||
Languages string `gorm:"size:64" json:"languages,omitempty"` // 逗号分隔的 ISO 639-1 代码,如 "zh,en"
|
||||
Countries string `gorm:"size:128" json:"countries,omitempty"` // 逗号分隔的 ISO 3166-1,如 "CN,US"
|
||||
Genres string `gorm:"type:text" json:"genres,omitempty"` // 逗号分隔的类型名,如 "Action,Animation"
|
||||
NSFW bool `gorm:"default:false" json:"nsfw"`
|
||||
LibraryID string `gorm:"index;size:36;index:idx_media_library_release,priority:1" json:"library_id"`
|
||||
LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"`
|
||||
SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
|
||||
Title string `gorm:"size:255;not null" json:"title"`
|
||||
OriginalName string `gorm:"size:255" json:"original_name,omitempty"`
|
||||
EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"`
|
||||
Path string `gorm:"uniqueIndex;size:1024;not null" json:"path"`
|
||||
RelativePath string `gorm:"size:1024" json:"relative_path,omitempty"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
DurationSec int `json:"duration_sec"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
VideoCodec string `gorm:"size:32" json:"video_codec,omitempty"`
|
||||
AudioCodec string `gorm:"size:32" json:"audio_codec,omitempty"`
|
||||
Container string `gorm:"size:128" json:"container,omitempty"`
|
||||
PosterURL string `gorm:"size:1024" json:"poster_url,omitempty"`
|
||||
BackdropURL string `gorm:"size:1024" json:"backdrop_url,omitempty"`
|
||||
Overview string `gorm:"type:text" json:"overview,omitempty"`
|
||||
Rating float32 `json:"rating"`
|
||||
Year int `json:"year"`
|
||||
ReleaseDate string `gorm:"size:10;index:idx_media_library_release,priority:2" json:"release_date,omitempty"`
|
||||
SeasonNum int `json:"season_num"`
|
||||
EpisodeNum int `json:"episode_num"`
|
||||
EpisodeFraction float64 `gorm:"default:0" json:"episode_fraction,omitempty"` // 集号小数部分(S01E11.5 → 0.5);Emby 的 IndexNumber 只能是整数,小数单独存放
|
||||
ScrapeStatus string `gorm:"size:16;default:pending" json:"scrape_status"`
|
||||
TMDbID int `json:"tmdb_id"`
|
||||
BangumiID int `json:"bangumi_id"`
|
||||
DoubanID string `gorm:"column:douban_id;size:32" json:"douban_id,omitempty"`
|
||||
TheTVDBID string `gorm:"column:thetvdb_id;size:64" json:"thetvdb_id,omitempty"`
|
||||
Languages string `gorm:"size:64" json:"languages,omitempty"` // 逗号分隔的 ISO 639-1 代码,如 "zh,en"
|
||||
Countries string `gorm:"size:128" json:"countries,omitempty"` // 逗号分隔的 ISO 3166-1,如 "CN,US"
|
||||
Genres string `gorm:"type:text" json:"genres,omitempty"` // 逗号分隔的类型名,如 "Action,Animation"
|
||||
NSFW bool `gorm:"default:false" json:"nsfw"`
|
||||
|
||||
// STRMURL is the indirection target for .strm files: when present the
|
||||
// stream handler redirects to it instead of opening the local file.
|
||||
|
||||
@@ -37,8 +37,8 @@ type User struct {
|
||||
// original / simplified / traditional。
|
||||
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
|
||||
// 网页播放器偏好按用户存储,切换媒体对象后继续沿用。
|
||||
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
|
||||
PlayerPlaybackRate float64 `gorm:"not null;default:1" json:"player_playback_rate"`
|
||||
// 播放倍速不在此列:它只作用于当前视频,不进数据库。
|
||||
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
|
||||
// PlayerVr360GuideSeen 记录用户是否已经看过 VR 全景播放的首次操作说明,
|
||||
// 按用户保存:看过一次之后不再弹出。
|
||||
PlayerVr360GuideSeen bool `gorm:"not null;default:false" json:"player_vr360_guide_seen"`
|
||||
|
||||
@@ -232,6 +232,31 @@ func (r *MediaRepository) ExistsSiblingWithTMDbID(ctx context.Context, m *model.
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// ListSeasonSiblings returns other episodes in the same season as m.
|
||||
// Prefers series_id; falls back to shared library+title+tm_db_id (anime scrape path).
|
||||
func (r *MediaRepository) ListSeasonSiblings(ctx context.Context, m *model.Media) ([]model.Media, error) {
|
||||
if r == nil || m == nil || m.SeasonNum <= 0 || m.ID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
query := r.db.WithContext(ctx).Model(&model.Media{}).
|
||||
Where("season_num = ? AND id <> ?", m.SeasonNum, m.ID)
|
||||
if seriesID := strings.TrimSpace(m.SeriesID); seriesID != "" {
|
||||
query = query.Where("series_id = ?", seriesID)
|
||||
} else {
|
||||
libraryID := strings.TrimSpace(m.LibraryID)
|
||||
title := strings.TrimSpace(m.Title)
|
||||
if libraryID == "" || title == "" || m.TMDbID <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
query = query.Where("library_id = ? AND title = ? AND tm_db_id = ?", libraryID, title, m.TMDbID)
|
||||
}
|
||||
var rows []model.Media
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ListByLibrary returns paginated media items for a library.
|
||||
func (r *MediaRepository) ListByLibrary(ctx context.Context, libraryID string, offset, limit int) ([]model.Media, int64, error) {
|
||||
return r.ListByLibraryFiltered(ctx, libraryID, offset, limit, MediaQueryFilter{IncludeNSFW: true})
|
||||
|
||||
@@ -332,6 +332,11 @@ func addMediaPlacementUpdates(updates map[string]any, existing, incoming model.M
|
||||
if episodeChanged {
|
||||
updates["episode_num"] = incoming.EpisodeNum
|
||||
}
|
||||
// 半集的小数部分独立于整数集号比较:S01E11.5 重命名/新增/删除时都要落库,
|
||||
// 否则「11」与「11.5」会被当成同一集折叠。
|
||||
if existing.EpisodeFraction != incoming.EpisodeFraction {
|
||||
updates["episode_fraction"] = incoming.EpisodeFraction
|
||||
}
|
||||
if strings.TrimSpace(existing.ScrapeStatus) == "no_match" && incoming.ScrapeStatus != "matched" && (seasonChanged || episodeChanged) {
|
||||
updates["scrape_status"] = "pending"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
@@ -84,3 +85,45 @@ func (r *MediaSegmentRepository) UpsertFetch(ctx context.Context, row *model.Med
|
||||
}
|
||||
return r.db.WithContext(ctx).Clauses(onConflict).Create(row).Error
|
||||
}
|
||||
|
||||
// ListPrewarmCandidates returns media that should be refreshed from source:
|
||||
// no ledger, expired miss (fetched_at < missBefore), or expired hit (fetched_at < hitBefore).
|
||||
// Recently played items come first so hot titles recover coverage sooner.
|
||||
func (r *MediaSegmentRepository) ListPrewarmCandidates(
|
||||
ctx context.Context,
|
||||
source string,
|
||||
missBefore, hitBefore time.Time,
|
||||
limit int,
|
||||
) ([]model.Media, error) {
|
||||
if r == nil || limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows := make([]model.Media, 0, limit)
|
||||
// 可查询:有 TMDb,且是剧集(有季集)或电影(无季集)。
|
||||
err := r.db.WithContext(ctx).Raw(`
|
||||
SELECT m.*
|
||||
FROM media m
|
||||
LEFT JOIN media_segment_fetches f
|
||||
ON f.media_id = m.id AND f.source = ? AND f.deleted_at IS NULL
|
||||
LEFT JOIN (
|
||||
SELECT media_id, MAX(updated_at) AS last_played
|
||||
FROM playback_histories
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY media_id
|
||||
) ph ON ph.media_id = m.id
|
||||
WHERE m.deleted_at IS NULL
|
||||
AND m.tm_db_id > 0
|
||||
AND (
|
||||
(m.season_num > 0 AND m.episode_num > 0)
|
||||
OR (COALESCE(m.season_num, 0) = 0 AND COALESCE(m.episode_num, 0) = 0)
|
||||
)
|
||||
AND (
|
||||
f.id IS NULL
|
||||
OR (f.found = 0 AND f.fetched_at < ?)
|
||||
OR (f.found = 1 AND f.fetched_at < ?)
|
||||
)
|
||||
ORDER BY ph.last_played DESC
|
||||
LIMIT ?
|
||||
`, source, missBefore, hitBefore, limit).Scan(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
@@ -152,3 +152,56 @@ func TestUpsertFetchKeepsOneRowPerMediaAndSource(t *testing.T) {
|
||||
t.Fatalf("ledger should be updated in place, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSeasonSiblingsSharesLibraryTitleTMDb(t *testing.T) {
|
||||
repos := newSegmentTestRepos(t)
|
||||
ctx := t.Context()
|
||||
eps := []*model.Media{
|
||||
{Base: model.Base{ID: "a"}, LibraryID: "lib", Title: "Show", Path: "/a", SeasonNum: 1, EpisodeNum: 1, TMDbID: 99},
|
||||
{Base: model.Base{ID: "b"}, LibraryID: "lib", Title: "Show", Path: "/b", SeasonNum: 1, EpisodeNum: 2, TMDbID: 99},
|
||||
{Base: model.Base{ID: "c"}, LibraryID: "lib", Title: "Show", Path: "/c", SeasonNum: 2, EpisodeNum: 1, TMDbID: 99},
|
||||
}
|
||||
for _, ep := range eps {
|
||||
if err := repos.DB.Create(ep).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := repos.Media.ListSeasonSiblings(ctx, eps[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != "b" {
|
||||
t.Fatalf("siblings = %#v, want only same-season ep b", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPrewarmCandidatesOrdersRecentPlaysFirst(t *testing.T) {
|
||||
repos := newSegmentTestRepos(t)
|
||||
ctx := t.Context()
|
||||
now := time.Now()
|
||||
for _, m := range []*model.Media{
|
||||
{Base: model.Base{ID: "cold"}, Path: "/cold", TMDbID: 1},
|
||||
{Base: model.Base{ID: "hot"}, Path: "/hot", TMDbID: 2},
|
||||
} {
|
||||
if err := repos.DB.Create(m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := repos.DB.Create(&model.PlaybackHistory{
|
||||
Base: model.Base{ID: "ph1"}, UserID: "u", MediaID: "hot",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := repos.MediaSegment.ListPrewarmCandidates(
|
||||
ctx, "theintrodb", now.Add(-time.Hour), now.Add(-time.Hour), 10,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) < 2 {
|
||||
t.Fatalf("candidates = %#v, want both", got)
|
||||
}
|
||||
if got[0].ID != "hot" {
|
||||
t.Fatalf("first = %s, want hot (recently played)", got[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,12 +49,6 @@ const (
|
||||
// dandanplay protocol (search/episodes + comment/{episodeId}) may be used.
|
||||
const DanmakuDefaultSource = "https://api.dandanplay.net"
|
||||
|
||||
const (
|
||||
minPlayerPlaybackRate = 0.5
|
||||
maxPlayerPlaybackRate = 3.0
|
||||
defaultPlayerPlaybackRate = 1.0
|
||||
)
|
||||
|
||||
// danmakuOfficialBase is where identification (/api/v2/match) and the
|
||||
// comment/search fallback always go, regardless of the configured source.
|
||||
// A package var (not a const) so tests can point it at a local server.
|
||||
@@ -69,8 +63,6 @@ type DanmakuRenderConfig struct {
|
||||
FontSize string `json:"font_size"`
|
||||
Area string `json:"area"`
|
||||
Volume float64 `json:"volume"`
|
||||
// PlaybackRate 是当前用户的播放倍速偏好(按用户存储)。
|
||||
PlaybackRate float64 `json:"playback_rate"`
|
||||
// Vr360GuideSeen 表示当前用户是否已经看过 VR 全景播放的首次操作说明。
|
||||
Vr360GuideSeen bool `json:"vr360_guide_seen"`
|
||||
// MergeSources 是当前用户的弹幕合并偏好(按用户存储)。
|
||||
@@ -99,7 +91,6 @@ type DanmakuSettingsPatch struct {
|
||||
Area *float64 `json:"area"`
|
||||
MergeSources *bool `json:"merge_sources"`
|
||||
Volume *float64 `json:"volume"`
|
||||
PlaybackRate *float64 `json:"playback_rate"`
|
||||
// Vr360GuideSeen 标记 VR 全景播放的首次操作说明是否已经看过。
|
||||
Vr360GuideSeen *bool `json:"vr360_guide_seen"`
|
||||
}
|
||||
@@ -358,11 +349,10 @@ func cloneDanmakuAnimeList(in []DanmakuAnime) []DanmakuAnime {
|
||||
// Config reads danmaku settings from the runtime settings table.
|
||||
func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
|
||||
cfg := DanmakuRenderConfig{
|
||||
Opacity: "1",
|
||||
FontSize: "24",
|
||||
Area: "1",
|
||||
Volume: 1,
|
||||
PlaybackRate: defaultPlayerPlaybackRate,
|
||||
Opacity: "1",
|
||||
FontSize: "24",
|
||||
Area: "1",
|
||||
Volume: 1,
|
||||
}
|
||||
if s == nil || s.repo == nil || s.repo.Setting == nil {
|
||||
return cfg
|
||||
@@ -414,12 +404,11 @@ func (s *DanmakuService) findUser(ctx context.Context, userID string) (*model.Us
|
||||
func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
|
||||
if user == nil {
|
||||
return DanmakuRenderConfig{
|
||||
Enabled: true,
|
||||
Opacity: "1",
|
||||
FontSize: "24",
|
||||
Area: "1",
|
||||
Volume: 1,
|
||||
PlaybackRate: defaultPlayerPlaybackRate,
|
||||
Enabled: true,
|
||||
Opacity: "1",
|
||||
FontSize: "24",
|
||||
Area: "1",
|
||||
Volume: 1,
|
||||
}
|
||||
}
|
||||
opacity := user.DanmakuOpacity
|
||||
@@ -438,10 +427,6 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
|
||||
if volume < 0 || volume > 1 {
|
||||
volume = 1
|
||||
}
|
||||
playbackRate := user.PlayerPlaybackRate
|
||||
if playbackRate < minPlayerPlaybackRate || playbackRate > maxPlayerPlaybackRate {
|
||||
playbackRate = defaultPlayerPlaybackRate
|
||||
}
|
||||
return DanmakuRenderConfig{
|
||||
Enabled: user.DanmakuEnabled,
|
||||
Source: strings.TrimSpace(user.DanmakuSource),
|
||||
@@ -450,7 +435,6 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
|
||||
FontSize: strconv.Itoa(fontSize),
|
||||
Area: strconv.FormatFloat(area, 'f', -1, 64),
|
||||
Volume: volume,
|
||||
PlaybackRate: playbackRate,
|
||||
Vr360GuideSeen: user.PlayerVr360GuideSeen,
|
||||
MergeSources: user.DanmakuMergeSources,
|
||||
AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "",
|
||||
@@ -522,17 +506,6 @@ func (s *DanmakuService) UpdateUserSettings(ctx context.Context, userID string,
|
||||
}
|
||||
updates["player_volume"] = *patch.Volume
|
||||
}
|
||||
if patch.PlaybackRate != nil {
|
||||
if *patch.PlaybackRate < minPlayerPlaybackRate || *patch.PlaybackRate > maxPlayerPlaybackRate {
|
||||
return DanmakuRenderConfig{}, fmt.Errorf(
|
||||
"%w: playback_rate must be between %.2f and %.2f",
|
||||
ErrInvalidDanmakuSettings,
|
||||
minPlayerPlaybackRate,
|
||||
maxPlayerPlaybackRate,
|
||||
)
|
||||
}
|
||||
updates["player_playback_rate"] = *patch.PlaybackRate
|
||||
}
|
||||
if patch.Vr360GuideSeen != nil {
|
||||
updates["player_vr360_guide_seen"] = *patch.Vr360GuideSeen
|
||||
}
|
||||
|
||||
@@ -422,7 +422,6 @@ func TestDanmakuFetchUsesPerUserSourceAndCredentials(t *testing.T) {
|
||||
user.DanmakuFontSize = 30
|
||||
user.DanmakuArea = 0.7
|
||||
user.PlayerVolume = 0.42
|
||||
user.PlayerPlaybackRate = 1.25
|
||||
require.NoError(t, svc.repo.User.Create(ctx, &user))
|
||||
seedDanmakuMedia(t, svc, "per-user-media", "测试动画", "", 0)
|
||||
|
||||
@@ -431,7 +430,6 @@ func TestDanmakuFetchUsesPerUserSourceAndCredentials(t *testing.T) {
|
||||
require.Contains(t, res.Raw, "弹幕A")
|
||||
require.Equal(t, srv.URL(), res.Source)
|
||||
require.Equal(t, 0.42, res.Volume)
|
||||
require.Equal(t, 1.25, res.PlaybackRate)
|
||||
require.Equal(t, "0.65", res.Opacity)
|
||||
require.Equal(t, "30", res.FontSize)
|
||||
require.Equal(t, srv.lastHeaders.Get("X-AppId"), "user-app-id")
|
||||
|
||||
@@ -682,9 +682,21 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
|
||||
seasonID = e.seasonIDForMedia(ctx, m)
|
||||
parentID = seasonID
|
||||
episodeTitle := strings.TrimSpace(m.EpisodeTitle)
|
||||
if episodeTitle != "" {
|
||||
// 半集(S01E11.5)在 Emby 协议里只能占用整数 IndexNumber 11,标题里
|
||||
// 不再带上小数就会和真正的第 11 集完全分不清,所以显式补上集号。
|
||||
halfLabel := ""
|
||||
if m.EpisodeFraction > 0 && m.EpisodeNum > 0 {
|
||||
halfLabel = fmt.Sprintf("第 %s 集", FormatEpisodeNumber(m.EpisodeNum, m.EpisodeFraction))
|
||||
}
|
||||
switch {
|
||||
case episodeTitle != "":
|
||||
name = episodeTitle
|
||||
} else if m.EpisodeNum > 0 {
|
||||
if halfLabel != "" && !strings.HasPrefix(episodeTitle, halfLabel) {
|
||||
name = halfLabel + " · " + episodeTitle
|
||||
}
|
||||
case halfLabel != "":
|
||||
name = halfLabel
|
||||
case m.EpisodeNum > 0:
|
||||
name = fmt.Sprintf("第 %d 集", m.EpisodeNum)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,8 +153,15 @@ func (e *EmbyService) episodeItems(ctx context.Context, rows []model.Media, p It
|
||||
if rows[i].EpisodeNum != rows[j].EpisodeNum {
|
||||
return rows[i].EpisodeNum < rows[j].EpisodeNum
|
||||
}
|
||||
if rows[i].EpisodeFraction != rows[j].EpisodeFraction {
|
||||
return rows[i].EpisodeFraction < rows[j].EpisodeFraction
|
||||
}
|
||||
return rows[i].CreatedAt.Before(rows[j].CreatedAt)
|
||||
})
|
||||
// 先折叠同集的多个版本再统计总数与分页,与 payloadsForMedia 内部保持同一步骤。
|
||||
// 若按未折叠的行数报 TotalRecordCount(例如 S01E11 与 S01E11.5 两行折叠成一条),
|
||||
// 客户端会认为还有一条没取到,反复请求下一页 —— 分集列表就会一直加载不出来。
|
||||
rows = e.collapseMediaVersionRows(ctx, rows)
|
||||
total := len(rows)
|
||||
items, err := e.payloadsForMedia(ctx, pageSlice(rows, p.StartIndex, p.Limit), p.UserID)
|
||||
if err != nil {
|
||||
|
||||
@@ -33,7 +33,7 @@ func (e *EmbyService) mediaVersionSiblings(ctx context.Context, m *model.Media)
|
||||
}
|
||||
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
||||
Where("library_id IN ?", libraryIDs).
|
||||
Where("season_num = ? AND episode_num = ?", m.SeasonNum, m.EpisodeNum)
|
||||
Where("season_num = ? AND episode_num = ? AND episode_fraction = ?", m.SeasonNum, m.EpisodeNum, m.EpisodeFraction)
|
||||
if m.TMDbID > 0 {
|
||||
q = q.Where("tm_db_id = ?", m.TMDbID)
|
||||
} else if m.BangumiID > 0 {
|
||||
@@ -110,11 +110,15 @@ func (e *EmbyService) mediaVersionKey(ctx context.Context, m *model.Media) strin
|
||||
}
|
||||
kind := mediaSpecialKind(m.Path)
|
||||
season, episode := m.SeasonNum, m.EpisodeNum
|
||||
fraction := m.EpisodeFraction
|
||||
if kind != "" && kind != mediaSpecialTheatrical && episode <= 0 {
|
||||
if parsedSeason, parsedEpisode := ParseEpisode(m.Path); parsedEpisode > 0 {
|
||||
season, episode = parsedSeason, parsedEpisode
|
||||
if parsedSeason, parsedEpisode, parsedFraction := ParseEpisodeParts(m.Path); parsedEpisode > 0 {
|
||||
season, episode, fraction = parsedSeason, parsedEpisode, parsedFraction
|
||||
}
|
||||
}
|
||||
// 集号后缀让 S01E11.5 与 S01E11 属于不同版本组;没有小数时后缀为空,
|
||||
// 键与历史完全一致。
|
||||
fractionSuffix := episodeFractionSuffix(fraction)
|
||||
kindKey := ""
|
||||
if kind != "" {
|
||||
kindKey = "|kind:" + kind
|
||||
@@ -126,10 +130,10 @@ func (e *EmbyService) mediaVersionKey(ctx context.Context, m *model.Media) strin
|
||||
return libraryGroup + "|special-item:" + kind + "|id:" + m.ID
|
||||
}
|
||||
if m.TMDbID > 0 {
|
||||
return fmt.Sprintf("%s|tmdb:%d|s:%d|e:%d%s", libraryGroup, m.TMDbID, season, episode, kindKey)
|
||||
return fmt.Sprintf("%s|tmdb:%d|s:%d|e:%d%s%s", libraryGroup, m.TMDbID, season, episode, fractionSuffix, kindKey)
|
||||
}
|
||||
if m.BangumiID > 0 {
|
||||
return fmt.Sprintf("%s|bangumi:%d|s:%d|e:%d%s", libraryGroup, m.BangumiID, season, episode, kindKey)
|
||||
return fmt.Sprintf("%s|bangumi:%d|s:%d|e:%d%s%s", libraryGroup, m.BangumiID, season, episode, fractionSuffix, kindKey)
|
||||
}
|
||||
title := strings.ToLower(strings.TrimSpace(m.Title))
|
||||
if title == "" {
|
||||
@@ -138,7 +142,7 @@ func (e *EmbyService) mediaVersionKey(ctx context.Context, m *model.Media) strin
|
||||
if title == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s|title:%s|y:%d|s:%d|e:%d%s", libraryGroup, title, m.Year, season, episode, kindKey)
|
||||
return fmt.Sprintf("%s|title:%s|y:%d|s:%d|e:%d%s%s", libraryGroup, title, m.Year, season, episode, fractionSuffix, kindKey)
|
||||
}
|
||||
|
||||
func preferMediaVersion(candidate, current model.Media) bool {
|
||||
|
||||
@@ -55,7 +55,7 @@ func (e *EmbyService) findSeriesGroup(ctx context.Context, id, userID string) (e
|
||||
// 有 series_id 时分组 key 就是 series_id 本身(UUID,不带虚拟前缀)。
|
||||
q = q.Where("series_id IS NULL OR series_id = ''")
|
||||
}
|
||||
if err := q.Order("media.season_num asc, media.episode_num asc, media.created_at asc").Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
|
||||
if err := q.Order("media.season_num asc, media.episode_num asc, media.episode_fraction asc, media.created_at asc").Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
|
||||
return embySeriesGroup{}, false, err
|
||||
}
|
||||
for _, group := range e.seriesGroupsFromMedia(ctx, rows) {
|
||||
@@ -68,20 +68,20 @@ func (e *EmbyService) findSeriesGroup(ctx context.Context, id, userID string) (e
|
||||
if series, err := e.repo.Series.FindByID(ctx, id); err != nil {
|
||||
return embySeriesGroup{}, false, err
|
||||
} else if series != nil {
|
||||
return embySeriesGroup{
|
||||
ID: series.ID,
|
||||
LibraryID: series.LibraryID,
|
||||
Name: series.Title,
|
||||
PosterURL: series.PosterURL,
|
||||
BackdropURL: series.BackdropURL,
|
||||
Overview: series.Overview,
|
||||
Rating: series.Rating,
|
||||
Year: series.Year,
|
||||
TMDbID: series.TMDbID,
|
||||
BangumiID: series.BangumiID,
|
||||
CreatedAt: series.CreatedAt,
|
||||
DateLastMediaAdded: series.CreatedAt,
|
||||
}, true, nil
|
||||
return embySeriesGroup{
|
||||
ID: series.ID,
|
||||
LibraryID: series.LibraryID,
|
||||
Name: series.Title,
|
||||
PosterURL: series.PosterURL,
|
||||
BackdropURL: series.BackdropURL,
|
||||
Overview: series.Overview,
|
||||
Rating: series.Rating,
|
||||
Year: series.Year,
|
||||
TMDbID: series.TMDbID,
|
||||
BangumiID: series.BangumiID,
|
||||
CreatedAt: series.CreatedAt,
|
||||
DateLastMediaAdded: series.CreatedAt,
|
||||
}, true, nil
|
||||
}
|
||||
}
|
||||
return embySeriesGroup{}, false, nil
|
||||
@@ -108,7 +108,7 @@ func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (e
|
||||
Where("(series_id IS NULL OR series_id = '') AND (season_num > 0 OR episode_num > 0)")
|
||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||
if err := q.
|
||||
Order("media.season_num asc, media.episode_num asc, media.created_at asc").
|
||||
Order("media.season_num asc, media.episode_num asc, media.episode_fraction asc, media.created_at asc").
|
||||
Limit(embySeriesGroupingLimit).
|
||||
Find(&rows).Error; err != nil {
|
||||
return embySeasonGroup{}, false, err
|
||||
@@ -167,7 +167,7 @@ func (e *EmbyService) seasonGroupForSeries(ctx context.Context, id, seriesID, us
|
||||
Where("series_id = ? AND (season_num > 0 OR episode_num > 0)", seriesID)
|
||||
rq = e.applyUserMediaVisibility(ctx, rq, userID)
|
||||
if err := rq.
|
||||
Order("media.season_num asc, media.episode_num asc, media.created_at asc").
|
||||
Order("media.season_num asc, media.episode_num asc, media.episode_fraction asc, media.created_at asc").
|
||||
Limit(embySeriesGroupingLimit).
|
||||
Find(&rows).Error; err != nil {
|
||||
return embySeasonGroup{}, false, err
|
||||
@@ -194,26 +194,26 @@ func (e *EmbyService) seriesGroupsFromMedia(ctx context.Context, rows []model.Me
|
||||
seriesID := e.seriesIDForMedia(ctx, &row)
|
||||
group, ok := byID[seriesID]
|
||||
if !ok {
|
||||
group = &embySeriesGroup{
|
||||
ID: seriesID,
|
||||
LibraryID: row.LibraryID,
|
||||
Name: e.seriesNameForMedia(ctx, &row),
|
||||
Year: row.Year,
|
||||
ReleaseDate: row.ReleaseDate,
|
||||
TMDbID: row.TMDbID,
|
||||
BangumiID: row.BangumiID,
|
||||
CreatedAt: row.CreatedAt,
|
||||
DateLastMediaAdded: row.CreatedAt,
|
||||
}
|
||||
byID[seriesID] = group
|
||||
order = append(order, seriesID)
|
||||
}
|
||||
if row.CreatedAt.Before(group.CreatedAt) || group.CreatedAt.IsZero() {
|
||||
group.CreatedAt = row.CreatedAt
|
||||
}
|
||||
if row.CreatedAt.After(group.DateLastMediaAdded) {
|
||||
group.DateLastMediaAdded = row.CreatedAt
|
||||
group = &embySeriesGroup{
|
||||
ID: seriesID,
|
||||
LibraryID: row.LibraryID,
|
||||
Name: e.seriesNameForMedia(ctx, &row),
|
||||
Year: row.Year,
|
||||
ReleaseDate: row.ReleaseDate,
|
||||
TMDbID: row.TMDbID,
|
||||
BangumiID: row.BangumiID,
|
||||
CreatedAt: row.CreatedAt,
|
||||
DateLastMediaAdded: row.CreatedAt,
|
||||
}
|
||||
byID[seriesID] = group
|
||||
order = append(order, seriesID)
|
||||
}
|
||||
if row.CreatedAt.Before(group.CreatedAt) || group.CreatedAt.IsZero() {
|
||||
group.CreatedAt = row.CreatedAt
|
||||
}
|
||||
if row.CreatedAt.After(group.DateLastMediaAdded) {
|
||||
group.DateLastMediaAdded = row.CreatedAt
|
||||
}
|
||||
if strings.TrimSpace(row.ReleaseDate) != "" && mediaReleaseSortTime(row).After(embySeriesReleaseSortTime(*group)) {
|
||||
group.ReleaseDate = row.ReleaseDate
|
||||
if row.Year > 0 {
|
||||
@@ -249,6 +249,9 @@ func (e *EmbyService) seriesGroupsFromMedia(ctx context.Context, rows []model.Me
|
||||
if group.Episodes[i].EpisodeNum != group.Episodes[j].EpisodeNum {
|
||||
return group.Episodes[i].EpisodeNum < group.Episodes[j].EpisodeNum
|
||||
}
|
||||
if group.Episodes[i].EpisodeFraction != group.Episodes[j].EpisodeFraction {
|
||||
return group.Episodes[i].EpisodeFraction < group.Episodes[j].EpisodeFraction
|
||||
}
|
||||
return group.Episodes[i].CreatedAt.Before(group.Episodes[j].CreatedAt)
|
||||
})
|
||||
groups = append(groups, group)
|
||||
|
||||
@@ -28,9 +28,13 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
|
||||
patSEnERange = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\s*[-~–—]\s*(?:s(\d{1,2}))?e?(\d{1,3})(?:[^0-9]|$)`)
|
||||
patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`)
|
||||
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
|
||||
// patSEnEFraction 捕获 S01E11.5 这类「半集」写法的小数位:只在 SxxEyy 后
|
||||
// 紧跟一个点加一位数字时成立;解析时还要确认该数字后面不是另一个数字,
|
||||
// 否则 S01E11.1080p 的分辨率会被误读成小数集号。
|
||||
patSEnEFraction = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\.(\d)`)
|
||||
patSEnERange = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\s*[-~–—]\s*(?:s(\d{1,2}))?e?(\d{1,3})(?:[^0-9]|$)`)
|
||||
patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`)
|
||||
// patNxE 匹配 1x02 这类季集写法的捕获组,同时用于从标题里剔除季集残留
|
||||
// (ReplaceAllString),因此本身不带边界守卫。解析时改用 patNxEGuarded,
|
||||
// 避免 "1920x1080" 被从中间匹配出 "20x108" 而误判成 S20E108。
|
||||
@@ -123,6 +127,62 @@ func ParseEpisode(path string) (season, episode int) {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// ParseEpisodeParts 在 ParseEpisode 的基础上返回集号的小数部分:
|
||||
// S01E11.5 → (1, 11, 0.5)。小数部分用来把「半集」和同季的整集区分开
|
||||
// (折叠/身份键、排序、网页端显示集号都带上它),解析不出来时为 0。
|
||||
func ParseEpisodeParts(path string) (season, episode int, fraction float64) {
|
||||
name := mediaSidecarBase(path)
|
||||
if name == "" {
|
||||
name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
}
|
||||
if m := patSEnE.FindStringSubmatch(name); len(m) == 3 {
|
||||
return mustAtoi(m[1]), mustAtoi(m[2]), episodeFractionFromName(name)
|
||||
}
|
||||
season, episode = ParseEpisode(path)
|
||||
return season, episode, 0
|
||||
}
|
||||
|
||||
// episodeFractionFromName 从 "S01E11.5" 这类名字里取出小数部分(0.5)。
|
||||
// 只在 SxxEyy 后紧跟「点 + 一位数字」、且该数字之后不是数字时才成立,
|
||||
// 否则 S01E11.1080p / S01E05.10bit 会被读成小数集号。
|
||||
func episodeFractionFromName(name string) float64 {
|
||||
lower := strings.ToLower(name)
|
||||
m := patSEnEFraction.FindStringSubmatchIndex(lower)
|
||||
if len(m) != 8 {
|
||||
return 0
|
||||
}
|
||||
digitsStart, digitsEnd := m[6], m[7]
|
||||
if digitsStart < 0 || digitsEnd <= digitsStart {
|
||||
return 0
|
||||
}
|
||||
if digitsEnd < len(lower) && lower[digitsEnd] >= '0' && lower[digitsEnd] <= '9' {
|
||||
return 0
|
||||
}
|
||||
value, err := strconv.ParseFloat("0."+lower[digitsStart:digitsEnd], 64)
|
||||
if err != nil || value <= 0 || value >= 1 {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// episodeFractionSuffix 把小数部分渲染成身份/显示后缀:0.5 → ".5",无小数 → ""。
|
||||
// 折叠键用它拼接,所以没有小数的集(绝大多数)产生的键与历史完全一致。
|
||||
func episodeFractionSuffix(fraction float64) string {
|
||||
if fraction <= 0 || fraction >= 1 {
|
||||
return ""
|
||||
}
|
||||
formatted := strconv.FormatFloat(fraction, 'f', -1, 64)
|
||||
if !strings.HasPrefix(formatted, "0.") {
|
||||
return ""
|
||||
}
|
||||
return formatted[1:]
|
||||
}
|
||||
|
||||
// FormatEpisodeNumber 把整数集号与小数部分拼成显示集号:11 → "11",11.5 → "11.5"。
|
||||
func FormatEpisodeNumber(episode int, fraction float64) string {
|
||||
return strconv.Itoa(episode) + episodeFractionSuffix(fraction)
|
||||
}
|
||||
|
||||
// resolutionEpisodeArtifact returns the bogus (season, episode) pair the legacy
|
||||
// `(\d{1,2})x(\d{1,3})` pattern would extract from a WxH pixel-dimension token
|
||||
// in path — e.g. 1920x1080 -> (20, 108), 3840x2160 -> (40, 216). Reports ok=false
|
||||
@@ -215,6 +275,21 @@ func onlineEpisodeIdentityFromPath(path string) (season, episode int) {
|
||||
return season, episode
|
||||
}
|
||||
|
||||
// onlineEpisodeFractionFromPath 返回按在线身份规则得到的集号小数部分:
|
||||
// S01E11.5 → 0.5。只有在线身份未被重映射(SxxE00 特别篇那种改写)时才给出,
|
||||
// 避免把重映射后的特别篇又带上原季的小数。
|
||||
func onlineEpisodeFractionFromPath(path string) float64 {
|
||||
season, episode, fraction := ParseEpisodeParts(path)
|
||||
if fraction <= 0 || episode <= 0 {
|
||||
return 0
|
||||
}
|
||||
onlineSeason, onlineEpisode := onlineEpisodeIdentityFromPath(path)
|
||||
if onlineSeason != season || onlineEpisode != episode {
|
||||
return 0
|
||||
}
|
||||
return fraction
|
||||
}
|
||||
|
||||
type episodeRef struct {
|
||||
Season int
|
||||
Episode int
|
||||
|
||||
@@ -152,3 +152,60 @@ func TestDropResolutionArtifactEpisodeIdentity(t *testing.T) {
|
||||
t.Fatalf("legitimate S20E108 identity was cleared: %+v", legit)
|
||||
}
|
||||
}
|
||||
|
||||
// S01E11.5 这类「半集」要保留小数部分,才能和真正的第 11 集区分开;
|
||||
// 同时不能把 S01E11.1080p / S01E05.10bit 这类分辨率、位深读成小数集号。
|
||||
func TestParseEpisodePartsReadsHalfEpisodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
wantSeason int
|
||||
wantEpisode int
|
||||
wantFraction float64
|
||||
}{
|
||||
{"half episode in anime folder", `动漫/三月的狮子/3月的狮子 S01E11.5.mkv.strm`, 1, 11, 0.5},
|
||||
{"half episode bare", `三月的狮子 S01E07.5.mkv`, 1, 7, 0.5},
|
||||
{"plain episode has no fraction", `三月的狮子 S01E11.mkv`, 1, 11, 0},
|
||||
{"resolution is not a fraction", `Show S01E11.1080p.WEB-DL.mkv`, 1, 11, 0},
|
||||
{"bit depth is not a fraction", `Show S01E05.10bit.mkv`, 1, 5, 0},
|
||||
{"special naming keeps integral episode", `三月的狮子 S00E11.mkv`, 0, 11, 0},
|
||||
{"no episode marker", `movie.mkv`, 0, 0, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
season, episode, fraction := ParseEpisodeParts(tc.path)
|
||||
if season != tc.wantSeason || episode != tc.wantEpisode || fraction != tc.wantFraction {
|
||||
t.Fatalf("ParseEpisodeParts(%q) = (%d, %d, %v), want (%d, %d, %v)",
|
||||
tc.path, season, episode, fraction, tc.wantSeason, tc.wantEpisode, tc.wantFraction)
|
||||
}
|
||||
// ParseEpisode 必须保持原行为(只返回整数季集号)。
|
||||
plainSeason, plainEpisode := ParseEpisode(tc.path)
|
||||
if plainSeason != tc.wantSeason || plainEpisode != tc.wantEpisode {
|
||||
t.Fatalf("ParseEpisode(%q) = (%d, %d), want (%d, %d)",
|
||||
tc.path, plainSeason, plainEpisode, tc.wantSeason, tc.wantEpisode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlineEpisodeFractionFromPath(t *testing.T) {
|
||||
if got := onlineEpisodeFractionFromPath(`动漫/三月的狮子/3月的狮子 S01E11.5.mkv.strm`); got != 0.5 {
|
||||
t.Fatalf("half episode fraction = %v, want 0.5", got)
|
||||
}
|
||||
if got := onlineEpisodeFractionFromPath(`三月的狮子 S01E11.mkv`); got != 0 {
|
||||
t.Fatalf("plain episode fraction = %v, want 0", got)
|
||||
}
|
||||
// S01E00 会被重映射成特别篇 S00E01,此时不能再带上原季的小数规则。
|
||||
if got := onlineEpisodeFractionFromPath(`三月的狮子 S01E00.mkv`); got != 0 {
|
||||
t.Fatalf("remapped special fraction = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatEpisodeNumber(t *testing.T) {
|
||||
if got := FormatEpisodeNumber(11, 0.5); got != "11.5" {
|
||||
t.Fatalf("FormatEpisodeNumber(11, 0.5) = %q, want 11.5", got)
|
||||
}
|
||||
if got := FormatEpisodeNumber(11, 0); got != "11" {
|
||||
t.Fatalf("FormatEpisodeNumber(11, 0) = %q, want 11", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func LookupLocalAvailability(ctx context.Context, repo *repository.Container, ti
|
||||
var rows []model.Media
|
||||
if err := repo.DB.WithContext(ctx).
|
||||
Where("title LIKE ? OR original_name LIKE ? OR path LIKE ?", like, like, like).
|
||||
Order("season_num asc, episode_num asc, created_at desc").
|
||||
Order("season_num asc, episode_num asc, episode_fraction asc, created_at desc").
|
||||
Limit(2000).
|
||||
Find(&rows).Error; err != nil {
|
||||
return out
|
||||
|
||||
@@ -68,7 +68,11 @@ func applyLocalEpisodeMetadata(m *model.Media, local *LocalMetadata) {
|
||||
if local.EpisodeTitle != "" {
|
||||
m.EpisodeTitle = local.EpisodeTitle
|
||||
}
|
||||
if local.SeasonNum > 0 || local.EpisodeNum > 0 {
|
||||
// tvshow.nfo 里常带 scraper 写的 <season>-1</season> / <episode>-1</episode>
|
||||
// 哨兵(表示「整剧级、不适用」)。负季号不是有效季号:一旦让它覆盖,该集会被
|
||||
// 写成 -1,网页端按 seasonLabel(-1) 显示成「剧场版」。因此负值一律忽略,
|
||||
// 保留文件名解析出来的季号(S00Exx 会被解析成第 0 季 = 特别篇)。
|
||||
if local.SeasonNum >= 0 && (local.SeasonNum > 0 || local.EpisodeNum > 0) {
|
||||
m.SeasonNum = local.SeasonNum
|
||||
}
|
||||
if local.EpisodeNum > 0 {
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
func TestReadLocalMetadataDropsResolutionArtifactEpisode(t *testing.T) {
|
||||
@@ -436,3 +438,48 @@ func TestReadLocalMetadataFindsLegacyImgPoster(t *testing.T) {
|
||||
t.Fatalf("PosterURL = %q, want legacy .img poster %q", got.PosterURL, poster)
|
||||
}
|
||||
}
|
||||
|
||||
// tvshow.nfo 里 scraper 写的 <season>-1</season>(整剧级"不适用"哨兵)不能覆盖
|
||||
// 文件名解析出的季号:一旦覆盖,该集会被写成 -1,网页端按 seasonLabel(-1) 显示成
|
||||
// 「剧场版」,Emby 端也只能落进特别篇。S00Exx 这类特别篇命名应保留第 0 季。
|
||||
func TestApplyLocalEpisodeMetadataIgnoresNegativeSeasonSentinel(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
parsed model.Media
|
||||
local LocalMetadata
|
||||
wantSeason int
|
||||
wantEpisode int
|
||||
}{
|
||||
{
|
||||
name: "show level sentinel keeps parsed season",
|
||||
parsed: model.Media{SeasonNum: 2, EpisodeNum: 5},
|
||||
local: LocalMetadata{SeasonNum: -1, EpisodeNum: 5, HasNFO: true},
|
||||
wantSeason: 2,
|
||||
wantEpisode: 5,
|
||||
},
|
||||
{
|
||||
name: "special naming keeps season zero",
|
||||
parsed: model.Media{SeasonNum: 0, EpisodeNum: 11},
|
||||
local: LocalMetadata{SeasonNum: -1, EpisodeNum: 11, HasNFO: true},
|
||||
wantSeason: 0,
|
||||
wantEpisode: 11,
|
||||
},
|
||||
{
|
||||
name: "positive episode season still applies",
|
||||
parsed: model.Media{SeasonNum: 0, EpisodeNum: 11},
|
||||
local: LocalMetadata{SeasonNum: 1, EpisodeNum: 11, HasNFO: true},
|
||||
wantSeason: 1,
|
||||
wantEpisode: 11,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
media := tc.parsed
|
||||
local := tc.local
|
||||
applyLocalMetadata(&media, &local)
|
||||
if media.SeasonNum != tc.wantSeason || media.EpisodeNum != tc.wantEpisode {
|
||||
t.Fatalf("season/episode = %d/%d, want %d/%d", media.SeasonNum, media.EpisodeNum, tc.wantSeason, tc.wantEpisode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,10 +82,14 @@ func rebuildManualScrapeIdentity(media *model.Media, lib *model.Library, match *
|
||||
switch mediaType {
|
||||
case "tv", "anime", "variety":
|
||||
media.SeasonNum, media.EpisodeNum = onlineEpisodeIdentityFromPath(media.Path)
|
||||
// 半集(S01E11.5)的小数部分同样按路径重建,否则重新刮削会把 11.5
|
||||
// 变回普通的第 11 集。
|
||||
media.EpisodeFraction = onlineEpisodeFractionFromPath(media.Path)
|
||||
media.EpisodeTitle = ""
|
||||
case "movie", "adult":
|
||||
media.SeasonNum = 0
|
||||
media.EpisodeNum = 0
|
||||
media.EpisodeFraction = 0
|
||||
media.EpisodeTitle = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,10 @@ type MediaMetadataUpdate struct {
|
||||
Rating *float32 `json:"rating"`
|
||||
SeasonNum *int `json:"season_num"`
|
||||
EpisodeNum *int `json:"episode_num"`
|
||||
TMDbID *int `json:"tmdb_id"`
|
||||
// EpisodeFraction 是集号的小数部分(11.5 的 0.5)。单独提供是为了让编辑
|
||||
// 「集」时不会把半集写成整数集号,也不会把整集写成小数。
|
||||
EpisodeFraction *float64 `json:"episode_fraction"`
|
||||
TMDbID *int `json:"tmdb_id"`
|
||||
BangumiID *int `json:"bangumi_id"`
|
||||
DoubanID *string `json:"douban_id"`
|
||||
TheTVDBID *string `json:"thetvdb_id"`
|
||||
@@ -77,6 +80,9 @@ func (s *MediaService) UpdateMetadata(ctx context.Context, id string, req MediaM
|
||||
if req.EpisodeNum != nil {
|
||||
updates["episode_num"] = clampNonNegativeInt(*req.EpisodeNum)
|
||||
}
|
||||
if req.EpisodeFraction != nil {
|
||||
updates["episode_fraction"] = clampEpisodeFraction(*req.EpisodeFraction)
|
||||
}
|
||||
if req.TMDbID != nil {
|
||||
updates["tm_db_id"] = clampNonNegativeInt(*req.TMDbID)
|
||||
}
|
||||
@@ -141,6 +147,15 @@ func clampNonNegativeInt(value int) int {
|
||||
return value
|
||||
}
|
||||
|
||||
// clampEpisodeFraction 把集号小数部分夹到 [0,1):小于 0 或大于等于 1 都视为
|
||||
// 「没有小数」,避免把非法值写进库。
|
||||
func clampEpisodeFraction(value float64) float64 {
|
||||
if value <= 0 || value >= 1 {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func clampRating(value float32) float32 {
|
||||
if value < 0 {
|
||||
return 0
|
||||
|
||||
@@ -18,9 +18,21 @@ import (
|
||||
// 片段数据的缓存时长。命中过说明社区库里已有记录、数据很少变动,可以放很久;
|
||||
// 未命中说明这部片还没人贡献,隔一段时间再试一次即可——负缓存是必须的,否则
|
||||
// 每次播放一部没有片段数据的影片都会打一次外网。
|
||||
//
|
||||
// 未命中 TTL 刻意短于命中:社区库在持续补充,尤其是热门剧,24h 重试一次比
|
||||
// 锁死 7 天更能跟上贡献节奏;预热任务也会优先扫最近播放过的 miss。
|
||||
const (
|
||||
segmentFoundTTL = 30 * 24 * time.Hour
|
||||
segmentMissingTTL = 7 * 24 * time.Hour
|
||||
segmentMissingTTL = 24 * time.Hour
|
||||
|
||||
// SegmentSourceManual / Propagated 与 IntroDBSource 并列,播放时按优先级合并。
|
||||
SegmentSourceManual = "manual"
|
||||
SegmentSourcePropagated = "propagated"
|
||||
|
||||
// segmentPrewarmDefaultLimit 是一次预热任务最多处理的媒体数,避免单次跑太久。
|
||||
segmentPrewarmDefaultLimit = 200
|
||||
// segmentPrewarmInterval 是预热请求之间的间隔,压低对 TheIntroDB 的 429。
|
||||
segmentPrewarmInterval = time.Second
|
||||
)
|
||||
|
||||
// SegmentView 是播放器消费的最小片段结构,避免把库内字段(source 等)暴露给前端。
|
||||
@@ -44,14 +56,29 @@ type MediaSegmentService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
introdb *IntroDBService
|
||||
// probe 负责从文件内嵌章节里提取片头/片尾(异步、落库)。未注入时只用
|
||||
// TheIntroDB。
|
||||
// probe 负责异步补齐媒体信息(主要是 STRM 时长)。未注入时只用 TheIntroDB。
|
||||
probe *MediaProbeService
|
||||
// prewarmGap 是预热两次 IntroDB 请求之间的间隔;测试可设为 0。
|
||||
prewarmGap time.Duration
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewMediaSegmentService is the constructor.
|
||||
func NewMediaSegmentService(log *zap.Logger, repo *repository.Container) *MediaSegmentService {
|
||||
return &MediaSegmentService{log: log, repo: repo}
|
||||
return &MediaSegmentService{
|
||||
log: log,
|
||||
repo: repo,
|
||||
prewarmGap: segmentPrewarmInterval,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// SetPrewarmGap overrides the delay between prewarm fetches (tests use 0).
|
||||
func (s *MediaSegmentService) SetPrewarmGap(gap time.Duration) *MediaSegmentService {
|
||||
if s != nil {
|
||||
s.prewarmGap = gap
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SetIntroDB wires the provider. Without it the service only reads cached rows.
|
||||
@@ -62,8 +89,8 @@ func (s *MediaSegmentService) SetIntroDB(p *IntroDBService) *MediaSegmentService
|
||||
return s
|
||||
}
|
||||
|
||||
// SetProbe wires the in-file chapter extractor. Without it the ffprobe source
|
||||
// simply yields nothing and auto falls back to TheIntroDB.
|
||||
// SetProbe wires the async media probe (duration backfill for STRM). Without it
|
||||
// open-ended credits still work once duration is known from elsewhere.
|
||||
func (s *MediaSegmentService) SetProbe(p *MediaProbeService) *MediaSegmentService {
|
||||
if s != nil {
|
||||
s.probe = p
|
||||
@@ -121,28 +148,73 @@ func mediaProbeSettled(row *model.MediaProbe) bool {
|
||||
}
|
||||
|
||||
// introDBSegments 读社区库的片段,缓存过期时按调用方的预算抓一次并落库。
|
||||
// 返回值是按来源优先级合并后的结果(manual > theintrodb > propagated)。
|
||||
func (s *MediaSegmentService) introDBSegments(ctx context.Context, m *model.Media) ([]model.MediaSegment, error) {
|
||||
cached, err := s.repo.MediaSegment.ListByMediaSource(ctx, m.ID, IntroDBSource)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ledger, err := s.repo.MediaSegment.GetFetch(ctx, m.ID, IntroDBSource)
|
||||
if err != nil {
|
||||
// 调用方预算耗尽时仍读本地缓存,绝不能让播放接口报错。
|
||||
if ctx.Err() != nil {
|
||||
return s.mergeForPlayback(context.WithoutCancel(ctx), m.ID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if ledger != nil && ledgerFresh(ledger) {
|
||||
return cached, nil
|
||||
if ledger == nil || !ledgerFresh(ledger) {
|
||||
if _, _, err := s.refresh(ctx, m); err != nil {
|
||||
// 社区库不可达或返回异常:沿用已有缓存,不影响播放;也不写负缓存。
|
||||
logIntroDBFailure(s.log, 0, err)
|
||||
}
|
||||
}
|
||||
refreshed, attempted, err := s.refresh(ctx, m)
|
||||
// 合并读库只碰本地,脱离调用方 deadline,避免外网抓取耗尽预算后读缓存也失败。
|
||||
return s.mergeForPlayback(context.WithoutCancel(ctx), m.ID)
|
||||
}
|
||||
|
||||
// mergeForPlayback 合并同一媒体上多来源片段。同 kind 只保留优先级最高的来源。
|
||||
func (s *MediaSegmentService) mergeForPlayback(ctx context.Context, mediaID string) ([]model.MediaSegment, error) {
|
||||
rows, err := s.repo.MediaSegment.ListByMedia(ctx, mediaID)
|
||||
if err != nil {
|
||||
// 社区库不可达或返回异常:沿用已有缓存,不影响播放。
|
||||
logIntroDBFailure(s.log, 0, err)
|
||||
return cached, nil
|
||||
return nil, err
|
||||
}
|
||||
if !attempted {
|
||||
return cached, nil
|
||||
return preferSegmentsBySource(rows), nil
|
||||
}
|
||||
|
||||
// segmentSourcePriority 数值越大越优先。未知来源视为最低,避免挡住已知源。
|
||||
func segmentSourcePriority(source string) int {
|
||||
switch source {
|
||||
case SegmentSourceManual:
|
||||
return 3
|
||||
case IntroDBSource:
|
||||
return 2
|
||||
case SegmentSourcePropagated:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
// preferSegmentsBySource 按 kind 选取最高优先级来源的全部区间。
|
||||
func preferSegmentsBySource(rows []model.MediaSegment) []model.MediaSegment {
|
||||
bestPri := make(map[string]int, 4)
|
||||
byKind := make(map[string][]model.MediaSegment, 4)
|
||||
for _, row := range rows {
|
||||
pri := segmentSourcePriority(row.Source)
|
||||
cur, seen := bestPri[row.Kind]
|
||||
if !seen || pri > cur {
|
||||
bestPri[row.Kind] = pri
|
||||
byKind[row.Kind] = []model.MediaSegment{row}
|
||||
continue
|
||||
}
|
||||
if pri == cur {
|
||||
byKind[row.Kind] = append(byKind[row.Kind], row)
|
||||
}
|
||||
}
|
||||
out := make([]model.MediaSegment, 0, len(rows))
|
||||
for _, kind := range []string{
|
||||
model.SegmentKindIntro, model.SegmentKindRecap,
|
||||
model.SegmentKindCredits, model.SegmentKindPreview,
|
||||
} {
|
||||
out = append(out, byKind[kind]...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *MediaSegmentService) debug(message, mediaID string, err error) {
|
||||
@@ -210,9 +282,111 @@ func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]mo
|
||||
}); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
s.propagateIntroToSeason(fetchCtx, m, rows)
|
||||
return rows, true, nil
|
||||
}
|
||||
|
||||
// propagateIntroToSeason 把本集 IntroDB 命中的 intro 复制到同季还没有
|
||||
// theintrodb intro 的兄弟集。片尾/预告不传播:各集时长与片尾位置经常不同。
|
||||
func (s *MediaSegmentService) propagateIntroToSeason(ctx context.Context, m *model.Media, rows []model.MediaSegment) {
|
||||
if s == nil || s.repo == nil || m == nil || m.SeasonNum <= 0 {
|
||||
return
|
||||
}
|
||||
intros := introSpansFrom(rows)
|
||||
if len(intros) == 0 {
|
||||
return
|
||||
}
|
||||
siblings, err := s.repo.Media.ListSeasonSiblings(ctx, m)
|
||||
if err != nil {
|
||||
s.debug("list season siblings failed", m.ID, err)
|
||||
return
|
||||
}
|
||||
for i := range siblings {
|
||||
sib := &siblings[i]
|
||||
existing, err := s.repo.MediaSegment.ListByMediaSource(ctx, sib.ID, IntroDBSource)
|
||||
if err != nil {
|
||||
s.debug("list sibling introdb segments failed", sib.ID, err)
|
||||
continue
|
||||
}
|
||||
if hasSegmentKind(existing, model.SegmentKindIntro) {
|
||||
continue
|
||||
}
|
||||
copied := make([]model.MediaSegment, 0, len(intros))
|
||||
for _, intro := range intros {
|
||||
copied = append(copied, model.MediaSegment{
|
||||
MediaID: sib.ID,
|
||||
SeriesID: sib.SeriesID,
|
||||
Kind: model.SegmentKindIntro,
|
||||
StartMs: intro.StartMs,
|
||||
EndMs: intro.EndMs,
|
||||
Source: SegmentSourcePropagated,
|
||||
})
|
||||
}
|
||||
if err := s.repo.MediaSegment.ReplaceForMedia(ctx, sib.ID, SegmentSourcePropagated, copied); err != nil {
|
||||
s.debug("propagate intro failed", sib.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func introSpansFrom(rows []model.MediaSegment) []model.MediaSegment {
|
||||
out := make([]model.MediaSegment, 0, 1)
|
||||
for _, row := range rows {
|
||||
if row.Kind == model.SegmentKindIntro {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasSegmentKind(rows []model.MediaSegment, kind string) bool {
|
||||
for _, row := range rows {
|
||||
if row.Kind == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Prewarm 批量向 TheIntroDB 补齐过期/缺失账本的可查询媒体。返回实际发起过查询的数量。
|
||||
func (s *MediaSegmentService) Prewarm(ctx context.Context, limit int) (int, error) {
|
||||
if s == nil || s.repo == nil || s.introdb == nil {
|
||||
return 0, nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = segmentPrewarmDefaultLimit
|
||||
}
|
||||
now := time.Now()
|
||||
if s.now != nil {
|
||||
now = s.now()
|
||||
}
|
||||
candidates, err := s.repo.MediaSegment.ListPrewarmCandidates(
|
||||
ctx, IntroDBSource, now.Add(-segmentMissingTTL), now.Add(-segmentFoundTTL), limit,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
attempted := 0
|
||||
for i := range candidates {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return attempted, err
|
||||
}
|
||||
m := &candidates[i]
|
||||
_, did, err := s.refresh(ctx, m)
|
||||
if err != nil {
|
||||
logIntroDBFailure(s.log, m.TMDbID, err)
|
||||
}
|
||||
if did {
|
||||
attempted++
|
||||
}
|
||||
if i+1 < len(candidates) && s.prewarmGap > 0 {
|
||||
if !waitForIntroDBRetry(ctx, s.prewarmGap) {
|
||||
return attempted, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
return attempted, nil
|
||||
}
|
||||
|
||||
// queryIDs resolves the provider query key. Movies use their own TMDb id.
|
||||
//
|
||||
// 剧集需要「剧集级」TMDb id 加季/集。优先取 Series.TMDbID;但有些刮削路径
|
||||
|
||||
@@ -385,6 +385,123 @@ func TestListForPlaybackRespectsCallerDeadline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferSegmentsBySourcePrefersManualThenIntroDBThenPropagated(t *testing.T) {
|
||||
rows := []model.MediaSegment{
|
||||
{Kind: model.SegmentKindIntro, StartMs: 1, EndMs: 2, Source: SegmentSourcePropagated},
|
||||
{Kind: model.SegmentKindIntro, StartMs: 10, EndMs: 20, Source: IntroDBSource},
|
||||
{Kind: model.SegmentKindIntro, StartMs: 100, EndMs: 200, Source: SegmentSourceManual},
|
||||
{Kind: model.SegmentKindCredits, StartMs: 1000, EndMs: 0, Source: SegmentSourcePropagated},
|
||||
{Kind: model.SegmentKindCredits, StartMs: 2000, EndMs: 0, Source: IntroDBSource},
|
||||
}
|
||||
got := preferSegmentsBySource(rows)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %#v, want manual intro + introdb credits", got)
|
||||
}
|
||||
if got[0].Source != SegmentSourceManual || got[0].StartMs != 100 {
|
||||
t.Fatalf("intro = %#v, want manual", got[0])
|
||||
}
|
||||
if got[1].Source != IntroDBSource || got[1].StartMs != 2000 {
|
||||
t.Fatalf("credits = %#v, want theintrodb", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestListForPlaybackPropagatesIntroToSeasonSiblings(t *testing.T) {
|
||||
svc, repos, _ := newSegmentServiceFixture(t, writeJSONBody(introDBTVPayload))
|
||||
svc.SetPrewarmGap(0)
|
||||
ctx := t.Context()
|
||||
episodes := []*model.Media{
|
||||
{Base: model.Base{ID: "ep-1"}, LibraryID: "lib-anime", Title: "便·当",
|
||||
Path: "/anime/S01E01.mkv", SeasonNum: 1, EpisodeNum: 1, TMDbID: 61970},
|
||||
{Base: model.Base{ID: "ep-2"}, LibraryID: "lib-anime", Title: "便·当",
|
||||
Path: "/anime/S01E02.mkv", SeasonNum: 1, EpisodeNum: 2, TMDbID: 61970},
|
||||
}
|
||||
for _, ep := range episodes {
|
||||
if err := repos.DB.Create(ep).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := svc.ListForPlayback(ctx, episodes[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("ep1 rows = %#v, want intro+credits from provider", rows)
|
||||
}
|
||||
|
||||
sib, err := repos.MediaSegment.ListByMediaSource(ctx, "ep-2", SegmentSourcePropagated)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sib) != 1 || sib[0].Kind != model.SegmentKindIntro {
|
||||
t.Fatalf("sibling propagated = %#v, want intro only", sib)
|
||||
}
|
||||
if sib[0].StartMs != 228_664 || sib[0].EndMs != 246_143 {
|
||||
t.Fatalf("propagated window = %#v", sib[0])
|
||||
}
|
||||
|
||||
// 兄弟集播放时应直接看到传播来的 intro(即使自己还没打过 IntroDB)。
|
||||
if err := repos.MediaSegment.UpsertFetch(ctx, &model.MediaSegmentFetch{
|
||||
MediaID: "ep-2", Source: IntroDBSource, FetchedAt: time.Now(), Found: false,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
merged, err := svc.ListForPlayback(ctx, episodes[1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(merged) != 1 || merged[0].Kind != model.SegmentKindIntro || merged[0].Source != SegmentSourcePropagated {
|
||||
t.Fatalf("ep2 playback = %#v, want propagated intro", merged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrewarmFetchesStaleMissesPreferringRecentPlays(t *testing.T) {
|
||||
svc, repos, calls := newSegmentServiceFixture(t, writeJSONBody(introDBMoviePayload))
|
||||
svc.SetPrewarmGap(0)
|
||||
ctx := t.Context()
|
||||
|
||||
old := &model.Media{Base: model.Base{ID: "mv-old"}, Path: "/a.mkv", TMDbID: 111}
|
||||
hot := &model.Media{Base: model.Base{ID: "mv-hot"}, Path: "/b.mkv", TMDbID: 27205}
|
||||
for _, m := range []*model.Media{old, hot} {
|
||||
if err := repos.DB.Create(m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// 两条都是过期 miss,热播的应优先被预热。
|
||||
stale := time.Now().Add(-segmentMissingTTL - time.Hour)
|
||||
for _, id := range []string{"mv-old", "mv-hot"} {
|
||||
if err := repos.MediaSegment.UpsertFetch(ctx, &model.MediaSegmentFetch{
|
||||
MediaID: id, Source: IntroDBSource, FetchedAt: stale, Found: false,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := repos.DB.Create(&model.PlaybackHistory{
|
||||
Base: model.Base{ID: "h-1"}, UserID: "u1", MediaID: "mv-hot",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
n, err := svc.Prewarm(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("attempted = %d, want 1", n)
|
||||
}
|
||||
if got := atomic.LoadInt32(calls); got != 1 {
|
||||
t.Fatalf("provider calls = %d, want 1", got)
|
||||
}
|
||||
ledger, err := repos.MediaSegment.GetFetch(ctx, "mv-hot", IntroDBSource)
|
||||
if err != nil || ledger == nil || !ledger.Found {
|
||||
t.Fatalf("hot ledger = %#v err=%v, want found", ledger, err)
|
||||
}
|
||||
oldLedger, err := repos.MediaSegment.GetFetch(ctx, "mv-old", IntroDBSource)
|
||||
if err != nil || oldLedger == nil || oldLedger.Found {
|
||||
t.Fatalf("old ledger should remain a miss, got %#v err=%v", oldLedger, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLedgerFreshUsesLongerTTLWhenDataWasFound(t *testing.T) {
|
||||
now := time.Now()
|
||||
found := &model.MediaSegmentFetch{FetchedAt: now.Add(-segmentMissingTTL), Found: true}
|
||||
@@ -399,3 +516,4 @@ func TestLedgerFreshUsesLongerTTLWhenDataWasFound(t *testing.T) {
|
||||
t.Fatal("a missing ledger must not be considered fresh")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -287,7 +287,7 @@ func (s *MediaService) ListLibrarySeriesEpisodes(ctx context.Context, libraryID,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// sortEpisodesForDisplay 与历史行为一致:季/集号升序,再按入库时间兜底。
|
||||
// sortEpisodesForDisplay 与历史行为一致:季/集号升序(含半集的小数部分),再按入库时间兜底。
|
||||
func sortEpisodesForDisplay(out []model.Media) {
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].SeasonNum != out[j].SeasonNum {
|
||||
@@ -296,6 +296,9 @@ func sortEpisodesForDisplay(out []model.Media) {
|
||||
if out[i].EpisodeNum != out[j].EpisodeNum {
|
||||
return out[i].EpisodeNum < out[j].EpisodeNum
|
||||
}
|
||||
if out[i].EpisodeFraction != out[j].EpisodeFraction {
|
||||
return out[i].EpisodeFraction < out[j].EpisodeFraction
|
||||
}
|
||||
return out[i].CreatedAt.Before(out[j].CreatedAt)
|
||||
})
|
||||
}
|
||||
@@ -403,6 +406,9 @@ func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, vi
|
||||
if out[i].EpisodeNum != out[j].EpisodeNum {
|
||||
return out[i].EpisodeNum < out[j].EpisodeNum
|
||||
}
|
||||
if out[i].EpisodeFraction != out[j].EpisodeFraction {
|
||||
return out[i].EpisodeFraction < out[j].EpisodeFraction
|
||||
}
|
||||
if out[i].Path != out[j].Path {
|
||||
return out[i].Path < out[j].Path
|
||||
}
|
||||
|
||||
@@ -134,6 +134,9 @@ func GroupEpisodeVersionsForDisplay(items []model.Media) []MediaItem {
|
||||
if grouped[i].EpisodeNum != grouped[j].EpisodeNum {
|
||||
return grouped[i].EpisodeNum < grouped[j].EpisodeNum
|
||||
}
|
||||
if grouped[i].EpisodeFraction != grouped[j].EpisodeFraction {
|
||||
return grouped[i].EpisodeFraction < grouped[j].EpisodeFraction
|
||||
}
|
||||
return grouped[i].CreatedAt.Before(grouped[j].CreatedAt)
|
||||
})
|
||||
return grouped
|
||||
@@ -192,7 +195,7 @@ func adultVouchedGroupKey(m model.Media, vouched map[string]bool) string {
|
||||
return fmt.Sprintf("adult:%s:%s", libKey, code)
|
||||
}
|
||||
|
||||
func mediaVersionGroupKey(m model.Media) string { // 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
|
||||
func mediaVersionGroupKey(m model.Media) string { // 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
|
||||
if IsEmbyRemoteID(m.ID) {
|
||||
return fmt.Sprintf("embyremote:%s", m.ID)
|
||||
}
|
||||
@@ -200,11 +203,15 @@ func mediaVersionGroupKey(m model.Media) string { // 远程 Emby 挂载条目保
|
||||
libKey := mediaVersionLibraryKey(m)
|
||||
specialKind := mediaSpecialKind(m.Path)
|
||||
season, episode := m.SeasonNum, m.EpisodeNum
|
||||
fraction := m.EpisodeFraction
|
||||
if specialKind != "" && specialKind != mediaSpecialTheatrical && episode <= 0 {
|
||||
if parsedSeason, parsedEpisode := ParseEpisode(m.Path); parsedEpisode > 0 {
|
||||
season, episode = parsedSeason, parsedEpisode
|
||||
if parsedSeason, parsedEpisode, parsedFraction := ParseEpisodeParts(m.Path); parsedEpisode > 0 {
|
||||
season, episode, fraction = parsedSeason, parsedEpisode, parsedFraction
|
||||
}
|
||||
}
|
||||
// 集号后缀:S01E11.5 记成 "11.5",没有小数的集后缀为空,
|
||||
// 因此绝大多数条目的版本身份键与历史完全一致。
|
||||
episodeTag := fmt.Sprintf("%d:%d%s", season, episode, episodeFractionSuffix(fraction))
|
||||
|
||||
if season > 0 || episode > 0 {
|
||||
kind := specialKind
|
||||
@@ -213,13 +220,13 @@ func mediaVersionGroupKey(m model.Media) string { // 远程 Emby 挂载条目保
|
||||
}
|
||||
switch {
|
||||
case m.TMDbID > 0:
|
||||
return fmt.Sprintf("episode:%s:tmdb:%d:%d:%d", kind, m.TMDbID, season, episode)
|
||||
return fmt.Sprintf("episode:%s:tmdb:%d:%s", kind, m.TMDbID, episodeTag)
|
||||
case m.BangumiID > 0:
|
||||
return fmt.Sprintf("episode:%s:bangumi:%d:%d:%d", kind, m.BangumiID, season, episode)
|
||||
return fmt.Sprintf("episode:%s:bangumi:%d:%s", kind, m.BangumiID, episodeTag)
|
||||
case strings.TrimSpace(m.DoubanID) != "":
|
||||
return fmt.Sprintf("episode:%s:douban:%s:%d:%d", kind, strings.ToLower(strings.TrimSpace(m.DoubanID)), season, episode)
|
||||
return fmt.Sprintf("episode:%s:douban:%s:%s", kind, strings.ToLower(strings.TrimSpace(m.DoubanID)), episodeTag)
|
||||
case strings.TrimSpace(m.TheTVDBID) != "":
|
||||
return fmt.Sprintf("episode:%s:thetvdb:%s:%d:%d", kind, strings.ToLower(strings.TrimSpace(m.TheTVDBID)), season, episode)
|
||||
return fmt.Sprintf("episode:%s:thetvdb:%s:%s", kind, strings.ToLower(strings.TrimSpace(m.TheTVDBID)), episodeTag)
|
||||
}
|
||||
title := firstNonEmpty(m.OriginalName, m.Title)
|
||||
if title == "" {
|
||||
@@ -234,7 +241,7 @@ func mediaVersionGroupKey(m model.Media) string { // 远程 Emby 挂载条目保
|
||||
kind,
|
||||
libKey,
|
||||
title,
|
||||
fmt.Sprintf("%d:%d", season, episode),
|
||||
episodeTag,
|
||||
}, "|")
|
||||
}
|
||||
|
||||
|
||||
@@ -737,3 +737,53 @@ func TestMediaAdultGroupCodeRequiresNSFW(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// S01E11 与 S01E11.5 必须折叠成两条不同记录(既不合并,也不丢集),
|
||||
// 且 11.5 排在 11 之后、12 之前。这是「第一季分集加载不出来」那次的根因:
|
||||
// 两条同季同集的记录被折成一条,计数与返回条目对不上。
|
||||
func TestGroupEpisodeVersionsForDisplayKeepsHalfEpisodeSeparate(t *testing.T) {
|
||||
rows := []model.Media{
|
||||
{Base: model.Base{ID: "ep12"}, LibraryID: "anime", Title: "三月的狮子", SeasonNum: 1, EpisodeNum: 12, TMDbID: 65336, Path: "/anime/三月的狮子/3月的狮子 S01E12.mkv"},
|
||||
{Base: model.Base{ID: "ep11"}, LibraryID: "anime", Title: "三月的狮子", SeasonNum: 1, EpisodeNum: 11, TMDbID: 65336, Path: "/anime/三月的狮子/3月的狮子 S01E11.mkv"},
|
||||
{Base: model.Base{ID: "ep11half"}, LibraryID: "anime", Title: "三月的狮子", SeasonNum: 1, EpisodeNum: 11, EpisodeFraction: 0.5, TMDbID: 65336, Path: "/anime/三月的狮子/3月的狮子 S01E11.5.mkv"},
|
||||
}
|
||||
|
||||
grouped := GroupEpisodeVersionsForDisplay(rows)
|
||||
if len(grouped) != 3 {
|
||||
t.Fatalf("grouped len = %d, want 3 (11 / 11.5 / 12): %#v", len(grouped), grouped)
|
||||
}
|
||||
wantOrder := []string{"ep11", "ep11half", "ep12"}
|
||||
for i, want := range wantOrder {
|
||||
if grouped[i].ID != want {
|
||||
t.Fatalf("grouped[%d] = %q, want %q (order %#v)", i, grouped[i].ID, want, grouped)
|
||||
}
|
||||
}
|
||||
for _, item := range grouped {
|
||||
if len(item.Versions) != 0 {
|
||||
t.Fatalf("half episode must not fold with the integral one: %#v", grouped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 版本身份键必须区分 11 与 11.5,同时保持没有小数时的历史键不变。
|
||||
func TestMediaVersionGroupKeyDistinguishesHalfEpisode(t *testing.T) {
|
||||
base := model.Media{LibraryID: "anime", Title: "三月的狮子", SeasonNum: 1, EpisodeNum: 11}
|
||||
plain := base
|
||||
half := base
|
||||
half.EpisodeFraction = 0.5
|
||||
|
||||
plainKey := mediaVersionGroupKey(plain)
|
||||
halfKey := mediaVersionGroupKey(half)
|
||||
if plainKey == "" || halfKey == "" {
|
||||
t.Fatalf("version keys must not be empty: %q %q", plainKey, halfKey)
|
||||
}
|
||||
if plainKey == halfKey {
|
||||
t.Fatalf("11 and 11.5 must have different version keys, both = %q", plainKey)
|
||||
}
|
||||
if !strings.HasSuffix(halfKey, "1:11.5") {
|
||||
t.Fatalf("half episode key = %q, want suffix 1:11.5", halfKey)
|
||||
}
|
||||
if !strings.HasSuffix(plainKey, "1:11") {
|
||||
t.Fatalf("plain episode key = %q, want suffix 1:11", plainKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +142,9 @@ func WriteMediaNFO(m *model.Media) (string, error) {
|
||||
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
|
||||
title := strings.TrimSpace(m.EpisodeTitle)
|
||||
if title == "" && m.EpisodeNum > 0 {
|
||||
title = fmt.Sprintf("第 %d 集", m.EpisodeNum)
|
||||
// 兜底标题也要带上小数:S01E11.5 导出成「第 11.5 集」,
|
||||
// 否则回写 NFO 后重新入库会把半集写成普通的第 11 集。
|
||||
title = fmt.Sprintf("第 %s 集", FormatEpisodeNumber(m.EpisodeNum, m.EpisodeFraction))
|
||||
}
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(m.Title)
|
||||
|
||||
@@ -170,9 +170,12 @@ type existingLocalMedia struct {
|
||||
TheTVDBID string
|
||||
SeasonNum int
|
||||
EpisodeNum int
|
||||
Genres string
|
||||
Countries string
|
||||
Languages string
|
||||
NSFW bool
|
||||
ScrapeStatus string
|
||||
// EpisodeFraction 是集号的小数部分(S01E11.5 → 0.5);变化同样要触发刷新,
|
||||
// 否则已有行上的「半集」标记不会被写回。
|
||||
EpisodeFraction float64
|
||||
Genres string
|
||||
Countries string
|
||||
Languages string
|
||||
NSFW bool
|
||||
ScrapeStatus string
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func (s *ScannerService) existingLocalMediaSnapshotForRoot(ctx context.Context,
|
||||
query = query.Where("(library_root_id = ? OR library_root_id = '' OR library_root_id IS NULL)", rootID)
|
||||
}
|
||||
rows, err := query.
|
||||
Select("path", "library_root_id", "relative_path", "title", "original_name", "episode_title", "size_bytes", "duration_sec", "width", "height", "video_codec", "audio_codec", "container", "strm_url", "file_id", "poster_url", "backdrop_url", "overview", "year", "release_date", "rating", "tm_db_id", "bangumi_id", "douban_id", "thetvdb_id", "season_num", "episode_num", "genres", "countries", "languages", "nsfw", "scrape_status").
|
||||
Select("path", "library_root_id", "relative_path", "title", "original_name", "episode_title", "size_bytes", "duration_sec", "width", "height", "video_codec", "audio_codec", "container", "strm_url", "file_id", "poster_url", "backdrop_url", "overview", "year", "release_date", "rating", "tm_db_id", "bangumi_id", "douban_id", "thetvdb_id", "season_num", "episode_num", "episode_fraction", "genres", "countries", "languages", "nsfw", "scrape_status").
|
||||
Rows()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -74,6 +74,7 @@ func (s *ScannerService) existingLocalMediaSnapshotForRoot(ctx context.Context,
|
||||
&row.TheTVDBID,
|
||||
&row.SeasonNum,
|
||||
&row.EpisodeNum,
|
||||
&row.EpisodeFraction,
|
||||
&row.Genres,
|
||||
&row.Countries,
|
||||
&row.Languages,
|
||||
|
||||
@@ -23,18 +23,19 @@ func (s *ScannerService) ingestFile(ctx context.Context, lib *model.Library, roo
|
||||
return
|
||||
}
|
||||
|
||||
parsedSeason, parsedEpisode := ParseEpisode(path)
|
||||
parsedSeason, parsedEpisode, parsedFraction := ParseEpisodeParts(path)
|
||||
localMeta := s.readLocalScanMetadata(lib, root, path, parsedSeason, parsedEpisode)
|
||||
media := s.buildLocalScanMedia(localScanMediaInput{
|
||||
lib: lib,
|
||||
root: root,
|
||||
path: path,
|
||||
ext: ext,
|
||||
fileID: fileID,
|
||||
size: size,
|
||||
parsedSeason: parsedSeason,
|
||||
parsedEpisode: parsedEpisode,
|
||||
localMeta: localMeta,
|
||||
lib: lib,
|
||||
root: root,
|
||||
path: path,
|
||||
ext: ext,
|
||||
fileID: fileID,
|
||||
size: size,
|
||||
parsedSeason: parsedSeason,
|
||||
parsedEpisode: parsedEpisode,
|
||||
parsedFraction: parsedFraction,
|
||||
localMeta: localMeta,
|
||||
})
|
||||
isNewMedia, skipUnchanged := s.localMediaScanState(localMediaScanStateInput{
|
||||
ctx: ctx,
|
||||
@@ -169,15 +170,16 @@ func localMediaProbeDataMissing(existing existingLocalMedia) bool {
|
||||
}
|
||||
|
||||
type localScanMediaInput struct {
|
||||
lib *model.Library
|
||||
root *model.LibraryRoot
|
||||
path string
|
||||
ext string
|
||||
fileID string
|
||||
size int64
|
||||
parsedSeason int
|
||||
parsedEpisode int
|
||||
localMeta *LocalMetadata
|
||||
lib *model.Library
|
||||
root *model.LibraryRoot
|
||||
path string
|
||||
ext string
|
||||
fileID string
|
||||
size int64
|
||||
parsedSeason int
|
||||
parsedEpisode int
|
||||
parsedFraction float64
|
||||
localMeta *LocalMetadata
|
||||
}
|
||||
|
||||
func (s *ScannerService) buildLocalScanMedia(in localScanMediaInput) *model.Media {
|
||||
@@ -191,17 +193,18 @@ func (s *ScannerService) buildLocalScanMedia(in localScanMediaInput) *model.Medi
|
||||
title, year = preferISOParentScrapeIdentity(in.path, in.lib.Path, title, year)
|
||||
|
||||
media := &model.Media{
|
||||
LibraryID: in.lib.ID,
|
||||
LibraryRootID: libraryRootID(in.root),
|
||||
RelativePath: localRelativePath(in.path, in.root),
|
||||
Title: title,
|
||||
Year: year,
|
||||
Path: in.path,
|
||||
SizeBytes: in.size,
|
||||
Container: strings.TrimPrefix(in.ext, "."),
|
||||
FileID: in.fileID,
|
||||
SeasonNum: in.parsedSeason,
|
||||
EpisodeNum: in.parsedEpisode,
|
||||
LibraryID: in.lib.ID,
|
||||
LibraryRootID: libraryRootID(in.root),
|
||||
RelativePath: localRelativePath(in.path, in.root),
|
||||
Title: title,
|
||||
Year: year,
|
||||
Path: in.path,
|
||||
SizeBytes: in.size,
|
||||
Container: strings.TrimPrefix(in.ext, "."),
|
||||
FileID: in.fileID,
|
||||
SeasonNum: in.parsedSeason,
|
||||
EpisodeNum: in.parsedEpisode,
|
||||
EpisodeFraction: in.parsedFraction,
|
||||
}
|
||||
if in.ext == ".strm" {
|
||||
media.Container = "strm"
|
||||
|
||||
@@ -17,6 +17,9 @@ type scanDerivedMetadata struct {
|
||||
TheTVDBID string
|
||||
SeasonNum int
|
||||
EpisodeNum int
|
||||
// EpisodeFraction 是集号的小数部分(S01E11.5 → 0.5):它变化时同样要刷新,
|
||||
// 否则已有行上的半集标记永远写不进去。
|
||||
EpisodeFraction float64
|
||||
}
|
||||
|
||||
func localMetadataNeedsRefresh(existing existingLocalMedia, local *LocalMetadata) bool {
|
||||
@@ -97,16 +100,17 @@ func localDerivedMetadataNeedsRefresh(existing existingLocalMedia, incoming *mod
|
||||
return true
|
||||
}
|
||||
return scanDerivedMetadataNeedsRefresh(scanDerivedMetadata{
|
||||
Title: existing.Title,
|
||||
ScrapeStatus: existing.ScrapeStatus,
|
||||
Year: existing.Year,
|
||||
ReleaseDate: existing.ReleaseDate,
|
||||
TMDbID: existing.TMDbID,
|
||||
BangumiID: existing.BangumiID,
|
||||
DoubanID: existing.DoubanID,
|
||||
TheTVDBID: existing.TheTVDBID,
|
||||
SeasonNum: existing.SeasonNum,
|
||||
EpisodeNum: existing.EpisodeNum,
|
||||
Title: existing.Title,
|
||||
ScrapeStatus: existing.ScrapeStatus,
|
||||
Year: existing.Year,
|
||||
ReleaseDate: existing.ReleaseDate,
|
||||
TMDbID: existing.TMDbID,
|
||||
BangumiID: existing.BangumiID,
|
||||
DoubanID: existing.DoubanID,
|
||||
TheTVDBID: existing.TheTVDBID,
|
||||
SeasonNum: existing.SeasonNum,
|
||||
EpisodeNum: existing.EpisodeNum,
|
||||
EpisodeFraction: existing.EpisodeFraction,
|
||||
}, incoming)
|
||||
}
|
||||
|
||||
@@ -128,6 +132,9 @@ func scanDerivedMetadataNeedsRefresh(existing scanDerivedMetadata, incoming *mod
|
||||
if incoming.EpisodeNum > 0 && existing.EpisodeNum != incoming.EpisodeNum {
|
||||
return true
|
||||
}
|
||||
if (incoming.SeasonNum > 0 || incoming.EpisodeNum > 0) && existing.EpisodeFraction != incoming.EpisodeFraction {
|
||||
return true
|
||||
}
|
||||
if incoming.TMDbID > 0 && existing.TMDbID != incoming.TMDbID {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
// organize_source opt-in — organize the configured staging folder.
|
||||
// transcode_cleanup every 24 h — purge HLS transcode artefacts
|
||||
// older than 24 h.
|
||||
// segment_prewarm every 6 h — fill IntroDB skip segments for queryable media.
|
||||
//
|
||||
// Each job runs at most once at a time (an in-flight run blocks the
|
||||
// next tick). All work happens on a long-lived background context so
|
||||
@@ -42,6 +43,8 @@ type SchedulerService struct {
|
||||
|
||||
imagesPolicyProvider func() ImageCachePolicy
|
||||
|
||||
segments *MediaSegmentService
|
||||
|
||||
mu sync.Mutex
|
||||
stopCh chan struct{}
|
||||
jobs []*scheduledJob
|
||||
@@ -145,6 +148,14 @@ func (s *SchedulerService) Start(ctx context.Context) {
|
||||
run: s.jobCleanImageCache,
|
||||
},
|
||||
}
|
||||
// 片头预热只在注入了 Segments 时注册,避免测试跑无转外网任务。
|
||||
if s.segments != nil {
|
||||
s.jobs = append(s.jobs, &scheduledJob{
|
||||
name: "segment_prewarm",
|
||||
interval: segmentPrewarmJobInterval,
|
||||
run: s.jobSegmentPrewarm,
|
||||
})
|
||||
}
|
||||
// 到期提醒只在配置了巡检器时注册,避免测试与未启用通知的部署跑空转任务。
|
||||
if s.expiryWatcher != nil {
|
||||
s.jobs = append(s.jobs, &scheduledJob{
|
||||
@@ -155,8 +166,8 @@ func (s *SchedulerService) Start(ctx context.Context) {
|
||||
}
|
||||
for _, j := range s.jobs {
|
||||
initialDelay := 15 * time.Second
|
||||
if j.name == "library_scan" || j.name == "organize_source" {
|
||||
// 重启后不立即整库重扫/整理下载目录:更新窗口恰是登录高峰,
|
||||
if j.name == "library_scan" || j.name == "organize_source" || j.name == "segment_prewarm" {
|
||||
// 重启后不立即整库重扫/整理/预热:更新窗口恰是登录高峰,
|
||||
// 15 秒即全量 walk + ffprobe 曾把 CPU/磁盘打满导致无法登录。
|
||||
// 首轮等满一个完整周期再跑,平时节奏不变。
|
||||
initialDelay = j.interval
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
segmentPrewarmJobInterval = 6 * time.Hour
|
||||
// SegmentPrewarmEnabledKey 控制是否后台预热 TheIntroDB 片段;默认关闭。
|
||||
SegmentPrewarmEnabledKey = "segment.prewarm_enabled"
|
||||
)
|
||||
|
||||
// SetSegments wires the IntroDB prewarm job. Without it the job is not registered.
|
||||
func (s *SchedulerService) SetSegments(segments *MediaSegmentService) {
|
||||
if s != nil {
|
||||
s.segments = segments
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SchedulerService) jobSegmentPrewarm(ctx context.Context) error {
|
||||
if s == nil || s.segments == nil {
|
||||
return nil
|
||||
}
|
||||
manual, _ := ctx.Value(schedulerManualRunKey{}).(bool)
|
||||
if !manual && !s.segmentPrewarmEnabled(ctx) {
|
||||
return nil
|
||||
}
|
||||
n, err := s.segments.Prewarm(ctx, segmentPrewarmDefaultLimit)
|
||||
if s.log != nil {
|
||||
s.log.Info("segment prewarm finished",
|
||||
zap.Int("attempted", n),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// segmentPrewarmEnabled reports whether the operator opted into background
|
||||
// IntroDB prewarm. Defaults to false so playback-on-demand remains the only path.
|
||||
func (s *SchedulerService) segmentPrewarmEnabled(ctx context.Context) bool {
|
||||
if s.repo == nil || s.repo.Setting == nil {
|
||||
return false
|
||||
}
|
||||
v, err := s.repo.Setting.Get(ctx, SegmentPrewarmEnabledKey)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return parseBoolSetting(v, false)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func TestSegmentPrewarmDefaultsOff(t *testing.T) {
|
||||
repos := repository.New(newServiceTestDB(t, &model.Setting{}))
|
||||
scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, nil, nil, "")
|
||||
if scheduler.segmentPrewarmEnabled(t.Context()) {
|
||||
t.Fatal("prewarm must default to off")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobSegmentPrewarmSkippedWhenDisabled(t *testing.T) {
|
||||
var calls int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
repos := repository.New(newServiceTestDB(t))
|
||||
if err := repos.DB.Create(&model.Media{
|
||||
Base: model.Base{ID: "mv-1"}, Path: "/a.mkv", TMDbID: 1,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
segments := NewMediaSegmentService(zap.NewNop(), repos).
|
||||
SetIntroDB(NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL)).
|
||||
SetPrewarmGap(0)
|
||||
scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, nil, nil, "")
|
||||
scheduler.SetSegments(segments)
|
||||
|
||||
if err := scheduler.jobSegmentPrewarm(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 0 {
|
||||
t.Fatalf("provider calls = %d, want 0 when prewarm is disabled", got)
|
||||
}
|
||||
|
||||
if err := repos.Setting.Set(t.Context(), SegmentPrewarmEnabledKey, "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := scheduler.jobSegmentPrewarm(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("provider calls = %d, want 1 after enabling prewarm", got)
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,7 @@ func (b *serviceContainerBuilder) initAccessAndStorageServices() {
|
||||
)
|
||||
b.c.Scheduler.SetTaskTracker(b.c.Tasks)
|
||||
b.c.Scheduler.SetOrganizePipeline(b.c.OrganizePipeline)
|
||||
b.c.Scheduler.SetSegments(b.c.Segments)
|
||||
b.c.Scheduler.SetImageCachePolicyProvider(func() ImageCachePolicy {
|
||||
if b.cfg == nil {
|
||||
return ImageCachePolicy{}
|
||||
|
||||
@@ -71,7 +71,6 @@ export interface DanmakuConfig {
|
||||
font_size: string
|
||||
area: string
|
||||
volume: number
|
||||
playback_rate: number
|
||||
/** 当前用户是否已经看过 VR 全景播放的首次操作说明(按用户存储)。 */
|
||||
vr360_guide_seen: boolean
|
||||
/** Per-user preference: merge the same episode's multiple sources. */
|
||||
@@ -90,7 +89,6 @@ export interface DanmakuSettingsPatch {
|
||||
area?: number
|
||||
merge_sources?: boolean
|
||||
volume?: number
|
||||
playback_rate?: number
|
||||
/** 看过 VR 操作说明后置为 true,之后不再弹出。 */
|
||||
vr360_guide_seen?: boolean
|
||||
}
|
||||
|
||||
@@ -83,6 +83,8 @@ export interface MediaMetadataUpdate {
|
||||
rating?: number
|
||||
season_num?: number
|
||||
episode_num?: number
|
||||
/** 集号的小数部分(11.5 的 0.5);与 episode_num 一起构成显示集号。 */
|
||||
episode_fraction?: number
|
||||
tmdb_id?: number
|
||||
bangumi_id?: number
|
||||
douban_id?: string
|
||||
|
||||
@@ -52,7 +52,7 @@ export function ExternalPlayerButton({
|
||||
className={
|
||||
compact
|
||||
? 'shrink-0 inline-flex items-center rounded-lg border border-primary-400/35 bg-white px-2 py-1 text-xs font-semibold text-brand-500 hover:bg-primary-400/10 disabled:opacity-50 transition-colors whitespace-nowrap'
|
||||
: 'btn-outline border-brand-500/30 px-5 text-[#c9954a] hover:border-brand-500 hover:bg-brand-50'
|
||||
: 'btn-outline h-11 border-brand-500/30 px-5 text-[#c9954a] hover:border-brand-500 hover:bg-brand-50'
|
||||
}
|
||||
>
|
||||
<PlaySquare size={compact ? 13 : 14} className="mr-1 inline" />
|
||||
|
||||
@@ -4,6 +4,7 @@ import toast from 'react-hot-toast'
|
||||
|
||||
import { mediaAPI, type MediaMetadataUpdate } from '../api/library'
|
||||
import type { Media } from '../types'
|
||||
import { formatEpisodeNumber } from '../utils/episodeNumber'
|
||||
|
||||
interface MetadataEditDialogProps {
|
||||
open: boolean
|
||||
@@ -58,7 +59,7 @@ export function MetadataEditDialog({
|
||||
release_date: media.release_date || '',
|
||||
rating: media.rating > 0 ? String(media.rating) : '',
|
||||
season_num: media.season_num > 0 || media.episode_num > 0 ? String(media.season_num || 0) : '',
|
||||
episode_num: media.episode_num > 0 ? String(media.episode_num) : '',
|
||||
episode_num: media.episode_num > 0 ? formatEpisodeNumber(media) : '',
|
||||
tmdb_id: media.tmdb_id > 0 ? String(media.tmdb_id) : '',
|
||||
bangumi_id: media.bangumi_id > 0 ? String(media.bangumi_id) : '',
|
||||
douban_id: media.douban_id || '',
|
||||
@@ -105,7 +106,11 @@ export function MetadataEditDialog({
|
||||
if (!isSeries) {
|
||||
payload.original_name = form.original_name
|
||||
payload.season_num = Math.trunc(toNumber(form.season_num))
|
||||
payload.episode_num = Math.trunc(toNumber(form.episode_num))
|
||||
// 「集」输入框接受 11.5 这种半集写法:整数部分进 episode_num,
|
||||
// 小数部分单独进 episode_fraction,避免保存后变成整集或残留小数。
|
||||
const episodeValue = toNumber(form.episode_num)
|
||||
payload.episode_num = Math.trunc(episodeValue)
|
||||
payload.episode_fraction = Number((episodeValue - Math.trunc(episodeValue)).toFixed(3))
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -133,10 +133,9 @@ type PlayerControlsProps = {
|
||||
volume?: number
|
||||
onVolumeChange?: (volume: number) => void
|
||||
onVolumeCommit?: (volume: number) => void
|
||||
/** 用户级播放倍速,由播放页从数据库读取。 */
|
||||
/** 播放倍速(1x = 原速)。只作用于当前视频,不落库。 */
|
||||
playbackRate?: number
|
||||
onPlaybackRateChange?: (rate: number) => void
|
||||
onPlaybackRateCommit?: (rate: number) => void
|
||||
uiVisible: boolean
|
||||
onUiVisibleChange: (visible: boolean) => void
|
||||
/** 外部浮层(VR 工具条/设置面板)正被鼠标悬停:此时控制栏不自动隐藏。 */
|
||||
@@ -213,7 +212,6 @@ export function PlayerControls({
|
||||
onVolumeCommit,
|
||||
playbackRate: playbackRateProp = 1,
|
||||
onPlaybackRateChange,
|
||||
onPlaybackRateCommit,
|
||||
uiVisible,
|
||||
onUiVisibleChange,
|
||||
uiHold = false,
|
||||
@@ -381,7 +379,7 @@ export function PlayerControls({
|
||||
setMuted(next === 0)
|
||||
}, [video, volumeProp])
|
||||
|
||||
// 倍速由播放页按用户持久化;配置加载、切换剧集或切换播放源后同步到当前 video。
|
||||
// 倍速只影响当前视频:挂载、切换剧集或切换播放源后同步到当前 video。
|
||||
useEffect(() => {
|
||||
const el = video()
|
||||
if (!el) return
|
||||
@@ -626,15 +624,9 @@ export function PlayerControls({
|
||||
}, PLAYBACK_RATE_HINT_MS)
|
||||
}, [onPlaybackRateChange])
|
||||
|
||||
const commitPlaybackRate = useCallback(() => {
|
||||
onPlaybackRateCommit?.(playbackRateRef.current)
|
||||
}, [onPlaybackRateCommit])
|
||||
|
||||
const selectPlaybackRate = useCallback((next: number) => {
|
||||
const normalized = normalizePlaybackRate(next)
|
||||
changePlaybackRate(normalized)
|
||||
onPlaybackRateCommit?.(normalized)
|
||||
}, [changePlaybackRate, onPlaybackRateCommit])
|
||||
changePlaybackRate(normalizePlaybackRate(next))
|
||||
}, [changePlaybackRate])
|
||||
|
||||
const applyAbsoluteSeek = useCallback(
|
||||
(absolute: number) => {
|
||||
@@ -787,18 +779,6 @@ export function PlayerControls({
|
||||
return () => window.removeEventListener('keydown', onKey, true)
|
||||
}, [changePlaybackRate, queueRelativeSeek])
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return
|
||||
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return
|
||||
if (shouldIgnorePlayerSeekShortcut(e.target)) return
|
||||
e.preventDefault()
|
||||
commitPlaybackRate()
|
||||
}
|
||||
window.addEventListener('keyup', onKeyUp, true)
|
||||
return () => window.removeEventListener('keyup', onKeyUp, true)
|
||||
}, [commitPlaybackRate])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (seekApplyTimerRef.current) clearTimeout(seekApplyTimerRef.current)
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
|
||||
import { ChevronDown, Layers, ListVideo } from 'lucide-react'
|
||||
|
||||
import type { Media } from '../types'
|
||||
import { formatEpisodeLabel, formatEpisodeNumber } from '../utils/episodeNumber'
|
||||
import { mediaVersionLabel, mediaVersionMatches } from '../utils/mediaVersion'
|
||||
|
||||
// PlayerMobileTheaterInfo — 竖屏剧场模式下视频下方的可滚动内容区。
|
||||
@@ -38,15 +39,17 @@ type PlayerMobileTheaterInfoProps = {
|
||||
}
|
||||
|
||||
function episodeShortLabel(ep: Media): string {
|
||||
if (ep.episode_num > 0) return `${ep.episode_num}`
|
||||
const number = formatEpisodeNumber(ep)
|
||||
if (number) return number
|
||||
const title = ep.episode_title?.trim() || ep.title?.trim() || ''
|
||||
return title ? title.slice(0, 4) : '·'
|
||||
}
|
||||
|
||||
function episodeFullLabel(ep: Media): string {
|
||||
if (ep.episode_num > 0) {
|
||||
const label = formatEpisodeLabel(ep)
|
||||
if (label) {
|
||||
const title = ep.episode_title?.trim()
|
||||
return title && title !== ep.title?.trim() ? `第 ${ep.episode_num} 集 · ${title}` : `第 ${ep.episode_num} 集`
|
||||
return title && title !== ep.title?.trim() ? `${label} · ${title}` : label
|
||||
}
|
||||
return ep.episode_title?.trim() || ep.title?.trim() || '未命名'
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, Cloud, Film, Layers, Play, Search, X } from 'lucide-react'
|
||||
|
||||
import type { Media } from '../types'
|
||||
import { formatEpisodeLabel, formatEpisodeNumber, episodeNumberValue } from '../utils/episodeNumber'
|
||||
import { seasonLabel, seasonSortOrder, seriesTitleFromPath } from '../utils/groupSeries'
|
||||
import {
|
||||
isStrmMedia,
|
||||
@@ -72,7 +73,7 @@ export function PlayerPlaylistPanel({
|
||||
seasonsMap.get(s)!.push(ep)
|
||||
}
|
||||
for (const [, list] of seasonsMap) {
|
||||
list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
|
||||
list.sort((a, b) => episodeNumberValue(a) - episodeNumberValue(b))
|
||||
}
|
||||
return Array.from(seasonsMap.entries())
|
||||
.sort(([a], [b]) => seasonSortOrder(a) - seasonSortOrder(b))
|
||||
@@ -118,7 +119,7 @@ export function PlayerPlaylistPanel({
|
||||
? listToDisplay.filter((ep) => {
|
||||
const query = filterText.trim().toLowerCase()
|
||||
const title = (ep.episode_title || ep.title || '').toLowerCase()
|
||||
const epNum = String(ep.episode_num)
|
||||
const epNum = formatEpisodeNumber(ep)
|
||||
return title.includes(query) || epNum === query || `e${epNum}`.includes(query) || `第${epNum}集`.includes(query)
|
||||
})
|
||||
: listToDisplay
|
||||
@@ -293,7 +294,7 @@ export function PlayerPlaylistPanel({
|
||||
const durationText =
|
||||
ep.duration_sec > 0 ? `${Math.round(ep.duration_sec / 60)} 分钟` : ''
|
||||
const cellTitle = [
|
||||
ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : '',
|
||||
formatEpisodeLabel(ep),
|
||||
displayTitle,
|
||||
durationText,
|
||||
versions.length > 0 ? `${versions.length} 个版本` : '',
|
||||
@@ -316,7 +317,7 @@ export function PlayerPlaylistPanel({
|
||||
{isPlaying ? (
|
||||
<Play size={12} className="fill-current" />
|
||||
) : ep.episode_num > 0 ? (
|
||||
ep.episode_num
|
||||
formatEpisodeNumber(ep)
|
||||
) : (
|
||||
<Film size={13} className="text-white/45" />
|
||||
)}
|
||||
@@ -429,7 +430,8 @@ function getEpisodeTitle(ep: Media, siblings: Media[]): string {
|
||||
return mediaTitle
|
||||
}
|
||||
|
||||
return ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : mediaTitle || title || '未命名'
|
||||
const episodeLabel = formatEpisodeLabel(ep)
|
||||
return episodeLabel || mediaTitle || title || '未命名'
|
||||
}
|
||||
|
||||
function looksLikeSeriesTitle(ep: Media, title: string, siblings: Media[]): boolean {
|
||||
|
||||
@@ -28,6 +28,7 @@ import type { SeriesCard } from '../utils/groupSeries'
|
||||
import { seriesCardLink } from '../utils/groupSeries'
|
||||
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||
import { getLibraryArtworks } from './librariesPageModel'
|
||||
import { formatEpisodeNumber } from '../utils/episodeNumber'
|
||||
|
||||
const TYPE_ICONS: Record<string, ReactNode> = {
|
||||
movie: <Film size={18} />,
|
||||
@@ -773,7 +774,7 @@ function ContinueCard({ media, progress }: { media: Media; progress: number }) {
|
||||
{media.year > 0 && <span>{media.year}</span>}
|
||||
{media.season_num !== undefined && media.episode_num !== undefined && (
|
||||
<span>
|
||||
S{media.season_num}E{media.episode_num}
|
||||
S{media.season_num}E{formatEpisodeNumber(media)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
||||
import { MediaFavouriteButton } from '../components/MediaFavouriteButton'
|
||||
import type { Media } from '../types'
|
||||
import { isTheatricalFeature, seriesTitle, type SeriesCard } from '../utils/groupSeries'
|
||||
import { episodeNumberValue } from '../utils/episodeNumber'
|
||||
|
||||
type LibrarySeriesDetailHeaderProps = {
|
||||
series: SeriesCard
|
||||
@@ -152,7 +153,7 @@ function firstPlayableEpisode(episodes: Media[]): Media | null {
|
||||
const sorted = [...episodes]
|
||||
sorted.sort((a, b) =>
|
||||
(a.season_num || 0) - (b.season_num || 0)
|
||||
|| (a.episode_num || 0) - (b.episode_num || 0),
|
||||
|| episodeNumberValue(a) - episodeNumberValue(b),
|
||||
)
|
||||
return sorted[0] ?? null
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
THEATRICAL_SEASON,
|
||||
} from '../utils/groupSeries'
|
||||
import { formatSize } from './libraryPageModel'
|
||||
import { formatEpisodeLabel, formatEpisodeNumber } from '../utils/episodeNumber'
|
||||
|
||||
type SeasonGroup = {
|
||||
season: number
|
||||
@@ -95,14 +96,14 @@ export function LibrarySeriesEpisodes({
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-brand-600 font-bold text-sm">{ep.episode_num || '—'}</span>
|
||||
<span className="text-brand-600 font-bold text-sm">{formatEpisodeNumber(ep) || '—'}</span>
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/25 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<Play size={15} className="fill-white text-white drop-shadow-sm" />
|
||||
</div>
|
||||
{ep.episode_num > 0 && (
|
||||
<span className="absolute bottom-0 right-0 rounded-tl bg-black/75 px-1 py-0.5 text-[9px] font-bold leading-none text-white backdrop-blur-[2px]">
|
||||
{ep.episode_num}
|
||||
{formatEpisodeNumber(ep)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -153,15 +154,18 @@ function episodeDisplayTitle(ep: Media, siblings: Media[]): string {
|
||||
}
|
||||
}
|
||||
|
||||
if (ep.episode_num > 0) {
|
||||
const episodeLabel = formatEpisodeLabel(ep)
|
||||
if (episodeLabel) {
|
||||
if (!mainTitle) {
|
||||
return `第 ${ep.episode_num} 集`
|
||||
return episodeLabel
|
||||
}
|
||||
const prefixRegex = new RegExp(`^(第\\s*0*${ep.episode_num}\\s*集|ep?\\.?\\s*0*${ep.episode_num}\\b)`, 'i')
|
||||
// 标题里已经以「第 11 集 / 第 11.5 集 / ep 11」开头时直接用它,避免重复前缀。
|
||||
const episodeNumber = formatEpisodeNumber(ep).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const prefixRegex = new RegExp(`^(第\\s*0*${episodeNumber}\\s*集|ep?\\.?\\s*0*${episodeNumber}\\b)`, 'i')
|
||||
if (prefixRegex.test(mainTitle)) {
|
||||
return mainTitle
|
||||
}
|
||||
return `第 ${ep.episode_num} 集 · ${mainTitle}`
|
||||
return `${episodeLabel} · ${mainTitle}`
|
||||
}
|
||||
|
||||
return mainTitle || '未命名'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Database, FileText, FolderInput, Pencil, Search, Sparkles, Trash2 } from 'lucide-react'
|
||||
import { Database, FileText, FolderInput, Pencil, Search, ShieldAlert, Sparkles, Trash2 } from 'lucide-react'
|
||||
|
||||
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
|
||||
import type { Media } from '../types'
|
||||
@@ -34,50 +34,56 @@ export function MediaDetailAdminPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50/50 p-5 space-y-3">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-[#c9954a]">系统后台高级控制面板</p>
|
||||
{isEpisodeArtworkTarget(media) && (
|
||||
<EpisodeArtworkToggle
|
||||
checked={scrapeEpisodeArtwork}
|
||||
onChange={onScrapeEpisodeArtworkChange}
|
||||
title="关闭后仍会获取每集简介、评分和时长,只跳过单集图片"
|
||||
className="h-10"
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={onSmartScrape} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<section className="overflow-hidden rounded-2xl border border-gray-200 bg-gray-50/50">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-gray-200/60 px-4 py-3 sm:px-5">
|
||||
<p className="inline-flex items-center gap-2 text-2xs font-bold uppercase tracking-[0.18em] text-gray-500">
|
||||
<ShieldAlert size={14} className="text-brand-500" />
|
||||
系统后台高级控制面板
|
||||
</p>
|
||||
{isEpisodeArtworkTarget(media) && (
|
||||
<EpisodeArtworkToggle
|
||||
checked={scrapeEpisodeArtwork}
|
||||
onChange={onScrapeEpisodeArtworkChange}
|
||||
title="关闭后仍会获取每集简介、评分和时长,只跳过单集图片"
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 px-4 py-4 sm:px-5">
|
||||
<button onClick={onSmartScrape} className="btn-outline h-9 rounded-lg px-3 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<Sparkles size={13} className="text-[#c9954a]" />
|
||||
<span>智能刮削 (TMDB)</span>
|
||||
</button>
|
||||
<button onClick={onManualScrape} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<button onClick={onManualScrape} className="btn-outline h-9 rounded-lg px-3 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<Search size={13} className="text-[#c9954a]" />
|
||||
<span>手动匹配刮削</span>
|
||||
</button>
|
||||
<button onClick={onMetadataEdit} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<Pencil size={13} className="text-gray-600" />
|
||||
<button onClick={onMetadataEdit} className="btn-outline h-9 rounded-lg px-3 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<Pencil size={13} className="text-brand-500" />
|
||||
<span>编辑元数据</span>
|
||||
</button>
|
||||
<button onClick={onOrganize} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<button onClick={onOrganize} className="btn-outline h-9 rounded-lg px-3 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<FolderInput size={13} className="text-[#c9954a]" />
|
||||
<span>整理入库</span>
|
||||
</button>
|
||||
<button onClick={onProbe} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<Database size={13} className="text-gray-600" />
|
||||
<button onClick={onProbe} className="btn-outline h-9 rounded-lg px-3 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<Database size={13} className="text-brand-500" />
|
||||
<span>探测媒体轨 (ffprobe)</span>
|
||||
</button>
|
||||
<button onClick={onExportNFO} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<FileText size={13} />
|
||||
<button onClick={onExportNFO} className="btn-outline h-9 rounded-lg px-3 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<FileText size={13} className="text-brand-500" />
|
||||
<span>写出本地 NFO 属性</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="btn-outline py-2 px-3.5 text-xs gap-1.5 !border-red-100 !text-red-500 hover:!bg-red-50 hover:!border-red-200"
|
||||
className="btn-outline ml-auto h-9 rounded-lg px-3 text-xs gap-1.5 !border-red-100 !text-red-500 hover:!bg-red-50 hover:!border-red-200"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
<Trash2 size={13} className="text-red-500" />
|
||||
<span>删除</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ type MediaDetailMetadataProps = {
|
||||
export function MediaDetailMetadata({ media }: MediaDetailMetadataProps) {
|
||||
const heading = media.episode_title?.trim() || media.title
|
||||
const showTitleContext = Boolean(media.episode_title?.trim() && media.title && media.title !== heading)
|
||||
const genres = parseCSV(media.genres)
|
||||
const languages = parseCSV(media.languages)
|
||||
const countries = parseCSV(media.countries)
|
||||
const hasTaxonomy = genres.length > 0 || languages.length > 0 || countries.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -21,26 +25,26 @@ export function MediaDetailMetadata({ media }: MediaDetailMetadataProps) {
|
||||
{media.title}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2.5 text-xs text-gray-500 font-bold tracking-wide uppercase">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{media.year > 0 && (
|
||||
<span className="inline-flex items-center gap-1 bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700">
|
||||
<span className="inline-flex h-7 items-center rounded-lg border border-gray-200/60 bg-gray-100 px-2.5 text-2xs font-bold uppercase tracking-wide gap-1.5 text-gray-700">
|
||||
<Calendar size={13} className="text-brand-500" />
|
||||
<span>{media.year} 年</span>
|
||||
</span>
|
||||
)}
|
||||
{media.width > 0 && (
|
||||
<span className="inline-flex items-center gap-1 bg-brand-50 text-brand-700 border border-brand-100/50 px-2.5 py-1 rounded-xl">
|
||||
{media.width > 0 && media.height > 0 && (
|
||||
<span className="inline-flex h-7 items-center rounded-lg border border-brand-100/50 bg-brand-50 px-2.5 text-2xs font-bold uppercase tracking-wide text-brand-700">
|
||||
<span>{media.width} × {media.height}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700">
|
||||
{fmtSize(media.size_bytes)}
|
||||
</span>
|
||||
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700">
|
||||
{fmtDuration(media.duration_sec)}
|
||||
</span>
|
||||
{media.size_bytes > 0 && (
|
||||
<span className="inline-flex h-7 items-center rounded-lg border border-gray-200/60 bg-gray-100 px-2.5 text-2xs font-bold uppercase tracking-wide text-gray-700">{fmtSize(media.size_bytes)}</span>
|
||||
)}
|
||||
{media.duration_sec > 0 && (
|
||||
<span className="inline-flex h-7 items-center rounded-lg border border-gray-200/60 bg-gray-100 px-2.5 text-2xs font-bold uppercase tracking-wide text-gray-700">{fmtDuration(media.duration_sec)}</span>
|
||||
)}
|
||||
{media.container && (
|
||||
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700 font-mono">
|
||||
<span className="inline-flex h-7 items-center rounded-lg border border-gray-200/60 bg-gray-100 px-2.5 text-2xs font-bold uppercase tracking-wide text-gray-700 font-mono">
|
||||
{media.container}
|
||||
</span>
|
||||
)}
|
||||
@@ -56,24 +60,44 @@ export function MediaDetailMetadata({ media }: MediaDetailMetadataProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<MetadataTags label="类型流派" values={parseCSV(media.genres)} primary />
|
||||
<MetadataTags label="语言" values={parseCSV(media.languages)} />
|
||||
<MetadataTags label="国家/地区" values={parseCSV(media.countries)} />
|
||||
</div>
|
||||
{hasTaxonomy && (
|
||||
<div className="space-y-3">
|
||||
<MetadataRow label="类型流派" values={parseCSV(media.genres)} tone="brand" />
|
||||
<MetadataRow label="语言" values={parseCSV(media.languages)} />
|
||||
<MetadataRow label="国家/地区" values={parseCSV(media.countries)} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MetadataTags({ label, values, primary = false }: { label: string; values: string[]; primary?: boolean }) {
|
||||
/**
|
||||
* One "label - values" row of the taxonomy block.
|
||||
*
|
||||
* All rows share the same label gutter, so every value column starts on the
|
||||
* same vertical line. Pills keep one geometry and differ by tone only.
|
||||
*/
|
||||
function MetadataRow({
|
||||
label,
|
||||
values,
|
||||
tone = 'neutral',
|
||||
}: {
|
||||
label: string
|
||||
values: string[]
|
||||
tone?: 'neutral' | 'brand'
|
||||
}) {
|
||||
if (values.length === 0) return null
|
||||
const tagClass = primary
|
||||
? 'rounded-full bg-brand-50 text-brand-700 border border-brand-100/30 px-3 py-1 text-2xs font-bold uppercase tracking-wider'
|
||||
: 'rounded-xl bg-gray-100 text-gray-600 border border-gray-200/40 px-2.5 py-1 text-2xs font-semibold'
|
||||
const tagClass = [
|
||||
'inline-flex items-center rounded-lg border px-2.5 py-1 text-2xs font-bold uppercase tracking-wide',
|
||||
tone === 'brand'
|
||||
? 'border-brand-100/50 bg-brand-50 text-brand-700'
|
||||
: 'border-gray-200/60 bg-gray-100 text-gray-600',
|
||||
].join(' ')
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-xs font-bold text-gray-500 w-16 uppercase tracking-wider">{label}</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="grid grid-cols-[4.5rem_1fr] items-start gap-2 sm:grid-cols-[5.5rem_1fr]">
|
||||
<span className="pt-1 text-xs font-semibold text-gray-500">{label}</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{values.map((value) => (
|
||||
<span key={value} className={tagClass}>
|
||||
{value}
|
||||
|
||||
@@ -99,19 +99,23 @@ export function MediaDetailPlaybackActions({
|
||||
}: MediaDetailPlaybackActionsProps) {
|
||||
const targetId = media.id || playTargetId || ''
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link to={`/play/${targetId}`} state={{ from: `/media/${media.id}` }} className="btn-primary px-6 py-3.5 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<Link
|
||||
to={`/play/${targetId}`}
|
||||
state={{ from: `/media/${media.id}` }}
|
||||
className="btn-primary h-11 px-6 shadow-sm"
|
||||
>
|
||||
<Play size={16} fill="currentColor" />
|
||||
<span>立即播放</span>
|
||||
</Link>
|
||||
|
||||
{!isDirectStreamMedia(media) && (
|
||||
<Link
|
||||
to={`/play/${targetId}?mode=hls`}
|
||||
state={{ from: `/media/${media.id}` }}
|
||||
className="btn-outline border-brand-500/30 hover:border-brand-500 text-[#c9954a] hover:bg-brand-50 px-5"
|
||||
>
|
||||
<RefreshCw size={14} className="animate-spin-slow" />
|
||||
to={`/play/${targetId}?mode=hls`}
|
||||
state={{ from: `/media/${media.id}` }}
|
||||
className="btn-outline h-11 border-brand-500/30 px-5 text-[#c9954a] hover:border-brand-500 hover:bg-brand-50"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
<span>HLS 兼容转码播放</span>
|
||||
</Link>
|
||||
)}
|
||||
@@ -121,7 +125,7 @@ export function MediaDetailPlaybackActions({
|
||||
<button
|
||||
onClick={onToggleFavourite}
|
||||
className={
|
||||
'btn-outline gap-2 ' +
|
||||
'btn-outline h-11 gap-2 ' +
|
||||
(favourite
|
||||
? '!border-red-200 !bg-red-50 !text-red-600 hover:!bg-red-100/50'
|
||||
: 'hover:border-red-200 hover:text-red-600 hover:bg-red-50/50')
|
||||
@@ -154,10 +158,10 @@ export function MediaDetailMainContent({
|
||||
<div className="relative z-10 p-6 sm:p-10 flex flex-col md:flex-row gap-8 lg:gap-12">
|
||||
<MediaDetailPoster media={media} />
|
||||
|
||||
<div className="flex-1 space-y-6">
|
||||
<div className="min-w-0 flex-1 space-y-5">
|
||||
<MediaDetailMetadata media={media} />
|
||||
<div className="divider border-gray-200/60" />
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="divider" />
|
||||
<div className="space-y-4">
|
||||
<MediaDetailPlaybackActions
|
||||
media={media}
|
||||
favourite={favourite}
|
||||
@@ -165,21 +169,21 @@ export function MediaDetailMainContent({
|
||||
playTargetId={playTargetId}
|
||||
/>
|
||||
<MediaVersionSwitcher media={media} />
|
||||
{isAdmin && (
|
||||
<MediaDetailAdminPanel
|
||||
media={media}
|
||||
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
|
||||
onScrapeEpisodeArtworkChange={onScrapeEpisodeArtworkChange}
|
||||
onSmartScrape={onSmartScrape}
|
||||
onManualScrape={onManualScrape}
|
||||
onMetadataEdit={onMetadataEdit}
|
||||
onOrganize={onOrganize}
|
||||
onProbe={onProbe}
|
||||
onExportNFO={onExportNFO}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<MediaDetailAdminPanel
|
||||
media={media}
|
||||
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
|
||||
onScrapeEpisodeArtworkChange={onScrapeEpisodeArtworkChange}
|
||||
onSmartScrape={onSmartScrape}
|
||||
onManualScrape={onManualScrape}
|
||||
onMetadataEdit={onMetadataEdit}
|
||||
onOrganize={onOrganize}
|
||||
onProbe={onProbe}
|
||||
onExportNFO={onExportNFO}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+395
-94
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import type Hls from 'hls.js'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
@@ -19,8 +19,9 @@ import { profileAPI } from '../api/profile'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { Media, PlaybackInfo, PlaybackQuality, PlaybackSegment, PlaybackSegmentKind } from '../types'
|
||||
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
|
||||
import { formatEpisodeLabel } from '../utils/episodeNumber'
|
||||
import { mediaVersionMatches, mediaVersionsOf } from '../utils/mediaVersion'
|
||||
import { normalizePlaybackRate } from '../utils/playbackRate'
|
||||
import { DEFAULT_PLAYBACK_RATE, normalizePlaybackRate } from '../utils/playbackRate'
|
||||
import { resolveActiveSkip, skippedNoticeText, toSkipSegments, type SkipPrompt } from '../utils/skipSegments'
|
||||
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||
import {
|
||||
@@ -36,7 +37,8 @@ import {
|
||||
type SubtitleStylePreset,
|
||||
} from '../utils/subtitleDisplay'
|
||||
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, isStrmMedia, type PlayerMode } from './playerPageModel'
|
||||
import { classifyDirectPlayError } from './directPlayError'
|
||||
import { classifyDirectPlayError, DIRECT_SEEK_GRACE_MS } from './directPlayError'
|
||||
import { canSeekDirectNow, DIRECT_SEEK_WAIT_MS } from './directPlaySeek'
|
||||
import { apiErrorMessage } from './StrmManagePage'
|
||||
import { PlayerTopBar } from './PlayerTopBar'
|
||||
import { PlayerVideoStage } from './PlayerVideoStage'
|
||||
@@ -58,6 +60,10 @@ import {
|
||||
// ?mode=hls force HLS even when direct play would work
|
||||
// ?mode=direct force direct play (default for browser-friendly codecs)
|
||||
//
|
||||
// ?mode= 只在进入某个媒体时读取一次(媒体详情页的「HLS 兼容转码播放」入口依赖它);
|
||||
// 播放模式不再写回 URL——写 URL 会让 search 变化触发依赖 setSearchParams 的 effect
|
||||
// 重跑,把刚切好的 HLS 打回直连(表现为「切了 HLS 还是直连播放」)。
|
||||
//
|
||||
// We pick a sensible default based on the source codec: H.264 + AAC in
|
||||
// MP4 / WebM containers play directly; everything else (HEVC, MKV, AV1,
|
||||
// AC3 audio, …) gets routed through ffmpeg → HLS. STRM / 云盘直链默认直连,
|
||||
@@ -96,7 +102,6 @@ function newPlaybackProgressSession(mediaId: string): PlaybackProgressSession {
|
||||
|
||||
export function PlayerPage() {
|
||||
const { id = '' } = useParams()
|
||||
const [params, setParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
@@ -106,6 +111,17 @@ export function PlayerPage() {
|
||||
const progressSessionRef = useRef<PlaybackProgressSession | null>(null)
|
||||
const directRetryRef = useRef(false)
|
||||
const retryingDirectRef = useRef(false)
|
||||
// 程序化续播 seek 后的宽限截止时间;期内直连误报不升到 HLS。
|
||||
const directSeekGraceUntilRef = useRef(0)
|
||||
// STRM 直连跳转目标;seekable 未覆盖时会等到可跳再写 currentTime。
|
||||
const pendingDirectSeekRef = useRef<number | null>(null)
|
||||
const directSeekWaitCleanupRef = useRef<(() => void) | null>(null)
|
||||
// 每个媒体加载时只读一次 URL 上的 ?mode=(媒体详情页「HLS 兼容转码播放」入口依赖它)。
|
||||
// 播放模式本身不再写回 URL:写 URL 会让 search 变化触发依赖 setSearchParams 的 effect
|
||||
// 重跑,把刚切好的 HLS 打回直连(表现为「切了 HLS 还是直连播放」)。
|
||||
const requestedModeRef = useRef<PlayerMode | null>(null)
|
||||
// URL 明确要求 HLS 时用它把「选线路」推迟到播放能力返回之后(云 HLS 优先,其次本地)。
|
||||
const initialHlsRequestRef = useRef(false)
|
||||
const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const [media, setMedia] = useState<Media | null>(null)
|
||||
@@ -139,16 +155,13 @@ export function PlayerPage() {
|
||||
const [playerVolume, setPlayerVolume] = useState(() =>
|
||||
normalizePlayerVolume(authUser?.player_volume),
|
||||
)
|
||||
const [playerPlaybackRate, setPlayerPlaybackRate] = useState(() =>
|
||||
normalizePlaybackRate(authUser?.player_playback_rate),
|
||||
)
|
||||
// 播放倍速只作用于当前视频:不入库、不跨视频沿用(换集/换片会重置回 1x)。
|
||||
const [playerPlaybackRate, setPlayerPlaybackRate] = useState(() => DEFAULT_PLAYBACK_RATE)
|
||||
const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode)
|
||||
// VR 全景播放的首次操作说明是否已经看过:null = 服务端配置还没读回来,
|
||||
// 这时不弹说明,避免给老用户闪一下。
|
||||
const [vr360GuideSeen, setVr360GuideSeen] = useState<boolean | null>(null)
|
||||
const playerVolumeTouchedRef = useRef(false)
|
||||
const playerPlaybackRateTouchedRef = useRef(false)
|
||||
const playerPlaybackRateSaveSeqRef = useRef(0)
|
||||
const subtitlePreferenceTouchedRef = useRef(false)
|
||||
const subtitlePreferenceSaveQueueRef = useRef<Promise<void>>(Promise.resolve())
|
||||
const [hlsUnavailable, setHlsUnavailable] = useState(false)
|
||||
@@ -285,10 +298,6 @@ export function PlayerPage() {
|
||||
if (!playerVolumeTouchedRef.current) {
|
||||
setPlayerVolume(volume)
|
||||
}
|
||||
const playbackRate = normalizePlaybackRate(cfg.playback_rate)
|
||||
if (!playerPlaybackRateTouchedRef.current) {
|
||||
setPlayerPlaybackRate(playbackRate)
|
||||
}
|
||||
setDanmakuEnabled(cfg.enabled)
|
||||
setDanmakuOpacity(Number(cfg.opacity) || 1)
|
||||
setDanmakuFontSize(Number(cfg.font_size) || 24)
|
||||
@@ -300,10 +309,6 @@ export function PlayerPage() {
|
||||
const current = useAuthStore.getState().user
|
||||
if (current) setAuthUser({ ...current, player_volume: volume })
|
||||
}
|
||||
if (!playerPlaybackRateTouchedRef.current) {
|
||||
const current = useAuthStore.getState().user
|
||||
if (current) setAuthUser({ ...current, player_playback_rate: playbackRate })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 配置读取失败时保持本地默认值,不影响播放。
|
||||
@@ -338,34 +343,11 @@ export function PlayerPage() {
|
||||
[setAuthUser],
|
||||
)
|
||||
|
||||
// 倍速只改本机播放状态:不写回服务端,也不跨视频沿用。
|
||||
const changePlayerPlaybackRate = useCallback((next: number) => {
|
||||
playerPlaybackRateTouchedRef.current = true
|
||||
setPlayerPlaybackRate(normalizePlaybackRate(next))
|
||||
}, [])
|
||||
|
||||
const commitPlayerPlaybackRate = useCallback(
|
||||
(next: number) => {
|
||||
playerPlaybackRateTouchedRef.current = true
|
||||
const playbackRate = normalizePlaybackRate(next)
|
||||
setPlayerPlaybackRate(playbackRate)
|
||||
const saveSeq = ++playerPlaybackRateSaveSeqRef.current
|
||||
void danmakuAPI
|
||||
.updateSettings({ playback_rate: playbackRate })
|
||||
.then((cfg) => {
|
||||
if (saveSeq !== playerPlaybackRateSaveSeqRef.current) return
|
||||
const saved = normalizePlaybackRate(cfg.playback_rate)
|
||||
setPlayerPlaybackRate(saved)
|
||||
const current = useAuthStore.getState().user
|
||||
if (current) setAuthUser({ ...current, player_playback_rate: saved })
|
||||
})
|
||||
.catch(() => {
|
||||
if (saveSeq !== playerPlaybackRateSaveSeqRef.current) return
|
||||
toast.error('倍速保存失败,请重试')
|
||||
})
|
||||
},
|
||||
[setAuthUser],
|
||||
)
|
||||
|
||||
const saveDanmakuAdvanced = useCallback(
|
||||
async (values: { source: string; appId: string; appKey: string; clearAppKey: boolean }) => {
|
||||
const patch: DanmakuSettingsPatch = {
|
||||
@@ -519,6 +501,16 @@ export function PlayerPage() {
|
||||
setDanmakuSearching(true)
|
||||
directRetryRef.current = false
|
||||
retryingDirectRef.current = false
|
||||
directSeekGraceUntilRef.current = 0
|
||||
pendingDirectSeekRef.current = null
|
||||
directSeekWaitCleanupRef.current?.()
|
||||
directSeekWaitCleanupRef.current = null
|
||||
// 换片子时重新读一次 URL 上的强制模式;没有就按片源自动判定。
|
||||
const urlMode = new URLSearchParams(window.location.search).get('mode')
|
||||
requestedModeRef.current = urlMode === 'hls' || urlMode === 'direct' ? urlMode : null
|
||||
// URL 明确要求 HLS 时,等播放能力拿到后还要选一次线路(云 HLS 优先)。
|
||||
initialHlsRequestRef.current = urlMode === 'hls'
|
||||
setMode('direct')
|
||||
if (fallbackTimerRef.current) {
|
||||
clearTimeout(fallbackTimerRef.current)
|
||||
fallbackTimerRef.current = null
|
||||
@@ -531,18 +523,11 @@ export function PlayerPage() {
|
||||
}
|
||||
}, [id])
|
||||
|
||||
// 依赖收敛为 mode 参数的字符串值:避免 params 对象引用每次变化都重复拉取元数据
|
||||
const modeParam = params.get('mode') as PlayerMode | null
|
||||
|
||||
const setPlaybackMode = useCallback(
|
||||
(next: PlayerMode) => {
|
||||
setMode(next)
|
||||
const nextParams = new URLSearchParams(window.location.search)
|
||||
nextParams.set('mode', next)
|
||||
setParams(nextParams, { replace: true })
|
||||
},
|
||||
[setParams],
|
||||
)
|
||||
const setPlaybackMode = useCallback((next: PlayerMode) => {
|
||||
// 一旦显式决定过播放方式,URL 上的 ?mode= 就不再是权威(避免它把状态打回去)。
|
||||
requestedModeRef.current = null
|
||||
setMode(next)
|
||||
}, [])
|
||||
|
||||
const clearFallbackTimer = useCallback(() => {
|
||||
if (fallbackTimerRef.current) {
|
||||
@@ -551,6 +536,185 @@ export function PlayerPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const clearDirectSeekWait = useCallback(() => {
|
||||
directSeekWaitCleanupRef.current?.()
|
||||
directSeekWaitCleanupRef.current = null
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* STRM/115 直连跳转:等 seekable 覆盖目标再写 currentTime,避免被钳回 0;
|
||||
* 同时打开误报宽限期,防止 seek 触发 video.load() 从头播。
|
||||
*
|
||||
* 超过 timeoutMs 仍未落点就直接放弃并回调 onStalled,绝不把播放器挂在
|
||||
* 「正在定位」状态里(之前的表现就是「跳转等半天没反应」)。
|
||||
*/
|
||||
const seekStrmDirectTo = useCallback(
|
||||
(
|
||||
absoluteSec: number,
|
||||
options?: {
|
||||
onSettled?: () => void
|
||||
onStalled?: (target: number) => void
|
||||
timeoutMs?: number
|
||||
},
|
||||
) => {
|
||||
const video = ref.current
|
||||
if (!video) return false
|
||||
const target = Math.max(0, absoluteSec)
|
||||
const timeoutMs = options?.timeoutMs ?? DIRECT_SEEK_WAIT_MS
|
||||
const wasPlaying = !video.paused
|
||||
pendingDirectSeekRef.current = target
|
||||
directSeekGraceUntilRef.current = Date.now() + DIRECT_SEEK_GRACE_MS
|
||||
directRetryRef.current = false
|
||||
clearFallbackTimer()
|
||||
clearDirectSeekWait()
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
let applied = false
|
||||
let attempts = 0
|
||||
let toastShown = false
|
||||
const startedAt = Date.now()
|
||||
|
||||
const cleanup = () => {
|
||||
video.removeEventListener('progress', tick)
|
||||
video.removeEventListener('durationchange', tick)
|
||||
video.removeEventListener('loadedmetadata', tick)
|
||||
video.removeEventListener('seeked', onSeeked)
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
if (directSeekWaitCleanupRef.current === cleanup) {
|
||||
directSeekWaitCleanupRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const finish = (ok: boolean) => {
|
||||
cleanup()
|
||||
pendingDirectSeekRef.current = null
|
||||
if (!ok) {
|
||||
options?.onStalled?.(target)
|
||||
return
|
||||
}
|
||||
// 定位完成后继续播:用户拖动时视频通常还在播,别让它停在暂停态。
|
||||
if (wasPlaying) void video.play().catch(() => undefined)
|
||||
options?.onSettled?.()
|
||||
}
|
||||
|
||||
const settleIfMatched = () => {
|
||||
const el = ref.current
|
||||
if (!el || pendingDirectSeekRef.current === null) return false
|
||||
if (Math.abs(el.currentTime - target) <= 2.5) {
|
||||
finish(true)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const writeCurrentTime = (want: number) => {
|
||||
const el = ref.current
|
||||
if (!el) return false
|
||||
selfSkipSeekRef.current = true
|
||||
try {
|
||||
el.currentTime = want
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
applied = true
|
||||
attempts += 1
|
||||
// 续期宽限:Range 拉取关键帧期间仍可能误报 error。
|
||||
directSeekGraceUntilRef.current = Date.now() + DIRECT_SEEK_GRACE_MS
|
||||
return true
|
||||
}
|
||||
|
||||
const onSeeked = () => {
|
||||
if (settleIfMatched()) return
|
||||
const el = ref.current
|
||||
if (!el || pendingDirectSeekRef.current === null) return
|
||||
// 被钳回开头:允许 seekable 就绪后再写一次。
|
||||
if (el.currentTime < 1.5 && target > 3) {
|
||||
applied = false
|
||||
directSeekGraceUntilRef.current = Date.now() + DIRECT_SEEK_GRACE_MS
|
||||
}
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
const el = ref.current
|
||||
if (!el || pendingDirectSeekRef.current === null) {
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
if (settleIfMatched()) return
|
||||
const elapsed = Date.now() - startedAt
|
||||
if (elapsed >= timeoutMs + 2500) {
|
||||
// 给过一次强制写入的机会仍然没落点:交给 HLS,别继续等。
|
||||
finish(false)
|
||||
return
|
||||
}
|
||||
const canSeek = canSeekDirectNow(el.seekable, target)
|
||||
const overCap = elapsed >= timeoutMs
|
||||
if (!applied && (canSeek || overCap)) {
|
||||
writeCurrentTime(target)
|
||||
return
|
||||
}
|
||||
if (applied && attempts < 3 && canSeek && el.currentTime < 1.5 && target > 3) {
|
||||
writeCurrentTime(target)
|
||||
return
|
||||
}
|
||||
if (!toastShown && elapsed > 700 && !applied) {
|
||||
toastShown = true
|
||||
toast('正在定位播放位置…', { duration: 2200 })
|
||||
}
|
||||
}
|
||||
|
||||
video.addEventListener('progress', tick)
|
||||
video.addEventListener('durationchange', tick)
|
||||
video.addEventListener('loadedmetadata', tick)
|
||||
video.addEventListener('seeked', onSeeked)
|
||||
intervalId = setInterval(tick, 250)
|
||||
directSeekWaitCleanupRef.current = cleanup
|
||||
tick()
|
||||
return true
|
||||
},
|
||||
[clearDirectSeekWait, clearFallbackTimer],
|
||||
)
|
||||
|
||||
/**
|
||||
* 直连定位确实做不到时(例如 115 直链上 Chrome 无法跳到目标关键帧),
|
||||
* 用 HLS 接管并跳到同一个位置,而不是把用户晾在原地。
|
||||
*/
|
||||
const handleDirectSeekStalled = useCallback(
|
||||
(target: number) => {
|
||||
const cloudQuality =
|
||||
findPlaybackQualityById(playbackInfo, selectedQuality) ??
|
||||
findPlaybackQualityById(playbackInfo, playbackInfo?.default_quality ?? '')
|
||||
if (cloudQuality?.source === 'cloud' && cloudQuality.available) {
|
||||
setSelectedQuality(cloudQuality.id)
|
||||
setHlsSource('cloud')
|
||||
setCloudWaiting(false)
|
||||
setCloudWaitMessage('')
|
||||
pendingSeekRef.current = target > 2 ? target : null
|
||||
setPlaybackMode('hls')
|
||||
toast('直连定位失败,已切到 115 云 HLS 跳转')
|
||||
return
|
||||
}
|
||||
const localQuality =
|
||||
findPlaybackQualityById(playbackInfo, selectedQuality) ??
|
||||
findPlaybackQualityById(playbackInfo, defaultLocalQualityId(playbackInfo))
|
||||
if (localQuality?.available) {
|
||||
setSelectedQuality(localQuality.id)
|
||||
setHlsSource('local')
|
||||
setCloudWaiting(false)
|
||||
setCloudWaitMessage('')
|
||||
setHlsStartSec(target)
|
||||
setPlaybackMode('hls')
|
||||
toast('直连定位失败,已切到本地 HLS 跳转')
|
||||
return
|
||||
}
|
||||
toast.error('直连定位失败,且当前没有可用的 HLS 转码档位')
|
||||
},
|
||||
[playbackInfo, selectedQuality, setPlaybackMode],
|
||||
)
|
||||
|
||||
// Load metadata and pick a default mode.
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
@@ -560,12 +724,23 @@ export function PlayerPage() {
|
||||
.then((m) => {
|
||||
if (cancelled) return
|
||||
setMedia(m)
|
||||
const isDirect = isDirectStreamMedia(m)
|
||||
const auto = pickPlayerMode(m)
|
||||
// 直连解码模式以及远程 Emby 挂载忽略 ?mode=hls。STRM 默认直连,但允许手动/失败后切 HLS。
|
||||
setMode(directOnly || isDirect ? 'direct' : (modeParam ?? auto))
|
||||
setPlayerError('')
|
||||
setLoadError('')
|
||||
const isDirect = isDirectStreamMedia(m)
|
||||
const auto = pickPlayerMode(m)
|
||||
// 直连解码 / 远程 Emby:强制直连。
|
||||
if (directOnly || isDirect) {
|
||||
setMode('direct')
|
||||
return
|
||||
}
|
||||
// URL 上带 ?mode= 时以它为准(详情页「HLS 兼容转码播放」入口);
|
||||
// 否则按片源自动判定,但不要用滞后的 get 回调把用户手动切的 HLS 盖回直连。
|
||||
const requested = requestedModeRef.current
|
||||
if (requested) {
|
||||
setMode(requested)
|
||||
return
|
||||
}
|
||||
setMode((prev) => (prev === 'hls' ? prev : auto))
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return
|
||||
@@ -575,7 +750,7 @@ export function PlayerPage() {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [id, modeParam, directOnly])
|
||||
}, [id, directOnly])
|
||||
|
||||
// VR 全景素材识别:只在用户没手动改过开关时自动进入 VR 模式。
|
||||
// 识别完全基于文件名/路径关键词与画幅比例,误判时点一下 VR 按钮即可退出。
|
||||
@@ -674,6 +849,12 @@ export function PlayerPage() {
|
||||
const mediaId = media?.id
|
||||
const playbackProvider = playbackInfo?.provider
|
||||
|
||||
// 倍速只影响当前视频:换到另一集/另一部片时回到 1x,避免上一个视频的
|
||||
// 倍速被顺手带到下一集。
|
||||
useEffect(() => {
|
||||
setPlayerPlaybackRate(DEFAULT_PLAYBACK_RATE)
|
||||
}, [mediaId])
|
||||
|
||||
// 加载统一播放能力:115 云端清晰度 + 本地 HLS 清晰度。
|
||||
useEffect(() => {
|
||||
if (!mediaId) return
|
||||
@@ -799,24 +980,29 @@ export function PlayerPage() {
|
||||
// 切到 HLS 播放(优先 115 云端,其次本地转码)。toggleMode 与「VR 需要同源帧」
|
||||
// 的自愈逻辑共用,避免两处各写一遍云端/本地选择。
|
||||
const enterHlsPlayback = useCallback(
|
||||
(startSec: number) => {
|
||||
setHlsStartSec(startSec)
|
||||
(startSec: number, infoOverride?: PlaybackInfo | null) => {
|
||||
const position = Math.max(0, startSec)
|
||||
// 允许传入刚拿到的能力信息:从 URL 强制 HLS 时不想等到 state 更新后才选线路。
|
||||
const info = infoOverride ?? playbackInfo
|
||||
setHlsStartSec(position)
|
||||
const preferred =
|
||||
playbackInfo?.provider === 'cloud115'
|
||||
? findPlaybackQualityById(playbackInfo, selectedQuality) ??
|
||||
findPlaybackQualityById(playbackInfo, playbackInfo.default_quality)
|
||||
info?.provider === 'cloud115'
|
||||
? findPlaybackQualityById(info, selectedQuality) ??
|
||||
findPlaybackQualityById(info, info.default_quality)
|
||||
: undefined
|
||||
if (preferred?.source === 'cloud') {
|
||||
setHlsSource('cloud')
|
||||
if (preferred.available) {
|
||||
setCloudWaiting(false)
|
||||
// 云 HLS 是整段时间轴:切过去后按当前位置跳一下,别让用户从头看。
|
||||
pendingSeekRef.current = position > 2 ? position : null
|
||||
} else {
|
||||
setCloudWaiting(true)
|
||||
setCloudWaitMessage(preferred.note || `正在等待 115 转码 ${preferred.label}…`)
|
||||
void startCloudTranscode(Number(preferred.id) || 4)
|
||||
}
|
||||
} else {
|
||||
switchToLocalHLS(startSec)
|
||||
switchToLocalHLS(position)
|
||||
}
|
||||
setPlaybackMode('hls')
|
||||
},
|
||||
@@ -868,6 +1054,15 @@ export function PlayerPage() {
|
||||
}
|
||||
}, [cloudWaiting, cloudWaitStartedAt, mediaId, selectedQuality, setPlaybackMode, switchToLocalHLS])
|
||||
|
||||
// 从 URL 强制 HLS(媒体详情页「HLS 兼容转码播放」)时,等播放能力返回后再选线路:
|
||||
// 115 有可用的云转码档位就走云 HLS,否则退回本地转码。只做一次。
|
||||
useEffect(() => {
|
||||
if (!playbackInfo || !initialHlsRequestRef.current) return
|
||||
if (directOnly || !mediaId) return
|
||||
initialHlsRequestRef.current = false
|
||||
enterHlsPlayback(0, playbackInfo)
|
||||
}, [directOnly, enterHlsPlayback, mediaId, playbackInfo])
|
||||
|
||||
// 直连播放只发现外挂字幕;只有 HLS 模式需要探测可烧录的内嵌字幕。
|
||||
useEffect(() => {
|
||||
if (!mediaId) return
|
||||
@@ -1111,32 +1306,92 @@ export function PlayerPage() {
|
||||
}
|
||||
const video = ref.current
|
||||
if (!video) return
|
||||
let onSeeked: (() => void) | undefined
|
||||
const applyResume = () => {
|
||||
if (resumePosition > 0 && Math.abs(video.currentTime - resumePosition) > 2) {
|
||||
onSeeked = () => {
|
||||
setInitialSeekDone(true)
|
||||
const m = Math.floor(resumePosition / 60)
|
||||
const s = Math.floor(resumePosition % 60)
|
||||
const timeStr = `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
toast.success(`已恢复上次播放进度至 ${timeStr}`, { duration: 2500 })
|
||||
}
|
||||
video.addEventListener('seeked', onSeeked, { once: true })
|
||||
video.currentTime = resumePosition
|
||||
|
||||
let cancelled = false
|
||||
let seekStarted = false
|
||||
let onPlaying: (() => void) | undefined
|
||||
let onCanPlay: (() => void) | undefined
|
||||
|
||||
const notifyResume = () => {
|
||||
const m = Math.floor(resumePosition / 60)
|
||||
const s = Math.floor(resumePosition % 60)
|
||||
const timeStr = `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
toast.success(`已恢复上次播放进度至 ${timeStr}`, { duration: 2500 })
|
||||
}
|
||||
|
||||
const performResumeSeek = () => {
|
||||
if (cancelled || seekStarted) return
|
||||
if (Math.abs(video.currentTime - resumePosition) <= 2) {
|
||||
seekStarted = true
|
||||
setInitialSeekDone(true)
|
||||
return
|
||||
}
|
||||
setInitialSeekDone(true)
|
||||
seekStarted = true
|
||||
if (isStrmMedia(mediaRef.current)) {
|
||||
seekStrmDirectTo(resumePosition, {
|
||||
timeoutMs: 6000,
|
||||
onSettled: () => {
|
||||
if (!cancelled) {
|
||||
setInitialSeekDone(true)
|
||||
notifyResume()
|
||||
}
|
||||
},
|
||||
onStalled: () => {
|
||||
// 定位不成就从头播,别把用户挂在「正在定位」上。
|
||||
if (cancelled) return
|
||||
setInitialSeekDone(true)
|
||||
try {
|
||||
video.currentTime = 0
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
void video.play().catch(() => undefined)
|
||||
toast('续播定位超时,已从头播放', { duration: 2600 })
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
directSeekGraceUntilRef.current = Date.now() + DIRECT_SEEK_GRACE_MS
|
||||
directRetryRef.current = false
|
||||
clearFallbackTimer()
|
||||
const onSeeked = () => {
|
||||
setInitialSeekDone(true)
|
||||
notifyResume()
|
||||
}
|
||||
video.addEventListener('seeked', onSeeked, { once: true })
|
||||
try {
|
||||
video.currentTime = resumePosition
|
||||
} catch {
|
||||
setInitialSeekDone(true)
|
||||
}
|
||||
}
|
||||
if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
|
||||
applyResume()
|
||||
|
||||
// STRM/云盘:等真正起播后再续播跳转,避免 302 未完成就 seek。
|
||||
const deferForStrm = isStrmMedia(mediaRef.current)
|
||||
if (deferForStrm) {
|
||||
onPlaying = () => {
|
||||
performResumeSeek()
|
||||
}
|
||||
if (!video.paused && video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
|
||||
performResumeSeek()
|
||||
} else {
|
||||
video.addEventListener('playing', onPlaying, { once: true })
|
||||
}
|
||||
} else if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
|
||||
performResumeSeek()
|
||||
} else {
|
||||
video.addEventListener('canplay', applyResume, { once: true })
|
||||
onCanPlay = () => {
|
||||
performResumeSeek()
|
||||
}
|
||||
video.addEventListener('canplay', onCanPlay, { once: true })
|
||||
}
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('canplay', applyResume)
|
||||
if (onSeeked) video.removeEventListener('seeked', onSeeked)
|
||||
cancelled = true
|
||||
if (onPlaying) video.removeEventListener('playing', onPlaying)
|
||||
if (onCanPlay) video.removeEventListener('canplay', onCanPlay)
|
||||
}
|
||||
}, [resumePosition, initialSeekDone, mode, hlsStartSec])
|
||||
}, [resumePosition, initialSeekDone, mode, hlsStartSec, clearFallbackTimer, seekStrmDirectTo])
|
||||
|
||||
// 使用 ref 实时同步进度计算所需的状态,避免每次 hlsStartSec 改变都触发 cleanup 并误上报旧进度
|
||||
const hlsStartSecRef = useRef(hlsStartSec)
|
||||
@@ -1462,7 +1717,8 @@ export function PlayerPage() {
|
||||
const selectedQualityLabel = findPlaybackQualityById(playbackInfo, selectedQuality)?.label || ''
|
||||
const qualityLabel =
|
||||
mode === 'direct' ? originalQualityLabel : selectedQualityLabel || '清晰度'
|
||||
// 播放方式只描述状态,切换入口在控制栏的「设置」面板里(见 onTogglePlaybackMode)。
|
||||
// 播放方式标签:直接说清「这条片子现在是怎么在播」,避免用户猜线路
|
||||
//(115 直链 / 115 云 HLS / 本地转码 / 客户端解码)。
|
||||
const playbackModeLabel = isDirectStream
|
||||
? isRemoteEmbyID(media?.id)
|
||||
? 'Emby 直连播放'
|
||||
@@ -1470,8 +1726,12 @@ export function PlayerPage() {
|
||||
: directOnly
|
||||
? '客户端直连解码'
|
||||
: mode === 'hls'
|
||||
? 'HLS 转码'
|
||||
: '直接播放'
|
||||
? hlsSource === 'cloud'
|
||||
? '115 云 HLS 转码'
|
||||
: '本地 HLS 转码'
|
||||
: playbackInfo?.provider === 'cloud115'
|
||||
? '直连播放 · 115 直链'
|
||||
: '直接播放'
|
||||
const canTogglePlaybackMode = !isDirectStream && !directOnly
|
||||
|
||||
// 顶栏标题下的次要信息:集数进度与版本数量,让用户一眼知道「在看什么、在哪」。
|
||||
@@ -1524,7 +1784,8 @@ export function PlayerPage() {
|
||||
}
|
||||
const next = mode === 'hls' ? 'direct' : 'hls'
|
||||
if (next === 'hls') {
|
||||
enterHlsPlayback(0)
|
||||
// 带着当前位置切过去:云 HLS 跳一下、本地 HLS 从该点重开转码,都不从头播。
|
||||
enterHlsPlayback(ref.current?.currentTime || 0)
|
||||
} else {
|
||||
setCloudWaiting(false)
|
||||
}
|
||||
@@ -1616,10 +1877,30 @@ export function PlayerPage() {
|
||||
video.currentTime = Math.max(0, target - hlsStartSec)
|
||||
return
|
||||
}
|
||||
if (isStrmMedia(mediaRef.current)) {
|
||||
seekStrmDirectTo(target, { onStalled: handleDirectSeekStalled })
|
||||
return
|
||||
}
|
||||
selfSkipSeekRef.current = true
|
||||
video.currentTime = target
|
||||
},
|
||||
[handleSeekAbsolute, hlsSource, hlsStartSec, mode],
|
||||
[handleDirectSeekStalled, handleSeekAbsolute, hlsSource, hlsStartSec, mode, seekStrmDirectTo],
|
||||
)
|
||||
|
||||
const handlePlayerSeekAbsolute = useCallback(
|
||||
(absoluteSec: number) => {
|
||||
if (mode === 'hls' && hlsSource === 'local') {
|
||||
return handleSeekAbsolute(absoluteSec)
|
||||
}
|
||||
if (mode === 'direct' && isStrmMedia(mediaRef.current)) {
|
||||
return seekStrmDirectTo(absoluteSec, {
|
||||
timeoutMs: 4500,
|
||||
onStalled: handleDirectSeekStalled,
|
||||
})
|
||||
}
|
||||
return false
|
||||
},
|
||||
[handleDirectSeekStalled, handleSeekAbsolute, hlsSource, mode, seekStrmDirectTo],
|
||||
)
|
||||
|
||||
const showSkipNotice = useCallback(
|
||||
@@ -1790,6 +2071,7 @@ export function PlayerPage() {
|
||||
elementSrc: video?.src ?? '',
|
||||
expectedSrc,
|
||||
alreadyRetried: directRetryRef.current,
|
||||
seekGraceActive: Date.now() < directSeekGraceUntilRef.current,
|
||||
})
|
||||
if (action === 'ignore') return
|
||||
|
||||
@@ -1797,8 +2079,17 @@ export function PlayerPage() {
|
||||
directRetryRef.current = true
|
||||
retryingDirectRef.current = true
|
||||
try {
|
||||
video.load()
|
||||
void video.play().catch(() => undefined)
|
||||
// STRM/115 直连:load() 会清空进度从头播。软恢复即可。
|
||||
if (isStrmMedia(mediaRef.current) || pendingDirectSeekRef.current !== null) {
|
||||
const pending = pendingDirectSeekRef.current
|
||||
void video.play().catch(() => undefined)
|
||||
if (pending !== null && pending > 1) {
|
||||
seekStrmDirectTo(pending)
|
||||
}
|
||||
} else {
|
||||
video.load()
|
||||
void video.play().catch(() => undefined)
|
||||
}
|
||||
} finally {
|
||||
retryingDirectRef.current = false
|
||||
}
|
||||
@@ -1807,6 +2098,8 @@ export function PlayerPage() {
|
||||
|
||||
const fallbackDirectPlay = () => {
|
||||
if (modeRef.current !== 'direct') return
|
||||
// 宽限期内又触发的迟到 fallback 定时器:直接丢掉。
|
||||
if (Date.now() < directSeekGraceUntilRef.current) return
|
||||
const current = ref.current
|
||||
if (current && current.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return
|
||||
if (isRemoteEmbyID(mediaRef.current?.id) || isDirectStreamMedia(mediaRef.current)) {
|
||||
@@ -1841,7 +2134,9 @@ export function PlayerPage() {
|
||||
}
|
||||
|
||||
clearFallbackTimer()
|
||||
fallbackTimerRef.current = setTimeout(fallbackDirectPlay, 1500)
|
||||
// STRM/115 直链冷启动比本地文件慢,给足缓冲时间再判定失败。
|
||||
const fallbackDelayMs = isStrmMedia(mediaRef.current) ? 4000 : 1500
|
||||
fallbackTimerRef.current = setTimeout(fallbackDirectPlay, fallbackDelayMs)
|
||||
}, [
|
||||
clearFallbackTimer,
|
||||
directOnly,
|
||||
@@ -1849,6 +2144,7 @@ export function PlayerPage() {
|
||||
mediaId,
|
||||
mode,
|
||||
playbackInfo,
|
||||
seekStrmDirectTo,
|
||||
selectedQuality,
|
||||
setPlaybackMode,
|
||||
startCloudTranscode,
|
||||
@@ -1883,6 +2179,7 @@ export function PlayerPage() {
|
||||
directOnly={directOnly}
|
||||
isDirectStream={isDirectStream}
|
||||
directStreamLabel={isRemoteEmbyID(media?.id) ? 'Emby 直连播放' : undefined}
|
||||
modeLabel={playbackModeLabel}
|
||||
mode={mode}
|
||||
onBack={goBack}
|
||||
/>
|
||||
@@ -1907,7 +2204,6 @@ export function PlayerPage() {
|
||||
onPlayerVolumeCommit={commitPlayerVolume}
|
||||
playerPlaybackRate={playerPlaybackRate}
|
||||
onPlayerPlaybackRateChange={changePlayerPlaybackRate}
|
||||
onPlayerPlaybackRateCommit={commitPlayerPlaybackRate}
|
||||
danmakuEnabled={danmakuEnabled}
|
||||
danmakuOpacity={danmakuOpacity}
|
||||
danmakuFontSize={danmakuFontSize}
|
||||
@@ -1932,7 +2228,11 @@ export function PlayerPage() {
|
||||
onTogglePlaylist={isMobileTheater ? openPlaylistEntry : togglePlaylistOpen}
|
||||
knownDuration={media?.duration_sec || 0}
|
||||
streamOffset={mode === 'hls' && hlsSource === 'local' ? hlsStartSec : 0}
|
||||
onSeekAbsolute={mode === 'hls' && hlsSource === 'local' ? handleSeekAbsolute : undefined}
|
||||
onSeekAbsolute={
|
||||
(mode === 'hls' && hlsSource === 'local') || (mode === 'direct' && isStrmMedia(media))
|
||||
? handlePlayerSeekAbsolute
|
||||
: undefined
|
||||
}
|
||||
qualities={qualityOptions}
|
||||
qualityLabel={qualityLabel}
|
||||
selectedQuality={selectedQuality}
|
||||
@@ -2048,7 +2348,8 @@ function formatEpisodeDisplay(ep: Media, siblings: Media[]): string {
|
||||
return mediaTitle
|
||||
}
|
||||
|
||||
return ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : mediaTitle || title || '未命名'
|
||||
const episodeLabel = formatEpisodeLabel(ep)
|
||||
return episodeLabel || mediaTitle || title || '未命名'
|
||||
}
|
||||
|
||||
function findPlaybackQualityById(info: PlaybackInfo | null, id: string): PlaybackQuality | undefined {
|
||||
|
||||
@@ -18,6 +18,8 @@ type PlayerTopBarProps = {
|
||||
directOnly: boolean
|
||||
isDirectStream?: boolean
|
||||
directStreamLabel?: string
|
||||
/** 已算好的播放方式文字(如「直连播放 · 115 直链」「115 云 HLS 转码」);传入时优先展示。 */
|
||||
modeLabel?: string
|
||||
mode: PlayerMode
|
||||
onBack: () => void
|
||||
}
|
||||
@@ -28,10 +30,11 @@ export function PlayerTopBar({
|
||||
directOnly,
|
||||
isDirectStream,
|
||||
directStreamLabel,
|
||||
modeLabel,
|
||||
mode,
|
||||
onBack,
|
||||
}: PlayerTopBarProps) {
|
||||
const status = isDirectStream
|
||||
const derived = isDirectStream
|
||||
? {
|
||||
icon: <Sparkles size={12} />,
|
||||
label: directStreamLabel || '直连播放',
|
||||
@@ -56,6 +59,7 @@ export function PlayerTopBar({
|
||||
label: '直接播放',
|
||||
title: '当前直接播放原始文件,可在控制栏「设置」里切到 HLS 转码',
|
||||
}
|
||||
const status = modeLabel ? { ...derived, label: modeLabel } : derived
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-start justify-between gap-3 bg-gradient-to-b from-black/60 via-black/20 to-transparent px-2.5 pb-10 pt-2.5 sm:px-3.5 sm:pt-3.5">
|
||||
|
||||
@@ -184,7 +184,6 @@ type PlayerVideoStageProps = {
|
||||
onPlayerVolumeCommit: (volume: number) => void
|
||||
playerPlaybackRate: number
|
||||
onPlayerPlaybackRateChange: (rate: number) => void
|
||||
onPlayerPlaybackRateCommit: (rate: number) => void
|
||||
danmakuEnabled: boolean
|
||||
danmakuOpacity: number
|
||||
danmakuFontSize: number
|
||||
@@ -268,7 +267,6 @@ export function PlayerVideoStage({
|
||||
onPlayerVolumeCommit,
|
||||
playerPlaybackRate,
|
||||
onPlayerPlaybackRateChange,
|
||||
onPlayerPlaybackRateCommit,
|
||||
danmakuEnabled,
|
||||
danmakuOpacity,
|
||||
danmakuFontSize,
|
||||
@@ -892,7 +890,6 @@ export function PlayerVideoStage({
|
||||
onVolumeCommit={onPlayerVolumeCommit}
|
||||
playbackRate={playerPlaybackRate}
|
||||
onPlaybackRateChange={onPlayerPlaybackRateChange}
|
||||
onPlaybackRateCommit={onPlayerPlaybackRateCommit}
|
||||
uiVisible={controlsVisible && !locked}
|
||||
onUiVisibleChange={setControlsVisible}
|
||||
uiHold={vrUiHovered}
|
||||
|
||||
@@ -3,9 +3,15 @@ export type DirectPlayErrorAction = 'ignore' | 'retry' | 'fallback'
|
||||
const MEDIA_ERR_ABORTED = 1
|
||||
const HAVE_CURRENT_DATA = 2
|
||||
|
||||
/** 程序化续播/跳转后忽略直连误报的宽限期(毫秒)。 */
|
||||
export const DIRECT_SEEK_GRACE_MS = 12_000
|
||||
|
||||
/**
|
||||
* Chromium 常把被中断的直连(换 src、插入 track、302 未完成就 seek)
|
||||
* 报成 error。真正不兼容应 fallback;瞬时中断应忽略或静默重试一次。
|
||||
*
|
||||
* seekGraceActive:程序化续播/跳转后的宽限期。115 STRM 在 302 直链上
|
||||
* seek 时 Chromium 经常误报 decode/network error,此时绝不能升到 HLS。
|
||||
*/
|
||||
export function classifyDirectPlayError(input: {
|
||||
errorCode: number | undefined | null
|
||||
@@ -13,12 +19,15 @@ export function classifyDirectPlayError(input: {
|
||||
elementSrc: string
|
||||
expectedSrc: string
|
||||
alreadyRetried: boolean
|
||||
seekGraceActive?: boolean
|
||||
}): DirectPlayErrorAction {
|
||||
const code = input.errorCode ?? 0
|
||||
if (code === MEDIA_ERR_ABORTED) return 'ignore'
|
||||
if (input.readyState >= HAVE_CURRENT_DATA) return 'ignore'
|
||||
if (!input.elementSrc) return 'ignore'
|
||||
if (input.expectedSrc && input.elementSrc !== input.expectedSrc) return 'ignore'
|
||||
// 续播 seek 宽限期内:误报很常见,且 retry 会 video.load() 把进度清零,直接忽略。
|
||||
if (input.seekGraceActive) return 'ignore'
|
||||
if (!input.alreadyRetried) return 'retry'
|
||||
return 'fallback'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
canSeekDirectNow,
|
||||
seekableEndSec,
|
||||
timeInSeekableRanges,
|
||||
} from './directPlaySeek.ts'
|
||||
|
||||
function check(name: string, cond: boolean) {
|
||||
if (!cond) throw new Error(`directPlaySeek: ${name}`)
|
||||
}
|
||||
|
||||
function ranges(parts: Array<[number, number]>) {
|
||||
return {
|
||||
length: parts.length,
|
||||
start: (i: number) => parts[i][0],
|
||||
end: (i: number) => parts[i][1],
|
||||
}
|
||||
}
|
||||
|
||||
check('empty seekable rejects mid seek', !timeInSeekableRanges(null, 120))
|
||||
check('empty seekable end is 0', seekableEndSec(undefined) === 0)
|
||||
|
||||
check(
|
||||
'covered range accepts target',
|
||||
timeInSeekableRanges(ranges([[0, 600]]), 120),
|
||||
)
|
||||
|
||||
check(
|
||||
'target beyond end is rejected',
|
||||
!timeInSeekableRanges(ranges([[0, 30]]), 120),
|
||||
)
|
||||
|
||||
check(
|
||||
'multi-range uses any segment',
|
||||
timeInSeekableRanges(
|
||||
ranges([
|
||||
[0, 10],
|
||||
[100, 200],
|
||||
]),
|
||||
150,
|
||||
),
|
||||
)
|
||||
|
||||
check('seekableEndSec takes max end', seekableEndSec(ranges([[0, 10], [5, 80]])) === 80)
|
||||
|
||||
check('canSeekDirectNow allows near-zero without seekable', canSeekDirectNow(null, 0.2))
|
||||
check('canSeekDirectNow blocks mid when not seekable', !canSeekDirectNow(null, 90))
|
||||
check(
|
||||
'canSeekDirectNow allows mid when covered',
|
||||
canSeekDirectNow(ranges([[0, 900]]), 90),
|
||||
)
|
||||
|
||||
console.log('directPlaySeek.test.ts ok')
|
||||
@@ -0,0 +1,60 @@
|
||||
/** STRM/115 直连跳转:等 seekable 覆盖目标后再写 currentTime,避免被钳回 0。 */
|
||||
|
||||
export const DIRECT_SEEK_WAIT_MS = 10_000
|
||||
export const DIRECT_SEEK_MARGIN_SEC = 0.35
|
||||
|
||||
type SeekableLike = {
|
||||
length: number
|
||||
start: (index: number) => number
|
||||
end: (index: number) => number
|
||||
}
|
||||
|
||||
/** 目标时间是否落在任一 seekable 区间内(末端留一点边距)。 */
|
||||
export function timeInSeekableRanges(
|
||||
seekable: SeekableLike | null | undefined,
|
||||
time: number,
|
||||
margin = DIRECT_SEEK_MARGIN_SEC,
|
||||
): boolean {
|
||||
if (!seekable || seekable.length <= 0) return false
|
||||
if (!Number.isFinite(time) || time < 0) return false
|
||||
for (let i = 0; i < seekable.length; i += 1) {
|
||||
try {
|
||||
const start = seekable.start(i)
|
||||
const end = seekable.end(i)
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) continue
|
||||
if (time + margin >= start && time <= end + margin) return true
|
||||
} catch {
|
||||
// TimeRanges 在部分浏览器上可能抛 InvalidStateError,跳过该段。
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** seekable 已覆盖的最右端(秒);没有可用区间时返回 0。 */
|
||||
export function seekableEndSec(seekable: SeekableLike | null | undefined): number {
|
||||
if (!seekable || seekable.length <= 0) return 0
|
||||
let max = 0
|
||||
for (let i = 0; i < seekable.length; i += 1) {
|
||||
try {
|
||||
const end = seekable.end(i)
|
||||
if (Number.isFinite(end) && end > max) max = end
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
/**
|
||||
* 直连是否可以立刻跳到 target。
|
||||
* - seekable 已覆盖 → 可以
|
||||
* - 目标接近 0 → 可以(不需要等)
|
||||
* - 否则应等待 progress / durationchange
|
||||
*/
|
||||
export function canSeekDirectNow(
|
||||
seekable: SeekableLike | null | undefined,
|
||||
time: number,
|
||||
): boolean {
|
||||
if (!Number.isFinite(time) || time <= 1) return true
|
||||
return timeInSeekableRanges(seekable, time)
|
||||
}
|
||||
@@ -72,4 +72,28 @@ check(
|
||||
}) === 'fallback',
|
||||
)
|
||||
|
||||
check(
|
||||
'seek grace ignores decode miss without retrying',
|
||||
classifyDirectPlayError({
|
||||
errorCode: 4,
|
||||
readyState: 0,
|
||||
elementSrc: src,
|
||||
expectedSrc: src,
|
||||
alreadyRetried: false,
|
||||
seekGraceActive: true,
|
||||
}) === 'ignore',
|
||||
)
|
||||
|
||||
check(
|
||||
'seek grace never falls back to HLS',
|
||||
classifyDirectPlayError({
|
||||
errorCode: 4,
|
||||
readyState: 0,
|
||||
elementSrc: src,
|
||||
expectedSrc: src,
|
||||
alreadyRetried: true,
|
||||
seekGraceActive: true,
|
||||
}) === 'ignore',
|
||||
)
|
||||
|
||||
console.log('playerPageModel.test.ts ok')
|
||||
|
||||
@@ -23,6 +23,13 @@ export const generalSettingsGroup: SettingGroup = {
|
||||
hint: '默认关闭。开启后宿主机不再进行任何 FFmpeg 转码,所有播放交给第三方客户端(Infuse / VLC / Emby 客户端等)或浏览器本地解码直连(direct play / 302 直链),大幅降低宿主机 CPU 占用。若客户端不支持源编码可能无法播放。',
|
||||
defaultValue: 'false',
|
||||
},
|
||||
{
|
||||
key: 'segment.prewarm_enabled',
|
||||
label: '后台预热 IntroDB 片头片段',
|
||||
type: 'toggle',
|
||||
hint: '默认关闭。开启后每 6 小时在后台向 TheIntroDB 拉取可识别媒体的片头/片尾区间并写入本地;关闭时仅在播放时按需请求。',
|
||||
defaultValue: 'false',
|
||||
},
|
||||
{
|
||||
key: 'transcode.enabled',
|
||||
label: '启用转码',
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type SeriesCard,
|
||||
} from '../utils/groupSeries'
|
||||
import { resolveSeriesCardByKey } from '../utils/seriesCardResolve'
|
||||
import { episodeNumberValue } from '../utils/episodeNumber'
|
||||
|
||||
type SeasonEpisodes = {
|
||||
season: number
|
||||
@@ -72,7 +73,7 @@ export function useLibrarySeriesSelection({
|
||||
seasons.get(s)!.push(ep)
|
||||
}
|
||||
for (const [, list] of seasons) {
|
||||
list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
|
||||
list.sort((a, b) => episodeNumberValue(a) - episodeNumberValue(b))
|
||||
}
|
||||
return Array.from(seasons.entries())
|
||||
.sort(([a], [b]) => seasonSortOrder(a) - seasonSortOrder(b))
|
||||
|
||||
@@ -9,6 +9,7 @@ import { confirmActionResult } from '../components/confirmAction'
|
||||
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
||||
import type { Media } from '../types'
|
||||
import { seasonSortOrder } from '../utils/groupSeries'
|
||||
import { episodeNumberValue } from '../utils/episodeNumber'
|
||||
import { mediaLibraryBackTarget } from './MediaDetailPageModel'
|
||||
|
||||
type MediaDetailLocationState = { from?: string } | null
|
||||
@@ -81,7 +82,7 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa
|
||||
seasons.get(s)!.push(ep)
|
||||
}
|
||||
for (const [, list] of seasons) {
|
||||
list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
|
||||
list.sort((a, b) => episodeNumberValue(a) - episodeNumberValue(b))
|
||||
}
|
||||
return Array.from(seasons.entries())
|
||||
.sort(([a], [b]) => seasonSortOrder(a) - seasonSortOrder(b))
|
||||
|
||||
@@ -12,8 +12,6 @@ export interface User {
|
||||
subtitle_chinese_mode?: 'original' | 'simplified' | 'traditional'
|
||||
/** 本地缓存的播放器音量,服务端配置接口会刷新。 */
|
||||
player_volume?: number
|
||||
/** 本地缓存的播放倍速,服务端配置接口会刷新。 */
|
||||
player_playback_rate?: number
|
||||
force_password_reset: boolean
|
||||
is_active: boolean
|
||||
allowed_library_ids?: string[]
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface Media {
|
||||
release_date?: string
|
||||
season_num: number
|
||||
episode_num: number
|
||||
/** 集号的小数部分(S01E11.5 → 0.5);没有小数时缺省。 */
|
||||
episode_fraction?: number
|
||||
scrape_status: string
|
||||
tmdb_id: number
|
||||
bangumi_id: number
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Media } from '../types'
|
||||
|
||||
type EpisodeNumberSource = Pick<Media, 'episode_num' | 'episode_fraction'> | null | undefined
|
||||
|
||||
/**
|
||||
* 含小数的集号:S01E11.5 → 11.5。没有小数(绝大多数集)时就是 episode_num。
|
||||
*
|
||||
* 后端把整数集号和它的小数部分分开存(Emby 协议的 IndexNumber 只能是整数),
|
||||
* 网页端所有「显示集号 / 按集排序」的地方都应该用这个函数,而不是直接读
|
||||
* episode_num,否则 11 与 11.5 会显示、排序成同一集。
|
||||
*/
|
||||
export function episodeNumberValue(media: EpisodeNumberSource): number {
|
||||
const base = media?.episode_num ?? 0
|
||||
const fraction = media?.episode_fraction ?? 0
|
||||
if (!(fraction > 0 && fraction < 1)) return base
|
||||
return base + fraction
|
||||
}
|
||||
|
||||
/** 显示用集号文本:11 → "11",11.5 → "11.5";没有集号时返回空串。 */
|
||||
export function formatEpisodeNumber(media: EpisodeNumberSource): string {
|
||||
const value = episodeNumberValue(media)
|
||||
return value > 0 ? String(value) : ''
|
||||
}
|
||||
|
||||
/** "第 11 集" / "第 11.5 集";没有集号时返回空串。 */
|
||||
export function formatEpisodeLabel(media: EpisodeNumberSource): string {
|
||||
const value = formatEpisodeNumber(media)
|
||||
return value ? `第 ${value} 集` : ''
|
||||
}
|
||||
|
||||
/** 按 (季, 集, 小数部分) 升序比较,供剧集列表排序复用。 */
|
||||
export function compareEpisodeOrder(a: Media, b: Media): number {
|
||||
const seasonDiff = (a.season_num ?? 0) - (b.season_num ?? 0)
|
||||
if (seasonDiff !== 0) return seasonDiff
|
||||
return episodeNumberValue(a) - episodeNumberValue(b)
|
||||
}
|
||||
@@ -3,6 +3,9 @@ export const PLAYBACK_RATE_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3]
|
||||
export const MIN_PLAYBACK_RATE = PLAYBACK_RATE_OPTIONS[0]
|
||||
export const MAX_PLAYBACK_RATE = PLAYBACK_RATE_OPTIONS[PLAYBACK_RATE_OPTIONS.length - 1]
|
||||
|
||||
/** 默认倍速(1x)。倍速不落库,每个视频都从这个值开始。 */
|
||||
export const DEFAULT_PLAYBACK_RATE = 1
|
||||
|
||||
export function normalizePlaybackRate(value: unknown): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return 1
|
||||
|
||||
Reference in New Issue
Block a user