mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 13d60b3bd9 | |||
| 3d30f6f27c | |||
| bd02e194ff | |||
| 1c9b742c8d | |||
| 20e7d7fa99 | |||
| 394ea0382e | |||
| 4d698d2757 | |||
| fdfb2ee987 |
@@ -150,6 +150,7 @@ func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attemptedAccess := strings.TrimPrefix(req.Header.Get("Authorization"), "Bearer ")
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
@@ -218,7 +219,7 @@ func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map
|
||||
// refresh_token 刷新后重试一次。刷新失败或重试后仍失败才返回,
|
||||
// 避免长时间同步因 token 过期而整体失败。
|
||||
if isTokenCode(base.Code) {
|
||||
if access && c.tryRefreshTokenLocked(ctx) {
|
||||
if access && c.tryRefreshTokenLocked(ctx, attemptedAccess) {
|
||||
continue
|
||||
}
|
||||
if access {
|
||||
@@ -305,21 +306,23 @@ func (c *OpenClient) doAuthJSONWithUA(ctx context.Context, method, rawURL string
|
||||
// tryRefreshTokenLocked 并发安全地刷新 access_token;成功返回 true(调用方
|
||||
// 应使用内存中的新 token 重试原请求)。
|
||||
//
|
||||
// 拿到写锁后在锁内读取 oldAccess,与持锁期间的当前值对比:若已被其他
|
||||
// goroutine 刷新过则直接复用新 token,避免并发请求连环轮转消耗 115 的
|
||||
// 一次性 refresh_token。全程持写锁读写 token 字段,无 TOCTOU 窗口。
|
||||
// failedAccess 是失败请求实际携带的 token。拿到写锁后与当前 token 对比:
|
||||
// 若已被其他 goroutine 刷新过则直接复用,避免并发请求连环轮转消耗 115
|
||||
// 的一次性 refresh_token。全程持写锁读写 token 字段,无 TOCTOU 窗口。
|
||||
//
|
||||
// 对"refresh_token 本身已失效/被吊销"(IsRefreshTokenDead,如 40140114/116/119/120)
|
||||
// 这类不可恢复的错误直接放弃并清空内存 token(提示需重新授权)。
|
||||
// 对其它失败(网络瞬时抖动、刷新接口可重试错误码等)做指数退避重试几次再放弃,
|
||||
// 避免同步长任务中途 token 到期时恰好撞上一个短暂的刷新失败就整体失败。
|
||||
func (c *OpenClient) tryRefreshTokenLocked(ctx context.Context) bool {
|
||||
func (c *OpenClient) tryRefreshTokenLocked(ctx context.Context, failedAccess string) bool {
|
||||
c.tokenMu.Lock()
|
||||
// 在已持有写锁内读取当前 token 作为"刷新前快照",消除双重加锁窗口:
|
||||
// 若在拿锁期间已有其他 goroutine 完成刷新,refreshTokenWhileLocked
|
||||
// 内的 c.AccessToken != oldAccess 判断会立即命中并返回复用。
|
||||
oldAccess := c.AccessToken
|
||||
newToken, ok := c.refreshTokenWhileLocked(ctx, oldAccess)
|
||||
// 请求发出后若其他 goroutine 已经刷新完成,直接复用新 token 重试;
|
||||
// 不能再次轮换一次性的 refresh_token。
|
||||
if failedAccess != "" && c.AccessToken != failedAccess {
|
||||
c.tokenMu.Unlock()
|
||||
return true
|
||||
}
|
||||
newToken, ok := c.refreshTokenWhileLocked(ctx, failedAccess)
|
||||
c.tokenMu.Unlock()
|
||||
// 回调必须在 tokenMu 释放后调用,避免上层在回调内访问客户端时死锁
|
||||
if ok && newToken != nil && c.OnTokenRefreshed != nil {
|
||||
|
||||
@@ -140,6 +140,47 @@ func TestRefreshTokenDead(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentTokenFailuresShareOneRefresh(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
refreshCalls := 0
|
||||
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/open/refreshToken" {
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
mu.Lock()
|
||||
refreshCalls++
|
||||
mu.Unlock()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
_, _ = w.Write([]byte(`{"state":true,"data":{"access_token":"at-new","refresh_token":"rt-new","expires_in":7200}}`))
|
||||
})
|
||||
|
||||
client := NewOpenClient("100195129", "at-old", "rt-old")
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan bool, 2)
|
||||
for range 2 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
results <- client.tryRefreshTokenLocked(context.Background(), "at-old")
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
for ok := range results {
|
||||
if !ok {
|
||||
t.Fatal("concurrent refresh should reuse the refreshed token")
|
||||
}
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if refreshCalls != 1 {
|
||||
t.Fatalf("refresh calls = %d, want 1", refreshCalls)
|
||||
}
|
||||
if client.CurrentAccessToken() != "at-new" {
|
||||
t.Fatalf("access token = %q, want at-new", client.CurrentAccessToken())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFsListAndDownload(t *testing.T) {
|
||||
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
|
||||
@@ -477,24 +477,32 @@ func (c *OpenClient) GetToken(qrCode *QrCodeDataReturn) (*TokenData, error) {
|
||||
|
||||
// RefreshToken 刷新访问令牌。
|
||||
func (c *OpenClient) RefreshToken(refreshToken string) (*TokenData, error) {
|
||||
c.tokenMu.Lock()
|
||||
if refreshToken == "" {
|
||||
refreshToken = c.currentRefreshToken()
|
||||
refreshToken = c.RefreshTokenStr
|
||||
}
|
||||
if refreshToken == "" {
|
||||
c.tokenMu.Unlock()
|
||||
return nil, fmt.Errorf("没有可用的 refresh_token")
|
||||
}
|
||||
token, err := c.doRefreshToken(refreshToken)
|
||||
if err != nil {
|
||||
// refresh_token 已失效时清空内存令牌(提示需重新授权)
|
||||
if IsRefreshTokenDead(err) {
|
||||
c.SetAuthToken("", "")
|
||||
c.setAuthTokenLocked("", "")
|
||||
}
|
||||
c.tokenMu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
if token.AccessToken == "" || token.RefreshToken == "" {
|
||||
c.tokenMu.Unlock()
|
||||
return nil, fmt.Errorf("115: 刷新返回空凭证(access_token/refresh_token 缺失)")
|
||||
}
|
||||
c.SetAuthToken(token.AccessToken, token.RefreshToken)
|
||||
c.setAuthTokenLocked(token.AccessToken, token.RefreshToken)
|
||||
c.tokenMu.Unlock()
|
||||
if c.OnTokenRefreshed != nil {
|
||||
c.OnTokenRefreshed(token.AccessToken, token.RefreshToken)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ func (b *serviceContainerBuilder) initContentServices() {
|
||||
// STRM 直连失败后的 HLS 转码:把 .strm 解析成 ffmpeg 可读取的本地路径或 HTTP 直链。
|
||||
b.c.Transcoder.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget)
|
||||
b.c.Transcoder.SetProbe(b.c.FFprobe)
|
||||
b.c.Subtitle.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget)
|
||||
// 弹幕识别需要把远程 Emby 条目解析为 Media 元数据及可拉取前 16MB 的直链 URL。
|
||||
if b.c.EmbyRemote != nil {
|
||||
b.c.Danmaku.SetRemoteMediaResolver(func(ctx context.Context, encodedID string) (*model.Media, string, error) {
|
||||
|
||||
@@ -25,7 +25,8 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request,
|
||||
}
|
||||
startSec := parseHLSStartSec(r)
|
||||
seekGen := parseHLSSeekGen(r)
|
||||
if _, err := s.transcoder.EnsureJobFrom(r.Context(), mediaID, startSec, seekGen); err != nil {
|
||||
subtitleStream := parseHLSSubtitleStream(r)
|
||||
if _, err := s.transcoder.EnsureJobFromSubtitle(r.Context(), mediaID, startSec, seekGen, subtitleStream); err != nil {
|
||||
return err
|
||||
}
|
||||
s.transcoder.TouchJob(mediaID)
|
||||
@@ -60,6 +61,21 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request,
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseHLSSubtitleStream(r *http.Request) int {
|
||||
if r == nil {
|
||||
return -1
|
||||
}
|
||||
raw := strings.TrimSpace(r.URL.Query().Get("subtitle"))
|
||||
if raw == "" {
|
||||
return -1
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v < 0 {
|
||||
return -1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func parseHLSStartSec(r *http.Request) float64 {
|
||||
if r == nil {
|
||||
return 0
|
||||
@@ -94,8 +110,8 @@ func appendQueryToHLSSegments(playlist, rawQuery string) string {
|
||||
if strings.TrimSpace(rawQuery) == "" {
|
||||
return playlist
|
||||
}
|
||||
// Segment fetches only need auth/profile tokens; drop start= so a seek
|
||||
// restart does not keep forcing EnsureJobFrom on every .ts hit.
|
||||
// Segment fetches do not need start=, but must keep _seek as a cache-busting
|
||||
// generation because every transcode restart reuses seg_00000.ts names.
|
||||
q := filterHLSSegmentQuery(rawQuery)
|
||||
if q == "" {
|
||||
return playlist
|
||||
@@ -131,7 +147,7 @@ func filterHLSSegmentQuery(rawQuery string) string {
|
||||
key = part[:i]
|
||||
}
|
||||
switch strings.ToLower(key) {
|
||||
case "start", "_seek":
|
||||
case "start":
|
||||
continue
|
||||
}
|
||||
kept = append(kept, part)
|
||||
|
||||
@@ -302,9 +302,12 @@ func TestRequestTokenFromMediaBrowserAuthorizationHeader(t *testing.T) {
|
||||
|
||||
func TestAppendQueryToHLSSegments(t *testing.T) {
|
||||
in := "#EXTM3U\n#EXTINF:4.0,\nseg_00000.ts\n#EXTINF:4.0,\nseg_00001.ts?old=1\n"
|
||||
got := appendQueryToHLSSegments(in, "token=abc")
|
||||
if !strings.Contains(got, "seg_00000.ts?token=abc") {
|
||||
t.Fatalf("missing tokenized segment: %q", got)
|
||||
got := appendQueryToHLSSegments(in, "token=abc&start=120.5&_seek=1001")
|
||||
if !strings.Contains(got, "seg_00000.ts?token=abc&_seek=1001") {
|
||||
t.Fatalf("missing token or seek generation on segment: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "start=120.5") {
|
||||
t.Fatalf("segment URL must not contain transcode start: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "seg_00001.ts?old=1") {
|
||||
t.Fatalf("existing query should be preserved: %q", got)
|
||||
|
||||
@@ -293,7 +293,11 @@ func (s *StrmService) save115OAuthToken(ctx context.Context, session *strm115Aut
|
||||
acct.LastTestAt = &now
|
||||
acct.LastTestResult = "授权成功"
|
||||
acct.LastTestOK = true
|
||||
return s.repo.StrmAccount.Update(ctx, acct)
|
||||
if err := s.repo.StrmAccount.Update(ctx, acct); err != nil {
|
||||
return err
|
||||
}
|
||||
s.invalidate115Provider(acct.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *StrmService) drop115AuthSession(sessionID string) {
|
||||
@@ -382,8 +386,15 @@ func (s *StrmService) refresh115TokensOnce(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
client := cloud115.NewOpenClient(cfg["app_id"], cfg["access_token"], cfg["refresh_token"])
|
||||
token, err := client.RefreshToken(cfg["refresh_token"])
|
||||
provider, err := s.providerFor(ctx, acct)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
openProvider, ok := provider.(interface{ OpenClient() *cloud115.OpenClient })
|
||||
if !ok || openProvider.OpenClient() == nil {
|
||||
continue
|
||||
}
|
||||
token, err := openProvider.OpenClient().RefreshToken("")
|
||||
if err != nil {
|
||||
msg := "令牌刷新失败:" + err.Error()
|
||||
if cloud115.IsRefreshTokenDead(err) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||
)
|
||||
|
||||
func TestStrmAccountConfigPreviewOf(t *testing.T) {
|
||||
@@ -71,6 +72,90 @@ func TestUpdateStrmAccountMergesConfigWithoutClearingSecrets(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderForReuses115ClientUntilCredentialsChange(t *testing.T) {
|
||||
svc := testStrmService(t)
|
||||
ctx := context.Background()
|
||||
acct, err := svc.CreateStrmAccount(ctx, "115", model.StrmProvider115, map[string]string{
|
||||
"app_id": "100195129",
|
||||
"access_token": "at-1",
|
||||
"refresh_token": "rt-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
|
||||
first, err := svc.providerFor(ctx, acct)
|
||||
if err != nil {
|
||||
t.Fatalf("first provider: %v", err)
|
||||
}
|
||||
second, err := svc.providerFor(ctx, acct)
|
||||
if err != nil {
|
||||
t.Fatalf("second provider: %v", err)
|
||||
}
|
||||
if first != second {
|
||||
t.Fatal("115 provider should be shared per account")
|
||||
}
|
||||
|
||||
updated, err := svc.UpdateStrmAccount(ctx, acct.ID, "", nil, map[string]string{
|
||||
"access_token": "at-2",
|
||||
"refresh_token": "rt-2",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update account: %v", err)
|
||||
}
|
||||
third, err := svc.providerFor(ctx, updated)
|
||||
if err != nil {
|
||||
t.Fatalf("provider after credential update: %v", err)
|
||||
}
|
||||
if first == third {
|
||||
t.Fatal("credential update must invalidate the shared provider")
|
||||
}
|
||||
|
||||
oldClient := first.(interface{ OpenClient() *cloud115.OpenClient }).OpenClient()
|
||||
oldClient.OnTokenRefreshed("at-stale", "rt-stale")
|
||||
fresh, err := svc.repo.StrmAccount.FindByID(ctx, acct.ID)
|
||||
if err != nil || fresh == nil {
|
||||
t.Fatalf("reload account: %v", err)
|
||||
}
|
||||
freshCfg, err := svc.strmAccountConfig(fresh)
|
||||
if err != nil {
|
||||
t.Fatalf("decode refreshed account: %v", err)
|
||||
}
|
||||
if freshCfg["access_token"] != "at-2" || freshCfg["refresh_token"] != "rt-2" {
|
||||
t.Fatalf("stale client overwrote new credentials: %#v", freshCfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersist115TokensKeepsSharedClient(t *testing.T) {
|
||||
svc := testStrmService(t)
|
||||
ctx := context.Background()
|
||||
acct, err := svc.CreateStrmAccount(ctx, "115", model.StrmProvider115, map[string]string{
|
||||
"app_id": "100195129",
|
||||
"access_token": "at-1",
|
||||
"refresh_token": "rt-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
first, err := svc.providerFor(ctx, acct)
|
||||
if err != nil {
|
||||
t.Fatalf("first provider: %v", err)
|
||||
}
|
||||
|
||||
svc.persist115Tokens(acct.ID, "at-2", "rt-2")
|
||||
fresh, err := svc.repo.StrmAccount.FindByID(ctx, acct.ID)
|
||||
if err != nil || fresh == nil {
|
||||
t.Fatalf("reload account: %v", err)
|
||||
}
|
||||
second, err := svc.providerFor(ctx, fresh)
|
||||
if err != nil {
|
||||
t.Fatalf("second provider: %v", err)
|
||||
}
|
||||
if first != second {
|
||||
t.Fatal("automatic token persistence must keep the in-memory shared client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteStrmAccountCascadesEmbyMounts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{}, &model.StrmSyncPath{})
|
||||
|
||||
@@ -95,6 +95,9 @@ type StrmService struct {
|
||||
oauthSessions map[string]*strm115AuthSession
|
||||
wafUntil time.Time // 115 风控/限流熔断截止时间(由 mu 保护)
|
||||
|
||||
providerMu sync.Mutex
|
||||
provider115Cache map[string]cloud.Provider // account ID -> shared provider/OpenClient
|
||||
|
||||
downloadSem115 chan struct{} // 115 换直链+下载并发上限(风控兜底)
|
||||
downloadSemDAV chan struct{} // WebDAV/OpenList/CloudDrive2 元数据下载并发上限
|
||||
downloadSemOnce sync.Once
|
||||
@@ -158,15 +161,16 @@ func (s *StrmService) releaseDownloadSlot(provider string) {
|
||||
// NewStrmService constructs the STRM service.
|
||||
func NewStrmService(cfg *config.Config, log *zap.Logger, repos *repository.Container, crypto *CryptoService) *StrmService {
|
||||
return &StrmService{
|
||||
log: log,
|
||||
repo: repos,
|
||||
cfg: cfg,
|
||||
crypto: crypto,
|
||||
http: &http.Client{Timeout: 90 * time.Second},
|
||||
stopCh: make(chan struct{}),
|
||||
baseCtx: context.Background(),
|
||||
running: map[string]context.CancelFunc{},
|
||||
oauthSessions: map[string]*strm115AuthSession{},
|
||||
log: log,
|
||||
repo: repos,
|
||||
cfg: cfg,
|
||||
crypto: crypto,
|
||||
http: &http.Client{Timeout: 90 * time.Second},
|
||||
stopCh: make(chan struct{}),
|
||||
baseCtx: context.Background(),
|
||||
running: map[string]context.CancelFunc{},
|
||||
oauthSessions: map[string]*strm115AuthSession{},
|
||||
provider115Cache: map[string]cloud.Provider{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,6 +413,9 @@ func (s *StrmService) UpdateStrmAccount(ctx context.Context, id, name string, en
|
||||
if err := s.repo.StrmAccount.Update(ctx, acct); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if acct.Provider == model.StrmProvider115 && len(config) > 0 {
|
||||
s.invalidate115Provider(acct.ID)
|
||||
}
|
||||
return acct, nil
|
||||
}
|
||||
|
||||
@@ -426,6 +433,7 @@ func (s *StrmService) DeleteStrmAccount(ctx context.Context, id string) error {
|
||||
if err := s.repo.StrmAccount.Delete(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
s.invalidate115Provider(id)
|
||||
// 级联清理远程 Emby 挂载:否则留下孤儿挂载,挂载计数/列表仍会显示。
|
||||
// 账号已删,挂载清理失败只记日志,不让删除请求报错。
|
||||
if _, err := s.repo.EmbyMount.DeleteByAccountID(ctx, id); err != nil && s.log != nil {
|
||||
@@ -464,6 +472,23 @@ func (s *StrmService) ListAccounts(ctx context.Context) ([]model.StrmAccount, er
|
||||
|
||||
// providerFor 依据账号配置构建网盘驱动。
|
||||
func (s *StrmService) providerFor(ctx context.Context, acct *model.StrmAccount) (cloud.Provider, error) {
|
||||
if acct != nil && acct.Provider == model.StrmProvider115 {
|
||||
s.providerMu.Lock()
|
||||
defer s.providerMu.Unlock()
|
||||
if provider := s.provider115Cache[acct.ID]; provider != nil {
|
||||
return provider, nil
|
||||
}
|
||||
provider, err := s.newProvider(ctx, acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.provider115Cache[acct.ID] = provider
|
||||
return provider, nil
|
||||
}
|
||||
return s.newProvider(ctx, acct)
|
||||
}
|
||||
|
||||
func (s *StrmService) newProvider(ctx context.Context, acct *model.StrmAccount) (cloud.Provider, error) {
|
||||
cfg, err := s.strmAccountConfig(acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -482,13 +507,33 @@ func (s *StrmService) providerFor(ctx context.Context, acct *model.StrmAccount)
|
||||
// refresh_token 再刷(一次性轮转),两者互相作废,最终把有效账号
|
||||
// 标成“授权已失效”。
|
||||
if oc, ok := provider.(interface{ OpenClient() *cloud115.OpenClient }); ok {
|
||||
oc.OpenClient().OnTokenRefreshed = func(accessToken, refreshToken string) {
|
||||
client := oc.OpenClient()
|
||||
client.OnTokenRefreshed = func(accessToken, refreshToken string) {
|
||||
// 账号重新授权/修改凭据后,旧客户端可能仍有在途请求。旧请求
|
||||
// 刷新的令牌不能覆盖新授权写入的凭据。
|
||||
if !s.isCurrent115Client(acct.ID, client) {
|
||||
return
|
||||
}
|
||||
s.persist115Tokens(acct.ID, accessToken, refreshToken)
|
||||
}
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func (s *StrmService) invalidate115Provider(accountID string) {
|
||||
s.providerMu.Lock()
|
||||
delete(s.provider115Cache, accountID)
|
||||
s.providerMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *StrmService) isCurrent115Client(accountID string, client *cloud115.OpenClient) bool {
|
||||
s.providerMu.Lock()
|
||||
defer s.providerMu.Unlock()
|
||||
provider := s.provider115Cache[accountID]
|
||||
openProvider, ok := provider.(interface{ OpenClient() *cloud115.OpenClient })
|
||||
return ok && openProvider.OpenClient() == client
|
||||
}
|
||||
|
||||
// ─── 全局设置 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// GetStrmSettings 返回全局 strm 设置(含默认值)。
|
||||
|
||||
+197
-13
@@ -16,12 +16,15 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -29,14 +32,16 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// SubtitleService is the discovery + conversion entry point.
|
||||
type SubtitleService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
cfg *config.Config
|
||||
strmResolve func(ctx context.Context, raw string) (*StrmPlayResult, error)
|
||||
|
||||
// 目录发现是 Emby 条目列表的热路径(每个媒体源一次 DB 查询 + 最多 5 次
|
||||
// os.ReadDir),而字幕文件极少变化:按 media_id 做短 TTL 缓存。
|
||||
@@ -61,11 +66,14 @@ func NewSubtitleService(cfg *config.Config, log *zap.Logger, repo *repository.Co
|
||||
|
||||
// SubtitleTrack describes one external subtitle file.
|
||||
type SubtitleTrack struct {
|
||||
Lang string `json:"lang"`
|
||||
Label string `json:"label"`
|
||||
Path string `json:"path"`
|
||||
URL string `json:"url"`
|
||||
Codec string `json:"codec"`
|
||||
Lang string `json:"lang"`
|
||||
Label string `json:"label"`
|
||||
Path string `json:"path"`
|
||||
URL string `json:"url"`
|
||||
Codec string `json:"codec"`
|
||||
Source string `json:"source"`
|
||||
Delivery string `json:"delivery"`
|
||||
StreamIndex int `json:"stream_index,omitempty"`
|
||||
}
|
||||
|
||||
// extToCodec maps the file extension to the inner codec name.
|
||||
@@ -86,7 +94,17 @@ func (s *SubtitleService) Discover(ctx context.Context, mediaID string) ([]Subti
|
||||
// DiscoverExternalOnly 只返回媒体旁边的外挂字幕文件,不含容器内嵌字幕轨。
|
||||
// Emby 字幕接口(/Videos/:id/Subtitles/...)用。
|
||||
func (s *SubtitleService) DiscoverExternalOnly(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
|
||||
return s.discover(ctx, mediaID)
|
||||
tracks, err := s.discover(ctx, mediaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]SubtitleTrack, 0, len(tracks))
|
||||
for _, track := range tracks {
|
||||
if track.Source != "embedded" {
|
||||
out = append(out, track)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *SubtitleService) discover(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
|
||||
@@ -187,16 +205,153 @@ func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string)
|
||||
}
|
||||
lang := detectLang(fullName, matchedBase)
|
||||
tracks = append(tracks, SubtitleTrack{
|
||||
Lang: lang,
|
||||
Label: lang,
|
||||
Path: filepath.Join(c, e.Name()),
|
||||
Codec: codec,
|
||||
Lang: lang,
|
||||
Label: lang,
|
||||
Path: filepath.Join(c, e.Name()),
|
||||
Codec: codec,
|
||||
Source: "external",
|
||||
Delivery: "webvtt",
|
||||
})
|
||||
}
|
||||
}
|
||||
embedded, err := s.discoverEmbedded(ctx, m)
|
||||
if err != nil {
|
||||
if s.log != nil {
|
||||
s.log.Debug("discover embedded subtitles failed", zap.String("media_id", mediaID), zap.Error(err))
|
||||
}
|
||||
} else {
|
||||
tracks = append(tracks, embedded...)
|
||||
}
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
type embeddedSubtitleProbe struct {
|
||||
Streams []struct {
|
||||
Index int `json:"index"`
|
||||
CodecName string `json:"codec_name"`
|
||||
Tags struct {
|
||||
Language string `json:"language"`
|
||||
Title string `json:"title"`
|
||||
} `json:"tags"`
|
||||
Disposition struct {
|
||||
Default int `json:"default"`
|
||||
Forced int `json:"forced"`
|
||||
} `json:"disposition"`
|
||||
} `json:"streams"`
|
||||
}
|
||||
|
||||
var imageSubtitleCodecs = map[string]bool{
|
||||
"hdmv_pgs_subtitle": true,
|
||||
"dvd_subtitle": true,
|
||||
"dvb_subtitle": true,
|
||||
"xsub": true,
|
||||
}
|
||||
|
||||
func (s *SubtitleService) discoverEmbedded(ctx context.Context, media *model.Media) ([]SubtitleTrack, error) {
|
||||
if s == nil || s.cfg == nil {
|
||||
return nil, errors.New("subtitle probe unavailable")
|
||||
}
|
||||
input, err := s.resolveInput(ctx, media)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bin, err := resolveLocalExecutable(s.cfg.App.FFprobePath, "ffprobe")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
args := []string{"-v", "error"}
|
||||
if headers := ffmpegHeaderText(input.Headers); headers != "" {
|
||||
args = append(args, "-headers", headers)
|
||||
}
|
||||
args = append(args,
|
||||
"-select_streams", "s",
|
||||
"-show_entries", "stream=index,codec_name:stream_tags=language,title:stream_disposition=default,forced",
|
||||
"-of", "json", input.Source,
|
||||
)
|
||||
out, err := exec.CommandContext(probeCtx, bin, args...).Output() // #nosec G204 -- executable is resolved locally and arguments do not use a shell.
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var probe embeddedSubtitleProbe
|
||||
if err := json.Unmarshal(out, &probe); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return subtitleTracksFromProbe(probe), nil
|
||||
}
|
||||
|
||||
func subtitleTracksFromProbe(probe embeddedSubtitleProbe) []SubtitleTrack {
|
||||
tracks := make([]SubtitleTrack, 0, len(probe.Streams))
|
||||
for _, stream := range probe.Streams {
|
||||
codec := strings.ToLower(strings.TrimSpace(stream.CodecName))
|
||||
lang := strings.ToLower(strings.TrimSpace(stream.Tags.Language))
|
||||
if lang == "" {
|
||||
lang = "und"
|
||||
}
|
||||
label := strings.TrimSpace(stream.Tags.Title)
|
||||
if label == "" {
|
||||
label = lang
|
||||
}
|
||||
if stream.Disposition.Forced != 0 {
|
||||
label += "(强制)"
|
||||
} else if stream.Disposition.Default != 0 {
|
||||
label += "(默认)"
|
||||
}
|
||||
delivery := "webvtt"
|
||||
if imageSubtitleCodecs[codec] {
|
||||
delivery = "burn"
|
||||
}
|
||||
sourceLabel := "(内嵌)"
|
||||
if delivery == "burn" {
|
||||
sourceLabel = "(内嵌·图片)"
|
||||
}
|
||||
tracks = append(tracks, SubtitleTrack{
|
||||
Lang: lang,
|
||||
Label: label + sourceLabel,
|
||||
Path: "embedded:" + strconv.Itoa(stream.Index),
|
||||
Codec: codec,
|
||||
Source: "embedded",
|
||||
Delivery: delivery,
|
||||
StreamIndex: stream.Index,
|
||||
})
|
||||
}
|
||||
return tracks
|
||||
}
|
||||
|
||||
func (s *SubtitleService) SetStrmPlayTargetResolver(resolve func(context.Context, string) (*StrmPlayResult, error)) {
|
||||
if s != nil {
|
||||
s.strmResolve = resolve
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SubtitleService) resolveInput(ctx context.Context, media *model.Media) (transcodeInput, error) {
|
||||
if media == nil {
|
||||
return transcodeInput{}, ErrMediaNotFound
|
||||
}
|
||||
if !isStrmMediaRow(media) {
|
||||
if _, err := os.Stat(media.Path); err != nil {
|
||||
return transcodeInput{}, ErrMediaNotFound
|
||||
}
|
||||
return transcodeInput{Source: media.Path}, nil
|
||||
}
|
||||
raw := strings.TrimSpace(media.STRMURL)
|
||||
if raw == "" && strings.HasSuffix(strings.ToLower(media.Path), ".strm") {
|
||||
raw, _ = readLocalSTRMTarget(media.Path)
|
||||
}
|
||||
if s.strmResolve != nil {
|
||||
resolved, err := s.strmResolve(ctx, raw)
|
||||
if err != nil {
|
||||
return transcodeInput{}, err
|
||||
}
|
||||
return transcodeInputFromPlayResult(resolved)
|
||||
}
|
||||
if isHTTPPlaybackTarget(raw) {
|
||||
return transcodeInput{Source: raw}, nil
|
||||
}
|
||||
return transcodeInput{}, errors.New("subtitle source unavailable")
|
||||
}
|
||||
|
||||
// langTag matches the .zh / .zh-cn / .chs language sub-extensions.
|
||||
var langTag = regexp.MustCompile(`(?i)\.([a-z]{2,3}(?:[-_][a-z]{2,4})?)$`)
|
||||
|
||||
@@ -220,6 +375,13 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W
|
||||
if err != nil || m == nil {
|
||||
return errors.New("media not found")
|
||||
}
|
||||
if strings.HasPrefix(sub, "embedded:") {
|
||||
index, err := strconv.Atoi(strings.TrimPrefix(sub, "embedded:"))
|
||||
if err != nil || index < 0 {
|
||||
return errors.New("invalid embedded subtitle")
|
||||
}
|
||||
return s.serveEmbedded(ctx, m, index, w)
|
||||
}
|
||||
abs, err := filepath.Abs(sub)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -252,6 +414,28 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SubtitleService) serveEmbedded(ctx context.Context, media *model.Media, streamIndex int, w io.Writer) error {
|
||||
input, err := s.resolveInput(ctx, media)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bin, err := resolveLocalExecutable(s.cfg.App.FFmpegPath, "ffmpeg")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
args := []string{"-hide_banner", "-loglevel", "error"}
|
||||
args = append(args, ffmpegHTTPInputArgs(input)...)
|
||||
args = append(args, "-i", input.Source, "-map", "0:"+strconv.Itoa(streamIndex), "-f", "webvtt", "-")
|
||||
cmd := exec.CommandContext(ctx, bin, args...) // #nosec G204 -- executable is resolved locally and arguments do not use a shell.
|
||||
cmd.Stdout = w
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("extract embedded subtitle: %w: %s", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ServeRaw writes the subtitle file in its original format without any
|
||||
// WebVTT conversion. Emby/Jellyfin clients advertise the source codec (ASS,
|
||||
// subrip, etc.) in MediaStreams, then fetch the subtitle bytes via the
|
||||
|
||||
@@ -27,6 +27,7 @@ func srtToVTT(body string) string {
|
||||
func assToVTT(body string) string {
|
||||
out := strings.Builder{}
|
||||
out.WriteString("WEBVTT\n\n")
|
||||
seen := make(map[string]struct{})
|
||||
for i, line := range strings.Split(body, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "Dialogue:") {
|
||||
@@ -36,11 +37,22 @@ func assToVTT(body string) string {
|
||||
if len(parts) < 10 {
|
||||
continue
|
||||
}
|
||||
start := normaliseTimecode(parts[1])
|
||||
end := normaliseTimecode(parts[2])
|
||||
text := stripASSTags(parts[9])
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
key := start + "\x00" + end + "\x00" + text
|
||||
if _, duplicate := seen[key]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
fmt.Fprintf(&out, "%d\n%s --> %s\n%s\n\n",
|
||||
i,
|
||||
normaliseTimecode(parts[1]),
|
||||
normaliseTimecode(parts[2]),
|
||||
stripASSTags(parts[9]),
|
||||
start,
|
||||
end,
|
||||
text,
|
||||
)
|
||||
}
|
||||
return out.String()
|
||||
@@ -86,5 +98,9 @@ func normaliseTimecode(t string) string {
|
||||
var assTag = regexp.MustCompile(`\{[^}]*\}`)
|
||||
|
||||
func stripASSTags(s string) string {
|
||||
return assTag.ReplaceAllString(s, "")
|
||||
s = assTag.ReplaceAllString(s, "")
|
||||
s = strings.ReplaceAll(s, `\N`, "\n")
|
||||
s = strings.ReplaceAll(s, `\n`, "\n")
|
||||
s = strings.ReplaceAll(s, `\h`, "\u00a0")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
@@ -46,6 +48,27 @@ func TestSubtitleDiscoverNoTracksReturnsEmptySlice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedSubtitleProbeClassifiesTextAndBitmapTracks(t *testing.T) {
|
||||
var probe embeddedSubtitleProbe
|
||||
raw := []byte(`{"streams":[
|
||||
{"index":2,"codec_name":"ass","tags":{"language":"chi","title":"中文"},"disposition":{"default":1}},
|
||||
{"index":4,"codec_name":"hdmv_pgs_subtitle","tags":{"language":"eng"},"disposition":{"forced":1}}
|
||||
]}`)
|
||||
if err := json.Unmarshal(raw, &probe); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tracks := subtitleTracksFromProbe(probe)
|
||||
if len(tracks) != 2 {
|
||||
t.Fatalf("len(tracks) = %d, want 2", len(tracks))
|
||||
}
|
||||
if tracks[0].Delivery != "webvtt" || tracks[0].Path != "embedded:2" {
|
||||
t.Fatalf("text track = %#v", tracks[0])
|
||||
}
|
||||
if tracks[1].Delivery != "burn" || tracks[1].StreamIndex != 4 {
|
||||
t.Fatalf("bitmap track = %#v", tracks[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormaliseTimecode(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"0:00:01": "00:00:01",
|
||||
@@ -62,6 +85,25 @@ func TestNormaliseTimecode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssToVTTDeduplicatesDialogueAndNormalisesLineBreaks(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
`Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,{\an2}第一行\N第二行`,
|
||||
`Dialogue: 1,0:00:01.00,0:00:02.00,Copy,,0,0,0,,{\bord2}第一行\N第二行`,
|
||||
`Dialogue: 0,0:00:03.00,0:00:04.00,Default,,0,0,0,,{\i1}`,
|
||||
}, "\n")
|
||||
|
||||
got := assToVTT(body)
|
||||
if strings.Count(got, "第一行\n第二行") != 1 {
|
||||
t.Fatalf("duplicate ASS dialogue was not collapsed:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, `\N`) || strings.Contains(got, `\an2`) {
|
||||
t.Fatalf("ASS control sequences leaked into WebVTT:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "00:00:03.000 --> 00:00:04.000") {
|
||||
t.Fatalf("empty styled dialogue should be omitted:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitleServeRawWritesSourceBytes(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
|
||||
@@ -71,6 +71,9 @@ type hlsJob struct {
|
||||
// seekGen is the client `_seek` token for this job. Newer gens win;
|
||||
// older/missing gens must not cancel a mid-file restart.
|
||||
seekGen int64
|
||||
// subtitleStream is an absolute ffmpeg stream index to burn into the
|
||||
// picture, or -1 when no bitmap subtitle is selected.
|
||||
subtitleStream int
|
||||
// done is closed when the ffmpeg goroutine fully exits (after process death).
|
||||
done chan struct{}
|
||||
}
|
||||
@@ -121,6 +124,12 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
|
||||
// in-flight playlist refreshes from a destroyed player commonly arrive as
|
||||
// start=0 right after a scrub and would otherwise reset playback to the head.
|
||||
func (t *TranscoderService) EnsureJobFrom(ctx context.Context, mediaID string, startSec float64, seekGen int64) (string, error) {
|
||||
return t.EnsureJobFromSubtitle(ctx, mediaID, startSec, seekGen, -1)
|
||||
}
|
||||
|
||||
// EnsureJobFromSubtitle is EnsureJobFrom with an optional bitmap subtitle
|
||||
// stream. Selecting or closing a burned subtitle creates a new HLS generation.
|
||||
func (t *TranscoderService) EnsureJobFromSubtitle(ctx context.Context, mediaID string, startSec float64, seekGen int64, subtitleStream int) (string, error) {
|
||||
if !t.cfg.Transcoder.Enabled {
|
||||
return "", ErrTranscodeDisabled
|
||||
}
|
||||
@@ -141,12 +150,12 @@ func (t *TranscoderService) EnsureJobFrom(ctx context.Context, mediaID string, s
|
||||
|
||||
t.mu.Lock()
|
||||
if existing, ok := t.jobs[mediaID]; ok {
|
||||
if sameHLSStart(existing.startSec, startSec) {
|
||||
if sameHLSConfiguration(existing, startSec, subtitleStream) {
|
||||
t.touchJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
}
|
||||
if !shouldReplaceHLSJob(existing, startSec, seekGen) {
|
||||
if !shouldReplaceHLSJobConfiguration(existing, startSec, seekGen, subtitleStream) {
|
||||
t.touchJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
@@ -163,6 +172,9 @@ func (t *TranscoderService) EnsureJobFrom(ctx context.Context, mediaID string, s
|
||||
return "", err
|
||||
}
|
||||
input.StartSec = startSec
|
||||
if subtitleStream >= 0 {
|
||||
input.SubtitleStream = &subtitleStream
|
||||
}
|
||||
t.maybeFillDuration(ctx, m, input)
|
||||
if _, err := t.resolveFFmpegPath(); err != nil {
|
||||
return "", err
|
||||
@@ -177,12 +189,12 @@ func (t *TranscoderService) EnsureJobFrom(ctx context.Context, mediaID string, s
|
||||
|
||||
t.mu.Lock()
|
||||
if existing, ok := t.jobs[mediaID]; ok {
|
||||
if sameHLSStart(existing.startSec, startSec) {
|
||||
if sameHLSConfiguration(existing, startSec, subtitleStream) {
|
||||
t.touchJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
}
|
||||
if !shouldReplaceHLSJob(existing, startSec, seekGen) {
|
||||
if !shouldReplaceHLSJobConfiguration(existing, startSec, seekGen, subtitleStream) {
|
||||
t.touchJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
@@ -198,16 +210,21 @@ func (t *TranscoderService) EnsureJobFrom(ctx context.Context, mediaID string, s
|
||||
}
|
||||
|
||||
jobCtx, cancel := context.WithCancel(context.Background())
|
||||
jobEncoder := t.effectiveEncoder()
|
||||
if subtitleStream >= 0 {
|
||||
jobEncoder = "software (subtitle burn-in)"
|
||||
}
|
||||
job := &hlsJob{
|
||||
mediaID: mediaID,
|
||||
outputDir: outDir,
|
||||
cancel: cancel,
|
||||
startedAt: time.Now(),
|
||||
lastAccess: time.Now(),
|
||||
encoder: t.effectiveEncoder(),
|
||||
startSec: startSec,
|
||||
seekGen: seekGen,
|
||||
done: make(chan struct{}),
|
||||
mediaID: mediaID,
|
||||
outputDir: outDir,
|
||||
cancel: cancel,
|
||||
startedAt: time.Now(),
|
||||
lastAccess: time.Now(),
|
||||
encoder: jobEncoder,
|
||||
startSec: startSec,
|
||||
seekGen: seekGen,
|
||||
subtitleStream: subtitleStream,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
t.jobs[mediaID] = job
|
||||
t.mu.Unlock()
|
||||
@@ -229,6 +246,20 @@ func shouldReplaceHLSJob(existing *hlsJob, startSec float64, seekGen int64) bool
|
||||
if sameHLSStart(existing.startSec, startSec) {
|
||||
return false
|
||||
}
|
||||
return newerHLSGenerationMayReplace(existing, seekGen)
|
||||
}
|
||||
|
||||
func shouldReplaceHLSJobConfiguration(existing *hlsJob, startSec float64, seekGen int64, subtitleStream int) bool {
|
||||
if existing == nil {
|
||||
return true
|
||||
}
|
||||
if sameHLSConfiguration(existing, startSec, subtitleStream) {
|
||||
return false
|
||||
}
|
||||
return newerHLSGenerationMayReplace(existing, seekGen)
|
||||
}
|
||||
|
||||
func newerHLSGenerationMayReplace(existing *hlsJob, seekGen int64) bool {
|
||||
if seekGen > 0 && existing.seekGen > 0 && seekGen < existing.seekGen {
|
||||
return false
|
||||
}
|
||||
@@ -239,6 +270,12 @@ func shouldReplaceHLSJob(existing *hlsJob, startSec float64, seekGen int64) bool
|
||||
return true
|
||||
}
|
||||
|
||||
func sameHLSConfiguration(existing *hlsJob, startSec float64, subtitleStream int) bool {
|
||||
return existing != nil &&
|
||||
existing.subtitleStream == subtitleStream &&
|
||||
sameHLSStart(existing.startSec, startSec)
|
||||
}
|
||||
|
||||
func (t *TranscoderService) mediaStartGate(mediaID string) *sync.Mutex {
|
||||
v, _ := t.startGates.LoadOrStore(mediaID, &sync.Mutex{})
|
||||
return v.(*sync.Mutex)
|
||||
|
||||
@@ -11,9 +11,10 @@ import (
|
||||
)
|
||||
|
||||
type transcodeInput struct {
|
||||
Source string
|
||||
Headers map[string]string
|
||||
StartSec float64
|
||||
Source string
|
||||
Headers map[string]string
|
||||
StartSec float64
|
||||
SubtitleStream *int
|
||||
}
|
||||
|
||||
type ffmpegArgSettings struct {
|
||||
@@ -46,6 +47,13 @@ func buildFFmpegArgs(cfg *config.Config, source, playlist, segments string) []st
|
||||
func buildFFmpegArgsForInput(cfg *config.Config, input transcodeInput, playlist, segments string) []string {
|
||||
settings := ffmpegArgSettingsFromConfig(cfg)
|
||||
video := ffmpegVideoPlanForSettings(settings)
|
||||
if input.SubtitleStream != nil {
|
||||
// Bitmap subtitles must be composited in software. Keeping CUDA/QSV/
|
||||
// VAAPI frames here would require a download/upload filter chain that
|
||||
// differs by driver and is considerably less portable.
|
||||
settings.encoder = ""
|
||||
video = ffmpegVideoPlanForSettings(settings)
|
||||
}
|
||||
|
||||
// Mid-file restarts must not use -re: output -ss would otherwise crawl to the
|
||||
// seek point at 1x wall-clock before emitting the first HLS segment.
|
||||
@@ -140,7 +148,16 @@ func appendInputAndVideoArgs(args []string, input transcodeInput, settings ffmpe
|
||||
args = append(args, "-ss", ss)
|
||||
}
|
||||
args = append(args, "-i", input.Source)
|
||||
args = append(args, "-map", "0:v:0?", "-map", "0:a:0?", "-vf", video.filter, "-c:v", video.codec)
|
||||
if input.SubtitleStream != nil {
|
||||
filter := fmt.Sprintf(
|
||||
"[0:v:0][0:%d]overlay=0:0:eof_action=pass,scale=-2:min(%d\\,ih)[v]",
|
||||
*input.SubtitleStream,
|
||||
settings.height,
|
||||
)
|
||||
args = append(args, "-filter_complex", filter, "-map", "[v]", "-map", "0:a:0?", "-c:v", video.codec)
|
||||
} else {
|
||||
args = append(args, "-map", "0:v:0?", "-map", "0:a:0?", "-vf", video.filter, "-c:v", video.codec)
|
||||
}
|
||||
if settings.threads > 0 && video.codec == "libx264" {
|
||||
args = append(args, "-threads", strconv.Itoa(settings.threads))
|
||||
}
|
||||
|
||||
@@ -255,6 +255,29 @@ func TestBuildFFmpegArgsHTTPSeekAfterDashI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFFmpegArgsBurnsBitmapSubtitleInSoftware(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Transcoder.HardwareAccel = true
|
||||
cfg.Transcoder.Encoder = "nvenc"
|
||||
cfg.Transcoder.MaxHeight = 720
|
||||
cfg.Transcoder.SegmentSeconds = 4
|
||||
stream := 3
|
||||
args := buildFFmpegArgsForInput(cfg, transcodeInput{
|
||||
Source: "/x.mkv",
|
||||
SubtitleStream: &stream,
|
||||
}, "/o/x.m3u8", "/o/seg_%05d.ts")
|
||||
joined := strings.Join(args, " ")
|
||||
if !strings.Contains(joined, "[0:v:0][0:3]overlay=0:0:eof_action=pass") {
|
||||
t.Fatalf("bitmap subtitle overlay missing: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "-map [v]") || !strings.Contains(joined, "-c:v libx264") {
|
||||
t.Fatalf("burn-in should use the filtered software video stream: %s", joined)
|
||||
}
|
||||
if strings.Contains(joined, "cuda") || strings.Contains(joined, "h264_nvenc") {
|
||||
t.Fatalf("burn-in must not retain hardware-only frames: %s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameHLSStart(t *testing.T) {
|
||||
if !sameHLSStart(10, 10.2) {
|
||||
t.Fatal("expected close starts to match")
|
||||
@@ -283,12 +306,28 @@ func TestShouldReplaceHLSJob(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBitmapSubtitleChangeReplacesHLSJob(t *testing.T) {
|
||||
existing := &hlsJob{startSec: 120, seekGen: 1000, subtitleStream: 2}
|
||||
if !shouldReplaceHLSJobConfiguration(existing, 120, 1001, 3) {
|
||||
t.Fatal("changing bitmap subtitle must replace the HLS generation")
|
||||
}
|
||||
if !shouldReplaceHLSJobConfiguration(existing, 120, 1001, -1) {
|
||||
t.Fatal("closing bitmap subtitle must replace the HLS generation")
|
||||
}
|
||||
if shouldReplaceHLSJobConfiguration(existing, 120.2, 1001, 2) {
|
||||
t.Fatal("same subtitle and nearby start should reuse the HLS generation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterHLSSegmentQueryDropsStart(t *testing.T) {
|
||||
got := filterHLSSegmentQuery("token=abc&start=120.5&profile_id=1")
|
||||
got := filterHLSSegmentQuery("token=abc&start=120.5&_seek=1001&profile_id=1")
|
||||
if strings.Contains(got, "start=") {
|
||||
t.Fatalf("start should be stripped, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "token=abc") || !strings.Contains(got, "profile_id=1") {
|
||||
t.Fatalf("auth/profile query should remain, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "_seek=1001") {
|
||||
t.Fatalf("_seek must remain to isolate cached segment generations, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ export function streamURL(mediaId: string): string {
|
||||
|
||||
// hlsURL returns the m3u8 playlist URL fed into hls.js.
|
||||
// startSec > 0 asks the server to (re)start ffmpeg from that source offset.
|
||||
export function hlsURL(mediaId: string, startSec = 0): string {
|
||||
export function hlsURL(mediaId: string, startSec = 0, subtitleStream?: number): string {
|
||||
const safeStart = Math.max(0, Math.round(startSec * 1000) / 1000)
|
||||
// Always send start= (including 0) so the server can tell an intentional
|
||||
// restart-from-head apart from a missing query on a stale refresh.
|
||||
@@ -155,7 +155,11 @@ export function hlsURL(mediaId: string, startSec = 0): string {
|
||||
// Monotonic-ish client generation: newer seeks win; older in-flight playlist
|
||||
// requests must not cancel the active ffmpeg job back to t=0.
|
||||
const bust = `&_seek=${Date.now()}`
|
||||
return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}${start}${bust}`
|
||||
const subtitle =
|
||||
subtitleStream !== undefined && subtitleStream >= 0
|
||||
? `&subtitle=${encodeURIComponent(String(subtitleStream))}`
|
||||
: ''
|
||||
return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}${start}${subtitle}${bust}`
|
||||
}
|
||||
|
||||
// Stop an on-demand HLS job. keepalive makes the request survive page
|
||||
|
||||
@@ -7,6 +7,9 @@ export interface SubtitleTrack {
|
||||
path: string
|
||||
url: string
|
||||
codec: string
|
||||
source: 'external' | 'embedded'
|
||||
delivery: 'webvtt' | 'burn'
|
||||
stream_index?: number
|
||||
}
|
||||
|
||||
export const subtitlesAPI = {
|
||||
|
||||
@@ -30,6 +30,8 @@ function formatTime(s: number): string {
|
||||
|
||||
type PlayerControlsProps = {
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
uiVisible: boolean
|
||||
onUiVisibleChange: (visible: boolean) => void
|
||||
subs: SubtitleTrack[]
|
||||
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
|
||||
subtitleIndex: number
|
||||
@@ -56,6 +58,8 @@ type PlayerControlsProps = {
|
||||
|
||||
export function PlayerControls({
|
||||
videoRef,
|
||||
uiVisible,
|
||||
onUiVisibleChange,
|
||||
subs,
|
||||
subtitleIndex,
|
||||
onSelectSubtitle,
|
||||
@@ -88,7 +92,6 @@ export function PlayerControls({
|
||||
const [muted, setMuted] = useState(false)
|
||||
const [fullscreen, setFullscreen] = useState(false)
|
||||
const [pip, setPip] = useState(false)
|
||||
const [uiVisible, setUiVisible] = useState(true)
|
||||
const [controlsHovered, setControlsHovered] = useState(false)
|
||||
const [isScrubbing, setIsScrubbing] = useState(false)
|
||||
const [scrubValue, setScrubValue] = useState<number | null>(null)
|
||||
@@ -159,14 +162,14 @@ export function PlayerControls({
|
||||
!danmakuOpenRef.current &&
|
||||
!playlistOpenRef.current
|
||||
) {
|
||||
setUiVisible(false)
|
||||
onUiVisibleChange(false)
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
const onMove = () => {
|
||||
setUiVisible(true)
|
||||
onUiVisibleChange(true)
|
||||
resetTimer()
|
||||
}
|
||||
|
||||
@@ -177,7 +180,7 @@ export function PlayerControls({
|
||||
}
|
||||
if (el.paused || controlsHoveredRef.current || isScrubbingRef.current || playlistOpenRef.current) return
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
setUiVisible(false)
|
||||
onUiVisibleChange(false)
|
||||
}
|
||||
|
||||
const syncPlay = () => {
|
||||
@@ -248,7 +251,7 @@ export function PlayerControls({
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [videoRef, knownDuration, streamOffset])
|
||||
}, [videoRef, knownDuration, streamOffset, onUiVisibleChange])
|
||||
|
||||
// Keep the scrubber max in sync when metadata duration arrives after mount.
|
||||
useEffect(() => {
|
||||
@@ -260,16 +263,16 @@ export function PlayerControls({
|
||||
// 当悬停或菜单状态改变时,更新控制栏计时器
|
||||
useEffect(() => {
|
||||
if (controlsHovered || isScrubbing || subtitleMenuOpen || danmakuOpen || playlistOpen) {
|
||||
setUiVisible(true)
|
||||
onUiVisibleChange(true)
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
} else {
|
||||
const el = video()
|
||||
if (el && !el.paused) {
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
hideTimerRef.current = setTimeout(() => setUiVisible(false), 3000)
|
||||
hideTimerRef.current = setTimeout(() => onUiVisibleChange(false), 3000)
|
||||
}
|
||||
}
|
||||
}, [controlsHovered, isScrubbing, subtitleMenuOpen, danmakuOpen, playlistOpen])
|
||||
}, [controlsHovered, isScrubbing, subtitleMenuOpen, danmakuOpen, playlistOpen, onUiVisibleChange])
|
||||
|
||||
const togglePlay = () => {
|
||||
const el = video()
|
||||
@@ -301,7 +304,7 @@ export function PlayerControls({
|
||||
|
||||
const handleSeekStart = () => {
|
||||
setIsScrubbing(true)
|
||||
setUiVisible(true)
|
||||
onUiVisibleChange(true)
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
}
|
||||
|
||||
@@ -361,6 +364,7 @@ export function PlayerControls({
|
||||
onMouseEnter={() => setControlsHovered(true)}
|
||||
onMouseLeave={() => setControlsHovered(false)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerUp={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 text-white sm:flex-nowrap sm:gap-2.5">
|
||||
{/* 上一集 */}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetState
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { mediaAPI, type ManualScrapeCandidate } from '../api/library'
|
||||
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
||||
import type { Media } from '../types'
|
||||
import {
|
||||
candidateKey,
|
||||
@@ -103,7 +104,7 @@ export function useManualScrapeDialogState({
|
||||
function useManualScrapeFormState() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedProviders, setSelectedProviders] = useState<string[]>([])
|
||||
const [includeEpisodeArtwork, setIncludeEpisodeArtwork] = useState(false)
|
||||
const [includeEpisodeArtwork, setIncludeEpisodeArtwork] = useEpisodeArtworkPreference()
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [applyingKey, setApplyingKey] = useState('')
|
||||
const [items, setItems] = useState<ManualScrapeCandidate[]>([])
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { adminAPI } from '../api/admin'
|
||||
|
||||
const SETTING_KEY = 'scrape.episode_images'
|
||||
|
||||
// This is an instance-wide scraper preference. The existing admin settings
|
||||
// endpoint stores it in the Setting table, so it follows the server instead
|
||||
// of a particular browser.
|
||||
export function useEpisodeArtworkPreference(): [boolean, Dispatch<SetStateAction<boolean>>] {
|
||||
const [enabled, setEnabledState] = useState(false)
|
||||
const enabledRef = useRef(false)
|
||||
const changedLocally = useRef(false)
|
||||
const saveQueue = useRef(Promise.resolve())
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
adminAPI.listSettings()
|
||||
.then((settings) => {
|
||||
if (!active || changedLocally.current) return
|
||||
const value = settings.find((setting) => setting.key === SETTING_KEY)?.value
|
||||
const next = value === 'true' || value === '1'
|
||||
enabledRef.current = next
|
||||
setEnabledState(next)
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
toast.error('读取每集图片设置失败')
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setEnabled = useCallback((nextValue: SetStateAction<boolean>) => {
|
||||
const next = typeof nextValue === 'function' ? nextValue(enabledRef.current) : nextValue
|
||||
if (enabledRef.current === next) return
|
||||
changedLocally.current = true
|
||||
enabledRef.current = next
|
||||
setEnabledState(next)
|
||||
|
||||
saveQueue.current = saveQueue.current
|
||||
.catch(() => undefined)
|
||||
.then(() => adminAPI.updateSetting(SETTING_KEY, String(next)))
|
||||
.catch(() => {
|
||||
toast.error('保存每集图片设置失败')
|
||||
})
|
||||
}, [])
|
||||
|
||||
return [enabled, setEnabled]
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { toolsAPI } from '../api/tools'
|
||||
import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
|
||||
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
||||
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
||||
import {
|
||||
LibrariesContent,
|
||||
@@ -21,7 +22,7 @@ export function LibrariesPage() {
|
||||
const { pinnedIds, loading: pinnedLoading, togglePin } = usePinnedLibraries()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [repairing, setRepairing] = useState(false)
|
||||
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useState(false)
|
||||
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useEpisodeArtworkPreference()
|
||||
const [repairMsg, setRepairMsg] = useState('')
|
||||
|
||||
const fetchedLibIdsRef = useRef<Set<string>>(new Set())
|
||||
|
||||
+185
-62
@@ -13,6 +13,7 @@ import type { Media } from '../types'
|
||||
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
|
||||
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, type PlayerMode } from './playerPageModel'
|
||||
import { classifyDirectPlayError } from './directPlayError'
|
||||
import { apiErrorMessage } from './StrmManagePage'
|
||||
import { PlayerTopBar } from './PlayerTopBar'
|
||||
import { PlayerVideoStage } from './PlayerVideoStage'
|
||||
@@ -33,23 +34,6 @@ import { mediaVersionsOf } from '../utils/mediaVersion'
|
||||
//
|
||||
// External subtitles next to the source file are auto-discovered and
|
||||
// attached as <track> elements.
|
||||
const SUBTITLE_STORAGE_KEY = 'mebox.subtitle'
|
||||
|
||||
// 初始字幕偏好:localStorage 记录上次选择的轨道(-1=关闭);没有偏好时
|
||||
// 默认 0(自动加载第一条字幕)。
|
||||
function initialSubtitleIndex(): number {
|
||||
try {
|
||||
const saved = localStorage.getItem(SUBTITLE_STORAGE_KEY)
|
||||
if (saved !== null && saved !== '') {
|
||||
const n = parseInt(saved, 10)
|
||||
if (Number.isFinite(n)) return n
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
export function PlayerPage() {
|
||||
const { id = '' } = useParams()
|
||||
const [params, setParams] = useSearchParams()
|
||||
@@ -59,17 +43,21 @@ export function PlayerPage() {
|
||||
const ref = useRef<HTMLVideoElement>(null)
|
||||
const hlsRef = useRef<Hls | null>(null)
|
||||
const lastSentRef = useRef(0)
|
||||
const directRetryRef = useRef(false)
|
||||
const retryingDirectRef = useRef(false)
|
||||
const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const [media, setMedia] = useState<Media | null>(null)
|
||||
const [mode, setMode] = useState<PlayerMode>('direct')
|
||||
const [subs, setSubs] = useState<SubtitleTrack[]>([])
|
||||
const [subtitleIndex, setSubtitleIndex] = useState<number>(initialSubtitleIndex)
|
||||
const [subtitleIndex, setSubtitleIndex] = useState<number>(0)
|
||||
const [hlsUnavailable, setHlsUnavailable] = useState(false)
|
||||
const [playerError, setPlayerError] = useState('')
|
||||
// 媒体元数据加载失败(404 / 无权限等):舞台区直接展示错误而不是永远「加载中」
|
||||
const [loadError, setLoadError] = useState('')
|
||||
// 「客户端直连解码」模式:宿主机不转码,播放器强制 direct play、隐藏 HLS 切换。
|
||||
const [directOnly, setDirectOnly] = useState(false)
|
||||
const [directOnlyKnown, setDirectOnlyKnown] = useState(false)
|
||||
const [resumePosition, setResumePosition] = useState(0)
|
||||
const [initialSeekDone, setInitialSeekDone] = useState(false)
|
||||
// HLS session source offset: playlist t=0 maps to this absolute second.
|
||||
@@ -121,6 +109,7 @@ export function PlayerPage() {
|
||||
.info()
|
||||
.then((info) => setDirectOnly(Boolean(info.direct_play_only)))
|
||||
.catch(() => setDirectOnly(false))
|
||||
.finally(() => setDirectOnlyKnown(true))
|
||||
}, [])
|
||||
|
||||
// 读取宿主机已保存的弹幕参数作为面板初始值(无 admin 权限也可读)。
|
||||
@@ -193,11 +182,40 @@ export function PlayerPage() {
|
||||
setDanmakuSelectedSource('')
|
||||
setDanmakuInfo(null)
|
||||
setDanmakuSearching(true)
|
||||
directRetryRef.current = false
|
||||
retryingDirectRef.current = false
|
||||
if (fallbackTimerRef.current) {
|
||||
clearTimeout(fallbackTimerRef.current)
|
||||
fallbackTimerRef.current = null
|
||||
}
|
||||
return () => {
|
||||
if (fallbackTimerRef.current) {
|
||||
clearTimeout(fallbackTimerRef.current)
|
||||
fallbackTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [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 clearFallbackTimer = useCallback(() => {
|
||||
if (fallbackTimerRef.current) {
|
||||
clearTimeout(fallbackTimerRef.current)
|
||||
fallbackTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load metadata and pick a default mode.
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
@@ -225,9 +243,8 @@ export function PlayerPage() {
|
||||
if (cancelled) return
|
||||
const list = tracks ?? []
|
||||
setSubs(list)
|
||||
// 记忆的轨道下标可能超出当前媒体的轨道数(不同媒体字幕数量不同),
|
||||
// 越界时回退到第一条;无字幕则关闭。
|
||||
setSubtitleIndex((cur) => (cur >= list.length ? (list.length > 0 ? 0 : -1) : cur))
|
||||
// 服务端始终把外挂字幕排在内嵌字幕前面,因此第一条就是默认优先轨。
|
||||
setSubtitleIndex(list.length > 0 ? 0 : -1)
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
@@ -242,6 +259,13 @@ export function PlayerPage() {
|
||||
// Depend on media.id (not the media object): refreshing duration after
|
||||
// MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE.
|
||||
const mediaId = media?.id
|
||||
const selectedSubtitle = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
||||
const burnedSubtitleStream =
|
||||
selectedSubtitle?.delivery === 'burn' ? selectedSubtitle.stream_index : undefined
|
||||
// 直连不使用烧录字幕参数。字幕列表通常比媒体信息晚返回,若把该参数直接
|
||||
// 作为播放 effect 的依赖,会在 STRM 的 302 直链仍在建立时重复设置 src,
|
||||
// Chromium 会把被中断的首次加载报告成播放错误并误触发 HLS 回退。
|
||||
const activeBurnedSubtitleStream = mode === 'hls' ? burnedSubtitleStream : undefined
|
||||
const mediaRef = useRef(media)
|
||||
mediaRef.current = media
|
||||
useEffect(() => {
|
||||
@@ -254,11 +278,19 @@ export function PlayerPage() {
|
||||
const video = ref.current
|
||||
const durationSec = currentMedia.duration_sec || 0
|
||||
if (mode === 'hls') {
|
||||
const url = hlsURL(mediaId, hlsStartSec)
|
||||
const url = hlsURL(mediaId, hlsStartSec, activeBurnedSubtitleStream)
|
||||
void import('hls.js').then(({ default: HlsCtor }) => {
|
||||
if (cancelled || !ref.current) return
|
||||
if (HlsCtor.isSupported()) {
|
||||
const hls = new HlsCtor({ enableWorker: true, lowLatencyMode: false })
|
||||
const hls = new HlsCtor({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
// The server waits up to 45s for the first segment. Slow two-core
|
||||
// hosts and remote STRM sources regularly need more than hls.js's
|
||||
// 10s default, which otherwise aborts a healthy transcode.
|
||||
manifestLoadingTimeOut: 60_000,
|
||||
manifestLoadingMaxRetry: 1,
|
||||
})
|
||||
try {
|
||||
video.currentTime = 0
|
||||
} catch {
|
||||
@@ -273,6 +305,7 @@ export function PlayerPage() {
|
||||
mediaAPI
|
||||
.get(mediaId)
|
||||
.then((fresh) => {
|
||||
if (cancelled || fresh.id !== mediaId) return
|
||||
if ((fresh.duration_sec || 0) > 0) setMedia(fresh)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
@@ -282,9 +315,7 @@ export function PlayerPage() {
|
||||
setHlsUnavailable(true)
|
||||
setPlayerError('HLS 转码不可用,正在尝试直接播放原始文件。若出现有画面无声音,通常是 MKV/AC3/EAC3 音轨需要配置本机 ffmpeg 转码为 AAC。')
|
||||
toast.error('HLS 转码失败,尝试切换到直接播放')
|
||||
setMode('direct')
|
||||
params.set('mode', 'direct')
|
||||
setParams(params, { replace: true })
|
||||
setPlaybackMode('direct')
|
||||
}
|
||||
})
|
||||
if (cancelled) {
|
||||
@@ -304,26 +335,46 @@ export function PlayerPage() {
|
||||
setHlsUnavailable(true)
|
||||
setPlayerError('当前浏览器不支持 HLS,正在尝试直接播放。')
|
||||
toast.error('当前浏览器不支持 HLS,降级到直接播放')
|
||||
setMode('direct')
|
||||
setPlaybackMode('direct')
|
||||
}
|
||||
}).catch(() => {
|
||||
if (cancelled) return
|
||||
setHlsUnavailable(true)
|
||||
setPlayerError('HLS 播放组件加载失败,正在尝试直接播放。')
|
||||
setMode('direct')
|
||||
setPlaybackMode('direct')
|
||||
})
|
||||
} else {
|
||||
video.src = streamURL(mediaId)
|
||||
const url = streamURL(mediaId)
|
||||
const absoluteURL = new URL(url, window.location.href).href
|
||||
// 其它异步播放器状态更新不应重启同一个直连请求;STRM 的重定向/换链
|
||||
// 比本地文件慢,重启请求可能产生一个短暂但会触发 onError 的中断。
|
||||
if (video.src !== absoluteURL) {
|
||||
directRetryRef.current = false
|
||||
clearFallbackTimer()
|
||||
video.src = url
|
||||
void video.play().catch(() => undefined)
|
||||
}
|
||||
if (hlsUnavailable && needsTranscodeForBrowser(currentMedia)) {
|
||||
setPlayerError('当前正在直连播放原始文件;此封装或音轨浏览器兼容性有限,可能只有画面没有声音。请配置本机 ffmpeg 后切回 HLS 转码播放。')
|
||||
}
|
||||
void video.play().catch(() => undefined)
|
||||
}
|
||||
const onPlaying = () => clearFallbackTimer()
|
||||
video.addEventListener('playing', onPlaying)
|
||||
return () => {
|
||||
cancelled = true
|
||||
video.removeEventListener('playing', onPlaying)
|
||||
teardownHls()
|
||||
}
|
||||
}, [hlsUnavailable, hlsStartSec, mediaId, mode, params, setParams, teardownHls])
|
||||
}, [
|
||||
activeBurnedSubtitleStream,
|
||||
clearFallbackTimer,
|
||||
hlsUnavailable,
|
||||
hlsStartSec,
|
||||
mediaId,
|
||||
mode,
|
||||
setPlaybackMode,
|
||||
teardownHls,
|
||||
])
|
||||
|
||||
// Stop host ffmpeg when leaving this HLS player. The keepalive request also
|
||||
// survives route navigation while the component is being torn down.
|
||||
@@ -374,21 +425,30 @@ 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
|
||||
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 })
|
||||
return
|
||||
}
|
||||
setInitialSeekDone(true)
|
||||
}
|
||||
if (video.readyState >= 1) {
|
||||
if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
|
||||
applyResume()
|
||||
} else {
|
||||
video.addEventListener('loadedmetadata', applyResume, { once: true })
|
||||
return () => video.removeEventListener('loadedmetadata', applyResume)
|
||||
video.addEventListener('canplay', applyResume, { once: true })
|
||||
}
|
||||
return () => {
|
||||
video.removeEventListener('canplay', applyResume)
|
||||
if (onSeeked) video.removeEventListener('seeked', onSeeked)
|
||||
}
|
||||
}, [resumePosition, initialSeekDone, mode, hlsStartSec])
|
||||
|
||||
@@ -508,7 +568,11 @@ export function PlayerPage() {
|
||||
[navigate, location.search, location.state],
|
||||
)
|
||||
|
||||
const versionList = useMemo(() => mediaVersionsOf(media), [media])
|
||||
// URL 已切换但新媒体尚未返回时,不渲染上一条媒体遗留的版本入口。
|
||||
const versionList = useMemo(
|
||||
() => (media?.id === id ? mediaVersionsOf(media) : []),
|
||||
[id, media],
|
||||
)
|
||||
const switchVersion = useCallback(
|
||||
(version: Media) => {
|
||||
if (!version?.id || version.id === media?.id) return
|
||||
@@ -600,6 +664,28 @@ export function PlayerPage() {
|
||||
|
||||
const isDirectStream = isDirectStreamMedia(media)
|
||||
|
||||
// 没有外挂字幕且第一条内嵌字幕是图片时,默认轨需要通过 HLS 烧录。
|
||||
useEffect(() => {
|
||||
if (
|
||||
!directOnlyKnown ||
|
||||
directOnly ||
|
||||
isDirectStream ||
|
||||
selectedSubtitle?.delivery !== 'burn' ||
|
||||
mode === 'hls'
|
||||
) {
|
||||
return
|
||||
}
|
||||
setHlsStartSec(ref.current?.currentTime || 0)
|
||||
setPlaybackMode('hls')
|
||||
}, [
|
||||
directOnly,
|
||||
directOnlyKnown,
|
||||
isDirectStream,
|
||||
mode,
|
||||
selectedSubtitle?.delivery,
|
||||
setPlaybackMode,
|
||||
])
|
||||
|
||||
const toggleMode = useCallback(() => {
|
||||
if (isDirectStream) {
|
||||
toast('该媒体为直连播放,无需且不支持转码')
|
||||
@@ -609,10 +695,8 @@ export function PlayerPage() {
|
||||
if (next === 'hls') {
|
||||
setHlsStartSec(0)
|
||||
}
|
||||
setMode(next)
|
||||
params.set('mode', next)
|
||||
setParams(params, { replace: true })
|
||||
}, [isDirectStream, mode, params, setParams])
|
||||
setPlaybackMode(next)
|
||||
}, [isDirectStream, mode, setPlaybackMode])
|
||||
|
||||
const handleSeekAbsolute = useCallback(
|
||||
(absoluteSec: number) => {
|
||||
@@ -646,21 +730,63 @@ export function PlayerPage() {
|
||||
[hlsStartSec, mode],
|
||||
)
|
||||
|
||||
// 用户切换字幕轨道:-1=关闭;记忆偏好,下次播放默认沿用。
|
||||
// 用户切换图片字幕时从当前位置创建新的 HLS 烧录任务;文本字幕只在网页层切换。
|
||||
const selectSubtitle = useCallback((index: number) => {
|
||||
setSubtitleIndex(index)
|
||||
try {
|
||||
localStorage.setItem(SUBTITLE_STORAGE_KEY, String(index))
|
||||
} catch {
|
||||
// ignore
|
||||
const oldTrack = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
||||
const nextTrack = index >= 0 ? subs[index] : undefined
|
||||
if (nextTrack?.delivery === 'burn' && (directOnly || isDirectStream)) {
|
||||
toast.error('图片字幕需要开启 HLS 转码后才能显示')
|
||||
return
|
||||
}
|
||||
}, [])
|
||||
const burnChanged =
|
||||
oldTrack?.delivery === 'burn' || nextTrack?.delivery === 'burn'
|
||||
if (burnChanged && mode === 'hls' && ref.current) {
|
||||
setHlsStartSec(hlsStartSec + (ref.current.currentTime || 0))
|
||||
}
|
||||
setSubtitleIndex(index)
|
||||
if (nextTrack?.delivery === 'burn' && mode !== 'hls' && !directOnly && !isDirectStream) {
|
||||
setHlsStartSec(ref.current?.currentTime || 0)
|
||||
setPlaybackMode('hls')
|
||||
}
|
||||
}, [directOnly, hlsStartSec, isDirectStream, mode, setPlaybackMode, subs, subtitleIndex])
|
||||
|
||||
const handleVideoError = useCallback(() => {
|
||||
// 浏览器对 <video src> 的错误描述非常有限,把详细原因
|
||||
// 转给开发者控制台 + 一条 toast;常见原因是 codec 不支持。
|
||||
if (mode === 'direct') {
|
||||
if (isRemoteEmbyID(media?.id) || isDirectStreamMedia(media)) {
|
||||
const video = ref.current
|
||||
if (mode !== 'direct') {
|
||||
setPlayerError('视频播放失败,请检查文件是否存在,或确认 ffmpeg 已正确配置。')
|
||||
toast.error('视频播放失败,请检查文件是否存在')
|
||||
return
|
||||
}
|
||||
|
||||
if (retryingDirectRef.current) return
|
||||
|
||||
const expectedSrc = mediaId ? new URL(streamURL(mediaId), window.location.href).href : ''
|
||||
const action = classifyDirectPlayError({
|
||||
errorCode: video?.error?.code,
|
||||
readyState: video?.readyState ?? 0,
|
||||
elementSrc: video?.src ?? '',
|
||||
expectedSrc,
|
||||
alreadyRetried: directRetryRef.current,
|
||||
})
|
||||
if (action === 'ignore') return
|
||||
|
||||
if (action === 'retry' && video && mediaId) {
|
||||
directRetryRef.current = true
|
||||
retryingDirectRef.current = true
|
||||
try {
|
||||
video.load()
|
||||
void video.play().catch(() => undefined)
|
||||
} finally {
|
||||
retryingDirectRef.current = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const fallbackDirectPlay = () => {
|
||||
if (modeRef.current !== 'direct') return
|
||||
const current = ref.current
|
||||
if (current && current.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return
|
||||
if (isRemoteEmbyID(mediaRef.current?.id) || isDirectStreamMedia(mediaRef.current)) {
|
||||
setPlayerError('直接播放失败。该媒体为远程 Emby 挂载直连播放(不进行转码);当前浏览器可能不支持该视频编码或音频格式,建议使用外部播放器(如 PotPlayer / VLC / IINA)播放。')
|
||||
toast.error('直接播放失败,建议使用外部播放器')
|
||||
} else if (directOnly) {
|
||||
@@ -671,16 +797,13 @@ export function PlayerPage() {
|
||||
toast.error('直接播放失败,HLS 转码不可用')
|
||||
} else {
|
||||
toast.error('直接播放失败,切换到 HLS 转码')
|
||||
setMode('hls')
|
||||
params.set('mode', 'hls')
|
||||
setParams(params, { replace: true })
|
||||
setPlaybackMode('hls')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setPlayerError('视频播放失败,请检查文件是否存在,或确认 ffmpeg 已正确配置。')
|
||||
toast.error('视频播放失败,请检查文件是否存在')
|
||||
}, [directOnly, hlsUnavailable, media, mode, params, setParams])
|
||||
clearFallbackTimer()
|
||||
fallbackTimerRef.current = setTimeout(fallbackDirectPlay, 1500)
|
||||
}, [clearFallbackTimer, directOnly, hlsUnavailable, mediaId, mode, setPlaybackMode])
|
||||
|
||||
const danmakuAutoTitle =
|
||||
danmakuInfo?.animeTitle ||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
import type { PointerEvent, ReactNode, RefObject } from 'react'
|
||||
|
||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
|
||||
@@ -7,6 +7,54 @@ import type { Media } from '../types'
|
||||
import { DanmakuStage } from '../components/DanmakuStage'
|
||||
import { PlayerControls } from '../components/PlayerControls'
|
||||
|
||||
type SubtitleCue = {
|
||||
startTime: number
|
||||
endTime: number
|
||||
text: string
|
||||
}
|
||||
|
||||
function parseVTTTimestamp(value: string): number {
|
||||
const parts = value.trim().replace(',', '.').split(':')
|
||||
if (parts.length !== 2 && parts.length !== 3) return Number.NaN
|
||||
const seconds = Number(parts.pop())
|
||||
const minutes = Number(parts.pop())
|
||||
const hours = parts.length > 0 ? Number(parts.pop()) : 0
|
||||
if (![hours, minutes, seconds].every(Number.isFinite)) return Number.NaN
|
||||
return hours * 3600 + minutes * 60 + seconds
|
||||
}
|
||||
|
||||
function parseWebVTTCues(body: string): SubtitleCue[] {
|
||||
const blocks = body
|
||||
.replace(/^\uFEFF/, '')
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.split(/\n{2,}/)
|
||||
const cues: SubtitleCue[] = []
|
||||
|
||||
for (const block of blocks) {
|
||||
const lines = block.split('\n')
|
||||
const timingIndex = lines.findIndex((line) => line.includes('-->'))
|
||||
if (timingIndex < 0) continue
|
||||
|
||||
const [rawStart, rawEnd] = lines[timingIndex].split('-->', 2)
|
||||
const startTime = parseVTTTimestamp(rawStart)
|
||||
const endTime = parseVTTTimestamp(rawEnd.trim().split(/\s+/, 1)[0])
|
||||
const text = lines.slice(timingIndex + 1).join('\n').trim()
|
||||
if (Number.isFinite(startTime) && Number.isFinite(endTime) && endTime >= startTime && text) {
|
||||
cues.push({ startTime, endTime, text })
|
||||
}
|
||||
}
|
||||
|
||||
return cues
|
||||
}
|
||||
|
||||
function uniqueSubtitleTexts(texts: string[]): string[] {
|
||||
const unique = [...new Set(texts.map((text) => text.trim()).filter(Boolean))]
|
||||
// ponytail: WebVTT cannot preserve ASS layers/positions. Keep at most two
|
||||
// simultaneous blocks for bilingual subtitles; use libass if full ASS
|
||||
// typesetting support is added later.
|
||||
return unique.slice(-2)
|
||||
}
|
||||
|
||||
type PlayerVideoStageProps = {
|
||||
media: Media | null
|
||||
/** 媒体元数据加载失败提示(非空时替代「加载中」展示)。 */
|
||||
@@ -85,8 +133,65 @@ export function PlayerVideoStage({
|
||||
const stageRef = useRef<HTMLDivElement>(null)
|
||||
const [videoRatio, setVideoRatio] = useState<number | null>(null)
|
||||
const [stageRect, setStageRect] = useState<{ width: number; height: number } | null>(null)
|
||||
const [controlsVisible, setControlsVisible] = useState(true)
|
||||
const revealControlsOnlyRef = useRef(false)
|
||||
// 当前展示的字幕文本(由自定义字幕层渲染,100% 透明无黑框)
|
||||
const [activeCueText, setActiveCueText] = useState<string>('')
|
||||
// 独立保存完整 WebVTT 时间轴。HLS seek 会替换媒体源,Chromium 此时可能清空
|
||||
// <track>.track.cues;独立时间轴不受 MediaSource 重挂载和轨道 mode 切换影响。
|
||||
const [subtitleTimeline, setSubtitleTimeline] = useState<{
|
||||
path: string
|
||||
cues: SubtitleCue[]
|
||||
} | null>(null)
|
||||
// 直连 302 尚未完成时插入 <track> 会中断加载并误报播放失败;等 canplay 再挂。
|
||||
const [tracksArmed, setTracksArmed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setTracksArmed(false)
|
||||
const video = videoRef.current
|
||||
if (!video || !media?.id) return
|
||||
const arm = () => setTracksArmed(true)
|
||||
if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
|
||||
arm()
|
||||
return
|
||||
}
|
||||
video.addEventListener('canplay', arm)
|
||||
video.addEventListener('playing', arm)
|
||||
return () => {
|
||||
video.removeEventListener('canplay', arm)
|
||||
video.removeEventListener('playing', arm)
|
||||
}
|
||||
}, [media?.id, videoRef])
|
||||
|
||||
useEffect(() => {
|
||||
const selectedTrack = subs[subtitleIndex]
|
||||
if (!media || subtitleIndex < 0 || !selectedTrack || selectedTrack.delivery !== 'webvtt') {
|
||||
setSubtitleTimeline(null)
|
||||
return
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
setSubtitleTimeline(null)
|
||||
fetch(subtitlesAPI.url(media.id, selectedTrack.path), { signal: controller.signal })
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error(`subtitle request failed: ${response.status}`)
|
||||
return response.text()
|
||||
})
|
||||
.then((body) => {
|
||||
setSubtitleTimeline({
|
||||
path: selectedTrack.path,
|
||||
cues: parseWebVTTCues(body),
|
||||
})
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!(error instanceof DOMException && error.name === 'AbortError')) {
|
||||
// 保留原生 TextTrack 作为请求失败时的降级路径。
|
||||
setSubtitleTimeline(null)
|
||||
}
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}, [media, subs, subtitleIndex])
|
||||
|
||||
// 监听舞台容器的真实尺寸(响应窗口大小调整和全屏切换)
|
||||
useEffect(() => {
|
||||
@@ -123,13 +228,26 @@ export function PlayerVideoStage({
|
||||
}
|
||||
}, [videoRef, media])
|
||||
|
||||
// 点击视频切换播放/暂停;双击切换全屏(控制栏事件自行阻止冒泡)。
|
||||
// 桌面端点击直接切换播放;移动端控制栏隐藏时首次轻触只唤出控制栏,
|
||||
// 控制栏已显示时再次轻触才切换播放/暂停。
|
||||
const togglePlay = () => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
if (video.paused) void video.play()?.catch(() => undefined)
|
||||
else video.pause()
|
||||
}
|
||||
const handleStagePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
revealControlsOnlyRef.current = event.pointerType === 'touch' && !controlsVisible
|
||||
}
|
||||
const handleStageClick = () => {
|
||||
if (revealControlsOnlyRef.current) {
|
||||
revealControlsOnlyRef.current = false
|
||||
setControlsVisible(true)
|
||||
return
|
||||
}
|
||||
revealControlsOnlyRef.current = false
|
||||
togglePlay()
|
||||
}
|
||||
const toggleFullscreen = () => {
|
||||
const stage = stageRef.current
|
||||
if (!stage) return
|
||||
@@ -142,15 +260,34 @@ export function PlayerVideoStage({
|
||||
// 由下方的 React 自定义层输出 100% 纯透明背景、高清晰文字阴影的字幕。
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video || subs.length === 0 || subtitleIndex < 0 || !subs[subtitleIndex]) {
|
||||
const selectedTrack = subs[subtitleIndex]
|
||||
if (
|
||||
!video ||
|
||||
!tracksArmed ||
|
||||
subs.length === 0 ||
|
||||
subtitleIndex < 0 ||
|
||||
!selectedTrack ||
|
||||
selectedTrack.delivery === 'burn'
|
||||
) {
|
||||
setActiveCueText('')
|
||||
return
|
||||
}
|
||||
const trackIdx = subtitleIndex
|
||||
|
||||
const updateCue = () => {
|
||||
const trackEls = Array.from(video.querySelectorAll('track'))
|
||||
const selectedEl = trackEls[trackIdx]
|
||||
const absoluteTime = video.currentTime + (streamOffset ?? 0)
|
||||
if (subtitleTimeline?.path === selectedTrack.path) {
|
||||
const texts = uniqueSubtitleTexts(
|
||||
subtitleTimeline.cues
|
||||
.filter((cue) => absoluteTime >= cue.startTime && absoluteTime <= cue.endTime)
|
||||
.map((cue) => cue.text),
|
||||
)
|
||||
setActiveCueText(texts.join('\n'))
|
||||
return
|
||||
}
|
||||
|
||||
const selectedEl = video.querySelector<HTMLTrackElement>(
|
||||
`track[data-subtitle-index="${subtitleIndex}"]`,
|
||||
)
|
||||
const tt = selectedEl?.track
|
||||
if (!tt) {
|
||||
setActiveCueText('')
|
||||
@@ -160,35 +297,42 @@ export function PlayerVideoStage({
|
||||
// 优先从浏览器 activeCues 中取当前文本;若浏览器在 hidden 模式下延迟触发 cuechange,
|
||||
// 则从 tt.cues 中根据 video.currentTime 实时匹配当前字幕,确保初次加载无感立即可见。
|
||||
const texts: string[] = []
|
||||
if (tt.activeCues && tt.activeCues.length > 0) {
|
||||
if ((!streamOffset || streamOffset <= 0.05) && tt.activeCues && tt.activeCues.length > 0) {
|
||||
for (let i = 0; i < tt.activeCues.length; i++) {
|
||||
const cue = tt.activeCues[i] as VTTCue
|
||||
if (cue && cue.text) texts.push(cue.text)
|
||||
}
|
||||
} else if (tt.cues && tt.cues.length > 0) {
|
||||
const cur = video.currentTime
|
||||
for (let i = 0; i < tt.cues.length; i++) {
|
||||
const cue = tt.cues[i] as VTTCue
|
||||
if (cue && cur >= cue.startTime && cur <= cue.endTime && cue.text) {
|
||||
if (
|
||||
cue &&
|
||||
absoluteTime >= cue.startTime &&
|
||||
absoluteTime <= cue.endTime &&
|
||||
cue.text
|
||||
) {
|
||||
texts.push(cue.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
setActiveCueText(texts.join('\n'))
|
||||
setActiveCueText(uniqueSubtitleTexts(texts).join('\n'))
|
||||
}
|
||||
|
||||
const apply = () => {
|
||||
const trackEls = Array.from(video.querySelectorAll('track'))
|
||||
if (trackEls.length === 0) return
|
||||
trackEls.forEach((el, i) => {
|
||||
trackEls.forEach((el) => {
|
||||
const tt = el.track
|
||||
if (tt) {
|
||||
// 'hidden' 模式:浏览器解析 WebVTT 并触发 cuechange,但隐藏原生黑底 UI
|
||||
tt.mode = i === trackIdx ? 'hidden' : 'disabled'
|
||||
tt.mode =
|
||||
el.dataset.subtitleIndex === String(subtitleIndex) ? 'hidden' : 'disabled'
|
||||
}
|
||||
})
|
||||
|
||||
const selected = trackEls[trackIdx]
|
||||
const selected = video.querySelector<HTMLTrackElement>(
|
||||
`track[data-subtitle-index="${subtitleIndex}"]`,
|
||||
)
|
||||
if (!selected) return
|
||||
|
||||
const tt = selected.track
|
||||
@@ -216,8 +360,9 @@ export function PlayerVideoStage({
|
||||
video.removeEventListener('seeking', updateCue)
|
||||
video.removeEventListener('seeked', updateCue)
|
||||
video.removeEventListener('playing', updateCue)
|
||||
const trackEls = Array.from(video.querySelectorAll('track'))
|
||||
const selected = trackEls[trackIdx]
|
||||
const selected = video.querySelector<HTMLTrackElement>(
|
||||
`track[data-subtitle-index="${subtitleIndex}"]`,
|
||||
)
|
||||
if (selected) {
|
||||
selected.removeEventListener('load', updateCue)
|
||||
if (selected.track) {
|
||||
@@ -225,7 +370,7 @@ export function PlayerVideoStage({
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [subtitleIndex, subs, videoRef, media])
|
||||
}, [subtitleIndex, subs, videoRef, media, streamOffset, subtitleTimeline, tracksArmed])
|
||||
|
||||
// 根据视频画面宽高比与舞台宽高比,确定视频在哪个轴向撑满 100%
|
||||
const isWiderThanStage =
|
||||
@@ -251,7 +396,8 @@ export function PlayerVideoStage({
|
||||
ref={stageRef}
|
||||
data-player-stage
|
||||
className="relative flex h-full w-full flex-1 items-center justify-center overflow-hidden bg-black"
|
||||
onClick={togglePlay}
|
||||
onPointerDown={handleStagePointerDown}
|
||||
onClick={handleStageClick}
|
||||
onDoubleClick={toggleFullscreen}
|
||||
>
|
||||
{media ? (
|
||||
@@ -267,16 +413,19 @@ export function PlayerVideoStage({
|
||||
className="h-full w-full object-contain bg-black"
|
||||
onError={onVideoError}
|
||||
>
|
||||
{subs.map((track, index) => (
|
||||
<track
|
||||
key={track.path}
|
||||
kind="subtitles"
|
||||
src={subtitlesAPI.url(media.id, track.path)}
|
||||
srcLang={track.lang}
|
||||
label={track.label || track.lang}
|
||||
default={subtitleIndex === index}
|
||||
/>
|
||||
))}
|
||||
{tracksArmed &&
|
||||
subs.map((track, index) =>
|
||||
track.delivery === 'burn' ? null : (
|
||||
<track
|
||||
key={track.path}
|
||||
data-subtitle-index={index}
|
||||
kind="subtitles"
|
||||
src={subtitlesAPI.url(media.id, track.path)}
|
||||
srcLang={track.lang}
|
||||
label={track.label || track.lang}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</video>
|
||||
<DanmakuStage
|
||||
key={media.id}
|
||||
@@ -310,6 +459,8 @@ export function PlayerVideoStage({
|
||||
</div>
|
||||
<PlayerControls
|
||||
videoRef={videoRef}
|
||||
uiVisible={controlsVisible}
|
||||
onUiVisibleChange={setControlsVisible}
|
||||
subs={subs}
|
||||
subtitleIndex={subtitleIndex}
|
||||
onSelectSubtitle={onSelectSubtitle}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export type DirectPlayErrorAction = 'ignore' | 'retry' | 'fallback'
|
||||
|
||||
const MEDIA_ERR_ABORTED = 1
|
||||
const HAVE_CURRENT_DATA = 2
|
||||
|
||||
/**
|
||||
* Chromium 常把被中断的直连(换 src、插入 track、302 未完成就 seek)
|
||||
* 报成 error。真正不兼容应 fallback;瞬时中断应忽略或静默重试一次。
|
||||
*/
|
||||
export function classifyDirectPlayError(input: {
|
||||
errorCode: number | undefined | null
|
||||
readyState: number
|
||||
elementSrc: string
|
||||
expectedSrc: string
|
||||
alreadyRetried: 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'
|
||||
if (!input.alreadyRetried) return 'retry'
|
||||
return 'fallback'
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { classifyDirectPlayError } from './directPlayError.ts'
|
||||
|
||||
function check(name: string, cond: boolean) {
|
||||
if (!cond) throw new Error(`classifyDirectPlayError: ${name}`)
|
||||
}
|
||||
|
||||
const src = 'http://nas.local/api/stream/m1?token=abc'
|
||||
|
||||
check(
|
||||
'aborted load is ignored',
|
||||
classifyDirectPlayError({
|
||||
errorCode: 1,
|
||||
readyState: 0,
|
||||
elementSrc: src,
|
||||
expectedSrc: src,
|
||||
alreadyRetried: false,
|
||||
}) === 'ignore',
|
||||
)
|
||||
|
||||
check(
|
||||
'already-playing error is ignored',
|
||||
classifyDirectPlayError({
|
||||
errorCode: 4,
|
||||
readyState: 3,
|
||||
elementSrc: src,
|
||||
expectedSrc: src,
|
||||
alreadyRetried: false,
|
||||
}) === 'ignore',
|
||||
)
|
||||
|
||||
check(
|
||||
'stale src error is ignored',
|
||||
classifyDirectPlayError({
|
||||
errorCode: 4,
|
||||
readyState: 0,
|
||||
elementSrc: 'http://nas.local/old',
|
||||
expectedSrc: src,
|
||||
alreadyRetried: false,
|
||||
}) === 'ignore',
|
||||
)
|
||||
|
||||
check(
|
||||
'empty src during reload is ignored',
|
||||
classifyDirectPlayError({
|
||||
errorCode: 4,
|
||||
readyState: 0,
|
||||
elementSrc: '',
|
||||
expectedSrc: src,
|
||||
alreadyRetried: false,
|
||||
}) === 'ignore',
|
||||
)
|
||||
|
||||
check(
|
||||
'first decode/network miss retries',
|
||||
classifyDirectPlayError({
|
||||
errorCode: 4,
|
||||
readyState: 0,
|
||||
elementSrc: src,
|
||||
expectedSrc: src,
|
||||
alreadyRetried: false,
|
||||
}) === 'retry',
|
||||
)
|
||||
|
||||
check(
|
||||
'second failure falls back to HLS',
|
||||
classifyDirectPlayError({
|
||||
errorCode: 4,
|
||||
readyState: 0,
|
||||
elementSrc: src,
|
||||
expectedSrc: src,
|
||||
alreadyRetried: true,
|
||||
}) === 'fallback',
|
||||
)
|
||||
|
||||
console.log('playerPageModel.test.ts ok')
|
||||
@@ -6,6 +6,7 @@ import { libraryAPI } from '../api/library'
|
||||
import { mediaAPI } from '../api/library'
|
||||
import { toolsAPI } from '../api/tools'
|
||||
import { confirmAction, confirmActionResult } from '../components/confirmAction'
|
||||
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
||||
import type { Library, Media } from '../types'
|
||||
import { seriesTitle, type SeriesCard } from '../utils/groupSeries'
|
||||
import { LibraryMovieActions } from './LibraryMovieActions'
|
||||
@@ -33,7 +34,7 @@ export function useLibraryAdminActions({
|
||||
setManualMovie,
|
||||
}: UseLibraryAdminActionsOptions) {
|
||||
const [scraping, setScraping] = useState(false)
|
||||
const [scrapeEpisodeArtwork, setScrapeEpisodeArtwork] = useState(false)
|
||||
const [scrapeEpisodeArtwork, setScrapeEpisodeArtwork] = useEpisodeArtworkPreference()
|
||||
const [repairing, setRepairing] = useState(false)
|
||||
const [seriesToolBusy, setSeriesToolBusy] = useState('')
|
||||
const [movieToolBusy, setMovieToolBusy] = useState('')
|
||||
|
||||
@@ -6,6 +6,7 @@ import { api } from '../api/client'
|
||||
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 { mediaLibraryBackTarget } from './MediaDetailPageModel'
|
||||
|
||||
@@ -42,7 +43,7 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa
|
||||
const [manualScrapeOpen, setManualScrapeOpen] = useState(false)
|
||||
const [metadataEditOpen, setMetadataEditOpen] = useState(false)
|
||||
const [organizeOpen, setOrganizeOpen] = useState(false)
|
||||
const [scrapeEpisodeArtwork, setScrapeEpisodeArtwork] = useState(false)
|
||||
const [scrapeEpisodeArtwork, setScrapeEpisodeArtwork] = useEpisodeArtworkPreference()
|
||||
|
||||
const refresh = useMediaDetailRefresh({
|
||||
id,
|
||||
|
||||
+2
-1
@@ -20,5 +20,6 @@
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user