mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4239474100 | |||
| 8225b4e319 | |||
| c086b01381 | |||
| f196e5621f | |||
| 13d60b3bd9 | |||
| 3d30f6f27c | |||
| bd02e194ff | |||
| 1c9b742c8d | |||
| 20e7d7fa99 |
@@ -195,7 +195,8 @@ func listLibrarySeriesEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
writeInternalOrCanceled(c, err)
|
writeInternalOrCanceled(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
|
grouped := service.GroupEpisodeVersionsForDisplay(items)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": grouped, "total": len(grouped)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +236,7 @@ func listMediaEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
writeInternalOrCanceled(c, err)
|
writeInternalOrCanceled(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
|
grouped := service.GroupEpisodeVersionsForDisplay(items)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": grouped, "total": len(grouped)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -180,6 +181,7 @@ func (p *AdultProvider) SearchCandidates(ctx context.Context, query string) ([]*
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
matches = p.enrichMetaTubeCandidates(ctx, mtCfg, code, matches)
|
||||||
for _, m := range matches {
|
for _, m := range matches {
|
||||||
m.OriginalName = code
|
m.OriginalName = code
|
||||||
m.Title = FormatAdultTitle(code, m.Title)
|
m.Title = FormatAdultTitle(code, m.Title)
|
||||||
@@ -194,6 +196,7 @@ func (p *AdultProvider) SearchCandidates(ctx context.Context, query string) ([]*
|
|||||||
if mtCfg.ServerURL != "" {
|
if mtCfg.ServerURL != "" {
|
||||||
matches, err := p.metatube.Search(ctx, mtCfg, code)
|
matches, err := p.metatube.Search(ctx, mtCfg, code)
|
||||||
if err == nil && len(matches) > 0 {
|
if err == nil && len(matches) > 0 {
|
||||||
|
matches = p.enrichMetaTubeCandidates(ctx, mtCfg, code, matches)
|
||||||
for _, m := range matches {
|
for _, m := range matches {
|
||||||
m.OriginalName = code
|
m.OriginalName = code
|
||||||
m.Title = FormatAdultTitle(code, m.Title)
|
m.Title = FormatAdultTitle(code, m.Title)
|
||||||
@@ -217,6 +220,50 @@ func (p *AdultProvider) SearchCandidates(ctx context.Context, query string) ([]*
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetMetaTubeCandidate fetches the selected provider result instead of
|
||||||
|
// re-running a search that may choose a different provider.
|
||||||
|
func (p *AdultProvider) GetMetaTubeCandidate(ctx context.Context, provider, id string) (*Match, error) {
|
||||||
|
if p == nil || p.metatube == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
provider = strings.TrimSpace(provider)
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if provider == "" || id == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
engine := strings.ToLower(p.getSetting(ctx, "adult.scraper.engine", "builtin"))
|
||||||
|
if engine != "metatube" && engine != "auto" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
cfg := p.ResolveMetaTubeConfig(ctx)
|
||||||
|
if cfg.ServerURL == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return p.metatube.GetMovie(ctx, cfg, provider, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdultProvider) enrichMetaTubeCandidates(ctx context.Context, cfg MetaTubeConfig, code string, matches []*Match) []*Match {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, candidate := range matches {
|
||||||
|
if candidate == nil ||
|
||||||
|
normalizeAdultCode(candidate.OriginalName) != code ||
|
||||||
|
strings.TrimSpace(candidate.DoubanID) == "" ||
|
||||||
|
strings.TrimSpace(candidate.TheTVDBID) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wg.Add(1)
|
||||||
|
go func(index int, current *Match) {
|
||||||
|
defer wg.Done()
|
||||||
|
detailed, err := p.metatube.GetMovie(ctx, cfg, current.TheTVDBID, current.DoubanID)
|
||||||
|
if err == nil && detailed != nil {
|
||||||
|
matches[index] = detailed
|
||||||
|
}
|
||||||
|
}(i, candidate)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
func (p *AdultProvider) searchBuiltin(ctx context.Context, code string) (*Match, error) {
|
func (p *AdultProvider) searchBuiltin(ctx context.Context, code string) (*Match, error) {
|
||||||
bases := p.resolveBases(ctx)
|
bases := p.resolveBases(ctx)
|
||||||
if len(bases) == 0 {
|
if len(bases) == 0 {
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ func TestAdultProviderRouting(t *testing.T) {
|
|||||||
repos := repository.New(db)
|
repos := repository.New(db)
|
||||||
|
|
||||||
mtServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
mtServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path == "/v1/movies/search" {
|
switch r.URL.Path {
|
||||||
|
case "/v1/movies/search":
|
||||||
q := r.URL.Query().Get("q")
|
q := r.URL.Query().Get("q")
|
||||||
if q == "SSIS-001" {
|
if q == "SSIS-001" {
|
||||||
results := struct {
|
results := struct {
|
||||||
@@ -46,6 +47,21 @@ func TestAdultProviderRouting(t *testing.T) {
|
|||||||
_ = json.NewEncoder(w).Encode(results)
|
_ = json.NewEncoder(w).Encode(results)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
case "/v1/movies/javdb/999":
|
||||||
|
_ = json.NewEncoder(w).Encode(struct {
|
||||||
|
Data MetaTubeMovieInfo `json:"data"`
|
||||||
|
}{
|
||||||
|
Data: MetaTubeMovieInfo{
|
||||||
|
ID: "999",
|
||||||
|
Number: "SSIS-001",
|
||||||
|
Title: "河北彩花 専属デビュー",
|
||||||
|
Provider: "javdb",
|
||||||
|
CoverURL: "https://example.com/poster.jpg",
|
||||||
|
PreviewImages: []string{"https://example.com/backdrop.jpg"},
|
||||||
|
ReleaseDate: "2021-06-19",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}))
|
}))
|
||||||
@@ -80,6 +96,11 @@ func TestAdultProviderRouting(t *testing.T) {
|
|||||||
if len(candidates) != 1 {
|
if len(candidates) != 1 {
|
||||||
t.Fatalf("expected 1 candidate, got %d", len(candidates))
|
t.Fatalf("expected 1 candidate, got %d", len(candidates))
|
||||||
}
|
}
|
||||||
|
wantPoster := mtServer.URL + "/v1/images/primary/javdb/999?auto=false&pos=-1&quality=90&ratio=0.6666666666666666"
|
||||||
|
wantBackdrop := mtServer.URL + "/v1/images/backdrop/javdb/999?quality=90"
|
||||||
|
if candidates[0].PosterURL != wantPoster || candidates[0].BackdropURL != wantBackdrop {
|
||||||
|
t.Fatalf("candidate artwork was not enriched: %#v", candidates[0])
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Test auto mode with failing metatube query
|
// 2. Test auto mode with failing metatube query
|
||||||
_ = repos.Setting.Set(context.Background(), "adult.scraper.engine", "auto")
|
_ = repos.Setting.Set(context.Background(), "adult.scraper.engine", "auto")
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
attemptedAccess := strings.TrimPrefix(req.Header.Get("Authorization"), "Bearer ")
|
||||||
|
|
||||||
resp, err := c.HTTP.Do(req)
|
resp, err := c.HTTP.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -218,7 +219,7 @@ func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map
|
|||||||
// refresh_token 刷新后重试一次。刷新失败或重试后仍失败才返回,
|
// refresh_token 刷新后重试一次。刷新失败或重试后仍失败才返回,
|
||||||
// 避免长时间同步因 token 过期而整体失败。
|
// 避免长时间同步因 token 过期而整体失败。
|
||||||
if isTokenCode(base.Code) {
|
if isTokenCode(base.Code) {
|
||||||
if access && c.tryRefreshTokenLocked(ctx) {
|
if access && c.tryRefreshTokenLocked(ctx, attemptedAccess) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if access {
|
if access {
|
||||||
@@ -305,21 +306,23 @@ func (c *OpenClient) doAuthJSONWithUA(ctx context.Context, method, rawURL string
|
|||||||
// tryRefreshTokenLocked 并发安全地刷新 access_token;成功返回 true(调用方
|
// tryRefreshTokenLocked 并发安全地刷新 access_token;成功返回 true(调用方
|
||||||
// 应使用内存中的新 token 重试原请求)。
|
// 应使用内存中的新 token 重试原请求)。
|
||||||
//
|
//
|
||||||
// 拿到写锁后在锁内读取 oldAccess,与持锁期间的当前值对比:若已被其他
|
// failedAccess 是失败请求实际携带的 token。拿到写锁后与当前 token 对比:
|
||||||
// goroutine 刷新过则直接复用新 token,避免并发请求连环轮转消耗 115 的
|
// 若已被其他 goroutine 刷新过则直接复用,避免并发请求连环轮转消耗 115
|
||||||
// 一次性 refresh_token。全程持写锁读写 token 字段,无 TOCTOU 窗口。
|
// 的一次性 refresh_token。全程持写锁读写 token 字段,无 TOCTOU 窗口。
|
||||||
//
|
//
|
||||||
// 对"refresh_token 本身已失效/被吊销"(IsRefreshTokenDead,如 40140114/116/119/120)
|
// 对"refresh_token 本身已失效/被吊销"(IsRefreshTokenDead,如 40140114/116/119/120)
|
||||||
// 这类不可恢复的错误直接放弃并清空内存 token(提示需重新授权)。
|
// 这类不可恢复的错误直接放弃并清空内存 token(提示需重新授权)。
|
||||||
// 对其它失败(网络瞬时抖动、刷新接口可重试错误码等)做指数退避重试几次再放弃,
|
// 对其它失败(网络瞬时抖动、刷新接口可重试错误码等)做指数退避重试几次再放弃,
|
||||||
// 避免同步长任务中途 token 到期时恰好撞上一个短暂的刷新失败就整体失败。
|
// 避免同步长任务中途 token 到期时恰好撞上一个短暂的刷新失败就整体失败。
|
||||||
func (c *OpenClient) tryRefreshTokenLocked(ctx context.Context) bool {
|
func (c *OpenClient) tryRefreshTokenLocked(ctx context.Context, failedAccess string) bool {
|
||||||
c.tokenMu.Lock()
|
c.tokenMu.Lock()
|
||||||
// 在已持有写锁内读取当前 token 作为"刷新前快照",消除双重加锁窗口:
|
// 请求发出后若其他 goroutine 已经刷新完成,直接复用新 token 重试;
|
||||||
// 若在拿锁期间已有其他 goroutine 完成刷新,refreshTokenWhileLocked
|
// 不能再次轮换一次性的 refresh_token。
|
||||||
// 内的 c.AccessToken != oldAccess 判断会立即命中并返回复用。
|
if failedAccess != "" && c.AccessToken != failedAccess {
|
||||||
oldAccess := c.AccessToken
|
c.tokenMu.Unlock()
|
||||||
newToken, ok := c.refreshTokenWhileLocked(ctx, oldAccess)
|
return true
|
||||||
|
}
|
||||||
|
newToken, ok := c.refreshTokenWhileLocked(ctx, failedAccess)
|
||||||
c.tokenMu.Unlock()
|
c.tokenMu.Unlock()
|
||||||
// 回调必须在 tokenMu 释放后调用,避免上层在回调内访问客户端时死锁
|
// 回调必须在 tokenMu 释放后调用,避免上层在回调内访问客户端时死锁
|
||||||
if ok && newToken != nil && c.OnTokenRefreshed != nil {
|
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) {
|
func TestFsListAndDownload(t *testing.T) {
|
||||||
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
|
|||||||
@@ -477,24 +477,32 @@ func (c *OpenClient) GetToken(qrCode *QrCodeDataReturn) (*TokenData, error) {
|
|||||||
|
|
||||||
// RefreshToken 刷新访问令牌。
|
// RefreshToken 刷新访问令牌。
|
||||||
func (c *OpenClient) RefreshToken(refreshToken string) (*TokenData, error) {
|
func (c *OpenClient) RefreshToken(refreshToken string) (*TokenData, error) {
|
||||||
|
c.tokenMu.Lock()
|
||||||
if refreshToken == "" {
|
if refreshToken == "" {
|
||||||
refreshToken = c.currentRefreshToken()
|
refreshToken = c.RefreshTokenStr
|
||||||
}
|
}
|
||||||
if refreshToken == "" {
|
if refreshToken == "" {
|
||||||
|
c.tokenMu.Unlock()
|
||||||
return nil, fmt.Errorf("没有可用的 refresh_token")
|
return nil, fmt.Errorf("没有可用的 refresh_token")
|
||||||
}
|
}
|
||||||
token, err := c.doRefreshToken(refreshToken)
|
token, err := c.doRefreshToken(refreshToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// refresh_token 已失效时清空内存令牌(提示需重新授权)
|
// refresh_token 已失效时清空内存令牌(提示需重新授权)
|
||||||
if IsRefreshTokenDead(err) {
|
if IsRefreshTokenDead(err) {
|
||||||
c.SetAuthToken("", "")
|
c.setAuthTokenLocked("", "")
|
||||||
}
|
}
|
||||||
|
c.tokenMu.Unlock()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if token.AccessToken == "" || token.RefreshToken == "" {
|
if token.AccessToken == "" || token.RefreshToken == "" {
|
||||||
|
c.tokenMu.Unlock()
|
||||||
return nil, fmt.Errorf("115: 刷新返回空凭证(access_token/refresh_token 缺失)")
|
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
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,18 +22,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
|
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
|
||||||
patSEnERange = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\s*[-~–—]\s*(?:s(\d{1,2}))?e?(\d{1,3})(?:[^0-9]|$)`)
|
patSEnERange = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\s*[-~–—]\s*(?:s(\d{1,2}))?e?(\d{1,3})(?:[^0-9]|$)`)
|
||||||
patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`)
|
patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`)
|
||||||
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
|
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
|
||||||
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
|
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
|
||||||
patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
|
patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
|
||||||
patCNRange = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[-~–—]\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
|
patCNRange = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[-~–—]\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
|
||||||
patDashEpisode = regexp.MustCompile(`[\s._-][-–—]\s*(\d{1,3})(?:\s*(?:v\d+)?)?(?:\s*[\[\(._-]|$)`)
|
patDashEpisode = regexp.MustCompile(`[\s._-][-–—]\s*(\d{1,3})(?:\s*(?:v\d+)?)?(?:\s*[\[\(._-]|$)`)
|
||||||
patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*([0-9一二三四五六七八九十百零两]+)\s*季`)
|
patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*([0-9一二三四五六七八九十百零两]+)\s*季`)
|
||||||
patSeasonOnly = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\.?\s*\d{1,2}(?:[\s._-]|$)`)
|
patSeasonOnly = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\.?\s*\d{1,2}(?:[\s._-]|$)`)
|
||||||
patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`)
|
patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`)
|
||||||
patSpecialSeason = regexp.MustCompile(`(?i)^(?:s0+|season[\s._-]*0+|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|番外篇?|特别篇|特別篇|特典|外传|外傳|总集篇|總集篇)$`)
|
patSpecialSeason = regexp.MustCompile(`(?i)^(?:s0+|season[\s._-]*0+|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|番外篇?|特别篇|特別篇|特典|外传|外傳|总集篇|總集篇)$`)
|
||||||
|
patSeasonEpisodeZero = regexp.MustCompile(`(?i)s0*([1-9]\d?)e0+(?:[^0-9]|$)`)
|
||||||
// patCNSeason 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。
|
// patCNSeason 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。
|
||||||
patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`)
|
patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`)
|
||||||
)
|
)
|
||||||
@@ -93,6 +94,27 @@ func ParseEpisode(path string) (season, episode int) {
|
|||||||
return 0, 0
|
return 0, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// onlineEpisodeIdentityFromPath converts the common anime SxxE00 convention
|
||||||
|
// to provider-style specials. For example, S01E00 becomes S00E01 and S02E00
|
||||||
|
// becomes S00E02. Normal episodes retain their parsed identity.
|
||||||
|
func onlineEpisodeIdentityFromPath(path string) (season, episode int) {
|
||||||
|
season, episode = ParseEpisode(path)
|
||||||
|
if episode != 0 {
|
||||||
|
return season, episode
|
||||||
|
}
|
||||||
|
name := mediaSidecarBase(path)
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||||
|
}
|
||||||
|
if m := patSeasonEpisodeZero.FindStringSubmatch(name); len(m) == 2 {
|
||||||
|
specialEpisode := mustAtoi(m[1])
|
||||||
|
if specialEpisode > 0 {
|
||||||
|
return 0, specialEpisode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return season, episode
|
||||||
|
}
|
||||||
|
|
||||||
type episodeRef struct {
|
type episodeRef struct {
|
||||||
Season int
|
Season int
|
||||||
Episode int
|
Episode int
|
||||||
|
|||||||
@@ -68,3 +68,23 @@ func TestEpisodeRefsFromTitleParsesRanges(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOnlineEpisodeIdentityFromPathMapsAnimeEpisodeZeroToSpecials(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
wantSeason int
|
||||||
|
wantEpisode int
|
||||||
|
}{
|
||||||
|
{`动漫/路人女主/Season 1/S01E00 - 爱与青春的杀必死回.mkv`, 0, 1},
|
||||||
|
{`动漫/路人女主/Season 2/S02E00 - 恋爱与纯情的杀必死回.mkv`, 0, 2},
|
||||||
|
{`动漫/路人女主/Season 2/S02E03 - 初稿与二稿.mkv`, 2, 3},
|
||||||
|
{`动漫/路人女主/Specials/S00E04.mkv`, 0, 4},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
season, episode := onlineEpisodeIdentityFromPath(tc.path)
|
||||||
|
if season != tc.wantSeason || episode != tc.wantEpisode {
|
||||||
|
t.Errorf("onlineEpisodeIdentityFromPath(%q) = (%d, %d), want (%d, %d)",
|
||||||
|
tc.path, season, episode, tc.wantSeason, tc.wantEpisode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,10 +39,19 @@ func (r ManualScrapeRequest) EpisodeArtworkOption() *bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ScraperService) ApplyManualMatch(ctx context.Context, mediaID string, req ManualScrapeRequest) (*model.Media, error) {
|
func (s *ScraperService) ApplyManualMatch(ctx context.Context, mediaID string, req ManualScrapeRequest) (*model.Media, error) {
|
||||||
return s.ApplyManualMatchWithOptions(ctx, mediaID, req, ScrapeOptions{EpisodeArtwork: req.EpisodeArtworkOption()})
|
return s.ApplyManualMatchWithOptions(ctx, mediaID, req, ScrapeOptions{
|
||||||
|
EpisodeArtwork: req.EpisodeArtworkOption(),
|
||||||
|
ForceRematch: true,
|
||||||
|
RebuildIdentity: true,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ScraperService) ApplyManualMatchWithOptions(ctx context.Context, mediaID string, req ManualScrapeRequest, options ScrapeOptions) (*model.Media, error) {
|
func (s *ScraperService) ApplyManualMatchWithOptions(ctx context.Context, mediaID string, req ManualScrapeRequest, options ScrapeOptions) (*model.Media, error) {
|
||||||
|
// A user-triggered match is authoritative. Do not let metadata written by a
|
||||||
|
// previous scrape influence the new result.
|
||||||
|
options.ForceRematch = true
|
||||||
|
options.RebuildIdentity = true
|
||||||
|
|
||||||
media, err := s.repo.Media.FindByID(ctx, mediaID)
|
media, err := s.repo.Media.FindByID(ctx, mediaID)
|
||||||
if err != nil || media == nil {
|
if err != nil || media == nil {
|
||||||
return nil, errors.New("media not found")
|
return nil, errors.New("media not found")
|
||||||
@@ -55,12 +64,32 @@ func (s *ScraperService) ApplyManualMatchWithOptions(ctx context.Context, mediaI
|
|||||||
if strings.TrimSpace(match.Title) == "" {
|
if strings.TrimSpace(match.Title) == "" {
|
||||||
return nil, errors.New("manual match title required")
|
return nil, errors.New("manual match title required")
|
||||||
}
|
}
|
||||||
|
rebuildManualScrapeIdentity(media, lib, match)
|
||||||
if err := s.applyProviderMatchWithOptions(ctx, media, lib, match, options); err != nil {
|
if err := s.applyProviderMatchWithOptions(ctx, media, lib, match, options); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.repo.Media.FindByID(ctx, mediaID)
|
return s.repo.Media.FindByID(ctx, mediaID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rebuildManualScrapeIdentity(media *model.Media, lib *model.Library, match *Match) {
|
||||||
|
if media == nil || match == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mediaType := normalizeOrganizeMediaType(match.MediaType)
|
||||||
|
if mediaType == "" && librarySupportsSeasons(lib) {
|
||||||
|
mediaType = "tv"
|
||||||
|
}
|
||||||
|
switch mediaType {
|
||||||
|
case "tv", "anime", "variety":
|
||||||
|
media.SeasonNum, media.EpisodeNum = onlineEpisodeIdentityFromPath(media.Path)
|
||||||
|
media.EpisodeTitle = ""
|
||||||
|
case "movie", "adult":
|
||||||
|
media.SeasonNum = 0
|
||||||
|
media.EpisodeNum = 0
|
||||||
|
media.EpisodeTitle = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ScraperService) manualRequestMatch(ctx context.Context, req ManualScrapeRequest) (*Match, error) {
|
func (s *ScraperService) manualRequestMatch(ctx context.Context, req ManualScrapeRequest) (*Match, error) {
|
||||||
source := strings.ToLower(strings.TrimSpace(req.Source))
|
source := strings.ToLower(strings.TrimSpace(req.Source))
|
||||||
mediaType := normalizeMediaType(req.MediaType, req.Title, "")
|
mediaType := normalizeMediaType(req.MediaType, req.Title, "")
|
||||||
@@ -98,7 +127,22 @@ func (s *ScraperService) manualRequestMatch(ctx context.Context, req ManualScrap
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case source == "adult":
|
case source == "adult":
|
||||||
if match := s.manualAdultMatch(ctx, firstText(req.OriginalName, req.Title)); match != nil {
|
var match *Match
|
||||||
|
if s.adult != nil {
|
||||||
|
match, _ = s.adult.GetMetaTubeCandidate(ctx, req.TheTVDBID, req.DoubanID)
|
||||||
|
}
|
||||||
|
if match == nil {
|
||||||
|
match = s.manualAdultMatch(ctx, firstText(req.OriginalName, req.Title))
|
||||||
|
}
|
||||||
|
if match != nil {
|
||||||
|
// Older search responses used the portrait poster as a backdrop
|
||||||
|
// fallback. Do not let that placeholder replace a real preview
|
||||||
|
// image or processed primary image fetched from MetaTube.
|
||||||
|
if strings.TrimSpace(match.BackdropURL) != "" &&
|
||||||
|
strings.TrimSpace(req.BackdropURL) == strings.TrimSpace(req.PosterURL) {
|
||||||
|
req.BackdropURL = ""
|
||||||
|
req.PosterURL = ""
|
||||||
|
}
|
||||||
return mergeManualRequestIntoMatch(match, req), nil
|
return mergeManualRequestIntoMatch(match, req), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -34,6 +35,74 @@ func TestManualRequestMatchFallsBackToCandidatePayload(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestManualAdultMatchUsesSelectedMetaTubeDetailsAndRealBackdrop(t *testing.T) {
|
||||||
|
var detailCalls atomic.Int32
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/movies/AVE/94600" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detailCalls.Add(1)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(struct {
|
||||||
|
Data MetaTubeMovieInfo `json:"data"`
|
||||||
|
}{
|
||||||
|
Data: MetaTubeMovieInfo{
|
||||||
|
ID: "94600",
|
||||||
|
Number: "CWPBD-138",
|
||||||
|
Title: "Selected title",
|
||||||
|
Provider: "AVE",
|
||||||
|
CoverURL: "https://example.com/poster.jpg",
|
||||||
|
PreviewImages: []string{"https://example.com/backdrop.jpg"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.Setting{}, &model.APIConfig{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
if err := repos.Setting.Set(t.Context(), "adult.scraper.engine", "metatube"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := repos.Setting.Set(t.Context(), "adult.scraper.metatube_server", upstream.URL); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
adult := NewAdultProvider(zap.NewNop(), nil, repos)
|
||||||
|
scraper := &ScraperService{adult: adult}
|
||||||
|
|
||||||
|
match, err := scraper.manualRequestMatch(t.Context(), ManualScrapeRequest{
|
||||||
|
Source: "adult",
|
||||||
|
MediaType: "adult",
|
||||||
|
Title: "CWPBD-138 Selected title",
|
||||||
|
OriginalName: "CWPBD-138",
|
||||||
|
PosterURL: "https://example.com/search-cover.jpg",
|
||||||
|
BackdropURL: "https://example.com/search-cover.jpg",
|
||||||
|
DoubanID: "94600",
|
||||||
|
TheTVDBID: "AVE",
|
||||||
|
NSFW: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if detailCalls.Load() != 1 {
|
||||||
|
t.Fatalf("selected MetaTube detail calls = %d, want 1", detailCalls.Load())
|
||||||
|
}
|
||||||
|
wantPoster := upstream.URL + "/v1/images/primary/AVE/94600?auto=false&pos=-1&quality=90&ratio=0.6666666666666666"
|
||||||
|
if match.PosterURL != wantPoster {
|
||||||
|
t.Fatalf("selected poster = %q, want %q", match.PosterURL, wantPoster)
|
||||||
|
}
|
||||||
|
wantBackdrop := upstream.URL + "/v1/images/backdrop/AVE/94600?quality=90"
|
||||||
|
if match.BackdropURL != wantBackdrop {
|
||||||
|
t.Fatalf("backdrop = %q, want %q", match.BackdropURL, wantBackdrop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParsePositiveIDStringAcceptsProviderPrefixes(t *testing.T) {
|
func TestParsePositiveIDStringAcceptsProviderPrefixes(t *testing.T) {
|
||||||
cases := map[string]string{
|
cases := map[string]string{
|
||||||
"12345": "12345",
|
"12345": "12345",
|
||||||
@@ -627,3 +696,90 @@ func TestApplyManualMovieMatchClearsEpisodeMarkers(t *testing.T) {
|
|||||||
t.Fatalf("stale external IDs were not cleared for manual movie fallback: tmdb=%d thetvdb=%q", got.TMDbID, got.TheTVDBID)
|
t.Fatalf("stale external IDs were not cleared for manual movie fallback: tmdb=%d thetvdb=%q", got.TMDbID, got.TheTVDBID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyManualTVMatchRebuildsEpisodeIdentityFromPath(t *testing.T) {
|
||||||
|
var requestedEpisodePath string
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/tv/69367":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"id": 69367,
|
||||||
|
"name": "路人女主的养成方法",
|
||||||
|
"original_name": "冴えない彼女の育てかた",
|
||||||
|
"overview": "整剧简介",
|
||||||
|
"poster_path": "/show.jpg",
|
||||||
|
"backdrop_path": "/show-backdrop.jpg",
|
||||||
|
"first_air_date": "2015-01-09",
|
||||||
|
"vote_average": 7.0,
|
||||||
|
})
|
||||||
|
case "/tv/69367/season/0/episode/1":
|
||||||
|
requestedEpisodePath = r.URL.Path
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"name": "爱与青春的杀必死回",
|
||||||
|
"overview": "特别篇简介",
|
||||||
|
"still_path": "/special.jpg",
|
||||||
|
"air_date": "2015-01-07",
|
||||||
|
"vote_average": 7.5,
|
||||||
|
"runtime": 24,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Secrets.TMDbAPIKey = "test-key"
|
||||||
|
cfg.Secrets.TMDbAPIProxy = upstream.URL
|
||||||
|
log := zap.NewNop()
|
||||||
|
scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log))
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
lib := model.Library{Name: "动漫", Path: root, Type: "anime", Enabled: true}
|
||||||
|
if err := repos.DB.Create(&lib).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
media := model.Media{
|
||||||
|
LibraryID: lib.ID,
|
||||||
|
Title: "旧标题",
|
||||||
|
Path: filepath.Join(root, "路人女主的养成方法 (2015)", "Season 1", "S01E00 - 爱与青春的杀必死回.strm"),
|
||||||
|
SeasonNum: 1,
|
||||||
|
EpisodeNum: 1,
|
||||||
|
EpisodeTitle: "错误百出的序曲",
|
||||||
|
SeriesID: "stale-series",
|
||||||
|
TMDbID: 111,
|
||||||
|
BangumiID: 222,
|
||||||
|
ScrapeStatus: "matched",
|
||||||
|
}
|
||||||
|
if err := repos.DB.Create(&media).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := scraper.ApplyManualMatch(t.Context(), media.ID, ManualScrapeRequest{
|
||||||
|
Source: "tmdb",
|
||||||
|
MediaType: "tv",
|
||||||
|
Title: "路人女主的养成方法",
|
||||||
|
TMDbID: 69367,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if requestedEpisodePath != "/tv/69367/season/0/episode/1" {
|
||||||
|
t.Fatalf("episode details path = %q, want season-zero special", requestedEpisodePath)
|
||||||
|
}
|
||||||
|
if got.SeasonNum != 0 || got.EpisodeNum != 1 || got.EpisodeTitle != "爱与青春的杀必死回" {
|
||||||
|
t.Fatalf("rebuilt episode identity = S%02dE%02d %q", got.SeasonNum, got.EpisodeNum, got.EpisodeTitle)
|
||||||
|
}
|
||||||
|
if got.SeriesID != "" || got.TMDbID != 69367 || got.BangumiID != 0 {
|
||||||
|
t.Fatalf("stale scrape identity survived manual rematch: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -120,6 +120,25 @@ func GroupMediaVersions(items []model.Media) []MediaItem {
|
|||||||
return groupMediaVersions(items)
|
return groupMediaVersions(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GroupEpisodeVersionsForDisplay folds encoding/container variants into one
|
||||||
|
// episode while preserving season/episode ordering for episode-list APIs.
|
||||||
|
func GroupEpisodeVersionsForDisplay(items []model.Media) []MediaItem {
|
||||||
|
grouped := groupMediaVersions(items)
|
||||||
|
if grouped == nil {
|
||||||
|
return []MediaItem{}
|
||||||
|
}
|
||||||
|
sort.SliceStable(grouped, func(i, j int) bool {
|
||||||
|
if grouped[i].SeasonNum != grouped[j].SeasonNum {
|
||||||
|
return grouped[i].SeasonNum < grouped[j].SeasonNum
|
||||||
|
}
|
||||||
|
if grouped[i].EpisodeNum != grouped[j].EpisodeNum {
|
||||||
|
return grouped[i].EpisodeNum < grouped[j].EpisodeNum
|
||||||
|
}
|
||||||
|
return grouped[i].CreatedAt.Before(grouped[j].CreatedAt)
|
||||||
|
})
|
||||||
|
return grouped
|
||||||
|
}
|
||||||
|
|
||||||
func mediaVersionGroupKey(m model.Media) string {
|
func mediaVersionGroupKey(m model.Media) string {
|
||||||
// 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
|
// 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
|
||||||
if IsEmbyRemoteID(m.ID) {
|
if IsEmbyRemoteID(m.ID) {
|
||||||
|
|||||||
@@ -49,6 +49,28 @@ func TestGroupMediaVersionsMergesEpisodeByExternalIDAcrossLibraries(t *testing.T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGroupEpisodeVersionsForDisplayMergesAndKeepsEpisodeOrder(t *testing.T) {
|
||||||
|
rows := []model.Media{
|
||||||
|
{Base: model.Base{ID: "ep2"}, LibraryID: "tv", Title: "Show", SeasonNum: 1, EpisodeNum: 2, TMDbID: 99, Path: "/show/s01e02.mkv"},
|
||||||
|
{Base: model.Base{ID: "ep1-small"}, LibraryID: "tv", Title: "Show", SeasonNum: 1, EpisodeNum: 1, TMDbID: 99, Path: "/show/s01e01.mp4", SizeBytes: 100},
|
||||||
|
{Base: model.Base{ID: "ep1-large"}, LibraryID: "tv", Title: "Show", SeasonNum: 1, EpisodeNum: 1, TMDbID: 99, Path: "/show/s01e01.mkv", SizeBytes: 200},
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped := GroupEpisodeVersionsForDisplay(rows)
|
||||||
|
if len(grouped) != 2 {
|
||||||
|
t.Fatalf("grouped len = %d, want 2: %#v", len(grouped), grouped)
|
||||||
|
}
|
||||||
|
if grouped[0].EpisodeNum != 1 || len(grouped[0].Versions) != 2 {
|
||||||
|
t.Fatalf("episode 1 was not merged first: %#v", grouped)
|
||||||
|
}
|
||||||
|
if grouped[0].ID != "ep1-large" {
|
||||||
|
t.Fatalf("primary version = %q, want ep1-large", grouped[0].ID)
|
||||||
|
}
|
||||||
|
if grouped[1].EpisodeNum != 2 {
|
||||||
|
t.Fatalf("second item episode = %d, want 2", grouped[1].EpisodeNum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGroupMediaVersionsMergesMovieEncodingVariants(t *testing.T) {
|
func TestGroupMediaVersionsMergesMovieEncodingVariants(t *testing.T) {
|
||||||
hd := model.Media{
|
hd := model.Media{
|
||||||
LibraryID: "movies",
|
LibraryID: "movies",
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ func (p *MetaTubeProvider) Search(ctx context.Context, cfg MetaTubeConfig, query
|
|||||||
|
|
||||||
matches := make([]*Match, 0, len(results))
|
matches := make([]*Match, 0, len(results))
|
||||||
for _, res := range results {
|
for _, res := range results {
|
||||||
match := p.convertSearchResultToMatch(query, &res)
|
match := p.convertSearchResultToMatch(cfg, query, &res)
|
||||||
if match != nil {
|
if match != nil {
|
||||||
matches = append(matches, match)
|
matches = append(matches, match)
|
||||||
}
|
}
|
||||||
@@ -134,7 +134,7 @@ func (p *MetaTubeProvider) GetMovie(ctx context.Context, cfg MetaTubeConfig, pro
|
|||||||
return nil, fmt.Errorf("decode metatube movie response failed: %w", directErr)
|
return nil, fmt.Errorf("decode metatube movie response failed: %w", directErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
return p.convertMovieInfoToMatch(&movie), nil
|
return p.convertMovieInfoToMatch(cfg, &movie), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchAndGetBestMatch 执行搜索并拉取首个最佳结果的完整电影详情。
|
// SearchAndGetBestMatch 执行搜索并拉取首个最佳结果的完整电影详情。
|
||||||
@@ -276,7 +276,7 @@ func (p *MetaTubeProvider) applyAuthHeader(req *http.Request, token string) {
|
|||||||
req.Header.Set("User-Agent", "MeBox/1.0 (MetaTube Client)")
|
req.Header.Set("User-Agent", "MeBox/1.0 (MetaTube Client)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *MetaTubeProvider) convertSearchResultToMatch(query string, res *MetaTubeSearchResult) *Match {
|
func (p *MetaTubeProvider) convertSearchResultToMatch(cfg MetaTubeConfig, query string, res *MetaTubeSearchResult) *Match {
|
||||||
if res == nil {
|
if res == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -300,14 +300,20 @@ func (p *MetaTubeProvider) convertSearchResultToMatch(query string, res *MetaTub
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
posterURL := firstNonEmpty(res.BigCoverURL, res.CoverURL, res.BigThumbURL, res.ThumbURL)
|
posterURL, backdropURL := metaTubeArtworkURLs(cfg, res.Provider, res.ID)
|
||||||
|
if posterURL == "" {
|
||||||
|
posterURL = firstNonEmpty(res.BigThumbURL, res.ThumbURL, res.BigCoverURL, res.CoverURL)
|
||||||
|
}
|
||||||
|
if backdropURL == "" {
|
||||||
|
backdropURL = firstNonEmpty(res.BigCoverURL, res.CoverURL, posterURL)
|
||||||
|
}
|
||||||
|
|
||||||
return &Match{
|
return &Match{
|
||||||
MediaType: "adult",
|
MediaType: "adult",
|
||||||
Title: formattedTitle,
|
Title: formattedTitle,
|
||||||
OriginalName: code,
|
OriginalName: code,
|
||||||
PosterURL: posterURL,
|
PosterURL: posterURL,
|
||||||
BackdropURL: posterURL,
|
BackdropURL: backdropURL,
|
||||||
Year: year,
|
Year: year,
|
||||||
ReleaseDate: cleanDateString(res.ReleaseDate),
|
ReleaseDate: cleanDateString(res.ReleaseDate),
|
||||||
Rating: res.Score,
|
Rating: res.Score,
|
||||||
@@ -318,7 +324,7 @@ func (p *MetaTubeProvider) convertSearchResultToMatch(query string, res *MetaTub
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *MetaTubeProvider) convertMovieInfoToMatch(movie *MetaTubeMovieInfo) *Match {
|
func (p *MetaTubeProvider) convertMovieInfoToMatch(cfg MetaTubeConfig, movie *MetaTubeMovieInfo) *Match {
|
||||||
if movie == nil {
|
if movie == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -336,10 +342,16 @@ func (p *MetaTubeProvider) convertMovieInfoToMatch(movie *MetaTubeMovieInfo) *Ma
|
|||||||
|
|
||||||
year := parseYearFromDate(movie.ReleaseDate)
|
year := parseYearFromDate(movie.ReleaseDate)
|
||||||
|
|
||||||
posterURL := firstNonEmpty(movie.BigCoverURL, movie.CoverURL, movie.BigThumbURL, movie.ThumbURL)
|
posterURL, backdropURL := metaTubeArtworkURLs(cfg, movie.Provider, movie.ID)
|
||||||
backdropURL := posterURL
|
if posterURL == "" {
|
||||||
if len(movie.PreviewImages) > 0 && movie.PreviewImages[0] != "" {
|
posterURL = firstNonEmpty(movie.BigThumbURL, movie.ThumbURL, movie.BigCoverURL, movie.CoverURL)
|
||||||
backdropURL = movie.PreviewImages[0]
|
}
|
||||||
|
if backdropURL == "" {
|
||||||
|
if len(movie.PreviewImages) > 0 && movie.PreviewImages[0] != "" {
|
||||||
|
backdropURL = movie.PreviewImages[0]
|
||||||
|
} else {
|
||||||
|
backdropURL = firstNonEmpty(movie.BigCoverURL, movie.CoverURL, posterURL)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
genres := make([]string, 0, len(movie.Genres)+len(movie.Actors)+4)
|
genres := make([]string, 0, len(movie.Genres)+len(movie.Actors)+4)
|
||||||
@@ -385,6 +397,33 @@ func (p *MetaTubeProvider) convertMovieInfoToMatch(movie *MetaTubeMovieInfo) *Ma
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func metaTubeArtworkURLs(cfg MetaTubeConfig, provider, id string) (string, string) {
|
||||||
|
serverURL := strings.TrimRight(strings.TrimSpace(cfg.ServerURL), "/")
|
||||||
|
provider = strings.TrimSpace(provider)
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if serverURL == "" || provider == "" || id == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
imageURL := func(kind string, primary bool) string {
|
||||||
|
base := fmt.Sprintf("%s/v1/images/%s/%s/%s", serverURL, kind, url.PathEscape(provider), url.PathEscape(id))
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("quality", "90")
|
||||||
|
if primary {
|
||||||
|
values.Set("pos", "-1")
|
||||||
|
values.Set("auto", "false")
|
||||||
|
if cfg.CropCover {
|
||||||
|
values.Set("ratio", strconv.FormatFloat(2.0/3.0, 'f', -1, 64))
|
||||||
|
} else {
|
||||||
|
values.Set("ratio", "-1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return base + "?" + values.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageURL("primary", true), imageURL("backdrop", false)
|
||||||
|
}
|
||||||
|
|
||||||
func parseYearFromDate(dateStr string) int {
|
func parseYearFromDate(dateStr string) int {
|
||||||
dateStr = strings.TrimSpace(dateStr)
|
dateStr = strings.TrimSpace(dateStr)
|
||||||
if len(dateStr) >= 4 {
|
if len(dateStr) >= 4 {
|
||||||
|
|||||||
@@ -74,6 +74,12 @@ func TestMetaTubeProviderSearch(t *testing.T) {
|
|||||||
if len(m.Genres) != 1 || m.Genres[0] != "相沢みなみ" {
|
if len(m.Genres) != 1 || m.Genres[0] != "相沢みなみ" {
|
||||||
t.Errorf("unexpected genres: %v", m.Genres)
|
t.Errorf("unexpected genres: %v", m.Genres)
|
||||||
}
|
}
|
||||||
|
if want := server.URL + "/v1/images/primary/javdb/123456?auto=false&pos=-1&quality=90&ratio=-1"; m.PosterURL != want {
|
||||||
|
t.Errorf("poster URL = %q, want %q", m.PosterURL, want)
|
||||||
|
}
|
||||||
|
if want := server.URL + "/v1/images/backdrop/javdb/123456?quality=90"; m.BackdropURL != want {
|
||||||
|
t.Errorf("backdrop URL = %q, want %q", m.BackdropURL, want)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMetaTubeProviderGetMovie(t *testing.T) {
|
func TestMetaTubeProviderGetMovie(t *testing.T) {
|
||||||
@@ -112,6 +118,7 @@ func TestMetaTubeProviderGetMovie(t *testing.T) {
|
|||||||
provider := NewMetaTubeProvider(zap.NewNop())
|
provider := NewMetaTubeProvider(zap.NewNop())
|
||||||
cfg := MetaTubeConfig{
|
cfg := MetaTubeConfig{
|
||||||
ServerURL: server.URL,
|
ServerURL: server.URL,
|
||||||
|
CropCover: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
match, err := provider.GetMovie(context.Background(), cfg, "javdb", "123456")
|
match, err := provider.GetMovie(context.Background(), cfg, "javdb", "123456")
|
||||||
@@ -124,11 +131,11 @@ func TestMetaTubeProviderGetMovie(t *testing.T) {
|
|||||||
if match.Overview != "超绝美少女相沢みなみ出道作品!" {
|
if match.Overview != "超绝美少女相沢みなみ出道作品!" {
|
||||||
t.Errorf("unexpected overview: %s", match.Overview)
|
t.Errorf("unexpected overview: %s", match.Overview)
|
||||||
}
|
}
|
||||||
if match.PosterURL != "https://example.com/big_cover.jpg" {
|
if want := server.URL + "/v1/images/primary/javdb/123456?auto=false&pos=-1&quality=90&ratio=0.6666666666666666"; match.PosterURL != want {
|
||||||
t.Errorf("unexpected poster URL: %s", match.PosterURL)
|
t.Errorf("poster URL = %q, want %q", match.PosterURL, want)
|
||||||
}
|
}
|
||||||
if match.BackdropURL != "https://example.com/preview1.jpg" {
|
if want := server.URL + "/v1/images/backdrop/javdb/123456?quality=90"; match.BackdropURL != want {
|
||||||
t.Errorf("unexpected backdrop URL: %s", match.BackdropURL)
|
t.Errorf("backdrop URL = %q, want %q", match.BackdropURL, want)
|
||||||
}
|
}
|
||||||
if match.Year != 2018 {
|
if match.Year != 2018 {
|
||||||
t.Errorf("expected year 2018, got %d", match.Year)
|
t.Errorf("expected year 2018, got %d", match.Year)
|
||||||
|
|||||||
@@ -119,8 +119,22 @@ func (s *ScraperService) applyProviderMatch(ctx context.Context, m *model.Media,
|
|||||||
func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *model.Media, lib *model.Library, match *Match, options ScrapeOptions) error {
|
func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *model.Media, lib *model.Library, match *Match, options ScrapeOptions) error {
|
||||||
posterCandidate := match.PosterURL
|
posterCandidate := match.PosterURL
|
||||||
backdropCandidate := match.BackdropURL
|
backdropCandidate := match.BackdropURL
|
||||||
posterURL, removePoster := s.prepareScrapedArtworkURL(ctx, m.ID, "poster_url", m.PosterURL, posterCandidate)
|
currentPoster := m.PosterURL
|
||||||
backdropURL, removeBackdrop := s.prepareScrapedArtworkURL(ctx, m.ID, "backdrop_url", m.BackdropURL, backdropCandidate)
|
currentBackdrop := m.BackdropURL
|
||||||
|
if options.RebuildIdentity {
|
||||||
|
currentPoster = ""
|
||||||
|
currentBackdrop = ""
|
||||||
|
}
|
||||||
|
posterURL, removePoster := s.prepareScrapedArtworkURL(ctx, m.ID, "poster_url", currentPoster, posterCandidate)
|
||||||
|
backdropURL, removeBackdrop := s.prepareScrapedArtworkURL(ctx, m.ID, "backdrop_url", currentBackdrop, backdropCandidate)
|
||||||
|
if options.RebuildIdentity {
|
||||||
|
if strings.TrimSpace(m.PosterURL) != posterURL {
|
||||||
|
removePoster = m.PosterURL
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(m.BackdropURL) != backdropURL {
|
||||||
|
removeBackdrop = m.BackdropURL
|
||||||
|
}
|
||||||
|
}
|
||||||
updates := map[string]any{
|
updates := map[string]any{
|
||||||
"title": match.Title,
|
"title": match.Title,
|
||||||
"overview": match.Overview,
|
"overview": match.Overview,
|
||||||
@@ -142,6 +156,12 @@ func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *m
|
|||||||
updates["languages"] = strings.Join(match.Languages, ",")
|
updates["languages"] = strings.Join(match.Languages, ",")
|
||||||
updates["nsfw"] = match.NSFW
|
updates["nsfw"] = match.NSFW
|
||||||
}
|
}
|
||||||
|
if options.RebuildIdentity {
|
||||||
|
updates["season_num"] = m.SeasonNum
|
||||||
|
updates["episode_num"] = m.EpisodeNum
|
||||||
|
updates["episode_title"] = ""
|
||||||
|
updates["series_id"] = ""
|
||||||
|
}
|
||||||
if match.ReleaseDate != "" {
|
if match.ReleaseDate != "" {
|
||||||
updates["release_date"] = match.ReleaseDate
|
updates["release_date"] = match.ReleaseDate
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ type ScrapeOptions struct {
|
|||||||
EpisodeArtwork *bool
|
EpisodeArtwork *bool
|
||||||
DeferEpisodeDetails bool
|
DeferEpisodeDetails bool
|
||||||
ForceRematch bool
|
ForceRematch bool
|
||||||
|
RebuildIdentity bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o ScrapeOptions) episodeArtworkEnabled() bool {
|
func (o ScrapeOptions) episodeArtworkEnabled() bool {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -24,6 +25,10 @@ type ScraperService struct {
|
|||||||
hub *Hub
|
hub *Hub
|
||||||
cache *RuntimeCacheService
|
cache *RuntimeCacheService
|
||||||
images *ImageProxy
|
images *ImageProxy
|
||||||
|
|
||||||
|
// Serializes final sidecar replacement. Windows cannot rename over an
|
||||||
|
// existing file, and concurrent scrapes can target the same sidecar.
|
||||||
|
artworkWriteMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewScraperService is the constructor.
|
// NewScraperService is the constructor.
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func (s *ScraperService) writeMediaArtworkFilesAfterScrape(ctx context.Context,
|
|||||||
if base == "" || base == "." {
|
if base == "" || base == "." {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
isAdult := IsAdultMediaPathOrMetadata(refreshed.Path, refreshed.LibraryID, refreshed.NSFW) || IsAdultArtworkURL(refreshed.PosterURL)
|
isAdult := shouldCropAdultPoster(refreshed, lib)
|
||||||
if refreshed.PosterURL != "" {
|
if refreshed.PosterURL != "" {
|
||||||
s.downloadArtworkToPathWithOptions(ctx, dir, base+"-poster", refreshed.PosterURL, isAdult)
|
s.downloadArtworkToPathWithOptions(ctx, dir, base+"-poster", refreshed.PosterURL, isAdult)
|
||||||
}
|
}
|
||||||
@@ -60,6 +60,21 @@ func (s *ScraperService) downloadArtworkToPath(ctx context.Context, dir, name, r
|
|||||||
s.downloadArtworkToPathWithOptions(ctx, dir, name, raw, false)
|
s.downloadArtworkToPathWithOptions(ctx, dir, name, raw, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shouldCropAdultPoster keeps adult-cover handling independent of which
|
||||||
|
// metadata provider won. A code-numbered title may match TMDb first, so the
|
||||||
|
// provider's NSFW flag or artwork host alone is not sufficient.
|
||||||
|
func shouldCropAdultPoster(media *model.Media, lib *model.Library) bool {
|
||||||
|
if media == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
mediaType := ""
|
||||||
|
if lib != nil {
|
||||||
|
mediaType = lib.Type
|
||||||
|
}
|
||||||
|
return IsAdultMediaPathOrMetadata(media.Path, mediaType, media.NSFW) ||
|
||||||
|
IsAdultArtworkURL(media.PosterURL)
|
||||||
|
}
|
||||||
|
|
||||||
// downloadArtworkToPathWithOptions fetches an artwork URL via the image proxy cache and
|
// downloadArtworkToPathWithOptions fetches an artwork URL via the image proxy cache and
|
||||||
// writes it under dir/<name>.<ext>. For adult posters, it crops the right half of the cover.
|
// writes it under dir/<name>.<ext>. For adult posters, it crops the right half of the cover.
|
||||||
func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, dir, name, raw string, cropAdultPoster bool) {
|
func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, dir, name, raw string, cropAdultPoster bool) {
|
||||||
@@ -110,10 +125,23 @@ func (s *ScraperService) writeArtworkDataToPath(dir, name, ctype string, data []
|
|||||||
s.log.Warn("scrape artwork write failed", zap.String("dst", dst), zap.Error(err))
|
s.log.Warn("scrape artwork write failed", zap.String("dst", dst), zap.Error(err))
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
_ = tmp.Close()
|
if err := tmp.Close(); err != nil {
|
||||||
if err := os.Rename(tmp.Name(), dst); err != nil {
|
|
||||||
_ = os.Remove(tmp.Name())
|
_ = os.Remove(tmp.Name())
|
||||||
s.log.Warn("scrape artwork rename failed", zap.String("dst", dst), zap.Error(err))
|
s.log.Warn("scrape artwork close failed", zap.String("dst", dst), zap.Error(err))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// On Windows os.Rename does not replace dst. Serialize remove+rename so two
|
||||||
|
// concurrent scrapes cannot leave the previous uncropped DVD cover behind.
|
||||||
|
s.artworkWriteMu.Lock()
|
||||||
|
err = os.Remove(dst)
|
||||||
|
if err == nil || os.IsNotExist(err) {
|
||||||
|
err = os.Rename(tmp.Name(), dst)
|
||||||
|
}
|
||||||
|
s.artworkWriteMu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(tmp.Name())
|
||||||
|
s.log.Warn("scrape artwork replace failed", zap.String("dst", dst), zap.Error(err))
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
s.log.Debug("scrape artwork written", zap.String("dst", dst))
|
s.log.Debug("scrape artwork written", zap.String("dst", dst))
|
||||||
|
|||||||
@@ -33,6 +33,47 @@ func TestWriteArtworkDataToPathWritesJellyfinSidecar(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWriteArtworkDataToPathReplacesExistingSidecar(t *testing.T) {
|
||||||
|
scraper := &ScraperService{log: zap.NewNop()}
|
||||||
|
mediaDir := t.TempDir()
|
||||||
|
dst := filepath.Join(mediaDir, "poster.jpg")
|
||||||
|
if err := os.WriteFile(dst, []byte("old DVD cover"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := scraper.writeArtworkDataToPath(mediaDir, "poster", "image/jpeg", testJPEG); got != dst {
|
||||||
|
t.Fatalf("destination = %q, want %q", got, dst)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(dst)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(data) != string(testJPEG) {
|
||||||
|
t.Fatal("existing sidecar was not replaced")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldCropAdultPosterWhenTMDbArtworkMatchedCodePath(t *testing.T) {
|
||||||
|
media := &model.Media{
|
||||||
|
Path: filepath.Join(t.TempDir(), "IPX-235.mp4"),
|
||||||
|
Title: "TMDb matched title",
|
||||||
|
PosterURL: "https://image.tmdb.org/t/p/w500/poster.jpg",
|
||||||
|
}
|
||||||
|
if !shouldCropAdultPoster(media, &model.Library{Type: "movie"}) {
|
||||||
|
t.Fatal("code-numbered media should retain adult crop handling after a TMDb match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldCropAdultPosterUsesLibraryType(t *testing.T) {
|
||||||
|
media := &model.Media{
|
||||||
|
Path: filepath.Join(t.TempDir(), "renamed.mp4"),
|
||||||
|
PosterURL: "https://image.tmdb.org/t/p/w500/poster.jpg",
|
||||||
|
}
|
||||||
|
if !shouldCropAdultPoster(media, &model.Library{Type: "adult"}) {
|
||||||
|
t.Fatal("adult library media should be cropped regardless of provider")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestImageExtForContentType verifies the MIME -> extension mapping used to
|
// TestImageExtForContentType verifies the MIME -> extension mapping used to
|
||||||
// name Jellyfin sidecar files.
|
// name Jellyfin sidecar files.
|
||||||
func TestImageExtForContentType(t *testing.T) {
|
func TestImageExtForContentType(t *testing.T) {
|
||||||
|
|||||||
@@ -293,7 +293,11 @@ func (s *StrmService) save115OAuthToken(ctx context.Context, session *strm115Aut
|
|||||||
acct.LastTestAt = &now
|
acct.LastTestAt = &now
|
||||||
acct.LastTestResult = "授权成功"
|
acct.LastTestResult = "授权成功"
|
||||||
acct.LastTestOK = true
|
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) {
|
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"])
|
provider, err := s.providerFor(ctx, acct)
|
||||||
token, err := client.RefreshToken(cfg["refresh_token"])
|
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 {
|
if err != nil {
|
||||||
msg := "令牌刷新失败:" + err.Error()
|
msg := "令牌刷新失败:" + err.Error()
|
||||||
if cloud115.IsRefreshTokenDead(err) {
|
if cloud115.IsRefreshTokenDead(err) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/truewhile/MeBox/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/truewhile/MeBox/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestStrmAccountConfigPreviewOf(t *testing.T) {
|
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) {
|
func TestDeleteStrmAccountCascadesEmbyMounts(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{}, &model.StrmSyncPath{})
|
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{}, &model.StrmSyncPath{})
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ type StrmService struct {
|
|||||||
oauthSessions map[string]*strm115AuthSession
|
oauthSessions map[string]*strm115AuthSession
|
||||||
wafUntil time.Time // 115 风控/限流熔断截止时间(由 mu 保护)
|
wafUntil time.Time // 115 风控/限流熔断截止时间(由 mu 保护)
|
||||||
|
|
||||||
|
providerMu sync.Mutex
|
||||||
|
provider115Cache map[string]cloud.Provider // account ID -> shared provider/OpenClient
|
||||||
|
|
||||||
downloadSem115 chan struct{} // 115 换直链+下载并发上限(风控兜底)
|
downloadSem115 chan struct{} // 115 换直链+下载并发上限(风控兜底)
|
||||||
downloadSemDAV chan struct{} // WebDAV/OpenList/CloudDrive2 元数据下载并发上限
|
downloadSemDAV chan struct{} // WebDAV/OpenList/CloudDrive2 元数据下载并发上限
|
||||||
downloadSemOnce sync.Once
|
downloadSemOnce sync.Once
|
||||||
@@ -158,15 +161,16 @@ func (s *StrmService) releaseDownloadSlot(provider string) {
|
|||||||
// NewStrmService constructs the STRM service.
|
// NewStrmService constructs the STRM service.
|
||||||
func NewStrmService(cfg *config.Config, log *zap.Logger, repos *repository.Container, crypto *CryptoService) *StrmService {
|
func NewStrmService(cfg *config.Config, log *zap.Logger, repos *repository.Container, crypto *CryptoService) *StrmService {
|
||||||
return &StrmService{
|
return &StrmService{
|
||||||
log: log,
|
log: log,
|
||||||
repo: repos,
|
repo: repos,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
crypto: crypto,
|
crypto: crypto,
|
||||||
http: &http.Client{Timeout: 90 * time.Second},
|
http: &http.Client{Timeout: 90 * time.Second},
|
||||||
stopCh: make(chan struct{}),
|
stopCh: make(chan struct{}),
|
||||||
baseCtx: context.Background(),
|
baseCtx: context.Background(),
|
||||||
running: map[string]context.CancelFunc{},
|
running: map[string]context.CancelFunc{},
|
||||||
oauthSessions: map[string]*strm115AuthSession{},
|
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 {
|
if err := s.repo.StrmAccount.Update(ctx, acct); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if acct.Provider == model.StrmProvider115 && len(config) > 0 {
|
||||||
|
s.invalidate115Provider(acct.ID)
|
||||||
|
}
|
||||||
return acct, nil
|
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 {
|
if err := s.repo.StrmAccount.Delete(ctx, id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
s.invalidate115Provider(id)
|
||||||
// 级联清理远程 Emby 挂载:否则留下孤儿挂载,挂载计数/列表仍会显示。
|
// 级联清理远程 Emby 挂载:否则留下孤儿挂载,挂载计数/列表仍会显示。
|
||||||
// 账号已删,挂载清理失败只记日志,不让删除请求报错。
|
// 账号已删,挂载清理失败只记日志,不让删除请求报错。
|
||||||
if _, err := s.repo.EmbyMount.DeleteByAccountID(ctx, id); err != nil && s.log != nil {
|
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 依据账号配置构建网盘驱动。
|
// providerFor 依据账号配置构建网盘驱动。
|
||||||
func (s *StrmService) providerFor(ctx context.Context, acct *model.StrmAccount) (cloud.Provider, error) {
|
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)
|
cfg, err := s.strmAccountConfig(acct)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -482,13 +507,33 @@ func (s *StrmService) providerFor(ctx context.Context, acct *model.StrmAccount)
|
|||||||
// refresh_token 再刷(一次性轮转),两者互相作废,最终把有效账号
|
// refresh_token 再刷(一次性轮转),两者互相作废,最终把有效账号
|
||||||
// 标成“授权已失效”。
|
// 标成“授权已失效”。
|
||||||
if oc, ok := provider.(interface{ OpenClient() *cloud115.OpenClient }); ok {
|
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)
|
s.persist115Tokens(acct.ID, accessToken, refreshToken)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return provider, nil
|
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 设置(含默认值)。
|
// GetStrmSettings 返回全局 strm 设置(含默认值)。
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ func srtToVTT(body string) string {
|
|||||||
func assToVTT(body string) string {
|
func assToVTT(body string) string {
|
||||||
out := strings.Builder{}
|
out := strings.Builder{}
|
||||||
out.WriteString("WEBVTT\n\n")
|
out.WriteString("WEBVTT\n\n")
|
||||||
|
seen := make(map[string]struct{})
|
||||||
for i, line := range strings.Split(body, "\n") {
|
for i, line := range strings.Split(body, "\n") {
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
if !strings.HasPrefix(line, "Dialogue:") {
|
if !strings.HasPrefix(line, "Dialogue:") {
|
||||||
@@ -36,11 +37,22 @@ func assToVTT(body string) string {
|
|||||||
if len(parts) < 10 {
|
if len(parts) < 10 {
|
||||||
continue
|
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",
|
fmt.Fprintf(&out, "%d\n%s --> %s\n%s\n\n",
|
||||||
i,
|
i,
|
||||||
normaliseTimecode(parts[1]),
|
start,
|
||||||
normaliseTimecode(parts[2]),
|
end,
|
||||||
stripASSTags(parts[9]),
|
text,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return out.String()
|
return out.String()
|
||||||
@@ -86,5 +98,9 @@ func normaliseTimecode(t string) string {
|
|||||||
var assTag = regexp.MustCompile(`\{[^}]*\}`)
|
var assTag = regexp.MustCompile(`\{[^}]*\}`)
|
||||||
|
|
||||||
func stripASSTags(s string) string {
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
@@ -84,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) {
|
func TestSubtitleServeRawWritesSourceBytes(t *testing.T) {
|
||||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetState
|
|||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
import { mediaAPI, type ManualScrapeCandidate } from '../api/library'
|
import { mediaAPI, type ManualScrapeCandidate } from '../api/library'
|
||||||
|
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
||||||
import type { Media } from '../types'
|
import type { Media } from '../types'
|
||||||
import {
|
import {
|
||||||
candidateKey,
|
candidateKey,
|
||||||
@@ -103,7 +104,7 @@ export function useManualScrapeDialogState({
|
|||||||
function useManualScrapeFormState() {
|
function useManualScrapeFormState() {
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [selectedProviders, setSelectedProviders] = useState<string[]>([])
|
const [selectedProviders, setSelectedProviders] = useState<string[]>([])
|
||||||
const [includeEpisodeArtwork, setIncludeEpisodeArtwork] = useState(false)
|
const [includeEpisodeArtwork, setIncludeEpisodeArtwork] = useEpisodeArtworkPreference()
|
||||||
const [searching, setSearching] = useState(false)
|
const [searching, setSearching] = useState(false)
|
||||||
const [applyingKey, setApplyingKey] = useState('')
|
const [applyingKey, setApplyingKey] = useState('')
|
||||||
const [items, setItems] = useState<ManualScrapeCandidate[]>([])
|
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 { libraryAPI } from '../api/library'
|
||||||
import { toolsAPI } from '../api/tools'
|
import { toolsAPI } from '../api/tools'
|
||||||
import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
|
import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
|
||||||
|
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
||||||
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
||||||
import {
|
import {
|
||||||
LibrariesContent,
|
LibrariesContent,
|
||||||
@@ -21,7 +22,7 @@ export function LibrariesPage() {
|
|||||||
const { pinnedIds, loading: pinnedLoading, togglePin } = usePinnedLibraries()
|
const { pinnedIds, loading: pinnedLoading, togglePin } = usePinnedLibraries()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [repairing, setRepairing] = useState(false)
|
const [repairing, setRepairing] = useState(false)
|
||||||
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useState(false)
|
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useEpisodeArtworkPreference()
|
||||||
const [repairMsg, setRepairMsg] = useState('')
|
const [repairMsg, setRepairMsg] = useState('')
|
||||||
|
|
||||||
const fetchedLibIdsRef = useRef<Set<string>>(new Set())
|
const fetchedLibIdsRef = useRef<Set<string>>(new Set())
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export function LibrarySeriesEpisodes({
|
|||||||
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||||
{visibleEpisodes.map((ep) => {
|
{visibleEpisodes.map((ep) => {
|
||||||
const displayTitle = episodeDisplayTitle(ep, visibleEpisodes)
|
const displayTitle = episodeDisplayTitle(ep, visibleEpisodes)
|
||||||
|
const versionCount = ep.versions?.length ?? 1
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={ep.id}
|
key={ep.id}
|
||||||
@@ -102,6 +103,7 @@ export function LibrarySeriesEpisodes({
|
|||||||
{ep.duration_sec > 0
|
{ep.duration_sec > 0
|
||||||
? `${Math.floor(ep.duration_sec / 60)} 分钟`
|
? `${Math.floor(ep.duration_sec / 60)} 分钟`
|
||||||
: formatSize(ep.size_bytes)}
|
: formatSize(ep.size_bytes)}
|
||||||
|
{versionCount > 1 ? ` · ${versionCount} 版本` : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
+142
-43
@@ -13,6 +13,7 @@ import type { Media } from '../types'
|
|||||||
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
|
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
|
||||||
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||||
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, type PlayerMode } from './playerPageModel'
|
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, type PlayerMode } from './playerPageModel'
|
||||||
|
import { classifyDirectPlayError } from './directPlayError'
|
||||||
import { apiErrorMessage } from './StrmManagePage'
|
import { apiErrorMessage } from './StrmManagePage'
|
||||||
import { PlayerTopBar } from './PlayerTopBar'
|
import { PlayerTopBar } from './PlayerTopBar'
|
||||||
import { PlayerVideoStage } from './PlayerVideoStage'
|
import { PlayerVideoStage } from './PlayerVideoStage'
|
||||||
@@ -42,6 +43,9 @@ export function PlayerPage() {
|
|||||||
const ref = useRef<HTMLVideoElement>(null)
|
const ref = useRef<HTMLVideoElement>(null)
|
||||||
const hlsRef = useRef<Hls | null>(null)
|
const hlsRef = useRef<Hls | null>(null)
|
||||||
const lastSentRef = useRef(0)
|
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 [media, setMedia] = useState<Media | null>(null)
|
||||||
const [mode, setMode] = useState<PlayerMode>('direct')
|
const [mode, setMode] = useState<PlayerMode>('direct')
|
||||||
@@ -178,11 +182,40 @@ export function PlayerPage() {
|
|||||||
setDanmakuSelectedSource('')
|
setDanmakuSelectedSource('')
|
||||||
setDanmakuInfo(null)
|
setDanmakuInfo(null)
|
||||||
setDanmakuSearching(true)
|
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])
|
}, [id])
|
||||||
|
|
||||||
// 依赖收敛为 mode 参数的字符串值:避免 params 对象引用每次变化都重复拉取元数据
|
// 依赖收敛为 mode 参数的字符串值:避免 params 对象引用每次变化都重复拉取元数据
|
||||||
const modeParam = params.get('mode') as PlayerMode | null
|
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.
|
// Load metadata and pick a default mode.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
@@ -229,6 +262,10 @@ export function PlayerPage() {
|
|||||||
const selectedSubtitle = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
const selectedSubtitle = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
||||||
const burnedSubtitleStream =
|
const burnedSubtitleStream =
|
||||||
selectedSubtitle?.delivery === 'burn' ? selectedSubtitle.stream_index : undefined
|
selectedSubtitle?.delivery === 'burn' ? selectedSubtitle.stream_index : undefined
|
||||||
|
// 直连不使用烧录字幕参数。字幕列表通常比媒体信息晚返回,若把该参数直接
|
||||||
|
// 作为播放 effect 的依赖,会在 STRM 的 302 直链仍在建立时重复设置 src,
|
||||||
|
// Chromium 会把被中断的首次加载报告成播放错误并误触发 HLS 回退。
|
||||||
|
const activeBurnedSubtitleStream = mode === 'hls' ? burnedSubtitleStream : undefined
|
||||||
const mediaRef = useRef(media)
|
const mediaRef = useRef(media)
|
||||||
mediaRef.current = media
|
mediaRef.current = media
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -241,11 +278,19 @@ export function PlayerPage() {
|
|||||||
const video = ref.current
|
const video = ref.current
|
||||||
const durationSec = currentMedia.duration_sec || 0
|
const durationSec = currentMedia.duration_sec || 0
|
||||||
if (mode === 'hls') {
|
if (mode === 'hls') {
|
||||||
const url = hlsURL(mediaId, hlsStartSec, burnedSubtitleStream)
|
const url = hlsURL(mediaId, hlsStartSec, activeBurnedSubtitleStream)
|
||||||
void import('hls.js').then(({ default: HlsCtor }) => {
|
void import('hls.js').then(({ default: HlsCtor }) => {
|
||||||
if (cancelled || !ref.current) return
|
if (cancelled || !ref.current) return
|
||||||
if (HlsCtor.isSupported()) {
|
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 {
|
try {
|
||||||
video.currentTime = 0
|
video.currentTime = 0
|
||||||
} catch {
|
} catch {
|
||||||
@@ -260,6 +305,7 @@ export function PlayerPage() {
|
|||||||
mediaAPI
|
mediaAPI
|
||||||
.get(mediaId)
|
.get(mediaId)
|
||||||
.then((fresh) => {
|
.then((fresh) => {
|
||||||
|
if (cancelled || fresh.id !== mediaId) return
|
||||||
if ((fresh.duration_sec || 0) > 0) setMedia(fresh)
|
if ((fresh.duration_sec || 0) > 0) setMedia(fresh)
|
||||||
})
|
})
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
@@ -269,9 +315,7 @@ export function PlayerPage() {
|
|||||||
setHlsUnavailable(true)
|
setHlsUnavailable(true)
|
||||||
setPlayerError('HLS 转码不可用,正在尝试直接播放原始文件。若出现有画面无声音,通常是 MKV/AC3/EAC3 音轨需要配置本机 ffmpeg 转码为 AAC。')
|
setPlayerError('HLS 转码不可用,正在尝试直接播放原始文件。若出现有画面无声音,通常是 MKV/AC3/EAC3 音轨需要配置本机 ffmpeg 转码为 AAC。')
|
||||||
toast.error('HLS 转码失败,尝试切换到直接播放')
|
toast.error('HLS 转码失败,尝试切换到直接播放')
|
||||||
setMode('direct')
|
setPlaybackMode('direct')
|
||||||
params.set('mode', 'direct')
|
|
||||||
setParams(params, { replace: true })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
@@ -291,26 +335,46 @@ export function PlayerPage() {
|
|||||||
setHlsUnavailable(true)
|
setHlsUnavailable(true)
|
||||||
setPlayerError('当前浏览器不支持 HLS,正在尝试直接播放。')
|
setPlayerError('当前浏览器不支持 HLS,正在尝试直接播放。')
|
||||||
toast.error('当前浏览器不支持 HLS,降级到直接播放')
|
toast.error('当前浏览器不支持 HLS,降级到直接播放')
|
||||||
setMode('direct')
|
setPlaybackMode('direct')
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setHlsUnavailable(true)
|
setHlsUnavailable(true)
|
||||||
setPlayerError('HLS 播放组件加载失败,正在尝试直接播放。')
|
setPlayerError('HLS 播放组件加载失败,正在尝试直接播放。')
|
||||||
setMode('direct')
|
setPlaybackMode('direct')
|
||||||
})
|
})
|
||||||
} else {
|
} 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)) {
|
if (hlsUnavailable && needsTranscodeForBrowser(currentMedia)) {
|
||||||
setPlayerError('当前正在直连播放原始文件;此封装或音轨浏览器兼容性有限,可能只有画面没有声音。请配置本机 ffmpeg 后切回 HLS 转码播放。')
|
setPlayerError('当前正在直连播放原始文件;此封装或音轨浏览器兼容性有限,可能只有画面没有声音。请配置本机 ffmpeg 后切回 HLS 转码播放。')
|
||||||
}
|
}
|
||||||
void video.play().catch(() => undefined)
|
|
||||||
}
|
}
|
||||||
|
const onPlaying = () => clearFallbackTimer()
|
||||||
|
video.addEventListener('playing', onPlaying)
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
|
video.removeEventListener('playing', onPlaying)
|
||||||
teardownHls()
|
teardownHls()
|
||||||
}
|
}
|
||||||
}, [burnedSubtitleStream, 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
|
// Stop host ffmpeg when leaving this HLS player. The keepalive request also
|
||||||
// survives route navigation while the component is being torn down.
|
// survives route navigation while the component is being torn down.
|
||||||
@@ -361,21 +425,30 @@ export function PlayerPage() {
|
|||||||
}
|
}
|
||||||
const video = ref.current
|
const video = ref.current
|
||||||
if (!video) return
|
if (!video) return
|
||||||
|
let onSeeked: (() => void) | undefined
|
||||||
const applyResume = () => {
|
const applyResume = () => {
|
||||||
if (resumePosition > 0 && Math.abs(video.currentTime - resumePosition) > 2) {
|
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
|
video.currentTime = resumePosition
|
||||||
setInitialSeekDone(true)
|
return
|
||||||
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 })
|
|
||||||
}
|
}
|
||||||
|
setInitialSeekDone(true)
|
||||||
}
|
}
|
||||||
if (video.readyState >= 1) {
|
if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
|
||||||
applyResume()
|
applyResume()
|
||||||
} else {
|
} else {
|
||||||
video.addEventListener('loadedmetadata', applyResume, { once: true })
|
video.addEventListener('canplay', applyResume, { once: true })
|
||||||
return () => video.removeEventListener('loadedmetadata', applyResume)
|
}
|
||||||
|
return () => {
|
||||||
|
video.removeEventListener('canplay', applyResume)
|
||||||
|
if (onSeeked) video.removeEventListener('seeked', onSeeked)
|
||||||
}
|
}
|
||||||
}, [resumePosition, initialSeekDone, mode, hlsStartSec])
|
}, [resumePosition, initialSeekDone, mode, hlsStartSec])
|
||||||
|
|
||||||
@@ -495,7 +568,11 @@ export function PlayerPage() {
|
|||||||
[navigate, location.search, location.state],
|
[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(
|
const switchVersion = useCallback(
|
||||||
(version: Media) => {
|
(version: Media) => {
|
||||||
if (!version?.id || version.id === media?.id) return
|
if (!version?.id || version.id === media?.id) return
|
||||||
@@ -599,17 +676,14 @@ export function PlayerPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setHlsStartSec(ref.current?.currentTime || 0)
|
setHlsStartSec(ref.current?.currentTime || 0)
|
||||||
setMode('hls')
|
setPlaybackMode('hls')
|
||||||
params.set('mode', 'hls')
|
|
||||||
setParams(params, { replace: true })
|
|
||||||
}, [
|
}, [
|
||||||
directOnly,
|
directOnly,
|
||||||
directOnlyKnown,
|
directOnlyKnown,
|
||||||
isDirectStream,
|
isDirectStream,
|
||||||
mode,
|
mode,
|
||||||
params,
|
|
||||||
selectedSubtitle?.delivery,
|
selectedSubtitle?.delivery,
|
||||||
setParams,
|
setPlaybackMode,
|
||||||
])
|
])
|
||||||
|
|
||||||
const toggleMode = useCallback(() => {
|
const toggleMode = useCallback(() => {
|
||||||
@@ -621,10 +695,8 @@ export function PlayerPage() {
|
|||||||
if (next === 'hls') {
|
if (next === 'hls') {
|
||||||
setHlsStartSec(0)
|
setHlsStartSec(0)
|
||||||
}
|
}
|
||||||
setMode(next)
|
setPlaybackMode(next)
|
||||||
params.set('mode', next)
|
}, [isDirectStream, mode, setPlaybackMode])
|
||||||
setParams(params, { replace: true })
|
|
||||||
}, [isDirectStream, mode, params, setParams])
|
|
||||||
|
|
||||||
const handleSeekAbsolute = useCallback(
|
const handleSeekAbsolute = useCallback(
|
||||||
(absoluteSec: number) => {
|
(absoluteSec: number) => {
|
||||||
@@ -674,17 +746,47 @@ export function PlayerPage() {
|
|||||||
setSubtitleIndex(index)
|
setSubtitleIndex(index)
|
||||||
if (nextTrack?.delivery === 'burn' && mode !== 'hls' && !directOnly && !isDirectStream) {
|
if (nextTrack?.delivery === 'burn' && mode !== 'hls' && !directOnly && !isDirectStream) {
|
||||||
setHlsStartSec(ref.current?.currentTime || 0)
|
setHlsStartSec(ref.current?.currentTime || 0)
|
||||||
setMode('hls')
|
setPlaybackMode('hls')
|
||||||
params.set('mode', 'hls')
|
|
||||||
setParams(params, { replace: true })
|
|
||||||
}
|
}
|
||||||
}, [directOnly, hlsStartSec, isDirectStream, mode, params, setParams, subs, subtitleIndex])
|
}, [directOnly, hlsStartSec, isDirectStream, mode, setPlaybackMode, subs, subtitleIndex])
|
||||||
|
|
||||||
const handleVideoError = useCallback(() => {
|
const handleVideoError = useCallback(() => {
|
||||||
// 浏览器对 <video src> 的错误描述非常有限,把详细原因
|
const video = ref.current
|
||||||
// 转给开发者控制台 + 一条 toast;常见原因是 codec 不支持。
|
if (mode !== 'direct') {
|
||||||
if (mode === 'direct') {
|
setPlayerError('视频播放失败,请检查文件是否存在,或确认 ffmpeg 已正确配置。')
|
||||||
if (isRemoteEmbyID(media?.id) || isDirectStreamMedia(media)) {
|
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)播放。')
|
setPlayerError('直接播放失败。该媒体为远程 Emby 挂载直连播放(不进行转码);当前浏览器可能不支持该视频编码或音频格式,建议使用外部播放器(如 PotPlayer / VLC / IINA)播放。')
|
||||||
toast.error('直接播放失败,建议使用外部播放器')
|
toast.error('直接播放失败,建议使用外部播放器')
|
||||||
} else if (directOnly) {
|
} else if (directOnly) {
|
||||||
@@ -695,16 +797,13 @@ export function PlayerPage() {
|
|||||||
toast.error('直接播放失败,HLS 转码不可用')
|
toast.error('直接播放失败,HLS 转码不可用')
|
||||||
} else {
|
} else {
|
||||||
toast.error('直接播放失败,切换到 HLS 转码')
|
toast.error('直接播放失败,切换到 HLS 转码')
|
||||||
setMode('hls')
|
setPlaybackMode('hls')
|
||||||
params.set('mode', 'hls')
|
|
||||||
setParams(params, { replace: true })
|
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setPlayerError('视频播放失败,请检查文件是否存在,或确认 ffmpeg 已正确配置。')
|
clearFallbackTimer()
|
||||||
toast.error('视频播放失败,请检查文件是否存在')
|
fallbackTimerRef.current = setTimeout(fallbackDirectPlay, 1500)
|
||||||
}, [directOnly, hlsUnavailable, media, mode, params, setParams])
|
}, [clearFallbackTimer, directOnly, hlsUnavailable, mediaId, mode, setPlaybackMode])
|
||||||
|
|
||||||
const danmakuAutoTitle =
|
const danmakuAutoTitle =
|
||||||
danmakuInfo?.animeTitle ||
|
danmakuInfo?.animeTitle ||
|
||||||
|
|||||||
@@ -47,6 +47,14 @@ function parseWebVTTCues(body: string): SubtitleCue[] {
|
|||||||
return cues
|
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 = {
|
type PlayerVideoStageProps = {
|
||||||
media: Media | null
|
media: Media | null
|
||||||
/** 媒体元数据加载失败提示(非空时替代「加载中」展示)。 */
|
/** 媒体元数据加载失败提示(非空时替代「加载中」展示)。 */
|
||||||
@@ -135,6 +143,25 @@ export function PlayerVideoStage({
|
|||||||
path: string
|
path: string
|
||||||
cues: SubtitleCue[]
|
cues: SubtitleCue[]
|
||||||
} | null>(null)
|
} | 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(() => {
|
useEffect(() => {
|
||||||
const selectedTrack = subs[subtitleIndex]
|
const selectedTrack = subs[subtitleIndex]
|
||||||
@@ -236,6 +263,7 @@ export function PlayerVideoStage({
|
|||||||
const selectedTrack = subs[subtitleIndex]
|
const selectedTrack = subs[subtitleIndex]
|
||||||
if (
|
if (
|
||||||
!video ||
|
!video ||
|
||||||
|
!tracksArmed ||
|
||||||
subs.length === 0 ||
|
subs.length === 0 ||
|
||||||
subtitleIndex < 0 ||
|
subtitleIndex < 0 ||
|
||||||
!selectedTrack ||
|
!selectedTrack ||
|
||||||
@@ -248,9 +276,11 @@ export function PlayerVideoStage({
|
|||||||
const updateCue = () => {
|
const updateCue = () => {
|
||||||
const absoluteTime = video.currentTime + (streamOffset ?? 0)
|
const absoluteTime = video.currentTime + (streamOffset ?? 0)
|
||||||
if (subtitleTimeline?.path === selectedTrack.path) {
|
if (subtitleTimeline?.path === selectedTrack.path) {
|
||||||
const texts = subtitleTimeline.cues
|
const texts = uniqueSubtitleTexts(
|
||||||
.filter((cue) => absoluteTime >= cue.startTime && absoluteTime <= cue.endTime)
|
subtitleTimeline.cues
|
||||||
.map((cue) => cue.text)
|
.filter((cue) => absoluteTime >= cue.startTime && absoluteTime <= cue.endTime)
|
||||||
|
.map((cue) => cue.text),
|
||||||
|
)
|
||||||
setActiveCueText(texts.join('\n'))
|
setActiveCueText(texts.join('\n'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -285,7 +315,7 @@ export function PlayerVideoStage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setActiveCueText(texts.join('\n'))
|
setActiveCueText(uniqueSubtitleTexts(texts).join('\n'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const apply = () => {
|
const apply = () => {
|
||||||
@@ -340,7 +370,7 @@ export function PlayerVideoStage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [subtitleIndex, subs, videoRef, media, streamOffset, subtitleTimeline])
|
}, [subtitleIndex, subs, videoRef, media, streamOffset, subtitleTimeline, tracksArmed])
|
||||||
|
|
||||||
// 根据视频画面宽高比与舞台宽高比,确定视频在哪个轴向撑满 100%
|
// 根据视频画面宽高比与舞台宽高比,确定视频在哪个轴向撑满 100%
|
||||||
const isWiderThanStage =
|
const isWiderThanStage =
|
||||||
@@ -383,19 +413,19 @@ export function PlayerVideoStage({
|
|||||||
className="h-full w-full object-contain bg-black"
|
className="h-full w-full object-contain bg-black"
|
||||||
onError={onVideoError}
|
onError={onVideoError}
|
||||||
>
|
>
|
||||||
{subs.map((track, index) =>
|
{tracksArmed &&
|
||||||
track.delivery === 'burn' ? null : (
|
subs.map((track, index) =>
|
||||||
<track
|
track.delivery === 'burn' ? null : (
|
||||||
key={track.path}
|
<track
|
||||||
data-subtitle-index={index}
|
key={track.path}
|
||||||
kind="subtitles"
|
data-subtitle-index={index}
|
||||||
src={subtitlesAPI.url(media.id, track.path)}
|
kind="subtitles"
|
||||||
srcLang={track.lang}
|
src={subtitlesAPI.url(media.id, track.path)}
|
||||||
label={track.label || track.lang}
|
srcLang={track.lang}
|
||||||
default={subtitleIndex === index}
|
label={track.label || track.lang}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</video>
|
</video>
|
||||||
<DanmakuStage
|
<DanmakuStage
|
||||||
key={media.id}
|
key={media.id}
|
||||||
|
|||||||
@@ -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 { mediaAPI } from '../api/library'
|
||||||
import { toolsAPI } from '../api/tools'
|
import { toolsAPI } from '../api/tools'
|
||||||
import { confirmAction, confirmActionResult } from '../components/confirmAction'
|
import { confirmAction, confirmActionResult } from '../components/confirmAction'
|
||||||
|
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
||||||
import type { Library, Media } from '../types'
|
import type { Library, Media } from '../types'
|
||||||
import { seriesTitle, type SeriesCard } from '../utils/groupSeries'
|
import { seriesTitle, type SeriesCard } from '../utils/groupSeries'
|
||||||
import { LibraryMovieActions } from './LibraryMovieActions'
|
import { LibraryMovieActions } from './LibraryMovieActions'
|
||||||
@@ -33,7 +34,7 @@ export function useLibraryAdminActions({
|
|||||||
setManualMovie,
|
setManualMovie,
|
||||||
}: UseLibraryAdminActionsOptions) {
|
}: UseLibraryAdminActionsOptions) {
|
||||||
const [scraping, setScraping] = useState(false)
|
const [scraping, setScraping] = useState(false)
|
||||||
const [scrapeEpisodeArtwork, setScrapeEpisodeArtwork] = useState(false)
|
const [scrapeEpisodeArtwork, setScrapeEpisodeArtwork] = useEpisodeArtworkPreference()
|
||||||
const [repairing, setRepairing] = useState(false)
|
const [repairing, setRepairing] = useState(false)
|
||||||
const [seriesToolBusy, setSeriesToolBusy] = useState('')
|
const [seriesToolBusy, setSeriesToolBusy] = useState('')
|
||||||
const [movieToolBusy, setMovieToolBusy] = useState('')
|
const [movieToolBusy, setMovieToolBusy] = useState('')
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { api } from '../api/client'
|
|||||||
import { mediaAPI } from '../api/library'
|
import { mediaAPI } from '../api/library'
|
||||||
import { playbackAPI } from '../api/playback'
|
import { playbackAPI } from '../api/playback'
|
||||||
import { confirmActionResult } from '../components/confirmAction'
|
import { confirmActionResult } from '../components/confirmAction'
|
||||||
|
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
||||||
import type { Media } from '../types'
|
import type { Media } from '../types'
|
||||||
import { mediaLibraryBackTarget } from './MediaDetailPageModel'
|
import { mediaLibraryBackTarget } from './MediaDetailPageModel'
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa
|
|||||||
const [manualScrapeOpen, setManualScrapeOpen] = useState(false)
|
const [manualScrapeOpen, setManualScrapeOpen] = useState(false)
|
||||||
const [metadataEditOpen, setMetadataEditOpen] = useState(false)
|
const [metadataEditOpen, setMetadataEditOpen] = useState(false)
|
||||||
const [organizeOpen, setOrganizeOpen] = useState(false)
|
const [organizeOpen, setOrganizeOpen] = useState(false)
|
||||||
const [scrapeEpisodeArtwork, setScrapeEpisodeArtwork] = useState(false)
|
const [scrapeEpisodeArtwork, setScrapeEpisodeArtwork] = useEpisodeArtworkPreference()
|
||||||
|
|
||||||
const refresh = useMediaDetailRefresh({
|
const refresh = useMediaDetailRefresh({
|
||||||
id,
|
id,
|
||||||
|
|||||||
+2
-1
@@ -20,5 +20,6 @@
|
|||||||
"@/*": ["src/*"]
|
"@/*": ["src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user