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)]*>.*?`) - rows := rowRegex.FindAllString(pageHTML, -1) - out := make([]string, 0, len(rows)) - for _, row := range rows { - if strings.Contains(strings.ToLower(row), "details.php") { - out = append(out, row) - } - } - return out -} - -func firstNexusPHPLink(row, path string) *nexusPHPLink { - pattern := regexp.MustCompile(`(?is)]*href\s*=\s*["']([^"']*)["'][^>]*)>(.*?)`) - for _, match := range pattern.FindAllStringSubmatch(row, -1) { - if len(match) < 4 { - continue - } - href := html.UnescapeString(strings.TrimSpace(match[2])) - parsed, err := url.Parse(href) - if err != nil || !nexusPHPLinkPathMatches(parsed, path) { - continue - } - return &nexusPHPLink{ - href: href, - attrs: match[1], - text: cleanNexusPHPText(match[3]), - query: parsed.Query(), - } - } - return nil -} - -func nexusPHPLinkPathMatches(parsed *url.URL, want string) bool { - if parsed == nil { - return false - } - path := strings.TrimSpace(parsed.Path) - if path == "" { - path = strings.TrimSpace(parsed.Opaque) - } - path = strings.Trim(strings.ToLower(path), "/") - want = strings.Trim(strings.ToLower(strings.TrimSpace(want)), "/") - if path == "" || want == "" { - return false - } - return path == want || strings.HasSuffix(path, "/"+want) -} - -func nexusPHPTitleFromLink(link nexusPHPLink) string { - for _, attr := range []string{"title", "data-title"} { - if value := htmlAttr(link.attrs, attr); value != "" { - return value - } - } - return link.text -} - -func nexusPHPSubtitle(row string) string { - for _, pattern := range []*regexp.Regexp{ - regexp.MustCompile(`(?is)]*(?:class|id)\s*=\s*["'][^"']*(?:subtitle|small_descr|descr|sub)[^"']*["'][^>]*>(.*?)`), - regexp.MustCompile(`(?is)]*(?:class|id)\s*=\s*["'][^"']*(?:subtitle|small_descr|descr|sub)[^"']*["'][^>]*>(.*?)`), - } { - if match := pattern.FindStringSubmatch(row); len(match) >= 2 { - return cleanNexusPHPText(match[1]) - } - } - return "" -} - -func nexusPHPIntByClass(row, className string) (int, bool) { - pattern := regexp.MustCompile(`(?is)]*(?:class|id)\s*=\s*["'][^"']*` + regexp.QuoteMeta(className) + `[^"']*["'][^>]*>(.*?)`) - if match := pattern.FindStringSubmatch(row); len(match) >= 2 { - text := cleanNexusPHPText(match[1]) - valueMatch := regexp.MustCompile(`\d+`).FindString(text) - if valueMatch != "" { - value, _ := strconv.Atoi(valueMatch) - return value, true - } - } - return 0, false -} - -func htmlAttr(attrs, name string) string { - pattern := regexp.MustCompile(`(?is)\b` + regexp.QuoteMeta(name) + `\s*=\s*["']([^"']*)["']`) - if match := pattern.FindStringSubmatch(attrs); len(match) >= 2 { - return cleanNexusPHPText(match[1]) - } - return "" -} - -func cleanNexusPHPText(value string) string { - return strings.Join(strings.Fields(html.UnescapeString(stripHTML(value))), " ") -} - -func resolveSiteURL(baseURL, href string) string { - base, err := url.Parse(strings.TrimRight(baseURL, "/") + "/") - if err != nil { - return strings.TrimSpace(href) - } - ref, err := url.Parse(strings.TrimSpace(href)) - if err != nil { - return strings.TrimSpace(href) - } - return base.ResolveReference(ref).String() -} - -// parseNexusPHPDetailHTML 解析种子详情页。 -func parseNexusPHPDetailHTML(html, id, baseURL string) (*TorrentDetail, error) { - detail := &TorrentDetail{ - ID: id, - DetailURL: baseURL + "/details.php?id=" + id, - } - - // Title - titleRegex := regexp.MustCompile(`]*>([^<]+)`) - if m := titleRegex.FindStringSubmatch(html); len(m) >= 2 { - detail.Title = strings.TrimSpace(m[1]) - } - - // Subtitle - subRegex := regexp.MustCompile(`]*class="[^"]*sub[^"]*"[^>]*>([^<]+)`) - if m := subRegex.FindStringSubmatch(html); len(m) >= 2 { - detail.Subtitle = strings.TrimSpace(m[1]) - } - - // Info hash - hashRegex := regexp.MustCompile(`(?i)info_hash[^<]*\s*]*>([^<]+)`) - if m := hashRegex.FindStringSubmatch(html); len(m) >= 2 { - detail.InfoHash = strings.TrimSpace(m[1]) - } - - // IMDB ID - imdbRegex := regexp.MustCompile(`(?i)imdb[^<]*\s*]*>[^<]*(tt\d+)`) - if m := imdbRegex.FindStringSubmatch(html); len(m) >= 2 { - detail.ImdbID = m[1] - } - - // Size - sizeRegex := regexp.MustCompile(`(?i)size[^<]*\s*]*>(\d+\.?\d*)\s*(GB|MB|TB|KB)`) - if m := sizeRegex.FindStringSubmatch(html); len(m) >= 3 { - detail.Size = parseSizeString(m[1], m[2]) - } - - // Seeders / Leechers / Snatched - slRegex := regexp.MustCompile(`seeders[^<]*\s*]*>(\d+)\s*]*>\s*\s*]*>\s*\s*]*>leechers[^<]*\s*]*>(\d+)`) - if m := slRegex.FindStringSubmatch(html); len(m) >= 3 { - detail.Seeders, _ = strconv.Atoi(m[1]) - detail.Leechers, _ = strconv.Atoi(m[2]) - } - - snRegex := regexp.MustCompile(`(?i)times completed[^<]*\s*]*>(\d+)`) - if m := snRegex.FindStringSubmatch(html); len(m) >= 2 { - detail.Snatched, _ = strconv.Atoi(m[1]) - } - - // Description - descRegex := regexp.MustCompile(`(?i)]*id="kdescr"[^>]*>(.*?)`) - if m := descRegex.FindStringSubmatch(html); len(m) >= 2 { - detail.Description = stripHTML(m[1]) - } - - detail.DownloadURL = baseURL + "/download.php?id=" + id - detail.Free = strings.Contains(html, "free") || strings.Contains(html, "免费") - return detail, nil -} diff --git a/internal/service/site_adapter_nexusphp_detail.go b/internal/service/site_adapter_nexusphp_detail.go new file mode 100644 index 0000000..6ce57a8 --- /dev/null +++ b/internal/service/site_adapter_nexusphp_detail.go @@ -0,0 +1,43 @@ +package service + +import ( + "regexp" + "strconv" + "strings" +) + +// parseNexusPHPDetailHTML 解析种子详情页。 +func parseNexusPHPDetailHTML(html, id, baseURL string) (*TorrentDetail, error) { + detail := &TorrentDetail{ + ID: id, + DetailURL: baseURL + "/details.php?id=" + id, + } + if m := regexp.MustCompile(`]*>([^<]+)`).FindStringSubmatch(html); len(m) >= 2 { + detail.Title = strings.TrimSpace(m[1]) + } + if m := regexp.MustCompile(`]*class="[^"]*sub[^"]*"[^>]*>([^<]+)`).FindStringSubmatch(html); len(m) >= 2 { + detail.Subtitle = strings.TrimSpace(m[1]) + } + if m := regexp.MustCompile(`(?i)info_hash[^<]*\s*]*>([^<]+)`).FindStringSubmatch(html); len(m) >= 2 { + detail.InfoHash = strings.TrimSpace(m[1]) + } + if m := regexp.MustCompile(`(?i)imdb[^<]*\s*]*>[^<]*(tt\d+)`).FindStringSubmatch(html); len(m) >= 2 { + detail.ImdbID = m[1] + } + if m := regexp.MustCompile(`(?i)size[^<]*\s*]*>(\d+\.?\d*)\s*(GB|MB|TB|KB)`).FindStringSubmatch(html); len(m) >= 3 { + detail.Size = parseSizeString(m[1], m[2]) + } + if m := regexp.MustCompile(`seeders[^<]*\s*]*>(\d+)\s*]*>\s*\s*]*>\s*\s*]*>leechers[^<]*\s*]*>(\d+)`).FindStringSubmatch(html); len(m) >= 3 { + detail.Seeders, _ = strconv.Atoi(m[1]) + detail.Leechers, _ = strconv.Atoi(m[2]) + } + if m := regexp.MustCompile(`(?i)times completed[^<]*\s*]*>(\d+)`).FindStringSubmatch(html); len(m) >= 2 { + detail.Snatched, _ = strconv.Atoi(m[1]) + } + if m := regexp.MustCompile(`(?i)]*id="kdescr"[^>]*>(.*?)`).FindStringSubmatch(html); len(m) >= 2 { + detail.Description = stripHTML(m[1]) + } + detail.DownloadURL = baseURL + "/download.php?id=" + id + detail.Free = strings.Contains(html, "free") || strings.Contains(html, "免费") + return detail, nil +} diff --git a/internal/service/site_adapter_nexusphp_list.go b/internal/service/site_adapter_nexusphp_list.go new file mode 100644 index 0000000..483c018 --- /dev/null +++ b/internal/service/site_adapter_nexusphp_list.go @@ -0,0 +1,204 @@ +package service + +import ( + "html" + "net/url" + "regexp" + "strconv" + "strings" + "time" +) + +// 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 +} + +func nexusPHPPageLooksLogin(pageHTML string) bool { + lower := strings.ToLower(pageHTML) + if strings.Contains(lower, "details.php") || strings.Contains(lower, "download.php") { + return false + } + for _, marker := range []string{ + "takelogin.php", + "id=\"loginform\"", + "id='loginform'", + "name=\"loginform\"", + "name='loginform'", + "type=\"password\"", + "type='password'", + } { + if strings.Contains(lower, marker) { + return true + } + } + return false +} + +// parseNexusPHPRow 解析单行种子条目。 +func parseNexusPHPRow(row, baseURL string) TorrentItem { + item := TorrentItem{} + 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) + } + if link := firstNexusPHPLink(row, "download.php"); link != nil { + item.DownloadURL = resolveSiteURL(baseURL, link.href) + } + if sizeMatches := regexp.MustCompile(`(?i)(\d+\.?\d*)\s*(GiB|MiB|TiB|KiB|GB|MB|TB|KB)`).FindStringSubmatch(row); len(sizeMatches) >= 3 { + item.Size = parseSizeString(sizeMatches[1], sizeMatches[2]) + } + 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 + } + if item.Snatched == 0 { + if m := regexp.MustCompile(`snatched[^"]*"[^>]*>(\d+)`).FindStringSubmatch(row); len(m) >= 2 { + item.Snatched, _ = strconv.Atoi(m[1]) + } + } + item.Free = regexp.MustCompile(`(?i)(class="free|free2|twoupfree|free_download|促销|免费)`).MatchString(row) + if m := regexp.MustCompile(`(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})`).FindStringSubmatch(row); len(m) >= 2 { + if t, err := time.Parse("2006-01-02 15:04", m[1]); err == nil { + item.UploadTime = t + } + } + if m := regexp.MustCompile(`cat=(\d+)[^"]*"[^>]*title="([^"]+)"`).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 { + rows := regexp.MustCompile(`(?is)]*>.*?`).FindAllString(pageHTML, -1) + out := make([]string, 0, len(rows)) + for _, row := range rows { + if strings.Contains(strings.ToLower(row), "details.php") { + out = append(out, row) + } + } + return out +} + +func firstNexusPHPLink(row, path string) *nexusPHPLink { + pattern := regexp.MustCompile(`(?is)]*href\s*=\s*["']([^"']*)["'][^>]*)>(.*?)`) + for _, match := range pattern.FindAllStringSubmatch(row, -1) { + if len(match) < 4 { + continue + } + href := html.UnescapeString(strings.TrimSpace(match[2])) + parsed, err := url.Parse(href) + if err != nil || !nexusPHPLinkPathMatches(parsed, path) { + continue + } + return &nexusPHPLink{ + href: href, + attrs: match[1], + text: cleanNexusPHPText(match[3]), + query: parsed.Query(), + } + } + return nil +} + +func nexusPHPLinkPathMatches(parsed *url.URL, want string) bool { + if parsed == nil { + return false + } + path := strings.TrimSpace(parsed.Path) + if path == "" { + path = strings.TrimSpace(parsed.Opaque) + } + path = strings.Trim(strings.ToLower(path), "/") + want = strings.Trim(strings.ToLower(strings.TrimSpace(want)), "/") + if path == "" || want == "" { + return false + } + return path == want || strings.HasSuffix(path, "/"+want) +} + +func nexusPHPTitleFromLink(link nexusPHPLink) string { + for _, attr := range []string{"title", "data-title"} { + if value := htmlAttr(link.attrs, attr); value != "" { + return value + } + } + return link.text +} + +func nexusPHPSubtitle(row string) string { + for _, pattern := range []*regexp.Regexp{ + regexp.MustCompile(`(?is)]*(?:class|id)\s*=\s*["'][^"']*(?:subtitle|small_descr|descr|sub)[^"']*["'][^>]*>(.*?)`), + regexp.MustCompile(`(?is)]*(?:class|id)\s*=\s*["'][^"']*(?:subtitle|small_descr|descr|sub)[^"']*["'][^>]*>(.*?)`), + } { + if match := pattern.FindStringSubmatch(row); len(match) >= 2 { + return cleanNexusPHPText(match[1]) + } + } + return "" +} + +func nexusPHPIntByClass(row, className string) (int, bool) { + pattern := regexp.MustCompile(`(?is)]*(?:class|id)\s*=\s*["'][^"']*` + regexp.QuoteMeta(className) + `[^"']*["'][^>]*>(.*?)`) + if match := pattern.FindStringSubmatch(row); len(match) >= 2 { + text := cleanNexusPHPText(match[1]) + valueMatch := regexp.MustCompile(`\d+`).FindString(text) + if valueMatch != "" { + value, _ := strconv.Atoi(valueMatch) + return value, true + } + } + return 0, false +} + +func htmlAttr(attrs, name string) string { + pattern := regexp.MustCompile(`(?is)\b` + regexp.QuoteMeta(name) + `\s*=\s*["']([^"']*)["']`) + if match := pattern.FindStringSubmatch(attrs); len(match) >= 2 { + return cleanNexusPHPText(match[1]) + } + return "" +} + +func cleanNexusPHPText(value string) string { + return strings.Join(strings.Fields(html.UnescapeString(stripHTML(value))), " ") +} + +func resolveSiteURL(baseURL, href string) string { + base, err := url.Parse(strings.TrimRight(baseURL, "/") + "/") + if err != nil { + return strings.TrimSpace(href) + } + ref, err := url.Parse(strings.TrimSpace(href)) + if err != nil { + return strings.TrimSpace(href) + } + return base.ResolveReference(ref).String() +} diff --git a/internal/service/site_adapter_test.go b/internal/service/site_adapter_test.go index 1152c30..7983ad4 100644 --- a/internal/service/site_adapter_test.go +++ b/internal/service/site_adapter_test.go @@ -212,6 +212,25 @@ func TestNexusPHPSearchUsesSearchstr(t *testing.T) { } } +func TestNexusPHPSearchReportsExpiredCookieLoginPage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`
`)) + })) + defer server.Close() + + adapter := NewNexusPHPAdapter() + _, err := adapter.Search(t.Context(), SiteConfig{ + Name: "Nexus", + URL: server.URL, + AuthType: "cookie", + Cookie: "uid=1; pass=expired", + Timeout: 5 * time.Second, + }, "测试", 1) + if err == nil || !strings.Contains(err.Error(), "cookie expired") { + t.Fatalf("Search error = %v, want cookie expired hint", err) + } +} + func TestParseNexusPHPHTMLModernRows(t *testing.T) { page := ` diff --git a/internal/service/site_search.go b/internal/service/site_search.go index 6c35afb..70a0de6 100644 --- a/internal/service/site_search.go +++ b/internal/service/site_search.go @@ -102,28 +102,10 @@ func (s *SiteService) Search(ctx context.Context, keyword string) ([]SearchResul if result == nil { return } - items := result.Items - if items == nil { - items = []TorrentItem{} - } - for _, item := range items { - mu.Lock() - results = append(results, SearchResult{ - SiteName: site.Name, - SiteID: site.ID, - Title: item.Title, - Subtitle: item.Subtitle, - TorrentURL: item.DetailURL, - DownloadURL: item.DownloadURL, - Category: item.Category, - SearchKeyword: keyword, - Size: item.Size, - Seeders: item.Seeders, - Leechers: item.Leechers, - Free: item.Free, - }) - mu.Unlock() - } + siteResults := siteSearchResultsFromItems(site, result, keyword) + mu.Lock() + results = append(results, siteResults...) + mu.Unlock() }(sites[i]) } wg.Wait() @@ -152,3 +134,83 @@ func (s *SiteService) Search(ctx context.Context, keyword string) ([]SearchResul } return results, nil } + +// SearchSite runs a keyword search against one configured site, regardless of +// whether the site is enabled globally. This is used by per-site diagnostics in +// the management UI, where the user expects the selected site to be tested +// directly instead of a full fan-out followed by filtering. +func (s *SiteService) SearchSite(ctx context.Context, siteID, keyword string, page int) ([]SearchResult, error) { + if strings.TrimSpace(keyword) == "" { + return []SearchResult{}, nil + } + if page <= 0 { + page = 1 + } + site, err := s.FindByID(ctx, siteID) + if err != nil { + return nil, err + } + if site == nil { + return nil, fmt.Errorf("site not found") + } + adapter := NewSiteAdapter(site) + if adapter == nil { + return nil, fmt.Errorf("%s: unsupported site type %s", site.Name, site.Type) + } + cfg := s.siteModelToConfig(site) + timeout := time.Duration(site.Timeout) * time.Second + if timeout <= 0 { + timeout = 30 * time.Second + } + ctxWithTimeout, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + result, err := adapter.Search(ctxWithTimeout, cfg, keyword, page) + if err != nil { + if s.log != nil { + s.log.Warn("single site search failed", + zap.String("site", site.Name), + zap.String("type", site.Type), + zap.String("url", site.URL), + zap.String("keyword", keyword), + zap.Duration("timeout", timeout), + zap.Error(err)) + } + return nil, err + } + out := siteSearchResultsFromItems(*site, result, keyword) + sort.Slice(out, func(i, j int) bool { + return out[i].Seeders > out[j].Seeders + }) + if s.log != nil { + s.log.Info("single site search completed", + zap.String("site", site.Name), + zap.String("keyword", keyword), + zap.Int("results_count", len(out))) + } + return out, nil +} + +func siteSearchResultsFromItems(site model.Site, result *SiteSearchResult, keyword string) []SearchResult { + if result == nil || len(result.Items) == 0 { + return []SearchResult{} + } + out := make([]SearchResult, 0, len(result.Items)) + for _, item := range result.Items { + out = append(out, SearchResult{ + SiteName: site.Name, + SiteID: site.ID, + Title: item.Title, + Subtitle: item.Subtitle, + TorrentURL: item.DetailURL, + DownloadURL: item.DownloadURL, + Category: item.Category, + SearchKeyword: keyword, + Size: item.Size, + Seeders: item.Seeders, + Leechers: item.Leechers, + Free: item.Free, + }) + } + return out +} diff --git a/internal/service/site_test.go b/internal/service/site_test.go index 5eb9e5b..82894a2 100644 --- a/internal/service/site_test.go +++ b/internal/service/site_test.go @@ -146,3 +146,39 @@ func TestSiteSearchReturnsErrorWhenAllEnabledSitesFail(t *testing.T) { t.Fatalf("error = %q, want site failure context", err.Error()) } } + +func TestSearchSiteQueriesSelectedSiteEvenWhenDisabled(t *testing.T) { + var gotQuery string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + _, _ = w.Write([]byte(`
Selected Site Result下载
`)) + })) + defer upstream.Close() + + db := newServiceTestDB(t, &model.Site{}) + repos := repository.New(db) + svc := NewSiteService(zap.NewNop(), repos, "") + site := &model.Site{ + Name: "Selected Nexus", + Type: "nexusphp", + URL: upstream.URL, + AuthType: "cookie", + Cookie: "uid=1; pass=token", + Enabled: false, + Timeout: 5, + } + if err := svc.Create(context.Background(), site); err != nil { + t.Fatal(err) + } + + results, err := svc.SearchSite(context.Background(), site.ID, "Selected", 1) + if err != nil { + t.Fatalf("SearchSite returned error: %v", err) + } + if !strings.Contains(gotQuery, "searchstr=Selected") { + t.Fatalf("query = %q, want searchstr=Selected", gotQuery) + } + if len(results) != 1 || results[0].SiteID != site.ID || results[0].Title != "Selected Site Result" { + t.Fatalf("results = %#v", results) + } +} diff --git a/internal/service/storage.go b/internal/service/storage.go index 799c069..7ba20ba 100644 --- a/internal/service/storage.go +++ b/internal/service/storage.go @@ -56,6 +56,7 @@ func (s *StorageService) Compute(ctx context.Context) (*Breakdown, error) { if err != nil { return nil, err } + libs = NormalizeCloudLibraryDisplay(libs) out := &Breakdown{ByLibrary: make([]LibraryUsage, 0, len(libs))} for _, l := range libs { var usage LibraryUsage diff --git a/internal/service/storage_test.go b/internal/service/storage_test.go new file mode 100644 index 0000000..b86f532 --- /dev/null +++ b/internal/service/storage_test.go @@ -0,0 +1,50 @@ +package service + +import ( + "slices" + "testing" + + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" + "go.uber.org/zap" +) + +func TestStorageBreakdownUsesCanonicalLibraryDisplay(t *testing.T) { + db := newServiceTestDB(t, &model.Library{}, &model.Media{}) + repos := repository.New(db) + libs := []model.Library{ + {Name: "外语电影", Path: "/media/电影/外语电影", Type: "movie", Enabled: true}, + {Name: "欧美动漫", Path: "/media/动漫/欧美动漫", Type: "tv", Enabled: true}, + {Name: "9KG", Path: "/media/成人/9KG", Type: "movie", Enabled: true}, + } + for i := range libs { + if err := repos.Library.Create(t.Context(), &libs[i]); err != nil { + t.Fatal(err) + } + if err := repos.Media.Upsert(t.Context(), &model.Media{ + LibraryID: libs[i].ID, + Title: libs[i].Name, + Path: libs[i].Path + "/item.mkv", + SizeBytes: 1024, + }); err != nil { + t.Fatal(err) + } + } + + breakdown, err := NewStorageService(zap.NewNop(), repos).Compute(t.Context()) + if err != nil { + t.Fatal(err) + } + gotNames := make([]string, 0, len(breakdown.ByLibrary)) + gotTypes := make([]string, 0, len(breakdown.ByLibrary)) + for _, row := range breakdown.ByLibrary { + gotNames = append(gotNames, row.Name) + gotTypes = append(gotTypes, row.Type) + } + if want := []string{"欧美电影", "美漫", "成人"}; !slices.Equal(gotNames, want) { + t.Fatalf("library names = %#v, want %#v", gotNames, want) + } + if want := []string{"movie", "anime", "adult"}; !slices.Equal(gotTypes, want) { + t.Fatalf("library types = %#v, want %#v", gotTypes, want) + } +} diff --git a/internal/service/strm_output_dir.go b/internal/service/strm_output_dir.go index c83925f..60e25c4 100644 --- a/internal/service/strm_output_dir.go +++ b/internal/service/strm_output_dir.go @@ -108,7 +108,7 @@ func strmCategoryPartsFromPath(parts []string) []string { return append([]string{root}, strmSanitizedTail(parts[i+1:])...) } if root := strmCategoryRoot(part); root != "" { - return []string{root, part} + return []string{root, strmCanonicalCategory(part)} } } return nil @@ -143,11 +143,11 @@ func strmCanonicalRoot(part string) string { func strmCategoryRoot(part string) string { key := strings.ToLower(strings.TrimSpace(part)) switch key { - case "动画电影", "动漫电影", "华语电影", "国产电影", "外语电影", "欧美电影", "日韩电影": + case "演唱会", "音乐会", "纪录片", "纪录", "动画电影", "动漫电影", "华语电影", "国产电影", "外语电影", "外国电影", "欧美电影", "日韩电影", "日本电影", "韩国电影": return "电影" - case "国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "综艺", "真人秀", "纪录片", "纪录", "未分类": + case "国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "综艺", "真人秀", "儿童", "少儿", "未分类": return "电视剧" - case "国漫", "国产动漫", "日番", "番剧", "日漫", "日本动漫", "日本动画", "欧美动漫", "欧美动画", "西方动画", "儿童", "少儿": + case "国漫", "国产动漫", "日番", "番剧", "日漫", "日本动漫", "日本动画", "韩漫", "韩国动漫", "韩国动画", "美漫", "欧美动漫", "欧美动画", "西方动画", "其他", "其他动漫", "其它动漫": return "动漫" case "番号": return "成人" @@ -155,3 +155,43 @@ func strmCategoryRoot(part string) string { return "" } } + +func strmCanonicalCategory(part string) string { + key := strings.ToLower(strings.TrimSpace(part)) + switch key { + case "音乐会": + return "演唱会" + case "纪录": + return "纪录片" + case "动漫电影": + return "动画电影" + case "国产电影": + return "华语电影" + case "外语电影", "外国电影": + return "欧美电影" + case "日本电影", "韩国电影": + return "日韩电影" + case "日剧", "韩剧": + return "日韩剧" + case "真人秀": + return "综艺" + case "少儿": + return "儿童" + case "未分类": + return "欧美剧" + case "国产动漫": + return "国漫" + case "番剧", "日漫", "日本动漫", "日本动画": + return "日番" + case "韩国动漫", "韩国动画": + return "韩漫" + case "欧美动漫", "欧美动画", "西方动画": + return "美漫" + case "其他动漫", "其它动漫": + return "其他" + case "番号": + return "成人" + default: + return strings.TrimSpace(part) + } +} diff --git a/internal/service/subscription.go b/internal/service/subscription.go index 38506f4..d12e2fb 100644 --- a/internal/service/subscription.go +++ b/internal/service/subscription.go @@ -166,10 +166,16 @@ func (s *SubscriptionService) Delete(ctx context.Context, id string) error { // by the admin UI's "test now" button. func (s *SubscriptionService) RunNow(ctx context.Context, id string) (int, error) { var sub model.Subscription - if err := s.repo.DB.Where("id = ?", id).First(&sub).Error; err != nil { + if err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&sub).Error; err != nil { return 0, err } if sub.ArchivedAt != nil { + if s.log != nil { + s.log.Info("subscription run skipped because it is archived", + zap.String("subscription_id", sub.ID), + zap.String("subscription", sub.Name), + zap.String("archive_reason", sub.ArchiveReason)) + } return 0, nil } return s.runOne(ctx, &sub) @@ -236,6 +242,9 @@ func (s *SubscriptionService) runAll(ctx context.Context) { s.log.Warn("subscription list failed", zap.Error(err)) return } + if s.log != nil { + s.log.Info("subscription sweep started", zap.Int("count", len(subs))) + } for i := range subs { if !subs[i].Enabled { continue diff --git a/internal/service/subscription_classifier.go b/internal/service/subscription_classifier.go index 523df18..fd20a24 100644 --- a/internal/service/subscription_classifier.go +++ b/internal/service/subscription_classifier.go @@ -56,7 +56,7 @@ func (s *SubscriptionService) lookupSubscriptionMetadata(ctx context.Context, me for _, libType := range subscriptionMetadataLibraryTypes(mediaType, title) { lib := &model.Library{Type: libType, Enabled: true} for _, query := range queries { - cleaned, year := CleanQuery(query) + cleaned, year := CleanQueryWithRecognition(ctx, s.repo, query) if cleaned == "" { cleaned = strings.TrimSpace(query) } diff --git a/internal/service/subscription_exclude_rules.go b/internal/service/subscription_exclude_rules.go new file mode 100644 index 0000000..b5a9b80 --- /dev/null +++ b/internal/service/subscription_exclude_rules.go @@ -0,0 +1,189 @@ +package service + +import ( + "strings" + "unicode" +) + +// defaultExcludeWords 是默认过滤的「垃圾版本」排除清单,对所有订阅生效。 +// 拉丁词在 containsAnyExcludeToken 里按词边界匹配以避免子串误伤。 +const defaultExcludeWords = "cam,ts,tc,telesync,telecine,hdcam,hdts,枪版,抢先,抢鲜,预告,trailer,sample" + +// defaultCompatibilityExcludeWords 是面向自动订阅的兼容性默认排除清单。 +// 仅在用户未真正自定义排除词时启用,避免默认命中 DoVi/H.265/10bit/杜比音轨等版本。 +const defaultCompatibilityExcludeWords = "dovi,dv,dolby vision,dolby,杜比视界,杜比,h265,h.265,h-265,h_265,h 265,hevc,x265,10bit,10-bit,10 bit,hi10p,atmos,truehd,ddp,dd+,eac3" + +const legacyFrontendExcludeWords = "cam,ts,tc,枪版" + +func containsAnyToken(titleFold, csv string) bool { + for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool { + return r == ',' || r == '/' || r == '|' || r == ';' || r == ',' + }) { + token = strings.TrimSpace(token) + if token != "" && strings.Contains(titleFold, token) { + return true + } + } + return false +} + +// containsAnyExcludeToken 用于排除词匹配:纯 ASCII 字母数字的词按词边界匹配(避免 "ts" +// 误伤 "tsukihime"、"cam" 误伤 "camp" 之类的子串误判),含 CJK/符号的词仍按子串匹配。 +func containsAnyExcludeToken(titleFold, csv string) bool { + for _, token := range excludeWordTokens(csv) { + if matchesExcludeToken(titleFold, token) { + return true + } + } + return false +} + +func excludeWordTokens(csv string) []string { + parts := make([]string, 0) + for _, token := range strings.FieldsFunc(strings.ToLower(csv), isExcludeSeparator) { + token = strings.TrimSpace(token) + if token == "" { + continue + } + parts = append(parts, token) + if shouldExpandDottedExcludeToken(token) { + parts = append(parts, dottedExcludeTokenParts(token)...) + } + } + return parts +} + +func isExcludeSeparator(r rune) bool { + switch r { + case ',', '/', '|', ';', ',', '、', '\n', '\r', '\t': + return true + default: + return false + } +} + +func shouldExpandDottedExcludeToken(token string) bool { + return strings.Count(token, ".") >= 2 +} + +func dottedExcludeTokenParts(token string) []string { + rawParts := strings.Split(token, ".") + parts := make([]string, 0, len(rawParts)) + for _, part := range rawParts { + part = strings.TrimSpace(part) + if len(part) < 2 || isDigitsOnly(part) { + continue + } + parts = append(parts, part) + } + return parts +} + +func isDigitsOnly(value string) bool { + if value == "" { + return false + } + for _, r := range value { + if !unicode.IsDigit(r) { + return false + } + } + return true +} + +func matchesExcludeToken(titleFold, token string) bool { + if token == "" { + return false + } + if isASCIIWordToken(token) { + return matchesWordBoundary(titleFold, token) || matchesReleasePrefixToken(titleFold, token) + } + return strings.Contains(titleFold, token) +} + +func isASCIIWordToken(token string) bool { + for _, r := range token { + if r > unicode.MaxASCII || !(unicode.IsLetter(r) || unicode.IsDigit(r)) { + return false + } + } + return token != "" +} + +// matchesWordBoundary 判断 token 是否作为独立词出现在 title 中,词边界为「非字母数字」。 +func matchesWordBoundary(titleFold, token string) bool { + from := 0 + for { + idx := strings.Index(titleFold[from:], token) + if idx < 0 { + return false + } + start := from + idx + end := start + len(token) + leftOK := start == 0 || !isASCIIAlnumByte(titleFold[start-1]) + rightOK := end >= len(titleFold) || !isASCIIAlnumByte(titleFold[end]) + if leftOK && rightOK { + return true + } + from = start + 1 + if from >= len(titleFold) { + return false + } + } +} + +func matchesReleasePrefixToken(titleFold, token string) bool { + if !isReleasePrefixExcludeToken(token) { + return false + } + from := 0 + for { + idx := strings.Index(titleFold[from:], token) + if idx < 0 { + return false + } + start := from + idx + end := start + len(token) + leftOK := start == 0 || !isASCIIAlnumByte(titleFold[start-1]) + if leftOK && releasePrefixSuffixOK(token, titleFold[end:]) { + return true + } + from = start + 1 + if from >= len(titleFold) { + return false + } + } +} + +func isReleasePrefixExcludeToken(token string) bool { + switch token { + case "ddp", "dolby": + return true + default: + return false + } +} + +func releasePrefixSuffixOK(token, suffix string) bool { + if suffix == "" { + return false + } + switch token { + case "ddp": + return isASCIIDigitByte(suffix[0]) + case "dolby": + return strings.HasPrefix(suffix, "vision") || + strings.HasPrefix(suffix, "atmos") || + strings.HasPrefix(suffix, "digital") + default: + return false + } +} + +func isASCIIAlnumByte(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || isASCIIDigitByte(b) +} + +func isASCIIDigitByte(b byte) bool { + return b >= '0' && b <= '9' +} diff --git a/internal/service/subscription_logging.go b/internal/service/subscription_logging.go new file mode 100644 index 0000000..85973a4 --- /dev/null +++ b/internal/service/subscription_logging.go @@ -0,0 +1,54 @@ +package service + +import ( + "net/url" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func subscriptionRunLogFields(sub *model.Subscription) []zap.Field { + fields := []zap.Field{} + if sub == nil { + return fields + } + return append(fields, + zap.String("subscription_id", sub.ID), + zap.String("subscription", sub.Name), + zap.String("feed_kind", subscriptionFeedKind(sub.FeedURL)), + zap.String("filter", sub.Filter), + zap.String("media_type", sub.MediaType), + zap.String("media_category", sub.MediaCategory), + zap.String("search_mode", sub.SearchMode), + zap.Bool("enabled", sub.Enabled), + zap.Bool("wash_enabled", sub.WashEnabled), + zap.String("wash_priority", sub.WashPriority), + zap.Int("total_episodes", sub.TotalEpisodes), + ) +} + +func appendSubscriptionRunResultFields(fields []zap.Field, queued int, started time.Time) []zap.Field { + return append(fields, + zap.Int("queued", queued), + zap.Int64("duration_ms", time.Since(started).Milliseconds()), + ) +} + +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" +} diff --git a/internal/service/subscription_pack.go b/internal/service/subscription_pack.go new file mode 100644 index 0000000..02bf2f4 --- /dev/null +++ b/internal/service/subscription_pack.go @@ -0,0 +1,23 @@ +package service + +import ( + "regexp" + "strings" +) + +var ( + seriesPackRE = regexp.MustCompile(`(?i)(complete|batch|合集|全集|全\s*\d+\s*[集话話期]|整季|全季|s\d{1,2}\s*(?:complete|batch|pack)|season\s*\d{1,2}\s*(?:complete|batch|pack)|s\d{1,2}e\d{1,3}\s*[-~–—]\s*(?:s\d{1,2})?e?\d{1,3}|第\s*\d+\s*[-~–—]\s*\d+\s*[集话話期])`) + seasonOnlyRE = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\s*\d{1,2}(?:[\s._-]|$)|第\s*\d+\s*季`) +) + +func isSeriesPackTitle(title string) bool { + title = strings.TrimSpace(title) + if title == "" { + return false + } + if seriesPackRE.MatchString(title) { + return true + } + _, episode := ParseEpisode(title) + return episode == 0 && seasonOnlyRE.MatchString(title) +} diff --git a/internal/service/subscription_rss_run.go b/internal/service/subscription_rss_run.go index a41a911..7580d4b 100644 --- a/internal/service/subscription_rss_run.go +++ b/internal/service/subscription_rss_run.go @@ -19,8 +19,21 @@ type rssSubscriptionRunState struct { washOff bool } -func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscription) (int, error) { +func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscription) (queued int, err error) { s.prepareSubscriptionForRun(ctx, sub) + started := time.Now() + if s.log != nil { + s.log.Info("subscription run started", subscriptionRunLogFields(sub)...) + defer func() { + fields := appendSubscriptionRunResultFields(subscriptionRunLogFields(sub), queued, started) + if err != nil { + fields = append(fields, zap.Error(err)) + s.log.Warn("subscription run finished with error", fields...) + return + } + s.log.Info("subscription run finished", fields...) + }() + } if strings.HasPrefix(strings.ToLower(strings.TrimSpace(sub.FeedURL)), "site-search://") { return s.runSiteSearch(ctx, sub) } @@ -50,7 +63,7 @@ func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscriptio washOff: !sub.WashEnabled, } candidates := selectRSSSubscriptionCandidates(feed.Channel.Items, sub, filter, runState.seenSet, runState.availability) - queued := s.enqueueRSSSubscriptionCandidates(ctx, sub, candidates, runState) + queued = s.enqueueRSSSubscriptionCandidates(ctx, sub, candidates, runState) s.finishRSSSubscriptionRun(ctx, sub, guidKey, runState, queued) return queued, nil } @@ -105,6 +118,15 @@ func (s *SubscriptionService) enqueueRSSSubscriptionCandidate(ctx context.Contex zap.Error(err)) return false } + if s.log != nil { + s.log.Info("rss subscription candidate queued", + zap.String("subscription_id", sub.ID), + zap.String("subscription", sub.Name), + zap.String("title", item.Title), + zap.String("media_type", mediaType), + zap.String("media_category", mediaCategory), + zap.String("save_path", savePath)) + } state.markTitleAvailable(item.Title) state.markSeen(candidate.GUID) return true @@ -116,11 +138,26 @@ func (s *SubscriptionService) finishRSSSubscriptionRun(ctx context.Context, sub if len(state.seen) > 200 { state.seen = state.seen[len(state.seen)-200:] } - _ = s.repo.Setting.Set(ctx, guidKey, strings.Join(state.seen, "\n")) + if err := s.repo.Setting.Set(ctx, guidKey, strings.Join(state.seen, "\n")); err != nil && s.log != nil { + s.log.Warn("subscription seen state update failed", + zap.String("subscription_id", sub.ID), + zap.String("subscription", sub.Name), + zap.Error(err)) + } now := time.Now() - _ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error - _ = s.archiveCompletedSubscription(ctx, sub, state.availability) + if err := s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error; err != nil && s.log != nil { + s.log.Warn("subscription last_run_at update failed", + zap.String("subscription_id", sub.ID), + zap.String("subscription", sub.Name), + zap.Error(err)) + } + if err := s.archiveCompletedSubscription(ctx, sub, state.availability); err != nil && s.log != nil { + s.log.Warn("subscription archive check failed", + zap.String("subscription_id", sub.ID), + zap.String("subscription", sub.Name), + zap.Error(err)) + } if queued > 0 { s.hub.Publish("subscription", map[string]any{ "id": sub.ID, diff --git a/internal/service/subscription_rules.go b/internal/service/subscription_rules.go index 99731c4..5d57f21 100644 --- a/internal/service/subscription_rules.go +++ b/internal/service/subscription_rules.go @@ -1,28 +1,11 @@ package service import ( - "regexp" "strings" - "unicode" "github.com/ShukeBta/MediaStationGo/internal/model" ) -var ( - seriesPackRE = regexp.MustCompile(`(?i)(complete|batch|合集|全集|全\s*\d+\s*[集话話期]|整季|全季|s\d{1,2}\s*(?:complete|batch|pack)|season\s*\d{1,2}\s*(?:complete|batch|pack)|s\d{1,2}e\d{1,3}\s*[-~–—]\s*(?:s\d{1,2})?e?\d{1,3}|第\s*\d+\s*[-~–—]\s*\d+\s*[集话話期])`) - seasonOnlyRE = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\s*\d{1,2}(?:[\s._-]|$)|第\s*\d+\s*季`) -) - -// defaultExcludeWords 是默认过滤的「垃圾版本」排除清单,对所有订阅生效。 -// 拉丁词在 containsAnyExcludeToken 里按词边界匹配以避免子串误伤。 -const defaultExcludeWords = "cam,ts,tc,telesync,telecine,hdcam,hdts,枪版,抢先,抢鲜,预告,trailer,sample" - -// defaultCompatibilityExcludeWords 是面向自动订阅的兼容性默认排除清单。 -// 仅在用户未真正自定义排除词时启用,避免默认命中 DoVi/H.265/10bit/杜比音轨等版本。 -const defaultCompatibilityExcludeWords = "dovi,dv,dolby vision,dolby,杜比视界,杜比,h265,h.265,h-265,h_265,h 265,hevc,x265,10bit,10-bit,10 bit,hi10p,atmos,truehd,ddp,dd+,eac3" - -const legacyFrontendExcludeWords = "cam,ts,tc,枪版" - func matchesSubscriptionRules(sub *model.Subscription, title string) bool { titleFold := strings.ToLower(title) if containsAnyExcludeToken(titleFold, defaultExcludeWords) { @@ -58,214 +41,7 @@ func shouldApplyDefaultCompatibilityExcludes(excludeWords string) bool { } func normalizeExcludeWords(csv string) string { - parts := make([]string, 0) - for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool { - return r == ',' || r == '/' || r == '|' || r == ';' || r == ',' - }) { - token = strings.TrimSpace(token) - if token != "" { - parts = append(parts, token) - } - } - return strings.Join(parts, ",") -} - -func subscriptionCandidateScore(sub *model.Subscription, item SearchResult) int { - title := strings.ToLower(subscriptionSearchResultText(item)) - score := item.Seeders - if sub == nil || !sub.WashEnabled { - if item.Free { - score += 25 - } - return score - } - resolutionScore := detectResolutionScore(title) - qualityScore := detectQualityScore(title) - effectScore := detectEffectScore(title) - - priority := "balanced" - if sub != nil && strings.TrimSpace(sub.WashPriority) != "" { - priority = strings.ToLower(strings.TrimSpace(sub.WashPriority)) - } - switch priority { - case "resolution": - score += resolutionScore*1000 + qualityScore*100 + effectScore*50 - case "quality": - score += qualityScore*1000 + resolutionScore*200 + effectScore*50 - case "effects": - score += effectScore*1000 + resolutionScore*200 + qualityScore*100 - case "seeders": - score += qualityScore*3 + resolutionScore*2 + effectScore - default: - score += resolutionScore*500 + qualityScore*300 + effectScore*150 - } - if item.Free { - score += 25 - } - return score -} - -func containsAnyToken(titleFold, csv string) bool { - for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool { - return r == ',' || r == '/' || r == '|' || r == ';' || r == ',' - }) { - token = strings.TrimSpace(token) - if token != "" && strings.Contains(titleFold, token) { - return true - } - } - return false -} - -// containsAnyExcludeToken 用于排除词匹配:纯 ASCII 字母数字的词按词边界匹配(避免 "ts" -// 误伤 "tsukihime"、"cam" 误伤 "camp" 之类的子串误判),含 CJK/符号的词仍按子串匹配。 -func containsAnyExcludeToken(titleFold, csv string) bool { - for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool { - return r == ',' || r == '/' || r == '|' || r == ';' || r == ',' - }) { - token = strings.TrimSpace(token) - if token == "" { - continue - } - if isASCIIWordToken(token) { - if matchesWordBoundary(titleFold, token) { - return true - } - continue - } - if strings.Contains(titleFold, token) { - return true - } - } - return false -} - -func isASCIIWordToken(token string) bool { - for _, r := range token { - if r > unicode.MaxASCII || !(unicode.IsLetter(r) || unicode.IsDigit(r)) { - return false - } - } - return token != "" -} - -// matchesWordBoundary 判断 token 是否作为独立词出现在 title 中,词边界为「非字母数字」。 -func matchesWordBoundary(titleFold, token string) bool { - isWordRune := func(r rune) bool { - return unicode.IsLetter(r) || unicode.IsDigit(r) - } - from := 0 - for { - idx := strings.Index(titleFold[from:], token) - if idx < 0 { - return false - } - start := from + idx - end := start + len(token) - leftOK := start == 0 || !isWordRune(rune(titleFold[start-1])) - rightOK := end >= len(titleFold) || !isWordRune(rune(titleFold[end])) - if leftOK && rightOK { - return true - } - from = start + 1 - if from >= len(titleFold) { - return false - } - } -} - -func containsAnyEffect(titleFold, csv string) bool { - for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool { - return r == ',' || r == '/' || r == '|' || r == ';' || r == ',' - }) { - token = strings.TrimSpace(token) - if token == "" { - continue - } - switch token { - case "dolby-vision", "dolby vision", "dv": - if strings.Contains(titleFold, "dolby vision") || strings.Contains(titleFold, "dovi") || regexp.MustCompile(`\bdv\b`).MatchString(titleFold) { - return true - } - default: - if strings.Contains(titleFold, token) { - return true - } - } - } - return false -} - -func titleMatchesResolution(titleFold, resolution string) bool { - switch strings.ToLower(strings.TrimSpace(resolution)) { - case "2160p", "4k", "uhd": - return strings.Contains(titleFold, "2160p") || strings.Contains(titleFold, "4k") || strings.Contains(titleFold, "uhd") - case "1080p": - return strings.Contains(titleFold, "1080p") || strings.Contains(titleFold, "fhd") - case "720p": - return strings.Contains(titleFold, "720p") - default: - return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(resolution))) - } -} - -func titleMatchesQuality(titleFold, quality string) bool { - switch strings.ToLower(strings.TrimSpace(quality)) { - case "webdl", "web-dl": - return strings.Contains(titleFold, "web-dl") || strings.Contains(titleFold, "webdl") - case "bluray", "blu-ray": - return strings.Contains(titleFold, "bluray") || strings.Contains(titleFold, "blu-ray") || strings.Contains(titleFold, "bdrip") - case "remux": - return strings.Contains(titleFold, "remux") - case "hdtv": - return strings.Contains(titleFold, "hdtv") - default: - return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(quality))) - } -} - -func detectResolutionScore(titleFold string) int { - switch { - case titleMatchesResolution(titleFold, "2160p"): - return 4 - case titleMatchesResolution(titleFold, "1080p"): - return 3 - case titleMatchesResolution(titleFold, "720p"): - return 2 - default: - return 1 - } -} - -func detectQualityScore(titleFold string) int { - switch { - case titleMatchesQuality(titleFold, "remux"): - return 5 - case titleMatchesQuality(titleFold, "bluray"): - return 4 - case titleMatchesQuality(titleFold, "web-dl"): - return 3 - case titleMatchesQuality(titleFold, "hdtv"): - return 2 - default: - return 1 - } -} - -func detectEffectScore(titleFold string) int { - score := 0 - if containsAnyEffect(titleFold, "dolby-vision") { - score += 4 - } - if strings.Contains(titleFold, "hdr10+") { - score += 3 - } else if strings.Contains(titleFold, "hdr") { - score += 2 - } - if strings.Contains(titleFold, "atmos") { - score += 2 - } - return score + return strings.Join(excludeWordTokens(csv), ",") } func isSubscriptionSeriesType(mediaType string) bool { @@ -276,15 +52,3 @@ func isSubscriptionSeriesType(mediaType string) bool { return false } } - -func isSeriesPackTitle(title string) bool { - title = strings.TrimSpace(title) - if title == "" { - return false - } - if seriesPackRE.MatchString(title) { - return true - } - _, episode := ParseEpisode(title) - return episode == 0 && seasonOnlyRE.MatchString(title) -} diff --git a/internal/service/subscription_rules_test.go b/internal/service/subscription_rules_test.go index 4d905ad..fc3051c 100644 --- a/internal/service/subscription_rules_test.go +++ b/internal/service/subscription_rules_test.go @@ -24,6 +24,42 @@ func TestMatchesSubscriptionRulesUserExcludeWords(t *testing.T) { } } +func TestMatchesSubscriptionRulesReleaseStyleExcludeWords(t *testing.T) { + cases := []struct { + name string + sub *model.Subscription + title string + }{ + { + name: "default excludes ddp channel suffix", + sub: &model.Subscription{}, + title: "Some Show 2026 S01E01 1080p WEB-DL DDP5.1 H264", + }, + { + name: "default excludes dolby glued word", + sub: &model.Subscription{}, + title: "Some Movie 2026 1080p WEB-DL DolbyVision H264", + }, + { + name: "custom dotted list excludes split tokens", + sub: &model.Subscription{ExcludeWords: "DoVi.H265.10bit.杜比"}, + title: "Some Movie 2026 1080p WEB-DL H265", + }, + { + name: "custom dotted list excludes cjk split token", + sub: &model.Subscription{ExcludeWords: "DoVi.H265.10bit.杜比"}, + title: "某电影 2026 1080p 杜比全景声", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if matchesSubscriptionRules(c.sub, c.title) { + t.Fatalf("expected exclude words to reject %q", c.title) + } + }) + } +} + func TestMatchesSubscriptionRulesDefaultExcludesJunkReleases(t *testing.T) { sub := &model.Subscription{} for _, title := range []string{ diff --git a/internal/service/subscription_score.go b/internal/service/subscription_score.go new file mode 100644 index 0000000..3a62be8 --- /dev/null +++ b/internal/service/subscription_score.go @@ -0,0 +1,137 @@ +package service + +import ( + "regexp" + "strings" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func subscriptionCandidateScore(sub *model.Subscription, item SearchResult) int { + title := strings.ToLower(subscriptionSearchResultText(item)) + score := item.Seeders + if sub == nil || !sub.WashEnabled { + if item.Free { + score += 25 + } + return score + } + resolutionScore := detectResolutionScore(title) + qualityScore := detectQualityScore(title) + effectScore := detectEffectScore(title) + + priority := "balanced" + if sub != nil && strings.TrimSpace(sub.WashPriority) != "" { + priority = strings.ToLower(strings.TrimSpace(sub.WashPriority)) + } + switch priority { + case "resolution": + score += resolutionScore*1000 + qualityScore*100 + effectScore*50 + case "quality": + score += qualityScore*1000 + resolutionScore*200 + effectScore*50 + case "effects": + score += effectScore*1000 + resolutionScore*200 + qualityScore*100 + case "seeders": + score += qualityScore*3 + resolutionScore*2 + effectScore + default: + score += resolutionScore*500 + qualityScore*300 + effectScore*150 + } + if item.Free { + score += 25 + } + return score +} + +func containsAnyEffect(titleFold, csv string) bool { + for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool { + return r == ',' || r == '/' || r == '|' || r == ';' || r == ',' + }) { + token = strings.TrimSpace(token) + if token == "" { + continue + } + switch token { + case "dolby-vision", "dolby vision", "dv": + if strings.Contains(titleFold, "dolby vision") || strings.Contains(titleFold, "dovi") || regexp.MustCompile(`\bdv\b`).MatchString(titleFold) { + return true + } + default: + if strings.Contains(titleFold, token) { + return true + } + } + } + return false +} + +func titleMatchesResolution(titleFold, resolution string) bool { + switch strings.ToLower(strings.TrimSpace(resolution)) { + case "2160p", "4k", "uhd": + return strings.Contains(titleFold, "2160p") || strings.Contains(titleFold, "4k") || strings.Contains(titleFold, "uhd") + case "1080p": + return strings.Contains(titleFold, "1080p") || strings.Contains(titleFold, "fhd") + case "720p": + return strings.Contains(titleFold, "720p") + default: + return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(resolution))) + } +} + +func titleMatchesQuality(titleFold, quality string) bool { + switch strings.ToLower(strings.TrimSpace(quality)) { + case "webdl", "web-dl": + return strings.Contains(titleFold, "web-dl") || strings.Contains(titleFold, "webdl") + case "bluray", "blu-ray": + return strings.Contains(titleFold, "bluray") || strings.Contains(titleFold, "blu-ray") || strings.Contains(titleFold, "bdrip") + case "remux": + return strings.Contains(titleFold, "remux") + case "hdtv": + return strings.Contains(titleFold, "hdtv") + default: + return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(quality))) + } +} + +func detectResolutionScore(titleFold string) int { + switch { + case titleMatchesResolution(titleFold, "2160p"): + return 4 + case titleMatchesResolution(titleFold, "1080p"): + return 3 + case titleMatchesResolution(titleFold, "720p"): + return 2 + default: + return 1 + } +} + +func detectQualityScore(titleFold string) int { + switch { + case titleMatchesQuality(titleFold, "remux"): + return 5 + case titleMatchesQuality(titleFold, "bluray"): + return 4 + case titleMatchesQuality(titleFold, "web-dl"): + return 3 + case titleMatchesQuality(titleFold, "hdtv"): + return 2 + default: + return 1 + } +} + +func detectEffectScore(titleFold string) int { + score := 0 + if containsAnyEffect(titleFold, "dolby-vision") { + score += 4 + } + if strings.Contains(titleFold, "hdr10+") { + score += 3 + } else if strings.Contains(titleFold, "hdr") { + score += 2 + } + if strings.Contains(titleFold, "atmos") { + score += 2 + } + return score +} diff --git a/internal/service/subscription_site_search.go b/internal/service/subscription_site_search.go index 76760c0..12b0244 100644 --- a/internal/service/subscription_site_search.go +++ b/internal/service/subscription_site_search.go @@ -69,15 +69,42 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs func (s *SubscriptionService) finishSiteSearchRun(ctx context.Context, sub *model.Subscription, guidKey string, state *siteSearchRunState) LocalAvailability { availability := s.finalizePendingAvailability(sub, state.Availability) seen := trimSiteSearchSeen(state.Seen) - _ = s.repo.Setting.Set(ctx, guidKey, strings.Join(seen, "\n")) + if err := s.repo.Setting.Set(ctx, guidKey, strings.Join(seen, "\n")); err != nil && s.log != nil { + s.log.Warn("site-search subscription seen state update failed", + zap.String("subscription_id", sub.ID), + zap.String("subscription", sub.Name), + zap.Error(err)) + } now := time.Now() - _ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error - _ = s.archiveCompletedSubscription(ctx, sub, availability) + if err := s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error; err != nil && s.log != nil { + s.log.Warn("site-search subscription last_run_at update failed", + zap.String("subscription_id", sub.ID), + zap.String("subscription", sub.Name), + zap.Error(err)) + } + if err := s.archiveCompletedSubscription(ctx, sub, availability); err != nil && s.log != nil { + s.log.Warn("site-search subscription archive check failed", + zap.String("subscription_id", sub.ID), + zap.String("subscription", sub.Name), + zap.Error(err)) + } return availability } func (s *SubscriptionService) handleSiteSearchQueueResult(sub *model.Subscription, keyword string, queueResult siteSearchQueueResult, selectionStats siteSearchSelectionStats, availability LocalAvailability) (int, error) { if queueResult.Queued > 0 { + if s.log != nil { + fields := subscriptionSiteSearchLogFields(sub, keyword) + fields = appendSiteSearchSelectionLogFields(fields, selectionStats) + fields = appendAvailabilityLogFields(fields, availability) + fields = append(fields, + zap.Int("queued", queueResult.Queued), + zap.Strings("resources", queueResult.Resources), + zap.Bool("archived", sub.ArchivedAt != nil), + zap.String("archive_reason", sub.ArchiveReason), + ) + s.log.Info("site-search subscription queued resources", fields...) + } s.hub.Publish("subscription", map[string]any{ "id": sub.ID, "name": sub.Name, @@ -186,7 +213,12 @@ func (s *SubscriptionService) finishSiteSearchNoResults(sub *model.Subscription, s.log.Info("site-search subscription no results", fields...) } now := time.Now() - _ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error + if err := s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error; err != nil && s.log != nil { + s.log.Warn("site-search subscription last_run_at update failed", + zap.String("subscription_id", sub.ID), + zap.String("subscription", sub.Name), + zap.Error(err)) + } return 0, nil } diff --git a/web/src/api/recognitionWords.ts b/web/src/api/recognitionWords.ts new file mode 100644 index 0000000..952b707 --- /dev/null +++ b/web/src/api/recognitionWords.ts @@ -0,0 +1,30 @@ +import { api } from './client' + +export interface RecognitionWordsConfig { + enabled: boolean + local_text: string + shared_urls: string[] + shared_text?: string + synced_at?: string + rule_count: number +} + +export interface RecognitionWordsTestResult { + input: string + output: string + title: string + year: number + changed: boolean +} + +export const recognitionWordsAPI = { + get: () => api.get('/admin/recognition-words').then((r) => r.data), + + save: (payload: RecognitionWordsConfig) => + api.put('/admin/recognition-words', payload).then((r) => r.data), + + sync: () => api.post('/admin/recognition-words/sync').then((r) => r.data), + + test: (input: string) => + api.post('/admin/recognition-words/test', { input }).then((r) => r.data), +} diff --git a/web/src/api/subscriptions.ts b/web/src/api/subscriptions.ts index b284aa7..2d1d783 100644 --- a/web/src/api/subscriptions.ts +++ b/web/src/api/subscriptions.ts @@ -42,10 +42,14 @@ export function buildSubscriptionAliases(item: { export const subscriptionsAPI = { list: () => - api.get<{ items: Subscription[] }>('/subscriptions').then((r) => r.data.items), + api + .get<{ items: Subscription[] }>('/subscriptions', subscriptionListRequestConfig()) + .then((r) => r.data.items), history: () => - api.get<{ items: Subscription[] }>('/subscriptions/history').then((r) => r.data.items), + api + .get<{ items: Subscription[] }>('/subscriptions/history', subscriptionListRequestConfig()) + .then((r) => r.data.items), create: (input: { name: string @@ -86,3 +90,10 @@ export const subscriptionsAPI = { runNow: (id: string) => api.post<{ queued: number }>(`/subscriptions/${id}/run`).then((r) => r.data), } + +function subscriptionListRequestConfig() { + return { + headers: { 'Cache-Control': 'no-cache' }, + params: { _ts: Date.now() }, + } +} diff --git a/web/src/components/LayoutHeaderSections.tsx b/web/src/components/LayoutHeaderSections.tsx new file mode 100644 index 0000000..fd39c8d --- /dev/null +++ b/web/src/components/LayoutHeaderSections.tsx @@ -0,0 +1,204 @@ +import { Link } from 'react-router-dom' +import { Menu, MessageSquareText, Search, Sparkles } from 'lucide-react' + +import type { PlayProfile, User } from '../types' +import { LayoutSearchBox } from './LayoutSearchBox' +import { LayoutThemeToggle } from './LayoutThemeToggle' +import { LayoutUserMenu } from './LayoutUserMenu' +import type { useLayoutProfiles } from './useLayoutProfiles' +import type { useLayoutSearch } from './useLayoutSearch' +import type { ThemeMode, useThemeMode } from './useThemeMode' + +type LayoutSearchState = ReturnType +type LayoutProfileState = ReturnType +type LayoutThemeState = ReturnType + +type LayoutPermissionState = { + can: (key: string) => boolean + isAdmin: boolean +} + +type LayoutHeaderProps = { + search: LayoutSearchState + permissions: LayoutPermissionState + theme: LayoutThemeState + onOpenMobileDrawer: () => void + user: User | null | undefined + activeProfileId: string | null + profile: LayoutProfileState + onLogout: () => void +} + +export function LayoutHeader({ + search, + permissions, + theme, + onOpenMobileDrawer, + user, + activeProfileId, + profile, + onLogout, +}: LayoutHeaderProps) { + return ( +
+ + profile.setIsProfileOpen((open) => !open)} + onCloseProfile={() => profile.setIsProfileOpen(false)} + onUseDefaultProfile={profile.useDefaultProfile} + onSwitchProfile={profile.switchProfile} + onLogout={onLogout} + /> +
+ ) +} + +function LayoutHeaderSearch({ + search, + onOpenMobileDrawer, +}: { + search: LayoutSearchState + onOpenMobileDrawer: () => void +}) { + return ( +
+ + +
+ ) +} + +type LayoutHeaderActionsProps = { + permissions: LayoutPermissionState + themeMode: ThemeMode + onThemeChange: (mode: ThemeMode) => void + user: User | null | undefined + isProfileOpen: boolean + profiles: PlayProfile[] + activeProfileId: string | null + activeProfile: PlayProfile | null + onToggleProfile: () => void + onCloseProfile: () => void + onUseDefaultProfile: () => void + onSwitchProfile: (profile: PlayProfile) => void + onLogout: () => void +} + +function LayoutHeaderActions({ + permissions, + themeMode, + onThemeChange, + user, + isProfileOpen, + profiles, + activeProfileId, + activeProfile, + onToggleProfile, + onCloseProfile, + onUseDefaultProfile, + onSwitchProfile, + onLogout, +}: LayoutHeaderActionsProps) { + return ( +
+ + + + +
+ ) +} + +function LayoutQuickActions({ permissions }: { permissions: LayoutPermissionState }) { + return ( + <> + + + + {permissions.can('can_view_discover') && ( + + + 发现新片 + + )} + {permissions.isAdmin && ( + + + + )} + + ) +} + +function LayoutProfileMenu({ + user, + isProfileOpen, + profiles, + activeProfileId, + activeProfile, + onToggleProfile, + onCloseProfile, + onUseDefaultProfile, + onSwitchProfile, + onLogout, +}: Omit) { + return ( + + ) +} diff --git a/web/src/components/LayoutSections.tsx b/web/src/components/LayoutSections.tsx index 261d096..a092e28 100644 --- a/web/src/components/LayoutSections.tsx +++ b/web/src/components/LayoutSections.tsx @@ -1,29 +1,12 @@ -import { Link, Outlet } from 'react-router-dom' +import { Outlet } from 'react-router-dom' import { AnimatePresence, motion } from 'framer-motion' -import { Menu, MessageSquareText, Search, Sparkles } from 'lucide-react' import clsx from 'clsx' -import type { PlayProfile, User } from '../types' import { AppFooter } from './AppFooter' -import { LayoutSearchBox } from './LayoutSearchBox' import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent' -import { LayoutThemeToggle } from './LayoutThemeToggle' -import { LayoutUserMenu } from './LayoutUserMenu' -import type { ThemeMode } from './useThemeMode' -import type { useLayoutSearch } from './useLayoutSearch' -import type { useLayoutProfiles } from './useLayoutProfiles' import type { useLayoutSidebar } from './useLayoutSidebar' -import type { useThemeMode } from './useThemeMode' -type LayoutSearchState = ReturnType -type LayoutProfileState = ReturnType type LayoutSidebarState = ReturnType -type LayoutThemeState = ReturnType - -type LayoutPermissionState = { - can: (key: string) => boolean - isAdmin: boolean -} type LayoutSidebarProps = { children: React.ReactNode @@ -36,17 +19,6 @@ type LayoutMobileSidebarProps = { onClose: () => void } -type LayoutHeaderProps = { - search: LayoutSearchState - permissions: LayoutPermissionState - theme: LayoutThemeState - onOpenMobileDrawer: () => void - user: User | null | undefined - activeProfileId: string | null - profile: LayoutProfileState - onLogout: () => void -} - type LayoutSidebarsProps = Omit< LayoutSidebarContentProps, 'isSidebarOpen' | 'isMobileDrawerOpen' | 'openGroups' | 'isRouteIn' | 'onToggleGroup' | 'onToggleSidebar' | 'onCloseMobileDrawer' @@ -58,6 +30,8 @@ type LayoutWorkspaceProps = { routeKey: string } +export { LayoutHeader } from './LayoutHeaderSections' + export function LayoutDesktopSidebar({ children, isSidebarOpen }: LayoutSidebarProps) { return (