mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-15 12:34:22 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ffac320c4 | |||
| 9b8ca33279 |
@@ -0,0 +1,406 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
"github.com/truewhile/MeBox/internal/service/reader"
|
||||
)
|
||||
|
||||
// 音轨「区间直通」的行为契约。
|
||||
//
|
||||
// 背景(真实故障):播放器(media3)取一条 m4a 会开两次请求——先整段打开嗅探
|
||||
// 容器,再带 Range 从目标位置重开。旧实现只允许「无 Range + 无分片」的请求
|
||||
// 边下边发,于是带 Range 的那次(真正的起播请求)必须把整章下完再按区间下发。
|
||||
// 线上日志:每章固定 200(探针,被播放器掐断)+ 206(整章下完才吐第一个字节),
|
||||
// 42 章里「媒体已装载 → 实际起播」平均 4.79 秒,最坏 35 秒。
|
||||
//
|
||||
// 现在的契约:
|
||||
// - 带 Range 的冷缓存请求 → 上游一到字节就往下发,不等整章;
|
||||
// - 分片已有的前缀从本地发,上游只续下剩余字节;
|
||||
// - 整章落满时把分片提升为正式缓存,之后走本地;
|
||||
// - 上游不认 Range 时也要给出正确的 206(跳过前缀),且不写带洞的分片;
|
||||
// - 拿不到写锁时降级成纯透传,绝不排队等前一个下载。
|
||||
|
||||
// rangeUpstream 支持 Range 的上游:/content 给出音轨地址,/media 回音频本体。
|
||||
type rangeUpstream struct {
|
||||
*httptest.Server
|
||||
payload []byte
|
||||
honorRange bool
|
||||
// firstChunk > 0 且 release 非 nil 时:先写这么多字节再等放行,
|
||||
// 用来证明「客户端不必等整章下完」。
|
||||
firstChunk int
|
||||
release chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
ranges []string
|
||||
hits int
|
||||
}
|
||||
|
||||
func newRangeUpstream(t *testing.T, payload []byte, honorRange bool) *rangeUpstream {
|
||||
t.Helper()
|
||||
u := &rangeUpstream{payload: payload, honorRange: honorRange}
|
||||
u.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/book/1"):
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`<html><body><h1>区间之书</h1></body></html>`))
|
||||
case r.URL.Path == "/content":
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_, _ = w.Write([]byte(fmt.Sprintf(`{"data":{"url":"%s/media"}}`, u.URL)))
|
||||
case r.URL.Path == "/media":
|
||||
u.serveMedia(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(u.Close)
|
||||
return u
|
||||
}
|
||||
|
||||
func (u *rangeUpstream) serveMedia(w http.ResponseWriter, r *http.Request) {
|
||||
u.mu.Lock()
|
||||
u.hits++
|
||||
u.ranges = append(u.ranges, r.Header.Get("Range"))
|
||||
u.mu.Unlock()
|
||||
|
||||
total := len(u.payload)
|
||||
start, end := 0, total-1
|
||||
partial := false
|
||||
if u.honorRange {
|
||||
if s, e, ok := parseReaderAudioRange(r.Header.Get("Range")); ok {
|
||||
start = int(s)
|
||||
if e >= 0 && int(e) < end {
|
||||
end = int(e)
|
||||
}
|
||||
partial = true
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "audio/mp4")
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
if start >= total {
|
||||
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
|
||||
return
|
||||
}
|
||||
body := u.payload[start : end+1]
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
||||
if partial {
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, total))
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
if u.release != nil && u.firstChunk > 0 && len(body) > u.firstChunk {
|
||||
_, _ = w.Write(body[:u.firstChunk])
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
select {
|
||||
case <-u.release:
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
body = body[u.firstChunk:]
|
||||
}
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func (u *rangeUpstream) rangeHeaders() []string {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
return append([]string(nil), u.ranges...)
|
||||
}
|
||||
|
||||
func (u *rangeUpstream) requestCount() int {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
return u.hits
|
||||
}
|
||||
|
||||
// prepareRangeBook 建一本「书源直连上游」的音频书,并取回该音轨的签名代理路径。
|
||||
func prepareRangeBook(t *testing.T, upstream string) (*service.Container, *model.ReaderBook, string) {
|
||||
t.Helper()
|
||||
svc := newReaderHandlerContainer(t)
|
||||
svc.Cfg.Cache.CacheDir = t.TempDir()
|
||||
srcJSON := fmt.Sprintf(`{
|
||||
"bookSourceName": "区间直通测试",
|
||||
"bookSourceType": 1,
|
||||
"bookSourceUrl": %q,
|
||||
"ruleContent": {"content": "$.data.url"}
|
||||
}`, upstream)
|
||||
if _, err := svc.Reader.ImportSources(t.Context(), "u1", srcJSON); err != nil {
|
||||
t.Fatalf("导入书源失败: %v", err)
|
||||
}
|
||||
srcs, err := svc.Reader.ListSources(t.Context(), "u1")
|
||||
if err != nil || len(srcs) == 0 {
|
||||
t.Fatalf("读不到书源: %v", err)
|
||||
}
|
||||
book, err := svc.Reader.AddBook(t.Context(), "u1", reader.SearchOrigin{
|
||||
SourceID: srcs[0].ID,
|
||||
Origin: upstream,
|
||||
OriginName: "区间直通测试",
|
||||
OriginType: 1,
|
||||
BookURL: upstream + "/book/1",
|
||||
}, "区间之书", "某作者", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
if err := svc.Reader.SaveChapters(t.Context(), book.ID, []reader.ChapterInput{
|
||||
{Index: 0, Title: "第一章", URL: upstream + "/content"},
|
||||
}); err != nil {
|
||||
t.Fatalf("保存目录失败: %v", err)
|
||||
}
|
||||
out, err := svc.Reader.GetContentForBook(t.Context(), "u1", book.ID, 0, reader.ContentOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("取章节失败: %v", err)
|
||||
}
|
||||
if len(out.Tracks) != 1 {
|
||||
t.Fatalf("音轨数 = %d,期望 1", len(out.Tracks))
|
||||
}
|
||||
return svc, book, out.Tracks[0]
|
||||
}
|
||||
|
||||
func audioPayload(n int) []byte {
|
||||
payload := make([]byte, n)
|
||||
for i := range payload {
|
||||
payload[i] = byte(i % 251)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// waitCached 等待分片被提升为正式缓存(提升发生在响应收尾,与客户端读完成之间有微小竞态)。
|
||||
func waitCached(t *testing.T, svc *service.Container, bookID, mediaURL string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if svc.Reader.HasCachedRemoteAudio(bookID, mediaURL) {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("整章下完后应提升为正式缓存")
|
||||
}
|
||||
|
||||
// TestReaderAudioRangeStreamsWithoutWaitingForWholeChapter 冷缓存 + Range:
|
||||
// 上游只吐了第一段,客户端就必须已经拿到字节。
|
||||
func TestReaderAudioRangeStreamsWithoutWaitingForWholeChapter(t *testing.T) {
|
||||
payload := audioPayload(512 << 10)
|
||||
upstream := newRangeUpstream(t, payload, true)
|
||||
upstream.firstChunk = 64 << 10
|
||||
upstream.release = make(chan struct{})
|
||||
|
||||
svc, book, track := prepareRangeBook(t, upstream.URL)
|
||||
mediaURL := upstream.URL + "/media"
|
||||
srv := httptest.NewServer(mediaRouterForTest(svc))
|
||||
defer srv.Close()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL+track, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("构造请求失败: %v", err)
|
||||
}
|
||||
req.Header.Set("Range", "bytes=0-")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusPartialContent {
|
||||
t.Fatalf("状态码 = %d,期望 206", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Content-Range"); got != fmt.Sprintf("bytes 0-%d/%d", len(payload)-1, len(payload)) {
|
||||
t.Fatalf("Content-Range = %q", got)
|
||||
}
|
||||
if got := resp.Header.Get("Content-Length"); got != strconv.Itoa(len(payload)) {
|
||||
t.Fatalf("Content-Length = %q,期望 %d", got, len(payload))
|
||||
}
|
||||
|
||||
// 上游此时还被 release 卡着:能在超时内读到第一段就说明没有等整章。
|
||||
head := make([]byte, upstream.firstChunk)
|
||||
readDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, readErr := io.ReadFull(resp.Body, head)
|
||||
readDone <- readErr
|
||||
}()
|
||||
select {
|
||||
case readErr := <-readDone:
|
||||
if readErr != nil {
|
||||
t.Fatalf("读首段失败: %v", readErr)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatalf("上游还没给完就卡住了:客户端没能在整章下完前拿到第一个字节")
|
||||
}
|
||||
if !bytes.Equal(head, payload[:upstream.firstChunk]) {
|
||||
t.Fatalf("首段字节不一致")
|
||||
}
|
||||
|
||||
close(upstream.release)
|
||||
rest, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("读剩余字节失败: %v", err)
|
||||
}
|
||||
if got := append(head, rest...); !bytes.Equal(got, payload) {
|
||||
t.Fatalf("整体字节不一致: got %d bytes", len(got))
|
||||
}
|
||||
if got := upstream.rangeHeaders(); len(got) != 1 || got[0] != "bytes=0-" {
|
||||
t.Fatalf("上游收到的 Range = %v,期望且仅一次 bytes=0-", got)
|
||||
}
|
||||
waitCached(t, svc, book.ID, mediaURL)
|
||||
}
|
||||
|
||||
// TestReaderAudioRangeResumesFromPartialFragment 分片里已有前缀时,前缀从本地发,
|
||||
// 上游只续下剩余字节;整章落满后提升为正式缓存。
|
||||
func TestReaderAudioRangeResumesFromPartialFragment(t *testing.T) {
|
||||
payload := audioPayload(256 << 10)
|
||||
upstream := newRangeUpstream(t, payload, true)
|
||||
svc, book, track := prepareRangeBook(t, upstream.URL)
|
||||
mediaURL := upstream.URL + "/media"
|
||||
|
||||
// 模拟「播放器嗅探完就断开」留下的分片:前 96KB 已经在盘上。
|
||||
const partLen = 96 << 10
|
||||
cachePath := svc.Reader.RemoteAudioCachePath(book.ID, mediaURL)
|
||||
if cachePath == "" {
|
||||
t.Fatalf("缓存路径为空")
|
||||
}
|
||||
if mkErr := os.MkdirAll(cachePath[:strings.LastIndex(cachePath, string(os.PathSeparator))], 0o750); mkErr != nil {
|
||||
t.Fatalf("建缓存目录失败: %v", mkErr)
|
||||
}
|
||||
if writeErr := os.WriteFile(cachePath+".part", payload[:partLen], 0o640); writeErr != nil {
|
||||
t.Fatalf("写分片失败: %v", writeErr)
|
||||
}
|
||||
|
||||
code, body := getReaderMediaWithRange(t, mediaRouterForTest(svc), track, "bytes=0-")
|
||||
if code != http.StatusPartialContent {
|
||||
t.Fatalf("状态码 = %d,期望 206 body=%.120s", code, body)
|
||||
}
|
||||
if !bytes.Equal([]byte(body), payload) {
|
||||
t.Fatalf("拼接后的字节不一致: got %d bytes want %d", len(body), len(payload))
|
||||
}
|
||||
headers := upstream.rangeHeaders()
|
||||
if len(headers) != 1 || headers[0] != fmt.Sprintf("bytes=%d-", partLen) {
|
||||
t.Fatalf("上游收到的 Range = %v,期望从分片长度续下 bytes=%d-", headers, partLen)
|
||||
}
|
||||
waitCached(t, svc, book.ID, mediaURL)
|
||||
|
||||
// 提升之后同一首歌不再回源。
|
||||
before := upstream.requestCount()
|
||||
code, body = getReaderMedia(t, mediaRouterForTest(svc), track)
|
||||
if code != http.StatusOK || !bytes.Equal([]byte(body), payload) {
|
||||
t.Fatalf("命中缓存失败 status=%d len=%d", code, len(body))
|
||||
}
|
||||
if after := upstream.requestCount(); after != before {
|
||||
t.Fatalf("命中缓存后不应再回源,多了 %d 次", after-before)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReaderAudioRangeHandlesUpstreamThatIgnoresRange 上游不认 Range(回 200 整段)时,
|
||||
// 仍要按客户端要的起点给出正确的 206,并且不写带洞的分片。
|
||||
func TestReaderAudioRangeHandlesUpstreamThatIgnoresRange(t *testing.T) {
|
||||
payload := audioPayload(300 << 10)
|
||||
upstream := newRangeUpstream(t, payload, false)
|
||||
svc, book, track := prepareRangeBook(t, upstream.URL)
|
||||
mediaURL := upstream.URL + "/media"
|
||||
|
||||
const start = 1000
|
||||
code, body := getReaderMediaWithRange(t, mediaRouterForTest(svc), track, fmt.Sprintf("bytes=%d-", start))
|
||||
if code != http.StatusPartialContent {
|
||||
t.Fatalf("状态码 = %d,期望 206 body=%.120s", code, body)
|
||||
}
|
||||
if !bytes.Equal([]byte(body), payload[start:]) {
|
||||
t.Fatalf("区间字节不一致: got %d want %d", len(body), len(payload)-start)
|
||||
}
|
||||
// 起点不在分片内 → 不能落盘(否则分片开头会是空洞)。
|
||||
if svc.Reader.HasPartialRemoteAudio(book.ID, mediaURL) {
|
||||
t.Fatalf("起点靠后的区间请求不应生成分片")
|
||||
}
|
||||
if svc.Reader.HasCachedRemoteAudio(book.ID, mediaURL) {
|
||||
t.Fatalf("只下了一段不应生成完整缓存")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReaderAudioRangeFallsBackToPassthroughWhenWriterBusy 拿不到写锁时不排队:
|
||||
// 仍然立刻把区间发出去,只是这一轮不落盘。
|
||||
func TestReaderAudioRangeFallsBackToPassthroughWhenWriterBusy(t *testing.T) {
|
||||
payload := audioPayload(128 << 10)
|
||||
upstream := newRangeUpstream(t, payload, true)
|
||||
svc, book, track := prepareRangeBook(t, upstream.URL)
|
||||
mediaURL := upstream.URL + "/media"
|
||||
|
||||
cacheKey := svc.Reader.RemoteAudioCachePath(book.ID, mediaURL)
|
||||
if cacheKey == "" {
|
||||
t.Fatalf("缓存路径为空")
|
||||
}
|
||||
unlock := readerAudioStreamLocks.Lock(cacheKey)
|
||||
defer unlock()
|
||||
|
||||
done := make(chan struct{})
|
||||
var code int
|
||||
var body string
|
||||
go func() {
|
||||
code, body = getReaderMediaWithRange(t, mediaRouterForTest(svc), track, "bytes=0-")
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatalf("写锁被占用时不该让请求排队等待")
|
||||
}
|
||||
if code != http.StatusPartialContent {
|
||||
t.Fatalf("状态码 = %d,期望 206 body=%.120s", code, body)
|
||||
}
|
||||
if !bytes.Equal([]byte(body), payload) {
|
||||
t.Fatalf("区间字节不一致: got %d want %d", len(body), len(payload))
|
||||
}
|
||||
if svc.Reader.HasCachedRemoteAudio(book.ID, mediaURL) {
|
||||
t.Fatalf("降级透传不应写缓存")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReaderAudioRangeCases(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
start int64
|
||||
end int64
|
||||
want bool
|
||||
}{
|
||||
{"bytes=0-", 0, -1, true},
|
||||
{"bytes=100-199", 100, 199, true},
|
||||
{"Bytes=1024-", 1024, -1, true},
|
||||
{" bytes=5-5 ", 5, 5, true},
|
||||
{"bytes=-500", 0, 0, false},
|
||||
{"bytes=0-99,200-299", 0, 0, false},
|
||||
{"items=0-", 0, 0, false},
|
||||
{"bytes=abc-", 0, 0, false},
|
||||
{"bytes=200-100", 0, 0, false},
|
||||
{"", 0, 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
start, end, ok := parseReaderAudioRange(c.in)
|
||||
if ok != c.want || start != c.start || end != c.end {
|
||||
t.Errorf("parseReaderAudioRange(%q) = (%d,%d,%v),期望 (%d,%d,%v)",
|
||||
c.in, start, end, ok, c.start, c.end, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getReaderMediaWithRange 发一次带 Range 头的媒体代理请求。
|
||||
func getReaderMediaWithRange(t *testing.T, router *gin.Engine, signedPath, rangeHeader string) (int, string) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, signedPath, nil)
|
||||
req.Header.Set("Range", rangeHeader)
|
||||
router.ServeHTTP(w, req)
|
||||
return w.Code, w.Body.String()
|
||||
}
|
||||
@@ -1062,15 +1062,77 @@ func (k *keyedMutex) Lock(key string) func() {
|
||||
}
|
||||
}
|
||||
|
||||
// keyedMutexPollInterval 等锁的重试间隔。只用于「已有写入者时短暂等待」,
|
||||
// 不值得为它引入条件变量。
|
||||
const keyedMutexPollInterval = 20 * time.Millisecond
|
||||
|
||||
// LockWait 最多等 timeout 就放弃。
|
||||
//
|
||||
// 音频缓存的写入者可能是「首播直通」,它会一直持锁到客户端读完整章;让后来的
|
||||
// 请求排队等它,播放器的重试就会干等几十秒(用户看到的就是「一直转圈」)。
|
||||
// 等不到锁的请求改走纯透传,字节照样能出声,只是这次不落盘。
|
||||
func (k *keyedMutex) LockWait(key string, timeout time.Duration) (func(), bool) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
if unlock, ok := k.acquire(key); ok {
|
||||
return unlock, true
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return nil, false
|
||||
}
|
||||
time.Sleep(keyedMutexPollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// acquire 取一次锁,取不到立刻返回。引用计数保证条目在最后一个持有者归还后回收。
|
||||
func (k *keyedMutex) acquire(key string) (func(), bool) {
|
||||
k.mu.Lock()
|
||||
entry := k.locks[key]
|
||||
if entry == nil {
|
||||
entry = &keyedMutexEntry{}
|
||||
k.locks[key] = entry
|
||||
}
|
||||
entry.refs++
|
||||
k.mu.Unlock()
|
||||
|
||||
if !entry.mu.TryLock() {
|
||||
k.release(key, entry)
|
||||
return nil, false
|
||||
}
|
||||
return func() {
|
||||
entry.mu.Unlock()
|
||||
k.release(key, entry)
|
||||
}, true
|
||||
}
|
||||
|
||||
// release 归还一次引用,无人持有时回收条目。
|
||||
func (k *keyedMutex) release(key string, entry *keyedMutexEntry) {
|
||||
k.mu.Lock()
|
||||
entry.refs--
|
||||
if entry.refs == 0 {
|
||||
delete(k.locks, key)
|
||||
}
|
||||
k.mu.Unlock()
|
||||
}
|
||||
|
||||
// readerAudioLockWait 单条音轨的写锁最多等多久;等不到就降级成纯透传。
|
||||
const readerAudioLockWait = 500 * time.Millisecond
|
||||
|
||||
// handleCachedReaderAudio 音频直链的统一入口。
|
||||
//
|
||||
// 三条路径,按代价从低到高:
|
||||
// 四条路径,按代价从低到高:
|
||||
// 1. 命中完整缓存 → http.ServeContent 按 Range 下发(拖动、续播都读本地);
|
||||
// 2. 冷启动首播 → 边下边发给客户端,同时落盘(DownloadRemoteAudioStreaming)。
|
||||
// 旧实现是「整段下完再下发」,慢链路上用户要对着转圈等整章 3–5MB,
|
||||
// 把「偶发失败」换成了「必然慢」;直通后几百毫秒就能出声,落盘照旧;
|
||||
// 3. 已有 .part 分片 → 走整段下载:分片意味着上次已经下到一半,同步续传
|
||||
// 剩余字节比重新直通更省流量,下完再一次性下发。
|
||||
// 2. 带 Range 的冷缓存 → 区间直通(serveReaderAudioRange):客户端要哪一段就
|
||||
// 从上游边下边发哪一段,同时补全分片。**播放器嗅探完容器一定会带 Range 重开
|
||||
// 一次**(media3 的固有行为),所以这条才是真正的起播路径。旧实现要求整章
|
||||
// 下完才能按区间下发,每章起播都要等 1–4MB 跨境回源,实测 1.5–35 秒;
|
||||
// 3. 冷启动整段请求 → 边下边发给客户端,同时落盘(streamReaderAudioInto):
|
||||
// 播放器嗅探容器的那次 200 请求,几百毫秒就能出声,落盘照旧;
|
||||
// 4. 已有 .part 分片的整段请求 → 走整段下载:分片意味着上次已经下到一半,
|
||||
// 同步续传剩余字节比重新直通更省流量,下完再一次性下发。
|
||||
//
|
||||
// 同一条音轨同时只允许一个写入者。等不到写锁的请求降级成纯透传(不排队、不
|
||||
// 落盘):首播直通会一直持锁到客户端读完整章,让它排队等于让播放器的重试干等。
|
||||
func handleCachedReaderAudio(
|
||||
c *gin.Context,
|
||||
svc *service.Container,
|
||||
@@ -1083,15 +1145,36 @@ func handleCachedReaderAudio(
|
||||
serveCachedReaderAudio(c, f, info, rawURL)
|
||||
return
|
||||
}
|
||||
cacheKey := svc.Reader.RemoteAudioCachePath(bookID, rawURL)
|
||||
if cacheKey == "" {
|
||||
c.String(http.StatusBadGateway, "音频缓存不可用")
|
||||
return
|
||||
}
|
||||
rangeHeader := strings.TrimSpace(c.GetHeader("Range"))
|
||||
unlock, locked := readerAudioStreamLocks.LockWait(cacheKey, readerAudioLockWait)
|
||||
if locked {
|
||||
defer unlock()
|
||||
// 排队期间前一个请求可能已经把整章下完:直接下发本地文件,不重复回源。
|
||||
if f, info, ok := svc.Reader.OpenCachedRemoteAudio(bookID, rawURL); ok {
|
||||
defer f.Close()
|
||||
serveCachedReaderAudio(c, f, info, rawURL)
|
||||
return
|
||||
}
|
||||
}
|
||||
if rangeHeader != "" && serveReaderAudioRange(c, svc, book, bookID, rawURL, headers, rangeHeader, locked) {
|
||||
return
|
||||
}
|
||||
if !locked {
|
||||
proxyReaderAudio(c, svc, book, bookID, rawURL, headers, rangeHeader)
|
||||
return
|
||||
}
|
||||
// 播放器拖进度时会带 Range。已缓存的文件才谈得上 Range;未缓存时直通
|
||||
// 只能从 0 发,否则客户端拿到的区间与它请求的对不上。
|
||||
rangeHeader := strings.TrimSpace(c.GetHeader("Range"))
|
||||
canStream := c.Request.Method == http.MethodGet &&
|
||||
rangeHeader == "" &&
|
||||
!svc.Reader.HasPartialRemoteAudio(bookID, rawURL)
|
||||
|
||||
if canStream {
|
||||
handleStreamingReaderAudio(c, svc, book, bookID, rawURL, headers)
|
||||
streamReaderAudioInto(c, svc, book, bookID, rawURL, headers)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1117,33 +1200,285 @@ func handleCachedReaderAudio(
|
||||
serveCachedReaderAudio(c, f, info, rawURL)
|
||||
}
|
||||
|
||||
// handleStreamingReaderAudio 首播直通:一边落盘一边把字节写给客户端。
|
||||
// serveReaderAudioRange 区间直通:把客户端请求的那一段从上游边下边发。
|
||||
//
|
||||
// persist 表示调用方已拿到写锁,可以顺手把字节补进分片(落盘后下次拖动/重播
|
||||
// 直接读本地)。返回 false 表示这次没法按区间处理(多区间、后缀区间、上游不认
|
||||
// Range 且总长未知等),调用方应退回整段路径。
|
||||
func serveReaderAudioRange(
|
||||
c *gin.Context,
|
||||
svc *service.Container,
|
||||
book *model.ReaderBook,
|
||||
bookID, rawURL string,
|
||||
headers map[string]string,
|
||||
rangeHeader string,
|
||||
persist bool,
|
||||
) bool {
|
||||
start, end, ok := parseReaderAudioRange(rangeHeader)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// 分片里已经躺着 [0, partLen) 的字节:起点在分片内就先把这段发出去,再从
|
||||
// partLen 处回源续下。起点比分片还靠后说明中间有空洞,拼不上,只能从起点
|
||||
// 重新取,并且不落盘(否则分片会被写成带洞的文件)。
|
||||
var partFile *os.File
|
||||
partLen := int64(0)
|
||||
upstreamStart := start
|
||||
if f, size, okPart := svc.Reader.PartialRemoteAudio(bookID, rawURL); okPart {
|
||||
partFile = f
|
||||
partLen = size
|
||||
if partLen > start {
|
||||
upstreamStart = partLen
|
||||
}
|
||||
}
|
||||
// 收尾时关掉分片句柄:Windows 下「文件还开着就 rename」会被共享冲突挡住,
|
||||
// 提升分片为正式缓存之前必须先关。
|
||||
defer func() {
|
||||
if partFile != nil {
|
||||
_ = partFile.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
out, err := fetchReaderMediaResilient(c.Request.Context(), svc, book, rawURL,
|
||||
fmt.Sprintf("bytes=%d-", upstreamStart), headers, false)
|
||||
if err != nil || out == nil || out.resp == nil {
|
||||
if svc.Log != nil && err != nil {
|
||||
svc.Log.Warn("reader 音频区间直通失败", zap.String("book", bookID), zap.Error(err))
|
||||
}
|
||||
return false
|
||||
}
|
||||
resp := out.resp
|
||||
|
||||
total := int64(-1)
|
||||
switch resp.StatusCode {
|
||||
case http.StatusPartialContent:
|
||||
crStart, crTotal, crOK := reader.ParseContentRange(resp.Header.Get("Content-Range"))
|
||||
if !crOK || crTotal <= 0 || crStart != upstreamStart {
|
||||
drainReaderMediaBody(resp)
|
||||
return false
|
||||
}
|
||||
total = crTotal
|
||||
case http.StatusOK:
|
||||
// 上游忽略了 Range,整段从头给:把前缀读掉丢弃,后面的字节与分片仍然
|
||||
// 对得上(前缀正好也是 [0, upstreamStart)),所以落盘可以照旧。
|
||||
if resp.ContentLength <= 0 {
|
||||
drainReaderMediaBody(resp)
|
||||
return false
|
||||
}
|
||||
if upstreamStart > 0 {
|
||||
if _, copyErr := io.CopyN(io.Discard, resp.Body, upstreamStart); copyErr != nil {
|
||||
drainReaderMediaBody(resp)
|
||||
return false
|
||||
}
|
||||
}
|
||||
total = resp.ContentLength
|
||||
case http.StatusRequestedRangeNotSatisfiable:
|
||||
// 分片比对象还长(对象换过):丢掉分片走整段路径重来。
|
||||
drainReaderMediaBody(resp)
|
||||
return false
|
||||
default:
|
||||
drainReaderMediaBody(resp)
|
||||
return false
|
||||
}
|
||||
if total <= 0 || start >= total {
|
||||
drainReaderMediaBody(resp)
|
||||
if total > 0 {
|
||||
c.Header("Content-Range", fmt.Sprintf("bytes */%d", total))
|
||||
c.Status(http.StatusRequestedRangeNotSatisfiable)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
serveEnd := end
|
||||
if serveEnd < 0 || serveEnd >= total {
|
||||
serveEnd = total - 1
|
||||
}
|
||||
wantLen := serveEnd - start + 1
|
||||
prefixLen := int64(0)
|
||||
if partFile != nil && partLen > start {
|
||||
prefixLen = partLen - start
|
||||
if prefixLen > wantLen {
|
||||
prefixLen = wantLen
|
||||
}
|
||||
}
|
||||
|
||||
// 响应头必须一次写全:播放器据此决定后面要不要等更多字节。
|
||||
contentType := readerAudioContentType(rawURL)
|
||||
if upstreamType := strings.TrimSpace(resp.Header.Get("Content-Type")); strings.HasPrefix(strings.ToLower(upstreamType), "audio/") {
|
||||
contentType = upstreamType
|
||||
}
|
||||
writer := c.Writer
|
||||
writer.Header().Set("Content-Type", contentType)
|
||||
writer.Header().Set("Accept-Ranges", "bytes")
|
||||
writer.Header().Set("Cache-Control", "private, max-age=604800")
|
||||
writer.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, serveEnd, total))
|
||||
writer.Header().Set("Content-Length", strconv.FormatInt(wantLen, 10))
|
||||
writer.WriteHeader(http.StatusPartialContent)
|
||||
writer.Flush()
|
||||
|
||||
written := int64(0)
|
||||
if prefixLen > 0 {
|
||||
n, copyErr := io.CopyN(writer, io.NewSectionReader(partFile, start, prefixLen), prefixLen)
|
||||
written += n
|
||||
if copyErr != nil {
|
||||
drainReaderMediaBody(resp)
|
||||
if svc.Log != nil {
|
||||
svc.Log.Warn("reader 音频区间下发失败", zap.String("book", bookID), zap.Error(copyErr))
|
||||
}
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
}
|
||||
// 前缀发完就不需要读句柄了。必须先关掉:Windows 下文件还开着时 rename
|
||||
// 会被共享冲突挡住,下面的「提升分片为正式缓存」就会静默失败。
|
||||
_ = partFile.Close()
|
||||
partFile = nil
|
||||
if written >= wantLen {
|
||||
// 客户端要的区间完全落在本地分片里:这一轮不必再回源。
|
||||
drainReaderMediaBody(resp)
|
||||
return true
|
||||
}
|
||||
|
||||
// 剩余字节边下边发;拿得到写锁时同时补进分片,落满后转成完整缓存。
|
||||
var partWriter *os.File
|
||||
if persist && partLen >= start {
|
||||
if f, size, appendErr := svc.Reader.AppendRemoteAudioPartial(bookID, rawURL); appendErr == nil {
|
||||
if size == partLen {
|
||||
partWriter = f
|
||||
} else {
|
||||
_ = f.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
sink := io.Writer(writer)
|
||||
if partWriter != nil {
|
||||
sink = io.MultiWriter(writer, partWriter)
|
||||
}
|
||||
copied, copyErr := io.CopyBuffer(sink, io.LimitReader(resp.Body, wantLen-written), make([]byte, 32<<10))
|
||||
written += copied
|
||||
_ = resp.Body.Close()
|
||||
if partWriter != nil {
|
||||
_ = partWriter.Close()
|
||||
if copyErr == nil {
|
||||
// 分片正好落满整章时提升为正式缓存(长度对不上时内部会拒绝)。
|
||||
svc.Reader.PromoteRemoteAudioPartial(bookID, rawURL, total)
|
||||
}
|
||||
}
|
||||
if copyErr == nil && written >= wantLen {
|
||||
return true
|
||||
}
|
||||
if svc.Log != nil {
|
||||
svc.Log.Warn("reader 音频区间中断",
|
||||
zap.String("book", bookID),
|
||||
zap.Int64("written", written),
|
||||
zap.Int64("want", wantLen),
|
||||
zap.Error(copyErr))
|
||||
}
|
||||
// 已经发出去的字节收不回来:只能中止响应让播放器判「不完整」,分片留给下次续传。
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
|
||||
// proxyReaderAudio 纯透传:不落盘、不排队,把上游字节原样转给播放器。
|
||||
//
|
||||
// 用在「同一条音轨已经有别的请求在写分片」时:旧实现让第二个请求排队等前一个
|
||||
// 下载结束,而首播直通会一直持锁到客户端读完整章,播放器重试因此可能干等几十秒。
|
||||
func proxyReaderAudio(
|
||||
c *gin.Context,
|
||||
svc *service.Container,
|
||||
book *model.ReaderBook,
|
||||
bookID, rawURL string,
|
||||
headers map[string]string,
|
||||
rangeHeader string,
|
||||
) {
|
||||
out, err := fetchReaderMediaResilient(c.Request.Context(), svc, book, rawURL,
|
||||
rangeHeader, headers, rangeHeader == "")
|
||||
if err != nil || out == nil || out.resp == nil {
|
||||
if svc.Log != nil && err != nil {
|
||||
svc.Log.Warn("reader 音频透传失败", zap.String("book", bookID), zap.Error(err))
|
||||
}
|
||||
c.String(http.StatusBadGateway, "音频拉取失败")
|
||||
return
|
||||
}
|
||||
resp := out.resp
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
contentType := readerAudioContentType(rawURL)
|
||||
if upstreamType := strings.TrimSpace(resp.Header.Get("Content-Type")); strings.HasPrefix(strings.ToLower(upstreamType), "audio/") {
|
||||
contentType = upstreamType
|
||||
}
|
||||
writer := c.Writer
|
||||
writer.Header().Set("Content-Type", contentType)
|
||||
writer.Header().Set("Accept-Ranges", "bytes")
|
||||
writer.Header().Set("Cache-Control", "private, max-age=604800")
|
||||
if v := resp.Header.Get("Content-Range"); v != "" {
|
||||
writer.Header().Set("Content-Range", v)
|
||||
}
|
||||
if resp.ContentLength > 0 {
|
||||
writer.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
|
||||
}
|
||||
writer.WriteHeader(resp.StatusCode)
|
||||
if len(out.head) > 0 {
|
||||
if _, writeErr := writer.Write(out.head); writeErr != nil {
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
}
|
||||
streamReaderMediaBody(writer, resp.Body)
|
||||
}
|
||||
|
||||
// drainReaderMediaBody 丢掉剩余响应体并关闭连接(让上游把连接收回去)。
|
||||
func drainReaderMediaBody(resp *http.Response) {
|
||||
if resp == nil || resp.Body == nil {
|
||||
return
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
// parseReaderAudioRange 解析客户端的单区间 Range 头("bytes=0-" / "bytes=100-199")。
|
||||
//
|
||||
// 多区间与后缀区间("bytes=-500" 要总长才能换算)不在这里处理:播放器不会这么
|
||||
// 发,真遇到就退回磁盘路径,由 http.ServeContent 负责。
|
||||
func parseReaderAudioRange(header string) (start, end int64, ok bool) {
|
||||
header = strings.TrimSpace(header)
|
||||
const prefix = "bytes="
|
||||
if len(header) <= len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) {
|
||||
return 0, 0, false
|
||||
}
|
||||
spec := strings.TrimSpace(header[len(prefix):])
|
||||
if spec == "" || strings.Contains(spec, ",") {
|
||||
return 0, 0, false
|
||||
}
|
||||
dash := strings.IndexByte(spec, '-')
|
||||
if dash <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
start, err := strconv.ParseInt(strings.TrimSpace(spec[:dash]), 10, 64)
|
||||
if err != nil || start < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
endPart := strings.TrimSpace(spec[dash+1:])
|
||||
if endPart == "" {
|
||||
return start, -1, true
|
||||
}
|
||||
end, err = strconv.ParseInt(endPart, 10, 64)
|
||||
if err != nil || end < start {
|
||||
return 0, 0, false
|
||||
}
|
||||
return start, end, true
|
||||
}
|
||||
|
||||
// streamReaderAudioInto 首播直通:一边落盘一边把字节写给客户端(调用方已持写锁)。
|
||||
//
|
||||
// 直接写 c.Writer 而不是先落盘再 ServeContent,是为了让第一个字节尽早到达。
|
||||
// 下载失败时客户端已经收到部分字节,只能中止响应(见下面的 panic),
|
||||
// 不能改成 502 —— 状态码早就发出去了。
|
||||
func handleStreamingReaderAudio(
|
||||
func streamReaderAudioInto(
|
||||
c *gin.Context,
|
||||
svc *service.Container,
|
||||
book *model.ReaderBook,
|
||||
bookID, rawURL string,
|
||||
headers map[string]string,
|
||||
) {
|
||||
key := svc.Reader.RemoteAudioCachePath(bookID, rawURL)
|
||||
if key == "" {
|
||||
c.String(http.StatusBadGateway, "音频缓存不可用")
|
||||
return
|
||||
}
|
||||
unlock := readerAudioStreamLocks.Lock(key)
|
||||
defer unlock()
|
||||
|
||||
// 排队期间前一个请求可能已经把整章下完:直接下发本地文件,不重复回源。
|
||||
if f, info, ok := svc.Reader.OpenCachedRemoteAudio(bookID, rawURL); ok {
|
||||
defer f.Close()
|
||||
serveCachedReaderAudio(c, f, info, rawURL)
|
||||
return
|
||||
}
|
||||
|
||||
writer := c.Writer
|
||||
// started 表示响应头是否已发出。没发出时失败还能改成 502;已经发出就只能
|
||||
// 中止连接——状态码收不回来了。
|
||||
|
||||
@@ -30,10 +30,12 @@ import (
|
||||
//
|
||||
// include_media=0 只返回历史行本身。客户端判断「继续播放」指向哪一集时只
|
||||
// 需要 media_id/position_ms;带上整条 media 会让 300 条的响应从 ~40KB 涨到
|
||||
// ~420KB,手机链路上光传输就要十几秒,详情页的续播解析会被它拖住。
|
||||
// ~420KB。更重要的是服务端不再 hydrate:不查整条 media、也不为挂载条目
|
||||
// 逐条请求上游 Emby(见 PlaybackService.RecentHistoryWithoutMedia)。
|
||||
func historyListHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
userID := toString(uid)
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 50
|
||||
@@ -44,19 +46,27 @@ func historyListHandler(svc *service.Container) gin.HandlerFunc {
|
||||
includeMedia = parsed
|
||||
}
|
||||
}
|
||||
items, err := svc.Playback.RecentHistory(c.Request.Context(), toString(uid), limit)
|
||||
ctx := c.Request.Context()
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
if !includeMedia {
|
||||
// 可见性由服务层用窄列查询判定,handler 不再需要 media。
|
||||
items, err := svc.Playback.RecentHistoryWithoutMedia(ctx, userID, limit, visibility)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, items)
|
||||
return
|
||||
}
|
||||
items, err := svc.Playback.RecentHistory(ctx, userID, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
filtered := make([]service.HistoryItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
// 可见性仍然按 media 判断,只是决定要不要把它写进响应。
|
||||
// 可见性按 media 判断:媒体已删除/远端取不到时仍然保留这条历史。
|
||||
if item.Media == nil || visibility.Allows(item.Media) {
|
||||
if !includeMedia {
|
||||
item.Media = nil
|
||||
}
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,3 +175,77 @@ func TestHistoryListCanOmitEmbeddedMedia(t *testing.T) {
|
||||
t.Fatalf("include_media=0 must not embed media: %s", lean.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoryListIncludeMediaFalseSkipsHydration 保证 include_media=0 时服务端
|
||||
// 真的不 hydrate media:响应里只有历史行(客户端按 media_id 匹配续播目标)。
|
||||
func TestHistoryListIncludeMediaFalseSkipsHydration(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.PlaybackHistory{}, &model.Media{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "media-1"},
|
||||
Title: "影片",
|
||||
Path: "/media/1.mkv",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now()
|
||||
for i, mediaID := range []string{"media-1", "media-deleted"} {
|
||||
if err := db.Create(&model.PlaybackHistory{
|
||||
Base: model.Base{ID: "history-" + mediaID},
|
||||
UserID: "user-1",
|
||||
MediaID: mediaID,
|
||||
PositionMs: 30_000,
|
||||
DurationMs: 120_000,
|
||||
WatchedAt: now.Add(-time.Duration(i) * time.Minute),
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
repos := repository.New(db)
|
||||
svc := &service.Container{
|
||||
Log: zap.NewNop(),
|
||||
Repo: repos,
|
||||
Playback: service.NewPlaybackService(zap.NewNop(), repos),
|
||||
}
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set(middleware.CtxUserID, "user-1")
|
||||
c.Next()
|
||||
})
|
||||
router.GET("/watch-history", historyListHandler(svc))
|
||||
|
||||
for _, path := range []string{
|
||||
"/watch-history?limit=10&include_media=0",
|
||||
"/watch-history?limit=10&include_media=false",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s status = %d, body = %s", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
var rows []map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &rows); err != nil {
|
||||
t.Fatalf("%s decode: %v", path, err)
|
||||
}
|
||||
// 媒体已删除的历史也保留:客户端只按 media_id 判断有没有记录。
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("%s rows = %d, want 2 (%s)", path, len(rows), rec.Body.String())
|
||||
}
|
||||
for _, row := range rows {
|
||||
if _, ok := row["media"]; ok {
|
||||
t.Fatalf("%s should not embed media: %v", path, row)
|
||||
}
|
||||
if row["media_id"] == "" || row["media_id"] == nil {
|
||||
t.Fatalf("%s missing media_id: %v", path, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +179,22 @@ func (s *MediaService) libraryCardsObjectKey(libraryID string, visibility MediaV
|
||||
return s.seriesCardsCacheKey(libraryID, visibility) + ":obj"
|
||||
}
|
||||
|
||||
// 版本分组对象的缓存 key。
|
||||
//
|
||||
// 详情页的「同片多版本」以前每次都现算:条目没有 series_id / tmdb / douban /
|
||||
// thetvdb 时走不到收窄分支,会直接把这个库最多 5000 行整行读出来再逐条算版本
|
||||
// 键(实测 5000 行的库单次 ~180ms,且没有缓存)。同一个版本组的结果只随扫描/
|
||||
// 刮削变化,所以按「合并库集合 + 版本组键」缓存,配合 media: 前缀一起被
|
||||
// invalidateMediaCache 清掉。
|
||||
func (s *MediaService) versionSiblingsCacheKey(libraryIDs []string, groupKey string) string {
|
||||
libs := append([]string(nil), libraryIDs...)
|
||||
sort.Strings(libs)
|
||||
return "media:obj:version-siblings:" + hashObjectCacheKey([]string{
|
||||
strings.Join(libs, ","),
|
||||
groupKey,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *MediaService) invalidateMediaCache(ctx context.Context) {
|
||||
if s != nil && s.cache != nil {
|
||||
s.cache.DeletePrefix(ctx, "media:")
|
||||
|
||||
@@ -255,6 +255,35 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
|
||||
if len(libraryIDs) == 0 {
|
||||
libraryIDs = []string{media.LibraryID}
|
||||
}
|
||||
// 同一个版本组的结果对组内任一成员都成立,而且只随扫描/刮削变化:缓存它
|
||||
// 才能让「逐部点开详情」的浏览不再每次都重扫该库最多 5000 行。
|
||||
cacheKey := s.versionSiblingsCacheKey(libraryIDs, key)
|
||||
if s.cache != nil {
|
||||
if obj, ok := s.cache.GetObject(cacheKey); ok {
|
||||
if cached, ok := obj.([]model.Media); ok {
|
||||
// GetObject 返回的对象此后视为不可变,调用方(详情页)可能重排/追加
|
||||
// 切片,所以拷贝一份再交出去。
|
||||
out := make([]model.Media, len(cached))
|
||||
copy(out, cached)
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
matched, err := s.loadVersionSiblings(ctx, media, libraryIDs, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.cache != nil {
|
||||
s.cache.SetObject(cacheKey, matched, s.derivedReadCacheTTL())
|
||||
}
|
||||
out := make([]model.Media, len(matched))
|
||||
copy(out, matched)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// loadVersionSiblings 是版本分组的慢路径(可能读全库),结果由
|
||||
// listVersionSiblings 缓存。
|
||||
func (s *MediaService) loadVersionSiblings(ctx context.Context, media *model.Media, libraryIDs []string, key string) ([]model.Media, error) {
|
||||
filter := repository.MediaQueryFilter{IncludeNSFW: true}
|
||||
// 成人条目按番号分组,而番号常常只存在于路径/标题里:本地 NFO 来源的分片
|
||||
// 没有 douban_id/thetvdb_id(MetaTube 刮削出来的那几个才有)。若仍按外部
|
||||
@@ -263,6 +292,7 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
|
||||
// 直接在该库范围内比对版本键。未刮削但文件名带番号的分片同理:同一部片的
|
||||
// 各分片元数据来源不一致(刮削 vs pending),收窄后标题也对不上。
|
||||
var candidates []model.Media
|
||||
var err error
|
||||
narrowed := false
|
||||
hasAdultCode := mediaAdultGroupCode(*media) != "" ||
|
||||
canonicalAdultGroupCode(AdultCodeFromMediaPath(media.Path)) != ""
|
||||
|
||||
@@ -787,3 +787,76 @@ func TestMediaVersionGroupKeyDistinguishesHalfEpisode(t *testing.T) {
|
||||
t.Fatalf("plain episode key = %q, want suffix 1:11", plainKey)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetMediaItemCachesVersionSiblings 覆盖详情页最贵的一次查询:
|
||||
// 条目没有外部 ID 时会整库读最多 5000 行来找同片版本,而结果只随扫描/刮削
|
||||
// 变化。这里断言第二次打开详情走缓存(删掉候选行后仍能拿到同一组版本),
|
||||
// 并且调用方修改返回切片不会污染缓存。
|
||||
func TestGetMediaItemCachesVersionSiblings(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "电影", Path: "/movies", Type: "movie"}
|
||||
if err := db.Create(&lib).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now()
|
||||
rows := []model.Media{
|
||||
{
|
||||
Base: model.Base{ID: "v-1080", CreatedAt: now, UpdatedAt: now},
|
||||
LibraryID: lib.ID,
|
||||
Title: "影片 2024 1080p",
|
||||
Path: "/movies/影片.2024.1080p.mkv",
|
||||
Width: 1920,
|
||||
Height: 1080,
|
||||
Year: 2024,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "v-2160", CreatedAt: now.Add(time.Hour), UpdatedAt: now.Add(time.Hour)},
|
||||
LibraryID: lib.ID,
|
||||
Title: "影片 2024 2160p",
|
||||
Path: "/movies/影片.2024.2160p.mkv",
|
||||
Width: 3840,
|
||||
Height: 2160,
|
||||
Year: 2024,
|
||||
},
|
||||
}
|
||||
if err := db.Create(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).
|
||||
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
|
||||
t.Cleanup(func() { _ = svc.cache.Close() })
|
||||
|
||||
item, err := svc.GetMediaItem(t.Context(), "v-1080")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(item.Versions) != 2 {
|
||||
t.Fatalf("versions = %d, want 2: %#v", len(item.Versions), item.Versions)
|
||||
}
|
||||
|
||||
// 把数据删掉:如果第二次还去查库,就只会剩下代表条目自己。
|
||||
if err := db.Where("id = ?", "v-2160").Delete(&model.Media{}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := svc.GetMediaItem(t.Context(), "v-1080")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(second.Versions) != 2 {
|
||||
t.Fatalf("cached versions = %d, want 2", len(second.Versions))
|
||||
}
|
||||
|
||||
// 调用方乱改返回的切片不能影响缓存内容。
|
||||
second.Versions[0] = model.Media{Base: model.Base{ID: "hacked"}}
|
||||
third, err := svc.GetMediaItem(t.Context(), "v-1080")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, v := range third.Versions {
|
||||
if v.ID == "hacked" {
|
||||
t.Fatal("caller mutation leaked into the cached version list")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,79 @@ func (p *PlaybackService) RecentHistory(ctx context.Context, userID string, limi
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// RecentHistoryWithoutMedia 返回最近观看记录,但完全不 join media。
|
||||
//
|
||||
// 详情页解析「继续播放」只需要 media_id/position_ms,带整条 media 会让 300 条
|
||||
// 响应大一个数量级。旧实现是先用 RecentHistory 把 media 全部查出来(挂载条目
|
||||
// 还会逐条串行请求上游 Emby),最后才在 handler 里把 media 置空 —— 也就是
|
||||
// 响应变小了,服务端一点没省。这里直接跳过 hydrate:
|
||||
//
|
||||
// - 本地条目:用 (id, library_id, nsfw) 窄列查询判断可见性,不读 overview /
|
||||
// path / 海报这些大字段;
|
||||
// - 挂载条目:按挂载媒体库的 ACL 判断,不再发上游请求(上游慢或挂了时,
|
||||
// 详情页的续播解析不会被它拖住);
|
||||
// - 记录还在但 media 已删除:保留。与旧行为一致 —— 旧实现 hydrate 失败时
|
||||
// 同样保留这条历史,客户端只拿 media_id 判断续播目标。
|
||||
func (p *PlaybackService) RecentHistoryWithoutMedia(ctx context.Context, userID string, limit int, visibility MediaVisibility) ([]HistoryItem, error) {
|
||||
rows, err := p.repo.History.ListByUser(ctx, userID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return []HistoryItem{}, nil
|
||||
}
|
||||
|
||||
mediaIDs := make([]string, 0, len(rows))
|
||||
for i := range rows {
|
||||
if rows[i].MediaID != "" {
|
||||
mediaIDs = append(mediaIDs, rows[i].MediaID)
|
||||
}
|
||||
}
|
||||
// 只取可见性判定真正需要的三列。Select 到 model.Media 上是本仓库既有写法
|
||||
// (见 database/backfillLibraryRootMedia),未选中的字段保持零值。
|
||||
visibleByID := make(map[string]bool, len(mediaIDs))
|
||||
if len(mediaIDs) > 0 {
|
||||
var lean []model.Media
|
||||
if err := p.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
||||
Select("id", "library_id", "nsfw").
|
||||
Where("id IN ?", mediaIDs).
|
||||
Find(&lean).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range lean {
|
||||
visibleByID[lean[i].ID] = visibility.Allows(&lean[i])
|
||||
}
|
||||
}
|
||||
|
||||
// 挂载库 ACL 每个挂载只解析一次:ResolveMount 只读本地表,不发网络请求。
|
||||
mountAllowed := make(map[string]bool, 2)
|
||||
items := make([]HistoryItem, 0, len(rows))
|
||||
for i := range rows {
|
||||
mediaID := rows[i].MediaID
|
||||
if allowed, known := visibleByID[mediaID]; known {
|
||||
if allowed {
|
||||
items = append(items, HistoryItem{PlaybackHistory: rows[i]})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if p.remote != nil && IsEmbyRemoteID(mediaID) {
|
||||
mountID, _, _ := DecodeEmbyRemoteID(mediaID)
|
||||
allowed, resolved := mountAllowed[mountID]
|
||||
if !resolved {
|
||||
mount, _, resolveErr := p.remote.ResolveMount(ctx, mountID)
|
||||
// 挂载解析不出来时无法判定,沿用旧行为保留这条历史。
|
||||
allowed = resolveErr != nil || mount == nil || EmbyMountLibraryAllowed(visibility, mount)
|
||||
mountAllowed[mountID] = allowed
|
||||
}
|
||||
if !allowed {
|
||||
continue
|
||||
}
|
||||
}
|
||||
items = append(items, HistoryItem{PlaybackHistory: rows[i]})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// ─── Favourites ─────────────────────────────────────────────────────────────
|
||||
|
||||
// ToggleFavourite flips the favourite flag and reports the new state.
|
||||
|
||||
@@ -113,3 +113,65 @@ func TestPlaybackProgressCompletedThreshold(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecentHistoryWithoutMediaSkipsHydration 覆盖详情页用的轻量历史路径:
|
||||
// 不返回 media,但可见性过滤必须与带 media 的版本一致。
|
||||
func TestRecentHistoryWithoutMediaSkipsHydration(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.PlaybackHistory{}, &model.Media{}, &model.Library{})
|
||||
repos := repository.New(db)
|
||||
svc := NewPlaybackService(zap.NewNop(), repos)
|
||||
|
||||
seed := func(id, libraryID string, nsfw bool) {
|
||||
t.Helper()
|
||||
m := model.Media{
|
||||
Base: model.Base{ID: id},
|
||||
LibraryID: libraryID,
|
||||
Title: "标题 " + id,
|
||||
Path: "/media/" + id + ".mkv",
|
||||
NSFW: nsfw,
|
||||
}
|
||||
if err := db.Create(&m).Error; err != nil {
|
||||
t.Fatalf("seed media %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
seed("visible", "lib-ok", false)
|
||||
seed("adult", "lib-ok", true)
|
||||
seed("hidden-lib", "lib-hidden", false)
|
||||
|
||||
now := time.Now()
|
||||
for i, mediaID := range []string{"visible", "adult", "hidden-lib", "gone"} {
|
||||
if err := db.Create(&model.PlaybackHistory{
|
||||
Base: model.Base{ID: "hist-" + mediaID},
|
||||
UserID: "user-1",
|
||||
MediaID: mediaID,
|
||||
PositionMs: 10_000,
|
||||
DurationMs: 60_000,
|
||||
WatchedAt: now.Add(-time.Duration(i) * time.Minute),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed history %s: %v", mediaID, err)
|
||||
}
|
||||
}
|
||||
|
||||
items, err := svc.RecentHistoryWithoutMedia(context.Background(), "user-1", 50, MediaVisibility{
|
||||
HiddenLibraryIDs: []string{"lib-hidden"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RecentHistoryWithoutMedia: %v", err)
|
||||
}
|
||||
got := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.Media != nil {
|
||||
t.Fatalf("expected no media hydration, got %#v", item.Media)
|
||||
}
|
||||
got = append(got, item.MediaID)
|
||||
}
|
||||
want := []string{"visible", "gone"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("media ids = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("media ids = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -647,3 +647,84 @@ func (s *ReaderService) pruneAudioMediaCache() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 区间直通:首播不必等整章下完 ───────────────────────────────────────────
|
||||
//
|
||||
// 播放器(media3)取一条 m4a 时会开两次请求:先整段打开嗅探容器,再带
|
||||
// Range 从目标位置重开。过去「带 Range 的冷缓存请求」只能整章下完再按区间
|
||||
// 下发,于是每一章的起播都要等 1–4MB 的跨境回源(实测 1.5–35 秒)。下面这
|
||||
// 三个方法让 handler 能把客户端要的那一段边下边发,同时把字节补进分片。
|
||||
|
||||
// PartialRemoteAudio 打开未完成分片(只读),返回文件句柄与当前长度。
|
||||
//
|
||||
// 播放器重开同一条音轨时,分片里已经躺着 [0, size) 的字节:区间直通先把这段
|
||||
// 发出去,再从 size 处回源续下,不必为了一个字节重下整章。
|
||||
func (s *ReaderService) PartialRemoteAudio(bookID, mediaURL string) (*os.File, int64, bool) {
|
||||
path := s.RemoteAudioCachePath(bookID, mediaURL)
|
||||
if path == "" {
|
||||
return nil, 0, false
|
||||
}
|
||||
f, err := os.Open(path + audioPartialSuffix)
|
||||
if err != nil {
|
||||
return nil, 0, false
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil || !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > audioMediaObjectMaxBytes {
|
||||
_ = f.Close()
|
||||
return nil, 0, false
|
||||
}
|
||||
return f, info.Size(), true
|
||||
}
|
||||
|
||||
// AppendRemoteAudioPartial 以追加方式打开分片,返回打开那一刻的长度。
|
||||
//
|
||||
// 调用方必须核对「返回长度 == 它算出的断点」:对不上说明分片已被别的写入者
|
||||
// 推进过,此时继续写会把字节落到错误位置,宁可放弃落盘只做纯透传。
|
||||
func (s *ReaderService) AppendRemoteAudioPartial(bookID, mediaURL string) (*os.File, int64, error) {
|
||||
path := s.RemoteAudioCachePath(bookID, mediaURL)
|
||||
if path == "" {
|
||||
return nil, 0, errors.New("音频缓存目录未配置")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return nil, 0, fmt.Errorf("创建音频缓存目录失败: %w", err)
|
||||
}
|
||||
f, err := os.OpenFile(path+audioPartialSuffix, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o640)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return nil, 0, err
|
||||
}
|
||||
return f, info.Size(), nil
|
||||
}
|
||||
|
||||
// PromoteRemoteAudioPartial 把已经写满的分片转成正式缓存文件。
|
||||
//
|
||||
// 只认「分片长度 == 上游声明的总长」。区间直通是按播放器节奏下发的,中途断开
|
||||
// 时字节数必然不足;把这种分片当完整缓存下发,播放器会表现为「时长正确但后
|
||||
// 半段没声音」。
|
||||
func (s *ReaderService) PromoteRemoteAudioPartial(bookID, mediaURL string, total int64) bool {
|
||||
path := s.RemoteAudioCachePath(bookID, mediaURL)
|
||||
if path == "" || total <= 0 {
|
||||
return false
|
||||
}
|
||||
partPath := path + audioPartialSuffix
|
||||
info, err := os.Stat(partPath)
|
||||
if err != nil || !info.Mode().IsRegular() || info.Size() != total {
|
||||
return false
|
||||
}
|
||||
if err := os.Rename(partPath, path); err != nil {
|
||||
return false
|
||||
}
|
||||
s.pruneAudioMediaCache()
|
||||
return true
|
||||
}
|
||||
|
||||
// ParseContentRange 解析 "bytes 100-199/1234" 的起点与总长度。
|
||||
//
|
||||
// 导出给 handler 用:区间直通要按上游声明的总长自己组装 206 响应头。
|
||||
func ParseContentRange(v string) (start, total int64, ok bool) {
|
||||
return parseContentRange(v)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,18 @@ export const historyAPI = {
|
||||
.get<HistoryItem[]>('/watch-history', { params: { limit } })
|
||||
.then((r) => r.data),
|
||||
|
||||
/**
|
||||
* 只要历史行、不要内嵌的 media。
|
||||
*
|
||||
* 媒体详情页解析「继续播放」只需要 media_id/position_ms;带上 media 会让
|
||||
* 300 条响应大一个数量级,而且服务端还得为每条去查 media(挂载条目逐条
|
||||
* 回源)。服务端已经支持 include_media=0 跳过 hydrate。
|
||||
*/
|
||||
listRows: (limit = 300) =>
|
||||
api
|
||||
.get<HistoryItem[]>('/watch-history', { params: { limit, include_media: false } })
|
||||
.then((r) => r.data),
|
||||
|
||||
stats: () => api.get<HistoryStats>('/watch-history/stats').then((r) => r.data),
|
||||
|
||||
continueWatching: (limit = 10) =>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { LibrarySeriesDetailSection } from './LibrarySeriesDetailSection'
|
||||
import { useLibraryData } from './useLibraryData'
|
||||
import { useLibraryScanStatus } from './useLibraryScanStatus'
|
||||
import { useLibrarySeriesSelection } from './useLibrarySeriesSelection'
|
||||
import { useSeriesEpisodeResume } from './useSeriesEpisodeResume'
|
||||
import { useLibraryAdminActions } from './useLibraryAdminActions'
|
||||
import { usePermission } from '../hooks/usePermission'
|
||||
import { useFavourites } from '../hooks/useFavourites'
|
||||
@@ -214,6 +215,13 @@ export function LibraryPage() {
|
||||
setSelectedSeason,
|
||||
onClearSeriesState: () => setSeriesMetadataEditOpen(false),
|
||||
})
|
||||
// 抽屉里的「继续播放」:在整剧所有集里挑最近看过的一条(代表条目可能是 OVA)。
|
||||
const { resume: seriesResume, progressById: seriesEpisodeProgress } = useSeriesEpisodeResume(
|
||||
selectedSeries?.key ?? '',
|
||||
selectedSeries?.rep ?? null,
|
||||
selectedSeriesAllEpisodes,
|
||||
)
|
||||
|
||||
const selectedSeriesScrapeMedia = selectedSeriesEpisodes.find((media) => !isTheatricalFeature(media))
|
||||
?? selectedSeries?.rep
|
||||
?? null
|
||||
@@ -350,6 +358,8 @@ export function LibraryPage() {
|
||||
selectedSeason={selectedSeason}
|
||||
visibleEpisodes={visibleEpisodes}
|
||||
allEpisodes={selectedSeriesEpisodes}
|
||||
resume={seriesResume}
|
||||
episodeProgress={seriesEpisodeProgress}
|
||||
loadingEpisodes={loadingSeriesEpisodes}
|
||||
playbackFrom={`${location.pathname}${location.search}`}
|
||||
isAdmin={role === 'admin'}
|
||||
|
||||
@@ -8,10 +8,12 @@ import { MediaFavouriteButton } from '../components/MediaFavouriteButton'
|
||||
import type { Media } from '../types'
|
||||
import { isTheatricalFeature, seriesTitle, type SeriesCard } from '../utils/groupSeries'
|
||||
import { episodeNumberValue } from '../utils/episodeNumber'
|
||||
import { canContinuePlaying, continueButtonLabel, type MediaDetailResume } from './mediaDetailResumeModel'
|
||||
|
||||
type LibrarySeriesDetailHeaderProps = {
|
||||
series: SeriesCard
|
||||
allEpisodes: Media[]
|
||||
resume: MediaDetailResume | null
|
||||
playbackFrom: string
|
||||
isAdmin: boolean
|
||||
canFavorite: boolean
|
||||
@@ -32,6 +34,7 @@ type LibrarySeriesDetailHeaderProps = {
|
||||
export function LibrarySeriesDetailHeader({
|
||||
series,
|
||||
allEpisodes,
|
||||
resume,
|
||||
playbackFrom,
|
||||
isAdmin,
|
||||
canFavorite,
|
||||
@@ -51,6 +54,9 @@ export function LibrarySeriesDetailHeader({
|
||||
const tvEpisodes = allEpisodes.filter((media) => !isTheatricalFeature(media))
|
||||
const theatricalCount = allEpisodes.length - tvEpisodes.length
|
||||
const firstEpisode = firstPlayableEpisode(tvEpisodes)
|
||||
// 看过的剧先给「继续播放 · 第N集 · mm:ss」,指向真正看的那一集;
|
||||
// 没有可用进度时保持原来的「从第一集开始播放」。
|
||||
const resumeTarget = resume && canContinuePlaying(resume) ? resume : null
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -90,14 +96,32 @@ export function LibrarySeriesDetailHeader({
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{firstEpisode && (
|
||||
{resumeTarget ? (
|
||||
<>
|
||||
<Link to={`/play/${firstEpisode.id}`} state={{ from: playbackFrom }} className="btn-primary inline-flex">
|
||||
<Link to={`/play/${resumeTarget.mediaId}`} state={{ from: playbackFrom }} className="btn-primary inline-flex">
|
||||
<Play size={16} fill="currentColor" />
|
||||
从第一集开始播放
|
||||
{continueButtonLabel(resumeTarget)}
|
||||
</Link>
|
||||
<ExternalPlayerButton mediaId={firstEpisode.id} label="外部播放器播放" />
|
||||
<Link
|
||||
to={`/play/${resumeTarget.mediaId}?restart=1`}
|
||||
state={{ from: playbackFrom }}
|
||||
className="btn-outline inline-flex"
|
||||
title="忽略续播进度,从 0 开始播放"
|
||||
>
|
||||
从头播放
|
||||
</Link>
|
||||
<ExternalPlayerButton mediaId={resumeTarget.mediaId} label="外部播放器播放" />
|
||||
</>
|
||||
) : (
|
||||
firstEpisode && (
|
||||
<>
|
||||
<Link to={`/play/${firstEpisode.id}`} state={{ from: playbackFrom }} className="btn-primary inline-flex">
|
||||
<Play size={16} fill="currentColor" />
|
||||
从第一集开始播放
|
||||
</Link>
|
||||
<ExternalPlayerButton mediaId={firstEpisode.id} label="外部播放器播放" />
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{canFavorite && (
|
||||
<MediaFavouriteButton
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AnimatePresence, motion } from 'framer-motion'
|
||||
|
||||
import type { Media } from '../types'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
import type { EpisodeProgress, MediaDetailResume } from './mediaDetailResumeModel'
|
||||
import { LibrarySeriesDetailHeader } from './LibrarySeriesDetailHeader'
|
||||
import { LibrarySeriesEpisodes } from './LibrarySeriesEpisodes'
|
||||
|
||||
@@ -16,6 +17,8 @@ type LibrarySeriesDetailSectionProps = {
|
||||
selectedSeason: number | null
|
||||
visibleEpisodes: Media[]
|
||||
allEpisodes: Media[]
|
||||
resume: MediaDetailResume | null
|
||||
episodeProgress: Map<string, EpisodeProgress>
|
||||
loadingEpisodes: boolean
|
||||
playbackFrom: string
|
||||
isAdmin: boolean
|
||||
@@ -42,6 +45,8 @@ export function LibrarySeriesDetailSection({
|
||||
selectedSeason,
|
||||
visibleEpisodes,
|
||||
allEpisodes,
|
||||
resume,
|
||||
episodeProgress,
|
||||
loadingEpisodes,
|
||||
playbackFrom,
|
||||
isAdmin,
|
||||
@@ -73,6 +78,7 @@ export function LibrarySeriesDetailSection({
|
||||
<LibrarySeriesDetailHeader
|
||||
series={selectedSeries}
|
||||
allEpisodes={allEpisodes}
|
||||
resume={resume}
|
||||
playbackFrom={playbackFrom}
|
||||
isAdmin={isAdmin}
|
||||
canFavorite={canFavorite}
|
||||
@@ -95,6 +101,7 @@ export function LibrarySeriesDetailSection({
|
||||
selectedEpisodes={selectedEpisodes}
|
||||
selectedSeason={selectedSeason}
|
||||
visibleEpisodes={visibleEpisodes}
|
||||
progressById={episodeProgress}
|
||||
playbackFrom={playbackFrom}
|
||||
isAdmin={isAdmin}
|
||||
onSeasonChange={onSeasonChange}
|
||||
@@ -111,6 +118,7 @@ type LibrarySeriesEpisodesPanelProps = {
|
||||
selectedEpisodes: SeasonEpisodes[]
|
||||
selectedSeason: number | null
|
||||
visibleEpisodes: Media[]
|
||||
progressById: Map<string, EpisodeProgress>
|
||||
playbackFrom: string
|
||||
isAdmin: boolean
|
||||
onSeasonChange: (season: number) => void
|
||||
@@ -122,6 +130,7 @@ function LibrarySeriesEpisodesPanel({
|
||||
selectedEpisodes,
|
||||
selectedSeason,
|
||||
visibleEpisodes,
|
||||
progressById,
|
||||
playbackFrom,
|
||||
isAdmin,
|
||||
onSeasonChange,
|
||||
@@ -134,6 +143,7 @@ function LibrarySeriesEpisodesPanel({
|
||||
selectedEpisodes={selectedEpisodes}
|
||||
selectedSeason={selectedSeason}
|
||||
visibleEpisodes={visibleEpisodes}
|
||||
progressById={progressById}
|
||||
playbackFrom={playbackFrom}
|
||||
isAdmin={isAdmin}
|
||||
onSeasonChange={onSeasonChange}
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
} from '../utils/groupSeries'
|
||||
import { formatSize } from './libraryPageModel'
|
||||
import { formatEpisodeLabel, formatEpisodeNumber } from '../utils/episodeNumber'
|
||||
import {
|
||||
formatResumeClock,
|
||||
progressPercent,
|
||||
type EpisodeProgress,
|
||||
} from './mediaDetailResumeModel'
|
||||
|
||||
type SeasonGroup = {
|
||||
season: number
|
||||
@@ -24,6 +29,8 @@ type LibrarySeriesEpisodesProps = {
|
||||
selectedSeason: number | null
|
||||
visibleEpisodes: Media[]
|
||||
playbackFrom: string
|
||||
/** 每集(按条目 id)的续播进度,用于卡片上的进度条。 */
|
||||
progressById?: Map<string, EpisodeProgress>
|
||||
isAdmin?: boolean
|
||||
onSeasonChange: (season: number) => void
|
||||
onManualScrape?: (media: Media) => void
|
||||
@@ -35,6 +42,7 @@ export function LibrarySeriesEpisodes({
|
||||
selectedSeason,
|
||||
visibleEpisodes,
|
||||
playbackFrom,
|
||||
progressById,
|
||||
isAdmin = false,
|
||||
onSeasonChange,
|
||||
onManualScrape,
|
||||
@@ -76,6 +84,7 @@ export function LibrarySeriesEpisodes({
|
||||
{visibleEpisodes.map((ep) => {
|
||||
const displayTitle = episodeDisplayTitle(ep, visibleEpisodes)
|
||||
const versionCount = ep.versions?.length ?? 1
|
||||
const progress = progressById?.get(ep.id)
|
||||
return (
|
||||
<div
|
||||
key={ep.id}
|
||||
@@ -117,6 +126,21 @@ export function LibrarySeriesEpisodes({
|
||||
: formatSize(ep.size_bytes)}
|
||||
{versionCount > 1 ? ` · ${versionCount} 版本` : ''}
|
||||
</p>
|
||||
{progress && (
|
||||
<div className="mt-1 flex items-center gap-1.5" title={progressTitle(progress)}>
|
||||
<div className="h-1 w-14 overflow-hidden rounded-full bg-sand-200">
|
||||
<div
|
||||
className="h-full rounded-full bg-brand-500"
|
||||
style={{ width: `${progressPercent(progress)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] font-medium text-brand-600 whitespace-nowrap">
|
||||
{progress.completed
|
||||
? '已看完'
|
||||
: `看到 ${formatResumeClock(progress.positionMs)}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
@@ -142,6 +166,13 @@ export function LibrarySeriesEpisodes({
|
||||
)
|
||||
}
|
||||
|
||||
function progressTitle(progress: EpisodeProgress): string {
|
||||
if (progress.completed) return '已看完'
|
||||
const position = formatResumeClock(progress.positionMs)
|
||||
const duration = formatResumeClock(progress.durationMs)
|
||||
return duration ? `看到 ${position} / ${duration}` : `看到 ${position}`
|
||||
}
|
||||
|
||||
function episodeDisplayTitle(ep: Media, siblings: Media[]): string {
|
||||
let mainTitle = ''
|
||||
const epTitle = ep.episode_title?.trim()
|
||||
|
||||
@@ -33,6 +33,7 @@ export function MediaDetailPage() {
|
||||
isAdmin={role === 'admin'}
|
||||
favourite={detail.favourite}
|
||||
playTargetId={media.id || detail.firstPlayableEpisode?.id}
|
||||
resume={detail.resume}
|
||||
scrapeEpisodeArtwork={detail.scrapeEpisodeArtwork}
|
||||
onToggleFavourite={detail.toggleFavourite}
|
||||
onScrapeEpisodeArtworkChange={detail.setScrapeEpisodeArtwork}
|
||||
@@ -53,6 +54,7 @@ export function MediaDetailPage() {
|
||||
selectedEpisodes={detail.seasonGroups}
|
||||
selectedSeason={detail.selectedSeason}
|
||||
visibleEpisodes={detail.visibleEpisodes}
|
||||
progressById={detail.episodeProgress}
|
||||
playbackFrom={`/media/${media.id}`}
|
||||
onSeasonChange={detail.setSelectedSeason}
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { MetadataEditDialog } from '../components/MetadataEditDialog'
|
||||
import { OrganizeMediaDialog } from '../components/OrganizeMediaDialog'
|
||||
import type { Media } from '../types'
|
||||
import { isDirectStreamMedia } from './playerPageModel'
|
||||
import { canContinuePlaying, continueButtonLabel, type MediaDetailResume } from './mediaDetailResumeModel'
|
||||
import { MediaDetailAdminPanel } from './MediaDetailAdminPanel'
|
||||
import { MediaDetailPoster } from './MediaDetailArtwork'
|
||||
import { MediaDetailMetadata } from './MediaDetailMetadata'
|
||||
@@ -20,6 +21,7 @@ interface MediaDetailPlaybackActionsProps {
|
||||
favourite: boolean
|
||||
onToggleFavourite: () => void
|
||||
playTargetId?: string
|
||||
resume?: MediaDetailResume | null
|
||||
}
|
||||
|
||||
interface MediaDetailMainContentProps extends MediaDetailPlaybackActionsProps {
|
||||
@@ -96,8 +98,12 @@ export function MediaDetailPlaybackActions({
|
||||
favourite,
|
||||
onToggleFavourite,
|
||||
playTargetId,
|
||||
resume,
|
||||
}: MediaDetailPlaybackActionsProps) {
|
||||
const targetId = media.id || playTargetId || ''
|
||||
// 代表条目可能是 OVA/特别篇:有续播记录时优先打开历史命中的那一集(或那集的
|
||||
// 其它版本),并直接把「看到第几秒」写在按钮上。播放器仍会自己读 /resume。
|
||||
const resumeTarget = resume && canContinuePlaying(resume) ? resume : null
|
||||
const targetId = resumeTarget?.mediaId || media.id || playTargetId || ''
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<Link
|
||||
@@ -106,9 +112,20 @@ export function MediaDetailPlaybackActions({
|
||||
className="btn-primary h-11 px-6 shadow-sm"
|
||||
>
|
||||
<Play size={16} fill="currentColor" />
|
||||
<span>立即播放</span>
|
||||
<span>{resumeTarget ? continueButtonLabel(resumeTarget) : '立即播放'}</span>
|
||||
</Link>
|
||||
|
||||
{resumeTarget && (
|
||||
<Link
|
||||
to={`/play/${resumeTarget.mediaId}?restart=1`}
|
||||
state={{ from: `/media/${media.id}` }}
|
||||
className="btn-ghost h-11 px-4 text-sm"
|
||||
title="忽略续播进度,从 0 开始播放"
|
||||
>
|
||||
<span>从头播放</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{!isDirectStreamMedia(media) && (
|
||||
<Link
|
||||
to={`/play/${targetId}?mode=hls`}
|
||||
@@ -143,6 +160,7 @@ export function MediaDetailMainContent({
|
||||
isAdmin,
|
||||
favourite,
|
||||
playTargetId,
|
||||
resume,
|
||||
scrapeEpisodeArtwork,
|
||||
onToggleFavourite,
|
||||
onScrapeEpisodeArtworkChange,
|
||||
@@ -167,6 +185,7 @@ export function MediaDetailMainContent({
|
||||
favourite={favourite}
|
||||
onToggleFavourite={onToggleFavourite}
|
||||
playTargetId={playTargetId}
|
||||
resume={resume}
|
||||
/>
|
||||
<MediaVersionSwitcher media={media} />
|
||||
</div>
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
//
|
||||
// ?mode=hls force HLS even when direct play would work
|
||||
// ?mode=direct force direct play (default for browser-friendly codecs)
|
||||
// ?restart=1 忽略服务端续播进度,从 0 开始播(详情页的「从头播放」入口)
|
||||
//
|
||||
// ?mode= 只在进入某个媒体时读取一次(媒体详情页的「HLS 兼容转码播放」入口依赖它);
|
||||
// 播放模式不再写回 URL——写 URL 会让 search 变化触发依赖 setSearchParams 的 effect
|
||||
@@ -102,6 +103,17 @@ function formatMarkTime(totalSec: number): string {
|
||||
const CLOUD_HLS_RELOAD_MAX = 3
|
||||
const CLOUD_HLS_RELOAD_WINDOW_MS = 10 * 60 * 1000
|
||||
|
||||
/**
|
||||
* 逐集切换时保留 ?mode=(HLS/直连是整段会话的选择),但丢掉 ?restart=1:
|
||||
* 「从头播放」只对用户点的那一集生效,下一集仍按自己的续播进度起播。
|
||||
*/
|
||||
function searchWithoutRestart(search: string): string {
|
||||
const params = new URLSearchParams(search)
|
||||
params.delete('restart')
|
||||
const next = params.toString()
|
||||
return next ? `?${next}` : ''
|
||||
}
|
||||
|
||||
function normalizePlayerVolume(value: unknown): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return 1
|
||||
@@ -1590,6 +1602,8 @@ export function PlayerPage() {
|
||||
if (!id) return
|
||||
setResumePosition(0)
|
||||
setInitialSeekDone(false)
|
||||
// 详情页的「从头播放」显式要求忽略续播:不再去读 /resume,直接从 0 起播。
|
||||
if (new URLSearchParams(window.location.search).get('restart') === '1') return
|
||||
const controller = new AbortController()
|
||||
playbackAPI
|
||||
.getResume(id, controller.signal)
|
||||
@@ -1855,7 +1869,7 @@ export function PlayerPage() {
|
||||
const target = autoPlayEpisodeFor(media, id, playlistEpisodes)
|
||||
if (!target) return
|
||||
navigate(
|
||||
{ pathname: `/play/${target.id}`, search: location.search },
|
||||
{ pathname: `/play/${target.id}`, search: searchWithoutRestart(location.search) },
|
||||
{ replace: true, state: location.state },
|
||||
)
|
||||
}, [media, id, playlistEpisodes, navigate, location.search, location.state])
|
||||
@@ -1937,7 +1951,9 @@ export function PlayerPage() {
|
||||
navigate(
|
||||
{
|
||||
pathname: `/play/${target.id}`,
|
||||
search: location.search,
|
||||
// 逐集切换保留 ?mode=,但丢掉 ?restart=1:「从头播放」只对用户点的那一集
|
||||
// 生效,下一集仍按各自的续播进度起播。
|
||||
search: searchWithoutRestart(location.search),
|
||||
},
|
||||
{ state: location.state },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
canContinuePlaying,
|
||||
continueButtonLabel,
|
||||
episodeProgressById,
|
||||
formatResumeClock,
|
||||
progressPercent,
|
||||
resolveMediaDetailResume,
|
||||
} from './mediaDetailResumeModel.ts'
|
||||
import type { HistoryItem, Media } from '../types'
|
||||
|
||||
function check(name: string, cond: boolean) {
|
||||
if (!cond) throw new Error(`mediaDetailResumeModel: ${name}`)
|
||||
}
|
||||
|
||||
function media(partial: Partial<Media> & { id: string }): Media {
|
||||
return {
|
||||
library_id: 'lib-1',
|
||||
title: '标题',
|
||||
path: `/media/${partial.id}.mkv`,
|
||||
size_bytes: 0,
|
||||
duration_sec: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
rating: 0,
|
||||
year: 2024,
|
||||
season_num: 0,
|
||||
episode_num: 0,
|
||||
scrape_status: 'matched',
|
||||
tmdb_id: 0,
|
||||
bangumi_id: 0,
|
||||
nsfw: false,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
...partial,
|
||||
} as Media
|
||||
}
|
||||
|
||||
function historyRow(
|
||||
mediaID: string,
|
||||
positionMs: number,
|
||||
watchedAt: string,
|
||||
extra: Partial<HistoryItem> = {},
|
||||
): HistoryItem {
|
||||
return {
|
||||
id: `h-${mediaID}`,
|
||||
user_id: 'u1',
|
||||
media_id: mediaID,
|
||||
position_ms: positionMs,
|
||||
duration_ms: 1_440_000,
|
||||
watched_at: watchedAt,
|
||||
completed: false,
|
||||
...extra,
|
||||
} as HistoryItem
|
||||
}
|
||||
|
||||
// ── 电影:直接用本条的进度 ────────────────────────────────────────────────
|
||||
const movie = media({ id: 'movie-1' })
|
||||
const movieResume = resolveMediaDetailResume(movie, [], [historyRow('movie-1', 754_000, '2024-05-01T10:00:00Z')])
|
||||
check('movie resume found', movieResume?.mediaId === 'movie-1')
|
||||
check('movie label', continueButtonLabel(movieResume!) === '继续播放 · 12:34')
|
||||
check('movie can continue', canContinuePlaying(movieResume))
|
||||
|
||||
// ── 剧集:代表条目是 OVA,续播要指向真正看过的那一集 ──────────────────────
|
||||
const ova = media({ id: 'ova', episode_num: 0, title: '特别篇' })
|
||||
const ep1 = media({ id: 'ep1', season_num: 1, episode_num: 1 })
|
||||
const ep2 = media({ id: 'ep2', season_num: 1, episode_num: 2 })
|
||||
const ep3 = media({ id: 'ep3', season_num: 1, episode_num: 3 })
|
||||
const episodes = [ep1, ep2, ep3]
|
||||
const seriesHistory = [
|
||||
historyRow('ep1', 600_000, '2024-05-01T09:00:00Z', { completed: true }),
|
||||
historyRow('ep2', 300_000, '2024-05-02T09:00:00Z'),
|
||||
historyRow('ep3', 0, '2024-05-03T09:00:00Z'),
|
||||
historyRow('other-show', 900_000, '2024-05-04T09:00:00Z'),
|
||||
]
|
||||
const seriesResume = resolveMediaDetailResume(ova, episodes, seriesHistory)
|
||||
check('series picks newest watched episode', seriesResume?.mediaId === 'ep2')
|
||||
check('series label', continueButtonLabel(seriesResume!) === '继续播放 · 第 2 集 · 5:00')
|
||||
|
||||
// 命中的是某一集的版本时,打开版本、但集号仍按它属于哪一集显示。
|
||||
const ep2Uhd = media({ id: 'ep2-uhd', season_num: 1, episode_num: 2 })
|
||||
const versionedEpisodes = [ep1, { ...ep2, versions: [ep2Uhd] }, ep3]
|
||||
const versionResume = resolveMediaDetailResume(ova, versionedEpisodes, [
|
||||
historyRow('ep2-uhd', 120_000, '2024-05-02T09:00:00Z'),
|
||||
])
|
||||
check('version resume target', versionResume?.mediaId === 'ep2-uhd')
|
||||
check('version resume label uses owning episode', versionResume?.media.episode_num === 2)
|
||||
|
||||
// ── 不值得续播的情况 ──────────────────────────────────────────────────────
|
||||
check(
|
||||
'near-start is not a resume',
|
||||
!canContinuePlaying(resolveMediaDetailResume(movie, [], [historyRow('movie-1', 1_500, '2024-05-01T10:00:00Z')])),
|
||||
)
|
||||
check(
|
||||
'completed is not a resume',
|
||||
!canContinuePlaying(
|
||||
resolveMediaDetailResume(movie, [], [
|
||||
historyRow('movie-1', 1_400_000, '2024-05-01T10:00:00Z', { completed: true }),
|
||||
]),
|
||||
),
|
||||
)
|
||||
check('no history', resolveMediaDetailResume(movie, [], []) === null)
|
||||
check(
|
||||
'unrelated history is ignored',
|
||||
resolveMediaDetailResume(movie, [], [historyRow('other', 900_000, '2024-05-01T10:00:00Z')]) === null,
|
||||
)
|
||||
|
||||
// ── 时间格式 ──────────────────────────────────────────────────────────────
|
||||
check('clock minutes', formatResumeClock(754_000) === '12:34')
|
||||
check('clock hours', formatResumeClock(3_723_000) === '1:02:03')
|
||||
check('clock invalid', formatResumeClock(0) === '' && formatResumeClock(Number.NaN) === '')
|
||||
|
||||
// ── 每集进度(剧集卡片上的进度条) ────────────────────────────────────────
|
||||
const progress = episodeProgressById(versionedEpisodes, [
|
||||
historyRow('ep1', 700_000, '2024-05-01T09:00:00Z', { completed: true }),
|
||||
historyRow('ep2', 200_000, '2024-05-01T09:10:00Z'),
|
||||
historyRow('ep2-uhd', 400_000, '2024-05-01T09:20:00Z'),
|
||||
])
|
||||
check('episode progress skips untouched episodes', !progress.has('ep3'))
|
||||
check('episode progress prefers newest version row', progress.get('ep2')?.positionMs === 400_000)
|
||||
check('completed episode is full', progressPercent(progress.get('ep1')!) === 100)
|
||||
check('partial episode is capped', progressPercent({ positionMs: 1_430_000, durationMs: 1_440_000, completed: false }) === 99)
|
||||
check('unknown duration still visible', progressPercent({ positionMs: 10_000, durationMs: 0, completed: false }) === 5)
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { HistoryItem, Media } from '../types'
|
||||
import { formatEpisodeLabel } from '../utils/episodeNumber.ts'
|
||||
|
||||
/**
|
||||
* 媒体详情页的「继续播放」解析。
|
||||
*
|
||||
* 服务端存的是「(user, media) → 最近位置」的续播行。详情页要回答两个问题:
|
||||
* 1. 这部剧最新看到哪一集?(代表条目可能是 OVA/特别篇,不能直接用它自己的进度)
|
||||
* 2. 那一集看到第几秒?
|
||||
*
|
||||
* 因此这里拿到的是「整库最近 N 条历史」,在「本条目 + 全部剧集(含各自的版本)」
|
||||
* 这个集合里挑 watched_at 最新的一条。历史请求用 include_media=false,服务端
|
||||
* 不需要 join media,也不会为挂载条目逐条回源。
|
||||
*/
|
||||
|
||||
/** 播放器只在「>2 秒且未看完」时才真正续播,详情页用同一阈值决定按钮文案。 */
|
||||
export const RESUME_MIN_POSITION_MS = 2000
|
||||
|
||||
/** 详情页续播扫描的条数(服务端上限 500)。 */
|
||||
export const RESUME_SCAN_LIMIT = 300
|
||||
|
||||
export interface MediaDetailResume {
|
||||
/** 实际要打开的媒体 id:命中的那条历史,可能是某集的某个版本。 */
|
||||
mediaId: string
|
||||
/** 用来显示集号/标题的条目(命中的那集;电影就是本体)。 */
|
||||
media: Media
|
||||
positionMs: number
|
||||
durationMs: number
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
export interface EpisodeProgress {
|
||||
positionMs: number
|
||||
durationMs: number
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
function versionIDs(media: Media | null | undefined): string[] {
|
||||
return (media?.versions ?? []).map((v) => v.id).filter(Boolean)
|
||||
}
|
||||
|
||||
/** 「本条 + 各集 + 各集版本」的 id 集合:续播候选范围。 */
|
||||
export function mediaDetailScopeIDs(media: Media | null, episodes: Media[]): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
const add = (item: Media | null | undefined) => {
|
||||
if (!item?.id) return
|
||||
ids.add(item.id)
|
||||
for (const id of versionIDs(item)) ids.add(id)
|
||||
}
|
||||
add(media)
|
||||
for (const ep of episodes) add(ep)
|
||||
return ids
|
||||
}
|
||||
|
||||
/** 命中的 history.media_id 属于哪个条目:集优先,命中的是版本时归到它那一集。 */
|
||||
function ownerOf(mediaID: string, media: Media | null, episodes: Media[]): Media | null {
|
||||
for (const ep of episodes) {
|
||||
if (ep.id === mediaID) return ep
|
||||
if (versionIDs(ep).includes(mediaID)) return ep
|
||||
}
|
||||
if (media) {
|
||||
if (media.id === mediaID) return media
|
||||
if (versionIDs(media).includes(mediaID)) return media
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function watchedAtScore(row: HistoryItem): number {
|
||||
const at = Date.parse(row.watched_at)
|
||||
return Number.isFinite(at) ? at : 0
|
||||
}
|
||||
|
||||
function newestRow(rows: HistoryItem[]): HistoryItem | null {
|
||||
let best: HistoryItem | null = null
|
||||
for (const row of rows) {
|
||||
if (!row || !row.media_id) continue
|
||||
if (best == null || watchedAtScore(row) > watchedAtScore(best)) best = row
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情页的续播目标;没有可用历史时返回 null(按钮退回「立即播放」)。
|
||||
*/
|
||||
export function resolveMediaDetailResume(
|
||||
media: Media | null,
|
||||
episodes: Media[],
|
||||
history: HistoryItem[],
|
||||
): MediaDetailResume | null {
|
||||
if (!media) return null
|
||||
const ids = mediaDetailScopeIDs(media, episodes)
|
||||
if (ids.size === 0) return null
|
||||
const rows = history.filter((row) => row?.media_id && ids.has(row.media_id) && row.position_ms > 0)
|
||||
const best = newestRow(rows)
|
||||
if (!best) return null
|
||||
return {
|
||||
mediaId: best.media_id,
|
||||
media: ownerOf(best.media_id, media, episodes) ?? media,
|
||||
positionMs: best.position_ms,
|
||||
durationMs: best.duration_ms,
|
||||
completed: Boolean(best.completed),
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否该显示「继续播放」(与播放器真正会续播的条件保持一致)。 */
|
||||
export function canContinuePlaying(resume: MediaDetailResume | null): boolean {
|
||||
if (!resume) return false
|
||||
return !resume.completed && resume.positionMs > RESUME_MIN_POSITION_MS
|
||||
}
|
||||
|
||||
/** 12:34 / 1:02:03;非法输入返回空串。 */
|
||||
export function formatResumeClock(positionMs: number): string {
|
||||
if (!Number.isFinite(positionMs) || positionMs <= 0) return ''
|
||||
const totalSec = Math.floor(positionMs / 1000)
|
||||
const hours = Math.floor(totalSec / 3600)
|
||||
const minutes = Math.floor((totalSec % 3600) / 60)
|
||||
const seconds = totalSec % 60
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`
|
||||
}
|
||||
|
||||
/** 「继续播放 · 第 3 集 · 12:34」;电影没有集号时只带位置。 */
|
||||
export function continueButtonLabel(resume: MediaDetailResume): string {
|
||||
const parts = ['继续播放']
|
||||
const episode = formatEpisodeLabel(resume.media)
|
||||
if (episode) parts.push(episode)
|
||||
const clock = formatResumeClock(resume.positionMs)
|
||||
if (clock) parts.push(clock)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
/** 每集(含版本)的续播进度,用于剧集卡片上的进度条。 */
|
||||
export function episodeProgressById(
|
||||
episodes: Media[],
|
||||
history: HistoryItem[],
|
||||
): Map<string, EpisodeProgress> {
|
||||
const map = new Map<string, EpisodeProgress>()
|
||||
if (episodes.length === 0 || history.length === 0) return map
|
||||
const byMediaID = new Map<string, HistoryItem>()
|
||||
for (const row of history) {
|
||||
if (row?.media_id && row.position_ms > 0) byMediaID.set(row.media_id, row)
|
||||
}
|
||||
for (const ep of episodes) {
|
||||
const rows: HistoryItem[] = []
|
||||
for (const id of [ep.id, ...versionIDs(ep)]) {
|
||||
const row = byMediaID.get(id)
|
||||
if (row) rows.push(row)
|
||||
}
|
||||
const best = newestRow(rows)
|
||||
if (!best) continue
|
||||
map.set(ep.id, {
|
||||
positionMs: best.position_ms,
|
||||
durationMs: best.duration_ms,
|
||||
completed: Boolean(best.completed),
|
||||
})
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** 进度条宽度(0-100)。时长未知时按「看过」显示一小段,避免整条空着。 */
|
||||
export function progressPercent(progress: EpisodeProgress): number {
|
||||
if (progress.completed) return 100
|
||||
if (progress.durationMs > 0) {
|
||||
const ratio = (progress.positionMs / progress.durationMs) * 100
|
||||
return Math.min(99, Math.max(2, ratio))
|
||||
}
|
||||
return 5
|
||||
}
|
||||
@@ -3,13 +3,19 @@ import { useLocation, type NavigateFunction } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { api } from '../api/client'
|
||||
import { historyAPI } from '../api/history'
|
||||
import { mediaAPI } from '../api/library'
|
||||
import { playbackAPI } from '../api/playback'
|
||||
import { confirmActionResult } from '../components/confirmAction'
|
||||
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
||||
import type { Media } from '../types'
|
||||
import type { HistoryItem, Media } from '../types'
|
||||
import { seasonSortOrder } from '../utils/groupSeries'
|
||||
import { episodeNumberValue } from '../utils/episodeNumber'
|
||||
import {
|
||||
episodeProgressById,
|
||||
resolveMediaDetailResume,
|
||||
RESUME_SCAN_LIMIT,
|
||||
} from './mediaDetailResumeModel'
|
||||
import { mediaLibraryBackTarget } from './MediaDetailPageModel'
|
||||
|
||||
type MediaDetailLocationState = { from?: string } | null
|
||||
@@ -26,6 +32,7 @@ interface MediaDetailRefreshParams {
|
||||
setLoading: Dispatch<SetStateAction<boolean>>
|
||||
setEpisodes: Dispatch<SetStateAction<Media[]>>
|
||||
setLoadingEpisodes: Dispatch<SetStateAction<boolean>>
|
||||
setHistory: Dispatch<SetStateAction<HistoryItem[]>>
|
||||
}
|
||||
|
||||
interface MediaDetailActionsParams {
|
||||
@@ -43,6 +50,7 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [episodes, setEpisodes] = useState<Media[]>([])
|
||||
const [loadingEpisodes, setLoadingEpisodes] = useState(false)
|
||||
const [history, setHistory] = useState<HistoryItem[]>([])
|
||||
const [selectedSeason, setSelectedSeason] = useState<number | null>(null)
|
||||
|
||||
const [manualScrapeOpen, setManualScrapeOpen] = useState(false)
|
||||
@@ -57,6 +65,7 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa
|
||||
setLoading,
|
||||
setEpisodes,
|
||||
setLoadingEpisodes,
|
||||
setHistory,
|
||||
})
|
||||
const location = useLocation()
|
||||
const backFrom = (location.state as MediaDetailLocationState)?.from
|
||||
@@ -108,6 +117,17 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa
|
||||
return (visibleEpisodes.length > 0 ? visibleEpisodes[0] : episodes[0]) || null
|
||||
}, [episodes, visibleEpisodes])
|
||||
|
||||
// 「继续播放」:在「本条目 + 全部剧集(含版本)」里找最近看过的一条。
|
||||
// 详情页的代表条目可能是 OVA/特别篇,所以不能只看它自己的进度。
|
||||
const resume = useMemo(
|
||||
() => resolveMediaDetailResume(media, episodes, history),
|
||||
[episodes, history, media],
|
||||
)
|
||||
const episodeProgress = useMemo(
|
||||
() => episodeProgressById(episodes, history),
|
||||
[episodes, history],
|
||||
)
|
||||
|
||||
const handleMetadataSaved = useCallback(async (next: Media) => {
|
||||
setMedia(next)
|
||||
await refresh()
|
||||
@@ -124,6 +144,8 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa
|
||||
visibleEpisodes,
|
||||
hasEpisodes,
|
||||
firstPlayableEpisode,
|
||||
resume,
|
||||
episodeProgress,
|
||||
setSelectedSeason,
|
||||
manualScrapeOpen,
|
||||
metadataEditOpen,
|
||||
@@ -146,20 +168,26 @@ function useMediaDetailRefresh({
|
||||
setLoading,
|
||||
setEpisodes,
|
||||
setLoadingEpisodes,
|
||||
setHistory,
|
||||
}: MediaDetailRefreshParams): () => Promise<void> {
|
||||
return useCallback(async () => {
|
||||
if (!id) return
|
||||
setLoading(true)
|
||||
setLoadingEpisodes(true)
|
||||
try {
|
||||
// 三个请求并行发出;详情一到就解锁整页渲染,收藏状态与分集列表
|
||||
// 到达后各自补齐(原先完全串行,首屏要排队等满三个往返)。
|
||||
// 四个请求并行发出;详情一到就解锁整页渲染,收藏、分集列表、续播历史
|
||||
// 到达后各自补齐(原先完全串行,首屏要排队等满所有往返)。
|
||||
const nextMediaPromise = mediaAPI.get(id)
|
||||
const favouritePromise = playbackAPI.favouriteStatus(id).catch(() => false)
|
||||
const episodesPromise = mediaAPI
|
||||
.getEpisodes(id)
|
||||
.then((r) => r.items ?? [])
|
||||
.catch(() => [])
|
||||
// 续播扫描只要历史行(include_media=false):服务端不 join media,
|
||||
// 300 条也只有几十 KB。
|
||||
const historyPromise = historyAPI
|
||||
.listRows(RESUME_SCAN_LIMIT)
|
||||
.catch(() => [] as HistoryItem[])
|
||||
|
||||
const nextMedia = await nextMediaPromise
|
||||
setMedia(nextMedia)
|
||||
@@ -169,11 +197,12 @@ function useMediaDetailRefresh({
|
||||
|
||||
const episodes = await episodesPromise
|
||||
setEpisodes(episodes)
|
||||
setHistory(await historyPromise)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoadingEpisodes(false)
|
||||
}
|
||||
}, [id, setFavourite, setLoading, setMedia, setEpisodes, setLoadingEpisodes])
|
||||
}, [id, setFavourite, setHistory, setLoading, setMedia, setEpisodes, setLoadingEpisodes])
|
||||
}
|
||||
|
||||
function useMediaDetailActions({
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { historyAPI } from '../api/history'
|
||||
import type { HistoryItem, Media } from '../types'
|
||||
import {
|
||||
episodeProgressById,
|
||||
resolveMediaDetailResume,
|
||||
RESUME_SCAN_LIMIT,
|
||||
type EpisodeProgress,
|
||||
type MediaDetailResume,
|
||||
} from './mediaDetailResumeModel'
|
||||
|
||||
interface SeriesEpisodeResume {
|
||||
resume: MediaDetailResume | null
|
||||
progressById: Map<string, EpisodeProgress>
|
||||
}
|
||||
|
||||
/**
|
||||
* 剧集面板(媒体库抽屉 / 媒体详情)用的续播数据。
|
||||
*
|
||||
* 代表条目可能是 OVA/特别篇,因此续播目标必须在「整剧所有集」里挑最近看过
|
||||
* 的一条;打开抽屉时扫一次整库最近历史即可,只取历史行(include_media=false)。
|
||||
*/
|
||||
export function useSeriesEpisodeResume(
|
||||
seriesID: string,
|
||||
rep: Media | null,
|
||||
episodes: Media[],
|
||||
): SeriesEpisodeResume {
|
||||
const [history, setHistory] = useState<HistoryItem[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!seriesID) {
|
||||
setHistory([])
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
historyAPI
|
||||
.listRows(RESUME_SCAN_LIMIT)
|
||||
.then((rows) => {
|
||||
if (!cancelled) setHistory(rows)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setHistory([])
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [seriesID])
|
||||
|
||||
const resume = useMemo(
|
||||
() => resolveMediaDetailResume(rep, episodes, history),
|
||||
[episodes, history, rep],
|
||||
)
|
||||
const progressById = useMemo(() => episodeProgressById(episodes, history), [episodes, history])
|
||||
return { resume, progressById }
|
||||
}
|
||||
Reference in New Issue
Block a user