mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-10 23:36:37 +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/
|
downloads/
|
||||||
media/
|
media/
|
||||||
*.pid
|
*.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 {
|
func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
q := c.Query("q")
|
q := c.Query("q")
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func registerAuthenticatedRoutes(api *gin.RouterGroup, cfg *config.Config, svc *
|
|||||||
// Media.
|
// Media.
|
||||||
authed.GET("/media/:id", getMediaHandler(svc))
|
authed.GET("/media/:id", getMediaHandler(svc))
|
||||||
authed.GET("/media", searchMediaHandler(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.POST("/media/:id/scrape", middleware.AdminRequired(), scrapeOneHandler(svc))
|
||||||
authed.GET("/media/:id/scrape/search", middleware.AdminRequired(), manualScrapeSearchHandler(svc))
|
authed.GET("/media/:id/scrape/search", middleware.AdminRequired(), manualScrapeSearchHandler(svc))
|
||||||
authed.POST("/media/:id/scrape/apply", middleware.AdminRequired(), manualScrapeApplyOneHandler(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" {
|
status := strings.TrimSpace(existing.ScrapeStatus)
|
||||||
backfilledExternalID := false
|
canRefreshExternalIDs := status == "pending" || status == "" || status == "no_match" ||
|
||||||
if m.TMDbID > 0 && existing.TMDbID <= 0 {
|
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
|
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
|
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
|
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
|
updates["thetvdb_id"] = m.TheTVDBID
|
||||||
backfilledExternalID = true
|
changedExternalID = true
|
||||||
}
|
}
|
||||||
if m.Year > 0 && existing.Year <= 0 {
|
if m.Year > 0 && existing.Year <= 0 {
|
||||||
updates["year"] = m.Year
|
updates["year"] = m.Year
|
||||||
}
|
}
|
||||||
if backfilledExternalID && existing.ScrapeStatus == "no_match" {
|
if changedExternalID && (status == "no_match" || status == "matched") && m.ScrapeStatus != "matched" {
|
||||||
updates["scrape_status"] = "pending"
|
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 {
|
if lib := m.LibraryID; lib != "" && lib != existing.LibraryID {
|
||||||
updates["library_id"] = m.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
|
updates["season_num"] = m.SeasonNum
|
||||||
}
|
}
|
||||||
if m.EpisodeNum > 0 && existing.EpisodeNum != m.EpisodeNum {
|
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 {
|
type fakeMediaSearchBackend struct {
|
||||||
ids []string
|
ids []string
|
||||||
err error
|
err error
|
||||||
|
|||||||
@@ -112,23 +112,37 @@ func (p *AdultProvider) resolveBases(ctx context.Context) []string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
configured := []string{}
|
configured := []string{}
|
||||||
if resolved.BaseURL != "" {
|
configured = append(configured, adultConfiguredBases(resolved.BaseURL)...)
|
||||||
configured = append(configured, resolved.BaseURL)
|
configured = append(configured, adultConfiguredBases(resolved.Extra)...)
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(configured) > 0 {
|
if len(configured) > 0 {
|
||||||
out = append(configured, out...)
|
out = append(configured, out...)
|
||||||
}
|
}
|
||||||
return dedupeStrings(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 {
|
func adultSourceKind(base string) string {
|
||||||
u, err := url.Parse(strings.TrimSpace(base))
|
u, err := url.Parse(strings.TrimSpace(base))
|
||||||
host := ""
|
host := ""
|
||||||
|
|||||||
@@ -2,7 +2,16 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"testing"
|
"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) {
|
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) {
|
func TestAdultSourceKindRecognizesJavBusMirrors(t *testing.T) {
|
||||||
cases := map[string]string{
|
cases := map[string]string{
|
||||||
"https://javdb.com": "javdb",
|
"https://javdb.com": "javdb",
|
||||||
|
|||||||
@@ -1133,3 +1133,21 @@ func TestBotAdminUnbindInactiveAndInvalidBindings(t *testing.T) {
|
|||||||
t.Fatal("invalid binding should be removed")
|
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 != "" {
|
if src.TheTVDBID != "" {
|
||||||
dst.TheTVDBID = src.TheTVDBID
|
dst.TheTVDBID = src.TheTVDBID
|
||||||
}
|
}
|
||||||
if src.SeasonNum > 0 {
|
if src.SeasonNum > 0 || src.EpisodeNum > 0 {
|
||||||
dst.SeasonNum = src.SeasonNum
|
dst.SeasonNum = src.SeasonNum
|
||||||
}
|
}
|
||||||
if src.EpisodeNum > 0 {
|
if src.EpisodeNum > 0 {
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import (
|
|||||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
"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
|
// "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) {
|
func (c *Container) RepairCloudPathMetadata(ctx context.Context) (int, error) {
|
||||||
if c == nil || c.Repo == nil || c.Repo.DB == nil {
|
if c == nil || c.Repo == nil || c.Repo.DB == nil {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
@@ -22,7 +22,6 @@ func (c *Container) RepairCloudPathMetadata(ctx context.Context) (int, error) {
|
|||||||
query := c.Repo.DB.WithContext(ctx).
|
query := c.Repo.DB.WithContext(ctx).
|
||||||
Model(&model.Media{}).
|
Model(&model.Media{}).
|
||||||
Select("id, title, path, year, season_num, episode_num, scrape_status, tm_db_id, bangumi_id, douban_id, thetvdb_id").
|
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{
|
Where("("+strings.Join([]string{
|
||||||
"LOWER(path) LIKE ?",
|
"LOWER(path) LIKE ?",
|
||||||
"LOWER(path) LIKE ?",
|
"LOWER(path) LIKE ?",
|
||||||
@@ -44,22 +43,22 @@ func (c *Container) RepairCloudPathMetadata(ctx context.Context) (int, error) {
|
|||||||
updates := map[string]any{}
|
updates := map[string]any{}
|
||||||
status := strings.TrimSpace(row.ScrapeStatus)
|
status := strings.TrimSpace(row.ScrapeStatus)
|
||||||
enrichable := status == "" || status == "pending" || status == "no_match"
|
enrichable := status == "" || status == "pending" || status == "no_match"
|
||||||
backfilledExternalID := false
|
changedExternalID := false
|
||||||
if meta.TMDbID > 0 && row.TMDbID <= 0 {
|
if meta.TMDbID > 0 && row.TMDbID != meta.TMDbID {
|
||||||
updates["tm_db_id"] = 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
|
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)
|
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)
|
updates["thetvdb_id"] = strings.TrimSpace(meta.TheTVDBID)
|
||||||
backfilledExternalID = true
|
changedExternalID = true
|
||||||
}
|
}
|
||||||
if meta.Year > 0 && row.Year <= 0 {
|
if meta.Year > 0 && row.Year <= 0 {
|
||||||
updates["year"] = meta.Year
|
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) {
|
if enrichable && strings.TrimSpace(meta.Title) != "" && cloudPathRepairShouldReplaceTitle(row.Title, meta.Title) {
|
||||||
updates["title"] = strings.TrimSpace(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"
|
updates["scrape_status"] = "pending"
|
||||||
}
|
}
|
||||||
if len(updates) == 0 {
|
if len(updates) == 0 {
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
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*$`)
|
embyYearSuffixRE = regexp.MustCompile(`\s*[\((\[]\d{4}[\))\]]\s*$`)
|
||||||
embyEpisodeTitleRE = regexp.MustCompile(`(?i)\s*[-_ ]*s\d{1,2}e\d{1,3}.*$`)
|
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") {
|
if filterBySeasonNumbers && containsItemType(p.IncludeItemTypes, "Movie") && !containsItemType(p.IncludeItemTypes, "Episode") {
|
||||||
q = e.filterMovieItems(ctx, q)
|
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") {
|
if filterBySeasonNumbers && containsItemType(p.IncludeItemTypes, "Episode") && !containsItemType(p.IncludeItemTypes, "Movie") {
|
||||||
q = e.filterEpisodeItems(ctx, q)
|
q = e.filterEpisodeItems(ctx, q)
|
||||||
}
|
}
|
||||||
@@ -892,7 +895,7 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
|
|||||||
seriesID := m.SeriesID
|
seriesID := m.SeriesID
|
||||||
seriesName := ""
|
seriesName := ""
|
||||||
seasonID := ""
|
seasonID := ""
|
||||||
if e.mediaBelongsToEpisodicLibrary(ctx, m) && (m.SeasonNum > 0 || m.EpisodeNum > 0) {
|
if e.mediaShouldBeEpisode(ctx, m) {
|
||||||
itemType = "Episode"
|
itemType = "Episode"
|
||||||
seriesID = e.seriesIDForMedia(m)
|
seriesID = e.seriesIDForMedia(m)
|
||||||
seriesName = e.seriesNameForMedia(m)
|
seriesName = e.seriesNameForMedia(m)
|
||||||
@@ -1027,6 +1030,16 @@ func (e *EmbyService) mediaBelongsToEpisodicLibrary(ctx context.Context, m *mode
|
|||||||
return embyLibraryTypeIsEpisodic(lib.Type)
|
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 {
|
func embyLibraryTypeIsEpisodic(typ string) bool {
|
||||||
switch strings.ToLower(strings.TrimSpace(typ)) {
|
switch strings.ToLower(strings.TrimSpace(typ)) {
|
||||||
case "tv", "anime", "variety":
|
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 {
|
func (e *EmbyService) filterMovieItems(ctx context.Context, q *gorm.DB) *gorm.DB {
|
||||||
episodicIDs := e.episodicLibraryIDs(ctx)
|
episodicIDs := e.episodicLibraryIDs(ctx)
|
||||||
if len(episodicIDs) == 0 {
|
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 {
|
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
|
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) {
|
func (e *EmbyService) rememberSeriesGroup(group embySeriesGroup) {
|
||||||
if e == nil || strings.TrimSpace(group.ID) == "" {
|
if e == nil || strings.TrimSpace(group.ID) == "" {
|
||||||
return
|
return
|
||||||
@@ -1316,7 +1387,7 @@ func (e *EmbyService) seasonsForSeries(series embySeriesGroup) []embySeasonGroup
|
|||||||
order := []int{}
|
order := []int{}
|
||||||
for _, episode := range series.Episodes {
|
for _, episode := range series.Episodes {
|
||||||
seasonNum := episode.SeasonNum
|
seasonNum := episode.SeasonNum
|
||||||
if seasonNum <= 0 {
|
if seasonNum < 0 {
|
||||||
seasonNum = 1
|
seasonNum = 1
|
||||||
}
|
}
|
||||||
season, ok := bySeason[seasonNum]
|
season, ok := bySeason[seasonNum]
|
||||||
@@ -1502,11 +1573,17 @@ func stableEmbyID(prefix string, parts ...string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func seasonID(seriesID string, seasonNum int) 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 {
|
func seasonName(seasonNum int) string {
|
||||||
if seasonNum <= 0 {
|
if seasonNum == 0 {
|
||||||
|
return "特别篇"
|
||||||
|
}
|
||||||
|
if seasonNum < 0 {
|
||||||
seasonNum = 1
|
seasonNum = 1
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("第 %d 季", seasonNum)
|
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) {
|
func TestEmbyMergedLocalCloudMovieVersionsShareMediaSources(t *testing.T) {
|
||||||
svc := newTestEmbyService(t)
|
svc := newTestEmbyService(t)
|
||||||
local := model.Library{Name: "国产电影", Path: `/media/国产电影`, Type: "movie", Enabled: true}
|
local := model.Library{Name: "国产电影", Path: `/media/国产电影`, Type: "movie", Enabled: true}
|
||||||
|
|||||||
@@ -22,14 +22,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
|
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
|
||||||
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
|
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
|
||||||
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
|
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
|
||||||
patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
|
patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
|
||||||
patDashEpisode = regexp.MustCompile(`[\s._-][-–—]\s*(\d{1,3})(?:\s*(?:v\d+)?)?(?:\s*[\[\(._-]|$)`)
|
patDashEpisode = regexp.MustCompile(`[\s._-][-–—]\s*(\d{1,3})(?:\s*(?:v\d+)?)?(?:\s*[\[\(._-]|$)`)
|
||||||
patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*([0-9一二三四五六七八九十百零两]+)\s*季`)
|
patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*([0-9一二三四五六七八九十百零两]+)\s*季`)
|
||||||
patSeasonOnly = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\.?\s*\d{1,2}(?:[\s._-]|$)`)
|
patSeasonOnly = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\.?\s*\d{1,2}(?:[\s._-]|$)`)
|
||||||
patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`)
|
patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`)
|
||||||
|
patSpecialSeason = regexp.MustCompile(`(?i)^(?:s0+|season[\s._-]*0+|specials?|sp|ova|oad|extra|extras|番外|特别篇|特別篇|特典)$`)
|
||||||
// patCNSeason 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。
|
// patCNSeason 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。
|
||||||
patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`)
|
patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`)
|
||||||
)
|
)
|
||||||
@@ -50,30 +51,33 @@ func ParseEpisode(path string) (season, episode int) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if m := patEP.FindStringSubmatch(name); len(m) >= 2 {
|
if m := patEP.FindStringSubmatch(name); len(m) >= 2 {
|
||||||
season = seasonFromParents(path)
|
var found bool
|
||||||
if season == 0 {
|
season, found = seasonFromParents(path)
|
||||||
|
if !found {
|
||||||
season = 1
|
season = 1
|
||||||
}
|
}
|
||||||
episode = mustAtoi(m[1])
|
episode = mustAtoi(m[1])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if m := patCN.FindStringSubmatch(name); len(m) >= 2 {
|
if m := patCN.FindStringSubmatch(name); len(m) >= 2 {
|
||||||
season = seasonFromParents(path)
|
var found bool
|
||||||
if season == 0 {
|
season, found = seasonFromParents(path)
|
||||||
|
if !found {
|
||||||
season = 1
|
season = 1
|
||||||
}
|
}
|
||||||
episode = mustAtoi(m[1])
|
episode = mustAtoi(m[1])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if m := patDashEpisode.FindStringSubmatch(name); len(m) >= 2 {
|
if m := patDashEpisode.FindStringSubmatch(name); len(m) >= 2 {
|
||||||
season = seasonFromParents(path)
|
var found bool
|
||||||
if season == 0 {
|
season, found = seasonFromParents(path)
|
||||||
|
if !found {
|
||||||
season = 1
|
season = 1
|
||||||
}
|
}
|
||||||
episode = mustAtoi(m[1])
|
episode = mustAtoi(m[1])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if parentSeason := seasonFromParents(path); parentSeason > 0 {
|
if parentSeason, found := seasonFromParents(path); found {
|
||||||
if m := patBareEpisode.FindStringSubmatch(strings.TrimSpace(name)); len(m) >= 2 {
|
if m := patBareEpisode.FindStringSubmatch(strings.TrimSpace(name)); len(m) >= 2 {
|
||||||
season = parentSeason
|
season = parentSeason
|
||||||
episode = mustAtoi(m[1])
|
episode = mustAtoi(m[1])
|
||||||
@@ -83,27 +87,41 @@ func ParseEpisode(path string) (season, episode int) {
|
|||||||
return 0, 0
|
return 0, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func seasonFromParents(path string) int {
|
func seasonFromParents(path string) (int, bool) {
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
for i := 0; i < 4; i++ {
|
for i := 0; i < 4; i++ {
|
||||||
base := filepath.Base(dir)
|
base := filepath.Base(dir)
|
||||||
if base == "." || base == string(filepath.Separator) {
|
if base == "." || base == string(filepath.Separator) {
|
||||||
return 0
|
return 0, false
|
||||||
}
|
}
|
||||||
if m := patSeasonFolder.FindStringSubmatch(base); len(m) >= 3 {
|
if season, ok := seasonFromDir(base); ok {
|
||||||
for _, group := range m[1:] {
|
return season, true
|
||||||
if group != "" {
|
|
||||||
return mustAtoi(group)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
parent := filepath.Dir(dir)
|
parent := filepath.Dir(dir)
|
||||||
if parent == dir {
|
if parent == dir {
|
||||||
return 0
|
return 0, false
|
||||||
}
|
}
|
||||||
dir = parent
|
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 {
|
func mustAtoi(s string) int {
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ func TestParseEpisode(t *testing.T) {
|
|||||||
{`综艺/Season 06/综艺 第17期.mkv`, 6, 17},
|
{`综艺/Season 06/综艺 第17期.mkv`, 6, 17},
|
||||||
{`动漫/第二季/04.mkv`, 2, 4},
|
{`动漫/第二季/04.mkv`, 2, 4},
|
||||||
{`动漫/第十季/第十一集.mkv`, 10, 11},
|
{`动漫/第十季/第十一集.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},
|
{"Movie.2020.1080p.mkv", 0, 0},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
|
|||||||
@@ -11,24 +11,29 @@ import (
|
|||||||
// intentionally separate from model.Media because the item may not exist in
|
// intentionally separate from model.Media because the item may not exist in
|
||||||
// the local library yet.
|
// the local library yet.
|
||||||
type ExternalMediaResult struct {
|
type ExternalMediaResult struct {
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
MediaType string `json:"media_type,omitempty"`
|
MediaType string `json:"media_type,omitempty"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Overview string `json:"overview,omitempty"`
|
OriginalName string `json:"original_name,omitempty"`
|
||||||
PosterURL string `json:"poster_url,omitempty"`
|
Overview string `json:"overview,omitempty"`
|
||||||
BackdropURL string `json:"backdrop_url,omitempty"`
|
PosterURL string `json:"poster_url,omitempty"`
|
||||||
Year int `json:"year,omitempty"`
|
BackdropURL string `json:"backdrop_url,omitempty"`
|
||||||
Rating float32 `json:"rating,omitempty"`
|
Year int `json:"year,omitempty"`
|
||||||
TMDbID int `json:"tmdb_id,omitempty"`
|
Rating float32 `json:"rating,omitempty"`
|
||||||
BangumiID int `json:"bangumi_id,omitempty"`
|
TMDbID int `json:"tmdb_id,omitempty"`
|
||||||
DoubanID string `json:"douban_id,omitempty"`
|
BangumiID int `json:"bangumi_id,omitempty"`
|
||||||
TheTVDBID string `json:"thetvdb_id,omitempty"`
|
DoubanID string `json:"douban_id,omitempty"`
|
||||||
SubscribeKeyword string `json:"subscribe_keyword"`
|
TheTVDBID string `json:"thetvdb_id,omitempty"`
|
||||||
TotalEpisodes int `json:"total_episodes,omitempty"`
|
SubscribeKeyword string `json:"subscribe_keyword"`
|
||||||
DownloadedEpisodes int `json:"downloaded_episodes,omitempty"`
|
TotalEpisodes int `json:"total_episodes,omitempty"`
|
||||||
LocalMediaCount int `json:"local_media_count,omitempty"`
|
DownloadedEpisodes int `json:"downloaded_episodes,omitempty"`
|
||||||
MissingEpisodes []int `json:"missing_episodes,omitempty"`
|
LocalMediaCount int `json:"local_media_count,omitempty"`
|
||||||
InLibrary bool `json:"in_library"`
|
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
|
// 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,
|
Source: source,
|
||||||
MediaType: typ,
|
MediaType: typ,
|
||||||
Title: m.Title,
|
Title: m.Title,
|
||||||
|
OriginalName: m.OriginalName,
|
||||||
Overview: m.Overview,
|
Overview: m.Overview,
|
||||||
PosterURL: m.PosterURL,
|
PosterURL: m.PosterURL,
|
||||||
BackdropURL: m.BackdropURL,
|
BackdropURL: m.BackdropURL,
|
||||||
@@ -62,6 +68,10 @@ func SearchExternalMedia(ctx context.Context, query string, year int, mediaType
|
|||||||
BangumiID: m.BangumiID,
|
BangumiID: m.BangumiID,
|
||||||
SubscribeKeyword: buildSubscribeKeyword(m.Title, m.Year),
|
SubscribeKeyword: buildSubscribeKeyword(m.Title, m.Year),
|
||||||
TotalEpisodes: totalEpisodes,
|
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 {
|
for {
|
||||||
names := []string{"tvshow.nfo", "series.nfo"}
|
names := []string{"tvshow.nfo", "series.nfo"}
|
||||||
base := filepath.Base(dir)
|
base := filepath.Base(dir)
|
||||||
if seasonFromDir(base) > 0 {
|
if _, ok := seasonFromDir(base); ok {
|
||||||
parentBase := filepath.Base(filepath.Dir(dir))
|
parentBase := filepath.Base(filepath.Dir(dir))
|
||||||
names = append(names, parentBase+".nfo")
|
names = append(names, parentBase+".nfo")
|
||||||
}
|
}
|
||||||
@@ -447,7 +447,7 @@ func mergeEpisodeMetadata(dst, episode *LocalMetadata, doc *nfoDocument) {
|
|||||||
if episode.TheTVDBID != "" {
|
if episode.TheTVDBID != "" {
|
||||||
dst.TheTVDBID = episode.TheTVDBID
|
dst.TheTVDBID = episode.TheTVDBID
|
||||||
}
|
}
|
||||||
if episode.SeasonNum > 0 {
|
if episode.SeasonNum > 0 || episode.EpisodeNum > 0 {
|
||||||
dst.SeasonNum = episode.SeasonNum
|
dst.SeasonNum = episode.SeasonNum
|
||||||
}
|
}
|
||||||
if episode.EpisodeNum > 0 {
|
if episode.EpisodeNum > 0 {
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ func (s *ScraperService) ManualSearch(ctx context.Context, media *model.Media, q
|
|||||||
Source: source,
|
Source: source,
|
||||||
MediaType: typ,
|
MediaType: typ,
|
||||||
Title: match.Title,
|
Title: match.Title,
|
||||||
|
OriginalName: match.OriginalName,
|
||||||
Overview: match.Overview,
|
Overview: match.Overview,
|
||||||
PosterURL: match.PosterURL,
|
PosterURL: match.PosterURL,
|
||||||
BackdropURL: match.BackdropURL,
|
BackdropURL: match.BackdropURL,
|
||||||
@@ -77,9 +78,18 @@ func (s *ScraperService) ManualSearch(ctx context.Context, media *model.Media, q
|
|||||||
DoubanID: match.DoubanID,
|
DoubanID: match.DoubanID,
|
||||||
TheTVDBID: match.TheTVDBID,
|
TheTVDBID: match.TheTVDBID,
|
||||||
SubscribeKeyword: buildSubscribeKeyword(match.Title, match.Year),
|
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" {
|
if provider == "all" || provider == "tmdb" {
|
||||||
for _, match := range s.manualTMDbMatches(ctx, query, year, mediaType) {
|
for _, match := range s.manualTMDbMatches(ctx, query, year, mediaType) {
|
||||||
typ := mediaType
|
typ := mediaType
|
||||||
@@ -165,6 +175,10 @@ func (s *ScraperService) manualRequestMatch(ctx context.Context, req ManualScrap
|
|||||||
return mergeManualRequestIntoMatch(match, req), nil
|
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()
|
return fallback()
|
||||||
}
|
}
|
||||||
@@ -180,18 +194,52 @@ func (s *ScraperService) manualTMDbMatches(ctx context.Context, query string, ye
|
|||||||
}
|
}
|
||||||
out := make([]*Match, 0, 2)
|
out := make([]*Match, 0, 2)
|
||||||
if mediaType == "" || mediaType == "movie" {
|
if mediaType == "" || mediaType == "movie" {
|
||||||
if match, err := s.tmdb.SearchMovie(ctx, query, year); err == nil && match != nil {
|
if matches, err := s.tmdb.SearchMovieCandidates(ctx, query, year); err == nil {
|
||||||
out = append(out, match)
|
out = append(out, matches...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if mediaType == "" || mediaType == "tv" || mediaType == "anime" || mediaType == "variety" {
|
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)
|
out = append(out, match)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
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 {
|
func (s *ScraperService) manualTMDbMatchByID(ctx context.Context, id int, mediaType string) *Match {
|
||||||
if s.tmdb == nil || !s.tmdb.Enabled() || id <= 0 {
|
if s.tmdb == nil || !s.tmdb.Enabled() || id <= 0 {
|
||||||
return nil
|
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",
|
Path: "/media/国产剧/折腰 (2025)/Season 1/折腰.S01E01.mkv",
|
||||||
SeasonNum: 1,
|
SeasonNum: 1,
|
||||||
EpisodeNum: 1,
|
EpisodeNum: 1,
|
||||||
TMDbID: 220269,
|
TMDbID: 296753,
|
||||||
SizeBytes: 100,
|
SizeBytes: 100,
|
||||||
PosterURL: "https://image.tmdb.org/t/p/w500/poster.jpg",
|
PosterURL: "https://image.tmdb.org/t/p/w500/poster.jpg",
|
||||||
}
|
}
|
||||||
cloud := model.Media{
|
cloud := model.Media{
|
||||||
LibraryID: "cloud-tv",
|
LibraryID: "cloud-tv",
|
||||||
Title: "折腰",
|
Title: "折腰",
|
||||||
Path: "cloud://openlist/国产剧/折腰 (2025) {tmdb-220269}/Season 1/折腰.S01E01.mkv",
|
Path: "cloud://openlist/国产剧/折腰 (2025) {tmdb-296753}/Season 1/折腰.S01E01.mkv",
|
||||||
SeasonNum: 1,
|
SeasonNum: 1,
|
||||||
EpisodeNum: 1,
|
EpisodeNum: 1,
|
||||||
TMDbID: 220269,
|
TMDbID: 296753,
|
||||||
SizeBytes: 200,
|
SizeBytes: 200,
|
||||||
STRMURL: "/api/cloud/play/openlist?ref=/国产剧/折腰/01.mkv",
|
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) {
|
func TestMediaUpsertBackfillsExternalIDsForPendingCloudRows(t *testing.T) {
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -268,7 +311,7 @@ func TestMediaUpsertBackfillsExternalIDsForPendingCloudRows(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
repos := repository.New(db)
|
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{
|
if err := repos.DB.Create(&model.Media{
|
||||||
LibraryID: "cloud-tv",
|
LibraryID: "cloud-tv",
|
||||||
Title: "折腰",
|
Title: "折腰",
|
||||||
@@ -285,7 +328,7 @@ func TestMediaUpsertBackfillsExternalIDsForPendingCloudRows(t *testing.T) {
|
|||||||
Path: path,
|
Path: path,
|
||||||
SeasonNum: 1,
|
SeasonNum: 1,
|
||||||
EpisodeNum: 1,
|
EpisodeNum: 1,
|
||||||
TMDbID: 220269,
|
TMDbID: 296753,
|
||||||
Year: 2025,
|
Year: 2025,
|
||||||
ScrapeStatus: "pending",
|
ScrapeStatus: "pending",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -295,11 +338,53 @@ func TestMediaUpsertBackfillsExternalIDsForPendingCloudRows(t *testing.T) {
|
|||||||
if err := repos.DB.First(&got, "path = ?", path).Error; err != nil {
|
if err := repos.DB.First(&got, "path = ?", path).Error; err != nil {
|
||||||
t.Fatal(err)
|
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)
|
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) {
|
func TestRepairCloudPathMetadataBackfillsExistingPlaceholders(t *testing.T) {
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
if err != nil {
|
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) {
|
func TestSoftDeleteCloudMediaPurgesRecordWithoutRecycleBin(t *testing.T) {
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ type episodeNFO struct {
|
|||||||
XMLName xml.Name `xml:"episodedetails"`
|
XMLName xml.Name `xml:"episodedetails"`
|
||||||
Title string `xml:"title"`
|
Title string `xml:"title"`
|
||||||
ShowTitle string `xml:"showtitle,omitempty"`
|
ShowTitle string `xml:"showtitle,omitempty"`
|
||||||
Season int `xml:"season,omitempty"`
|
Season int `xml:"season"`
|
||||||
Episode int `xml:"episode,omitempty"`
|
Episode int `xml:"episode"`
|
||||||
Year int `xml:"year,omitempty"`
|
Year int `xml:"year,omitempty"`
|
||||||
Plot string `xml:"plot,omitempty"`
|
Plot string `xml:"plot,omitempty"`
|
||||||
Rating float32 `xml:"rating,omitempty"`
|
Rating float32 `xml:"rating,omitempty"`
|
||||||
|
|||||||
@@ -1840,16 +1840,16 @@ func cloudMetadataNeedsRefresh(existing existingCloudMedia, localMeta *LocalMeta
|
|||||||
if localMeta.Year > 0 && existing.Year <= 0 {
|
if localMeta.Year > 0 && existing.Year <= 0 {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if localMeta.TMDbID > 0 && existing.TMDbID <= 0 {
|
if localMeta.TMDbID > 0 && existing.TMDbID != localMeta.TMDbID {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if localMeta.BangumiID > 0 && existing.BangumiID <= 0 {
|
if localMeta.BangumiID > 0 && existing.BangumiID != localMeta.BangumiID {
|
||||||
return true
|
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
|
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
|
return true
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(localMeta.PosterURL) != "" && strings.TrimSpace(existing.PosterURL) == "" {
|
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])
|
base := strings.TrimSpace(dirs[len(dirs)-1])
|
||||||
usedSeasonFolder := false
|
usedSeasonFolder := false
|
||||||
if seasonFromDir(base) > 0 {
|
if _, ok := seasonFromDir(base); ok {
|
||||||
usedSeasonFolder = true
|
usedSeasonFolder = true
|
||||||
dirs = dirs[:len(dirs)-1]
|
dirs = dirs[:len(dirs)-1]
|
||||||
if len(dirs) == 0 {
|
if len(dirs) == 0 {
|
||||||
@@ -2387,7 +2387,7 @@ func applyLocalMetadata(m *model.Media, local *LocalMetadata) {
|
|||||||
if local.TheTVDBID != "" {
|
if local.TheTVDBID != "" {
|
||||||
m.TheTVDBID = local.TheTVDBID
|
m.TheTVDBID = local.TheTVDBID
|
||||||
}
|
}
|
||||||
if local.SeasonNum > 0 {
|
if local.SeasonNum > 0 || local.EpisodeNum > 0 {
|
||||||
m.SeasonNum = local.SeasonNum
|
m.SeasonNum = local.SeasonNum
|
||||||
}
|
}
|
||||||
if local.EpisodeNum > 0 {
|
if local.EpisodeNum > 0 {
|
||||||
@@ -2405,11 +2405,15 @@ func applyLocalMetadata(m *model.Media, local *LocalMetadata) {
|
|||||||
if local.NSFW {
|
if local.NSFW {
|
||||||
m.NSFW = true
|
m.NSFW = true
|
||||||
}
|
}
|
||||||
if local.HasNFO || (!local.PathHint && localHasDescriptiveMetadata(local)) {
|
if localMetadataMarksMatched(local) {
|
||||||
m.ScrapeStatus = "matched"
|
m.ScrapeStatus = "matched"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func localMetadataMarksMatched(local *LocalMetadata) bool {
|
||||||
|
return local != nil && (local.HasNFO || (!local.PathHint && localHasDescriptiveMetadata(local)))
|
||||||
|
}
|
||||||
|
|
||||||
func localHasDescriptiveMetadata(local *LocalMetadata) bool {
|
func localHasDescriptiveMetadata(local *LocalMetadata) bool {
|
||||||
if local == nil {
|
if local == nil {
|
||||||
return false
|
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) {
|
func TestScanCloudLibraryReadsRemoteSTRMTarget(t *testing.T) {
|
||||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
|
|||||||
+79
-21
@@ -224,6 +224,9 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
|
|||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
s.log.Warn("read local metadata before scrape failed", zap.String("media_id", m.ID), zap.Error(err))
|
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)
|
year := mediaYearHint(m)
|
||||||
@@ -299,6 +302,9 @@ func (s *ScraperService) matchFromMediaExternalIDs(ctx context.Context, m *model
|
|||||||
mediaType = lib.Type
|
mediaType = lib.Type
|
||||||
}
|
}
|
||||||
if m.TMDbID > 0 {
|
if m.TMDbID > 0 {
|
||||||
|
if mediaIsEpisodic(m, lib) {
|
||||||
|
mediaType = "tv"
|
||||||
|
}
|
||||||
if match := s.manualTMDbMatchByID(ctx, m.TMDbID, normalizeMediaType(mediaType, m.Title, "")); match != nil {
|
if match := s.manualTMDbMatchByID(ctx, m.TMDbID, normalizeMediaType(mediaType, m.Title, "")); match != nil {
|
||||||
return match
|
return match
|
||||||
}
|
}
|
||||||
@@ -422,6 +428,15 @@ func mergeLocalMetadataIntoMatch(match *Match, local *LocalMetadata) {
|
|||||||
if local.TMDbID > 0 {
|
if local.TMDbID > 0 {
|
||||||
match.TMDbID = local.TMDbID
|
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 != "" {
|
if local.Genres != "" {
|
||||||
match.Genres = splitNFOList(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
|
// Manual cloud/batch applies must not fail just because an optional provider
|
||||||
// details request is slow or unavailable.
|
// details request is slow or unavailable.
|
||||||
if match.TMDbID > 0 && s.tmdb != nil && s.tmdb.Enabled() {
|
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)
|
detailCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), tmdbDetailsTimeout)
|
||||||
details, err := s.tmdb.GetDetails(detailCtx, match.TMDbID, mediaType)
|
details, err := s.tmdb.GetDetails(detailCtx, match.TMDbID, mediaType)
|
||||||
cancel()
|
cancel()
|
||||||
@@ -518,6 +533,50 @@ func (s *ScraperService) applyProviderMatch(ctx context.Context, m *model.Media,
|
|||||||
zap.Strings("countries", details.Countries),
|
zap.Strings("countries", details.Countries),
|
||||||
zap.Strings("genres", details.Genres))
|
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))
|
cloudMedia := isCloudMediaPath(m.Path) || (lib != nil && isCloudMediaPath(lib.Path))
|
||||||
if !cloudMedia {
|
if !cloudMedia {
|
||||||
@@ -548,9 +607,13 @@ func isCloudMediaPath(value string) bool {
|
|||||||
func (s *ScraperService) applyLocalMetadataMatch(ctx context.Context, m *model.Media, local *LocalMetadata) error {
|
func (s *ScraperService) applyLocalMetadataMatch(ctx context.Context, m *model.Media, local *LocalMetadata) error {
|
||||||
next := *m
|
next := *m
|
||||||
applyLocalMetadata(&next, local)
|
applyLocalMetadata(&next, local)
|
||||||
|
status := "matched"
|
||||||
|
if !localMetadataMarksMatched(local) {
|
||||||
|
status = "pending"
|
||||||
|
}
|
||||||
updates := map[string]any{
|
updates := map[string]any{
|
||||||
"title": next.Title,
|
"title": next.Title,
|
||||||
"scrape_status": "matched",
|
"scrape_status": status,
|
||||||
}
|
}
|
||||||
if next.OriginalName != "" {
|
if next.OriginalName != "" {
|
||||||
updates["original_name"] = next.OriginalName
|
updates["original_name"] = next.OriginalName
|
||||||
@@ -582,7 +645,7 @@ func (s *ScraperService) applyLocalMetadataMatch(ctx context.Context, m *model.M
|
|||||||
if next.TheTVDBID != "" {
|
if next.TheTVDBID != "" {
|
||||||
updates["thetvdb_id"] = next.TheTVDBID
|
updates["thetvdb_id"] = next.TheTVDBID
|
||||||
}
|
}
|
||||||
if next.SeasonNum > 0 {
|
if next.SeasonNum > 0 || next.EpisodeNum > 0 {
|
||||||
updates["season_num"] = next.SeasonNum
|
updates["season_num"] = next.SeasonNum
|
||||||
}
|
}
|
||||||
if next.EpisodeNum > 0 {
|
if next.EpisodeNum > 0 {
|
||||||
@@ -644,7 +707,7 @@ func scrapeQueryCandidates(m *model.Media, lib *model.Library) []string {
|
|||||||
|
|
||||||
func seriesFolderTitle(mediaPath, libraryRoot string) string {
|
func seriesFolderTitle(mediaPath, libraryRoot string) string {
|
||||||
dir := filepath.Dir(mediaPath)
|
dir := filepath.Dir(mediaPath)
|
||||||
if strictSeasonFolder(filepath.Base(dir)) > 0 {
|
if strictSeasonFolderMatched(filepath.Base(dir)) {
|
||||||
dir = filepath.Dir(dir)
|
dir = filepath.Dir(dir)
|
||||||
}
|
}
|
||||||
if libraryRoot != "" && samePath(dir, filepath.Clean(libraryRoot)) {
|
if libraryRoot != "" && samePath(dir, filepath.Clean(libraryRoot)) {
|
||||||
@@ -687,27 +750,15 @@ func isGenericMediaCategoryFolder(name string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func strictSeasonFolder(name string) int {
|
func strictSeasonFolder(name string) int {
|
||||||
name = strings.TrimSpace(name)
|
if season, ok := seasonFromDir(name); ok {
|
||||||
if name == "" {
|
return season
|
||||||
return 0
|
|
||||||
}
|
|
||||||
for _, pattern := range strictSeasonFolderPatterns {
|
|
||||||
if m := pattern.FindStringSubmatch(name); len(m) == 2 {
|
|
||||||
return mustAtoi(m[1])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func seasonFromDir(name string) int {
|
func strictSeasonFolderMatched(name string) bool {
|
||||||
if m := patSeasonFolder.FindStringSubmatch(name); len(m) >= 3 {
|
_, ok := seasonFromDir(name)
|
||||||
for _, group := range m[1:] {
|
return ok
|
||||||
if group != "" {
|
|
||||||
return mustAtoi(group)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func titleCandidates(title string) []string {
|
func titleCandidates(title string) []string {
|
||||||
@@ -961,6 +1012,13 @@ func (s *ScraperService) AnyEnabled() bool {
|
|||||||
// determineMediaType returns "tv" for TV shows and "movie" for movies.
|
// determineMediaType returns "tv" for TV shows and "movie" for movies.
|
||||||
// It uses the library type as the primary signal.
|
// It uses the library type as the primary signal.
|
||||||
func (s *ScraperService) determineMediaType(lib *model.Library, match *Match) string {
|
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 {
|
if lib != nil {
|
||||||
switch lib.Type {
|
switch lib.Type {
|
||||||
case "tv", "anime", "variety", "show", "shows":
|
case "tv", "anime", "variety", "show", "shows":
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ func TestCleanQuery(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestExternalIDHintsFromText(t *testing.T) {
|
func TestExternalIDHintsFromText(t *testing.T) {
|
||||||
hints := externalIDHintsFromText("国漫/折腰 (2025) {tmdb 220269}/Season 1/折腰.S01E01.mkv")
|
hints := externalIDHintsFromText("国漫/折腰 (2025) {tmdb 296753}/Season 1/折腰.S01E01.mkv")
|
||||||
if hints.TMDbID != 220269 {
|
if hints.TMDbID != 296753 {
|
||||||
t.Fatalf("tmdb hint = %d, want 220269", hints.TMDbID)
|
t.Fatalf("tmdb hint = %d, want 296753", hints.TMDbID)
|
||||||
}
|
}
|
||||||
hints = externalIDHintsFromText("Movie (2026) {tmdb-1630433} [douban=3622222] {bgm 456789} {tvdb:12345}")
|
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" {
|
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) {
|
func TestPathHintMetadataDoesNotMarkMediaMatched(t *testing.T) {
|
||||||
meta, hints := pathHintMetadata("cloud://openlist/国漫/折腰 (2025) {tmdb 220269}/Season 1/折腰.S01E01.mkv", true)
|
meta, hints := pathHintMetadata("cloud://openlist/国漫/折腰 (2025) {tmdb 296753}/Season 1/折腰.S01E01.mkv", true)
|
||||||
if meta == nil || hints.TMDbID != 220269 || meta.TMDbID != 220269 || meta.Title != "折腰" || meta.Year != 2025 {
|
if meta == nil || hints.TMDbID != 296753 || meta.TMDbID != 296753 || meta.Title != "折腰" || meta.Year != 2025 {
|
||||||
t.Fatalf("path hint metadata = %+v hints=%+v", meta, hints)
|
t.Fatalf("path hint metadata = %+v hints=%+v", meta, hints)
|
||||||
}
|
}
|
||||||
media := &model.Media{Title: "折腰", ScrapeStatus: "pending"}
|
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) {
|
func TestManualRequestMatchFallsBackToCandidatePayload(t *testing.T) {
|
||||||
scraper := &ScraperService{}
|
scraper := &ScraperService{}
|
||||||
match, err := scraper.manualRequestMatch(t.Context(), ManualScrapeRequest{
|
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) {
|
func TestApplyManualMatchSavesSelectedCloudMatchWhenDetailsSlow(t *testing.T) {
|
||||||
oldTimeout := tmdbDetailsTimeout
|
oldTimeout := tmdbDetailsTimeout
|
||||||
tmdbDetailsTimeout = 20 * time.Millisecond
|
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) {
|
func TestEnrichOneRejectsWrongYearMatchFromSeriesFolder(t *testing.T) {
|
||||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -485,6 +708,15 @@ func newTestScraper(t *testing.T) (*ScraperService, *repository.Container, func(
|
|||||||
"vote_average": 8.6,
|
"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"):
|
case strings.HasPrefix(r.URL.Path, "/tv/12345"):
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
"id": 12345,
|
"id": 12345,
|
||||||
|
|||||||
+116
-31
@@ -142,9 +142,47 @@ type Match struct {
|
|||||||
NSFW bool `json:"nsfw,omitempty"`
|
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
|
// SearchMovie issues `/search/movie` and returns the best match, or nil
|
||||||
// when no result is found. The `year` argument is optional (0 = any).
|
// 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) {
|
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 == "" {
|
if query == "" {
|
||||||
return nil, errors.New("empty 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()
|
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 {
|
type page struct {
|
||||||
Results []result `json:"results"`
|
Results []tmdbMovieSearchResult `json:"results"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var p page
|
var p page
|
||||||
@@ -189,7 +215,14 @@ func (t *TMDbProvider) SearchMovie(ctx context.Context, query string, year int)
|
|||||||
if len(p.Results) == 0 {
|
if len(p.Results) == 0 {
|
||||||
return nil, nil
|
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{
|
m := &Match{
|
||||||
TMDbID: r.ID,
|
TMDbID: r.ID,
|
||||||
Title: r.Title,
|
Title: r.Title,
|
||||||
@@ -208,12 +241,21 @@ func (t *TMDbProvider) SearchMovie(ctx context.Context, query string, year int)
|
|||||||
if len(r.ReleaseDate) >= 4 {
|
if len(r.ReleaseDate) >= 4 {
|
||||||
_, _ = fmt.Sscanf(r.ReleaseDate[:4], "%d", &m.Year)
|
_, _ = 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 /
|
// SearchTV issues `/search/tv` and returns the best match. Used by anime /
|
||||||
// tv libraries before falling back to SearchMovie.
|
// tv libraries before falling back to SearchMovie.
|
||||||
func (t *TMDbProvider) SearchTV(ctx context.Context, query string, year int) (*Match, error) {
|
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 == "" {
|
if query == "" {
|
||||||
return nil, errors.New("empty 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()
|
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 {
|
type page struct {
|
||||||
Results []result `json:"results"`
|
Results []tmdbTVSearchResult `json:"results"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var p page
|
var p page
|
||||||
@@ -258,7 +287,14 @@ func (t *TMDbProvider) SearchTV(ctx context.Context, query string, year int) (*M
|
|||||||
if len(p.Results) == 0 {
|
if len(p.Results) == 0 {
|
||||||
return nil, nil
|
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{
|
m := &Match{
|
||||||
TMDbID: r.ID,
|
TMDbID: r.ID,
|
||||||
Title: r.Name,
|
Title: r.Name,
|
||||||
@@ -281,7 +317,7 @@ func (t *TMDbProvider) SearchTV(ctx context.Context, query string, year int) (*M
|
|||||||
if len(r.FirstAirDate) >= 4 {
|
if len(r.FirstAirDate) >= 4 {
|
||||||
_, _ = fmt.Sscanf(r.FirstAirDate[:4], "%d", &m.Year)
|
_, _ = 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 {
|
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
|
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.
|
// TMDbDetails holds extended metadata from the /movie/{id} or /tv/{id} endpoints.
|
||||||
type TMDbDetails struct {
|
type TMDbDetails struct {
|
||||||
Languages []string `json:"languages"`
|
Languages []string `json:"languages"`
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface ManualScrapeCandidate {
|
|||||||
source: string
|
source: string
|
||||||
media_type?: string
|
media_type?: string
|
||||||
title: string
|
title: string
|
||||||
|
original_name?: string
|
||||||
overview?: string
|
overview?: string
|
||||||
poster_url?: string
|
poster_url?: string
|
||||||
backdrop_url?: string
|
backdrop_url?: string
|
||||||
@@ -31,6 +32,27 @@ export interface ManualScrapeCandidate {
|
|||||||
languages?: string[]
|
languages?: string[]
|
||||||
countries?: string[]
|
countries?: string[]
|
||||||
genres?: 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 = {
|
export const libraryAPI = {
|
||||||
@@ -75,6 +97,9 @@ export const mediaAPI = {
|
|||||||
|
|
||||||
get: (id: string) => api.get<Media>(`/media/${id}`).then((r) => r.data),
|
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 }) =>
|
manualScrapeSearch: (id: string, params: { query: string; provider?: string; media_type?: string }) =>
|
||||||
api
|
api
|
||||||
.get<{ items: ManualScrapeCandidate[] }>(`/media/${id}/scrape/search`, { params })
|
.get<{ items: ManualScrapeCandidate[] }>(`/media/${id}/scrape/search`, { params })
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function APIConfigsPanel() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-display text-lg font-semibold text-ink-600">外部 API 配置</p>
|
<p className="font-display text-lg font-semibold text-ink-600">外部 API 配置</p>
|
||||||
<p className="text-xs text-ink-50">
|
<p className="text-xs text-ink-50">
|
||||||
TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban 密钥管理
|
TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban / Adult 密钥与源管理
|
||||||
· AES-GCM 加密存储
|
· AES-GCM 加密存储
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,14 +73,17 @@ export function APIConfigsPanel() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 font-mono text-xs">
|
<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-brand-500">{item.masked_key}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-500">未配置</span>
|
<span className="text-gray-500">未配置</span>
|
||||||
)}
|
)}
|
||||||
|
{item.provider === 'adult' && apiConfigSourceCount(item) > 0 ? (
|
||||||
|
<span className="text-brand-500">{apiConfigSourceCount(item)} 个源</span>
|
||||||
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<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 className="inline-flex items-center gap-1 rounded-full bg-emerald-400/10 px-2 py-0.5 text-xs text-emerald-400">
|
||||||
已配置
|
已配置
|
||||||
</span>
|
</span>
|
||||||
@@ -148,14 +151,17 @@ function EditingRow({
|
|||||||
}) {
|
}) {
|
||||||
const [apiKey, setAPIKey] = useState('')
|
const [apiKey, setAPIKey] = useState('')
|
||||||
const [baseURL, setBaseURL] = useState(item.base_url ?? '')
|
const [baseURL, setBaseURL] = useState(item.base_url ?? '')
|
||||||
|
const [extra, setExtra] = useState(item.extra ?? '')
|
||||||
const [enabled, setEnabled] = useState(item.enabled)
|
const [enabled, setEnabled] = useState(item.enabled)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const isAdult = item.provider === 'adult'
|
||||||
|
|
||||||
const submit = async (e: FormEvent) => {
|
const submit = async (e: FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const patch: Record<string, unknown> = { base_url: baseURL, enabled }
|
const patch: Record<string, unknown> = { base_url: baseURL, enabled }
|
||||||
|
if (isAdult) patch.extra = extra
|
||||||
if (apiKey.trim()) patch.api_key = apiKey.trim()
|
if (apiKey.trim()) patch.api_key = apiKey.trim()
|
||||||
await apiConfigsAPI.update(item.provider, patch)
|
await apiConfigsAPI.update(item.provider, patch)
|
||||||
toast.success(`${item.provider} 已保存`)
|
toast.success(`${item.provider} 已保存`)
|
||||||
@@ -175,25 +181,38 @@ function EditingRow({
|
|||||||
<td colSpan={4} className="px-4 py-3">
|
<td colSpan={4} className="px-4 py-3">
|
||||||
<form onSubmit={submit} className="flex flex-wrap items-end gap-3">
|
<form onSubmit={submit} className="flex flex-wrap items-end gap-3">
|
||||||
<span className="text-sm font-medium text-ink-600">{item.provider}</span>
|
<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">
|
<label className="flex-1 text-xs text-ink-50">
|
||||||
API Key
|
{isAdult ? '主源 URL' : 'Base URL'}
|
||||||
<input
|
<input
|
||||||
className="input-base mt-1"
|
className="input-base mt-1"
|
||||||
type="password"
|
placeholder={isAdult ? 'https://javdb.com' : 'https://api.themoviedb.org/3'}
|
||||||
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"
|
|
||||||
value={baseURL}
|
value={baseURL}
|
||||||
onChange={(e) => setBaseURL(e.target.value)}
|
onChange={(e) => setBaseURL(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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">
|
<label className="flex items-center gap-2 text-xs text-ink-50">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -217,3 +236,19 @@ function EditingRow({
|
|||||||
</tr>
|
</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: 'douban', label: '豆瓣' },
|
||||||
{ value: 'bangumi', label: 'Bangumi' },
|
{ value: 'bangumi', label: 'Bangumi' },
|
||||||
{ value: 'thetvdb', label: 'TheTVDB' },
|
{ value: 'thetvdb', label: 'TheTVDB' },
|
||||||
|
{ value: 'adult', label: 'Adult / 番号' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function ManualScrapeDialog({
|
export function ManualScrapeDialog({
|
||||||
@@ -164,6 +165,7 @@ export function ManualScrapeDialog({
|
|||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h3 className="truncate font-semibold text-ink-600">{item.title}</h3>
|
<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>
|
<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}
|
{item.year ? <span className="text-xs text-sand-500">{item.year}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-ink-50">{item.overview || '暂无简介'}</p>
|
<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 {
|
function candidateIDText(item: ManualScrapeCandidate): string {
|
||||||
const parts = [
|
const parts = [
|
||||||
item.tmdb_id ? `TMDb ${item.tmdb_id}` : '',
|
item.tmdb_id ? `TMDb ${item.tmdb_id}` : '',
|
||||||
|
item.original_name && item.source === 'adult' ? `番号 ${item.original_name}` : '',
|
||||||
item.douban_id ? `豆瓣 ${item.douban_id}` : '',
|
item.douban_id ? `豆瓣 ${item.douban_id}` : '',
|
||||||
item.bangumi_id ? `Bangumi ${item.bangumi_id}` : '',
|
item.bangumi_id ? `Bangumi ${item.bangumi_id}` : '',
|
||||||
item.thetvdb_id ? `TheTVDB ${item.thetvdb_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>
|
<div>
|
||||||
<h1 className="font-display text-3xl font-bold text-ink-600">外部 API 配置</h1>
|
<h1 className="font-display text-3xl font-bold text-ink-600">外部 API 配置</h1>
|
||||||
<p className="text-sm text-ink-50">
|
<p className="text-sm text-ink-50">
|
||||||
管理 TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban 的密钥。
|
管理 TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban / Adult 的密钥与源。
|
||||||
后端使用 AES-GCM 加密存储,数据库泄漏时密钥仍然安全。
|
后端使用 AES-GCM 加密存储,数据库泄漏时密钥仍然安全。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,14 +50,17 @@ export function APIConfigsPage() {
|
|||||||
function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => void }) {
|
function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => void }) {
|
||||||
const [apiKey, setAPIKey] = useState('')
|
const [apiKey, setAPIKey] = useState('')
|
||||||
const [baseURL, setBaseURL] = useState(item.base_url ?? '')
|
const [baseURL, setBaseURL] = useState(item.base_url ?? '')
|
||||||
|
const [extra, setExtra] = useState(item.extra ?? '')
|
||||||
const [enabled, setEnabled] = useState(item.enabled)
|
const [enabled, setEnabled] = useState(item.enabled)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const isAdult = item.provider === 'adult'
|
||||||
|
|
||||||
const submit = async (e: FormEvent) => {
|
const submit = async (e: FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const patch: Record<string, unknown> = { base_url: baseURL, enabled }
|
const patch: Record<string, unknown> = { base_url: baseURL, enabled }
|
||||||
|
if (isAdult) patch.extra = extra
|
||||||
if (apiKey.trim()) patch.api_key = apiKey.trim()
|
if (apiKey.trim()) patch.api_key = apiKey.trim()
|
||||||
await apiConfigsAPI.update(item.provider, patch)
|
await apiConfigsAPI.update(item.provider, patch)
|
||||||
toast.success(`${item.provider} 已保存`)
|
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="text-xs text-ink-50">{item.description}</p>
|
||||||
)}
|
)}
|
||||||
<p className="mt-2 text-xs text-sand-500">
|
<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 && (
|
{item.has_key && (
|
||||||
<span className="ml-2 font-mono text-brand-500">{item.masked_key}</span>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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">
|
<label className="block text-xs text-ink-50">
|
||||||
API Key (留空保留原值)
|
{isAdult ? '主源 URL' : 'Base URL (可选)'}
|
||||||
<input
|
<input
|
||||||
className="input-base mt-1"
|
className="input-base mt-1"
|
||||||
type="password"
|
placeholder={isAdult ? 'https://javdb.com' : 'https://api.themoviedb.org/3'}
|
||||||
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"
|
|
||||||
value={baseURL}
|
value={baseURL}
|
||||||
onChange={(e) => setBaseURL(e.target.value)}
|
onChange={(e) => setBaseURL(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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">
|
<label className="inline-flex items-center gap-2 text-xs text-ink-50">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -152,3 +171,19 @@ function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => v
|
|||||||
</form>
|
</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}
|
key={card.key}
|
||||||
media={card.rep}
|
media={card.rep}
|
||||||
count={card.count}
|
count={card.count}
|
||||||
linkTo={card.count > 1 ? seriesCardLink(card) : undefined}
|
linkTo={seriesCardLink(card)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|||||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import toast from 'react-hot-toast'
|
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 { libraryAPI } from '../api/library'
|
||||||
import { storageAPI, type CloudScanStatus } from '../api/storage_config'
|
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 { useWebSocket } from '../hooks/useWebSocket'
|
||||||
import { confirmAction } from '../components/ConfirmDialog'
|
import { confirmAction } from '../components/ConfirmDialog'
|
||||||
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
|
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
|
||||||
|
import { MetadataEditDialog } from '../components/MetadataEditDialog'
|
||||||
|
|
||||||
export function LibraryPage() {
|
export function LibraryPage() {
|
||||||
const { id = '' } = useParams()
|
const { id = '' } = useParams()
|
||||||
@@ -33,6 +34,7 @@ export function LibraryPage() {
|
|||||||
const [scraping, setScraping] = useState(false)
|
const [scraping, setScraping] = useState(false)
|
||||||
const [seriesToolBusy, setSeriesToolBusy] = useState('')
|
const [seriesToolBusy, setSeriesToolBusy] = useState('')
|
||||||
const [manualSeriesScrapeOpen, setManualSeriesScrapeOpen] = useState(false)
|
const [manualSeriesScrapeOpen, setManualSeriesScrapeOpen] = useState(false)
|
||||||
|
const [seriesMetadataEditOpen, setSeriesMetadataEditOpen] = useState(false)
|
||||||
const [manualMovie, setManualMovie] = useState<Media | null>(null)
|
const [manualMovie, setManualMovie] = useState<Media | null>(null)
|
||||||
const [movieToolBusy, setMovieToolBusy] = useState('')
|
const [movieToolBusy, setMovieToolBusy] = useState('')
|
||||||
|
|
||||||
@@ -56,7 +58,7 @@ export function LibraryPage() {
|
|||||||
// 按季分组,按集排序
|
// 按季分组,按集排序
|
||||||
const seasons = new Map<number, Media[]>()
|
const seasons = new Map<number, Media[]>()
|
||||||
for (const ep of eps) {
|
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, [])
|
if (!seasons.has(s)) seasons.set(s, [])
|
||||||
seasons.get(s)!.push(ep)
|
seasons.get(s)!.push(ep)
|
||||||
}
|
}
|
||||||
@@ -226,7 +228,11 @@ export function LibraryPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const next = seriesCards.find((card) => card.key === key)
|
const next = seriesCards.find((card) => card.key === key)
|
||||||
if (next) setSelectedSeries(next)
|
if (next) {
|
||||||
|
setSelectedSeries(next)
|
||||||
|
} else {
|
||||||
|
setSelectedSeries(null)
|
||||||
|
}
|
||||||
}, [isSeries, loading, searchParams, seriesCards])
|
}, [isSeries, loading, searchParams, seriesCards])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -385,6 +391,7 @@ export function LibraryPage() {
|
|||||||
const clearSelectedSeries = () => {
|
const clearSelectedSeries = () => {
|
||||||
setSelectedSeries(null)
|
setSelectedSeries(null)
|
||||||
setSelectedSeason(null)
|
setSelectedSeason(null)
|
||||||
|
setSeriesMetadataEditOpen(false)
|
||||||
const next = new URLSearchParams(searchParams)
|
const next = new URLSearchParams(searchParams)
|
||||||
next.delete('series')
|
next.delete('series')
|
||||||
setSearchParams(next)
|
setSearchParams(next)
|
||||||
@@ -518,6 +525,10 @@ export function LibraryPage() {
|
|||||||
<Search size={13} className="text-[#c9954a]" />
|
<Search size={13} className="text-[#c9954a]" />
|
||||||
<span>手动匹配整剧</span>
|
<span>手动匹配整剧</span>
|
||||||
</button>
|
</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">
|
<button onClick={handleSeriesProbe} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
|
||||||
<Database size={13} />
|
<Database size={13} />
|
||||||
<span>{seriesToolBusy === 'probe' ? '探测中…' : '探测媒体轨'}</span>
|
<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')
|
: '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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 font-display text-lg font-semibold text-ink-600">
|
<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>
|
</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">
|
<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) => (
|
{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"
|
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">
|
<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">
|
<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.episode_num || '—'}
|
{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>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-medium text-ink-600">
|
<p className="truncate text-sm font-medium text-ink-600">
|
||||||
@@ -609,6 +631,15 @@ export function LibraryPage() {
|
|||||||
onClose={() => setManualSeriesScrapeOpen(false)}
|
onClose={() => setManualSeriesScrapeOpen(false)}
|
||||||
onApplied={reloadCurrentLibrary}
|
onApplied={reloadCurrentLibrary}
|
||||||
/>
|
/>
|
||||||
|
<MetadataEditDialog
|
||||||
|
open={seriesMetadataEditOpen}
|
||||||
|
media={selectedSeries?.rep ?? null}
|
||||||
|
mediaIds={selectedSeriesMediaIDs}
|
||||||
|
mode="series"
|
||||||
|
scopeLabel={selectedSeries ? seriesTitle(selectedSeries.rep) : '当前剧集'}
|
||||||
|
onClose={() => setSeriesMetadataEditOpen(false)}
|
||||||
|
onSaved={reloadCurrentLibrary}
|
||||||
|
/>
|
||||||
<ManualScrapeDialog
|
<ManualScrapeDialog
|
||||||
open={!!manualMovie}
|
open={!!manualMovie}
|
||||||
media={manualMovie}
|
media={manualMovie}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
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 toast from 'react-hot-toast'
|
||||||
|
|
||||||
import { mediaAPI } from '../api/library'
|
import { mediaAPI } from '../api/library'
|
||||||
@@ -14,6 +14,7 @@ import type { Media } from '../types'
|
|||||||
import { confirmAction } from '../components/ConfirmDialog'
|
import { confirmAction } from '../components/ConfirmDialog'
|
||||||
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
||||||
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
|
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
|
||||||
|
import { MetadataEditDialog } from '../components/MetadataEditDialog'
|
||||||
|
|
||||||
function fmtDuration(sec: number): string {
|
function fmtDuration(sec: number): string {
|
||||||
if (!sec || sec <= 0) return '—'
|
if (!sec || sec <= 0) return '—'
|
||||||
@@ -47,6 +48,7 @@ export function MediaDetailPage() {
|
|||||||
const [favourite, setFavourite] = useState(false)
|
const [favourite, setFavourite] = useState(false)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [manualScrapeOpen, setManualScrapeOpen] = useState(false)
|
const [manualScrapeOpen, setManualScrapeOpen] = useState(false)
|
||||||
|
const [metadataEditOpen, setMetadataEditOpen] = useState(false)
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
if (!id) return
|
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)]">
|
<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 ── */}
|
{/* ── Cinematic Blurred Backdrop Glow ── */}
|
||||||
<div className="absolute inset-0 h-[480px] z-0 overflow-hidden">
|
<div className="absolute inset-0 h-[480px] z-0 overflow-hidden">
|
||||||
{media.poster_url ? (
|
{(media.backdrop_url || media.poster_url) ? (
|
||||||
<img
|
<img
|
||||||
src={imageURL(media.poster_url)}
|
src={imageURL(media.backdrop_url || media.poster_url || '')}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-full h-full object-cover opacity-[0.04] scale-110 blur-2xl"
|
className="w-full h-full object-cover opacity-[0.04] scale-110 blur-2xl"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
@@ -321,6 +323,10 @@ export function MediaDetailPage() {
|
|||||||
<Search size={13} className="text-[#c9954a]" />
|
<Search size={13} className="text-[#c9954a]" />
|
||||||
<span>手动匹配刮削</span>
|
<span>手动匹配刮削</span>
|
||||||
</button>
|
</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">
|
<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" />
|
<Database size={13} className="text-gray-600" />
|
||||||
<span>探测媒体轨 (ffprobe)</span>
|
<span>探测媒体轨 (ffprobe)</span>
|
||||||
@@ -351,6 +357,15 @@ export function MediaDetailPage() {
|
|||||||
onClose={() => setManualScrapeOpen(false)}
|
onClose={() => setManualScrapeOpen(false)}
|
||||||
onApplied={refresh}
|
onApplied={refresh}
|
||||||
/>
|
/>
|
||||||
|
<MetadataEditDialog
|
||||||
|
open={metadataEditOpen}
|
||||||
|
media={media}
|
||||||
|
onClose={() => setMetadataEditOpen(false)}
|
||||||
|
onSaved={async (next) => {
|
||||||
|
setMedia(next)
|
||||||
|
await refresh()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</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.bangumi_id && media.bangumi_id > 0) return `bgm:${media.bangumi_id}`
|
||||||
if (media.douban_id) return `douban:${media.douban_id}`
|
if (media.douban_id) return `douban:${media.douban_id}`
|
||||||
if (media.thetvdb_id) return `thetvdb:${media.thetvdb_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.tmdb_id && media.tmdb_id > 0) return `tmdb:${media.tmdb_id}`
|
||||||
if (media.bangumi_id && media.bangumi_id > 0) return `bgm:${media.bangumi_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
|
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 {
|
export function seriesTitle(media: Media): string {
|
||||||
const fromPath = seriesTitleFromPath(media.path)
|
const fromPath = seriesTitleFromPath(media.path)
|
||||||
return fromPath || media.title || media.original_name || '未命名节目'
|
return fromPath || media.title || media.original_name || '未命名节目'
|
||||||
@@ -95,8 +99,8 @@ export function groupSeries(items: Media[] = []): SeriesCard[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function seriesCardLink(card: SeriesCard): string {
|
export function seriesCardLink(card: SeriesCard): string {
|
||||||
if (card.count > 1) {
|
if (isSeriesCard(card)) {
|
||||||
return `/library/${targetLibraryID(card.linkMedia)}`
|
return `/library/${targetLibraryID(card.linkMedia)}?series=${encodeURIComponent(card.key)}`
|
||||||
}
|
}
|
||||||
return `/media/${card.rep.id}`
|
return `/media/${card.rep.id}`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user