Compare commits

...

9 Commits

Author SHA1 Message Date
truewhile 4239474100 优化 2026-09-08 23:11:57 +08:00
truewhile 8225b4e319 优化 2026-09-08 23:05:07 +08:00
truewhile c086b01381 优化 2026-09-08 22:44:36 +08:00
truewhile f196e5621f 优化 2026-09-08 22:12:26 +08:00
truewhile 13d60b3bd9 分集图片设置落库 2026-09-08 22:00:06 +08:00
truewhile 3d30f6f27c bug处理 2026-09-08 21:08:47 +08:00
truewhile bd02e194ff 优化 2026-09-08 20:52:35 +08:00
truewhile 1c9b742c8d 优化 2026-09-08 20:24:45 +08:00
truewhile 20e7d7fa99 bug处理 2026-09-08 18:12:52 +08:00
35 changed files with 1144 additions and 133 deletions
+4 -2
View File
@@ -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)})
} }
} }
+47
View File
@@ -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 {
+22 -1
View File
@@ -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")
+13 -10
View File
@@ -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 {
+11 -3
View File
@@ -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
} }
+34 -12
View File
@@ -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
+20
View File
@@ -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)
}
}
}
+46 -2
View File
@@ -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
} }
} }
+156
View File
@@ -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)
}
}
+19
View File
@@ -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) {
+22
View File
@@ -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",
+49 -10
View File
@@ -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 {
+11 -4
View File
@@ -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)
+22 -2
View File
@@ -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
} }
+1
View File
@@ -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 {
+5
View File
@@ -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) {
+14 -3
View File
@@ -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) {
+85
View File
@@ -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{})
+55 -10
View File
@@ -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 设置(含默认值)。
+20 -4
View File
@@ -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)
} }
+20
View File
@@ -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]
}
+2 -1
View File
@@ -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())
+2
View File
@@ -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
View File
@@ -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 ||
+48 -18
View File
@@ -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}
+24
View File
@@ -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'
}
+75
View File
@@ -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')
+2 -1
View File
@@ -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('')
+2 -1
View File
@@ -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
View File
@@ -20,5 +20,6 @@
"@/*": ["src/*"] "@/*": ["src/*"]
} }
}, },
"include": ["src"] "include": ["src"],
"exclude": ["src/**/*.test.ts"]
} }