diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 53fb179..eb997ea 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -72,9 +72,10 @@ func setDefaults(v *viper.Viper) { v.SetDefault("organize.scrape_after", true) v.SetDefault("scrape.delay_min_ms", 250) v.SetDefault("scrape.delay_max_ms", 500) + v.SetDefault("organizer.categories.concert_movie", "演唱会") + v.SetDefault("organizer.categories.documentary_movie", "纪录片") v.SetDefault("organizer.categories.chinese_movie", "华语电影") v.SetDefault("organizer.categories.animation_movie", "动画电影") - v.SetDefault("organizer.categories.foreign_movie", "外语电影") v.SetDefault("organizer.categories.euus_movie", "欧美电影") v.SetDefault("organizer.categories.jk_movie", "日韩电影") v.SetDefault("organizer.categories.domestic_tv", "国产剧") @@ -82,11 +83,19 @@ func setDefaults(v *viper.Viper) { v.SetDefault("organizer.categories.jk_tv", "日韩剧") v.SetDefault("organizer.categories.jp_anime", "日番") v.SetDefault("organizer.categories.cn_anime", "国漫") - v.SetDefault("organizer.categories.euus_anime", "欧美动漫") + v.SetDefault("organizer.categories.kr_anime", "韩漫") + v.SetDefault("organizer.categories.us_anime", "美漫") + v.SetDefault("organizer.categories.other_anime", "其他") v.SetDefault("organizer.categories.variety", "综艺") v.SetDefault("organizer.categories.documentary", "纪录片") v.SetDefault("organizer.categories.children", "儿童") - v.SetDefault("organizer.categories.uncategorized_tv", "未分类") + v.SetDefault("organizer.categories.adult", "成人") + v.SetDefault("recognition_words.enabled", true) + v.SetDefault("recognition_words.shared_urls", []string{ + "https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/general.txt", + "https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/TV.txt", + "https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/anime.txt", + }) v.SetDefault("transcoder.encoder", "") v.SetDefault("transcoder.enabled", true) diff --git a/internal/handler/media.go b/internal/handler/media.go index e800cfa..d9e8cfd 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -31,7 +31,6 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { return } libs = service.FilterDeprecatedNativeCloudLibraries(libs) - libs = service.FilterInternalCloudAutoCategoryLibraries(libs) role, _ := c.Get(middleware.CtxUserRole) includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("all") == "1") if !includeHidden { @@ -45,6 +44,7 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { } libs = filtered } else { + libs = service.FilterMergedCloudAutoCategoryLibraries(libs) libs = service.NormalizeCloudLibraryDisplayNames(libs) } c.JSON(http.StatusOK, libs) @@ -63,7 +63,6 @@ func getLibraryHandler(svc *service.Container) gin.HandlerFunc { return } libs := service.FilterDeprecatedNativeCloudLibraries([]model.Library{*lib}) - libs = service.FilterInternalCloudAutoCategoryLibraries(libs) if len(libs) == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return diff --git a/internal/handler/media_test.go b/internal/handler/media_test.go index c77c030..6827e80 100644 --- a/internal/handler/media_test.go +++ b/internal/handler/media_test.go @@ -95,7 +95,7 @@ func TestListLibrariesIncludeHiddenNormalizesCloudDisplayNames(t *testing.T) { } } -func TestListLibrariesIncludeHiddenHidesInternalAutoCategoryLibraries(t *testing.T) { +func TestListLibrariesShowsAutoCategoryLibraries(t *testing.T) { gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { @@ -118,8 +118,32 @@ func TestListLibrariesIncludeHiddenHidesInternalAutoCategoryLibraries(t *testing } all := requestLibraries(t, svc, "admin", "admin", "/api/libraries?include_hidden=1") - if len(all) != 1 || all[0].ID != root.ID { - t.Fatalf("include_hidden list = %#v, want only user-mounted cloud library", all) + if len(all) != 2 { + t.Fatalf("include_hidden list = %#v, want root plus auto category library", all) + } + ids := map[string]bool{} + for _, lib := range all { + ids[lib.ID] = true + } + if !ids[root.ID] || !ids[auto.ID] { + t.Fatalf("include_hidden list = %#v, want root %s and auto category %s", all, root.ID, auto.ID) + } + + visible := requestLibraries(t, svc, "user-1", "user", "/api/libraries") + if len(visible) != 2 { + t.Fatalf("visible list = %#v, want root plus auto category library", visible) + } + ids = map[string]bool{} + for _, lib := range visible { + ids[lib.ID] = true + } + if !ids[root.ID] || !ids[auto.ID] { + t.Fatalf("visible list = %#v, want root %s and auto category %s", visible, root.ID, auto.ID) + } + + got := requestLibrary(t, svc, "user-1", "user", "/api/libraries/"+auto.ID, auto.ID) + if got.ID != auto.ID || got.Name != auto.Name { + t.Fatalf("auto category detail = %#v, want accessible category library", got) } } diff --git a/internal/handler/recognition_words.go b/internal/handler/recognition_words.go new file mode 100644 index 0000000..065f12d --- /dev/null +++ b/internal/handler/recognition_words.go @@ -0,0 +1,84 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/ShukeBta/MediaStationGo/internal/service" +) + +func recognitionWordsService(svc *service.Container) *service.RecognitionWordsService { + if svc == nil { + return nil + } + if svc.RecognitionWords != nil { + return svc.RecognitionWords + } + return service.NewRecognitionWordsService(svc.Log, svc.Repo) +} + +func getRecognitionWordsHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + rw := recognitionWordsService(svc) + if rw == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "recognition words service unavailable"}) + return + } + c.JSON(http.StatusOK, rw.Config(c.Request.Context())) + } +} + +func saveRecognitionWordsHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + rw := recognitionWordsService(svc) + if rw == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "recognition words service unavailable"}) + return + } + var req service.RecognitionWordsConfig + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := rw.SaveConfig(c.Request.Context(), req); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, rw.Config(c.Request.Context())) + } +} + +func syncRecognitionWordsHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + rw := recognitionWordsService(svc) + if rw == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "recognition words service unavailable"}) + return + } + cfg, err := rw.SyncShared(c.Request.Context()) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, cfg) + } +} + +func testRecognitionWordsHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + rw := recognitionWordsService(svc) + if rw == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "recognition words service unavailable"}) + return + } + var req struct { + Input string `json:"input"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, rw.Test(c.Request.Context(), req.Input)) + } +} diff --git a/internal/handler/routes_admin.go b/internal/handler/routes_admin.go index d7683d1..d25ea2f 100644 --- a/internal/handler/routes_admin.go +++ b/internal/handler/routes_admin.go @@ -25,6 +25,7 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service. registerAdminRepairRoutes(admin, svc) registerAdminAPIConfigRoutes(admin, svc) registerAdminSchedulerRoutes(admin, svc) + registerAdminRecognitionWordRoutes(admin, svc) } func registerAdminUserRoutes(admin *gin.RouterGroup, svc *service.Container) { @@ -130,3 +131,10 @@ func registerAdminSchedulerRoutes(admin *gin.RouterGroup, svc *service.Container admin.GET("/scheduler", schedulerStatusHandler(svc)) admin.POST("/scheduler/:name/run", schedulerRunHandler(svc)) } + +func registerAdminRecognitionWordRoutes(admin *gin.RouterGroup, svc *service.Container) { + admin.GET("/recognition-words", getRecognitionWordsHandler(svc)) + admin.PUT("/recognition-words", saveRecognitionWordsHandler(svc)) + admin.POST("/recognition-words/sync", syncRecognitionWordsHandler(svc)) + admin.POST("/recognition-words/test", testRecognitionWordsHandler(svc)) +} diff --git a/internal/handler/sites_extra.go b/internal/handler/sites_extra.go index 9f41a60..f9385ac 100644 --- a/internal/handler/sites_extra.go +++ b/internal/handler/sites_extra.go @@ -6,6 +6,7 @@ package handler import ( "net/http" + "strconv" "github.com/gin-gonic/gin" @@ -23,19 +24,13 @@ func siteResourceHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": "keyword required"}) return } - all, err := svc.Site.Search(c.Request.Context(), keyword) + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + items, err := svc.Site.SearchSite(c.Request.Context(), c.Param("id"), keyword, page) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - want := c.Param("id") - filtered := make([]service.SearchResult, 0, len(all)) - for _, r := range all { - if r.SiteID == want { - filtered = append(filtered, r) - } - } - c.JSON(http.StatusOK, gin.H{"items": filtered}) + c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)}) } } diff --git a/internal/handler/subscription_extra.go b/internal/handler/subscription_extra.go index 3e0f963..8598bf2 100644 --- a/internal/handler/subscription_extra.go +++ b/internal/handler/subscription_extra.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/gin-gonic/gin" + "go.uber.org/zap" "github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/service" @@ -56,9 +57,17 @@ func updateSubscriptionHandler(svc *service.Container) gin.HandlerFunc { Model(&model.Subscription{}). Where("id = ?", c.Param("id")). Updates(updates).Error; err != nil { + logSubscriptionWarn(svc, "subscription update failed", + zap.String("user_id", subscriptionRequestUserID(c)), + zap.String("subscription_id", c.Param("id")), + zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + logSubscriptionInfo(svc, "subscription updated", + zap.String("user_id", subscriptionRequestUserID(c)), + zap.String("subscription_id", c.Param("id")), + zap.Strings("fields", subscriptionUpdateFieldNames(updates))) c.Status(http.StatusNoContent) } } @@ -146,6 +155,14 @@ func subscriptionPatchUpdates(patch subscriptionPatchReq) map[string]any { return updates } +func subscriptionUpdateFieldNames(updates map[string]any) []string { + names := make([]string, 0, len(updates)) + for name := range updates { + names = append(names, name) + } + return names +} + // searchSubscriptionHandler runs a one-off keyword search against the // configured tracker sites for the given subscription. We treat the // subscription's filter as the search term; this lets the UI preview diff --git a/internal/handler/subscription_logging.go b/internal/handler/subscription_logging.go new file mode 100644 index 0000000..cbbf70e --- /dev/null +++ b/internal/handler/subscription_logging.go @@ -0,0 +1,54 @@ +package handler + +import ( + "net/url" + "strings" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/middleware" + "github.com/ShukeBta/MediaStationGo/internal/service" +) + +func subscriptionRequestUserID(c *gin.Context) string { + if c == nil { + return "" + } + if uid, ok := c.Get(middleware.CtxUserID); ok { + if userID, ok := uid.(string); ok { + return userID + } + } + return "" +} + +func subscriptionFeedKind(feedURL string) string { + raw := strings.TrimSpace(feedURL) + if raw == "" { + return "empty" + } + lower := strings.ToLower(raw) + if strings.HasPrefix(lower, "site-search://") { + return "site-search" + } + parsed, err := url.Parse(raw) + if err == nil && parsed.Scheme != "" { + return parsed.Scheme + } + return "unknown" +} + +func logSubscriptionInfo(svc *service.Container, msg string, fields ...zap.Field) { + if svc == nil || svc.Log == nil { + return + } + svc.Log.Info(msg, fields...) +} + +func logSubscriptionWarn(svc *service.Container, msg string, fields ...zap.Field) { + if svc == nil || svc.Log == nil { + return + } + svc.Log.Warn(msg, fields...) +} diff --git a/internal/handler/subscriptions.go b/internal/handler/subscriptions.go index 87c2090..e1f5eaf 100644 --- a/internal/handler/subscriptions.go +++ b/internal/handler/subscriptions.go @@ -6,6 +6,7 @@ import ( "net/http" "github.com/gin-gonic/gin" + "go.uber.org/zap" "github.com/ShukeBta/MediaStationGo/internal/middleware" "github.com/ShukeBta/MediaStationGo/internal/model" @@ -80,9 +81,24 @@ func createSubscriptionHandler(svc *service.Container) gin.HandlerFunc { } enrichSubscriptionArtwork(c.Request.Context(), svc, s) if err := svc.Subscription.Create(c.Request.Context(), s); err != nil { + logSubscriptionWarn(svc, "subscription create failed", + zap.String("user_id", s.UserID), + zap.String("name", req.Name), + zap.String("feed_kind", subscriptionFeedKind(req.FeedURL)), + zap.Bool("enabled", enabled), + zap.Error(err)) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + logSubscriptionInfo(svc, "subscription created", + zap.String("user_id", s.UserID), + zap.String("subscription_id", s.ID), + zap.String("name", s.Name), + zap.String("feed_kind", subscriptionFeedKind(s.FeedURL)), + zap.String("media_type", s.MediaType), + zap.String("media_category", s.MediaCategory), + zap.Bool("enabled", s.Enabled), + zap.Bool("wash_enabled", s.WashEnabled)) enriched := []model.Subscription{*s} svc.Subscription.EnrichManagementProgress(c.Request.Context(), enriched) *s = enriched[0] @@ -94,11 +110,18 @@ func listSubscriptionsHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { items, err := svc.Subscription.List(c.Request.Context()) if err != nil { + logSubscriptionWarn(svc, "subscription list failed", + zap.String("user_id", subscriptionRequestUserID(c)), + zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } svc.Subscription.EnrichManagementProgress(c.Request.Context(), items) go enrichAndPersistSubscriptions(context.Background(), svc, append([]model.Subscription(nil), items...)) + logSubscriptionInfo(svc, "subscription list returned", + zap.String("user_id", subscriptionRequestUserID(c)), + zap.Int("count", len(items)), + zap.Bool("history", false)) c.JSON(http.StatusOK, gin.H{"items": items}) } } @@ -107,10 +130,17 @@ func listSubscriptionHistoryHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { items, err := svc.Subscription.History(c.Request.Context()) if err != nil { + logSubscriptionWarn(svc, "subscription history list failed", + zap.String("user_id", subscriptionRequestUserID(c)), + zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } svc.Subscription.EnrichManagementProgress(c.Request.Context(), items) + logSubscriptionInfo(svc, "subscription list returned", + zap.String("user_id", subscriptionRequestUserID(c)), + zap.Int("count", len(items)), + zap.Bool("history", true)) c.JSON(http.StatusOK, gin.H{"items": items}) } } @@ -118,9 +148,16 @@ func listSubscriptionHistoryHandler(svc *service.Container) gin.HandlerFunc { func deleteSubscriptionHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { if err := svc.Subscription.Delete(c.Request.Context(), c.Param("id")); err != nil { + logSubscriptionWarn(svc, "subscription delete failed", + zap.String("user_id", subscriptionRequestUserID(c)), + zap.String("subscription_id", c.Param("id")), + zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + logSubscriptionInfo(svc, "subscription deleted", + zap.String("user_id", subscriptionRequestUserID(c)), + zap.String("subscription_id", c.Param("id"))) c.Status(http.StatusNoContent) } } @@ -129,9 +166,17 @@ func runSubscriptionHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { n, err := svc.Subscription.RunNow(c.Request.Context(), c.Param("id")) if err != nil { + logSubscriptionWarn(svc, "subscription run now failed", + zap.String("user_id", subscriptionRequestUserID(c)), + zap.String("subscription_id", c.Param("id")), + zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + logSubscriptionInfo(svc, "subscription run now completed", + zap.String("user_id", subscriptionRequestUserID(c)), + zap.String("subscription_id", c.Param("id")), + zap.Int("queued", n)) c.JSON(http.StatusOK, gin.H{"queued": n}) } } @@ -140,9 +185,17 @@ func restoreSubscriptionHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { sub, err := svc.Subscription.Restore(c.Request.Context(), c.Param("id")) if err != nil { + logSubscriptionWarn(svc, "subscription restore failed", + zap.String("user_id", subscriptionRequestUserID(c)), + zap.String("subscription_id", c.Param("id")), + zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + logSubscriptionInfo(svc, "subscription restored", + zap.String("user_id", subscriptionRequestUserID(c)), + zap.String("subscription_id", sub.ID), + zap.String("name", sub.Name)) enriched := []model.Subscription{*sub} svc.Subscription.EnrichManagementProgress(c.Request.Context(), enriched) c.JSON(http.StatusOK, enriched[0]) diff --git a/internal/service/cloud/clouddrive2_test.go b/internal/service/cloud/clouddrive2_test.go index 9c8d65a..78cb264 100644 --- a/internal/service/cloud/clouddrive2_test.go +++ b/internal/service/cloud/clouddrive2_test.go @@ -152,7 +152,7 @@ func TestCloudDrive2MutableProviderUsesWebDAV(t *testing.T) { if _, err := mutable.Rename(context.Background(), "/TV", "电视剧"); err != nil { t.Fatalf("rename: %v", err) } - moved, err := mutable.(MovableProvider).Move(context.Background(), "/Inbox/Movie.mkv", "/电影/外语电影/Movie (2026)", "Movie (2026).mkv") + moved, err := mutable.(MovableProvider).Move(context.Background(), "/Inbox/Movie.mkv", "/电影/欧美电影/Movie (2026)", "Movie (2026).mkv") if err != nil { t.Fatalf("move: %v", err) } @@ -162,10 +162,10 @@ func TestCloudDrive2MutableProviderUsesWebDAV(t *testing.T) { if destinations[0] != srv.URL+"/dav/%E7%94%B5%E8%A7%86%E5%89%A7" { t.Fatalf("rename Destination = %q", destinations[0]) } - if destinations[1] != srv.URL+"/dav/%E7%94%B5%E5%BD%B1/%E5%A4%96%E8%AF%AD%E7%94%B5%E5%BD%B1/Movie%20%282026%29/Movie%20%282026%29.mkv" { + if destinations[1] != srv.URL+"/dav/%E7%94%B5%E5%BD%B1/%E6%AC%A7%E7%BE%8E%E7%94%B5%E5%BD%B1/Movie%20%282026%29/Movie%20%282026%29.mkv" { t.Fatalf("move Destination = %q", destinations[1]) } - if moved.ID != "/电影/外语电影/Movie (2026)/Movie (2026).mkv" { + if moved.ID != "/电影/欧美电影/Movie (2026)/Movie (2026).mkv" { t.Fatalf("moved entry = %#v", moved) } } diff --git a/internal/service/cloud_auto_category.go b/internal/service/cloud_auto_category.go index 4d330cc..707f769 100644 --- a/internal/service/cloud_auto_category.go +++ b/internal/service/cloud_auto_category.go @@ -13,7 +13,11 @@ import ( const cloudAutoCategoryQueryKey = "auto_category" func BuildCloudAutoCategoryLibraryPath(provider, displayDir string) string { - base := BuildCloudLibraryPath(provider, "", displayDir) + return BuildCloudAutoCategoryLibraryPathWithScanDir(provider, "", displayDir) +} + +func BuildCloudAutoCategoryLibraryPathWithScanDir(provider, scanDir, displayDir string) string { + base := BuildCloudLibraryPath(provider, scanDir, displayDir) if base == "" || strings.TrimSpace(displayDir) == "" { return "" } @@ -42,40 +46,45 @@ func cloudRootMountNeedsAutoCategory(mount CloudMountInfo) bool { } func cloudAutoCategoryDisplayDirForMediaPath(path string) string { + displayDir, _ := cloudAutoCategoryDirsForMediaPath(path) + return displayDir +} + +func cloudAutoCategoryDirsForMediaPath(path string) (string, string) { info, ok := ParseCloudLibraryMount(path) if !ok { - return "" + return "", "" } parts := strmSlashParts(info.DisplayDir) if len(parts) <= 1 { - return "" + return "", "" } parts = parts[:len(parts)-1] - categoryParts := cloudAutoCategoryParts(parts) + categoryParts, scanParts := cloudAutoCategoryParts(parts) if len(categoryParts) == 0 { - return "" + return "", "" } - return strings.Join(categoryParts, "/") + return strings.Join(categoryParts, "/"), strings.Join(scanParts, "/") } -func cloudAutoCategoryParts(parts []string) []string { +func cloudAutoCategoryParts(parts []string) ([]string, []string) { for i, part := range parts { root := strmCanonicalRoot(part) if root != "" { if i+1 >= len(parts) { - return nil + return nil, nil } category := strings.TrimSpace(parts[i+1]) if cloudAutoCategoryRootMatches(root, category) { - return []string{root, category} + return []string{root, strmCanonicalCategory(category)}, append([]string(nil), parts[:i+2]...) } - return nil + return nil, nil } if root := strmCategoryRoot(part); root != "" { - return []string{root, strings.TrimSpace(part)} + return []string{root, strmCanonicalCategory(part)}, append([]string(nil), parts[:i+1]...) } } - return nil + return nil, nil } func cloudAutoCategoryRootMatches(root, category string) bool { @@ -92,33 +101,61 @@ func cloudAutoCategoryRootMatches(root, category string) bool { return false } -func (s *ScannerService) ensureCloudAutoCategoryLibrary(ctx context.Context, rootLib *model.Library, provider, displayDir string) (*model.Library, error) { +type cloudAutoCategoryTarget struct { + Library *model.Library + RootID string +} + +func (s *ScannerService) ensureCloudAutoCategoryTarget(ctx context.Context, rootLib *model.Library, provider, displayDir, scanDir string) (cloudAutoCategoryTarget, error) { displayDir = normalizeCloudMountDir(provider, displayDir) + scanDir = normalizeCloudMountDir(provider, firstNonEmpty(scanDir, displayDir)) if s == nil || s.repo == nil || s.repo.DB == nil || rootLib == nil || provider == "" || displayDir == "" { - return rootLib, nil + return cloudAutoCategoryTarget{Library: rootLib}, nil } - if existing := s.findCloudLibraryByDisplayDir(ctx, provider, displayDir); existing != nil { - return existing, nil - } - path := BuildCloudAutoCategoryLibraryPath(provider, displayDir) + path := BuildCloudAutoCategoryLibraryPathWithScanDir(provider, scanDir, displayDir) if path == "" { - return rootLib, nil + return cloudAutoCategoryTarget{Library: rootLib}, nil } name := cloudMountDirBase(displayDir) if name == "" { name = displayDir } + kind := InferCloudMountMediaType(displayDir, name) + target, existingAuto := s.findCloudAutoCategoryTarget(ctx, rootLib.ID, provider, displayDir, name, kind) + if target != nil { + root, err := s.ensureCloudLibraryRoot(ctx, target.ID, name, path) + if err != nil { + return cloudAutoCategoryTarget{}, err + } + if existingAuto != nil && existingAuto.ID != target.ID { + s.migrateCloudAutoCategoryLibrary(ctx, existingAuto, target, root) + } + return cloudAutoCategoryTarget{Library: target, RootID: libraryRootID(root)}, nil + } + if existingAuto != nil { + root, err := s.ensureCloudLibraryRoot(ctx, existingAuto.ID, name, path) + if err != nil { + return cloudAutoCategoryTarget{}, err + } + return cloudAutoCategoryTarget{Library: existingAuto, RootID: libraryRootID(root)}, nil + } lib := &model.Library{ Name: name, Path: path, - Type: InferCloudMountMediaType(displayDir, name), + Type: kind, Enabled: true, } - if err := s.repo.Library.Create(ctx, lib); err != nil { - if existing := s.findCloudLibraryByDisplayDir(ctx, provider, displayDir); existing != nil { - return existing, nil + root := model.LibraryRoot{Name: name, Path: path, Enabled: true} + if err := s.repo.Library.CreateWithRoots(ctx, lib, []model.LibraryRoot{root}); err != nil { + _, existing := s.findCloudAutoCategoryTarget(ctx, rootLib.ID, provider, displayDir, name, kind) + if existing != nil { + ensuredRoot, rootErr := s.ensureCloudLibraryRoot(ctx, existing.ID, name, path) + if rootErr != nil { + return cloudAutoCategoryTarget{}, rootErr + } + return cloudAutoCategoryTarget{Library: existing, RootID: libraryRootID(ensuredRoot)}, nil } - return nil, err + return cloudAutoCategoryTarget{}, err } if s.log != nil { s.log.Info("created cloud auto category library", @@ -127,29 +164,100 @@ func (s *ScannerService) ensureCloudAutoCategoryLibrary(ctx context.Context, roo zap.String("provider", provider), zap.String("display_dir", displayDir)) } - return lib, nil + if len(lib.Roots) > 0 { + return cloudAutoCategoryTarget{Library: lib, RootID: lib.Roots[0].ID}, nil + } + return cloudAutoCategoryTarget{Library: lib}, nil } -func (s *ScannerService) findCloudLibraryByDisplayDir(ctx context.Context, provider, displayDir string) *model.Library { +func (s *ScannerService) findCloudAutoCategoryTarget(ctx context.Context, rootLibraryID, provider, displayDir, name, kind string) (*model.Library, *model.Library) { if s == nil || s.repo == nil || s.repo.Library == nil { - return nil + return nil, nil } libs, err := s.repo.Library.List(ctx) if err != nil { if s.log != nil { s.log.Warn("list libraries for cloud auto category failed", zap.Error(err)) } - return nil + return nil, nil } displayDir = normalizeCloudMountDir(provider, displayDir) + targetKey, _ := CloudLibraryMergeKey(model.Library{Name: name, Type: kind}) + var target *model.Library + var existingAuto *model.Library for _, lib := range libs { info, ok := ParseCloudLibraryMount(lib.Path) - if !ok || info.Provider != provider || normalizeCloudMountDir(provider, info.DisplayDir) != displayDir { + if ok && info.Provider == provider && normalizeCloudMountDir(provider, info.DisplayDir) == displayDir && CloudLibraryAutoCategory(lib) { + copy := lib + existingAuto = © continue } - return &lib + if lib.ID == rootLibraryID || CloudLibraryAutoCategory(lib) || !lib.Enabled || targetKey == "" { + continue + } + key, ok := CloudLibraryMergeKey(lib) + if target == nil && ok && key == targetKey { + copy := lib + target = © + } + } + return target, existingAuto +} + +func (s *ScannerService) ensureCloudLibraryRoot(ctx context.Context, libraryID, name, pathValue string) (*model.LibraryRoot, error) { + if s == nil || s.repo == nil || s.repo.Library == nil { + return nil, nil + } + roots, err := s.repo.Library.ListRoots(ctx, libraryID) + if err != nil { + return nil, err + } + targetKey := libraryRootPathKey(pathValue) + for i := range roots { + if libraryRootPathKey(roots[i].Path) == targetKey { + if strings.TrimSpace(roots[i].Name) == "" && strings.TrimSpace(name) != "" { + _ = s.repo.Library.UpdateRoot(ctx, &roots[i], map[string]any{"name": strings.TrimSpace(name)}) + roots[i].Name = strings.TrimSpace(name) + } + return &roots[i], nil + } + } + root := &model.LibraryRoot{ + LibraryID: libraryID, + Name: strings.TrimSpace(name), + Path: pathValue, + Enabled: true, + SortOrder: len(roots), + } + if err := s.repo.Library.CreateRoot(ctx, root); err != nil { + return nil, err + } + return root, nil +} + +func (s *ScannerService) migrateCloudAutoCategoryLibrary(ctx context.Context, source, target *model.Library, root *model.LibraryRoot) { + if s == nil || s.repo == nil || s.repo.DB == nil || source == nil || target == nil || source.ID == "" || target.ID == "" { + return + } + updates := map[string]any{"library_id": target.ID} + if rootID := libraryRootID(root); rootID != "" { + updates["library_root_id"] = rootID + } + if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("library_id = ?", source.ID).Updates(updates).Error; err != nil { + if s.log != nil { + s.log.Warn("migrate cloud auto category media failed", + zap.String("from_library_id", source.ID), + zap.String("to_library_id", target.ID), + zap.Error(err)) + } + return + } + _ = hardDeleteLibraryRoots(ctx, s.repo.DB, source.ID) + if err := s.repo.Library.Delete(ctx, source.ID); err != nil && s.log != nil { + s.log.Warn("remove migrated cloud auto category library failed", + zap.String("library_id", source.ID), + zap.Error(err)) } - return nil } func (s *ScannerService) cloudScanLibraryScopeIDs(ctx context.Context, lib *model.Library, mount CloudMountInfo) []string { diff --git a/internal/service/cloud_mount_counts.go b/internal/service/cloud_mount_counts.go new file mode 100644 index 0000000..7c23268 --- /dev/null +++ b/internal/service/cloud_mount_counts.go @@ -0,0 +1,38 @@ +package service + +import ( + "context" + + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +func cloudLibraryMediaCounts(ctx context.Context, repo *repository.Container, libs []model.Library) map[string]int64 { + counts := make(map[string]int64, len(libs)) + if repo == nil || repo.DB == nil || len(libs) == 0 { + return counts + } + ids := make([]string, 0, len(libs)) + for _, lib := range libs { + ids = append(ids, lib.ID) + } + if len(ids) == 0 { + return counts + } + var rows []struct { + LibraryID string + Count int64 + } + if err := repo.DB.WithContext(ctx). + Model(&model.Media{}). + Select("library_id, COUNT(*) AS count"). + Where("library_id IN ? AND deleted_at IS NULL", ids). + Group("library_id"). + Scan(&rows).Error; err != nil { + return counts + } + for _, row := range rows { + counts[row.LibraryID] = row.Count + } + return counts +} diff --git a/internal/service/cloud_mount_dedupe.go b/internal/service/cloud_mount_dedupe.go new file mode 100644 index 0000000..3bc9b96 --- /dev/null +++ b/internal/service/cloud_mount_dedupe.go @@ -0,0 +1,122 @@ +package service + +import ( + "strings" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func betterDisplayCloudLibrary(candidate, current model.Library, counts map[string]int64) bool { + candidateCount := counts[candidate.ID] + currentCount := counts[current.ID] + if (candidateCount > 0) != (currentCount > 0) { + return candidateCount > 0 + } + if candidate.Enabled != current.Enabled { + return candidate.Enabled + } + candidateCanonical := cloudLibraryPathIsCanonical(candidate) + currentCanonical := cloudLibraryPathIsCanonical(current) + if candidateCanonical != currentCanonical { + return candidateCanonical + } + if !candidate.CreatedAt.Equal(current.CreatedAt) { + return candidate.CreatedAt.After(current.CreatedAt) + } + return candidate.ID > current.ID +} + +func mergeDisplayCloudLibraries(libs []model.Library) []model.Library { + if len(libs) == 0 { + return libs + } + localByKey := make(map[string]struct{}, len(libs)) + for _, lib := range libs { + if _, ok := ParseCloudLibraryMount(lib.Path); ok || !lib.Enabled { + continue + } + if key, ok := CloudLibraryMergeKey(lib); ok { + localByKey[key] = struct{}{} + } + } + out := make([]model.Library, 0, len(libs)) + for _, lib := range libs { + if displayName, ok := CloudLibraryDisplayName(lib); ok && displayName != "" { + lib.Name = displayName + if key, ok := CloudLibraryMergeKey(lib); ok { + if _, exists := localByKey[key]; exists && !CloudLibraryAutoCategory(lib) { + continue + } + } + } else if displayName := CanonicalLibraryDisplayName(lib); displayName != "" { + lib.Name = displayName + } + out = append(out, lib) + } + return out +} + +func dedupeDisplayLibrariesByMergeKey(libs []model.Library, counts map[string]int64) []model.Library { + if len(libs) == 0 { + return libs + } + out := make([]model.Library, 0, len(libs)) + byKey := make(map[string]int, len(libs)) + for _, lib := range libs { + if displayName := CanonicalLibraryDisplayName(lib); displayName != "" { + lib.Name = displayName + } + if CloudLibraryAutoCategory(lib) { + out = append(out, lib) + continue + } + key, ok := CloudLibraryMergeKey(lib) + if !ok { + out = append(out, lib) + continue + } + if prev, exists := byKey[key]; exists { + if betterCanonicalDisplayLibrary(lib, out[prev], counts) { + out[prev] = lib + } + continue + } + byKey[key] = len(out) + out = append(out, lib) + } + return out +} + +func betterCanonicalDisplayLibrary(candidate, current model.Library, counts map[string]int64) bool { + candidateScore := canonicalDisplayLibraryScore(candidate) + currentScore := canonicalDisplayLibraryScore(current) + if candidateScore != currentScore { + return candidateScore > currentScore + } + candidateCount := counts[candidate.ID] + currentCount := counts[current.ID] + if (candidateCount > 0) != (currentCount > 0) { + return candidateCount > 0 + } + if candidate.Enabled != current.Enabled { + return candidate.Enabled + } + if !candidate.CreatedAt.Equal(current.CreatedAt) { + return candidate.CreatedAt.After(current.CreatedAt) + } + return candidate.ID > current.ID +} + +func canonicalDisplayLibraryScore(lib model.Library) int { + score := 0 + if canonical := CanonicalLibraryDisplayName(lib); canonical == "" || strings.EqualFold(strings.TrimSpace(lib.Name), canonical) { + score += 4 + } + if canonical := canonicalLibraryCategoryName(lib.Type, pathBaseSlash(lib.Path)); canonical == "" { + score += 2 + } + if _, ok := ParseCloudLibraryMount(lib.Path); !ok { + score++ + } + return score +} diff --git a/internal/service/cloud_mount_display.go b/internal/service/cloud_mount_display.go new file mode 100644 index 0000000..3cde0d8 --- /dev/null +++ b/internal/service/cloud_mount_display.go @@ -0,0 +1,234 @@ +package service + +import ( + "strings" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func NormalizeCloudLibraryDisplayNames(libs []model.Library) []model.Library { + out := make([]model.Library, 0, len(libs)) + for _, lib := range libs { + if displayName, ok := CloudLibraryDisplayName(lib); ok && displayName != "" { + lib.Name = displayName + } else if displayName := CanonicalLibraryDisplayName(lib); displayName != "" { + lib.Name = displayName + } + out = append(out, lib) + } + return out +} + +func NormalizeCloudLibraryDisplay(libs []model.Library) []model.Library { + return normalizeDisplayLibraries(libs) +} + +func normalizeDisplayLibraries(libs []model.Library) []model.Library { + out := make([]model.Library, 0, len(libs)) + for _, lib := range libs { + if displayName, ok := CloudLibraryDisplayName(lib); ok && displayName != "" { + lib.Name = displayName + } else if displayName := CanonicalLibraryDisplayName(lib); displayName != "" { + lib.Name = displayName + } + if displayType := CanonicalLibraryDisplayType(lib); displayType != "" { + lib.Type = displayType + } + if displayPath := CanonicalLibraryDisplayPath(lib); displayPath != "" { + lib.Path = displayPath + } + out = append(out, lib) + } + return out +} + +func CloudLibraryDisplayName(lib model.Library) (string, bool) { + info, ok := ParseCloudLibraryMount(lib.Path) + if !ok { + return "", false + } + name := stripCloudProviderDisplayPrefix(strings.TrimSpace(lib.Name), info.Provider) + dir := firstNonEmpty(info.DisplayDir, info.ScanDir) + if name == "" || strings.EqualFold(name, CloudMountProviderLabel(info.Provider)) { + if base := cloudMountDirBase(dir); base != "" { + name = base + } + } + if name == "" { + name = CloudMountProviderLabel(info.Provider) + } + if canonical := canonicalLibraryCategoryName(lib.Type, name); canonical != "" { + name = canonical + } else if canonical := canonicalLibraryCategoryNameAny(name); canonical != "" { + name = canonical + } + return name, true +} + +func CanonicalLibraryDisplayName(lib model.Library) string { + if canonical := canonicalLibraryCategoryName(lib.Type, lib.Name); canonical != "" { + return canonical + } + return canonicalLibraryCategoryNameAny(lib.Name) +} + +func CanonicalLibraryDisplayType(lib model.Library) string { + if displayName, ok := CloudLibraryDisplayName(lib); ok { + if typ := canonicalLibraryCategoryDisplayType(displayName); typ != "" { + return typ + } + } + if typ := canonicalLibraryCategoryDisplayType(lib.Name); typ != "" { + return typ + } + return canonicalLibraryCategoryDisplayType(pathBaseSlash(lib.Path)) +} + +func CanonicalLibraryDisplayPath(lib model.Library) string { + raw := strings.TrimSpace(lib.Path) + if raw == "" { + return "" + } + if info, ok := ParseCloudLibraryMount(raw); ok { + dir := firstNonEmpty(info.DisplayDir, info.ScanDir) + displayDir := canonicalLibraryDisplayDir(dir) + if displayDir == "" { + return raw + } + if CloudLibraryAutoCategory(lib) { + return BuildCloudAutoCategoryLibraryPathWithScanDir(info.Provider, info.ScanDir, displayDir) + } + return BuildCloudLibraryPath(info.Provider, info.ScanDir, displayDir) + } + return canonicalLocalLibraryDisplayPath(raw) +} + +func canonicalLibraryCategoryName(libraryType, name string) string { + typeKey := cloudLibraryMergeTypeKey(libraryType) + name = normalizeLibraryMergeName(name) + switch typeKey { + case "movie": + switch name { + case "国产电影", "大陆电影": + return "华语电影" + case "外语电影", "外国电影": + return "欧美电影" + case "日本电影", "韩国电影": + return "日韩电影" + case "音乐会", "concert": + return "演唱会" + case "纪录": + return "纪录片" + case "动漫电影": + return "动画电影" + } + case "tvshows": + switch name { + case "国剧", "大陆剧", "国产电视剧", "华语剧": + return "国产剧" + case "欧美电视剧", "美剧", "英剧", "未分类", "uncategorized": + return "欧美剧" + case "日剧", "韩剧": + return "日韩剧" + case "真人秀": + return "综艺" + case "纪录": + return "纪录片" + case "少儿": + return "儿童" + case "国产动漫", "国产动画": + return "国漫" + case "日漫", "番剧", "日本动漫", "日本动画": + return "日番" + case "韩国动漫", "韩国动画": + return "韩漫" + case "欧美动漫", "欧美动画", "西方动画": + return "美漫" + case "其他动漫", "其它动漫", "other": + return "其他" + } + case "adult": + switch name { + case "9kg", "番号", "jav", "nsfw", "adult": + return "成人" + } + } + return "" +} + +func canonicalLibraryCategoryNameAny(name string) string { + for _, libraryType := range []string{"movie", "tv", "anime", "adult"} { + if canonical := canonicalLibraryCategoryName(libraryType, name); canonical != "" { + return canonical + } + } + return "" +} + +func canonicalLibraryDisplayDir(raw string) string { + parts := strmSlashParts(raw) + if len(parts) == 0 { + return "" + } + return strings.Join(canonicalLibraryDisplayParts(parts), "/") +} + +func canonicalLocalLibraryDisplayPath(raw string) string { + value := strings.TrimSpace(raw) + if value == "" { + return "" + } + sep := "/" + if strings.Contains(value, "\\") { + sep = "\\" + } + slash := strings.ReplaceAll(value, "\\", "/") + prefix := "" + for strings.HasPrefix(slash, "/") { + prefix += "/" + slash = strings.TrimPrefix(slash, "/") + } + parts := strings.Split(slash, "/") + canonical := canonicalLibraryDisplayParts(parts) + if len(canonical) == 0 { + return raw + } + out := prefix + strings.Join(canonical, "/") + if sep == "\\" { + out = strings.ReplaceAll(out, "/", "\\") + } + return out +} + +func canonicalLibraryDisplayParts(parts []string) []string { + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" || part == "." { + continue + } + if canonical := canonicalLibraryCategoryNameAny(part); canonical != "" { + part = canonical + } + if len(out) > 0 && normalizeLibraryMergeName(out[len(out)-1]) == normalizeLibraryMergeName(part) { + continue + } + out = append(out, part) + } + return out +} + +func canonicalLibraryCategoryDisplayType(name string) string { + switch normalizeLibraryMergeName(name) { + case "演唱会", "音乐会", "动画电影", "动漫电影", "华语电影", "国产电影", "大陆电影", "欧美电影", "外语电影", "外国电影", "日韩电影", "日本电影", "韩国电影": + return "movie" + case "国产剧", "国剧", "大陆剧", "国产电视剧", "华语剧", "欧美剧", "欧美电视剧", "美剧", "英剧", "未分类", "uncategorized", "日韩剧", "日剧", "韩剧", "综艺", "真人秀", "儿童", "少儿": + return "tv" + case "国漫", "国产动漫", "国产动画", "日番", "日漫", "番剧", "日本动漫", "日本动画", "韩漫", "韩国动漫", "韩国动画", "美漫", "欧美动漫", "欧美动画", "西方动画", "其他", "其他动漫", "其它动漫", "other": + return "anime" + case "成人", "9kg", "番号", "jav", "nsfw", "adult": + return "adult" + default: + return "" + } +} diff --git a/internal/service/cloud_mount_filter.go b/internal/service/cloud_mount_filter.go index 4657441..dff884a 100644 --- a/internal/service/cloud_mount_filter.go +++ b/internal/service/cloud_mount_filter.go @@ -2,7 +2,6 @@ package service import ( "context" - "strings" "github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/repository" @@ -13,7 +12,7 @@ func FilterDisplayCloudLibraries(ctx context.Context, repo *repository.Container return libs } libs = FilterDeprecatedNativeCloudLibraries(libs) - libs = FilterInternalCloudAutoCategoryLibraries(libs) + libs = FilterMergedCloudAutoCategoryLibraries(libs) counts := cloudLibraryMediaCounts(ctx, repo, libs) collapsed := make([]model.Library, 0, len(libs)) byKey := make(map[string]int, len(libs)) @@ -33,7 +32,7 @@ func FilterDisplayCloudLibraries(ctx context.Context, repo *repository.Container collapsed = append(collapsed, lib) } collapsed = FilterShadowedCloudLibraries(collapsed) - return mergeDisplayCloudLibraries(collapsed) + return normalizeDisplayLibraries(dedupeDisplayLibrariesByMergeKey(mergeDisplayCloudLibraries(collapsed), counts)) } func FilterInternalCloudAutoCategoryLibraries(libs []model.Library) []model.Library { @@ -50,6 +49,33 @@ func FilterInternalCloudAutoCategoryLibraries(libs []model.Library) []model.Libr return out } +func FilterMergedCloudAutoCategoryLibraries(libs []model.Library) []model.Library { + if len(libs) == 0 { + return libs + } + nonAutoKeys := make(map[string]struct{}, len(libs)) + for _, lib := range libs { + if CloudLibraryAutoCategory(lib) { + continue + } + if key, ok := CloudLibraryMergeKey(lib); ok { + nonAutoKeys[key] = struct{}{} + } + } + out := make([]model.Library, 0, len(libs)) + for _, lib := range libs { + if CloudLibraryAutoCategory(lib) { + if key, ok := CloudLibraryMergeKey(lib); ok { + if _, merged := nonAutoKeys[key]; merged { + continue + } + } + } + out = append(out, lib) + } + return out +} + func FilterScannableCloudLibraries(ctx context.Context, repo *repository.Container, libs []model.Library) []model.Library { if len(libs) == 0 { return libs @@ -95,187 +121,3 @@ func FilterDeprecatedNativeCloudLibraries(libs []model.Library) []model.Library } return out } - -func NormalizeCloudLibraryDisplayNames(libs []model.Library) []model.Library { - out := make([]model.Library, 0, len(libs)) - for _, lib := range libs { - if displayName, ok := CloudLibraryDisplayName(lib); ok && displayName != "" { - lib.Name = displayName - } - out = append(out, lib) - } - return out -} - -func cloudLibraryMediaCounts(ctx context.Context, repo *repository.Container, libs []model.Library) map[string]int64 { - counts := make(map[string]int64, len(libs)) - if repo == nil || repo.DB == nil || len(libs) == 0 { - return counts - } - ids := make([]string, 0, len(libs)) - for _, lib := range libs { - if _, ok := ParseCloudLibraryMount(lib.Path); ok { - ids = append(ids, lib.ID) - } - } - if len(ids) == 0 { - return counts - } - var rows []struct { - LibraryID string - Count int64 - } - if err := repo.DB.WithContext(ctx). - Model(&model.Media{}). - Select("library_id, COUNT(*) AS count"). - Where("library_id IN ? AND deleted_at IS NULL", ids). - Group("library_id"). - Scan(&rows).Error; err != nil { - return counts - } - for _, row := range rows { - counts[row.LibraryID] = row.Count - } - return counts -} - -func cloudLibraryDisplayKey(lib model.Library) (string, bool) { - info, ok := ParseCloudLibraryMount(lib.Path) - if !ok { - return "", false - } - dir := firstNonEmpty(info.DisplayDir, info.ScanDir) - return info.Provider + "\x00" + dir, true -} - -func betterDisplayCloudLibrary(candidate, current model.Library, counts map[string]int64) bool { - candidateCount := counts[candidate.ID] - currentCount := counts[current.ID] - if (candidateCount > 0) != (currentCount > 0) { - return candidateCount > 0 - } - if candidate.Enabled != current.Enabled { - return candidate.Enabled - } - candidateCanonical := cloudLibraryPathIsCanonical(candidate) - currentCanonical := cloudLibraryPathIsCanonical(current) - if candidateCanonical != currentCanonical { - return candidateCanonical - } - if !candidate.CreatedAt.Equal(current.CreatedAt) { - return candidate.CreatedAt.After(current.CreatedAt) - } - return candidate.ID > current.ID -} - -func cloudLibraryPathIsCanonical(lib model.Library) bool { - info, ok := ParseCloudLibraryMount(lib.Path) - if !ok { - return false - } - return BuildCloudLibraryPath(info.Provider, info.ScanDir, info.DisplayDir) == strings.TrimSpace(lib.Path) -} - -func mergeDisplayCloudLibraries(libs []model.Library) []model.Library { - if len(libs) == 0 { - return libs - } - localByKey := make(map[string]struct{}, len(libs)) - for _, lib := range libs { - if _, ok := ParseCloudLibraryMount(lib.Path); ok || !lib.Enabled { - continue - } - if key, ok := CloudLibraryMergeKey(lib); ok { - localByKey[key] = struct{}{} - } - } - out := make([]model.Library, 0, len(libs)) - for _, lib := range libs { - if displayName, ok := CloudLibraryDisplayName(lib); ok && displayName != "" { - lib.Name = displayName - if key, ok := CloudLibraryMergeKey(lib); ok { - if _, exists := localByKey[key]; exists { - continue - } - } - } - out = append(out, lib) - } - return out -} - -func CloudLibraryDisplayName(lib model.Library) (string, bool) { - info, ok := ParseCloudLibraryMount(lib.Path) - if !ok { - return "", false - } - name := stripCloudProviderDisplayPrefix(strings.TrimSpace(lib.Name), info.Provider) - dir := firstNonEmpty(info.DisplayDir, info.ScanDir) - if name == "" || strings.EqualFold(name, CloudMountProviderLabel(info.Provider)) { - if base := cloudMountDirBase(dir); base != "" { - name = base - } - } - if name == "" { - name = CloudMountProviderLabel(info.Provider) - } - return name, true -} - -func CloudLibraryMergeKey(lib model.Library) (string, bool) { - name := strings.TrimSpace(lib.Name) - if displayName, ok := CloudLibraryDisplayName(lib); ok { - name = displayName - } - name = normalizeLibraryMergeName(name) - if name == "" { - return "", false - } - typeKey := cloudLibraryMergeTypeKey(lib.Type) - return typeKey + "\x00" + cloudLibraryMergeNameKey(typeKey, name), true -} - -func cloudLibraryMergeTypeKey(libraryType string) string { - switch strings.ToLower(strings.TrimSpace(libraryType)) { - case "tv", "anime", "variety": - return "tvshows" - default: - return strings.ToLower(strings.TrimSpace(libraryType)) - } -} - -func cloudLibraryMergeNameKey(typeKey, name string) string { - switch typeKey { - case "movie": - switch name { - case "国产电影", "大陆电影", "华语电影": - return "华语电影" - case "外语电影", "欧美电影", "日韩电影", "日本电影", "韩国电影": - return "外语电影" - case "纪录", "纪录片": - return "纪录片" - case "演唱会", "concert": - return "演唱会" - case "动画电影", "动漫电影": - return "动画电影" - } - case "tvshows": - switch name { - case "国产剧", "大陆剧", "华语剧", "国剧": - return "国产剧" - case "欧美剧", "美剧", "英剧": - return "欧美剧" - case "日韩剧", "日剧", "韩剧": - return "日韩剧" - case "国漫", "国产动漫", "国产动画": - return "国漫" - case "日番", "日漫", "番剧", "日本动漫", "日本动画": - return "日番" - case "欧美动漫", "欧美动画", "西方动画": - return "欧美动漫" - case "纪录", "纪录片": - return "纪录片" - } - } - return name -} diff --git a/internal/service/cloud_mount_filter_test.go b/internal/service/cloud_mount_filter_test.go index e9a48a5..7aa270f 100644 --- a/internal/service/cloud_mount_filter_test.go +++ b/internal/service/cloud_mount_filter_test.go @@ -2,6 +2,7 @@ package service import ( "slices" + "strings" "testing" "time" @@ -125,13 +126,13 @@ func TestFilterDisplayCloudLibrariesMergesCategoryNameAliases(t *testing.T) { } filtered := FilterDisplayCloudLibraries(t.Context(), repos, []model.Library{foreignMovie, westernMovie, eastAsianMovie, jpAnime, jpAnimeCloud}) - if got := libraryNames(filtered); !slices.Equal(got, []string{"外语电影", "日番"}) { - t.Fatalf("filtered names = %#v, want user-facing alias libraries only", got) + if got := libraryNames(filtered); !slices.Equal(got, []string{"欧美电影", "日韩电影", "日番"}) { + t.Fatalf("filtered names = %#v, want legacy foreign movie merged into western movie plus anime aliases", got) } movieMerged := MergedLibraryIDs([]model.Library{foreignMovie, westernMovie, eastAsianMovie, jpAnime, jpAnimeCloud}, foreignMovie) - if !slices.Equal(movieMerged, []string{foreignMovie.ID, westernMovie.ID, eastAsianMovie.ID}) { - t.Fatalf("movie merged ids = %#v, want foreign movie aliases", movieMerged) + if !slices.Equal(movieMerged, []string{foreignMovie.ID, westernMovie.ID}) { + t.Fatalf("movie merged ids = %#v, want legacy foreign movie merged with western movie", movieMerged) } animeMerged := MergedLibraryIDs([]model.Library{foreignMovie, westernMovie, eastAsianMovie, jpAnime, jpAnimeCloud}, jpAnime) if !slices.Equal(animeMerged, []string{jpAnime.ID, jpAnimeCloud.ID}) { @@ -139,6 +140,94 @@ func TestFilterDisplayCloudLibrariesMergesCategoryNameAliases(t *testing.T) { } } +func TestFilterDisplayCloudLibrariesCanonicalizesLegacyDisplayPaths(t *testing.T) { + db := newServiceTestDB(t, &model.Library{}, &model.Media{}) + repos := repository.New(db) + westernAnimation := model.Library{Name: "欧美动漫", Path: `F:\media\动漫\欧美动漫`, Type: "tv", Enabled: true} + uncategorizedCloud := model.Library{Name: "OpenList · 未分类", Path: BuildCloudLibraryPath("openlist", "/未分类", "/未分类"), Type: "movie", Enabled: true} + adult := model.Library{Name: "9KG", Path: `F:\media\成人\9KG`, Type: "movie", Enabled: true} + for _, lib := range []*model.Library{&westernAnimation, &uncategorizedCloud, &adult} { + if err := repos.Library.Create(t.Context(), lib); err != nil { + t.Fatal(err) + } + } + + filtered := FilterDisplayCloudLibraries(t.Context(), repos, []model.Library{westernAnimation, uncategorizedCloud, adult}) + if got := libraryNames(filtered); !slices.Equal(got, []string{"美漫", "欧美剧", "成人"}) { + t.Fatalf("filtered names = %#v, want canonical category names", got) + } + if got := []string{filtered[0].Type, filtered[1].Type, filtered[2].Type}; !slices.Equal(got, []string{"anime", "tv", "adult"}) { + t.Fatalf("filtered types = %#v, want canonical display types", got) + } + combined := strings.Join([]string{filtered[0].Path, filtered[1].Path, filtered[2].Path}, "\n") + for _, legacy := range []string{"欧美动漫", "未分类", "9KG"} { + if strings.Contains(combined, legacy) { + t.Fatalf("display paths contain legacy category %q: %s", legacy, combined) + } + } +} + +func TestCanonicalLibraryDisplayPathPreservesAutoCategoryScanDir(t *testing.T) { + raw := BuildCloudAutoCategoryLibraryPathWithScanDir("openlist", "国漫", "动漫/国产动漫") + + got := CanonicalLibraryDisplayPath(model.Library{Name: "国漫", Path: raw, Type: "anime", Enabled: true}) + info, ok := ParseCloudLibraryMount(got) + if !ok { + t.Fatalf("canonical path did not parse: %q", got) + } + if !CloudLibraryAutoCategory(model.Library{Path: got}) { + t.Fatalf("canonical path lost auto_category flag: %q", got) + } + if info.ScanDir != "国漫" || info.DisplayDir != "动漫/国漫" { + t.Fatalf("canonical path info = %#v, want scan 国漫 and canonical display 动漫/国漫", info) + } +} + +func TestListMediaVisibleDoesNotMergeDistinctMovieRegionLibraries(t *testing.T) { + db := newServiceTestDB(t, &model.Library{}, &model.Media{}) + repos := repository.New(db) + foreignMovie := model.Library{Name: "外语电影", Path: "/media/电影/外语电影", Type: "movie", Enabled: true} + westernMovie := model.Library{Name: "OpenList · 欧美电影", Path: BuildCloudLibraryPath("openlist", "/欧美电影", "/欧美电影"), Type: "movie", Enabled: true} + eastAsianMovie := model.Library{Name: "OpenList · 日韩电影", Path: BuildCloudLibraryPath("openlist", "/日韩电影", "/日韩电影"), Type: "movie", Enabled: true} + for _, lib := range []*model.Library{&foreignMovie, &westernMovie, &eastAsianMovie} { + if err := repos.Library.Create(t.Context(), lib); err != nil { + t.Fatal(err) + } + } + if err := repos.DB.Create(&model.Media{ + LibraryID: westernMovie.ID, + Title: "Western Movie", + Path: "cloud://openlist/欧美电影/Western.Movie.2026.mkv", + }).Error; err != nil { + t.Fatal(err) + } + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) + + items, total, err := svc.ListMediaVisible(t.Context(), foreignMovie.ID, 1, 20, MediaVisibility{IncludeNSFW: true}) + if err != nil { + t.Fatal(err) + } + if total != 1 || !slices.Equal(mediaTitles(items), []string{"Western Movie"}) { + t.Fatalf("legacy foreign movie items total=%d items=%#v, want merged western media", total, mediaTitles(items)) + } + + items, total, err = svc.ListMediaVisible(t.Context(), eastAsianMovie.ID, 1, 20, MediaVisibility{IncludeNSFW: true}) + if err != nil { + t.Fatal(err) + } + if total != 0 || len(items) != 0 { + t.Fatalf("east asian movie items total=%d items=%#v, want empty isolated library", total, mediaTitles(items)) + } + + items, total, err = svc.ListMediaVisible(t.Context(), westernMovie.ID, 1, 20, MediaVisibility{IncludeNSFW: true}) + if err != nil { + t.Fatal(err) + } + if total != 1 || !slices.Equal(mediaTitles(items), []string{"Western Movie"}) { + t.Fatalf("western movie items total=%d items=%#v, want own media only", total, mediaTitles(items)) + } +} + func TestFilterDeprecatedNativeCloudLibrariesHidesPopulatedHistory(t *testing.T) { db := newServiceTestDB(t, &model.Library{}, &model.Media{}) repos := repository.New(db) @@ -284,12 +373,13 @@ func TestStartAllCloudLibraryScansIncludesMergedCloudMounts(t *testing.T) { } } -func TestAutoCategoryCloudLibrariesDoNotShadowRootOrScan(t *testing.T) { +func TestAutoCategoryCloudLibrariesMergeIntoExistingDisplayLibrary(t *testing.T) { db := newServiceTestDB(t, &model.Library{}, &model.Media{}) repos := repository.New(db) + local := model.Library{Name: "欧美剧", Path: "/media/电视剧/欧美剧", Type: "tv", Enabled: true} root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} auto := model.Library{Name: "欧美剧", Path: BuildCloudAutoCategoryLibraryPath("openlist", "电视剧/欧美剧"), Type: "tv", Enabled: true} - for _, lib := range []*model.Library{&root, &auto} { + for _, lib := range []*model.Library{&local, &root, &auto} { if err := repos.Library.Create(t.Context(), lib); err != nil { t.Fatal(err) } @@ -303,12 +393,12 @@ func TestAutoCategoryCloudLibrariesDoNotShadowRootOrScan(t *testing.T) { t.Fatalf("auto category should not shadow root scan: %#v", shadow) } display := FilterDisplayCloudLibraries(t.Context(), repos, libs) - if len(display) != 1 || display[0].ID != root.ID { - t.Fatalf("display libraries = %#v, want only user-mounted root", display) + if got := libraryNames(display); !slices.Equal(got, []string{"欧美剧", "OpenList"}) { + t.Fatalf("display libraries = %#v, want local library and user-mounted root only", got) } scannable := FilterScannableCloudLibraries(t.Context(), repos, libs) - if len(scannable) != 1 || scannable[0].ID != root.ID { - t.Fatalf("scannable libraries = %#v, want only root", scannable) + if got := libraryNames(scannable); !slices.Equal(got, []string{"欧美剧", "OpenList"}) { + t.Fatalf("scannable libraries = %#v, want local library and root only", got) } scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) @@ -317,11 +407,11 @@ func TestAutoCategoryCloudLibrariesDoNotShadowRootOrScan(t *testing.T) { t.Fatal(err) } if len(statuses) != 1 || statuses[0].LibraryID != root.ID { - t.Fatalf("scan-all statuses = %#v, want only root queued", statuses) + t.Fatalf("scan-all statuses = %#v, want only cloud root queued", statuses) } } -func TestRootCloudLibraryIncludesHiddenAutoCategoryMedia(t *testing.T) { +func TestRootCloudLibraryIncludesAutoCategoryMedia(t *testing.T) { db := newServiceTestDB(t, &model.Library{}, &model.Media{}) repos := repository.New(db) root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} @@ -345,13 +435,13 @@ func TestRootCloudLibraryIncludesHiddenAutoCategoryMedia(t *testing.T) { t.Fatal(err) } if total != 1 || len(items) != 1 { - t.Fatalf("root cloud items total=%d len=%d, want hidden auto-category media", total, len(items)) + t.Fatalf("root cloud items total=%d len=%d, want auto-category media", total, len(items)) } - if items[0].LibraryName != root.Name || items[0].LibraryPath != root.Path { - t.Fatalf("media library metadata = (%q, %q), want user-mounted root", items[0].LibraryName, items[0].LibraryPath) + if items[0].LibraryName != auto.Name || items[0].LibraryPath != auto.Path { + t.Fatalf("media library metadata = (%q, %q), want auto category", items[0].LibraryName, items[0].LibraryPath) } - if items[0].DisplayLibraryID != root.ID || items[0].DisplayLibraryPath != root.Path { - t.Fatalf("display library = (%q, %q), want user-mounted root", items[0].DisplayLibraryID, items[0].DisplayLibraryPath) + if items[0].DisplayLibraryID != auto.ID || items[0].DisplayLibraryPath != auto.Path { + t.Fatalf("display library = (%q, %q), want auto category", items[0].DisplayLibraryID, items[0].DisplayLibraryPath) } } diff --git a/internal/service/cloud_mount_label.go b/internal/service/cloud_mount_label.go index 20147a8..7fbd55e 100644 --- a/internal/service/cloud_mount_label.go +++ b/internal/service/cloud_mount_label.go @@ -85,11 +85,11 @@ func InferCloudMountMediaType(dir, name string) string { switch { case strings.Contains(text, "成人") || strings.Contains(text, "adult") || strings.Contains(text, "jav") || strings.Contains(text, "9kg"): return "adult" - case containsAny(text, "动画电影", "华语电影", "外语电影", "欧美电影", "日韩电影", "韩国电影", "日本电影", "港台电影", "香港电影", "台湾电影", "大陆电影", "国产电影", "纪录片", "演唱会", "电影", "movie", "movies", "film", "films", "documentary", "concert"): + case containsAny(text, "动画电影", "华语电影", "外语电影", "外国电影", "欧美电影", "日韩电影", "韩国电影", "日本电影", "港台电影", "香港电影", "台湾电影", "大陆电影", "国产电影", "纪录片", "演唱会", "音乐会", "电影", "movie", "movies", "film", "films", "documentary", "concert"): return "movie" case containsAny(text, "综艺", "真人秀", "脱口秀", "晚会", "variety"): return "variety" - case containsAny(text, "国漫", "日漫", "日番", "番剧", "动漫", "欧美动漫", "动画剧集", "anime"): + case containsAny(text, "国漫", "日漫", "日番", "韩漫", "美漫", "番剧", "动漫", "欧美动漫", "动画剧集", "anime"): return "anime" case containsAny(text, "国产剧", "大陆剧", "华语剧", "欧美剧", "日韩剧", "韩剧", "日剧", "港剧", "台剧", "泰剧", "英剧", "美剧", "短剧", "电视剧", "剧集", "连续剧", "series", "tv", "shows"): return "tv" diff --git a/internal/service/cloud_mount_merge.go b/internal/service/cloud_mount_merge.go index 70bfd2a..dfc6419 100644 --- a/internal/service/cloud_mount_merge.go +++ b/internal/service/cloud_mount_merge.go @@ -1,167 +1,90 @@ package service import ( - "context" "strings" "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" ) -func MergedLibraryIDsForLibrary(ctx context.Context, repo *repository.Container, libraryID string) ([]string, error) { - libraryID = strings.TrimSpace(libraryID) - if libraryID == "" || repo == nil || repo.Library == nil { - return []string{libraryID}, nil - } - lib, err := repo.Library.FindByID(ctx, libraryID) - if err != nil { - return nil, err - } - if lib == nil { - return []string{libraryID}, nil - } - libs, err := repo.Library.List(ctx) - if err != nil { - return nil, err - } - return MergedLibraryIDs(libs, *lib), nil -} - -func MergedLibraryIDs(libs []model.Library, lib model.Library) []string { - ids := []string{} - seen := map[string]struct{}{} - add := func(more ...string) { - for _, id := range more { - id = strings.TrimSpace(id) - if id == "" { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - ids = append(ids, id) - } - } - add(lib.ID) - if rootAutoIDs := cloudRootAutoCategoryLibraryIDs(libs, lib); len(rootAutoIDs) > 0 { - add(rootAutoIDs...) - } - key, ok := CloudLibraryMergeKey(lib) +func cloudLibraryDisplayKey(lib model.Library) (string, bool) { + info, ok := ParseCloudLibraryMount(lib.Path) if !ok { - return ids + return "", false } - _, libIsCloud := ParseCloudLibraryMount(lib.Path) - for _, candidate := range libs { - if candidate.ID == lib.ID || !candidate.Enabled { - continue - } - candidateKey, ok := CloudLibraryMergeKey(candidate) - if !ok || candidateKey != key { - continue - } - _, candidateIsCloud := ParseCloudLibraryMount(candidate.Path) - if !libIsCloud && !candidateIsCloud { - continue - } - add(candidate.ID) - } - return ids + dir := firstNonEmpty(info.DisplayDir, info.ScanDir) + return info.Provider + "\x00" + dir, true } -func cloudRootAutoCategoryLibraryIDs(libs []model.Library, lib model.Library) []string { - mount, ok := ParseCloudLibraryMount(lib.Path) - if !ok || !cloudRootMountNeedsAutoCategory(mount) { - return nil +func cloudLibraryPathIsCanonical(lib model.Library) bool { + info, ok := ParseCloudLibraryMount(lib.Path) + if !ok { + return false } - ids := make([]string, 0) - for _, candidate := range libs { - if candidate.ID == lib.ID || !candidate.Enabled || !CloudLibraryAutoCategory(candidate) { - continue - } - info, ok := ParseCloudLibraryMount(candidate.Path) - if ok && info.Provider == mount.Provider { - ids = appendUniqueLibraryIDs(ids, candidate.ID) - } - } - return ids + return BuildCloudLibraryPath(info.Provider, info.ScanDir, info.DisplayDir) == strings.TrimSpace(lib.Path) } -func ExpandMediaVisibilityForMergedCloudLibraries(ctx context.Context, repo *repository.Container, visibility MediaVisibility) MediaVisibility { - if repo == nil || repo.Library == nil { - return visibility +func CloudLibraryMergeKey(lib model.Library) (string, bool) { + name := strings.TrimSpace(lib.Name) + if displayName, ok := CloudLibraryDisplayName(lib); ok { + name = displayName } - libs, err := repo.Library.List(ctx) - if err != nil { - return visibility + name = normalizeLibraryMergeName(name) + if name == "" { + return "", false } - if len(visibility.AllowedLibraryIDs) > 0 { - visibility.AllowedLibraryIDs = expandMergedLibraryIDsFromLibraries(libs, visibility.AllowedLibraryIDs) - } - if len(visibility.HiddenLibraryIDs) > 0 { - visibility.HiddenLibraryIDs = expandMergedLibraryIDsFromLibraries(libs, visibility.HiddenLibraryIDs) - } - visibility.HiddenLibraryIDs = appendUniqueLibraryIDs(visibility.HiddenLibraryIDs, DeprecatedNativeCloudLibraryIDs(libs)...) - return visibility + typeKey := cloudLibraryMergeTypeKey(lib.Type) + return typeKey + "\x00" + cloudLibraryMergeNameKey(typeKey, name), true } -func expandMergedLibraryIDs(ctx context.Context, repo *repository.Container, ids []string) []string { - if len(ids) == 0 { - return ids +func cloudLibraryMergeTypeKey(libraryType string) string { + switch strings.ToLower(strings.TrimSpace(libraryType)) { + case "tv", "anime", "variety": + return "tvshows" + default: + return strings.ToLower(strings.TrimSpace(libraryType)) } - libs, err := repo.Library.List(ctx) - if err != nil { - return ids - } - return expandMergedLibraryIDsFromLibraries(libs, ids) } -func expandMergedLibraryIDsFromLibraries(libs []model.Library, ids []string) []string { - byID := make(map[string]model.Library, len(libs)) - for _, lib := range libs { - byID[lib.ID] = lib - } - out := make([]string, 0, len(ids)) - for _, id := range ids { - lib, ok := byID[id] - if !ok { - out = appendUniqueLibraryIDs(out, id) - continue +func cloudLibraryMergeNameKey(typeKey, name string) string { + switch typeKey { + case "movie": + switch name { + case "国产电影", "大陆电影": + return "华语电影" + case "华语电影": + return "华语电影" + case "外语电影", "外国电影", "欧美电影": + return "欧美电影" + case "日韩电影", "日本电影", "韩国电影": + return "日韩电影" + case "纪录", "纪录片": + return "纪录片" + case "演唱会", "concert": + return "演唱会" + case "动画电影", "动漫电影": + return "动画电影" } - for _, mergedID := range MergedLibraryIDs(libs, lib) { - out = appendUniqueLibraryIDs(out, mergedID) + case "tvshows": + switch name { + case "国产剧", "大陆剧", "华语剧", "国剧": + return "国产剧" + case "欧美剧", "美剧", "英剧": + return "欧美剧" + case "日韩剧", "日剧", "韩剧": + return "日韩剧" + case "国漫", "国产动漫", "国产动画": + return "国漫" + case "日番", "日漫", "番剧", "日本动漫", "日本动画": + return "日番" + case "韩漫", "韩国动漫", "韩国动画": + return "韩漫" + case "美漫", "欧美动漫", "欧美动画", "西方动画": + return "美漫" + case "其他", "其他动漫", "其它动漫", "other": + return "其他" + case "纪录", "纪录片": + return "纪录片" } } - return out -} - -func DeprecatedNativeCloudLibraryIDs(libs []model.Library) []string { - ids := make([]string, 0) - for _, lib := range libs { - info, ok := ParseCloudLibraryMount(lib.Path) - if ok && IsDeprecatedNativeCloudProvider(info.Provider) { - ids = appendUniqueLibraryIDs(ids, lib.ID) - } - } - return ids -} - -func appendUniqueLibraryIDs(ids []string, more ...string) []string { - for _, id := range more { - id = strings.TrimSpace(id) - if id == "" { - continue - } - found := false - for _, existing := range ids { - if existing == id { - found = true - break - } - } - if !found { - ids = append(ids, id) - } - } - return ids + return name } diff --git a/internal/service/cloud_mount_scope.go b/internal/service/cloud_mount_scope.go new file mode 100644 index 0000000..9fe8505 --- /dev/null +++ b/internal/service/cloud_mount_scope.go @@ -0,0 +1,152 @@ +package service + +import ( + "context" + "strings" + + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +func MergedLibraryIDsForLibrary(ctx context.Context, repo *repository.Container, libraryID string) ([]string, error) { + libraryID = strings.TrimSpace(libraryID) + if libraryID == "" || repo == nil || repo.Library == nil { + return []string{libraryID}, nil + } + lib, err := repo.Library.FindByID(ctx, libraryID) + if err != nil { + return nil, err + } + if lib == nil { + return []string{libraryID}, nil + } + libs, err := repo.Library.List(ctx) + if err != nil { + return nil, err + } + return MergedLibraryIDs(libs, *lib), nil +} + +func MergedLibraryIDs(libs []model.Library, target model.Library) []string { + ids := appendUniqueLibraryIDs(nil, target.ID) + if rootAutoIDs := cloudRootAutoCategoryLibraryIDs(libs, target); len(rootAutoIDs) > 0 { + ids = appendUniqueLibraryIDs(ids, rootAutoIDs...) + } + targetKey, hasTargetKey := CloudLibraryMergeKey(target) + if !hasTargetKey { + return ids + } + _, targetIsCloud := ParseCloudLibraryMount(target.Path) + for _, candidate := range libs { + if candidate.ID == target.ID || strings.TrimSpace(candidate.ID) == "" || !candidate.Enabled { + continue + } + key, ok := CloudLibraryMergeKey(candidate) + if ok && key == targetKey { + _, candidateIsCloud := ParseCloudLibraryMount(candidate.Path) + if !targetIsCloud && !candidateIsCloud { + continue + } + ids = appendUniqueLibraryIDs(ids, candidate.ID) + } + } + return ids +} + +func cloudRootAutoCategoryLibraryIDs(libs []model.Library, lib model.Library) []string { + mount, ok := ParseCloudLibraryMount(lib.Path) + if !ok || !cloudRootMountNeedsAutoCategory(mount) { + return nil + } + ids := make([]string, 0) + for _, candidate := range libs { + if candidate.ID == lib.ID || !candidate.Enabled || !CloudLibraryAutoCategory(candidate) { + continue + } + info, ok := ParseCloudLibraryMount(candidate.Path) + if ok && info.Provider == mount.Provider { + ids = appendUniqueLibraryIDs(ids, candidate.ID) + } + } + return ids +} + +func ExpandMediaVisibilityForMergedCloudLibraries(ctx context.Context, repo *repository.Container, visibility MediaVisibility) MediaVisibility { + if repo == nil || repo.Library == nil { + return visibility + } + libs, err := repo.Library.List(ctx) + if err != nil { + return visibility + } + if len(visibility.AllowedLibraryIDs) > 0 { + visibility.AllowedLibraryIDs = expandMergedLibraryIDsFromLibraries(libs, visibility.AllowedLibraryIDs) + } + if len(visibility.HiddenLibraryIDs) > 0 { + visibility.HiddenLibraryIDs = expandMergedLibraryIDsFromLibraries(libs, visibility.HiddenLibraryIDs) + } + visibility.HiddenLibraryIDs = appendUniqueLibraryIDs(visibility.HiddenLibraryIDs, DeprecatedNativeCloudLibraryIDs(libs)...) + return visibility +} + +func expandMergedLibraryIDs(ctx context.Context, repo *repository.Container, ids []string) []string { + if len(ids) == 0 || repo == nil || repo.Library == nil { + return ids + } + libs, err := repo.Library.List(ctx) + if err != nil { + return ids + } + return expandMergedLibraryIDsFromLibraries(libs, ids) +} + +func expandMergedLibraryIDsFromLibraries(libs []model.Library, ids []string) []string { + byID := make(map[string]model.Library, len(libs)) + for _, lib := range libs { + byID[lib.ID] = lib + } + out := make([]string, 0, len(ids)) + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if lib, ok := byID[id]; ok { + out = appendUniqueLibraryIDs(out, MergedLibraryIDs(libs, lib)...) + continue + } + out = appendUniqueLibraryIDs(out, id) + } + return out +} + +func DeprecatedNativeCloudLibraryIDs(libs []model.Library) []string { + ids := make([]string, 0) + for _, lib := range libs { + info, ok := ParseCloudLibraryMount(lib.Path) + if ok && IsDeprecatedNativeCloudProvider(info.Provider) { + ids = appendUniqueLibraryIDs(ids, lib.ID) + } + } + return ids +} + +func appendUniqueLibraryIDs(ids []string, values ...string) []string { + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + exists := false + for _, id := range ids { + if id == value { + exists = true + break + } + } + if !exists { + ids = append(ids, value) + } + } + return ids +} diff --git a/internal/service/downloads_progress_test.go b/internal/service/downloads_progress_test.go index 434cda6..aa67f08 100644 --- a/internal/service/downloads_progress_test.go +++ b/internal/service/downloads_progress_test.go @@ -51,7 +51,7 @@ func TestSyncDownloadTaskProgressMatchesSeasonFolderTorrentName(t *testing.T) { Source: "qbittorrent", URL: "magnet:?xt=urn:btih:test", Title: "The First Jasmine S01E01 1080p TX WEB-DL AAC2.0 H.264-MWeb", - SavePath: "/downloads/未分类", + SavePath: "/downloads/欧美剧", Status: "queued", Progress: 0.5, } @@ -82,7 +82,7 @@ func TestProcessDownloadSnapshotQueuesCompletedPendingTaskOnFirstSnapshot(t *tes Source: "qbittorrent", URL: "magnet:?xt=urn:btih:test", Title: "Blades of the Guardians S02E01 1080p TX WEB-DL AAC2.0 H.264-MWeb", - SavePath: "/downloads/未分类", + SavePath: "/downloads/欧美剧", Status: "queued", Progress: 0, } @@ -113,7 +113,7 @@ func TestProcessDownloadSnapshotDoesNotQueueActiveDownloadAtFullProgress(t *test Source: "qbittorrent", URL: "magnet:?xt=urn:btih:test", Title: "Still Downloading S01E01", - SavePath: "/downloads/未分类", + SavePath: "/downloads/欧美剧", Status: "downloading", Progress: 0.99, } @@ -151,7 +151,7 @@ func TestProcessDownloadSnapshotDoesNotQueueFullProgressWithoutQBitState(t *test Source: "qbittorrent", URL: "magnet:?xt=urn:btih:test", Title: "Missing State S01E01", - SavePath: "/downloads/未分类", + SavePath: "/downloads/欧美剧", Status: "downloading", Progress: 0.99, } @@ -188,7 +188,7 @@ func TestProcessDownloadSnapshotDoesNotTrustCompletionOnForActiveDownload(t *tes Source: "qbittorrent", URL: "magnet:?xt=urn:btih:test", Title: "Still Downloading With Completion Timestamp S01E01", - SavePath: "/downloads/未分类", + SavePath: "/downloads/欧美剧", Status: "downloading", Progress: 0.5, } diff --git a/internal/service/manual_scrape_search.go b/internal/service/manual_scrape_search.go index fe97855..8a06fde 100644 --- a/internal/service/manual_scrape_search.go +++ b/internal/service/manual_scrape_search.go @@ -13,7 +13,7 @@ func (s *ScraperService) ManualSearch(ctx context.Context, media *model.Media, q return nil, errors.New("media required") } lib, _ := s.repo.Library.FindByID(ctx, media.LibraryID) - queries := manualSearchQueries(media, lib, query) + queries := s.manualSearchQueries(ctx, media, lib, query) if len(queries) == 0 { return nil, errors.New("search query required") } @@ -28,7 +28,7 @@ func (s *ScraperService) ManualSearch(ctx context.Context, media *model.Media, q providers := manualSearchProviderSet(provider) year := mediaYearHint(media) if year <= 0 { - _, year = CleanQuery(queries[0]) + _, year = CleanQueryWithRecognition(ctx, s.repo, queries[0]) } out := make([]ExternalMediaResult, 0, 6) @@ -124,7 +124,7 @@ func (p manualSearchProviders) want(provider string) bool { return ok } -func manualSearchQueries(media *model.Media, lib *model.Library, query string) []string { +func (s *ScraperService) manualSearchQueries(ctx context.Context, media *model.Media, lib *model.Library, query string) []string { seen := map[string]struct{}{} out := make([]string, 0, 4) add := func(value string) { @@ -140,16 +140,16 @@ func manualSearchQueries(media *model.Media, lib *model.Library, query string) [ out = append(out, value) } - add(query) + add(ApplyRecognitionWords(ctx, s.repo, query)) if strings.TrimSpace(query) == "" && media != nil { add(firstText(media.Title, media.OriginalName)) } if media != nil { - for _, candidate := range scrapeQueryCandidates(media, lib) { + for _, candidate := range scrapeQueryCandidatesWithRecognition(ctx, s.repo, media, lib) { add(candidate) } if len(out) == 0 { - title, _ := CleanQuery(media.Path) + title, _ := CleanQueryWithRecognition(ctx, s.repo, media.Path) add(title) } } diff --git a/internal/service/media_classifier.go b/internal/service/media_classifier.go index 71cee3b..229c9b1 100644 --- a/internal/service/media_classifier.go +++ b/internal/service/media_classifier.go @@ -44,12 +44,9 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin isChineseByText := containsHan(rawTitleText) || containsAnyText(strings.ToLower(rawTitleText), "华语", "国产", "国剧", "国漫") isChineseByCategory := containsAnyText(categoryText, "华语", "国产", "国剧", "大陆剧", "国产电视剧", "国产电影", "国漫", "国产动漫", "国产动画") isChinese := isChineseByMetadata || (!hasMetadata && isChineseByText) - // 动漫的中文译名几乎都是纯汉字(如日本动画「葬送的芙莉莲」),用 containsHan - // 判中文会把日本动画误判成国漫。动漫只在有元数据或显式中文标记时才算国漫, - // 否则默认日番(日本动画占绝大多数;未刮削的国漫刮出 origin_country=CN 后仍正确)。 isChineseAnime := isChineseByMetadata || (!hasMetadata && containsAnyText(text, "华语", "国产", "国漫", "國漫", "国创", "国产动漫", "国产动画")) - isJapanese := hasAny(languages, "JA", "JP") || hasAny(countries, "JP") || containsJapaneseKana(rawText) || strings.Contains(text, "日番") - isKorean := hasAny(languages, "KO", "KR") || hasAny(countries, "KR", "KP") || containsKoreanHangul(rawText) + isJapanese := hasAny(languages, "JA", "JP") || hasAny(countries, "JP") || containsJapaneseKana(rawTitleText) || (!hasMetadata && strings.Contains(text, "日番")) + isKorean := hasAny(languages, "KO", "KR") || hasAny(countries, "KR", "KP") || containsKoreanHangul(rawTitleText) || (!hasMetadata && containsAnyText(categoryText, "韩漫", "韩国动漫", "韩国动画")) isEastAsianByCategory := containsAnyText(categoryText, "日韩剧", "日剧", "韩剧", "日韩电影") isEastAsian := isJapanese || isKorean || hasAny(countries, "TH", "IN", "SG") || (!hasMetadata && isEastAsianByCategory) isWesternByMetadata := hasAny(countries, @@ -58,9 +55,11 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin ) isWesternByCategory := containsAnyText(categoryText, "欧美剧", "欧美电视剧", "美剧", "英剧", "欧美电影", "外语电影") isWestern := isWesternByMetadata || (!hasMetadata && isWesternByCategory) - hasAnimeText := containsAnyText(text, "动画", "动漫", "番剧", "年番", "国漫", "日番", "bangumi", "anime", "b-global", "ani-one", "crunchyroll") + isUSAnime := hasAny(countries, "US") + hasAnimeText := containsAnyText(text, "动画", "动漫", "番剧", "年番", "国漫", "日番", "韩漫", "美漫", "bangumi", "anime", "b-global", "ani-one", "crunchyroll") hasVarietyText := containsAnyText(text, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show") hasDocumentaryText := containsAnyText(text, "纪录", "纪录片", "documentary", "docu", "national geographic", "natgeo") + hasConcertText := containsAnyText(text, "演唱会", "音乐会", "concert", "live concert") isAdultText := containsAnyText(text, "adult", "nsfw", "成人", "番号", "jav", "9kg", "uncensored", "无码", "有码") || classifierJAVCodeRE.MatchString(strings.ToUpper(rawText)) hasGenre := func(values ...string) bool { @@ -77,12 +76,33 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin } return false } + animeCategory := func() string { + if isChineseAnime { + return categoryName(categories, "cn_anime", "国漫") + } + if isJapanese { + return categoryName(categories, "jp_anime", "日番") + } + if isKorean { + return categoryName(categories, "kr_anime", "韩漫") + } + if isUSAnime || (!hasMetadata && containsAnyText(categoryText, "美漫", "欧美动漫", "欧美动画", "西方动画")) { + return categoryName(categories, "us_anime", "美漫") + } + return categoryName(categories, "other_anime", "其他") + } switch mediaType { case "movie": if isAdultText { return categoryName(categories, "adult", "成人") } + if hasGenre("10402", "MUSIC", "音乐") || hasConcertText { + return categoryName(categories, "concert_movie", "演唱会") + } + if hasGenre("99", "DOCUMENTARY", "纪录", "纪录片") || hasDocumentaryText { + return categoryName(categories, "documentary_movie", "纪录片") + } if hasGenre("16", "ANIMATION", "动画", "动漫") || hasAnimeText { return categoryName(categories, "animation_movie", "动画电影") } @@ -92,41 +112,35 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin if isChinese { return categoryName(categories, "chinese_movie", "华语电影") } - return categoryName(categories, "foreign_movie", "外语电影") + if hasAny(languages, "JA", "KO") || (!hasAny(languages, "ZH", "ZH-CN", "ZH-TW", "CN", "BO", "ZA") && hasAny(countries, "JP", "KP", "KR")) { + return categoryName(categories, "jk_movie", "日韩电影") + } + return categoryName(categories, "euus_movie", "欧美电影") case "anime": + if isAdultText { + return categoryName(categories, "adult", "成人") + } if !hasMetadata && sourceHint != "" { return sourceHint } - if isChineseAnime { - return categoryName(categories, "cn_anime", "国漫") - } - if isWesternByMetadata || (!hasMetadata && containsAnyText(categoryText, "欧美动漫", "欧美动画", "西方动画")) { - return categoryName(categories, "euus_anime", "欧美动漫") - } - return categoryName(categories, "jp_anime", "日番") + return animeCategory() case "variety": return categoryName(categories, "variety", "综艺") case "tv": if isAdultText { return categoryName(categories, "adult", "成人") } - if hasGenre("10764", "10767", "REALITY", "TALK", "综艺", "真人秀", "脱口秀") || hasVarietyText { - return categoryName(categories, "variety", "综艺") - } if hasGenre("99", "DOCUMENTARY", "纪录", "纪录片") || hasDocumentaryText { return categoryName(categories, "documentary", "纪录片") } if hasGenre("10762", "KIDS", "儿童") { return categoryName(categories, "children", "儿童") } + if hasGenre("10764", "10767", "REALITY", "TALK", "综艺", "真人秀", "脱口秀") || hasVarietyText { + return categoryName(categories, "variety", "综艺") + } if hasGenre("16", "ANIMATION", "动画", "动漫") || hasAnimeText { - if isChineseAnime { - return categoryName(categories, "cn_anime", "国漫") - } - if isWesternByMetadata || (!hasMetadata && containsAnyText(categoryText, "欧美动漫", "欧美动画", "西方动画")) { - return categoryName(categories, "euus_anime", "欧美动漫") - } - return categoryName(categories, "jp_anime", "日番") + return animeCategory() } if !hasMetadata && sourceHint != "" { return sourceHint @@ -140,7 +154,7 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin if isWestern { return categoryName(categories, "euus_tv", "欧美剧") } - return categoryName(categories, "uncategorized_tv", "未分类") + return categoryName(categories, "euus_tv", "欧美剧") case "adult": return categoryName(categories, "adult", "成人") } @@ -166,11 +180,11 @@ func normalizeMediaType(mediaType, title, category string) string { return "adult" case containsAnyText(raw, "综艺", "真人秀"): return "variety" - case (containsAnyText(raw, "国漫", "日漫", "日番", "动漫", "动画") || classifierAnimeRE.MatchString(raw)) && !containsAnyText(raw, "动画电影"): + case (containsAnyText(raw, "国漫", "日漫", "日番", "韩漫", "美漫", "欧美动漫", "其他动漫", "动漫", "动画") || classifierAnimeRE.MatchString(raw)) && !containsAnyText(raw, "动画电影"): return "anime" case containsAnyText(raw, "电视剧", "国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "剧集") || classifierTVRE.MatchString(raw): return "tv" - case containsAnyText(raw, "电影") || classifierMovieRE.MatchString(raw): + case containsAnyText(raw, "电影", "演唱会") || classifierMovieRE.MatchString(raw): return "movie" } text := strings.ToLower(title + " " + category) diff --git a/internal/service/media_classifier_source_hints.go b/internal/service/media_classifier_source_hints.go index 6e7e482..ae4c599 100644 --- a/internal/service/media_classifier_source_hints.go +++ b/internal/service/media_classifier_source_hints.go @@ -6,26 +6,29 @@ type sourceCategoryHintDef struct { Key string Fallback string MediaType string + Aliases []string } var sourceCategoryHints = []sourceCategoryHintDef{ + {Key: "concert_movie", Fallback: "演唱会", MediaType: "movie", Aliases: []string{"concert", "音乐会"}}, + {Key: "documentary_movie", Fallback: "纪录片", MediaType: "movie", Aliases: []string{"纪录", "documentary"}}, {Key: "animation_movie", Fallback: "动画电影", MediaType: "movie"}, {Key: "chinese_movie", Fallback: "华语电影", MediaType: "movie"}, {Key: "jk_movie", Fallback: "日韩电影", MediaType: "movie"}, - {Key: "euus_movie", Fallback: "欧美电影", MediaType: "movie"}, - {Key: "foreign_movie", Fallback: "外语电影", MediaType: "movie"}, + {Key: "euus_movie", Fallback: "欧美电影", MediaType: "movie", Aliases: []string{"外语电影", "外国电影", "western movie", "foreign movie"}}, {Key: "domestic_tv", Fallback: "国产剧", MediaType: "tv"}, {Key: "euus_tv", Fallback: "欧美剧", MediaType: "tv"}, - {Key: "jk_tv", Fallback: "日韩剧", MediaType: "tv"}, + {Key: "jk_tv", Fallback: "日韩剧", MediaType: "tv", Aliases: []string{"日剧", "韩剧", "泰剧"}}, {Key: "cn_anime", Fallback: "国漫", MediaType: "anime"}, {Key: "jp_anime", Fallback: "日番", MediaType: "anime"}, - {Key: "euus_anime", Fallback: "欧美动漫", MediaType: "anime"}, + {Key: "kr_anime", Fallback: "韩漫", MediaType: "anime"}, + {Key: "us_anime", Fallback: "美漫", MediaType: "anime", Aliases: []string{"欧美动漫", "欧美动画", "西方动画"}}, + {Key: "other_anime", Fallback: "其他", MediaType: "anime", Aliases: []string{"其他动漫", "其它动漫", "other"}}, {Key: "variety", Fallback: "综艺", MediaType: "variety"}, {Key: "documentary", Fallback: "纪录片", MediaType: "tv"}, {Key: "children", Fallback: "儿童", MediaType: "tv"}, - {Key: "adult", Fallback: "成人", MediaType: "adult"}, - {Key: "adult_9kg", Fallback: "9KG", MediaType: "adult"}, - {Key: "adult_jav", Fallback: "番号", MediaType: "adult"}, + {Key: "euus_tv", Fallback: "欧美剧", MediaType: "tv", Aliases: []string{"未分类", "uncategorized"}}, + {Key: "adult", Fallback: "成人", MediaType: "adult", Aliases: []string{"9KG", "番号", "JAV", "adult", "nsfw"}}, } func sourceCategoryHint(category, mediaType string, categories map[string]string) string { @@ -37,7 +40,8 @@ func sourceCategoryHint(category, mediaType string, categories map[string]string if !sourceCategoryCompatible(mediaType, hint.MediaType) { continue } - for _, name := range []string{hint.Fallback, categoryName(categories, hint.Key, hint.Fallback)} { + names := append([]string{hint.Fallback, categoryName(categories, hint.Key, hint.Fallback)}, hint.Aliases...) + for _, name := range names { if _, ok := tokens[strings.ToLower(strings.TrimSpace(name))]; ok { return categoryName(categories, hint.Key, hint.Fallback) } diff --git a/internal/service/media_classifier_test.go b/internal/service/media_classifier_test.go index c6f54fa..4c2de44 100644 --- a/internal/service/media_classifier_test.go +++ b/internal/service/media_classifier_test.go @@ -46,7 +46,7 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) { Countries: []string{"NL"}, Genres: []string{"Comedy"}, }, - want: "外语电影", + want: "欧美电影", }, { name: "movie animation source category fallback", @@ -99,7 +99,7 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) { MediaType: "movie", Title: "Dune 2021 2160p", }, - want: "外语电影", + want: "欧美电影", }, { name: "chinese tv title without metadata", @@ -110,12 +110,12 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) { want: "国产剧", }, { - name: "latin tv title without metadata stays uncategorized", + name: "latin tv title without metadata falls back to western tv", input: mediaClassifyInput{ MediaType: "tv", Title: "The Last of Us S01E01 1080p", }, - want: "未分类", + want: "欧美剧", }, { name: "latin tv keeps explicit western source category", @@ -133,7 +133,7 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) { Title: "The Last of Us S01E01 1080p", Category: "downloads 电视剧", }, - want: "未分类", + want: "欧美剧", }, { name: "gala title overrides wrong western source category", @@ -150,7 +150,7 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) { MediaType: "tv", Title: "Motherhood.of.Taihang.S01E01.2026.1080p.iQIYI.WEB-DL", }, - want: "未分类", + want: "欧美剧", }, { name: "metadata classifies romanized chinese drama", @@ -170,12 +170,12 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) { want: "综艺", }, { - name: "japanese anime localized chinese title defaults to jp without metadata", + name: "japanese anime localized chinese title without metadata falls back to other", input: mediaClassifyInput{ MediaType: "anime", Title: "葬送的芙莉莲", }, - want: "日番", + want: "其他", }, { name: "chinese anime explicit marker without metadata", @@ -197,7 +197,7 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) { want: "日番", }, { - name: "western anime metadata uses western anime category", + name: "western anime metadata uses us anime category", input: mediaClassifyInput{ MediaType: "anime", Title: "Family Guy", @@ -205,26 +205,26 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) { Genres: []string{"16"}, Category: "日番", }, - want: "欧美动漫", + want: "美漫", }, { - name: "tv animation with western metadata uses western anime category", + name: "tv animation with western metadata uses us anime category", input: mediaClassifyInput{ MediaType: "tv", Title: "The Simpsons", Countries: []string{"US"}, Genres: []string{"Animation"}, }, - want: "欧美动漫", + want: "美漫", }, { - name: "western anime source category is preserved without metadata", + name: "western anime legacy source category maps to us anime without metadata", input: mediaClassifyInput{ MediaType: "anime", Title: "The Simpsons S01E01 1080p", Category: "downloads 欧美动漫", }, - want: "欧美动漫", + want: "美漫", }, { name: "anime with CN country metadata is cn", @@ -245,6 +245,47 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) { }, want: "欧美电影", }, + { + name: "movie concert by music genre", + input: mediaClassifyInput{ + MediaType: "movie", + Title: "Taylor Swift The Eras Tour", + Genres: []string{"10402"}, + }, + want: "演唱会", + }, + { + name: "movie documentary before region", + input: mediaClassifyInput{ + MediaType: "movie", + Title: "Planet Earth", + Languages: []string{"en"}, + Countries: []string{"GB"}, + Genres: []string{"99"}, + }, + want: "纪录片", + }, + { + name: "movie korean language uses jk movie", + input: mediaClassifyInput{ + MediaType: "movie", + Title: "Parasite", + Languages: []string{"ko"}, + Countries: []string{"KR"}, + Genres: []string{"18"}, + }, + want: "日韩电影", + }, + { + name: "anime korean metadata uses korean anime category", + input: mediaClassifyInput{ + MediaType: "anime", + Title: "Korean Animation", + Countries: []string{"KR"}, + Genres: []string{"16"}, + }, + want: "韩漫", + }, { name: "jav code is adult", input: mediaClassifyInput{ diff --git a/internal/service/media_display_library.go b/internal/service/media_display_library.go index d26d50a..ba065d1 100644 --- a/internal/service/media_display_library.go +++ b/internal/service/media_display_library.go @@ -80,13 +80,13 @@ func (r mediaDisplayLibraryResolver) DisplayLibraryForMedia(media model.Media) ( } own, hasOwn := r.byID[media.LibraryID] if hasOwn { - if CloudLibraryAutoCategory(own) { - if lib, ok := r.rootCloudDisplayLibraryForAutoCategory(own); ok { + if key, ok := CloudLibraryMergeKey(own); ok { + if lib, exists := r.displayByMergeKey[key]; exists { return lib, true } } - if key, ok := CloudLibraryMergeKey(own); ok { - if lib, exists := r.displayByMergeKey[key]; exists { + if CloudLibraryAutoCategory(own) { + if lib, ok := r.rootCloudDisplayLibraryForAutoCategory(own); ok { return lib, true } } diff --git a/internal/service/media_library_roots.go b/internal/service/media_library_roots.go index 9aeb8e7..08ed5ad 100644 --- a/internal/service/media_library_roots.go +++ b/internal/service/media_library_roots.go @@ -32,6 +32,16 @@ func (s *MediaService) CreateLibraryWithRoots(ctx context.Context, name, kind st return nil, err } kind = inferLibraryKind(name, roots[0].Path, kind) + if existing, err := s.findLogicalLibrary(ctx, name, kind); err != nil { + return nil, err + } else if existing != nil { + lib, err := s.appendLibraryRoots(ctx, existing, roots) + if err != nil { + return nil, err + } + s.invalidateMediaCache(ctx) + return lib, nil + } lib := &model.Library{Name: strings.TrimSpace(name), Path: roots[0].Path, Type: kind, Enabled: true} if err := s.repo.Library.CreateWithRoots(ctx, lib, roots); err != nil { return nil, err @@ -40,6 +50,53 @@ func (s *MediaService) CreateLibraryWithRoots(ctx context.Context, name, kind st return lib, nil } +func (s *MediaService) findLogicalLibrary(ctx context.Context, name, kind string) (*model.Library, error) { + if s == nil || s.repo == nil || s.repo.Library == nil { + return nil, nil + } + libs, err := s.repo.Library.List(ctx) + if err != nil { + return nil, err + } + nameKey := strings.ToLower(strings.TrimSpace(name)) + typeKey := strings.ToLower(strings.TrimSpace(kind)) + for i := range libs { + if strings.ToLower(strings.TrimSpace(libs[i].Name)) == nameKey && + strings.ToLower(strings.TrimSpace(libs[i].Type)) == typeKey { + return &libs[i], nil + } + } + return nil, nil +} + +func (s *MediaService) appendLibraryRoots(ctx context.Context, lib *model.Library, roots []model.LibraryRoot) (*model.Library, error) { + if lib == nil { + return nil, errors.New("library not found") + } + if err := s.ensureLibraryRoots(ctx, lib.ID); err != nil { + return nil, err + } + existing, err := s.repo.Library.ListRoots(ctx, lib.ID) + if err != nil { + return nil, err + } + for i := range roots { + root := roots[i] + root.LibraryID = lib.ID + root.SortOrder = len(existing) + i + if err := s.ensureLibraryRootPathUnique(ctx, lib.ID, "", root.Path); err != nil { + return nil, err + } + if err := s.repo.Library.CreateRoot(ctx, &root); err != nil { + return nil, err + } + } + if err := s.syncLibraryPrimaryRoot(ctx, lib.ID); err != nil { + return nil, err + } + return s.repo.Library.FindByID(ctx, lib.ID) +} + func normalizeLibraryRootInputs(inputs []LibraryRootInput, requirePath bool) ([]model.LibraryRoot, error) { roots := make([]model.LibraryRoot, 0, len(inputs)) seen := map[string]struct{}{} @@ -51,11 +108,11 @@ func normalizeLibraryRootInputs(inputs []LibraryRootInput, requirePath bool) ([] } continue } - abs, err := resolveAccessibleLibraryPath(rawPath) + abs, err := normalizeLibraryRootPath(rawPath) if err != nil { return nil, err } - key := strings.ToLower(filepath.Clean(abs)) + key := libraryRootPathKey(abs) if _, ok := seen[key]; ok { return nil, fmt.Errorf("duplicate library path: %s", abs) } @@ -186,7 +243,7 @@ func (s *MediaService) ensureLibraryRoots(ctx context.Context, libraryID string) } root := &model.LibraryRoot{ LibraryID: libraryID, - Name: filepath.Base(filepath.Clean(lib.Path)), + Name: libraryRootNameForPath(lib.Path), Path: lib.Path, Enabled: lib.Enabled, SortOrder: 0, @@ -208,13 +265,51 @@ func (s *MediaService) ensureLibraryRootPathUnique(ctx context.Context, libraryI return err } key := strings.ToLower(filepath.Clean(strings.TrimSpace(pathValue))) + key = libraryRootPathKey(pathValue) for _, existing := range roots { if existing.ID == exceptRootID { continue } - if strings.ToLower(filepath.Clean(strings.TrimSpace(existing.Path))) == key { + if libraryRootPathKey(existing.Path) == key { return fmt.Errorf("duplicate library path: %s", pathValue) } } return nil } + +func normalizeLibraryRootPath(rawPath string) (string, error) { + rawPath = strings.TrimSpace(rawPath) + if info, ok := ParseCloudLibraryMount(rawPath); ok { + displayDir := canonicalLibraryDisplayDir(firstNonEmpty(info.DisplayDir, info.ScanDir)) + if displayDir == "" { + displayDir = firstNonEmpty(info.DisplayDir, info.ScanDir) + } + if CloudLibraryAutoCategory(model.Library{Path: rawPath}) { + return BuildCloudAutoCategoryLibraryPathWithScanDir(info.Provider, info.ScanDir, displayDir), nil + } + return BuildCloudLibraryPath(info.Provider, info.ScanDir, displayDir), nil + } + return resolveAccessibleLibraryPath(rawPath) +} + +func libraryRootPathKey(pathValue string) string { + pathValue = strings.TrimSpace(pathValue) + if info, ok := ParseCloudLibraryMount(pathValue); ok { + auto := "0" + if CloudLibraryAutoCategory(model.Library{Path: pathValue}) { + auto = "1" + } + return strings.ToLower(info.Provider + "\x00" + info.ScanDir + "\x00" + info.DisplayDir + "\x00" + auto) + } + return strings.ToLower(filepath.Clean(pathValue)) +} + +func libraryRootNameForPath(pathValue string) string { + if info, ok := ParseCloudLibraryMount(pathValue); ok { + if base := cloudMountDirBase(firstNonEmpty(info.DisplayDir, info.ScanDir)); base != "" { + return base + } + return CloudMountProviderLabel(info.Provider) + } + return filepath.Base(filepath.Clean(pathValue)) +} diff --git a/internal/service/media_library_roots_test.go b/internal/service/media_library_roots_test.go new file mode 100644 index 0000000..1395c72 --- /dev/null +++ b/internal/service/media_library_roots_test.go @@ -0,0 +1,101 @@ +package service + +import ( + "path/filepath" + "testing" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +func TestCreateLibraryWithRootsAppendsToExistingLogicalLibrary(t *testing.T) { + rootA := t.TempDir() + rootB := t.TempDir() + db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}) + repos := repository.New(db) + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) + + first, err := svc.CreateLibraryWithRoots(t.Context(), "欧美电影", "movie", []LibraryRootInput{ + {Name: "硬盘1", Path: rootA}, + }) + if err != nil { + t.Fatal(err) + } + second, err := svc.CreateLibraryWithRoots(t.Context(), "欧美电影", "movie", []LibraryRootInput{ + {Name: "硬盘2", Path: rootB}, + }) + if err != nil { + t.Fatal(err) + } + if second.ID != first.ID { + t.Fatalf("second library id = %q, want existing %q", second.ID, first.ID) + } + + var libraryCount int64 + if err := db.Model(&model.Library{}).Count(&libraryCount).Error; err != nil { + t.Fatal(err) + } + if libraryCount != 1 { + t.Fatalf("library count = %d, want one logical library", libraryCount) + } + roots, err := repos.Library.ListRoots(t.Context(), first.ID) + if err != nil { + t.Fatal(err) + } + if len(roots) != 2 { + t.Fatalf("roots = %#v, want 2", roots) + } + if roots[0].Path != filepath.Clean(rootA) || roots[1].Path != filepath.Clean(rootB) { + t.Fatalf("root paths = %#v, want %q then %q", roots, filepath.Clean(rootA), filepath.Clean(rootB)) + } +} + +func TestCreateLibraryWithRootsKeepsDifferentTypesSeparate(t *testing.T) { + rootMovie := t.TempDir() + rootTV := t.TempDir() + db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}) + repos := repository.New(db) + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) + + if _, err := svc.CreateLibraryWithRoots(t.Context(), "综合", "movie", []LibraryRootInput{{Path: rootMovie}}); err != nil { + t.Fatal(err) + } + if _, err := svc.CreateLibraryWithRoots(t.Context(), "综合", "tv", []LibraryRootInput{{Path: rootTV}}); err != nil { + t.Fatal(err) + } + var libraryCount int64 + if err := db.Model(&model.Library{}).Count(&libraryCount).Error; err != nil { + t.Fatal(err) + } + if libraryCount != 2 { + t.Fatalf("library count = %d, want separate libraries for different types", libraryCount) + } +} + +func TestCreateLibraryWithRootsAcceptsCloudRoot(t *testing.T) { + db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}) + repos := repository.New(db) + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) + + lib, err := svc.CreateLibraryWithRoots(t.Context(), "国漫", "anime", []LibraryRootInput{{ + Name: "OpenList", + Path: "cloud://openlist/动漫/国漫?dir=国漫&auto_category=1", + }}) + if err != nil { + t.Fatal(err) + } + roots, err := repos.Library.ListRoots(t.Context(), lib.ID) + if err != nil { + t.Fatal(err) + } + if len(roots) != 1 { + t.Fatalf("roots = %#v, want one cloud root", roots) + } + info, ok := ParseCloudLibraryMount(roots[0].Path) + if !ok || info.DisplayDir != "动漫/国漫" || info.ScanDir != "国漫" || !CloudLibraryAutoCategory(model.Library{Path: roots[0].Path}) { + t.Fatalf("cloud root = %#v info=%#v", roots[0], info) + } +} diff --git a/internal/service/media_series_key.go b/internal/service/media_series_key.go index f6f2c84..136483c 100644 --- a/internal/service/media_series_key.go +++ b/internal/service/media_series_key.go @@ -9,7 +9,7 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/model" ) -var episodicPathRE = regexp.MustCompile(`(?i)[\\/](?:电视剧|剧集|国产剧|欧美剧|日韩剧|日剧|韩剧|综艺|纪录片|动漫|番剧|国漫|日番|欧美动漫|欧美动画|儿童|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]`) +var episodicPathRE = regexp.MustCompile(`(?i)[\\/](?:电视剧|剧集|国产剧|欧美剧|日韩剧|日剧|韩剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]`) func mediaSeriesKey(media model.Media) string { return compactSeriesKey(mediaSeriesRawKey(media)) diff --git a/internal/service/organizer_classification_test.go b/internal/service/organizer_classification_test.go index bc745ac..522a212 100644 --- a/internal/service/organizer_classification_test.go +++ b/internal/service/organizer_classification_test.go @@ -190,7 +190,7 @@ func TestOrganizeDirectoryMovieMetadataOverridesWrongTVFolder(t *testing.T) { if err != nil { t.Fatalf("organize directory: %v", err) } - want := filepath.Join(dest, "电影", "外语电影", "杀的就是你 (2026)", "杀的就是你 (2026).mkv") + want := filepath.Join(dest, "电影", "欧美电影", "杀的就是你 (2026)", "杀的就是你 (2026).mkv") if res.Organized != 1 || res.Reclassified != 0 { t.Fatalf("result = %+v, want organized movie only; paths=%v", res, paths) } @@ -200,8 +200,8 @@ func TestOrganizeDirectoryMovieMetadataOverridesWrongTVFolder(t *testing.T) { if len(paths) == 0 || paths[0] != "/search/movie" { t.Fatalf("first metadata search path = %q, want /search/movie; all=%v", firstQuery(paths), paths) } - if len(res.Items) != 1 || res.Items[0].MediaType != "movie" || res.Items[0].Category != "外语电影" { - t.Fatalf("organize item = %#v, want movie/外语电影", res.Items) + if len(res.Items) != 1 || res.Items[0].MediaType != "movie" || res.Items[0].Category != "欧美电影" { + t.Fatalf("organize item = %#v, want movie/欧美电影", res.Items) } } @@ -238,7 +238,7 @@ func TestOrganizeDirectoryReclassifiesMovieFromDirtyGeneratedEpisodePath(t *test root := t.TempDir() dest := filepath.Join(root, "media") euusLib := model.Library{Name: "欧美剧", Path: filepath.Join(dest, "电视剧", "欧美剧"), Type: "tv", Enabled: true} - foreignMovieLib := model.Library{Name: "外语电影", Path: filepath.Join(dest, "电影", "外语电影"), Type: "movie", Enabled: true} + foreignMovieLib := model.Library{Name: "欧美电影", Path: filepath.Join(dest, "电影", "欧美电影"), Type: "movie", Enabled: true} if err := repos.Library.Create(t.Context(), &euusLib); err != nil { t.Fatal(err) } @@ -477,7 +477,7 @@ func TestOrganizeDirectoryEpisodeMarkerOverridesMovieSourceFolder(t *testing.T) root := t.TempDir() srcRoot := filepath.Join(root, "downloads") dest := filepath.Join(root, "media") - sourceFile := filepath.Join(srcRoot, "外语电影", "The.Last.of.Us.S01E01.2023.1080p.mkv") + sourceFile := filepath.Join(srcRoot, "欧美电影", "The.Last.of.Us.S01E01.2023.1080p.mkv") writeOrgFile(t, sourceFile, "episode") organizer := NewOrganizerService(cfg, zap.NewNop(), repos) diff --git a/internal/service/organizer_directory_classification_test.go b/internal/service/organizer_directory_classification_test.go index 24c702a..c4263b5 100644 --- a/internal/service/organizer_directory_classification_test.go +++ b/internal/service/organizer_directory_classification_test.go @@ -80,6 +80,46 @@ func TestOrganizeDirectoryUsesExplicitCategoryLibraryRoot(t *testing.T) { } } +func TestOrganizeDirectoryDoesNotTargetLegacyCategoryLibrary(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "downloads", "Some.Movie.2026.1080p.mkv") + dest := filepath.Join(root, "media") + writeOrgFile(t, src, "movie") + + repos := newOrganizerTestRepo(t) + legacyRoot := filepath.Join(dest, "电影", "外语电影") + currentRoot := filepath.Join(dest, "电影", "欧美电影") + legacyLib := model.Library{Name: "外语电影", Path: legacyRoot, Type: "movie", Enabled: true} + currentLib := model.Library{Name: "欧美电影", Path: currentRoot, Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &legacyLib); err != nil { + t.Fatal(err) + } + if err := repos.Library.Create(t.Context(), ¤tLib); err != nil { + t.Fatal(err) + } + + org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) + res, err := org.OrganizeDirectory(t.Context(), OrganizeOptions{ + SourcePath: src, + DestPath: dest, + MediaType: "movie", + MediaCategory: "外语电影", + TransferMode: TransferCopy, + }) + if err != nil { + t.Fatalf("organize legacy category: %v", err) + } + if res.Organized != 1 || len(res.Items) != 1 { + t.Fatalf("result = %+v, want one organized item", res) + } + if !pathWithin(res.Items[0].Target, currentRoot) { + t.Fatalf("target = %q, want current category root %q", res.Items[0].Target, currentRoot) + } + if pathWithin(res.Items[0].Target, legacyRoot) { + t.Fatalf("target must not use legacy category root %q", res.Items[0].Target) + } +} + func TestOrganizeDirectoryTreatsCategoryDestAsCollectionRoot(t *testing.T) { root := t.TempDir() src := filepath.Join(root, "downloads", "Some.Show.S01E01.2026.1080p.mkv") @@ -172,7 +212,7 @@ func TestOrganizeDirectoryCreatesMissingCategoryLibraryForVisibility(t *testing. srcRoot := filepath.Join(root, "downloads") dest := filepath.Join(root, "media") source := filepath.Join(srcRoot, "Gourd.Brothers.S01E01.2026.1080p.mkv") - target := filepath.Join(dest, "电视剧", "未分类", "Gourd Brothers", "Season 01", "Gourd Brothers - S01E01.mkv") + target := filepath.Join(dest, "电视剧", "欧美剧", "Gourd Brothers", "Season 01", "Gourd Brothers - S01E01.mkv") writeOrgFile(t, source, "source") writeOrgFile(t, target, "already-there") @@ -182,7 +222,7 @@ func TestOrganizeDirectoryCreatesMissingCategoryLibraryForVisibility(t *testing. SourcePath: srcRoot, DestPath: dest, MediaType: "tv", - MediaCategory: "未分类", + MediaCategory: "欧美剧", TransferMode: TransferCopy, AllowReplaceExisting: false, }) @@ -194,11 +234,11 @@ func TestOrganizeDirectoryCreatesMissingCategoryLibraryForVisibility(t *testing. } var lib model.Library - if err := repos.DB.Where("path = ?", filepath.Join(dest, "电视剧", "未分类")).First(&lib).Error; err != nil { + if err := repos.DB.Where("path = ?", filepath.Join(dest, "电视剧", "欧美剧")).First(&lib).Error; err != nil { t.Fatalf("missing auto-created category library: %v", err) } - if lib.Name != "未分类" || lib.Type != "tv" || !lib.Enabled { - t.Fatalf("auto-created library = %+v, want enabled tv 未分类", lib) + if lib.Name != "欧美剧" || lib.Type != "tv" || !lib.Enabled { + t.Fatalf("auto-created library = %+v, want enabled tv 欧美剧", lib) } scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) @@ -231,7 +271,7 @@ func TestOrganizeDirectoryCanDisableAutoAddLibrary(t *testing.T) { SourcePath: srcRoot, DestPath: dest, MediaType: "tv", - MediaCategory: "未分类", + MediaCategory: "欧美剧", TransferMode: TransferCopy, }) if err != nil { @@ -240,13 +280,13 @@ func TestOrganizeDirectoryCanDisableAutoAddLibrary(t *testing.T) { if res.Organized != 1 || len(res.Items) != 1 { t.Fatalf("result = %+v, want one organized item", res) } - want := filepath.Join(dest, "电视剧", "未分类", "Some Show", "Season 01", "Some Show - S01E01.mkv") + want := filepath.Join(dest, "电视剧", "欧美剧", "Some Show", "Season 01", "Some Show - S01E01.mkv") if _, err := os.Stat(want); err != nil { t.Fatalf("expected organized file at %q: %v", want, err) } var count int64 - if err := repos.DB.Model(&model.Library{}).Where("path = ?", filepath.Join(dest, "电视剧", "未分类")).Count(&count).Error; err != nil { + if err := repos.DB.Model(&model.Library{}).Where("path = ?", filepath.Join(dest, "电视剧", "欧美剧")).Count(&count).Error; err != nil { t.Fatal(err) } if count != 0 { @@ -282,9 +322,9 @@ func TestOrganizeDirectorySmartClassifiesUncategorizedSources(t *testing.T) { for _, want := range []string{ filepath.Join(dest, "电影", "华语电影", "流浪地球2 (2023)", "流浪地球2 (2023).mkv"), - filepath.Join(dest, "电影", "外语电影", "Dune (2021)", "Dune (2021).mkv"), + filepath.Join(dest, "电影", "欧美电影", "Dune (2021)", "Dune (2021).mkv"), filepath.Join(dest, "电视剧", "国产剧", "狂飙", "Season 01", "狂飙 - S01E01.mkv"), - filepath.Join(dest, "电视剧", "未分类", "The Last Of Us", "Season 01", "The Last Of Us - S01E01.mkv"), + filepath.Join(dest, "电视剧", "欧美剧", "The Last Of Us", "Season 01", "The Last Of Us - S01E01.mkv"), } { if _, err := os.Stat(want); err != nil { t.Fatalf("expected smart classified file at %q: %v; items=%+v", want, err, res.Items) diff --git a/internal/service/organizer_directory_layout.go b/internal/service/organizer_directory_layout.go index 05c61e5..e2ec183 100644 --- a/internal/service/organizer_directory_layout.go +++ b/internal/service/organizer_directory_layout.go @@ -106,24 +106,46 @@ func (o *OrganizerService) directoryCategoryTypes() map[string]organizeDirectory add(fallback, mediaType) add(categoryName(categories, key, fallback), mediaType) } + addAlias := func(alias, canonicalKey, fallback, mediaType string) { + alias = strings.TrimSpace(alias) + if alias == "" { + return + } + out[strings.ToLower(alias)] = organizeDirectoryLayout{ + MediaType: mediaType, + Category: categoryName(categories, canonicalKey, fallback), + } + } + addConfigured("concert_movie", "演唱会", "movie") + addConfigured("documentary_movie", "纪录片", "movie") addConfigured("animation_movie", "动画电影", "movie") addConfigured("chinese_movie", "华语电影", "movie") addConfigured("jk_movie", "日韩电影", "movie") addConfigured("euus_movie", "欧美电影", "movie") - addConfigured("foreign_movie", "外语电影", "movie") + addAlias("外语电影", "euus_movie", "欧美电影", "movie") + addAlias("外国电影", "euus_movie", "欧美电影", "movie") addConfigured("domestic_tv", "国产剧", "tv") addConfigured("euus_tv", "欧美剧", "tv") addConfigured("jk_tv", "日韩剧", "tv") addConfigured("cn_anime", "国漫", "anime") addConfigured("jp_anime", "日番", "anime") - addConfigured("euus_anime", "欧美动漫", "anime") + addConfigured("kr_anime", "韩漫", "anime") + addConfigured("us_anime", "美漫", "anime") + addConfigured("other_anime", "其他", "anime") + addAlias("欧美动漫", "us_anime", "美漫", "anime") + addAlias("欧美动画", "us_anime", "美漫", "anime") + addAlias("西方动画", "us_anime", "美漫", "anime") + addAlias("其他动漫", "other_anime", "其他", "anime") + addAlias("其它动漫", "other_anime", "其他", "anime") addConfigured("variety", "综艺", "variety") addConfigured("documentary", "纪录片", "tv") addConfigured("children", "儿童", "tv") - addConfigured("uncategorized_tv", "未分类", "tv") + addAlias("未分类", "euus_tv", "欧美剧", "tv") + addAlias("uncategorized", "euus_tv", "欧美剧", "tv") addConfigured("adult", "成人", "adult") - addConfigured("adult_9kg", "9KG", "adult") - addConfigured("adult_jav", "番号", "adult") + addAlias("9KG", "adult", "成人", "adult") + addAlias("番号", "adult", "成人", "adult") + addAlias("JAV", "adult", "成人", "adult") return out } diff --git a/internal/service/organizer_directory_libraries.go b/internal/service/organizer_directory_libraries.go index 418b1f4..7209cd2 100644 --- a/internal/service/organizer_directory_libraries.go +++ b/internal/service/organizer_directory_libraries.go @@ -115,7 +115,7 @@ func (o *OrganizerService) organizeLibraryMatchesExpectedPhysicalRoot(libPath, c if strings.TrimSpace(category) == "" { return true } - physicalRoot := o.categoryPhysicalRootDir(category) + physicalRoot := o.mediaTypeRootDirForCategory(mediaType, category) if physicalRoot == "" { physicalRoot = mediaTypeRootDir(mediaType) } @@ -126,7 +126,7 @@ func (o *OrganizerService) organizeLibraryMatchesExpectedPhysicalRoot(libPath, c } func (o *OrganizerService) organizeLibraryPhysicalRootScore(libPath, collectionRoot, mediaType, category string) int { - physicalRoot := o.categoryPhysicalRootDir(category) + physicalRoot := o.mediaTypeRootDirForCategory(mediaType, category) if physicalRoot == "" { physicalRoot = mediaTypeRootDir(mediaType) } diff --git a/internal/service/organizer_directory_metadata.go b/internal/service/organizer_directory_metadata.go index a883b9a..12673a5 100644 --- a/internal/service/organizer_directory_metadata.go +++ b/internal/service/organizer_directory_metadata.go @@ -43,7 +43,7 @@ func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sour SeasonNum: lookupSeason, EpisodeNum: lookupEpisode, } - for _, candidate := range scrapeQueryCandidates(media, lib) { + for _, candidate := range scrapeQueryCandidatesWithRecognition(ctx, o.repo, media, lib) { key := organizeMetadataCacheKey(lib.Type, candidate, year) if cache != nil { if cached, ok := cache[key]; ok { diff --git a/internal/service/organizer_directory_source_plan.go b/internal/service/organizer_directory_source_plan.go index dffcacd..bef8be2 100644 --- a/internal/service/organizer_directory_source_plan.go +++ b/internal/service/organizer_directory_source_plan.go @@ -56,7 +56,7 @@ func (o *OrganizerService) resolveOrganizeSourceIdentity(ctx context.Context, re src := req.Source ext := filepath.Ext(src) season, episode := ParseEpisode(src) - title, year := CleanQuery(src) + title, year := CleanQueryWithRecognition(ctx, o.repo, src) if organizeWeakFileTitle(title) { if folderTitle, folderYear := organizeTitleFromParentFolder(src, req.SourceRoot, season > 0 || episode > 0); folderTitle != "" { title = folderTitle @@ -133,7 +133,7 @@ func organizeStandaloneMovieSourceHint(src string, identity organizeSourceIdenti if identity.Season > 0 || identity.Episode > 0 { return organizeEpisodeLooksSourcedFromMovieYear(src, identity) } - _, year := CleanQuery(filepath.Base(src)) + _, year := CleanQueryWithRecognition(context.Background(), nil, filepath.Base(src)) if year <= 0 { year = identity.Year } @@ -184,9 +184,9 @@ func (o *OrganizerService) applyOrganizeSourceCategory( } else if category := o.smartClassifySourceFile(ctx, req.Source, req.SourceRoot, layout.MediaType, identity.Title, identity.ParsedTitle, metadataMatch); category != "" { layout.Category = category } - if forcedType == "" { - if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(layout.Category); impliedType != "" { - layout.Category = normalizedCategory + if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(layout.Category); impliedType != "" { + layout.Category = normalizedCategory + if forcedType == "" { if layout.MediaType == "" || layout.MediaType == "tv" || layout.MediaType == "anime" || pathLayout.Category != layout.Category { layout.MediaType = impliedType } diff --git a/internal/service/organizer_library_classification.go b/internal/service/organizer_library_classification.go index 820893a..dad421d 100644 --- a/internal/service/organizer_library_classification.go +++ b/internal/service/organizer_library_classification.go @@ -45,38 +45,41 @@ func (o *OrganizerService) organizeCategoryAliases(mediaType, category string) m } } categories := o.categoryMap() - add(category) switch normalizeOrganizeCategoryKey(category) { case normalizeOrganizeCategoryKey(categoryName(categories, "jp_anime", "日番")), "日番", "日漫", "日本动漫", "日本動畫", "日本动画": - add("日番", "日漫", "日本动漫", "日本动画") + add("日番", categoryName(categories, "jp_anime", "日番")) case normalizeOrganizeCategoryKey(categoryName(categories, "cn_anime", "国漫")), "国漫", "国产动漫", "國漫": - add("国漫", "国产动漫") - case normalizeOrganizeCategoryKey(categoryName(categories, "euus_anime", "欧美动漫")), "欧美动漫", "欧美动画", "西方动画": - add("欧美动漫", "欧美动画", "西方动画") + add("国漫", categoryName(categories, "cn_anime", "国漫")) + case normalizeOrganizeCategoryKey(categoryName(categories, "kr_anime", "韩漫")), "韩漫", "韩国动漫", "韩国动画": + add("韩漫", categoryName(categories, "kr_anime", "韩漫")) + case normalizeOrganizeCategoryKey(categoryName(categories, "us_anime", "美漫")), "美漫", "欧美动漫", "欧美动画", "西方动画": + add("美漫", categoryName(categories, "us_anime", "美漫")) + case normalizeOrganizeCategoryKey(categoryName(categories, "other_anime", "其他")), "其他", "其他动漫", "其它动漫": + add("其他", categoryName(categories, "other_anime", "其他")) case normalizeOrganizeCategoryKey(categoryName(categories, "domestic_tv", "国产剧")), "国产剧", "国剧", "大陆剧", "国产电视剧": - add("国产剧", "国剧", "大陆剧", "国产电视剧") + add("国产剧", categoryName(categories, "domestic_tv", "国产剧")) case normalizeOrganizeCategoryKey(categoryName(categories, "euus_tv", "欧美剧")), "欧美剧", "欧美电视剧": - add("欧美剧", "欧美电视剧") + add("欧美剧", categoryName(categories, "euus_tv", "欧美剧")) case normalizeOrganizeCategoryKey(categoryName(categories, "jk_tv", "日韩剧")), "日韩剧", "日剧", "韩剧": - add("日韩剧", "日剧", "韩剧") + add("日韩剧", categoryName(categories, "jk_tv", "日韩剧")) case normalizeOrganizeCategoryKey(categoryName(categories, "variety", "综艺")), "综艺", "真人秀": - add("综艺", "真人秀") + add("综艺", categoryName(categories, "variety", "综艺")) case normalizeOrganizeCategoryKey(categoryName(categories, "documentary", "纪录片")), "纪录片", "纪录": - add("纪录片", "纪录") + add("纪录片", categoryName(categories, "documentary", "纪录片")) case normalizeOrganizeCategoryKey(categoryName(categories, "children", "儿童")), "儿童", "少儿": - add("儿童", "少儿") + add("儿童", categoryName(categories, "children", "儿童")) case normalizeOrganizeCategoryKey(categoryName(categories, "chinese_movie", "华语电影")), "华语电影", "国产电影", "大陆电影": - add("华语电影", "国产电影", "大陆电影") - case normalizeOrganizeCategoryKey(categoryName(categories, "foreign_movie", "外语电影")), "外语电影": - add("外语电影") + add("华语电影", categoryName(categories, "chinese_movie", "华语电影")) + case normalizeOrganizeCategoryKey(categoryName(categories, "euus_movie", "欧美电影")), "欧美电影", "外语电影", "外国电影": + add("欧美电影", categoryName(categories, "euus_movie", "欧美电影")) + case normalizeOrganizeCategoryKey(categoryName(categories, "jk_movie", "日韩电影")), "日韩电影", "日本电影", "韩国电影": + add("日韩电影", categoryName(categories, "jk_movie", "日韩电影")) + case normalizeOrganizeCategoryKey(categoryName(categories, "concert_movie", "演唱会")), "演唱会", "音乐会": + add("演唱会", categoryName(categories, "concert_movie", "演唱会")) case normalizeOrganizeCategoryKey(categoryName(categories, "animation_movie", "动画电影")), "动画电影", "动漫电影": - add("动画电影", "动漫电影") - case normalizeOrganizeCategoryKey(categoryName(categories, "adult", "成人")), "成人": - add("成人") - case normalizeOrganizeCategoryKey(categoryName(categories, "adult_9kg", "9KG")), "9kg": - add("9KG") - case normalizeOrganizeCategoryKey(categoryName(categories, "adult_jav", "番号")), "番号", "jav": - add("番号", "JAV") + add("动画电影", categoryName(categories, "animation_movie", "动画电影")) + case normalizeOrganizeCategoryKey(categoryName(categories, "adult", "成人")), "成人", "9kg", "番号", "jav": + add("成人", categoryName(categories, "adult", "成人")) } return aliases } diff --git a/internal/service/organizer_media.go b/internal/service/organizer_media.go index 87a4ce0..ad5bb1a 100644 --- a/internal/service/organizer_media.go +++ b/internal/service/organizer_media.go @@ -83,8 +83,10 @@ func (o *OrganizerService) buildOrganizeMediaDestination(ctx context.Context, re category = o.classifyMedia(ctx, m, mediaType) } if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(category); impliedType != "" { - mediaType = impliedType category = normalizedCategory + if normalizeOrganizeMediaType(req.mediaType) == "" { + mediaType = impliedType + } } root := o.organizeRoot(req.baseRoot, mediaType, category) targetLibraryID := "" diff --git a/internal/service/organizer_paths.go b/internal/service/organizer_paths.go index 1876958..a8ffacd 100644 --- a/internal/service/organizer_paths.go +++ b/internal/service/organizer_paths.go @@ -26,12 +26,73 @@ func (o *OrganizerService) organizeRoot(libraryPath, mediaType, category string) } func (o *OrganizerService) mediaTypeRootDirForCategory(mediaType, category string) string { + if root := o.categoryPhysicalRootDirForType(mediaType, category); root != "" { + return root + } if root := o.categoryPhysicalRootDir(category); root != "" { return root } return mediaTypeRootDir(mediaType) } +func (o *OrganizerService) categoryPhysicalRootDirForType(mediaType, category string) string { + key := normalizeOrganizeCategoryKey(category) + if key == "" { + return "" + } + categories := o.categoryMap() + match := func(values ...string) bool { + for _, value := range values { + if key == normalizeOrganizeCategoryKey(value) { + return true + } + } + return false + } + switch normalizeMediaType(mediaType, "", "") { + case "movie": + if match( + categoryName(categories, "concert_movie", "演唱会"), + categoryName(categories, "documentary_movie", "纪录片"), + categoryName(categories, "animation_movie", "动画电影"), + categoryName(categories, "chinese_movie", "华语电影"), + categoryName(categories, "euus_movie", "欧美电影"), + categoryName(categories, "jk_movie", "日韩电影"), + "演唱会", "音乐会", "纪录片", "纪录", "动画电影", "动漫电影", "华语电影", "国产电影", "外语电影", "外国电影", "欧美电影", "日韩电影", + ) { + return "电影" + } + case "tv", "variety": + if match( + categoryName(categories, "domestic_tv", "国产剧"), + categoryName(categories, "euus_tv", "欧美剧"), + categoryName(categories, "jk_tv", "日韩剧"), + categoryName(categories, "variety", "综艺"), + categoryName(categories, "documentary", "纪录片"), + categoryName(categories, "children", "儿童"), + "国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "综艺", "真人秀", "纪录片", "纪录", "儿童", "少儿", "未分类", + ) { + return "电视剧" + } + case "anime": + if match( + categoryName(categories, "cn_anime", "国漫"), + categoryName(categories, "jp_anime", "日番"), + categoryName(categories, "kr_anime", "韩漫"), + categoryName(categories, "us_anime", "美漫"), + categoryName(categories, "other_anime", "其他"), + "国漫", "国产动漫", "日番", "番剧", "日漫", "日本动漫", "日本动画", "韩漫", "韩国动漫", "美漫", "欧美动漫", "欧美动画", "西方动画", "其他", "其他动漫", "其它动漫", + ) { + return "动漫" + } + case "adult": + if match(categoryName(categories, "adult", "成人"), "成人", "9kg", "番号", "jav") { + return "成人" + } + } + return "" +} + func (o *OrganizerService) categoryPhysicalRootDir(category string) string { key := normalizeOrganizeCategoryKey(category) if key == "" { @@ -50,9 +111,10 @@ func (o *OrganizerService) categoryPhysicalRootDir(category string) string { case match( categoryName(categories, "cn_anime", "国漫"), categoryName(categories, "jp_anime", "日番"), - categoryName(categories, "euus_anime", "欧美动漫"), - categoryName(categories, "children", "儿童"), - "国漫", "国产动漫", "日番", "番剧", "日漫", "日本动漫", "日本动画", "欧美动漫", "欧美动画", "西方动画", "儿童", "少儿", + categoryName(categories, "kr_anime", "韩漫"), + categoryName(categories, "us_anime", "美漫"), + categoryName(categories, "other_anime", "其他"), + "国漫", "国产动漫", "日番", "番剧", "日漫", "日本动漫", "日本动画", "韩漫", "韩国动漫", "美漫", "欧美动漫", "欧美动画", "西方动画", "其他", "其他动漫", "其它动漫", ): return "动漫" case match( @@ -61,20 +123,21 @@ func (o *OrganizerService) categoryPhysicalRootDir(category string) string { categoryName(categories, "jk_tv", "日韩剧"), categoryName(categories, "variety", "综艺"), categoryName(categories, "documentary", "纪录片"), - categoryName(categories, "uncategorized_tv", "未分类"), - "国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "综艺", "真人秀", "纪录片", "纪录", "未分类", + categoryName(categories, "children", "儿童"), + "国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "综艺", "真人秀", "纪录片", "纪录", "儿童", "少儿", "未分类", ): return "电视剧" case match( + categoryName(categories, "concert_movie", "演唱会"), + categoryName(categories, "documentary_movie", "纪录片"), categoryName(categories, "animation_movie", "动画电影"), categoryName(categories, "chinese_movie", "华语电影"), - categoryName(categories, "foreign_movie", "外语电影"), categoryName(categories, "euus_movie", "欧美电影"), categoryName(categories, "jk_movie", "日韩电影"), - "动画电影", "动漫电影", "华语电影", "国产电影", "外语电影", "欧美电影", "日韩电影", + "演唱会", "音乐会", "动画电影", "动漫电影", "华语电影", "国产电影", "外语电影", "外国电影", "欧美电影", "日韩电影", ): return "电影" - case match(categoryName(categories, "adult", "成人"), categoryName(categories, "adult_9kg", "9KG"), categoryName(categories, "adult_jav", "番号"), "成人", "9kg", "番号", "jav"): + case match(categoryName(categories, "adult", "成人"), "成人", "9kg", "番号", "jav"): return "成人" default: return "" diff --git a/internal/service/organizer_reclassify_anime_test.go b/internal/service/organizer_reclassify_anime_test.go index 7d71c2c..c3414fc 100644 --- a/internal/service/organizer_reclassify_anime_test.go +++ b/internal/service/organizer_reclassify_anime_test.go @@ -194,7 +194,7 @@ func TestReclassifyMisclassifiedMediaMovesWesternAnimationToWesternAnimeLibrary( root := t.TempDir() dest := filepath.Join(root, "media") jpAnimeLib := model.Library{Name: "日番", Path: filepath.Join(dest, "动漫", "日番"), Type: "anime", Enabled: true} - westernAnimeLib := model.Library{Name: "欧美动漫", Path: filepath.Join(dest, "动漫", "欧美动漫"), Type: "anime", Enabled: true} + westernAnimeLib := model.Library{Name: "美漫", Path: filepath.Join(dest, "动漫", "美漫"), Type: "anime", Enabled: true} for _, lib := range []*model.Library{&jpAnimeLib, &westernAnimeLib} { if err := repos.Library.Create(t.Context(), lib); err != nil { t.Fatal(err) diff --git a/internal/service/organizer_reclassify_test.go b/internal/service/organizer_reclassify_test.go index c87fff4..afff23c 100644 --- a/internal/service/organizer_reclassify_test.go +++ b/internal/service/organizer_reclassify_test.go @@ -271,7 +271,7 @@ func TestReclassifyMisclassifiedMediaHonorsManualMovieHint(t *testing.T) { root := t.TempDir() dest := filepath.Join(root, "media") euusLib := model.Library{Name: "欧美剧", Path: filepath.Join(dest, "电视剧", "欧美剧"), Type: "tv", Enabled: true} - foreignMovieLib := model.Library{Name: "外语电影", Path: filepath.Join(dest, "电影", "外语电影"), Type: "movie", Enabled: true} + foreignMovieLib := model.Library{Name: "欧美电影", Path: filepath.Join(dest, "电影", "欧美电影"), Type: "movie", Enabled: true} if err := repos.Library.Create(t.Context(), &euusLib); err != nil { t.Fatal(err) } diff --git a/internal/service/recognition_words.go b/internal/service/recognition_words.go new file mode 100644 index 0000000..93c4949 --- /dev/null +++ b/internal/service/recognition_words.go @@ -0,0 +1,197 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +const ( + RecognitionWordsEnabledKey = "recognition_words.enabled" + RecognitionWordsLocalTextKey = "recognition_words.local_text" + RecognitionWordsSharedURLsKey = "recognition_words.shared_urls" + RecognitionWordsSharedTextKey = "recognition_words.shared_text" + RecognitionWordsSyncedAtKey = "recognition_words.synced_at" +) + +var DefaultRecognitionWordURLs = []string{ + "https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/general.txt", + "https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/TV.txt", + "https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/anime.txt", +} + +type RecognitionWordsService struct { + log *zap.Logger + repo *repository.Container + client *http.Client +} + +type RecognitionWordsConfig struct { + Enabled bool `json:"enabled"` + LocalText string `json:"local_text"` + SharedURLs []string `json:"shared_urls"` + SharedText string `json:"shared_text,omitempty"` + SyncedAt string `json:"synced_at,omitempty"` + RuleCount int `json:"rule_count"` +} + +type RecognitionWordsTestResult struct { + Input string `json:"input"` + Output string `json:"output"` + Title string `json:"title"` + Year int `json:"year"` + Changed bool `json:"changed"` +} + +func NewRecognitionWordsService(log *zap.Logger, repo *repository.Container) *RecognitionWordsService { + return &RecognitionWordsService{ + log: log, + repo: repo, + client: recognitionWordHTTPClient(), + } +} + +func (s *RecognitionWordsService) Config(ctx context.Context) RecognitionWordsConfig { + cfg := recognitionWordsConfig(ctx, s.repo) + cfg.RuleCount = len(parseRecognitionWordRules(recognitionWordsCombinedText(cfg))) + return cfg +} + +func (s *RecognitionWordsService) SaveConfig(ctx context.Context, cfg RecognitionWordsConfig) error { + if s == nil || s.repo == nil || s.repo.Setting == nil { + return fmt.Errorf("setting repository unavailable") + } + if err := s.repo.Setting.Set(ctx, RecognitionWordsEnabledKey, strconv.FormatBool(cfg.Enabled)); err != nil { + return err + } + if err := s.repo.Setting.Set(ctx, RecognitionWordsLocalTextKey, cfg.LocalText); err != nil { + return err + } + rawURLs, err := json.Marshal(normalizeRecognitionWordURLs(cfg.SharedURLs)) + if err != nil { + return err + } + return s.repo.Setting.Set(ctx, RecognitionWordsSharedURLsKey, string(rawURLs)) +} + +func (s *RecognitionWordsService) SyncShared(ctx context.Context) (RecognitionWordsConfig, error) { + cfg := s.Config(ctx) + urls := cfg.SharedURLs + if len(urls) == 0 { + urls = DefaultRecognitionWordURLs + } + var combined []string + for _, rawURL := range urls { + text, err := s.fetchSharedWords(ctx, rawURL) + if err != nil { + return cfg, err + } + combined = append(combined, "# "+rawURL, text) + } + now := time.Now().Format(time.RFC3339) + if err := s.repo.Setting.Set(ctx, RecognitionWordsSharedTextKey, strings.Join(combined, "\n")); err != nil { + return cfg, err + } + if err := s.repo.Setting.Set(ctx, RecognitionWordsSyncedAtKey, now); err != nil { + return cfg, err + } + return s.Config(ctx), nil +} + +func (s *RecognitionWordsService) Test(ctx context.Context, input string) RecognitionWordsTestResult { + output := ApplyRecognitionWords(ctx, s.repo, input) + title, year := CleanQuery(output) + return RecognitionWordsTestResult{ + Input: input, + Output: output, + Title: title, + Year: year, + Changed: strings.TrimSpace(input) != strings.TrimSpace(output), + } +} + +func ApplyRecognitionWords(ctx context.Context, repo *repository.Container, raw string) string { + cfg := recognitionWordsConfig(ctx, repo) + if !cfg.Enabled { + return raw + } + rules := parseRecognitionWordRules(recognitionWordsCombinedText(cfg)) + return applyRecognitionWordRules(raw, rules) +} + +func CleanQueryWithRecognition(ctx context.Context, repo *repository.Container, raw string) (string, int) { + return CleanQuery(ApplyRecognitionWords(ctx, repo, raw)) +} + +func recognitionWordsConfig(ctx context.Context, repo *repository.Container) RecognitionWordsConfig { + cfg := RecognitionWordsConfig{Enabled: true, SharedURLs: DefaultRecognitionWordURLs} + if repo == nil || repo.DB == nil || repo.Setting == nil || !repo.DB.Migrator().HasTable(&model.Setting{}) { + return cfg + } + if value, err := repo.Setting.Get(ctx, RecognitionWordsEnabledKey); err == nil && strings.TrimSpace(value) != "" { + cfg.Enabled = parseBoolSetting(value, true) + } + if value, err := repo.Setting.Get(ctx, RecognitionWordsLocalTextKey); err == nil { + cfg.LocalText = value + } + if value, err := repo.Setting.Get(ctx, RecognitionWordsSharedURLsKey); err == nil && strings.TrimSpace(value) != "" { + cfg.SharedURLs = parseRecognitionWordURLs(value) + } + if value, err := repo.Setting.Get(ctx, RecognitionWordsSharedTextKey); err == nil { + cfg.SharedText = value + } + if value, err := repo.Setting.Get(ctx, RecognitionWordsSyncedAtKey); err == nil { + cfg.SyncedAt = value + } + return cfg +} + +func recognitionWordsCombinedText(cfg RecognitionWordsConfig) string { + return strings.TrimSpace(cfg.LocalText + "\n" + cfg.SharedText) +} + +func parseRecognitionWordURLs(raw string) []string { + var values []string + if err := json.Unmarshal([]byte(raw), &values); err == nil { + return normalizeRecognitionWordURLs(values) + } + return normalizeRecognitionWordURLs(strings.FieldsFunc(raw, func(r rune) bool { + return r == '\n' || r == '\r' || r == ',' || r == ';' + })) +} + +func normalizeRecognitionWordURLs(values []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + +type recognitionWordRule struct { + raw string + block string + replaceFrom string + replaceTo string + offsetLeft string + offsetRight string + offsetExpr string +} diff --git a/internal/service/recognition_words_rules.go b/internal/service/recognition_words_rules.go new file mode 100644 index 0000000..d8a95cc --- /dev/null +++ b/internal/service/recognition_words_rules.go @@ -0,0 +1,155 @@ +package service + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +func parseRecognitionWordRules(raw string) []recognitionWordRule { + var out []recognitionWordRule + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") { + continue + } + out = append(out, parseRecognitionWordRule(line)) + } + return out +} + +func parseRecognitionWordRule(line string) recognitionWordRule { + rule := recognitionWordRule{raw: line} + for _, part := range strings.Split(line, "&&") { + part = strings.TrimSpace(part) + switch { + case strings.Contains(part, "=>"): + pieces := strings.SplitN(part, "=>", 2) + rule.replaceFrom = strings.TrimSpace(pieces[0]) + rule.replaceTo = normalizeRecognitionReplacement(strings.TrimSpace(pieces[1])) + case strings.Contains(part, "<>") && strings.Contains(part, ">>"): + beforeAfter := strings.SplitN(part, ">>", 2) + bounds := strings.SplitN(beforeAfter[0], "<>", 2) + rule.offsetLeft = strings.TrimSpace(bounds[0]) + rule.offsetRight = strings.TrimSpace(bounds[1]) + rule.offsetExpr = strings.TrimSpace(beforeAfter[1]) + default: + rule.block = part + } + } + return rule +} + +func normalizeRecognitionReplacement(value string) string { + re := regexp.MustCompile(`\\([0-9]+)`) + return re.ReplaceAllString(value, "$$$1") +} + +func applyRecognitionWordRules(raw string, rules []recognitionWordRule) string { + out := strings.TrimSpace(raw) + for _, rule := range rules { + if rule.block != "" { + out = applyRecognitionBlock(out, rule.block) + } + if rule.replaceFrom != "" { + out = applyRecognitionReplace(out, rule.replaceFrom, rule.replaceTo) + } + if rule.offsetLeft != "" || rule.offsetRight != "" { + out = applyRecognitionOffset(out, rule.offsetLeft, rule.offsetRight, rule.offsetExpr) + } + } + return strings.Join(strings.Fields(out), " ") +} + +func applyRecognitionBlock(raw, block string) string { + if re, err := regexp.Compile(block); err == nil { + return re.ReplaceAllString(raw, " ") + } + return strings.ReplaceAll(raw, block, " ") +} + +func applyRecognitionReplace(raw, from, to string) string { + if re, err := regexp.Compile(from); err == nil { + return re.ReplaceAllString(raw, to) + } + return strings.ReplaceAll(raw, from, to) +} + +func applyRecognitionOffset(raw, left, right, expr string) string { + if strings.TrimSpace(expr) == "" { + return raw + } + leftPattern := firstNonEmpty(left, `^`) + rightPattern := firstNonEmpty(right, `$`) + re, err := regexp.Compile(`(?i)(` + leftPattern + `)(\d{1,5})(` + rightPattern + `)`) + if err != nil { + return raw + } + return re.ReplaceAllStringFunc(raw, func(match string) string { + return applyRecognitionOffsetMatch(re, match, expr) + }) +} + +func applyRecognitionOffsetMatch(re *regexp.Regexp, match, expr string) string { + parts := re.FindStringSubmatch(match) + if len(parts) < 4 { + return match + } + ep, err := strconv.Atoi(parts[2]) + if err != nil { + return match + } + next, ok := evalRecognitionEpisodeExpr(expr, ep) + if !ok || next < 0 { + return match + } + format := "%d" + if width := len(parts[2]); width > 1 && width <= 2 { + format = "%0" + strconv.Itoa(width) + "d" + } + return parts[1] + fmt.Sprintf(format, next) + parts[3] +} + +func evalRecognitionEpisodeExpr(expr string, ep int) (int, bool) { + value := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(expr), " ", "")) + if value == "" || value == "EP" { + return ep, true + } + if n, err := strconv.Atoi(value); err == nil { + return n, true + } + if out, ok := evalRecognitionEpisodeAddSub(value, ep); ok { + return out, true + } + return evalRecognitionEpisodeMul(value, ep) +} + +func evalRecognitionEpisodeAddSub(value string, ep int) (int, bool) { + for _, op := range []string{"+", "-"} { + if !strings.HasPrefix(value, "EP"+op) { + continue + } + n, err := strconv.Atoi(strings.TrimPrefix(value, "EP"+op)) + if err != nil { + return 0, false + } + if op == "+" { + return ep + n, true + } + return ep - n, true + } + return 0, false +} + +func evalRecognitionEpisodeMul(value string, ep int) (int, bool) { + if strings.HasPrefix(value, "EP*") { + n, err := strconv.Atoi(strings.TrimPrefix(value, "EP*")) + return ep * n, err == nil + } + if strings.HasSuffix(value, "*EP") { + n, err := strconv.Atoi(strings.TrimSuffix(value, "*EP")) + return ep * n, err == nil + } + return 0, false +} diff --git a/internal/service/recognition_words_shared.go b/internal/service/recognition_words_shared.go new file mode 100644 index 0000000..ba5167f --- /dev/null +++ b/internal/service/recognition_words_shared.go @@ -0,0 +1,139 @@ +package service + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" +) + +func recognitionWordHTTPClient() *http.Client { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.DialContext = dialRecognitionWordContext + return &http.Client{ + Timeout: 20 * time.Second, + Transport: transport, + CheckRedirect: func(req *http.Request, _ []*http.Request) error { + return validateRecognitionWordURL(req.Context(), req.URL.String()) + }, + } +} + +func dialRecognitionWordContext(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + if isLocalHostname(host) { + return nil, fmt.Errorf("recognition word URL host is not allowed: %s", host) + } + dialer := &net.Dialer{Timeout: 20 * time.Second} + if ip := net.ParseIP(host); ip != nil { + if err := validateRecognitionWordIP(host, ip); err != nil { + return nil, err + } + return dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) + } + addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, fmt.Errorf("resolve recognition word URL host %s: %w", host, err) + } + if len(addrs) == 0 { + return nil, fmt.Errorf("resolve recognition word URL host %s: no addresses", host) + } + for _, addr := range addrs { + if err := validateRecognitionWordIP(host, addr.IP); err != nil { + return nil, err + } + } + var lastErr error + for _, addr := range addrs { + conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(addr.IP.String(), port)) + if err == nil { + return conn, nil + } + lastErr = err + } + return nil, lastErr +} + +func (s *RecognitionWordsService) fetchSharedWords(ctx context.Context, rawURL string) (string, error) { + rawURL = strings.TrimSpace(rawURL) + if err := validateRecognitionWordURL(ctx, rawURL); err != nil { + return "", err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return "", err + } + resp, err := s.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("fetch %s failed: %s", rawURL, resp.Status) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return "", err + } + return string(body), nil +} + +func validateRecognitionWordURL(ctx context.Context, rawURL string) error { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return fmt.Errorf("invalid recognition word URL %q: %w", rawURL, err) + } + if parsed.User != nil { + return fmt.Errorf("recognition word URL must not include userinfo: %s", rawURL) + } + switch strings.ToLower(parsed.Scheme) { + case "http", "https": + default: + return fmt.Errorf("recognition word URL must use http or https: %s", rawURL) + } + host := strings.TrimSpace(parsed.Hostname()) + if host == "" { + return fmt.Errorf("recognition word URL host is required: %s", rawURL) + } + if isLocalHostname(host) { + return fmt.Errorf("recognition word URL host is not allowed: %s", host) + } + if ip := net.ParseIP(host); ip != nil { + return validateRecognitionWordIP(host, ip) + } + addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return fmt.Errorf("resolve recognition word URL host %s: %w", host, err) + } + if len(addrs) == 0 { + return fmt.Errorf("resolve recognition word URL host %s: no addresses", host) + } + for _, addr := range addrs { + if err := validateRecognitionWordIP(host, addr.IP); err != nil { + return err + } + } + return nil +} + +func isLocalHostname(host string) bool { + host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".") + return host == "localhost" || host == "localhost.localdomain" +} + +func validateRecognitionWordIP(host string, ip net.IP) error { + if ip == nil { + return fmt.Errorf("recognition word URL host %s resolved to an invalid address", host) + } + if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() { + return fmt.Errorf("recognition word URL host %s resolved to a restricted address: %s", host, ip.String()) + } + return nil +} diff --git a/internal/service/recognition_words_test.go b/internal/service/recognition_words_test.go new file mode 100644 index 0000000..04ffe40 --- /dev/null +++ b/internal/service/recognition_words_test.go @@ -0,0 +1,60 @@ +package service + +import ( + "strings" + "testing" +) + +func TestApplyRecognitionWordRules(t *testing.T) { + rules := parseRecognitionWordRules(` +BADWORD +Wrong.Title => 正确标题 +One\.Piece\.S01E(89[2-9]|9\d{2}|10\d{2})\.1999 => 海贼王.S21E\1.1999 && S21E <> \. >> EP-892 +`) + got := applyRecognitionWordRules("BADWORD Wrong.Title One.Piece.S01E1076.1999.1080p", rules) + want := "正确标题 海贼王.S21E184.1999.1080p" + if got != want { + t.Fatalf("recognized = %q, want %q", got, want) + } +} + +func TestCleanQueryWithRecognitionDisabledByDefaultRepoNil(t *testing.T) { + title, year := CleanQueryWithRecognition(t.Context(), nil, "Dune.2021.2160p.WEB-DL.mkv") + if title != "dune" || year != 2021 { + t.Fatalf("CleanQueryWithRecognition = %q/%d, want dune/2021", title, year) + } +} + +func TestValidateRecognitionWordURLRejectsUnsafeTargets(t *testing.T) { + tests := []string{ + "file:///etc/passwd", + "https://user:pass@example.com/words.txt", + "http://localhost/words.txt", + "http://127.0.0.1/words.txt", + "http://10.0.0.1/words.txt", + "http://172.16.0.1/words.txt", + "http://192.168.1.1/words.txt", + "http://[::1]/words.txt", + } + for _, rawURL := range tests { + if err := validateRecognitionWordURL(t.Context(), rawURL); err == nil { + t.Fatalf("validateRecognitionWordURL(%q) succeeded, want rejection", rawURL) + } + } +} + +func TestValidateRecognitionWordURLAllowsPublicHTTPTargets(t *testing.T) { + if err := validateRecognitionWordURL(t.Context(), "https://1.1.1.1/words.txt"); err != nil { + t.Fatalf("public IP should be allowed: %v", err) + } + if err := validateRecognitionWordURL(t.Context(), "http://8.8.8.8/words.txt"); err != nil { + t.Fatalf("public HTTP IP should be allowed: %v", err) + } +} + +func TestRecognitionWordDialRejectsUnsafeTargets(t *testing.T) { + _, err := dialRecognitionWordContext(t.Context(), "tcp", "127.0.0.1:80") + if err == nil || !strings.Contains(err.Error(), "restricted address") { + t.Fatalf("dialRecognitionWordContext err = %v, want restricted address", err) + } +} diff --git a/internal/service/scanner_cloud_autocategory_test.go b/internal/service/scanner_cloud_autocategory_test.go index 4f7c300..67d4fac 100644 --- a/internal/service/scanner_cloud_autocategory_test.go +++ b/internal/service/scanner_cloud_autocategory_test.go @@ -120,7 +120,7 @@ func TestScanRootCloudLibraryCreatesAutoCategoryLibraries(t *testing.T) { wantLibraries := map[string]string{ "cloud://openlist/电视剧/欧美剧/The Show/The.Show.S01E01.mkv": byDisplayDir["电视剧/欧美剧"].ID, "cloud://openlist/电影/华语电影/Movie.2024.mkv": byDisplayDir["电影/华语电影"].ID, - "cloud://openlist/国漫/剑来/剑来.S01E01.mkv": byDisplayDir["动漫/国漫"].ID, + "cloud://openlist/动漫/国漫/剑来/剑来.S01E01.mkv": byDisplayDir["动漫/国漫"].ID, } for _, row := range rows { if row.LibraryID != wantLibraries[row.Path] { @@ -162,6 +162,203 @@ func TestScanRootCloudLibraryCreatesAutoCategoryLibraries(t *testing.T) { } } +func TestScanRootCloudAutoCategoryAppendsExistingLibraryRoot(t *testing.T) { + upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { + switch path { + case "/": + return []openListTestEntry{{Name: "电影", IsDir: true}}, 1 + case "/电影": + return []openListTestEntry{{Name: "华语电影", IsDir: true}}, 1 + case "/电影/华语电影": + return []openListTestEntry{{Name: "Movie.2024.mkv", Size: 202}}, 1 + default: + t.Fatalf("unexpected openlist path %q", path) + return nil, 0 + } + }) + defer upstream.Close() + + db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) + repos := repository.New(db) + storage := newOpenListStorageForTest(t, repos, upstream.URL) + local := model.Library{Name: "华语电影", Path: "/media/电影/华语电影", Type: "movie", Enabled: true} + if err := repos.Library.CreateWithRoots(t.Context(), &local, []model.LibraryRoot{{ + Name: "华语电影", + Path: local.Path, + Enabled: true, + }}); err != nil { + t.Fatal(err) + } + root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &root); err != nil { + t.Fatal(err) + } + scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) + scanner.SetStorageConfig(storage) + + res, err := scanner.ScanLibrary(t.Context(), root.ID) + if err != nil { + t.Fatalf("scan root cloud: %v", err) + } + if res.Added != 1 { + t.Fatalf("added = %d, want 1", res.Added) + } + libs, err := repos.Library.List(t.Context()) + if err != nil { + t.Fatal(err) + } + for _, lib := range libs { + if CloudLibraryAutoCategory(lib) { + t.Fatalf("auto category should append to existing library, got extra library %#v", lib) + } + } + roots, err := repos.Library.ListRoots(t.Context(), local.ID) + if err != nil { + t.Fatal(err) + } + if len(roots) != 2 { + t.Fatalf("roots = %#v, want local root plus cloud root", roots) + } + cloudRoot := roots[1] + if cloudRoot.Name != "华语电影" || !CloudLibraryAutoCategory(model.Library{Path: cloudRoot.Path}) { + t.Fatalf("cloud root = %#v, want auto-category 华语电影 root", cloudRoot) + } + info, ok := ParseCloudLibraryMount(cloudRoot.Path) + if !ok || info.DisplayDir != "电影/华语电影" || info.ScanDir != "电影/华语电影" { + t.Fatalf("cloud root mount = %#v, want display/scan 电影/华语电影", info) + } + var media model.Media + if err := repos.DB.First(&media, "path = ?", "cloud://openlist/电影/华语电影/Movie.2024.mkv").Error; err != nil { + t.Fatal(err) + } + if media.LibraryID != local.ID || media.LibraryRootID != cloudRoot.ID { + t.Fatalf("media placement = library %s root %s, want %s/%s", media.LibraryID, media.LibraryRootID, local.ID, cloudRoot.ID) + } +} + +func TestScanRootCloudAutoCategoryPreservesFlatScanDir(t *testing.T) { + upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { + switch path { + case "/": + return []openListTestEntry{{Name: "国漫", IsDir: true}}, 1 + case "/国漫": + return []openListTestEntry{{Name: "剑来", IsDir: true}}, 1 + case "/国漫/剑来": + return []openListTestEntry{{Name: "剑来.S01E01.mkv", Size: 303}}, 1 + default: + t.Fatalf("unexpected openlist path %q", path) + return nil, 0 + } + }) + defer upstream.Close() + + db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) + repos := repository.New(db) + storage := newOpenListStorageForTest(t, repos, upstream.URL) + local := model.Library{Name: "国漫", Path: "/media/动漫/国漫", Type: "anime", Enabled: true} + if err := repos.Library.CreateWithRoots(t.Context(), &local, []model.LibraryRoot{{ + Name: "国漫", + Path: local.Path, + Enabled: true, + }}); err != nil { + t.Fatal(err) + } + root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &root); err != nil { + t.Fatal(err) + } + scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) + scanner.SetStorageConfig(storage) + + if _, err := scanner.ScanLibrary(t.Context(), root.ID); err != nil { + t.Fatalf("scan root cloud: %v", err) + } + roots, err := repos.Library.ListRoots(t.Context(), local.ID) + if err != nil { + t.Fatal(err) + } + if len(roots) != 2 { + t.Fatalf("roots = %#v, want local root plus flat cloud root", roots) + } + cloudRoot := roots[1] + info, ok := ParseCloudLibraryMount(cloudRoot.Path) + if !ok || info.DisplayDir != "动漫/国漫" || info.ScanDir != "国漫" { + t.Fatalf("flat cloud root mount = %#v, want display 动漫/国漫 and scan 国漫", info) + } + res, err := scanner.ScanLibraryRoot(t.Context(), local.ID, cloudRoot.ID) + if err != nil { + t.Fatalf("scan flat cloud root: %v", err) + } + if res.Skipped != 1 && res.Updated != 1 { + t.Fatalf("flat cloud root rescan = %#v, want existing media refreshed/skipped", res) + } +} + +func TestScanRootCloudAutoCategoryMigratesExistingAutoLibrary(t *testing.T) { + upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { + switch path { + case "/": + return []openListTestEntry{{Name: "电视剧", IsDir: true}}, 1 + case "/电视剧": + return []openListTestEntry{{Name: "欧美剧", IsDir: true}}, 1 + case "/电视剧/欧美剧": + return []openListTestEntry{{Name: "The Show", IsDir: true}}, 1 + case "/电视剧/欧美剧/The Show": + return []openListTestEntry{{Name: "The.Show.S01E01.mkv", Size: 101}}, 1 + default: + t.Fatalf("unexpected openlist path %q", path) + return nil, 0 + } + }) + defer upstream.Close() + + db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) + repos := repository.New(db) + storage := newOpenListStorageForTest(t, repos, upstream.URL) + local := model.Library{Name: "欧美剧", Path: "/media/电视剧/欧美剧", Type: "tv", Enabled: true} + if err := repos.Library.CreateWithRoots(t.Context(), &local, []model.LibraryRoot{{ + Name: "欧美剧", + Path: local.Path, + Enabled: true, + }}); err != nil { + t.Fatal(err) + } + root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} + oldAuto := model.Library{Name: "欧美剧", Path: BuildCloudAutoCategoryLibraryPath("openlist", "电视剧/欧美剧"), Type: "tv", Enabled: true} + for _, lib := range []*model.Library{&root, &oldAuto} { + if err := repos.Library.Create(t.Context(), lib); err != nil { + t.Fatal(err) + } + } + mediaPath := "cloud://openlist/电视剧/欧美剧/The Show/The.Show.S01E01.mkv" + if err := repos.DB.Create(&model.Media{LibraryID: oldAuto.ID, Title: "The Show", Path: mediaPath}).Error; err != nil { + t.Fatal(err) + } + scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) + scanner.SetStorageConfig(storage) + + if _, err := scanner.ScanLibrary(t.Context(), root.ID); err != nil { + t.Fatalf("scan root cloud: %v", err) + } + if old, err := repos.Library.FindByID(t.Context(), oldAuto.ID); err != nil || old != nil { + t.Fatalf("old auto library = %#v, err=%v; want removed", old, err) + } + roots, err := repos.Library.ListRoots(t.Context(), local.ID) + if err != nil { + t.Fatal(err) + } + if len(roots) != 2 { + t.Fatalf("roots = %#v, want local root plus migrated cloud root", roots) + } + var media model.Media + if err := repos.DB.First(&media, "path = ?", mediaPath).Error; err != nil { + t.Fatal(err) + } + if media.LibraryID != local.ID || media.LibraryRootID != roots[1].ID { + t.Fatalf("migrated media placement = %s/%s, want %s/%s", media.LibraryID, media.LibraryRootID, local.ID, roots[1].ID) + } +} + func TestScanCloudLibraryListsChildDirectoriesConcurrently(t *testing.T) { var active int32 var maxActive int32 @@ -242,3 +439,19 @@ func TestScanCloudLibraryListsChildDirectoriesConcurrently(t *testing.T) { t.Fatalf("scan result = %#v, want visited=2 added=2", res) } } + +func newOpenListStorageForTest(t *testing.T, repos *repository.Container, serverURL string) *StorageConfigService { + t.Helper() + log := zap.NewNop() + storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) + if _, err := storage.Save(t.Context(), StorageInput{ + Type: "openlist", + Config: map[string]any{ + "server": serverURL, + "token": "openlist-token", + }, + }); err != nil { + t.Fatal(err) + } + return storage +} diff --git a/internal/service/scanner_cloud_candidates.go b/internal/service/scanner_cloud_candidates.go index d804888..21b32e1 100644 --- a/internal/service/scanner_cloud_candidates.go +++ b/internal/service/scanner_cloud_candidates.go @@ -171,24 +171,45 @@ func (c *cloudScanCandidateCollector) addFileCandidate(displayDir string, entry c.req.progress.publish(c.scanner, c.lib.ID, c.req.result, "listing", c.req.progress.markFileDiscovered()) displayPath := joinCloudDisplayPath(displayDir, entry.Name) path := cloudMediaPath(c.req.provider, displayPath) - localMeta := c.scanner.cloudFileMetadata(c.ctx, c.req.provider, displayPath, entry.Name, sidecars, dirMeta, librarySupportsSeasons(c.lib)) - localMeta = c.scanner.enrichCloudMetadataFromExternalIDs(c.ctx, c.lib, path, localMeta) - if localMeta != nil { - c.scanner.cacheCloudMetadataArtworkNow(c.ctx, localMeta) - } candidate := cloudCandidate{ ref: ref, name: entry.Name, size: entry.Size, path: path, - localMeta: localMeta, } if c.req.autoCategoryRoot { - candidate.categoryDisplayDir = cloudAutoCategoryDisplayDirForMediaPath(path) + candidate.categoryDisplayDir, candidate.categoryScanDir = cloudAutoCategoryDirsForMediaPath(path) + if candidate.categoryDisplayDir != "" { + displayPath = canonicalCloudAutoCategoryMediaDisplayPath(displayPath, candidate.categoryDisplayDir, candidate.categoryScanDir) + candidate.path = cloudMediaPath(c.req.provider, displayPath) + } } + localMeta := c.scanner.cloudFileMetadata(c.ctx, c.req.provider, displayPath, entry.Name, sidecars, dirMeta, librarySupportsSeasons(c.lib)) + localMeta = c.scanner.enrichCloudMetadataFromExternalIDs(c.ctx, c.lib, candidate.path, localMeta) + if localMeta != nil { + c.scanner.cacheCloudMetadataArtworkNow(c.ctx, localMeta) + } + candidate.localMeta = localMeta c.addCandidate(displayDir, entry, candidate) } +func canonicalCloudAutoCategoryMediaDisplayPath(displayPath, categoryDisplayDir, categoryScanDir string) string { + displayPath = strings.Trim(strings.TrimSpace(strings.ReplaceAll(displayPath, "\\", "/")), "/") + categoryDisplayDir = strings.Trim(strings.TrimSpace(strings.ReplaceAll(categoryDisplayDir, "\\", "/")), "/") + categoryScanDir = strings.Trim(strings.TrimSpace(strings.ReplaceAll(categoryScanDir, "\\", "/")), "/") + if displayPath == "" || categoryDisplayDir == "" || categoryScanDir == "" || displayPath == categoryDisplayDir || categoryDisplayDir == categoryScanDir { + return displayPath + } + if displayPath == categoryScanDir { + return categoryDisplayDir + } + prefix := strings.TrimRight(categoryScanDir, "/") + "/" + if strings.HasPrefix(displayPath, prefix) { + return strings.TrimRight(categoryDisplayDir, "/") + "/" + strings.TrimPrefix(displayPath, prefix) + } + return displayPath +} + func (c *cloudScanCandidateCollector) markRefSeen(ref string) bool { c.mu.Lock() defer c.mu.Unlock() diff --git a/internal/service/scanner_cloud_ingest.go b/internal/service/scanner_cloud_ingest.go index 1a0bc83..29f0191 100644 --- a/internal/service/scanner_cloud_ingest.go +++ b/internal/service/scanner_cloud_ingest.go @@ -10,10 +10,10 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/model" ) -func (s *ScannerService) ingestCloudFile(ctx context.Context, lib *model.Library, typ, ref, path, name string, size int64, localMeta *LocalMetadata, existingMedia map[string]existingCloudMedia, writeBatch *localMediaWriteBatch, probeBudget *int, res *ScanResult) { +func (s *ScannerService) ingestCloudFile(ctx context.Context, lib *model.Library, rootID, typ, ref, path, name string, size int64, localMeta *LocalMetadata, existingMedia map[string]existingCloudMedia, writeBatch *localMediaWriteBatch, probeBudget *int, res *ScanResult) { res.Visited++ ext := strings.ToLower(filepath.Ext(name)) - title, year := CleanQuery(name) + title, year := CleanQueryWithRecognition(ctx, s.repo, name) if title == "" { title = strings.TrimSuffix(filepath.Base(name), ext) } @@ -31,16 +31,17 @@ func (s *ScannerService) ingestCloudFile(ctx context.Context, lib *model.Library } expectedSTRMURL := BuildRelativeCloudPlayURL(typ, ref) m := &model.Media{ - LibraryID: lib.ID, - Title: title, - Year: year, - Path: path, - SizeBytes: size, - Container: strings.TrimPrefix(ext, "."), - STRMURL: expectedSTRMURL, - ScrapeStatus: "pending", - SeasonNum: parsedSeason, - EpisodeNum: parsedEpisode, + LibraryID: lib.ID, + LibraryRootID: strings.TrimSpace(rootID), + Title: title, + Year: year, + Path: path, + SizeBytes: size, + Container: strings.TrimPrefix(ext, "."), + STRMURL: expectedSTRMURL, + ScrapeStatus: "pending", + SeasonNum: parsedSeason, + EpisodeNum: parsedEpisode, } if ext == ".strm" { if targetURL, err := s.resolveCloudSTRMTarget(ctx, typ, ref); err == nil && targetURL != "" { diff --git a/internal/service/scanner_cloud_scan.go b/internal/service/scanner_cloud_scan.go index 31b630c..45d2105 100644 --- a/internal/service/scanner_cloud_scan.go +++ b/internal/service/scanner_cloud_scan.go @@ -15,6 +15,7 @@ type cloudScanImportRequest struct { existingMedia map[string]existingCloudMedia writeBatch *localMediaWriteBatch probeBudget *int + defaultRootID string progress *cloudScanProgressState result *ScanResult } @@ -34,6 +35,14 @@ type cloudLibraryScanCompletion struct { } func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Library, mount CloudMountInfo, autoScrape bool) (*ScanResult, error) { + return s.scanCloudLibraryWithRoot(ctx, lib, mount, "", autoScrape) +} + +func (s *ScannerService) scanCloudLibraryRoot(ctx context.Context, lib *model.Library, root *model.LibraryRoot, mount CloudMountInfo, autoScrape bool) (*ScanResult, error) { + return s.scanCloudLibraryWithRoot(ctx, lib, mount, libraryRootID(root), autoScrape) +} + +func (s *ScannerService) scanCloudLibraryWithRoot(ctx context.Context, lib *model.Library, mount CloudMountInfo, defaultRootID string, autoScrape bool) (*ScanResult, error) { res := &ScanResult{LibraryID: lib.ID} if s.storage == nil { return res, fmt.Errorf("cloud storage service unavailable") @@ -78,6 +87,7 @@ func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Librar existingMedia: existingMedia, writeBatch: writeBatch, probeBudget: &probeBudget, + defaultRootID: defaultRootID, progress: progress, result: res, }) @@ -86,7 +96,12 @@ func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Librar } scopeIDs = appendUniqueLibraryIDs(scopeIDs, imported.scopeLibraryIDs...) writeBatch.Flush() - removed, err := s.pruneMissingCloudMediaForLibraries(ctx, scopeIDs, imported.seen) + var removed int64 + if defaultRootID != "" { + removed, err = s.pruneMissingCloudMediaForRoot(ctx, lib.ID, defaultRootID, imported.seen) + } else { + removed, err = s.pruneMissingCloudMediaForLibraries(ctx, scopeIDs, imported.seen) + } if err != nil { s.log.Warn("prune missing cloud media failed", zap.String("library_id", lib.ID), zap.Error(err)) } else { @@ -102,38 +117,49 @@ func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Librar return res, nil } +type cloudScanTarget struct { + lib *model.Library + rootID string +} + func (s *ScannerService) importCloudScanCandidates(ctx context.Context, rootLib *model.Library, req cloudScanImportRequest) (cloudScanImportResult, error) { imported := cloudScanImportResult{ seen: make(map[string]struct{}), touchedLibraryIDs: []string{}, scopeLibraryIDs: []string{}, } - targetLibs := map[string]*model.Library{"": rootLib} + targetLibs := map[string]cloudScanTarget{"": {lib: rootLib, rootID: req.defaultRootID}} for _, candidate := range req.candidates { select { case <-ctx.Done(): return imported, ctx.Err() default: } - targetLib := rootLib + target := targetLibs[""] if candidate.categoryDisplayDir != "" { - if cached, ok := targetLibs[candidate.categoryDisplayDir]; ok { - targetLib = cached - } else if categoryLib, err := s.ensureCloudAutoCategoryLibrary(ctx, rootLib, req.provider, candidate.categoryDisplayDir); err == nil && categoryLib != nil { - targetLib = categoryLib - targetLibs[candidate.categoryDisplayDir] = categoryLib - imported.scopeLibraryIDs = appendUniqueLibraryIDs(imported.scopeLibraryIDs, categoryLib.ID) + categoryKey := candidate.categoryDisplayDir + "\x00" + candidate.categoryScanDir + if cached, ok := targetLibs[categoryKey]; ok { + target = cached + } else if categoryTarget, err := s.ensureCloudAutoCategoryTarget(ctx, rootLib, req.provider, candidate.categoryDisplayDir, candidate.categoryScanDir); err == nil && categoryTarget.Library != nil { + target = cloudScanTarget{lib: categoryTarget.Library, rootID: categoryTarget.RootID} + targetLibs[categoryKey] = target + imported.scopeLibraryIDs = appendUniqueLibraryIDs(imported.scopeLibraryIDs, categoryTarget.Library.ID) } else if err != nil { s.log.Warn("ensure cloud auto category library failed", zap.String("library_id", rootLib.ID), zap.String("provider", req.provider), zap.String("category", candidate.categoryDisplayDir), + zap.String("scan_dir", candidate.categoryScanDir), zap.Error(err)) } } + targetLib := target.lib + if targetLib == nil { + targetLib = rootLib + } imported.touchedLibraryIDs = appendUniqueLibraryIDs(imported.touchedLibraryIDs, targetLib.ID) imported.seen[candidate.path] = struct{}{} - s.ingestCloudFile(ctx, targetLib, req.provider, candidate.ref, candidate.path, candidate.name, candidate.size, candidate.localMeta, req.existingMedia, req.writeBatch, req.probeBudget, req.result) + s.ingestCloudFile(ctx, targetLib, target.rootID, req.provider, candidate.ref, candidate.path, candidate.name, candidate.size, candidate.localMeta, req.existingMedia, req.writeBatch, req.probeBudget, req.result) req.progress.publish(s, rootLib.ID, req.result, "importing", req.result.Visited == 1 || req.result.Visited%100 == 0) } return imported, nil diff --git a/internal/service/scanner_cloud_scan_progress.go b/internal/service/scanner_cloud_scan_progress.go index 120152e..284671a 100644 --- a/internal/service/scanner_cloud_scan_progress.go +++ b/internal/service/scanner_cloud_scan_progress.go @@ -12,6 +12,7 @@ type cloudCandidate struct { size int64 path string categoryDisplayDir string + categoryScanDir string localMeta *LocalMetadata } diff --git a/internal/service/scanner_cloud_test.go b/internal/service/scanner_cloud_test.go index f18bcda..38fb4dd 100644 --- a/internal/service/scanner_cloud_test.go +++ b/internal/service/scanner_cloud_test.go @@ -88,13 +88,13 @@ func TestInferCloudMountMediaType(t *testing.T) { cases := map[string]string{ "/日漫": "anime", "/国漫": "anime", - "/欧美动漫": "anime", + "/美漫": "anime", "/电视剧/国产剧": "tv", "/电视剧/欧美剧": "tv", "/电视剧/日韩剧": "tv", "/电影/动画电影": "movie", "/电影/华语电影": "movie", - "/电影/外语电影": "movie", + "/电影/欧美电影": "movie", "/综艺": "variety", } for dir, want := range cases { diff --git a/internal/service/scanner_local_ingest.go b/internal/service/scanner_local_ingest.go index 542fc0a..1b2510a 100644 --- a/internal/service/scanner_local_ingest.go +++ b/internal/service/scanner_local_ingest.go @@ -136,7 +136,7 @@ type localScanMediaInput struct { } func (s *ScannerService) buildLocalScanMedia(in localScanMediaInput) *model.Media { - title, year := CleanQuery(in.path) + title, year := CleanQueryWithRecognition(context.Background(), s.repo, in.path) if title == "" { title = strings.TrimSuffix(filepath.Base(in.path), in.ext) } diff --git a/internal/service/scanner_prune.go b/internal/service/scanner_prune.go index e460fdc..415b5c5 100644 --- a/internal/service/scanner_prune.go +++ b/internal/service/scanner_prune.go @@ -131,6 +131,31 @@ func (s *ScannerService) pruneMissingCloudMedia(ctx context.Context, libraryID s return s.pruneMissingCloudMediaForLibraries(ctx, []string{libraryID}, seen) } +func (s *ScannerService) pruneMissingCloudMediaForRoot(ctx context.Context, libraryID, rootID string, seen map[string]struct{}) (int64, error) { + if strings.TrimSpace(libraryID) == "" || strings.TrimSpace(rootID) == "" { + return 0, nil + } + var rows []struct { + ID string + Path string + } + if err := s.repo.DB.WithContext(ctx). + Model(&model.Media{}). + Select("id, path"). + Where("library_id = ? AND library_root_id = ? AND path LIKE ?", libraryID, rootID, "cloud://%"). + Find(&rows).Error; err != nil { + return 0, err + } + stale := make([]string, 0) + for _, row := range rows { + if _, ok := seen[row.Path]; ok { + continue + } + stale = append(stale, row.ID) + } + return s.deleteMediaByIDs(ctx, stale, true) +} + func (s *ScannerService) pruneMissingCloudMediaForLibraries(ctx context.Context, libraryIDs []string, seen map[string]struct{}) (int64, error) { if len(libraryIDs) == 0 { return 0, nil diff --git a/internal/service/scanner_scan.go b/internal/service/scanner_scan.go index 9a9aa69..bbfa99d 100644 --- a/internal/service/scanner_scan.go +++ b/internal/service/scanner_scan.go @@ -32,6 +32,9 @@ func (s *ScannerService) ScanLibraryRoot(ctx context.Context, libraryID, rootID if root == nil { return nil, errors.New("library root not found") } + if mount, ok := ParseCloudLibraryMount(root.Path); ok { + return s.scanCloudLibraryRoot(ctx, lib, root, mount, true) + } return s.scanLocalLibraryRoot(ctx, lib, root, true) } diff --git a/internal/service/scraper.go b/internal/service/scraper.go index 0ca926a..37fffc0 100644 --- a/internal/service/scraper.go +++ b/internal/service/scraper.go @@ -61,7 +61,7 @@ func (s *ScraperService) EnrichOneWithOptions(ctx context.Context, m *model.Medi return s.applyProviderMatchWithOptions(ctx, m, lib, match, options) } - candidates := scrapeQueryCandidates(m, lib) + candidates := scrapeQueryCandidatesWithRecognition(ctx, s.repo, m, lib) var query string match := (*Match)(nil) for _, candidate := range candidates { diff --git a/internal/service/scraper_query.go b/internal/service/scraper_query.go index f8109c0..c5ea779 100644 --- a/internal/service/scraper_query.go +++ b/internal/service/scraper_query.go @@ -1,11 +1,13 @@ package service import ( + "context" "path/filepath" "regexp" "strings" "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" ) var ( @@ -16,10 +18,22 @@ var ( ) func scrapeQueryCandidates(m *model.Media, lib *model.Library) []string { + return scrapeQueryCandidatesWithNormalizer(m, lib, func(raw string) (string, int) { + return CleanQuery(raw) + }) +} + +func scrapeQueryCandidatesWithRecognition(ctx context.Context, repo *repository.Container, m *model.Media, lib *model.Library) []string { + return scrapeQueryCandidatesWithNormalizer(m, lib, func(raw string) (string, int) { + return CleanQueryWithRecognition(ctx, repo, raw) + }) +} + +func scrapeQueryCandidatesWithNormalizer(m *model.Media, lib *model.Library, clean func(string) (string, int)) []string { seen := map[string]struct{}{} var out []string add := func(raw string) { - cleaned, _ := CleanQuery(raw) + cleaned, _ := clean(raw) if cleaned == "" { cleaned = strings.TrimSpace(raw) } diff --git a/internal/service/scraper_query_paths.go b/internal/service/scraper_query_paths.go index c308416..390c6db 100644 --- a/internal/service/scraper_query_paths.go +++ b/internal/service/scraper_query_paths.go @@ -132,9 +132,9 @@ func isGenericMediaCategoryFolder(name string) bool { "欧美剧", "欧美电视剧", "日韩剧", "日剧", "韩剧", "华语电影", "国产电影", "大陆电影", - "外语电影", "欧美电影", "日韩电影", + "外语电影", "外国电影", "欧美电影", "日韩电影", "演唱会", "音乐会", "动画电影", "动漫电影", - "国漫", "国产动漫", "日番", "日漫", "日本动漫", "日本动画", "欧美动漫", "欧美动画", "西方动画", + "国漫", "国产动漫", "日番", "日漫", "日本动漫", "日本动画", "韩漫", "韩国动漫", "韩国动画", "美漫", "欧美动漫", "欧美动画", "西方动画", "其他动漫", "其它动漫", "综艺", "真人秀", "纪录片", "纪录", "儿童", "少儿", diff --git a/internal/service/service.go b/internal/service/service.go index 20cffee..ea4fb44 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -74,6 +74,7 @@ type Container struct { Device *DeviceService Cache *RuntimeCacheService Sessions *SessionTrackerService + RecognitionWords *RecognitionWordsService stopCtx context.Context stopCancel context.CancelFunc diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index 3fec2b8..e0845d7 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -63,6 +63,7 @@ func (b *serviceContainerBuilder) initProviderServices() { b.c.TheTVDB = NewTheTVDBProvider(b.cfg, b.log) b.c.Douban = NewDoubanProvider(b.cfg, b.log) b.c.Fanart = NewFanartProvider(b.cfg, b.log) + b.c.RecognitionWords = NewRecognitionWordsService(b.log, b.repos) adult := NewAdultProvider(b.log, b.c.APIConfig) b.c.Scraper = NewScraperService( diff --git a/internal/service/site_adapter_nexusphp.go b/internal/service/site_adapter_nexusphp.go index dd403c3..1700a45 100644 --- a/internal/service/site_adapter_nexusphp.go +++ b/internal/service/site_adapter_nexusphp.go @@ -4,10 +4,8 @@ package service import ( "context" "fmt" - "html" "net/http" "net/url" - "regexp" "strconv" "strings" "time" @@ -76,7 +74,11 @@ func (a *NexusPHPAdapter) Search(ctx context.Context, cfg SiteConfig, keyword st return nil, fmt.Errorf("search failed: status %d", status) } - return parseNexusPHPHTML(string(data), cfg.Name, cfg.URL) + body := string(data) + if nexusPHPPageLooksLogin(body) { + return nil, fmt.Errorf("search failed: not logged in or cookie expired") + } + return parseNexusPHPHTML(body, cfg.Name, cfg.URL) } func (a *NexusPHPAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) { @@ -95,7 +97,11 @@ func (a *NexusPHPAdapter) Browse(ctx context.Context, cfg SiteConfig, category s return nil, fmt.Errorf("browse failed: status %d", status) } - return parseNexusPHPHTML(string(data), cfg.Name, cfg.URL) + body := string(data) + if nexusPHPPageLooksLogin(body) { + return nil, fmt.Errorf("browse failed: not logged in or cookie expired") + } + return parseNexusPHPHTML(body, cfg.Name, cfg.URL) } func (a *NexusPHPAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) { @@ -114,259 +120,3 @@ func (a *NexusPHPAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id stri func (a *NexusPHPAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) { return cfg.URL + "/download.php?id=" + id, nil } - -// parseNexusPHPHTML 解析 NexusPHP 种子列表 HTML。 -func parseNexusPHPHTML(html, siteName, baseURL string) (*SiteSearchResult, error) { - result := &SiteSearchResult{ - SiteName: siteName, - Items: []TorrentItem{}, - Page: 1, - } - - for _, row := range nexusPHPTorrentRows(html) { - item := parseNexusPHPRow(row, baseURL) - if item.ID != "" { - result.Items = append(result.Items, item) - } - } - - result.Total = len(result.Items) - return result, nil -} - -// parseNexusPHPRow 解析单行种子条目。 -func parseNexusPHPRow(row, baseURL string) TorrentItem { - item := TorrentItem{} - - // Extract torrent ID and title - if link := firstNexusPHPLink(row, "details.php"); link != nil { - item.ID = link.query.Get("id") - item.Title = nexusPHPTitleFromLink(*link) - item.Subtitle = nexusPHPSubtitle(row) - item.DetailURL = resolveSiteURL(baseURL, link.href) - } - - // Extract download link - if link := firstNexusPHPLink(row, "download.php"); link != nil { - item.DownloadURL = resolveSiteURL(baseURL, link.href) - } - - // Extract size - sizeRegex := regexp.MustCompile(`(?i)(\d+\.?\d*)\s*(GiB|MiB|TiB|KiB|GB|MB|TB|KB)`) - if sizeMatches := sizeRegex.FindStringSubmatch(row); len(sizeMatches) >= 3 { - item.Size = parseSizeString(sizeMatches[1], sizeMatches[2]) - } - - // Extract seeders and leechers - if value, ok := nexusPHPIntByClass(row, "seeders"); ok { - item.Seeders = value - } - if value, ok := nexusPHPIntByClass(row, "leechers"); ok { - item.Leechers = value - } - if value, ok := nexusPHPIntByClass(row, "snatched"); ok { - item.Snatched = value - } - - // Extract snatched - snatchedRegex := regexp.MustCompile(`snatched[^"]*"[^>]*>(\d+)`) - if item.Snatched == 0 { - if m := snatchedRegex.FindStringSubmatch(row); len(m) >= 2 { - item.Snatched, _ = strconv.Atoi(m[1]) - } - } - - // Check for free flag - freeRegex := regexp.MustCompile(`(?i)(class="free|free2|twoupfree|free_download|促销|免费)`) - item.Free = freeRegex.MatchString(row) - - // Extract upload time - timeRegex := regexp.MustCompile(`(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})`) - if m := timeRegex.FindStringSubmatch(row); len(m) >= 2 { - if t, err := time.Parse("2006-01-02 15:04", m[1]); err == nil { - item.UploadTime = t - } - } - - // Extract category - catRegex := regexp.MustCompile(`cat=(\d+)[^"]*"[^>]*title="([^"]+)"`) - if m := catRegex.FindStringSubmatch(row); len(m) >= 3 { - item.Category = strings.TrimSpace(m[2]) - } - - return item -} - -type nexusPHPLink struct { - href string - attrs string - text string - query url.Values -} - -func nexusPHPTorrentRows(pageHTML string) []string { - rowRegex := regexp.MustCompile(`(?is)
| Selected Site Result下载 |