Compare commits

..

7 Commits

Author SHA1 Message Date
truewhile d6355d5582 Keep TV episodes as mixed-series representatives 2026-09-11 19:52:49 +08:00
truewhile 0f06bdf929 支持 ASS 字幕原样渲染与字幕显示设置 2026-09-11 19:35:08 +08:00
truewhile e840cbd2c9 处理bug 2026-09-11 15:35:02 +08:00
truewhile 2bb3610da7 添加繁体转简体,简体转繁体功能 2026-09-11 15:14:24 +08:00
truewhile 9ca1b28cb4 优化 2026-09-10 22:30:17 +08:00
truewhile 9effb1422b 优化 2026-09-10 22:06:28 +08:00
truewhile b855e00345 优化 2026-09-10 16:58:41 +08:00
64 changed files with 2242 additions and 251 deletions
+1
View File
@@ -14,6 +14,7 @@ verify-cache/
verify-media/
verify-downloads/
.codex-*
.codex/
.tmp/
.tmp_*
.tmp-deploy-*
+1
View File
@@ -66,6 +66,7 @@ config.yaml
.tmp-live-backups/
.tmp-*
.codex-*
.codex/
downloads/
media/
*.pid
@@ -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
View File
@@ -34,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")
+3
View File
@@ -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.
+101 -5
View File
@@ -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,10 +28,18 @@ 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*[集话話期]`)
@@ -38,6 +52,8 @@ var (
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.
@@ -53,11 +69,20 @@ 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])
}
@@ -98,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.
+60
View File
@@ -34,6 +34,15 @@ func TestParseEpisode(t *testing.T) {
{`动漫/示例/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) {
@@ -92,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)
}
}
+6 -3
View File
@@ -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...)
}
+6
View File
@@ -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
}
+42
View File
@@ -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")
+22 -9
View File
@@ -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(&current)
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 {
+15 -5
View File
@@ -410,15 +410,19 @@ func TestGroupMediaSeriesCardsKeepsTheatricalMovieWithTVSeries(t *testing.T) {
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`,
TMDbID: 566466,
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})
@@ -428,6 +432,12 @@ func TestGroupMediaSeriesCardsKeepsTheatricalMovieWithTVSeries(t *testing.T) {
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) {
+36
View File
@@ -1,6 +1,7 @@
package service
import (
"fmt"
"strings"
"testing"
"time"
@@ -114,6 +115,41 @@ func TestGroupMediaVersionsSeparatesAnimeSpecialKindsAndNumbers(t *testing.T) {
}
}
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{
{
@@ -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 {
+5 -1
View File
@@ -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)
+16 -6
View File
@@ -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 {
+60
View File
@@ -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)
}
}
+73 -6
View File
@@ -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)
}
}
+59 -21
View File
@@ -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)
})
}
+6 -1
View File
@@ -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
}
+28 -1
View File
@@ -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
@@ -28,6 +33,21 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media *
} 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"
@@ -107,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
+126
View File
@@ -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)
}
}
+43 -5
View File
@@ -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)
}
})
}
}
+5
View File
@@ -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 != "" {
// 番号海报原图为完整封套横图,在无独立背景图时直接作为背景图写出
+4
View File
@@ -825,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 {
@@ -1113,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 {
@@ -1168,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)
}
@@ -1461,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 {
+66 -2
View File
@@ -234,7 +234,7 @@ func discoverExternalSubtitleTracks(m *model.Media) []SubtitleTrack {
Path: filepath.Join(c, e.Name()),
Codec: codec,
Source: "external",
Delivery: "webvtt",
Delivery: subtitleDeliveryForCodec(codec),
})
}
}
@@ -297,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 {
@@ -314,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"
}
@@ -464,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
@@ -472,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
@@ -480,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
}
+26 -1
View File
@@ -100,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 {
@@ -108,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",
@@ -176,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")
}
}
+45
View File
@@ -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",
+2
View File
@@ -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",
+3
View File
@@ -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 -1
View File
@@ -8,7 +8,7 @@ export interface SubtitleTrack {
url: string
codec: string
source: 'external' | 'embedded'
delivery: 'webvtt' | 'burn'
delivery: 'webvtt' | 'ass' | 'burn'
stream_index?: number
}
@@ -26,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.
+107
View File
@@ -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
}
+3 -1
View File
@@ -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>
)
}
+109 -10
View File
@@ -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>
+2 -34
View File
@@ -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>
)
}
+4 -1
View File
@@ -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
+6 -6
View File
@@ -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
View File
@@ -1,4 +1,3 @@
/* eslint-disable react-refresh/only-export-components */
import { useCallback, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import {
+2 -1
View File
@@ -132,6 +132,7 @@ export function LibraryPage() {
selectedEpisodes,
visibleEpisodes,
selectedSeriesEpisodes,
selectedSeriesAllEpisodes,
selectedSeriesMediaIDs,
handleSeriesClick,
clearSelectedSeries,
@@ -172,7 +173,7 @@ export function LibraryPage() {
role,
library,
selectedSeries,
selectedSeriesEpisodes,
selectedSeriesEpisodes: selectedSeriesAllEpisodes,
reloadCurrentLibrary,
clearSelectedSeries,
setManualMovie,
+2 -2
View File
@@ -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`
+91 -3
View File
@@ -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
@@ -278,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
@@ -465,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(() => {
@@ -761,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') {
@@ -816,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() ||
@@ -846,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}
+245 -70
View File
@@ -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}
+4 -4
View File
@@ -23,7 +23,8 @@ import { imageURL } from '../api/client'
import { scraperAPI } from '../api/scraper'
import type { ScrapeQueueSnapshot, ScrapeTask, ScrapeTaskStatus } from '../types/scraper'
import { apiErrorMessage, formatTime, taskStatusMeta } from './StrmManagePage'
import { PROVIDER_LABELS, ScrapeDetailModal } from './scraper-queue/ScrapeDetailModal'
import { ScrapeDetailModal } from './scraper-queue/ScrapeDetailModal'
import { PROVIDER_LABELS } from './scraper-queue/scrapeLabels'
import { copyToClipboard, useTaskSelection } from './queue-shared'
const FILTERS: { key: 'all' | ScrapeTaskStatus; label: string; icon: typeof Clock; color: string }[] = [
@@ -196,9 +197,8 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
}
// Filter and search
const tasks = snapshot?.tasks ?? []
const filteredTasks = useMemo(() => {
let list = tasks
let list = snapshot?.tasks ?? []
if (filter !== 'all') {
list = list.filter((t) => t.status === filter)
}
@@ -214,7 +214,7 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
)
}
return list
}, [tasks, filter, search])
}, [snapshot?.tasks, filter, search])
const counts = snapshot?.counts
const pendingCount = counts?.pending ?? 0
+4 -4
View File
@@ -21,7 +21,8 @@ import { strmAPI } from '../api/strm'
import type { StrmQueueSnapshot, StrmTask, StrmTaskStatus } from '../types/strm'
import { STRM_PROVIDER_LABELS } from '../types/strm'
import { apiErrorMessage, formatBytes, formatTime, taskStatusMeta } from './StrmManagePage'
import { TaskDetailModal, getFileIcon } from './strm-queue/TaskDetailModal'
import { TaskDetailModal } from './strm-queue/TaskDetailModal'
import { getFileIcon } from './strm-queue/taskFileIcon'
import { copyToClipboard, useTaskSelection } from './queue-shared'
const FILTERS: { key: 'all' | StrmTaskStatus; label: string; icon: typeof Clock; color: string }[] = [
@@ -190,9 +191,8 @@ export function StrmQueuePanel({
}
// Filter and search tasks in memory
const tasks = snapshot?.tasks ?? []
const filteredTasks = useMemo(() => {
let list = tasks
let list = snapshot?.tasks ?? []
if (filter !== 'all') {
list = list.filter((t) => t.status === filter)
}
@@ -207,7 +207,7 @@ export function StrmQueuePanel({
)
}
return list
}, [tasks, filter, search])
}, [snapshot?.tasks, filter, search])
const counts = snapshot?.counts
const pendingCount = counts?.pending ?? 0
@@ -4,21 +4,7 @@ import { AlertCircle, Ban, Copy, ExternalLink, RefreshCw, Sparkles, Trash2, X }
import { imageURL } from '../../api/client'
import type { ScrapeTask } from '../../types/scraper'
import { formatTime, taskStatusMeta } from '../StrmManagePage'
export const PROVIDER_LABELS: Record<string, string> = {
tmdb: 'TheMovieDB',
douban: '豆瓣 Douban',
bangumi: 'Bangumi 番组计划',
thetvdb: 'TheTVDB',
metatube: 'MetaTube',
}
export const TYPE_LABELS: Record<string, string> = {
movie: '电影',
tv: '剧集',
anime: '动漫',
adult: 'Adult',
}
import { PROVIDER_LABELS, TYPE_LABELS } from './scrapeLabels'
export function ScrapeDetailModal({
task,
@@ -0,0 +1,14 @@
export const PROVIDER_LABELS: Record<string, string> = {
tmdb: 'TheMovieDB',
douban: '豆瓣 Douban',
bangumi: 'Bangumi 番组计划',
thetvdb: 'TheTVDB',
metatube: 'MetaTube',
}
export const TYPE_LABELS: Record<string, string> = {
movie: '电影',
tv: '剧集',
anime: '动漫',
adult: 'Adult',
}
+2 -19
View File
@@ -1,26 +1,9 @@
import type { ReactNode } from 'react'
import { AlertCircle, Ban, Copy, FileText, Film, Image as ImageIcon, File, MessageSquare, RefreshCw, Trash2, X } from 'lucide-react'
import { AlertCircle, Ban, Copy, RefreshCw, Trash2, X } from 'lucide-react'
import type { StrmTask } from '../../types/strm'
import { STRM_PROVIDER_LABELS } from '../../types/strm'
import { formatBytes, formatTime, taskStatusMeta } from '../StrmManagePage'
export function getFileIcon(filename: string): ReactNode {
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
if (['nfo', 'txt', 'xml', 'json'].includes(ext)) {
return <FileText size={15} className="text-amber-500 shrink-0" />
}
if (['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'svg'].includes(ext)) {
return <ImageIcon size={15} className="text-blue-500 shrink-0" />
}
if (['srt', 'ass', 'ssa', 'sub', 'vtt'].includes(ext)) {
return <MessageSquare size={15} className="text-purple-500 shrink-0" />
}
if (['mkv', 'mp4', 'avi', 'mov', 'wmv', 'ts', 'flv', 'iso', 'm4v', 'strm'].includes(ext)) {
return <Film size={15} className="text-emerald-500 shrink-0" />
}
return <File size={15} className="text-gray-400 shrink-0" />
}
import { getFileIcon } from './taskFileIcon'
export function TaskDetailModal({
task,
+19
View File
@@ -0,0 +1,19 @@
import type { ReactNode } from 'react'
import { File, FileText, Film, Image as ImageIcon, MessageSquare } from 'lucide-react'
export function getFileIcon(filename: string): ReactNode {
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
if (['nfo', 'txt', 'xml', 'json'].includes(ext)) {
return <FileText size={15} className="text-amber-500 shrink-0" />
}
if (['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'svg'].includes(ext)) {
return <ImageIcon size={15} className="text-blue-500 shrink-0" />
}
if (['srt', 'ass', 'ssa', 'sub', 'vtt'].includes(ext)) {
return <MessageSquare size={15} className="text-purple-500 shrink-0" />
}
if (['mkv', 'mp4', 'avi', 'mov', 'wmv', 'ts', 'flv', 'iso', 'm4v', 'strm'].includes(ext)) {
return <Film size={15} className="text-emerald-500 shrink-0" />
}
return <File size={15} className="text-gray-400 shrink-0" />
}
+1 -1
View File
@@ -180,7 +180,7 @@ function useLibraryActions(refresh: () => Promise<void>) {
}
const toggleCarouselLibrary = async (library: Library) => {
const next = !Boolean(library.carousel_enabled)
const next = !library.carousel_enabled
await libraryAPI.update(library.id, { carousel_enabled: next })
toast.success(next ? `「${library.name}」已加入首页轮播` : `「${library.name}」已移出首页轮播`)
await refresh()
+10 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo } from 'react'
import type { Media } from '../types'
import {
expandSeriesMediaVersions,
getSeriesKey,
isTheatricalFeature,
seasonSortOrder,
@@ -77,11 +78,17 @@ export function useLibrarySeriesSelection({
[selectedEpisodes],
)
const selectedSeriesMediaIDs = useMemo(
() => selectedSeriesEpisodes.filter((ep) => !isTheatricalFeature(ep)).map((ep) => ep.id),
// 批量/整剧操作必须覆盖被折叠进 versions 的每一行, 否则只作用到代表行。
const selectedSeriesAllEpisodes = useMemo(
() => expandSeriesMediaVersions(selectedSeriesEpisodes),
[selectedSeriesEpisodes],
)
const selectedSeriesMediaIDs = useMemo(
() => selectedSeriesAllEpisodes.filter((ep) => !isTheatricalFeature(ep)).map((ep) => ep.id),
[selectedSeriesAllEpisodes],
)
useEffect(() => {
if (loading) return
if (!isSeries) {
@@ -131,6 +138,7 @@ export function useLibrarySeriesSelection({
selectedEpisodes,
visibleEpisodes,
selectedSeriesEpisodes,
selectedSeriesAllEpisodes,
selectedSeriesMediaIDs,
handleSeriesClick,
clearSelectedSeries,
+1
View File
@@ -7,6 +7,7 @@ export interface User {
email?: string
avatar_url?: string
hide_adult?: boolean
subtitle_chinese_mode?: 'original' | 'simplified' | 'traditional'
force_password_reset: boolean
is_active: boolean
allowed_library_ids?: string[]
+43 -7
View File
@@ -127,6 +127,31 @@ export function isTheatricalFeature(media: Media): boolean {
return THEATRICAL_TITLE_RE.test(`${media.title || ''} ${path}`) || THEATRICAL_FOLDER_RE.test(path)
}
/**
* 把剧集列表展开成"每一行 media"。
*
* 剧集接口会把同一集的多版本(含被误判折叠的行)合成一个 item, 真实的行放在
* `versions` 里, 顶层只保留代表行。批量操作(整剧匹配 / 整剧刮削 / 编辑整剧 /
* 删除整剧)必须作用到每一行, 否则只会更新代表行, 折叠掉的版本被漏掉。
*
* 注意 `versions` 已包含代表行本身, 因此命中 versions 时不要再额外加回 item,
* 否则会重复。仅用于「操作」而非展示——展示仍按折叠后的剧集渲染版本切换器。
*/
export function expandSeriesMediaVersions(episodes: Media[] = []): Media[] {
const out: Media[] = []
const seen = new Set<string>()
for (const ep of episodes) {
if (!ep) continue
const rows = ep.versions && ep.versions.length > 0 ? ep.versions : [ep]
for (const row of rows) {
if (!row || !row.id || seen.has(row.id)) continue
seen.add(row.id)
out.push(row)
}
}
return out
}
const SPECIAL_SECTION_PATTERNS: Array<[number, RegExp]> = [
[OVA_SEASON, /(?:^|[^a-z0-9])ovas?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)/i],
[OAD_SEASON, /(?:^|[^a-z0-9])oads?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)/i],
@@ -455,20 +480,31 @@ export function groupSeries(items: Media[] = []): SeriesCard[] {
if (betterSeriesLinkMedia(m, g.linkMedia)) {
g.linkMedia = m
}
const currentArtwork = artworkScore(m)
const representativeArtwork = artworkScore(g.rep)
if (currentArtwork > representativeArtwork) {
if (betterSeriesRepresentative(m, g.rep)) {
g.rep = m
} else if (currentArtwork === representativeArtwork) {
const cur = (m.season_num ?? 0) * 10000 + (m.episode_num ?? 0)
const rep = (g.rep.season_num ?? 0) * 10000 + (g.rep.episode_num ?? 0)
if (cur > 0 && (rep === 0 || cur < rep)) g.rep = m
}
}
}
return Array.from(groups.values())
}
function betterSeriesRepresentative(candidate: Media, current: Media): boolean {
// A theatrical feature can have local poster/background artwork that scores
// higher than the TV poster. Keep the TV row as the series-card identity so
// the movie cannot replace the whole series title and overview.
const candidateTheatrical = isTheatricalFeature(candidate)
const currentTheatrical = isTheatricalFeature(current)
if (candidateTheatrical !== currentTheatrical) return !candidateTheatrical
const candidateArtwork = artworkScore(candidate)
const currentArtwork = artworkScore(current)
if (candidateArtwork !== currentArtwork) return candidateArtwork > currentArtwork
const cur = (candidate.season_num ?? 0) * 10000 + (candidate.episode_num ?? 0)
const rep = (current.season_num ?? 0) * 10000 + (current.episode_num ?? 0)
return cur > 0 && (rep === 0 || cur < rep)
}
function repeatedSeriesExternalRawKey(media: Media): string {
if (!isEpisodeLike(media) && !pathLooksEpisodic(media)) return ''
let identity = ''
+32
View File
@@ -0,0 +1,32 @@
export type SubtitleChineseConverter = (text: string) => string
function commaIndexAtField(line: string, fieldCount: number): number {
let index = -1
for (let field = 0; field < fieldCount; field += 1) {
index = line.indexOf(',', index + 1)
if (index < 0) return -1
}
return index
}
/**
* Convert only the Text column of ASS/SSA Dialogue lines. ASS styling tags,
* drawings, timing and positioning metadata are bytes-for-bytes preserved so
* the event can still be rendered by libass after a Chinese conversion.
*/
export function convertASSContent(
content: string,
convert: SubtitleChineseConverter,
): string {
return content
.replace(/^\uFEFF/, '')
.split(/\r?\n/)
.map((line) => {
const lower = line.toLowerCase()
if (!lower.startsWith('dialogue:')) return line
const textIndex = commaIndexAtField(line, 9)
if (textIndex < 0) return line
return `${line.slice(0, textIndex + 1)}${convert(line.slice(textIndex + 1))}`
})
.join('\n')
}
+30
View File
@@ -0,0 +1,30 @@
export type SubtitleChineseMode = 'original' | 'simplified' | 'traditional'
type ChineseConverter = (text: string) => string
const identity: ChineseConverter = (text) => text
export function normalizeSubtitleChineseMode(value: unknown): SubtitleChineseMode {
return value === 'simplified' || value === 'traditional' ? value : 'original'
}
let simplifiedConverter: Promise<ChineseConverter> | undefined
let traditionalConverter: Promise<ChineseConverter> | undefined
export function loadSubtitleChineseConverter(
mode: SubtitleChineseMode,
): Promise<ChineseConverter> {
if (mode === 'original') return Promise.resolve(identity)
if (mode === 'simplified') {
simplifiedConverter ??= import('opencc-js/t2cn').then(({ Converter }) =>
Converter({ from: 't', to: 'cn' }),
)
return simplifiedConverter
}
traditionalConverter ??= import('opencc-js/cn2t').then(({ Converter }) =>
Converter({ from: 'cn', to: 'tw' }),
)
return traditionalConverter
}
+99
View File
@@ -0,0 +1,99 @@
import type { CSSProperties } from 'react'
export type SubtitlePosition = 'auto' | 'bottom' | 'lower' | 'middle' | 'top'
export type SubtitleStylePreset = 'shadow' | 'outline' | 'box'
export const SUBTITLE_POSITION_OPTIONS: Array<[SubtitlePosition, string]> = [
['auto', '默认位置'],
['bottom', '贴底'],
['lower', '下方'],
['middle', '居中'],
['top', '顶部'],
]
export const SUBTITLE_STYLE_OPTIONS: Array<[SubtitleStylePreset, string]> = [
['shadow', '柔和阴影'],
['outline', '黑边描边'],
['box', '半透明底衬'],
]
const POSITION_KEY = 'mebox.subtitle.position'
const STYLE_KEY = 'mebox.subtitle.style'
export function normalizeSubtitlePosition(value: unknown): SubtitlePosition {
return value === 'bottom' || value === 'lower' || value === 'middle' || value === 'top'
? value
: 'auto'
}
export function normalizeSubtitleStyle(value: unknown): SubtitleStylePreset {
return value === 'outline' || value === 'box' ? value : 'shadow'
}
export function loadSubtitlePosition(): SubtitlePosition {
if (typeof window === 'undefined') return 'auto'
try {
return normalizeSubtitlePosition(window.localStorage.getItem(POSITION_KEY))
} catch {
return 'auto'
}
}
export function loadSubtitleStyle(): SubtitleStylePreset {
if (typeof window === 'undefined') return 'shadow'
try {
return normalizeSubtitleStyle(window.localStorage.getItem(STYLE_KEY))
} catch {
return 'shadow'
}
}
export function saveSubtitlePosition(position: SubtitlePosition): void {
try {
window.localStorage.setItem(POSITION_KEY, position)
} catch {
// localStorage may be unavailable in private mode; the player still works.
}
}
export function saveSubtitleStyle(style: SubtitleStylePreset): void {
try {
window.localStorage.setItem(STYLE_KEY, style)
} catch {
// localStorage may be unavailable in private mode; the player still works.
}
}
export function subtitleTextStyle(style: SubtitleStylePreset): CSSProperties {
if (style === 'outline') {
return {
fontWeight: 700,
textShadow: [
'-1.5px 0 #000',
'1.5px 0 #000',
'0 -1.5px #000',
'0 1.5px #000',
'-1px -1px #000',
'1px -1px #000',
'-1px 1px #000',
'1px 1px #000',
].join(', '),
}
}
if (style === 'box') {
return {
fontWeight: 600,
background: 'rgba(0, 0, 0, 0.72)',
padding: '0.16em 0.5em',
borderRadius: '0.24em',
boxDecorationBreak: 'clone',
WebkitBoxDecorationBreak: 'clone',
textShadow: '0 1px 2px rgba(0, 0, 0, 0.85)',
}
}
return {
fontWeight: 500,
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)',
}
}
+94
View File
@@ -0,0 +1,94 @@
export type SubtitleCueAlignment = 'start' | 'center' | 'end' | 'left' | 'right'
export type SubtitleCueVertical = 'rl' | 'lr'
export type SubtitleCuePositionAlignment = SubtitleCueAlignment | 'line-left' | 'line-right' | 'auto'
export type SubtitleCueSettings = {
align?: SubtitleCueAlignment
line?: string
lineAlign?: SubtitleCueAlignment
position?: number
positionAlign?: SubtitleCuePositionAlignment
size?: number
vertical?: SubtitleCueVertical
}
export type SubtitleCue = {
startTime: number
endTime: number
text: string
settings: SubtitleCueSettings
}
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 parseCueSettings(tokens: string[]): SubtitleCueSettings {
const settings: SubtitleCueSettings = {}
for (const token of tokens) {
const colon = token.indexOf(':')
if (colon <= 0) continue
const key = token.slice(0, colon).toLowerCase()
const value = token.slice(colon + 1)
if (key === 'align') {
if (['start', 'center', 'end', 'left', 'right'].includes(value)) {
settings.align = value as SubtitleCueAlignment
}
} else if (key === 'line') {
settings.line = value
} else if (key === 'linealign') {
if (['start', 'center', 'end'].includes(value)) {
settings.lineAlign = value as SubtitleCueAlignment
}
} else if (key === 'position') {
const position = Number.parseFloat(value)
if (Number.isFinite(position)) settings.position = Math.min(100, Math.max(0, position))
} else if (key === 'positionalign') {
if (['start', 'center', 'end', 'line-left', 'line-right', 'auto'].includes(value)) {
settings.positionAlign = value as SubtitleCuePositionAlignment
}
} else if (key === 'size') {
const size = Number.parseFloat(value)
if (Number.isFinite(size)) settings.size = Math.min(100, Math.max(0, size))
} else if (key === 'vertical') {
if (value === 'rl' || value === 'lr') settings.vertical = value
}
}
return settings
}
export function parseWebVTTCues(body: string): SubtitleCue[] {
const blocks = body
.replace(/^\uFEFF/, '')
.replace(/\r\n?/g, '\n')
.split(/\n{2,}/)
const cues: SubtitleCue[] = []
for (const block of blocks) {
const lines = block.split('\n')
const timingIndex = lines.findIndex((line) => line.includes('-->'))
if (timingIndex < 0) continue
const [rawStart, rawRest] = lines[timingIndex].split('-->', 2)
const rest = rawRest.trim().split(/\s+/)
const startTime = parseVTTTimestamp(rawStart)
const endTime = parseVTTTimestamp(rest[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,
settings: parseCueSettings(rest.slice(1)),
})
}
}
return cues
}
+5
View File
@@ -21,6 +21,11 @@ export default defineConfig({
},
},
},
worker: {
// JASSUB ships its libass worker as an ES module. Vite's default IIFE
// worker output cannot split that worker's own imports.
format: 'es',
},
build: {
outDir: 'dist',
sourcemap: false,