mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
fix(media): 修复emby电影库混入剧集/特别篇识别/剧集合集 + 新增手动编辑元数据
emby 兼容层(hills/yamby 等客户端): - 电影库不再混入剧集:filterLikelyEpisodicPathsFromMovieQuery 按路径 特征(Season/S0x/Specials/电视剧/日番/国漫等)剔除误入电影库的剧集 - itemPayload 改用 mediaShouldBeEpisode:有季集号且(属剧集库或路径像剧集) 才标记为 Episode,修正电影库里剧集被当成 Movie 特别篇识别: - episode_parser 新增 patSpecialSeason,识别 S0/Season 0/Specials/SP/OVA/ OAD/番外/特别篇/特典 文件夹 → 第0季;seasonFromParents 返回 found 标志, 区分"明确第0季"与"未识别默认第1季" - emby seasonDir 正则同步支持 Specials/SP/OVA/特别篇 等 剧集合集: - groupSeries 抽出 isSeriesCard,卡片链接按是否剧集判定,修复剧集卡片 跳转;每集分离的根因(季集号/分组key)随入库识别修复一并改善 手动编辑元数据(自采集视频无TMDB信息时可自定义封面/标题/简介/分类): - 新增 MediaService.UpdateMetadata + PATCH /media/:id/metadata(需admin) - 前端 MetadataEditDialog 接入 LibraryPage 与 MediaDetailPage 其他:scanner/scraper/nfo/local+cloud_metadata/adult 元数据识别配套改进; .gitignore 忽略本地 .dev-cache/.dev-data 运行产物。
This commit is contained in:
@@ -68,3 +68,7 @@ config.yaml
|
||||
downloads/
|
||||
media/
|
||||
*.pid
|
||||
|
||||
# 本地开发运行产物
|
||||
.dev-cache/
|
||||
.dev-data/
|
||||
|
||||
@@ -255,6 +255,28 @@ func getMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func updateMediaMetadataHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req service.MediaMetadataUpdate
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
m, err := svc.Media.UpdateMetadata(c.Request.Context(), c.Param("id"), req)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if strings.Contains(strings.ToLower(err.Error()), "not found") {
|
||||
status = http.StatusNotFound
|
||||
} else if strings.Contains(strings.ToLower(err.Error()), "required") {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, m)
|
||||
}
|
||||
}
|
||||
|
||||
func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
q := c.Query("q")
|
||||
|
||||
@@ -41,6 +41,7 @@ func registerAuthenticatedRoutes(api *gin.RouterGroup, cfg *config.Config, svc *
|
||||
// Media.
|
||||
authed.GET("/media/:id", getMediaHandler(svc))
|
||||
authed.GET("/media", searchMediaHandler(svc))
|
||||
authed.PATCH("/media/:id/metadata", middleware.AdminRequired(), updateMediaMetadataHandler(svc))
|
||||
authed.POST("/media/:id/scrape", middleware.AdminRequired(), scrapeOneHandler(svc))
|
||||
authed.GET("/media/:id/scrape/search", middleware.AdminRequired(), manualScrapeSearchHandler(svc))
|
||||
authed.POST("/media/:id/scrape/apply", middleware.AdminRequired(), manualScrapeApplyOneHandler(svc))
|
||||
|
||||
@@ -377,28 +377,31 @@ func (r *MediaRepository) upsert(ctx context.Context, m *model.Media) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
if existing.ScrapeStatus == "pending" || existing.ScrapeStatus == "" || existing.ScrapeStatus == "no_match" {
|
||||
backfilledExternalID := false
|
||||
if m.TMDbID > 0 && existing.TMDbID <= 0 {
|
||||
status := strings.TrimSpace(existing.ScrapeStatus)
|
||||
canRefreshExternalIDs := status == "pending" || status == "" || status == "no_match" ||
|
||||
m.ScrapeStatus == "matched" || strings.HasPrefix(strings.ToLower(strings.TrimSpace(m.Path)), "cloud://")
|
||||
if canRefreshExternalIDs {
|
||||
changedExternalID := false
|
||||
if m.TMDbID > 0 && existing.TMDbID != m.TMDbID {
|
||||
updates["tm_db_id"] = m.TMDbID
|
||||
backfilledExternalID = true
|
||||
changedExternalID = true
|
||||
}
|
||||
if m.BangumiID > 0 && existing.BangumiID <= 0 {
|
||||
if m.BangumiID > 0 && existing.BangumiID != m.BangumiID {
|
||||
updates["bangumi_id"] = m.BangumiID
|
||||
backfilledExternalID = true
|
||||
changedExternalID = true
|
||||
}
|
||||
if m.DoubanID != "" && existing.DoubanID == "" {
|
||||
if m.DoubanID != "" && strings.TrimSpace(existing.DoubanID) != strings.TrimSpace(m.DoubanID) {
|
||||
updates["douban_id"] = m.DoubanID
|
||||
backfilledExternalID = true
|
||||
changedExternalID = true
|
||||
}
|
||||
if m.TheTVDBID != "" && existing.TheTVDBID == "" {
|
||||
if m.TheTVDBID != "" && strings.TrimSpace(existing.TheTVDBID) != strings.TrimSpace(m.TheTVDBID) {
|
||||
updates["thetvdb_id"] = m.TheTVDBID
|
||||
backfilledExternalID = true
|
||||
changedExternalID = true
|
||||
}
|
||||
if m.Year > 0 && existing.Year <= 0 {
|
||||
updates["year"] = m.Year
|
||||
}
|
||||
if backfilledExternalID && existing.ScrapeStatus == "no_match" {
|
||||
if changedExternalID && (status == "no_match" || status == "matched") && m.ScrapeStatus != "matched" {
|
||||
updates["scrape_status"] = "pending"
|
||||
}
|
||||
}
|
||||
@@ -456,7 +459,7 @@ func (r *MediaRepository) upsert(ctx context.Context, m *model.Media) error {
|
||||
if lib := m.LibraryID; lib != "" && lib != existing.LibraryID {
|
||||
updates["library_id"] = m.LibraryID
|
||||
}
|
||||
if m.SeasonNum > 0 && existing.SeasonNum != m.SeasonNum {
|
||||
if (m.SeasonNum > 0 || m.EpisodeNum > 0) && existing.SeasonNum != m.SeasonNum {
|
||||
updates["season_num"] = m.SeasonNum
|
||||
}
|
||||
if m.EpisodeNum > 0 && existing.EpisodeNum != m.EpisodeNum {
|
||||
|
||||
@@ -72,6 +72,52 @@ func TestMediaUpsertSkipsUnchangedExistingRow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaUpsertRefreshesCloudExternalIDFromPathHint(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.AutoMigrate(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := New(db)
|
||||
lib := model.Library{Name: "OpenList · 国产剧", Path: "cloud://openlist/国产剧", Type: "tv", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := "cloud://openlist/国产剧/折腰 (2025) {tmdb-296753}/Season 1/折腰.S01E01.mkv"
|
||||
existing := model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "折腰",
|
||||
Path: path,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
TMDbID: 220269,
|
||||
ScrapeStatus: "matched",
|
||||
}
|
||||
if err := repos.Media.Upsert(t.Context(), &existing); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next := model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "折腰",
|
||||
Path: path,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
TMDbID: 296753,
|
||||
}
|
||||
if err := repos.Media.Upsert(t.Context(), &next); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got model.Media
|
||||
if err := repos.DB.Where("path = ?", path).First(&got).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.TMDbID != 296753 || got.ScrapeStatus != "pending" {
|
||||
t.Fatalf("cloud path hint should refresh tmdb and retry scrape, got tmdb=%d status=%q", got.TMDbID, got.ScrapeStatus)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeMediaSearchBackend struct {
|
||||
ids []string
|
||||
err error
|
||||
|
||||
@@ -112,23 +112,37 @@ func (p *AdultProvider) resolveBases(ctx context.Context) []string {
|
||||
return nil
|
||||
}
|
||||
configured := []string{}
|
||||
if resolved.BaseURL != "" {
|
||||
configured = append(configured, resolved.BaseURL)
|
||||
}
|
||||
if resolved.Extra != "" {
|
||||
for _, part := range strings.Split(resolved.Extra, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if strings.HasPrefix(part, "http://") || strings.HasPrefix(part, "https://") {
|
||||
configured = append(configured, part)
|
||||
}
|
||||
}
|
||||
}
|
||||
configured = append(configured, adultConfiguredBases(resolved.BaseURL)...)
|
||||
configured = append(configured, adultConfiguredBases(resolved.Extra)...)
|
||||
if len(configured) > 0 {
|
||||
out = append(configured, out...)
|
||||
}
|
||||
return dedupeStrings(out)
|
||||
}
|
||||
|
||||
func adultConfiguredBases(value string) []string {
|
||||
parts := strings.FieldsFunc(value, func(r rune) bool {
|
||||
switch r {
|
||||
case ',', ';', '\n', '\r', '\t', ' ':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(part, "http://") && !strings.HasPrefix(part, "https://") {
|
||||
part = "https://" + part
|
||||
}
|
||||
out = append(out, strings.TrimRight(part, "/"))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func adultSourceKind(base string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(base))
|
||||
host := ""
|
||||
|
||||
@@ -2,7 +2,16 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
func TestNormalizeAdultCode(t *testing.T) {
|
||||
@@ -59,6 +68,49 @@ func TestParseAdultDetailHTMLDerivesDMMPoster(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdultProviderUsesConfiguredMultipleSources(t *testing.T) {
|
||||
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "temporary failure", http.StatusInternalServerError)
|
||||
}))
|
||||
defer bad.Close()
|
||||
|
||||
good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/search":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`<a class="box" href="/v/ssis001"><strong>SSIS-001 多源入口</strong></a>`))
|
||||
case "/v/ssis001":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`<h2 class="title"><strong>SSIS-001 多源命中标题</strong></h2><img class="video-cover" src="/cover.jpg">`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer good.Close()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.APIConfig{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
apiConfig := NewAPIConfigService(zap.NewNop(), repository.New(db), NewCryptoService("", zap.NewNop()))
|
||||
baseURL := bad.URL + "\n" + good.URL
|
||||
if _, err := apiConfig.Update(context.Background(), "adult", APIConfigPatch{BaseURL: &baseURL}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
provider := NewAdultProvider(zap.NewNop(), apiConfig)
|
||||
match, err := provider.Search(context.Background(), "SSIS-001")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if match == nil || match.Title != "多源命中标题" || match.OriginalName != "SSIS-001" || !match.NSFW {
|
||||
t.Fatalf("multi-source adult match = %+v", match)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdultSourceKindRecognizesJavBusMirrors(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"https://javdb.com": "javdb",
|
||||
|
||||
@@ -1133,3 +1133,21 @@ func TestBotAdminUnbindInactiveAndInvalidBindings(t *testing.T) {
|
||||
t.Fatal("invalid binding should be removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramMembershipChatIDsIncludesCommandChatID(t *testing.T) {
|
||||
_, bot := newBotTestService(t)
|
||||
channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"command_chat_id":"-100123"}`}
|
||||
got := bot.telegramMembershipChatIDs(channel)
|
||||
if len(got) != 1 || got[0] != "-100123" {
|
||||
t.Fatalf("telegramMembershipChatIDs() = %#v, want command_chat_id", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramMembershipChatIDsDedupesGroupChannelAndCommandIDs(t *testing.T) {
|
||||
_, bot := newBotTestService(t)
|
||||
channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"group_chat_id":"-100123","channel_chat_id":"-100124","command_chat_id":"-100123"}`}
|
||||
got := bot.telegramMembershipChatIDs(channel)
|
||||
if len(got) != 2 || got[0] != "-100123" || got[1] != "-100124" {
|
||||
t.Fatalf("telegramMembershipChatIDs() = %#v, want deduped ids", got)
|
||||
}
|
||||
}
|
||||
@@ -221,7 +221,7 @@ func mergeCloudMetadata(dst, src *LocalMetadata) *LocalMetadata {
|
||||
if src.TheTVDBID != "" {
|
||||
dst.TheTVDBID = src.TheTVDBID
|
||||
}
|
||||
if src.SeasonNum > 0 {
|
||||
if src.SeasonNum > 0 || src.EpisodeNum > 0 {
|
||||
dst.SeasonNum = src.SeasonNum
|
||||
}
|
||||
if src.EpisodeNum > 0 {
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
)
|
||||
|
||||
// RepairCloudPathMetadata backfills external IDs from cloud paths such as
|
||||
// RepairCloudPathMetadata backfills external IDs from media paths such as
|
||||
// "Movie (2025) {tmdb-123}" so existing placeholder rows can be scraped
|
||||
// without requiring another successful cloud provider traversal.
|
||||
// without requiring another successful filesystem or cloud provider traversal.
|
||||
func (c *Container) RepairCloudPathMetadata(ctx context.Context) (int, error) {
|
||||
if c == nil || c.Repo == nil || c.Repo.DB == nil {
|
||||
return 0, nil
|
||||
@@ -22,7 +22,6 @@ func (c *Container) RepairCloudPathMetadata(ctx context.Context) (int, error) {
|
||||
query := c.Repo.DB.WithContext(ctx).
|
||||
Model(&model.Media{}).
|
||||
Select("id, title, path, year, season_num, episode_num, scrape_status, tm_db_id, bangumi_id, douban_id, thetvdb_id").
|
||||
Where("path LIKE ?", "cloud://%").
|
||||
Where("("+strings.Join([]string{
|
||||
"LOWER(path) LIKE ?",
|
||||
"LOWER(path) LIKE ?",
|
||||
@@ -44,22 +43,22 @@ func (c *Container) RepairCloudPathMetadata(ctx context.Context) (int, error) {
|
||||
updates := map[string]any{}
|
||||
status := strings.TrimSpace(row.ScrapeStatus)
|
||||
enrichable := status == "" || status == "pending" || status == "no_match"
|
||||
backfilledExternalID := false
|
||||
if meta.TMDbID > 0 && row.TMDbID <= 0 {
|
||||
changedExternalID := false
|
||||
if meta.TMDbID > 0 && row.TMDbID != meta.TMDbID {
|
||||
updates["tm_db_id"] = meta.TMDbID
|
||||
backfilledExternalID = true
|
||||
changedExternalID = true
|
||||
}
|
||||
if meta.BangumiID > 0 && row.BangumiID <= 0 {
|
||||
if meta.BangumiID > 0 && row.BangumiID != meta.BangumiID {
|
||||
updates["bangumi_id"] = meta.BangumiID
|
||||
backfilledExternalID = true
|
||||
changedExternalID = true
|
||||
}
|
||||
if strings.TrimSpace(meta.DoubanID) != "" && strings.TrimSpace(row.DoubanID) == "" {
|
||||
if strings.TrimSpace(meta.DoubanID) != "" && strings.TrimSpace(row.DoubanID) != strings.TrimSpace(meta.DoubanID) {
|
||||
updates["douban_id"] = strings.TrimSpace(meta.DoubanID)
|
||||
backfilledExternalID = true
|
||||
changedExternalID = true
|
||||
}
|
||||
if strings.TrimSpace(meta.TheTVDBID) != "" && strings.TrimSpace(row.TheTVDBID) == "" {
|
||||
if strings.TrimSpace(meta.TheTVDBID) != "" && strings.TrimSpace(row.TheTVDBID) != strings.TrimSpace(meta.TheTVDBID) {
|
||||
updates["thetvdb_id"] = strings.TrimSpace(meta.TheTVDBID)
|
||||
backfilledExternalID = true
|
||||
changedExternalID = true
|
||||
}
|
||||
if meta.Year > 0 && row.Year <= 0 {
|
||||
updates["year"] = meta.Year
|
||||
@@ -67,7 +66,7 @@ func (c *Container) RepairCloudPathMetadata(ctx context.Context) (int, error) {
|
||||
if enrichable && strings.TrimSpace(meta.Title) != "" && cloudPathRepairShouldReplaceTitle(row.Title, meta.Title) {
|
||||
updates["title"] = strings.TrimSpace(meta.Title)
|
||||
}
|
||||
if backfilledExternalID && status == "no_match" {
|
||||
if changedExternalID && (status == "" || status == "no_match" || status == "matched") {
|
||||
updates["scrape_status"] = "pending"
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
|
||||
@@ -317,7 +317,7 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
embySeasonDirRE = regexp.MustCompile(`(?i)^(season[\s._-]*\d+|s\d+|第\s*[0-9一二三四五六七八九十百零两]+\s*季)$`)
|
||||
embySeasonDirRE = regexp.MustCompile(`(?i)^(season[\s._-]*\d+|s\d+|specials?|sp|ova|oad|extra|extras|第\s*[0-9一二三四五六七八九十百零两]+\s*季|特别篇|特別篇|番外|特典)$`)
|
||||
embyYearSuffixRE = regexp.MustCompile(`\s*[\((\[]\d{4}[\))\]]\s*$`)
|
||||
embyEpisodeTitleRE = regexp.MustCompile(`(?i)\s*[-_ ]*s\d{1,2}e\d{1,3}.*$`)
|
||||
)
|
||||
@@ -492,6 +492,9 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string
|
||||
if filterBySeasonNumbers && containsItemType(p.IncludeItemTypes, "Movie") && !containsItemType(p.IncludeItemTypes, "Episode") {
|
||||
q = e.filterMovieItems(ctx, q)
|
||||
}
|
||||
if parentKnownNonEpisodic && containsItemType(p.IncludeItemTypes, "Movie") && !containsItemType(p.IncludeItemTypes, "Episode") {
|
||||
q = filterLikelyEpisodicPathsFromMovieQuery(q)
|
||||
}
|
||||
if filterBySeasonNumbers && containsItemType(p.IncludeItemTypes, "Episode") && !containsItemType(p.IncludeItemTypes, "Movie") {
|
||||
q = e.filterEpisodeItems(ctx, q)
|
||||
}
|
||||
@@ -892,7 +895,7 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
|
||||
seriesID := m.SeriesID
|
||||
seriesName := ""
|
||||
seasonID := ""
|
||||
if e.mediaBelongsToEpisodicLibrary(ctx, m) && (m.SeasonNum > 0 || m.EpisodeNum > 0) {
|
||||
if e.mediaShouldBeEpisode(ctx, m) {
|
||||
itemType = "Episode"
|
||||
seriesID = e.seriesIDForMedia(m)
|
||||
seriesName = e.seriesNameForMedia(m)
|
||||
@@ -1027,6 +1030,16 @@ func (e *EmbyService) mediaBelongsToEpisodicLibrary(ctx context.Context, m *mode
|
||||
return embyLibraryTypeIsEpisodic(lib.Type)
|
||||
}
|
||||
|
||||
func (e *EmbyService) mediaShouldBeEpisode(ctx context.Context, m *model.Media) bool {
|
||||
if m == nil || (m.SeasonNum <= 0 && m.EpisodeNum <= 0) {
|
||||
return false
|
||||
}
|
||||
if e.mediaBelongsToEpisodicLibrary(ctx, m) {
|
||||
return true
|
||||
}
|
||||
return embyMediaPathLooksEpisodic(m.Path)
|
||||
}
|
||||
|
||||
func embyLibraryTypeIsEpisodic(typ string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(typ)) {
|
||||
case "tv", "anime", "variety":
|
||||
@@ -1039,9 +1052,10 @@ func embyLibraryTypeIsEpisodic(typ string) bool {
|
||||
func (e *EmbyService) filterMovieItems(ctx context.Context, q *gorm.DB) *gorm.DB {
|
||||
episodicIDs := e.episodicLibraryIDs(ctx)
|
||||
if len(episodicIDs) == 0 {
|
||||
return q
|
||||
return filterLikelyEpisodicPathsFromMovieQuery(q)
|
||||
}
|
||||
return q.Where("(media.season_num = 0 AND media.episode_num = 0) OR media.library_id NOT IN ?", episodicIDs)
|
||||
q = q.Where("(media.season_num = 0 AND media.episode_num = 0) OR media.library_id NOT IN ?", episodicIDs)
|
||||
return filterLikelyEpisodicPathsFromMovieQuery(q)
|
||||
}
|
||||
|
||||
func (e *EmbyService) filterEpisodeItems(ctx context.Context, q *gorm.DB) *gorm.DB {
|
||||
@@ -1065,6 +1079,63 @@ func (e *EmbyService) episodicLibraryIDs(ctx context.Context) []string {
|
||||
return ids
|
||||
}
|
||||
|
||||
func filterLikelyEpisodicPathsFromMovieQuery(q *gorm.DB) *gorm.DB {
|
||||
clause, args := embyLikelyEpisodicPathSQL()
|
||||
if clause == "" {
|
||||
return q
|
||||
}
|
||||
return q.Where("NOT ((media.season_num > 0 OR media.episode_num > 0) AND ("+clause+"))", args...)
|
||||
}
|
||||
|
||||
func embyLikelyEpisodicPathSQL() (string, []any) {
|
||||
patterns := []string{
|
||||
"%/season %/%", "%/season.%/%", "%/season-%/%", "%/season_%/%",
|
||||
"%/s0%/%", "%/s1%/%", "%/s2%/%", "%/s3%/%", "%/s4%/%", "%/s5%/%", "%/s6%/%", "%/s7%/%", "%/s8%/%", "%/s9%/%",
|
||||
"%/special/%", "%/specials/%", "%/sp/%", "%/ova/%", "%/oad/%", "%/extra/%", "%/extras/%",
|
||||
"%/电视剧/%", "%/剧集/%", "%/国产剧/%", "%/欧美剧/%", "%/日韩剧/%", "%/日剧/%", "%/韩剧/%",
|
||||
"%/日番/%", "%/国漫/%", "%/番剧/%", "%/动漫/%", "%/特别篇/%", "%/特別篇/%", "%/番外/%", "%/特典/%",
|
||||
}
|
||||
clauses := make([]string, 0, len(patterns)*2)
|
||||
args := make([]any, 0, len(patterns)*2)
|
||||
for _, pattern := range patterns {
|
||||
clauses = append(clauses, "LOWER(media.path) LIKE ?")
|
||||
args = append(args, pattern)
|
||||
if strings.Contains(pattern, "/") {
|
||||
clauses = append(clauses, "LOWER(media.path) LIKE ?")
|
||||
args = append(args, strings.ReplaceAll(pattern, "/", `\`))
|
||||
}
|
||||
}
|
||||
return strings.Join(clauses, " OR "), args
|
||||
}
|
||||
|
||||
func embyMediaPathLooksEpisodic(path string) bool {
|
||||
normalized := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/"))
|
||||
if normalized == "" {
|
||||
return false
|
||||
}
|
||||
for _, marker := range []string{
|
||||
"/season ", "/season.", "/season-", "/season_", "/special/", "/specials/", "/sp/", "/ova/", "/oad/", "/extra/", "/extras/",
|
||||
"/电视剧/", "/剧集/", "/国产剧/", "/欧美剧/", "/日韩剧/", "/日剧/", "/韩剧/",
|
||||
"/日番/", "/国漫/", "/番剧/", "/动漫/", "/特别篇/", "/特別篇/", "/番外/", "/特典/",
|
||||
} {
|
||||
if strings.Contains(normalized, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, marker := range []string{"/s0", "/s1", "/s2", "/s3", "/s4", "/s5", "/s6", "/s7", "/s8", "/s9"} {
|
||||
if idx := strings.Index(normalized, marker); idx >= 0 {
|
||||
after := idx + len(marker)
|
||||
if after < len(normalized) && normalized[after] >= '0' && normalized[after] <= '9' {
|
||||
slash := after + 1
|
||||
if slash < len(normalized) && normalized[slash] == '/' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e *EmbyService) rememberSeriesGroup(group embySeriesGroup) {
|
||||
if e == nil || strings.TrimSpace(group.ID) == "" {
|
||||
return
|
||||
@@ -1316,7 +1387,7 @@ func (e *EmbyService) seasonsForSeries(series embySeriesGroup) []embySeasonGroup
|
||||
order := []int{}
|
||||
for _, episode := range series.Episodes {
|
||||
seasonNum := episode.SeasonNum
|
||||
if seasonNum <= 0 {
|
||||
if seasonNum < 0 {
|
||||
seasonNum = 1
|
||||
}
|
||||
season, ok := bySeason[seasonNum]
|
||||
@@ -1502,11 +1573,17 @@ func stableEmbyID(prefix string, parts ...string) string {
|
||||
}
|
||||
|
||||
func seasonID(seriesID string, seasonNum int) string {
|
||||
return stableEmbyID(embyVirtualSeasonPrefix, seriesID, strconv.Itoa(maxInt(seasonNum, 1)))
|
||||
if seasonNum < 0 {
|
||||
seasonNum = 1
|
||||
}
|
||||
return stableEmbyID(embyVirtualSeasonPrefix, seriesID, strconv.Itoa(seasonNum))
|
||||
}
|
||||
|
||||
func seasonName(seasonNum int) string {
|
||||
if seasonNum <= 0 {
|
||||
if seasonNum == 0 {
|
||||
return "特别篇"
|
||||
}
|
||||
if seasonNum < 0 {
|
||||
seasonNum = 1
|
||||
}
|
||||
return fmt.Sprintf("第 %d 季", seasonNum)
|
||||
|
||||
@@ -295,6 +295,51 @@ func TestEmbyMovieLibrarySeasonNumbersStayMovies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyMovieLibraryFiltersMisplacedSeriesPaths(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
lib := model.Library{Name: "电影", Path: `/media/movies`, Type: "movie", Enabled: true}
|
||||
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
show := model.Media{
|
||||
Base: model.Base{ID: "misplaced-show"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "错放剧集",
|
||||
Path: `/media/movies/国产剧/错放剧集/Season 01/错放剧集 - S01E01.mkv`,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
}
|
||||
movie := model.Media{
|
||||
Base: model.Base{ID: "movie"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "普通电影",
|
||||
Path: `/media/movies/普通电影.2026.mkv`,
|
||||
}
|
||||
if err := svc.repo.DB.Create(&show).Error; err != nil {
|
||||
t.Fatalf("create show: %v", err)
|
||||
}
|
||||
if err := svc.repo.DB.Create(&movie).Error; err != nil {
|
||||
t.Fatalf("create movie: %v", err)
|
||||
}
|
||||
|
||||
out, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, IncludeItemTypes: []string{"Movie"}, Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("items: %v", err)
|
||||
}
|
||||
items := out["Items"].([]map[string]any)
|
||||
if len(items) != 1 || items[0]["Id"] != movie.ID {
|
||||
t.Fatalf("movie library should filter misplaced series paths, got %#v", items)
|
||||
}
|
||||
|
||||
item, err := svc.Item(t.Context(), show.ID, "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("direct item: %v", err)
|
||||
}
|
||||
if item["Type"] != "Episode" {
|
||||
t.Fatalf("misplaced series path should be typed as Episode directly, got %#v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyMergedLocalCloudMovieVersionsShareMediaSources(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
local := model.Library{Name: "国产电影", Path: `/media/国产电影`, Type: "movie", Enabled: true}
|
||||
|
||||
@@ -22,14 +22,15 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
|
||||
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
|
||||
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
|
||||
patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
|
||||
patDashEpisode = regexp.MustCompile(`[\s._-][-–—]\s*(\d{1,3})(?:\s*(?:v\d+)?)?(?:\s*[\[\(._-]|$)`)
|
||||
patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*([0-9一二三四五六七八九十百零两]+)\s*季`)
|
||||
patSeasonOnly = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\.?\s*\d{1,2}(?:[\s._-]|$)`)
|
||||
patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`)
|
||||
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
|
||||
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
|
||||
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
|
||||
patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
|
||||
patDashEpisode = regexp.MustCompile(`[\s._-][-–—]\s*(\d{1,3})(?:\s*(?:v\d+)?)?(?:\s*[\[\(._-]|$)`)
|
||||
patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*([0-9一二三四五六七八九十百零两]+)\s*季`)
|
||||
patSeasonOnly = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\.?\s*\d{1,2}(?:[\s._-]|$)`)
|
||||
patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`)
|
||||
patSpecialSeason = regexp.MustCompile(`(?i)^(?:s0+|season[\s._-]*0+|specials?|sp|ova|oad|extra|extras|番外|特别篇|特別篇|特典)$`)
|
||||
// patCNSeason 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。
|
||||
patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`)
|
||||
)
|
||||
@@ -50,30 +51,33 @@ func ParseEpisode(path string) (season, episode int) {
|
||||
return
|
||||
}
|
||||
if m := patEP.FindStringSubmatch(name); len(m) >= 2 {
|
||||
season = seasonFromParents(path)
|
||||
if season == 0 {
|
||||
var found bool
|
||||
season, found = seasonFromParents(path)
|
||||
if !found {
|
||||
season = 1
|
||||
}
|
||||
episode = mustAtoi(m[1])
|
||||
return
|
||||
}
|
||||
if m := patCN.FindStringSubmatch(name); len(m) >= 2 {
|
||||
season = seasonFromParents(path)
|
||||
if season == 0 {
|
||||
var found bool
|
||||
season, found = seasonFromParents(path)
|
||||
if !found {
|
||||
season = 1
|
||||
}
|
||||
episode = mustAtoi(m[1])
|
||||
return
|
||||
}
|
||||
if m := patDashEpisode.FindStringSubmatch(name); len(m) >= 2 {
|
||||
season = seasonFromParents(path)
|
||||
if season == 0 {
|
||||
var found bool
|
||||
season, found = seasonFromParents(path)
|
||||
if !found {
|
||||
season = 1
|
||||
}
|
||||
episode = mustAtoi(m[1])
|
||||
return
|
||||
}
|
||||
if parentSeason := seasonFromParents(path); parentSeason > 0 {
|
||||
if parentSeason, found := seasonFromParents(path); found {
|
||||
if m := patBareEpisode.FindStringSubmatch(strings.TrimSpace(name)); len(m) >= 2 {
|
||||
season = parentSeason
|
||||
episode = mustAtoi(m[1])
|
||||
@@ -83,27 +87,41 @@ func ParseEpisode(path string) (season, episode int) {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
func seasonFromParents(path string) int {
|
||||
func seasonFromParents(path string) (int, bool) {
|
||||
dir := filepath.Dir(path)
|
||||
for i := 0; i < 4; i++ {
|
||||
base := filepath.Base(dir)
|
||||
if base == "." || base == string(filepath.Separator) {
|
||||
return 0
|
||||
return 0, false
|
||||
}
|
||||
if m := patSeasonFolder.FindStringSubmatch(base); len(m) >= 3 {
|
||||
for _, group := range m[1:] {
|
||||
if group != "" {
|
||||
return mustAtoi(group)
|
||||
}
|
||||
}
|
||||
if season, ok := seasonFromDir(base); ok {
|
||||
return season, true
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return 0
|
||||
return 0, false
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
return 0
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func seasonFromDir(name string) (int, bool) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return 0, false
|
||||
}
|
||||
if patSpecialSeason.MatchString(name) {
|
||||
return 0, true
|
||||
}
|
||||
if m := patSeasonFolder.FindStringSubmatch(name); len(m) >= 3 {
|
||||
for _, group := range m[1:] {
|
||||
if group != "" {
|
||||
return mustAtoi(group), true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func mustAtoi(s string) int {
|
||||
|
||||
@@ -25,6 +25,10 @@ func TestParseEpisode(t *testing.T) {
|
||||
{`综艺/Season 06/综艺 第17期.mkv`, 6, 17},
|
||||
{`动漫/第二季/04.mkv`, 2, 4},
|
||||
{`动漫/第十季/第十一集.mkv`, 10, 11},
|
||||
{`剧集/S00/剧集 - E01.mkv`, 0, 1},
|
||||
{`剧集/Specials/剧集 - 02.mkv`, 0, 2},
|
||||
{`剧集/特别篇/03.mkv`, 0, 3},
|
||||
{`剧集/剧集 - S00E04.mkv`, 0, 4},
|
||||
{"Movie.2020.1080p.mkv", 0, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
|
||||
@@ -11,24 +11,29 @@ import (
|
||||
// intentionally separate from model.Media because the item may not exist in
|
||||
// the local library yet.
|
||||
type ExternalMediaResult struct {
|
||||
Source string `json:"source"`
|
||||
MediaType string `json:"media_type,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Overview string `json:"overview,omitempty"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
BackdropURL string `json:"backdrop_url,omitempty"`
|
||||
Year int `json:"year,omitempty"`
|
||||
Rating float32 `json:"rating,omitempty"`
|
||||
TMDbID int `json:"tmdb_id,omitempty"`
|
||||
BangumiID int `json:"bangumi_id,omitempty"`
|
||||
DoubanID string `json:"douban_id,omitempty"`
|
||||
TheTVDBID string `json:"thetvdb_id,omitempty"`
|
||||
SubscribeKeyword string `json:"subscribe_keyword"`
|
||||
TotalEpisodes int `json:"total_episodes,omitempty"`
|
||||
DownloadedEpisodes int `json:"downloaded_episodes,omitempty"`
|
||||
LocalMediaCount int `json:"local_media_count,omitempty"`
|
||||
MissingEpisodes []int `json:"missing_episodes,omitempty"`
|
||||
InLibrary bool `json:"in_library"`
|
||||
Source string `json:"source"`
|
||||
MediaType string `json:"media_type,omitempty"`
|
||||
Title string `json:"title"`
|
||||
OriginalName string `json:"original_name,omitempty"`
|
||||
Overview string `json:"overview,omitempty"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
BackdropURL string `json:"backdrop_url,omitempty"`
|
||||
Year int `json:"year,omitempty"`
|
||||
Rating float32 `json:"rating,omitempty"`
|
||||
TMDbID int `json:"tmdb_id,omitempty"`
|
||||
BangumiID int `json:"bangumi_id,omitempty"`
|
||||
DoubanID string `json:"douban_id,omitempty"`
|
||||
TheTVDBID string `json:"thetvdb_id,omitempty"`
|
||||
SubscribeKeyword string `json:"subscribe_keyword"`
|
||||
TotalEpisodes int `json:"total_episodes,omitempty"`
|
||||
DownloadedEpisodes int `json:"downloaded_episodes,omitempty"`
|
||||
LocalMediaCount int `json:"local_media_count,omitempty"`
|
||||
MissingEpisodes []int `json:"missing_episodes,omitempty"`
|
||||
InLibrary bool `json:"in_library"`
|
||||
Languages []string `json:"languages,omitempty"`
|
||||
Countries []string `json:"countries,omitempty"`
|
||||
Genres []string `json:"genres,omitempty"`
|
||||
NSFW bool `json:"nsfw,omitempty"`
|
||||
}
|
||||
|
||||
// SearchExternalMedia fans out one normalized search intent to TMDb, Douban
|
||||
@@ -53,6 +58,7 @@ func SearchExternalMedia(ctx context.Context, query string, year int, mediaType
|
||||
Source: source,
|
||||
MediaType: typ,
|
||||
Title: m.Title,
|
||||
OriginalName: m.OriginalName,
|
||||
Overview: m.Overview,
|
||||
PosterURL: m.PosterURL,
|
||||
BackdropURL: m.BackdropURL,
|
||||
@@ -62,6 +68,10 @@ func SearchExternalMedia(ctx context.Context, query string, year int, mediaType
|
||||
BangumiID: m.BangumiID,
|
||||
SubscribeKeyword: buildSubscribeKeyword(m.Title, m.Year),
|
||||
TotalEpisodes: totalEpisodes,
|
||||
Languages: m.Languages,
|
||||
Countries: m.Countries,
|
||||
Genres: m.Genres,
|
||||
NSFW: m.NSFW,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -263,7 +263,7 @@ func findShowNFO(mediaPath, libraryRoot string) (*nfoDocument, string, error) {
|
||||
for {
|
||||
names := []string{"tvshow.nfo", "series.nfo"}
|
||||
base := filepath.Base(dir)
|
||||
if seasonFromDir(base) > 0 {
|
||||
if _, ok := seasonFromDir(base); ok {
|
||||
parentBase := filepath.Base(filepath.Dir(dir))
|
||||
names = append(names, parentBase+".nfo")
|
||||
}
|
||||
@@ -447,7 +447,7 @@ func mergeEpisodeMetadata(dst, episode *LocalMetadata, doc *nfoDocument) {
|
||||
if episode.TheTVDBID != "" {
|
||||
dst.TheTVDBID = episode.TheTVDBID
|
||||
}
|
||||
if episode.SeasonNum > 0 {
|
||||
if episode.SeasonNum > 0 || episode.EpisodeNum > 0 {
|
||||
dst.SeasonNum = episode.SeasonNum
|
||||
}
|
||||
if episode.EpisodeNum > 0 {
|
||||
|
||||
@@ -67,6 +67,7 @@ func (s *ScraperService) ManualSearch(ctx context.Context, media *model.Media, q
|
||||
Source: source,
|
||||
MediaType: typ,
|
||||
Title: match.Title,
|
||||
OriginalName: match.OriginalName,
|
||||
Overview: match.Overview,
|
||||
PosterURL: match.PosterURL,
|
||||
BackdropURL: match.BackdropURL,
|
||||
@@ -77,9 +78,18 @@ func (s *ScraperService) ManualSearch(ctx context.Context, media *model.Media, q
|
||||
DoubanID: match.DoubanID,
|
||||
TheTVDBID: match.TheTVDBID,
|
||||
SubscribeKeyword: buildSubscribeKeyword(match.Title, match.Year),
|
||||
Languages: match.Languages,
|
||||
Countries: match.Countries,
|
||||
Genres: match.Genres,
|
||||
NSFW: match.NSFW,
|
||||
})
|
||||
}
|
||||
|
||||
if provider == "all" || provider == "adult" {
|
||||
for _, match := range s.manualAdultMatches(ctx, media, query) {
|
||||
add("adult", "adult", match)
|
||||
}
|
||||
}
|
||||
if provider == "all" || provider == "tmdb" {
|
||||
for _, match := range s.manualTMDbMatches(ctx, query, year, mediaType) {
|
||||
typ := mediaType
|
||||
@@ -165,6 +175,10 @@ func (s *ScraperService) manualRequestMatch(ctx context.Context, req ManualScrap
|
||||
return mergeManualRequestIntoMatch(match, req), nil
|
||||
}
|
||||
}
|
||||
case source == "adult":
|
||||
if match := s.manualAdultMatch(ctx, firstText(req.OriginalName, req.Title)); match != nil {
|
||||
return mergeManualRequestIntoMatch(match, req), nil
|
||||
}
|
||||
}
|
||||
return fallback()
|
||||
}
|
||||
@@ -180,18 +194,52 @@ func (s *ScraperService) manualTMDbMatches(ctx context.Context, query string, ye
|
||||
}
|
||||
out := make([]*Match, 0, 2)
|
||||
if mediaType == "" || mediaType == "movie" {
|
||||
if match, err := s.tmdb.SearchMovie(ctx, query, year); err == nil && match != nil {
|
||||
out = append(out, match)
|
||||
if matches, err := s.tmdb.SearchMovieCandidates(ctx, query, year); err == nil {
|
||||
out = append(out, matches...)
|
||||
}
|
||||
}
|
||||
if mediaType == "" || mediaType == "tv" || mediaType == "anime" || mediaType == "variety" {
|
||||
if match, err := s.tmdb.SearchTV(ctx, query, year); err == nil && match != nil {
|
||||
if matches, err := s.tmdb.SearchTVCandidates(ctx, query, year); err == nil {
|
||||
out = append(out, matches...)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *ScraperService) manualAdultMatches(ctx context.Context, media *model.Media, query string) []*Match {
|
||||
candidates := []string{query}
|
||||
if media != nil {
|
||||
candidates = append(candidates, media.Path, media.OriginalName, media.Title)
|
||||
}
|
||||
out := make([]*Match, 0, 1)
|
||||
seen := map[string]struct{}{}
|
||||
for _, candidate := range candidates {
|
||||
code := normalizeAdultCode(candidate)
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[code]; ok {
|
||||
continue
|
||||
}
|
||||
seen[code] = struct{}{}
|
||||
if match := s.manualAdultMatch(ctx, code); match != nil {
|
||||
out = append(out, match)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *ScraperService) manualAdultMatch(ctx context.Context, code string) *Match {
|
||||
if s.adult == nil || !s.adult.Enabled() {
|
||||
return nil
|
||||
}
|
||||
match, err := s.adult.Search(ctx, code)
|
||||
if err != nil || match == nil {
|
||||
return nil
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
func (s *ScraperService) manualTMDbMatchByID(ctx context.Context, id int, mediaType string) *Match {
|
||||
if s.tmdb == nil || !s.tmdb.Enabled() || id <= 0 {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
)
|
||||
|
||||
type MediaMetadataUpdate struct {
|
||||
Title *string `json:"title"`
|
||||
OriginalName *string `json:"original_name"`
|
||||
Overview *string `json:"overview"`
|
||||
PosterURL *string `json:"poster_url"`
|
||||
BackdropURL *string `json:"backdrop_url"`
|
||||
Year *int `json:"year"`
|
||||
Rating *float32 `json:"rating"`
|
||||
SeasonNum *int `json:"season_num"`
|
||||
EpisodeNum *int `json:"episode_num"`
|
||||
TMDbID *int `json:"tmdb_id"`
|
||||
BangumiID *int `json:"bangumi_id"`
|
||||
DoubanID *string `json:"douban_id"`
|
||||
TheTVDBID *string `json:"thetvdb_id"`
|
||||
Languages *string `json:"languages"`
|
||||
Countries *string `json:"countries"`
|
||||
Genres *string `json:"genres"`
|
||||
NSFW *bool `json:"nsfw"`
|
||||
}
|
||||
|
||||
func (s *MediaService) UpdateMetadata(ctx context.Context, id string, req MediaMetadataUpdate) (*model.Media, error) {
|
||||
if s == nil || s.repo == nil || s.repo.DB == nil {
|
||||
return nil, errors.New("media service unavailable")
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return nil, errors.New("media id required")
|
||||
}
|
||||
if existing, err := s.repo.Media.FindByID(ctx, id); err != nil {
|
||||
return nil, err
|
||||
} else if existing == nil {
|
||||
return nil, errors.New("media not found")
|
||||
}
|
||||
updates := map[string]any{"scrape_status": "matched"}
|
||||
if req.Title != nil {
|
||||
title := strings.TrimSpace(*req.Title)
|
||||
if title == "" {
|
||||
return nil, errors.New("title required")
|
||||
}
|
||||
updates["title"] = title
|
||||
}
|
||||
if req.OriginalName != nil {
|
||||
updates["original_name"] = strings.TrimSpace(*req.OriginalName)
|
||||
}
|
||||
if req.Overview != nil {
|
||||
updates["overview"] = strings.TrimSpace(*req.Overview)
|
||||
}
|
||||
if req.PosterURL != nil {
|
||||
updates["poster_url"] = strings.TrimSpace(*req.PosterURL)
|
||||
}
|
||||
if req.BackdropURL != nil {
|
||||
updates["backdrop_url"] = strings.TrimSpace(*req.BackdropURL)
|
||||
}
|
||||
if req.Year != nil {
|
||||
updates["year"] = clampNonNegativeInt(*req.Year)
|
||||
}
|
||||
if req.Rating != nil {
|
||||
updates["rating"] = clampRating(*req.Rating)
|
||||
}
|
||||
if req.SeasonNum != nil {
|
||||
updates["season_num"] = clampNonNegativeInt(*req.SeasonNum)
|
||||
}
|
||||
if req.EpisodeNum != nil {
|
||||
updates["episode_num"] = clampNonNegativeInt(*req.EpisodeNum)
|
||||
}
|
||||
if req.TMDbID != nil {
|
||||
updates["tm_db_id"] = clampNonNegativeInt(*req.TMDbID)
|
||||
}
|
||||
if req.BangumiID != nil {
|
||||
updates["bangumi_id"] = clampNonNegativeInt(*req.BangumiID)
|
||||
}
|
||||
if req.DoubanID != nil {
|
||||
updates["douban_id"] = strings.TrimSpace(*req.DoubanID)
|
||||
}
|
||||
if req.TheTVDBID != nil {
|
||||
updates["thetvdb_id"] = strings.TrimSpace(*req.TheTVDBID)
|
||||
}
|
||||
if req.Languages != nil {
|
||||
updates["languages"] = normalizeMetadataCSV(*req.Languages)
|
||||
}
|
||||
if req.Countries != nil {
|
||||
updates["countries"] = normalizeMetadataCSV(*req.Countries)
|
||||
}
|
||||
if req.Genres != nil {
|
||||
updates["genres"] = normalizeMetadataCSV(*req.Genres)
|
||||
}
|
||||
if req.NSFW != nil {
|
||||
updates["nsfw"] = *req.NSFW
|
||||
}
|
||||
if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.invalidateMediaCache(ctx)
|
||||
return s.repo.Media.FindByID(ctx, id)
|
||||
}
|
||||
|
||||
func normalizeMetadataCSV(value string) string {
|
||||
parts := strings.FieldsFunc(value, func(r rune) bool {
|
||||
switch r {
|
||||
case ',', ',', ';', ';', '\n', '\r', '\t':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
out := make([]string, 0, len(parts))
|
||||
seen := map[string]struct{}{}
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(part)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, part)
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
func clampNonNegativeInt(value int) int {
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func clampRating(value float32) float32 {
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
if value > 10 {
|
||||
return 10
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -235,17 +235,17 @@ func TestGroupMediaVersionsMergesEpisodeByExternalIDAcrossLibraries(t *testing.T
|
||||
Path: "/media/国产剧/折腰 (2025)/Season 1/折腰.S01E01.mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
TMDbID: 220269,
|
||||
TMDbID: 296753,
|
||||
SizeBytes: 100,
|
||||
PosterURL: "https://image.tmdb.org/t/p/w500/poster.jpg",
|
||||
}
|
||||
cloud := model.Media{
|
||||
LibraryID: "cloud-tv",
|
||||
Title: "折腰",
|
||||
Path: "cloud://openlist/国产剧/折腰 (2025) {tmdb-220269}/Season 1/折腰.S01E01.mkv",
|
||||
Path: "cloud://openlist/国产剧/折腰 (2025) {tmdb-296753}/Season 1/折腰.S01E01.mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
TMDbID: 220269,
|
||||
TMDbID: 296753,
|
||||
SizeBytes: 200,
|
||||
STRMURL: "/api/cloud/play/openlist?ref=/国产剧/折腰/01.mkv",
|
||||
}
|
||||
@@ -259,6 +259,49 @@ func TestGroupMediaVersionsMergesEpisodeByExternalIDAcrossLibraries(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateMediaMetadataMarksManualMatch(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "自采集", Path: "/media/custom", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
media := model.Media{Base: model.Base{ID: "custom-media"}, LibraryID: lib.ID, Title: "raw", Path: "/media/custom/raw.mp4", ScrapeStatus: "no_match"}
|
||||
if err := repos.DB.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
|
||||
title := "手动标题"
|
||||
overview := "手动简介"
|
||||
season := 0
|
||||
episode := 1
|
||||
tmdbID := 12345
|
||||
nsfw := true
|
||||
updated, err := svc.UpdateMetadata(t.Context(), media.ID, MediaMetadataUpdate{
|
||||
Title: &title,
|
||||
Overview: &overview,
|
||||
SeasonNum: &season,
|
||||
EpisodeNum: &episode,
|
||||
TMDbID: &tmdbID,
|
||||
NSFW: &nsfw,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update metadata: %v", err)
|
||||
}
|
||||
if updated.Title != title || updated.Overview != overview || updated.ScrapeStatus != "matched" {
|
||||
t.Fatalf("metadata not saved: %#v", updated)
|
||||
}
|
||||
if updated.SeasonNum != 0 || updated.EpisodeNum != 1 || updated.TMDbID != tmdbID || !updated.NSFW {
|
||||
t.Fatalf("ids/episode metadata not saved: %#v", updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaUpsertBackfillsExternalIDsForPendingCloudRows(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
@@ -268,7 +311,7 @@ func TestMediaUpsertBackfillsExternalIDsForPendingCloudRows(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
path := "cloud://openlist/国漫/折腰 (2025) {tmdb-220269}/Season 1/折腰.S01E01.mkv"
|
||||
path := "cloud://openlist/国漫/折腰 (2025) {tmdb-296753}/Season 1/折腰.S01E01.mkv"
|
||||
if err := repos.DB.Create(&model.Media{
|
||||
LibraryID: "cloud-tv",
|
||||
Title: "折腰",
|
||||
@@ -285,7 +328,7 @@ func TestMediaUpsertBackfillsExternalIDsForPendingCloudRows(t *testing.T) {
|
||||
Path: path,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
TMDbID: 220269,
|
||||
TMDbID: 296753,
|
||||
Year: 2025,
|
||||
ScrapeStatus: "pending",
|
||||
}); err != nil {
|
||||
@@ -295,11 +338,53 @@ func TestMediaUpsertBackfillsExternalIDsForPendingCloudRows(t *testing.T) {
|
||||
if err := repos.DB.First(&got, "path = ?", path).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.TMDbID != 220269 || got.Year != 2025 || got.ScrapeStatus != "pending" {
|
||||
if got.TMDbID != 296753 || got.Year != 2025 || got.ScrapeStatus != "pending" {
|
||||
t.Fatalf("pending cloud row was not backfilled correctly: tmdb=%d year=%d status=%q", got.TMDbID, got.Year, got.ScrapeStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaUpsertCorrectsCloudExternalIDConflicts(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Media{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
path := "cloud://openlist/国产剧/折腰 (2025) {tmdb-296753}/Season 1/折腰.S01E01.mkv"
|
||||
if err := repos.DB.Create(&model.Media{
|
||||
LibraryID: "cloud-tv",
|
||||
Title: "折腰",
|
||||
Path: path,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
TMDbID: 220269,
|
||||
ScrapeStatus: "matched",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Media.Upsert(t.Context(), &model.Media{
|
||||
LibraryID: "cloud-tv",
|
||||
Title: "折腰",
|
||||
Path: path,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
TMDbID: 296753,
|
||||
Year: 2025,
|
||||
ScrapeStatus: "pending",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got model.Media
|
||||
if err := repos.DB.First(&got, "path = ?", path).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.TMDbID != 296753 || got.ScrapeStatus != "pending" {
|
||||
t.Fatalf("cloud external id conflict was not corrected: tmdb=%d status=%q", got.TMDbID, got.ScrapeStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairCloudPathMetadataBackfillsExistingPlaceholders(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
@@ -335,6 +420,44 @@ func TestRepairCloudPathMetadataBackfillsExistingPlaceholders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairCloudPathMetadataCorrectsConflictingMatchedID(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Media{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
path := "cloud://openlist/国产剧/折腰 (2025) {tmdb-296753}/Season 1/折腰.S01E01.mkv"
|
||||
if err := repos.DB.Create(&model.Media{
|
||||
LibraryID: "cloud-tv",
|
||||
Title: "折腰",
|
||||
Path: path,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
TMDbID: 220269,
|
||||
ScrapeStatus: "matched",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
container := &Container{Repo: repos, Log: zap.NewNop()}
|
||||
repaired, err := container.RepairCloudPathMetadata(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repaired != 1 {
|
||||
t.Fatalf("repaired = %d, want 1", repaired)
|
||||
}
|
||||
var got model.Media
|
||||
if err := repos.DB.First(&got, "path = ?", path).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.TMDbID != 296753 || got.ScrapeStatus != "pending" {
|
||||
t.Fatalf("conflicting matched id was not repaired: tmdb=%d status=%q", got.TMDbID, got.ScrapeStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoftDeleteCloudMediaPurgesRecordWithoutRecycleBin(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
|
||||
@@ -57,8 +57,8 @@ type episodeNFO struct {
|
||||
XMLName xml.Name `xml:"episodedetails"`
|
||||
Title string `xml:"title"`
|
||||
ShowTitle string `xml:"showtitle,omitempty"`
|
||||
Season int `xml:"season,omitempty"`
|
||||
Episode int `xml:"episode,omitempty"`
|
||||
Season int `xml:"season"`
|
||||
Episode int `xml:"episode"`
|
||||
Year int `xml:"year,omitempty"`
|
||||
Plot string `xml:"plot,omitempty"`
|
||||
Rating float32 `xml:"rating,omitempty"`
|
||||
|
||||
@@ -1840,16 +1840,16 @@ func cloudMetadataNeedsRefresh(existing existingCloudMedia, localMeta *LocalMeta
|
||||
if localMeta.Year > 0 && existing.Year <= 0 {
|
||||
return true
|
||||
}
|
||||
if localMeta.TMDbID > 0 && existing.TMDbID <= 0 {
|
||||
if localMeta.TMDbID > 0 && existing.TMDbID != localMeta.TMDbID {
|
||||
return true
|
||||
}
|
||||
if localMeta.BangumiID > 0 && existing.BangumiID <= 0 {
|
||||
if localMeta.BangumiID > 0 && existing.BangumiID != localMeta.BangumiID {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(localMeta.DoubanID) != "" && strings.TrimSpace(existing.DoubanID) == "" {
|
||||
if strings.TrimSpace(localMeta.DoubanID) != "" && strings.TrimSpace(existing.DoubanID) != strings.TrimSpace(localMeta.DoubanID) {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(localMeta.TheTVDBID) != "" && strings.TrimSpace(existing.TheTVDBID) == "" {
|
||||
if strings.TrimSpace(localMeta.TheTVDBID) != "" && strings.TrimSpace(existing.TheTVDBID) != strings.TrimSpace(localMeta.TheTVDBID) {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(localMeta.PosterURL) != "" && strings.TrimSpace(existing.PosterURL) == "" {
|
||||
@@ -1897,7 +1897,7 @@ func cloudSeriesTitleFromMediaPath(mediaPath string) (string, int) {
|
||||
}
|
||||
base := strings.TrimSpace(dirs[len(dirs)-1])
|
||||
usedSeasonFolder := false
|
||||
if seasonFromDir(base) > 0 {
|
||||
if _, ok := seasonFromDir(base); ok {
|
||||
usedSeasonFolder = true
|
||||
dirs = dirs[:len(dirs)-1]
|
||||
if len(dirs) == 0 {
|
||||
@@ -2387,7 +2387,7 @@ func applyLocalMetadata(m *model.Media, local *LocalMetadata) {
|
||||
if local.TheTVDBID != "" {
|
||||
m.TheTVDBID = local.TheTVDBID
|
||||
}
|
||||
if local.SeasonNum > 0 {
|
||||
if local.SeasonNum > 0 || local.EpisodeNum > 0 {
|
||||
m.SeasonNum = local.SeasonNum
|
||||
}
|
||||
if local.EpisodeNum > 0 {
|
||||
@@ -2405,11 +2405,15 @@ func applyLocalMetadata(m *model.Media, local *LocalMetadata) {
|
||||
if local.NSFW {
|
||||
m.NSFW = true
|
||||
}
|
||||
if local.HasNFO || (!local.PathHint && localHasDescriptiveMetadata(local)) {
|
||||
if localMetadataMarksMatched(local) {
|
||||
m.ScrapeStatus = "matched"
|
||||
}
|
||||
}
|
||||
|
||||
func localMetadataMarksMatched(local *LocalMetadata) bool {
|
||||
return local != nil && (local.HasNFO || (!local.PathHint && localHasDescriptiveMetadata(local)))
|
||||
}
|
||||
|
||||
func localHasDescriptiveMetadata(local *LocalMetadata) bool {
|
||||
if local == nil {
|
||||
return false
|
||||
|
||||
@@ -409,6 +409,21 @@ func TestCloudSeriesTitlePrefersShowFolder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudMetadataNeedsRefreshWhenPathHintConflicts(t *testing.T) {
|
||||
existing := existingCloudMedia{
|
||||
Year: 2025,
|
||||
TMDbID: 220269,
|
||||
}
|
||||
local := &LocalMetadata{
|
||||
Year: 2025,
|
||||
TMDbID: 296753,
|
||||
PathHint: true,
|
||||
}
|
||||
if !cloudMetadataNeedsRefresh(existing, local) {
|
||||
t.Fatal("conflicting explicit cloud path hint should refresh existing media")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanCloudLibraryReadsRemoteSTRMTarget(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
|
||||
+79
-21
@@ -224,6 +224,9 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
|
||||
} else if err != nil {
|
||||
s.log.Warn("read local metadata before scrape failed", zap.String("media_id", m.ID), zap.Error(err))
|
||||
}
|
||||
} else if hinted, _ := pathHintMetadata(m.Path, seriesLike); hinted != nil {
|
||||
local = mergeCloudMetadata(local, hinted)
|
||||
applyLocalMetadata(m, local)
|
||||
}
|
||||
|
||||
year := mediaYearHint(m)
|
||||
@@ -299,6 +302,9 @@ func (s *ScraperService) matchFromMediaExternalIDs(ctx context.Context, m *model
|
||||
mediaType = lib.Type
|
||||
}
|
||||
if m.TMDbID > 0 {
|
||||
if mediaIsEpisodic(m, lib) {
|
||||
mediaType = "tv"
|
||||
}
|
||||
if match := s.manualTMDbMatchByID(ctx, m.TMDbID, normalizeMediaType(mediaType, m.Title, "")); match != nil {
|
||||
return match
|
||||
}
|
||||
@@ -422,6 +428,15 @@ func mergeLocalMetadataIntoMatch(match *Match, local *LocalMetadata) {
|
||||
if local.TMDbID > 0 {
|
||||
match.TMDbID = local.TMDbID
|
||||
}
|
||||
if local.BangumiID > 0 {
|
||||
match.BangumiID = local.BangumiID
|
||||
}
|
||||
if local.DoubanID != "" {
|
||||
match.DoubanID = local.DoubanID
|
||||
}
|
||||
if local.TheTVDBID != "" {
|
||||
match.TheTVDBID = local.TheTVDBID
|
||||
}
|
||||
if local.Genres != "" {
|
||||
match.Genres = splitNFOList(local.Genres)
|
||||
}
|
||||
@@ -483,7 +498,7 @@ func (s *ScraperService) applyProviderMatch(ctx context.Context, m *model.Media,
|
||||
// Manual cloud/batch applies must not fail just because an optional provider
|
||||
// details request is slow or unavailable.
|
||||
if match.TMDbID > 0 && s.tmdb != nil && s.tmdb.Enabled() {
|
||||
mediaType := s.determineMediaType(lib, match)
|
||||
mediaType := s.determineMediaTypeForMedia(lib, m, match)
|
||||
detailCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), tmdbDetailsTimeout)
|
||||
details, err := s.tmdb.GetDetails(detailCtx, match.TMDbID, mediaType)
|
||||
cancel()
|
||||
@@ -518,6 +533,50 @@ func (s *ScraperService) applyProviderMatch(ctx context.Context, m *model.Media,
|
||||
zap.Strings("countries", details.Countries),
|
||||
zap.Strings("genres", details.Genres))
|
||||
}
|
||||
if mediaType == "tv" && m != nil && m.EpisodeNum > 0 {
|
||||
episodeCtx, episodeCancel := context.WithTimeout(context.WithoutCancel(ctx), tmdbDetailsTimeout)
|
||||
episode, err := s.tmdb.GetTVEpisodeDetails(episodeCtx, match.TMDbID, m.SeasonNum, m.EpisodeNum)
|
||||
episodeCancel()
|
||||
if err != nil {
|
||||
s.log.Debug("failed to get tmdb episode details",
|
||||
zap.String("media_id", m.ID),
|
||||
zap.Int("tmdb_id", match.TMDbID),
|
||||
zap.Int("season", m.SeasonNum),
|
||||
zap.Int("episode", m.EpisodeNum),
|
||||
zap.Error(err))
|
||||
} else if episode != nil {
|
||||
episodeUpdates := map[string]any{}
|
||||
if strings.TrimSpace(episode.Name) != "" {
|
||||
episodeUpdates["original_name"] = strings.TrimSpace(episode.Name)
|
||||
}
|
||||
if strings.TrimSpace(episode.Overview) != "" {
|
||||
episodeUpdates["overview"] = strings.TrimSpace(episode.Overview)
|
||||
}
|
||||
if strings.TrimSpace(episode.StillURL) != "" {
|
||||
episodeUpdates["backdrop_url"] = strings.TrimSpace(episode.StillURL)
|
||||
}
|
||||
if episode.Rating > 0 {
|
||||
episodeUpdates["rating"] = episode.Rating
|
||||
}
|
||||
if episode.AirYear > 0 && match.Year <= 0 {
|
||||
episodeUpdates["year"] = episode.AirYear
|
||||
}
|
||||
if episode.Runtime > 0 && m.DurationSec <= 0 {
|
||||
episodeUpdates["duration_sec"] = episode.Runtime * 60
|
||||
}
|
||||
if len(episodeUpdates) > 0 {
|
||||
if err := s.repo.DB.Model(&model.Media{}).Where("id = ?", m.ID).
|
||||
Updates(episodeUpdates).Error; err != nil {
|
||||
s.log.Warn("failed to save tmdb episode metadata",
|
||||
zap.String("media_id", m.ID),
|
||||
zap.Int("tmdb_id", match.TMDbID),
|
||||
zap.Int("season", m.SeasonNum),
|
||||
zap.Int("episode", m.EpisodeNum),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cloudMedia := isCloudMediaPath(m.Path) || (lib != nil && isCloudMediaPath(lib.Path))
|
||||
if !cloudMedia {
|
||||
@@ -548,9 +607,13 @@ func isCloudMediaPath(value string) bool {
|
||||
func (s *ScraperService) applyLocalMetadataMatch(ctx context.Context, m *model.Media, local *LocalMetadata) error {
|
||||
next := *m
|
||||
applyLocalMetadata(&next, local)
|
||||
status := "matched"
|
||||
if !localMetadataMarksMatched(local) {
|
||||
status = "pending"
|
||||
}
|
||||
updates := map[string]any{
|
||||
"title": next.Title,
|
||||
"scrape_status": "matched",
|
||||
"scrape_status": status,
|
||||
}
|
||||
if next.OriginalName != "" {
|
||||
updates["original_name"] = next.OriginalName
|
||||
@@ -582,7 +645,7 @@ func (s *ScraperService) applyLocalMetadataMatch(ctx context.Context, m *model.M
|
||||
if next.TheTVDBID != "" {
|
||||
updates["thetvdb_id"] = next.TheTVDBID
|
||||
}
|
||||
if next.SeasonNum > 0 {
|
||||
if next.SeasonNum > 0 || next.EpisodeNum > 0 {
|
||||
updates["season_num"] = next.SeasonNum
|
||||
}
|
||||
if next.EpisodeNum > 0 {
|
||||
@@ -644,7 +707,7 @@ func scrapeQueryCandidates(m *model.Media, lib *model.Library) []string {
|
||||
|
||||
func seriesFolderTitle(mediaPath, libraryRoot string) string {
|
||||
dir := filepath.Dir(mediaPath)
|
||||
if strictSeasonFolder(filepath.Base(dir)) > 0 {
|
||||
if strictSeasonFolderMatched(filepath.Base(dir)) {
|
||||
dir = filepath.Dir(dir)
|
||||
}
|
||||
if libraryRoot != "" && samePath(dir, filepath.Clean(libraryRoot)) {
|
||||
@@ -687,27 +750,15 @@ func isGenericMediaCategoryFolder(name string) bool {
|
||||
}
|
||||
|
||||
func strictSeasonFolder(name string) int {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return 0
|
||||
}
|
||||
for _, pattern := range strictSeasonFolderPatterns {
|
||||
if m := pattern.FindStringSubmatch(name); len(m) == 2 {
|
||||
return mustAtoi(m[1])
|
||||
}
|
||||
if season, ok := seasonFromDir(name); ok {
|
||||
return season
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func seasonFromDir(name string) int {
|
||||
if m := patSeasonFolder.FindStringSubmatch(name); len(m) >= 3 {
|
||||
for _, group := range m[1:] {
|
||||
if group != "" {
|
||||
return mustAtoi(group)
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
func strictSeasonFolderMatched(name string) bool {
|
||||
_, ok := seasonFromDir(name)
|
||||
return ok
|
||||
}
|
||||
|
||||
func titleCandidates(title string) []string {
|
||||
@@ -961,6 +1012,13 @@ func (s *ScraperService) AnyEnabled() bool {
|
||||
// determineMediaType returns "tv" for TV shows and "movie" for movies.
|
||||
// It uses the library type as the primary signal.
|
||||
func (s *ScraperService) determineMediaType(lib *model.Library, match *Match) string {
|
||||
return s.determineMediaTypeForMedia(lib, nil, match)
|
||||
}
|
||||
|
||||
func (s *ScraperService) determineMediaTypeForMedia(lib *model.Library, media *model.Media, match *Match) string {
|
||||
if media != nil && mediaIsEpisodic(media, lib) {
|
||||
return "tv"
|
||||
}
|
||||
if lib != nil {
|
||||
switch lib.Type {
|
||||
case "tv", "anime", "variety", "show", "shows":
|
||||
|
||||
@@ -46,9 +46,9 @@ func TestCleanQuery(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExternalIDHintsFromText(t *testing.T) {
|
||||
hints := externalIDHintsFromText("国漫/折腰 (2025) {tmdb 220269}/Season 1/折腰.S01E01.mkv")
|
||||
if hints.TMDbID != 220269 {
|
||||
t.Fatalf("tmdb hint = %d, want 220269", hints.TMDbID)
|
||||
hints := externalIDHintsFromText("国漫/折腰 (2025) {tmdb 296753}/Season 1/折腰.S01E01.mkv")
|
||||
if hints.TMDbID != 296753 {
|
||||
t.Fatalf("tmdb hint = %d, want 296753", hints.TMDbID)
|
||||
}
|
||||
hints = externalIDHintsFromText("Movie (2026) {tmdb-1630433} [douban=3622222] {bgm 456789} {tvdb:12345}")
|
||||
if hints.TMDbID != 1630433 || hints.DoubanID != "3622222" || hints.BangumiID != 456789 || hints.TheTVDBID != "12345" {
|
||||
@@ -57,8 +57,8 @@ func TestExternalIDHintsFromText(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPathHintMetadataDoesNotMarkMediaMatched(t *testing.T) {
|
||||
meta, hints := pathHintMetadata("cloud://openlist/国漫/折腰 (2025) {tmdb 220269}/Season 1/折腰.S01E01.mkv", true)
|
||||
if meta == nil || hints.TMDbID != 220269 || meta.TMDbID != 220269 || meta.Title != "折腰" || meta.Year != 2025 {
|
||||
meta, hints := pathHintMetadata("cloud://openlist/国漫/折腰 (2025) {tmdb 296753}/Season 1/折腰.S01E01.mkv", true)
|
||||
if meta == nil || hints.TMDbID != 296753 || meta.TMDbID != 296753 || meta.Title != "折腰" || meta.Year != 2025 {
|
||||
t.Fatalf("path hint metadata = %+v hints=%+v", meta, hints)
|
||||
}
|
||||
media := &model.Media{Title: "折腰", ScrapeStatus: "pending"}
|
||||
@@ -68,6 +68,76 @@ func TestPathHintMetadataDoesNotMarkMediaMatched(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichOneCloudPathHintOverridesStaleTMDbID(t *testing.T) {
|
||||
var requested []string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requested = append(requested, r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/tv/296753":
|
||||
_ = json.NewEncoder(w).Encode(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/国产剧", Type: "tv", Enabled: true}
|
||||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
media := model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "折腰",
|
||||
Path: "cloud://openlist/国产剧/折腰 (2025) {tmdb-296753}/Season 1/折腰.S01E01.mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
TMDbID: 220269,
|
||||
ScrapeStatus: "pending",
|
||||
}
|
||||
if err := repos.DB.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := scraper.EnrichOne(t.Context(), &media); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got model.Media
|
||||
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ScrapeStatus != "matched" || got.TMDbID != 296753 || got.Title != "折腰" || got.PosterURL == "" {
|
||||
t.Fatalf("path hint was not authoritative: status=%q tmdb=%d title=%q poster=%q", got.ScrapeStatus, got.TMDbID, got.Title, got.PosterURL)
|
||||
}
|
||||
for _, path := range requested {
|
||||
if path == "/tv/220269" || path == "/movie/220269" {
|
||||
t.Fatalf("scraper queried stale tmdb id; requests=%v", requested)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualRequestMatchFallsBackToCandidatePayload(t *testing.T) {
|
||||
scraper := &ScraperService{}
|
||||
match, err := scraper.manualRequestMatch(t.Context(), ManualScrapeRequest{
|
||||
@@ -84,6 +154,119 @@ func TestManualRequestMatchFallsBackToCandidatePayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualSearchReturnsTMDbCandidatePage(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.URL.Path != "/search/movie" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"results": []map[string]any{
|
||||
{
|
||||
"id": 101,
|
||||
"title": "错误的同名电影",
|
||||
"poster_path": "/wrong.jpg",
|
||||
"release_date": "2021-01-01",
|
||||
"vote_average": 5.1,
|
||||
"genre_ids": []int{18},
|
||||
"backdrop_path": "/wrong-backdrop.jpg",
|
||||
},
|
||||
{
|
||||
"id": 202,
|
||||
"title": "正确的同名电影",
|
||||
"poster_path": "/right.jpg",
|
||||
"release_date": "2021-08-01",
|
||||
"vote_average": 8.2,
|
||||
"genre_ids": []int{28},
|
||||
"backdrop_path": "/right-backdrop.jpg",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.TMDbAPIKey = "test-key"
|
||||
cfg.Secrets.TMDbAPIProxy = upstream.URL
|
||||
log := zap.NewNop()
|
||||
scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log))
|
||||
|
||||
lib := model.Library{Name: "电影", Path: "/media/movie", Type: "movie", Enabled: true}
|
||||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
media := model.Media{LibraryID: lib.ID, Title: "同名电影", Path: "/media/movie/同名电影.mkv"}
|
||||
if err := repos.DB.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
results, err := scraper.ManualSearch(t.Context(), &media, "同名电影", "tmdb", "movie")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(results) != 2 || results[0].TMDbID != 101 || results[1].TMDbID != 202 {
|
||||
t.Fatalf("manual TMDb candidates = %#v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualSearchIncludesAdultProvider(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/search":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`<a class="box" href="/v/ssis001"><strong>SSIS-001 手动候选</strong></a>`))
|
||||
case "/v/ssis001":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`<h2 class="title"><strong>SSIS-001 手动成人标题</strong></h2><img class="video-cover" src="/cover.jpg">`))
|
||||
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{}, &model.APIConfig{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
apiConfig := NewAPIConfigService(zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||
baseURL := upstream.URL
|
||||
if _, err := apiConfig.Update(t.Context(), "adult", APIConfigPatch{BaseURL: &baseURL}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
log := zap.NewNop()
|
||||
scraper := NewScraperService(&config.Config{}, log, repos, nil, nil, nil, nil, NewHub(log), NewAdultProvider(log, apiConfig))
|
||||
|
||||
lib := model.Library{Name: "成人", Path: "/media/adult", Type: "movie", Enabled: true}
|
||||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
media := model.Media{LibraryID: lib.ID, Title: "SSIS-001", OriginalName: "SSIS-001", Path: "/media/adult/SSIS-001.mkv"}
|
||||
if err := repos.DB.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
results, err := scraper.ManualSearch(t.Context(), &media, "SSIS-001", "adult", "adult")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(results) != 1 || results[0].Source != "adult" || results[0].MediaType != "adult" || !results[0].NSFW || results[0].OriginalName != "SSIS-001" {
|
||||
t.Fatalf("manual adult candidates = %#v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyManualMatchSavesSelectedCloudMatchWhenDetailsSlow(t *testing.T) {
|
||||
oldTimeout := tmdbDetailsTimeout
|
||||
tmdbDetailsTimeout = 20 * time.Millisecond
|
||||
@@ -277,6 +460,46 @@ func TestEnrichOneWritesTMDbIDColumn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichOneWritesTMDbEpisodeMetadata(t *testing.T) {
|
||||
scraper, repos, closeServer := newTestScraper(t)
|
||||
defer closeServer()
|
||||
|
||||
lib := model.Library{Name: "番剧", Path: t.TempDir(), Type: "tv", Enabled: true}
|
||||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(lib.Path, "间谍过家家 - S02E01.mkv")
|
||||
media := model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "间谍过家家",
|
||||
Path: mediaPath,
|
||||
SeasonNum: 2,
|
||||
EpisodeNum: 1,
|
||||
ScrapeStatus: "pending",
|
||||
}
|
||||
if err := repos.DB.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := scraper.EnrichOne(t.Context(), &media); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var got model.Media
|
||||
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.OriginalName != "任务代号: 猫" || got.Overview != "单集剧情" {
|
||||
t.Fatalf("episode metadata not saved: original=%q overview=%q", got.OriginalName, got.Overview)
|
||||
}
|
||||
if !strings.HasSuffix(got.BackdropURL, "/images/w500/still.jpg") || got.DurationSec != 24*60 {
|
||||
t.Fatalf("episode still/runtime not saved: backdrop=%q duration=%d", got.BackdropURL, got.DurationSec)
|
||||
}
|
||||
if got.Rating < 9.09 || got.Rating > 9.11 {
|
||||
t.Fatalf("episode rating = %v, want 9.1", got.Rating)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichOneRejectsWrongYearMatchFromSeriesFolder(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -485,6 +708,15 @@ func newTestScraper(t *testing.T) (*ScraperService, *repository.Container, func(
|
||||
"vote_average": 8.6,
|
||||
}},
|
||||
})
|
||||
case r.URL.Path == "/tv/12345/season/2/episode/1":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"name": "任务代号: 猫",
|
||||
"overview": "单集剧情",
|
||||
"still_path": "/still.jpg",
|
||||
"air_date": "2023-10-07",
|
||||
"vote_average": 9.1,
|
||||
"runtime": 24,
|
||||
})
|
||||
case strings.HasPrefix(r.URL.Path, "/tv/12345"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": 12345,
|
||||
|
||||
+116
-31
@@ -142,9 +142,47 @@ type Match struct {
|
||||
NSFW bool `json:"nsfw,omitempty"`
|
||||
}
|
||||
|
||||
type tmdbMovieSearchResult struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
GenreIDs []int `json:"genre_ids"`
|
||||
}
|
||||
|
||||
type tmdbTVSearchResult struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"original_name"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
GenreIDs []int `json:"genre_ids"`
|
||||
}
|
||||
|
||||
// SearchMovie issues `/search/movie` and returns the best match, or nil
|
||||
// when no result is found. The `year` argument is optional (0 = any).
|
||||
func (t *TMDbProvider) SearchMovie(ctx context.Context, query string, year int) (*Match, error) {
|
||||
matches, err := t.SearchMovieCandidates(ctx, query, year)
|
||||
if err != nil || len(matches) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return matches[0], nil
|
||||
}
|
||||
|
||||
// SearchMovieCandidates returns the first TMDb result page as manual-scrape
|
||||
// candidates. Automatic scrape still uses SearchMovie's first-result behavior,
|
||||
// while manual correction can show alternatives when the top result is wrong.
|
||||
func (t *TMDbProvider) SearchMovieCandidates(ctx context.Context, query string, year int) ([]*Match, error) {
|
||||
if query == "" {
|
||||
return nil, errors.New("empty query")
|
||||
}
|
||||
@@ -166,20 +204,8 @@ func (t *TMDbProvider) SearchMovie(ctx context.Context, query string, year int)
|
||||
}
|
||||
u := base + "/search/movie?" + q.Encode()
|
||||
|
||||
type result struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
GenreIDs []int `json:"genre_ids"`
|
||||
}
|
||||
type page struct {
|
||||
Results []result `json:"results"`
|
||||
Results []tmdbMovieSearchResult `json:"results"`
|
||||
}
|
||||
|
||||
var p page
|
||||
@@ -189,7 +215,14 @@ func (t *TMDbProvider) SearchMovie(ctx context.Context, query string, year int)
|
||||
if len(p.Results) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
r := p.Results[0]
|
||||
out := make([]*Match, 0, len(p.Results))
|
||||
for _, r := range p.Results {
|
||||
out = append(out, t.movieSearchResultToMatch(r))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (t *TMDbProvider) movieSearchResultToMatch(r tmdbMovieSearchResult) *Match {
|
||||
m := &Match{
|
||||
TMDbID: r.ID,
|
||||
Title: r.Title,
|
||||
@@ -208,12 +241,21 @@ func (t *TMDbProvider) SearchMovie(ctx context.Context, query string, year int)
|
||||
if len(r.ReleaseDate) >= 4 {
|
||||
_, _ = fmt.Sscanf(r.ReleaseDate[:4], "%d", &m.Year)
|
||||
}
|
||||
return m, nil
|
||||
return m
|
||||
}
|
||||
|
||||
// SearchTV issues `/search/tv` and returns the best match. Used by anime /
|
||||
// tv libraries before falling back to SearchMovie.
|
||||
func (t *TMDbProvider) SearchTV(ctx context.Context, query string, year int) (*Match, error) {
|
||||
matches, err := t.SearchTVCandidates(ctx, query, year)
|
||||
if err != nil || len(matches) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return matches[0], nil
|
||||
}
|
||||
|
||||
// SearchTVCandidates returns the first TMDb TV result page for manual scrape.
|
||||
func (t *TMDbProvider) SearchTVCandidates(ctx context.Context, query string, year int) ([]*Match, error) {
|
||||
if query == "" {
|
||||
return nil, errors.New("empty query")
|
||||
}
|
||||
@@ -234,21 +276,8 @@ func (t *TMDbProvider) SearchTV(ctx context.Context, query string, year int) (*M
|
||||
}
|
||||
u := base + "/search/tv?" + q.Encode()
|
||||
|
||||
type result struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"original_name"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
GenreIDs []int `json:"genre_ids"`
|
||||
}
|
||||
type page struct {
|
||||
Results []result `json:"results"`
|
||||
Results []tmdbTVSearchResult `json:"results"`
|
||||
}
|
||||
|
||||
var p page
|
||||
@@ -258,7 +287,14 @@ func (t *TMDbProvider) SearchTV(ctx context.Context, query string, year int) (*M
|
||||
if len(p.Results) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
r := p.Results[0]
|
||||
out := make([]*Match, 0, len(p.Results))
|
||||
for _, r := range p.Results {
|
||||
out = append(out, t.tvSearchResultToMatch(r))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (t *TMDbProvider) tvSearchResultToMatch(r tmdbTVSearchResult) *Match {
|
||||
m := &Match{
|
||||
TMDbID: r.ID,
|
||||
Title: r.Name,
|
||||
@@ -281,7 +317,7 @@ func (t *TMDbProvider) SearchTV(ctx context.Context, query string, year int) (*M
|
||||
if len(r.FirstAirDate) >= 4 {
|
||||
_, _ = fmt.Sscanf(r.FirstAirDate[:4], "%d", &m.Year)
|
||||
}
|
||||
return m, nil
|
||||
return m
|
||||
}
|
||||
|
||||
func (t *TMDbProvider) getJSON(ctx context.Context, url string, out any) error {
|
||||
@@ -437,6 +473,55 @@ func (t *TMDbProvider) GetTVMatch(ctx context.Context, tmdbID int) (*Match, erro
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// TMDbEpisodeDetails holds per-episode metadata from /tv/{id}/season/{season}/episode/{episode}.
|
||||
type TMDbEpisodeDetails struct {
|
||||
Name string
|
||||
Overview string
|
||||
StillURL string
|
||||
AirYear int
|
||||
Rating float32
|
||||
Runtime int
|
||||
}
|
||||
|
||||
func (t *TMDbProvider) GetTVEpisodeDetails(ctx context.Context, tmdbID, season, episode int) (*TMDbEpisodeDetails, error) {
|
||||
if tmdbID <= 0 || episode <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
apiKey := t.resolveAPIKey(ctx)
|
||||
if apiKey == "" {
|
||||
return nil, nil
|
||||
}
|
||||
base := t.resolveBaseURL(ctx)
|
||||
q := url.Values{}
|
||||
q.Set("api_key", apiKey)
|
||||
q.Set("language", "zh-CN")
|
||||
u := base + "/tv/" + fmt.Sprint(tmdbID) + "/season/" + fmt.Sprint(season) + "/episode/" + fmt.Sprint(episode) + "?" + q.Encode()
|
||||
var r struct {
|
||||
Name string `json:"name"`
|
||||
Overview string `json:"overview"`
|
||||
StillPath string `json:"still_path"`
|
||||
AirDate string `json:"air_date"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
Runtime int `json:"runtime"`
|
||||
}
|
||||
if err := t.getJSON(ctx, u, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
details := &TMDbEpisodeDetails{
|
||||
Name: r.Name,
|
||||
Overview: r.Overview,
|
||||
Rating: r.VoteAverage,
|
||||
Runtime: r.Runtime,
|
||||
}
|
||||
if r.StillPath != "" {
|
||||
details.StillURL = t.imgCDN + "/w500" + r.StillPath
|
||||
}
|
||||
if len(r.AirDate) >= 4 {
|
||||
_, _ = fmt.Sscanf(r.AirDate[:4], "%d", &details.AirYear)
|
||||
}
|
||||
return details, nil
|
||||
}
|
||||
|
||||
// TMDbDetails holds extended metadata from the /movie/{id} or /tv/{id} endpoints.
|
||||
type TMDbDetails struct {
|
||||
Languages []string `json:"languages"`
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface ManualScrapeCandidate {
|
||||
source: string
|
||||
media_type?: string
|
||||
title: string
|
||||
original_name?: string
|
||||
overview?: string
|
||||
poster_url?: string
|
||||
backdrop_url?: string
|
||||
@@ -31,6 +32,27 @@ export interface ManualScrapeCandidate {
|
||||
languages?: string[]
|
||||
countries?: string[]
|
||||
genres?: string[]
|
||||
nsfw?: boolean
|
||||
}
|
||||
|
||||
export interface MediaMetadataUpdate {
|
||||
title?: string
|
||||
original_name?: string
|
||||
overview?: string
|
||||
poster_url?: string
|
||||
backdrop_url?: string
|
||||
year?: number
|
||||
rating?: number
|
||||
season_num?: number
|
||||
episode_num?: number
|
||||
tmdb_id?: number
|
||||
bangumi_id?: number
|
||||
douban_id?: string
|
||||
thetvdb_id?: string
|
||||
languages?: string
|
||||
countries?: string
|
||||
genres?: string
|
||||
nsfw?: boolean
|
||||
}
|
||||
|
||||
export const libraryAPI = {
|
||||
@@ -75,6 +97,9 @@ export const mediaAPI = {
|
||||
|
||||
get: (id: string) => api.get<Media>(`/media/${id}`).then((r) => r.data),
|
||||
|
||||
updateMetadata: (id: string, payload: MediaMetadataUpdate) =>
|
||||
api.patch<Media>(`/media/${id}/metadata`, payload, { timeout: LONG_REQUEST_TIMEOUT }).then((r) => r.data),
|
||||
|
||||
manualScrapeSearch: (id: string, params: { query: string; provider?: string; media_type?: string }) =>
|
||||
api
|
||||
.get<{ items: ManualScrapeCandidate[] }>(`/media/${id}/scrape/search`, { params })
|
||||
|
||||
@@ -28,7 +28,7 @@ export function APIConfigsPanel() {
|
||||
<div>
|
||||
<p className="font-display text-lg font-semibold text-ink-600">外部 API 配置</p>
|
||||
<p className="text-xs text-ink-50">
|
||||
TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban 密钥管理
|
||||
TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban / Adult 密钥与源管理
|
||||
· AES-GCM 加密存储
|
||||
</p>
|
||||
</div>
|
||||
@@ -73,14 +73,17 @@ export function APIConfigsPanel() {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">
|
||||
{item.has_key ? (
|
||||
{apiConfigConfigured(item) ? (
|
||||
<span className="text-brand-500">{item.masked_key}</span>
|
||||
) : (
|
||||
<span className="text-gray-500">未配置</span>
|
||||
)}
|
||||
{item.provider === 'adult' && apiConfigSourceCount(item) > 0 ? (
|
||||
<span className="text-brand-500">{apiConfigSourceCount(item)} 个源</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{item.has_key ? (
|
||||
{apiConfigConfigured(item) ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-400/10 px-2 py-0.5 text-xs text-emerald-400">
|
||||
已配置
|
||||
</span>
|
||||
@@ -148,14 +151,17 @@ function EditingRow({
|
||||
}) {
|
||||
const [apiKey, setAPIKey] = useState('')
|
||||
const [baseURL, setBaseURL] = useState(item.base_url ?? '')
|
||||
const [extra, setExtra] = useState(item.extra ?? '')
|
||||
const [enabled, setEnabled] = useState(item.enabled)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const isAdult = item.provider === 'adult'
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
const patch: Record<string, unknown> = { base_url: baseURL, enabled }
|
||||
if (isAdult) patch.extra = extra
|
||||
if (apiKey.trim()) patch.api_key = apiKey.trim()
|
||||
await apiConfigsAPI.update(item.provider, patch)
|
||||
toast.success(`${item.provider} 已保存`)
|
||||
@@ -175,25 +181,38 @@ function EditingRow({
|
||||
<td colSpan={4} className="px-4 py-3">
|
||||
<form onSubmit={submit} className="flex flex-wrap items-end gap-3">
|
||||
<span className="text-sm font-medium text-ink-600">{item.provider}</span>
|
||||
{!isAdult && (
|
||||
<label className="flex-1 text-xs text-ink-50">
|
||||
API Key
|
||||
<input
|
||||
className="input-base mt-1"
|
||||
type="password"
|
||||
placeholder={item.has_key ? '•••••••••••• (留空保留原值)' : '输入密钥'}
|
||||
value={apiKey}
|
||||
onChange={(e) => setAPIKey(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="flex-1 text-xs text-ink-50">
|
||||
API Key
|
||||
{isAdult ? '主源 URL' : 'Base URL'}
|
||||
<input
|
||||
className="input-base mt-1"
|
||||
type="password"
|
||||
placeholder={item.has_key ? '•••••••••••• (留空保留原值)' : '输入密钥'}
|
||||
value={apiKey}
|
||||
onChange={(e) => setAPIKey(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex-1 text-xs text-ink-50">
|
||||
Base URL
|
||||
<input
|
||||
className="input-base mt-1"
|
||||
placeholder="https://api.themoviedb.org/3"
|
||||
placeholder={isAdult ? 'https://javdb.com' : 'https://api.themoviedb.org/3'}
|
||||
value={baseURL}
|
||||
onChange={(e) => setBaseURL(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{isAdult && (
|
||||
<label className="min-w-64 flex-1 text-xs text-ink-50">
|
||||
备用源 URL
|
||||
<textarea
|
||||
className="input-base mt-1 min-h-20 resize-y"
|
||||
placeholder={'https://javbus.sbs\nhttps://www.javbus.com'}
|
||||
value={extra}
|
||||
onChange={(e) => setExtra(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-xs text-ink-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -217,3 +236,19 @@ function EditingRow({
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function apiConfigConfigured(item: APIConfig): boolean {
|
||||
if (item.provider === 'adult') {
|
||||
return Boolean(item.base_url?.trim() || item.extra?.trim())
|
||||
}
|
||||
return item.has_key
|
||||
}
|
||||
|
||||
function apiConfigSourceCount(item: APIConfig): number {
|
||||
if (item.provider !== 'adult') return 0
|
||||
return [item.base_url, item.extra]
|
||||
.join('\n')
|
||||
.split(/[\s,;]+/)
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean).length
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ const providers = [
|
||||
{ value: 'douban', label: '豆瓣' },
|
||||
{ value: 'bangumi', label: 'Bangumi' },
|
||||
{ value: 'thetvdb', label: 'TheTVDB' },
|
||||
{ value: 'adult', label: 'Adult / 番号' },
|
||||
]
|
||||
|
||||
export function ManualScrapeDialog({
|
||||
@@ -164,6 +165,7 @@ export function ManualScrapeDialog({
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="truncate font-semibold text-ink-600">{item.title}</h3>
|
||||
<span className="rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-bold uppercase text-brand-700">{item.source}</span>
|
||||
{item.nsfw ? <span className="rounded-full bg-rose-50 px-2 py-0.5 text-[11px] font-bold text-rose-600">成人</span> : null}
|
||||
{item.year ? <span className="text-xs text-sand-500">{item.year}</span> : null}
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-ink-50">{item.overview || '暂无简介'}</p>
|
||||
@@ -191,6 +193,7 @@ function candidateKey(item: ManualScrapeCandidate): string {
|
||||
function candidateIDText(item: ManualScrapeCandidate): string {
|
||||
const parts = [
|
||||
item.tmdb_id ? `TMDb ${item.tmdb_id}` : '',
|
||||
item.original_name && item.source === 'adult' ? `番号 ${item.original_name}` : '',
|
||||
item.douban_id ? `豆瓣 ${item.douban_id}` : '',
|
||||
item.bangumi_id ? `Bangumi ${item.bangumi_id}` : '',
|
||||
item.thetvdb_id ? `TheTVDB ${item.thetvdb_id}` : '',
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Save, X } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { mediaAPI, type MediaMetadataUpdate } from '../api/library'
|
||||
import type { Media } from '../types'
|
||||
|
||||
interface MetadataEditDialogProps {
|
||||
open: boolean
|
||||
media: Media | null
|
||||
mediaIds?: string[]
|
||||
mode?: 'media' | 'series'
|
||||
scopeLabel?: string
|
||||
onClose: () => void
|
||||
onSaved: (media: Media) => void | Promise<void>
|
||||
}
|
||||
|
||||
export function MetadataEditDialog({
|
||||
open,
|
||||
media,
|
||||
mediaIds,
|
||||
mode = 'media',
|
||||
scopeLabel,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: MetadataEditDialogProps) {
|
||||
const [form, setForm] = useState({
|
||||
title: '',
|
||||
original_name: '',
|
||||
overview: '',
|
||||
poster_url: '',
|
||||
backdrop_url: '',
|
||||
year: '',
|
||||
rating: '',
|
||||
season_num: '',
|
||||
episode_num: '',
|
||||
tmdb_id: '',
|
||||
bangumi_id: '',
|
||||
douban_id: '',
|
||||
thetvdb_id: '',
|
||||
languages: '',
|
||||
countries: '',
|
||||
genres: '',
|
||||
nsfw: false,
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !media) return
|
||||
setForm({
|
||||
title: media.title || '',
|
||||
original_name: media.original_name || '',
|
||||
overview: media.overview || '',
|
||||
poster_url: media.poster_url || '',
|
||||
backdrop_url: media.backdrop_url || '',
|
||||
year: media.year > 0 ? String(media.year) : '',
|
||||
rating: media.rating > 0 ? String(media.rating) : '',
|
||||
season_num: media.season_num > 0 || media.episode_num > 0 ? String(media.season_num || 0) : '',
|
||||
episode_num: media.episode_num > 0 ? String(media.episode_num) : '',
|
||||
tmdb_id: media.tmdb_id > 0 ? String(media.tmdb_id) : '',
|
||||
bangumi_id: media.bangumi_id > 0 ? String(media.bangumi_id) : '',
|
||||
douban_id: media.douban_id || '',
|
||||
thetvdb_id: media.thetvdb_id || '',
|
||||
languages: media.languages || '',
|
||||
countries: media.countries || '',
|
||||
genres: media.genres || '',
|
||||
nsfw: !!media.nsfw,
|
||||
})
|
||||
}, [open, media])
|
||||
|
||||
if (!open || !media) return null
|
||||
|
||||
const targetIds = Array.from(new Set((mediaIds && mediaIds.length > 0 ? mediaIds : [media.id]).filter(Boolean)))
|
||||
const isSeries = mode === 'series' && targetIds.length > 1
|
||||
|
||||
const set = (key: keyof typeof form, value: string | boolean) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
const toNumber = (value: string) => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return 0
|
||||
const parsed = Number(trimmed)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
const buildPayload = (): MediaMetadataUpdate => {
|
||||
const payload: MediaMetadataUpdate = {
|
||||
title: form.title,
|
||||
overview: form.overview,
|
||||
poster_url: form.poster_url,
|
||||
backdrop_url: form.backdrop_url,
|
||||
year: Math.trunc(toNumber(form.year)),
|
||||
rating: toNumber(form.rating),
|
||||
tmdb_id: Math.trunc(toNumber(form.tmdb_id)),
|
||||
bangumi_id: Math.trunc(toNumber(form.bangumi_id)),
|
||||
douban_id: form.douban_id,
|
||||
thetvdb_id: form.thetvdb_id,
|
||||
languages: form.languages,
|
||||
countries: form.countries,
|
||||
genres: form.genres,
|
||||
nsfw: form.nsfw,
|
||||
}
|
||||
if (!isSeries) {
|
||||
payload.original_name = form.original_name
|
||||
payload.season_num = Math.trunc(toNumber(form.season_num))
|
||||
payload.episode_num = Math.trunc(toNumber(form.episode_num))
|
||||
}
|
||||
return payload
|
||||
}
|
||||
const save = async () => {
|
||||
if (!form.title.trim()) {
|
||||
toast.error('标题不能为空')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = buildPayload()
|
||||
let next: Media | null = null
|
||||
for (const id of targetIds) {
|
||||
const updated = await mediaAPI.updateMetadata(id, payload)
|
||||
if (!next || id === media.id) next = updated
|
||||
}
|
||||
if (!next) next = await mediaAPI.updateMetadata(media.id, payload)
|
||||
toast.success(isSeries ? `整剧元数据已保存:${targetIds.length} 集` : '元数据已保存')
|
||||
await onSaved(next)
|
||||
onClose()
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || '保存失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-900/40 px-4 py-8 backdrop-blur-sm">
|
||||
<div className="flex max-h-[88vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-gray-200 px-5 py-4">
|
||||
<div>
|
||||
<h2 className="font-display text-xl font-bold text-gray-900">
|
||||
{isSeries ? '编辑整剧元数据' : '编辑元数据'}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-gray-500">{scopeLabel || '用于手动修正自采集或无法自动匹配的媒体。'}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="btn-ghost h-9 w-9 p-0" aria-label="关闭">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid flex-1 gap-4 overflow-y-auto p-5 md:grid-cols-2">
|
||||
<Field label="标题" value={form.title} onChange={(value) => set('title', value)} />
|
||||
{!isSeries && <Field label="原名 / 单集名" value={form.original_name} onChange={(value) => set('original_name', value)} />}
|
||||
<Field label="海报 URL" value={form.poster_url} onChange={(value) => set('poster_url', value)} />
|
||||
<Field label="背景 / 单集剧照 URL" value={form.backdrop_url} onChange={(value) => set('backdrop_url', value)} />
|
||||
<Field label="年份" value={form.year} onChange={(value) => set('year', value)} inputMode="numeric" />
|
||||
<Field label="评分" value={form.rating} onChange={(value) => set('rating', value)} inputMode="decimal" />
|
||||
{!isSeries && <Field label="季" value={form.season_num} onChange={(value) => set('season_num', value)} inputMode="numeric" />}
|
||||
{!isSeries && <Field label="集" value={form.episode_num} onChange={(value) => set('episode_num', value)} inputMode="numeric" />}
|
||||
<Field label="TMDb ID" value={form.tmdb_id} onChange={(value) => set('tmdb_id', value)} inputMode="numeric" />
|
||||
<Field label="Bangumi ID" value={form.bangumi_id} onChange={(value) => set('bangumi_id', value)} inputMode="numeric" />
|
||||
<Field label="豆瓣 ID" value={form.douban_id} onChange={(value) => set('douban_id', value)} />
|
||||
<Field label="TheTVDB ID" value={form.thetvdb_id} onChange={(value) => set('thetvdb_id', value)} />
|
||||
<Field label="语言" value={form.languages} onChange={(value) => set('languages', value)} placeholder="zh,en" />
|
||||
<Field label="国家/地区" value={form.countries} onChange={(value) => set('countries', value)} placeholder="CN,JP,US" />
|
||||
<Field label="类型" value={form.genres} onChange={(value) => set('genres', value)} placeholder="剧情,动画" />
|
||||
<label className="flex h-11 items-center gap-2 rounded-xl border border-gray-200 px-3 text-sm font-semibold text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.nsfw}
|
||||
onChange={(event) => set('nsfw', event.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-brand-600"
|
||||
/>
|
||||
成人内容
|
||||
</label>
|
||||
<label className="md:col-span-2">
|
||||
<span className="mb-1 block text-xs font-bold text-gray-500">简介</span>
|
||||
<textarea
|
||||
value={form.overview}
|
||||
onChange={(event) => set('overview', event.target.value)}
|
||||
rows={5}
|
||||
className="w-full rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-700 outline-none focus:border-brand-300"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-gray-200 px-5 py-4">
|
||||
<button onClick={onClose} className="btn-outline px-4">取消</button>
|
||||
<button onClick={save} disabled={saving} className="btn-primary px-5">
|
||||
<Save size={16} />
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
inputMode,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
inputMode?: 'numeric' | 'decimal'
|
||||
}) {
|
||||
return (
|
||||
<label>
|
||||
<span className="mb-1 block text-xs font-bold text-gray-500">{label}</span>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
inputMode={inputMode}
|
||||
className="h-11 w-full rounded-xl border border-gray-200 bg-white px-3 text-sm font-semibold text-gray-700 outline-none focus:border-brand-300"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export function APIConfigsPage() {
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">外部 API 配置</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
管理 TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban 的密钥。
|
||||
管理 TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban / Adult 的密钥与源。
|
||||
后端使用 AES-GCM 加密存储,数据库泄漏时密钥仍然安全。
|
||||
</p>
|
||||
</div>
|
||||
@@ -50,14 +50,17 @@ export function APIConfigsPage() {
|
||||
function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => void }) {
|
||||
const [apiKey, setAPIKey] = useState('')
|
||||
const [baseURL, setBaseURL] = useState(item.base_url ?? '')
|
||||
const [extra, setExtra] = useState(item.extra ?? '')
|
||||
const [enabled, setEnabled] = useState(item.enabled)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const isAdult = item.provider === 'adult'
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
const patch: Record<string, unknown> = { base_url: baseURL, enabled }
|
||||
if (isAdult) patch.extra = extra
|
||||
if (apiKey.trim()) patch.api_key = apiKey.trim()
|
||||
await apiConfigsAPI.update(item.provider, patch)
|
||||
toast.success(`${item.provider} 已保存`)
|
||||
@@ -93,32 +96,48 @@ function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => v
|
||||
<p className="text-xs text-ink-50">{item.description}</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-sand-500">
|
||||
状态: {item.has_key ? <span className="text-emerald-400">已配置</span> : <span className="text-sand-500">未配置</span>}
|
||||
状态: {apiConfigConfigured(item) ? <span className="text-emerald-400">已配置</span> : <span className="text-sand-500">未配置</span>}
|
||||
{item.has_key && (
|
||||
<span className="ml-2 font-mono text-brand-500">{item.masked_key}</span>
|
||||
)}
|
||||
{isAdult && apiConfigSourceCount(item) > 0 && (
|
||||
<span className="ml-2 text-brand-500">{apiConfigSourceCount(item)} 个源</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{!isAdult && (
|
||||
<label className="block text-xs text-ink-50">
|
||||
API Key (留空保留原值)
|
||||
<input
|
||||
className="input-base mt-1"
|
||||
type="password"
|
||||
placeholder={item.has_key ? '••••••••••••' : '尚未配置'}
|
||||
value={apiKey}
|
||||
onChange={(e) => setAPIKey(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="block text-xs text-ink-50">
|
||||
API Key (留空保留原值)
|
||||
{isAdult ? '主源 URL' : 'Base URL (可选)'}
|
||||
<input
|
||||
className="input-base mt-1"
|
||||
type="password"
|
||||
placeholder={item.has_key ? '••••••••••••' : '尚未配置'}
|
||||
value={apiKey}
|
||||
onChange={(e) => setAPIKey(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs text-ink-50">
|
||||
Base URL (可选)
|
||||
<input
|
||||
className="input-base mt-1"
|
||||
placeholder="https://api.themoviedb.org/3"
|
||||
placeholder={isAdult ? 'https://javdb.com' : 'https://api.themoviedb.org/3'}
|
||||
value={baseURL}
|
||||
onChange={(e) => setBaseURL(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{isAdult && (
|
||||
<label className="block text-xs text-ink-50">
|
||||
备用源 URL
|
||||
<textarea
|
||||
className="input-base mt-1 min-h-24 resize-y"
|
||||
placeholder={'https://javbus.sbs\nhttps://www.javbus.com'}
|
||||
value={extra}
|
||||
onChange={(e) => setExtra(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="inline-flex items-center gap-2 text-xs text-ink-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -152,3 +171,19 @@ function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => v
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function apiConfigConfigured(item: APIConfig): boolean {
|
||||
if (item.provider === 'adult') {
|
||||
return Boolean(item.base_url?.trim() || item.extra?.trim())
|
||||
}
|
||||
return item.has_key
|
||||
}
|
||||
|
||||
function apiConfigSourceCount(item: APIConfig): number {
|
||||
if (item.provider !== 'adult') return 0
|
||||
return [item.base_url, item.extra]
|
||||
.join('\n')
|
||||
.split(/[\s,;]+/)
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean).length
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ export function HomePage() {
|
||||
key={card.key}
|
||||
media={card.rep}
|
||||
count={card.count}
|
||||
linkTo={card.count > 1 ? seriesCardLink(card) : undefined}
|
||||
linkTo={seriesCardLink(card)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import toast from 'react-hot-toast'
|
||||
import { ArrowLeft, Play, Film, Database, FileText, Search, Sparkles, Trash2 } from 'lucide-react'
|
||||
import { ArrowLeft, Play, Film, Database, FileText, Search, Sparkles, Trash2, Pencil } from 'lucide-react'
|
||||
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { storageAPI, type CloudScanStatus } from '../api/storage_config'
|
||||
@@ -17,6 +17,7 @@ import { getSeriesKey, groupSeries, isEpisodeLike, seriesTitle, type SeriesCard
|
||||
import { useWebSocket } from '../hooks/useWebSocket'
|
||||
import { confirmAction } from '../components/ConfirmDialog'
|
||||
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
|
||||
import { MetadataEditDialog } from '../components/MetadataEditDialog'
|
||||
|
||||
export function LibraryPage() {
|
||||
const { id = '' } = useParams()
|
||||
@@ -33,6 +34,7 @@ export function LibraryPage() {
|
||||
const [scraping, setScraping] = useState(false)
|
||||
const [seriesToolBusy, setSeriesToolBusy] = useState('')
|
||||
const [manualSeriesScrapeOpen, setManualSeriesScrapeOpen] = useState(false)
|
||||
const [seriesMetadataEditOpen, setSeriesMetadataEditOpen] = useState(false)
|
||||
const [manualMovie, setManualMovie] = useState<Media | null>(null)
|
||||
const [movieToolBusy, setMovieToolBusy] = useState('')
|
||||
|
||||
@@ -56,7 +58,7 @@ export function LibraryPage() {
|
||||
// 按季分组,按集排序
|
||||
const seasons = new Map<number, Media[]>()
|
||||
for (const ep of eps) {
|
||||
const s = ep.season_num || 1
|
||||
const s = ep.episode_num > 0 ? (ep.season_num ?? 0) : (ep.season_num || 1)
|
||||
if (!seasons.has(s)) seasons.set(s, [])
|
||||
seasons.get(s)!.push(ep)
|
||||
}
|
||||
@@ -226,7 +228,11 @@ export function LibraryPage() {
|
||||
}
|
||||
|
||||
const next = seriesCards.find((card) => card.key === key)
|
||||
if (next) setSelectedSeries(next)
|
||||
if (next) {
|
||||
setSelectedSeries(next)
|
||||
} else {
|
||||
setSelectedSeries(null)
|
||||
}
|
||||
}, [isSeries, loading, searchParams, seriesCards])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -385,6 +391,7 @@ export function LibraryPage() {
|
||||
const clearSelectedSeries = () => {
|
||||
setSelectedSeries(null)
|
||||
setSelectedSeason(null)
|
||||
setSeriesMetadataEditOpen(false)
|
||||
const next = new URLSearchParams(searchParams)
|
||||
next.delete('series')
|
||||
setSearchParams(next)
|
||||
@@ -518,6 +525,10 @@ export function LibraryPage() {
|
||||
<Search size={13} className="text-[#c9954a]" />
|
||||
<span>手动匹配整剧</span>
|
||||
</button>
|
||||
<button onClick={() => setSeriesMetadataEditOpen(true)} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
|
||||
<Pencil size={13} />
|
||||
<span>编辑元数据</span>
|
||||
</button>
|
||||
<button onClick={handleSeriesProbe} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
|
||||
<Database size={13} />
|
||||
<span>{seriesToolBusy === 'probe' ? '探测中…' : '探测媒体轨'}</span>
|
||||
@@ -550,14 +561,16 @@ export function LibraryPage() {
|
||||
: 'border-sand-200 bg-white text-ink-100 hover:border-brand-200 hover:text-brand-600')
|
||||
}
|
||||
>
|
||||
第 {season} 季 · {episodes.length} 集
|
||||
{season === 0 ? '特别篇' : `第 ${season} 季`} · {episodes.length} 集
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 font-display text-lg font-semibold text-ink-600">
|
||||
第 {selectedSeason ?? selectedEpisodes[0]?.season ?? 1} 季
|
||||
{(selectedSeason ?? selectedEpisodes[0]?.season ?? 1) === 0
|
||||
? '特别篇'
|
||||
: `第 ${selectedSeason ?? selectedEpisodes[0]?.season ?? 1} 季`}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{visibleEpisodes.map((ep) => (
|
||||
@@ -566,8 +579,17 @@ export function LibraryPage() {
|
||||
className="group flex items-center gap-3 rounded-xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:border-brand-300 hover:shadow-card-hover"
|
||||
>
|
||||
<Link to={`/play/${ep.id}`} className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-50 text-brand-600 font-semibold text-sm">
|
||||
{ep.episode_num || '—'}
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-brand-50 text-brand-600 font-semibold text-sm">
|
||||
{ep.backdrop_url || ep.poster_url ? (
|
||||
<img
|
||||
src={imageURL(ep.backdrop_url || ep.poster_url || '')}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
ep.episode_num || '—'
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-ink-600">
|
||||
@@ -609,6 +631,15 @@ export function LibraryPage() {
|
||||
onClose={() => setManualSeriesScrapeOpen(false)}
|
||||
onApplied={reloadCurrentLibrary}
|
||||
/>
|
||||
<MetadataEditDialog
|
||||
open={seriesMetadataEditOpen}
|
||||
media={selectedSeries?.rep ?? null}
|
||||
mediaIds={selectedSeriesMediaIDs}
|
||||
mode="series"
|
||||
scopeLabel={selectedSeries ? seriesTitle(selectedSeries.rep) : '当前剧集'}
|
||||
onClose={() => setSeriesMetadataEditOpen(false)}
|
||||
onSaved={reloadCurrentLibrary}
|
||||
/>
|
||||
<ManualScrapeDialog
|
||||
open={!!manualMovie}
|
||||
media={manualMovie}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { FileText, Heart, Play, RefreshCw, Sparkles, Trash2, Calendar, Database, Search } from 'lucide-react'
|
||||
import { FileText, Heart, Play, RefreshCw, Sparkles, Trash2, Calendar, Database, Search, Pencil } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { mediaAPI } from '../api/library'
|
||||
@@ -14,6 +14,7 @@ import type { Media } from '../types'
|
||||
import { confirmAction } from '../components/ConfirmDialog'
|
||||
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
||||
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
|
||||
import { MetadataEditDialog } from '../components/MetadataEditDialog'
|
||||
|
||||
function fmtDuration(sec: number): string {
|
||||
if (!sec || sec <= 0) return '—'
|
||||
@@ -47,6 +48,7 @@ export function MediaDetailPage() {
|
||||
const [favourite, setFavourite] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [manualScrapeOpen, setManualScrapeOpen] = useState(false)
|
||||
const [metadataEditOpen, setMetadataEditOpen] = useState(false)
|
||||
|
||||
const refresh = async () => {
|
||||
if (!id) return
|
||||
@@ -136,9 +138,9 @@ export function MediaDetailPage() {
|
||||
<div className="relative overflow-hidden rounded-3xl bg-white border border-gray-200/90 shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)]">
|
||||
{/* ── Cinematic Blurred Backdrop Glow ── */}
|
||||
<div className="absolute inset-0 h-[480px] z-0 overflow-hidden">
|
||||
{media.poster_url ? (
|
||||
{(media.backdrop_url || media.poster_url) ? (
|
||||
<img
|
||||
src={imageURL(media.poster_url)}
|
||||
src={imageURL(media.backdrop_url || media.poster_url || '')}
|
||||
alt=""
|
||||
className="w-full h-full object-cover opacity-[0.04] scale-110 blur-2xl"
|
||||
referrerPolicy="no-referrer"
|
||||
@@ -321,6 +323,10 @@ export function MediaDetailPage() {
|
||||
<Search size={13} className="text-[#c9954a]" />
|
||||
<span>手动匹配刮削</span>
|
||||
</button>
|
||||
<button onClick={() => setMetadataEditOpen(true)} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<Pencil size={13} className="text-gray-600" />
|
||||
<span>编辑元数据</span>
|
||||
</button>
|
||||
<button onClick={reprobe} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<Database size={13} className="text-gray-600" />
|
||||
<span>探测媒体轨 (ffprobe)</span>
|
||||
@@ -351,6 +357,15 @@ export function MediaDetailPage() {
|
||||
onClose={() => setManualScrapeOpen(false)}
|
||||
onApplied={refresh}
|
||||
/>
|
||||
<MetadataEditDialog
|
||||
open={metadataEditOpen}
|
||||
media={media}
|
||||
onClose={() => setMetadataEditOpen(false)}
|
||||
onSaved={async (next) => {
|
||||
setMedia(next)
|
||||
await refresh()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export function getSeriesKey(media: Media): string {
|
||||
if (media.bangumi_id && media.bangumi_id > 0) return `bgm:${media.bangumi_id}`
|
||||
if (media.douban_id) return `douban:${media.douban_id}`
|
||||
if (media.thetvdb_id) return `thetvdb:${media.thetvdb_id}`
|
||||
return `lib:${media.library_id}|show:${normalizeTitle(fromPath || seriesTitle(media))}`
|
||||
return `lib:${targetLibraryID(media)}|show:${normalizeTitle(fromPath || seriesTitle(media))}`
|
||||
}
|
||||
if (media.tmdb_id && media.tmdb_id > 0) return `tmdb:${media.tmdb_id}`
|
||||
if (media.bangumi_id && media.bangumi_id > 0) return `bgm:${media.bangumi_id}`
|
||||
@@ -38,6 +38,10 @@ export function isEpisodeLike(media: Media): boolean {
|
||||
return (media.season_num ?? 0) > 0 || (media.episode_num ?? 0) > 0
|
||||
}
|
||||
|
||||
export function isSeriesCard(card: SeriesCard): boolean {
|
||||
return card.count > 1 || isEpisodeLike(card.rep) || isEpisodeLike(card.linkMedia)
|
||||
}
|
||||
|
||||
export function seriesTitle(media: Media): string {
|
||||
const fromPath = seriesTitleFromPath(media.path)
|
||||
return fromPath || media.title || media.original_name || '未命名节目'
|
||||
@@ -95,8 +99,8 @@ export function groupSeries(items: Media[] = []): SeriesCard[] {
|
||||
}
|
||||
|
||||
export function seriesCardLink(card: SeriesCard): string {
|
||||
if (card.count > 1) {
|
||||
return `/library/${targetLibraryID(card.linkMedia)}`
|
||||
if (isSeriesCard(card)) {
|
||||
return `/library/${targetLibraryID(card.linkMedia)}?series=${encodeURIComponent(card.key)}`
|
||||
}
|
||||
return `/media/${card.rep.id}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user