mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 21:06:38 +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:
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user