fix media workflows and download organization

This commit is contained in:
ShukeBta
2026-06-26 08:42:16 +08:00
parent 0da96f7e4e
commit 65adca04df
102 changed files with 4063 additions and 437 deletions
+30 -4
View File
@@ -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
}
}
+1 -1
View File
@@ -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
}
+7 -1
View File
@@ -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 {
+5 -1
View File
@@ -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
+1
View File
@@ -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)
}
+83
View File
@@ -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)
+37
View File
@@ -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
}
+11
View File
@@ -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})
}
}
+73
View File
@@ -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)
}
}
+11
View File
@@ -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)
}
+38 -22
View File
@@ -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
}
+2 -1
View File
@@ -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})
}
}
+31
View File
@@ -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).
@@ -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)
@@ -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 {
@@ -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
+133 -7
View File
@@ -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
}
+14 -6
View File
@@ -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 {
+36
View File
@@ -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
}
+1 -10
View File
@@ -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),
+14 -1
View File
@@ -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) == "" {
+1 -1
View File
@@ -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)
}
+122 -3
View File
@@ -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 {
+107 -8
View File
@@ -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.
+13
View File
@@ -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"}
+171
View File
@@ -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)
+84
View File
@@ -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))
+23 -5
View File
@@ -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.
@@ -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
}
@@ -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)
}
}
+13
View File
@@ -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})
@@ -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")
@@ -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 == "" {
@@ -74,6 +74,7 @@ func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sour
}
continue
}
preferLocalizedSearchTitle(candidate, match)
if cache != nil {
cache[key] = match
}
@@ -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
@@ -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 {
@@ -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")
@@ -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) != "" ||
+261 -1
View File
@@ -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)
}
}
+16 -9
View File
@@ -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
@@ -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()
+87 -5
View File
@@ -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
+12 -1
View File
@@ -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))
}
}
}()
}
+5 -4
View File
@@ -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
+75
View File
@@ -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)
}
}
+4 -8
View File
@@ -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
+15
View File
@@ -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")
+1
View File
@@ -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
+2 -2
View File
@@ -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 (
+55
View File
@@ -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) {
+2
View File
@@ -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)
+13 -4
View File
@@ -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 {
+38
View File
@@ -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}
+41 -22
View File
@@ -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
}
+3 -3
View File
@@ -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 {
+3 -1
View File
@@ -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:
+34
View File
@@ -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())
}
}
+88 -1
View File
@@ -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
+12 -32
View File
@@ -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 {
+105
View File
@@ -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) {
+21 -1
View File
@@ -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 {
+57
View File
@@ -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 {
+13 -8
View File
@@ -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)
+24
View File
@@ -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
+61 -4
View File
@@ -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) {
+15 -3
View File
@@ -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")
}
+83 -13
View File
@@ -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)
}
+33 -8
View File
@@ -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 ""
@@ -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)
@@ -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
@@ -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)
@@ -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{
@@ -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 {
@@ -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
}
@@ -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{
@@ -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
@@ -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 ""
}
}
@@ -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)
}
}
@@ -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)
@@ -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")
}
+18 -2
View File
@@ -47,7 +47,15 @@ func (s *TelegramBotService) cmdKick(ctx context.Context, msg *TelegramMessage,
if chosen == nil {
return telegramCommandReply{Text: "未找到该设备。请用 <code>/devices</code> 查看设备编号后重试。"}
}
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("已踢下线:<b>%s</b>。", 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)
+17 -16
View File
@@ -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 {
+6 -2
View File
@@ -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
}
+28 -4
View File
@@ -17,6 +17,11 @@ export interface DiscoverItem extends Partial<Media> {
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<string, DiscoverItem[]>
meta: Record<string, DiscoverFeedMeta>
}
// 后端在 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<DiscoverFeedResult> =>
api
.get<Record<string, DiscoverItem[] | null>>('/discover/feed', {
params: { sections: sectionKeys.join(',') },
.get<Record<string, DiscoverItem[] | DiscoverFeedMeta | Record<string, DiscoverFeedMeta> | null>>('/discover/feed', {
params: { sections: sectionKeys.join(','), page },
})
.then((r) => r.data),
.then((r) => {
const raw = r.data
const meta = ((raw._meta as Record<string, DiscoverFeedMeta> | undefined) ?? {})
const items: Record<string, DiscoverItem[]> = {}
for (const key of sectionKeys) {
const row = raw[key]
items[key] = Array.isArray(row) ? row : []
}
return { items, meta }
}),
}
@@ -58,21 +58,25 @@ export function ManualScrapeSearchControls({
onEpisodeArtworkChange,
}: ManualScrapeSearchControlsProps) {
return (
<div className="flex flex-col gap-3 border-b border-sand-200 p-5 lg:flex-row">
<ProviderSelector selectedProviders={selectedProviders} onProviderChange={onProviderChange} />
<div className="grid gap-4 border-b border-sand-200 bg-sand-50/40 p-5">
<ManualScrapeQueryBar
query={query}
searching={searching}
onQueryChange={onQueryChange}
onSearch={onSearch}
/>
{showEpisodeArtworkToggle && (
<EpisodeArtworkToggle
checked={includeEpisodeArtwork}
onChange={onEpisodeArtworkChange}
title="关闭后仍写入每集简介、评分和时长,只跳过每集图片"
/>
)}
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-end">
<ProviderSelector selectedProviders={selectedProviders} onProviderChange={onProviderChange} />
{showEpisodeArtworkToggle && (
<div className="flex justify-start xl:justify-end">
<EpisodeArtworkToggle
checked={includeEpisodeArtwork}
onChange={onEpisodeArtworkChange}
title="关闭后仍写入每集简介、评分和时长,只跳过每集图片"
/>
</div>
)}
</div>
</div>
)
}
@@ -85,11 +89,12 @@ function ProviderSelector({
onProviderChange: (value: string[] | ((current: string[]) => string[])) => void
}) {
return (
<div className="flex min-w-0 flex-col gap-2 lg:max-w-md">
<div className="flex min-w-0 flex-col gap-2">
<span className="text-xs font-bold text-sand-500">刮削源</span>
<div className="flex flex-wrap gap-2">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:flex lg:flex-wrap">
<button
type="button"
aria-pressed={selectedProviders.length === 0}
onClick={() => onProviderChange([])}
className={providerButtonClass(selectedProviders.length === 0)}
>
@@ -102,6 +107,7 @@ function ProviderSelector({
<button
key={item.value}
type="button"
aria-pressed={active}
onClick={() => onProviderChange((current) => toggleProvider(current, item.value))}
className={providerButtonClass(active)}
>
@@ -127,22 +133,22 @@ function ManualScrapeQueryBar({
onSearch: () => void
}) {
return (
<>
<div className="relative flex-1">
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_7.5rem]">
<div className="relative min-w-0">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-sand-500" />
<input
value={query}
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={(event) => { if (event.key === 'Enter') onSearch() }}
className="h-11 w-full rounded-xl border border-sand-200 bg-white pl-9 pr-3 text-sm font-semibold text-ink-600 outline-none focus:border-brand-300"
className="h-11 w-full rounded-xl border border-sand-200 bg-white pl-9 pr-3 text-sm font-semibold text-ink-600 outline-none transition focus:border-brand-300 focus:ring-2 focus:ring-brand-100"
placeholder="输入标题或 TMDb / 豆瓣 / Bangumi / TheTVDB ID"
/>
</div>
<button onClick={onSearch} disabled={searching} className="btn-primary h-11 px-5">
<button type="button" onClick={onSearch} disabled={searching} className="btn-primary h-11 justify-center px-5">
{searching ? <LoaderCircle size={16} className="animate-spin" /> : <Sparkles size={16} />}
搜索
</button>
</>
</div>
)
}
@@ -193,7 +199,7 @@ function ManualScrapeCandidateRow({
onApply: (item: ManualScrapeCandidate) => void
}) {
return (
<div className="flex gap-4 rounded-xl border border-sand-200 bg-white p-3 shadow-sm">
<div className="flex flex-col gap-4 rounded-xl border border-sand-200 bg-white p-3 shadow-sm sm:flex-row">
<div className="h-28 w-20 shrink-0 overflow-hidden rounded-lg bg-sand-100">
{item.poster_url ? (
<img src={imageURL(item.poster_url)} alt={item.title} className="h-full w-full object-cover" referrerPolicy="no-referrer" />
@@ -211,7 +217,7 @@ function ManualScrapeCandidateRow({
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-ink-50">{item.overview || '暂无简介'}</p>
<p className="mt-2 text-[11px] font-semibold text-sand-500">{candidateIDText(item)}</p>
</div>
<button onClick={() => onApply(item)} disabled={disabled} className="btn-outline h-10 shrink-0 self-center px-3 text-xs">
<button onClick={() => onApply(item)} disabled={disabled} className="btn-outline h-10 w-full shrink-0 justify-center px-3 text-xs sm:w-auto sm:self-center">
{applying ? <LoaderCircle size={14} className="animate-spin" /> : <Check size={14} />}
应用匹配
</button>
@@ -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')
+8
View File
@@ -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 = ({
</span>
)}
{count === undefined && versionCount > 1 && (
<span className="absolute right-3 top-3 inline-flex items-center gap-1 rounded-xl border border-white/15 bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-white shadow-sm">
<Layers size={10} className="text-[#c9954a]" />
<span>{versionCount} 版本</span>
</span>
)}
{/* Rating Badge */}
{displayRating > 0 && (
<span className="absolute left-3 top-3 inline-flex items-center gap-0.5 rounded-xl border border-white/15 bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-[#c9954a] shadow-sm">
+51 -81
View File
@@ -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<ExternalMediaResult | null>(null)
if (items.length === 0) return null
return (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{items.map((item) => {
const keyword = item.subscribe_keyword || item.title
const key = `${item.source}:${keyword}`
return (
<article key={key} className="rounded-2xl border border-gray-200 bg-gray-50 p-3">
<div className="flex gap-3">
<div className="h-24 w-16 shrink-0 overflow-hidden rounded-xl bg-white">
{item.poster_url ? (
<img
src={imageURL(item.poster_url)}
alt={item.title}
className="h-full w-full object-cover"
/>
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 flex flex-wrap gap-2 text-[10px] uppercase text-brand-500">
<span>{item.source}</span>
{item.media_type && <span>{item.media_type}</span>}
{item.year ? <span>{item.year}</span> : null}
<>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{items.map((item) => {
const keyword = item.subscribe_keyword || item.title
const key = `${item.source}:${keyword}`
return (
<article
key={key}
role="button"
tabIndex={0}
onClick={() => 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"
>
<div className="flex gap-3">
<div className="h-24 w-16 shrink-0 overflow-hidden rounded-xl bg-white">
{item.poster_url ? (
<img
src={imageURL(item.poster_url)}
alt={item.title}
className="h-full w-full object-cover"
/>
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 flex flex-wrap gap-2 text-[10px] uppercase text-brand-500">
<span>{item.source}</span>
{item.media_type && <span>{item.media_type}</span>}
{item.year ? <span>{item.year}</span> : null}
</div>
<h3 className="truncate font-semibold text-ink-600">{item.title}</h3>
<p className="mt-1 line-clamp-2 text-xs text-ink-50">
{item.overview || `订阅关键词:${keyword}`}
</p>
<p className="mt-2 text-xs font-semibold text-brand-500">详情 / 订阅设置</p>
</div>
<h3 className="truncate font-semibold text-ink-600">{item.title}</h3>
<p className="mt-1 line-clamp-2 text-xs text-ink-50">
{item.overview || `订阅关键词:${keyword}`}
</p>
<button
disabled={subscribing === key}
onClick={() => subscribeExternalItem(item, key, keyword, setSubscribing)}
className="mt-2 rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10 disabled:opacity-50"
>
<Rss size={12} className="mr-1 inline" />
{subscribing === key ? '订阅中…' : '订阅并搜索 PT'}
</button>
</div>
</div>
</article>
)
})}
</div>
</article>
)
})}
</div>
{activeItem && (
<DiscoverDetailModal
item={activeItem as unknown as DiscoverItem}
onClose={() => 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('')
}
}
+2 -2
View File
@@ -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 (
<div className="mt-2 rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] p-3">
+33 -2
View File
@@ -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 (
<section className="space-y-4">
<h2 className="pl-1 font-display text-2xl font-semibold text-ink-600">{title}</h2>
<div className="flex items-center justify-between gap-3">
<h2 className="pl-1 font-display text-2xl font-semibold text-ink-600">{title}</h2>
{onPageChange && (
<div className="flex items-center gap-2">
<button
type="button"
aria-label={`${title} 上一页`}
disabled={page <= 1}
onClick={() => onPageChange(-1)}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-gray-200 bg-white text-ink-100 transition hover:border-primary-300 hover:text-brand-500 disabled:cursor-not-allowed disabled:opacity-40"
>
<ChevronLeft size={16} />
</button>
<span className="min-w-10 text-center text-xs font-semibold text-sand-500">第 {page} 页</span>
<button
type="button"
aria-label={`${title} 下一页`}
disabled={!canNext}
onClick={() => onPageChange(1)}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-gray-200 bg-white text-ink-100 transition hover:border-primary-300 hover:text-brand-500 disabled:cursor-not-allowed disabled:opacity-40"
>
<ChevronRight size={16} />
</button>
</div>
)}
</div>
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
{items.map((item, index) => (
<DiscoverCard
+24 -4
View File
@@ -15,6 +15,8 @@ export function DiscoverPage() {
const [sections, setSections] = useState<DiscoverSection[]>([])
const [selected, setSelected] = useState<string[]>([])
const [rows, setRows] = useState<Record<string, DiscoverItem[]>>({})
const [rowPages, setRowPages] = useState<Record<string, number>>({})
const [rowCanNext, setRowCanNext] = useState<Record<string, boolean>>({})
const [rowLoading, setRowLoading] = useState<Record<string, boolean>>({})
const [rowErrors, setRowErrors] = useState<Record<string, string>>({})
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}
/>
)}
+9
View File
@@ -84,22 +84,28 @@ export function DiscoverResults({
rows,
rowLoading,
rowErrors,
rowPages,
rowCanNext,
loading,
hasContent,
imageVersion,
refreshImageVersion,
sectionLabel,
onPageChange,
onSelect,
}: {
selected: string[]
rows: Record<string, DiscoverItem[]>
rowLoading: Record<string, boolean>
rowErrors: Record<string, string>
rowPages: Record<string, number>
rowCanNext: Record<string, boolean>
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}
/>
)
+42 -25
View File
@@ -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<void>
}) {
const [detail, setDetail] = useState<ExternalMediaResult | null>(null)
return (
@@ -72,13 +77,12 @@ export function ExternalResults({
<button
onClick={(event) => {
event.stopPropagation()
onSubscribe(item)
setDetail(item)
}}
disabled={busyKey === key}
className="mt-3 rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10 disabled:opacity-50"
>
<Rss size={12} className="mr-1 inline" />
{busyKey === key ? '订阅中…' : '订阅并搜索 PT'}
订阅规则
</button>
</div>
</article>
@@ -88,9 +92,7 @@ export function ExternalResults({
{detail && (
<ExternalDetailModal
item={detail}
busy={busyKey === `${detail.source}:${detail.subscribe_keyword || detail.title}`}
onClose={() => setDetail(null)}
onSubscribe={onSubscribe}
/>
)}
</section>
@@ -99,19 +101,37 @@ export function ExternalResults({
function ExternalDetailModal({
item,
busy,
onClose,
onSubscribe,
}: {
item: ExternalMediaResult
busy: boolean
onClose: () => void
onSubscribe: (item: ExternalMediaResult) => Promise<void>
}) {
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm" onClick={onClose}>
<div className="max-h-[88vh] w-full max-w-3xl overflow-hidden rounded-3xl bg-white shadow-2xl" onClick={(event) => event.stopPropagation()}>
<div className="max-h-[88vh] w-full max-w-3xl overflow-y-auto rounded-3xl bg-white shadow-2xl" onClick={(event) => event.stopPropagation()}>
<div className="grid gap-0 md:grid-cols-[220px,1fr]">
<div className="min-h-72 bg-gray-100">
{item.poster_url ? (
@@ -158,18 +178,15 @@ function ExternalDetailModal({
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<DiscoverSubscriptionRules
form={form}
busy={formBusy}
onChange={(patch) => setForm((current) => ({ ...current, ...patch }))}
onSubmit={submit}
/>
<div className="flex justify-end pt-2">
<button onClick={onClose} className="rounded-xl border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50">关闭</button>
<button
disabled={busy}
onClick={async () => {
await onSubscribe(item)
onClose()
}}
className="neon-button"
>
<Rss size={14} /> {busy ? '订阅中…' : item.in_library ? '补全缺失集' : '订阅全集'}
</button>
</div>
</div>
</div>
-2
View File
@@ -47,8 +47,6 @@ export function SearchPage() {
{search.externalItems.length > 0 && (
<ExternalResults
items={search.externalItems}
busyKey={search.subscribing}
onSubscribe={search.onSubscribe}
/>
)}
</div>
+17 -3
View File
@@ -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) && <CloudBrowser type={type} />}
{isCloud(type) && <CloudBrowser key={`${type}-${browserVersion}-${enabled ? 'on' : 'off'}`} type={type} enabled={enabled} />}
</form>
)
}
+1 -1
View File
@@ -129,7 +129,7 @@ export function SubscriptionsPage() {
<div className="space-y-6">
<h1 className="font-display text-3xl font-bold text-ink-600">RSS 订阅</h1>
<p className="text-sm text-ink-50">
定期轮询 RSS 源(每 10 分钟一次),将匹配过滤器的项目自动加入下载队列;启用智能分类后会按二级分类写入下载目录。
后台统一每 3 小时轮询 RSS / 站点搜索订阅;手动立即执行不受影响。匹配过滤器的项目会自动加入下载队列,启用智能分类后按二级分类写入下载目录。
</p>
<SubscriptionForm
@@ -75,6 +75,7 @@ export function buildDiscoverSubscriptionInput(
overview: item.overview || undefined,
original_name: item.original_name || undefined,
year: item.year || undefined,
total_episodes: item.total_episodes || undefined,
resolution: form.resolution === 'best' ? 'best' : form.resolution,
quality: form.quality || undefined,
effects: form.effects || undefined,
@@ -0,0 +1,16 @@
import type { SettingGroup } from './settingsGroupTypes'
export const subscriptionSettingsGroup: SettingGroup = {
key: 'subscriptions',
label: '订阅任务',
description: '控制 RSS / 站点搜索订阅的唯一后台轮询频率。手动“立即执行”不受这里限制。',
items: [
{
key: 'subscription.interval_seconds',
label: '订阅自动同步间隔秒数',
type: 'number',
hint: '默认 10800 秒,最小 10800 秒。后台自动刷新固定保守节奏,避免馒头等站点 API 超限。',
defaultValue: '10800',
},
],
}
+2
View File
@@ -1,6 +1,7 @@
import { adultSettingsGroup } from './settingsGroupAccess'
import { cloudUploadSettingsGroup } from './settingsGroupCloud'
import { generalSettingsGroup, licenseSettingsGroup } from './settingsGroupGeneral'
import { subscriptionSettingsGroup } from './settingsGroupSubscriptions'
import type { SettingGroup } from './settingsGroupTypes'
export type { SettingGroup } from './settingsGroupTypes'
@@ -8,6 +9,7 @@ export type { SettingGroup } from './settingsGroupTypes'
export const GROUPS: SettingGroup[] = [
generalSettingsGroup,
licenseSettingsGroup,
subscriptionSettingsGroup,
cloudUploadSettingsGroup,
adultSettingsGroup,
]
+8 -2
View File
@@ -13,7 +13,7 @@ import { useCloudBrowserFileActions } from './useCloudBrowserFileActions'
import { useCloudBrowserMounts } from './useCloudBrowserMounts'
import { useCloudBrowserScan } from './useCloudBrowserScan'
export function useCloudBrowser(type: StorageType) {
export function useCloudBrowser(type: StorageType, enabled = true) {
const [stack, setStack] = useState<{ id: string; name: string }[]>([{ id: '', name: '根目录' }])
const [items, setItems] = useState<CloudEntry[]>([])
const [mounts, setMounts] = useState<Library[]>([])
@@ -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 })
+1 -1
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More