mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cfe0c0cf28 | |||
| d6355d5582 | |||
| 0f06bdf929 | |||
| e840cbd2c9 | |||
| 2bb3610da7 | |||
| 9ca1b28cb4 | |||
| 9effb1422b | |||
| b855e00345 | |||
| 8f2551a1b6 | |||
| 18e6cb40fd | |||
| 92e693ccac | |||
| 62206aa05a | |||
| f9dd082159 | |||
| d489c1608e | |||
| 1ea29cca9e | |||
| ec5f81e6cb | |||
| 8eb017fedc |
@@ -14,6 +14,7 @@ verify-cache/
|
||||
verify-media/
|
||||
verify-downloads/
|
||||
.codex-*
|
||||
.codex/
|
||||
.tmp/
|
||||
.tmp_*
|
||||
.tmp-deploy-*
|
||||
|
||||
@@ -66,6 +66,7 @@ config.yaml
|
||||
.tmp-live-backups/
|
||||
.tmp-*
|
||||
.codex-*
|
||||
.codex/
|
||||
downloads/
|
||||
media/
|
||||
*.pid
|
||||
|
||||
@@ -19,6 +19,7 @@ require (
|
||||
github.com/ulikunitz/xz v0.5.12
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/image v0.37.0
|
||||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/time v0.15.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -86,7 +87,6 @@ require (
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
golang.org/x/arch v0.25.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/image v0.37.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -359,6 +361,7 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
|
||||
Auth: auth,
|
||||
Media: service.NewMediaService(cfg, log, repos),
|
||||
Stream: service.NewStreamService(cfg, log, repos, nil),
|
||||
Subtitle: service.NewSubtitleService(cfg, log, repos),
|
||||
Permissions: permissions,
|
||||
}
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
@@ -440,6 +443,38 @@ func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitleListOnlyProbesEmbeddedTracksForHLS(t *testing.T) {
|
||||
router, svc, secret := newPlaybackScopeTestRouter(t)
|
||||
loginToken := signedTestToken(t, secret)
|
||||
resolveCalls := 0
|
||||
svc.Subtitle.SetStrmPlayTargetResolver(func(context.Context, string) (*service.StrmPlayResult, error) {
|
||||
resolveCalls++
|
||||
return nil, errors.New("probe resolver called")
|
||||
})
|
||||
|
||||
request := func(path string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://nas.local"+path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+loginToken)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
if w := request("/api/media/media-1/subtitles"); w.Code != http.StatusOK {
|
||||
t.Fatalf("direct subtitle status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if resolveCalls != 0 {
|
||||
t.Fatalf("direct subtitle request resolved cloud media %d times, want 0", resolveCalls)
|
||||
}
|
||||
|
||||
if w := request("/api/media/media-1/subtitles?include_embedded=true"); w.Code != http.StatusOK {
|
||||
t.Fatalf("HLS subtitle status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if resolveCalls != 1 {
|
||||
t.Fatalf("HLS subtitle request resolved cloud media %d times, want 1", resolveCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHLSPlaylistForRemoteEmbyMediaDisabled(t *testing.T) {
|
||||
router, svc, secret := newPlaybackScopeTestRouter(t)
|
||||
svc.EmbyRemote = &service.EmbyRemoteService{}
|
||||
|
||||
@@ -54,6 +54,7 @@ func registerAuthedMediaRoutes(authed *gin.RouterGroup, svc *service.Container)
|
||||
authed.DELETE("/media/:id", middleware.AdminRequired(), deleteMediaHandler(svc))
|
||||
authed.GET("/media/:id/subtitles", listSubtitlesHandler(svc))
|
||||
authed.GET("/subtitles/:id", serveSubtitleHandler(svc))
|
||||
authed.GET("/subtitles/:id/ass", serveASSSubtitleHandler(svc))
|
||||
authed.POST("/media/:id/nfo", middleware.AdminRequired(), exportNFOHandler(svc))
|
||||
authed.POST("/libraries/:id/nfo", middleware.AdminRequired(), exportLibraryNFOHandler(svc))
|
||||
}
|
||||
|
||||
@@ -16,7 +16,13 @@ func listSubtitlesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusOK, gin.H{"tracks": []service.SubtitleTrack{}})
|
||||
return
|
||||
}
|
||||
tracks, err := svc.Subtitle.Discover(c.Request.Context(), id)
|
||||
var tracks []service.SubtitleTrack
|
||||
var err error
|
||||
if c.Query("include_embedded") == "true" {
|
||||
tracks, err = svc.Subtitle.Discover(c.Request.Context(), id)
|
||||
} else {
|
||||
tracks, err = svc.Subtitle.DiscoverExternalOnly(c.Request.Context(), id)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -28,6 +34,22 @@ func listSubtitlesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func serveASSSubtitleHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := c.Query("path")
|
||||
if path == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing path"})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "text/plain; charset=utf-8")
|
||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
if err := svc.Subtitle.ServeASS(c.Request.Context(), c.Param("id"), path, c.Writer); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func serveSubtitleHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := c.Query("path")
|
||||
|
||||
@@ -28,6 +28,9 @@ type User struct {
|
||||
// PinnedLibraryIDs 存储用户置顶的媒体库 ID 列表(JSON 字符串),顺序即置顶优先级。
|
||||
PinnedLibraryIDs string `gorm:"type:text" json:"-"`
|
||||
PinnedLibraryList []string `gorm:"-" json:"pinned_library_ids,omitempty"`
|
||||
// SubtitleChineseMode 是网页播放器外挂字幕的简繁转换偏好:
|
||||
// original / simplified / traditional。
|
||||
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
|
||||
// ExpiredAt is the account expiry time. Nil means the account never
|
||||
// expires. When set and in the past, the account is treated as expired
|
||||
// (login blocked) until an admin or a redemption code renews it.
|
||||
|
||||
@@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -10,6 +11,18 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// MediaUpsertItem carries a media row and optional sibling paths from an
|
||||
// earlier keep_ext naming mode. An alias is only used when the exact new path
|
||||
// does not exist in the database; in that case the existing row is migrated to
|
||||
// the new path so scraped metadata survives renames such as:
|
||||
//
|
||||
// foo.strm -> foo.mkv.strm
|
||||
// foo.mkv.strm -> foo.strm
|
||||
type MediaUpsertItem struct {
|
||||
Media *model.Media
|
||||
AliasPaths []string
|
||||
}
|
||||
|
||||
// Upsert inserts or updates a media row keyed by Path (unique index).
|
||||
//
|
||||
// 重要:当一条行已经存在时,scanner 重扫只应该刷新文件级元数据
|
||||
@@ -22,20 +35,14 @@ import (
|
||||
// 显式写入)。这两个问题都让 EnrichLibrary(WHERE scrape_status='pending')
|
||||
// 永远捞不到数据。
|
||||
func (r *MediaRepository) Upsert(ctx context.Context, m *model.Media) error {
|
||||
var indexIDs []string
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
id, uerr := r.upsertWithDB(ctx, r.db, m)
|
||||
if uerr != nil {
|
||||
return uerr
|
||||
}
|
||||
indexIDs = append(indexIDs[:0], id)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.indexByIDBestEffort(ctx, indexIDs)
|
||||
return nil
|
||||
return r.UpsertWithAliases(ctx, m, nil)
|
||||
}
|
||||
|
||||
// UpsertWithAliases is Upsert with explicit, filesystem-verified STRM sibling
|
||||
// aliases. It preserves the old row's ID, CreatedAt and scraped metadata while
|
||||
// moving it to the current path.
|
||||
func (r *MediaRepository) UpsertWithAliases(ctx context.Context, m *model.Media, aliasPaths []string) error {
|
||||
return r.UpsertBatchWithAliases(ctx, []MediaUpsertItem{{Media: m, AliasPaths: aliasPaths}})
|
||||
}
|
||||
|
||||
// UpsertBatch 在单个事务里逐条执行 Upsert:扫描一批只提交(fsync)一次,
|
||||
@@ -46,6 +53,17 @@ func (r *MediaRepository) Upsert(ctx context.Context, m *model.Media) error {
|
||||
// 事务内会把 SQLite 写锁挂起在网络 IO 上,且批内用非事务连接回读只能
|
||||
// 拿到提交前的旧版本数据,把陈旧内容写进索引。
|
||||
func (r *MediaRepository) UpsertBatch(ctx context.Context, items []*model.Media) error {
|
||||
mapped := make([]MediaUpsertItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item != nil {
|
||||
mapped = append(mapped, MediaUpsertItem{Media: item})
|
||||
}
|
||||
}
|
||||
return r.UpsertBatchWithAliases(ctx, mapped)
|
||||
}
|
||||
|
||||
// UpsertBatchWithAliases runs alias-aware upserts in one transaction.
|
||||
func (r *MediaRepository) UpsertBatchWithAliases(ctx context.Context, items []MediaUpsertItem) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -53,11 +71,11 @@ func (r *MediaRepository) UpsertBatch(ctx context.Context, items []*model.Media)
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
indexIDs = indexIDs[:0]
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for _, m := range items {
|
||||
if m == nil {
|
||||
for _, item := range items {
|
||||
if item.Media == nil {
|
||||
continue
|
||||
}
|
||||
id, err := r.upsertWithDB(ctx, tx, m)
|
||||
id, err := r.upsertWithDB(ctx, tx, item.Media, item.AliasPaths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -87,9 +105,9 @@ func (r *MediaRepository) indexByIDBestEffort(ctx context.Context, ids []string)
|
||||
}
|
||||
}
|
||||
|
||||
// upsertWithDB 落库(新建或更新),返回需要重建索引的媒体 ID(无则空串)。
|
||||
func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *model.Media) (string, error) {
|
||||
existing, created, err := r.findOrCreateMediaByPath(ctx, db, m)
|
||||
// upsertWithDB 落库(新建、更新或从旧路径迁移),返回需要重建索引的媒体 ID。
|
||||
func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *model.Media, aliasPaths []string) (string, error) {
|
||||
existing, created, adopted, err := r.findOrCreateMediaByPath(ctx, db, m, aliasPaths)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -98,6 +116,12 @@ func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *mode
|
||||
}
|
||||
|
||||
updates := mediaUpsertUpdates(existing, *m)
|
||||
if adopted {
|
||||
updates["path"] = m.Path
|
||||
if existing.DeletedAt.Valid {
|
||||
updates["deleted_at"] = nil
|
||||
}
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
*m = existing
|
||||
return "", nil
|
||||
@@ -107,31 +131,67 @@ func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *mode
|
||||
return "", err
|
||||
}
|
||||
// 回写 ID / 不可变字段,让 caller 拿到完整的现有行。
|
||||
if adopted {
|
||||
existing.Path = m.Path
|
||||
existing.DeletedAt = gorm.DeletedAt{}
|
||||
}
|
||||
*m = existing
|
||||
return existing.ID, nil
|
||||
}
|
||||
|
||||
func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.DB, m *model.Media) (model.Media, bool, error) {
|
||||
func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.DB, m *model.Media, aliasPaths []string) (model.Media, bool, bool, error) {
|
||||
var existing model.Media
|
||||
err := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if alias, aliasErr := r.findMediaByAlias(ctx, db, m.Path, aliasPaths); aliasErr == nil {
|
||||
return alias, false, true, nil
|
||||
} else if !errors.Is(aliasErr, gorm.ErrRecordNotFound) {
|
||||
return model.Media{}, false, false, aliasErr
|
||||
}
|
||||
// 新行:保证 scrape_status 走 GORM default:pending(即留空让数据库填)。
|
||||
if m.ScrapeStatus == "" {
|
||||
m.ScrapeStatus = "pending"
|
||||
}
|
||||
if createErr := db.WithContext(ctx).Create(m).Error; createErr == nil {
|
||||
return *m, true, nil
|
||||
return *m, true, false, nil
|
||||
} else if retryErr := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error; retryErr != nil {
|
||||
return model.Media{}, false, createErr
|
||||
return model.Media{}, false, false, createErr
|
||||
} else {
|
||||
// 并发插入竞态:重查已命中既有行,直接走更新分支。
|
||||
return existing, false, nil
|
||||
return existing, false, false, nil
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return model.Media{}, false, err
|
||||
return model.Media{}, false, false, err
|
||||
}
|
||||
return existing, false, nil
|
||||
return existing, false, false, nil
|
||||
}
|
||||
|
||||
func (r *MediaRepository) findMediaByAlias(ctx context.Context, db *gorm.DB, currentPath string, aliasPaths []string) (model.Media, error) {
|
||||
aliases := make([]string, 0, len(aliasPaths))
|
||||
seen := make(map[string]struct{}, len(aliasPaths))
|
||||
for _, alias := range aliasPaths {
|
||||
alias = filepath.Clean(strings.TrimSpace(alias))
|
||||
if alias == "" || alias == "." || alias == filepath.Clean(currentPath) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[alias]; ok {
|
||||
continue
|
||||
}
|
||||
seen[alias] = struct{}{}
|
||||
aliases = append(aliases, alias)
|
||||
}
|
||||
if len(aliases) == 0 {
|
||||
return model.Media{}, gorm.ErrRecordNotFound
|
||||
}
|
||||
var existing model.Media
|
||||
err := db.WithContext(ctx).Unscoped().
|
||||
Where("path IN ?", aliases).
|
||||
Order("CASE WHEN scrape_status = 'matched' THEN 0 ELSE 1 END ASC, " +
|
||||
"CASE WHEN COALESCE(poster_url, '') <> '' OR COALESCE(overview, '') <> '' THEN 0 ELSE 1 END ASC, " +
|
||||
"CASE WHEN deleted_at IS NULL THEN 0 ELSE 1 END ASC, updated_at DESC, created_at DESC").
|
||||
First(&existing).Error
|
||||
return existing, err
|
||||
}
|
||||
|
||||
func mediaUpsertUpdates(existing, incoming model.Media) map[string]any {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/metatube-community/metatube-sdk-go/detector"
|
||||
_ "golang.org/x/image/webp" // register WebP decoder for AVE covers
|
||||
)
|
||||
|
||||
const adultPosterRatio = 2.0 / 3.0
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
@@ -58,6 +59,27 @@ func TestCropAdultCoverPosterWideLandscape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCropAdultCoverPosterDecodesWebP(t *testing.T) {
|
||||
wideWebP, err := base64.StdEncoding.DecodeString("UklGRjwAAABXRUJQVlA4IDAAAADQAQCdASoJAAYAAUAmJaACdLoB+AADsAD+8ut//NgVzXPv9//S4P0uD9Lg/9KQAAA=")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
croppedBytes, ctype, err := CropAdultCoverPoster(wideWebP)
|
||||
if err != nil {
|
||||
t.Fatalf("crop WebP: %v", err)
|
||||
}
|
||||
if ctype != "image/jpeg" {
|
||||
t.Fatalf("cropped WebP type = %q, want image/jpeg", ctype)
|
||||
}
|
||||
cropped, _, err := image.Decode(bytes.NewReader(croppedBytes))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := float64(cropped.Bounds().Dx()) / float64(cropped.Bounds().Dy()); got < 0.65 || got > 0.68 {
|
||||
t.Fatalf("cropped WebP ratio = %.3f, want 2:3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCropAdultCoverPosterCentersDetectedFace(t *testing.T) {
|
||||
originalDetector := findPrimaryFaceAxisRatio
|
||||
findPrimaryFaceAxisRatio = func(image.Image, float64, bool) (float64, bool) {
|
||||
|
||||
@@ -23,6 +23,7 @@ func parseAdultDetailHTML(body, code, source, detailURL string) *Match {
|
||||
}
|
||||
|
||||
match := &Match{
|
||||
Provider: strings.ToLower(strings.TrimSpace(source)),
|
||||
OriginalName: code,
|
||||
MediaType: "adult",
|
||||
NSFW: true,
|
||||
|
||||
@@ -120,8 +120,8 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
embySeasonDirRE = regexp.MustCompile(`(?i)^(season[\s._-]*\d+|s\d+|specials?|sp|ova|oad|extra|extras|第\s*[0-9一二三四五六七八九十百零两]+\s*季|特别篇|特別篇|番外|特典)$`)
|
||||
embySeasonSuffixRE = regexp.MustCompile(`(?i)(?:[\s._-]+(?:season[\s._-]*\d+|s\d+|第\s*[0-9一二三四五六七八九十百零两]+\s*季|specials?|sp|ova|oad|extra|extras|特别篇|特別篇|番外|特典)|\s*第\s*[0-9一二三四五六七八九十百零两]+\s*季)\s*$`)
|
||||
embySeasonDirRE = regexp.MustCompile(`(?i)^(season[\s._-]*\d+|s\d+|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|第\s*[0-9一二三四五六七八九十百零两]+\s*季|特别篇|特別篇|番外|特典|画像特典)$`)
|
||||
embySeasonSuffixRE = regexp.MustCompile(`(?i)(?:[\s._-]+(?:season[\s._-]*\d+|s\d+|第\s*[0-9一二三四五六七八九十百零两]+\s*季|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|特别篇|特別篇|番外|特典|画像特典)|\s*第\s*[0-9一二三四五六七八九十百零两]+\s*季)\s*$`)
|
||||
embyYearSuffixRE = regexp.MustCompile(`\s*[\((\[]\d{4}[\))\]]\s*$`)
|
||||
embyEpisodeTitleRE = regexp.MustCompile(`(?i)\s*[-_ ]*s\d{1,2}e\d{1,3}.*$`)
|
||||
)
|
||||
|
||||
@@ -55,6 +55,17 @@ func (e *EmbyService) mediaVersionSiblings(ctx context.Context, m *model.Media)
|
||||
if err := q.Find(&rows).Error; err != nil || len(rows) == 0 {
|
||||
return []model.Media{*m}
|
||||
}
|
||||
targetKey := e.mediaVersionKey(ctx, m)
|
||||
filtered := rows[:0]
|
||||
for i := range rows {
|
||||
if e.mediaVersionKey(ctx, &rows[i]) == targetKey {
|
||||
filtered = append(filtered, rows[i])
|
||||
}
|
||||
}
|
||||
rows = filtered
|
||||
if len(rows) == 0 {
|
||||
return []model.Media{*m}
|
||||
}
|
||||
rows = e.collapseExactPathRows(rows)
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
if rows[i].ID == m.ID {
|
||||
@@ -97,11 +108,28 @@ func (e *EmbyService) mediaVersionKey(ctx context.Context, m *model.Media) strin
|
||||
if libraryGroup == "" {
|
||||
libraryGroup = strings.TrimSpace(m.LibraryID)
|
||||
}
|
||||
kind := mediaSpecialKind(m.Path)
|
||||
season, episode := m.SeasonNum, m.EpisodeNum
|
||||
if kind != "" && kind != mediaSpecialTheatrical && episode <= 0 {
|
||||
if parsedSeason, parsedEpisode := ParseEpisode(m.Path); parsedEpisode > 0 {
|
||||
season, episode = parsedSeason, parsedEpisode
|
||||
}
|
||||
}
|
||||
kindKey := ""
|
||||
if kind != "" {
|
||||
kindKey = "|kind:" + kind
|
||||
}
|
||||
if kind != "" && kind != mediaSpecialTheatrical && episode <= 0 {
|
||||
if stemKey := mediaVersionStemGroupKey(*m, libraryGroup); stemKey != "" {
|
||||
return stemKey + kindKey
|
||||
}
|
||||
return libraryGroup + "|special-item:" + kind + "|id:" + m.ID
|
||||
}
|
||||
if m.TMDbID > 0 {
|
||||
return fmt.Sprintf("%s|tmdb:%d|s:%d|e:%d", libraryGroup, m.TMDbID, m.SeasonNum, m.EpisodeNum)
|
||||
return fmt.Sprintf("%s|tmdb:%d|s:%d|e:%d%s", libraryGroup, m.TMDbID, season, episode, kindKey)
|
||||
}
|
||||
if m.BangumiID > 0 {
|
||||
return fmt.Sprintf("%s|bangumi:%d|s:%d|e:%d", libraryGroup, m.BangumiID, m.SeasonNum, m.EpisodeNum)
|
||||
return fmt.Sprintf("%s|bangumi:%d|s:%d|e:%d%s", libraryGroup, m.BangumiID, season, episode, kindKey)
|
||||
}
|
||||
title := strings.ToLower(strings.TrimSpace(m.Title))
|
||||
if title == "" {
|
||||
@@ -110,7 +138,7 @@ func (e *EmbyService) mediaVersionKey(ctx context.Context, m *model.Media) strin
|
||||
if title == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s|title:%s|y:%d|s:%d|e:%d", libraryGroup, title, m.Year, m.SeasonNum, m.EpisodeNum)
|
||||
return fmt.Sprintf("%s|title:%s|y:%d|s:%d|e:%d%s", libraryGroup, title, m.Year, season, episode, kindKey)
|
||||
}
|
||||
|
||||
func preferMediaVersion(candidate, current model.Media) bool {
|
||||
|
||||
@@ -220,7 +220,7 @@ func embyLikelyEpisodicPathSQL() (string, []any) {
|
||||
patterns := []string{
|
||||
"%/season %/%", "%/season.%/%", "%/season-%/%", "%/season_%/%",
|
||||
"%/s0%/%", "%/s1%/%", "%/s2%/%", "%/s3%/%", "%/s4%/%", "%/s5%/%", "%/s6%/%", "%/s7%/%", "%/s8%/%", "%/s9%/%",
|
||||
"%/special/%", "%/specials/%", "%/sp/%", "%/ova/%", "%/oad/%", "%/extra/%", "%/extras/%",
|
||||
"%/special/%", "%/specials/%", "%/sp/%", "%/ova/%", "%/ovas/%", "%/oad/%", "%/oads/%", "%/ovd/%", "%/ovds/%", "%/ona/%", "%/onas/%", "%/extra/%", "%/extras/%", "%/bonus/%", "%/bonuses/%", "%/omake/%", "%/picture drama/%", "%/ncop/%", "%/nced/%",
|
||||
"%/电视剧/%", "%/剧集/%", "%/连续剧/%", "%/短剧/%", "%/国产剧/%", "%/国剧/%", "%/大陆剧/%", "%/华语剧/%", "%/国产电视剧/%", "%/大陆电视剧/%", "%/华语电视剧/%", "%/欧美剧/%", "%/欧美电视剧/%", "%/美剧/%", "%/英剧/%", "%/日韩剧/%", "%/日韩电视剧/%", "%/日剧/%", "%/韩剧/%", "%/港剧/%", "%/台剧/%", "%/港台剧/%", "%/泰剧/%",
|
||||
"%/日番/%", "%/国漫/%", "%/番剧/%", "%/动漫/%", "%/特别篇/%", "%/特別篇/%", "%/番外/%", "%/特典/%",
|
||||
}
|
||||
@@ -243,7 +243,7 @@ func embyMediaPathLooksEpisodic(path string) bool {
|
||||
return false
|
||||
}
|
||||
for _, marker := range []string{
|
||||
"/season ", "/season.", "/season-", "/season_", "/special/", "/specials/", "/sp/", "/ova/", "/oad/", "/extra/", "/extras/",
|
||||
"/season ", "/season.", "/season-", "/season_", "/special/", "/specials/", "/sp/", "/ova/", "/ovas/", "/oad/", "/oads/", "/ovd/", "/ovds/", "/ona/", "/onas/", "/extra/", "/extras/", "/bonus/", "/bonuses/", "/omake/", "/picture drama/", "/ncop/", "/nced/",
|
||||
"/电视剧/", "/剧集/", "/连续剧/", "/短剧/", "/国产剧/", "/国剧/", "/大陆剧/", "/华语剧/", "/国产电视剧/", "/大陆电视剧/", "/华语电视剧/", "/欧美剧/", "/欧美电视剧/", "/美剧/", "/英剧/", "/日韩剧/", "/日韩电视剧/", "/日剧/", "/韩剧/", "/港剧/", "/台剧/", "/港台剧/", "/泰剧/",
|
||||
"/日番/", "/国漫/", "/番剧/", "/动漫/", "/特别篇/", "/特別篇/", "/番外/", "/特典/",
|
||||
} {
|
||||
|
||||
@@ -19,7 +19,7 @@ const pollutedEpisodeCleanupSettingKey = "media.polluted_episode_cleanup_v2_done
|
||||
|
||||
// seasonFolderTailRE 去掉路径末尾的「季文件夹 + 文件名」,得到整剧目录(show_dir)。
|
||||
// 例: /tv/国漫/遮天 (2023)/Season 01/遮天 - S01E01.mkv → /tv/国漫/遮天 (2023)
|
||||
var seasonFolderTailRE = regexp.MustCompile(`(?i)[\\/](?:season[\s._-]*\d+|s\d{1,2}|specials?|sp|ova|oad|extra|extras|第\s*[0-9一二三四五六七八九十百零两]+\s*季|特别篇|特別篇|番外|特典)[\\/][^\\/]*$`)
|
||||
var seasonFolderTailRE = regexp.MustCompile(`(?i)[\\/](?:season[\s._-]*\d+|s\d{1,2}|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|第\s*[0-9一二三四五六七八九十百零两]+\s*季|特别篇|特別篇|番外|特典|画像特典)[\\/][^\\/]*$`)
|
||||
|
||||
// showDirFromEpisodePath 从单集路径推出整剧目录, 作为「同一部剧」的聚合键。
|
||||
// 若没有季文件夹, 则退而去掉文件名取其父目录。
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
// 1x02 / 01x02
|
||||
// EP02 / E02
|
||||
// 第2集 / 第02集
|
||||
// [01] / [001](字幕组方括号集号,如 [UHA-WINGS][…][01][BDRIP])
|
||||
//
|
||||
// The "1x02" pattern only matches when it is not embedded in a larger number,
|
||||
// so pixel dimensions such as 1920x1080 / 3840x2160 are never mistaken for
|
||||
// season×episode (the old pattern read those as S20E108 / S40E216).
|
||||
//
|
||||
// For bare episode markers such as "EP02", the parser also looks at parent
|
||||
// folders like "Season 02" / "S02" / "第2季" before falling back to season 1.
|
||||
@@ -14,6 +19,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -22,21 +28,32 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
|
||||
patSEnERange = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\s*[-~–—]\s*(?:s(\d{1,2}))?e?(\d{1,3})(?:[^0-9]|$)`)
|
||||
patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`)
|
||||
patNxE = regexp.MustCompile(`(\d{1,2})x(\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]|$)`)
|
||||
patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`)
|
||||
// patNxE 匹配 1x02 这类季集写法的捕获组,同时用于从标题里剔除季集残留
|
||||
// (ReplaceAllString),因此本身不带边界守卫。解析时改用 patNxEGuarded,
|
||||
// 避免 "1920x1080" 被从中间匹配出 "20x108" 而误判成 S20E108。
|
||||
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
|
||||
patNxEGuarded = regexp.MustCompile(`(?:^|[^0-9])(\d{1,2})x(\d{1,3})(?:[^0-9]|$)`)
|
||||
// patBracketEpisode 匹配字幕组常见的纯数字方括号集号,如 [01] / [012]。
|
||||
// 限定 1-3 位,避免把 [2024] 这类年份当成集号;含字母的 [NCOP]/[1080p]
|
||||
// 自然不匹配。
|
||||
patBracketEpisode = regexp.MustCompile(`\[0*(\d{1,3})\]`)
|
||||
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
|
||||
patSpecialEpisode = regexp.MustCompile(`(?i)(?:^|[^a-z0-9])(?:ova|oad|ovd|ona|sp|special(?:[\s._-]*episode)?|extra|bonus|omake)[\s._-]*0*(\d{1,3})(?:[^0-9]|$)`)
|
||||
patCN = regexp.MustCompile(`第\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*[\[\(._-]|$)`)
|
||||
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._-]|$)`)
|
||||
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?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|番外篇?|特别篇|特別篇|特典|外传|外傳|总集篇|總集篇|画像特典)$`)
|
||||
patSeasonEpisodeZero = regexp.MustCompile(`(?i)s0*([1-9]\d?)e0+(?:[^0-9]|$)`)
|
||||
// patCNSeason 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。
|
||||
patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`)
|
||||
// patResolutionDims 匹配 1920x1080 / 3840×2160 这类像素尺寸。
|
||||
patResolutionDims = regexp.MustCompile(`(?i)(\d{3,4})\s*[x×]\s*(\d{3,4})`)
|
||||
)
|
||||
|
||||
// ParseEpisode tries to extract (season, episode) from an arbitrary filename.
|
||||
@@ -52,11 +69,23 @@ func ParseEpisode(path string) (season, episode int) {
|
||||
episode = mustAtoi(m[2])
|
||||
return
|
||||
}
|
||||
if m := patNxE.FindStringSubmatch(name); len(m) == 3 {
|
||||
if m := patNxEGuarded.FindStringSubmatch(name); len(m) == 3 {
|
||||
season = mustAtoi(m[1])
|
||||
episode = mustAtoi(m[2])
|
||||
return
|
||||
}
|
||||
if m := patBracketEpisode.FindStringSubmatch(name); len(m) == 2 {
|
||||
var found bool
|
||||
season, found = seasonFromParents(path)
|
||||
if !found {
|
||||
season = 1
|
||||
}
|
||||
episode = mustAtoi(m[1])
|
||||
return
|
||||
}
|
||||
if m := patSpecialEpisode.FindStringSubmatch(name); len(m) >= 2 {
|
||||
return 0, mustAtoi(m[1])
|
||||
}
|
||||
if m := patEP.FindStringSubmatch(name); len(m) >= 2 {
|
||||
var found bool
|
||||
season, found = seasonFromParents(path)
|
||||
@@ -94,6 +123,77 @@ func ParseEpisode(path string) (season, episode int) {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// resolutionEpisodeArtifact returns the bogus (season, episode) pair the legacy
|
||||
// `(\d{1,2})x(\d{1,3})` pattern would extract from a WxH pixel-dimension token
|
||||
// in path — e.g. 1920x1080 -> (20, 108), 3840x2160 -> (40, 216). Reports ok=false
|
||||
// when the name carries no such token.
|
||||
//
|
||||
// MeBox used to persist these pairs into sidecar NFOs, so on rescan the NFO
|
||||
// would inject the wrong identity back even after the parser was fixed.
|
||||
func resolutionEpisodeArtifact(path string) (season, episode int, ok bool) {
|
||||
name := mediaSidecarBase(path)
|
||||
if name == "" {
|
||||
return 0, 0, false
|
||||
}
|
||||
dims := patResolutionDims.FindStringSubmatch(name)
|
||||
if len(dims) < 3 {
|
||||
return 0, 0, false
|
||||
}
|
||||
m := patNxE.FindStringSubmatch(dims[1] + "x" + dims[2])
|
||||
if len(m) != 3 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return mustAtoi(m[1]), mustAtoi(m[2]), true
|
||||
}
|
||||
|
||||
// dropResolutionArtifactEpisodeIdentity clears a season/episode pair (and the
|
||||
// episode title generated from it) that only exists because a resolution token
|
||||
// was once mistaken for an SxxExx marker.
|
||||
func dropResolutionArtifactEpisodeIdentity(meta *LocalMetadata, mediaPath string) {
|
||||
if meta == nil {
|
||||
return
|
||||
}
|
||||
season, episode, ok := resolutionEpisodeArtifact(mediaPath)
|
||||
if !ok || meta.SeasonNum != season || meta.EpisodeNum != episode {
|
||||
return
|
||||
}
|
||||
// 只有当文件名本身也无法权威地解析出同一季集号时才判定为伪集号。
|
||||
// 例如 Show.S20E108.1920x1080.mkv 的 S20E108 是真实标记,必须保留。
|
||||
if parsedSeason, parsedEpisode := ParseEpisode(mediaPath); parsedEpisode > 0 &&
|
||||
parsedSeason == meta.SeasonNum && parsedEpisode == meta.EpisodeNum {
|
||||
return
|
||||
}
|
||||
meta.SeasonNum = 0
|
||||
meta.EpisodeNum = 0
|
||||
if isGeneratedEpisodeTitle(meta.EpisodeTitle, episode) {
|
||||
meta.EpisodeTitle = ""
|
||||
}
|
||||
}
|
||||
|
||||
// isGeneratedEpisodeTitle reports whether title is the default "第 N 集" /
|
||||
// "Episode N" form auto-written from an episode number rather than a real name.
|
||||
func isGeneratedEpisodeTitle(title string, episode int) bool {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" || episode <= 0 {
|
||||
return false
|
||||
}
|
||||
for _, candidate := range []string{
|
||||
fmt.Sprintf("第 %d 集", episode),
|
||||
fmt.Sprintf("第%d集", episode),
|
||||
fmt.Sprintf("第 %d 话", episode),
|
||||
fmt.Sprintf("第%d话", episode),
|
||||
fmt.Sprintf("第 %d 話", episode),
|
||||
fmt.Sprintf("第%d話", episode),
|
||||
fmt.Sprintf("Episode %d", episode),
|
||||
fmt.Sprintf("EP%d", episode),
|
||||
} {
|
||||
if strings.EqualFold(title, candidate) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -29,7 +29,20 @@ func TestParseEpisode(t *testing.T) {
|
||||
{`剧集/Specials/剧集 - 02.mkv`, 0, 2},
|
||||
{`剧集/特别篇/03.mkv`, 0, 3},
|
||||
{`剧集/剧集 - S00E04.mkv`, 0, 4},
|
||||
{`动漫/摇曳露营/OVA/Season 3 [OVA01 [1080p].mkv`, 0, 1},
|
||||
{`动漫/示例/OAD/示例.OAD02.mkv`, 0, 2},
|
||||
{`动漫/示例/OVD/示例-OVD03.mkv`, 0, 3},
|
||||
{`动漫/示例/ONA/示例_ONA04.mkv`, 0, 4},
|
||||
{"Movie.2020.1080p.mkv", 0, 0},
|
||||
// 分辨率不能被当成季集号:1920x1080 曾匹配出 20x108。
|
||||
{"Movie.2020.1920x1080.mkv", 0, 0},
|
||||
{"1920x1080.mkv", 0, 0},
|
||||
{"[Group][Show][02][3840x2160].mkv", 1, 2},
|
||||
// 字幕组方括号集号。
|
||||
{"[UHA-WINGS][Peter Grill to Kenja no Jikan][01][BDRIP 1920x1080 HEVC-YUV420P10 FLAC].strm", 1, 1},
|
||||
{"[UHA-WINGS][Peter Grill to Kenja no Jikan][12][BDRIP 1920x1080 HEVC-YUV420P10 FLAC].strm", 1, 12},
|
||||
// 方括号里的年份/分辨率不是集号。
|
||||
{"[Group][Show][2024][1080p].mkv", 0, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
@@ -88,3 +101,54 @@ func TestOnlineEpisodeIdentityFromPathMapsAnimeEpisodeZeroToSpecials(t *testing.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolutionEpisodeArtifact(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
wantSeason int
|
||||
wantEpisode int
|
||||
wantOK bool
|
||||
}{
|
||||
{"[UHA-WINGS][Peter Grill to Kenja no Jikan][01][BDRIP 1920x1080 HEVC-YUV420P10 FLAC].strm", 20, 108, true},
|
||||
{"今永纱奈/20170601000150_2,00x_3840x2160_amq-13.strm", 40, 216, true},
|
||||
{"Movie.2020.1080p.mkv", 0, 0, false},
|
||||
{"Show.S01E02.mkv", 0, 0, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
season, episode, ok := resolutionEpisodeArtifact(tc.path)
|
||||
if season != tc.wantSeason || episode != tc.wantEpisode || ok != tc.wantOK {
|
||||
t.Errorf("resolutionEpisodeArtifact(%q) = (%d, %d, %v), want (%d, %d, %v)",
|
||||
tc.path, season, episode, ok, tc.wantSeason, tc.wantEpisode, tc.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropResolutionArtifactEpisodeIdentity(t *testing.T) {
|
||||
// 分辨率伪集号被剔除,并从生成的「第 N 集」标题里清掉。
|
||||
polluted := &LocalMetadata{SeasonNum: 20, EpisodeNum: 108, EpisodeTitle: "第 108 集"}
|
||||
dropResolutionArtifactEpisodeIdentity(polluted, "[G][Show][01][BDRIP 1920x1080 x].strm")
|
||||
if polluted.SeasonNum != 0 || polluted.EpisodeNum != 0 || polluted.EpisodeTitle != "" {
|
||||
t.Fatalf("resolution artifact not dropped: %+v", polluted)
|
||||
}
|
||||
|
||||
// 真实单集号不受影响。
|
||||
real := &LocalMetadata{SeasonNum: 1, EpisodeNum: 3, EpisodeTitle: "本地第三集"}
|
||||
dropResolutionArtifactEpisodeIdentity(real, "Show/S01E03 1920x1080.mkv")
|
||||
if real.SeasonNum != 1 || real.EpisodeNum != 3 || real.EpisodeTitle != "本地第三集" {
|
||||
t.Fatalf("real episode identity was modified: %+v", real)
|
||||
}
|
||||
|
||||
// 名称里没有分辨率时不改动任何值。
|
||||
plain := &LocalMetadata{SeasonNum: 20, EpisodeNum: 108}
|
||||
dropResolutionArtifactEpisodeIdentity(plain, "Show/S20E108.mkv")
|
||||
if plain.SeasonNum != 20 || plain.EpisodeNum != 108 {
|
||||
t.Fatalf("unrelated identity was cleared: %+v", plain)
|
||||
}
|
||||
|
||||
// 文件名里 S20E108 是真实标记时,即使同时含分辨率也必须保留。
|
||||
legit := &LocalMetadata{SeasonNum: 20, EpisodeNum: 108}
|
||||
dropResolutionArtifactEpisodeIdentity(legit, "Show/Show.S20E108.1920x1080.mkv")
|
||||
if legit.SeasonNum != 20 || legit.EpisodeNum != 108 {
|
||||
t.Fatalf("legitimate S20E108 identity was cleared: %+v", legit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,13 @@ func pathHintMetadata(raw string, seriesLike bool) (*LocalMetadata, mediaExterna
|
||||
title, year := "", 0
|
||||
if seriesLike {
|
||||
title, year = CleanQuery(source)
|
||||
} else if patTheatricalTitle.MatchString(raw) || patTheatricalFolder.MatchString(raw) {
|
||||
base := pathBaseSlash(raw)
|
||||
stem := mediaFileStem(base)
|
||||
if stem == "" {
|
||||
stem = strings.TrimSuffix(base, filepath.Ext(base))
|
||||
}
|
||||
title, year = CleanQuery(stem)
|
||||
} else {
|
||||
title, year = cloudSeriesTitleFromMediaPath(source)
|
||||
if title == "" {
|
||||
|
||||
@@ -73,7 +73,7 @@ func isLocalImagePath(raw string) bool {
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(raw))
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn":
|
||||
case ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn", ".img":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -13,6 +13,12 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
)
|
||||
|
||||
func TestIsLocalImagePathAcceptsLegacyImgSidecar(t *testing.T) {
|
||||
if !isLocalImagePath(filepath.Join(t.TempDir(), "movie-poster.img")) {
|
||||
t.Fatal("legacy .img sidecar should remain displayable")
|
||||
}
|
||||
}
|
||||
|
||||
var testJPEG = []byte{
|
||||
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F',
|
||||
0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01,
|
||||
@@ -117,12 +123,12 @@ func TestIsPrivateHost(t *testing.T) {
|
||||
// Hostnames must NOT be blocked even though GFW DNS poisoning may resolve
|
||||
// them to private/loopback IPs — blocking them broke legitimate posters.
|
||||
allowed := []string{"image.tmdb.org", "lain.bgm.tv", "example.com", "8.8.8.8"}
|
||||
for _, h := range allowed {
|
||||
if isPrivateHost(h) {
|
||||
t.Errorf("isPrivateHost(%q) = true, want false", h)
|
||||
}
|
||||
for _, h := range allowed {
|
||||
if isPrivateHost(h) {
|
||||
t.Errorf("isPrivateHost(%q) = true, want false", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageProxyAllowedRemoteHostBypassesPrivateCheck(t *testing.T) {
|
||||
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
|
||||
|
||||
@@ -74,12 +74,15 @@ func localPosterCandidates(mediaPath string) []string {
|
||||
add(base + "-cover")
|
||||
add(base + ".cover")
|
||||
add(base)
|
||||
}
|
||||
for _, name := range []string{"poster", "folder", "cover", "movie", "show"} {
|
||||
add(name)
|
||||
}
|
||||
for _, base := range mediaSidecarBaseVariants(mediaPath) {
|
||||
add(base + "-thumb")
|
||||
add(base + ".thumb")
|
||||
}
|
||||
for _, name := range []string{"poster", "folder", "cover", "movie", "show", "thumb"} {
|
||||
add(name)
|
||||
}
|
||||
add("thumb")
|
||||
return append(adultArtworkNameCandidates(mediaPath, "poster"), names...)
|
||||
}
|
||||
|
||||
@@ -155,7 +158,7 @@ func firstExistingImage(dir string, names ...string) string {
|
||||
return ""
|
||||
}
|
||||
for _, name := range names {
|
||||
for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn"} {
|
||||
for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn", ".img"} {
|
||||
path := filepath.Join(dir, name+ext)
|
||||
if fileExists(path) {
|
||||
return filepath.Clean(path)
|
||||
@@ -218,7 +221,7 @@ func firstExistingPosterImage(dir string, names ...string) string {
|
||||
if isRejectedPosterName(name) {
|
||||
continue
|
||||
}
|
||||
for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn"} {
|
||||
for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn", ".img"} {
|
||||
path := filepath.Join(dir, name+ext)
|
||||
if fileExists(path) && likelyPosterImage(path) {
|
||||
return filepath.Clean(path)
|
||||
@@ -237,7 +240,7 @@ func firstAdultLooseImage(dir, kind string) string {
|
||||
fallback := []string{}
|
||||
for _, path := range matches {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp" && ext != ".gif" && ext != ".bmp" && ext != ".tbn" {
|
||||
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp" && ext != ".gif" && ext != ".bmp" && ext != ".tbn" && ext != ".img" {
|
||||
continue
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSuffix(filepath.Base(path), ext))
|
||||
|
||||
@@ -23,6 +23,9 @@ func ReadLocalMetadata(mediaPath, libraryRoot string, seriesLike bool) (*LocalMe
|
||||
}
|
||||
meta := metadataFromDoc(doc, filepath.Dir(path), false)
|
||||
mergeArtworkMetadata(meta, mediaPath, filepath.Dir(path))
|
||||
// A sidecar written while resolution tokens were misread as SxxExx must not
|
||||
// reintroduce the bogus season/episode on rescan.
|
||||
dropResolutionArtifactEpisodeIdentity(meta, mediaPath)
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
@@ -116,6 +119,9 @@ func readSeriesMetadata(mediaPath, libraryRoot string) (*LocalMetadata, error) {
|
||||
} else {
|
||||
mergeArtworkMetadata(meta, mediaPath, showBaseDir)
|
||||
}
|
||||
// A sidecar written while resolution tokens were misread as SxxExx must not
|
||||
// reintroduce the bogus season/episode on rescan.
|
||||
dropResolutionArtifactEpisodeIdentity(meta, mediaPath)
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,48 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadLocalMetadataDropsResolutionArtifactEpisode(t *testing.T) {
|
||||
// 刮削曾把分辨率 1920x1080 误读成 S20E108 并回写进边车 NFO;
|
||||
// 重扫时必须忽略这个伪季集号,否则旧文件会把错误身份灌回 DB。
|
||||
root := t.TempDir()
|
||||
showDir := filepath.Join(root, "彼得·格里尔的贤者时间")
|
||||
if err := os.MkdirAll(showDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(showDir,
|
||||
"[UHA-WINGS][Peter Grill to Kenja no Jikan][01][BDRIP 1920x1080 HEVC-YUV420P10 FLAC].strm")
|
||||
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(nfoPath(mediaPath), []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<episodedetails>
|
||||
<title>第 108 集</title>
|
||||
<showtitle>彼得·格里尔的贤者时间</showtitle>
|
||||
<season>20</season>
|
||||
<episode>108</episode>
|
||||
<tmdbid>99080</tmdbid>
|
||||
</episodedetails>`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ReadLocalMetadata(mediaPath, root, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("metadata is nil")
|
||||
}
|
||||
if got.SeasonNum != 0 || got.EpisodeNum != 0 {
|
||||
t.Fatalf("resolution artifact season/episode not dropped: s=%d e=%d", got.SeasonNum, got.EpisodeNum)
|
||||
}
|
||||
if got.EpisodeTitle != "" {
|
||||
t.Fatalf("generated episode title not dropped: %q", got.EpisodeTitle)
|
||||
}
|
||||
if got.Title != "彼得·格里尔的贤者时间" {
|
||||
t.Fatalf("series title = %q, want 彼得·格里尔的贤者时间", got.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLocalMovieMetadata(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mediaPath := filepath.Join(dir, "Inception.2010.mkv")
|
||||
@@ -375,3 +417,22 @@ func TestReadLocalMetadataArtworkFallbackOnGarbageShowNFO(t *testing.T) {
|
||||
t.Fatalf("expected episode artwork fallback, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLocalMetadataFindsLegacyImgPoster(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mediaPath := filepath.Join(root, "Jigokuraku.S01E14.mkv.strm")
|
||||
poster := filepath.Join(root, "Jigokuraku.S01E14-poster.img")
|
||||
if err := os.WriteFile(mediaPath, []byte("https://example.test/video"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(poster, testJPEG, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ReadLocalMetadata(mediaPath, root, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil || got.PosterURL != poster {
|
||||
t.Fatalf("PosterURL = %q, want legacy .img poster %q", got.PosterURL, poster)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,13 @@ func (s *ScraperService) ManualSearch(ctx context.Context, media *model.Media, q
|
||||
mediaType = lib.Type
|
||||
}
|
||||
}
|
||||
// Keep manual scraping consistent with automatic scraping for theatrical
|
||||
// features stored inside anime libraries. The UI may pass the library type
|
||||
// ("anime"), which otherwise makes TMDb stop after a TV result and hide the
|
||||
// actual movie candidate.
|
||||
if mediaLooksLikeTheatricalFeature(media) {
|
||||
mediaType = "movie"
|
||||
}
|
||||
mediaType = normalizeMediaType(mediaType, queries[0], "")
|
||||
providers := manualSearchProviderSet(provider)
|
||||
year := mediaYearHint(media)
|
||||
|
||||
@@ -313,6 +313,80 @@ func TestManualSearchReturnsMovieFallbackForTVTypedTMDbSearch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualSearchAnimeTheatricalPrefersTMDbMovie(t *testing.T) {
|
||||
var paths []string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
paths = append(paths, r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/search/movie":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"results": []map[string]any{{
|
||||
"id": 635302,
|
||||
"title": "鬼灭之刃 剧场版 无限列车篇",
|
||||
"original_title": "劇場版「鬼滅の刃」無限列車編",
|
||||
"release_date": "2020-10-16",
|
||||
}},
|
||||
})
|
||||
case "/search/tv":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"results": []map[string]any{{
|
||||
"id": 85937,
|
||||
"name": "鬼灭之刃",
|
||||
"original_name": "鬼滅の刃",
|
||||
"first_air_date": "2019-04-06",
|
||||
}},
|
||||
})
|
||||
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))
|
||||
|
||||
lib := model.Library{Name: "动漫", Path: `/media/anime`, Type: "anime", Enabled: true}
|
||||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
media := model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "鬼灭之刃 剧场版 无限列车篇",
|
||||
Path: `/media/anime/鬼灭之刃/鬼灭之刃 剧场版 无限列车篇.mkv`,
|
||||
}
|
||||
if err := repos.DB.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
results, err := scraper.ManualSearch(t.Context(), &media, media.Title, "tmdb", "anime")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(results) == 0 || results[0].TMDbID != 635302 || results[0].MediaType != "movie" {
|
||||
t.Fatalf("manual theatrical results=%#v, paths=%v", results, paths)
|
||||
}
|
||||
if len(paths) == 0 || paths[0] != "/search/movie" {
|
||||
t.Fatalf("TMDb search paths=%v, want movie first", paths)
|
||||
}
|
||||
for _, path := range paths {
|
||||
if path == "/search/tv" {
|
||||
t.Fatalf("manual theatrical search unexpectedly queried TV after finding movie: paths=%v", paths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualSearchAllProvidersTMDbNumericIDTriesMovieAndTVNamespaces(t *testing.T) {
|
||||
var paths []string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -62,7 +62,7 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
|
||||
isWesternByCategory := containsAnyText(categoryText, "欧美剧", "欧美电视剧", "美剧", "英剧", "欧美电影", "外语电影")
|
||||
isWestern := isWesternByMetadata || (!hasRegionMetadata && isWesternByCategory)
|
||||
isUSAnime := hasAny(countries, "US")
|
||||
hasAnimeText := containsAnyText(contentText, "动画", "动漫", "番剧", "年番", "国漫", "日番", "韩漫", "美漫", "bangumi", "anime", "b-global", "ani-one", "crunchyroll")
|
||||
hasAnimeText := containsAnyText(contentText, "动画", "动漫", "番剧", "年番", "国漫", "日番", "韩漫", "美漫", "bangumi", "anime", "b-global", "ani-one", "crunchyroll", "剧场版", "劇場版", "动画电影", "動畫電影")
|
||||
hasVarietyText := containsAnyText(contentText, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show")
|
||||
hasDocumentaryText := containsAnyText(contentText, "纪录", "纪录片", "documentary", "docu", "national geographic", "natgeo")
|
||||
hasConcertText := containsAnyText(contentText, "演唱会", "音乐会", "concert", "live concert")
|
||||
@@ -191,21 +191,21 @@ func normalizeMediaType(mediaType, title, category string) string {
|
||||
return "adult"
|
||||
case containsAnyText(raw, "综艺", "真人秀"):
|
||||
return "variety"
|
||||
case (containsAnyText(raw, "国漫", "日漫", "日番", "韩漫", "美漫", "欧美动漫", "其他动漫", "动漫", "动画") || classifierAnimeRE.MatchString(raw)) && !containsAnyText(raw, "动画电影"):
|
||||
return "anime"
|
||||
case containsAnyText(raw, "电视剧", "剧集", "连续剧", "短剧", "国产剧", "国剧", "大陆剧", "华语剧", "国产电视剧", "大陆电视剧", "华语电视剧", "欧美剧", "欧美电视剧", "美剧", "英剧", "日韩剧", "日韩电视剧", "日剧", "韩剧", "港剧", "台剧", "港台剧", "泰剧") || classifierTVRE.MatchString(raw):
|
||||
return "tv"
|
||||
case containsAnyText(raw, "电影", "演唱会") || classifierMovieRE.MatchString(raw):
|
||||
return "movie"
|
||||
}
|
||||
text := strings.ToLower(title + " " + category)
|
||||
switch {
|
||||
case strings.Contains(text, "adult") || strings.Contains(text, "nsfw") || strings.Contains(text, "成人") || strings.Contains(text, "番号") || strings.Contains(text, "jav") || strings.Contains(text, "9kg") || classifierJAVCodeRE.MatchString(strings.ToUpper(title+" "+category)):
|
||||
return "adult"
|
||||
case containsAnyText(text, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show"):
|
||||
return "variety"
|
||||
case strings.Contains(text, "电影") || classifierMovieRE.MatchString(text):
|
||||
return "movie"
|
||||
case (containsAnyText(raw, "国漫", "日漫", "日番", "韩漫", "美漫", "欧美动漫", "其他动漫", "动漫", "动画") || classifierAnimeRE.MatchString(raw)) && !containsAnyText(raw, "动画电影", "剧场版", "劇場版"):
|
||||
return "anime"
|
||||
case containsAnyText(raw, "电视剧", "剧集", "连续剧", "短剧", "国产剧", "国剧", "大陆剧", "华语剧", "国产电视剧", "大陆电视剧", "华语电视剧", "欧美剧", "欧美电视剧", "美剧", "英剧", "日韩剧", "日韩电视剧", "日剧", "韩剧", "港剧", "台剧", "港台剧", "泰剧") || classifierTVRE.MatchString(raw):
|
||||
return "tv"
|
||||
case containsAnyText(raw, "电影", "演唱会", "剧场版", "劇場版") || classifierMovieRE.MatchString(raw):
|
||||
return "movie"
|
||||
}
|
||||
text := strings.ToLower(title + " " + category)
|
||||
switch {
|
||||
case strings.Contains(text, "adult") || strings.Contains(text, "nsfw") || strings.Contains(text, "成人") || strings.Contains(text, "番号") || strings.Contains(text, "jav") || strings.Contains(text, "9kg") || classifierJAVCodeRE.MatchString(strings.ToUpper(title+" "+category)):
|
||||
return "adult"
|
||||
case containsAnyText(text, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show"):
|
||||
return "variety"
|
||||
case containsAnyText(text, "电影", "剧场版", "劇場版") || classifierMovieRE.MatchString(text):
|
||||
return "movie"
|
||||
case classifierAnimeRE.MatchString(text) || strings.Contains(text, "动漫") || strings.Contains(text, "动画"):
|
||||
return "anime"
|
||||
case strings.Contains(text, "variety") || strings.Contains(text, "综艺") || strings.Contains(text, "真人秀"):
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// strmPathAliases returns sibling STRM names that can represent the same media
|
||||
// item when keep_ext is toggled on or off.
|
||||
//
|
||||
// Both directions are supported:
|
||||
//
|
||||
// foo.strm <-> foo.mkv.strm
|
||||
// foo.strm <-> foo.mp4.strm
|
||||
//
|
||||
// The caller must still verify that the aliases are no longer present on disk.
|
||||
// keep_ext intentionally allows foo.mkv.strm and foo.mp4.strm to coexist as
|
||||
// two real versions, so an existing sibling must never be absorbed.
|
||||
func strmPathAliases(path string) []string {
|
||||
clean := filepath.Clean(strings.TrimSpace(path))
|
||||
if clean == "" || clean == "." || !strings.EqualFold(filepath.Ext(clean), ".strm") {
|
||||
return nil
|
||||
}
|
||||
base := mediaSidecarBase(clean)
|
||||
if base == "" {
|
||||
return nil
|
||||
}
|
||||
name := filepath.Base(clean)
|
||||
stem := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
lastExt := strings.ToLower(filepath.Ext(stem))
|
||||
_, hasVideoExt := videoExtensions[lastExt]
|
||||
hasVideoExt = hasVideoExt && !strings.EqualFold(lastExt, ".strm")
|
||||
|
||||
dir := filepath.Dir(clean)
|
||||
out := make([]string, 0, len(videoExtensions)+1)
|
||||
seen := make(map[string]struct{}, len(videoExtensions)+1)
|
||||
add := func(candidate string) {
|
||||
candidate = filepath.Clean(candidate)
|
||||
if candidate == "" || candidate == "." || samePath(candidate, clean) {
|
||||
return
|
||||
}
|
||||
key := strings.ToLower(candidate)
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, candidate)
|
||||
}
|
||||
if hasVideoExt {
|
||||
// Current keep_ext shape: the only legacy shape is the stripped name.
|
||||
add(filepath.Join(dir, base+".strm"))
|
||||
return out
|
||||
}
|
||||
// Stripped shape: an old keep_ext file may use any configured video ext.
|
||||
exts := make([]string, 0, len(videoExtensions))
|
||||
for ext := range videoExtensions {
|
||||
if strings.EqualFold(ext, ".strm") {
|
||||
continue
|
||||
}
|
||||
exts = append(exts, strings.ToLower(ext))
|
||||
}
|
||||
sort.Strings(exts)
|
||||
for _, ext := range exts {
|
||||
add(filepath.Join(dir, base+ext+".strm"))
|
||||
if upper := strings.ToUpper(ext); upper != ext {
|
||||
add(filepath.Join(dir, base+upper+".strm"))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// missingSTRMPathAliases keeps only aliases which no longer exist on disk.
|
||||
// This is the key protection for keep_ext=true multi-version libraries: an
|
||||
// existing foo.mp4.strm is a real second version, not a rename tombstone.
|
||||
func missingSTRMPathAliases(path string) []string {
|
||||
aliases := strmPathAliases(path)
|
||||
if len(aliases) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(aliases))
|
||||
for _, alias := range aliases {
|
||||
if _, err := os.Lstat(alias); err == nil {
|
||||
continue
|
||||
} else if !os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
out = append(out, alias)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func liveSTRMPathAlias(path string) bool {
|
||||
for _, alias := range strmPathAliases(path) {
|
||||
if info, err := os.Stat(alias); err == nil && !info.IsDir() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -301,16 +301,8 @@ func groupMediaSeriesCards(items []model.Media) []SeriesCard {
|
||||
if betterSeriesLinkMedia(item, card.LinkMedia) {
|
||||
card.LinkMedia = item
|
||||
}
|
||||
currentArtwork := seriesArtworkScore(item)
|
||||
representativeArtwork := seriesArtworkScore(card.Rep)
|
||||
if currentArtwork > representativeArtwork {
|
||||
if betterSeriesRepresentative(item, card.Rep) {
|
||||
card.Rep = item
|
||||
} else if currentArtwork == representativeArtwork {
|
||||
cur := item.SeasonNum*10000 + item.EpisodeNum
|
||||
rep := card.Rep.SeasonNum*10000 + card.Rep.EpisodeNum
|
||||
if cur > 0 && (rep == 0 || cur < rep) {
|
||||
card.Rep = item
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -335,6 +327,27 @@ func groupMediaSeriesCards(items []model.Media) []SeriesCard {
|
||||
return cards
|
||||
}
|
||||
|
||||
func betterSeriesRepresentative(candidate, current model.Media) bool {
|
||||
// A theatrical feature can have local poster.jpg/background.jpg files and
|
||||
// therefore a higher artwork score than its TV episodes. Keep the TV row as
|
||||
// the visible identity of a mixed series card so a movie cannot hijack the
|
||||
// series title, overview, and artwork.
|
||||
candidateTheatrical := mediaLooksLikeTheatricalFeature(&candidate)
|
||||
currentTheatrical := mediaLooksLikeTheatricalFeature(¤t)
|
||||
if candidateTheatrical != currentTheatrical {
|
||||
return !candidateTheatrical
|
||||
}
|
||||
|
||||
currentArtwork := seriesArtworkScore(candidate)
|
||||
representativeArtwork := seriesArtworkScore(current)
|
||||
if currentArtwork != representativeArtwork {
|
||||
return currentArtwork > representativeArtwork
|
||||
}
|
||||
cur := candidate.SeasonNum*10000 + candidate.EpisodeNum
|
||||
rep := current.SeasonNum*10000 + current.EpisodeNum
|
||||
return cur > 0 && (rep == 0 || cur < rep)
|
||||
}
|
||||
|
||||
func seriesMediaTime(media model.Media) time.Time {
|
||||
if releaseDate := strings.TrimSpace(media.ReleaseDate); releaseDate != "" {
|
||||
if parsed, err := time.Parse("2006-01-02", releaseDate); err == nil {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
var episodicPathRE = regexp.MustCompile(`(?i)[\\/](?:电视剧|剧集|连续剧|短剧|国产剧|国剧|大陆剧|华语剧|国产电视剧|大陆电视剧|华语电视剧|欧美剧|欧美电视剧|美剧|英剧|日韩剧|日韩电视剧|日剧|韩剧|港剧|台剧|港台剧|泰剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]`)
|
||||
var episodicPathRE = regexp.MustCompile(`(?i)[\\/](?:电视剧|剧集|连续剧|短剧|国产剧|国剧|大陆剧|华语剧|国产电视剧|大陆电视剧|华语电视剧|欧美剧|欧美电视剧|美剧|英剧|日韩剧|日韩电视剧|日剧|韩剧|港剧|台剧|港台剧|泰剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇|画像特典)[\\/]`)
|
||||
|
||||
var genericMovieTitleRE = regexp.MustCompile(`(?i)^(?:cd\s*\d+|part\s*\d+|disc\s*\d+|disk\s*\d+|dvd\s*\d+|movie|film|video|main|feature|track\s*\d+|preview|sample|trailer|\d{3,4}p|4k|2160p|1080p|720p)$`)
|
||||
|
||||
@@ -123,9 +123,9 @@ var (
|
||||
seriesIDRE = regexp.MustCompile(`(?i)\s*\[(?:tmdb|tmdbid)[=-]\d+\]\s*`)
|
||||
seriesBraceRE = regexp.MustCompile(`(?i)\s*\{(?:tmdb|tmdbid|douban|bangumi|bgm|thetvdb|tvdb)[\s:=#-]*[a-z0-9_-]+\}\s*`)
|
||||
seriesSpacerRE = regexp.MustCompile(`[\s._-]+`)
|
||||
seriesSeasonDirRE = regexp.MustCompile(`(?i)^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*[0-9一二三四五六七八九十百零两]+\s*季|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)$`)
|
||||
seriesSpecialCodeRE = regexp.MustCompile(`(?i)\s*[\[((【]?\s*(?:s0+\s*e?\s*\d+|season\s*0+(?:\s*episode)?\s*\d*|special(?:\s*episode)?s?\s*\d*|sp\s*\d*|ovas?\s*\d*|oads?\s*\d*|extras?\s*\d*|bonus(?:es)?\s*\d*|omake\s*\d*)\s*[\]))】]?$`)
|
||||
seriesSpecialCJKRE = regexp.MustCompile(`(?i)\s*[\[((【]?\s*(?:特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)(?:\s*第?\s*[0-9一二三四五六七八九十百零两]+(?:[集话話期])?)?\s*[\]))】]?$`)
|
||||
seriesSeasonDirRE = regexp.MustCompile(`(?i)^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*[0-9一二三四五六七八九十百零两]+\s*季|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇|画像特典)$`)
|
||||
seriesSpecialCodeRE = regexp.MustCompile(`(?i)\s*[\[((【]?\s*(?:s0+\s*e?\s*\d+|season\s*0+(?:\s*episode)?\s*\d*|special(?:\s*episode)?s?\s*\d*|sp\s*\d*|ovas?\s*\d*|oads?\s*\d*|ovds?\s*\d*|onas?\s*\d*|extras?\s*\d*|bonus(?:es)?\s*\d*|omake\s*\d*|picture[\s._-]*drama\s*\d*|ncop\s*\d*|nced\s*\d*)\s*[\]))】]?$`)
|
||||
seriesSpecialCJKRE = regexp.MustCompile(`(?i)\s*[\[((【]?\s*(?:特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇|画像特典)(?:\s*第?\s*[0-9一二三四五六七八九十百零两]+(?:[集话話期])?)?\s*[\]))】]?$`)
|
||||
)
|
||||
|
||||
func normalizeSeriesTitle(value string) string {
|
||||
@@ -173,7 +173,7 @@ func seriesTitleFromMediaPath(path string) string {
|
||||
if last := parts[len(parts)-1]; !seriesPathPartLooksLikeFile(last) && !seriesSeasonDirRE.MatchString(filepath.Base(last)) {
|
||||
dirIndex = len(parts) - 1
|
||||
}
|
||||
for dirIndex >= 0 && seriesSeasonDirRE.MatchString(filepath.Base(parts[dirIndex])) {
|
||||
for dirIndex >= 0 && (seriesSeasonDirRE.MatchString(filepath.Base(parts[dirIndex])) || isTheatricalFolder(parts[dirIndex])) {
|
||||
dirIndex--
|
||||
}
|
||||
if dirIndex < 0 {
|
||||
|
||||
@@ -67,17 +67,20 @@ func newMediaSeriesKeyResolver(items []model.Media) mediaSeriesKeyResolver {
|
||||
|
||||
func (r mediaSeriesKeyResolver) key(media model.Media) string {
|
||||
if mediaLooksEpisodicForGrouping(media) {
|
||||
if key := repeatedSeriesTitleKey(media); key != "" && r.titleCounts[key] > 1 {
|
||||
return compactSeriesKey(key)
|
||||
}
|
||||
if pathKey := mediaSeriesRawKey(media); strings.HasPrefix(pathKey, "library-path") {
|
||||
if titleKey := r.pathTitles[pathKey]; titleKey != "" {
|
||||
return compactSeriesKey(titleKey)
|
||||
}
|
||||
// A series directory is the strongest identity for mixed rows:
|
||||
// main episodes and specials (CM/NCOP/PV/OVA) may be scraped to
|
||||
// slightly different titles, but they still belong to one show.
|
||||
if r.pathCounts[pathKey] > 1 {
|
||||
return compactSeriesKey(pathKey)
|
||||
}
|
||||
}
|
||||
if key := repeatedSeriesTitleKey(media); key != "" && r.titleCounts[key] > 1 {
|
||||
return compactSeriesKey(key)
|
||||
}
|
||||
if key := repeatedSeriesExternalKey(media); key != "" && r.externalCounts[key] > 1 {
|
||||
return compactSeriesKey(key)
|
||||
}
|
||||
|
||||
@@ -47,14 +47,14 @@ func TestListRecentSeriesCardsCountsAllEpisodesInSeries(t *testing.T) {
|
||||
if len(cards) != 1 {
|
||||
t.Fatalf("recent cards = %#v, want one series card", cards)
|
||||
}
|
||||
if cards[0].Count != 40 {
|
||||
t.Fatalf("recent series count = %d, want full 40 episodes", cards[0].Count)
|
||||
}
|
||||
expectedLastAdded := now.Add(40 * time.Minute)
|
||||
if cards[0].LastAddedAt == nil || !cards[0].LastAddedAt.Equal(expectedLastAdded) {
|
||||
t.Fatalf("recent series LastAddedAt = %v, want %v", cards[0].LastAddedAt, expectedLastAdded)
|
||||
}
|
||||
if cards[0].Count != 40 {
|
||||
t.Fatalf("recent series count = %d, want full 40 episodes", cards[0].Count)
|
||||
}
|
||||
expectedLastAdded := now.Add(40 * time.Minute)
|
||||
if cards[0].LastAddedAt == nil || !cards[0].LastAddedAt.Equal(expectedLastAdded) {
|
||||
t.Fatalf("recent series LastAddedAt = %v, want %v", cards[0].LastAddedAt, expectedLastAdded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaSeriesKeyCollapsesNestedSpecialFolders(t *testing.T) {
|
||||
main := model.Media{
|
||||
@@ -377,6 +377,51 @@ func TestGroupMediaSeriesCardsMergesPollutedEpisodeFoldersBySharedShowID(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaSeriesCardsKeepsMixedTitlesInSameEpisodicDirectoryTogether(t *testing.T) {
|
||||
items := []model.Media{
|
||||
{
|
||||
Base: model.Base{ID: "main-1"},
|
||||
LibraryID: "anime",
|
||||
Title: "住在拔作岛上的我应该如何是好?",
|
||||
Path: `/media/影视库/动漫/拔作岛/[64bitsub][Nukitashi][01][AVC_2×FLAC].mkv.strm`,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
ScrapeStatus: "matched",
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "main-2"},
|
||||
LibraryID: "anime",
|
||||
Title: "住在拔作岛上的我应该如何是好?",
|
||||
Path: `/media/影视库/动漫/拔作岛/[64bitsub][Nukitashi][02][AVC_2×FLAC].mkv.strm`,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 2,
|
||||
ScrapeStatus: "matched",
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "special-1"},
|
||||
LibraryID: "anime",
|
||||
Title: "nukitashi",
|
||||
Path: `/media/影视库/动漫/拔作岛/[64bitsub][Nukitashi][CM_01][AVC_FLAC].mkv.strm`,
|
||||
ScrapeStatus: "matched",
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "special-2"},
|
||||
LibraryID: "anime",
|
||||
Title: "nukitashi",
|
||||
Path: `/media/影视库/动漫/拔作岛/[64bitsub][Nukitashi][PV_01][AVC_FLAC].mkv.strm`,
|
||||
ScrapeStatus: "matched",
|
||||
},
|
||||
}
|
||||
|
||||
cards := groupMediaSeriesCards(items)
|
||||
if len(cards) != 1 {
|
||||
t.Fatalf("cards=%#v, want main episodes and specials in the same directory folded into one card", cards)
|
||||
}
|
||||
if cards[0].Count != 4 {
|
||||
t.Fatalf("series count=%d, want 4 items", cards[0].Count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaSeriesCardsKeepsMovieVersionsAsOneMovie(t *testing.T) {
|
||||
items := []model.Media{
|
||||
{
|
||||
@@ -404,6 +449,42 @@ func TestGroupMediaSeriesCardsKeepsMovieVersionsAsOneMovie(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaSeriesCardsKeepsTheatricalMovieWithTVSeries(t *testing.T) {
|
||||
episode := model.Media{
|
||||
Base: model.Base{ID: "episode"},
|
||||
LibraryID: "anime",
|
||||
Title: "摇曳露营△",
|
||||
Path: `/media/动漫/摇曳露营△ (2018)/Season 01/摇曳露营△.S01E01.mkv`,
|
||||
PosterURL: "https://image.tmdb.org/t/p/w500/episode.jpg",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
}
|
||||
theatrical := model.Media{
|
||||
Base: model.Base{ID: "theatrical"},
|
||||
LibraryID: "anime",
|
||||
Title: "摇曳露营△ 剧场版",
|
||||
Path: `/media/动漫/摇曳露营△ (2018)/摇曳露营△ 剧场版 (2022)/Eiga.Yurukyan.2022.Bluray.mkv`,
|
||||
PosterURL: "/media/动漫/摇曳露营△ (2018)/剧场版/poster.jpg",
|
||||
BackdropURL: "/media/动漫/摇曳露营△ (2018)/剧场版/background.jpg",
|
||||
Overview: "剧场版简介",
|
||||
TMDbID: 566466,
|
||||
}
|
||||
|
||||
cards := groupMediaSeriesCards([]model.Media{episode, theatrical})
|
||||
if len(cards) != 1 {
|
||||
t.Fatalf("cards=%#v, want theatrical movie retained with TV series", cards)
|
||||
}
|
||||
if cards[0].Count != 2 {
|
||||
t.Fatalf("series card count=%d, want TV episode plus theatrical movie", cards[0].Count)
|
||||
}
|
||||
if cards[0].Rep.ID != episode.ID {
|
||||
t.Fatalf("series representative=%q, want TV episode %q", cards[0].Rep.ID, episode.ID)
|
||||
}
|
||||
if cards[0].Rep.Title != episode.Title {
|
||||
t.Fatalf("series representative title=%q, want %q", cards[0].Rep.Title, episode.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaSeriesCardsDoesNotCollideMovieAndTVExternalIDs(t *testing.T) {
|
||||
movie := model.Media{
|
||||
Base: model.Base{ID: "movie"},
|
||||
@@ -467,11 +548,11 @@ func TestGroupMediaSeriesCardsKeepsIndependentMoviesSeparateInSharedSubdirectory
|
||||
{LibraryID: "movies", Title: "cd1", Path: `/media/电影/指环王 (2001)/cd1.mkv`},
|
||||
{LibraryID: "movies", Title: "cd2", Path: `/media/电影/指环王 (2001)/cd2.mkv`},
|
||||
}
|
||||
cdCards := groupMediaSeriesCards(cdItems)
|
||||
if len(cdCards) != 1 {
|
||||
t.Fatalf("got %d cards for cd1/cd2, want 1 folded movie card", len(cdCards))
|
||||
}
|
||||
cdCards := groupMediaSeriesCards(cdItems)
|
||||
if len(cdCards) != 1 {
|
||||
t.Fatalf("got %d cards for cd1/cd2, want 1 folded movie card", len(cdCards))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMediaEpisodesKeepsIndependentMoviesSeparate(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||||
@@ -512,4 +593,3 @@ func TestListMediaEpisodesKeepsIndependentMoviesSeparate(t *testing.T) {
|
||||
t.Fatalf("ListMediaEpisodes got %#v, want exactly m1", eps)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
mediaSpecialTheatrical = "theatrical"
|
||||
mediaSpecialOVA = "ova"
|
||||
mediaSpecialOAD = "oad"
|
||||
mediaSpecialOVD = "ovd"
|
||||
mediaSpecialONA = "ona"
|
||||
mediaSpecialExtra = "extra"
|
||||
mediaSpecialBonus = "bonus"
|
||||
mediaSpecialOmake = "omake"
|
||||
mediaSpecialPicture = "picture_drama"
|
||||
mediaSpecialNCOP = "ncop"
|
||||
mediaSpecialNCED = "nced"
|
||||
mediaSpecialGeneric = "special"
|
||||
)
|
||||
|
||||
var mediaSpecialKindPatterns = []struct {
|
||||
kind string
|
||||
re *regexp.Regexp
|
||||
}{
|
||||
{mediaSpecialOVA, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])ova(?:s)?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)},
|
||||
{mediaSpecialOAD, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])oad(?:s)?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)},
|
||||
{mediaSpecialOVD, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])ovd(?:s)?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)},
|
||||
{mediaSpecialONA, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])ona(?:s)?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)},
|
||||
{mediaSpecialPicture, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])(?:picture[\s._-]*drama|画像特典)(?:[^a-z0-9]|$)`)},
|
||||
{mediaSpecialNCOP, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])ncop(?:\d+)?(?:[^a-z0-9]|$)`)},
|
||||
{mediaSpecialNCED, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])nced(?:\d+)?(?:[^a-z0-9]|$)`)},
|
||||
{mediaSpecialExtra, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])extras?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)},
|
||||
{mediaSpecialBonus, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])bonus(?:es)?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)},
|
||||
{mediaSpecialOmake, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])omake(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)},
|
||||
{mediaSpecialGeneric, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])(?:special(?:[\s._-]*episodes?)?|specials|sps?)(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇`)},
|
||||
}
|
||||
|
||||
func mediaSpecialKind(path string) string {
|
||||
if pathHasTheatricalFolder(path) || patTheatricalTitle.MatchString(path) {
|
||||
return mediaSpecialTheatrical
|
||||
}
|
||||
for _, part := range strings.FieldsFunc(path, func(r rune) bool {
|
||||
return r == '/' || r == '\\'
|
||||
}) {
|
||||
for _, pattern := range mediaSpecialKindPatterns {
|
||||
if pattern.re.MatchString(part) {
|
||||
return pattern.kind
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -145,16 +145,32 @@ func mediaVersionGroupKey(m model.Media) string {
|
||||
return fmt.Sprintf("embyremote:%s", m.ID)
|
||||
}
|
||||
|
||||
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
|
||||
libKey := strings.ToLower(strings.TrimSpace(m.LibraryID))
|
||||
if libKey == "" {
|
||||
libKey = strings.ToLower(strings.TrimSpace(m.DisplayLibraryID))
|
||||
}
|
||||
specialKind := mediaSpecialKind(m.Path)
|
||||
season, episode := m.SeasonNum, m.EpisodeNum
|
||||
if specialKind != "" && specialKind != mediaSpecialTheatrical && episode <= 0 {
|
||||
if parsedSeason, parsedEpisode := ParseEpisode(m.Path); parsedEpisode > 0 {
|
||||
season, episode = parsedSeason, parsedEpisode
|
||||
}
|
||||
}
|
||||
|
||||
if season > 0 || episode > 0 {
|
||||
kind := specialKind
|
||||
if kind == "" {
|
||||
kind = "episode"
|
||||
}
|
||||
switch {
|
||||
case m.TMDbID > 0:
|
||||
return fmt.Sprintf("episode:tmdb:%d:%d:%d", m.TMDbID, m.SeasonNum, m.EpisodeNum)
|
||||
return fmt.Sprintf("episode:%s:tmdb:%d:%d:%d", kind, m.TMDbID, season, episode)
|
||||
case m.BangumiID > 0:
|
||||
return fmt.Sprintf("episode:bangumi:%d:%d:%d", m.BangumiID, m.SeasonNum, m.EpisodeNum)
|
||||
return fmt.Sprintf("episode:%s:bangumi:%d:%d:%d", kind, m.BangumiID, season, episode)
|
||||
case strings.TrimSpace(m.DoubanID) != "":
|
||||
return fmt.Sprintf("episode:douban:%s:%d:%d", strings.ToLower(strings.TrimSpace(m.DoubanID)), m.SeasonNum, m.EpisodeNum)
|
||||
return fmt.Sprintf("episode:%s:douban:%s:%d:%d", kind, strings.ToLower(strings.TrimSpace(m.DoubanID)), season, episode)
|
||||
case strings.TrimSpace(m.TheTVDBID) != "":
|
||||
return fmt.Sprintf("episode:thetvdb:%s:%d:%d", strings.ToLower(strings.TrimSpace(m.TheTVDBID)), m.SeasonNum, m.EpisodeNum)
|
||||
return fmt.Sprintf("episode:%s:thetvdb:%s:%d:%d", kind, strings.ToLower(strings.TrimSpace(m.TheTVDBID)), season, episode)
|
||||
}
|
||||
title := firstNonEmpty(m.OriginalName, m.Title)
|
||||
if title == "" {
|
||||
@@ -166,37 +182,61 @@ func mediaVersionGroupKey(m model.Media) string {
|
||||
}
|
||||
return strings.Join([]string{
|
||||
"episode",
|
||||
strings.ToLower(strings.TrimSpace(m.LibraryID)),
|
||||
kind,
|
||||
libKey,
|
||||
title,
|
||||
fmt.Sprintf("%d:%d", m.SeasonNum, m.EpisodeNum),
|
||||
fmt.Sprintf("%d:%d", season, episode),
|
||||
}, "|")
|
||||
}
|
||||
|
||||
libKey := strings.ToLower(strings.TrimSpace(m.LibraryID))
|
||||
if libKey == "" {
|
||||
libKey = strings.ToLower(strings.TrimSpace(m.DisplayLibraryID))
|
||||
if specialKind != "" && specialKind != mediaSpecialTheatrical {
|
||||
return mediaVersionStemGroupKey(m, libKey)
|
||||
}
|
||||
|
||||
switch {
|
||||
case m.TMDbID > 0:
|
||||
if libKey != "" {
|
||||
if specialKind != "" {
|
||||
return fmt.Sprintf("movie:%s:%s:tmdb:%d", specialKind, libKey, m.TMDbID)
|
||||
}
|
||||
return fmt.Sprintf("movie:%s:tmdb:%d", libKey, m.TMDbID)
|
||||
}
|
||||
if specialKind != "" {
|
||||
return fmt.Sprintf("movie:%s:tmdb:%d", specialKind, m.TMDbID)
|
||||
}
|
||||
return fmt.Sprintf("tmdb:%d", m.TMDbID)
|
||||
case m.BangumiID > 0:
|
||||
if libKey != "" {
|
||||
if specialKind != "" {
|
||||
return fmt.Sprintf("movie:%s:%s:bangumi:%d", specialKind, libKey, m.BangumiID)
|
||||
}
|
||||
return fmt.Sprintf("movie:%s:bangumi:%d", libKey, m.BangumiID)
|
||||
}
|
||||
if specialKind != "" {
|
||||
return fmt.Sprintf("movie:%s:bangumi:%d", specialKind, m.BangumiID)
|
||||
}
|
||||
return fmt.Sprintf("bangumi:%d", m.BangumiID)
|
||||
case strings.TrimSpace(m.DoubanID) != "":
|
||||
if libKey != "" {
|
||||
if specialKind != "" {
|
||||
return fmt.Sprintf("movie:%s:%s:douban:%s", specialKind, libKey, strings.ToLower(strings.TrimSpace(m.DoubanID)))
|
||||
}
|
||||
return fmt.Sprintf("movie:%s:douban:%s", libKey, strings.ToLower(strings.TrimSpace(m.DoubanID)))
|
||||
}
|
||||
if specialKind != "" {
|
||||
return "movie:" + specialKind + ":douban:" + strings.ToLower(strings.TrimSpace(m.DoubanID))
|
||||
}
|
||||
return "douban:" + strings.ToLower(strings.TrimSpace(m.DoubanID))
|
||||
case strings.TrimSpace(m.TheTVDBID) != "":
|
||||
if libKey != "" {
|
||||
if specialKind != "" {
|
||||
return fmt.Sprintf("movie:%s:%s:thetvdb:%s", specialKind, libKey, strings.ToLower(strings.TrimSpace(m.TheTVDBID)))
|
||||
}
|
||||
return fmt.Sprintf("movie:%s:thetvdb:%s", libKey, strings.ToLower(strings.TrimSpace(m.TheTVDBID)))
|
||||
}
|
||||
if specialKind != "" {
|
||||
return "movie:" + specialKind + ":thetvdb:" + strings.ToLower(strings.TrimSpace(m.TheTVDBID))
|
||||
}
|
||||
return "thetvdb:" + strings.ToLower(strings.TrimSpace(m.TheTVDBID))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -71,6 +72,108 @@ func TestGroupEpisodeVersionsForDisplayMergesAndKeepsEpisodeOrder(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaVersionsSeparatesAnimeSpecialKindsAndNumbers(t *testing.T) {
|
||||
rows := []model.Media{
|
||||
{
|
||||
Base: model.Base{ID: "movie"},
|
||||
LibraryID: "anime",
|
||||
Title: "摇曳露营△",
|
||||
Path: "/media/动漫/摇曳露营△/摇曳露营△ 剧场版 (2022)/movie.strm",
|
||||
TMDbID: 76075,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "ova-1"},
|
||||
LibraryID: "anime",
|
||||
Title: "摇曳露营△",
|
||||
Path: "/media/动漫/摇曳露营△/OVA/Yuru Camp OVA01.strm",
|
||||
TMDbID: 76075,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "ova-2"},
|
||||
LibraryID: "anime",
|
||||
Title: "摇曳露营△",
|
||||
Path: "/media/动漫/摇曳露营△/OVA/Yuru Camp OVA02.strm",
|
||||
TMDbID: 76075,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "oad-1"},
|
||||
LibraryID: "anime",
|
||||
Title: "摇曳露营△",
|
||||
Path: "/media/动漫/摇曳露营△/OAD/Yuru Camp OAD01.strm",
|
||||
TMDbID: 76075,
|
||||
},
|
||||
}
|
||||
|
||||
grouped := groupMediaVersions(rows)
|
||||
if len(grouped) != 4 {
|
||||
t.Fatalf("grouped len = %d, want theatrical, OVA01, OVA02 and OAD01 separate: %#v", len(grouped), grouped)
|
||||
}
|
||||
for _, item := range grouped {
|
||||
if len(item.Versions) > 1 {
|
||||
t.Fatalf("unrelated anime extras were merged as versions: %#v", item.Versions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaVersionsKeepsBracketNumberedEpisodesSeparate(t *testing.T) {
|
||||
// UHA-WINGS 命名:[组名][标题][01][BDRIP 1920x1080 ...].strm。
|
||||
// 曾因 1920x1080 被解析成 S20E108,12 集全部折叠成一集的多个版本。
|
||||
rows := make([]model.Media, 0, 12)
|
||||
for episode := 1; episode <= 12; episode++ {
|
||||
rows = append(rows, model.Media{
|
||||
Base: model.Base{ID: fmt.Sprintf("ep-%02d", episode)},
|
||||
LibraryID: "anime",
|
||||
Title: "彼得·格里尔的贤者时间",
|
||||
Path: fmt.Sprintf(
|
||||
"/media/影视库/动漫/彼得·格里尔的贤者时间/[UHA-WINGS][Peter Grill to Kenja no Jikan][%02d][BDRIP 1920x1080 HEVC-YUV420P10 FLAC].strm",
|
||||
episode),
|
||||
TMDbID: 99080,
|
||||
})
|
||||
}
|
||||
for i := range rows {
|
||||
season, episode := ParseEpisode(rows[i].Path)
|
||||
rows[i].SeasonNum = season
|
||||
rows[i].EpisodeNum = episode
|
||||
}
|
||||
|
||||
grouped := groupMediaVersions(rows)
|
||||
if len(grouped) != 12 {
|
||||
t.Fatalf("grouped len = %d, want 12 distinct episodes: %#v", len(grouped), grouped)
|
||||
}
|
||||
for _, item := range grouped {
|
||||
if len(item.Versions) > 1 {
|
||||
t.Fatalf("distinct episodes were folded into versions: %#v", item.Versions)
|
||||
}
|
||||
if item.SeasonNum != 1 || item.EpisodeNum < 1 || item.EpisodeNum > 12 {
|
||||
t.Fatalf("unexpected episode identity s=%d e=%d", item.SeasonNum, item.EpisodeNum)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaVersionsMergesSameNumberedOVAEncodes(t *testing.T) {
|
||||
rows := []model.Media{
|
||||
{
|
||||
Base: model.Base{ID: "ova-1-hd"},
|
||||
LibraryID: "anime",
|
||||
Path: "/media/动漫/示例/OVA/Show OVA01 1080p.mkv",
|
||||
TMDbID: 123,
|
||||
SizeBytes: 100,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "ova-1-uhd"},
|
||||
LibraryID: "anime",
|
||||
Path: "/media/动漫/示例/OVA/Show OVA01 2160p.mkv",
|
||||
TMDbID: 123,
|
||||
SizeBytes: 200,
|
||||
},
|
||||
}
|
||||
|
||||
grouped := groupMediaVersions(rows)
|
||||
if len(grouped) != 1 || len(grouped[0].Versions) != 2 {
|
||||
t.Fatalf("same numbered OVA encodes should be versions: %#v", grouped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaVersionsMergesMovieEncodingVariants(t *testing.T) {
|
||||
hd := model.Media{
|
||||
LibraryID: "movies",
|
||||
|
||||
@@ -310,6 +310,7 @@ func (p *MetaTubeProvider) convertSearchResultToMatch(cfg MetaTubeConfig, query
|
||||
}
|
||||
|
||||
return &Match{
|
||||
Provider: "metatube",
|
||||
MediaType: "adult",
|
||||
Title: formattedTitle,
|
||||
OriginalName: code,
|
||||
@@ -383,6 +384,7 @@ func (p *MetaTubeProvider) convertMovieInfoToMatch(cfg MetaTubeConfig, movie *Me
|
||||
}
|
||||
|
||||
return &Match{
|
||||
Provider: "metatube",
|
||||
MediaType: "adult",
|
||||
Title: formattedTitle,
|
||||
OriginalName: code,
|
||||
|
||||
@@ -62,6 +62,9 @@ func TestMetaTubeProviderSearch(t *testing.T) {
|
||||
t.Fatalf("expected 1 match, got %d", len(matches))
|
||||
}
|
||||
m := matches[0]
|
||||
if m.Provider != "metatube" {
|
||||
t.Errorf("expected provider metatube, got %q", m.Provider)
|
||||
}
|
||||
if m.OriginalName != "IPX-235" {
|
||||
t.Errorf("expected original name IPX-235, got %s", m.OriginalName)
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sour
|
||||
continue
|
||||
}
|
||||
}
|
||||
match := o.scraper.lookup(ctx, lib, media, candidate, year)
|
||||
match := o.scraper.lookup(ctx, lib, media, candidate, year, false)
|
||||
if match != nil && strings.TrimSpace(match.Title) != "" {
|
||||
if !organizeMetadataMatchTrusted(candidate, year, match) {
|
||||
if cache != nil {
|
||||
|
||||
@@ -43,7 +43,7 @@ var artworkSidecarSuffixes = []string{
|
||||
// artworkSidecarExtensions are the image extensions a sidecar may use. Both
|
||||
// "base-poster.jpg" (bare separator) and "base.poster.jpg" (dotted separator)
|
||||
// rely on suffix matching, so we probe the common image extensions.
|
||||
var artworkSidecarExtensions = []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn"}
|
||||
var artworkSidecarExtensions = []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn", ".img"}
|
||||
|
||||
// transferSidecarArtwork moves/copies/links the scraped poster/backdrop
|
||||
// sidecar files alongside its media using the same transfer mode, mirroring
|
||||
@@ -84,6 +84,7 @@ func transferSidecarArtwork(srcMedia, dstMedia string, mode TransferMode) error
|
||||
if dstBase == "" {
|
||||
dstBase = strings.TrimSuffix(filepath.Base(dstMedia), filepath.Ext(dstMedia))
|
||||
}
|
||||
srcAdultCode := AdultCodeFromMediaPath(srcMedia)
|
||||
for _, src := range sources {
|
||||
name := filepath.Base(src)
|
||||
// Remap any legacy "Title.mkv-poster.jpg" onto the shared destination stem.
|
||||
@@ -97,7 +98,10 @@ func transferSidecarArtwork(srcMedia, dstMedia string, mode TransferMode) error
|
||||
}
|
||||
}
|
||||
dstName := name
|
||||
if suffix != "" && dstBase != "" {
|
||||
if srcAdultCode != "" && strings.HasPrefix(strings.ToLower(name), strings.ToLower(srcAdultCode)) {
|
||||
// Adult sidecars are keyed by code (e.g. ADN-188-poster.jpg) and should
|
||||
// follow the media without being renamed to the display title stem.
|
||||
} else if suffix != "" && dstBase != "" {
|
||||
dstName = dstBase + suffix
|
||||
}
|
||||
dst := filepath.Join(dstDir, dstName)
|
||||
|
||||
@@ -27,12 +27,13 @@ func NewProfileService(log *zap.Logger, repo *repository.Container) *ProfileServ
|
||||
// ProfileUpdate is the patch object accepted by UpdateProfile. Empty
|
||||
// fields are ignored so the same payload can be reused across screens.
|
||||
type ProfileUpdate struct {
|
||||
Username *string `json:"username,omitempty"`
|
||||
Nickname *string `json:"nickname,omitempty"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||
HideAdult *bool `json:"hide_adult,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
Nickname *string `json:"nickname,omitempty"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||
HideAdult *bool `json:"hide_adult,omitempty"`
|
||||
SubtitleChineseMode *string `json:"subtitle_chinese_mode,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateProfile applies a non-credential patch to the user.
|
||||
@@ -73,6 +74,15 @@ func (p *ProfileService) UpdateProfile(ctx context.Context, userID string, patch
|
||||
if patch.HideAdult != nil {
|
||||
updates["hide_adult"] = *patch.HideAdult
|
||||
}
|
||||
if patch.SubtitleChineseMode != nil {
|
||||
mode := strings.ToLower(strings.TrimSpace(*patch.SubtitleChineseMode))
|
||||
switch mode {
|
||||
case "original", "simplified", "traditional":
|
||||
updates["subtitle_chinese_mode"] = mode
|
||||
default:
|
||||
return nil, errors.New("subtitle_chinese_mode must be original, simplified, or traditional")
|
||||
}
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
if err := p.repo.DB.Model(&model.User{}).Where("id = ?", userID).
|
||||
Updates(updates).Error; err != nil {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func TestProfileSubtitleChineseModePersistsAndValidates(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
repos := repository.New(db)
|
||||
svc := NewProfileService(zap.NewNop(), repos)
|
||||
user := &model.User{
|
||||
Username: "subtitle-viewer",
|
||||
PasswordHash: "hash",
|
||||
Role: "user",
|
||||
IsActive: true,
|
||||
}
|
||||
if err := repos.User.Create(t.Context(), user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
traditional := "traditional"
|
||||
updated, err := svc.UpdateProfile(t.Context(), user.ID, ProfileUpdate{
|
||||
SubtitleChineseMode: &traditional,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.SubtitleChineseMode != traditional {
|
||||
t.Fatalf("subtitle mode = %q, want %q", updated.SubtitleChineseMode, traditional)
|
||||
}
|
||||
|
||||
invalid := "automatic"
|
||||
if _, err := svc.UpdateProfile(t.Context(), user.ID, ProfileUpdate{
|
||||
SubtitleChineseMode: &invalid,
|
||||
}); err == nil {
|
||||
t.Fatal("invalid subtitle mode should be rejected")
|
||||
}
|
||||
|
||||
reloaded, err := repos.User.FindByID(t.Context(), user.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reloaded.SubtitleChineseMode != traditional {
|
||||
t.Fatalf("invalid update changed subtitle mode to %q", reloaded.SubtitleChineseMode)
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -61,8 +63,9 @@ func NewRecognitionWordsService(log *zap.Logger, repo *repository.Container) *Re
|
||||
}
|
||||
|
||||
func (s *RecognitionWordsService) Config(ctx context.Context) RecognitionWordsConfig {
|
||||
cfg := recognitionWordsConfig(ctx, s.repo)
|
||||
cfg.RuleCount = len(parseRecognitionWordRules(recognitionWordsCombinedText(cfg)))
|
||||
cfg, rules := recognitionWordsRuntime(ctx, s.repo)
|
||||
cfg = cloneRecognitionWordsConfig(cfg)
|
||||
cfg.RuleCount = len(rules)
|
||||
return cfg
|
||||
}
|
||||
|
||||
@@ -80,7 +83,11 @@ func (s *RecognitionWordsService) SaveConfig(ctx context.Context, cfg Recognitio
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.Setting.Set(ctx, RecognitionWordsSharedURLsKey, string(rawURLs))
|
||||
if err := s.repo.Setting.Set(ctx, RecognitionWordsSharedURLsKey, string(rawURLs)); err != nil {
|
||||
return err
|
||||
}
|
||||
invalidateRecognitionWordsRuntime(s.repo)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RecognitionWordsService) SyncShared(ctx context.Context) (RecognitionWordsConfig, error) {
|
||||
@@ -104,6 +111,7 @@ func (s *RecognitionWordsService) SyncShared(ctx context.Context) (RecognitionWo
|
||||
if err := s.repo.Setting.Set(ctx, RecognitionWordsSyncedAtKey, now); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
invalidateRecognitionWordsRuntime(s.repo)
|
||||
return s.Config(ctx), nil
|
||||
}
|
||||
|
||||
@@ -120,11 +128,10 @@ func (s *RecognitionWordsService) Test(ctx context.Context, input string) Recogn
|
||||
}
|
||||
|
||||
func ApplyRecognitionWords(ctx context.Context, repo *repository.Container, raw string) string {
|
||||
cfg := recognitionWordsConfig(ctx, repo)
|
||||
if !cfg.Enabled {
|
||||
cfg, rules := recognitionWordsRuntime(ctx, repo)
|
||||
if !cfg.Enabled || len(rules) == 0 {
|
||||
return raw
|
||||
}
|
||||
rules := parseRecognitionWordRules(recognitionWordsCombinedText(cfg))
|
||||
return applyRecognitionWordRules(raw, rules)
|
||||
}
|
||||
|
||||
@@ -189,9 +196,69 @@ func normalizeRecognitionWordURLs(values []string) []string {
|
||||
type recognitionWordRule struct {
|
||||
raw string
|
||||
block string
|
||||
blockRE *regexp.Regexp
|
||||
replaceFrom string
|
||||
replaceTo string
|
||||
replaceRE *regexp.Regexp
|
||||
offsetLeft string
|
||||
offsetRight string
|
||||
offsetExpr string
|
||||
offsetRE *regexp.Regexp
|
||||
}
|
||||
|
||||
// recognitionWordsRuntime caches the parsed rule set (with pre-compiled
|
||||
// regexes) per repository. Reading five settings rows and parsing/recompiling
|
||||
// the whole shared word list used to happen on every query candidate, which is
|
||||
// the scraper's hottest path. Writes through SaveConfig/SyncShared invalidate
|
||||
// the entry immediately; the TTL only bounds staleness for out-of-process edits.
|
||||
type recognitionWordsRuntimeEntry struct {
|
||||
repo *repository.Container
|
||||
cfg RecognitionWordsConfig
|
||||
rules []recognitionWordRule
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
const recognitionWordsRuntimeTTL = 30 * time.Second
|
||||
|
||||
var recognitionWordsRuntimeCache struct {
|
||||
sync.RWMutex
|
||||
entry recognitionWordsRuntimeEntry
|
||||
valid bool
|
||||
}
|
||||
|
||||
func recognitionWordsRuntime(ctx context.Context, repo *repository.Container) (RecognitionWordsConfig, []recognitionWordRule) {
|
||||
now := time.Now()
|
||||
recognitionWordsRuntimeCache.RLock()
|
||||
cached := recognitionWordsRuntimeCache.entry
|
||||
hit := recognitionWordsRuntimeCache.valid && cached.repo == repo && now.Before(cached.expiresAt)
|
||||
recognitionWordsRuntimeCache.RUnlock()
|
||||
if hit {
|
||||
return cached.cfg, cached.rules
|
||||
}
|
||||
|
||||
cfg := recognitionWordsConfig(ctx, repo)
|
||||
rules := parseRecognitionWordRules(recognitionWordsCombinedText(cfg))
|
||||
recognitionWordsRuntimeCache.Lock()
|
||||
recognitionWordsRuntimeCache.entry = recognitionWordsRuntimeEntry{
|
||||
repo: repo,
|
||||
cfg: cfg,
|
||||
rules: rules,
|
||||
expiresAt: now.Add(recognitionWordsRuntimeTTL),
|
||||
}
|
||||
recognitionWordsRuntimeCache.valid = true
|
||||
recognitionWordsRuntimeCache.Unlock()
|
||||
return cfg, rules
|
||||
}
|
||||
|
||||
func invalidateRecognitionWordsRuntime(repo *repository.Container) {
|
||||
recognitionWordsRuntimeCache.Lock()
|
||||
if recognitionWordsRuntimeCache.entry.repo == repo {
|
||||
recognitionWordsRuntimeCache.valid = false
|
||||
}
|
||||
recognitionWordsRuntimeCache.Unlock()
|
||||
}
|
||||
|
||||
func cloneRecognitionWordsConfig(cfg RecognitionWordsConfig) RecognitionWordsConfig {
|
||||
cfg.SharedURLs = append([]string(nil), cfg.SharedURLs...)
|
||||
return cfg
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// The rule set is cached process-wide, so a config write must invalidate it
|
||||
// immediately; otherwise the admin would have to wait out the TTL before a
|
||||
// saved word list takes effect.
|
||||
func TestRecognitionWordsCacheInvalidatedBySaveConfig(t *testing.T) {
|
||||
repos := newOrganizerTestRepo(t)
|
||||
svc := NewRecognitionWordsService(zap.NewNop(), repos)
|
||||
|
||||
if err := svc.SaveConfig(t.Context(), RecognitionWordsConfig{
|
||||
Enabled: true,
|
||||
LocalText: "BADWORD => 好标题",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ApplyRecognitionWords(t.Context(), repos, "BADWORD"); got != "好标题" {
|
||||
t.Fatalf("first apply = %q, want 好标题", got)
|
||||
}
|
||||
|
||||
if err := svc.SaveConfig(t.Context(), RecognitionWordsConfig{
|
||||
Enabled: true,
|
||||
LocalText: "BADWORD => 新标题",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ApplyRecognitionWords(t.Context(), repos, "BADWORD"); got != "新标题" {
|
||||
t.Fatalf("cached rules were not invalidated after SaveConfig: got %q, want 新标题", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecognitionWordsDisabledReturnsRawInput(t *testing.T) {
|
||||
repos := newOrganizerTestRepo(t)
|
||||
svc := NewRecognitionWordsService(zap.NewNop(), repos)
|
||||
if err := svc.SaveConfig(t.Context(), RecognitionWordsConfig{
|
||||
Enabled: false,
|
||||
LocalText: "BADWORD => 好标题",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ApplyRecognitionWords(t.Context(), repos, "BADWORD"); got != "BADWORD" {
|
||||
t.Fatalf("disabled recognition words changed input: got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,11 @@ func parseRecognitionWordRule(line string) recognitionWordRule {
|
||||
case strings.Contains(part, "<>") && strings.Contains(part, ">>"):
|
||||
beforeAfter := strings.SplitN(part, ">>", 2)
|
||||
bounds := strings.SplitN(beforeAfter[0], "<>", 2)
|
||||
// A malformed rule without "<>" would otherwise index past the
|
||||
// slice; word lists are fetched from the network, so stay defensive.
|
||||
if len(bounds) < 2 {
|
||||
continue
|
||||
}
|
||||
rule.offsetLeft = strings.TrimSpace(bounds[0])
|
||||
rule.offsetRight = strings.TrimSpace(bounds[1])
|
||||
rule.offsetExpr = strings.TrimSpace(beforeAfter[1])
|
||||
@@ -38,56 +43,89 @@ func parseRecognitionWordRule(line string) recognitionWordRule {
|
||||
rule.block = part
|
||||
}
|
||||
}
|
||||
compileRecognitionWordRule(&rule)
|
||||
return rule
|
||||
}
|
||||
|
||||
var recognitionReplacementRE = regexp.MustCompile(`\\([0-9]+)`)
|
||||
|
||||
func normalizeRecognitionReplacement(value string) string {
|
||||
re := regexp.MustCompile(`\\([0-9]+)`)
|
||||
return re.ReplaceAllString(value, "$$$1")
|
||||
return recognitionReplacementRE.ReplaceAllString(value, "$$$1")
|
||||
}
|
||||
|
||||
// compileRecognitionWordRule pre-compiles every pattern in a rule so the hot
|
||||
// clean-query path never recompiles regexes per candidate.
|
||||
func compileRecognitionWordRule(rule *recognitionWordRule) {
|
||||
if rule == nil {
|
||||
return
|
||||
}
|
||||
if rule.block != "" {
|
||||
if re, err := regexp.Compile(rule.block); err == nil {
|
||||
rule.blockRE = re
|
||||
}
|
||||
}
|
||||
if rule.replaceFrom != "" {
|
||||
if re, err := regexp.Compile(rule.replaceFrom); err == nil {
|
||||
rule.replaceRE = re
|
||||
}
|
||||
}
|
||||
if rule.offsetExpr != "" && (rule.offsetLeft != "" || rule.offsetRight != "") {
|
||||
if re, err := compileRecognitionOffsetRE(rule.offsetLeft, rule.offsetRight); err == nil {
|
||||
rule.offsetRE = re
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func compileRecognitionOffsetRE(left, right string) (*regexp.Regexp, error) {
|
||||
leftPattern := firstNonEmpty(left, `^`)
|
||||
rightPattern := firstNonEmpty(right, `$`)
|
||||
return regexp.Compile(`(?i)(` + leftPattern + `)(\d{1,5})(` + rightPattern + `)`)
|
||||
}
|
||||
|
||||
func applyRecognitionWordRules(raw string, rules []recognitionWordRule) string {
|
||||
out := strings.TrimSpace(raw)
|
||||
for _, rule := range rules {
|
||||
if rule.block != "" {
|
||||
out = applyRecognitionBlock(out, rule.block)
|
||||
out = applyRecognitionBlock(out, rule)
|
||||
}
|
||||
if rule.replaceFrom != "" {
|
||||
out = applyRecognitionReplace(out, rule.replaceFrom, rule.replaceTo)
|
||||
out = applyRecognitionReplace(out, rule)
|
||||
}
|
||||
if rule.offsetLeft != "" || rule.offsetRight != "" {
|
||||
out = applyRecognitionOffset(out, rule.offsetLeft, rule.offsetRight, rule.offsetExpr)
|
||||
out = applyRecognitionOffset(out, rule)
|
||||
}
|
||||
}
|
||||
return strings.Join(strings.Fields(out), " ")
|
||||
}
|
||||
|
||||
func applyRecognitionBlock(raw, block string) string {
|
||||
if re, err := regexp.Compile(block); err == nil {
|
||||
return re.ReplaceAllString(raw, " ")
|
||||
func applyRecognitionBlock(raw string, rule recognitionWordRule) string {
|
||||
if rule.blockRE != nil {
|
||||
return rule.blockRE.ReplaceAllString(raw, " ")
|
||||
}
|
||||
return strings.ReplaceAll(raw, block, " ")
|
||||
return strings.ReplaceAll(raw, rule.block, " ")
|
||||
}
|
||||
|
||||
func applyRecognitionReplace(raw, from, to string) string {
|
||||
if re, err := regexp.Compile(from); err == nil {
|
||||
return re.ReplaceAllString(raw, to)
|
||||
func applyRecognitionReplace(raw string, rule recognitionWordRule) string {
|
||||
if rule.replaceRE != nil {
|
||||
return rule.replaceRE.ReplaceAllString(raw, rule.replaceTo)
|
||||
}
|
||||
return strings.ReplaceAll(raw, from, to)
|
||||
return strings.ReplaceAll(raw, rule.replaceFrom, rule.replaceTo)
|
||||
}
|
||||
|
||||
func applyRecognitionOffset(raw, left, right, expr string) string {
|
||||
if strings.TrimSpace(expr) == "" {
|
||||
func applyRecognitionOffset(raw string, rule recognitionWordRule) string {
|
||||
if strings.TrimSpace(rule.offsetExpr) == "" {
|
||||
return raw
|
||||
}
|
||||
leftPattern := firstNonEmpty(left, `^`)
|
||||
rightPattern := firstNonEmpty(right, `$`)
|
||||
re, err := regexp.Compile(`(?i)(` + leftPattern + `)(\d{1,5})(` + rightPattern + `)`)
|
||||
if err != nil {
|
||||
return raw
|
||||
re := rule.offsetRE
|
||||
if re == nil {
|
||||
compiled, err := compileRecognitionOffsetRE(rule.offsetLeft, rule.offsetRight)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
re = compiled
|
||||
}
|
||||
return re.ReplaceAllStringFunc(raw, func(match string) string {
|
||||
return applyRecognitionOffsetMatch(re, match, expr)
|
||||
return applyRecognitionOffsetMatch(re, match, rule.offsetExpr)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,11 @@ func (s *ScannerService) readLocalScanMetadata(lib *model.Library, root *model.L
|
||||
if root != nil && strings.TrimSpace(root.Path) != "" {
|
||||
rootPath = root.Path
|
||||
}
|
||||
localMeta, err := ReadLocalMetadata(path, rootPath, librarySupportsSeasons(lib) || parsedSeason > 0 || parsedEpisode > 0)
|
||||
seriesLike := librarySupportsSeasons(lib) || parsedSeason > 0 || parsedEpisode > 0
|
||||
if mediaLooksLikeTheatricalFeature(&model.Media{Path: path}) {
|
||||
seriesLike = false
|
||||
}
|
||||
localMeta, err := ReadLocalMetadata(path, rootPath, seriesLike)
|
||||
if err != nil {
|
||||
s.log.Warn("read local metadata failed", zap.String("path", path), zap.Error(err))
|
||||
}
|
||||
@@ -196,7 +200,7 @@ func (s *ScannerService) writeLocalScanMedia(in localScanWriteInput) {
|
||||
in.writeBatch.AddWithAfter(in.path, in.media, in.after)
|
||||
return
|
||||
}
|
||||
if err := s.repo.Media.Upsert(in.ctx, in.media); err != nil {
|
||||
if err := s.repo.Media.UpsertWithAliases(in.ctx, in.media, missingSTRMPathAliases(in.path)); err != nil {
|
||||
addScanError(in.res, in.path, err)
|
||||
s.log.Warn("upsert media failed", zap.String("path", in.path), zap.Error(err))
|
||||
return
|
||||
@@ -248,8 +252,10 @@ func (s *ScannerService) duplicateByFileID(ctx context.Context, fileID, path str
|
||||
}
|
||||
|
||||
func (s *ScannerService) mediaPathExists(ctx context.Context, path string) bool {
|
||||
paths := []string{path}
|
||||
paths = append(paths, missingSTRMPathAliases(path)...)
|
||||
var count int64
|
||||
err := s.repo.DB.WithContext(ctx).Unscoped().Model(&model.Media{}).
|
||||
Where("path = ?", path).Count(&count).Error
|
||||
Where("path IN ?", paths).Count(&count).Error
|
||||
return err == nil && count > 0
|
||||
}
|
||||
|
||||
@@ -69,6 +69,49 @@ func TestScanLibraryUsesLocalMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanAnimeTheatricalFolderUsesMovieNFO(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
showDir := filepath.Join(root, "摇曳露营△ (2018)")
|
||||
movieDir := filepath.Join(showDir, "摇曳露营△ 剧场版 (2022)")
|
||||
if err := os.MkdirAll(movieDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(showDir, "tvshow.nfo"), []byte(
|
||||
`<tvshow><title>错误的剧集标题</title><tmdbid>76075</tmdbid><year>2018</year></tvshow>`,
|
||||
), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(movieDir, "Eiga.Yurukyan.2022.Bluray.mkv")
|
||||
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(nfoPath(mediaPath), []byte(
|
||||
`<movie><title>摇曳露营△ 剧场版</title><tmdbid>566466</tmdbid><year>2022</year></movie>`,
|
||||
), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{})
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "动漫", Path: root, Type: "anime", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
|
||||
if _, err := scanner.ScanLibrary(t.Context(), lib.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var media model.Media
|
||||
if err := db.First(&media, "path = ?", mediaPath).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if media.Title != "摇曳露营△ 剧场版" || media.TMDbID != 566466 || media.Year != 2022 {
|
||||
t.Fatalf("theatrical movie metadata = title %q tmdb %d year %d", media.Title, media.TMDbID, media.Year)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanLibraryDoesNotMarkArtworkOnlyAsMatched(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mediaPath := filepath.Join(root, "SSIS-001-CD1.mp4")
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
type localMediaWriteBatch struct {
|
||||
@@ -18,9 +19,10 @@ type localMediaWriteBatch struct {
|
||||
}
|
||||
|
||||
type localMediaWriteItem struct {
|
||||
path string
|
||||
media *model.Media
|
||||
after func()
|
||||
path string
|
||||
media *model.Media
|
||||
aliases []string
|
||||
after func()
|
||||
}
|
||||
|
||||
func newLocalMediaWriteBatch(scanner *ScannerService, ctx context.Context, res *ScanResult, limit int) *localMediaWriteBatch {
|
||||
@@ -41,7 +43,12 @@ func (b *localMediaWriteBatch) AddWithAfter(path string, media *model.Media, aft
|
||||
if media.ScrapeStatus == "" {
|
||||
media.ScrapeStatus = "pending"
|
||||
}
|
||||
b.items = append(b.items, localMediaWriteItem{path: path, media: media, after: after})
|
||||
b.items = append(b.items, localMediaWriteItem{
|
||||
path: path,
|
||||
media: media,
|
||||
aliases: missingSTRMPathAliases(path),
|
||||
after: after,
|
||||
})
|
||||
if len(b.items) >= b.limit {
|
||||
b.Flush()
|
||||
}
|
||||
@@ -53,39 +60,35 @@ func (b *localMediaWriteBatch) Flush() {
|
||||
}
|
||||
items := b.items
|
||||
b.items = nil
|
||||
media := make([]model.Media, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.media != nil {
|
||||
media = append(media, *item.media)
|
||||
}
|
||||
}
|
||||
if len(media) == 0 {
|
||||
return
|
||||
}
|
||||
existingPaths := b.existingPaths(items)
|
||||
upsertItems := make([]*model.Media, 0, len(items))
|
||||
upsertAfter := make([]func(), 0, len(items))
|
||||
|
||||
existingPaths, lookupOK := b.existingPathOrAliasSet(items)
|
||||
upsertItems := make([]localMediaWriteItem, 0, len(items))
|
||||
createItems := make([]localMediaWriteItem, 0, len(items))
|
||||
createMedia := make([]model.Media, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.media == nil {
|
||||
continue
|
||||
}
|
||||
if existingPaths[filepath.Clean(item.media.Path)] {
|
||||
// 已存在行:攒起来在一个事务里逐条 upsert(一批一次提交)。
|
||||
after := item.after
|
||||
upsertItems = append(upsertItems, item.media)
|
||||
upsertAfter = append(upsertAfter, after)
|
||||
// If the alias lookup failed, route through UpsertWithAliases instead of
|
||||
// direct-create. That is slower but cannot create a duplicate row.
|
||||
if !lookupOK || mediaPathOrAliasExists(item.media.Path, item.aliases, existingPaths) {
|
||||
upsertItems = append(upsertItems, item)
|
||||
continue
|
||||
}
|
||||
createItems = append(createItems, item)
|
||||
createMedia = append(createMedia, *item.media)
|
||||
}
|
||||
b.flushUpserts(items, upsertItems, upsertAfter)
|
||||
if len(createMedia) == 0 {
|
||||
|
||||
b.flushUpserts(upsertItems)
|
||||
if len(createItems) == 0 {
|
||||
b.publish()
|
||||
return
|
||||
}
|
||||
|
||||
createMedia := make([]model.Media, 0, len(createItems))
|
||||
for _, item := range createItems {
|
||||
if item.media != nil {
|
||||
createMedia = append(createMedia, *item.media)
|
||||
}
|
||||
}
|
||||
if err := b.scanner.repo.DB.WithContext(b.ctx).CreateInBatches(&createMedia, b.limit).Error; err == nil {
|
||||
b.res.Added += len(createMedia)
|
||||
for _, item := range createItems {
|
||||
@@ -96,12 +99,13 @@ func (b *localMediaWriteBatch) Flush() {
|
||||
b.publish()
|
||||
return
|
||||
}
|
||||
|
||||
for _, item := range createItems {
|
||||
if item.media == nil {
|
||||
continue
|
||||
}
|
||||
wasExisting := b.mediaPathExists(item.media.Path)
|
||||
if err := b.scanner.repo.Media.Upsert(b.ctx, item.media); err != nil {
|
||||
if err := b.scanner.repo.Media.UpsertWithAliases(b.ctx, item.media, item.aliases); err != nil {
|
||||
addScanError(b.res, item.path, err)
|
||||
b.scanner.log.Warn("upsert media failed", zap.String("path", item.path), zap.Error(err))
|
||||
continue
|
||||
@@ -118,47 +122,90 @@ func (b *localMediaWriteBatch) Flush() {
|
||||
b.publish()
|
||||
}
|
||||
|
||||
func (b *localMediaWriteBatch) existingPaths(items []localMediaWriteItem) map[string]bool {
|
||||
// existingPathOrAliasSet loads exact and alias paths in bounded query chunks. Aliases
|
||||
// have already been filtered against the filesystem by AddWithAfter, so an
|
||||
// existing keep_ext sibling is never treated as a rename.
|
||||
func (b *localMediaWriteBatch) existingPathOrAliasSet(items []localMediaWriteItem) (map[string]bool, bool) {
|
||||
out := map[string]bool{}
|
||||
if b == nil || b.scanner == nil || b.scanner.repo == nil || b.scanner.repo.DB == nil || len(items) == 0 {
|
||||
return out
|
||||
return out, false
|
||||
}
|
||||
seen := make(map[string]struct{}, len(items)*2)
|
||||
paths := make([]string, 0, len(items)*2)
|
||||
add := func(path string) {
|
||||
path = filepath.Clean(path)
|
||||
if path == "" || path == "." {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[path]; ok {
|
||||
return
|
||||
}
|
||||
seen[path] = struct{}{}
|
||||
paths = append(paths, path)
|
||||
}
|
||||
paths := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.media == nil || item.media.Path == "" {
|
||||
continue
|
||||
}
|
||||
paths = append(paths, item.media.Path)
|
||||
add(item.media.Path)
|
||||
for _, alias := range item.aliases {
|
||||
add(alias)
|
||||
}
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return out
|
||||
return out, true
|
||||
}
|
||||
var rows []string
|
||||
if err := b.scanner.repo.DB.WithContext(b.ctx).
|
||||
Unscoped().
|
||||
Model(&model.Media{}).
|
||||
Where("path IN ?", paths).
|
||||
Pluck("path", &rows).Error; err != nil {
|
||||
b.scanner.log.Debug("load existing media paths for scan batch failed", zap.Error(err))
|
||||
return out
|
||||
// Keep SQL variables below the legacy SQLite limit (999). A batch can
|
||||
// contain 100 STRM rows and every row has many sibling aliases.
|
||||
const pathLookupChunk = 400
|
||||
for start := 0; start < len(paths); start += pathLookupChunk {
|
||||
end := start + pathLookupChunk
|
||||
if end > len(paths) {
|
||||
end = len(paths)
|
||||
}
|
||||
var rows []string
|
||||
if err := b.scanner.repo.DB.WithContext(b.ctx).
|
||||
Unscoped().
|
||||
Model(&model.Media{}).
|
||||
Where("path IN ?", paths[start:end]).
|
||||
Pluck("path", &rows).Error; err != nil {
|
||||
b.scanner.log.Debug("load existing media paths for scan batch failed", zap.Error(err))
|
||||
return nil, false
|
||||
}
|
||||
for _, path := range rows {
|
||||
out[filepath.Clean(path)] = true
|
||||
}
|
||||
}
|
||||
for _, path := range rows {
|
||||
out[filepath.Clean(path)] = true
|
||||
}
|
||||
return out
|
||||
return out, true
|
||||
}
|
||||
|
||||
// flushUpserts 把已存在行的 upsert 攒成一个事务(一次提交/一组 fsync)。
|
||||
// 整批失败(如单条数据触发约束)时退回逐条 Upsert,只丢真正坏的那几条。
|
||||
func (b *localMediaWriteBatch) flushUpserts(allItems []localMediaWriteItem, upsertItems []*model.Media, upsertAfter []func()) {
|
||||
func mediaPathOrAliasExists(path string, aliases []string, existing map[string]bool) bool {
|
||||
if existing[filepath.Clean(path)] {
|
||||
return true
|
||||
}
|
||||
for _, alias := range aliases {
|
||||
if existing[filepath.Clean(alias)] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// flushUpserts 把已存在或可迁移路径的行攒成一个事务(一次提交/一组 fsync)。
|
||||
// 整批失败(如单条数据触发约束)时退回逐条 upsert,只丢真正坏的那几条。
|
||||
func (b *localMediaWriteBatch) flushUpserts(upsertItems []localMediaWriteItem) {
|
||||
if len(upsertItems) == 0 {
|
||||
return
|
||||
}
|
||||
if err := b.scanner.repo.Media.UpsertBatch(b.ctx, upsertItems); err == nil {
|
||||
batchItems := make([]repository.MediaUpsertItem, 0, len(upsertItems))
|
||||
for _, item := range upsertItems {
|
||||
batchItems = append(batchItems, repository.MediaUpsertItem{Media: item.media, AliasPaths: item.aliases})
|
||||
}
|
||||
if err := b.scanner.repo.Media.UpsertBatchWithAliases(b.ctx, batchItems); err == nil {
|
||||
b.res.Updated += len(upsertItems)
|
||||
for _, after := range upsertAfter {
|
||||
if after != nil {
|
||||
after()
|
||||
for _, item := range upsertItems {
|
||||
if item.after != nil {
|
||||
item.after()
|
||||
}
|
||||
}
|
||||
return
|
||||
@@ -166,13 +213,7 @@ func (b *localMediaWriteBatch) flushUpserts(allItems []localMediaWriteItem, upse
|
||||
b.scanner.log.Warn("batch upsert failed; falling back to per-item upsert",
|
||||
zap.Int("items", len(upsertItems)))
|
||||
}
|
||||
// 兜底:按原始顺序找回每个条目的 path/after(两个切片同序但可能含 nil)。
|
||||
idx := 0
|
||||
for _, item := range allItems {
|
||||
if item.media == nil || idx >= len(upsertItems) || upsertItems[idx] != item.media {
|
||||
continue
|
||||
}
|
||||
idx++
|
||||
for _, item := range upsertItems {
|
||||
b.upsertExistingItem(item)
|
||||
}
|
||||
}
|
||||
@@ -181,7 +222,7 @@ func (b *localMediaWriteBatch) upsertExistingItem(item localMediaWriteItem) {
|
||||
if item.media == nil {
|
||||
return
|
||||
}
|
||||
if err := b.scanner.repo.Media.Upsert(b.ctx, item.media); err != nil {
|
||||
if err := b.scanner.repo.Media.UpsertWithAliases(b.ctx, item.media, item.aliases); err != nil {
|
||||
addScanError(b.res, item.path, err)
|
||||
b.scanner.log.Warn("upsert media failed", zap.String("path", item.path), zap.Error(err))
|
||||
return
|
||||
|
||||
@@ -5,13 +5,15 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// RemovePath 物理删除磁盘上已不存在的媒体记录。
|
||||
// RemovePath 移除磁盘上已不存在的媒体记录。普通的删除仍做物理删除;
|
||||
// 可确认是 STRM 扩展名改名的路径则先保留软删除墓碑,供后续 ingest 继承元数据。
|
||||
func (s *ScannerService) RemovePath(ctx context.Context, path string) (int64, error) {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return 0, nil // still exists; nothing to remove
|
||||
@@ -30,24 +32,64 @@ func (s *ScannerService) RemovePath(ctx context.Context, path string) (int64, er
|
||||
Find(&rows).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ids := make([]string, 0, len(rows))
|
||||
softIDs := make([]string, 0, len(rows))
|
||||
hardIDs := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
// LIKE 里的 % _ 是通配符(候选集只会偏大),用 Go 前缀精确过滤,
|
||||
// 避免对含 % / _ 的路径误删。
|
||||
if row.Path == path || strings.HasPrefix(filepath.Clean(row.Path), prefix) {
|
||||
ids = append(ids, row.ID)
|
||||
if row.Path != path && !strings.HasPrefix(filepath.Clean(row.Path), prefix) {
|
||||
continue
|
||||
}
|
||||
// A vanished STRM with a live sibling (foo.strm <-> foo.mkv.strm)
|
||||
// is a rename. Keep a soft-deleted tombstone so the later ingest can
|
||||
// adopt its ID and scraped metadata even if fsnotify delivers the
|
||||
// remove event before the create event. A true deletion stays hard.
|
||||
if row.Path == path && liveSTRMPathAlias(path) {
|
||||
softIDs = append(softIDs, row.ID)
|
||||
continue
|
||||
}
|
||||
hardIDs = append(hardIDs, row.ID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
var removed int64
|
||||
if len(softIDs) > 0 {
|
||||
res := s.repo.DB.WithContext(ctx).
|
||||
Where("id IN ?", softIDs).
|
||||
Delete(&model.Media{})
|
||||
if res.Error != nil {
|
||||
return removed, res.Error
|
||||
}
|
||||
removed += res.RowsAffected
|
||||
}
|
||||
res := s.repo.DB.WithContext(ctx).Unscoped().
|
||||
Where("id IN ?", ids).
|
||||
Delete(&model.Media{})
|
||||
if res.Error == nil && res.RowsAffected > 0 {
|
||||
if len(hardIDs) > 0 {
|
||||
res := s.repo.DB.WithContext(ctx).Unscoped().
|
||||
Where("id IN ?", hardIDs).
|
||||
Delete(&model.Media{})
|
||||
if res.Error != nil {
|
||||
return removed, res.Error
|
||||
}
|
||||
removed += res.RowsAffected
|
||||
}
|
||||
if removed > 0 {
|
||||
s.invalidateMediaCache(ctx)
|
||||
}
|
||||
return res.RowsAffected, res.Error
|
||||
if len(softIDs) > 0 {
|
||||
s.purgeExpiredSTRMTombstones(ctx)
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
const strmRenameTombstoneRetention = 7 * 24 * time.Hour
|
||||
|
||||
// purgeExpiredSTRMTombstones keeps the soft-delete grace period bounded.
|
||||
// It is deliberately restricted to deleted media rows under a STRM alias
|
||||
// workflow; normal deletions are still hard-deleted immediately.
|
||||
func (s *ScannerService) purgeExpiredSTRMTombstones(ctx context.Context) {
|
||||
cutoff := time.Now().Add(-strmRenameTombstoneRetention)
|
||||
if err := s.repo.DB.WithContext(ctx).Unscoped().
|
||||
Where("deleted_at IS NOT NULL AND deleted_at < ? AND path LIKE ?", cutoff, "%.strm").
|
||||
Delete(&model.Media{}).Error; err != nil && s.log != nil {
|
||||
s.log.Warn("purge expired STRM rename tombstones failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ScannerService) pruneMissingMedia(ctx context.Context, libraryID string, seen map[string]struct{}) (int64, error) {
|
||||
|
||||
@@ -117,6 +117,10 @@ func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, auto
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Flush alias-aware upserts before pruning missing paths. This lets a
|
||||
// foo.strm <-> foo.mkv.strm rename migrate the existing row (including
|
||||
// scraped metadata) instead of deleting it as "old path missing".
|
||||
writeBatch.Flush()
|
||||
scannedRoots++
|
||||
removed, err := s.pruneMissingMediaForRoot(ctx, lib.ID, root.ID, root.Path, seen)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
func TestSTRMPathAliasesCoverBothKeepExtDirections(t *testing.T) {
|
||||
toKeepExt := strmPathAliases(filepath.Join(t.TempDir(), "Show.S01E01.strm"))
|
||||
if !containsPathAlias(toKeepExt, "Show.S01E01.mkv.strm") {
|
||||
t.Fatalf("missing keep_ext alias: %#v", toKeepExt)
|
||||
}
|
||||
fromKeepExt := strmPathAliases(filepath.Join(t.TempDir(), "Show.S01E01.mkv.strm"))
|
||||
if !containsPathAlias(fromKeepExt, "Show.S01E01.strm") {
|
||||
t.Fatalf("missing stripped alias: %#v", fromKeepExt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingSTRMPathAliasesKeepsRealKeepExtSiblingVersions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
current := filepath.Join(dir, "Show.S01E01.mkv.strm")
|
||||
sibling := filepath.Join(dir, "Show.S01E01.mp4.strm")
|
||||
writeFileContent(t, current, "https://example.test/mkv")
|
||||
writeFileContent(t, sibling, "https://example.test/mp4")
|
||||
|
||||
aliases := missingSTRMPathAliases(current)
|
||||
if containsPathAlias(aliases, sibling) {
|
||||
t.Fatalf("existing keep_ext sibling must remain a separate version: %#v", aliases)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanLibraryMigratesSTRMKeepExtRename(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
oldName string
|
||||
newName string
|
||||
}{
|
||||
{name: "add video extension", oldName: "Show.S01E01.strm", newName: "Show.S01E01.mkv.strm"},
|
||||
{name: "remove video extension", oldName: "Show.S01E01.mkv.strm", newName: "Show.S01E01.strm"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sc, repos := newScannerTestEnv(t)
|
||||
root := t.TempDir()
|
||||
lib := model.Library{Name: "Anime", Path: root, Type: "anime", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldPath := filepath.Join(root, tc.oldName)
|
||||
newPath := filepath.Join(root, tc.newName)
|
||||
writeFileContent(t, oldPath, "https://example.test/video")
|
||||
if _, err := sc.IngestPath(t.Context(), lib.ID, oldPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var before model.Media
|
||||
if err := repos.DB.First(&before, "path = ?", oldPath).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.DB.Model(&model.Media{}).Where("id = ?", before.ID).Updates(map[string]any{
|
||||
"title": "地狱乐",
|
||||
"original_name": "Jigokuraku",
|
||||
"overview": "preserved overview",
|
||||
"poster_url": "Jigokuraku-poster.jpg",
|
||||
"backdrop_url": "Jigokuraku-backdrop.jpg",
|
||||
"year": 2023,
|
||||
"rating": 8.6,
|
||||
"tm_db_id": 12345,
|
||||
"bangumi_id": 67890,
|
||||
"genres": "Action,Adventure",
|
||||
"scrape_status": "matched",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Rename(oldPath, newPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := sc.ScanLibrary(t.Context(), lib.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.ErrorCount != 0 {
|
||||
t.Fatalf("scan errors: %#v", res.Errors)
|
||||
}
|
||||
if got := countMedia(t, repos); got != 1 {
|
||||
t.Fatalf("media count = %d, want 1", got)
|
||||
}
|
||||
|
||||
var after model.Media
|
||||
if err := repos.DB.First(&after, "id = ?", before.ID).Error; err != nil {
|
||||
t.Fatalf("old media row was not preserved: %v", err)
|
||||
}
|
||||
if after.Path != newPath {
|
||||
t.Fatalf("path = %q, want %q", after.Path, newPath)
|
||||
}
|
||||
if after.Title != "地狱乐" || after.OriginalName != "Jigokuraku" || after.Overview != "preserved overview" {
|
||||
t.Fatalf("scraped identity metadata was lost: %#v", after)
|
||||
}
|
||||
if after.PosterURL != "Jigokuraku-poster.jpg" || after.BackdropURL != "Jigokuraku-backdrop.jpg" {
|
||||
t.Fatalf("artwork was lost: poster=%q backdrop=%q", after.PosterURL, after.BackdropURL)
|
||||
}
|
||||
if after.TMDbID != 12345 || after.BangumiID != 67890 || after.ScrapeStatus != "matched" {
|
||||
t.Fatalf("scrape IDs/status changed: tmdb=%d bgm=%d status=%q", after.TMDbID, after.BangumiID, after.ScrapeStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestPathAdoptsSoftDeletedSTRMRenameTombstone(t *testing.T) {
|
||||
sc, repos := newScannerTestEnv(t)
|
||||
root := t.TempDir()
|
||||
lib := model.Library{Name: "Anime", Path: root, Type: "anime", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldPath := filepath.Join(root, "Show.S01E01.mkv.strm")
|
||||
newPath := filepath.Join(root, "Show.S01E01.strm")
|
||||
writeFileContent(t, oldPath, "https://example.test/video")
|
||||
if _, err := sc.IngestPath(t.Context(), lib.ID, oldPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var before model.Media
|
||||
if err := repos.DB.First(&before, "path = ?", oldPath).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.DB.Model(&model.Media{}).Where("id = ?", before.ID).Updates(map[string]any{
|
||||
"title": "拔作岛",
|
||||
"overview": "kept through remove-before-create",
|
||||
"poster_url": "nukitashi-poster.webp",
|
||||
"tm_db_id": 222,
|
||||
"scrape_status": "matched",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Rename(oldPath, newPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Simulate fsnotify delivering Remove(old) before Create(new).
|
||||
if removed, err := sc.RemovePath(t.Context(), oldPath); err != nil || removed != 1 {
|
||||
t.Fatalf("RemovePath() removed=%d err=%v, want 1", removed, err)
|
||||
}
|
||||
var tombstone model.Media
|
||||
if err := repos.DB.Unscoped().First(&tombstone, "id = ?", before.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tombstone.DeletedAt.Valid {
|
||||
t.Fatal("rename tombstone should be soft-deleted")
|
||||
}
|
||||
if got := countMedia(t, repos); got != 0 {
|
||||
t.Fatalf("active media count = %d, want 0 before create event", got)
|
||||
}
|
||||
|
||||
if _, err := sc.IngestPath(t.Context(), lib.ID, newPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var after model.Media
|
||||
if err := repos.DB.First(&after, "id = ?", before.ID).Error; err != nil {
|
||||
t.Fatalf("soft-deleted row was not restored: %v", err)
|
||||
}
|
||||
if after.Path != newPath || after.Title != "拔作岛" || after.Overview != "kept through remove-before-create" {
|
||||
t.Fatalf("rename metadata was not adopted: %#v", after)
|
||||
}
|
||||
if after.TMDbID != 222 || after.ScrapeStatus != "matched" {
|
||||
t.Fatalf("scrape metadata changed: tmdb=%d status=%q", after.TMDbID, after.ScrapeStatus)
|
||||
}
|
||||
if got := countMedia(t, repos); got != 1 {
|
||||
t.Fatalf("media count = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemovePathStillHardDeletesTrueDeletionWithoutAlias(t *testing.T) {
|
||||
sc, repos := newScannerTestEnv(t)
|
||||
root := t.TempDir()
|
||||
lib := model.Library{Name: "Anime", Path: root, Type: "anime", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(root, "Deleted.S01E01.mkv.strm")
|
||||
writeFileContent(t, path, "https://example.test/video")
|
||||
if _, err := sc.IngestPath(t.Context(), lib.ID, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if removed, err := sc.RemovePath(t.Context(), path); err != nil || removed != 1 {
|
||||
t.Fatalf("RemovePath() removed=%d err=%v, want 1", removed, err)
|
||||
}
|
||||
var count int64
|
||||
if err := repos.DB.Unscoped().Model(&model.Media{}).Where("path = ?", path).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("true deletion left %d tombstone row(s)", count)
|
||||
}
|
||||
}
|
||||
|
||||
func containsPathAlias(paths []string, name string) bool {
|
||||
for _, path := range paths {
|
||||
if strings.EqualFold(filepath.Base(path), filepath.Base(name)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -63,12 +63,17 @@ func (s *ScraperService) EnrichOneWithOptions(ctx context.Context, m *model.Medi
|
||||
}
|
||||
}
|
||||
|
||||
// Same stale-negative problem as the library path: a single-item retry
|
||||
// (queue task / manual rescrape) must get a fresh provider round-trip.
|
||||
if options.RetryNoMatch && s != nil {
|
||||
s.lookupCache.clearNegatives()
|
||||
}
|
||||
candidates := scrapeQueryCandidatesWithRecognition(ctx, s.repo, m, lib)
|
||||
var query string
|
||||
match := (*Match)(nil)
|
||||
for _, candidate := range candidates {
|
||||
query = candidate
|
||||
candidateMatch := s.lookup(ctx, lib, m, candidate, year)
|
||||
candidateMatch := s.lookup(ctx, lib, m, candidate, year, options.ForceRematch)
|
||||
if candidateMatch == nil {
|
||||
continue
|
||||
}
|
||||
@@ -93,7 +98,11 @@ func (s *ScraperService) EnrichOneWithOptions(ctx context.Context, m *model.Medi
|
||||
}
|
||||
if match == nil {
|
||||
if local != nil && !local.PathHint {
|
||||
return s.applyLocalMetadataMatch(ctx, m, local)
|
||||
err := s.applyLocalMetadataMatch(ctx, m, local)
|
||||
if err == nil {
|
||||
options.recordProvider("local")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("id = ?", m.ID).
|
||||
Update("scrape_status", "no_match").Error; err != nil {
|
||||
@@ -227,6 +236,11 @@ func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *m
|
||||
"thetvdb_id": match.TheTVDBID,
|
||||
"source": map[bool]string{true: "adult"}[match.NSFW],
|
||||
})
|
||||
provider := strings.ToLower(strings.TrimSpace(match.Provider))
|
||||
if provider == "" {
|
||||
provider = "unknown"
|
||||
}
|
||||
options.recordProvider(provider)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
func TestTheatricalTitleVariants(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
input: "名侦探柯南 剧场版01 引爆摩天楼",
|
||||
want: []string{"名侦探柯南 引爆摩天楼", "名侦探柯南 剧场版01 引爆摩天楼"},
|
||||
},
|
||||
{
|
||||
input: "名侦探柯南 剧场版26 黑铁的鱼影",
|
||||
want: []string{"名侦探柯南 黑铁的鱼影", "名侦探柯南 剧场版26 黑铁的鱼影"},
|
||||
},
|
||||
{
|
||||
input: "鬼灭之刃 剧场版 无限列车篇",
|
||||
want: []string{"鬼灭之刃 无限列车篇", "鬼灭之刃 剧场版 无限列车篇"},
|
||||
},
|
||||
{
|
||||
input: "航海王 The Movie 黄金之城",
|
||||
want: []string{"航海王 The Movie 黄金之城"},
|
||||
},
|
||||
{
|
||||
input: "普通动漫 第01集",
|
||||
want: []string{"普通动漫 第01集"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
got := theatricalTitleVariants(tc.input)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("theatricalTitleVariants(%q) = %v, want %v", tc.input, got, tc.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Fatalf("theatricalTitleVariants(%q)[%d] = %q, want %q", tc.input, i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataMatchCompatibilityForTheatricalFeatures(t *testing.T) {
|
||||
animeMatch := &Match{MediaType: "anime"}
|
||||
|
||||
if metadataMatchCompatibleWithType("movie", animeMatch) {
|
||||
t.Fatal("ordinary movie lookups must not accept anime matches")
|
||||
}
|
||||
if !metadataMatchCompatibleWithTheatrical("movie", true, animeMatch) {
|
||||
t.Fatal("theatrical movie lookup should accept an anime provider match")
|
||||
}
|
||||
if metadataMatchCompatibleWithTheatrical("movie", false, animeMatch) {
|
||||
t.Fatal("non-theatrical movie lookup must not accept an anime provider match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaLooksLikeTheatricalFeatureIgnoresStaleEpisodeIdentity(t *testing.T) {
|
||||
media := &model.Media{
|
||||
Title: "未命名",
|
||||
Path: `/media/anime/超人高校生们/剧场版/超人高校生们 剧场版.mkv`,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
}
|
||||
if !mediaLooksLikeTheatricalFeature(media) {
|
||||
t.Fatal("theatrical path should override stale persisted season/episode fields")
|
||||
}
|
||||
|
||||
namedFolder := &model.Media{
|
||||
Title: "Eiga Yurukyan 2022 Bluray",
|
||||
Path: `/media/anime/摇曳露营△ (2018)/摇曳露营△ 剧场版 (2022)/Eiga.Yurukyan.2022.Bluray.mkv`,
|
||||
}
|
||||
if !mediaLooksLikeTheatricalFeature(namedFolder) {
|
||||
t.Fatal("a named theatrical folder with a year should be detected from the full path")
|
||||
}
|
||||
|
||||
realEpisode := &model.Media{
|
||||
Title: "剧场版制作幕后",
|
||||
Path: `/media/anime/超人高校生们/Season 01/超人高校生们.S01E01.mkv`,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
}
|
||||
if mediaLooksLikeTheatricalFeature(realEpisode) {
|
||||
t.Fatal("an actual episode marker in the path must remain episodic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrapeQueryCandidatesForAnimeTheatricalMix(t *testing.T) {
|
||||
lib := &model.Library{
|
||||
Path: `/media/anime`,
|
||||
Type: "anime",
|
||||
}
|
||||
|
||||
// 1. 同目录下包含剧场版01
|
||||
theatricalMedia := &model.Media{
|
||||
Title: "名侦探柯南 剧场版01 引爆摩天楼",
|
||||
Path: `/media/anime/名侦探柯南/名侦探柯南 剧场版01 引爆摩天楼.mkv`,
|
||||
}
|
||||
candidates := scrapeQueryCandidates(theatricalMedia, lib)
|
||||
if len(candidates) == 0 {
|
||||
t.Fatal("scrapeQueryCandidates returned no candidates for theatrical media")
|
||||
}
|
||||
if candidates[0] != "名侦探柯南 引爆摩天楼" {
|
||||
t.Fatalf("first candidate for theatrical media = %q, want cleaned movie title '名侦探柯南 引爆摩天楼'; all=%v", candidates[0], candidates)
|
||||
}
|
||||
|
||||
// 2. 剧场版放在以剧场版命名的子目录
|
||||
subfolderTheatricalMedia := &model.Media{
|
||||
Title: "无限列车篇",
|
||||
Path: `/media/anime/鬼灭之刃/剧场版/无限列车篇.mkv`,
|
||||
}
|
||||
subCandidates := scrapeQueryCandidates(subfolderTheatricalMedia, lib)
|
||||
if len(subCandidates) == 0 {
|
||||
t.Fatal("scrapeQueryCandidates returned no candidates for subfolder theatrical media")
|
||||
}
|
||||
foundCombined := false
|
||||
for _, c := range subCandidates {
|
||||
if c == "鬼灭之刃 无限列车篇" {
|
||||
foundCombined = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundCombined {
|
||||
t.Fatalf("candidates for subfolder theatrical did not contain '鬼灭之刃 无限列车篇': %v", subCandidates)
|
||||
}
|
||||
|
||||
// 3. 混合放置的普通剧集分集不应受剧场版影响
|
||||
episodeMedia := &model.Media{
|
||||
Title: "名侦探柯南 - S01E01",
|
||||
Path: `/media/anime/名侦探柯南/名侦探柯南 - S01E01.mp4`,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
}
|
||||
epCandidates := scrapeQueryCandidates(episodeMedia, lib)
|
||||
if len(epCandidates) == 0 {
|
||||
t.Fatal("scrapeQueryCandidates returned no candidates for episode media")
|
||||
}
|
||||
if epCandidates[0] != "名侦探柯南" {
|
||||
t.Fatalf("first candidate for tv episode = %q, want series folder title '名侦探柯南'", epCandidates[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichOneAnimeMixedEpisodesAndTheatrical(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/search/tv":
|
||||
q := r.URL.Query().Get("query")
|
||||
if q == "鬼灭之刃" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"results": []map[string]any{{
|
||||
"id": 85937,
|
||||
"name": "鬼灭之刃",
|
||||
"original_name": "鬼滅の刃",
|
||||
"overview": "大正时期、日本...",
|
||||
"first_air_date": "2019-04-06",
|
||||
}},
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}})
|
||||
case "/search/movie":
|
||||
q := r.URL.Query().Get("query")
|
||||
if q == "鬼灭之刃 无限列车篇" || q == "鬼灭之刃 剧场版 无限列车篇" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"results": []map[string]any{{
|
||||
"id": 635302,
|
||||
"title": "鬼灭之刃 剧场版 无限列车篇",
|
||||
"original_title": "劇場版「鬼滅の刃」無限列車編",
|
||||
"overview": "在结束了蝴蝶屋的修业之后...",
|
||||
"release_date": "2020-10-16",
|
||||
}},
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}})
|
||||
case "/tv/85937":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": 85937,
|
||||
"name": "鬼灭之刃",
|
||||
"original_name": "鬼滅の刃",
|
||||
"first_air_date": "2019-04-06",
|
||||
})
|
||||
case "/movie/635302":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": 635302,
|
||||
"title": "鬼灭之刃 剧场版 无限列车篇",
|
||||
"original_title": "劇場版「鬼滅の刃」無限列車編",
|
||||
"release_date": "2020-10-16",
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
repos := newOrganizerTestRepo(t)
|
||||
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))
|
||||
|
||||
lib := model.Library{Name: "动漫", Path: `/media/anime`, Type: "anime", Enabled: true}
|
||||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 1. 同一目录下的 TV 剧集
|
||||
tvEpisode := model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "鬼灭之刃 S01E01",
|
||||
Path: `/media/anime/鬼灭之刃/Season 01/鬼灭之刃 - S01E01.mkv`,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
ScrapeStatus: "pending",
|
||||
}
|
||||
if err := repos.DB.Create(&tvEpisode).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 2. 同一目录下的剧场版文件
|
||||
movieMedia := model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "鬼灭之刃 剧场版 无限列车篇",
|
||||
Path: `/media/anime/鬼灭之刃/鬼灭之刃 剧场版 无限列车篇.mkv`,
|
||||
ScrapeStatus: "pending",
|
||||
}
|
||||
if err := repos.DB.Create(&movieMedia).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 刮削剧集
|
||||
if err := scraper.EnrichOne(t.Context(), &tvEpisode); err != nil {
|
||||
t.Fatalf("enrich tv episode: %v", err)
|
||||
}
|
||||
gotTV, err := repos.Media.FindByID(t.Context(), tvEpisode.ID)
|
||||
if err != nil || gotTV == nil {
|
||||
t.Fatalf("load tv episode: %v", err)
|
||||
}
|
||||
if gotTV.ScrapeStatus != "matched" || gotTV.TMDbID != 85937 {
|
||||
t.Fatalf("tv episode scrape mismatch: status=%s, tmdb_id=%d", gotTV.ScrapeStatus, gotTV.TMDbID)
|
||||
}
|
||||
|
||||
// 刮削剧场版
|
||||
if err := scraper.EnrichOne(t.Context(), &movieMedia); err != nil {
|
||||
t.Fatalf("enrich movie: %v", err)
|
||||
}
|
||||
gotMovie, err := repos.Media.FindByID(t.Context(), movieMedia.ID)
|
||||
if err != nil || gotMovie == nil {
|
||||
t.Fatalf("load movie: %v", err)
|
||||
}
|
||||
if gotMovie.ScrapeStatus != "matched" || gotMovie.TMDbID != 635302 {
|
||||
t.Fatalf("theatrical movie scrape mismatch: status=%s, tmdb_id=%d, want 635302", gotMovie.ScrapeStatus, gotMovie.TMDbID)
|
||||
}
|
||||
if gotMovie.Title != "鬼灭之刃 剧场版 无限列车篇" {
|
||||
t.Fatalf("theatrical movie title = %q, want '鬼灭之刃 剧场版 无限列车篇'", gotMovie.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyAnimeTheatricalMovie(t *testing.T) {
|
||||
categories := map[string]string{
|
||||
"animation_movie": "动画电影",
|
||||
"jp_anime": "日番",
|
||||
"chinese_movie": "华语电影",
|
||||
}
|
||||
tests := []struct {
|
||||
title string
|
||||
mediaType string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
title: "名侦探柯南 剧场版01 引爆摩天楼",
|
||||
mediaType: "movie",
|
||||
want: "动画电影",
|
||||
},
|
||||
{
|
||||
title: "鬼灭之刃 剧场版 无限列车篇",
|
||||
mediaType: "movie",
|
||||
want: "动画电影",
|
||||
},
|
||||
{
|
||||
title: "鬼灭之刃 S01E01",
|
||||
mediaType: "tv",
|
||||
want: "日番",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
input := mediaClassifyInput{
|
||||
MediaType: tc.mediaType,
|
||||
Title: tc.title,
|
||||
Languages: []string{"JA"},
|
||||
Countries: []string{"JP"},
|
||||
Genres: []string{"Animation"},
|
||||
}
|
||||
got := classifyMediaCategory(input, categories)
|
||||
if got != tc.want {
|
||||
t.Fatalf("classifyMediaCategory(%q, %q) = %q, want %q", tc.title, tc.mediaType, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -20,10 +21,34 @@ func (s *ScraperService) prepareScrapedArtworkURL(ctx context.Context, mediaID,
|
||||
if s == nil || s.images == nil || !isHTTPish(candidate) {
|
||||
return candidate, ""
|
||||
}
|
||||
fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
|
||||
originalSource := metaTubeOriginalArtworkURL(candidate)
|
||||
timeout := 15 * time.Second
|
||||
if originalSource != "" {
|
||||
// MetaTube face processing can become very slow under a batch scrape.
|
||||
// Fail over quickly so the same source can be processed locally.
|
||||
timeout = 8 * time.Second
|
||||
}
|
||||
fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
|
||||
err := s.images.PrefetchRemote(fetchCtx, candidate)
|
||||
cancel()
|
||||
if err != nil {
|
||||
if originalSource != "" {
|
||||
fallbackCtx, fallbackCancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
|
||||
fallbackErr := s.images.PrefetchRemote(fallbackCtx, originalSource)
|
||||
fallbackCancel()
|
||||
if fallbackErr == nil {
|
||||
s.log.Warn("MetaTube artwork processing failed; using local face-aware crop",
|
||||
zap.String("media_id", mediaID),
|
||||
zap.String("field", field),
|
||||
zap.String("candidate", candidate),
|
||||
zap.String("source", originalSource),
|
||||
zap.Error(err))
|
||||
if current != "" && isHTTPish(current) {
|
||||
return originalSource, current
|
||||
}
|
||||
return originalSource, ""
|
||||
}
|
||||
}
|
||||
if current != "" {
|
||||
s.log.Warn("scrape artwork prefetch failed; keeping existing artwork",
|
||||
zap.String("media_id", mediaID),
|
||||
@@ -46,6 +71,18 @@ func (s *ScraperService) prepareScrapedArtworkURL(ctx context.Context, mediaID,
|
||||
return candidate, ""
|
||||
}
|
||||
|
||||
func metaTubeOriginalArtworkURL(raw string) string {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || !strings.Contains(parsed.Path, "/v1/images/primary/") {
|
||||
return ""
|
||||
}
|
||||
source := strings.TrimSpace(parsed.Query().Get("url"))
|
||||
if !isHTTPish(source) {
|
||||
return ""
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
func (s *ScraperService) removeCachedScrapedArtwork(urls ...string) {
|
||||
if s == nil || s.images == nil {
|
||||
return
|
||||
|
||||
@@ -3,8 +3,11 @@ package service
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"image"
|
||||
"image/color"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -67,10 +70,14 @@ func TestApplyProviderMatchInvalidatesMediaCache(t *testing.T) {
|
||||
if err := repos.DB.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
match := &Match{Title: "Matched", PosterURL: "https://image.tmdb.org/t/p/w500/poster.jpg", BackdropURL: "https://image.tmdb.org/t/p/w1280/backdrop.jpg"}
|
||||
if err := scraper.applyProviderMatch(t.Context(), &media, &lib, match); err != nil {
|
||||
match := &Match{Provider: "metatube", Title: "Matched", PosterURL: "https://image.tmdb.org/t/p/w500/poster.jpg", BackdropURL: "https://image.tmdb.org/t/p/w1280/backdrop.jpg"}
|
||||
usedProvider := ""
|
||||
if err := scraper.applyProviderMatchWithOptions(t.Context(), &media, &lib, match, ScrapeOptions{resultProvider: &usedProvider}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if usedProvider != "metatube" {
|
||||
t.Fatalf("recorded provider = %q, want metatube", usedProvider)
|
||||
}
|
||||
|
||||
var stale map[string]string
|
||||
if cache.GetJSON(t.Context(), "media:list:stale", &stale) {
|
||||
@@ -145,6 +152,92 @@ func TestApplyProviderMatchKeepsExistingArtworkWhenNewPrefetchFails(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyProviderMatchFallsBackToLocalFaceCropWhenMetaTubeImageFails(t *testing.T) {
|
||||
scraper, repos, closeServer := newTestScraper(t)
|
||||
defer closeServer()
|
||||
|
||||
wideCover := createTestImage(
|
||||
900,
|
||||
600,
|
||||
color.RGBA{R: 255, A: 255},
|
||||
color.RGBA{B: 255, A: 255},
|
||||
)
|
||||
sourceURL := "https://images.example.com/wide-cover.jpg"
|
||||
images := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
|
||||
images.client = &http.Client{Transport: imageRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Host == "metatube.example.com" {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusGatewayTimeout,
|
||||
Status: "504 Gateway Timeout",
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("timeout")),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: http.Header{"Content-Type": []string{"image/jpeg"}},
|
||||
Body: io.NopCloser(bytes.NewReader(wideCover)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
scraper.SetImageProxy(images)
|
||||
|
||||
libPath := t.TempDir()
|
||||
lib := model.Library{Name: "Adult", Path: libPath, Type: "adult", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
media := model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "Raw",
|
||||
Path: filepath.Join(libPath, "IPX-235.mp4"),
|
||||
PosterURL: filepath.Join(libPath, "old-poster.jpg"),
|
||||
NSFW: true,
|
||||
ScrapeStatus: "matched",
|
||||
}
|
||||
if err := repos.DB.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
query := url.Values{
|
||||
"auto": {"true"},
|
||||
"pos": {"1"},
|
||||
"quality": {"90"},
|
||||
"ratio": {"-1"},
|
||||
"url": {sourceURL},
|
||||
}
|
||||
match := &Match{
|
||||
Title: "IPX-235 Matched",
|
||||
MediaType: "adult",
|
||||
NSFW: true,
|
||||
PosterURL: "https://metatube.example.com/v1/images/primary/AVE/123?" + query.Encode(),
|
||||
}
|
||||
if err := scraper.applyProviderMatch(t.Context(), &media, &lib, match); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var stored model.Media
|
||||
if err := repos.DB.First(&stored, "id = ?", media.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filepath.Base(stored.PosterURL) != "IPX-235-poster.jpg" {
|
||||
t.Fatalf("poster = %q, want local face-cropped sidecar", stored.PosterURL)
|
||||
}
|
||||
data, err := os.ReadFile(stored.PosterURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cropped, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ratio := float64(cropped.Bounds().Dx()) / float64(cropped.Bounds().Dy())
|
||||
if ratio < 0.65 || ratio > 0.68 {
|
||||
t.Fatalf("local fallback poster ratio = %.3f", ratio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyProviderMatchReplacesArtworkAndRemovesOldCache(t *testing.T) {
|
||||
scraper, repos, closeServer := newTestScraper(t)
|
||||
defer closeServer()
|
||||
|
||||
@@ -8,6 +8,27 @@ import (
|
||||
)
|
||||
|
||||
func (s *ScraperService) lookupAutomaticTMDb(ctx context.Context, kind, query string, year int) *Match {
|
||||
if match := s.lookupAutomaticTMDbPrimary(ctx, kind, query, year); match != nil {
|
||||
return match
|
||||
}
|
||||
fallbackKind := ""
|
||||
normalized := normalizeOrganizeMediaType(kind)
|
||||
if normalized == "anime" {
|
||||
if isTVMetadataKind(kind) {
|
||||
fallbackKind = "movie"
|
||||
} else {
|
||||
fallbackKind = "tv"
|
||||
}
|
||||
}
|
||||
if fallbackKind != "" {
|
||||
if fbMatch := s.lookupAutomaticTMDbPrimary(ctx, fallbackKind, query, year); fbMatch != nil {
|
||||
return fbMatch
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ScraperService) lookupAutomaticTMDbPrimary(ctx context.Context, kind, query string, year int) *Match {
|
||||
var (
|
||||
candidates []*Match
|
||||
err error
|
||||
@@ -175,8 +196,10 @@ func metadataMatchCompatibleWithType(expectedType string, match *Match) bool {
|
||||
return true
|
||||
}
|
||||
switch expectedType {
|
||||
case "tv", "anime", "variety":
|
||||
case "tv", "variety":
|
||||
return matchType == "tv" || matchType == "anime" || matchType == "variety"
|
||||
case "anime":
|
||||
return matchType == "anime" || matchType == "tv" || matchType == "movie" || matchType == "variety"
|
||||
case "movie", "adult":
|
||||
return matchType == "movie" || matchType == "adult"
|
||||
default:
|
||||
@@ -184,6 +207,16 @@ func metadataMatchCompatibleWithType(expectedType string, match *Match) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func metadataMatchCompatibleWithTheatrical(expectedType string, isTheatrical bool, match *Match) bool {
|
||||
if isTheatrical &&
|
||||
normalizeOrganizeMediaType(expectedType) == "movie" &&
|
||||
match != nil &&
|
||||
normalizeOrganizeMediaType(match.MediaType) == "anime" {
|
||||
return true
|
||||
}
|
||||
return metadataMatchCompatibleWithType(expectedType, match)
|
||||
}
|
||||
|
||||
func queryNeedsEnglishTMDbFallback(query string) bool {
|
||||
for _, r := range query {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
|
||||
|
||||
@@ -13,7 +13,12 @@ import (
|
||||
// lookup runs the provider chain after local NFO has been considered:
|
||||
// TMDb -> Douban -> Bangumi -> TheTVDB. Douban and Bangumi do not require API
|
||||
// keys; providers that are unavailable or return an error are skipped.
|
||||
func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media *model.Media, query string, year int) *Match {
|
||||
//
|
||||
// Results are cached per (kind, theatrical, query, year) because every
|
||||
// episode of a show produces the same candidate; bypassCache forces a fresh
|
||||
// provider round-trip for user-triggered rematches but still refreshes the
|
||||
// cache so later episodes reuse the corrected result.
|
||||
func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media *model.Media, query string, year int, bypassCache bool) *Match {
|
||||
kind := ""
|
||||
if lib != nil {
|
||||
kind = lib.Type
|
||||
@@ -22,23 +27,50 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media *
|
||||
// was previously polluted into S01E20x. Do not let the dirty path override
|
||||
// that caller-supplied media type; TV/anime libraries still force TV below.
|
||||
explicitEpisode := media != nil && (media.SeasonNum > 0 || media.EpisodeNum > 0)
|
||||
if (normalizeOrganizeMediaType(kind) != "movie" || explicitEpisode) && mediaIsEpisodic(media, lib) {
|
||||
isTheatrical := mediaLooksLikeTheatricalFeature(media)
|
||||
if isTheatrical {
|
||||
kind = "movie"
|
||||
} else if (normalizeOrganizeMediaType(kind) != "movie" || explicitEpisode) && mediaIsEpisodic(media, lib) {
|
||||
kind = "tv"
|
||||
}
|
||||
|
||||
cacheKey := scrapeLookupCacheKey(kind, query, year, isTheatrical)
|
||||
if !bypassCache {
|
||||
if cached, ok := s.lookupCache.get(cacheKey); ok {
|
||||
return cached
|
||||
}
|
||||
}
|
||||
match := s.lookupUncached(ctx, kind, isTheatrical, query, year)
|
||||
// Always refresh the cache, even on bypassed (forced) lookups, so a
|
||||
// corrected rematch replaces the stale entry for later episodes.
|
||||
s.lookupCache.set(cacheKey, match)
|
||||
return match
|
||||
}
|
||||
|
||||
func (s *ScraperService) lookupUncached(ctx context.Context, kind string, isTheatrical bool, query string, year int) *Match {
|
||||
if s.tmdb != nil && s.tmdb.Enabled() {
|
||||
if match := s.lookupAutomaticTMDb(ctx, kind, query, year); match != nil {
|
||||
match.Provider = "tmdb"
|
||||
return match
|
||||
}
|
||||
if isTheatrical {
|
||||
if match := s.lookupAutomaticTMDb(ctx, "tv", query, year); match != nil {
|
||||
match.Provider = "tmdb"
|
||||
return match
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.douban != nil && s.douban.Enabled() {
|
||||
if m, err := s.douban.SearchMatch(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithType(kind, m) {
|
||||
if m, err := s.douban.SearchMatch(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithTheatrical(kind, isTheatrical, m) {
|
||||
m.Provider = "douban"
|
||||
return m
|
||||
} else if err != nil {
|
||||
s.log.Debug("douban search failed", zap.String("query", query), zap.Error(err))
|
||||
}
|
||||
}
|
||||
if s.bangumi != nil && s.bangumi.Enabled() {
|
||||
if m, err := s.bangumi.Search(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithType(kind, m) {
|
||||
if m, err := s.bangumi.Search(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithTheatrical(kind, isTheatrical, m) {
|
||||
m.Provider = "bangumi"
|
||||
return m
|
||||
} else if err != nil {
|
||||
s.log.Debug("bangumi search failed", zap.String("query", query), zap.Error(err))
|
||||
@@ -46,6 +78,7 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media *
|
||||
}
|
||||
if (kind == "anime" || kind == "tv" || kind == "variety" || kind == "show" || kind == "shows") && s.thetvdb != nil && s.thetvdb.Enabled() {
|
||||
if m, err := s.thetvdb.SearchSeries(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithType(kind, m) {
|
||||
m.Provider = "thetvdb"
|
||||
return m
|
||||
} else if err != nil {
|
||||
s.log.Debug("thetvdb search failed", zap.String("query", query), zap.Error(err))
|
||||
@@ -94,6 +127,13 @@ func (s *ScraperService) EnrichLibraryDetailed(ctx context.Context, libraryID st
|
||||
|
||||
func (s *ScraperService) EnrichLibraryDetailedWithOptions(ctx context.Context, libraryID string, options ScrapeOptions) (EnrichLibraryResult, error) {
|
||||
result := EnrichLibraryResult{LibraryID: libraryID}
|
||||
// A manual retry must not reuse stale negative cache entries from the
|
||||
// previous run, otherwise "重新刮削" looks like it did nothing. Positives
|
||||
// are kept so the rest of the season still dedupes; the first re-queried
|
||||
// episode refreshes the entry for the rows behind it.
|
||||
if options.RetryNoMatch && s != nil {
|
||||
s.lookupCache.clearNegatives()
|
||||
}
|
||||
rows, err := s.scrapeCandidateRows(ctx, libraryID, options)
|
||||
if err != nil {
|
||||
return result, err
|
||||
|
||||
@@ -23,6 +23,7 @@ func (s *ScraperService) matchFromMediaExternalIDs(ctx context.Context, m *model
|
||||
}
|
||||
if match := s.manualTMDbMatchByID(ctx, m.TMDbID, normalizeMediaType(mediaType, m.Title, "")); match != nil {
|
||||
if s.mediaExternalIDMatchTrusted(m, lib, match, "tmdb") {
|
||||
match.Provider = "tmdb"
|
||||
preferExistingLocalizedEpisodeTitle(m, lib, match)
|
||||
return match
|
||||
}
|
||||
@@ -31,6 +32,7 @@ func (s *ScraperService) matchFromMediaExternalIDs(ctx context.Context, m *model
|
||||
if strings.TrimSpace(m.DoubanID) != "" && s.douban != nil && s.douban.Enabled() {
|
||||
if match, err := s.douban.GetMatchByID(ctx, strings.TrimSpace(m.DoubanID)); err == nil && match != nil {
|
||||
if s.mediaExternalIDMatchTrusted(m, lib, match, "douban") {
|
||||
match.Provider = "douban"
|
||||
preferExistingLocalizedEpisodeTitle(m, lib, match)
|
||||
return match
|
||||
}
|
||||
@@ -41,6 +43,7 @@ func (s *ScraperService) matchFromMediaExternalIDs(ctx context.Context, m *model
|
||||
if m.BangumiID > 0 && s.bangumi != nil && s.bangumi.Enabled() {
|
||||
if match, err := s.bangumi.GetSubject(ctx, m.BangumiID); err == nil && match != nil {
|
||||
if s.mediaExternalIDMatchTrusted(m, lib, match, "bangumi") {
|
||||
match.Provider = "bangumi"
|
||||
preferExistingLocalizedEpisodeTitle(m, lib, match)
|
||||
return match
|
||||
}
|
||||
@@ -51,6 +54,7 @@ func (s *ScraperService) matchFromMediaExternalIDs(ctx context.Context, m *model
|
||||
if strings.TrimSpace(m.TheTVDBID) != "" && s.thetvdb != nil && s.thetvdb.Enabled() {
|
||||
if match, err := s.thetvdb.GetSeriesMatchByID(ctx, strings.TrimSpace(m.TheTVDBID)); err == nil && match != nil {
|
||||
if s.mediaExternalIDMatchTrusted(m, lib, match, "thetvdb") {
|
||||
match.Provider = "thetvdb"
|
||||
preferExistingLocalizedEpisodeTitle(m, lib, match)
|
||||
return match
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A TV library scrapes one media row per episode, and every row in the same
|
||||
// show computes the same query candidate (the series folder title). Without a
|
||||
// cache each episode re-issues the identical TMDb/Douban/Bangumi/TheTVDB
|
||||
// search, so a 100-episode season costs 100x the network calls it needs.
|
||||
//
|
||||
// The cache lives on the ScraperService instance (not a package global) so that
|
||||
// two services with different provider configuration never share results. TTL
|
||||
// bounds staleness for out-of-band edits; explicit invalidation is unnecessary
|
||||
// because the key includes the effective media kind, theatrical flag, query
|
||||
// and year. Negative entries use a shorter TTL so a failed query is retried
|
||||
// sooner without manual intervention.
|
||||
const (
|
||||
scrapeLookupCacheTTL = 10 * time.Minute
|
||||
scrapeLookupNegativeCacheTTL = 2 * time.Minute
|
||||
scrapeLookupCacheMaxItems = 1024
|
||||
)
|
||||
|
||||
type scrapeLookupCache struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]scrapeLookupCacheEntry
|
||||
}
|
||||
|
||||
type scrapeLookupCacheEntry struct {
|
||||
match *Match
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
func newScrapeLookupCache() *scrapeLookupCache {
|
||||
return &scrapeLookupCache{entries: map[string]scrapeLookupCacheEntry{}}
|
||||
}
|
||||
|
||||
func scrapeLookupCacheKey(kind, query string, year int, isTheatrical bool) string {
|
||||
theatrical := "0"
|
||||
if isTheatrical {
|
||||
theatrical = "1"
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(kind)) + "|" +
|
||||
theatrical + "|" +
|
||||
strconv.Itoa(year) + "|" +
|
||||
strings.ToLower(strings.TrimSpace(query))
|
||||
}
|
||||
|
||||
// get reports whether the key is cached. A hit may carry a nil match, which
|
||||
// means the provider chain already ran and found nothing (negative cache).
|
||||
// Use clearNegative before a user-triggered retry so stale negative entries
|
||||
// do not suppress the fresh provider round-trip.
|
||||
func (c *scrapeLookupCache) get(key string) (*Match, bool) {
|
||||
if c == nil || key == "" {
|
||||
return nil, false
|
||||
}
|
||||
now := time.Now()
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
item, ok := c.entries[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if now.After(item.expiresAt) {
|
||||
delete(c.entries, key)
|
||||
return nil, false
|
||||
}
|
||||
return cloneMatch(item.match), true
|
||||
}
|
||||
|
||||
func (c *scrapeLookupCache) set(key string, match *Match) {
|
||||
if c == nil || key == "" {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if len(c.entries) >= scrapeLookupCacheMaxItems {
|
||||
for k, item := range c.entries {
|
||||
if now.After(item.expiresAt) || len(c.entries) >= scrapeLookupCacheMaxItems {
|
||||
delete(c.entries, k)
|
||||
}
|
||||
if len(c.entries) < scrapeLookupCacheMaxItems {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
ttl := scrapeLookupCacheTTL
|
||||
if match == nil {
|
||||
ttl = scrapeLookupNegativeCacheTTL
|
||||
}
|
||||
c.entries[key] = scrapeLookupCacheEntry{match: cloneMatch(match), expiresAt: now.Add(ttl)}
|
||||
}
|
||||
|
||||
// clearNegatives drops cached misses so a user-triggered retry gets a fresh
|
||||
// provider round-trip instead of reusing a stale negative entry.
|
||||
func (c *scrapeLookupCache) clearNegatives() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for k, item := range c.entries {
|
||||
if item.match == nil {
|
||||
delete(c.entries, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cloneMatch returns an independent copy so callers that mutate the result
|
||||
// (localized title preference, local metadata merge, fanart artwork) cannot
|
||||
// corrupt the cached entry or leak state between media rows.
|
||||
func cloneMatch(m *Match) *Match {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
out := *m
|
||||
out.Languages = append([]string(nil), m.Languages...)
|
||||
out.Countries = append([]string(nil), m.Countries...)
|
||||
out.Genres = append([]string(nil), m.Genres...)
|
||||
out.Aliases = append([]string(nil), m.Aliases...)
|
||||
return &out
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// Every episode of a show produces the same query candidate (the series folder
|
||||
// title). Without the lookup cache each episode re-issues the identical search,
|
||||
// so a whole season costs one request per episode. This asserts the provider is
|
||||
// hit once and subsequent episodes reuse the cached match.
|
||||
func TestEnrichLibraryReusesLookupCacheAcrossEpisodes(t *testing.T) {
|
||||
var searchCalls int32
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/search/tv":
|
||||
if r.URL.Query().Get("query") != "折腰" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}})
|
||||
return
|
||||
}
|
||||
atomic.AddInt32(&searchCalls, 1)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"results": []map[string]any{{
|
||||
"id": 296753,
|
||||
"name": "折腰",
|
||||
"overview": "正确的剧集条目",
|
||||
"poster_path": "/zheyao.jpg",
|
||||
"first_air_date": "2025-05-13",
|
||||
"origin_country": []string{"CN"},
|
||||
}},
|
||||
})
|
||||
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
|
||||
cfg.Secrets.TMDbImageProxy = upstream.URL + "/images"
|
||||
log := zap.NewNop()
|
||||
scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log))
|
||||
|
||||
lib := model.Library{Name: "OpenList · 刮削缓存测试库", Path: "cloud://openlist/scrape-lookup-cache", Type: "tv", Enabled: true}
|
||||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const episodeCount = 4
|
||||
for episode := 1; episode <= episodeCount; episode++ {
|
||||
media := model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "折腰",
|
||||
Path: "cloud://openlist/scrape-lookup-cache/折腰 (2025)/Season 1/折腰.S01E0" + string(rune('0'+episode)) + ".mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: episode,
|
||||
ScrapeStatus: "pending",
|
||||
}
|
||||
if err := repos.DB.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
result, err := scraper.EnrichLibraryDetailedWithOptions(t.Context(), lib.ID, ScrapeOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Matched != episodeCount {
|
||||
t.Fatalf("matched = %d, want %d", result.Matched, episodeCount)
|
||||
}
|
||||
if calls := atomic.LoadInt32(&searchCalls); calls != 1 {
|
||||
t.Fatalf("tmdb /search/tv called %d times for %d episodes; want 1 (cache should dedupe identical queries)", calls, episodeCount)
|
||||
}
|
||||
}
|
||||
|
||||
// A cached match must be handed out as an independent copy: mutating one row's
|
||||
// result (localized title preference, local metadata merge) must not bleed into
|
||||
// the next row.
|
||||
func TestScrapeLookupCacheReturnsIndependentCopies(t *testing.T) {
|
||||
cache := newScrapeLookupCache()
|
||||
original := &Match{
|
||||
Title: "折腰",
|
||||
TMDbID: 296753,
|
||||
Genres: []string{"剧情"},
|
||||
Countries: []string{"CN"},
|
||||
Aliases: []string{"Zhe Yao"},
|
||||
}
|
||||
key := scrapeLookupCacheKey("tv", "折腰", 2025, false)
|
||||
cache.set(key, original)
|
||||
|
||||
first, ok := cache.get(key)
|
||||
if !ok || first == nil {
|
||||
t.Fatal("expected a cache hit")
|
||||
}
|
||||
first.Title = "mutated"
|
||||
first.Genres[0] = "mutated"
|
||||
first.Aliases = append(first.Aliases, "extra")
|
||||
|
||||
second, ok := cache.get(key)
|
||||
if !ok || second == nil {
|
||||
t.Fatal("expected a second cache hit")
|
||||
}
|
||||
if second.Title != "折腰" || second.Genres[0] != "剧情" || len(second.Aliases) != 1 {
|
||||
t.Fatalf("cached match was mutated through a returned copy: %+v", second)
|
||||
}
|
||||
}
|
||||
|
||||
// Negative results are cached too: a query that matched nothing must not be
|
||||
// re-issued for every remaining episode of the same show.
|
||||
func TestScrapeLookupCacheStoresNegativeResults(t *testing.T) {
|
||||
cache := newScrapeLookupCache()
|
||||
key := scrapeLookupCacheKey("tv", "no-such-show", 0, false)
|
||||
cache.set(key, nil)
|
||||
if _, ok := cache.get(key); !ok {
|
||||
t.Fatal("negative result should be cached to avoid repeated provider calls")
|
||||
}
|
||||
}
|
||||
|
||||
// The theatrical flag is part of the key: a theatrical feature gets a tv
|
||||
// fallback lookup, so its result must not be shared with (or returned for)
|
||||
// the same query issued for a non-theatrical media row.
|
||||
func TestScrapeLookupCacheKeySeparatesTheatrical(t *testing.T) {
|
||||
plain := scrapeLookupCacheKey("movie", "query", 2024, false)
|
||||
theatrical := scrapeLookupCacheKey("movie", "query", 2024, true)
|
||||
if plain == theatrical {
|
||||
t.Fatalf("theatrical flag must be part of the cache key: %q", plain)
|
||||
}
|
||||
}
|
||||
|
||||
// A manual "retry no match" run clears stale negative entries so the provider
|
||||
// chain is actually re-queried instead of short-circuiting on the cached miss.
|
||||
func TestScrapeLookupCacheClearNegativesKeepsPositives(t *testing.T) {
|
||||
cache := newScrapeLookupCache()
|
||||
negKey := scrapeLookupCacheKey("tv", "missing", 0, false)
|
||||
posKey := scrapeLookupCacheKey("tv", "found", 0, false)
|
||||
cache.set(negKey, nil)
|
||||
cache.set(posKey, &Match{Title: "found"})
|
||||
cache.clearNegatives()
|
||||
if _, ok := cache.get(negKey); ok {
|
||||
t.Fatal("negative entry should be cleared before a manual retry")
|
||||
}
|
||||
if got, ok := cache.get(posKey); !ok || got == nil || got.Title != "found" {
|
||||
t.Fatalf("positive entry should survive clearNegatives: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ type ScrapeOptions struct {
|
||||
DeferEpisodeDetails bool
|
||||
ForceRematch bool
|
||||
RebuildIdentity bool
|
||||
resultProvider *string
|
||||
}
|
||||
|
||||
func (o ScrapeOptions) episodeArtworkEnabled() bool {
|
||||
@@ -18,3 +19,9 @@ func skipEpisodeArtworkOptions(retryNoMatch bool) ScrapeOptions {
|
||||
episodeArtwork := false
|
||||
return ScrapeOptions{RetryNoMatch: retryNoMatch, EpisodeArtwork: &episodeArtwork}
|
||||
}
|
||||
|
||||
func (o ScrapeOptions) recordProvider(provider string) {
|
||||
if o.resultProvider != nil {
|
||||
*o.resultProvider = provider
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,42 @@ var (
|
||||
episodeTitleQueryRE = regexp.MustCompile(`^\s*第\s*[0-9一二三四五六七八九十百零两]+\s*[集期话話](?:\s*[上下])?\s*[::].+`)
|
||||
genericEpisodeWordsRE = regexp.MustCompile(`^\s*第\s*[集期话話]\s*$`)
|
||||
episodeReleaseTitleTagRE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e\d{1,3}(?:[\s._-]|$)`)
|
||||
patTheatricalTitle = regexp.MustCompile(`(?i)(?:剧场版|劇場版|动画电影|動畫電影|电影版|電影版|\bthe\s+movie\b|\bmovie\s*\d{1,2}\b)`)
|
||||
patTheatricalFolder = regexp.MustCompile(`(?i)[\\/][^\\/]*(?:剧场版|劇場版|動畫電影|动画电影|电影版|電影版)[^\\/]*[\\/]`)
|
||||
theatricalNoiseRE = regexp.MustCompile(`(?i)(?:剧场版|劇場版)\s*(?:第?\s*\d{1,3}\s*[部篇]?)?|电影版|電影版|动画电影|動畫電影`)
|
||||
)
|
||||
|
||||
func theatricalTitleVariants(raw string) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
if !theatricalNoiseRE.MatchString(raw) {
|
||||
return []string{raw}
|
||||
}
|
||||
stripped := theatricalNoiseRE.ReplaceAllString(raw, " ")
|
||||
stripped = strings.Join(strings.Fields(stripped), " ")
|
||||
if stripped != "" && !strings.EqualFold(stripped, raw) {
|
||||
return []string{stripped, raw}
|
||||
}
|
||||
return []string{raw}
|
||||
}
|
||||
|
||||
func mediaLooksLikeTheatricalFeature(m *model.Media) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
// Trust an episode marker that is actually present in the path, but do not
|
||||
// trust persisted season/episode fields here. Older scans could incorrectly
|
||||
// assign those fields to a theatrical file, which would permanently prevent
|
||||
// both manual re-scraping and separation from the TV series.
|
||||
if season, ep := ParseEpisode(m.Path); season > 0 || ep > 0 {
|
||||
return false
|
||||
}
|
||||
text := m.Title + " " + pathBaseSlash(m.Path)
|
||||
return patTheatricalTitle.MatchString(text) || pathHasTheatricalFolder(m.Path)
|
||||
}
|
||||
|
||||
func scrapeQueryCandidates(m *model.Media, lib *model.Library) []string {
|
||||
return scrapeQueryCandidatesWithNormalizer(m, lib, func(raw string) (string, int) {
|
||||
return CleanQuery(raw)
|
||||
@@ -33,31 +67,59 @@ func scrapeQueryCandidatesWithNormalizer(m *model.Media, lib *model.Library, cle
|
||||
seen := map[string]struct{}{}
|
||||
var out []string
|
||||
add := func(raw string) {
|
||||
cleaned, _ := clean(raw)
|
||||
if cleaned == "" {
|
||||
cleaned = strings.TrimSpace(raw)
|
||||
}
|
||||
for _, candidate := range titleCandidates(cleaned) {
|
||||
if unsafeAutomaticEpisodeQuery(candidate) {
|
||||
continue
|
||||
for _, variant := range theatricalTitleVariants(raw) {
|
||||
cleaned, _ := clean(variant)
|
||||
if cleaned == "" {
|
||||
cleaned = strings.TrimSpace(variant)
|
||||
}
|
||||
key := strings.ToLower(candidate)
|
||||
if _, ok := seen[key]; ok || candidate == "" {
|
||||
continue
|
||||
for _, candidate := range titleCandidates(cleaned) {
|
||||
if unsafeAutomaticEpisodeQuery(candidate) {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(candidate)
|
||||
if _, ok := seen[key]; ok || candidate == "" {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, candidate)
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, candidate)
|
||||
}
|
||||
}
|
||||
episodic := mediaIsEpisodic(m, lib)
|
||||
if lib != nil && episodic {
|
||||
add(seriesFolderTitle(m.Path, lib.Path))
|
||||
|
||||
isTheatrical := mediaLooksLikeTheatricalFeature(m)
|
||||
if isTheatrical {
|
||||
add(m.Title)
|
||||
add(m.Path)
|
||||
if lib != nil {
|
||||
seriesTitle := seriesFolderTitle(m.Path, lib.Path)
|
||||
if seriesTitle != "" {
|
||||
baseName := pathBaseSlash(m.Path)
|
||||
stem := mediaFileStem(baseName)
|
||||
if stem == "" {
|
||||
stem = strings.TrimSuffix(baseName, filepath.Ext(baseName))
|
||||
}
|
||||
cleanStem, _ := clean(stem)
|
||||
if cleanStem == "" {
|
||||
cleanStem = stem
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(cleanStem), strings.ToLower(seriesTitle)) {
|
||||
add(seriesTitle + " " + cleanStem)
|
||||
}
|
||||
}
|
||||
add(mediaFolderTitle(m.Path, lib.Path))
|
||||
}
|
||||
} else {
|
||||
episodic := mediaIsEpisodic(m, lib)
|
||||
if lib != nil && episodic {
|
||||
add(seriesFolderTitle(m.Path, lib.Path))
|
||||
}
|
||||
if lib != nil {
|
||||
add(mediaFolderTitle(m.Path, lib.Path))
|
||||
}
|
||||
add(m.Title)
|
||||
add(m.Path)
|
||||
}
|
||||
if lib != nil {
|
||||
add(mediaFolderTitle(m.Path, lib.Path))
|
||||
}
|
||||
add(m.Title)
|
||||
add(m.Path)
|
||||
|
||||
if len(out) == 0 {
|
||||
base := pathBaseSlash(m.Path)
|
||||
out = append(out, strings.TrimSuffix(base, filepath.Ext(base)))
|
||||
@@ -106,6 +168,9 @@ func containsCJK(s string) bool {
|
||||
}
|
||||
|
||||
func mediaIsEpisodic(m *model.Media, lib *model.Library) bool {
|
||||
if m != nil && mediaLooksLikeTheatricalFeature(m) {
|
||||
return false
|
||||
}
|
||||
if m != nil && (m.SeasonNum > 0 || m.EpisodeNum > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -24,11 +24,15 @@ var noiseTokens = []string{
|
||||
"hkfree", "yify", "rarbg", "ettv", "fgt", "tgx", "ctrlhd", "ntb", "flux", "qhstudio",
|
||||
|
||||
// 流媒体平台 / 字幕组 / 国家版本(动漫常见)
|
||||
"netflix", "nf", "amzn", "hulu", "disney", "max", "hbo",
|
||||
// 注意:不要把同时是常见英文单词的标记放进来(如 max / web / judas),
|
||||
// 否则 "Mad Max"、"Web Therapy" 这类正常标题会被误删。裸 "WEB" 发布标记
|
||||
// 通常位于分辨率之后,由 releaseBoundary 截断规则处理;"WEB-DL" 则由
|
||||
// multiWordNoise 单独匹配。
|
||||
"netflix", "nf", "amzn", "hulu", "disney", "hbo",
|
||||
"linetv", "ourtv", "iqiyi", "youku", "bilibili", "qiyi", "krj",
|
||||
"atvp", "appletv", "apple-tv", "tx", "txweb",
|
||||
"crunchyroll", "funimation", "anidb", "horriblesubs", "subsplease",
|
||||
"erai-raws", "judas", "asw", "smcat", "leopard-raws", "ohys-raws", "colortv",
|
||||
"erai-raws", "asw", "smcat", "leopard-raws", "ohys-raws", "colortv",
|
||||
"mweb", "ubweb", "hhweb", "adweb", "chdweb", "kurosawa", "qhstudio",
|
||||
|
||||
// 中文字幕标记
|
||||
@@ -55,6 +59,20 @@ var releaseBoundaryTokenSet = map[string]struct{}{
|
||||
"x264": {}, "x265": {}, "h264": {}, "h265": {}, "h266": {}, "hevc": {}, "avc": {}, "av1": {}, "vvc": {},
|
||||
}
|
||||
|
||||
// weakReleaseBoundaryTokenSet are release tags that double as plausible title
|
||||
// words. Before any release signal has been seen they are kept as part of the
|
||||
// title ("Mad Max", "Web Therapy"), so a title is never truncated to nothing;
|
||||
// after a real signal they behave like any other tag.
|
||||
var weakReleaseBoundaryTokenSet = map[string]struct{}{
|
||||
"bd": {}, "dvd": {}, "web": {},
|
||||
}
|
||||
|
||||
// releaseSignalToken marks the position of an extracted year. The year itself
|
||||
// is not a title token, but its presence still proves the following tokens are
|
||||
// release tags ("复仇者联盟4.2019.BD.1080p" must drop "BD"). A control rune is
|
||||
// used so it can never collide with a real filename token.
|
||||
const releaseSignalToken = "\u0001"
|
||||
|
||||
var dynamicReleaseBoundaryTokenRE = regexp.MustCompile(`(?i)^(?:\d{3,4}p|\d{2,3}fps)$`)
|
||||
|
||||
// bracketedTag matches "[anything]", "(anything)" or "{anything}" segments.
|
||||
@@ -86,7 +104,9 @@ func CleanQuery(raw string) (title string, year int) {
|
||||
if m := yearPattern.FindStringSubmatch(lower); len(m) >= 2 {
|
||||
if v, err := strconv.Atoi(m[1]); err == nil {
|
||||
year = v
|
||||
lower = strings.ReplaceAll(lower, m[1], " ")
|
||||
// Keep a positional marker: the year is not a title token, but its
|
||||
// presence arms release-tag truncation for what follows.
|
||||
lower = strings.ReplaceAll(lower, m[1], " "+releaseSignalToken+" ")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,17 +130,35 @@ func CleanQuery(raw string) (title string, year int) {
|
||||
}
|
||||
// 拆分后丢掉过短(≤1)且全为 ASCII 数字 / 字母的"碎片",避免
|
||||
// 「2」「0」「v」之类残留干扰 TMDb 搜索。中文字符不算碎片。
|
||||
//
|
||||
// seenReleaseBoundary 只有在遇到分辨率/编码等强标记后才置位,置位后其余
|
||||
// ASCII 词一律视为发布尾巴丢弃;releaseSignalled 则宽松得多,年份标记也会
|
||||
// 置位,它只用来武装弱标记(bd/dvd/web)。这样 "Web Therapy" 不会被截空,
|
||||
// 而 "2019.Avatar.1080p" 这种年份前置的标题也不会因为年份标记把后面的
|
||||
// 真实标题词当成尾巴丢掉。
|
||||
out := make([]string, 0, 8)
|
||||
seenReleaseBoundary := false
|
||||
releaseSignalled := false
|
||||
for _, w := range strings.Fields(lower) {
|
||||
if w == releaseSignalToken {
|
||||
releaseSignalled = true
|
||||
continue
|
||||
}
|
||||
if dynamicReleaseBoundaryTokenRE.MatchString(w) {
|
||||
releaseSignalled = true
|
||||
seenReleaseBoundary = true
|
||||
continue
|
||||
}
|
||||
if _, ok := noiseTokenSet[w]; ok {
|
||||
if _, boundary := releaseBoundaryTokenSet[w]; boundary {
|
||||
if _, boundary := releaseBoundaryTokenSet[w]; boundary {
|
||||
if _, weak := weakReleaseBoundaryTokenSet[w]; weak && !releaseSignalled {
|
||||
// Ambiguous tag that is also a plausible title word; keep it
|
||||
// until a real release signal proves we are past the title.
|
||||
} else {
|
||||
releaseSignalled = true
|
||||
seenReleaseBoundary = true
|
||||
continue
|
||||
}
|
||||
} else if _, ok := noiseTokenSet[w]; ok {
|
||||
continue
|
||||
}
|
||||
if seenReleaseBoundary && isASCIIWord(w) {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
// CleanQuery must not delete words that are also ordinary English title words
|
||||
// just because a release group reused them as a tag. Regression guard: "max"
|
||||
// and "web" used to be unconditional noise/boundary tokens, which turned
|
||||
// "Mad Max" into "mad" and "Web Therapy" into an empty query.
|
||||
func TestCleanQueryKeepsCommonEnglishTitleWords(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
wantTitle string
|
||||
wantYear int
|
||||
}{
|
||||
{"Mad.Max.1979.1080p.BluRay.mkv", "mad max", 1979},
|
||||
{"Max.Payne.2008.1080p.WEB-DL.mkv", "max payne", 2008},
|
||||
{"Web.Therapy.S01E01.1080p.WEB-DL.mkv", "web therapy", 0},
|
||||
{"The.Web.2019.1080p.mkv", "the web", 2019},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
gotTitle, gotYear := CleanQuery(tc.in)
|
||||
if gotTitle != tc.wantTitle || gotYear != tc.wantYear {
|
||||
t.Errorf("CleanQuery(%q) = (%q, %d), want (%q, %d)",
|
||||
tc.in, gotTitle, gotYear, tc.wantTitle, tc.wantYear)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A title consisting only of an ambiguous tag plus a release tail must not be
|
||||
// truncated to nothing; the tag stays so the query still has a chance.
|
||||
func TestCleanQueryDoesNotTruncateAmbiguousTagToNothing(t *testing.T) {
|
||||
for _, in := range []string{"Web.1080p.WEB-DL.mkv", "BD.720p.mkv"} {
|
||||
title, _ := CleanQuery(in)
|
||||
if title == "" {
|
||||
t.Errorf("CleanQuery(%q) returned an empty title", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A year-prefixed filename must keep its title: the year arms weak-tag
|
||||
// truncation but must not discard the real title words that follow it.
|
||||
func TestCleanQueryKeepsTitleAfterYearPrefix(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"2019.Avatar.1080p.BluRay.mkv": "avatar",
|
||||
"2024.Dune.Part.Two.2160p.WEB.mkv": "dune part two",
|
||||
}
|
||||
for in, want := range cases {
|
||||
t.Run(in, func(t *testing.T) {
|
||||
got, year := CleanQuery(in)
|
||||
if got != want {
|
||||
t.Errorf("CleanQuery(%q) = (%q, %d), want title %q", in, got, year, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Release-tag truncation must still fire once a real release signal (an
|
||||
// extracted year, resolution or codec) has been seen, so tags after it are
|
||||
// dropped as before.
|
||||
func TestCleanQueryStillDropsTagsAfterReleaseSignal(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"复仇者联盟4.2019.BD.1080p.mkv": "复仇者联盟4",
|
||||
"The.Matrix.1999.1080p.WEB-DL.H265.mp4": "the matrix",
|
||||
"Oppenheimer.2023.2160p.UHD.BluRay.mkv": "oppenheimer",
|
||||
"Interstellar.2014.4k.hdr.dts.atmos.mkv": "interstellar",
|
||||
}
|
||||
for in, want := range cases {
|
||||
t.Run(in, func(t *testing.T) {
|
||||
got, _ := CleanQuery(in)
|
||||
if got != want {
|
||||
t.Errorf("CleanQuery(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
package service
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var namedTheatricalFolderRE = regexp.MustCompile(`(?i)(?:剧场版|劇場版|动画电影|動畫電影|电影版|電影版)`)
|
||||
|
||||
func mediaFolderTitle(mediaPath, libraryRoot string) string {
|
||||
dir := parentSlashPath(mediaPath)
|
||||
@@ -13,7 +18,7 @@ func mediaFolderTitle(mediaPath, libraryRoot string) string {
|
||||
if base == "" || base == "." {
|
||||
return ""
|
||||
}
|
||||
if isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) {
|
||||
if isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) || isTheatricalFolder(base) {
|
||||
dir = parentSlashPath(dir)
|
||||
continue
|
||||
}
|
||||
@@ -104,7 +109,7 @@ func parentSlashPath(value string) string {
|
||||
|
||||
func seriesFolderTitle(mediaPath, libraryRoot string) string {
|
||||
dir := parentSlashPath(mediaPath)
|
||||
if strictSeasonFolderMatched(pathBaseSlash(dir)) {
|
||||
if strictSeasonFolderMatched(pathBaseSlash(dir)) || isTheatricalFolder(pathBaseSlash(dir)) {
|
||||
dir = parentSlashPath(dir)
|
||||
}
|
||||
if root := comparableLibraryRoot(libraryRoot); root != "" && sameSlashPath(dir, root) {
|
||||
@@ -114,12 +119,39 @@ func seriesFolderTitle(mediaPath, libraryRoot string) string {
|
||||
if base == "" || base == "." {
|
||||
return ""
|
||||
}
|
||||
if isGenericMediaCategoryFolder(base) || isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) {
|
||||
if isGenericMediaCategoryFolder(base) || isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) || isTheatricalFolder(base) {
|
||||
return ""
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func isTheatricalFolder(name string) bool {
|
||||
key := strings.ToLower(strings.TrimSpace(name))
|
||||
key = strings.Trim(key, `\/`)
|
||||
if namedTheatricalFolderRE.MatchString(key) {
|
||||
return true
|
||||
}
|
||||
switch key {
|
||||
case "剧场版", "劇場版", "动画电影", "動畫電影", "特别篇", "特別篇",
|
||||
"special", "specials", "sp", "ova", "ovas", "oad", "oads", "ovd", "ovds", "ona", "onas",
|
||||
"extra", "extras", "bonus", "bonuses", "omake", "picture drama", "ncop", "nced", "画像特典":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func pathHasTheatricalFolder(path string) bool {
|
||||
for _, part := range strings.FieldsFunc(path, func(r rune) bool {
|
||||
return r == '/' || r == '\\'
|
||||
}) {
|
||||
if isTheatricalFolder(part) && namedTheatricalFolderRE.MatchString(part) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func libraryRootTitle(libraryRoot string) string {
|
||||
base := ""
|
||||
if info, ok := ParseCloudLibraryMount(libraryRoot); ok {
|
||||
|
||||
@@ -48,6 +48,17 @@ func TestCleanQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanQueryIgnoresKeepExtSTRMSuffix(t *testing.T) {
|
||||
plain := filepath.Join("/media/anime", "Jigokuraku 2023 S01E14.strm")
|
||||
keepExt := filepath.Join("/media/anime", "Jigokuraku 2023 S01E14.mkv.strm")
|
||||
plainTitle, plainYear := CleanQuery(plain)
|
||||
keepTitle, keepYear := CleanQuery(keepExt)
|
||||
if plainTitle != keepTitle || plainYear != keepYear {
|
||||
t.Fatalf("keep_ext changed scrape identity: plain=(%q,%d) keep_ext=(%q,%d)",
|
||||
plainTitle, plainYear, keepTitle, keepYear)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrapeQueryCandidatesCleanDirtySeriesFolder(t *testing.T) {
|
||||
lib := &model.Library{
|
||||
Path: `F:\media\电视剧\欧美剧`,
|
||||
|
||||
@@ -115,10 +115,12 @@ func (s *ScraperService) processScrapeTask(ctx context.Context, task *model.Scra
|
||||
}
|
||||
|
||||
epArtwork := task.EpisodeImages
|
||||
usedProvider := ""
|
||||
options := ScrapeOptions{
|
||||
EpisodeArtwork: &epArtwork,
|
||||
IncludeMatched: task.RefreshMatched,
|
||||
RetryNoMatch: true,
|
||||
resultProvider: &usedProvider,
|
||||
}
|
||||
|
||||
enrichErr := s.EnrichOneWithOptions(ctx, media, options)
|
||||
@@ -133,16 +135,9 @@ func (s *ScraperService) processScrapeTask(ctx context.Context, task *model.Scra
|
||||
task.MatchedYear = refreshed.Year
|
||||
task.PosterURL = refreshed.PosterURL
|
||||
task.BackdropURL = refreshed.BackdropURL
|
||||
if refreshed.TMDbID > 0 {
|
||||
task.Provider = "tmdb"
|
||||
} else if strings.TrimSpace(refreshed.DoubanID) != "" {
|
||||
task.Provider = "douban"
|
||||
} else if refreshed.BangumiID > 0 {
|
||||
task.Provider = "bangumi"
|
||||
} else if strings.TrimSpace(refreshed.TheTVDBID) != "" {
|
||||
task.Provider = "thetvdb"
|
||||
} else {
|
||||
task.Provider = "metatube"
|
||||
task.Provider = strings.ToLower(strings.TrimSpace(usedProvider))
|
||||
if task.Provider == "" {
|
||||
task.Provider = "unknown"
|
||||
}
|
||||
} else {
|
||||
task.Status = model.ScrapeTaskFailed
|
||||
|
||||
@@ -26,6 +26,10 @@ type ScraperService struct {
|
||||
cache *RuntimeCacheService
|
||||
images *ImageProxy
|
||||
|
||||
// Caches provider-chain results keyed by media kind/query/year. Per-instance
|
||||
// so services with different provider config never share results.
|
||||
lookupCache *scrapeLookupCache
|
||||
|
||||
// Serializes final sidecar replacement. Windows cannot rename over an
|
||||
// existing file, and concurrent scrapes can target the same sidecar.
|
||||
artworkWriteMu sync.Mutex
|
||||
@@ -50,6 +54,7 @@ func NewScraperService(
|
||||
return &ScraperService{
|
||||
cfg: cfg, log: log, repo: repo,
|
||||
tmdb: tmdb, bangumi: bangumi, thetvdb: thetvdb, fanart: fanart, adult: adultProvider, hub: hub,
|
||||
lookupCache: newScrapeLookupCache(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,14 +46,21 @@ func (s *ScraperService) writeMediaArtworkFilesAfterScrape(ctx context.Context,
|
||||
}
|
||||
isAdult := shouldCropAdultPoster(refreshed, lib)
|
||||
artworkUpdates := map[string]any{}
|
||||
shouldPersistLocalArtworkURL := func(raw string) bool {
|
||||
return isAdult || !isHTTPish(raw)
|
||||
}
|
||||
if refreshed.PosterURL != "" {
|
||||
if dst := s.downloadArtworkToPathWithOptions(ctx, dir, base+"-poster", refreshed.PosterURL, isAdult); dst != "" {
|
||||
artworkUpdates["poster_url"] = filepath.Join(filepath.Dir(refreshed.Path), filepath.Base(dst))
|
||||
if shouldPersistLocalArtworkURL(refreshed.PosterURL) {
|
||||
artworkUpdates["poster_url"] = filepath.Join(filepath.Dir(refreshed.Path), filepath.Base(dst))
|
||||
}
|
||||
}
|
||||
}
|
||||
if refreshed.BackdropURL != "" {
|
||||
if dst := s.downloadArtworkToPathWithOptions(ctx, dir, base+"-backdrop", refreshed.BackdropURL, false); dst != "" {
|
||||
artworkUpdates["backdrop_url"] = filepath.Join(filepath.Dir(refreshed.Path), filepath.Base(dst))
|
||||
if shouldPersistLocalArtworkURL(refreshed.BackdropURL) {
|
||||
artworkUpdates["backdrop_url"] = filepath.Join(filepath.Dir(refreshed.Path), filepath.Base(dst))
|
||||
}
|
||||
}
|
||||
} else if isAdult && refreshed.PosterURL != "" {
|
||||
// 番号海报原图为完整封套横图,在无独立背景图时直接作为背景图写出
|
||||
@@ -91,10 +98,22 @@ func shouldCropAdultPoster(media *model.Media, lib *model.Library) bool {
|
||||
// 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.
|
||||
func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, dir, name, raw string, cropAdultPoster bool) string {
|
||||
if !isHTTPish(raw) {
|
||||
var (
|
||||
data []byte
|
||||
ctype string
|
||||
err error
|
||||
)
|
||||
switch {
|
||||
case isHTTPish(raw):
|
||||
data, ctype, err = s.images.Fetch(ctx, raw)
|
||||
case isLocalImagePath(raw):
|
||||
data, err = os.ReadFile(sanitizeLocalPath(resolveMappedDestinationPath(raw)))
|
||||
if err == nil {
|
||||
ctype = detectContentType(data)
|
||||
}
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
data, ctype, err := s.images.Fetch(ctx, raw)
|
||||
if err != nil || len(data) == 0 {
|
||||
s.log.Warn("scrape artwork download failed",
|
||||
zap.String("name", name),
|
||||
@@ -105,6 +124,12 @@ func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, d
|
||||
if !isImageContentType(ctype) || isTransparentPlaceholderData(data) {
|
||||
return ""
|
||||
}
|
||||
// Upstreams sometimes report non-standard values such as image/jpg.
|
||||
// Use the decoded bytes as the source of truth so sidecars receive a
|
||||
// standard extension instead of the legacy .img fallback.
|
||||
if detected := detectContentType(data); isImageContentType(detected) {
|
||||
ctype = detected
|
||||
}
|
||||
if cropAdultPoster {
|
||||
if cropped, croppedType, err := CropAdultCoverPoster(data); err == nil && len(cropped) > 0 {
|
||||
data = cropped
|
||||
@@ -166,7 +191,7 @@ func (s *ScraperService) writeArtworkDataToPath(dir, name, ctype string, data []
|
||||
// extension that could confuse media players.
|
||||
func imageExtForContentType(ctype string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(strings.Split(ctype, ";")[0])) {
|
||||
case "image/jpeg", "image/pjpeg":
|
||||
case "image/jpeg", "image/jpg", "image/pjpeg":
|
||||
return ".jpg"
|
||||
case "image/png", "image/x-png":
|
||||
return ".png"
|
||||
|
||||
@@ -88,6 +88,7 @@ func TestShouldCropAdultPosterUsesLibraryType(t *testing.T) {
|
||||
func TestImageExtForContentType(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"image/jpeg": ".jpg",
|
||||
"image/jpg": ".jpg",
|
||||
"image/pjpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
@@ -103,6 +104,32 @@ func TestImageExtForContentType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadArtworkMigratesLocalImgToCanonicalCroppedJPEG(t *testing.T) {
|
||||
scraper := &ScraperService{log: zap.NewNop()}
|
||||
dir := t.TempDir()
|
||||
legacy := filepath.Join(dir, "movie-poster.img")
|
||||
wide := createTestImage(900, 600, color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255})
|
||||
if err := os.WriteFile(legacy, wide, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dst := scraper.downloadArtworkToPathWithOptions(t.Context(), dir, "movie-poster", legacy, true)
|
||||
if filepath.Base(dst) != "movie-poster.jpg" {
|
||||
t.Fatalf("destination = %q, want canonical JPEG sidecar", dst)
|
||||
}
|
||||
data, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cropped, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := float64(cropped.Bounds().Dx()) / float64(cropped.Bounds().Dy()); got < 0.65 || got > 0.68 {
|
||||
t.Fatalf("migrated poster ratio = %.3f, want 2:3", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteMediaArtworkFilesAfterScrapeSkipsCloud verifies that cloud media
|
||||
// never has artwork written anywhere.
|
||||
func TestWriteMediaArtworkFilesAfterScrapeSkipsCloud(t *testing.T) {
|
||||
|
||||
@@ -49,7 +49,7 @@ const (
|
||||
|
||||
const (
|
||||
StrmDefaultVideoExt = "mkv,mp4,avi,rmvb,rm,mov,ts,wmv,flv,m4v,iso,mpg,mpeg,webm"
|
||||
StrmDefaultMetaExt = "nfo,jpg,jpeg,png,srt,ass,ssa,sub,txt,bmp,webp"
|
||||
StrmDefaultMetaExt = "nfo,jpg,jpeg,png,srt,ass,ssa,sub,txt,bmp,webp,img"
|
||||
StrmDefaultExclude = "sample,trailer,预告"
|
||||
)
|
||||
|
||||
|
||||
+223
-54
@@ -46,25 +46,26 @@ type strmSyncState struct {
|
||||
rec *model.StrmSyncRecord
|
||||
syncType string
|
||||
|
||||
mu sync.Mutex
|
||||
processed int // 已处理文件计数(用于定期落库进度)
|
||||
lastProgressFlush time.Time // 上次进度落库时间
|
||||
seenVideo map[string]bool // "v:"+strm 去扩展名相对路径 → 远端存在该视频(供 prune)
|
||||
seenMeta map[string]bool // "m:"+相对路径 → 远端存在该元数据
|
||||
remoteMeta map[string][]remoteMetaItem // "m:"+相对路径 → 远端元数据副本列表(多副本聚合,支持择优比对与冗余清理)
|
||||
seenMetaTarget map[string]cloud.FileEntry
|
||||
seenVideoTarget map[string]cloud.FileEntry
|
||||
mu sync.Mutex
|
||||
processed int // 已处理文件计数(用于定期落库进度)
|
||||
lastProgressFlush time.Time // 上次进度落库时间
|
||||
seenVideo map[string]bool // "v:"+strm 去扩展名相对路径 → 远端存在该视频(供 prune)
|
||||
seenDir map[string]bool // 清洗后的目录相对路径 → 远端存在该目录(供整目录 prune)
|
||||
seenMeta map[string]bool // "m:"+相对路径 → 远端存在该元数据
|
||||
remoteMeta map[string][]remoteMetaItem // "m:"+相对路径 → 远端元数据副本列表(多副本聚合,支持择优比对与冗余清理)
|
||||
seenMetaTarget map[string]cloud.FileEntry
|
||||
seenVideoTarget map[string]cloud.FileEntry
|
||||
// remoteVideos:prefer 模式下同名(去扩展名)视频候选列表,walk 结束后择优写盘
|
||||
remoteVideos map[string][]remoteVideoCandidate
|
||||
remoteVideos map[string][]remoteVideoCandidate
|
||||
activeDownloadPaths map[string]bool // 本地已在排队/进行的下载任务路径(内存去重)
|
||||
activeUploadPaths map[string]bool // 本地已在排队/进行的上传任务路径(内存去重)
|
||||
// recentDoneUploadSizes:近期已成功上传的 local_path → size,缩短「done 但列表未到」窗口内的重复入队
|
||||
recentDoneUploadSizes map[string]int64
|
||||
pendingDownloads []*model.StrmDownloadTask
|
||||
pendingUploads []*model.StrmUploadTask
|
||||
dirCache sync.Map // dirID (string) -> relativePath (string)
|
||||
dirPathToID map[string]string // relativePath (string) -> dirID(115 上传父目录寻址用,walk 后构建)
|
||||
dirCacheDirty map[string]string // 待批量落库的目录缓存(dirID → 相对路径),避免逐目录单条 upsert
|
||||
dirCache sync.Map // dirID (string) -> relativePath (string)
|
||||
dirPathToID map[string]string // relativePath (string) -> dirID(115 上传父目录寻址用,walk 后构建)
|
||||
dirCacheDirty map[string]string // 待批量落库的目录缓存(dirID → 相对路径),避免逐目录单条 upsert
|
||||
|
||||
scanIncomplete atomic.Bool // 远端目录树/文件列表本次扫描不完整 → 禁止增量 prune 误删本地文件
|
||||
}
|
||||
@@ -281,6 +282,7 @@ func (s *StrmService) runSync(ctx context.Context, p *model.StrmSyncPath, rec *m
|
||||
rec: rec,
|
||||
syncType: rec.SyncType,
|
||||
seenVideo: map[string]bool{},
|
||||
seenDir: map[string]bool{"": true},
|
||||
seenMeta: map[string]bool{},
|
||||
remoteMeta: map[string][]remoteMetaItem{},
|
||||
seenMetaTarget: map[string]cloud.FileEntry{},
|
||||
@@ -535,36 +537,38 @@ func (st *strmSyncState) walkRemote() error {
|
||||
if task.rel != "" {
|
||||
rel = task.rel + "/" + cleanName
|
||||
}
|
||||
if entry.IsDir {
|
||||
st.dirCache.Store(entry.ID, rel)
|
||||
st.deferDirCacheSave(entry.ID, rel)
|
||||
push(dirTask{id: entry.ID, rel: rel})
|
||||
} else {
|
||||
st.processRemoteFile(entry, rel)
|
||||
}
|
||||
if entry.IsDir {
|
||||
st.markSeenDir(rel)
|
||||
st.dirCache.Store(entry.ID, rel)
|
||||
st.deferDirCacheSave(entry.ID, rel)
|
||||
push(dirTask{id: entry.ID, rel: rel})
|
||||
} else {
|
||||
st.processRemoteFile(entry, rel)
|
||||
}
|
||||
walkMu.Lock()
|
||||
pending--
|
||||
if pending == 0 {
|
||||
walkCond.Broadcast()
|
||||
}
|
||||
walkMu.Unlock()
|
||||
}
|
||||
}); err != nil {
|
||||
cancel()
|
||||
walkMu.Lock()
|
||||
pending--
|
||||
if pending == 0 {
|
||||
walkCond.Broadcast()
|
||||
}
|
||||
walkMu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
st.flushDirCacheSave()
|
||||
if firstErr != nil {
|
||||
return firstErr
|
||||
}
|
||||
}); err != nil {
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
st.flushDirCacheSave()
|
||||
if firstErr != nil {
|
||||
return firstErr
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// processRemoteFile 分类处理远端文件:视频生成 STRM,元数据入下载队列。
|
||||
func (st *strmSyncState) processRemoteFile(entry cloud.FileEntry, rel string) {
|
||||
st.markSeenDir(filepath.ToSlash(filepath.Dir(rel)))
|
||||
fileName := entry.Name
|
||||
if st.isExcluded(fileName) {
|
||||
return
|
||||
@@ -608,6 +612,12 @@ func (st *strmSyncState) isVideoExt(ext string, size int64) bool {
|
||||
}
|
||||
|
||||
func (st *strmSyncState) isMetaExt(ext string) bool {
|
||||
// Legacy MeBox installs may have persisted strm.meta_ext without img.
|
||||
// .img is emitted by the artwork writer as a fallback image container, so
|
||||
// keep accepting existing sidecars without requiring a settings migration.
|
||||
if strings.EqualFold(ext, ".img") {
|
||||
return true
|
||||
}
|
||||
for _, e := range st.cfg.MetaExt {
|
||||
if "."+e == ext {
|
||||
return true
|
||||
@@ -698,16 +708,16 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
|
||||
if pathCounts[item.Path] > 1 {
|
||||
continue
|
||||
}
|
||||
st.dirCache.Store(item.DirID, cleanDirRel(item.Path))
|
||||
}
|
||||
st.dirCache.Store(item.DirID, cleanDirRel(item.Path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 自适应分治拉取文件列表(单目录超 9500 时自动对子目录并发分治扁平化)
|
||||
allFiles, err := st.fetch115FilesAdaptive(ctx, open115, rootCID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 2. 自适应分治拉取文件列表(单目录超 9500 时自动对子目录并发分治扁平化)
|
||||
allFiles, err := st.fetch115FilesAdaptive(ctx, open115, rootCID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
@@ -1031,10 +1041,10 @@ func list115DirDirect(ctx context.Context, open115 *cloud115.OpenClient, cid str
|
||||
|
||||
// fetch115FilesAdaptive 采用自适应分治策略抓取 115 目录树下的全部文件:
|
||||
// 115 开放平台扁平搜索对 offset+limit 有 10000 的最大深度限制。
|
||||
// - 若子树文件总数 < 9500,直接使用全速扁平分页批量拉取;
|
||||
// - 若子树文件总数 >= 9500(大库或超大分类目录),自动分治:仅单层列出该目录的直属子项(cur=1),
|
||||
// 直属纯文件直接收集,直属子目录则派发为独立的子树任务继续递归探测与拉取;
|
||||
// - 若超大单目录下无子目录或层级过深(>10层),安全回退到 errFallbackToWalkRemote。
|
||||
// - 若子树文件总数 < 9500,直接使用全速扁平分页批量拉取;
|
||||
// - 若子树文件总数 >= 9500(大库或超大分类目录),自动分治:仅单层列出该目录的直属子项(cur=1),
|
||||
// 直属纯文件直接收集,直属子目录则派发为独立的子树任务继续递归探测与拉取;
|
||||
// - 若超大单目录下无子目录或层级过深(>10层),安全回退到 errFallbackToWalkRemote。
|
||||
func (st *strmSyncState) fetch115FilesAdaptive(ctx context.Context, open115 *cloud115.OpenClient, rootCID string) ([]cloud115.RemoteFile, error) {
|
||||
var (
|
||||
allFiles []cloud115.RemoteFile
|
||||
@@ -1637,6 +1647,10 @@ func (st *strmSyncState) walkLocalSource() error {
|
||||
default:
|
||||
}
|
||||
if d.IsDir() {
|
||||
rel, relErr := filepath.Rel(srcRoot, path)
|
||||
if relErr == nil {
|
||||
st.markSeenDir(filepath.ToSlash(rel))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(srcRoot, path)
|
||||
@@ -1898,8 +1912,104 @@ func (st *strmSyncState) taskExists(kind, syncPathID, localPath string) bool {
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// pruneLocal 清理本地多余 .strm(远端已不存在的视频),可选删除空目录。
|
||||
// 元数据文件(nfo/图片/字幕等)一律保留:本地刮削结果不因网盘端缺失而被删除。
|
||||
// markSeenDir 记录远端存在的目录及其全部祖先目录。
|
||||
func (st *strmSyncState) markSeenDir(rel string) {
|
||||
rel = strings.Trim(filepath.ToSlash(rel), "/")
|
||||
if rel == "." {
|
||||
rel = ""
|
||||
}
|
||||
st.mu.Lock()
|
||||
if st.seenDir == nil {
|
||||
st.seenDir = map[string]bool{"": true}
|
||||
}
|
||||
for {
|
||||
st.seenDir[rel] = true
|
||||
if rel == "" {
|
||||
break
|
||||
}
|
||||
if idx := strings.LastIndexByte(rel, '/'); idx >= 0 {
|
||||
rel = rel[:idx]
|
||||
} else {
|
||||
rel = ""
|
||||
}
|
||||
}
|
||||
st.mu.Unlock()
|
||||
}
|
||||
|
||||
// refreshUnseen115Dirs 补查本地存在、但 115 扁平文件列表未覆盖的目录。
|
||||
// 扁平接口不返回空目录;逐层补查这些候选目录可以避免把远端仍存在的空目录误删。
|
||||
func (st *strmSyncState) refreshUnseen115Dirs(localRoot string, dirs []string) error {
|
||||
if st.p.Provider != model.StrmProvider115 || st.provider == nil {
|
||||
return nil
|
||||
}
|
||||
dirIDs := map[string]string{"": strings.TrimSpace(st.p.RemotePath)}
|
||||
if dirIDs[""] == "" {
|
||||
dirIDs[""] = "0"
|
||||
}
|
||||
st.dirCache.Range(func(key, value any) bool {
|
||||
id, idOK := key.(string)
|
||||
rel, relOK := value.(string)
|
||||
if idOK && relOK && id != "" {
|
||||
dirIDs[cleanDirRel(rel)] = id
|
||||
}
|
||||
return true
|
||||
})
|
||||
liveIDs := map[string]string{"": dirIDs[""]}
|
||||
listed := map[string]bool{}
|
||||
|
||||
sort.Strings(dirs)
|
||||
for _, dir := range dirs {
|
||||
rel, err := filepath.Rel(localRoot, dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
rel = cleanDirRel(filepath.ToSlash(rel))
|
||||
st.mu.Lock()
|
||||
seen := st.seenDir[rel]
|
||||
st.mu.Unlock()
|
||||
if seen {
|
||||
if id := dirIDs[rel]; id != "" {
|
||||
liveIDs[rel] = id
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
parentRel := ""
|
||||
if idx := strings.LastIndexByte(rel, '/'); idx >= 0 {
|
||||
parentRel = rel[:idx]
|
||||
}
|
||||
parentID := liveIDs[parentRel]
|
||||
if parentID == "" {
|
||||
continue // 父目录已确认不存在,子目录也必然是本地孤儿
|
||||
}
|
||||
if listed[parentRel] {
|
||||
continue
|
||||
}
|
||||
entries, err := st.provider.List(st.ctx, parentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("核对 115 远端目录 %s 失败:%w", parentRel, err)
|
||||
}
|
||||
listed[parentRel] = true
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir {
|
||||
continue
|
||||
}
|
||||
childRel := cleanEntryName(entry.Name, true)
|
||||
if parentRel != "" {
|
||||
childRel = parentRel + "/" + childRel
|
||||
}
|
||||
st.markSeenDir(childRel)
|
||||
liveIDs[childRel] = entry.ID
|
||||
dirIDs[childRel] = entry.ID
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pruneLocal 清理本地远端已不存在的内容:
|
||||
// - 整个目录在远端不存在时,递归删除该本地目录(包括元数据);
|
||||
// - 目录仍存在但视频已删除时,仅删除对应的 .strm,保留本地元数据;
|
||||
// - DeleteDir 开启时,最后再清理其余空目录。
|
||||
func (st *strmSyncState) pruneLocal() error {
|
||||
// 增量同步保护:本次远端扫描不完整(目录详情解析失败 / 文件父路径降级)时,
|
||||
// seenVideo 覆盖不全,按"远端不存在"清理会误删刚下载或已存在的本地 .strm,
|
||||
@@ -1911,6 +2021,7 @@ func (st *strmSyncState) pruneLocal() error {
|
||||
}
|
||||
localRoot := filepath.Clean(st.p.LocalPath)
|
||||
var dirs []string
|
||||
var strmFiles []string
|
||||
err := filepath.WalkDir(localRoot, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -1933,13 +2044,74 @@ func (st *strmSyncState) pruneLocal() error {
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
ext := strings.ToLower(filepath.Ext(rel))
|
||||
remove := false
|
||||
if ext == ".strm" {
|
||||
relSansExt := rel[:len(rel)-len(ext)]
|
||||
strmFiles = append(strmFiles, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.refreshUnseen115Dirs(localRoot, dirs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 先从浅到深找出最上层孤儿目录;父目录已判定为孤儿时无需重复处理子目录。
|
||||
sort.Strings(dirs)
|
||||
orphanRoots := make([]string, 0)
|
||||
for _, dir := range dirs {
|
||||
rel, relErr := filepath.Rel(localRoot, dir)
|
||||
if relErr != nil {
|
||||
continue
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
st.mu.Lock()
|
||||
existsRemotely := st.seenDir[rel]
|
||||
st.mu.Unlock()
|
||||
if existsRemotely {
|
||||
continue
|
||||
}
|
||||
underOrphan := false
|
||||
for _, root := range orphanRoots {
|
||||
childRel, childErr := filepath.Rel(root, dir)
|
||||
if childErr == nil && childRel != ".." && !strings.HasPrefix(childRel, ".."+string(filepath.Separator)) {
|
||||
underOrphan = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !underOrphan {
|
||||
orphanRoots = append(orphanRoots, dir)
|
||||
}
|
||||
}
|
||||
for _, dir := range orphanRoots {
|
||||
var fileCount int64
|
||||
_ = filepath.WalkDir(dir, func(_ string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr == nil && !d.IsDir() {
|
||||
fileCount++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err := os.RemoveAll(dir); err == nil {
|
||||
st.mu.Lock()
|
||||
remove = !st.seenVideo["v:"+relSansExt]
|
||||
st.rec.Pruned += fileCount
|
||||
st.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// 对仍存在于远端的目录,按原规则清理失去远端视频来源的单个 .strm。
|
||||
for _, path := range strmFiles {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
continue // 已随孤儿目录递归删除
|
||||
}
|
||||
rel, relErr := filepath.Rel(localRoot, path)
|
||||
if relErr != nil {
|
||||
continue
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
relSansExt := strings.TrimSuffix(rel, filepath.Ext(rel))
|
||||
st.mu.Lock()
|
||||
remove := !st.seenVideo["v:"+relSansExt]
|
||||
st.mu.Unlock()
|
||||
if remove {
|
||||
if err := os.Remove(path); err == nil {
|
||||
st.mu.Lock()
|
||||
@@ -1947,11 +2119,8 @@ func (st *strmSyncState) pruneLocal() error {
|
||||
st.mu.Unlock()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if st.cfg.DeleteDir {
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(dirs)))
|
||||
for _, dir := range dirs {
|
||||
|
||||
@@ -489,14 +489,12 @@ func taskNames(tasks []model.StrmUploadTask) []string {
|
||||
return names
|
||||
}
|
||||
|
||||
// TestPruneLocalKeepsLocalMeta 验证清理规则:远端已删除的视频 .strm 仍会被清理,
|
||||
// 但本地元数据一律保留(即使开启"下载元数据"且未开启"上传元数据"、网盘端没有
|
||||
// 该元数据,也不再删除本地刮削好的 nfo/图片/字幕)。
|
||||
func TestPruneLocalKeepsLocalMeta(t *testing.T) {
|
||||
// TestPruneLocalRemovesOrphanDirectory 验证远端目录不存在时,会递归删除整个本地
|
||||
// 目录,包括其中的 strm、NFO、图片等文件。
|
||||
func TestPruneLocalRemovesOrphanDirectory(t *testing.T) {
|
||||
svc := testStrmService(t)
|
||||
localDir := t.TempDir()
|
||||
|
||||
// 阿凡达.strm(对应视频已被网盘删除 → 应清理)+ 阿凡达.nfo(网盘没有 → 保留)
|
||||
writeFile(t, filepath.Join(localDir, "电影", "阿凡达.strm"), "http://test.local:8096/x")
|
||||
writeFile(t, filepath.Join(localDir, "电影", "阿凡达.nfo"), "<local scraped meta/>")
|
||||
writeFile(t, filepath.Join(localDir, "电影", "poster.jpg"), "local-poster")
|
||||
@@ -518,10 +516,40 @@ func TestPruneLocalKeepsLocalMeta(t *testing.T) {
|
||||
rec: &model.StrmSyncRecord{},
|
||||
syncType: model.StrmSyncTypeFull,
|
||||
seenVideo: map[string]bool{},
|
||||
seenDir: map[string]bool{"": true},
|
||||
seenMeta: map[string]bool{},
|
||||
remoteMeta: map[string][]remoteMetaItem{},
|
||||
}
|
||||
// 本次远端扫描既没有看到视频,也没有看到任何元数据
|
||||
if err := st.pruneLocal(); err != nil {
|
||||
t.Fatalf("pruneLocal failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(localDir, "电影")); !os.IsNotExist(err) {
|
||||
t.Fatalf("orphan directory should be removed recursively, stat err = %v", err)
|
||||
}
|
||||
if st.rec.Pruned != 3 {
|
||||
t.Fatalf("expected 3 pruned files, got %d", st.rec.Pruned)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPruneLocalKeepsMetaInExistingDirectory 验证目录仍在远端时,只清理失去
|
||||
// 视频来源的 strm,不删除本地刮削元数据。
|
||||
func TestPruneLocalKeepsMetaInExistingDirectory(t *testing.T) {
|
||||
svc := testStrmService(t)
|
||||
localDir := t.TempDir()
|
||||
writeFile(t, filepath.Join(localDir, "电影", "阿凡达.strm"), "http://test.local:8096/x")
|
||||
writeFile(t, filepath.Join(localDir, "电影", "阿凡达.nfo"), "<local scraped meta/>")
|
||||
|
||||
st := &strmSyncState{
|
||||
s: svc,
|
||||
ctx: context.Background(),
|
||||
p: &model.StrmSyncPath{Base: model.Base{ID: "prune-existing-dir"}, LocalPath: localDir},
|
||||
cfg: &strmPathConfig{},
|
||||
rec: &model.StrmSyncRecord{},
|
||||
syncType: model.StrmSyncTypeFull,
|
||||
seenVideo: map[string]bool{},
|
||||
seenDir: map[string]bool{"": true, "电影": true},
|
||||
}
|
||||
if err := st.pruneLocal(); err != nil {
|
||||
t.Fatalf("pruneLocal failed: %v", err)
|
||||
}
|
||||
@@ -530,10 +558,7 @@ func TestPruneLocalKeepsLocalMeta(t *testing.T) {
|
||||
t.Fatalf("orphan .strm should be pruned, stat err = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(localDir, "电影", "阿凡达.nfo")); err != nil {
|
||||
t.Fatalf("local meta must be kept even when missing on remote: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(localDir, "电影", "poster.jpg")); err != nil {
|
||||
t.Fatalf("local poster must be kept even when missing on remote: %v", err)
|
||||
t.Fatalf("local meta in an existing remote directory must be kept: %v", err)
|
||||
}
|
||||
if st.rec.Pruned != 1 {
|
||||
t.Fatalf("expected 1 pruned (strm only), got %d", st.rec.Pruned)
|
||||
@@ -800,6 +825,7 @@ func TestWalkRemoteConcurrent(t *testing.T) {
|
||||
if err := st.walkRemote(); err != nil {
|
||||
t.Fatalf("walkRemote failed: %v", err)
|
||||
}
|
||||
st.flushPreferredVideos()
|
||||
|
||||
for _, dir := range []string{"root", "a", "a1", "b"} {
|
||||
if provider.listed[dir] != 1 {
|
||||
@@ -1088,6 +1114,7 @@ func TestWalk115FlatConcurrentProcessing(t *testing.T) {
|
||||
if err := st.walk115Flat(oc); err != nil {
|
||||
t.Fatalf("walk115Flat: %v", err)
|
||||
}
|
||||
st.flushPreferredVideos()
|
||||
|
||||
// 3 个视频的 strm 全部生成
|
||||
if st.rec.NewStrm != 3 {
|
||||
@@ -1143,6 +1170,7 @@ func TestWalk115FlatConcurrentProcessing(t *testing.T) {
|
||||
if err := st2.walk115Flat(oc); err != nil {
|
||||
t.Fatalf("incremental walk115Flat: %v", err)
|
||||
}
|
||||
st2.flushPreferredVideos()
|
||||
if infoCalls != 0 {
|
||||
t.Fatalf("incremental sync should reuse dir cache, got %d get_info calls", infoCalls)
|
||||
}
|
||||
@@ -1436,6 +1464,7 @@ func TestWalk115AdaptiveHierarchicalFlatScan(t *testing.T) {
|
||||
if err := st.walk115Flat(oc); err != nil {
|
||||
t.Fatalf("walk115Flat adaptive failed: %v", err)
|
||||
}
|
||||
st.flushPreferredVideos()
|
||||
|
||||
// 1个根目录视频 + 2个Heyzo视频 + 1个S1视频 = 共4个视频成功生成 .strm
|
||||
if st.rec.NewStrm != 4 {
|
||||
@@ -1651,3 +1680,10 @@ func TestHandleVideoKeepExtWritesAllVersions(t *testing.T) {
|
||||
t.Fatalf("NewStrm=%d want 2", st.rec.NewStrm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrmSyncTreatsLegacyImgAsMetadata(t *testing.T) {
|
||||
st := &strmSyncState{cfg: &strmPathConfig{MetaExt: csvSplit("nfo,jpg")}}
|
||||
if !st.isMetaExt(".img") {
|
||||
t.Fatal("legacy .img sidecars must remain metadata even when an old setting omits img")
|
||||
}
|
||||
}
|
||||
|
||||
+101
-21
@@ -94,28 +94,28 @@ func (s *SubtitleService) Discover(ctx context.Context, mediaID string) ([]Subti
|
||||
// DiscoverExternalOnly 只返回媒体旁边的外挂字幕文件,不含容器内嵌字幕轨。
|
||||
// Emby 字幕接口(/Videos/:id/Subtitles/...)用。
|
||||
func (s *SubtitleService) DiscoverExternalOnly(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
|
||||
tracks, err := s.discover(ctx, mediaID)
|
||||
cacheKey := "external:" + mediaID
|
||||
if tracks, ok := s.cachedDiscovery(cacheKey); ok {
|
||||
return tracks, nil
|
||||
}
|
||||
tracks, err := s.discoverExternalUncached(ctx, mediaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]SubtitleTrack, 0, len(tracks))
|
||||
for _, track := range tracks {
|
||||
if track.Source != "embedded" {
|
||||
out = append(out, track)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
s.rememberDiscovery(cacheKey, tracks)
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
func (s *SubtitleService) discover(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
|
||||
if tracks, ok := s.cachedDiscovery(mediaID); ok {
|
||||
cacheKey := "all:" + mediaID
|
||||
if tracks, ok := s.cachedDiscovery(cacheKey); ok {
|
||||
return tracks, nil
|
||||
}
|
||||
tracks, err := s.discoverUncached(ctx, mediaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.rememberDiscovery(mediaID, tracks)
|
||||
s.rememberDiscovery(cacheKey, tracks)
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
@@ -159,6 +159,30 @@ func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string)
|
||||
if m == nil {
|
||||
return nil, errors.New("media not found")
|
||||
}
|
||||
tracks := discoverExternalSubtitleTracks(m)
|
||||
embedded, err := s.discoverEmbedded(ctx, m)
|
||||
if err != nil {
|
||||
if s.log != nil {
|
||||
s.log.Debug("discover embedded subtitles failed", zap.String("media_id", mediaID), zap.Error(err))
|
||||
}
|
||||
} else {
|
||||
tracks = append(tracks, embedded...)
|
||||
}
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
func (s *SubtitleService) discoverExternalUncached(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
|
||||
m, err := s.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m == nil {
|
||||
return nil, errors.New("media not found")
|
||||
}
|
||||
return discoverExternalSubtitleTracks(m), nil
|
||||
}
|
||||
|
||||
func discoverExternalSubtitleTracks(m *model.Media) []SubtitleTrack {
|
||||
dir := filepath.Dir(m.Path)
|
||||
bases := mediaSidecarBaseVariants(m.Path)
|
||||
if len(bases) == 0 {
|
||||
@@ -210,19 +234,11 @@ func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string)
|
||||
Path: filepath.Join(c, e.Name()),
|
||||
Codec: codec,
|
||||
Source: "external",
|
||||
Delivery: "webvtt",
|
||||
Delivery: subtitleDeliveryForCodec(codec),
|
||||
})
|
||||
}
|
||||
}
|
||||
embedded, err := s.discoverEmbedded(ctx, m)
|
||||
if err != nil {
|
||||
if s.log != nil {
|
||||
s.log.Debug("discover embedded subtitles failed", zap.String("media_id", mediaID), zap.Error(err))
|
||||
}
|
||||
} else {
|
||||
tracks = append(tracks, embedded...)
|
||||
}
|
||||
return tracks, nil
|
||||
return tracks
|
||||
}
|
||||
|
||||
type embeddedSubtitleProbe struct {
|
||||
@@ -281,6 +297,18 @@ func (s *SubtitleService) discoverEmbedded(ctx context.Context, media *model.Med
|
||||
return subtitleTracksFromProbe(probe), nil
|
||||
}
|
||||
|
||||
// subtitleDeliveryForCodec selects the browser rendering path for a text
|
||||
// subtitle codec. ASS/SSA keep their original bytes and are rendered by
|
||||
// libass in the web player; other text formats are converted to WebVTT.
|
||||
func subtitleDeliveryForCodec(codec string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(codec)) {
|
||||
case "ass", "ssa":
|
||||
return "ass"
|
||||
default:
|
||||
return "webvtt"
|
||||
}
|
||||
}
|
||||
|
||||
func subtitleTracksFromProbe(probe embeddedSubtitleProbe) []SubtitleTrack {
|
||||
tracks := make([]SubtitleTrack, 0, len(probe.Streams))
|
||||
for _, stream := range probe.Streams {
|
||||
@@ -298,7 +326,7 @@ func subtitleTracksFromProbe(probe embeddedSubtitleProbe) []SubtitleTrack {
|
||||
} else if stream.Disposition.Default != 0 {
|
||||
label += "(默认)"
|
||||
}
|
||||
delivery := "webvtt"
|
||||
delivery := subtitleDeliveryForCodec(codec)
|
||||
if imageSubtitleCodecs[codec] {
|
||||
delivery = "burn"
|
||||
}
|
||||
@@ -448,6 +476,9 @@ func (s *SubtitleService) ServeRaw(ctx context.Context, mediaID, sub string, w i
|
||||
if err != nil || m == nil {
|
||||
return errors.New("media not found")
|
||||
}
|
||||
if strings.HasPrefix(sub, "embedded:") {
|
||||
return errors.New("embedded subtitle is not available in raw mode")
|
||||
}
|
||||
abs, err := filepath.Abs(sub)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -456,6 +487,9 @@ func (s *SubtitleService) ServeRaw(ctx context.Context, mediaID, sub string, w i
|
||||
if !pathWithin(abs, mediaDir) {
|
||||
return fmt.Errorf("path escape")
|
||||
}
|
||||
if _, ok := extToCodec[strings.ToLower(filepath.Ext(abs))]; !ok {
|
||||
return errors.New("unsupported subtitle format")
|
||||
}
|
||||
f, err := os.Open(abs) // #nosec G304 -- abs is constrained to the media file directory with pathWithin.
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -464,3 +498,49 @@ func (s *SubtitleService) ServeRaw(ctx context.Context, mediaID, sub string, w i
|
||||
_, err = io.Copy(w, f)
|
||||
return err
|
||||
}
|
||||
|
||||
// ServeASS returns an ASS/SSA stream suitable for libass-wasm. External ASS
|
||||
// files are sent unchanged; embedded ASS/SSA tracks are remuxed to ASS by
|
||||
// ffmpeg. This keeps fonts, positioning and typesetting data available to the
|
||||
// browser renderer instead of flattening the track through assToVTT first.
|
||||
func (s *SubtitleService) ServeASS(ctx context.Context, mediaID, sub string, w io.Writer) error {
|
||||
m, err := s.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil || m == nil {
|
||||
return errors.New("media not found")
|
||||
}
|
||||
if strings.HasPrefix(sub, "embedded:") {
|
||||
index, err := strconv.Atoi(strings.TrimPrefix(sub, "embedded:"))
|
||||
if err != nil || index < 0 {
|
||||
return errors.New("invalid embedded subtitle")
|
||||
}
|
||||
return s.serveEmbeddedASS(ctx, m, index, w)
|
||||
}
|
||||
switch strings.ToLower(filepath.Ext(sub)) {
|
||||
case ".ass", ".ssa":
|
||||
return s.ServeRaw(ctx, mediaID, sub, w)
|
||||
default:
|
||||
return errors.New("subtitle is not ASS/SSA")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SubtitleService) serveEmbeddedASS(ctx context.Context, media *model.Media, streamIndex int, w io.Writer) error {
|
||||
input, err := s.resolveInput(ctx, media)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bin, err := resolveLocalExecutable(s.cfg.App.FFmpegPath, "ffmpeg")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
args := []string{"-hide_banner", "-loglevel", "error"}
|
||||
args = append(args, ffmpegHTTPInputArgs(input)...)
|
||||
args = append(args, "-i", input.Source, "-map", "0:"+strconv.Itoa(streamIndex), "-c:s", "ass", "-f", "ass", "-")
|
||||
cmd := exec.CommandContext(ctx, bin, args...) // #nosec G204 -- executable is resolved locally and arguments do not use a shell.
|
||||
cmd.Stdout = w
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("extract embedded ASS subtitle: %w: %s", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -48,6 +50,43 @@ func TestSubtitleDiscoverNoTracksReturnsEmptySlice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverExternalOnlyDoesNotResolveOrProbeCloudMedia(t *testing.T) {
|
||||
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.Media{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
media := model.Media{
|
||||
Title: "Cloud Media",
|
||||
Path: "cloud://115/example/video.mkv",
|
||||
STRMURL: "cloud://115/example/video.mkv",
|
||||
}
|
||||
if err := db.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := NewSubtitleService(&config.Config{}, zap.NewNop(), repository.New(db))
|
||||
resolveCalls := 0
|
||||
svc.SetStrmPlayTargetResolver(func(context.Context, string) (*StrmPlayResult, error) {
|
||||
resolveCalls++
|
||||
return nil, errors.New("resolver must not be called")
|
||||
})
|
||||
|
||||
tracks, err := svc.DiscoverExternalOnly(t.Context(), media.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolveCalls != 0 {
|
||||
t.Fatalf("STRM resolver called %d times, want 0", resolveCalls)
|
||||
}
|
||||
if len(tracks) != 0 {
|
||||
t.Fatalf("len(tracks) = %d, want 0", len(tracks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedSubtitleProbeClassifiesTextAndBitmapTracks(t *testing.T) {
|
||||
var probe embeddedSubtitleProbe
|
||||
raw := []byte(`{"streams":[
|
||||
@@ -61,7 +100,7 @@ func TestEmbeddedSubtitleProbeClassifiesTextAndBitmapTracks(t *testing.T) {
|
||||
if len(tracks) != 2 {
|
||||
t.Fatalf("len(tracks) = %d, want 2", len(tracks))
|
||||
}
|
||||
if tracks[0].Delivery != "webvtt" || tracks[0].Path != "embedded:2" {
|
||||
if tracks[0].Delivery != "ass" || tracks[0].Path != "embedded:2" {
|
||||
t.Fatalf("text track = %#v", tracks[0])
|
||||
}
|
||||
if tracks[1].Delivery != "burn" || tracks[1].StreamIndex != 4 {
|
||||
@@ -69,6 +108,18 @@ func TestEmbeddedSubtitleProbeClassifiesTextAndBitmapTracks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitleDeliveryClassifiesASSForLibass(t *testing.T) {
|
||||
if got := subtitleDeliveryForCodec("ass"); got != "ass" {
|
||||
t.Fatalf("ass delivery = %q, want ass", got)
|
||||
}
|
||||
if got := subtitleDeliveryForCodec("ssa"); got != "ass" {
|
||||
t.Fatalf("ssa delivery = %q, want ass", got)
|
||||
}
|
||||
if got := subtitleDeliveryForCodec("subrip"); got != "webvtt" {
|
||||
t.Fatalf("subrip delivery = %q, want webvtt", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormaliseTimecode(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"0:00:01": "00:00:01",
|
||||
@@ -137,4 +188,17 @@ func TestSubtitleServeRawWritesSourceBytes(t *testing.T) {
|
||||
if got := buf.String(); got != raw {
|
||||
t.Fatalf("ServeRaw returned %q, want raw %q", got, raw)
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
if err := svc.ServeASS(t.Context(), media.ID, subPath, &buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := buf.String(); got != raw {
|
||||
t.Fatalf("ServeASS returned %q, want raw ASS %q", got, raw)
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
if err := svc.ServeRaw(t.Context(), media.ID, videoPath, &buf); err == nil {
|
||||
t.Fatal("ServeRaw accepted a non-subtitle file")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ package service
|
||||
// across providers; provider-specific IDs sit side-by-side so the scraper
|
||||
// orchestrator can write them all into a single update.
|
||||
type Match struct {
|
||||
Provider string `json:"provider,omitempty"`
|
||||
TMDbID int `json:"tmdb_id"`
|
||||
BangumiID int `json:"bangumi_id"`
|
||||
DoubanID string `json:"douban_id,omitempty"`
|
||||
|
||||
Generated
+45
@@ -13,7 +13,9 @@
|
||||
"danmu": "^0.20.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"hls.js": "^1.5.11",
|
||||
"jassub": "^2.5.16",
|
||||
"lucide-react": "^0.379.0",
|
||||
"opencc-js": "^1.4.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -1752,6 +1754,12 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/abslink": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/abslink/-/abslink-1.2.2.tgz",
|
||||
"integrity": "sha512-HCOItvzORPjMzddCmEpbH8+W1VM3yjH+CR7DLtxlxFKX/jz5twJbm7KLfblO7eKLnXINLah8Hk2ZAJqxXeSkOg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -3107,6 +3115,18 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jassub": {
|
||||
"version": "2.5.16",
|
||||
"resolved": "https://registry.npmjs.org/jassub/-/jassub-2.5.16.tgz",
|
||||
"integrity": "sha512-n4vjXfraf3o8k3/5TK6DhCYVcbQ/VqhVOgwqLd3l0xfMtsN4fpE/hPq2hPaGagBTOtJS9yHwRoRp3mmchQ9XVg==",
|
||||
"license": "LGPL-2.1-or-later AND (FTL OR GPL-2.0-or-later) AND MIT AND MIT-Modern-Variant AND ISC AND NTP AND Zlib AND BSL-1.0",
|
||||
"dependencies": {
|
||||
"abslink": "^1.2.2",
|
||||
"lfa-ponyfill": "^1.1.0",
|
||||
"rvfc-polyfill": "^1.0.8",
|
||||
"throughput": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
@@ -3194,6 +3214,13 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lfa-ponyfill": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lfa-ponyfill/-/lfa-ponyfill-1.1.1.tgz",
|
||||
"integrity": "sha512-Had4tsGEHtIj58MRtf6SlubNDltxinxMSdxIn1GdwrWbye8TD+pZ+D6qKETnJ9+vQOfG9q/Ds4WUPskhyAMp5Q==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -3427,6 +3454,12 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/opencc-js": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/opencc-js/-/opencc-js-1.4.2.tgz",
|
||||
"integrity": "sha512-EdcspgdROiCCZDM+jZR6Pa670xW+EW2QhctPBQ8gRjg6lrx4IHEl+2s+kt5izk/TkxVRgdWuFgaGkaEdIUfdaw==",
|
||||
"license": "MIT AND Apache-2.0"
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -4025,6 +4058,12 @@
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rvfc-polyfill": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/rvfc-polyfill/-/rvfc-polyfill-1.0.8.tgz",
|
||||
"integrity": "sha512-uA+0wwTkZ4OT8v45pfDfH+7Yq8mY6MvNngiF5Sq6VBgjJsvsfgt7Q18cyZqZjfAhW9rhkgXPX0cW0R9uw7yElA==",
|
||||
"license": "GPL-3.0"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
@@ -4212,6 +4251,12 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/throughput": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/throughput/-/throughput-1.0.2.tgz",
|
||||
"integrity": "sha512-jvK1ZXuhsggjb3qYQjMiU/AVYYiTeqT5thWvYR2yuy2LGM84P5MSSyAinwHahGsdBYKR9m9HncVR/3f3nFKkxg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
"danmu": "^0.20.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"hls.js": "^1.5.11",
|
||||
"jassub": "^2.5.16",
|
||||
"lucide-react": "^0.379.0",
|
||||
"opencc-js": "^1.4.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
@@ -2,12 +2,15 @@ import { api } from './client'
|
||||
import type { User } from '../types'
|
||||
|
||||
export const profileAPI = {
|
||||
get: () => api.get<User>('/me').then((r) => r.data),
|
||||
|
||||
update: (patch: {
|
||||
username?: string
|
||||
nickname?: string
|
||||
email?: string
|
||||
avatar_url?: string
|
||||
hide_adult?: boolean
|
||||
subtitle_chinese_mode?: 'original' | 'simplified' | 'traditional'
|
||||
password?: string
|
||||
}) =>
|
||||
api.patch<User>('/me', patch).then((r) => r.data),
|
||||
|
||||
@@ -8,14 +8,16 @@ export interface SubtitleTrack {
|
||||
url: string
|
||||
codec: string
|
||||
source: 'external' | 'embedded'
|
||||
delivery: 'webvtt' | 'burn'
|
||||
delivery: 'webvtt' | 'ass' | 'burn'
|
||||
stream_index?: number
|
||||
}
|
||||
|
||||
export const subtitlesAPI = {
|
||||
list: (mediaId: string) =>
|
||||
list: (mediaId: string, includeEmbedded = false) =>
|
||||
api
|
||||
.get<{ tracks: SubtitleTrack[] | null }>(`/media/${mediaId}/subtitles`)
|
||||
.get<{ tracks: SubtitleTrack[] | null }>(`/media/${mediaId}/subtitles`, {
|
||||
params: includeEmbedded ? { include_embedded: 'true' } : undefined,
|
||||
})
|
||||
.then((r) => r.data.tracks ?? []),
|
||||
|
||||
url: (mediaId: string, path: string) => {
|
||||
@@ -24,4 +26,11 @@ export const subtitlesAPI = {
|
||||
path,
|
||||
)}&token=${encodeURIComponent(token)}`
|
||||
},
|
||||
|
||||
assUrl: (mediaId: string, path: string) => {
|
||||
const token = useAuthStore.getState().token ?? ''
|
||||
return `/api/subtitles/${encodeURIComponent(mediaId)}/ass?path=${encodeURIComponent(
|
||||
path,
|
||||
)}&token=${encodeURIComponent(token)}`
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
This Font Software is licensed under the SIL Open Font License,
|
||||
Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font
|
||||
creation efforts of academic and linguistic communities, and to
|
||||
provide a free and open framework in which fonts may be shared and
|
||||
improved in partnership with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply to
|
||||
any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software
|
||||
components as distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to,
|
||||
deleting, or substituting -- in part or in whole -- any of the
|
||||
components of the Original Version, by changing formats or by porting
|
||||
the Font Software to a new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed,
|
||||
modify, redistribute, and sell modified and unmodified copies of the
|
||||
Font Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components, in
|
||||
Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the
|
||||
corresponding Copyright Holder. This restriction only applies to the
|
||||
primary font name as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created using
|
||||
the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import JASSUB from 'jassub'
|
||||
import jassubWorkerUrl from 'jassub/dist/worker/worker.js?worker&url'
|
||||
import jassubWasmUrl from 'jassub/dist/wasm/jassub-worker.wasm?url'
|
||||
import jassubModernWasmUrl from 'jassub/dist/wasm/jassub-worker-modern.wasm?url'
|
||||
import notoSansSCUrl from '../assets/fonts/noto-sans-sc-regular.woff2?url'
|
||||
|
||||
import {
|
||||
loadSubtitleChineseConverter,
|
||||
type SubtitleChineseMode,
|
||||
} from '../utils/subtitleChinese'
|
||||
import { convertASSContent } from '../utils/subtitleASS'
|
||||
|
||||
type AssSubtitleStageProps = {
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
source: string
|
||||
timeOffset?: number
|
||||
chineseMode?: SubtitleChineseMode
|
||||
onReady?: () => void
|
||||
onError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* libass-wasm subtitle layer for external and embedded ASS/SSA tracks. JASSUB
|
||||
* owns its canvas and attaches it next to the video, so this component itself
|
||||
* renders no visible DOM nodes.
|
||||
*/
|
||||
export function AssSubtitleStage({
|
||||
videoRef,
|
||||
source,
|
||||
timeOffset = 0,
|
||||
chineseMode = 'original',
|
||||
onReady,
|
||||
onError,
|
||||
}: AssSubtitleStageProps) {
|
||||
const instanceRef = useRef<JASSUB | null>(null)
|
||||
const timeOffsetRef = useRef(timeOffset)
|
||||
const callbacksRef = useRef({ onReady, onError })
|
||||
|
||||
useEffect(() => {
|
||||
callbacksRef.current = { onReady, onError }
|
||||
}, [onReady, onError])
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video || !source) return
|
||||
|
||||
let cancelled = false
|
||||
let instance: JASSUB | null = null
|
||||
const controller = new AbortController()
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const [response, converter] = await Promise.all([
|
||||
fetch(source, { signal: controller.signal }),
|
||||
loadSubtitleChineseConverter(chineseMode).catch(() => (text: string) => text),
|
||||
])
|
||||
if (!response.ok) throw new Error(`subtitle request failed: ${response.status}`)
|
||||
const body = await response.text()
|
||||
if (!body.trim()) throw new Error('empty subtitle payload')
|
||||
if (cancelled) return
|
||||
|
||||
instance = new JASSUB({
|
||||
video,
|
||||
subContent: convertASSContent(body, converter),
|
||||
timeOffset: timeOffsetRef.current,
|
||||
workerUrl: jassubWorkerUrl,
|
||||
wasmUrl: jassubWasmUrl,
|
||||
modernWasmUrl: jassubModernWasmUrl,
|
||||
fonts: [notoSansSCUrl],
|
||||
defaultFont: 'Noto Sans SC',
|
||||
queryFonts: 'local',
|
||||
prescaleFactor: 1,
|
||||
})
|
||||
instanceRef.current = instance
|
||||
await instance.ready
|
||||
if (cancelled) {
|
||||
void instance.destroy().catch(() => undefined)
|
||||
return
|
||||
}
|
||||
callbacksRef.current.onReady?.()
|
||||
} catch (error: unknown) {
|
||||
if (cancelled) return
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return
|
||||
callbacksRef.current.onError?.(error)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
controller.abort()
|
||||
instanceRef.current = null
|
||||
if (instance) void instance.destroy().catch(() => undefined)
|
||||
}
|
||||
// timeOffset is applied live below. Recreating the full libass worker on a
|
||||
// seek offset change would be unnecessarily expensive.
|
||||
}, [source, chineseMode, videoRef])
|
||||
|
||||
useEffect(() => {
|
||||
timeOffsetRef.current = timeOffset
|
||||
if (instanceRef.current) instanceRef.current.timeOffset = timeOffset
|
||||
}, [timeOffset])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -55,7 +55,9 @@ export function LayoutUserMenu({
|
||||
const [menuPosition, setMenuPosition] = useState<MenuPosition | null>(null)
|
||||
const [isOtpOpen, setIsOtpOpen] = useState(false)
|
||||
|
||||
onCloseRef.current = onClose
|
||||
useEffect(() => {
|
||||
onCloseRef.current = onClose
|
||||
}, [onClose])
|
||||
|
||||
const updateMenuPosition = useCallback(() => {
|
||||
const trigger = triggerRef.current
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
import { AdminLibraryPanel } from '../pages/AdminLibraryPanel'
|
||||
|
||||
export function ManageLibrariesDialogView({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/35 p-2 sm:p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="flex h-[calc(100dvh-1rem)] sm:h-[85vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl sm:rounded-3xl border border-white/70 bg-white shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3 sm:px-6 sm:py-4">
|
||||
<h3 className="font-display text-base sm:text-lg font-bold text-ink-600">管理媒体库</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1.5 text-ink-50 transition hover:bg-gray-100 hover:text-ink-600"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-3 sm:p-6">
|
||||
<AdminLibraryPanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -44,8 +44,8 @@ export function ManualScrapeDialog({
|
||||
if (!open || !media) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-900/40 px-4 py-8 backdrop-blur-sm">
|
||||
<div className="flex max-h-[86vh] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-sand-200 bg-white shadow-2xl">
|
||||
<div className="fixed inset-0 z-50 flex min-w-0 items-center justify-center overflow-hidden bg-ink-900/40 px-4 py-8 backdrop-blur-sm">
|
||||
<div className="flex min-w-0 max-h-[86vh] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-sand-200 bg-white shadow-2xl">
|
||||
<ManualScrapeDialogHeader title={scopeLabel || media.title} targetCount={dialog.targetIds.length} onClose={onClose} />
|
||||
|
||||
<ManualScrapeSearchControls
|
||||
@@ -60,7 +60,7 @@ export function ManualScrapeDialog({
|
||||
onEpisodeArtworkChange={dialog.setIncludeEpisodeArtwork}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
<div className="min-w-0 flex-1 overflow-x-hidden overflow-y-auto p-5">
|
||||
<ManualScrapeCandidateList items={dialog.items} applyingKey={dialog.applyingKey} onApply={dialog.apply} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,14 +20,14 @@ export function ManualScrapeDialogHeader({
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 border-b border-sand-200 px-5 py-4">
|
||||
<div>
|
||||
<div className="flex min-w-0 items-start justify-between gap-4 border-b border-sand-200 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-display text-xl font-bold text-ink-600">手动搜索刮削</h2>
|
||||
<p className="mt-1 text-xs text-sand-500">
|
||||
<p className="mt-1 truncate text-xs text-sand-500">
|
||||
{title} · {targetCount > 1 ? `将应用到 ${targetCount} 个媒体` : '单个媒体'}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="btn-ghost h-9 w-9 p-0" aria-label="关闭">
|
||||
<button onClick={onClose} className="btn-ghost h-9 w-9 shrink-0 p-0" aria-label="关闭">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -58,7 +58,7 @@ export function ManualScrapeSearchControls({
|
||||
onEpisodeArtworkChange,
|
||||
}: ManualScrapeSearchControlsProps) {
|
||||
return (
|
||||
<div className="grid gap-4 border-b border-sand-200 bg-sand-50/40 p-5">
|
||||
<div className="grid min-w-0 gap-4 border-b border-sand-200 bg-sand-50/40 p-5">
|
||||
<ManualScrapeQueryBar
|
||||
query={query}
|
||||
searching={searching}
|
||||
@@ -170,7 +170,7 @@ export function ManualScrapeCandidateList({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<div className="grid min-w-0 gap-3">
|
||||
{items.map((item) => {
|
||||
const key = candidateKey(item)
|
||||
return (
|
||||
@@ -199,7 +199,7 @@ function ManualScrapeCandidateRow({
|
||||
onApply: (item: ManualScrapeCandidate) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-xl border border-sand-200 bg-white p-3 shadow-sm sm:flex-row">
|
||||
<div className="flex min-w-0 max-w-full flex-col gap-4 overflow-hidden rounded-xl border border-sand-200 bg-white p-3 shadow-sm sm:flex-row">
|
||||
<div className="h-28 w-20 shrink-0 overflow-hidden rounded-lg bg-sand-100">
|
||||
{item.poster_url ? (
|
||||
<img src={imageURL(item.poster_url)} alt={item.title} loading="lazy" decoding="async" className="h-full w-full object-cover" referrerPolicy="no-referrer" />
|
||||
@@ -208,14 +208,14 @@ function ManualScrapeCandidateRow({
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="truncate font-semibold text-ink-600">{item.title}</h3>
|
||||
<span className="rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-bold uppercase text-brand-700">{item.source}</span>
|
||||
{item.nsfw ? <span className="rounded-full bg-rose-50 px-2 py-0.5 text-[11px] font-bold text-rose-600">成人</span> : null}
|
||||
{item.year ? <span className="text-xs text-sand-500">{item.year}</span> : null}
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3 className="min-w-0 max-w-full flex-1 truncate font-semibold text-ink-600">{item.title}</h3>
|
||||
<span className="shrink-0 rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-bold uppercase text-brand-700">{item.source}</span>
|
||||
{item.nsfw ? <span className="shrink-0 rounded-full bg-rose-50 px-2 py-0.5 text-[11px] font-bold text-rose-600">成人</span> : null}
|
||||
{item.year ? <span className="shrink-0 text-xs text-sand-500">{item.year}</span> : null}
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-ink-50">{item.overview || '暂无简介'}</p>
|
||||
<p className="mt-2 text-[11px] font-semibold text-sand-500">{candidateIDText(item)}</p>
|
||||
<p className="mt-2 break-words text-[11px] font-semibold text-sand-500">{candidateIDText(item)}</p>
|
||||
</div>
|
||||
<button onClick={() => onApply(item)} disabled={disabled} className="btn-outline h-10 w-full shrink-0 justify-center px-3 text-xs sm:w-auto sm:self-center">
|
||||
{applying ? <LoaderCircle size={14} className="animate-spin" /> : <Check size={14} />}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Layers } from 'lucide-react'
|
||||
import { Cloud, Layers } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import type { Media } from '../types'
|
||||
import { mediaVersionLabel, mediaVersionsOf } from '../utils/mediaVersion'
|
||||
import { isStrmMedia, mediaVersionLabel, mediaVersionsOf } from '../utils/mediaVersion'
|
||||
|
||||
type MediaVersionSwitcherProps = {
|
||||
media: Media
|
||||
@@ -31,14 +31,30 @@ export function MediaVersionSwitcher({
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{versions.map((version) => {
|
||||
const active = version.id === media.id
|
||||
const isStrm = isStrmMedia(version)
|
||||
const label = mediaVersionLabel(version)
|
||||
const className = dark
|
||||
const btnClass = dark
|
||||
? active
|
||||
? 'border-white/40 bg-white/20 text-white'
|
||||
: 'border-white/15 bg-white/5 text-white/80 hover:bg-white/15'
|
||||
? 'border-white/50 bg-white/20 text-white shadow-sm ring-1 ring-white/20'
|
||||
: 'border-white/15 bg-white/5 text-white/80 hover:bg-white/15 hover:text-white'
|
||||
: active
|
||||
? 'border-brand-500/40 bg-brand-50 text-[#b07d35]'
|
||||
? 'border-brand-500/40 bg-brand-50 text-[#b07d35] font-bold shadow-sm'
|
||||
: 'border-gray-200 bg-white text-ink-100 hover:border-brand-500/30 hover:bg-brand-50/40'
|
||||
|
||||
const content = (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{isStrm && (
|
||||
<Cloud
|
||||
size={12}
|
||||
className={dark ? (active ? 'text-brand-300' : 'text-white/60') : (active ? 'text-brand-500' : 'text-sand-400')}
|
||||
aria-label="云端 / STRM"
|
||||
/>
|
||||
)}
|
||||
<span>{label}</span>
|
||||
{!dark && active ? <span className="opacity-75">· 当前</span> : null}
|
||||
</span>
|
||||
)
|
||||
|
||||
if (mode === 'player') {
|
||||
return (
|
||||
<button
|
||||
@@ -46,10 +62,10 @@ export function MediaVersionSwitcher({
|
||||
type="button"
|
||||
disabled={active}
|
||||
onClick={() => onSelect?.(version)}
|
||||
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition disabled:cursor-default ${className}`}
|
||||
title={version.path}
|
||||
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition disabled:cursor-default ${btnClass}`}
|
||||
title={version.path || version.strm_url}
|
||||
>
|
||||
{label}
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -58,11 +74,10 @@ export function MediaVersionSwitcher({
|
||||
key={version.id}
|
||||
to={`/play/${version.id}`}
|
||||
state={{ from: `/media/${media.id}` }}
|
||||
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition ${className}`}
|
||||
title={version.path}
|
||||
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition ${btnClass}`}
|
||||
title={version.path || version.strm_url}
|
||||
>
|
||||
{label}
|
||||
{active ? ' · 当前' : ''}
|
||||
{content}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Captions,
|
||||
CaptionsOff,
|
||||
@@ -15,6 +15,13 @@ import {
|
||||
VolumeX,
|
||||
} from 'lucide-react'
|
||||
import type { SubtitleTrack } from '../api/subtitles'
|
||||
import type { SubtitleChineseMode } from '../utils/subtitleChinese'
|
||||
import {
|
||||
SUBTITLE_POSITION_OPTIONS,
|
||||
SUBTITLE_STYLE_OPTIONS,
|
||||
type SubtitlePosition,
|
||||
type SubtitleStylePreset,
|
||||
} from '../utils/subtitleDisplay'
|
||||
|
||||
// PlayerControls — custom bottom control bar replacing the native <video
|
||||
// controls> (which cannot host custom buttons). The danmaku toggle sits right
|
||||
@@ -36,6 +43,12 @@ type PlayerControlsProps = {
|
||||
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
|
||||
subtitleIndex: number
|
||||
onSelectSubtitle: (index: number) => void
|
||||
subtitleChineseMode: SubtitleChineseMode
|
||||
onSubtitleChineseModeChange: (mode: SubtitleChineseMode) => void
|
||||
subtitlePosition: SubtitlePosition
|
||||
onSubtitlePositionChange: (position: SubtitlePosition) => void
|
||||
subtitleStyle: SubtitleStylePreset
|
||||
onSubtitleStyleChange: (style: SubtitleStylePreset) => void
|
||||
danmakuOpen: boolean
|
||||
danmakuEnabled: boolean
|
||||
onToggleDanmaku: () => void
|
||||
@@ -63,6 +76,12 @@ export function PlayerControls({
|
||||
subs,
|
||||
subtitleIndex,
|
||||
onSelectSubtitle,
|
||||
subtitleChineseMode,
|
||||
onSubtitleChineseModeChange,
|
||||
subtitlePosition,
|
||||
onSubtitlePositionChange,
|
||||
subtitleStyle,
|
||||
onSubtitleStyleChange,
|
||||
danmakuOpen,
|
||||
danmakuEnabled,
|
||||
onToggleDanmaku,
|
||||
@@ -79,11 +98,11 @@ export function PlayerControls({
|
||||
streamOffset = 0,
|
||||
onSeekAbsolute,
|
||||
}: PlayerControlsProps) {
|
||||
const video = () => videoRef.current
|
||||
const container = () =>
|
||||
const video = useCallback(() => videoRef.current, [videoRef])
|
||||
const container = useCallback(() =>
|
||||
videoRef.current?.closest<HTMLElement>('[data-player-stage]') ??
|
||||
videoRef.current?.parentElement ??
|
||||
null
|
||||
null, [videoRef])
|
||||
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
@@ -250,15 +269,14 @@ export function PlayerControls({
|
||||
el.removeEventListener('leavepictureinpicture', syncPip)
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [videoRef, knownDuration, streamOffset, onUiVisibleChange])
|
||||
}, [container, video, knownDuration, streamOffset, onUiVisibleChange])
|
||||
|
||||
// Keep the scrubber max in sync when metadata duration arrives after mount.
|
||||
useEffect(() => {
|
||||
const el = video()
|
||||
const streamDur = el && Number.isFinite(el.duration) ? el.duration : 0
|
||||
setDuration(Math.max(knownDuration || 0, streamOffset + streamDur))
|
||||
}, [knownDuration, streamOffset])
|
||||
}, [knownDuration, streamOffset, video])
|
||||
|
||||
// 当悬停或菜单状态改变时,更新控制栏计时器
|
||||
useEffect(() => {
|
||||
@@ -272,7 +290,7 @@ export function PlayerControls({
|
||||
hideTimerRef.current = setTimeout(() => onUiVisibleChange(false), 3000)
|
||||
}
|
||||
}
|
||||
}, [controlsHovered, isScrubbing, subtitleMenuOpen, danmakuOpen, playlistOpen, onUiVisibleChange])
|
||||
}, [controlsHovered, isScrubbing, subtitleMenuOpen, danmakuOpen, playlistOpen, onUiVisibleChange, video])
|
||||
|
||||
const togglePlay = () => {
|
||||
const el = video()
|
||||
@@ -355,6 +373,12 @@ export function PlayerControls({
|
||||
document.pictureInPictureEnabled
|
||||
|
||||
const displayTime = isScrubbing && scrubValue !== null ? scrubValue : currentTime
|
||||
const selectedSubtitle = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
||||
const canConvertSelectedSubtitle =
|
||||
selectedSubtitle?.source === 'external' &&
|
||||
(selectedSubtitle.delivery === 'webvtt' || selectedSubtitle.delivery === 'ass')
|
||||
const canAdjustSelectedSubtitle = selectedSubtitle?.delivery === 'webvtt'
|
||||
const usesOriginalASS = selectedSubtitle?.delivery === 'ass'
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -448,7 +472,7 @@ export function PlayerControls({
|
||||
)}
|
||||
</button>
|
||||
{subtitleMenuOpen && (
|
||||
<div className="absolute bottom-11 right-0 z-30 min-w-36 rounded-xl border border-white/15 bg-black/85 p-1 shadow-2xl backdrop-blur">
|
||||
<div className="absolute bottom-11 right-0 z-30 max-h-[70vh] min-w-44 overflow-y-auto rounded-xl border border-white/15 bg-black/85 p-1 shadow-2xl backdrop-blur">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -474,10 +498,85 @@ export function PlayerControls({
|
||||
}`}
|
||||
title={track.label || track.lang}
|
||||
>
|
||||
<span className="truncate">{track.label || track.lang || `字幕 ${index + 1}`}</span>
|
||||
<span className="truncate">
|
||||
{track.label || track.lang || `字幕 ${index + 1}`}
|
||||
{track.delivery === 'ass' ? ' · ASS' : ''}
|
||||
</span>
|
||||
{subtitleIndex === index && <span className="ml-auto text-rose-400">●</span>}
|
||||
</button>
|
||||
))}
|
||||
{canAdjustSelectedSubtitle && (
|
||||
<div className="mt-1 border-t border-white/10 pt-1">
|
||||
<p className="px-3 py-1 text-[10px] text-white/45">字幕位置</p>
|
||||
<div className="flex flex-wrap gap-1 px-2 pb-1">
|
||||
{SUBTITLE_POSITION_OPTIONS.map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => onSubtitlePositionChange(value)}
|
||||
className={`rounded-md px-2 py-1 text-[10px] transition ${
|
||||
subtitlePosition === value
|
||||
? 'bg-rose-500/20 text-rose-300'
|
||||
: 'bg-white/5 text-white/70 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="px-3 py-1 text-[10px] text-white/45">字幕样式</p>
|
||||
<div className="flex flex-wrap gap-1 px-2 pb-1">
|
||||
{SUBTITLE_STYLE_OPTIONS.map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => onSubtitleStyleChange(value)}
|
||||
className={`rounded-md px-2 py-1 text-[10px] transition ${
|
||||
subtitleStyle === value
|
||||
? 'bg-rose-500/20 text-rose-300'
|
||||
: 'bg-white/5 text-white/70 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{usesOriginalASS && (
|
||||
<p className="mx-2 mt-1 border-t border-white/10 px-1 pt-2 text-[10px] leading-relaxed text-white/45">
|
||||
ASS/SSA 使用字幕文件自带的样式与位置,不做统一覆盖。
|
||||
</p>
|
||||
)}
|
||||
{canConvertSelectedSubtitle && (
|
||||
<div className="mt-1 border-t border-white/10 pt-1">
|
||||
<p className="px-3 py-1 text-[10px] text-white/45">外挂字幕简繁转换</p>
|
||||
{(
|
||||
[
|
||||
['original', '保持原文'],
|
||||
['simplified', '转换为简体'],
|
||||
['traditional', '转换为繁体'],
|
||||
] as const
|
||||
).map(([mode, label]) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSubtitleChineseModeChange(mode)
|
||||
setSubtitleMenuOpen(false)
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs transition hover:bg-white/10 ${
|
||||
subtitleChineseMode === mode ? 'text-rose-400' : 'text-white/85'
|
||||
}`}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{subtitleChineseMode === mode && (
|
||||
<span className="ml-auto text-rose-400">●</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Check, Film, ListVideo, Play, Search, X } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import type { Media } from '../types'
|
||||
import { seriesTitleFromPath } from '../utils/groupSeries'
|
||||
import { seasonLabel, seasonSortOrder, seriesTitleFromPath } from '../utils/groupSeries'
|
||||
|
||||
export type SeasonGroup = {
|
||||
season: number
|
||||
@@ -42,7 +42,7 @@ export function PlayerPlaylistPanel({
|
||||
list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
|
||||
}
|
||||
return Array.from(seasonsMap.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.sort(([a], [b]) => seasonSortOrder(a) - seasonSortOrder(b))
|
||||
.map(([season, list]) => ({ season, episodes: list }))
|
||||
}, [episodes])
|
||||
|
||||
@@ -128,7 +128,7 @@ export function PlayerPlaylistPanel({
|
||||
: 'bg-white/5 text-white/70 hover:bg-white/10 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span>{season === 0 ? '特别篇' : `第 ${season} 季`}</span>
|
||||
<span>{seasonLabel(season)}</span>
|
||||
<span className="text-[10px] opacity-75">({sesEps.length})</span>
|
||||
{isPlayingThisSeason && !isSelected && (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-rose-400" />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
import { AdminLibraryPanel } from '../pages/AdminLibraryPanel'
|
||||
import { ManageLibrariesDialogView } from './ManageLibrariesDialogView'
|
||||
|
||||
export function openManageLibrariesDialog(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
@@ -15,37 +14,6 @@ export function openManageLibrariesDialog(): Promise<void> {
|
||||
resolve()
|
||||
}
|
||||
|
||||
root.render(<ManageLibrariesDialog onClose={close} />)
|
||||
root.render(<ManageLibrariesDialogView onClose={close} />)
|
||||
})
|
||||
}
|
||||
|
||||
function ManageLibrariesDialog({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/35 p-2 sm:p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="flex h-[calc(100dvh-1rem)] sm:h-[85vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl sm:rounded-3xl border border-white/70 bg-white shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3 sm:px-6 sm:py-4">
|
||||
<h3 className="font-display text-base sm:text-lg font-bold text-ink-600">管理媒体库</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1.5 text-ink-50 transition hover:bg-gray-100 hover:text-ink-600"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-3 sm:p-6">
|
||||
<AdminLibraryPanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,10 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
|
||||
const ref = useRef<WebSocket | null>(null)
|
||||
const token = useAuthStore((s) => s.token)
|
||||
const onEventRef = useRef(onEvent)
|
||||
onEventRef.current = onEvent
|
||||
|
||||
useEffect(() => {
|
||||
onEventRef.current = onEvent
|
||||
}, [onEvent])
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
|
||||
@@ -28,7 +28,7 @@ export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) {
|
||||
const [browsingRoot, setBrowsingRoot] = useState<{ libraryID: string; root: LibraryRoot; initialPath?: string } | null>(null)
|
||||
const [addingRootLib, setAddingRootLib] = useState<Library | null>(null)
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null)
|
||||
const dragOverId = useRef<string | null>(null)
|
||||
const [dragOverId, setDragOverId] = useState<string | null>(null)
|
||||
|
||||
const handleSelectRootPath = (selectedPath: string) => {
|
||||
if (browsingRoot) {
|
||||
@@ -59,7 +59,7 @@ export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) {
|
||||
|
||||
const handleDrop = (e: DragEvent, overId: string) => {
|
||||
e.preventDefault()
|
||||
dragOverId.current = null
|
||||
setDragOverId(null)
|
||||
setDraggingId(null)
|
||||
if (draggingId && overId !== draggingId) {
|
||||
handleReorder(draggingId, overId)
|
||||
@@ -90,19 +90,19 @@ export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) {
|
||||
onBrowseRoot={(root) => setBrowsingRoot({ libraryID: library.id, root, initialPath: root.path })}
|
||||
onOpenAddRoot={() => setAddingRootLib(library)}
|
||||
dragging={draggingId === library.id}
|
||||
dragOver={dragOverId.current === library.id}
|
||||
dragOver={dragOverId === library.id}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
dragOverId.current = null
|
||||
setDragOverId(null)
|
||||
setDraggingId(library.id)
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
if (dragOverId.current !== library.id) dragOverId.current = library.id
|
||||
if (dragOverId !== library.id) setDragOverId(library.id)
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
dragOverId.current = null
|
||||
setDragOverId(null)
|
||||
setDraggingId(null)
|
||||
}}
|
||||
onDrop={(e) => handleDrop(e, library.id)}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { motion } from 'framer-motion'
|
||||
import { historyAPI } from '../api/history'
|
||||
import type { Media } from '../types'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
import { isTheatricalFeature, type SeriesCard } from '../utils/groupSeries'
|
||||
import {
|
||||
sortMediaList,
|
||||
sortSeriesList,
|
||||
@@ -132,6 +132,7 @@ export function LibraryPage() {
|
||||
selectedEpisodes,
|
||||
visibleEpisodes,
|
||||
selectedSeriesEpisodes,
|
||||
selectedSeriesAllEpisodes,
|
||||
selectedSeriesMediaIDs,
|
||||
handleSeriesClick,
|
||||
clearSelectedSeries,
|
||||
@@ -150,6 +151,9 @@ export function LibraryPage() {
|
||||
setSelectedSeason,
|
||||
onClearSeriesState: () => setSeriesMetadataEditOpen(false),
|
||||
})
|
||||
const selectedSeriesScrapeMedia = selectedSeriesEpisodes.find((media) => !isTheatricalFeature(media))
|
||||
?? selectedSeries?.rep
|
||||
?? null
|
||||
|
||||
const {
|
||||
scraping,
|
||||
@@ -169,7 +173,7 @@ export function LibraryPage() {
|
||||
role,
|
||||
library,
|
||||
selectedSeries,
|
||||
selectedSeriesEpisodes,
|
||||
selectedSeriesEpisodes: selectedSeriesAllEpisodes,
|
||||
reloadCurrentLibrary,
|
||||
clearSelectedSeries,
|
||||
setManualMovie,
|
||||
@@ -287,6 +291,7 @@ export function LibraryPage() {
|
||||
onOrganize={handleSeriesOrganize}
|
||||
onDelete={handleSeriesDelete}
|
||||
onSeasonChange={setSelectedSeason}
|
||||
onManualScrapeMedia={setManualMovie}
|
||||
/>
|
||||
|
||||
<LibraryPageDialogs
|
||||
@@ -296,6 +301,7 @@ export function LibraryPage() {
|
||||
seriesMetadataEditOpen={seriesMetadataEditOpen}
|
||||
manualMovie={manualMovie}
|
||||
selectedSeries={selectedSeries}
|
||||
selectedSeriesScrapeMedia={selectedSeriesScrapeMedia}
|
||||
selectedSeriesMediaIDs={selectedSeriesMediaIDs}
|
||||
libraryType={library?.type}
|
||||
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
|
||||
import { MetadataEditDialog } from '../components/MetadataEditDialog'
|
||||
import { ScrapeMetadataDialog } from '../components/ScrapeMetadataDialog'
|
||||
import type { Library, Media } from '../types'
|
||||
import { seriesTitle, type SeriesCard } from '../utils/groupSeries'
|
||||
import { isTheatricalFeature, seriesTitle, type SeriesCard } from '../utils/groupSeries'
|
||||
|
||||
type LibraryPageDialogsProps = {
|
||||
scrapeDialogOpen: boolean
|
||||
@@ -11,6 +11,7 @@ type LibraryPageDialogsProps = {
|
||||
seriesMetadataEditOpen: boolean
|
||||
manualMovie: Media | null
|
||||
selectedSeries: SeriesCard | null
|
||||
selectedSeriesScrapeMedia: Media | null
|
||||
selectedSeriesMediaIDs: string[]
|
||||
libraryType?: string
|
||||
scrapeEpisodeArtwork: boolean
|
||||
@@ -29,6 +30,7 @@ export function LibraryPageDialogs({
|
||||
seriesMetadataEditOpen,
|
||||
manualMovie,
|
||||
selectedSeries,
|
||||
selectedSeriesScrapeMedia,
|
||||
selectedSeriesMediaIDs,
|
||||
libraryType,
|
||||
scrapeEpisodeArtwork,
|
||||
@@ -53,10 +55,10 @@ export function LibraryPageDialogs({
|
||||
/>
|
||||
<ManualScrapeDialog
|
||||
open={manualSeriesScrapeOpen}
|
||||
media={selectedSeries?.rep ?? null}
|
||||
media={selectedSeriesScrapeMedia}
|
||||
mediaIds={selectedSeriesMediaIDs}
|
||||
defaultQuery={selectedSeriesTitle}
|
||||
mediaType={selectedSeries ? scrapeMediaType(libraryType, selectedSeries.rep) : 'tv'}
|
||||
mediaType="tv"
|
||||
scopeLabel={selectedSeriesTitle || '当前剧集'}
|
||||
episodeArtwork={scrapeEpisodeArtwork}
|
||||
onClose={onCloseManualSeriesScrape}
|
||||
@@ -86,6 +88,9 @@ export function LibraryPageDialogs({
|
||||
}
|
||||
|
||||
function scrapeMediaType(libraryType: string | undefined, media: Media): string {
|
||||
if (isTheatricalFeature(media)) {
|
||||
return 'movie'
|
||||
}
|
||||
if ((media.season_num ?? 0) > 0 || (media.episode_num ?? 0) > 0) {
|
||||
return 'tv'
|
||||
}
|
||||
|
||||
@@ -6,11 +6,10 @@ import { imageURL } from '../api/client'
|
||||
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
||||
import { MediaFavouriteButton } from '../components/MediaFavouriteButton'
|
||||
import type { Media } from '../types'
|
||||
import { seriesTitle, type SeriesCard } from '../utils/groupSeries'
|
||||
import { isTheatricalFeature, seriesTitle, type SeriesCard } from '../utils/groupSeries'
|
||||
|
||||
type LibrarySeriesDetailHeaderProps = {
|
||||
series: SeriesCard
|
||||
visibleEpisodes: Media[]
|
||||
allEpisodes: Media[]
|
||||
playbackFrom: string
|
||||
isAdmin: boolean
|
||||
@@ -31,7 +30,6 @@ type LibrarySeriesDetailHeaderProps = {
|
||||
|
||||
export function LibrarySeriesDetailHeader({
|
||||
series,
|
||||
visibleEpisodes,
|
||||
allEpisodes,
|
||||
playbackFrom,
|
||||
isAdmin,
|
||||
@@ -49,7 +47,9 @@ export function LibrarySeriesDetailHeader({
|
||||
onOrganize,
|
||||
onDelete,
|
||||
}: LibrarySeriesDetailHeaderProps) {
|
||||
const firstEpisode = firstPlayableEpisode(visibleEpisodes.length > 0 ? visibleEpisodes : allEpisodes)
|
||||
const tvEpisodes = allEpisodes.filter((media) => !isTheatricalFeature(media))
|
||||
const theatricalCount = allEpisodes.length - tvEpisodes.length
|
||||
const firstEpisode = firstPlayableEpisode(tvEpisodes)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -61,7 +61,9 @@ export function LibrarySeriesDetailHeader({
|
||||
<h2 className="truncate font-display text-2xl font-bold text-ink-600">
|
||||
{seriesTitle(series.rep)}
|
||||
</h2>
|
||||
<span className="text-sm text-sand-500">共 {series.count} 集</span>
|
||||
<span className="text-sm text-sand-500">
|
||||
共 {tvEpisodes.length} 集{theatricalCount > 0 ? ` · ${theatricalCount} 部剧场版` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 sm:flex-row">
|
||||
@@ -115,7 +117,7 @@ export function LibrarySeriesDetailHeader({
|
||||
</button>
|
||||
<button onClick={onManualScrape} disabled={!!seriesToolBusy} className="btn-outline px-3.5 py-2 text-xs gap-1.5">
|
||||
<Search size={13} className="text-[#c9954a]" />
|
||||
<span>手动匹配整剧</span>
|
||||
<span>{theatricalCount > 0 ? '手动匹配 TV 版' : '手动匹配整剧'}</span>
|
||||
</button>
|
||||
<button onClick={onMetadataEdit} disabled={!!seriesToolBusy} className="btn-outline px-3.5 py-2 text-xs gap-1.5">
|
||||
<Pencil size={13} />
|
||||
|
||||
@@ -33,6 +33,7 @@ type LibrarySeriesDetailSectionProps = {
|
||||
onOrganize: () => void
|
||||
onDelete: () => void
|
||||
onSeasonChange: (season: number) => void
|
||||
onManualScrapeMedia: (media: Media) => void
|
||||
}
|
||||
|
||||
export function LibrarySeriesDetailSection({
|
||||
@@ -58,6 +59,7 @@ export function LibrarySeriesDetailSection({
|
||||
onOrganize,
|
||||
onDelete,
|
||||
onSeasonChange,
|
||||
onManualScrapeMedia,
|
||||
}: LibrarySeriesDetailSectionProps) {
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
@@ -70,7 +72,6 @@ export function LibrarySeriesDetailSection({
|
||||
>
|
||||
<LibrarySeriesDetailHeader
|
||||
series={selectedSeries}
|
||||
visibleEpisodes={visibleEpisodes}
|
||||
allEpisodes={allEpisodes}
|
||||
playbackFrom={playbackFrom}
|
||||
isAdmin={isAdmin}
|
||||
@@ -95,7 +96,9 @@ export function LibrarySeriesDetailSection({
|
||||
selectedSeason={selectedSeason}
|
||||
visibleEpisodes={visibleEpisodes}
|
||||
playbackFrom={playbackFrom}
|
||||
isAdmin={isAdmin}
|
||||
onSeasonChange={onSeasonChange}
|
||||
onManualScrape={onManualScrapeMedia}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -109,7 +112,9 @@ type LibrarySeriesEpisodesPanelProps = {
|
||||
selectedSeason: number | null
|
||||
visibleEpisodes: Media[]
|
||||
playbackFrom: string
|
||||
isAdmin: boolean
|
||||
onSeasonChange: (season: number) => void
|
||||
onManualScrape: (media: Media) => void
|
||||
}
|
||||
|
||||
function LibrarySeriesEpisodesPanel({
|
||||
@@ -118,7 +123,9 @@ function LibrarySeriesEpisodesPanel({
|
||||
selectedSeason,
|
||||
visibleEpisodes,
|
||||
playbackFrom,
|
||||
isAdmin,
|
||||
onSeasonChange,
|
||||
onManualScrape,
|
||||
}: LibrarySeriesEpisodesPanelProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -128,7 +135,9 @@ function LibrarySeriesEpisodesPanel({
|
||||
selectedSeason={selectedSeason}
|
||||
visibleEpisodes={visibleEpisodes}
|
||||
playbackFrom={playbackFrom}
|
||||
isAdmin={isAdmin}
|
||||
onSeasonChange={onSeasonChange}
|
||||
onManualScrape={onManualScrape}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Play } from 'lucide-react'
|
||||
import { Play, Search } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
||||
import type { Media } from '../types'
|
||||
import { seriesTitleFromPath } from '../utils/groupSeries'
|
||||
import {
|
||||
isTheatricalFeature,
|
||||
seasonLabel,
|
||||
seriesTitleFromPath,
|
||||
THEATRICAL_SEASON,
|
||||
} from '../utils/groupSeries'
|
||||
import { formatSize } from './libraryPageModel'
|
||||
|
||||
type SeasonGroup = {
|
||||
@@ -18,7 +23,9 @@ type LibrarySeriesEpisodesProps = {
|
||||
selectedSeason: number | null
|
||||
visibleEpisodes: Media[]
|
||||
playbackFrom: string
|
||||
isAdmin?: boolean
|
||||
onSeasonChange: (season: number) => void
|
||||
onManualScrape?: (media: Media) => void
|
||||
}
|
||||
|
||||
export function LibrarySeriesEpisodes({
|
||||
@@ -27,7 +34,9 @@ export function LibrarySeriesEpisodes({
|
||||
selectedSeason,
|
||||
visibleEpisodes,
|
||||
playbackFrom,
|
||||
isAdmin = false,
|
||||
onSeasonChange,
|
||||
onManualScrape,
|
||||
}: LibrarySeriesEpisodesProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -53,14 +62,14 @@ export function LibrarySeriesEpisodes({
|
||||
: 'border-sand-200 bg-white text-ink-100 hover:border-brand-200 hover:text-brand-600')
|
||||
}
|
||||
>
|
||||
{season === 0 ? '特别篇' : `第 ${season} 季`} · {episodes.length} 集
|
||||
{seasonLabel(season)} · {episodes.length} {season === THEATRICAL_SEASON ? '部' : '集'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 font-display text-lg font-semibold text-ink-600">
|
||||
{displaySeason === 0 ? '特别篇' : `第 ${displaySeason} 季`}
|
||||
{seasonLabel(displaySeason)}
|
||||
</h3>
|
||||
<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) => {
|
||||
@@ -107,7 +116,20 @@ export function LibrarySeriesEpisodes({
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<ExternalPlayerButton mediaId={ep.id} label="外部" compact />
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{isAdmin && onManualScrape && isTheatricalFeature(ep) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onManualScrape(ep)}
|
||||
className="btn-outline !px-2 !py-1.5 text-[11px]"
|
||||
title="手动匹配剧场版"
|
||||
>
|
||||
<Search size={12} />
|
||||
匹配
|
||||
</button>
|
||||
)}
|
||||
<ExternalPlayerButton mediaId={ep.id} label="外部" compact />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Database, HardDrive, PieChart, RefreshCw } from 'lucide-react'
|
||||
|
||||
import { storageAPI, type StorageBreakdown } from '../api/storage'
|
||||
|
||||
export function fmtBytes(n: number): string {
|
||||
function fmtBytes(n: number): string {
|
||||
if (!n) return '0 B'
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
||||
let v = n
|
||||
@@ -15,7 +15,7 @@ export function fmtBytes(n: number): string {
|
||||
return `${v.toFixed(2)} ${u[i]}`
|
||||
}
|
||||
|
||||
export function fmtHours(seconds: number): string {
|
||||
function fmtHours(seconds: number): string {
|
||||
if (!seconds) return '—'
|
||||
const h = Math.floor(seconds / 3600)
|
||||
return `${h.toLocaleString()} h`
|
||||
|
||||
@@ -9,9 +9,23 @@ import { danmakuAPI, type DanmakuAnime, type DanmakuLoadedInfo } from '../api/da
|
||||
import { playbackAPI } from '../api/playback'
|
||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||
import { systemAPI } from '../api/system'
|
||||
import { profileAPI } from '../api/profile'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { Media } from '../types'
|
||||
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
|
||||
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||
import {
|
||||
normalizeSubtitleChineseMode,
|
||||
type SubtitleChineseMode,
|
||||
} from '../utils/subtitleChinese'
|
||||
import {
|
||||
loadSubtitlePosition,
|
||||
loadSubtitleStyle,
|
||||
saveSubtitlePosition,
|
||||
saveSubtitleStyle,
|
||||
type SubtitlePosition,
|
||||
type SubtitleStylePreset,
|
||||
} from '../utils/subtitleDisplay'
|
||||
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, type PlayerMode } from './playerPageModel'
|
||||
import { classifyDirectPlayError } from './directPlayError'
|
||||
import { apiErrorMessage } from './StrmManagePage'
|
||||
@@ -51,6 +65,17 @@ export function PlayerPage() {
|
||||
const [mode, setMode] = useState<PlayerMode>('direct')
|
||||
const [subs, setSubs] = useState<SubtitleTrack[]>([])
|
||||
const [subtitleIndex, setSubtitleIndex] = useState<number>(0)
|
||||
const authUser = useAuthStore((state) => state.user)
|
||||
const setAuthUser = useAuthStore((state) => state.setUser)
|
||||
const [subtitleChineseMode, setSubtitleChineseMode] =
|
||||
useState<SubtitleChineseMode>(() =>
|
||||
normalizeSubtitleChineseMode(authUser?.subtitle_chinese_mode),
|
||||
)
|
||||
const [subtitlePosition, setSubtitlePosition] = useState<SubtitlePosition>(loadSubtitlePosition)
|
||||
const [subtitleStyle, setSubtitleStyle] = useState<SubtitleStylePreset>(loadSubtitleStyle)
|
||||
const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode)
|
||||
const subtitlePreferenceTouchedRef = useRef(false)
|
||||
const subtitlePreferenceSaveQueueRef = useRef<Promise<void>>(Promise.resolve())
|
||||
const [hlsUnavailable, setHlsUnavailable] = useState(false)
|
||||
const [playerError, setPlayerError] = useState('')
|
||||
// 媒体元数据加载失败(404 / 无权限等):舞台区直接展示错误而不是永远「加载中」
|
||||
@@ -112,6 +137,24 @@ export function PlayerPage() {
|
||||
.finally(() => setDirectOnlyKnown(true))
|
||||
}, [])
|
||||
|
||||
// 每次进入播放器都从数据库刷新用户的字幕转换偏好;本地登录缓存只用作首屏初值。
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
profileAPI
|
||||
.get()
|
||||
.then((user) => {
|
||||
if (cancelled || subtitlePreferenceTouchedRef.current) return
|
||||
const savedMode = normalizeSubtitleChineseMode(user.subtitle_chinese_mode)
|
||||
persistedSubtitleChineseModeRef.current = savedMode
|
||||
setSubtitleChineseMode(savedMode)
|
||||
setAuthUser(user)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [setAuthUser])
|
||||
|
||||
// 读取宿主机已保存的弹幕参数作为面板初始值(无 admin 权限也可读)。
|
||||
useEffect(() => {
|
||||
danmakuAPI
|
||||
@@ -237,8 +280,22 @@ export function PlayerPage() {
|
||||
// 404 / 无权限等:给出可见错误提示,避免永久「加载中」
|
||||
setLoadError(`无法加载该媒体:${apiErrorMessage(err)}`)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [id, modeParam, directOnly])
|
||||
|
||||
// Wire up the actual <video> element when we know the mode.
|
||||
// Depend on media.id (not the media object): refreshing duration after
|
||||
// MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE.
|
||||
const mediaId = media?.id
|
||||
|
||||
// 直连播放只发现外挂字幕;只有 HLS 模式需要探测可烧录的内嵌字幕。
|
||||
useEffect(() => {
|
||||
if (!mediaId) return
|
||||
let cancelled = false
|
||||
subtitlesAPI
|
||||
.list(id)
|
||||
.list(mediaId, mode === 'hls')
|
||||
.then((tracks) => {
|
||||
if (cancelled) return
|
||||
const list = tracks ?? []
|
||||
@@ -249,16 +306,13 @@ export function PlayerPage() {
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setSubs([])
|
||||
setSubtitleIndex(-1)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [id, modeParam, directOnly])
|
||||
}, [mediaId, mode])
|
||||
|
||||
// Wire up the actual <video> element when we know the mode.
|
||||
// Depend on media.id (not the media object): refreshing duration after
|
||||
// MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE.
|
||||
const mediaId = media?.id
|
||||
const selectedSubtitle = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
||||
const burnedSubtitleStream =
|
||||
selectedSubtitle?.delivery === 'burn' ? selectedSubtitle.stream_index : undefined
|
||||
@@ -267,7 +321,10 @@ export function PlayerPage() {
|
||||
// Chromium 会把被中断的首次加载报告成播放错误并误触发 HLS 回退。
|
||||
const activeBurnedSubtitleStream = mode === 'hls' ? burnedSubtitleStream : undefined
|
||||
const mediaRef = useRef(media)
|
||||
mediaRef.current = media
|
||||
useEffect(() => {
|
||||
mediaRef.current = media
|
||||
}, [media])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mediaId || !ref.current) return
|
||||
const currentMedia = mediaRef.current
|
||||
@@ -454,9 +511,11 @@ export function PlayerPage() {
|
||||
|
||||
// 使用 ref 实时同步进度计算所需的状态,避免每次 hlsStartSec 改变都触发 cleanup 并误上报旧进度
|
||||
const hlsStartSecRef = useRef(hlsStartSec)
|
||||
hlsStartSecRef.current = hlsStartSec
|
||||
const modeRef = useRef(mode)
|
||||
modeRef.current = mode
|
||||
useEffect(() => {
|
||||
hlsStartSecRef.current = hlsStartSec
|
||||
modeRef.current = mode
|
||||
}, [hlsStartSec, mode])
|
||||
|
||||
// Persist resume position every 10 seconds while playing, and immediately upon pause/unmount.
|
||||
useEffect(() => {
|
||||
@@ -750,6 +809,30 @@ export function PlayerPage() {
|
||||
}
|
||||
}, [directOnly, hlsStartSec, isDirectStream, mode, setPlaybackMode, subs, subtitleIndex])
|
||||
|
||||
const changeSubtitleChineseMode = useCallback(
|
||||
(nextMode: SubtitleChineseMode) => {
|
||||
subtitlePreferenceTouchedRef.current = true
|
||||
setSubtitleChineseMode(nextMode)
|
||||
|
||||
const save = subtitlePreferenceSaveQueueRef.current.then(async () => {
|
||||
const user = await profileAPI.update({ subtitle_chinese_mode: nextMode })
|
||||
persistedSubtitleChineseModeRef.current = nextMode
|
||||
setAuthUser(user)
|
||||
toast.success('字幕转换偏好已保存,后续播放将自动沿用')
|
||||
})
|
||||
subtitlePreferenceSaveQueueRef.current = save.catch(() => undefined)
|
||||
void save.catch(() => {
|
||||
setSubtitleChineseMode((currentMode) =>
|
||||
currentMode === nextMode
|
||||
? persistedSubtitleChineseModeRef.current
|
||||
: currentMode,
|
||||
)
|
||||
toast.error('字幕转换偏好保存失败,已恢复上次设置')
|
||||
})
|
||||
},
|
||||
[setAuthUser],
|
||||
)
|
||||
|
||||
const handleVideoError = useCallback(() => {
|
||||
const video = ref.current
|
||||
if (mode !== 'direct') {
|
||||
@@ -805,6 +888,16 @@ export function PlayerPage() {
|
||||
fallbackTimerRef.current = setTimeout(fallbackDirectPlay, 1500)
|
||||
}, [clearFallbackTimer, directOnly, hlsUnavailable, mediaId, mode, setPlaybackMode])
|
||||
|
||||
const changeSubtitlePosition = useCallback((nextPosition: SubtitlePosition) => {
|
||||
setSubtitlePosition(nextPosition)
|
||||
saveSubtitlePosition(nextPosition)
|
||||
}, [])
|
||||
|
||||
const changeSubtitleStyle = useCallback((nextStyle: SubtitleStylePreset) => {
|
||||
setSubtitleStyle(nextStyle)
|
||||
saveSubtitleStyle(nextStyle)
|
||||
}, [])
|
||||
|
||||
const danmakuAutoTitle =
|
||||
danmakuInfo?.animeTitle ||
|
||||
media?.original_name?.trim() ||
|
||||
@@ -835,6 +928,12 @@ export function PlayerPage() {
|
||||
subs={subs}
|
||||
subtitleIndex={subtitleIndex}
|
||||
onSelectSubtitle={selectSubtitle}
|
||||
subtitleChineseMode={subtitleChineseMode}
|
||||
onSubtitleChineseModeChange={changeSubtitleChineseMode}
|
||||
subtitlePosition={subtitlePosition}
|
||||
onSubtitlePositionChange={changeSubtitlePosition}
|
||||
subtitleStyle={subtitleStyle}
|
||||
onSubtitleStyleChange={changeSubtitleStyle}
|
||||
videoRef={ref}
|
||||
onVideoError={handleVideoError}
|
||||
danmakuEnabled={danmakuEnabled}
|
||||
|
||||
@@ -1,58 +1,133 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { PointerEvent, ReactNode, RefObject } from 'react'
|
||||
import type { CSSProperties, PointerEvent, ReactNode, RefObject } from 'react'
|
||||
|
||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
|
||||
import type { Media } from '../types'
|
||||
import {
|
||||
loadSubtitleChineseConverter,
|
||||
type SubtitleChineseMode,
|
||||
} from '../utils/subtitleChinese'
|
||||
import { AssSubtitleStage } from '../components/AssSubtitleStage'
|
||||
import { DanmakuStage } from '../components/DanmakuStage'
|
||||
import { PlayerControls } from '../components/PlayerControls'
|
||||
import {
|
||||
subtitleTextStyle,
|
||||
type SubtitlePosition,
|
||||
type SubtitleStylePreset,
|
||||
} from '../utils/subtitleDisplay'
|
||||
import { parseWebVTTCues, type SubtitleCue } from '../utils/subtitleVTT'
|
||||
|
||||
type SubtitleCue = {
|
||||
startTime: number
|
||||
endTime: number
|
||||
type SubtitleRenderGroup = {
|
||||
key: string
|
||||
text: string
|
||||
style: CSSProperties
|
||||
}
|
||||
|
||||
function parseVTTTimestamp(value: string): number {
|
||||
const parts = value.trim().replace(',', '.').split(':')
|
||||
if (parts.length !== 2 && parts.length !== 3) return Number.NaN
|
||||
const seconds = Number(parts.pop())
|
||||
const minutes = Number(parts.pop())
|
||||
const hours = parts.length > 0 ? Number(parts.pop()) : 0
|
||||
if (![hours, minutes, seconds].every(Number.isFinite)) return Number.NaN
|
||||
return hours * 3600 + minutes * 60 + seconds
|
||||
function uniqueSubtitleCues(cues: SubtitleCue[]): SubtitleCue[] {
|
||||
const seen = new Set<string>()
|
||||
const unique: SubtitleCue[] = []
|
||||
for (const cue of cues) {
|
||||
const key = `${cue.startTime}\u0000${cue.endTime}\u0000${JSON.stringify(cue.settings)}\u0000${cue.text}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
unique.push(cue)
|
||||
}
|
||||
// WebVTT can contain more than two simultaneous tracks after ASS conversion.
|
||||
// Keep the last two visual blocks, matching the previous bilingual behavior.
|
||||
return unique.slice(-2)
|
||||
}
|
||||
|
||||
function parseWebVTTCues(body: string): SubtitleCue[] {
|
||||
const blocks = body
|
||||
.replace(/^\uFEFF/, '')
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.split(/\n{2,}/)
|
||||
const cues: SubtitleCue[] = []
|
||||
function parsePercent(value: string | undefined): number | null {
|
||||
if (!value || !value.endsWith('%')) return null
|
||||
const parsed = Number.parseFloat(value)
|
||||
return Number.isFinite(parsed) ? Math.min(100, Math.max(0, parsed)) : null
|
||||
}
|
||||
|
||||
for (const block of blocks) {
|
||||
const lines = block.split('\n')
|
||||
const timingIndex = lines.findIndex((line) => line.includes('-->'))
|
||||
if (timingIndex < 0) continue
|
||||
function cueLineBottom(value: string | undefined): number | null {
|
||||
const percent = parsePercent(value)
|
||||
if (percent !== null) return Math.min(92, Math.max(2, 100 - percent))
|
||||
if (value === undefined) return null
|
||||
const line = Number.parseFloat(value)
|
||||
if (!Number.isFinite(line)) return null
|
||||
if (line < 0) return Math.min(92, Math.max(2, 6 - Math.abs(line) * 2))
|
||||
return Math.min(92, Math.max(2, 7 + line * 8))
|
||||
}
|
||||
|
||||
const [rawStart, rawEnd] = lines[timingIndex].split('-->', 2)
|
||||
const startTime = parseVTTTimestamp(rawStart)
|
||||
const endTime = parseVTTTimestamp(rawEnd.trim().split(/\s+/, 1)[0])
|
||||
const text = lines.slice(timingIndex + 1).join('\n').trim()
|
||||
if (Number.isFinite(startTime) && Number.isFinite(endTime) && endTime >= startTime && text) {
|
||||
cues.push({ startTime, endTime, text })
|
||||
}
|
||||
function cueTextAlign(cue: SubtitleCue): CSSProperties['textAlign'] {
|
||||
const align = cue.settings.align
|
||||
if (align === 'left' || align === 'start') return 'left'
|
||||
if (align === 'right' || align === 'end') return 'right'
|
||||
return 'center'
|
||||
}
|
||||
|
||||
function cuePlacementStyle(cue: SubtitleCue, position: SubtitlePosition): CSSProperties {
|
||||
const style: CSSProperties = {
|
||||
maxWidth: `${Math.min(cue.settings.size ?? 92, 94)}%`,
|
||||
textAlign: cueTextAlign(cue),
|
||||
}
|
||||
|
||||
return cues
|
||||
if (cue.settings.vertical) {
|
||||
style.writingMode = cue.settings.vertical === 'lr' ? 'vertical-lr' : 'vertical-rl'
|
||||
}
|
||||
|
||||
if (position !== 'auto') {
|
||||
style.left = '50%'
|
||||
style.textAlign = 'center'
|
||||
if (position === 'bottom') {
|
||||
style.bottom = '5%'
|
||||
style.transform = 'translateX(-50%)'
|
||||
} else if (position === 'lower') {
|
||||
style.bottom = '13%'
|
||||
style.transform = 'translateX(-50%)'
|
||||
} else if (position === 'middle') {
|
||||
style.top = '50%'
|
||||
style.transform = 'translate(-50%, -50%)'
|
||||
} else {
|
||||
style.top = '7%'
|
||||
style.transform = 'translateX(-50%)'
|
||||
}
|
||||
return style
|
||||
}
|
||||
|
||||
const explicitPosition = cue.settings.position
|
||||
const align = cue.settings.align
|
||||
if (explicitPosition !== undefined) {
|
||||
if (align === 'end' || align === 'right') {
|
||||
style.right = `${100 - explicitPosition}%`
|
||||
} else {
|
||||
style.left = `${explicitPosition}%`
|
||||
if (align !== 'start' && align !== 'left') style.transform = 'translateX(-50%)'
|
||||
}
|
||||
} else {
|
||||
style.left = '50%'
|
||||
style.transform = 'translateX(-50%)'
|
||||
}
|
||||
|
||||
const bottom = cueLineBottom(cue.settings.line)
|
||||
if (bottom !== null) style.bottom = `${bottom}%`
|
||||
else style.bottom = '7%'
|
||||
return style
|
||||
}
|
||||
|
||||
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)
|
||||
function buildSubtitleRenderGroups(
|
||||
cues: SubtitleCue[],
|
||||
position: SubtitlePosition,
|
||||
): SubtitleRenderGroup[] {
|
||||
const groups = new Map<string, SubtitleRenderGroup>()
|
||||
for (const cue of cues) {
|
||||
const style = cuePlacementStyle(cue, position)
|
||||
const key = JSON.stringify(style)
|
||||
const existing = groups.get(key)
|
||||
if (existing) {
|
||||
if (!existing.text.split('\n').includes(cue.text)) {
|
||||
existing.text += `\n${cue.text}`
|
||||
}
|
||||
continue
|
||||
}
|
||||
groups.set(key, { key, text: cue.text, style })
|
||||
}
|
||||
return [...groups.values()]
|
||||
}
|
||||
|
||||
type PlayerVideoStageProps = {
|
||||
@@ -64,6 +139,12 @@ type PlayerVideoStageProps = {
|
||||
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
|
||||
subtitleIndex: number
|
||||
onSelectSubtitle: (index: number) => void
|
||||
subtitleChineseMode: SubtitleChineseMode
|
||||
onSubtitleChineseModeChange: (mode: SubtitleChineseMode) => void
|
||||
subtitlePosition: SubtitlePosition
|
||||
onSubtitlePositionChange: (position: SubtitlePosition) => void
|
||||
subtitleStyle: SubtitleStylePreset
|
||||
onSubtitleStyleChange: (style: SubtitleStylePreset) => void
|
||||
videoRef: RefObject<HTMLVideoElement>
|
||||
onVideoError: () => void
|
||||
danmakuEnabled: boolean
|
||||
@@ -102,6 +183,12 @@ export function PlayerVideoStage({
|
||||
subs,
|
||||
subtitleIndex,
|
||||
onSelectSubtitle,
|
||||
subtitleChineseMode,
|
||||
onSubtitleChineseModeChange,
|
||||
subtitlePosition,
|
||||
onSubtitlePositionChange,
|
||||
subtitleStyle,
|
||||
onSubtitleStyleChange,
|
||||
videoRef,
|
||||
onVideoError,
|
||||
danmakuEnabled,
|
||||
@@ -136,7 +223,9 @@ export function PlayerVideoStage({
|
||||
const [controlsVisible, setControlsVisible] = useState(true)
|
||||
const revealControlsOnlyRef = useRef(false)
|
||||
// 当前展示的字幕文本(由自定义字幕层渲染,100% 透明无黑框)
|
||||
const [activeCueText, setActiveCueText] = useState<string>('')
|
||||
const [activeCues, setActiveCues] = useState<SubtitleCue[]>([])
|
||||
const [subtitleChineseConverter, setSubtitleChineseConverter] =
|
||||
useState<(text: string) => string>(() => (text: string) => text)
|
||||
// 独立保存完整 WebVTT 时间轴。HLS seek 会替换媒体源,Chromium 此时可能清空
|
||||
// <track>.track.cues;独立时间轴不受 MediaSource 重挂载和轨道 mode 切换影响。
|
||||
const [subtitleTimeline, setSubtitleTimeline] = useState<{
|
||||
@@ -145,6 +234,40 @@ export function PlayerVideoStage({
|
||||
} | null>(null)
|
||||
// 直连 302 尚未完成时插入 <track> 会中断加载并误报播放失败;等 canplay 再挂。
|
||||
const [tracksArmed, setTracksArmed] = useState(false)
|
||||
// libass/WASM is optional at runtime. If it fails, ASS tracks fall back to
|
||||
// the server's WebVTT conversion so playback still has visible subtitles.
|
||||
const [assFallbackPath, setAssFallbackPath] = useState<string | null>(null)
|
||||
const activeSubtitleTrack = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
||||
|
||||
useEffect(() => {
|
||||
setAssFallbackPath(null)
|
||||
}, [media?.id, activeSubtitleTrack?.path, subs])
|
||||
|
||||
useEffect(() => {
|
||||
const selectedTrack = subs[subtitleIndex]
|
||||
let cancelled = false
|
||||
|
||||
setSubtitleChineseConverter(() => (text: string) => text)
|
||||
if (
|
||||
subtitleChineseMode === 'original' ||
|
||||
selectedTrack?.source !== 'external' ||
|
||||
selectedTrack.delivery !== 'webvtt'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
void loadSubtitleChineseConverter(subtitleChineseMode)
|
||||
.then((converter) => {
|
||||
if (!cancelled) setSubtitleChineseConverter(() => converter)
|
||||
})
|
||||
.catch(() => {
|
||||
// 字典分包加载失败时保留原文,避免影响字幕正常显示。
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [subs, subtitleIndex, subtitleChineseMode])
|
||||
|
||||
useEffect(() => {
|
||||
setTracksArmed(false)
|
||||
@@ -165,7 +288,10 @@ export function PlayerVideoStage({
|
||||
|
||||
useEffect(() => {
|
||||
const selectedTrack = subs[subtitleIndex]
|
||||
if (!media || subtitleIndex < 0 || !selectedTrack || selectedTrack.delivery !== 'webvtt') {
|
||||
const useWebVTT =
|
||||
selectedTrack?.delivery === 'webvtt' ||
|
||||
(selectedTrack?.delivery === 'ass' && assFallbackPath === selectedTrack.path)
|
||||
if (!media || subtitleIndex < 0 || !selectedTrack || !useWebVTT) {
|
||||
setSubtitleTimeline(null)
|
||||
return
|
||||
}
|
||||
@@ -191,7 +317,7 @@ export function PlayerVideoStage({
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}, [media, subs, subtitleIndex])
|
||||
}, [assFallbackPath, media, subs, subtitleIndex])
|
||||
|
||||
// 监听舞台容器的真实尺寸(响应窗口大小调整和全屏切换)
|
||||
useEffect(() => {
|
||||
@@ -267,21 +393,22 @@ export function PlayerVideoStage({
|
||||
subs.length === 0 ||
|
||||
subtitleIndex < 0 ||
|
||||
!selectedTrack ||
|
||||
selectedTrack.delivery === 'burn'
|
||||
(selectedTrack.delivery !== 'webvtt' &&
|
||||
!(selectedTrack.delivery === 'ass' && assFallbackPath === selectedTrack.path))
|
||||
) {
|
||||
setActiveCueText('')
|
||||
setActiveCues([])
|
||||
return
|
||||
}
|
||||
|
||||
const updateCue = () => {
|
||||
const absoluteTime = video.currentTime + (streamOffset ?? 0)
|
||||
if (subtitleTimeline?.path === selectedTrack.path) {
|
||||
const texts = uniqueSubtitleTexts(
|
||||
subtitleTimeline.cues
|
||||
.filter((cue) => absoluteTime >= cue.startTime && absoluteTime <= cue.endTime)
|
||||
.map((cue) => cue.text),
|
||||
)
|
||||
setActiveCueText(texts.join('\n'))
|
||||
const cues = uniqueSubtitleCues(
|
||||
subtitleTimeline.cues.filter(
|
||||
(cue) => absoluteTime >= cue.startTime && absoluteTime <= cue.endTime,
|
||||
),
|
||||
).map((cue) => ({ ...cue, text: subtitleChineseConverter(cue.text) }))
|
||||
setActiveCues(cues)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -290,17 +417,19 @@ export function PlayerVideoStage({
|
||||
)
|
||||
const tt = selectedEl?.track
|
||||
if (!tt) {
|
||||
setActiveCueText('')
|
||||
setActiveCues([])
|
||||
return
|
||||
}
|
||||
|
||||
// 优先从浏览器 activeCues 中取当前文本;若浏览器在 hidden 模式下延迟触发 cuechange,
|
||||
// 则从 tt.cues 中根据 video.currentTime 实时匹配当前字幕,确保初次加载无感立即可见。
|
||||
const texts: string[] = []
|
||||
const cues: SubtitleCue[] = []
|
||||
if ((!streamOffset || streamOffset <= 0.05) && tt.activeCues && tt.activeCues.length > 0) {
|
||||
for (let i = 0; i < tt.activeCues.length; i++) {
|
||||
const cue = tt.activeCues[i] as VTTCue
|
||||
if (cue && cue.text) texts.push(cue.text)
|
||||
if (cue && cue.text) {
|
||||
cues.push({ startTime: cue.startTime, endTime: cue.endTime, text: cue.text, settings: {} })
|
||||
}
|
||||
}
|
||||
} else if (tt.cues && tt.cues.length > 0) {
|
||||
for (let i = 0; i < tt.cues.length; i++) {
|
||||
@@ -311,11 +440,16 @@ export function PlayerVideoStage({
|
||||
absoluteTime <= cue.endTime &&
|
||||
cue.text
|
||||
) {
|
||||
texts.push(cue.text)
|
||||
cues.push({ startTime: cue.startTime, endTime: cue.endTime, text: cue.text, settings: {} })
|
||||
}
|
||||
}
|
||||
}
|
||||
setActiveCueText(uniqueSubtitleTexts(texts).join('\n'))
|
||||
setActiveCues(
|
||||
uniqueSubtitleCues(cues).map((cue) => ({
|
||||
...cue,
|
||||
text: subtitleChineseConverter(cue.text),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
const apply = () => {
|
||||
@@ -370,7 +504,17 @@ export function PlayerVideoStage({
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [subtitleIndex, subs, videoRef, media, streamOffset, subtitleTimeline, tracksArmed])
|
||||
}, [
|
||||
subtitleIndex,
|
||||
subs,
|
||||
videoRef,
|
||||
media,
|
||||
streamOffset,
|
||||
subtitleTimeline,
|
||||
tracksArmed,
|
||||
subtitleChineseConverter,
|
||||
assFallbackPath,
|
||||
])
|
||||
|
||||
// 根据视频画面宽高比与舞台宽高比,确定视频在哪个轴向撑满 100%
|
||||
const isWiderThanStage =
|
||||
@@ -378,6 +522,7 @@ export function PlayerVideoStage({
|
||||
? videoRatio > stageRect.width / stageRect.height
|
||||
: true
|
||||
|
||||
const assTrack = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
||||
const wrapperStyle = videoRatio
|
||||
? {
|
||||
aspectRatio: `${videoRatio}`,
|
||||
@@ -414,8 +559,11 @@ export function PlayerVideoStage({
|
||||
onError={onVideoError}
|
||||
>
|
||||
{tracksArmed &&
|
||||
subs.map((track, index) =>
|
||||
track.delivery === 'burn' ? null : (
|
||||
subs.map((track, index) => {
|
||||
const renderAsFallback =
|
||||
track.delivery === 'ass' && assFallbackPath === track.path
|
||||
if (track.delivery !== 'webvtt' && !renderAsFallback) return null
|
||||
return (
|
||||
<track
|
||||
key={track.path}
|
||||
data-subtitle-index={index}
|
||||
@@ -424,8 +572,8 @@ export function PlayerVideoStage({
|
||||
srcLang={track.lang}
|
||||
label={track.label || track.lang}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
)
|
||||
})}
|
||||
</video>
|
||||
<DanmakuStage
|
||||
key={media.id}
|
||||
@@ -441,19 +589,40 @@ export function PlayerVideoStage({
|
||||
onLoaded={onDanmakuLoaded}
|
||||
onCandidates={onDanmakuCandidates}
|
||||
/>
|
||||
{/* 自定义沉浸式字幕层:纯透明背景 + 柔和阴影,完全消除浏览器原生黑框 */}
|
||||
{activeCueText ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-4 sm:bottom-6 md:bottom-8 z-10 flex justify-center text-center px-4">
|
||||
<span
|
||||
className="inline-block max-w-[92%] whitespace-pre-line text-center font-sans font-medium text-white text-base sm:text-lg md:text-xl lg:text-2xl select-none"
|
||||
style={{
|
||||
textShadow:
|
||||
'0 1px 3px rgba(0, 0, 0, 0.95), 0 0 8px rgba(0, 0, 0, 0.85), 0 0 16px rgba(0, 0, 0, 0.65)',
|
||||
lineHeight: 1.35,
|
||||
}}
|
||||
>
|
||||
{activeCueText}
|
||||
</span>
|
||||
{tracksArmed && media && assTrack && assFallbackPath !== assTrack.path ? (
|
||||
<AssSubtitleStage
|
||||
key={`${media.id}:${assTrack.path}`}
|
||||
videoRef={videoRef}
|
||||
source={subtitlesAPI.assUrl(media.id, assTrack.path)}
|
||||
timeOffset={streamOffset}
|
||||
chineseMode={assTrack.source === 'external' ? subtitleChineseMode : 'original'}
|
||||
onError={(error) => {
|
||||
console.warn('libass subtitle rendering failed; falling back to WebVTT', error)
|
||||
setAssFallbackPath(assTrack.path)
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{/* SRT/VTT 自定义字幕层:按 cue 位置对齐,并支持用户样式覆盖。 */}
|
||||
{activeCues.length > 0 ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-10">
|
||||
{buildSubtitleRenderGroups(activeCues, subtitlePosition).map((group) => (
|
||||
<div
|
||||
key={group.key}
|
||||
className="absolute flex px-4"
|
||||
style={group.style}
|
||||
>
|
||||
<span
|
||||
className="inline-block whitespace-pre-line font-sans text-white select-none"
|
||||
style={{
|
||||
...subtitleTextStyle(subtitleStyle),
|
||||
fontSize: 'clamp(16px, 2.1vw, 30px)',
|
||||
lineHeight: 1.35,
|
||||
}}
|
||||
>
|
||||
{group.text}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -464,6 +633,12 @@ export function PlayerVideoStage({
|
||||
subs={subs}
|
||||
subtitleIndex={subtitleIndex}
|
||||
onSelectSubtitle={onSelectSubtitle}
|
||||
subtitleChineseMode={subtitleChineseMode}
|
||||
onSubtitleChineseModeChange={onSubtitleChineseModeChange}
|
||||
subtitlePosition={subtitlePosition}
|
||||
onSubtitlePositionChange={onSubtitlePositionChange}
|
||||
subtitleStyle={subtitleStyle}
|
||||
onSubtitleStyleChange={onSubtitleStyleChange}
|
||||
danmakuOpen={danmakuOpen}
|
||||
danmakuEnabled={danmakuEnabled}
|
||||
onToggleDanmaku={onToggleDanmaku}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user