mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
fix media workflows and download organization
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,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})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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) == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) != "" ||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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,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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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('')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -47,8 +47,6 @@ export function SearchPage() {
|
||||
{search.externalItems.length > 0 && (
|
||||
<ExternalResults
|
||||
items={search.externalItems}
|
||||
busyKey={search.subscribing}
|
||||
onSubscribe={search.onSubscribe}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user