From 65adca04df3ad64c6c64b9b5d28cc6c919b46b9f Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:42:16 +0800 Subject: [PATCH] fix media workflows and download organization --- internal/handler/discover_extra.go | 34 +- internal/handler/emby_playstate_handlers.go | 2 +- internal/handler/manual_scrape.go | 8 +- internal/handler/media.go | 6 +- internal/handler/media_favorite.go | 1 + internal/handler/playback_extra_test.go | 83 +++++ internal/handler/scrape_reclassify.go | 37 +++ internal/handler/storage_config.go | 11 + internal/handler/storage_config_test.go | 73 +++++ internal/handler/streaming.go | 11 + internal/handler/subscription_extra.go | 60 ++-- internal/handler/subscriptions.go | 3 +- internal/repository/bot_repo.go | 31 ++ internal/service/bot_device_policy_test.go | 84 +++++ internal/service/cloud/clouddrive2_dav.go | 6 + .../service/cloud/clouddrive2_openlist.go | 11 +- internal/service/device_service.go | 140 +++++++- internal/service/discover.go | 20 +- internal/service/download_active_paths.go | 36 ++ internal/service/download_completion.go | 11 +- internal/service/download_completion_state.go | 15 +- internal/service/download_polling.go | 2 +- internal/service/downloads_test.go | 125 ++++++- internal/service/media.go | 115 ++++++- internal/service/media_classifier.go | 13 + internal/service/media_test.go | 171 ++++++++++ internal/service/metadata_match_trust.go | 84 +++++ internal/service/organizer.go | 28 +- .../service/organizer_active_downloads.go | 94 ++++++ .../service/organizer_classification_test.go | 14 +- internal/service/organizer_directory.go | 13 + ...organizer_directory_classification_test.go | 44 +++ .../service/organizer_directory_libraries.go | 26 +- .../service/organizer_directory_metadata.go | 1 + .../service/organizer_directory_reclassify.go | 128 +++++++- .../service/organizer_directory_source.go | 1 + internal/service/organizer_directory_test.go | 43 +++ .../service/organizer_reclassify_scanned.go | 308 +++++++++++++++++- internal/service/organizer_reclassify_test.go | 262 ++++++++++++++- internal/service/scanner.go | 25 +- internal/service/scanner_incremental_test.go | 35 ++ internal/service/scanner_local_write_batch.go | 92 +++++- internal/service/scanner_post_scan.go | 13 +- internal/service/scanner_strm.go | 9 +- internal/service/scanner_strm_test.go | 75 +++++ internal/service/scheduler.go | 12 +- internal/service/scheduler_test.go | 15 + internal/service/scraper.go | 1 + internal/service/scraper_query.go | 4 +- internal/service/scraper_query_test.go | 55 ++++ internal/service/service_builder.go | 2 + internal/service/session_tracker.go | 17 +- internal/service/session_tracker_test.go | 38 +++ internal/service/site.go | 63 ++-- internal/service/site_adapter_test.go | 6 +- internal/service/site_rate_limiter.go | 4 +- internal/service/site_test.go | 34 ++ internal/service/storage_config.go | 89 ++++- internal/service/storage_config_probe.go | 44 +-- internal/service/storage_upload_test.go | 105 ++++++ internal/service/stream.go | 22 +- internal/service/stream_test.go | 57 ++++ internal/service/strm_generate.go | 21 +- internal/service/strm_output_dir.go | 24 ++ internal/service/strm_svc_test.go | 65 +++- internal/service/strm_url.go | 18 +- internal/service/subscription.go | 96 +++++- internal/service/subscription_archive.go | 41 ++- internal/service/subscription_archive_test.go | 25 ++ internal/service/subscription_availability.go | 2 +- .../service/subscription_availability_test.go | 84 +++++ ...ubscription_candidate_availability_test.go | 50 +++ .../service/subscription_candidate_collect.go | 2 +- .../service/subscription_candidate_select.go | 32 +- .../subscription_candidate_selection_test.go | 216 ++++++++++++ .../service/subscription_episode_totals.go | 88 ++++- .../service/subscription_metadata_prepare.go | 105 ++++++ .../subscription_metadata_prepare_test.go | 76 +++++ internal/service/subscription_site_search.go | 51 +++ .../subscription_site_search_helpers.go | 12 +- internal/service/telegram_user_self.go | 20 +- internal/service/tmdb.go | 33 +- internal/service/tmdb_search.go | 8 +- web/src/api/discover.ts | 32 +- .../components/ManualScrapeDialogSections.tsx | 44 +-- web/src/components/MediaCard.tsx | 8 + web/src/pages/AIAssistantExternalResults.tsx | 132 +++----- web/src/pages/CloudBrowser.tsx | 4 +- web/src/pages/DiscoverContentRow.tsx | 35 +- web/src/pages/DiscoverPage.tsx | 28 +- web/src/pages/DiscoverPageSections.tsx | 9 + web/src/pages/SearchExternalResults.tsx | 67 ++-- web/src/pages/SearchPage.tsx | 2 - web/src/pages/StorageForm.tsx | 20 +- web/src/pages/SubscriptionsPage.tsx | 2 +- web/src/pages/discoverDetailModalModel.ts | 1 + web/src/pages/settingsGroupSubscriptions.ts | 16 + web/src/pages/settingsGroups.ts | 2 + web/src/pages/useCloudBrowser.ts | 10 +- web/src/pages/useLibraryData.ts | 2 +- web/src/pages/useSearchPage.ts | 39 +-- web/src/types/index.ts | 6 + 102 files changed, 4063 insertions(+), 437 deletions(-) create mode 100644 internal/handler/scrape_reclassify.go create mode 100644 internal/service/download_active_paths.go create mode 100644 internal/service/organizer_active_downloads.go create mode 100644 internal/service/scanner_strm_test.go create mode 100644 internal/service/subscription_metadata_prepare.go create mode 100644 internal/service/subscription_metadata_prepare_test.go create mode 100644 web/src/pages/settingsGroupSubscriptions.ts diff --git a/internal/handler/discover_extra.go b/internal/handler/discover_extra.go index 94ae5f0..6c65a24 100644 --- a/internal/handler/discover_extra.go +++ b/internal/handler/discover_extra.go @@ -9,6 +9,7 @@ package handler import ( "context" "net/http" + "strconv" "strings" "github.com/gin-gonic/gin" @@ -60,8 +61,13 @@ func discoverFeedHandler(svc *service.Container) gin.HandlerFunc { if strings.TrimSpace(rawSections) == "" { rawSections = strings.Join(defaultDiscoverSectionKeys(c.Request.Context(), svc), ",") } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + if page < 1 { + page = 1 + } keys := strings.Split(rawSections, ",") out := gin.H{} + meta := gin.H{} artworkItems := []service.ExternalMediaResult{} for _, raw := range keys { k := strings.TrimSpace(raw) @@ -70,16 +76,19 @@ func discoverFeedHandler(svc *service.Container) gin.HandlerFunc { } if provider := discoverSectionProvider(k); provider != "" && !discoverProviderEnabled(c.Request.Context(), svc, provider) { out[k] = []service.ExternalMediaResult{} + meta[k] = gin.H{"page": page, "has_next": false} continue } - items, err := discoverSectionItems(c.Request.Context(), svc, k) + items, err := discoverSectionItems(c.Request.Context(), svc, k, page) if err != nil { svc.Log.Debug("discover fetch failed") items = nil } artworkItems = append(artworkItems, items...) out[k] = items + meta[k] = gin.H{"page": page, "has_next": discoverSectionHasNext(k, len(items))} } + out["_meta"] = meta svc.Discover.WarmExternalArtwork(artworkItems) c.JSON(http.StatusOK, out) } @@ -145,22 +154,39 @@ func discoverProviderEnabled(ctx context.Context, svc *service.Container, provid return cfg.Enabled } -func discoverSectionItems(ctx context.Context, svc *service.Container, k string) ([]service.ExternalMediaResult, error) { +func discoverSectionItems(ctx context.Context, svc *service.Container, k string, page int) ([]service.ExternalMediaResult, error) { switch k { case "tmdb_trending_day", "tmdb_trending_week", "tmdb_latest_movie", "tmdb_latest_tv", "tmdb_popular_movie", "tmdb_popular_tv", "tmdb_top_rated_movie", "tmdb_upcoming_movie", "trending_day", "trending_week", "latest_movie", "latest_tv", "popular_movie", "popular_tv", "top_rated_movie", "upcoming_movie": - return svc.Discover.TMDbSection(ctx, k) + return svc.Discover.TMDbSection(ctx, k, page) case "douban_hot_movie", "douban_hot_tv", "douban_top_movie": if svc.Douban == nil { return []service.ExternalMediaResult{}, nil } - return svc.Douban.Discover(ctx, k) + return svc.Douban.Discover(ctx, k, page) case "bangumi_calendar": if svc.Bangumi == nil { return []service.ExternalMediaResult{}, nil } + if page > 1 { + return []service.ExternalMediaResult{}, nil + } return svc.Bangumi.Calendar(ctx) default: return []service.ExternalMediaResult{}, nil } } + +func discoverSectionHasNext(key string, itemCount int) bool { + if itemCount <= 0 { + return false + } + switch discoverSectionProvider(key) { + case "tmdb": + return itemCount >= 20 + case "douban": + return itemCount >= 24 + default: + return false + } +} diff --git a/internal/handler/emby_playstate_handlers.go b/internal/handler/emby_playstate_handlers.go index 96023f7..c8c58af 100644 --- a/internal/handler/emby_playstate_handlers.go +++ b/internal/handler/emby_playstate_handlers.go @@ -39,7 +39,7 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc { return } clientInfo := embyClientInfoFromRequest(c) - if svc.Device != nil && svc.Device.IsDeviceKicked(c.Request.Context(), uid, clientInfo.DeviceID) { + if svc.Device != nil && svc.Device.IsTerminalKicked(c.Request.Context(), uid, clientInfo.DeviceID, clientInfo.DeviceName, clientInfo.Client) { c.Status(http.StatusUnauthorized) return } diff --git a/internal/handler/manual_scrape.go b/internal/handler/manual_scrape.go index 3b0f19b..7ed7bfe 100644 --- a/internal/handler/manual_scrape.go +++ b/internal/handler/manual_scrape.go @@ -61,11 +61,16 @@ func manualScrapeApplyOneHandler(svc *service.Container) gin.HandlerFunc { } applyCtx, cancel := manualScrapeApplyContext(c) defer cancel() - media, err := svc.Scraper.ApplyManualMatch(applyCtx, c.Param("id"), req) + mediaID := c.Param("id") + media, err := svc.Scraper.ApplyManualMatch(applyCtx, mediaID, req) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + reclassifyMediaAfterScrape(applyCtx, svc, mediaID) + if refreshed, _ := svc.Repo.Media.FindByID(applyCtx, mediaID); refreshed != nil { + media = refreshed + } c.JSON(http.StatusOK, media) } } @@ -92,6 +97,7 @@ func manualScrapeApplyBatchHandler(svc *service.Container) gin.HandlerFunc { errorsOut = append(errorsOut, id+": "+err.Error()) continue } + reclassifyMediaAfterScrape(applyCtx, svc, id) applied++ } if applied == 0 && len(errorsOut) > 0 { diff --git a/internal/handler/media.go b/internal/handler/media.go index 37044d0..af9a174 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -341,7 +341,7 @@ func streamHandler(svc *service.Container) gin.HandlerFunc { if !enforceScopedPlaybackToken(c, m.ID) { return } - err = svc.Stream.ServeFileWithCloudMode(c.Writer, c.Request, c.Param("id"), service.CloudPlaybackModeSTRM) + err = svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id")) if errors.Is(err, service.ErrMediaNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return @@ -350,6 +350,10 @@ func streamHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) return } + if errors.Is(err, service.ErrCloudPlaybackUnavailable) { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/internal/handler/media_favorite.go b/internal/handler/media_favorite.go index 9dbeddc..61fc140 100644 --- a/internal/handler/media_favorite.go +++ b/internal/handler/media_favorite.go @@ -94,6 +94,7 @@ func aiScrapeMediaHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + reclassifyMediaAfterScrape(c.Request.Context(), svc, m.ID) refreshed, _ := svc.Repo.Media.FindByID(c.Request.Context(), m.ID) c.JSON(http.StatusOK, refreshed) } diff --git a/internal/handler/playback_extra_test.go b/internal/handler/playback_extra_test.go index d3a6127..ac5fe3b 100644 --- a/internal/handler/playback_extra_test.go +++ b/internal/handler/playback_extra_test.go @@ -84,6 +84,23 @@ func TestExternalURLUsesMediaScopedPlaybackToken(t *testing.T) { } } +func TestStreamCloudMediaWithoutSTRMURLReturnsBadGateway(t *testing.T) { + router, _, secret := newPlaybackScopeTestRouter(t) + loginToken := signedTestToken(t, secret) + + req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/stream/media-missing-strm", nil) + req.Header.Set("Authorization", "Bearer "+loginToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadGateway { + t.Fatalf("status = %d body=%s, want 502", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), service.ErrCloudPlaybackUnavailable.Error()) { + t.Fatalf("body = %q, want cloud playback unavailable error", w.Body.String()) + } +} + func TestExternalURLPrefersBrowserPublicOriginOverForwardedSource(t *testing.T) { router, _, secret := newPlaybackScopeTestRouter(t) loginToken := signedTestToken(t, secret) @@ -215,6 +232,29 @@ func TestExternalURLPrefersConfiguredPublicServerURLOverLocalBrowserOrigin(t *te } } +func TestAPIStreamAllowsRedirectPlaybackWhenSTRMModeDisabled(t *testing.T) { + router, svc, secret := newPlaybackScopeTestRouter(t) + if err := svc.Repo.Setting.Set(t.Context(), service.CloudPlaybackSTRMEnabledSettingKey, "false"); err != nil { + t.Fatal(err) + } + if err := svc.Repo.Setting.Set(t.Context(), service.CloudPlaybackRedirectEnabledSettingKey, "true"); err != nil { + t.Fatal(err) + } + loginToken := signedTestToken(t, secret) + + req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/stream/media-1?api_key="+url.QueryEscape(loginToken), nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusFound { + t.Fatalf("status = %d body=%s, want 302", w.Code, w.Body.String()) + } + loc := w.Header().Get("Location") + if !strings.Contains(loc, "/api/cloud/play/openlist?") || !strings.Contains(loc, "token="+url.QueryEscape(loginToken)) { + t.Fatalf("redirect Location should target tokenized cloud play endpoint, got %q", loc) + } +} + func TestScopedPlaybackTokenCannotStreamAnotherMedia(t *testing.T) { router, svc, _ := newPlaybackScopeTestRouter(t) user, err := svc.Repo.User.FindByID(t.Context(), "user-1") @@ -235,6 +275,43 @@ func TestScopedPlaybackTokenCannotStreamAnotherMedia(t *testing.T) { } } +func TestScopedPlaybackTokenCanFollowCloudRedirectForSameMedia(t *testing.T) { + router, svc, _ := newPlaybackScopeTestRouter(t) + user, err := svc.Repo.User.FindByID(t.Context(), "user-1") + if err != nil || user == nil { + t.Fatalf("find user: %v", err) + } + playToken, err := svc.Auth.IssueExternalPlaybackToken(user, "media-1", 2*60*60) + if err != nil { + t.Fatalf("issue playback token: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/stream/media-1?token="+url.QueryEscape(playToken), nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusFound { + t.Fatalf("stream status = %d body=%s, want 302", w.Code, w.Body.String()) + } + loc := w.Header().Get("Location") + if !strings.Contains(loc, "/api/cloud/play/openlist?") || + !strings.Contains(loc, "media_id=media-1") || + !strings.Contains(loc, "token="+url.QueryEscape(playToken)) { + t.Fatalf("redirect Location should carry scoped token and media_id, got %q", loc) + } + + req = httptest.NewRequest(http.MethodGet, loc, nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code == http.StatusForbidden || w.Code == http.StatusUnauthorized { + t.Fatalf("cloud redirect rejected scoped token: status=%d body=%s", w.Code, w.Body.String()) + } + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("cloud redirect status = %d body=%s, want storage service fallback 503", w.Code, w.Body.String()) + } +} + func TestScopedPlaybackTokenCannotRetargetCloudRef(t *testing.T) { router, svc, _ := newPlaybackScopeTestRouter(t) user, err := svc.Repo.User.FindByID(t.Context(), "user-1") @@ -370,6 +447,12 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container, Path: "cloud://openlist/Movies/Other.mkv", STRMURL: "/api/cloud/play/openlist?ref=/Movies/Other.mkv", }, + { + Base: model.Base{ID: "media-missing-strm"}, + LibraryID: lib.ID, + Title: "Cloud missing STRM", + Path: "cloud://openlist/Movies/Missing.mkv", + }, } if err := repos.DB.Create(&rows).Error; err != nil { t.Fatal(err) diff --git a/internal/handler/scrape_reclassify.go b/internal/handler/scrape_reclassify.go new file mode 100644 index 0000000..e06dbbf --- /dev/null +++ b/internal/handler/scrape_reclassify.go @@ -0,0 +1,37 @@ +package handler + +import ( + "context" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/service" +) + +func reclassifyMediaAfterScrape(ctx context.Context, svc *service.Container, mediaIDs ...string) int { + if svc == nil || svc.Organizer == nil { + return 0 + } + res, err := svc.Organizer.ReclassifyMisclassifiedMedia(ctx, service.MediaCategoryReclassifyOptions{MediaIDs: mediaIDs}) + if err != nil { + if svc.Log != nil { + svc.Log.Warn("scrape reclassify media failed", zap.Strings("media_ids", mediaIDs), zap.Error(err)) + } + return 0 + } + return res.Reclassified +} + +func reclassifyLibraryAfterScrape(ctx context.Context, svc *service.Container, libraryIDs ...string) int { + if svc == nil || svc.Organizer == nil { + return 0 + } + res, err := svc.Organizer.ReclassifyMisclassifiedMedia(ctx, service.MediaCategoryReclassifyOptions{LibraryIDs: libraryIDs}) + if err != nil { + if svc.Log != nil { + svc.Log.Warn("scrape reclassify library failed", zap.Strings("library_ids", libraryIDs), zap.Error(err)) + } + return 0 + } + return res.Reclassified +} diff --git a/internal/handler/storage_config.go b/internal/handler/storage_config.go index 481eae5..032ed54 100644 --- a/internal/handler/storage_config.go +++ b/internal/handler/storage_config.go @@ -85,6 +85,17 @@ func testStorageConfigHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()}) return } + if service.IsAdminCloudConfigurable(in.Type) { + enabled := true + if _, err := svc.StorageCfg.Save(c.Request.Context(), service.StorageInput{ + Type: in.Type, + Config: in.Config, + Enabled: &enabled, + }); err != nil { + c.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()}) + return + } + } c.JSON(http.StatusOK, gin.H{"ok": true}) } } diff --git a/internal/handler/storage_config_test.go b/internal/handler/storage_config_test.go index c4966cc..dffe2b8 100644 --- a/internal/handler/storage_config_test.go +++ b/internal/handler/storage_config_test.go @@ -1,12 +1,20 @@ package handler import ( + "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" + "github.com/ShukeBta/MediaStationGo/internal/service" ) func TestStorageConfigHandlersRejectQuark(t *testing.T) { @@ -26,3 +34,68 @@ func TestStorageConfigHandlersRejectQuark(t *testing.T) { t.Fatalf("body = %s, want unsupported storage type", w.Body.String()) } } + +func TestStorageConfigCloudTestSuccessSavesAndEnablesProvider(t *testing.T) { + gin.SetMode(gin.TestMode) + openlist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/fs/list" { + t.Fatalf("unexpected openlist path %s", r.URL.Path) + } + if r.Header.Get("Authorization") != "openlist-token" { + t.Fatalf("authorization = %q, want openlist-token", r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 200, + "data": map[string]any{"content": []any{}, "total": 0}, + }) + })) + defer openlist.Close() + + db, err := gorm.Open(sqlite.Open("file:storage_config_cloud_test?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.StorageConfig{}, &model.Setting{}, &model.Library{}, &model.Media{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + log := zap.NewNop() + storage := service.NewStorageConfigService(log, repos, service.NewCryptoService("", log)) + enabled := false + if _, err := storage.Save(t.Context(), service.StorageInput{ + Type: "openlist", + Config: map[string]any{ + "server": openlist.URL, + "token": "openlist-token", + }, + Enabled: &enabled, + }); err != nil { + t.Fatal(err) + } + + router := gin.New() + router.POST("/admin/storage/:type/test", testStorageConfigHandler(&service.Container{StorageCfg: storage})) + body := `{"type":"openlist","config":{"server":"` + openlist.URL + `","token":"openlist-token"}}` + req := httptest.NewRequest(http.MethodPost, "/admin/storage/openlist/test", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d body=%s, want 200", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), `"ok":true`) { + t.Fatalf("body = %s, want ok true", w.Body.String()) + } + view, err := storage.Get(t.Context(), "openlist") + if err != nil { + t.Fatal(err) + } + if view == nil || !view.Enabled { + t.Fatalf("openlist enabled = %#v, want enabled after successful test", view) + } + if _, err := storage.CloudProvider(t.Context(), "openlist"); err != nil { + t.Fatalf("cloud provider after test: %v", err) + } +} diff --git a/internal/handler/streaming.go b/internal/handler/streaming.go index edcd038..303b95f 100644 --- a/internal/handler/streaming.go +++ b/internal/handler/streaming.go @@ -182,11 +182,15 @@ func scrapeOneHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + reclassified := reclassifyMediaAfterScrape(c.Request.Context(), svc, m.ID) refreshed, _ := svc.Repo.Media.FindByID(c.Request.Context(), m.ID) metrics := map[string]int64{"processed": 1} if refreshed != nil && refreshed.ScrapeStatus == "matched" { metrics["matched"] = 1 } + if reclassified > 0 { + metrics["reclassified"] = int64(reclassified) + } finishHTTPTask(task, nil, "completed", "手动刮削媒体结束", metrics, nil) c.JSON(http.StatusOK, refreshed) } @@ -212,11 +216,18 @@ func scrapeLibraryHandler(svc *service.Container) gin.HandlerFunc { // pushes per-item progress on the "scrape" topic. go func(libID string, task *service.TaskHandle, options service.ScrapeOptions) { result, err := svc.Scraper.EnrichLibraryDetailedWithOptions(context.Background(), libID, options) + reclassified := 0 + if result.Processed > 0 { + reclassified = reclassifyLibraryAfterScrape(context.Background(), svc, libID) + } metrics := map[string]int64{ "matched": int64(result.Matched), "processed": int64(result.Processed), "candidates": int64(result.Candidates), } + if reclassified > 0 { + metrics["reclassified"] = int64(reclassified) + } if result.Failed > 0 { metrics["errors"] = int64(result.Failed) } diff --git a/internal/handler/subscription_extra.go b/internal/handler/subscription_extra.go index 18902f0..3e0f963 100644 --- a/internal/handler/subscription_extra.go +++ b/internal/handler/subscription_extra.go @@ -11,28 +11,32 @@ import ( ) type subscriptionPatchReq struct { - Name *string `json:"name"` - FeedURL *string `json:"feed_url"` - Filter *string `json:"filter"` - MediaType *string `json:"media_type"` - MediaCategory *string `json:"media_category"` - SavePath *string `json:"save_path"` - SearchMode *string `json:"search_mode"` - IMDBID *string `json:"imdb_id"` - Source *string `json:"source"` - PosterURL *string `json:"poster_url"` - BackdropURL *string `json:"backdrop_url"` - Overview *string `json:"overview"` - Resolution *string `json:"resolution"` - Quality *string `json:"quality"` - Effects *string `json:"effects"` - ReleaseGroups *string `json:"release_groups"` - ExcludeWords *string `json:"exclude_words"` - WashEnabled *bool `json:"wash_enabled"` - WashPriority *string `json:"wash_priority"` - TotalEpisodes *int `json:"total_episodes"` - Priority *int `json:"priority"` - Enabled *bool `json:"enabled"` + Name *string `json:"name"` + FeedURL *string `json:"feed_url"` + Filter *string `json:"filter"` + MediaType *string `json:"media_type"` + MediaCategory *string `json:"media_category"` + SavePath *string `json:"save_path"` + SearchMode *string `json:"search_mode"` + IMDBID *string `json:"imdb_id"` + Source *string `json:"source"` + PosterURL *string `json:"poster_url"` + BackdropURL *string `json:"backdrop_url"` + Overview *string `json:"overview"` + OriginalName *string `json:"original_name"` + Year *int `json:"year"` + Rating *float32 `json:"rating"` + Genres *string `json:"genres"` + Resolution *string `json:"resolution"` + Quality *string `json:"quality"` + Effects *string `json:"effects"` + ReleaseGroups *string `json:"release_groups"` + ExcludeWords *string `json:"exclude_words"` + WashEnabled *bool `json:"wash_enabled"` + WashPriority *string `json:"wash_priority"` + TotalEpisodes *int `json:"total_episodes"` + Priority *int `json:"priority"` + Enabled *bool `json:"enabled"` } // updateSubscriptionHandler patches a subscription row. @@ -97,6 +101,18 @@ func subscriptionPatchUpdates(patch subscriptionPatchReq) map[string]any { if patch.Overview != nil { updates["overview"] = *patch.Overview } + if patch.OriginalName != nil { + updates["original_name"] = *patch.OriginalName + } + if patch.Year != nil { + updates["year"] = *patch.Year + } + if patch.Rating != nil { + updates["rating"] = *patch.Rating + } + if patch.Genres != nil { + updates["genres"] = *patch.Genres + } if patch.Resolution != nil { updates["resolution"] = *patch.Resolution } diff --git a/internal/handler/subscriptions.go b/internal/handler/subscriptions.go index 092e876..a3c2342 100644 --- a/internal/handler/subscriptions.go +++ b/internal/handler/subscriptions.go @@ -2,6 +2,7 @@ package handler import ( + "context" "net/http" "github.com/gin-gonic/gin" @@ -96,8 +97,8 @@ func listSubscriptionsHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - enrichAndPersistSubscriptions(c.Request.Context(), svc, items) svc.Subscription.EnrichProgress(c.Request.Context(), items) + go enrichAndPersistSubscriptions(context.Background(), svc, append([]model.Subscription(nil), items...)) c.JSON(http.StatusOK, gin.H{"items": items}) } } diff --git a/internal/repository/bot_repo.go b/internal/repository/bot_repo.go index e1e24b9..9c0c6ca 100644 --- a/internal/repository/bot_repo.go +++ b/internal/repository/bot_repo.go @@ -114,6 +114,22 @@ func (r *UserDeviceRepository) Find(ctx context.Context, userID, deviceID string return &d, nil } +// FindByFingerprint returns the newest row for a terminal fingerprint. +func (r *UserDeviceRepository) FindByFingerprint(ctx context.Context, userID, fingerprint string) (*model.UserDevice, error) { + var d model.UserDevice + err := r.db.WithContext(ctx). + Where("user_id = ? AND fingerprint = ?", userID, fingerprint). + Order("last_seen_at desc"). + First(&d).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &d, nil +} + // Create inserts a new device row. func (r *UserDeviceRepository) Create(ctx context.Context, d *model.UserDevice) error { return r.db.WithContext(ctx).Create(d).Error @@ -163,12 +179,27 @@ func (r *UserDeviceRepository) DeleteByUser(ctx context.Context, userID string) return r.db.WithContext(ctx).Unscoped().Where("user_id = ?", userID).Delete(&model.UserDevice{}).Error } +// DeleteByFingerprintExcept removes stale login-channel rows for the same +// terminal, leaving the selected canonical row. +func (r *UserDeviceRepository) DeleteByFingerprintExcept(ctx context.Context, userID, fingerprint, keepID string) error { + return r.db.WithContext(ctx).Unscoped(). + Where("user_id = ? AND fingerprint = ? AND id <> ?", userID, fingerprint, keepID). + Delete(&model.UserDevice{}).Error +} + // SetKicked marks a device as kicked (forces re-login on next request). func (r *UserDeviceRepository) SetKicked(ctx context.Context, id string, kicked bool) error { return r.db.WithContext(ctx).Model(&model.UserDevice{}).Where("id = ?", id). Update("kicked", kicked).Error } +// SetKickedByFingerprint marks every row for a terminal fingerprint. +func (r *UserDeviceRepository) SetKickedByFingerprint(ctx context.Context, userID, fingerprint string, kicked bool) error { + return r.db.WithContext(ctx).Model(&model.UserDevice{}). + Where("user_id = ? AND fingerprint = ?", userID, fingerprint). + Update("kicked", kicked).Error +} + // SetKickedByUser marks every device for a user as kicked/un-kicked. func (r *UserDeviceRepository) SetKickedByUser(ctx context.Context, userID string, kicked bool) error { return r.db.WithContext(ctx).Model(&model.UserDevice{}).Where("user_id = ?", userID). diff --git a/internal/service/bot_device_policy_test.go b/internal/service/bot_device_policy_test.go index 5521476..bd312d5 100644 --- a/internal/service/bot_device_policy_test.go +++ b/internal/service/bot_device_policy_test.go @@ -60,6 +60,10 @@ func TestTerminalDeviceLimitDeduplicatesAppsOnSameDevice(t *testing.T) { ctx := context.Background() repos, _ := newBotTestService(t) dev := NewDeviceService(zap.NewNop(), repos) + now := time.Date(2026, 6, 25, 21, 30, 0, 0, time.UTC) + tracker := NewSessionTrackerService(zap.NewNop()) + tracker.now = func() time.Time { return now } + dev.SetSessionTracker(tracker) u := &model.User{Username: "device-user", PasswordHash: "x", Role: "user", IsActive: true} if err := repos.User.Create(ctx, u); err != nil { t.Fatal(err) @@ -81,6 +85,7 @@ func TestTerminalDeviceLimitDeduplicatesAppsOnSameDevice(t *testing.T) { {id: "phone-jellyfin", name: "IPHONE", client: "Jellyfin"}, } { dev.RecordLogin(ctx, u.ID, login.id, login.name, login.client, "1.2.3.4") + now = now.Add(time.Second) } count, err := repos.UserDevice.CountActiveClients(ctx, u.ID, time.Now().Add(-24*time.Hour)) if err != nil { @@ -89,6 +94,23 @@ func TestTerminalDeviceLimitDeduplicatesAppsOnSameDevice(t *testing.T) { if count != 1 { t.Fatalf("same terminal through multiple apps should count as 1, got %d", count) } + devices, err := dev.ListDevices(ctx, u.ID) + if err != nil { + t.Fatal(err) + } + if len(devices) != 1 { + t.Fatalf("same terminal should show as one device row, got %#v", devices) + } + rawRows, err := repos.UserDevice.ListByUser(ctx, u.ID) + if err != nil { + t.Fatal(err) + } + if len(rawRows) != 1 { + t.Fatalf("same terminal should be persisted as one canonical row, got %#v", rawRows) + } + if devices[0].DeviceID != "phone-jellyfin" || devices[0].Client != "Jellyfin" { + t.Fatalf("merged device row should keep latest login channel, got %#v", devices[0]) + } got, _ := repos.User.FindByID(ctx, u.ID) if !got.IsActive { t.Fatal("same terminal through multiple apps must not disable the account") @@ -115,6 +137,68 @@ func TestTerminalDeviceLimitDeduplicatesAppsOnSameDevice(t *testing.T) { } } +func TestDeviceKickAppliesToMergedTerminal(t *testing.T) { + ctx := context.Background() + repos, _ := newBotTestService(t) + dev := NewDeviceService(zap.NewNop(), repos) + u := &model.User{Username: "kick-merged", PasswordHash: "x", Role: "user", IsActive: true} + if err := repos.User.Create(ctx, u); err != nil { + t.Fatal(err) + } + + dev.RecordLogin(ctx, u.ID, "phone-infuse", "iPhone", "Infuse", "1.2.3.4") + dev.RecordLogin(ctx, u.ID, "phone-emby", " iPhone ", "Emby", "1.2.3.4") + if err := dev.KickDevice(ctx, u.ID, "phone-emby"); err != nil { + t.Fatal(err) + } + if !dev.IsTerminalKicked(ctx, u.ID, "phone-jellyfin", "IPHONE", "Jellyfin") { + t.Fatal("same terminal with a new app/device id should still be kicked") + } + + dev.RecordLogin(ctx, u.ID, "phone-jellyfin", "IPHONE", "Jellyfin", "1.2.3.4") + if dev.IsTerminalKicked(ctx, u.ID, "phone-jellyfin", "IPHONE", "Jellyfin") { + t.Fatal("re-login should clear kicked state for the merged terminal") + } + rawRows, err := repos.UserDevice.ListByUser(ctx, u.ID) + if err != nil { + t.Fatal(err) + } + if len(rawRows) != 1 || rawRows[0].DeviceID != "phone-jellyfin" { + t.Fatalf("merged terminal should keep one latest row, got %#v", rawRows) + } +} + +func TestRecordPlaybackMergesChangingDeviceIDOnSameTerminal(t *testing.T) { + ctx := context.Background() + repos, _ := newBotTestService(t) + dev := NewDeviceService(zap.NewNop(), repos) + u := &model.User{Username: "play-merged", PasswordHash: "x", Role: "user", IsActive: true} + if err := repos.User.Create(ctx, u); err != nil { + t.Fatal(err) + } + + dev.RecordPlayback(ctx, u.ID, "tv-emby", "Living Room TV", "Emby") + dev.RecordPlayback(ctx, u.ID, "tv-infuse", " living room tv ", "Infuse") + + rawRows, err := repos.UserDevice.ListByUser(ctx, u.ID) + if err != nil { + t.Fatal(err) + } + if len(rawRows) != 1 { + t.Fatalf("same playback terminal should persist one row, got %#v", rawRows) + } + if rawRows[0].DeviceID != "tv-infuse" || rawRows[0].Client != "Infuse" || rawRows[0].LastPlayAt == nil { + t.Fatalf("merged playback row should keep latest playback channel, got %#v", rawRows[0]) + } + count, err := repos.UserDevice.CountConcurrentPlaying(ctx, u.ID, time.Now().Add(-time.Minute)) + if err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("same terminal playback should count once, got %d", count) + } +} + func TestConcurrentPlaybackDeduplicatesAppsOnSameDevice(t *testing.T) { ctx := context.Background() repos, _ := newBotTestService(t) diff --git a/internal/service/cloud/clouddrive2_dav.go b/internal/service/cloud/clouddrive2_dav.go index f817f39..9f5f225 100644 --- a/internal/service/cloud/clouddrive2_dav.go +++ b/internal/service/cloud/clouddrive2_dav.go @@ -176,6 +176,12 @@ func (p *cloudDrive2Provider) decorateDAVStatusError(resp *http.Response, target body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) detail := compactDAVErrorBody(string(body)) if detail == "" { + if resp.StatusCode == http.StatusMethodNotAllowed { + return fmt.Errorf("%s: list %s returned http %d;请确认填写的是 WebDAV 地址(通常以 /dav 结尾),并且桥接网盘已在 OpenList/CloudDrive2 内完成登录或 Cookie 保存", p.name, target, resp.StatusCode) + } + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return fmt.Errorf("%s: list %s returned http %d;请填写 OpenList/CloudDrive2 的 Token 或用户名密码,或确认 WebDAV 凭据可用", p.name, target, resp.StatusCode) + } return fmt.Errorf("%s: list %s returned http %d", p.name, target, resp.StatusCode) } if resp.StatusCode == http.StatusMethodNotAllowed { diff --git a/internal/service/cloud/clouddrive2_openlist.go b/internal/service/cloud/clouddrive2_openlist.go index b3a8cf0..4e384cd 100644 --- a/internal/service/cloud/clouddrive2_openlist.go +++ b/internal/service/cloud/clouddrive2_openlist.go @@ -48,7 +48,7 @@ func (p *cloudDrive2Provider) listOpenListAPI(ctx context.Context, dir string) ( decodeErr := json.NewDecoder(io.LimitReader(resp.Body, 32<<20)).Decode(&decoded) resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("%s: api list %s returned http %d", p.name, target, resp.StatusCode) + return nil, p.openListAPIStatusError("list", target, resp.StatusCode) } if decodeErr != nil { return nil, fmt.Errorf("%s: decode api list: %w", p.name, decodeErr) @@ -108,7 +108,7 @@ func (p *cloudDrive2Provider) resolveOpenListAPIDirect(ctx context.Context, file } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("%s: api get %s returned http %d", p.name, fileRef, resp.StatusCode) + return nil, p.openListAPIStatusError("get", fileRef, resp.StatusCode) } var decoded openListGetResponse if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&decoded); err != nil { @@ -154,6 +154,13 @@ func (p *cloudDrive2Provider) resolveOpenListCDNRedirect(ctx context.Context, fi return "", fmt.Errorf("%s: api get %s returned an OpenList-hosted raw_url with http %d and no CDN Location; refusing OpenList/WebDAV proxy fallback for pure 302 playback", p.name, fileRef, status) } +func (p *cloudDrive2Provider) openListAPIStatusError(action, target string, status int) error { + if status == http.StatusUnauthorized || status == http.StatusForbidden { + return fmt.Errorf("%s: api %s %s returned http %d;请检查 OpenList Token 或用户名密码,并确认填写的是 OpenList 服务地址而不是 /dav 地址", p.name, action, target, status) + } + return fmt.Errorf("%s: api %s %s returned http %d", p.name, action, target, status) +} + func sortedHeaderNames(headers map[string]string) []string { if len(headers) == 0 { return nil diff --git a/internal/service/device_service.go b/internal/service/device_service.go index 6d4f5fc..3a56241 100644 --- a/internal/service/device_service.go +++ b/internal/service/device_service.go @@ -90,7 +90,7 @@ func (s *DeviceService) RecordLogin(ctx context.Context, userID, deviceID, devic fp := fingerprint(client, deviceName) now := s.now() - existing, _ := s.repo.UserDevice.Find(ctx, userID, deviceID) + existing, _ := s.findTerminalDevice(ctx, userID, deviceID, fp) mismatch := false if existing == nil { _ = s.repo.UserDevice.Create(ctx, &model.UserDevice{ @@ -107,6 +107,7 @@ func (s *DeviceService) RecordLogin(ctx context.Context, userID, deviceID, devic if existing.Fingerprint != "" && existing.Fingerprint != fp { mismatch = true } + existing.DeviceID = deviceID existing.DeviceName = deviceName existing.Client = client existing.Fingerprint = fp @@ -114,6 +115,7 @@ func (s *DeviceService) RecordLogin(ctx context.Context, userID, deviceID, devic existing.LastSeenAt = now existing.Kicked = false _ = s.repo.UserDevice.Save(ctx, existing) + s.deleteStaleTerminalDeviceRows(ctx, userID, fp, existing.ID) } cfg := loadBotConfig(ctx, s.repo) @@ -148,26 +150,29 @@ func (s *DeviceService) RecordPlayback(ctx context.Context, userID, deviceID, de s.sessions.RecordPlayback(ctx, userID, username, deviceID, deviceName, client, "", "", 0, 0, false) } now := s.now() - existing, _ := s.repo.UserDevice.Find(ctx, userID, deviceID) + fp := fingerprint(client, deviceName) + existing, _ := s.findTerminalDevice(ctx, userID, deviceID, fp) if existing == nil { existing = &model.UserDevice{ UserID: userID, DeviceID: deviceID, DeviceName: deviceName, Client: client, - Fingerprint: fingerprint(client, deviceName), + Fingerprint: fp, FirstSeenAt: now, LastSeenAt: now, } existing.LastPlayAt = &now _ = s.repo.UserDevice.Create(ctx, existing) } else { + existing.DeviceID = deviceID existing.DeviceName = deviceName existing.Client = client - existing.Fingerprint = fingerprint(client, deviceName) + existing.Fingerprint = fp existing.LastSeenAt = now existing.LastPlayAt = &now _ = s.repo.UserDevice.Save(ctx, existing) + s.deleteStaleTerminalDeviceRows(ctx, userID, fp, existing.ID) } cfg := loadBotConfig(ctx, s.repo) @@ -180,6 +185,29 @@ func (s *DeviceService) RecordPlayback(ctx context.Context, userID, deviceID, de } } +func (s *DeviceService) findTerminalDevice(ctx context.Context, userID, deviceID, fp string) (*model.UserDevice, error) { + existing, err := s.repo.UserDevice.Find(ctx, userID, deviceID) + if err != nil || existing != nil { + return existing, err + } + if strings.TrimSpace(fp) == "" { + return nil, nil + } + return s.repo.UserDevice.FindByFingerprint(ctx, userID, fp) +} + +func (s *DeviceService) deleteStaleTerminalDeviceRows(ctx context.Context, userID, fp, keepID string) { + if strings.TrimSpace(fp) == "" || strings.TrimSpace(keepID) == "" { + return + } + if err := s.repo.UserDevice.DeleteByFingerprintExcept(ctx, userID, fp, keepID); err != nil && s.log != nil { + s.log.Warn("device terminal cleanup failed", + zap.String("user_id", userID), + zap.String("fingerprint", fp), + zap.Error(err)) + } +} + // registerFingerprintWarning increments the fingerprint warning counter for a // user and disables the account once warnings exceed the threshold. Violations // are debounced so a single burst counts at most once per minute. @@ -310,6 +338,9 @@ func (s *DeviceService) KickDevice(ctx context.Context, userID, deviceID string) if d == nil { return fmt.Errorf("device not found") } + if fp := strings.TrimSpace(d.Fingerprint); fp != "" { + return s.repo.UserDevice.SetKickedByFingerprint(ctx, userID, fp, true) + } return s.repo.UserDevice.SetKicked(ctx, d.ID, true) } @@ -324,17 +355,24 @@ func (s *DeviceService) ListDevices(ctx context.Context, userID string) ([]model if err != nil { return nil, err } + rows = collapseUserDeviceRows(rows) if s.sessions == nil { return rows, nil } now := s.sessions.now() byDevice := make(map[string]int, len(rows)) + byTerminal := make(map[string]int, len(rows)) for i := range rows { byDevice[rows[i].DeviceID] = i + byTerminal[userDeviceTerminalKey(rows[i])] = i } for _, sess := range s.sessions.ListByUser(ctx, userID) { online := sess.LastActivityAt.After(now.Add(-realtimeSessionOnlineTTL)) - if idx, ok := byDevice[sess.DeviceID]; ok { + idx, ok := byDevice[sess.DeviceID] + if !ok { + idx, ok = byTerminal[sessionDeviceKey(sess)] + } + if ok { if sess.LastActivityAt.After(rows[idx].LastSeenAt) { rows[idx].LastSeenAt = sess.LastActivityAt } @@ -371,6 +409,8 @@ func (s *DeviceService) ListDevices(ctx context.Context, userID string) ([]model } row.ID = "rt:" + sess.ID rows = append(rows, row) + byDevice[row.DeviceID] = len(rows) - 1 + byTerminal[userDeviceTerminalKey(row)] = len(rows) - 1 } sort.SliceStable(rows, func(i, j int) bool { return rows[i].LastSeenAt.After(rows[j].LastSeenAt) @@ -378,13 +418,99 @@ func (s *DeviceService) ListDevices(ctx context.Context, userID string) ([]model return rows, nil } +func collapseUserDeviceRows(rows []model.UserDevice) []model.UserDevice { + if len(rows) < 2 { + return rows + } + out := make([]model.UserDevice, 0, len(rows)) + byTerminal := map[string]int{} + for _, row := range rows { + key := userDeviceTerminalKey(row) + if idx, ok := byTerminal[key]; ok { + out[idx] = mergeUserDeviceRows(out[idx], row) + continue + } + byTerminal[key] = len(out) + out = append(out, row) + } + return out +} + +func mergeUserDeviceRows(a, b model.UserDevice) model.UserDevice { + if b.LastSeenAt.After(a.LastSeenAt) { + a.ID = b.ID + a.DeviceID = b.DeviceID + a.DeviceName = b.DeviceName + a.Client = b.Client + a.LastIP = b.LastIP + a.FirstSeenAt = earlierTime(a.FirstSeenAt, b.FirstSeenAt) + a.LastSeenAt = b.LastSeenAt + a.LastPlayAt = latestOptionalTime(a.LastPlayAt, b.LastPlayAt) + a.Fingerprint = firstNonEmptyString(b.Fingerprint, a.Fingerprint) + a.Kicked = b.Kicked + return a + } + a.FirstSeenAt = earlierTime(a.FirstSeenAt, b.FirstSeenAt) + a.LastPlayAt = latestOptionalTime(a.LastPlayAt, b.LastPlayAt) + a.Fingerprint = firstNonEmptyString(a.Fingerprint, b.Fingerprint) + return a +} + +func userDeviceTerminalKey(row model.UserDevice) string { + if key := strings.TrimSpace(row.Fingerprint); key != "" { + return key + } + if strings.TrimSpace(row.DeviceName) != "" { + return "fp-" + fingerprint(row.Client, row.DeviceName) + } + if id := strings.TrimSpace(row.DeviceID); id != "" { + return id + } + return "unknown" +} + +func earlierTime(a, b time.Time) time.Time { + if a.IsZero() || (!b.IsZero() && b.Before(a)) { + return b + } + return a +} + +func latestOptionalTime(a, b *time.Time) *time.Time { + if a == nil { + return b + } + if b != nil && b.After(*a) { + return b + } + return a +} + // IsDeviceKicked reports whether a (user, device) pair was kicked and should be // forced to re-authenticate. func (s *DeviceService) IsDeviceKicked(ctx context.Context, userID, deviceID string) bool { - if userID == "" || deviceID == "" { + return s.IsTerminalKicked(ctx, userID, deviceID, "", "") +} + +// IsTerminalKicked reports whether a request belongs to a kicked terminal. +func (s *DeviceService) IsTerminalKicked(ctx context.Context, userID, deviceID, deviceName, client string) bool { + if userID == "" { return false } - d, err := s.repo.UserDevice.Find(ctx, userID, deviceID) + if strings.TrimSpace(deviceID) != "" { + d, err := s.repo.UserDevice.Find(ctx, userID, deviceID) + if err == nil && d != nil { + return d.Kicked + } + } + fp := "" + if strings.TrimSpace(deviceName) != "" { + fp = fingerprint(client, deviceName) + } + if fp == "" { + return false + } + d, err := s.repo.UserDevice.FindByFingerprint(ctx, userID, fp) return err == nil && d != nil && d.Kicked } diff --git a/internal/service/discover.go b/internal/service/discover.go index cb5638c..4a266c9 100644 --- a/internal/service/discover.go +++ b/internal/service/discover.go @@ -48,12 +48,12 @@ func (d *DiscoverService) Popular(ctx context.Context) ([]Match, error) { // TMDbSection returns one TMDb rail converted to the common external // discovery shape used by the multi-source Discover page. -func (d *DiscoverService) TMDbSection(ctx context.Context, key string) ([]ExternalMediaResult, error) { +func (d *DiscoverService) TMDbSection(ctx context.Context, key string, pages ...int) ([]ExternalMediaResult, error) { path := tmdbDiscoverPath(key) if path == "" { return []ExternalMediaResult{}, nil } - matches, err := d.Fetch(ctx, path) + matches, err := d.Fetch(ctx, path, pages...) if err != nil { return nil, err } @@ -90,7 +90,7 @@ func (d *DiscoverService) fetch(ctx context.Context, path string) ([]Match, erro // Fetch is the public entry point used by the multi-section handler. // It paginates page=1 only — that's all the home page needs and it // keeps us under TMDb's 50 rps limit. -func (d *DiscoverService) Fetch(ctx context.Context, path string) ([]Match, error) { +func (d *DiscoverService) Fetch(ctx context.Context, path string, pages ...int) ([]Match, error) { if d.tmdb == nil { return nil, nil } @@ -105,7 +105,11 @@ func (d *DiscoverService) Fetch(ctx context.Context, path string) ([]Match, erro q := url.Values{} q.Set("api_key", apiKey) q.Set("language", "zh-CN") - q.Set("page", "1") + pageNumber := 1 + if len(pages) > 0 && pages[0] > 0 { + pageNumber = pages[0] + } + q.Set("page", strconv.Itoa(pageNumber)) u := base + path + "?" + q.Encode() type result struct { @@ -197,7 +201,7 @@ func tmdbDiscoverPath(key string) string { // Discover returns public Douban movie/TV rails. Douban does not require a // formal API key here; these are the same public web endpoints the site uses. -func (d *DoubanProvider) Discover(ctx context.Context, key string) ([]ExternalMediaResult, error) { +func (d *DoubanProvider) Discover(ctx context.Context, key string, pages ...int) ([]ExternalMediaResult, error) { doubanType := "movie" tag := "热门" switch key { @@ -218,7 +222,11 @@ func (d *DoubanProvider) Discover(ctx context.Context, key string) ([]ExternalMe q.Set("tag", tag) q.Set("sort", "recommend") q.Set("page_limit", "24") - q.Set("page_start", "0") + pageNumber := 1 + if len(pages) > 0 && pages[0] > 0 { + pageNumber = pages[0] + } + q.Set("page_start", strconv.Itoa((pageNumber-1)*24)) u := "https://movie.douban.com/j/search_subjects?" + q.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { diff --git a/internal/service/download_active_paths.go b/internal/service/download_active_paths.go new file mode 100644 index 0000000..86fe8db --- /dev/null +++ b/internal/service/download_active_paths.go @@ -0,0 +1,36 @@ +package service + +import ( + "context" + "time" + + "go.uber.org/zap" +) + +const activeDownloadSnapshotFallbackAge = 2 * time.Minute + +func (d *DownloadService) ActiveDownloadPaths(ctx context.Context) []string { + if d == nil || d.qb == nil { + return nil + } + live, err := d.qb.List(ctx, "") + if err != nil { + live = d.LiveTorrentSnapshot(activeDownloadSnapshotFallbackAge) + if d.log != nil && len(live) == 0 { + d.log.Debug("active download guard could not list qbittorrent and has no fresh snapshot", zap.Error(err)) + } + } + return activeDownloadPathCandidates(live, d.downloadPathMappings(ctx)) +} + +func (d *DownloadService) downloadPathMappings(ctx context.Context) map[string]string { + mappings := map[string]string{ + "/var/apps/qBittorrent/shares/qBittorrent/Download": "/downloads", + "/data/qBittorrent/downloads": "/downloads", + "/downloads/qBittorrent": "/downloads", + } + for clientPrefix, localPrefix := range d.userPathMappings(ctx) { + mappings[clientPrefix] = localPrefix + } + return mappings +} diff --git a/internal/service/download_completion.go b/internal/service/download_completion.go index 3201e88..0caf285 100644 --- a/internal/service/download_completion.go +++ b/internal/service/download_completion.go @@ -148,16 +148,7 @@ func downloadTaskMediaCategory(task *model.DownloadTask) string { const DownloadPathMappingsSettingKey = "download.path_mappings" func (d *DownloadService) completedTorrentSource(ctx context.Context, torrent QBitTorrent) string { - // 常见路径映射:qBittorrent容器路径 -> MediaStationGo容器路径 - mappings := map[string]string{ - "/var/apps/qBittorrent/shares/qBittorrent/Download": "/downloads", - "/data/qBittorrent/downloads": "/downloads", - "/downloads/qBittorrent": "/downloads", - } - // 用户自定义映射优先(可覆盖内置猜测)。 - for clientPrefix, localPrefix := range d.userPathMappings(ctx) { - mappings[clientPrefix] = localPrefix - } + mappings := d.downloadPathMappings(ctx) for _, candidate := range []string{ torrent.ContentPath, filepath.Join(torrent.SavePath, torrent.Name), diff --git a/internal/service/download_completion_state.go b/internal/service/download_completion_state.go index cf2826a..e7e7889 100644 --- a/internal/service/download_completion_state.go +++ b/internal/service/download_completion_state.go @@ -43,6 +43,19 @@ func recentlyCompletedTorrent(torrent QBitTorrent, now time.Time) bool { return now.Sub(completed) <= completedTorrentCatchupWindow } +func qbitTorrentCompleted(torrent QBitTorrent) bool { + if torrent.Progress < 1 { + return false + } + state := strings.ToLower(strings.TrimSpace(torrent.State)) + switch state { + case "completed", "uploading", "stalledup", "pausedup", "queuedup", "forcedup": + return true + default: + return false + } +} + func (d *DownloadService) completedTorrentCatchupRecorded(ctx context.Context, torrent QBitTorrent) bool { if d == nil || d.repo == nil || d.repo.Setting == nil { return false @@ -148,7 +161,7 @@ func (d *DownloadService) syncDownloadTaskProgress(ctx context.Context, torrent return } status := torrent.State - if torrent.Progress >= 1 { + if qbitTorrentCompleted(torrent) { status = "completed" } if strings.TrimSpace(status) == "" { diff --git a/internal/service/download_polling.go b/internal/service/download_polling.go index e831f9f..8f3f450 100644 --- a/internal/service/download_polling.go +++ b/internal/service/download_polling.go @@ -69,7 +69,7 @@ func (d *DownloadService) processTorrentSnapshot(ctx context.Context, torrent QB if stateKey == "" { return } - if d.completedTorrentShouldQueue(stateKey, torrent.Progress >= 1.0, firstSnapshot, taskNeedsOrganize) && + if d.completedTorrentShouldQueue(stateKey, qbitTorrentCompleted(torrent), firstSnapshot, taskNeedsOrganize) && d.enqueueCompletedTorrent(torrent) { d.markCompletedTorrentState(stateKey) } diff --git a/internal/service/downloads_test.go b/internal/service/downloads_test.go index 34c9db2..ac9cd63 100644 --- a/internal/service/downloads_test.go +++ b/internal/service/downloads_test.go @@ -214,6 +214,120 @@ func TestProcessDownloadSnapshotQueuesCompletedPendingTaskOnFirstSnapshot(t *tes } } +func TestProcessDownloadSnapshotDoesNotQueueActiveDownloadAtFullProgress(t *testing.T) { + db := newServiceTestDB(t, &model.DownloadTask{}, &model.Setting{}) + repos := repository.New(db) + task := &model.DownloadTask{ + Source: "qbittorrent", + URL: "magnet:?xt=urn:btih:test", + Title: "Still Downloading S01E01", + SavePath: "/downloads/未分类", + Status: "downloading", + Progress: 0.99, + } + if err := repos.Download.Create(t.Context(), task); err != nil { + t.Fatal(err) + } + if err := repos.Setting.Set(t.Context(), "organizer.auto_after_download", "true"); err != nil { + t.Fatal(err) + } + + svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) + svc.processDownloadSnapshot(t.Context(), []QBitTorrent{{ + Hash: "notdone", + Name: "Still.Downloading.S01E01", + Progress: 1, + State: "downloading", + }}, tasksByTorrentIdentity([]model.DownloadTask{*task})) + + if got := len(svc.organizeQueue); got != 0 { + t.Fatalf("queued active download organize jobs = %d, want 0", got) + } + var after model.DownloadTask + if err := db.First(&after, "id = ?", task.ID).Error; err != nil { + t.Fatal(err) + } + if after.Status == "completed" { + t.Fatalf("active download status = %q, should not be completed", after.Status) + } +} + +func TestProcessDownloadSnapshotDoesNotQueueFullProgressWithoutQBitState(t *testing.T) { + db := newServiceTestDB(t, &model.DownloadTask{}, &model.Setting{}) + repos := repository.New(db) + task := &model.DownloadTask{ + Source: "qbittorrent", + URL: "magnet:?xt=urn:btih:test", + Title: "Missing State S01E01", + SavePath: "/downloads/未分类", + Status: "downloading", + Progress: 0.99, + } + if err := repos.Download.Create(t.Context(), task); err != nil { + t.Fatal(err) + } + if err := repos.Setting.Set(t.Context(), "organizer.auto_after_download", "true"); err != nil { + t.Fatal(err) + } + + svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) + svc.processDownloadSnapshot(t.Context(), []QBitTorrent{{ + Hash: "missing-state", + Name: "Missing.State.S01E01", + Progress: 1, + }}, tasksByTorrentIdentity([]model.DownloadTask{*task})) + + if got := len(svc.organizeQueue); got != 0 { + t.Fatalf("queued full-progress torrent without state = %d, want 0", got) + } + var after model.DownloadTask + if err := db.First(&after, "id = ?", task.ID).Error; err != nil { + t.Fatal(err) + } + if after.Status == "completed" { + t.Fatalf("missing-state torrent status = %q, should not be completed", after.Status) + } +} + +func TestProcessDownloadSnapshotDoesNotTrustCompletionOnForActiveDownload(t *testing.T) { + db := newServiceTestDB(t, &model.DownloadTask{}, &model.Setting{}) + repos := repository.New(db) + task := &model.DownloadTask{ + Source: "qbittorrent", + URL: "magnet:?xt=urn:btih:test", + Title: "Still Downloading With Completion Timestamp S01E01", + SavePath: "/downloads/未分类", + Status: "downloading", + Progress: 0.5, + } + if err := repos.Download.Create(t.Context(), task); err != nil { + t.Fatal(err) + } + if err := repos.Setting.Set(t.Context(), "organizer.auto_after_download", "true"); err != nil { + t.Fatal(err) + } + + svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) + svc.processDownloadSnapshot(t.Context(), []QBitTorrent{{ + Hash: "notdone-completion-on", + Name: "Still.Downloading.With.Completion.Timestamp.S01E01", + Progress: 0.5, + State: "downloading", + CompletionOn: time.Now().Unix(), + }}, tasksByTorrentIdentity([]model.DownloadTask{*task})) + + if got := len(svc.organizeQueue); got != 0 { + t.Fatalf("queued active download organize jobs = %d, want 0", got) + } + var after model.DownloadTask + if err := db.First(&after, "id = ?", task.ID).Error; err != nil { + t.Fatal(err) + } + if after.Status == "completed" || after.Progress >= 1 { + t.Fatalf("active download mutated to completed state: status=%q progress=%v", after.Status, after.Progress) + } +} + func TestProcessDownloadSnapshotSkipsUntrackedCompletedTorrentOnFirstSnapshot(t *testing.T) { db := newServiceTestDB(t, &model.DownloadTask{}, &model.Setting{}) repos := repository.New(db) @@ -260,6 +374,7 @@ func TestDownloadPollBaselinesAlreadyCompletedTorrents(t *testing.T) { Hash: "already-complete", Name: "Already Complete S01E01", Progress: 1, + State: "stalledUP", }}, nil) if got := len(svc.organizeQueue); got != 0 { @@ -273,6 +388,7 @@ func TestDownloadPollBaselinesAlreadyCompletedTorrents(t *testing.T) { Hash: "late-complete", Name: "Late Complete S01E01", Progress: 1, + State: "stalledUP", }}, nil) if got := len(svc.organizeQueue); got != 0 { t.Fatalf("newly discovered completed torrent queued %d organize jobs, want 0", got) @@ -287,6 +403,7 @@ func TestDownloadPollBaselinesAlreadyCompletedTorrents(t *testing.T) { Hash: "new-download", Name: "New Download S01E01", Progress: 1, + State: "stalledUP", }}, nil) if got := len(svc.organizeQueue); got != 1 { @@ -316,9 +433,9 @@ func TestDownloadPollCatchesUpRecentlyCompletedTorrents(t *testing.T) { svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) svc.processDownloadSnapshot(t.Context(), []QBitTorrent{ - {Hash: "fresh-complete", Name: "Fresh Complete S01E01", Progress: 1, CompletionOn: time.Now().Add(-time.Hour).Unix()}, - {Hash: "stale-complete", Name: "Stale Complete S01E01", Progress: 1, CompletionOn: time.Now().Add(-48 * time.Hour).Unix()}, - {Hash: "no-timestamp", Name: "No Timestamp S01E01", Progress: 1}, + {Hash: "fresh-complete", Name: "Fresh Complete S01E01", Progress: 1, State: "stalledUP", CompletionOn: time.Now().Add(-time.Hour).Unix()}, + {Hash: "stale-complete", Name: "Stale Complete S01E01", Progress: 1, State: "stalledUP", CompletionOn: time.Now().Add(-48 * time.Hour).Unix()}, + {Hash: "no-timestamp", Name: "No Timestamp S01E01", Progress: 1, State: "stalledUP"}, }, tasksByTorrentIdentity([]model.DownloadTask{*task})) // 只有补整理时间窗内、且存在本地追踪任务的种子会被补整理;无 completion_on 的保守跳过。 @@ -348,6 +465,7 @@ func TestDownloadPollDoesNotCatchUpWhenAutoOrganizeDisabled(t *testing.T) { Hash: "fresh-complete", Name: "Fresh Complete S01E01", Progress: 1, + State: "stalledUP", CompletionOn: time.Now().Add(-time.Hour).Unix(), } @@ -365,6 +483,7 @@ func TestDownloadPollSkipsRecordedCompletedTorrentCatchup(t *testing.T) { Hash: "fresh-complete", Name: "Fresh Complete S01E01", Progress: 1, + State: "stalledUP", CompletionOn: time.Now().Add(-time.Hour).Unix(), } if err := repos.Setting.Set(t.Context(), completedTorrentCatchupSettingKey(torrent), "true"); err != nil { diff --git a/internal/service/media.go b/internal/service/media.go index b70d17b..6a12e7f 100644 --- a/internal/service/media.go +++ b/internal/service/media.go @@ -171,12 +171,13 @@ func (s *MediaService) ListMediaVisible(ctx context.Context, libraryID string, p } func (s *MediaService) ListMediaVisibleGrouped(ctx context.Context, libraryID string, page, pageSize int, visibility MediaVisibility) ([]MediaItem, int64, error) { - items, _, err := s.ListMediaVisible(ctx, libraryID, page, pageSize, visibility) + page, pageSize = normalizeGroupedMediaPage(page, pageSize) + items, err := s.listMediaVisibleForGrouping(ctx, libraryID, visibility) if err != nil { return nil, 0, err } grouped := groupMediaVersions(items) - return grouped, int64(len(grouped)), nil + return paginateMediaItems(grouped, page, pageSize), int64(len(grouped)), nil } type mediaListCacheValue struct { @@ -215,6 +216,85 @@ func (s *MediaService) invalidateMediaCache(ctx context.Context) { } } +func (s *MediaService) listMediaVisibleForGrouping(ctx context.Context, libraryID string, visibility MediaVisibility) ([]model.Media, error) { + visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility) + libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID) + if err != nil { + return nil, err + } + filter := repository.MediaQueryFilter{ + IncludeNSFW: visibility.IncludeNSFW, + AllowedLibraryIDs: visibility.AllowedLibraryIDs, + HiddenLibraryIDs: visibility.HiddenLibraryIDs, + } + cacheKey := s.mediaListCacheKey(libraryID, libraryIDs, 0, maxMediaSearchLimit, filter) + ":group-source" + var cached mediaListCacheValue + if s.cache != nil && s.cache.GetJSON(ctx, cacheKey, &cached) { + s.attachLibraryMetadata(ctx, cached.Items) + return cached.Items, nil + } + items, total, err := s.repo.Media.ListByLibrariesFiltered(ctx, libraryIDs, 0, maxMediaSearchLimit, filter) + if err != nil { + return nil, err + } + if total > int64(len(items)) && s.log != nil { + s.log.Warn("media version grouping truncated by safety limit", + zap.String("library_id", libraryID), + zap.Int64("total", total), + zap.Int("limit", maxMediaSearchLimit)) + } + s.attachLibraryMetadata(ctx, items) + if s.cache != nil { + s.cache.SetJSON(ctx, cacheKey, mediaListCacheValue{Items: items, Total: total}, time.Duration(s.mediaCacheTTLSeconds())*time.Second) + } + return items, nil +} + +func normalizeGroupedMediaPage(page, pageSize int) (int, int) { + if page < 1 { + page = 1 + } + if pageSize <= 0 { + pageSize = 50 + } + if pageSize > maxMediaSearchPageSize { + pageSize = maxMediaSearchPageSize + } + return page, pageSize +} + +func paginateMediaItems(items []MediaItem, page, pageSize int) []MediaItem { + page, pageSize = normalizeGroupedMediaPage(page, pageSize) + if len(items) == 0 { + return nil + } + start := (page - 1) * pageSize + if start >= len(items) { + return []MediaItem{} + } + end := start + pageSize + if end > len(items) { + end = len(items) + } + return items[start:end] +} + +func firstMediaItems(items []MediaItem, limit int) []MediaItem { + if len(items) == 0 { + return nil + } + if limit <= 0 { + limit = 50 + } + if limit > maxMediaSearchLimit { + limit = maxMediaSearchLimit + } + if limit > len(items) { + limit = len(items) + } + return items[:limit] +} + func groupMediaVersions(items []model.Media) []MediaItem { if len(items) == 0 { return nil @@ -275,7 +355,7 @@ func mediaVersionGroupKey(m model.Media) string { if title == "" { title, _ = CleanQuery(m.Path) } - title = normalizeMediaVersionText(title) + title, _ = mediaVersionTitleKey(title) if title == "" { return "" } @@ -297,20 +377,33 @@ func mediaVersionGroupKey(m model.Media) string { return "thetvdb:" + strings.ToLower(strings.TrimSpace(m.TheTVDBID)) } title := firstNonEmpty(m.OriginalName, m.Title) + titleYear := 0 if title == "" { title, _ = CleanQuery(m.Path) + } else { + title, titleYear = mediaVersionTitleKey(title) } - title = normalizeMediaVersionText(title) if title == "" { return "" } year := m.Year + if year <= 0 { + year = titleYear + } if year <= 0 { _, year = CleanQuery(m.Path) } return fmt.Sprintf("movie:%s:%d", title, year) } +func mediaVersionTitleKey(value string) (string, int) { + cleaned, year := CleanQuery(value) + if strings.TrimSpace(cleaned) == "" { + cleaned = value + } + return normalizeMediaVersionText(cleaned), year +} + func normalizeMediaVersionText(value string) string { value = strings.ToLower(strings.TrimSpace(value)) if value == "" { @@ -385,11 +478,16 @@ func (s *MediaService) SearchMediaVisible(ctx context.Context, query string, lim } func (s *MediaService) SearchMediaVisibleGrouped(ctx context.Context, query string, limit int, visibility MediaVisibility) ([]MediaItem, error) { - items, err := s.SearchMediaVisible(ctx, query, limit, visibility) + if limit <= 0 { + limit = 50 + } else if limit > maxMediaSearchLimit { + limit = maxMediaSearchLimit + } + items, err := s.SearchMediaVisible(ctx, query, maxMediaSearchLimit, visibility) if err != nil { return nil, err } - return groupMediaVersions(items), nil + return firstMediaItems(groupMediaVersions(items), limit), nil } func (s *MediaService) SearchMediaVisiblePage(ctx context.Context, query string, page, pageSize int, visibility MediaVisibility) ([]model.Media, int64, error) { @@ -416,12 +514,13 @@ func (s *MediaService) SearchMediaVisiblePage(ctx context.Context, query string, } func (s *MediaService) SearchMediaVisiblePageGrouped(ctx context.Context, query string, page, pageSize int, visibility MediaVisibility) ([]MediaItem, int64, error) { - items, _, err := s.SearchMediaVisiblePage(ctx, query, page, pageSize, visibility) + page, pageSize = normalizeGroupedMediaPage(page, pageSize) + items, err := s.SearchMediaVisible(ctx, query, maxMediaSearchLimit, visibility) if err != nil { return nil, 0, err } grouped := groupMediaVersions(items) - return grouped, int64(len(grouped)), nil + return paginateMediaItems(grouped, page, pageSize), int64(len(grouped)), nil } // GetMedia returns a single media row. diff --git a/internal/service/media_classifier.go b/internal/service/media_classifier.go index 01d0cfe..f703419 100644 --- a/internal/service/media_classifier.go +++ b/internal/service/media_classifier.go @@ -477,6 +477,19 @@ func subscriptionMetadataQueries(title string, sub *model.Subscription) []string } func subscriptionMetadataLibraryTypes(mediaType, title string) []string { + if strings.TrimSpace(mediaType) == "" { + text := strings.ToLower(title) + switch { + case classifierEpisodeRE.MatchString(text) || classifierSeasonRE.MatchString(text): + return []string{"tv", "anime", "movie"} + case containsAnyText(text, "动漫", "动画", "anime", "bangumi"): + return []string{"anime", "tv", "movie"} + case containsAnyText(text, "电影", "movie", "film"): + return []string{"movie", "tv", "anime"} + default: + return []string{"tv", "movie", "anime"} + } + } switch normalizeMediaType(mediaType, title, "") { case "movie": return []string{"movie"} diff --git a/internal/service/media_test.go b/internal/service/media_test.go index 14482cb..14d08a5 100644 --- a/internal/service/media_test.go +++ b/internal/service/media_test.go @@ -258,6 +258,177 @@ func TestGroupMediaVersionsMergesEpisodeByExternalIDAcrossLibraries(t *testing.T } } +func TestGroupMediaVersionsMergesMovieEncodingVariants(t *testing.T) { + hd := model.Media{ + LibraryID: "movies", + Title: "Inception 2010 1080p BluRay x264", + Path: "/media/Movies/Inception.2010.1080p.BluRay.x264.mkv", + Year: 2010, + SizeBytes: 100, + VideoCodec: "h264", + ScrapeStatus: "pending", + } + uhd := model.Media{ + LibraryID: "movies", + Title: "Inception 2010 2160p UHD BluRay x265", + Path: "/media/Movies/Inception.2010.2160p.UHD.BluRay.x265.mkv", + Year: 2010, + SizeBytes: 200, + VideoCodec: "hevc", + ScrapeStatus: "pending", + } + + grouped := groupMediaVersions([]model.Media{hd, uhd}) + if len(grouped) != 1 { + t.Fatalf("grouped len = %d, want 1: %#v", len(grouped), grouped) + } + if len(grouped[0].Versions) != 2 { + t.Fatalf("versions len = %d, want 2: %#v", len(grouped[0].Versions), grouped[0].Versions) + } + if grouped[0].Media.Path != uhd.Path { + t.Fatalf("larger 2160p version should be primary, got %q want %q", grouped[0].Media.Path, uhd.Path) + } +} + +func TestGroupMediaVersionsCleansCodecPunctuation(t *testing.T) { + web := model.Media{ + LibraryID: "movies", + Title: "Everything Everywhere All at Once 2022 WEB-DL H.265 DDP5.1", + Path: "/media/Movies/Everything.Everywhere.All.At.Once.2022.2160p.WEB-DL.H.265.DDP5.1-GRP.mkv", + SizeBytes: 200, + VideoCodec: "hevc", + ScrapeStatus: "pending", + } + bluray := model.Media{ + LibraryID: "movies", + Title: "Everything Everywhere All at Once 2022 BluRay x264 DTS", + Path: "/media/Movies/Everything.Everywhere.All.At.Once.2022.1080p.BluRay.x264.DTS-GRP.mkv", + SizeBytes: 100, + VideoCodec: "h264", + ScrapeStatus: "pending", + } + + grouped := groupMediaVersions([]model.Media{web, bluray}) + if len(grouped) != 1 { + t.Fatalf("grouped len = %d, want 1: %#v", len(grouped), grouped) + } + if len(grouped[0].Versions) != 2 { + t.Fatalf("versions len = %d, want 2: %#v", len(grouped[0].Versions), grouped[0].Versions) + } + if grouped[0].Media.Path != web.Path { + t.Fatalf("larger WEB-DL version should be primary, got %q want %q", grouped[0].Media.Path, web.Path) + } +} + +func TestListMediaVisibleGroupedPaginatesAfterVersionGrouping(t *testing.T) { + db := newServiceTestDB(t, &model.Library{}, &model.Media{}) + repos := repository.New(db) + lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + now := time.Now() + rows := []model.Media{ + { + Base: model.Base{CreatedAt: now.Add(2 * time.Hour), UpdatedAt: now.Add(2 * time.Hour)}, + LibraryID: lib.ID, + Title: "Inception 2010 2160p UHD BluRay x265", + Path: "/media/movies/Inception.2010.2160p.UHD.BluRay.x265.mkv", + TMDbID: 27205, + Year: 2010, + Width: 3840, + Height: 2160, + SizeBytes: 200, + }, + { + Base: model.Base{CreatedAt: now.Add(time.Hour), UpdatedAt: now.Add(time.Hour)}, + LibraryID: lib.ID, + Title: "Inception 2010 1080p BluRay x264", + Path: "/media/movies/Inception.2010.1080p.BluRay.x264.mkv", + TMDbID: 27205, + Year: 2010, + Width: 1920, + Height: 1080, + SizeBytes: 100, + }, + { + Base: model.Base{CreatedAt: now, UpdatedAt: now}, + LibraryID: lib.ID, + Title: "The Matrix 1999 1080p BluRay", + Path: "/media/movies/The.Matrix.1999.1080p.BluRay.mkv", + TMDbID: 603, + Year: 1999, + SizeBytes: 90, + }, + } + if err := db.Create(&rows).Error; err != nil { + t.Fatal(err) + } + + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) + page, total, err := svc.ListMediaVisibleGrouped(t.Context(), lib.ID, 1, 1, MediaVisibility{IncludeNSFW: true}) + if err != nil { + t.Fatal(err) + } + if total != 2 { + t.Fatalf("grouped total = %d, want 2", total) + } + if len(page) != 1 || len(page[0].Versions) != 2 { + t.Fatalf("first page should contain merged Inception versions, got %#v", page) + } + if page[0].Media.Path != rows[0].Path { + t.Fatalf("primary version = %q, want %q", page[0].Media.Path, rows[0].Path) + } +} + +func TestSearchMediaVisiblePageGroupedPaginatesAfterVersionGrouping(t *testing.T) { + db := newServiceTestDB(t, &model.Media{}) + repos := repository.New(db) + now := time.Now() + rows := []model.Media{ + { + Base: model.Base{CreatedAt: now.Add(time.Hour), UpdatedAt: now.Add(time.Hour)}, + LibraryID: "movies", + Title: "Dune 2021 2160p WEB-DL H265", + Path: "/media/movies/Dune.2021.2160p.WEB-DL.H265.mkv", + TMDbID: 438631, + Year: 2021, + Width: 3840, + Height: 2160, + SizeBytes: 220, + }, + { + Base: model.Base{CreatedAt: now, UpdatedAt: now}, + LibraryID: "movies", + Title: "Dune 2021 1080p BluRay x264", + Path: "/media/movies/Dune.2021.1080p.BluRay.x264.mkv", + TMDbID: 438631, + Year: 2021, + Width: 1920, + Height: 1080, + SizeBytes: 120, + }, + } + if err := db.Create(&rows).Error; err != nil { + t.Fatal(err) + } + + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) + page, total, err := svc.SearchMediaVisiblePageGrouped(t.Context(), "Dune", 1, 1, MediaVisibility{IncludeNSFW: true}) + if err != nil { + t.Fatal(err) + } + if total != 1 { + t.Fatalf("grouped total = %d, want 1", total) + } + if len(page) != 1 || len(page[0].Versions) != 2 { + t.Fatalf("search page should contain merged Dune versions, got %#v", page) + } + if page[0].Media.Path != rows[0].Path { + t.Fatalf("primary version = %q, want %q", page[0].Media.Path, rows[0].Path) + } +} + func TestUpdateMediaMetadataMarksManualMatch(t *testing.T) { db := newServiceTestDB(t, &model.Library{}, &model.Media{}) repos := repository.New(db) diff --git a/internal/service/metadata_match_trust.go b/internal/service/metadata_match_trust.go index 4d3b4ac..9b3cbe3 100644 --- a/internal/service/metadata_match_trust.go +++ b/internal/service/metadata_match_trust.go @@ -24,6 +24,12 @@ func automaticMetadataTitleTrusted(query string, match *Match) bool { return true } } + if metadataTrustChineseReleaseAlias(queryKey, match) { + return true + } + if metadataTrustLocalizedSearchKeyword(queryKey, match) { + return true + } return false } @@ -83,6 +89,84 @@ func metadataTrustTokenOverlap(queryKey, titleKey string) bool { return queryCoverage >= 0.80 && titleCoverage >= 0.50 } +func metadataTrustChineseReleaseAlias(queryKey string, match *Match) bool { + if match == nil || !metadataMatchHasChineseOrigin(match) { + return false + } + tokens := metadataTrustSignificantTokens(queryKey) + if len(tokens) < 2 { + return false + } + latinTokens := 0 + for _, token := range tokens { + if containsLatin(token) { + latinTokens++ + } + } + return latinTokens >= 2 +} + +func metadataTrustLocalizedSearchKeyword(queryKey string, match *Match) bool { + if match == nil || !metadataMatchHasExternalID(match) { + return false + } + searchKey := metadataTrustKey(match.SearchKeyword) + return searchKey != "" && searchKey == queryKey && metadataTrustStrongCJKQuery(queryKey) +} + +func preferLocalizedSearchTitle(query string, match *Match) { + if match == nil || containsCJK(match.Title) { + return + } + if !metadataTrustLocalizedSearchKeyword(metadataTrustKey(query), match) { + return + } + localized := strings.TrimSpace(match.SearchKeyword) + if localized == "" { + localized = strings.TrimSpace(query) + } + if localized == "" { + return + } + if strings.TrimSpace(match.OriginalName) == "" { + match.OriginalName = strings.TrimSpace(match.Title) + } + match.Title = localized +} + +func metadataTrustStrongCJKQuery(queryKey string) bool { + cjk := 0 + for _, r := range queryKey { + switch { + case r >= '\u3400' && r <= '\u4dbf': + cjk++ + case r >= '\u4e00' && r <= '\u9fff': + cjk++ + case r >= '\uf900' && r <= '\ufaff': + cjk++ + } + } + return cjk >= 4 +} + +func metadataMatchHasExternalID(match *Match) bool { + return match != nil && + (match.TMDbID > 0 || + match.BangumiID > 0 || + strings.TrimSpace(match.DoubanID) != "" || + strings.TrimSpace(match.TheTVDBID) != "") +} + +func metadataMatchHasChineseOrigin(match *Match) bool { + if match == nil { + return false + } + languages := normalizeTokens(match.Languages...) + countries := normalizeTokens(match.Countries...) + return hasAny(languages, "ZH", "ZH-CN", "ZH-TW", "CN") || + hasAny(countries, "CN", "TW", "HK", "MO") +} + func metadataTrustSignificantTokens(key string) []string { fields := strings.Fields(key) out := make([]string, 0, len(fields)) diff --git a/internal/service/organizer.go b/internal/service/organizer.go index 2c36fca..9da67c0 100644 --- a/internal/service/organizer.go +++ b/internal/service/organizer.go @@ -27,11 +27,12 @@ import ( // OrganizerService moves/renames files into library structures. type OrganizerService struct { - cfg *config.Config - log *zap.Logger - repo *repository.Container - probe *FFprobeService // optional; used for 洗版 resolution comparison - scraper *ScraperService // optional; used to identify metadata before rename + cfg *config.Config + log *zap.Logger + repo *repository.Container + probe *FFprobeService // optional; used for 洗版 resolution comparison + scraper *ScraperService // optional; used to identify metadata before rename + activeDownloadPaths func(context.Context) []string } // NewOrganizerService is the constructor. @@ -48,6 +49,23 @@ func (o *OrganizerService) SetProbe(p *FFprobeService) { o.probe = p } // metadata before it decides the final folder and filename. func (o *OrganizerService) SetScraper(s *ScraperService) { o.scraper = s } +// SetActiveDownloadPathProvider wires a live downloader snapshot. Directory +// organize must never move/copy files that still belong to an unfinished +// torrent, regardless of which UI switch triggered the organize operation. +func (o *OrganizerService) SetActiveDownloadPathProvider(provider func(context.Context) []string) { + o.activeDownloadPaths = provider +} + +func (o *OrganizerService) SetActiveDownloadProvider(provider func(context.Context) []QBitTorrent) { + if provider == nil { + o.activeDownloadPaths = nil + return + } + o.activeDownloadPaths = func(ctx context.Context) []string { + return activeDownloadPathCandidates(provider(ctx), nil) + } +} + // OrganizeMedia moves a single media file into the target library directory. // It auto-detects whether the media is a movie or TV episode based on the // parsed season/episode numbers and builds the destination path accordingly. diff --git a/internal/service/organizer_active_downloads.go b/internal/service/organizer_active_downloads.go new file mode 100644 index 0000000..cd2392c --- /dev/null +++ b/internal/service/organizer_active_downloads.go @@ -0,0 +1,94 @@ +package service + +import ( + "context" + "os" + "path/filepath" + "strings" +) + +type activeDownloadGuard struct { + paths []string +} + +func (o *OrganizerService) newActiveDownloadGuard(ctx context.Context) activeDownloadGuard { + if o == nil || o.activeDownloadPaths == nil { + return activeDownloadGuard{} + } + return activeDownloadGuard{paths: cleanUniqueExistingPaths(o.activeDownloadPaths(ctx))} +} + +func (g activeDownloadGuard) contains(path string) bool { + path = filepath.Clean(strings.TrimSpace(path)) + if path == "" || path == "." { + return false + } + for _, root := range g.paths { + if pathWithin(path, root) { + return true + } + } + return false +} + +func activeDownloadPathCandidates(torrents []QBitTorrent, mappings map[string]string) []string { + if len(torrents) == 0 { + return nil + } + var out []string + for _, torrent := range torrents { + if qbitTorrentCompleted(torrent) { + continue + } + for _, raw := range []string{ + torrent.ContentPath, + filepath.Join(strings.TrimSpace(torrent.SavePath), strings.TrimSpace(torrent.Name)), + } { + out = appendDownloadPathCandidates(out, raw, mappings) + } + } + return cleanUniqueExistingPaths(out) +} + +func appendDownloadPathCandidates(out []string, raw string, mappings map[string]string) []string { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "." { + return out + } + if translated := translateClientPath(raw, mappings); translated != "" { + out = append(out, translated) + } + for _, candidate := range mappedPathCandidates(raw) { + if _, err := os.Stat(candidate); err == nil { + out = append(out, candidate) + } + } + return out +} + +func cleanUniqueExistingPaths(paths []string) []string { + if len(paths) == 0 { + return nil + } + out := make([]string, 0, len(paths)) + for _, path := range paths { + path = filepath.Clean(strings.TrimSpace(path)) + if path == "" || path == "." { + continue + } + if _, err := os.Stat(path); err != nil { + continue + } + duplicate := false + for _, existing := range out { + if sameLibraryPath(existing, path) { + duplicate = true + break + } + } + if !duplicate { + out = append(out, path) + } + } + return out +} diff --git a/internal/service/organizer_classification_test.go b/internal/service/organizer_classification_test.go index 18dd745..bc745ac 100644 --- a/internal/service/organizer_classification_test.go +++ b/internal/service/organizer_classification_test.go @@ -299,7 +299,7 @@ func TestOrganizeDirectoryReclassifiesMovieFromDirtyGeneratedEpisodePath(t *test } } -func TestOrganizeDirectoryRejectedMetadataKeepsExplicitWesternSourceCategory(t *testing.T) { +func TestOrganizeDirectoryChineseAliasMetadataOverridesWrongWesternSourceCategory(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.URL.Path != "/search/tv" { @@ -344,19 +344,19 @@ func TestOrganizeDirectoryRejectedMetadataKeepsExplicitWesternSourceCategory(t * if err != nil { t.Fatalf("organize directory: %v", err) } - want := filepath.Join(dest, "电视剧", "欧美剧", "Blades Of The Guardians", "Season 02", "Blades Of The Guardians - S02E01.mkv") + want := filepath.Join(dest, "动漫", "国漫", "镖人", "Season 02", "镖人 - S02E01.mkv") if res.Organized != 1 { t.Fatalf("organized = %d, want 1; items=%#v errors=%#v", res.Organized, res.Items, res.Errors) } if _, err := os.Stat(want); err != nil { - t.Fatalf("rejected metadata should fall back to explicit source category at %q: %v; items=%#v", want, err, res.Items) + t.Fatalf("Chinese alias metadata should override wrong western source category at %q: %v; items=%#v", want, err, res.Items) } - wrong := filepath.Join(dest, "电视剧", "国产剧", "Blades Of The Guardians") + wrong := filepath.Join(dest, "电视剧", "欧美剧", "Blades Of The Guardians") if _, err := os.Stat(wrong); !os.IsNotExist(err) { - t.Fatalf("rejected metadata must not fall back to domestic category %q, err=%v", wrong, err) + t.Fatalf("wrong western category should not remain at %q, err=%v", wrong, err) } - if len(res.Items) != 1 || res.Items[0].Category != "欧美剧" || res.Items[0].MediaType != "tv" || res.Items[0].Title != "Blades Of The Guardians" { - t.Fatalf("organize item = %#v, want Blades Of The Guardians in 欧美剧/tv", res.Items) + if len(res.Items) != 1 || res.Items[0].Category != "国漫" || res.Items[0].MediaType != "anime" || res.Items[0].Title != "镖人" { + t.Fatalf("organize item = %#v, want 镖人 in 国漫/anime", res.Items) } } diff --git a/internal/service/organizer_directory.go b/internal/service/organizer_directory.go index 9a99e7a..44c808d 100644 --- a/internal/service/organizer_directory.go +++ b/internal/service/organizer_directory.go @@ -28,6 +28,7 @@ var ErrUnsupportedOrganizeSource = errors.New("source is not a supported video f const ( organizeSkipAlreadyOrganized = "already organized" + organizeSkipActiveDownload = "active download" organizeSkipDuplicateLibrary = "duplicate in library" organizeSkipTargetExists = "target file exists" organizeSkipSampleClip = "sample/trailer clip" @@ -60,11 +61,18 @@ func (o *OrganizerService) OrganizeDirectory(ctx context.Context, opts OrganizeO mode := o.resolveTransferMode(ctx, opts.TransferMode) res := &OrganizeResult{SourcePath: source, DestPath: dest, DryRun: opts.DryRun} metadataCache := map[string]*Match{} + activeDownloads := o.newActiveDownloadGuard(ctx) if !info.IsDir() { ext := strings.ToLower(filepath.Ext(source)) if _, ok := videoExtensions[ext]; !ok { return nil, fmt.Errorf("%w: %s", ErrUnsupportedOrganizeSource, source) } + if activeDownloads.contains(source) { + res.Skipped++ + res.Items = append(res.Items, OrganizePreviewItem{Source: source, Action: "skip", Reason: organizeSkipActiveDownload}) + o.logOrganizeDirectoryResult("organize file finished", res, mode) + return res, nil + } if skipped, reason := shouldSkipOrganizeSourceVideo(source, filepath.Dir(source)); skipped { res.Skipped++ res.Items = append(res.Items, OrganizePreviewItem{Source: source, Action: "skip", Reason: reason}) @@ -97,6 +105,11 @@ func (o *OrganizerService) OrganizeDirectory(ctx context.Context, opts OrganizeO if _, ok := videoExtensions[ext]; !ok { return nil } + if activeDownloads.contains(path) { + res.Skipped++ + res.Items = append(res.Items, OrganizePreviewItem{Source: path, Action: "skip", Reason: organizeSkipActiveDownload}) + return nil + } if skipped, reason := shouldSkipOrganizeSourceVideo(path, source); skipped { res.Skipped++ res.Items = append(res.Items, OrganizePreviewItem{Source: path, Action: "skip", Reason: reason}) diff --git a/internal/service/organizer_directory_classification_test.go b/internal/service/organizer_directory_classification_test.go index 12ac083..a0fa75b 100644 --- a/internal/service/organizer_directory_classification_test.go +++ b/internal/service/organizer_directory_classification_test.go @@ -123,6 +123,50 @@ func TestOrganizeDirectoryTreatsCategoryDestAsCollectionRoot(t *testing.T) { } } +func TestOrganizeDirectoryCreatesAnimeCategoryUnderPhysicalAnimeRoot(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "downloads", "Blades.of.the.Guardians.S02E01.2026.1080p.mkv") + dest := filepath.Join(root, "media") + writeOrgFile(t, src, "episode") + + repos := newOrganizerTestRepo(t) + oldWrongLib := model.Library{Name: "国漫", Path: filepath.Join(dest, "电视剧", "国漫"), Type: "tv", Enabled: true} + if err := repos.Library.Create(t.Context(), &oldWrongLib); err != nil { + t.Fatal(err) + } + + org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) + res, err := org.OrganizeDirectory(t.Context(), OrganizeOptions{ + SourcePath: src, + DestPath: oldWrongLib.Path, + MediaType: "anime", + MediaCategory: "国漫", + TransferMode: TransferCopy, + }) + if err != nil { + t.Fatalf("organize anime category: %v", err) + } + wantRoot := filepath.Join(dest, "动漫", "国漫") + want := filepath.Join(wantRoot, "Blades Of The Guardians", "Season 02", "Blades Of The Guardians - S02E01.mkv") + if res.Organized != 1 || len(res.Items) != 1 { + t.Fatalf("result = %+v, want one organized anime item", res) + } + if _, err := os.Stat(want); err != nil { + t.Fatalf("anime category should be created under physical anime root %q: %v; items=%#v", want, err, res.Items) + } + wrong := filepath.Join(oldWrongLib.Path, "Blades Of The Guardians", "Season 02", "Blades Of The Guardians - S02E01.mkv") + if _, err := os.Stat(wrong); !os.IsNotExist(err) { + t.Fatalf("must not keep anime category under old TV root %q, stat err=%v", wrong, err) + } + var created model.Library + if err := repos.DB.Where("path = ?", wantRoot).First(&created).Error; err != nil { + t.Fatalf("missing physical anime category library: %v", err) + } + if created.Type != "tv" || created.Name != "国漫" { + t.Fatalf("created library = %+v, want visible tv-compatible 国漫 library", created) + } +} + func TestOrganizeDirectoryCreatesMissingCategoryLibraryForVisibility(t *testing.T) { root := t.TempDir() srcRoot := filepath.Join(root, "downloads") diff --git a/internal/service/organizer_directory_libraries.go b/internal/service/organizer_directory_libraries.go index 956a971..19d30b7 100644 --- a/internal/service/organizer_directory_libraries.go +++ b/internal/service/organizer_directory_libraries.go @@ -89,24 +89,42 @@ func (o *OrganizerService) organizeLibraryTargetScopeScore(libPath, destRoot, me if libPath == "" || libPath == "." { return 0, false } + collectionRoot := organizeMediaCollectionRoot(destRoot) + if collectionRoot != "" && pathWithin(libPath, collectionRoot) && !o.organizeLibraryMatchesExpectedPhysicalRoot(libPath, collectionRoot, mediaType, category) { + return 0, false + } if pathWithin(libPath, destRoot) || pathWithin(destRoot, libPath) { return 8, true } if _, destCategory := o.mediaTypeForDirectoryCategory(filepath.Base(destRoot)); destCategory == "" { - if root := organizeMediaCollectionRoot(destRoot); root != "" && pathWithin(libPath, root) { - return o.organizeLibraryPhysicalRootScore(libPath, root, mediaType, category), true + if collectionRoot != "" && pathWithin(libPath, collectionRoot) { + return o.organizeLibraryPhysicalRootScore(libPath, collectionRoot, mediaType, category), true } return 0, false } if strings.EqualFold(filepath.Dir(libPath), filepath.Dir(destRoot)) { return 4, true } - if root := organizeMediaCollectionRoot(destRoot); root != "" && pathWithin(libPath, root) { - return o.organizeLibraryPhysicalRootScore(libPath, root, mediaType, category), true + if collectionRoot != "" && pathWithin(libPath, collectionRoot) { + return o.organizeLibraryPhysicalRootScore(libPath, collectionRoot, mediaType, category), true } return 0, false } +func (o *OrganizerService) organizeLibraryMatchesExpectedPhysicalRoot(libPath, collectionRoot, mediaType, category string) bool { + if strings.TrimSpace(category) == "" { + return true + } + physicalRoot := o.categoryPhysicalRootDir(category) + if physicalRoot == "" { + physicalRoot = mediaTypeRootDir(mediaType) + } + if physicalRoot == "" { + return true + } + return pathHasDirectChild(libPath, collectionRoot, physicalRoot) +} + func (o *OrganizerService) organizeLibraryPhysicalRootScore(libPath, collectionRoot, mediaType, category string) int { physicalRoot := o.categoryPhysicalRootDir(category) if physicalRoot == "" { diff --git a/internal/service/organizer_directory_metadata.go b/internal/service/organizer_directory_metadata.go index 360bd1b..a883b9a 100644 --- a/internal/service/organizer_directory_metadata.go +++ b/internal/service/organizer_directory_metadata.go @@ -74,6 +74,7 @@ func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sour } continue } + preferLocalizedSearchTitle(candidate, match) if cache != nil { cache[key] = match } diff --git a/internal/service/organizer_directory_reclassify.go b/internal/service/organizer_directory_reclassify.go index e572b11..b5bd32a 100644 --- a/internal/service/organizer_directory_reclassify.go +++ b/internal/service/organizer_directory_reclassify.go @@ -2,6 +2,7 @@ package service import ( "context" + "fmt" "os" "path/filepath" "strings" @@ -24,6 +25,7 @@ type organizeExistingReclassifyRequest struct { Year int Season int Episode int + MetadataMatch *Match Result *OrganizeResult } @@ -116,10 +118,12 @@ func (o *OrganizerService) cleanupReclassifiedDuplicates(ctx context.Context, re cleaned := 0 for _, oldPath := range candidates { if !safeToRemoveReclassifiedDuplicate(oldPath, target) { - if o != nil && o.log != nil { - o.log.Warn("organize kept duplicate with different size during reclassify", - zap.String("path", oldPath), - zap.String("target", target)) + moved, err := o.moveReclassifiedConflict(ctx, req, oldPath, target) + if err != nil { + return cleaned, err + } + if moved { + cleaned++ } continue } @@ -150,6 +154,69 @@ func (o *OrganizerService) cleanupReclassifiedDuplicates(ctx context.Context, re return cleaned, nil } +func (o *OrganizerService) moveReclassifiedConflict(ctx context.Context, req organizeExistingReclassifyRequest, oldPath, target string) (bool, error) { + conflictTarget := o.nextReclassifyConflictTarget(ctx, target) + if conflictTarget == "" { + if o != nil && o.log != nil { + o.log.Warn("organize kept duplicate with different size during reclassify", + zap.String("path", oldPath), + zap.String("target", target)) + } + return false, nil + } + req.Result.Items = append(req.Result.Items, OrganizePreviewItem{ + Source: oldPath, Target: conflictTarget, Action: "reclassify", + MediaType: req.MediaType, Category: req.Category, Title: req.Title, + Reason: "metadata category changed; target occupied by different file", + }) + if req.DryRun { + return true, nil + } + if err := os.MkdirAll(filepath.Dir(conflictTarget), 0o755); err != nil { // #nosec G301 -- organized media directories must remain readable by NAS/player users. + return false, err + } + if err := moveFile(oldPath, conflictTarget); err != nil { + return false, err + } + if err := moveSidecarNFO(oldPath, conflictTarget); err != nil && o != nil && o.log != nil { + o.log.Warn("organize reclassify conflict sidecar nfo failed", + zap.String("from", nfoPath(oldPath)), + zap.String("to", nfoPath(conflictTarget)), + zap.Error(err)) + } + if err := o.updateReclassifiedMediaRow(ctx, oldPath, conflictTarget, req); err != nil { + return false, err + } + cleanupEmptyMediaDirs(filepath.Dir(oldPath), req.DestRoot) + if o != nil && o.log != nil { + o.log.Info("organize moved conflicting duplicate to correct category", + zap.String("from", oldPath), + zap.String("to", conflictTarget), + zap.String("occupied_target", target), + zap.String("category", req.Category), + zap.String("media_type", req.MediaType)) + } + return true, nil +} + +func (o *OrganizerService) nextReclassifyConflictTarget(ctx context.Context, target string) string { + target = filepath.Clean(strings.TrimSpace(target)) + if target == "" || target == "." { + return "" + } + dir := filepath.Dir(target) + ext := filepath.Ext(target) + base := strings.TrimSuffix(filepath.Base(target), ext) + for i := 2; i <= 999; i++ { + candidate := filepath.Join(dir, fmt.Sprintf("%s (%d)%s", base, i, ext)) + if organizeFileExists(candidate) || o.mediaPathExists(ctx, candidate) { + continue + } + return candidate + } + return "" +} + func safeToRemoveReclassifiedDuplicate(path, target string) bool { info, err := os.Stat(path) if err != nil { @@ -221,9 +288,62 @@ func (o *OrganizerService) updateReclassifiedMediaRow(ctx context.Context, oldPa } else if req.Episode > 0 { updates["episode_num"] = req.Episode } + applyReclassifyMatchUpdates(updates, req.MetadataMatch) return o.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("path = ?", oldPath).Updates(updates).Error } +func applyReclassifyMatchUpdates(updates map[string]any, match *Match) { + if updates == nil || match == nil { + return + } + if value := strings.TrimSpace(match.Title); value != "" { + updates["title"] = value + } + if value := strings.TrimSpace(match.OriginalName); value != "" { + updates["original_name"] = value + } + if value := strings.TrimSpace(match.Overview); value != "" { + updates["overview"] = value + } + if value := strings.TrimSpace(match.PosterURL); value != "" { + updates["poster_url"] = value + } + if value := strings.TrimSpace(match.BackdropURL); value != "" { + updates["backdrop_url"] = value + } + if match.Rating > 0 { + updates["rating"] = match.Rating + } + if match.Year > 0 { + updates["year"] = match.Year + } + if match.TMDbID > 0 { + updates["tm_db_id"] = match.TMDbID + } + if match.BangumiID > 0 { + updates["bangumi_id"] = match.BangumiID + } + if value := strings.TrimSpace(match.DoubanID); value != "" { + updates["douban_id"] = value + } + if value := strings.TrimSpace(match.TheTVDBID); value != "" { + updates["thetvdb_id"] = value + } + if len(match.Genres) > 0 { + updates["genres"] = strings.Join(match.Genres, ",") + } + if len(match.Countries) > 0 { + updates["countries"] = strings.Join(match.Countries, ",") + } + if len(match.Languages) > 0 { + updates["languages"] = strings.Join(match.Languages, ",") + } + if match.NSFW { + updates["nsfw"] = true + } + updates["scrape_status"] = "matched" +} + func (o *OrganizerService) deleteMediaRowForPath(ctx context.Context, path string) { if o == nil || o.repo == nil || o.repo.DB == nil { return diff --git a/internal/service/organizer_directory_source.go b/internal/service/organizer_directory_source.go index 90a4452..9bda321 100644 --- a/internal/service/organizer_directory_source.go +++ b/internal/service/organizer_directory_source.go @@ -337,6 +337,7 @@ func (o *OrganizerService) handleExistingSourceVersions(ctx context.Context, req Year: plan.Identity.Year, Season: plan.Identity.Season, Episode: plan.Identity.Episode, + MetadataMatch: plan.MetadataMatch, Result: req.Result, }) if err != nil || reclassified { diff --git a/internal/service/organizer_directory_test.go b/internal/service/organizer_directory_test.go index 57e1e62..d1de43e 100644 --- a/internal/service/organizer_directory_test.go +++ b/internal/service/organizer_directory_test.go @@ -1,6 +1,7 @@ package service import ( + "context" "os" "path/filepath" "testing" @@ -100,6 +101,48 @@ func TestOrganizeDirectoryUsesConfiguredSourceWhenRequestSourceEmpty(t *testing. } } +func TestOrganizeDirectorySkipsActiveQBitTorrentDownloads(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "downloads") + dest := filepath.Join(root, "media") + active := filepath.Join(src, "Still.Downloading.2026.1080p.mkv") + done := filepath.Join(src, "Dune 2021 2160p WEB-DL.mkv") + writeOrgFile(t, active, "partial") + writeOrgFile(t, done, "done") + + org := NewOrganizerService(&config.Config{}, zap.NewNop(), newOrganizerTestRepo(t)) + org.SetActiveDownloadProvider(func(context.Context) []QBitTorrent { + return []QBitTorrent{{ + Hash: "active", + Name: "Still.Downloading.2026.1080p", + State: "downloading", + Progress: 0.42, + SavePath: src, + ContentPath: active, + }} + }) + res, err := org.OrganizeDirectory(t.Context(), OrganizeOptions{ + SourcePath: src, + DestPath: dest, + TransferMode: TransferMove, + }) + if err != nil { + t.Fatalf("organize directory: %v", err) + } + if res.Organized != 1 || res.Skipped != 1 { + t.Fatalf("result = %+v, want organized=1 skipped=1", res) + } + if _, err := os.Stat(active); err != nil { + t.Fatalf("active qB file must stay in source: %v", err) + } + for _, item := range res.Items { + if item.Source == active && item.Action == "skip" && item.Reason == organizeSkipActiveDownload { + return + } + } + t.Fatalf("missing active-download skip item: %+v", res.Items) +} + func TestOrganizeDirectoryMapsConfiguredHostPathsToContainerPaths(t *testing.T) { root := t.TempDir() hostDownloads := filepath.Join(root, "nas-host", "downloads") diff --git a/internal/service/organizer_reclassify_scanned.go b/internal/service/organizer_reclassify_scanned.go index ccf2c4c..b7f999c 100644 --- a/internal/service/organizer_reclassify_scanned.go +++ b/internal/service/organizer_reclassify_scanned.go @@ -16,6 +16,7 @@ import ( // Empty LibraryIDs means all enabled local libraries. type MediaCategoryReclassifyOptions struct { LibraryIDs []string + MediaIDs []string DryRun bool } @@ -31,6 +32,7 @@ func (o *OrganizerService) ReclassifyMisclassifiedMedia(ctx context.Context, opt return res, err } filterIDs := compactLibraryIDs(opts.LibraryIDs...) + mediaIDs := compactLibraryIDs(opts.MediaIDs...) filter := map[string]struct{}{} for _, id := range filterIDs { filter[id] = struct{}{} @@ -55,6 +57,9 @@ func (o *OrganizerService) ReclassifyMisclassifiedMedia(ctx context.Context, opt if len(filter) > 0 { query = query.Where("library_id IN ?", filterIDs) } + if len(mediaIDs) > 0 { + query = query.Where("id IN ?", mediaIDs) + } var rows []model.Media err = query.FindInBatches(&rows, 500, func(_ *gorm.DB, _ int) error { for i := range rows { @@ -88,14 +93,25 @@ func (o *OrganizerService) reclassifyScannedMedia(ctx context.Context, media mod if res == nil || !lib.Enabled || strings.TrimSpace(media.Path) == "" { return false, nil } - if _, ok := ParseCloudLibraryMount(lib.Path); ok { - return false, nil + if mount, ok := ParseCloudLibraryMount(lib.Path); ok { + return o.reclassifyCloudScannedMedia(ctx, media, lib, mount, dryRun, res) } - if !organizeFileExists(media.Path) || !mediaHasReliableCategoryMetadata(media) { + if !organizeFileExists(media.Path) { return false, nil } mediaType := normalizeOrganizeMediaType(lib.Type) + metadataMatch := organizeMatchFromMedia(&media) + if !mediaHasReliableCategoryMetadata(media) { + metadataMatch = o.lookupReclassifyMetadata(ctx, media, lib, mediaType) + if metadataMatch == nil { + return false, nil + } + media = mediaWithReclassifyMatch(media, metadataMatch) + } + if matchType := normalizeOrganizeMediaType(metadataMatchMediaType(metadataMatch)); matchType != "" { + mediaType = matchType + } category := o.classifyMedia(ctx, &media, mediaType) if category == "" { return false, nil @@ -127,7 +143,7 @@ func (o *OrganizerService) reclassifyScannedMedia(ctx context.Context, media mod return false, nil } if pathWithin(media.Path, targetLibrary.Path) { - return o.reclassifyScannedMediaLibraryOnly(ctx, media, lib, targetLibrary, category, mediaType, dryRun, res) + return o.reclassifyScannedMediaLibraryOnly(ctx, media, lib, targetLibrary, category, mediaType, dryRun, res, metadataMatch) } title := sanitizeFilename(strings.TrimSpace(media.Title)) @@ -162,11 +178,177 @@ func (o *OrganizerService) reclassifyScannedMedia(ctx context.Context, media mod Year: media.Year, Season: media.SeasonNum, Episode: media.EpisodeNum, + MetadataMatch: metadataMatch, Result: res, }) } -func (o *OrganizerService) reclassifyScannedMediaLibraryOnly(ctx context.Context, media model.Media, oldLib, targetLib model.Library, category, mediaType string, dryRun bool, res *OrganizeResult) (bool, error) { +func (o *OrganizerService) reclassifyCloudScannedMedia(ctx context.Context, media model.Media, lib model.Library, mount CloudMountInfo, dryRun bool, res *OrganizeResult) (bool, error) { + mediaType := normalizeOrganizeMediaType(lib.Type) + metadataMatch := organizeMatchFromMedia(&media) + if !mediaHasReliableCategoryMetadata(media) { + metadataMatch = o.lookupReclassifyMetadata(ctx, media, lib, mediaType) + if metadataMatch == nil { + return false, nil + } + media = mediaWithReclassifyMatch(media, metadataMatch) + } + if matchType := normalizeOrganizeMediaType(metadataMatchMediaType(metadataMatch)); matchType != "" { + mediaType = matchType + } + category := o.classifyMedia(ctx, &media, mediaType) + if category == "" { + return false, nil + } + if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(category); impliedType != "" { + mediaType = impliedType + category = normalizedCategory + } + if mediaType == "" { + mediaType = normalizeOrganizeMediaType(lib.Type) + } + displayDir := o.cloudReclassifyCategoryDisplayDir(mediaType, category) + if displayDir == "" { + return false, nil + } + if normalizeCloudMountDir(mount.Provider, mount.DisplayDir) == normalizeCloudMountDir(mount.Provider, displayDir) { + return false, nil + } + targetLibrary, ok, err := o.ensureCloudReclassifyLibrary(ctx, mount.Provider, displayDir, mediaType, dryRun) + if err != nil || !ok { + return false, err + } + if strings.TrimSpace(targetLibrary.ID) != "" && targetLibrary.ID == lib.ID { + return false, nil + } + title := sanitizeFilename(strings.TrimSpace(media.Title)) + if title == "" { + title = "Unknown" + } + res.Items = append(res.Items, OrganizePreviewItem{ + Source: media.Path, + Target: targetLibrary.Path, + Action: "reclassify", + Reason: "cloud metadata category library changed", + MediaType: mediaType, + Category: category, + Title: title, + }) + if dryRun { + res.Reclassified++ + return true, nil + } + updates := map[string]any{ + "library_id": targetLibrary.ID, + "series_id": "", + } + applyReclassifyMatchUpdates(updates, metadataMatch) + if err := o.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("id = ?", media.ID).Updates(updates).Error; err != nil { + return false, err + } + if o.log != nil { + o.log.Info("cloud media library reclassified by metadata", + zap.String("media", media.ID), + zap.String("path", media.Path), + zap.String("from_library", lib.ID), + zap.String("to_library", targetLibrary.ID), + zap.String("category", category), + zap.String("media_type", mediaType), + zap.String("display_dir", displayDir)) + } + res.Reclassified++ + return true, nil +} + +func (o *OrganizerService) cloudReclassifyCategoryDisplayDir(mediaType, category string) string { + category = sanitizeFilename(strings.TrimSpace(category)) + if category == "" { + return "" + } + root := o.mediaTypeRootDirForCategory(mediaType, category) + if root == "" { + return "" + } + return strings.Join([]string{root, category}, "/") +} + +func (o *OrganizerService) ensureCloudReclassifyLibrary(ctx context.Context, provider, displayDir, mediaType string, dryRun bool) (model.Library, bool, error) { + if o == nil || o.repo == nil || o.repo.Library == nil { + return model.Library{}, false, nil + } + provider = strings.TrimSpace(provider) + displayDir = normalizeCloudMountDir(provider, displayDir) + if provider == "" || displayDir == "" { + return model.Library{}, false, nil + } + if existing := o.findCloudReclassifyLibrary(ctx, provider, displayDir); existing != nil { + return *existing, true, nil + } + path := BuildCloudAutoCategoryLibraryPath(provider, displayDir) + if path == "" { + return model.Library{}, false, nil + } + name := cloudMountDirBase(displayDir) + if name == "" { + name = displayDir + } + libType := InferCloudMountMediaType(displayDir, name) + if normalizeOrganizeMediaType(libType) == "" { + libType = organizeLibraryModelType(mediaType) + } + lib := model.Library{ + Name: name, + Path: path, + Type: libType, + Enabled: true, + } + if dryRun { + return lib, true, nil + } + if err := o.repo.Library.Create(ctx, &lib); err != nil { + if existing := o.findCloudReclassifyLibrary(ctx, provider, displayDir); existing != nil { + return *existing, true, nil + } + return model.Library{}, false, err + } + if o.log != nil { + o.log.Info("created cloud metadata reclassify library", + zap.String("library_id", lib.ID), + zap.String("provider", provider), + zap.String("display_dir", displayDir), + zap.String("type", lib.Type)) + } + return lib, true, nil +} + +func (o *OrganizerService) findCloudReclassifyLibrary(ctx context.Context, provider, displayDir string) *model.Library { + if o == nil || o.repo == nil || o.repo.Library == nil { + return nil + } + libs, err := o.repo.Library.List(ctx) + if err != nil { + if o.log != nil { + o.log.Warn("list cloud libraries for metadata reclassify failed", zap.Error(err)) + } + return nil + } + displayDir = normalizeCloudMountDir(provider, displayDir) + for _, lib := range libs { + if !lib.Enabled { + continue + } + info, ok := ParseCloudLibraryMount(lib.Path) + if !ok || info.Provider != provider { + continue + } + if normalizeCloudMountDir(provider, info.DisplayDir) == displayDir { + return &lib + } + } + return nil +} + +func (o *OrganizerService) reclassifyScannedMediaLibraryOnly(ctx context.Context, media model.Media, oldLib, targetLib model.Library, category, mediaType string, dryRun bool, res *OrganizeResult, metadataMatch *Match) (bool, error) { res.Items = append(res.Items, OrganizePreviewItem{ Source: media.Path, Target: media.Path, @@ -180,10 +362,12 @@ func (o *OrganizerService) reclassifyScannedMediaLibraryOnly(ctx context.Context res.Reclassified++ return true, nil } + updates := map[string]any{"library_id": targetLib.ID, "series_id": ""} + applyReclassifyMatchUpdates(updates, metadataMatch) if err := o.repo.DB.WithContext(ctx). Model(&model.Media{}). Where("id = ?", media.ID). - Update("library_id", targetLib.ID).Error; err != nil { + Updates(updates).Error; err != nil { return false, err } if o.log != nil { @@ -199,6 +383,118 @@ func (o *OrganizerService) reclassifyScannedMediaLibraryOnly(ctx context.Context return true, nil } +func (o *OrganizerService) lookupReclassifyMetadata(ctx context.Context, media model.Media, lib model.Library, mediaType string) *Match { + if o == nil || o.scraper == nil || !o.scraper.AnyEnabled() { + return nil + } + title := strings.TrimSpace(media.Title) + if title == "" { + title, _ = CleanQuery(media.Path) + } + for _, typ := range reclassifyMetadataLookupTypes(mediaType, media) { + if match := o.lookupOrganizeMetadata(ctx, media.Path, lib.Path, typ, title, media.Year, media.SeasonNum, media.EpisodeNum, nil); match != nil { + if o.log != nil { + o.log.Info("metadata category reclassify filled missing metadata", + zap.String("media", media.ID), + zap.String("path", media.Path), + zap.String("title", match.Title), + zap.String("media_type", typ), + zap.Int("tmdb_id", match.TMDbID), + zap.Int("bangumi_id", match.BangumiID), + zap.String("douban_id", match.DoubanID), + zap.String("thetvdb_id", match.TheTVDBID)) + } + return match + } + } + return nil +} + +func reclassifyMetadataLookupTypes(mediaType string, media model.Media) []string { + seen := map[string]struct{}{} + out := make([]string, 0, 3) + add := func(value string) { + value = normalizeOrganizeMediaType(value) + if value == "" { + return + } + if _, ok := seen[value]; ok { + return + } + seen[value] = struct{}{} + out = append(out, value) + } + add(mediaType) + if media.SeasonNum > 0 || media.EpisodeNum > 0 { + add("tv") + add("anime") + } + switch normalizeOrganizeMediaType(mediaType) { + case "tv": + add("anime") + add("movie") + case "anime": + add("tv") + add("movie") + case "movie", "": + add("tv") + add("anime") + add("movie") + default: + add("tv") + add("movie") + } + return out +} + +func mediaWithReclassifyMatch(media model.Media, match *Match) model.Media { + if match == nil { + return media + } + if value := strings.TrimSpace(match.Title); value != "" { + media.Title = value + } + if value := strings.TrimSpace(match.OriginalName); value != "" { + media.OriginalName = value + } + if match.Year > 0 { + media.Year = match.Year + } + if match.TMDbID > 0 { + media.TMDbID = match.TMDbID + } + if match.BangumiID > 0 { + media.BangumiID = match.BangumiID + } + if value := strings.TrimSpace(match.DoubanID); value != "" { + media.DoubanID = value + } + if value := strings.TrimSpace(match.TheTVDBID); value != "" { + media.TheTVDBID = value + } + if len(match.Languages) > 0 { + media.Languages = strings.Join(match.Languages, ",") + } + if len(match.Countries) > 0 { + media.Countries = strings.Join(match.Countries, ",") + } + if len(match.Genres) > 0 { + media.Genres = strings.Join(match.Genres, ",") + } + if match.NSFW { + media.NSFW = true + } + media.ScrapeStatus = "matched" + return media +} + +func metadataMatchMediaType(match *Match) string { + if match == nil { + return "" + } + return match.MediaType +} + func mediaHasReliableCategoryMetadata(media model.Media) bool { return media.NSFW || strings.TrimSpace(media.Languages) != "" || diff --git a/internal/service/organizer_reclassify_test.go b/internal/service/organizer_reclassify_test.go index 6335a6d..6bd931e 100644 --- a/internal/service/organizer_reclassify_test.go +++ b/internal/service/organizer_reclassify_test.go @@ -25,7 +25,7 @@ func TestOrganizeDirectoryReclassifiesExistingWrongCategoryMedia(t *testing.T) { "results": []map[string]any{{ "id": 292696, "name": "莫离", - "original_name": "The First Jasmine", + "original_name": "莫离", "original_language": "zh", "origin_country": []string{"CN"}, "genre_ids": []int{18}, @@ -108,6 +108,161 @@ func TestOrganizeDirectoryReclassifiesExistingWrongCategoryMedia(t *testing.T) { } } +func TestReclassifyMisclassifiedMediaFiltersByMediaID(t *testing.T) { + repos := newOrganizerTestRepo(t) + cfg := &config.Config{} + cfg.Organizer.SmartClassify = true + + root := t.TempDir() + dest := filepath.Join(root, "media") + euusLib := model.Library{Name: "欧美剧", Path: filepath.Join(dest, "电视剧", "欧美剧"), Type: "tv", Enabled: true} + domesticLib := model.Library{Name: "国产剧", Path: filepath.Join(dest, "电视剧", "国产剧"), Type: "tv", Enabled: true} + if err := repos.Library.Create(t.Context(), &euusLib); err != nil { + t.Fatal(err) + } + if err := repos.Library.Create(t.Context(), &domesticLib); err != nil { + t.Fatal(err) + } + + movingPath := filepath.Join(euusLib.Path, "Motherhood Of Taihang", "Season 01", "Motherhood Of Taihang - S01E01.mkv") + stayingPath := filepath.Join(euusLib.Path, "The First Jasmine", "Season 01", "The First Jasmine - S01E01.mkv") + writeOrgFile(t, movingPath, "episode") + writeOrgFile(t, stayingPath, "episode") + + moving := model.Media{ + LibraryID: euusLib.ID, + Title: "太行谣", + OriginalName: "Motherhood Of Taihang", + Path: movingPath, + SeasonNum: 1, + EpisodeNum: 1, + TMDbID: 323682, + Languages: "zh", + Countries: "CN", + Genres: "剧情", + ScrapeStatus: "matched", + } + staying := model.Media{ + LibraryID: euusLib.ID, + Title: "莫离", + OriginalName: "The First Jasmine", + Path: stayingPath, + SeasonNum: 1, + EpisodeNum: 1, + TMDbID: 292696, + Languages: "zh", + Countries: "CN", + Genres: "剧情", + ScrapeStatus: "matched", + } + if err := repos.DB.Create(&moving).Error; err != nil { + t.Fatal(err) + } + if err := repos.DB.Create(&staying).Error; err != nil { + t.Fatal(err) + } + + organizer := NewOrganizerService(cfg, zap.NewNop(), repos) + res, err := organizer.ReclassifyMisclassifiedMedia(t.Context(), MediaCategoryReclassifyOptions{MediaIDs: []string{moving.ID}}) + if err != nil { + t.Fatalf("reclassify media: %v", err) + } + want := filepath.Join(domesticLib.Path, "太行谣", "Season 01", "太行谣 - S01E01.mkv") + if res.Reclassified != 1 { + t.Fatalf("reclassified = %d, want 1; items=%#v errors=%#v", res.Reclassified, res.Items, res.Errors) + } + if _, err := os.Stat(want); err != nil { + t.Fatalf("selected media should move to domestic path %q: %v", want, err) + } + if _, err := os.Stat(stayingPath); err != nil { + t.Fatalf("unselected media should stay at wrong path for single-media reclassify: %v", err) + } + var got model.Media + if err := repos.DB.First(&got, "id = ?", staying.ID).Error; err != nil { + t.Fatal(err) + } + if got.LibraryID != euusLib.ID || got.Path != stayingPath { + t.Fatalf("unselected row changed: %#v", got) + } +} + +func TestReclassifyMisclassifiedMediaRetriesNoMatchMetadata(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path != "/search/tv" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "results": []map[string]any{{ + "id": 292696, + "name": "莫离", + "original_name": "The First Jasmine", + "original_language": "zh", + "origin_country": []string{"CN"}, + "genre_ids": []int{18}, + "first_air_date": "2026-06-23", + }}, + }) + })) + defer upstream.Close() + + repos := newOrganizerTestRepo(t) + cfg := &config.Config{} + cfg.Organizer.SmartClassify = true + cfg.Secrets.TMDbAPIKey = "test-key" + cfg.Secrets.TMDbAPIProxy = upstream.URL + scraper := NewScraperService(cfg, zap.NewNop(), repos, NewTMDbProvider(cfg, zap.NewNop(), nil), nil, nil, nil, NewHub(zap.NewNop())) + + root := t.TempDir() + dest := filepath.Join(root, "media") + euusLib := model.Library{Name: "欧美剧", Path: filepath.Join(dest, "电视剧", "欧美剧"), Type: "tv", Enabled: true} + domesticLib := model.Library{Name: "国产剧", Path: filepath.Join(dest, "电视剧", "国产剧"), Type: "tv", Enabled: true} + if err := repos.Library.Create(t.Context(), &euusLib); err != nil { + t.Fatal(err) + } + if err := repos.Library.Create(t.Context(), &domesticLib); err != nil { + t.Fatal(err) + } + + wrongPath := filepath.Join(euusLib.Path, "The First Jasmine", "Season 1", "The First Jasmine - S01E01.mkv") + writeOrgFile(t, wrongPath, "episode") + if err := repos.DB.Create(&model.Media{ + LibraryID: euusLib.ID, + Title: "the first jasmine", + Path: wrongPath, + SeasonNum: 1, + EpisodeNum: 1, + ScrapeStatus: "no_match", + }).Error; err != nil { + t.Fatal(err) + } + + organizer := NewOrganizerService(cfg, zap.NewNop(), repos) + organizer.SetScraper(scraper) + res, err := organizer.ReclassifyMisclassifiedMedia(t.Context(), MediaCategoryReclassifyOptions{}) + if err != nil { + t.Fatalf("reclassify media: %v", err) + } + want := filepath.Join(domesticLib.Path, "莫离", "Season 01", "莫离 - S01E01.mkv") + if res.Reclassified != 1 { + t.Fatalf("reclassified = %d, want 1; items=%#v errors=%#v", res.Reclassified, res.Items, res.Errors) + } + if _, err := os.Stat(wrongPath); !os.IsNotExist(err) { + t.Fatalf("wrong no_match path should move away, stat err=%v", err) + } + if _, err := os.Stat(want); err != nil { + t.Fatalf("reclassified media missing at %q: %v; items=%#v", want, err, res.Items) + } + var got model.Media + if err := repos.DB.First(&got, "path = ?", want).Error; err != nil { + t.Fatal(err) + } + if got.LibraryID != domesticLib.ID || got.Title != "莫离" || got.TMDbID != 292696 || got.Countries != "CN" || got.Languages != "zh" || got.ScrapeStatus != "matched" { + t.Fatalf("row after metadata retry = %#v, want domestic matched metadata", got) + } +} + func TestOrganizeDirectoryCleansReleaseNoiseBeforeMetadataClassify(t *testing.T) { var queries []string upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -509,3 +664,108 @@ func TestOrganizeDirectoryCleansWrongCategoryDuplicateWhenTargetExists(t *testin t.Fatalf("source download should remain untouched: %v", err) } } + +func TestOrganizeDirectoryMovesWrongCategoryDifferentSizeDuplicateToConflictPath(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path != "/search/tv" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "results": []map[string]any{{ + "id": 292696, + "name": "莫离", + "original_name": "The First Jasmine", + "original_language": "zh", + "origin_country": []string{"CN"}, + "genre_ids": []int{18}, + "first_air_date": "2026-06-23", + }}, + }) + })) + defer upstream.Close() + + repos := newOrganizerTestRepo(t) + cfg := &config.Config{} + cfg.Organizer.SmartClassify = true + cfg.Secrets.TMDbAPIKey = "test-key" + cfg.Secrets.TMDbAPIProxy = upstream.URL + scraper := NewScraperService(cfg, zap.NewNop(), repos, NewTMDbProvider(cfg, zap.NewNop(), nil), nil, nil, nil, NewHub(zap.NewNop())) + + root := t.TempDir() + srcRoot := filepath.Join(root, "downloads") + dest := filepath.Join(root, "media") + sourceFile := filepath.Join(srcRoot, "欧美剧", "The.First.Jasmine.S01.1080p.TX.WEB-DL.AAC2.0.H.264-MWeb", "The.First.Jasmine.S01E01.1080p.TX.WEB-DL.AAC2.0.H.264-MWeb.mkv") + writeOrgFile(t, sourceFile, "episode") + + euusLib := model.Library{Name: "欧美剧", Path: filepath.Join(dest, "电视剧", "欧美剧"), Type: "tv", Enabled: true} + domesticLib := model.Library{Name: "国产剧", Path: filepath.Join(dest, "电视剧", "国产剧"), Type: "tv", Enabled: true} + if err := repos.Library.Create(t.Context(), &euusLib); err != nil { + t.Fatal(err) + } + if err := repos.Library.Create(t.Context(), &domesticLib); err != nil { + t.Fatal(err) + } + + targetPath := filepath.Join(domesticLib.Path, "莫离", "Season 01", "莫离 - S01E01.mkv") + conflictPath := filepath.Join(domesticLib.Path, "莫离", "Season 01", "莫离 - S01E01 (2).mkv") + wrongPath := filepath.Join(euusLib.Path, "The First Jasmine", "Season 01", "The First Jasmine - S01E01.mkv") + writeOrgFile(t, targetPath, "short") + writeOrgFile(t, wrongPath, "different-longer-bytes") + if err := repos.DB.Create(&model.Media{ + LibraryID: domesticLib.ID, + Title: "莫离", + Path: targetPath, + SeasonNum: 1, + EpisodeNum: 1, + TMDbID: 292696, + ScrapeStatus: "matched", + }).Error; err != nil { + t.Fatal(err) + } + if err := repos.DB.Create(&model.Media{ + LibraryID: euusLib.ID, + Title: "莫离", + Path: wrongPath, + SeasonNum: 1, + EpisodeNum: 1, + TMDbID: 292696, + Languages: "zh", + Countries: "CN", + Genres: "剧情", + ScrapeStatus: "matched", + }).Error; err != nil { + t.Fatal(err) + } + + organizer := NewOrganizerService(cfg, zap.NewNop(), repos) + organizer.SetScraper(scraper) + res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{ + SourcePath: srcRoot, + DestPath: euusLib.Path, + TransferMode: TransferCopy, + }) + if err != nil { + t.Fatalf("organize directory: %v", err) + } + if res.Reclassified != 1 || res.Organized != 0 || res.Skipped != 0 { + t.Fatalf("result = %+v, want reclassified=1 only", res) + } + if _, err := os.Stat(targetPath); err != nil { + t.Fatalf("canonical target should remain: %v", err) + } + if _, err := os.Stat(conflictPath); err != nil { + t.Fatalf("different-size duplicate should move into correct category conflict path %q: %v; items=%#v", conflictPath, err, res.Items) + } + if _, err := os.Stat(wrongPath); !os.IsNotExist(err) { + t.Fatalf("wrong category duplicate should be moved away, stat err=%v", err) + } + var got model.Media + if err := repos.DB.First(&got, "path = ?", conflictPath).Error; err != nil { + t.Fatal(err) + } + if got.LibraryID != domesticLib.ID { + t.Fatalf("library_id = %q, want domestic library %q", got.LibraryID, domesticLib.ID) + } +} diff --git a/internal/service/scanner.go b/internal/service/scanner.go index 88ff870..683bcaa 100644 --- a/internal/service/scanner.go +++ b/internal/service/scanner.go @@ -43,15 +43,16 @@ var videoExtensions = map[string]struct{}{ // ScannerService walks libraries on disk and upserts model.Media rows. type ScannerService struct { - cfg *config.Config - log *zap.Logger - repo *repository.Container - hub *Hub - probe *FFprobeService - scraper *ScraperService - storage *StorageConfigService - cache *RuntimeCacheService - notify *NotifyChannelService + cfg *config.Config + log *zap.Logger + repo *repository.Container + hub *Hub + probe *FFprobeService + scraper *ScraperService + organizer *OrganizerService + storage *StorageConfigService + cache *RuntimeCacheService + notify *NotifyChannelService imageProxy *ImageProxy @@ -118,6 +119,12 @@ func (s *ScannerService) SetStorageConfig(storage *StorageConfigService) { } } +func (s *ScannerService) SetOrganizer(organizer *OrganizerService) { + if s != nil { + s.organizer = organizer + } +} + func (s *ScannerService) SetRuntimeCache(cache *RuntimeCacheService) { if s != nil { s.cache = cache diff --git a/internal/service/scanner_incremental_test.go b/internal/service/scanner_incremental_test.go index 3e1ea91..0467af9 100644 --- a/internal/service/scanner_incremental_test.go +++ b/internal/service/scanner_incremental_test.go @@ -118,6 +118,41 @@ func TestScanLibrarySkipsUnchangedExistingLocalMedia(t *testing.T) { } } +func TestScanLibraryUpdatesExistingPathFromOverlappingLibrary(t *testing.T) { + sc, repos := newScannerTestEnv(t) + root := t.TempDir() + file := filepath.Join(root, "Shared Movie (2026).mkv") + if err := os.WriteFile(file, []byte("same-file"), 0o644); err != nil { + t.Fatal(err) + } + firstLib := model.Library{Name: "Movies A", Path: root, Type: "movie", Enabled: true} + secondLib := model.Library{Name: "Movies B", Path: root, Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &firstLib); err != nil { + t.Fatal(err) + } + if err := repos.Library.Create(t.Context(), &secondLib); err != nil { + t.Fatal(err) + } + + first, err := sc.ScanLibrary(t.Context(), firstLib.ID) + if err != nil { + t.Fatalf("first scan: %v", err) + } + if first.Added != 1 { + t.Fatalf("first scan = %#v, want added=1", first) + } + second, err := sc.ScanLibrary(t.Context(), secondLib.ID) + if err != nil { + t.Fatalf("second scan: %v", err) + } + if second.Added != 0 || second.Updated != 1 || second.ErrorCount != 0 { + t.Fatalf("second scan = %#v, want existing global path updated without duplicate insert errors", second) + } + if got := countMedia(t, repos); got != 1 { + t.Fatalf("media count = %d, want one row for overlapping libraries", got) + } +} + func TestScanLibrarySkipsUnchangedLocalMetadata(t *testing.T) { sc, repos := newScannerTestEnv(t) root := t.TempDir() diff --git a/internal/service/scanner_local_write_batch.go b/internal/service/scanner_local_write_batch.go index da97cb6..7b38825 100644 --- a/internal/service/scanner_local_write_batch.go +++ b/internal/service/scanner_local_write_batch.go @@ -2,6 +2,7 @@ package service import ( "context" + "path/filepath" "go.uber.org/zap" @@ -61,9 +62,27 @@ func (b *localMediaWriteBatch) Flush() { if len(media) == 0 { return } - if err := b.scanner.repo.DB.WithContext(b.ctx).CreateInBatches(&media, b.limit).Error; err == nil { - b.res.Added += len(media) - for _, item := range items { + existingPaths := b.existingPaths(items) + createItems := make([]localMediaWriteItem, 0, len(items)) + createMedia := make([]model.Media, 0, len(items)) + for _, item := range items { + if item.media == nil { + continue + } + if existingPaths[filepath.Clean(item.media.Path)] { + b.upsertExistingItem(item) + continue + } + createItems = append(createItems, item) + createMedia = append(createMedia, *item.media) + } + if len(createMedia) == 0 { + b.publish() + return + } + if err := b.scanner.repo.DB.WithContext(b.ctx).CreateInBatches(&createMedia, b.limit).Error; err == nil { + b.res.Added += len(createMedia) + for _, item := range createItems { if item.after != nil { item.after() } @@ -71,16 +90,21 @@ func (b *localMediaWriteBatch) Flush() { b.publish() return } - for _, item := range items { + for _, item := range createItems { if item.media == nil { continue } + wasExisting := b.mediaPathExists(item.media.Path) if err := b.scanner.repo.Media.Upsert(b.ctx, item.media); err != nil { addScanError(b.res, item.path, err) b.scanner.log.Warn("upsert media failed", zap.String("path", item.path), zap.Error(err)) continue } - b.res.Added++ + if wasExisting { + b.res.Updated++ + } else { + b.res.Added++ + } if item.after != nil { item.after() } @@ -88,6 +112,64 @@ func (b *localMediaWriteBatch) Flush() { b.publish() } +func (b *localMediaWriteBatch) existingPaths(items []localMediaWriteItem) map[string]bool { + out := map[string]bool{} + if b == nil || b.scanner == nil || b.scanner.repo == nil || b.scanner.repo.DB == nil || len(items) == 0 { + return out + } + paths := make([]string, 0, len(items)) + for _, item := range items { + if item.media == nil || item.media.Path == "" { + continue + } + paths = append(paths, item.media.Path) + } + if len(paths) == 0 { + return out + } + var rows []string + if err := b.scanner.repo.DB.WithContext(b.ctx). + Unscoped(). + Model(&model.Media{}). + Where("path IN ?", paths). + Pluck("path", &rows).Error; err != nil { + b.scanner.log.Debug("load existing media paths for scan batch failed", zap.Error(err)) + return out + } + for _, path := range rows { + out[filepath.Clean(path)] = true + } + return out +} + +func (b *localMediaWriteBatch) upsertExistingItem(item localMediaWriteItem) { + if item.media == nil { + return + } + if err := b.scanner.repo.Media.Upsert(b.ctx, item.media); err != nil { + addScanError(b.res, item.path, err) + b.scanner.log.Warn("upsert media failed", zap.String("path", item.path), zap.Error(err)) + return + } + b.res.Updated++ + if item.after != nil { + item.after() + } +} + +func (b *localMediaWriteBatch) mediaPathExists(path string) bool { + if b == nil || b.scanner == nil || b.scanner.repo == nil || b.scanner.repo.DB == nil || path == "" { + return false + } + var count int64 + err := b.scanner.repo.DB.WithContext(b.ctx). + Unscoped(). + Model(&model.Media{}). + Where("path = ?", path). + Count(&count).Error + return err == nil && count > 0 +} + func (b *localMediaWriteBatch) publish() { if b == nil || b.scanner == nil || b.scanner.hub == nil || b.res == nil { return diff --git a/internal/service/scanner_post_scan.go b/internal/service/scanner_post_scan.go index bf7d923..ffa1b7b 100644 --- a/internal/service/scanner_post_scan.go +++ b/internal/service/scanner_post_scan.go @@ -18,8 +18,19 @@ func (s *ScannerService) startAutoScrape(ctx context.Context, libraryID string) scrapeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Minute) go func() { defer cancel() - if _, err := s.scraper.EnrichLibraryDetailedWithOptions(scrapeCtx, libraryID, skipEpisodeArtworkOptions(false)); err != nil { + result, err := s.scraper.EnrichLibraryDetailedWithOptions(scrapeCtx, libraryID, skipEpisodeArtworkOptions(false)) + if err != nil { s.log.Warn("scraper enrich failed", zap.Error(err)) + return + } + if result.Processed > 0 && s.organizer != nil { + if reclassified, err := s.organizer.ReclassifyMisclassifiedMedia(scrapeCtx, MediaCategoryReclassifyOptions{LibraryIDs: []string{libraryID}}); err != nil { + s.log.Warn("scrape auto reclassify failed", zap.String("library_id", libraryID), zap.Error(err)) + } else if reclassified.Reclassified > 0 { + s.log.Info("scrape auto reclassified media", + zap.String("library_id", libraryID), + zap.Int("reclassified", reclassified.Reclassified)) + } } }() } diff --git a/internal/service/scanner_strm.go b/internal/service/scanner_strm.go index 2641a00..87dfd7f 100644 --- a/internal/service/scanner_strm.go +++ b/internal/service/scanner_strm.go @@ -72,10 +72,11 @@ func (s *ScannerService) maybeGenerateSTRMAfterScan(libraryID string) { ctx := context.Background() strmSvc := NewSTRMService(s.log, s.repo, s.cfg) opts := GenerateSTRMOptions{ - LibraryID: libraryID, - Enabled: true, - IncludeLocal: true, - Overwrite: true, + LibraryID: libraryID, + Enabled: true, + IncludeLocal: true, + Overwrite: true, + SkipSettingsSave: true, } if outDir, scope := s.autoSTRMOutputDir(ctx); outDir != "" { opts.OutputDir = outDir diff --git a/internal/service/scanner_strm_test.go b/internal/service/scanner_strm_test.go new file mode 100644 index 0000000..2ccc9a4 --- /dev/null +++ b/internal/service/scanner_strm_test.go @@ -0,0 +1,75 @@ +package service + +import ( + "os" + "path/filepath" + "testing" + "time" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +func TestAutoGenerateSTRMAfterScanUsesAllScopeRoot(t *testing.T) { + db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{}) + repos := repository.New(db) + outDir := t.TempDir() + if err := repos.Setting.Set(t.Context(), "strm.auto_generate_enabled", "true"); err != nil { + t.Fatal(err) + } + if err := repos.Setting.Set(t.Context(), "strm.output_dir", outDir); err != nil { + t.Fatal(err) + } + if err := repos.Setting.Set(t.Context(), "strm.output_scope", "all"); err != nil { + t.Fatal(err) + } + lib := model.Library{ + Base: model.Base{ID: "tv-lib"}, + Name: "OpenList 欧美剧", + Path: BuildCloudLibraryPath("openlist", "/电视剧/欧美剧", "/电视剧/欧美剧"), + Type: "tv", + Enabled: true, + } + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + media := model.Media{ + Base: model.Base{ID: "show-1"}, + LibraryID: lib.ID, + Title: "第一集", + Path: "cloud://openlist/电视剧/欧美剧/Show/S01E01.mkv", + STRMURL: "/api/cloud/play/openlist?ref=show", + SeasonNum: 1, + EpisodeNum: 1, + } + if err := repos.DB.Create(&media).Error; err != nil { + t.Fatal(err) + } + + scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) + scanner.maybeGenerateSTRMAfterScan(lib.ID) + + want := filepath.Join(outDir, "电视剧", "欧美剧", "Show", "Season 01", "Show - S01E01.strm") + waitForFile(t, want) + assertFileContains(t, want, "/api/stream/show-1") + if _, err := os.Stat(filepath.Join(outDir, "电视剧", "欧美剧", "电视剧", "欧美剧")); !os.IsNotExist(err) { + t.Fatalf("auto STRM output was nested twice") + } +} + +func waitForFile(t *testing.T, path string) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for { + if _, err := os.Stat(path); err == nil { + return + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for %s", path) + } + time.Sleep(20 * time.Millisecond) + } +} diff --git a/internal/service/scheduler.go b/internal/service/scheduler.go index 890b59d..ce1ebe8 100644 --- a/internal/service/scheduler.go +++ b/internal/service/scheduler.go @@ -1,17 +1,13 @@ // Package service — periodic scheduled jobs. // -// SchedulerService runs five recurring background jobs that keep the +// SchedulerService runs recurring background jobs that keep the // library up-to-date without operator intervention: // // library_scan every 24 h — optional full re-scan for local libraries; // filesystem watchers handle normal changes. -// subscription_pull every 30 min — re-poll RSS feeds (in addition to -// the existing SubscriptionService -// internal timer). -// download_sync every 30 s — refresh the qBittorrent torrent -// list (already covered by the -// download poller, kept here as a -// watchdog). +// cloud_sync low frequency — optional cloud library sync. +// cloud_upload low frequency — optional local-to-cloud metadata upload. +// organize_source opt-in — organize the configured staging folder. // transcode_cleanup every 24 h — purge HLS transcode artefacts // older than 24 h. // recycle_purge every 24 h — empty the recycle bin of rows diff --git a/internal/service/scheduler_test.go b/internal/service/scheduler_test.go index 20ad44b..0748b59 100644 --- a/internal/service/scheduler_test.go +++ b/internal/service/scheduler_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "strings" "sync/atomic" "testing" "time" @@ -189,6 +190,20 @@ func TestSchedulerRunNowAsyncRejectsDuplicateRun(t *testing.T) { close(release) } +func TestSchedulerStartDoesNotRegisterSubscriptionPullJob(t *testing.T) { + scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, nil, "") + ctx, cancel := context.WithCancel(t.Context()) + cancel() + scheduler.Start(ctx) + defer scheduler.Stop() + + for _, status := range scheduler.Status() { + if strings.Contains(status.Name, "subscription") { + t.Fatalf("scheduler registered subscription job %q; subscriptions must be owned by SubscriptionService only", status.Name) + } + } +} + func TestSchedulerOrganizeSourceSyncsVisibilityWhenTargetAlreadyExists(t *testing.T) { root := t.TempDir() src := filepath.Join(root, "downloads") diff --git a/internal/service/scraper.go b/internal/service/scraper.go index f5ee92d..d7db709 100644 --- a/internal/service/scraper.go +++ b/internal/service/scraper.go @@ -175,6 +175,7 @@ func (s *ScraperService) EnrichOneWithOptions(ctx context.Context, m *model.Medi zap.String("thetvdb_id", candidateMatch.TheTVDBID)) continue } + preferLocalizedSearchTitle(candidate, candidateMatch) match = candidateMatch if match != nil { break diff --git a/internal/service/scraper_query.go b/internal/service/scraper_query.go index aed4d06..164ed0f 100644 --- a/internal/service/scraper_query.go +++ b/internal/service/scraper_query.go @@ -18,7 +18,7 @@ var noiseTokens = []string{ "1080p", "2160p", "4k", "720p", "480p", "uhd", "ds4k", "fhd", "bd", "bdrip", "brrip", "dvd", "dvdrip", "hdtv", "pdtv", "webdl", "hdrip", "bluray", "blu-ray", "webrip", "web-dl", "web", - "x264", "x265", "h264", "h265", "hevc", "avc", "10bit", "8bit", "hi10p", "hi10", + "x264", "x265", "h264", "h265", "h266", "hevc", "avc", "av1", "vvc", "10bit", "8bit", "hi10p", "hi10", "hdr", "hdr10", "sdr", "dts", "ddp", "ddp5", "dd5", "dd2", "eac3", "truehd", "dovi", "atmos", "aac", "ac3", "flac", "fps", "hlg", "dv", "remux", "extended", "uncut", "remastered", "repack", "proper", "internal", @@ -54,7 +54,7 @@ var releaseBoundaryTokenSet = map[string]struct{}{ "1080p": {}, "2160p": {}, "4k": {}, "720p": {}, "480p": {}, "uhd": {}, "fhd": {}, "bd": {}, "bdrip": {}, "brrip": {}, "dvd": {}, "dvdrip": {}, "hdtv": {}, "pdtv": {}, "webdl": {}, "hdrip": {}, "bluray": {}, "webrip": {}, "web": {}, "remux": {}, - "x264": {}, "x265": {}, "h264": {}, "h265": {}, "hevc": {}, "avc": {}, + "x264": {}, "x265": {}, "h264": {}, "h265": {}, "h266": {}, "hevc": {}, "avc": {}, "av1": {}, "vvc": {}, } var ( diff --git a/internal/service/scraper_query_test.go b/internal/service/scraper_query_test.go index 89904bd..363ddff 100644 --- a/internal/service/scraper_query_test.go +++ b/internal/service/scraper_query_test.go @@ -476,6 +476,61 @@ func TestOrganizeMetadataTrustsCleanedBroadcastReleaseQuery(t *testing.T) { } } +func TestOrganizeMetadataTrustsChineseReleaseAliasWithoutOriginalName(t *testing.T) { + match := &Match{ + Title: "莫离", + Languages: []string{"zh"}, + Countries: []string{"CN"}, + Year: 2026, + TMDbID: 292696, + } + if !organizeMetadataMatchTrusted("the first jasmine", 2026, match) { + t.Fatal("multi-word English release alias should be trusted for Chinese-origin metadata") + } +} + +func TestOrganizeMetadataTrustsLocalizedSearchKeyword(t *testing.T) { + match := &Match{ + Title: "Monarch: Legacy of Monsters", + OriginalName: "Monarch: Legacy of Monsters", + Year: 2023, + TMDbID: 202411, + SearchKeyword: "帝王计划:怪兽遗产", + } + if !organizeMetadataMatchTrusted("帝王计划:怪兽遗产", 2023, match) { + t.Fatal("localized TMDb search keyword should be trusted even when returned title is not localized") + } + preferLocalizedSearchTitle("帝王计划:怪兽遗产", match) + if match.Title != "帝王计划:怪兽遗产" || match.OriginalName != "Monarch: Legacy of Monsters" { + t.Fatalf("localized title not preserved: title=%q original=%q", match.Title, match.OriginalName) + } +} + +func TestOrganizeMetadataRejectsShortLocalizedSearchKeyword(t *testing.T) { + match := &Match{ + Title: "Hello, Saturday", + OriginalName: "Hello, Saturday", + TMDbID: 123456, + SearchKeyword: "你好", + } + if organizeMetadataMatchTrusted("你好", 0, match) { + t.Fatal("short generic localized search keyword must not be trusted") + } +} + +func TestOrganizeMetadataRejectsSingleTokenChineseAlias(t *testing.T) { + match := &Match{ + Title: "莫离", + Languages: []string{"zh"}, + Countries: []string{"CN"}, + Year: 2026, + TMDbID: 292696, + } + if organizeMetadataMatchTrusted("jasmine", 2026, match) { + t.Fatal("single token alias should stay too weak for automatic matching") + } +} + func TestOrganizeMetadataRejectsLooseSingleTokenQuery(t *testing.T) { match := &Match{Title: "步步惊心泰版", OriginalName: "Scarlet Heart Thailand", Year: 2025, TMDbID: 252886} if organizeMetadataMatchTrusted("thailand", 0, match) { diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index 888e64e..99d617b 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -91,6 +91,7 @@ func (b *serviceContainerBuilder) initContentServices() { b.c.Discover = NewDiscoverService(b.log, b.c.TMDb) b.c.Transcoder = NewTranscoderService(b.cfg, b.log, b.repos, b.c.WSHub) b.c.Scan = NewScannerService(b.cfg, b.log, b.repos, b.c.WSHub, b.c.FFprobe, b.c.Scraper) + b.c.Scan.SetOrganizer(b.c.Organizer) b.c.Scan.SetRuntimeCache(b.c.Cache) b.c.OrganizePipeline = NewOrganizePipelineService(b.log, b.repos, b.c.Organizer, b.c.Scan, b.c.Tasks) b.c.Watcher = NewWatcherService(b.log, b.repos, b.c.Scan) @@ -153,6 +154,7 @@ func (b *serviceContainerBuilder) initIdentityServices() { func (b *serviceContainerBuilder) initSiteDownloadServices() { b.c.Site = NewSiteService(b.log, b.repos, b.flareSolverrURL()) b.c.Downloads = NewDownloadService(b.log, b.repos, b.c.WSHub, b.c.Organizer, b.c.Site) + b.c.Organizer.SetActiveDownloadPathProvider(b.c.Downloads.ActiveDownloadPaths) b.c.Downloads.SetScanner(b.c.Scan) b.c.Downloads.SetTaskTracker(b.c.Tasks) b.c.Downloads.SetOrganizePipeline(b.c.OrganizePipeline) diff --git a/internal/service/session_tracker.go b/internal/service/session_tracker.go index f42a523..6fcce34 100644 --- a/internal/service/session_tracker.go +++ b/internal/service/session_tracker.go @@ -271,7 +271,7 @@ func (s *SessionTrackerService) upsert(ctx context.Context, in realtimeSessionIn if in.DeviceID == "" { in.DeviceID = fallbackSessionDeviceID(in.DeviceName, in.Client, in.RemoteEndPoint) } - key := userID + "\x00" + in.DeviceID + key := userID + "\x00" + realtimeSessionTerminalKey(in.DeviceID, in.DeviceName, in.Client, in.RemoteEndPoint) s.mu.Lock() defer s.mu.Unlock() s.pruneLocked(now) @@ -363,10 +363,19 @@ func fallbackSessionDeviceID(deviceName, client, remoteEndPoint string) string { } func sessionDeviceKey(sess RealtimeSession) string { - if strings.TrimSpace(sess.DeviceID) != "" { - return strings.TrimSpace(sess.DeviceID) + return realtimeSessionTerminalKey(sess.DeviceID, sess.DeviceName, sess.Client, sess.RemoteEndPoint) +} + +func realtimeSessionTerminalKey(deviceID, deviceName, client, remoteEndPoint string) string { + deviceName = strings.TrimSpace(deviceName) + if deviceName != "" { + return "fp-" + fingerprint(client, deviceName) } - return fallbackSessionDeviceID(sess.DeviceName, sess.Client, sess.RemoteEndPoint) + deviceID = strings.TrimSpace(deviceID) + if deviceID != "" { + return deviceID + } + return fallbackSessionDeviceID(deviceName, client, remoteEndPoint) } func firstNonEmptyString(values ...string) string { diff --git a/internal/service/session_tracker_test.go b/internal/service/session_tracker_test.go index 964e653..f42530d 100644 --- a/internal/service/session_tracker_test.go +++ b/internal/service/session_tracker_test.go @@ -29,6 +29,44 @@ func TestSessionTrackerAppliesRealtimeActivityToUsers(t *testing.T) { } } +func TestSessionTrackerDeduplicatesAppsOnSameTerminal(t *testing.T) { + tracker := NewSessionTrackerService(zap.NewNop()) + now := time.Date(2026, 6, 21, 10, 30, 0, 0, time.UTC) + tracker.now = func() time.Time { return now } + users := []model.User{{Base: model.Base{ID: "u1"}, Username: "viewer"}} + + tracker.RecordActivity(t.Context(), "u1", "viewer", "infuse-device", "iPhone", "Infuse", "10.0.0.8") + now = now.Add(time.Minute) + tracker.RecordActivity(t.Context(), "u1", "viewer", "emby-device", " IPHONE ", "Emby", "10.0.0.8") + + sessions := tracker.List(t.Context()) + if len(sessions) != 1 { + t.Fatalf("sessions = %#v, want one merged terminal", sessions) + } + if sessions[0].DeviceID != "emby-device" || sessions[0].Client != "Emby" { + t.Fatalf("merged session should keep latest client details, got %#v", sessions[0]) + } + tracker.ApplyToUsers(t.Context(), users) + if !users[0].RealtimeOnline || users[0].RealtimeDeviceCount != 1 { + t.Fatalf("same terminal should count as one online device, online=%v devices=%d", users[0].RealtimeOnline, users[0].RealtimeDeviceCount) + } +} + +func TestSessionTrackerLogoutUsesLatestMergedDeviceID(t *testing.T) { + tracker := NewSessionTrackerService(zap.NewNop()) + now := time.Date(2026, 6, 21, 10, 45, 0, 0, time.UTC) + tracker.now = func() time.Time { return now } + + tracker.RecordActivity(t.Context(), "u1", "viewer", "infuse-device", "iPhone", "Infuse", "10.0.0.8") + now = now.Add(time.Minute) + tracker.RecordActivity(t.Context(), "u1", "viewer", "emby-device", "iPhone", "Emby", "10.0.0.8") + tracker.Logout(t.Context(), "u1", "emby-device", "10.0.0.8") + + if sessions := tracker.List(t.Context()); len(sessions) != 0 { + t.Fatalf("merged terminal should logout by latest device id, got %#v", sessions) + } +} + func TestDeviceListMergesRealtimeSessions(t *testing.T) { repos := newSessionTrackerTestRepos(t) user := model.User{Base: model.Base{ID: "u1"}, Username: "viewer", PasswordHash: "x", Role: "user", IsActive: true} diff --git a/internal/service/site.go b/internal/service/site.go index 9e4b099..88a5658 100644 --- a/internal/service/site.go +++ b/internal/service/site.go @@ -386,17 +386,18 @@ func (s *SiteService) TestConnection(ctx context.Context, id string) (bool, stri // SearchResult is one torrent returned by a site adapter search. type SearchResult struct { - SiteName string `json:"site_name"` - SiteID string `json:"site_id"` - Title string `json:"title"` - Subtitle string `json:"subtitle,omitempty"` - TorrentURL string `json:"torrent_url"` - DownloadURL string `json:"download_url"` - Category string `json:"category,omitempty"` - Size int64 `json:"size"` - Seeders int `json:"seeders"` - Leechers int `json:"leechers"` - Free bool `json:"free"` + SiteName string `json:"site_name"` + SiteID string `json:"site_id"` + Title string `json:"title"` + Subtitle string `json:"subtitle,omitempty"` + TorrentURL string `json:"torrent_url"` + DownloadURL string `json:"download_url"` + Category string `json:"category,omitempty"` + SearchKeyword string `json:"search_keyword,omitempty"` + Size int64 `json:"size"` + Seeders int `json:"seeders"` + Leechers int `json:"leechers"` + Free bool `json:"free"` } // Search fans out a keyword query to every enabled site and returns @@ -416,6 +417,8 @@ func (s *SiteService) Search(ctx context.Context, keyword string) ([]SearchResul wg sync.WaitGroup enabledCount int failedCount int + failureErrs []error + failures []string results []SearchResult ) @@ -430,6 +433,12 @@ func (s *SiteService) Search(ctx context.Context, keyword string) ([]SearchResul adapter := NewSiteAdapter(&site) if adapter == nil { + mu.Lock() + failedCount++ + err := fmt.Errorf("%s: unsupported site type %s", site.Name, site.Type) + failureErrs = append(failureErrs, err) + failures = append(failures, err.Error()) + mu.Unlock() return } @@ -447,6 +456,9 @@ func (s *SiteService) Search(ctx context.Context, keyword string) ([]SearchResul if err != nil { mu.Lock() failedCount++ + failureErr := fmt.Errorf("%s: %w", site.Name, err) + failureErrs = append(failureErrs, failureErr) + failures = append(failures, failureErr.Error()) mu.Unlock() s.log.Warn("site search failed", zap.String("site", site.Name), @@ -467,17 +479,18 @@ func (s *SiteService) Search(ctx context.Context, keyword string) ([]SearchResul 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, - Size: item.Size, - Seeders: item.Seeders, - Leechers: item.Leechers, - Free: item.Free, + 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() } @@ -501,6 +514,12 @@ func (s *SiteService) Search(ctx context.Context, keyword string) ([]SearchResul zap.Int("failed_sites", failedCount), zap.Int("results_count", len(results))) } + if enabledCount > 0 && failedCount >= enabledCount && len(results) == 0 { + if len(failureErrs) > 0 { + return results, fmt.Errorf("all enabled sites failed while searching %q: %w", keyword, errors.Join(failureErrs...)) + } + return results, fmt.Errorf("all enabled sites failed while searching %q: %s", keyword, strings.Join(failures, "; ")) + } return results, nil } diff --git a/internal/service/site_adapter_test.go b/internal/service/site_adapter_test.go index bd4ae7b..c5cc1d6 100644 --- a/internal/service/site_adapter_test.go +++ b/internal/service/site_adapter_test.go @@ -180,10 +180,10 @@ func TestBuildRequestAPIKeyHeaderBySite(t *testing.T) { } } -func TestMTeamPublishedAPIRateLimits(t *testing.T) { +func TestMTeamAPIRateLimits(t *testing.T) { search := mteamAPIRateLimits(mteamAPIEndpointSearch) - if len(search) != 1 || search[0].Limit != 1000 || search[0].Window != 24*time.Hour { - t.Fatalf("search limits = %#v, want 1000/24h", search) + if len(search) != 1 || search[0].Limit != 1500 || search[0].Window != 24*time.Hour { + t.Fatalf("search limits = %#v, want 1500/24h", search) } detail := mteamAPIRateLimits(mteamAPIEndpointDetail) if len(detail) != 1 || detail[0].Limit != 100 || detail[0].Window != time.Hour { diff --git a/internal/service/site_rate_limiter.go b/internal/service/site_rate_limiter.go index e0e3c07..322cde0 100644 --- a/internal/service/site_rate_limiter.go +++ b/internal/service/site_rate_limiter.go @@ -18,6 +18,8 @@ const ( mteamAPIEndpointSearch = "torrent_search" mteamAPIEndpointDetail = "torrent_detail" mteamAPIEndpointDownload = "torrent_download" + + mteamAPISearchDailyLimit = 1500 ) type siteAPIRateLimit struct { @@ -165,7 +167,7 @@ func reserveMTeamAPIQuota(ctx context.Context, cfg SiteConfig, endpoint string) func mteamAPIRateLimits(endpoint string) []siteAPIRateLimit { switch endpoint { case mteamAPIEndpointSearch: - return []siteAPIRateLimit{{Bucket: "torrent_search_24h", Limit: 1000, Window: 24 * time.Hour}} + return []siteAPIRateLimit{{Bucket: "torrent_search_24h", Limit: mteamAPISearchDailyLimit, Window: 24 * time.Hour}} case mteamAPIEndpointDetail: return []siteAPIRateLimit{{Bucket: "torrent_detail_1h", Limit: 100, Window: time.Hour}} case mteamAPIEndpointDownload: diff --git a/internal/service/site_test.go b/internal/service/site_test.go index 1cbe131..5eb9e5b 100644 --- a/internal/service/site_test.go +++ b/internal/service/site_test.go @@ -112,3 +112,37 @@ func TestRedactSensitiveDownloadURL(t *testing.T) { }) } } + +func TestSiteSearchReturnsErrorWhenAllEnabledSitesFail(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "upstream timeout simulation", http.StatusGatewayTimeout) + })) + defer upstream.Close() + + db := newServiceTestDB(t, &model.Site{}) + repos := repository.New(db) + svc := NewSiteService(zap.NewNop(), repos, "") + site := &model.Site{ + Name: "馒头", + Type: "mteam", + URL: upstream.URL, + AuthType: "api_key", + APIKey: "token-123", + Enabled: true, + Timeout: 5, + } + if err := svc.Create(context.Background(), site); err != nil { + t.Fatal(err) + } + + results, err := svc.Search(context.Background(), "南部档案 2026") + if err == nil { + t.Fatalf("Search error = nil, want all-sites-failed error; results=%#v", results) + } + if len(results) != 0 { + t.Fatalf("results = %#v, want none on all-sites failure", results) + } + if !strings.Contains(err.Error(), "all enabled sites failed") || !strings.Contains(err.Error(), "馒头") { + t.Fatalf("error = %q, want site failure context", err.Error()) + } +} diff --git a/internal/service/storage_config.go b/internal/service/storage_config.go index 824bcd1..de5eeb5 100644 --- a/internal/service/storage_config.go +++ b/internal/service/storage_config.go @@ -108,7 +108,15 @@ func (s *StorageConfigService) Save(ctx context.Context, in StorageInput) (*Stor if !validStorageType(in.Type) { return nil, fmt.Errorf("unsupported storage type %q", in.Type) } - blob, err := json.Marshal(in.Config) + cfg := cloneStorageConfigMap(in.Config) + if shouldPreserveStorageSecretsOnSave(in.Enabled) { + merged, err := s.mergeExistingStorageSecrets(ctx, in.Type, cfg) + if err != nil { + return nil, err + } + cfg = merged + } + blob, err := json.Marshal(cfg) if err != nil { return nil, err } @@ -128,6 +136,85 @@ func (s *StorageConfigService) Save(ctx context.Context, in StorageInput) (*Stor return s.Get(ctx, in.Type) } +func shouldPreserveStorageSecretsOnSave(enabled *bool) bool { + return enabled == nil || *enabled +} + +func cloneStorageConfigMap(cfg map[string]any) map[string]any { + out := make(map[string]any, len(cfg)) + for k, v := range cfg { + out[k] = v + } + return out +} + +func (s *StorageConfigService) mergeExistingStorageSecrets(ctx context.Context, typ string, cfg map[string]any) (map[string]any, error) { + view, err := s.Get(ctx, typ) + if err != nil || view == nil { + return cfg, err + } + for _, key := range storagePreservedSecretKeys() { + existing := strings.TrimSpace(strr(view.Config[key])) + if existing == "" { + continue + } + incoming, hasIncoming := cfg[key] + if hasIncoming && !isBlankStorageSecret(incoming) { + continue + } + if storageSecretReplacedByAlternative(typ, key, cfg, view.Config) { + continue + } + cfg[key] = existing + } + return cfg, nil +} + +func storagePreservedSecretKeys() []string { + return []string{"password", "secret_key", "token", "cookie", "access_key"} +} + +func isBlankStorageSecret(value any) bool { + text := strings.TrimSpace(strr(value)) + return text == "" || text == "********" +} + +func storageSecretReplacedByAlternative(typ, key string, cfg, existing map[string]any) bool { + switch typ { + case cloud.TypeOpenList: + switch key { + case "token": + return strings.TrimSpace(strr(cfg["username"])) != "" && strings.TrimSpace(strr(cfg["password"])) != "" + case "password": + if strings.TrimSpace(strr(cfg["token"])) != "" { + return true + } + return storagePlainFieldChanged("username", cfg, existing) + } + case "webdav", cloud.TypeCloudDrive2: + if key == "password" { + if strings.TrimSpace(strr(cfg["token"])) != "" { + return true + } + return storagePlainFieldChanged("username", cfg, existing) + } + case "s3": + if key == "secret_key" { + return storagePlainFieldChanged("access_key", cfg, existing) + } + } + return false +} + +func storagePlainFieldChanged(key string, cfg, existing map[string]any) bool { + incoming := strings.TrimSpace(strr(cfg[key])) + if incoming == "" { + return false + } + current := strings.TrimSpace(strr(existing[key])) + return current != "" && incoming != current +} + // Logout clears saved cloud login credentials, disables the storage backend, // and removes virtual cloud libraries/media for that provider. It intentionally // keeps non-secret connection hints such as server / WebDAV URL / timeout so diff --git a/internal/service/storage_config_probe.go b/internal/service/storage_config_probe.go index db0355a..8f34d3b 100644 --- a/internal/service/storage_config_probe.go +++ b/internal/service/storage_config_probe.go @@ -36,34 +36,8 @@ func (s *StorageConfigService) Test(ctx context.Context, in StorageInput) error return err } defer resp.Body.Close() - if resp.StatusCode >= 500 { - return fmt.Errorf("alist returned %d", resp.StatusCode) - } - return nil + return validateStorageProbeStatus("alist", resp.StatusCode) case cloud.TypeOpenList: - if hasWebDAVProbeConfig(cfg) { - p, err := cloud.New(in.Type, cfg, client) - if err != nil { - return err - } - return p.Ping(ctx) - } - server := strings.TrimRight(strr(cfg["server"]), "/") - if server != "" { - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, server+"/api/me", nil) - if tok := strr(cfg["token"]); tok != "" { - req.Header.Set("Authorization", tok) - } - resp, err := client.Do(req) - if err != nil { - return decorateStorageTransportError("openlist", server, err) - } - defer resp.Body.Close() - if resp.StatusCode >= 500 { - return fmt.Errorf("openlist returned %d", resp.StatusCode) - } - return nil - } p, err := cloud.New(in.Type, cfg, client) if err != nil { return err @@ -162,11 +136,17 @@ func storageTimeoutFromConfig(cfg map[string]any, fallback time.Duration) time.D return time.Duration(seconds) * time.Second } -func hasWebDAVProbeConfig(cfg map[string]any) bool { - return strr(cfg["url"]) != "" || - strr(cfg["webdav_url"]) != "" || - strr(cfg["username"]) != "" || - strr(cfg["password"]) != "" +func validateStorageProbeStatus(name string, status int) error { + if status >= 200 && status < 300 { + return nil + } + if status == http.StatusUnauthorized || status == http.StatusForbidden { + return fmt.Errorf("%s authentication failed: http %d;请检查 Token / 用户名密码", name, status) + } + if status >= 300 { + return fmt.Errorf("%s returned %d", name, status) + } + return nil } func decorateStorageTransportError(name, target string, err error) error { diff --git a/internal/service/storage_upload_test.go b/internal/service/storage_upload_test.go index cbc433b..8e37b63 100644 --- a/internal/service/storage_upload_test.go +++ b/internal/service/storage_upload_test.go @@ -246,6 +246,78 @@ func TestStorageConfigOpenListHTTPSAgainstHTTPHint(t *testing.T) { } } +func TestStorageConfigOpenListTestRejectsUnauthorizedList(t *testing.T) { + openlist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/dav" { + t.Fatalf("unexpected openlist path %s", r.URL.Path) + } + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("unauthorized")) + })) + defer openlist.Close() + + _, storage := newStorageUploadTestService(t) + err := storage.Test(t.Context(), StorageInput{ + Type: "openlist", + Config: map[string]any{ + "server": openlist.URL, + }, + }) + if err == nil || !strings.Contains(err.Error(), "http 401") { + t.Fatalf("openlist unauthorized probe error = %v, want http 401", err) + } +} + +func TestStorageConfigOpenListTestUsesAPIListWithToken(t *testing.T) { + var listed bool + openlist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/fs/list" { + t.Fatalf("unexpected openlist path %s", r.URL.Path) + } + if r.Header.Get("Authorization") != "openlist-token" { + t.Fatalf("authorization = %q", r.Header.Get("Authorization")) + } + listed = true + _, _ = w.Write([]byte(`{"code":200,"data":{"content":[],"total":0}}`)) + })) + defer openlist.Close() + + _, storage := newStorageUploadTestService(t) + if err := storage.Test(t.Context(), StorageInput{ + Type: "openlist", + Config: map[string]any{ + "server": openlist.URL, + "token": "openlist-token", + }, + }); err != nil { + t.Fatalf("openlist API probe: %v", err) + } + if !listed { + t.Fatal("openlist test should probe /api/fs/list") + } +} + +func TestStorageConfigAlistTestRejectsUnauthorized(t *testing.T) { + alist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/me" { + t.Fatalf("unexpected alist path %s", r.URL.Path) + } + w.WriteHeader(http.StatusUnauthorized) + })) + defer alist.Close() + + _, storage := newStorageUploadTestService(t) + err := storage.Test(t.Context(), StorageInput{ + Type: "alist", + Config: map[string]any{ + "server": alist.URL, + }, + }) + if err == nil || !strings.Contains(err.Error(), "authentication failed") { + t.Fatalf("alist unauthorized probe error = %v, want authentication failed", err) + } +} + func TestStorageConfigCloudProviderRejectsDisabledConfig(t *testing.T) { _, storage := newStorageUploadTestService(t) enabled := false @@ -264,6 +336,39 @@ func TestStorageConfigCloudProviderRejectsDisabledConfig(t *testing.T) { } } +func TestStorageConfigSavePreservesExistingSecretWhenFormLeavesItBlank(t *testing.T) { + _, storage := newStorageUploadTestService(t) + if _, err := storage.Save(t.Context(), StorageInput{ + Type: "openlist", + Config: map[string]any{ + "server": "http://openlist.test", + "token": "openlist-token", + }, + }); err != nil { + t.Fatal(err) + } + if _, err := storage.Save(t.Context(), StorageInput{ + Type: "openlist", + Config: map[string]any{ + "server": "http://openlist.test", + "token": "", + "timeout_seconds": "180", + }, + }); err != nil { + t.Fatal(err) + } + view, err := storage.Get(t.Context(), "openlist") + if err != nil { + t.Fatal(err) + } + if view.Config["token"] != "openlist-token" { + t.Fatalf("token = %#v, want preserved token", view.Config["token"]) + } + if view.Config["timeout_seconds"] != "180" { + t.Fatalf("timeout_seconds = %#v, want updated timeout", view.Config["timeout_seconds"]) + } +} + func TestSchedulerCloudUploadUsesConfiguredLocalSource(t *testing.T) { var uploaded []string alist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/service/stream.go b/internal/service/stream.go index 863cf5a..1f5f4de 100644 --- a/internal/service/stream.go +++ b/internal/service/stream.go @@ -30,6 +30,7 @@ import ( "go.uber.org/zap" "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/repository" ) @@ -403,7 +404,7 @@ func (s *StreamService) ServeFileWithCloudMode(w http.ResponseWriter, r *http.Re if m == nil { return ErrMediaNotFound } - if strmURL := strings.TrimSpace(m.STRMURL); strmURL != "" && (isCloudPlaybackTarget(strmURL) || STRMPlaybackEnabled(r.Context(), s.repo)) { + if strmURL := strings.TrimSpace(m.STRMURL); strmURL != "" && playableSTRMTarget(r.Context(), s.repo, strmURL, m) { if !cloudPlaybackModeEnabled(r.Context(), s.repo, cloudMode) { return ErrCloudPlaybackDisabled } @@ -450,6 +451,25 @@ func isCloudPlaybackTarget(raw string) bool { return ok } +func playableSTRMTarget(ctx context.Context, repo *repository.Container, raw string, m *model.Media) bool { + if isCloudPlaybackTarget(raw) || isHTTPPlaybackTarget(raw) { + return true + } + if m != nil && strings.EqualFold(strings.TrimSpace(m.Container), "strm") { + return true + } + return STRMPlaybackEnabled(ctx, repo) +} + +func isHTTPPlaybackTarget(raw string) bool { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || u == nil || !u.IsAbs() { + return false + } + scheme := strings.ToLower(strings.TrimSpace(u.Scheme)) + return scheme == "http" || scheme == "https" +} + // ServeHLSPlaylist makes sure a transcode is running and writes the m3u8. // We block (with a 30s timeout) until the playlist file shows up. func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request, mediaID string) error { diff --git a/internal/service/stream_test.go b/internal/service/stream_test.go index b2ba69a..c40fb0b 100644 --- a/internal/service/stream_test.go +++ b/internal/service/stream_test.go @@ -159,6 +159,63 @@ func TestServeFileRedirectsCloudMediaForVideoStreamMode(t *testing.T) { } } +func TestServeFileRedirectsCloudMediaExternalHTTPSTRMURL(t *testing.T) { + repos := newStreamTestRepo(t) + target := "https://cdn.example.test/Movie.mkv?sign=direct" + if err := repos.DB.Create(&model.Media{ + Base: model.Base{ID: "cloud-http"}, + Title: "Cloud HTTP", + Path: "cloud://openlist/Movie.mkv", + STRMURL: target, + }).Error; err != nil { + t.Fatal(err) + } + svc := NewStreamService(&config.Config{}, zap.NewNop(), repos, nil) + req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/cloud-http?token=jwt123", nil) + w := httptest.NewRecorder() + + if err := svc.ServeFile(w, req, "cloud-http"); err != nil { + t.Fatalf("external HTTP STRM target should redirect: %v", err) + } + if w.Code != http.StatusFound { + t.Fatalf("status = %d, want 302", w.Code) + } + loc := w.Header().Get("Location") + if loc != target { + t.Fatalf("Location = %q, want %q", loc, target) + } + if strings.Contains(loc, "jwt123") || strings.Contains(loc, "media_id=") { + t.Fatalf("external direct link must not receive internal auth query, got %q", loc) + } +} + +func TestServeFileRedirectsLocalSTRMFileTargetByDefault(t *testing.T) { + repos := newStreamTestRepo(t) + target := "https://cdn.example.test/LocalMovie.mkv?sign=direct" + if err := repos.DB.Create(&model.Media{ + Base: model.Base{ID: "local-strm"}, + Title: "Local STRM", + Path: "D:/media/LocalMovie.strm", + Container: "strm", + STRMURL: target, + }).Error; err != nil { + t.Fatal(err) + } + svc := NewStreamService(&config.Config{}, zap.NewNop(), repos, nil) + req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/local-strm?token=jwt123", nil) + w := httptest.NewRecorder() + + if err := svc.ServeFile(w, req, "local-strm"); err != nil { + t.Fatalf("local .strm media should redirect to its target: %v", err) + } + if w.Code != http.StatusFound { + t.Fatalf("status = %d, want 302", w.Code) + } + if loc := w.Header().Get("Location"); loc != target { + t.Fatalf("Location = %q, want %q", loc, target) + } +} + func TestCloudPlaybackModeUsesExplicitModeBeforeLegacySTRMFlag(t *testing.T) { repos := newStreamTestRepo(t) if got := CloudPlaybackMode(t.Context(), repos); got != CloudPlaybackModeRedirectProxy { diff --git a/internal/service/strm_generate.go b/internal/service/strm_generate.go index fb3d0bf..d63f2c9 100644 --- a/internal/service/strm_generate.go +++ b/internal/service/strm_generate.go @@ -13,13 +13,14 @@ import ( ) type GenerateSTRMOptions struct { - LibraryID string `json:"library_id"` - OutputDir string `json:"output_dir"` - BaseURL string `json:"base_url,omitempty"` - Enabled bool `json:"enabled"` - Overwrite bool `json:"overwrite"` - IncludeLocal bool `json:"include_local"` - PlaybackToken string `json:"-"` + LibraryID string `json:"library_id"` + OutputDir string `json:"output_dir"` + BaseURL string `json:"base_url,omitempty"` + Enabled bool `json:"enabled"` + Overwrite bool `json:"overwrite"` + IncludeLocal bool `json:"include_local"` + PlaybackToken string `json:"-"` + SkipSettingsSave bool `json:"-"` } type GenerateSTRMResult struct { @@ -112,6 +113,7 @@ func (s *STRMService) GenerateForAllLibraries(ctx context.Context, opts Generate } next := opts next.LibraryID = lib.ID + next.SkipSettingsSave = true if baseOutputDir != "" && baseOutputDir != "." { next.OutputDir = filepath.Join(baseOutputDir, strmLibraryOutputSubdir(lib)) } @@ -140,10 +142,13 @@ func (s *STRMService) resolveSTRMOutputDir(ctx context.Context, lib *model.Libra if outputDir == "" || outputDir == "." { outputDir = s.defaultOutputDir(lib) } - return outputDir + return strmLibrarySpecificOutputDir(outputDir, lib) } func (s *STRMService) saveSTRMGenerationSettings(ctx context.Context, outputDir string, opts GenerateSTRMOptions) { + if opts.SkipSettingsSave { + return + } if strings.TrimSpace(opts.BaseURL) != "" && s.repo.Setting != nil { baseURL := strings.TrimRight(strings.TrimSpace(opts.BaseURL), "/") _ = s.repo.Setting.Set(ctx, "app.server_url", baseURL) diff --git a/internal/service/strm_output_dir.go b/internal/service/strm_output_dir.go index 8fd9818..16d7982 100644 --- a/internal/service/strm_output_dir.go +++ b/internal/service/strm_output_dir.go @@ -24,6 +24,30 @@ func strmLibraryOutputSubdir(lib model.Library) string { return filepath.Join(clean...) } +func strmLibrarySpecificOutputDir(base string, lib *model.Library) string { + outputDir := filepath.Clean(strings.TrimSpace(base)) + if outputDir == "" || outputDir == "." || lib == nil { + return outputDir + } + subdir := strmLibraryOutputSubdir(*lib) + if subdir == "" || strmPathHasSuffix(outputDir, subdir) || pathAlreadyEndsWith(outputDir, filepath.Base(subdir)) { + return outputDir + } + return filepath.Join(outputDir, subdir) +} + +func strmPathHasSuffix(pathValue, suffix string) bool { + pathValue = strings.Trim(filepath.ToSlash(filepath.Clean(strings.TrimSpace(pathValue))), "/") + suffix = strings.Trim(filepath.ToSlash(filepath.Clean(strings.TrimSpace(suffix))), "/") + if pathValue == "" || suffix == "" || suffix == "." { + return false + } + if strings.EqualFold(pathValue, suffix) { + return true + } + return strings.HasSuffix(strings.ToLower(pathValue), "/"+strings.ToLower(suffix)) +} + func strmLibraryCategoryParts(lib model.Library) []string { if parts := strmCategoryPartsFromPath(strmLibraryPathParts(lib.Path)); len(parts) > 0 { return parts diff --git a/internal/service/strm_svc_test.go b/internal/service/strm_svc_test.go index ca6b2b2..6f03595 100644 --- a/internal/service/strm_svc_test.go +++ b/internal/service/strm_svc_test.go @@ -47,8 +47,12 @@ func TestGenerateSTRMForLibraryWritesFilesAndRecords(t *testing.T) { if res.Generated != 2 || res.Skipped != 0 { t.Fatalf("result = %#v, want generated=2 skipped=0", res) } - cloudSTRM := filepath.Join(outDir, "云盘电影 (2026)", "云盘电影 (2026).strm") - localSTRM := filepath.Join(outDir, "本地电影 (2025)", "本地电影 (2025).strm") + libraryOutDir := filepath.Join(outDir, "电影") + if res.OutputDir != libraryOutDir { + t.Fatalf("output dir = %q, want %q", res.OutputDir, libraryOutDir) + } + cloudSTRM := filepath.Join(libraryOutDir, "云盘电影 (2026)", "云盘电影 (2026).strm") + localSTRM := filepath.Join(libraryOutDir, "本地电影 (2025)", "本地电影 (2025).strm") assertFileContains(t, cloudSTRM, "http://nas.example:18080/api/stream/cloud-media?token=strm-token") assertFileContains(t, localSTRM, "http://nas.example:18080/api/stream/local-media?token=strm-token") if got, err := repos.Setting.Get(t.Context(), "app.server_url"); err != nil || got != "http://nas.example:18080" { @@ -112,7 +116,7 @@ func TestGenerateSTRMForLibrarySignsDefaultPlaybackToken(t *testing.T) { if res.Generated != 1 || len(res.Errors) != 0 { t.Fatalf("result = %#v, want generated=1 with no errors", res) } - cloudSTRM := filepath.Join(outDir, "云盘电影 (2026)", "云盘电影 (2026).strm") + cloudSTRM := filepath.Join(outDir, "电影", "云盘电影 (2026)", "云盘电影 (2026).strm") got := readSTRM(t, cloudSTRM) if !strings.HasPrefix(got, "http://nas.example:18080/api/stream/cloud-media?token=") { t.Fatalf("generated url = %q, want tokenized /api/stream url", got) @@ -145,7 +149,8 @@ func TestGenerateSTRMForLibraryCleanupStaleFilesAndRecords(t *testing.T) { t.Fatal(err) } outDir := filepath.Join(t.TempDir(), "strm") - stalePath := filepath.Join(outDir, "旧电影", "旧电影.strm") + libraryOutDir := filepath.Join(outDir, "电影") + stalePath := filepath.Join(libraryOutDir, "旧电影", "旧电影.strm") if err := os.MkdirAll(filepath.Dir(stalePath), 0o755); err != nil { t.Fatal(err) } @@ -182,6 +187,13 @@ func TestGenerateSTRMForLibraryCleanupStaleFilesAndRecords(t *testing.T) { if count != 0 { t.Fatalf("stale strm record count = %d, want 0", count) } + if err := repos.DB.Model(&model.STRMRecord{}).Where("media_id = ?", media.ID).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("current strm record count = %d, want 1", count) + } + assertFileContains(t, filepath.Join(libraryOutDir, "云盘电影 (2026)", "云盘电影 (2026).strm"), "http://nas.example:18080/api/stream/cloud-media?token=strm-token") } func TestSTRMLibraryOutputSubdirUsesLibraryCategoryPath(t *testing.T) { @@ -250,6 +262,43 @@ func TestGenerateSTRMForLibraryUsesCategoryDefaultOutputDir(t *testing.T) { assertFileContains(t, filepath.Join(wantDir, "Show", "Season 01", "Show - S01E01.strm"), "http://nas.example:18080/api/stream/show-1?token=strm-token") } +func TestGenerateSTRMForLibraryUsesPathEpisodeFallback(t *testing.T) { + db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{}) + repos := repository.New(db) + lib := model.Library{Name: "OpenList · 国产剧", Path: BuildCloudLibraryPath("openlist", "/电视剧/国产剧", "/电视剧/国产剧"), Type: "tv", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + rows := []model.Media{ + {Base: model.Base{ID: "ep-1"}, LibraryID: lib.ID, Title: "南部档案", Path: "cloud://openlist/电视剧/国产剧/南部档案/Season 01/Archives.The.Nanyang.Mystery.S01E01.mkv", STRMURL: "/api/cloud/play/openlist?ref=ep1"}, + {Base: model.Base{ID: "ep-2"}, LibraryID: lib.ID, Title: "南部档案", Path: "cloud://openlist/电视剧/国产剧/南部档案/Season 01/Archives.The.Nanyang.Mystery.S01E02.mkv", STRMURL: "/api/cloud/play/openlist?ref=ep2"}, + } + for i := range rows { + if err := repos.DB.Create(&rows[i]).Error; err != nil { + t.Fatal(err) + } + } + + outDir := filepath.Join(t.TempDir(), "strm") + svc := NewSTRMService(zap.NewNop(), repos, &config.Config{}) + res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{ + LibraryID: lib.ID, + OutputDir: outDir, + BaseURL: "http://nas.example:18080", + IncludeLocal: true, + PlaybackToken: "strm-token", + }) + if err != nil { + t.Fatal(err) + } + if res.Generated != 2 || res.Skipped != 0 { + t.Fatalf("result = %#v, want generated=2 skipped=0", res) + } + libraryOutDir := filepath.Join(outDir, "电视剧", "国产剧") + assertFileContains(t, filepath.Join(libraryOutDir, "南部档案", "Season 01", "南部档案 - S01E01.strm"), "http://nas.example:18080/api/stream/ep-1?token=strm-token") + assertFileContains(t, filepath.Join(libraryOutDir, "南部档案", "Season 01", "南部档案 - S01E02.strm"), "http://nas.example:18080/api/stream/ep-2?token=strm-token") +} + func TestGenerateSTRMForAllLibrariesWritesPerLibraryFolders(t *testing.T) { db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{}) repos := repository.New(db) @@ -277,6 +326,7 @@ func TestGenerateSTRMForAllLibrariesWritesPerLibraryFolders(t *testing.T) { OutputDir: outDir, BaseURL: "http://nas.example:18080", IncludeLocal: true, + Overwrite: true, PlaybackToken: "strm-token", }) if err != nil { @@ -287,6 +337,13 @@ func TestGenerateSTRMForAllLibrariesWritesPerLibraryFolders(t *testing.T) { } assertFileContains(t, filepath.Join(outDir, "电影", "云盘电影 (2026)", "云盘电影 (2026).strm"), "http://nas.example:18080/api/stream/movie-1?token=strm-token") assertFileContains(t, filepath.Join(outDir, "电视剧", "欧美剧", "Show", "Season 01", "Show - S01E01.strm"), "http://nas.example:18080/api/stream/show-1?token=strm-token") + var count int64 + if err := repos.DB.Model(&model.STRMRecord{}).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 2 { + t.Fatalf("active strm record count = %d, want 2", count) + } } func assertFileContains(t *testing.T, path, want string) { diff --git a/internal/service/strm_url.go b/internal/service/strm_url.go index 7390258..cba70c8 100644 --- a/internal/service/strm_url.go +++ b/internal/service/strm_url.go @@ -85,12 +85,24 @@ func (s *STRMService) strmRelativePath(lib model.Library, media model.Media) str show = title } season := media.SeasonNum + episode := media.EpisodeNum + if season <= 0 || episode <= 0 { + parsedSeason, parsedEpisode := ParseEpisode(media.Path) + if season <= 0 { + season = parsedSeason + } + if episode <= 0 { + episode = parsedEpisode + } + } if season <= 0 { season = 1 } - name := title - if media.EpisodeNum > 0 { - name = fmt.Sprintf("%s - S%02dE%02d", show, season, media.EpisodeNum) + name := strings.TrimSuffix(filepath.Base(media.Path), filepath.Ext(media.Path)) + if episode > 0 { + name = fmt.Sprintf("%s - S%02dE%02d", show, season, episode) + } else if strings.TrimSpace(name) == "" { + name = title } return filepath.Join(sanitizeFilename(show), fmt.Sprintf("Season %02d", season), sanitizeFilename(name)+".strm") } diff --git a/internal/service/subscription.go b/internal/service/subscription.go index 3ee97a1..2b71efe 100644 --- a/internal/service/subscription.go +++ b/internal/service/subscription.go @@ -10,7 +10,9 @@ import ( "context" "errors" "fmt" + "strconv" "strings" + "sync" "time" "go.uber.org/zap" @@ -31,9 +33,17 @@ type SubscriptionService struct { scraper *ScraperService hub *Hub notify *NotifyChannelService + mu sync.Mutex stop chan struct{} + running bool } +const ( + defaultSubscriptionPollInterval = 3 * time.Hour + minSubscriptionPollInterval = 3 * time.Hour + subscriptionStartupDelay = defaultSubscriptionPollInterval +) + type rssSubscriptionRunState struct { seen []string seenSet map[string]struct{} @@ -51,7 +61,6 @@ func NewSubscriptionService(cfg *config.Config, log *zap.Logger, repo *repositor downloads: downloads, site: site, hub: hub, - stop: make(chan struct{}), } } @@ -65,11 +74,31 @@ func (s *SubscriptionService) SetNotifyChannels(notify *NotifyChannelService) { // Start runs the polling loop in the background. func (s *SubscriptionService) Start(ctx context.Context) { - go s.loop(ctx) + s.mu.Lock() + if s.running { + s.mu.Unlock() + return + } + stop := make(chan struct{}) + s.stop = stop + s.running = true + s.mu.Unlock() + go s.loop(ctx, stop) } // Stop shuts the loop down. -func (s *SubscriptionService) Stop() { close(s.stop) } +func (s *SubscriptionService) Stop() { + s.mu.Lock() + if !s.running { + s.mu.Unlock() + return + } + stop := s.stop + s.stop = nil + s.running = false + s.mu.Unlock() + close(stop) +} // Create persists a new subscription. func (s *SubscriptionService) Create(ctx context.Context, sub *model.Subscription) error { @@ -141,26 +170,61 @@ func (s *SubscriptionService) RunNow(ctx context.Context, id string) (int, error return s.runOne(ctx, &sub) } -// loop polls every 10 minutes. -func (s *SubscriptionService) loop(ctx context.Context) { - t := time.NewTicker(10 * time.Minute) - defer t.Stop() - // First run shortly after startup. - first := time.NewTimer(30 * time.Second) - defer first.Stop() +// loop polls subscription feeds and site-search subscriptions at a conservative +// cadence so tracker APIs are not hammered by every alias keyword. +func (s *SubscriptionService) loop(ctx context.Context, stop <-chan struct{}) { + defer s.markLoopStopped(stop) + interval := s.pollInterval(ctx) + delay := subscriptionStartupDelay + if interval < delay { + delay = interval + } for { + timer := time.NewTimer(delay) select { case <-ctx.Done(): + timer.Stop() return - case <-s.stop: + case <-stop: + timer.Stop() return - case <-first.C: - case <-t.C: + case <-timer.C: } s.runAll(ctx) + // Re-read after every run so changes from the settings page take effect + // without restarting the service. + delay = s.pollInterval(ctx) } } +func (s *SubscriptionService) markLoopStopped(stop <-chan struct{}) { + s.mu.Lock() + defer s.mu.Unlock() + if s.stop == stop { + s.stop = nil + s.running = false + } +} + +func (s *SubscriptionService) pollInterval(ctx context.Context) time.Duration { + if s == nil || s.repo == nil || s.repo.Setting == nil { + return defaultSubscriptionPollInterval + } + raw, err := s.repo.Setting.Get(ctx, "subscription.interval_seconds") + if err != nil { + return defaultSubscriptionPollInterval + } + seconds, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || seconds <= 0 { + return defaultSubscriptionPollInterval + } + interval := time.Duration(seconds) * time.Second + if interval < minSubscriptionPollInterval { + return minSubscriptionPollInterval + } + return interval +} + func (s *SubscriptionService) runAll(ctx context.Context) { subs, err := s.repo.Subscription.List(ctx) if err != nil { @@ -174,6 +238,11 @@ func (s *SubscriptionService) runAll(ctx context.Context) { if n, err := s.runOne(ctx, &subs[i]); err != nil { s.log.Warn("subscription run failed", zap.String("name", subs[i].Name), zap.Error(err)) + if subscriptionSiteSearchShouldStopOnError(err) { + s.log.Warn("subscription sweep stopped after upstream failure", + zap.String("name", subs[i].Name), zap.Error(err)) + return + } } else if n > 0 { s.log.Info("subscription queued items", zap.String("name", subs[i].Name), zap.Int("count", n)) @@ -182,6 +251,7 @@ func (s *SubscriptionService) runAll(ctx context.Context) { } func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscription) (int, error) { + s.prepareSubscriptionForRun(ctx, sub) if strings.HasPrefix(strings.ToLower(strings.TrimSpace(sub.FeedURL)), "site-search://") { return s.runSiteSearch(ctx, sub) } diff --git a/internal/service/subscription_archive.go b/internal/service/subscription_archive.go index 7080802..8656c53 100644 --- a/internal/service/subscription_archive.go +++ b/internal/service/subscription_archive.go @@ -3,7 +3,6 @@ package service import ( "context" "fmt" - "strings" "time" "go.uber.org/zap" @@ -87,16 +86,17 @@ func subscriptionShouldArchive(sub *model.Subscription, availability LocalAvaila if sub == nil || sub.WashEnabled || sub.ArchivedAt != nil { return false } - mediaType := strings.ToLower(strings.TrimSpace(sub.MediaType)) - if !isSubscriptionSeriesType(mediaType) { + mediaType := normalizeMediaType(sub.MediaType, sub.Name+" "+sub.Filter, "") + seriesLike := isSubscriptionSeriesType(mediaType) || len(availability.ExistingEpisodeKeys) > 0 || len(availability.MissingEpisodeKeys) > 0 + if !seriesLike { return availability.InLibrary || availability.LocalMediaCount > 0 || availability.DownloadedEpisodes > 0 } + total := trustedSeriesArchiveTotal(sub, availability) if availability.HasSeriesPack { - return true - } - total := sub.TotalEpisodes - if total <= 0 { - total = availability.TotalEpisodes + if len(availability.ExistingEpisodeKeys) == 0 { + return true + } + return total > 0 && availability.DownloadedEpisodes >= total && len(availability.MissingEpisodes) == 0 } if total > 0 { return availability.DownloadedEpisodes >= total && len(availability.MissingEpisodes) == 0 @@ -104,6 +104,31 @@ func subscriptionShouldArchive(sub *model.Subscription, availability LocalAvaila return subscriptionLooksSingleEpisode(sub) && availability.DownloadedEpisodes > 0 } +func trustedSeriesArchiveTotal(sub *model.Subscription, availability LocalAvailability) int { + total := 0 + if sub != nil { + total = sub.TotalEpisodes + } + if total <= 0 { + total = availability.TotalEpisodes + } + if maxEpisode := maxAvailabilityEpisode(availability.ExistingEpisodeKeys); total > 0 && maxEpisode > total { + return 0 + } + return total +} + +func maxAvailabilityEpisode(keys map[string]struct{}) int { + maxEpisode := 0 + for key := range keys { + var season, episode int + if _, err := fmt.Sscanf(key, "%02dE%03d", &season, &episode); err == nil && episode > maxEpisode { + maxEpisode = episode + } + } + return maxEpisode +} + func subscriptionArchiveReason(sub *model.Subscription, availability LocalAvailability) string { if sub != nil && sub.WashEnabled { return "" diff --git a/internal/service/subscription_archive_test.go b/internal/service/subscription_archive_test.go index 85ef02e..b1ca7af 100644 --- a/internal/service/subscription_archive_test.go +++ b/internal/service/subscription_archive_test.go @@ -151,6 +151,31 @@ func TestSubscriptionArchiveKeepsGenericUnknownTotalSeriesActive(t *testing.T) { } } +func TestSubscriptionArchiveKeepsPartialSeriesWithParentRowActive(t *testing.T) { + sub := &model.Subscription{ + Name: "南部档案 自动订阅", + Filter: "南部档案", + } + availability := LocalAvailability{ + DownloadedEpisodes: 6, + TotalEpisodes: 1, + LocalMediaCount: 7, + InLibrary: true, + HasSeriesPack: true, + ExistingEpisodeKeys: map[string]struct{}{ + episodeKey(1, 1): {}, + episodeKey(1, 2): {}, + episodeKey(1, 3): {}, + episodeKey(1, 4): {}, + episodeKey(1, 5): {}, + episodeKey(1, 6): {}, + }, + } + if subscriptionShouldArchive(sub, availability) { + t.Fatal("partial series with a parent/collection row should stay active") + } +} + func TestSubscriptionArchiveKeepsWashSubscriptionActive(t *testing.T) { db := newServiceTestDB(t, &model.Subscription{}) repos := repository.New(db) diff --git a/internal/service/subscription_availability.go b/internal/service/subscription_availability.go index 0cd3463..bf2a24b 100644 --- a/internal/service/subscription_availability.go +++ b/internal/service/subscription_availability.go @@ -204,7 +204,7 @@ func mergeLocalAvailability(values ...LocalAvailability) LocalAvailability { MissingEpisodeKeys: map[string]struct{}{}, } for _, value := range values { - if out.TotalEpisodes == 0 { + if value.TotalEpisodes > out.TotalEpisodes { out.TotalEpisodes = value.TotalEpisodes } out.LocalMediaCount += value.LocalMediaCount diff --git a/internal/service/subscription_availability_test.go b/internal/service/subscription_availability_test.go index 66a60a3..f2a358f 100644 --- a/internal/service/subscription_availability_test.go +++ b/internal/service/subscription_availability_test.go @@ -1,9 +1,11 @@ package service import ( + "context" "net/http" "net/http/httptest" "testing" + "time" "go.uber.org/zap" @@ -41,6 +43,88 @@ func TestSubscriptionEnrichProgressIncludesPendingDownloads(t *testing.T) { } } +func TestSubscriptionPollIntervalDefaultsAndClampsMinimum(t *testing.T) { + if subscriptionStartupDelay != defaultSubscriptionPollInterval { + t.Fatalf("startup delay = %v, want default poll interval %v", subscriptionStartupDelay, defaultSubscriptionPollInterval) + } + + db := newServiceTestDB(t, &model.Setting{}) + repos := repository.New(db) + svc := NewSubscriptionService(nil, nil, repos, nil, nil, nil) + if got := svc.pollInterval(t.Context()); got != defaultSubscriptionPollInterval { + t.Fatalf("default poll interval = %v, want %v", got, defaultSubscriptionPollInterval) + } + + if err := repos.Setting.Set(t.Context(), "subscription.interval_seconds", "1800"); err != nil { + t.Fatal(err) + } + if got := svc.pollInterval(t.Context()); got != minSubscriptionPollInterval { + t.Fatalf("clamped poll interval = %v, want %v", got, minSubscriptionPollInterval) + } + + if err := repos.Setting.Set(t.Context(), "subscription.interval_seconds", "14400"); err != nil { + t.Fatal(err) + } + if got := svc.pollInterval(t.Context()); got != 4*time.Hour { + t.Fatalf("configured poll interval = %v, want 4h", got) + } +} + +func TestSubscriptionServiceStartIsSingleLoopAndRestartable(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + svc := NewSubscriptionService(nil, zap.NewNop(), nil, nil, nil, nil) + + svc.Start(ctx) + firstStop := subscriptionStopChannel(svc) + if firstStop == nil { + t.Fatal("first Start did not create a stop channel") + } + svc.Start(ctx) + if got := subscriptionStopChannel(svc); got != firstStop { + t.Fatal("second Start should reuse the running loop instead of starting another") + } + + svc.Stop() + svc.Stop() + svc.Start(ctx) + secondStop := subscriptionStopChannel(svc) + if secondStop == nil { + t.Fatal("restart did not create a stop channel") + } + if secondStop == firstStop { + t.Fatal("restart should create a fresh loop after Stop") + } + svc.Stop() +} + +func subscriptionStopChannel(svc *SubscriptionService) chan struct{} { + svc.mu.Lock() + defer svc.mu.Unlock() + return svc.stop +} + +func TestMergeLocalAvailabilityKeepsLargerSeriesTotal(t *testing.T) { + existing := map[string]struct{}{} + for episode := 1; episode <= 6; episode++ { + existing[episodeKey(1, episode)] = struct{}{} + } + + got := mergeLocalAvailability( + LocalAvailability{TotalEpisodes: 1, LocalMediaCount: 1}, + LocalAvailability{TotalEpisodes: 33, LocalMediaCount: 6, ExistingEpisodeKeys: existing}, + ) + if got.TotalEpisodes != 33 { + t.Fatalf("TotalEpisodes = %d, want 33", got.TotalEpisodes) + } + if got.DownloadedEpisodes != 6 { + t.Fatalf("DownloadedEpisodes = %d, want 6", got.DownloadedEpisodes) + } + if len(got.MissingEpisodes) != 27 { + t.Fatalf("missing episodes = %d, want 27", len(got.MissingEpisodes)) + } +} + func TestSubscriptionLocalAvailabilityMatchesMediaPath(t *testing.T) { db := newServiceTestDB(t, &model.Media{}) repos := repository.New(db) diff --git a/internal/service/subscription_candidate_availability_test.go b/internal/service/subscription_candidate_availability_test.go index 0ceb089..3f8120f 100644 --- a/internal/service/subscription_candidate_availability_test.go +++ b/internal/service/subscription_candidate_availability_test.go @@ -87,6 +87,56 @@ func TestSelectSiteSearchCandidatesFullPackUsedAsFallbackWhenLibraryPartiallyExi } } +func TestSelectSiteSearchCandidatesPartialSeriesPackDoesNotSatisfySubscription(t *testing.T) { + sub := &model.Subscription{Name: "问心2 自动订阅", Filter: "问心2", MediaType: "tv", TotalEpisodes: 33} + results := []SearchResult{ + {Title: "问心2 S01E07 2160p WEB-DL", DownloadURL: "https://pt/download/7", Seeders: 100}, + } + availability := LocalAvailability{ + TotalEpisodes: 33, + LocalMediaCount: 7, + HasSeriesPack: true, + MissingEpisodes: []int{7}, + ExistingEpisodeKeys: map[string]struct{}{}, + } + for episode := 1; episode <= 6; episode++ { + availability.ExistingEpisodeKeys[episodeKey(1, episode)] = struct{}{} + } + + got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, availability) + if len(got) != 1 || got[0].Episode != 7 { + t.Fatalf("selected %#v, want missing episode 7 despite local pack marker", got) + } + if stats.LocalSeriesPackPresent { + t.Fatalf("LocalSeriesPackPresent = true, want false for partial series availability") + } +} + +func TestSelectSiteSearchCandidatesIgnoresUnderestimatedLocalTotal(t *testing.T) { + sub := &model.Subscription{Name: "南部档案 自动订阅", Filter: "南部档案", MediaType: "tv"} + results := []SearchResult{ + {Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", SearchKeyword: "南部档案 2026", DownloadURL: "https://pt/download/29-33", Seeders: 100}, + } + existing := map[string]struct{}{} + for episode := 1; episode <= 6; episode++ { + existing[episodeKey(1, episode)] = struct{}{} + } + availability := LocalAvailability{ + TotalEpisodes: 1, + LocalMediaCount: 7, + HasSeriesPack: true, + ExistingEpisodeKeys: existing, + } + + got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, availability) + if len(got) != 1 || got[0].Download != "https://pt/download/29-33" { + t.Fatalf("selected %#v, want high-episode candidate despite underestimated local total", got) + } + if stats.SeriesComplete || stats.NotMissingEpisodeSkipped != 0 { + t.Fatalf("stats = %#v, underestimated total must not mark series complete or skip high episodes", stats) + } +} + func TestSelectSiteSearchCandidatesMissingEpisodeCanMatchSubtitleAlias(t *testing.T) { sub := &model.Subscription{Name: "躲在超市后门抽烟的两人 自动订阅", Filter: "躲在超市后门抽烟的两人", MediaType: "tv", TotalEpisodes: 12} results := []SearchResult{ diff --git a/internal/service/subscription_candidate_collect.go b/internal/service/subscription_candidate_collect.go index e103933..6b764c4 100644 --- a/internal/service/subscription_candidate_collect.go +++ b/internal/service/subscription_candidate_collect.go @@ -11,7 +11,7 @@ func collectSiteSearchCandidates(results []SearchResult, sub *model.Subscription candidates := make([]siteSearchCandidate, 0, len(results)) for _, item := range results { matchText := subscriptionSearchResultText(item) - if !subscriptionTitleMatchesQuery(sub, matchText) { + if !subscriptionSearchResultMatchesQuery(sub, item) { if allowQueryMismatch { stats.RelaxedQueryMatch++ } else { diff --git a/internal/service/subscription_candidate_select.go b/internal/service/subscription_candidate_select.go index 6d8a5f0..0d6bb82 100644 --- a/internal/service/subscription_candidate_select.go +++ b/internal/service/subscription_candidate_select.go @@ -38,14 +38,15 @@ func selectPreparedSubscriptionCandidatesWithStats(candidates []siteSearchCandid return recordPreparedSelection(candidates[:1], stats) } - if local.HasSeriesPack { + if localSeriesPackSatisfiesSubscription(local) { if stats != nil { stats.LocalSeriesPackPresent = true } return recordPreparedSelection(nil, stats) } if local.LocalMediaCount > 0 { - if local.TotalEpisodes > 0 && len(local.MissingEpisodes) == 0 { + trustedTotal := trustedAvailabilityTotal(local) + if trustedTotal > 0 && len(local.MissingEpisodes) == 0 { if stats != nil { stats.SeriesComplete = true } @@ -81,7 +82,7 @@ func selectPreparedSubscriptionCandidatesWithStats(candidates []siteSearchCandid } continue } - if local.TotalEpisodes > 0 { + if trustedTotal > 0 { if _, missing := missingSet[candidate.Episode]; !missing { if stats != nil { stats.NotMissingEpisodeSkipped++ @@ -122,3 +123,28 @@ func recordPreparedSelection(candidates []siteSearchCandidate, stats *siteSearch } return candidates } + +func localSeriesPackSatisfiesSubscription(local LocalAvailability) bool { + if !local.HasSeriesPack { + return false + } + total := trustedAvailabilityTotal(local) + if total <= 0 { + return len(local.ExistingEpisodeKeys) == 0 + } + if len(local.MissingEpisodes) > 0 { + return false + } + return len(local.ExistingEpisodeKeys) >= total +} + +func trustedAvailabilityTotal(local LocalAvailability) int { + total := local.TotalEpisodes + if total <= 0 { + return 0 + } + if maxEpisode := maxAvailabilityEpisode(local.ExistingEpisodeKeys); maxEpisode > total { + return 0 + } + return total +} diff --git a/internal/service/subscription_candidate_selection_test.go b/internal/service/subscription_candidate_selection_test.go index f681a9f..6377ab9 100644 --- a/internal/service/subscription_candidate_selection_test.go +++ b/internal/service/subscription_candidate_selection_test.go @@ -1,10 +1,18 @@ package service import ( + "errors" + "net/http" + "net/http/httptest" "strings" + "sync/atomic" "testing" + "time" + + "go.uber.org/zap" "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" ) func TestSelectSiteSearchCandidatesPrefersSeriesPack(t *testing.T) { @@ -119,6 +127,214 @@ func TestSelectSiteSearchCandidatesMatchesSubscriptionOriginalNameAlias(t *testi } } +func TestSelectSiteSearchCandidatesTrustsMatchedSearchKeyword(t *testing.T) { + sub := &model.Subscription{ + Name: "南部档案 自动订阅", + Filter: "南部档案", + MediaType: "tv", + TotalEpisodes: 33, + } + results := []SearchResult{{ + Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", + DownloadURL: "https://pt/download/nanyang-29-33", + SearchKeyword: "南部档案 2026", + Seeders: 90, + }} + availability := LocalAvailability{ + TotalEpisodes: 33, + ExistingEpisodeKeys: map[string]struct{}{ + episodeKey(1, 1): {}, + }, + MissingEpisodes: []int{2, 3, 4, 5}, + } + + got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, availability) + if len(got) != 1 || got[0].Download != "https://pt/download/nanyang-29-33" || !got[0].Pack { + t.Fatalf("selected %#v, want English pack matched by Chinese search keyword", got) + } + if stats.QueryMismatch != 0 || stats.Prepared != 1 || stats.Selected != 1 { + t.Fatalf("stats = %#v, want keyword-origin match without query mismatch", stats) + } +} + +func TestDedupeSiteSearchResultsKeepsMatchedSearchKeyword(t *testing.T) { + sub := &model.Subscription{ + Name: "南部档案 自动订阅", + Filter: "南部档案", + MediaType: "tv", + TotalEpisodes: 33, + } + results := dedupeSiteSearchResults([]SearchResult{ + { + SiteID: "mteam", + Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", + DownloadURL: "https://pt/download/nanyang-29-33", + SearchKeyword: "Archives The Nanyang Mystery", + Seeders: 80, + Size: 1024, + }, + { + SiteID: "mteam", + Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", + DownloadURL: "https://pt/download/nanyang-29-33", + SearchKeyword: "南部档案 2026", + Seeders: 80, + Size: 1024, + }, + }) + if len(results) != 1 { + t.Fatalf("deduped results = %#v, want one merged result", results) + } + if !strings.Contains(results[0].SearchKeyword, "南部档案 2026") { + t.Fatalf("merged search keyword = %q, missing Chinese keyword", results[0].SearchKeyword) + } + availability := LocalAvailability{TotalEpisodes: 33, MissingEpisodes: []int{29, 30, 31, 32, 33}, ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 1): {}}} + got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, availability) + if len(got) != 1 || got[0].Download != "https://pt/download/nanyang-29-33" { + t.Fatalf("selected %#v, want merged keyword candidate", got) + } + if stats.QueryMismatch != 0 || stats.Prepared != 1 { + t.Fatalf("stats = %#v, want merged keyword to avoid query mismatch", stats) + } +} + +func TestSearchSubscriptionSitesStopsAfterRateLimit(t *testing.T) { + var requests atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":"0","message":"SUCCESS","data":{"total":"0","data":[]}}`)) + })) + defer upstream.Close() + + db := newServiceTestDB(t, &model.Site{}, &model.Setting{}) + repos := repository.New(db) + siteSvc := NewSiteService(zap.NewNop(), repos, "") + limiter := &staticSiteAPIRateLimiter{err: &siteAPIRateLimitError{ + Bucket: "torrent_search_24h", + Limit: 1500, + Window: 24 * time.Hour, + RetryAfter: time.Hour, + }} + siteSvc.apiRateLimiter = limiter + if err := siteSvc.Create(t.Context(), &model.Site{ + Name: "馒头", + Type: "mteam", + URL: upstream.URL, + AuthType: "api_key", + APIKey: "token-123", + Enabled: true, + Timeout: 5, + }); err != nil { + t.Fatal(err) + } + svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, siteSvc, NewHub(zap.NewNop())) + sub := &model.Subscription{Name: "问心2 自动订阅", Filter: "问心2", MediaType: "tv"} + + _, err := svc.searchSubscriptionSites(t.Context(), sub, []string{"问心2", "问心", "问心2 2023"}) + var limited *siteAPIRateLimitError + if !errors.As(err, &limited) { + t.Fatalf("searchSubscriptionSites error = %v, want siteAPIRateLimitError", err) + } + if limiter.calls != 1 { + t.Fatalf("rate limiter calls = %d, want 1 keyword attempt", limiter.calls) + } + if got := requests.Load(); got != 0 { + t.Fatalf("HTTP requests = %d, want 0 after local rate limit", got) + } +} + +func TestSubscriptionRunAllStopsSweepAfterRateLimit(t *testing.T) { + var requests atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":"0","message":"SUCCESS","data":{"total":"0","data":[]}}`)) + })) + defer upstream.Close() + + db := newServiceTestDB(t, &model.Site{}, &model.Setting{}, &model.Subscription{}) + repos := repository.New(db) + siteSvc := NewSiteService(zap.NewNop(), repos, "") + limiter := &staticSiteAPIRateLimiter{err: &siteAPIRateLimitError{ + Bucket: "torrent_search_24h", + Limit: 1500, + Window: 24 * time.Hour, + RetryAfter: time.Hour, + }} + siteSvc.apiRateLimiter = limiter + if err := siteSvc.Create(t.Context(), &model.Site{ + Name: "馒头", + Type: "mteam", + URL: upstream.URL, + AuthType: "api_key", + APIKey: "token-123", + Enabled: true, + Timeout: 5, + }); err != nil { + t.Fatal(err) + } + for _, name := range []string{"问心2 自动订阅", "南部档案 自动订阅"} { + sub := &model.Subscription{ + Name: name, + FeedURL: "site-search://search?keyword=" + name, + Filter: name, + Enabled: true, + } + if err := repos.Subscription.Create(t.Context(), sub); err != nil { + t.Fatal(err) + } + } + svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, siteSvc, NewHub(zap.NewNop())) + + svc.runAll(t.Context()) + if limiter.calls != 1 { + t.Fatalf("rate limiter calls = %d, want sweep to stop after first quota failure", limiter.calls) + } + if got := requests.Load(); got != 0 { + t.Fatalf("HTTP requests = %d, want 0 after local rate limit", got) + } +} + +func TestSubscriptionSiteSearchStopsAfterTransientSiteErrors(t *testing.T) { + for _, errText := range []string{ + `search: Post "https://api.m-team.cc/api/torrent/search": context deadline exceeded`, + `search: Post "https://api.m-team.cc/api/torrent/search": net/http: TLS handshake timeout`, + `search: Post "https://api.m-team.cc/api/torrent/search": unexpected EOF`, + `search: Post "https://api.m-team.cc/api/torrent/search": read tcp 127.0.0.1: connection reset by peer`, + } { + if !subscriptionSiteSearchShouldStopOnError(errors.New(errText)) { + t.Fatalf("subscriptionSiteSearchShouldStopOnError(%q) = false, want true", errText) + } + } + if subscriptionSiteSearchShouldStopOnError(errors.New("temporary parser warning: no matching torrent rows")) { + t.Fatal("non-upstream-failure errors should not stop alias search") + } +} + +func TestSelectSiteSearchCandidatesRejectsKeywordOriginWithConflictingYear(t *testing.T) { + sub := &model.Subscription{ + Name: "玩具总动员 5 自动订阅", + Filter: "玩具总动员 5 2026", + MediaType: "movie", + Year: 2026, + } + results := []SearchResult{{ + Title: "Toy Story 4 2019 2160p DSNP WEB-DL", + DownloadURL: "https://pt/download/toy-story-4", + SearchKeyword: "玩具总动员 5", + Seeders: 90, + }} + + got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, LocalAvailability{}) + if len(got) != 0 { + t.Fatalf("selected %#v, want conflicting-year keyword-origin result rejected", got) + } + if stats.QueryMismatch != 1 || stats.Prepared != 0 { + t.Fatalf("stats = %#v, want query mismatch for conflicting year", stats) + } +} + func TestSelectSiteSearchCandidatesDoesNotWashByDefault(t *testing.T) { sub := &model.Subscription{Name: "Inception 自动订阅", Filter: "Inception 2010", MediaType: "movie", WashPriority: "resolution"} results := []SearchResult{ diff --git a/internal/service/subscription_episode_totals.go b/internal/service/subscription_episode_totals.go index 271d0dc..2b94382 100644 --- a/internal/service/subscription_episode_totals.go +++ b/internal/service/subscription_episode_totals.go @@ -5,6 +5,7 @@ import ( "fmt" "net/url" "regexp" + "strconv" "strings" "go.uber.org/zap" @@ -54,7 +55,7 @@ func inferSearchTotalEpisodes(results []SearchResult, sub *model.Subscription) i maxEpisode := 0 for _, item := range results { matchText := subscriptionSearchResultText(item) - if !subscriptionTitleMatchesQuery(sub, matchText) { + if !subscriptionSearchResultMatchesQuery(sub, item) { continue } if !matchesSubscriptionRules(sub, matchText) { @@ -210,6 +211,91 @@ func subscriptionTitleMatchesQuery(sub *model.Subscription, title string) bool { return len(subscriptionTitleMatchQueries(sub)) == 0 } +func subscriptionSearchResultMatchesQuery(sub *model.Subscription, item SearchResult) bool { + matchText := subscriptionSearchResultText(item) + if subscriptionTitleMatchesQuery(sub, matchText) { + return true + } + if !subscriptionSearchResultYearCompatible(sub, matchText) { + return false + } + return subscriptionKeywordMatchesQuery(sub, item.SearchKeyword) +} + +func subscriptionSearchResultYearCompatible(sub *model.Subscription, title string) bool { + expected := subscriptionExpectedYear(sub) + if expected <= 0 { + return true + } + years := titleYears(title) + if len(years) == 0 { + return true + } + for _, year := range years { + if year == expected { + return true + } + } + return false +} + +func subscriptionExpectedYear(sub *model.Subscription) int { + if sub == nil { + return 0 + } + if sub.Year > 0 { + return sub.Year + } + for _, value := range []string{sub.Filter, sub.Name, sub.FeedURL} { + for _, year := range titleYears(value) { + return year + } + } + return 0 +} + +func titleYears(value string) []int { + matches := regexp.MustCompile(`\b(19\d{2}|20\d{2})\b`).FindAllString(value, -1) + if len(matches) == 0 { + return nil + } + out := make([]int, 0, len(matches)) + seen := map[int]struct{}{} + for _, match := range matches { + year, err := strconv.Atoi(match) + if err != nil { + continue + } + if _, ok := seen[year]; ok { + continue + } + seen[year] = struct{}{} + out = append(out, year) + } + return out +} + +func subscriptionKeywordMatchesQuery(sub *model.Subscription, keyword string) bool { + keyword = strings.TrimSpace(keyword) + if keyword == "" { + return false + } + normalizedKeyword := normalizeAvailabilityComparable(keyword) + if normalizedKeyword == "" { + return false + } + for _, query := range subscriptionTitleMatchQueries(sub) { + normalizedQuery := normalizeAvailabilityComparable(query) + if normalizedQuery == "" { + continue + } + if strings.Contains(normalizedKeyword, normalizedQuery) || strings.Contains(normalizedQuery, normalizedKeyword) { + return true + } + } + return len(subscriptionTitleMatchQueries(sub)) == 0 +} + func subscriptionTitleMatchQueries(sub *model.Subscription) []string { if sub == nil { return nil diff --git a/internal/service/subscription_metadata_prepare.go b/internal/service/subscription_metadata_prepare.go new file mode 100644 index 0000000..27cf13a --- /dev/null +++ b/internal/service/subscription_metadata_prepare.go @@ -0,0 +1,105 @@ +package service + +import ( + "context" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func (s *SubscriptionService) prepareSubscriptionForRun(ctx context.Context, sub *model.Subscription) { + if s == nil || sub == nil { + return + } + normalizeSubscriptionDefaults(sub) + updates := map[string]any{} + if s.fillSubscriptionRunMetadata(ctx, sub, updates); len(updates) > 0 && s.repo != nil && s.repo.DB != nil { + if err := s.repo.DB.WithContext(ctx).Model(&model.Subscription{}).Where("id = ?", sub.ID).Updates(updates).Error; err != nil && s.log != nil { + s.log.Debug("subscription metadata prepare persist failed", zap.String("id", sub.ID), zap.Error(err)) + } + } +} + +func (s *SubscriptionService) fillSubscriptionRunMetadata(ctx context.Context, sub *model.Subscription, updates map[string]any) { + if sub == nil { + return + } + if needsSubscriptionMetadataLookup(sub) { + query := subscriptionMetadataPrepareQuery(sub) + lookupCtx, cancel := context.WithTimeout(ctx, 12*time.Second) + defer cancel() + if match := s.lookupSubscriptionMetadata(lookupCtx, strings.TrimSpace(sub.MediaType), query, sub); match != nil { + applySubscriptionMetadataMatch(sub, match, updates) + } + } + if isSubscriptionSeriesType(strings.TrimSpace(sub.MediaType)) && sub.TotalEpisodes <= 0 { + if total := s.resolveSubscriptionTotalEpisodes(ctx, sub, 0); total > 0 { + sub.TotalEpisodes = total + updates["total_episodes"] = total + } + } +} + +func needsSubscriptionMetadataLookup(sub *model.Subscription) bool { + if sub == nil { + return false + } + return strings.TrimSpace(sub.MediaType) == "" || + strings.TrimSpace(sub.OriginalName) == "" || + sub.Year <= 0 +} + +func subscriptionMetadataPrepareQuery(sub *model.Subscription) string { + if sub == nil { + return "" + } + if value := strings.TrimSpace(sub.Filter); value != "" { + return value + } + return strings.TrimSpace(sub.Name) +} + +func applySubscriptionMetadataMatch(sub *model.Subscription, match *Match, updates map[string]any) { + if sub == nil || match == nil { + return + } + if strings.TrimSpace(sub.MediaType) == "" { + if mediaType := normalizeMetadataMatchSubscriptionType(match); mediaType != "" { + sub.MediaType = mediaType + updates["media_type"] = mediaType + } + } + if strings.TrimSpace(sub.OriginalName) == "" { + if value := strings.TrimSpace(match.OriginalName); value != "" { + sub.OriginalName = value + updates["original_name"] = value + } + } + if sub.Year <= 0 && match.Year > 0 { + sub.Year = match.Year + updates["year"] = match.Year + } +} + +func normalizeMetadataMatchSubscriptionType(match *Match) string { + if match == nil { + return "" + } + switch normalizeOrganizeMediaType(match.MediaType) { + case "movie": + return "movie" + case "tv": + return "tv" + case "anime": + return "anime" + case "variety": + return "variety" + case "adult": + return "adult" + default: + return "" + } +} diff --git a/internal/service/subscription_metadata_prepare_test.go b/internal/service/subscription_metadata_prepare_test.go new file mode 100644 index 0000000..6aeeecb --- /dev/null +++ b/internal/service/subscription_metadata_prepare_test.go @@ -0,0 +1,76 @@ +package service + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +func TestPrepareSubscriptionForRunFillsSeriesMetadata(t *testing.T) { + var searchedTV bool + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/search/tv": + searchedTV = true + _ = json.NewEncoder(w).Encode(map[string]any{ + "results": []map[string]any{{ + "id": 12345, + "name": "南部档案", + "original_name": "Archives The Nanyang Mystery", + "original_language": "zh", + "origin_country": []string{"CN"}, + "first_air_date": "2026-01-01", + }}, + }) + case "/tv/12345": + _ = json.NewEncoder(w).Encode(map[string]any{"number_of_episodes": 33}) + default: + http.NotFound(w, r) + } + })) + defer upstream.Close() + + cfg := &config.Config{} + cfg.Secrets.TMDbAPIKey = "test-key" + cfg.Secrets.TMDbAPIProxy = upstream.URL + repos := repository.New(newServiceTestDB(t, &model.Subscription{})) + scraper := NewScraperService(cfg, zap.NewNop(), repos, NewTMDbProvider(cfg, zap.NewNop(), nil), nil, nil, nil, NewHub(zap.NewNop())) + svc := NewSubscriptionService(cfg, zap.NewNop(), repos, nil, nil, nil) + svc.SetScraper(scraper) + + sub := model.Subscription{Name: "南部档案 自动订阅", FeedURL: "site-search://search?keyword=南部档案", Filter: "南部档案 2026", Enabled: true} + if err := repos.Subscription.Create(t.Context(), &sub); err != nil { + t.Fatal(err) + } + + svc.prepareSubscriptionForRun(t.Context(), &sub) + if !searchedTV { + t.Fatal("blank media type subscription should try TV metadata before defaulting to movie") + } + if sub.MediaType != "tv" || sub.OriginalName != "Archives The Nanyang Mystery" || sub.Year != 2026 || sub.TotalEpisodes != 33 { + t.Fatalf("prepared subscription = %#v, want tv metadata with total episodes", sub) + } + + var stored model.Subscription + if err := repos.DB.First(&stored, "id = ?", sub.ID).Error; err != nil { + t.Fatal(err) + } + if stored.MediaType != "tv" || stored.OriginalName != "Archives The Nanyang Mystery" || stored.Year != 2026 || stored.TotalEpisodes != 33 { + t.Fatalf("stored subscription = %#v, want persisted metadata", stored) + } +} + +func TestSubscriptionMetadataLibraryTypesSearchesTVForBlankType(t *testing.T) { + got := subscriptionMetadataLibraryTypes("", "南部档案 2026") + if len(got) < 2 || got[0] != "tv" || got[1] != "movie" { + t.Fatalf("library types = %#v, want tv before movie for blank subscription type", got) + } +} diff --git a/internal/service/subscription_site_search.go b/internal/service/subscription_site_search.go index c81c42e..76760c0 100644 --- a/internal/service/subscription_site_search.go +++ b/internal/service/subscription_site_search.go @@ -117,9 +117,15 @@ func (s *SubscriptionService) searchSubscriptionSites(ctx context.Context, sub * fields = append(fields, zap.Error(err)) s.log.Warn("site-search subscription search failed", fields...) } + if subscriptionSiteSearchShouldStopOnError(err) { + return nil, err + } continue } results = append(results, found...) + if hasUsableSubscriptionSearchResult(found, sub) { + break + } } results = dedupeSiteSearchResults(results) if len(results) == 0 && lastSearchErr != nil && searchErrors == len(keywords) { @@ -128,6 +134,51 @@ func (s *SubscriptionService) searchSubscriptionSites(ctx context.Context, sub * return results, nil } +func subscriptionSiteSearchShouldStopOnError(err error) bool { + if err == nil { + return false + } + var limited *siteAPIRateLimitError + if errors.As(err, &limited) { + return true + } + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return true + } + var timeout interface{ Timeout() bool } + if errors.As(err, &timeout) && timeout.Timeout() { + return true + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "rate limit") || + strings.Contains(msg, "quota") || + strings.Contains(msg, "too many requests") || + strings.Contains(msg, "status 429") || + strings.Contains(msg, "context deadline exceeded") || + strings.Contains(msg, "tls handshake timeout") || + strings.Contains(msg, "i/o timeout") || + strings.Contains(msg, "unexpected eof") || + strings.Contains(msg, "connection reset") || + strings.Contains(msg, "connection refused") || + strings.Contains(msg, "no such host") || + strings.Contains(msg, "temporary failure") +} + +func hasUsableSubscriptionSearchResult(results []SearchResult, sub *model.Subscription) bool { + for _, item := range results { + if !subscriptionSearchResultMatchesQuery(sub, item) { + continue + } + if !matchesSubscriptionRules(sub, subscriptionSearchResultText(item)) { + continue + } + if strings.TrimSpace(firstNonEmpty(item.DownloadURL, item.TorrentURL)) != "" { + return true + } + } + return false +} + func (s *SubscriptionService) finishSiteSearchNoResults(sub *model.Subscription, keyword string) (int, error) { if s.log != nil { fields := subscriptionSiteSearchLogFields(sub, keyword) diff --git a/internal/service/subscription_site_search_helpers.go b/internal/service/subscription_site_search_helpers.go index ee86648..796088b 100644 --- a/internal/service/subscription_site_search_helpers.go +++ b/internal/service/subscription_site_search_helpers.go @@ -179,7 +179,7 @@ func dedupeSiteSearchResults(results []SearchResult) []SearchResult { if len(results) < 2 { return results } - seen := make(map[string]struct{}, len(results)) + seen := make(map[string]int, len(results)) out := make([]SearchResult, 0, len(results)) for _, item := range results { download := strings.TrimSpace(item.DownloadURL) @@ -187,11 +187,17 @@ func dedupeSiteSearchResults(results []SearchResult) []SearchResult { download = strings.TrimSpace(item.TorrentURL) } key := stableSiteSearchGUID(item, download) - if _, ok := seen[key]; ok { + if idx, ok := seen[key]; ok { + out[idx].SearchKeyword = mergeSearchResultTextHints(out[idx].SearchKeyword, item.SearchKeyword) + out[idx].Subtitle = mergeSearchResultTextHints(out[idx].Subtitle, item.Subtitle) continue } - seen[key] = struct{}{} + seen[key] = len(out) out = append(out, item) } return out } + +func mergeSearchResultTextHints(current, next string) string { + return strings.Join(compactUniqueStrings(current, next), "\n") +} diff --git a/internal/service/telegram_user_self.go b/internal/service/telegram_user_self.go index 61c5c3f..3588129 100644 --- a/internal/service/telegram_user_self.go +++ b/internal/service/telegram_user_self.go @@ -47,7 +47,15 @@ func (s *TelegramBotService) cmdKick(ctx context.Context, msg *TelegramMessage, if chosen == nil { return telegramCommandReply{Text: "未找到该设备。请用 /devices 查看设备编号后重试。"} } - if err := s.repo.UserDevice.SetKicked(ctx, chosen.ID, true); err != nil { + if s.device != nil { + if err := s.device.KickDevice(ctx, user.ID, chosen.DeviceID); err != nil { + return telegramCommandReply{Text: "踢下线失败:" + err.Error()} + } + } else if fp := strings.TrimSpace(chosen.Fingerprint); fp != "" { + if err := s.repo.UserDevice.SetKickedByFingerprint(ctx, user.ID, fp, true); err != nil { + return telegramCommandReply{Text: "踢下线失败:" + err.Error()} + } + } else if err := s.repo.UserDevice.SetKicked(ctx, chosen.ID, true); err != nil { return telegramCommandReply{Text: "踢下线失败:" + err.Error()} } return telegramCommandReply{Text: fmt.Sprintf("已踢下线:%s。", deviceLabel(chosen.DeviceName, chosen.Client))} @@ -138,7 +146,15 @@ func (s *TelegramBotService) replyKick(ctx context.Context, msg *TelegramMessage if err := s.repo.DB.WithContext(ctx).Where("id = ? AND user_id = ?", deviceRowID, user.ID).First(&d).Error; err != nil { return telegramCommandReply{Text: "未找到该设备。"} } - if err := s.repo.UserDevice.SetKicked(ctx, d.ID, true); err != nil { + if s.device != nil { + if err := s.device.KickDevice(ctx, user.ID, d.DeviceID); err != nil { + return telegramCommandReply{Text: "操作失败:" + err.Error()} + } + } else if fp := strings.TrimSpace(d.Fingerprint); fp != "" { + if err := s.repo.UserDevice.SetKickedByFingerprint(ctx, user.ID, fp, true); err != nil { + return telegramCommandReply{Text: "操作失败:" + err.Error()} + } + } else if err := s.repo.UserDevice.SetKicked(ctx, d.ID, true); err != nil { return telegramCommandReply{Text: "操作失败:" + err.Error()} } return s.replyDevices(ctx, msg) diff --git a/internal/service/tmdb.go b/internal/service/tmdb.go index 7b93769..87f95ec 100644 --- a/internal/service/tmdb.go +++ b/internal/service/tmdb.go @@ -124,22 +124,23 @@ func (t *TMDbProvider) resolveBaseURL(ctx context.Context) string { // across providers; provider-specific IDs sit side-by-side so the scraper // orchestrator can write them all into a single update. type Match struct { - TMDbID int `json:"tmdb_id"` - BangumiID int `json:"bangumi_id"` - DoubanID string `json:"douban_id,omitempty"` - TheTVDBID string `json:"thetvdb_id,omitempty"` - MediaType string `json:"media_type,omitempty"` - Title string `json:"title"` - OriginalName string `json:"original_name,omitempty"` - Overview string `json:"overview"` - PosterURL string `json:"poster_url"` - BackdropURL string `json:"backdrop_url"` - Year int `json:"year"` - Rating float32 `json:"rating"` - Languages []string `json:"languages,omitempty"` - Countries []string `json:"countries,omitempty"` - Genres []string `json:"genres,omitempty"` - NSFW bool `json:"nsfw,omitempty"` + TMDbID int `json:"tmdb_id"` + BangumiID int `json:"bangumi_id"` + DoubanID string `json:"douban_id,omitempty"` + TheTVDBID string `json:"thetvdb_id,omitempty"` + MediaType string `json:"media_type,omitempty"` + Title string `json:"title"` + OriginalName string `json:"original_name,omitempty"` + Overview string `json:"overview"` + PosterURL string `json:"poster_url"` + BackdropURL string `json:"backdrop_url"` + Year int `json:"year"` + Rating float32 `json:"rating"` + Languages []string `json:"languages,omitempty"` + Countries []string `json:"countries,omitempty"` + Genres []string `json:"genres,omitempty"` + NSFW bool `json:"nsfw,omitempty"` + SearchKeyword string `json:"-"` } func (t *TMDbProvider) getJSON(ctx context.Context, url string, out any) error { diff --git a/internal/service/tmdb_search.go b/internal/service/tmdb_search.go index 6330253..7bdb2bc 100644 --- a/internal/service/tmdb_search.go +++ b/internal/service/tmdb_search.go @@ -81,7 +81,9 @@ func (t *TMDbProvider) SearchMovieCandidates(ctx context.Context, query string, } out := make([]*Match, 0, len(p.Results)) for _, r := range p.Results { - out = append(out, t.movieSearchResultToMatch(r)) + match := t.movieSearchResultToMatch(r) + match.SearchKeyword = query + out = append(out, match) } return out, nil } @@ -154,7 +156,9 @@ func (t *TMDbProvider) SearchTVCandidates(ctx context.Context, query string, yea } out := make([]*Match, 0, len(p.Results)) for _, r := range p.Results { - out = append(out, t.tvSearchResultToMatch(r)) + match := t.tvSearchResultToMatch(r) + match.SearchKeyword = query + out = append(out, match) } return out, nil } diff --git a/web/src/api/discover.ts b/web/src/api/discover.ts index c945bff..d5e2dd5 100644 --- a/web/src/api/discover.ts +++ b/web/src/api/discover.ts @@ -17,6 +17,11 @@ export interface DiscoverItem extends Partial { rating?: number subscribe_keyword?: string subscribe_aliases?: string[] + total_episodes?: number + downloaded_episodes?: number + local_media_count?: number + missing_episodes?: number[] + in_library?: boolean } export interface DiscoverSection { @@ -25,6 +30,16 @@ export interface DiscoverSection { provider?: string } +export interface DiscoverFeedMeta { + page: number + has_next: boolean +} + +export interface DiscoverFeedResult { + items: Record + meta: Record +} + // 后端在 TMDb 不可达 / API key 缺失时统一返回 { items: [], error: "..." } // 200 状态码——前端必须能区分这两种情况,不能简单用 items.length === 0 // 推断"未配置 API key"。 @@ -46,10 +61,19 @@ export const discoverAPI = { })), sections: () => api.get<{ sections: DiscoverSection[] }>('/discover/sections').then((r) => r.data.sections), - feed: (sectionKeys: string[]) => + feed: (sectionKeys: string[], page = 1): Promise => api - .get>('/discover/feed', { - params: { sections: sectionKeys.join(',') }, + .get | null>>('/discover/feed', { + params: { sections: sectionKeys.join(','), page }, }) - .then((r) => r.data), + .then((r) => { + const raw = r.data + const meta = ((raw._meta as Record | undefined) ?? {}) + const items: Record = {} + for (const key of sectionKeys) { + const row = raw[key] + items[key] = Array.isArray(row) ? row : [] + } + return { items, meta } + }), } diff --git a/web/src/components/ManualScrapeDialogSections.tsx b/web/src/components/ManualScrapeDialogSections.tsx index 28d0a80..1c6b1d4 100644 --- a/web/src/components/ManualScrapeDialogSections.tsx +++ b/web/src/components/ManualScrapeDialogSections.tsx @@ -58,21 +58,25 @@ export function ManualScrapeSearchControls({ onEpisodeArtworkChange, }: ManualScrapeSearchControlsProps) { return ( -
- +
- {showEpisodeArtworkToggle && ( - - )} +
+ + {showEpisodeArtworkToggle && ( +
+ +
+ )} +
) } @@ -85,11 +89,12 @@ function ProviderSelector({ onProviderChange: (value: string[] | ((current: string[]) => string[])) => void }) { return ( -
+
刮削源 -
+
- +
) } @@ -193,7 +199,7 @@ function ManualScrapeCandidateRow({ onApply: (item: ManualScrapeCandidate) => void }) { return ( -
+
{item.poster_url ? ( {item.title} @@ -211,7 +217,7 @@ function ManualScrapeCandidateRow({

{item.overview || '暂无简介'}

{candidateIDText(item)}

- @@ -221,7 +227,7 @@ function ManualScrapeCandidateRow({ function providerButtonClass(active: boolean): string { return ( - 'inline-flex h-11 items-center gap-1.5 rounded-xl border px-3 text-xs font-bold transition ' + + 'inline-flex h-9 min-w-0 shrink-0 items-center justify-center gap-1.5 rounded-lg border px-3 text-xs font-bold transition ' + (active ? 'border-brand-300 bg-brand-50 text-brand-700' : 'border-sand-200 bg-white text-sand-600 hover:border-brand-200 hover:text-brand-600') diff --git a/web/src/components/MediaCard.tsx b/web/src/components/MediaCard.tsx index 5b22b99..1c368a7 100644 --- a/web/src/components/MediaCard.tsx +++ b/web/src/components/MediaCard.tsx @@ -21,6 +21,7 @@ export const MediaCard = ({ const [posterFit, setPosterFit] = useState<'cover' | 'contain'>('cover') const posterSrc = imageURL(media.poster_url, media.updated_at) const displayRating = rating ?? media.rating + const versionCount = media.versions?.length ?? 0 useEffect(() => { setPosterFit('cover') @@ -78,6 +79,13 @@ export const MediaCard = ({ )} + {count === undefined && versionCount > 1 && ( + + + {versionCount} 版本 + + )} + {/* Rating Badge */} {displayRating > 0 && ( diff --git a/web/src/pages/AIAssistantExternalResults.tsx b/web/src/pages/AIAssistantExternalResults.tsx index 920e9fc..1781615 100644 --- a/web/src/pages/AIAssistantExternalResults.tsx +++ b/web/src/pages/AIAssistantExternalResults.tsx @@ -1,99 +1,69 @@ import { useState } from 'react' -import { Rss } from 'lucide-react' -import toast from 'react-hot-toast' import type { ExternalMediaResult } from '../api/ai' +import type { DiscoverItem } from '../api/discover' import { imageURL } from '../api/client' -import { buildSiteSearchFeedURL, buildSubscriptionAliases, subscriptionsAPI } from '../api/subscriptions' +import { DiscoverDetailModal } from './DiscoverDetailModal' type AIAssistantExternalResultsProps = { items: ExternalMediaResult[] } export function AIAssistantExternalResults({ items }: AIAssistantExternalResultsProps) { - const [subscribing, setSubscribing] = useState('') + const [activeItem, setActiveItem] = useState(null) if (items.length === 0) return null return ( -
- {items.map((item) => { - const keyword = item.subscribe_keyword || item.title - const key = `${item.source}:${keyword}` - return ( -
-
-
- {item.poster_url ? ( - {item.title} - ) : null} -
-
-
- {item.source} - {item.media_type && {item.media_type}} - {item.year ? {item.year} : null} + <> +
+ {items.map((item) => { + const keyword = item.subscribe_keyword || item.title + const key = `${item.source}:${keyword}` + return ( +
setActiveItem(item)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') setActiveItem(item) + }} + className="cursor-pointer rounded-2xl border border-gray-200 bg-gray-50 p-3 transition hover:-translate-y-0.5 hover:border-primary-300" + > +
+
+ {item.poster_url ? ( + {item.title} + ) : null} +
+
+
+ {item.source} + {item.media_type && {item.media_type}} + {item.year ? {item.year} : null} +
+

{item.title}

+

+ {item.overview || `订阅关键词:${keyword}`} +

+

详情 / 订阅设置

-

{item.title}

-

- {item.overview || `订阅关键词:${keyword}`} -

-
-
-
- ) - })} -
+ + ) + })} +
+ {activeItem && ( + setActiveItem(null)} + /> + )} + ) } - -async function subscribeExternalItem( - item: ExternalMediaResult, - key: string, - keyword: string, - setSubscribing: (key: string) => void, -) { - setSubscribing(key) - try { - const feed = buildSiteSearchFeedURL(keyword, item.source, buildSubscriptionAliases(item)) - const sub = await subscriptionsAPI.create({ - name: `${item.title} 自动订阅`, - feed_url: feed, - filter: keyword, - media_type: item.media_type, - source: item.source, - poster_url: item.poster_url, - backdrop_url: item.backdrop_url, - overview: item.overview, - original_name: item.original_name, - year: item.year, - total_episodes: item.total_episodes, - enabled: true, - }) - const run = await subscriptionsAPI.runNow(sub.id) - toast.success( - run.queued > 0 - ? `已订阅并加入 ${run.queued} 个下载` - : '已订阅,暂未在 PT 站点找到可下载资源', - ) - } catch (err: unknown) { - const msg = - (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? - '订阅失败' - toast.error(msg) - } finally { - setSubscribing('') - } -} diff --git a/web/src/pages/CloudBrowser.tsx b/web/src/pages/CloudBrowser.tsx index 7ee709a..fdad1e8 100644 --- a/web/src/pages/CloudBrowser.tsx +++ b/web/src/pages/CloudBrowser.tsx @@ -6,8 +6,8 @@ import { CloudScanPanel } from './CloudScanPanel' import { useCloudBrowser } from './useCloudBrowser' // Lists cloud directories and imports a file as a 302-backed media. -export function CloudBrowser({ type }: { type: StorageType }) { - const browser = useCloudBrowser(type) +export function CloudBrowser({ type, enabled = true }: { type: StorageType; enabled?: boolean }) { + const browser = useCloudBrowser(type, enabled) return (
diff --git a/web/src/pages/DiscoverContentRow.tsx b/web/src/pages/DiscoverContentRow.tsx index 85579b1..b15f2d3 100644 --- a/web/src/pages/DiscoverContentRow.tsx +++ b/web/src/pages/DiscoverContentRow.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react' -import { Info } from 'lucide-react' +import { ChevronLeft, ChevronRight, Info } from 'lucide-react' import type { DiscoverItem } from '../api/discover' import { imageURL } from '../api/client' @@ -8,19 +8,50 @@ import { discoverItemSource } from './discoverPageModel' export function ContentRow({ title, items, + page = 1, + canNext = false, imageVersion, refreshImageVersion, + onPageChange, onSelect, }: { title: string items: DiscoverItem[] + page?: number + canNext?: boolean imageVersion?: string refreshImageVersion?: string + onPageChange?: (delta: number) => void onSelect: (item: DiscoverItem) => void }) { return (
-

{title}

+
+

{title}

+ {onPageChange && ( +
+ + 第 {page} 页 + +
+ )} +
{items.map((item, index) => ( ([]) const [selected, setSelected] = useState([]) const [rows, setRows] = useState>({}) + const [rowPages, setRowPages] = useState>({}) + const [rowCanNext, setRowCanNext] = useState>({}) const [rowLoading, setRowLoading] = useState>({}) const [rowErrors, setRowErrors] = useState>({}) const [sectionsReady, setSectionsReady] = useState(false) @@ -35,7 +37,9 @@ export function DiscoverPage() { const saved = readSavedSections(items) const available = new Set(items.map((item) => item.key)) const fallback = defaultSections.filter((key) => available.has(key)) - setSelected(saved.length > 0 ? saved : fallback) + const nextSelected = saved.length > 0 ? saved : fallback + setSelected(nextSelected) + setRowPages(Object.fromEntries(nextSelected.map((key) => [key, 1]))) setSectionsReady(true) }) .catch(() => { @@ -60,6 +64,7 @@ export function DiscoverPage() { if (selected.length === 0) { setRows({}) setRowLoading({}) + setRowCanNext({}) setRowErrors({}) setLoading(false) return @@ -83,16 +88,19 @@ export function DiscoverPage() { if (!cancelled && pending <= 0) setLoading(false) } for (const key of selected) { + const page = rowPages[key] ?? 1 discoverAPI - .feed([key]) + .feed([key], page) .then((feed) => { if (cancelled) return - setRows((current) => ({ ...current, [key]: feed[key] ?? [] })) + setRows((current) => ({ ...current, [key]: feed.items[key] ?? [] })) + setRowCanNext((current) => ({ ...current, [key]: Boolean(feed.meta[key]?.has_next) })) }) .catch((err) => { if (cancelled) return const message = err instanceof Error ? err.message : String(err) setRows((current) => ({ ...current, [key]: [] })) + setRowCanNext((current) => ({ ...current, [key]: false })) setRowErrors((current) => ({ ...current, [key]: message })) }) .finally(() => { @@ -105,7 +113,7 @@ export function DiscoverPage() { return () => { cancelled = true } - }, [sections, sectionsReady, selected, reloadSeq]) + }, [sections, sectionsReady, selected, rowPages, reloadSeq]) const sectionMap = useMemo( () => new Map(sections.map((section) => [section.key, section])), @@ -121,6 +129,15 @@ export function DiscoverPage() { } return [...current, key] }) + setRowPages((current) => ({ ...current, [key]: current[key] ?? 1 })) + } + + const changeDiscoverPage = (key: string, delta: number) => { + setRowPages((current) => { + const nextPage = Math.max(1, (current[key] ?? 1) + delta) + if (nextPage === (current[key] ?? 1)) return current + return { ...current, [key]: nextPage } + }) } const refreshDiscover = () => { @@ -153,11 +170,14 @@ export function DiscoverPage() { rows={rows} rowLoading={rowLoading} rowErrors={rowErrors} + rowPages={rowPages} + rowCanNext={rowCanNext} loading={loading} hasContent={hasContent} imageVersion={imageVersion} refreshImageVersion={refreshImageVersion} sectionLabel={sectionLabel} + onPageChange={changeDiscoverPage} onSelect={setActiveItem} /> )} diff --git a/web/src/pages/DiscoverPageSections.tsx b/web/src/pages/DiscoverPageSections.tsx index 3c20c5a..62a4c39 100644 --- a/web/src/pages/DiscoverPageSections.tsx +++ b/web/src/pages/DiscoverPageSections.tsx @@ -84,22 +84,28 @@ export function DiscoverResults({ rows, rowLoading, rowErrors, + rowPages, + rowCanNext, loading, hasContent, imageVersion, refreshImageVersion, sectionLabel, + onPageChange, onSelect, }: { selected: string[] rows: Record rowLoading: Record rowErrors: Record + rowPages: Record + rowCanNext: Record loading: boolean hasContent: boolean imageVersion: string refreshImageVersion?: string sectionLabel: SectionLabel + onPageChange: (key: string, delta: number) => void onSelect: (item: DiscoverItem) => void }) { const hasRowErrors = Object.keys(rowErrors).length > 0 @@ -119,8 +125,11 @@ export function DiscoverResults({ key={key} title={sectionLabel(key)} items={items} + page={rowPages[key] ?? 1} + canNext={Boolean(rowCanNext[key])} imageVersion={imageVersion} refreshImageVersion={refreshImageVersion} + onPageChange={(delta) => onPageChange(key, delta)} onSelect={onSelect} /> ) diff --git a/web/src/pages/SearchExternalResults.tsx b/web/src/pages/SearchExternalResults.tsx index e88ae47..532cf2e 100644 --- a/web/src/pages/SearchExternalResults.tsx +++ b/web/src/pages/SearchExternalResults.tsx @@ -1,17 +1,22 @@ import { useState } from 'react' import { CheckCircle2, Info, Rss } from 'lucide-react' +import toast from 'react-hot-toast' +import type { DiscoverItem } from '../api/discover' import type { ExternalMediaResult } from '../api/ai' import { imageURL } from '../api/client' +import { subscriptionsAPI } from '../api/subscriptions' +import { DiscoverSubscriptionRules } from './DiscoverDetailModalSections' +import { + apiErrorMessage, + buildDiscoverSubscriptionInput, + initialDiscoverSubscriptionForm, +} from './discoverDetailModalModel' export function ExternalResults({ items, - busyKey, - onSubscribe, }: { items: ExternalMediaResult[] - busyKey: string - onSubscribe: (item: ExternalMediaResult) => Promise }) { const [detail, setDetail] = useState(null) return ( @@ -72,13 +77,12 @@ export function ExternalResults({
@@ -88,9 +92,7 @@ export function ExternalResults({ {detail && ( setDetail(null)} - onSubscribe={onSubscribe} /> )}
@@ -99,19 +101,37 @@ export function ExternalResults({ function ExternalDetailModal({ item, - busy, onClose, - onSubscribe, }: { item: ExternalMediaResult - busy: boolean onClose: () => void - onSubscribe: (item: ExternalMediaResult) => Promise }) { const missing = item.missing_episodes ?? [] + const discoverItem = item as unknown as DiscoverItem + const [form, setForm] = useState(() => initialDiscoverSubscriptionForm(discoverItem)) + const [formBusy, setFormBusy] = useState(false) + const submit = async () => { + setFormBusy(true) + try { + const sub = await subscriptionsAPI.create( + buildDiscoverSubscriptionInput(discoverItem, form, item.source || 'tmdb'), + ) + if (form.run_now) { + const run = await subscriptionsAPI.runNow(sub.id) + toast.success(run.queued > 0 ? `已订阅并加入 ${run.queued} 个下载` : '已订阅,暂未命中可下载资源') + } else { + toast.success('已创建订阅') + } + onClose() + } catch (err) { + toast.error(apiErrorMessage(err, '订阅失败')) + } finally { + setFormBusy(false) + } + } return (
-
event.stopPropagation()}> +
event.stopPropagation()}>
{item.poster_url ? ( @@ -158,18 +178,15 @@ function ExternalDetailModal({
)} -
+ setForm((current) => ({ ...current, ...patch }))} + onSubmit={submit} + /> + +
-
diff --git a/web/src/pages/SearchPage.tsx b/web/src/pages/SearchPage.tsx index 03a4f66..27ee7bf 100644 --- a/web/src/pages/SearchPage.tsx +++ b/web/src/pages/SearchPage.tsx @@ -47,8 +47,6 @@ export function SearchPage() { {search.externalItems.length > 0 && ( )}
diff --git a/web/src/pages/StorageForm.tsx b/web/src/pages/StorageForm.tsx index 7548a49..8222551 100644 --- a/web/src/pages/StorageForm.tsx +++ b/web/src/pages/StorageForm.tsx @@ -28,6 +28,7 @@ export function StorageForm({ type }: { type: StorageType }) { const [saving, setSaving] = useState(false) const [testing, setTesting] = useState(false) const [loggingOut, setLoggingOut] = useState(false) + const [browserVersion, setBrowserVersion] = useState(0) const refresh = async () => { setLoading(true) @@ -66,6 +67,7 @@ export function StorageForm({ type }: { type: StorageType }) { await storageAPI.save(type, config, enabled) toast.success('已保存') await refresh() + setBrowserVersion((version) => version + 1) } catch (err: unknown) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? @@ -80,8 +82,19 @@ export function StorageForm({ type }: { type: StorageType }) { setTesting(true) try { const r = await storageAPI.test(type, config) - if (r.ok) toast.success('连接成功') - else toast.error(r.error ?? '连接失败') + if (!r.ok) { + toast.error(r.error ?? '连接失败') + return + } + if (isCloud(type)) { + await storageAPI.save(type, config, true) + setEnabled(true) + toast.success('连接成功,已保存并启用') + await refresh() + setBrowserVersion((version) => version + 1) + return + } + toast.success('连接成功') } catch (err: unknown) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? @@ -104,6 +117,7 @@ export function StorageForm({ type }: { type: StorageType }) { await storageAPI.logout(type) toast.success('已退出云盘登录、停用该存储并清理本项目挂载') await refresh() + setBrowserVersion((version) => version + 1) } catch (err: unknown) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? @@ -182,7 +196,7 @@ export function StorageForm({ type }: { type: StorageType }) { transferEnabled={transferEnabled} transferMode={transferMode} /> - {isCloud(type) && } + {isCloud(type) && } ) } diff --git a/web/src/pages/SubscriptionsPage.tsx b/web/src/pages/SubscriptionsPage.tsx index 5deb1f0..61aa8f4 100644 --- a/web/src/pages/SubscriptionsPage.tsx +++ b/web/src/pages/SubscriptionsPage.tsx @@ -129,7 +129,7 @@ export function SubscriptionsPage() {

RSS 订阅

- 定期轮询 RSS 源(每 10 分钟一次),将匹配过滤器的项目自动加入下载队列;启用智能分类后会按二级分类写入下载目录。 + 后台统一每 3 小时轮询 RSS / 站点搜索订阅;手动立即执行不受影响。匹配过滤器的项目会自动加入下载队列,启用智能分类后按二级分类写入下载目录。

([{ id: '', name: '根目录' }]) const [items, setItems] = useState([]) const [mounts, setMounts] = useState([]) @@ -23,6 +23,12 @@ export function useCloudBrowser(type: StorageType) { const cur = stack[stack.length - 1] const load = useCallback(async (dir: string) => { + if (!enabled) { + setItems([]) + setError('该外部存储未启用,勾选启用并保存后再刷新资源') + setLoading(false) + return + } setLoading(true) setError('') try { @@ -35,7 +41,7 @@ export function useCloudBrowser(type: StorageType) { } finally { setLoading(false) } - }, [type]) + }, [enabled, type]) const loadMounts = useCallback(async () => { const libs = await libraryAPI.list({ includeHidden: true }) diff --git a/web/src/pages/useLibraryData.ts b/web/src/pages/useLibraryData.ts index 7e5c595..0703655 100644 --- a/web/src/pages/useLibraryData.ts +++ b/web/src/pages/useLibraryData.ts @@ -156,7 +156,7 @@ async function loadAllMedia( let page = 1 let collected: Media[] = [] for (;;) { - const data = await libraryAPI.listMedia(libraryID, page, pageSize, { groupVersions: false }) + const data = await libraryAPI.listMedia(libraryID, page, pageSize) collected = collected.concat(data.items) onPage({ items: collected, total: data.total, firstPage: page === 1 }) if (collected.length >= data.total || data.items.length < pageSize) break diff --git a/web/src/pages/useSearchPage.ts b/web/src/pages/useSearchPage.ts index 28ab88e..87d38fc 100644 --- a/web/src/pages/useSearchPage.ts +++ b/web/src/pages/useSearchPage.ts @@ -4,7 +4,6 @@ import toast from 'react-hot-toast' import { aiAPI, type ExternalMediaResult, type SearchIntent } from '../api/ai' import { mediaAPI } from '../api/library' -import { buildSiteSearchFeedURL, buildSubscriptionAliases, subscriptionsAPI } from '../api/subscriptions' import type { Media } from '../types' import { groupSeries } from '../utils/groupSeries' @@ -26,7 +25,6 @@ export function useSearchPage() { const [intent, setIntent] = useState(null) const [hasSearched, setHasSearched] = useState(false) const [externalItems, setExternalItems] = useState([]) - const [subscribing, setSubscribing] = useState('') const [searchTotal, setSearchTotal] = useState(0) const searchSeq = useRef(0) const localCards = useMemo(() => groupSeries(items), [items]) @@ -58,7 +56,7 @@ export function useSearchPage() { let page = 1 let collected: Media[] = [] for (;;) { - const data = await mediaAPI.searchPage(query, page, LOCAL_SEARCH_PAGE_SIZE, { groupVersions: false }) + const data = await mediaAPI.searchPage(query, page, LOCAL_SEARCH_PAGE_SIZE) if (seq !== searchSeq.current) return const pageItems = data.items ?? [] collected = collected.concat(pageItems) @@ -115,39 +113,6 @@ export function useSearchPage() { } } - const onSubscribe = async (item: ExternalMediaResult) => { - const keyword = item.subscribe_keyword || item.title - const key = `${item.source}:${keyword}` - setSubscribing(key) - try { - const feed = buildSiteSearchFeedURL(keyword, item.source, buildSubscriptionAliases(item)) - const subscription = await subscriptionsAPI.create({ - name: `${item.title} 自动订阅`, - feed_url: feed, - filter: keyword, - media_type: item.media_type, - source: item.source, - poster_url: item.poster_url, - backdrop_url: item.backdrop_url, - overview: item.overview, - original_name: item.original_name, - year: item.year, - total_episodes: item.total_episodes, - enabled: true, - }) - const run = await subscriptionsAPI.runNow(subscription.id) - toast.success( - run.queued > 0 - ? `已订阅并加入 ${run.queued} 个下载` - : '已订阅,暂未在 PT 站点找到可下载资源', - ) - } catch (err) { - toast.error(apiErrorMessage(err, '订阅失败')) - } finally { - setSubscribing('') - } - } - return { aiAvailable, aiOn, @@ -158,13 +123,11 @@ export function useSearchPage() { loading, localCards, onAISubmit, - onSubscribe, q, searchTotal, setAiOn, setQ, showEmpty: !loading && !error && hasSearched && localCards.length === 0, showIdle: !loading && !error && !hasSearched, - subscribing, } } diff --git a/web/src/types/index.ts b/web/src/types/index.ts index bc8a8bd..88fcfe7 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -141,6 +141,12 @@ export interface Media { countries?: string genres?: string nsfw: boolean + strm_url?: string + file_hash?: string + file_id?: string + is_duplicate?: boolean + duplicate_of?: string + versions?: Media[] created_at: string updated_at: string }