fix media organize and subscription workflows

This commit is contained in:
ShukeBta
2026-06-30 20:18:02 +08:00
parent b41f62d572
commit 941bb273b9
107 changed files with 5420 additions and 526 deletions
+143 -6
View File
@@ -8,11 +8,14 @@ package handler
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
@@ -38,6 +41,9 @@ var discoverSectionCatalog = []discoverSectionDef{
{Key: "bangumi_calendar", Label: "Bangumi 每日放送", Provider: "bangumi"},
}
const discoverFeedSectionTimeout = 15 * time.Second
const discoverFeedSlowSectionThreshold = 2 * time.Second
// discoverSectionsHandler returns the catalog of sections the UI can
// pick from. The names match the upstream Vue UI so existing settings
// keep working.
@@ -76,24 +82,155 @@ 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}
meta[k] = gin.H{"page": page, "has_next": false, "disabled": true}
continue
}
items, err := discoverSectionItems(c.Request.Context(), svc, k, page)
sectionCtx, cancel := context.WithTimeout(c.Request.Context(), discoverFeedSectionTimeout)
started := time.Now()
items, err := discoverSectionItems(sectionCtx, svc, k, page)
elapsed := time.Since(started)
cancel()
metaEntry := gin.H{"page": page, "has_next": false, "duration_ms": elapsed.Milliseconds()}
if err != nil {
svc.Log.Debug("discover fetch failed")
items = nil
logDiscoverFetchFailed(svc, k, page, elapsed, err)
if cached, ok := cachedDiscoverSection(svc, k, page); ok {
items = cached
metaEntry["stale"] = true
metaEntry["warning"] = discoverFeedStaleMessage(err)
} else if fallbackItems, fallbackKey, ok := fallbackDiscoverSectionItems(c.Request.Context(), svc, k, page); ok {
items = fallbackItems
metaEntry["fallback"] = fallbackKey
metaEntry["warning"] = discoverFeedFallbackMessage(fallbackKey, err)
rememberDiscoverSection(svc, k, page, items)
} else {
metaEntry["error"] = discoverFeedErrorMessage(err)
items = nil
}
} else {
logDiscoverFetchSlow(svc, k, page, elapsed, len(items))
rememberDiscoverSection(svc, k, page, items)
}
artworkItems = append(artworkItems, items...)
out[k] = items
meta[k] = gin.H{"page": page, "has_next": discoverSectionHasNext(k, len(items))}
metaEntry["has_next"] = discoverSectionHasNext(k, len(items))
meta[k] = metaEntry
}
out["_meta"] = meta
svc.Discover.WarmExternalArtwork(artworkItems)
if svc != nil && svc.Discover != nil {
svc.Discover.WarmExternalArtwork(artworkItems)
}
c.JSON(http.StatusOK, out)
}
}
func cachedDiscoverSection(svc *service.Container, key string, page int) ([]service.ExternalMediaResult, bool) {
if svc == nil || svc.Discover == nil {
return nil, false
}
return svc.Discover.CachedSection(key, page)
}
func rememberDiscoverSection(svc *service.Container, key string, page int, items []service.ExternalMediaResult) {
if svc == nil || svc.Discover == nil {
return
}
svc.Discover.RememberSection(key, page, items)
}
func fallbackDiscoverSectionItems(parent context.Context, svc *service.Container, key string, page int) ([]service.ExternalMediaResult, string, bool) {
fallbackKey := fallbackDiscoverSectionKey(key)
if fallbackKey == "" || svc == nil || svc.Discover == nil {
return nil, "", false
}
ctx, cancel := context.WithTimeout(parent, discoverFeedSectionTimeout)
defer cancel()
items, err := discoverSectionItems(ctx, svc, fallbackKey, page)
if err != nil || len(items) == 0 {
return nil, fallbackKey, false
}
if svc.Log != nil {
svc.Log.Info("discover section fallback used",
zap.String("section", key),
zap.String("fallback_section", fallbackKey),
zap.Int("page", page),
zap.Int("items", len(items)))
}
return items, fallbackKey, true
}
func fallbackDiscoverSectionKey(key string) string {
switch key {
case "douban_hot_movie":
return "tmdb_popular_movie"
case "douban_hot_tv":
return "tmdb_popular_tv"
case "douban_top_movie":
return "tmdb_top_rated_movie"
default:
return ""
}
}
func logDiscoverFetchFailed(svc *service.Container, key string, page int, elapsed time.Duration, err error) {
if svc == nil || svc.Log == nil || err == nil {
return
}
svc.Log.Warn("discover section fetch failed",
zap.String("section", key),
zap.String("provider", discoverSectionProvider(key)),
zap.Int("page", page),
zap.Duration("duration", elapsed),
zap.Int64("duration_ms", elapsed.Milliseconds()),
zap.Duration("timeout", discoverFeedSectionTimeout),
zap.Error(err))
}
func logDiscoverFetchSlow(svc *service.Container, key string, page int, elapsed time.Duration, itemCount int) {
if svc == nil || svc.Log == nil || elapsed < discoverFeedSlowSectionThreshold {
return
}
svc.Log.Info("discover section fetch slow",
zap.String("section", key),
zap.String("provider", discoverSectionProvider(key)),
zap.Int("page", page),
zap.Int("items", itemCount),
zap.Duration("duration", elapsed),
zap.Int64("duration_ms", elapsed.Milliseconds()),
zap.Duration("slow_threshold", discoverFeedSlowSectionThreshold))
}
func discoverFeedErrorMessage(err error) string {
if err == nil {
return ""
}
if errors.Is(err, context.DeadlineExceeded) {
return "推荐源响应超时,已跳过本次加载"
}
var timeout interface{ Timeout() bool }
if errors.As(err, &timeout) && timeout.Timeout() {
return "推荐源响应超时,已跳过本次加载"
}
msg := strings.ToLower(err.Error())
if strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline exceeded") || strings.Contains(msg, "context deadline exceeded") {
return "推荐源响应超时,已跳过本次加载"
}
return "推荐源暂时不可用,已跳过本次加载"
}
func discoverFeedStaleMessage(err error) string {
if discoverFeedErrorMessage(err) == "推荐源响应超时,已跳过本次加载" {
return "推荐源响应超时,已显示上次成功结果"
}
return "推荐源暂时不可用,已显示上次成功结果"
}
func discoverFeedFallbackMessage(fallbackKey string, err error) string {
if strings.TrimSpace(fallbackKey) == "" {
return discoverFeedErrorMessage(err)
}
return "推荐源暂时不可用,已显示同类备用榜单"
}
func enabledDiscoverSections(ctx context.Context, svc *service.Container) []discoverSectionDef {
sections := make([]discoverSectionDef, 0, len(discoverSectionCatalog))
for _, section := range discoverSectionCatalog {
+93
View File
@@ -1,10 +1,14 @@
package handler
import (
"context"
"errors"
"testing"
"time"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/model"
@@ -36,6 +40,81 @@ func TestDiscoverProviderEnabledHonorsAPIConfigToggle(t *testing.T) {
}
}
func TestDiscoverFetchFailureLogIncludesDiagnostics(t *testing.T) {
core, observed := observer.New(zap.WarnLevel)
logger := zap.New(core)
logDiscoverFetchFailed(
&service.Container{Log: logger},
"tmdb_latest_movie",
2,
1500*time.Millisecond,
context.DeadlineExceeded,
)
entries := observed.FilterMessage("discover section fetch failed").All()
if len(entries) != 1 {
t.Fatalf("expected one failure log entry, got %d", len(entries))
}
fields := entries[0].ContextMap()
if fields["section"] != "tmdb_latest_movie" || fields["provider"] != "tmdb" {
t.Fatalf("unexpected section/provider fields: %#v", fields)
}
if fields["page"] != int64(2) && fields["page"] != 2 {
t.Fatalf("page field missing or wrong: %#v", fields["page"])
}
if fields["duration_ms"] != int64(1500) && fields["duration_ms"] != 1500 {
t.Fatalf("duration_ms field missing or wrong: %#v", fields["duration_ms"])
}
if _, ok := fields["timeout"]; !ok {
t.Fatalf("timeout field missing: %#v", fields)
}
}
func TestDiscoverSlowFetchLogIncludesSectionTiming(t *testing.T) {
core, observed := observer.New(zap.InfoLevel)
logger := zap.New(core)
logDiscoverFetchSlow(&service.Container{Log: logger}, "douban_hot_movie", 1, discoverFeedSlowSectionThreshold-time.Millisecond, 24)
if got := observed.FilterMessage("discover section fetch slow").Len(); got != 0 {
t.Fatalf("fast section should not log, got %d entries", got)
}
logDiscoverFetchSlow(&service.Container{Log: logger}, "douban_hot_movie", 1, discoverFeedSlowSectionThreshold, 24)
entries := observed.FilterMessage("discover section fetch slow").All()
if len(entries) != 1 {
t.Fatalf("expected one slow log entry, got %d", len(entries))
}
fields := entries[0].ContextMap()
if fields["section"] != "douban_hot_movie" || fields["provider"] != "douban" {
t.Fatalf("unexpected section/provider fields: %#v", fields)
}
if fields["items"] != int64(24) && fields["items"] != 24 {
t.Fatalf("items field missing or wrong: %#v", fields["items"])
}
if _, ok := fields["duration_ms"]; !ok {
t.Fatalf("duration_ms field missing: %#v", fields)
}
if _, ok := fields["slow_threshold"]; !ok {
t.Fatalf("slow_threshold field missing: %#v", fields)
}
}
func TestDiscoverFeedErrorMessageHidesTechnicalTimeout(t *testing.T) {
for _, err := range []error{
context.DeadlineExceeded,
errors.New("timeout of 30000ms exceeded"),
} {
got := discoverFeedErrorMessage(err)
if got != "推荐源响应超时,已跳过本次加载" {
t.Fatalf("message for %q = %q", err, got)
}
}
if got := discoverFeedErrorMessage(errors.New("upstream 503")); got != "推荐源暂时不可用,已跳过本次加载" {
t.Fatalf("generic message = %q", got)
}
}
func TestDefaultDiscoverSectionKeysSkipDisabledProviders(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
@@ -78,3 +157,17 @@ func TestDefaultDiscoverSectionKeysIncludeLatestTMDbRails(t *testing.T) {
}
}
}
func TestFallbackDiscoverSectionKeyUsesTMDbForDoubanRails(t *testing.T) {
cases := map[string]string{
"douban_hot_movie": "tmdb_popular_movie",
"douban_hot_tv": "tmdb_popular_tv",
"douban_top_movie": "tmdb_top_rated_movie",
"tmdb_latest_tv": "",
}
for key, want := range cases {
if got := fallbackDiscoverSectionKey(key); got != want {
t.Fatalf("fallbackDiscoverSectionKey(%q) = %q, want %q", key, got, want)
}
}
}
@@ -54,6 +54,7 @@ func registerAuthedSTRMRoutes(authed *gin.RouterGroup, svc *service.Container) {
authed.DELETE("/media/:id/strm", middleware.AdminRequired(), clearSTRMHandler(svc))
authed.POST("/strm/import", middleware.AdminRequired(), importSTRMHandler(svc))
authed.POST("/strm/generate", middleware.AdminRequired(), generateSTRMHandler(svc))
authed.POST("/strm/generate-from-tree", middleware.AdminRequired(), generateSTRMFromTreeHandler(svc))
}
func registerAuthedDuplicateRoutes(authed *gin.RouterGroup, svc *service.Container) {
+54 -2
View File
@@ -104,7 +104,20 @@ type generateSTRMReq struct {
BaseURL string `json:"base_url"`
Enabled bool `json:"enabled"`
Overwrite bool `json:"overwrite"`
IncludeLocal bool `json:"include_local"`
IncludeLocal *bool `json:"include_local"`
PreserveTree bool `json:"preserve_tree"`
}
type generateSTRMTreeReq struct {
Provider string `json:"provider"`
TreeText string `json:"tree_text"`
Paths []string `json:"paths"`
SourceRoot string `json:"source_root"`
OutputPrefix string `json:"output_prefix"`
OutputDir string `json:"output_dir"`
BaseURL string `json:"base_url"`
Overwrite bool `json:"overwrite"`
Cleanup bool `json:"cleanup"`
}
func generateSTRMHandler(svc *service.Container) gin.HandlerFunc {
@@ -122,13 +135,18 @@ func generateSTRMHandler(svc *service.Container) gin.HandlerFunc {
if baseURL == "" {
baseURL = strings.TrimRight(absoluteRequestURL(c, "/"), "/")
}
includeLocal := true
if req.IncludeLocal != nil {
includeLocal = *req.IncludeLocal
}
options := service.GenerateSTRMOptions{
LibraryID: req.LibraryID,
OutputDir: req.OutputDir,
BaseURL: baseURL,
Enabled: req.Enabled,
Overwrite: req.Overwrite,
IncludeLocal: true,
IncludeLocal: includeLocal,
PreserveTree: req.PreserveTree,
PlaybackToken: strmPlaybackTokenForRequest(c, svc),
}
var res *service.GenerateSTRMResult
@@ -146,6 +164,40 @@ func generateSTRMHandler(svc *service.Container) gin.HandlerFunc {
}
}
func generateSTRMFromTreeHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req generateSTRMTreeReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
strmSvc := svc.STRM
if strmSvc == nil {
strmSvc = service.NewSTRMService(svc.Log, svc.Repo, svc.Cfg)
}
baseURL := strings.TrimRight(strings.TrimSpace(req.BaseURL), "/")
if baseURL == "" {
baseURL = strings.TrimRight(absoluteRequestURL(c, "/"), "/")
}
res, err := strmSvc.GenerateFromTree(c.Request.Context(), service.GenerateSTRMTreeOptions{
Provider: req.Provider,
TreeText: req.TreeText,
Paths: req.Paths,
SourceRoot: req.SourceRoot,
OutputPrefix: req.OutputPrefix,
OutputDir: req.OutputDir,
BaseURL: baseURL,
Overwrite: req.Overwrite,
Cleanup: req.Cleanup,
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, res)
}
}
func strmPlaybackTokenForRequest(c *gin.Context, svc *service.Container) string {
if svc == nil || svc.Auth == nil || svc.Repo == nil || svc.Repo.User == nil {
return ""
+20
View File
@@ -33,6 +33,11 @@ type subscriptionPatchReq struct {
Effects *string `json:"effects"`
ReleaseGroups *string `json:"release_groups"`
ExcludeWords *string `json:"exclude_words"`
MinSeeders *int `json:"min_seeders"`
MaxSeeders *int `json:"max_seeders"`
MinSizeGB *float64 `json:"min_size_gb"`
MaxSizeGB *float64 `json:"max_size_gb"`
FreeOnly *bool `json:"free_only"`
WashEnabled *bool `json:"wash_enabled"`
WashPriority *string `json:"wash_priority"`
TotalEpisodes *int `json:"total_episodes"`
@@ -137,6 +142,21 @@ func subscriptionPatchUpdates(patch subscriptionPatchReq) map[string]any {
if patch.ExcludeWords != nil {
updates["exclude_words"] = *patch.ExcludeWords
}
if patch.MinSeeders != nil {
updates["min_seeders"] = *patch.MinSeeders
}
if patch.MaxSeeders != nil {
updates["max_seeders"] = *patch.MaxSeeders
}
if patch.MinSizeGB != nil {
updates["min_size_gb"] = *patch.MinSizeGB
}
if patch.MaxSizeGB != nil {
updates["max_size_gb"] = *patch.MaxSizeGB
}
if patch.FreeOnly != nil {
updates["free_only"] = *patch.FreeOnly
}
if patch.WashEnabled != nil {
updates["wash_enabled"] = *patch.WashEnabled
}
+34 -24
View File
@@ -14,30 +14,35 @@ import (
)
type subscriptionReq struct {
Name string `json:"name" binding:"required"`
FeedURL string `json:"feed_url" binding:"required"`
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"`
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" binding:"required"`
FeedURL string `json:"feed_url" binding:"required"`
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"`
Resolution string `json:"resolution"`
Quality string `json:"quality"`
Effects string `json:"effects"`
ReleaseGroups string `json:"release_groups"`
ExcludeWords string `json:"exclude_words"`
MinSeeders int `json:"min_seeders"`
MaxSeeders int `json:"max_seeders"`
MinSizeGB float64 `json:"min_size_gb"`
MaxSizeGB float64 `json:"max_size_gb"`
FreeOnly bool `json:"free_only"`
WashEnabled bool `json:"wash_enabled"`
WashPriority string `json:"wash_priority"`
TotalEpisodes int `json:"total_episodes"`
Priority int `json:"priority"`
Enabled *bool `json:"enabled"`
}
func createSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
@@ -73,6 +78,11 @@ func createSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
Effects: req.Effects,
ReleaseGroups: req.ReleaseGroups,
ExcludeWords: req.ExcludeWords,
MinSeeders: req.MinSeeders,
MaxSeeders: req.MaxSeeders,
MinSizeGB: req.MinSizeGB,
MaxSizeGB: req.MaxSizeGB,
FreeOnly: req.FreeOnly,
WashEnabled: req.WashEnabled,
WashPriority: req.WashPriority,
TotalEpisodes: req.TotalEpisodes,
+5
View File
@@ -57,6 +57,11 @@ type Subscription struct {
Effects string `gorm:"size:128" json:"effects,omitempty"` // hdr,dolby-vision,atmos
ReleaseGroups string `gorm:"size:255" json:"release_groups,omitempty"` // comma separated
ExcludeWords string `gorm:"size:255" json:"exclude_words,omitempty"` // comma separated
MinSeeders int `gorm:"default:0" json:"min_seeders,omitempty"`
MaxSeeders int `gorm:"default:0" json:"max_seeders,omitempty"`
MinSizeGB float64 `gorm:"default:0" json:"min_size_gb,omitempty"`
MaxSizeGB float64 `gorm:"default:0" json:"max_size_gb,omitempty"`
FreeOnly bool `gorm:"default:false" json:"free_only,omitempty"`
WashEnabled bool `gorm:"default:false" json:"wash_enabled"`
WashPriority string `gorm:"size:32" json:"wash_priority,omitempty"` // balanced / resolution / quality / effects / seeders
TotalEpisodes int `gorm:"default:0" json:"total_episodes,omitempty"`
@@ -240,6 +240,7 @@ func (r *MediaRepository) applyMediaUpsertUpdates(ctx context.Context, m *model.
// 回写 ID / 不可变字段,让 caller 拿到完整的现有行。
*m = existing
if fresh, err := r.FindByID(ctx, existing.ID); err == nil && fresh != nil {
*m = *fresh
r.indexMediaBestEffort(ctx, *fresh)
}
return nil
+73
View File
@@ -186,6 +186,79 @@ func TestMediaUpsertMatchedIncomingRefreshesScrapedMetadata(t *testing.T) {
}
}
func TestMediaUpsertScanDoesNotClearMatchedMetadata(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := New(db)
lib := model.Library{Name: "剧集", Path: "/media/tv", Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
path := "/media/tv/间谍过家家/Season 01/间谍过家家 - S01E01.mkv"
existing := model.Media{
LibraryID: lib.ID,
Title: "间谍过家家",
OriginalName: "SPY×FAMILY",
Path: path,
PosterURL: "/poster.jpg",
BackdropURL: "/backdrop.jpg",
Overview: "剧情简介",
Year: 2022,
SeasonNum: 1,
EpisodeNum: 1,
ScrapeStatus: "matched",
TMDbID: 12345,
BangumiID: 67890,
DoubanID: "db-spy",
TheTVDBID: "tvdb-spy",
}
if err := repos.Media.Upsert(t.Context(), &existing); err != nil {
t.Fatal(err)
}
scan := model.Media{
LibraryID: lib.ID,
Title: "Spy.x.Family.S01E01.2022.1080p.WEB-DL",
Path: path,
SizeBytes: 2048,
DurationSec: 1500,
Width: 1920,
Height: 1080,
VideoCodec: "h264",
AudioCodec: "aac",
Container: "mkv",
SeasonNum: 1,
EpisodeNum: 1,
}
if err := repos.Media.Upsert(t.Context(), &scan); err != nil {
t.Fatal(err)
}
var got model.Media
if err := repos.DB.Where("path = ?", path).First(&got).Error; err != nil {
t.Fatal(err)
}
if got.Title != "间谍过家家" || got.OriginalName != "SPY×FAMILY" || got.ScrapeStatus != "matched" {
t.Fatalf("matched names/status were overwritten by scan: %#v", got)
}
if got.TMDbID != 12345 || got.BangumiID != 67890 || got.DoubanID != "db-spy" || got.TheTVDBID != "tvdb-spy" {
t.Fatalf("matched provider ids were cleared by scan: %#v", got)
}
if got.PosterURL != "/poster.jpg" || got.BackdropURL != "/backdrop.jpg" || got.Overview != "剧情简介" {
t.Fatalf("matched artwork/overview were overwritten by scan: %#v", got)
}
if got.SizeBytes != 2048 || got.DurationSec != 1500 || got.Width != 1920 || got.Height != 1080 || got.Container != "mkv" {
t.Fatalf("file scan fields were not refreshed: %#v", got)
}
if scan.ID != got.ID || scan.Title != got.Title || scan.TMDbID != got.TMDbID || scan.ScrapeStatus != "matched" {
t.Fatalf("upsert caller did not receive fresh matched row: %#v want %#v", scan, got)
}
}
// TestMediaUpsertMigratesCloudLibraryIDOnRescan 复现"一键挂载子目录后媒体消失"的
// 回归:同一 cloud:// 文件先被父目录库扫描入库,之后用户按二级分类重新挂载到更
// 精确的分类库并扫描,library_id 必须迁移到新分类库,否则媒体被钉死在旧库、新库
+3 -3
View File
@@ -124,11 +124,11 @@ func canonicalLibraryCategoryName(libraryType, name string) string {
}
case "tvshows":
switch name {
case "国剧", "大陆剧", "国产电视剧", "华语剧":
case "国剧", "大陆剧", "华语剧", "国产电视剧", "大陆电视剧", "华语电视剧", "港剧", "台剧", "港台剧":
return "国产剧"
case "欧美电视剧", "美剧", "英剧", "未分类", "uncategorized":
return "欧美剧"
case "日剧", "韩剧":
case "日韩电视剧", "日剧", "韩剧", "泰剧":
return "日韩剧"
case "真人秀":
return "综艺"
@@ -222,7 +222,7 @@ func canonicalLibraryCategoryDisplayType(name string) string {
switch normalizeLibraryMergeName(name) {
case "演唱会", "音乐会", "动画电影", "动漫电影", "华语电影", "国产电影", "大陆电影", "欧美电影", "外语电影", "外国电影", "日韩电影", "日本电影", "韩国电影":
return "movie"
case "国产剧", "国剧", "大陆剧", "国产电视剧", "华语剧", "欧美剧", "欧美电视剧", "美剧", "英剧", "未分类", "uncategorized", "日韩剧", "日剧", "韩剧", "综艺", "真人秀", "儿童", "少儿":
case "国产剧", "国剧", "大陆剧", "华语剧", "国产电视剧", "大陆电视剧", "华语电视剧", "港剧", "台剧", "港台剧", "欧美剧", "欧美电视剧", "美剧", "英剧", "未分类", "uncategorized", "日韩剧", "日韩电视剧", "日剧", "韩剧", "泰剧", "综艺", "真人秀", "儿童", "少儿":
return "tv"
case "国漫", "国产动漫", "国产动画", "日番", "日漫", "番剧", "日本动漫", "日本动画", "韩漫", "韩国动漫", "韩国动画", "美漫", "欧美动漫", "欧美动画", "西方动画", "其他", "其他动漫", "其它动漫", "other":
return "anime"
+3 -3
View File
@@ -66,11 +66,11 @@ func cloudLibraryMergeNameKey(typeKey, name string) string {
}
case "tvshows":
switch name {
case "国产剧", "大陆剧", "华语剧", "国剧":
case "国产剧", "大陆剧", "华语剧", "国剧", "国产电视剧", "大陆电视剧", "华语电视剧", "港剧", "台剧", "港台剧":
return "国产剧"
case "欧美剧", "美剧", "英剧":
case "欧美剧", "欧美电视剧", "美剧", "英剧":
return "欧美剧"
case "日韩剧", "日剧", "韩剧":
case "日韩剧", "日韩电视剧", "日剧", "韩剧", "泰剧":
return "日韩剧"
case "国漫", "国产动漫", "国产动画":
return "国漫"
+9 -7
View File
@@ -21,18 +21,20 @@ import (
// DiscoverService talks to TMDb's /trending and /movie/popular endpoints.
type DiscoverService struct {
log *zap.Logger
tmdb *TMDbProvider
client *http.Client
images *ImageProxy
log *zap.Logger
tmdb *TMDbProvider
client *http.Client
images *ImageProxy
sectionCache *DiscoverSectionCache
}
// NewDiscoverService is the constructor.
func NewDiscoverService(log *zap.Logger, tmdb *TMDbProvider) *DiscoverService {
return &DiscoverService{
log: log,
tmdb: tmdb,
client: NewExternalHTTPClient(15 * time.Second),
log: log,
tmdb: tmdb,
client: NewExternalHTTPClient(15 * time.Second),
sectionCache: NewDiscoverSectionCache(6 * time.Hour),
}
}
+104
View File
@@ -0,0 +1,104 @@
package service
import (
"fmt"
"sync"
"time"
)
// DiscoverSectionCache keeps the last good discover rail in memory so a slow
// upstream provider does not turn a populated page into empty rows.
type DiscoverSectionCache struct {
ttl time.Duration
mu sync.RWMutex
entries map[string]discoverSectionCacheEntry
}
type discoverSectionCacheEntry struct {
items []ExternalMediaResult
storedAt time.Time
}
func NewDiscoverSectionCache(ttl time.Duration) *DiscoverSectionCache {
if ttl <= 0 {
ttl = 6 * time.Hour
}
return &DiscoverSectionCache{
ttl: ttl,
entries: map[string]discoverSectionCacheEntry{},
}
}
func (d *DiscoverService) RememberSection(key string, page int, items []ExternalMediaResult) {
if d == nil || d.sectionCache == nil || len(items) == 0 {
return
}
d.sectionCache.Set(key, page, items)
}
func (d *DiscoverService) CachedSection(key string, page int) ([]ExternalMediaResult, bool) {
if d == nil || d.sectionCache == nil {
return nil, false
}
return d.sectionCache.Get(key, page)
}
func (c *DiscoverSectionCache) Set(key string, page int, items []ExternalMediaResult) {
if c == nil || key == "" || page < 1 || len(items) == 0 {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.entries[discoverSectionCacheKey(key, page)] = discoverSectionCacheEntry{
items: cloneExternalMediaResults(items),
storedAt: time.Now(),
}
}
func (c *DiscoverSectionCache) Get(key string, page int) ([]ExternalMediaResult, bool) {
if c == nil || key == "" || page < 1 {
return nil, false
}
c.mu.RLock()
entry, ok := c.entries[discoverSectionCacheKey(key, page)]
c.mu.RUnlock()
if !ok || time.Since(entry.storedAt) > c.ttl || len(entry.items) == 0 {
return nil, false
}
return cloneExternalMediaResults(entry.items), true
}
func discoverSectionCacheKey(key string, page int) string {
return fmt.Sprintf("%s:%d", key, page)
}
func cloneExternalMediaResults(items []ExternalMediaResult) []ExternalMediaResult {
out := make([]ExternalMediaResult, len(items))
for i, item := range items {
out[i] = item
out[i].SubscribeAliases = cloneStrings(item.SubscribeAliases)
out[i].MissingEpisodes = cloneInts(item.MissingEpisodes)
out[i].Languages = cloneStrings(item.Languages)
out[i].Countries = cloneStrings(item.Countries)
out[i].Genres = cloneStrings(item.Genres)
}
return out
}
func cloneStrings(items []string) []string {
if len(items) == 0 {
return nil
}
out := make([]string, len(items))
copy(out, items)
return out
}
func cloneInts(items []int) []int {
if len(items) == 0 {
return nil
}
out := make([]int, len(items))
copy(out, items)
return out
}
@@ -0,0 +1,44 @@
package service
import (
"testing"
"time"
)
func TestDiscoverSectionCacheReturnsClone(t *testing.T) {
cache := NewDiscoverSectionCache(time.Hour)
cache.Set("douban_hot_movie", 1, []ExternalMediaResult{{
Title: "第一部",
SubscribeAliases: []string{"别名"},
MissingEpisodes: []int{1},
Languages: []string{"zh"},
}})
got, ok := cache.Get("douban_hot_movie", 1)
if !ok || len(got) != 1 || got[0].Title != "第一部" {
t.Fatalf("cached section = %#v, %v", got, ok)
}
got[0].Title = "被修改"
got[0].SubscribeAliases[0] = "别名被改"
got[0].MissingEpisodes[0] = 9
got[0].Languages[0] = "en"
again, ok := cache.Get("douban_hot_movie", 1)
if !ok ||
again[0].Title != "第一部" ||
again[0].SubscribeAliases[0] != "别名" ||
again[0].MissingEpisodes[0] != 1 ||
again[0].Languages[0] != "zh" {
t.Fatalf("cache should return a clone, got %#v", again)
}
}
func TestDiscoverSectionCacheExpires(t *testing.T) {
cache := NewDiscoverSectionCache(time.Nanosecond)
cache.Set("tmdb_latest_movie", 1, []ExternalMediaResult{{Title: "旧数据"}})
time.Sleep(time.Millisecond)
if got, ok := cache.Get("tmdb_latest_movie", 1); ok || len(got) != 0 {
t.Fatalf("expired cache should miss, got %#v", got)
}
}
+2 -2
View File
@@ -207,7 +207,7 @@ func embyLikelyEpisodicPathSQL() (string, []any) {
"%/season %/%", "%/season.%/%", "%/season-%/%", "%/season_%/%",
"%/s0%/%", "%/s1%/%", "%/s2%/%", "%/s3%/%", "%/s4%/%", "%/s5%/%", "%/s6%/%", "%/s7%/%", "%/s8%/%", "%/s9%/%",
"%/special/%", "%/specials/%", "%/sp/%", "%/ova/%", "%/oad/%", "%/extra/%", "%/extras/%",
"%/电视剧/%", "%/剧集/%", "%/国产剧/%", "%/欧美剧/%", "%/日韩剧/%", "%/日剧/%", "%/韩剧/%",
"%/电视剧/%", "%/剧集/%", "%/连续剧/%", "%/短剧/%", "%/国产剧/%", "%/国剧/%", "%/大陆剧/%", "%/华语剧/%", "%/国产电视剧/%", "%/大陆电视剧/%", "%/华语电视剧/%", "%/欧美剧/%", "%/欧美电视剧/%", "%/美剧/%", "%/英剧/%", "%/日韩剧/%", "%/日韩电视剧/%", "%/日剧/%", "%/韩剧/%", "%/港剧/%", "%/台剧/%", "%/港台剧/%", "%/泰剧/%",
"%/日番/%", "%/国漫/%", "%/番剧/%", "%/动漫/%", "%/特别篇/%", "%/特別篇/%", "%/番外/%", "%/特典/%",
}
clauses := make([]string, 0, len(patterns)*2)
@@ -230,7 +230,7 @@ func embyMediaPathLooksEpisodic(path string) bool {
}
for _, marker := range []string{
"/season ", "/season.", "/season-", "/season_", "/special/", "/specials/", "/sp/", "/ova/", "/oad/", "/extra/", "/extras/",
"/电视剧/", "/剧集/", "/国产剧/", "/欧美剧/", "/日韩剧/", "/日剧/", "/韩剧/",
"/电视剧/", "/剧集/", "/连续剧/", "/短剧/", "/国产剧/", "/国剧/", "/大陆剧/", "/华语剧/", "/国产电视剧/", "/大陆电视剧/", "/华语电视剧/", "/欧美剧/", "/欧美电视剧/", "/美剧/", "/英剧/", "/日韩剧/", "/日韩电视剧/", "/日剧/", "/韩剧/", "/港剧/", "/台剧/", "/港台剧/", "/泰剧/",
"/日番/", "/国漫/", "/番剧/", "/动漫/", "/特别篇/", "/特別篇/", "/番外/", "/特典/",
} {
if strings.Contains(normalized, marker) {
+55 -19
View File
@@ -164,30 +164,66 @@ func missingEpisodeSet(availability LocalAvailability) map[int]struct{} {
}
func sortedEpisodeCandidates(candidates []siteSearchCandidate) []siteSearchCandidate {
byEpisode := make(map[string]siteSearchCandidate)
order := make([]string, 0, len(candidates))
selected := make([]siteSearchCandidate, 0, len(candidates))
covered := make(map[string]struct{}, len(candidates))
for _, candidate := range candidates {
if candidate.Episode <= 0 {
keys := candidateEpisodeKeys(candidate)
if len(keys) == 0 {
continue
}
season := candidate.Season
if season <= 0 {
season = 1
}
key := episodeKey(season, candidate.Episode)
if current, ok := byEpisode[key]; ok {
if current.Score < candidate.Score {
byEpisode[key] = candidate
}
if episodeKeysOverlap(covered, keys) {
continue
}
byEpisode[key] = candidate
order = append(order, key)
}
sort.Strings(order)
selected := make([]siteSearchCandidate, 0, len(order))
for _, key := range order {
selected = append(selected, byEpisode[key])
selected = append(selected, candidate)
for _, key := range keys {
covered[key] = struct{}{}
}
}
sort.SliceStable(selected, func(i, j int) bool {
return candidateFirstEpisodeKey(selected[i]) < candidateFirstEpisodeKey(selected[j])
})
return selected
}
func candidateEpisodeKeys(candidate siteSearchCandidate) []string {
episodes := candidateEpisodeNumbers(candidate)
if len(episodes) == 0 {
return nil
}
season := candidate.Season
if season <= 0 {
season = 1
}
keys := make([]string, 0, len(episodes))
seen := make(map[string]struct{}, len(episodes))
for _, episode := range episodes {
if episode <= 0 {
continue
}
key := episodeKey(season, episode)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func candidateFirstEpisodeKey(candidate siteSearchCandidate) string {
keys := candidateEpisodeKeys(candidate)
if len(keys) == 0 {
return ""
}
return keys[0]
}
func episodeKeysOverlap(covered map[string]struct{}, keys []string) bool {
for _, key := range keys {
if _, ok := covered[key]; ok {
return true
}
}
return false
}
+72 -5
View File
@@ -66,17 +66,84 @@ func isTVLikeTMDbMatch(match *Match, mediaType string) bool {
}
func parsePositiveInt(value string) (int, bool) {
value = strings.TrimSpace(value)
if strings.Contains(value, ":") {
value = value[strings.LastIndex(value, ":")+1:]
idString, ok := parsePositiveIDString(value)
if !ok {
return 0, false
}
id, err := strconv.Atoi(strings.TrimSpace(value))
id, err := strconv.Atoi(idString)
return id, err == nil && id > 0
}
func parsePositiveIDString(value string) (string, bool) {
id, ok := parsePositiveInt(value)
for _, provider := range []string{"tmdb", "bangumi", "douban", "thetvdb"} {
if id, ok := parseProviderIDString(value, provider); ok {
return id, true
}
}
return parseBarePositiveIDString(value)
}
func parseProviderIDInt(value, provider string) (int, bool) {
idString, ok := parseProviderIDString(value, provider)
if !ok {
return 0, false
}
id, err := strconv.Atoi(idString)
return id, err == nil && id > 0
}
func parseProviderIDString(value, provider string) (string, bool) {
hints := externalIDHintsFromText(value)
switch strings.ToLower(strings.TrimSpace(provider)) {
case "tmdb":
if hints.TMDbID > 0 {
return strconv.Itoa(hints.TMDbID), true
}
case "bangumi", "bgm":
if hints.BangumiID > 0 {
return strconv.Itoa(hints.BangumiID), true
}
case "douban", "db":
if strings.TrimSpace(hints.DoubanID) != "" {
return strings.TrimSpace(hints.DoubanID), true
}
case "thetvdb", "tvdb":
if strings.TrimSpace(hints.TheTVDBID) != "" {
return strings.TrimSpace(hints.TheTVDBID), true
}
}
if hints.useful() {
return "", false
}
return parseBarePositiveIDString(value)
}
func providerIDHintMismatched(value, provider string) bool {
hints := externalIDHintsFromText(value)
if !hints.useful() {
return false
}
switch strings.ToLower(strings.TrimSpace(provider)) {
case "tmdb":
return hints.TMDbID <= 0
case "bangumi", "bgm":
return hints.BangumiID <= 0
case "douban", "db":
return strings.TrimSpace(hints.DoubanID) == ""
case "thetvdb", "tvdb":
return strings.TrimSpace(hints.TheTVDBID) == ""
default:
return false
}
}
func parseBarePositiveIDString(value string) (string, bool) {
value = strings.Trim(strings.TrimSpace(value), "[]{}()")
if strings.Contains(value, ":") {
value = value[strings.LastIndex(value, ":")+1:]
}
id, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || id <= 0 {
return "", false
}
return strconv.Itoa(id), true
+21 -4
View File
@@ -16,7 +16,7 @@ func (s *ScraperService) manualTMDbCandidates(ctx context.Context, query string,
if s.tmdb == nil || !s.tmdb.Enabled() {
return nil
}
if id, ok := parsePositiveInt(query); ok {
if id, ok := parseProviderIDInt(query, "tmdb"); ok {
out := make([]manualTMDbCandidate, 0, 2)
for _, typ := range manualTMDbIDSearchTypes(mediaType) {
if match := s.manualTMDbMatchByIDForType(ctx, id, typ); match != nil {
@@ -25,6 +25,9 @@ func (s *ScraperService) manualTMDbCandidates(ctx context.Context, query string,
}
return out
}
if providerIDHintMismatched(query, "tmdb") {
return nil
}
out := make([]manualTMDbCandidate, 0, 4)
for _, typ := range manualTMDbSearchTypes(mediaType) {
switch typ {
@@ -152,11 +155,14 @@ func (s *ScraperService) manualDoubanMatch(ctx context.Context, query string) *M
if s.douban == nil || !s.douban.Enabled() {
return nil
}
if id, ok := parsePositiveIDString(query); ok {
if id, ok := parseProviderIDString(query, "douban"); ok {
if match, err := s.douban.GetMatchByID(ctx, id); err == nil && match != nil {
return match
}
}
if providerIDHintMismatched(query, "douban") {
return nil
}
match, err := s.douban.SearchMatch(ctx, query)
if err != nil {
return nil
@@ -168,11 +174,14 @@ func (s *ScraperService) manualBangumiMatch(ctx context.Context, query string) *
if s.bangumi == nil || !s.bangumi.Enabled() {
return nil
}
if id, ok := parsePositiveInt(query); ok {
if id, ok := parseProviderIDInt(query, "bangumi"); ok {
if match, err := s.bangumi.GetSubject(ctx, id); err == nil && match != nil {
return match
}
}
if providerIDHintMismatched(query, "bangumi") {
return nil
}
match, err := s.bangumi.Search(ctx, query)
if err != nil {
return nil
@@ -184,7 +193,15 @@ func (s *ScraperService) manualTheTVDBMatch(ctx context.Context, query string) *
if s.thetvdb == nil || !s.thetvdb.Enabled() {
return nil
}
if id, ok := parsePositiveIDString(normalizeTheTVDBSeriesID(query)); ok {
if id, ok := parseProviderIDString(query, "thetvdb"); ok {
if match, err := s.thetvdb.GetSeriesMatchByID(ctx, id); err == nil && match != nil {
return match
}
}
if providerIDHintMismatched(query, "thetvdb") {
return nil
}
if id, ok := parseBarePositiveIDString(normalizeTheTVDBSeriesID(query)); ok {
if match, err := s.thetvdb.GetSeriesMatchByID(ctx, id); err == nil && match != nil {
return match
}
+8 -1
View File
@@ -61,6 +61,9 @@ func (s *ScraperService) ManualSearch(ctx context.Context, media *model.Media, q
if providers.want("adult") {
for _, candidateQuery := range queries {
if externalIDHintsFromText(candidateQuery).useful() {
continue
}
for _, match := range s.manualAdultMatches(ctx, media, candidateQuery) {
add("adult", "adult", match)
}
@@ -140,7 +143,11 @@ func (s *ScraperService) manualSearchQueries(ctx context.Context, media *model.M
out = append(out, value)
}
add(ApplyRecognitionWords(ctx, s.repo, query))
recognizedQuery := ApplyRecognitionWords(ctx, s.repo, query)
add(recognizedQuery)
if externalIDHintsFromText(recognizedQuery).useful() {
return out
}
if strings.TrimSpace(query) == "" && media != nil {
add(firstText(media.Title, media.OriginalName))
}
+162 -5
View File
@@ -4,6 +4,8 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
@@ -34,10 +36,12 @@ func TestManualRequestMatchFallsBackToCandidatePayload(t *testing.T) {
func TestParsePositiveIDStringAcceptsProviderPrefixes(t *testing.T) {
cases := map[string]string{
"12345": "12345",
"tmdb:12345": "12345",
"douban:67890": "67890",
"thetvdb:24680": "24680",
"12345": "12345",
"tmdb:12345": "12345",
"[tmdbid-12345]": "12345",
"douban:67890": "67890",
"{douban=67890}": "67890",
"thetvdb:24680": "24680",
}
for input, want := range cases {
got, ok := parsePositiveIDString(input)
@@ -48,6 +52,9 @@ func TestParsePositiveIDStringAcceptsProviderPrefixes(t *testing.T) {
if got, ok := parsePositiveIDString("tmdb:not-a-number"); ok || got != "" {
t.Fatalf("parsePositiveIDString invalid = %q,%v; want empty,false", got, ok)
}
if got, ok := parseProviderIDString("[tmdbid-12345]", "douban"); ok || got != "" {
t.Fatalf("parseProviderIDString provider mismatch = %q,%v; want empty,false", got, ok)
}
}
func TestManualSearchReturnsTMDbCandidatePage(t *testing.T) {
@@ -279,7 +286,7 @@ func TestManualSearchAllProvidersTMDbNumericIDTriesMovieAndTVNamespaces(t *testi
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
media := model.Media{LibraryID: lib.ID, Title: "待匹配", Path: `/media/movie/raw.mkv`}
media := model.Media{LibraryID: lib.ID, Title: "待匹配", Path: `/media/movie/raw-provider-id.mkv`}
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
@@ -296,6 +303,156 @@ func TestManualSearchAllProvidersTMDbNumericIDTriesMovieAndTVNamespaces(t *testi
}
}
func TestManualSearchTMDbProviderIDUsesIDLookupOnly(t *testing.T) {
var paths []string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/movie/1208850":
_ = json.NewEncoder(w).Encode(map[string]any{
"id": 1208850,
"title": "多拉特行动",
"original_title": "Malbatt: Misi Bakara",
"overview": "ID matched movie.",
"poster_path": "/malbatt.jpg",
"release_date": "2024-01-11",
"vote_average": 6.9,
})
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.TMDbAPIKey = "test-key"
cfg.Secrets.TMDbAPIProxy = upstream.URL
log := zap.NewNop()
scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log))
lib := model.Library{Name: "电影", Path: `/media/movie`, Type: "movie", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
media := model.Media{LibraryID: lib.ID, Title: "待匹配", Path: `/media/movie/raw-provider-id-skip-adult.mkv`}
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
results, err := scraper.ManualSearch(t.Context(), &media, "[tmdbid-1208850]", "all", "movie")
if err != nil {
t.Fatal(err)
}
if len(results) != 1 || results[0].TMDbID != 1208850 || results[0].Title != "多拉特行动" {
t.Fatalf("manual TMDb provider-id results=%#v, paths=%v", results, paths)
}
for _, path := range paths {
if strings.HasPrefix(path, "/search/") {
t.Fatalf("provider-id search should not use fuzzy search; paths=%v", paths)
}
}
if len(paths) == 0 || paths[0] != "/movie/1208850" {
t.Fatalf("tmdb provider-id paths=%v, want /movie/1208850 first", paths)
}
}
func TestManualSearchAllProvidersProviderIDSkipsAdultSource(t *testing.T) {
var tmdbPaths []string
tmdbUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tmdbPaths = append(tmdbPaths, r.URL.Path)
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/movie/1208850":
_ = json.NewEncoder(w).Encode(map[string]any{
"id": 1208850,
"title": "多拉特行动",
"original_title": "Malbatt: Misi Bakara",
"release_date": "2024-01-11",
})
default:
http.NotFound(w, r)
}
}))
defer tmdbUpstream.Close()
var adultCalls atomic.Int32
adultUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
adultCalls.Add(1)
http.NotFound(w, r)
}))
defer adultUpstream.Close()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}, &model.APIConfig{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.TMDbAPIKey = "test-key"
cfg.Secrets.TMDbAPIProxy = tmdbUpstream.URL
log := zap.NewNop()
apiConfig := NewAPIConfigService(log, repos, NewCryptoService("", log))
adultBaseURL := adultUpstream.URL
if _, err := apiConfig.Update(t.Context(), "adult", APIConfigPatch{BaseURL: &adultBaseURL}); err != nil {
t.Fatal(err)
}
scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log), NewAdultProvider(log, apiConfig))
lib := model.Library{Name: "电影", Path: `/media/movie`, Type: "movie", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
media := model.Media{LibraryID: lib.ID, Title: "待匹配", Path: `/media/movie/raw.mkv`}
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
results, err := scraper.ManualSearch(t.Context(), &media, "[tmdbid-1208850]", "all", "movie")
if err != nil {
t.Fatal(err)
}
if len(results) != 1 || results[0].TMDbID != 1208850 || results[0].Source != "tmdb" {
t.Fatalf("manual provider-id results=%#v, tmdb paths=%v", results, tmdbPaths)
}
if calls := adultCalls.Load(); calls != 0 {
t.Fatalf("adult provider was called %d times for explicit tmdb id", calls)
}
}
func TestManualTMDbCandidatesSkipOtherProviderIDs(t *testing.T) {
var paths []string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
http.NotFound(w, r)
}))
defer upstream.Close()
cfg := &config.Config{}
cfg.Secrets.TMDbAPIKey = "test-key"
cfg.Secrets.TMDbAPIProxy = upstream.URL
log := zap.NewNop()
scraper := NewScraperService(cfg, log, nil, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log))
if got := scraper.manualTMDbCandidates(t.Context(), "{douban=36941123}", 0, "movie"); len(got) != 0 {
t.Fatalf("tmdb candidates for douban id = %#v, want none", got)
}
if len(paths) != 0 {
t.Fatalf("tmdb should not be called for douban id hint, paths=%v", paths)
}
}
func TestManualSearchIncludesAdultProvider(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
+2 -2
View File
@@ -182,7 +182,7 @@ func normalizeMediaType(mediaType, title, category string) string {
return "variety"
case (containsAnyText(raw, "国漫", "日漫", "日番", "韩漫", "美漫", "欧美动漫", "其他动漫", "动漫", "动画") || classifierAnimeRE.MatchString(raw)) && !containsAnyText(raw, "动画电影"):
return "anime"
case containsAnyText(raw, "电视剧", "国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "剧集") || classifierTVRE.MatchString(raw):
case containsAnyText(raw, "电视剧", "剧集", "连续剧", "短剧", "国产剧", "国剧", "大陆剧", "华语剧", "国产电视剧", "大陆电视剧", "华语电视剧", "欧美剧", "欧美电视剧", "美剧", "英剧", "日韩剧", "日韩电视剧", "日剧", "韩剧", "港剧", "台剧", "港台剧", "泰剧") || classifierTVRE.MatchString(raw):
return "tv"
case containsAnyText(raw, "电影", "演唱会") || classifierMovieRE.MatchString(raw):
return "movie"
@@ -199,7 +199,7 @@ func normalizeMediaType(mediaType, title, category string) string {
return "anime"
case strings.Contains(text, "variety") || strings.Contains(text, "综艺") || strings.Contains(text, "真人秀"):
return "variety"
case classifierEpisodeRE.MatchString(text) || classifierSeasonRE.MatchString(text) || classifierTVRE.MatchString(text) || strings.Contains(text, "剧集") || strings.Contains(text, "电视剧"):
case classifierEpisodeRE.MatchString(text) || classifierSeasonRE.MatchString(text) || classifierTVRE.MatchString(text) || containsAnyText(text, "剧集", "电视剧", "连续剧", "短剧", "国产电视剧", "大陆电视剧", "华语电视剧", "欧美电视剧", "日韩电视剧", "美剧", "英剧", "港剧", "台剧", "港台剧", "泰剧"):
return "tv"
default:
return "movie"
+1 -1
View File
@@ -18,7 +18,7 @@ func inferLibraryKind(name, path, requested string) string {
return "variety"
case containsAnyText(text, "国漫", "日漫", "日番", "动漫", "动画", "anime", "bangumi") && !containsAnyText(text, "动画电影"):
return "anime"
case containsAnyText(text, "电视剧", "国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "剧集", "tv", "series"):
case containsAnyText(text, "电视剧", "剧集", "连续剧", "短剧", "国产剧", "国剧", "大陆剧", "华语剧", "国产电视剧", "大陆电视剧", "华语电视剧", "欧美剧", "欧美电视剧", "美剧", "英剧", "日韩剧", "日韩电视剧", "日剧", "韩剧", "港剧", "台剧", "港台剧", "泰剧", "tv", "series"):
return "tv"
case containsAnyText(text, "电影", "movie", "film"):
return "movie"
+36 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/ShukeBta/MediaStationGo/internal/model"
)
var episodicPathRE = regexp.MustCompile(`(?i)[\\/](?:电视剧|剧集|国产剧|欧美剧|日韩剧|日剧|韩剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]`)
var episodicPathRE = regexp.MustCompile(`(?i)[\\/](?:电视剧|剧集|连续剧|短剧|国产剧|国剧|大陆剧|华语剧|国产电视剧|大陆电视剧|华语电视剧|欧美剧|欧美电视剧|美剧|英剧|日韩剧|日韩电视剧|日剧|韩剧|港剧|台剧|港台剧|泰剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]`)
func mediaSeriesKey(media model.Media) string {
return compactSeriesKey(mediaSeriesRawKey(media))
@@ -21,6 +21,9 @@ func mediaSeriesRawKey(media model.Media) string {
if fromPath != "" {
return seriesFingerprint("library-path", mediaTargetLibraryID(media), fromPath)
}
if idKey := seriesExternalIDKeyFromPath(media.Path); idKey != "" {
return seriesFingerprint("library-path-id", mediaTargetLibraryID(media), idKey)
}
if media.TMDbID > 0 {
return fmt.Sprintf("tmdb:%d", media.TMDbID)
}
@@ -122,6 +125,9 @@ func seriesTitleFromMediaPath(path string) string {
return ""
}
dirIndex := len(parts) - 2
if last := parts[len(parts)-1]; !seriesPathPartLooksLikeFile(last) && !seriesSeasonDirRE.MatchString(filepath.Base(last)) {
dirIndex = len(parts) - 1
}
for dirIndex >= 0 && seriesSeasonDirRE.MatchString(filepath.Base(parts[dirIndex])) {
dirIndex--
}
@@ -135,6 +141,19 @@ func seriesTitleFromMediaPath(path string) string {
return title
}
func seriesPathPartLooksLikeFile(part string) bool {
ext := strings.ToLower(filepath.Ext(part))
if _, ok := videoExtensions[ext]; ok {
return true
}
switch ext {
case ".iso", ".nfo", ".srt", ".ass", ".ssa", ".vtt", ".sub", ".idx", ".jpg", ".jpeg", ".png", ".webp":
return true
default:
return false
}
}
func seriesDisplayTitle(media model.Media) string {
if fromPath := seriesTitleFromMediaPath(media.Path); fromPath != "" {
return fromPath
@@ -148,6 +167,22 @@ func seriesDisplayTitle(media model.Media) string {
return "未命名节目"
}
func seriesExternalIDKeyFromPath(path string) string {
_, hints := pathHintMetadata(path, true)
switch {
case hints.TMDbID > 0:
return fmt.Sprintf("tmdb:%d", hints.TMDbID)
case hints.BangumiID > 0:
return fmt.Sprintf("bgm:%d", hints.BangumiID)
case strings.TrimSpace(hints.DoubanID) != "":
return "douban:" + strings.TrimSpace(hints.DoubanID)
case strings.TrimSpace(hints.TheTVDBID) != "":
return "thetvdb:" + strings.TrimSpace(hints.TheTVDBID)
default:
return ""
}
}
func mediaTargetLibraryID(media model.Media) string {
if strings.TrimSpace(media.DisplayLibraryID) != "" {
return media.DisplayLibraryID
+55
View File
@@ -204,3 +204,58 @@ func TestMediaSeriesKeyCleansReleaseNoiseFolders(t *testing.T) {
t.Fatalf("release-noise folder key=%q, want clean key=%q", got, want)
}
}
func TestMediaSeriesKeyTreatsDomesticTelevisionFolderAsSeries(t *testing.T) {
main := model.Media{
LibraryID: "lib-domestic-tv",
Path: `/media/国产电视剧/人世间 (2022) [TMDBID-156568]/人世间.S01E01.mkv`,
SeasonNum: 1,
EpisodeNum: 1,
TMDbID: 156568,
}
weakEpisode := model.Media{
LibraryID: "lib-domestic-tv",
Path: `/media/国产电视剧/人世间 (2022) [TMDBID-156568]/人世间.S01E02.mkv`,
// Some local/cloud scans may miss S/E at first while local NFO or
// scraper metadata already carries an episode-level TMDb id.
TMDbID: 4375419,
}
folderRecord := model.Media{
LibraryID: "lib-domestic-tv",
Path: `/media/国产电视剧/人世间 (2022) [TMDBID-156568]`,
Title: "人世间",
TMDbID: 156568,
}
if got, want := mediaSeriesKey(weakEpisode), mediaSeriesKey(main); got != want {
t.Fatalf("domestic television folder key=%q, want main key=%q", got, want)
}
if got, want := mediaSeriesKey(folderRecord), mediaSeriesKey(main); got != want {
t.Fatalf("domestic television folder record key=%q, want main key=%q", got, want)
}
cards := groupMediaSeriesCards([]model.Media{main, weakEpisode, folderRecord})
if len(cards) != 1 || cards[0].Count != 3 {
t.Fatalf("cards=%#v, want one merged series card with three items", cards)
}
}
func TestMediaSeriesKeyUsesSeriesDirectoryExternalID(t *testing.T) {
episodeIDOnly := model.Media{
LibraryID: "lib-domestic-tv",
Path: `/media/电视剧/国产剧/人世间 (2022)/Season 01/人世间.S01E03.{tmdb-7129826}.mkv`,
SeasonNum: 1,
EpisodeNum: 3,
TMDbID: 7129826,
}
cleanFolder := model.Media{
LibraryID: "lib-domestic-tv",
Path: `/media/电视剧/国产剧/人世间 (2022)/Season 01/人世间.S01E04.mkv`,
SeasonNum: 1,
EpisodeNum: 4,
TMDbID: 156568,
}
if got, want := mediaSeriesKey(episodeIDOnly), mediaSeriesKey(cleanFolder); got != want {
t.Fatalf("episode filename tmdb id should not split clean folder key=%q, want %q", got, want)
}
}
-30
View File
@@ -24,9 +24,6 @@ func automaticMetadataTitleTrusted(query string, match *Match) bool {
return true
}
}
if metadataTrustChineseReleaseAlias(queryKey, match) {
return true
}
if metadataTrustLocalizedSearchKeyword(queryKey, match) {
return true
}
@@ -89,23 +86,6 @@ 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
@@ -157,16 +137,6 @@ func metadataMatchHasExternalID(match *Match) bool {
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))
+22 -4
View File
@@ -134,10 +134,7 @@ func (p *OrganizePipelineService) Run(ctx context.Context, req OrganizePipelineR
if scanRoot == "" && strings.TrimSpace(path) != "" {
scanRoot = filepath.Dir(path)
}
preferredLibraryID := strings.TrimSpace(req.PreferredLibraryID)
if preferredLibraryID == "" && req.Scope == OrganizeScopeLibrary {
preferredLibraryID = strings.TrimSpace(req.LibraryID)
}
preferredLibraryID := p.scanPreferredLibraryID(ctx, req)
res.Scans, res.Scrapes = p.scanner.ScanAndScrapeLibrariesForPath(ctx, scanRoot, preferredLibraryID, p.scrapeAfter(ctx, req))
} else if p.log != nil && res != nil && !req.DryRun {
p.log.Info("organize pipeline skipped scan; no destination changes",
@@ -211,3 +208,24 @@ func (p *OrganizePipelineService) scrapeAfter(ctx context.Context, req OrganizeP
}
return OrganizeScrapeAfterEnabled(ctx, p.repo)
}
func (p *OrganizePipelineService) scanPreferredLibraryID(ctx context.Context, req OrganizePipelineRequest) string {
if preferred := strings.TrimSpace(req.PreferredLibraryID); preferred != "" {
return preferred
}
switch req.Scope {
case OrganizeScopeMedia:
if p == nil || p.repo == nil || p.repo.Media == nil {
return ""
}
media, err := p.repo.Media.FindByID(ctx, strings.TrimSpace(req.MediaID))
if err != nil || media == nil {
return ""
}
return strings.TrimSpace(media.LibraryID)
case OrganizeScopeLibrary:
return strings.TrimSpace(req.LibraryID)
default:
return ""
}
}
@@ -7,6 +7,8 @@ import (
"testing"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
func TestOrganizeScanRootUsesActualOrganizedTarget(t *testing.T) {
@@ -85,3 +87,44 @@ func TestOrganizePipelineFailsWhenEveryOrganizeItemErrors(t *testing.T) {
t.Fatalf("task details = %#v, want transfer error detail", task.Details)
}
}
func TestOrganizePipelinePrefersCurrentMediaLibraryAfterMediaOrganize(t *testing.T) {
repos := newOrganizerTestRepo(t)
sourceLib := model.Library{Name: "待整理", Path: filepath.Join(t.TempDir(), "incoming"), Type: "tv", Enabled: true}
targetLib := model.Library{Name: "国产剧", Path: filepath.Join(t.TempDir(), "media", "电视剧", "国产剧"), Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &sourceLib); err != nil {
t.Fatal(err)
}
if err := repos.Library.Create(t.Context(), &targetLib); err != nil {
t.Fatal(err)
}
media := model.Media{
LibraryID: targetLib.ID,
Title: "南部档案",
Path: filepath.Join(targetLib.Path, "南部档案", "Season 01", "南部档案 - S01E01.mkv"),
SeasonNum: 1,
EpisodeNum: 1,
ScrapeStatus: "matched",
}
if err := repos.Media.Upsert(t.Context(), &media); err != nil {
t.Fatal(err)
}
pipeline := NewOrganizePipelineService(zap.NewNop(), repos, nil, nil, nil)
got := pipeline.scanPreferredLibraryID(t.Context(), OrganizePipelineRequest{
Scope: OrganizeScopeMedia,
MediaID: media.ID,
})
if got != targetLib.ID {
t.Fatalf("preferred library = %q, want current media library %q", got, targetLib.ID)
}
got = pipeline.scanPreferredLibraryID(t.Context(), OrganizePipelineRequest{
Scope: OrganizeScopeMedia,
MediaID: media.ID,
PreferredLibraryID: sourceLib.ID,
})
if got != sourceLib.ID {
t.Fatalf("explicit preferred library = %q, want %q", got, sourceLib.ID)
}
}
+6 -1
View File
@@ -88,6 +88,11 @@ func (o *OrganizerService) OrganizeMediaWithOptions(ctx context.Context, mediaID
// Skip if already in place.
if req.media.Path == dst.path {
if !req.dryRun {
if err := o.persistOrganizedMediaMetadata(ctx, req.media); err != nil {
return "", err
}
}
return dst.path, nil
}
if req.dryRun {
@@ -136,7 +141,7 @@ func (o *OrganizerService) OrganizeLibraryWithOptions(ctx context.Context, libra
}
res := &OrganizeResult{SourcePath: sourceRoot, DestPath: baseRoot, DryRun: opts.DryRun}
for i := range rows {
if changed, err := o.reclassifyScannedMedia(ctx, rows[i], *lib, "", opts.DryRun, res); err != nil {
if changed, err := o.reclassifyScannedMedia(ctx, rows[i], *lib, "", opts, opts.DryRun, res); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("%s: %s", rows[i].Title, err.Error()))
continue
} else if changed {
@@ -299,7 +299,7 @@ func TestOrganizeDirectoryReclassifiesMovieFromDirtyGeneratedEpisodePath(t *test
}
}
func TestOrganizeDirectoryChineseAliasMetadataOverridesWrongWesternSourceCategory(t *testing.T) {
func TestOrganizeDirectoryRejectsLooseChineseAliasMetadataForWesternSourceCategory(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 TestOrganizeDirectoryChineseAliasMetadataOverridesWrongWesternSourceCategor
if err != nil {
t.Fatalf("organize directory: %v", err)
}
want := filepath.Join(dest, "动漫", "国漫", "镖人", "Season 02", "镖人 - S02E01.mkv")
want := filepath.Join(dest, "电视剧", "欧美剧", "Blades Of The Guardians", "Season 02", "Blades Of The Guardians - 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("Chinese alias metadata should override wrong western source category at %q: %v; items=%#v", want, err, res.Items)
t.Fatalf("loose Chinese alias metadata should be rejected and keep source category at %q: %v; items=%#v", want, err, res.Items)
}
wrong := filepath.Join(dest, "电视剧", "欧美剧", "Blades Of The Guardians")
wrong := filepath.Join(dest, "动漫", "国漫", "镖人")
if _, err := os.Stat(wrong); !os.IsNotExist(err) {
t.Fatalf("wrong western category should not remain at %q, err=%v", wrong, err)
t.Fatalf("rejected loose alias should not create Chinese anime category at %q, err=%v", wrong, err)
}
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)
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)
}
}
@@ -437,6 +437,83 @@ func TestOrganizeDirectoryDoesNotScrapeByDownloadCategoryFolder(t *testing.T) {
}
}
func TestOrganizeDirectoryUsesPathTMDbIDBeforeTitleSearch(t *testing.T) {
var paths []string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
paths = append(paths, r.URL.Path)
switch r.URL.Path {
case "/tv/156568":
_ = json.NewEncoder(w).Encode(map[string]any{
"id": 156568,
"name": "人世间",
"original_name": "A Lifelong Journey",
"overview": "正确的剧集条目",
"poster_path": "/lifelong.jpg",
"first_air_date": "2022-01-28",
"origin_country": []string{"CN"},
"genre_ids": []int{18},
"vote_average": 8.1,
"original_language": "zh",
})
case "/search/tv", "/search/movie":
_ = json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{{
"id": 999999,
"name": "错误候选",
"first_air_date": "2022-01-01",
}},
})
default:
http.NotFound(w, r)
}
}))
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, "东南亚电影", "人世间 (2022) [tmdbid-156568]", "人世间.S01E01.1080p.mkv")
writeOrgFile(t, sourceFile, "episode")
organizer := NewOrganizerService(cfg, zap.NewNop(), repos)
organizer.SetScraper(scraper)
res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
SourcePath: srcRoot,
DestPath: filepath.Join(dest, "电视剧", "国产电视剧"),
MediaType: "tv",
TransferMode: TransferCopy,
})
if err != nil {
t.Fatalf("organize directory: %v", err)
}
want := filepath.Join(dest, "电视剧", "国产剧", "人世间", "Season 01", "人世间 - S01E01.mkv")
if res.Organized != 1 {
t.Fatalf("organized = %d, want 1; items=%#v errors=%#v paths=%v", res.Organized, res.Items, res.Errors, paths)
}
if _, err := os.Stat(want); err != nil {
t.Fatalf("tmdb-id organize target missing at %q: %v; items=%#v paths=%v", want, err, res.Items, paths)
}
if len(paths) == 0 || paths[0] != "/tv/156568" {
t.Fatalf("first metadata lookup path=%q, want /tv/156568; all=%v", firstQuery(paths), paths)
}
for _, path := range paths {
if strings.HasPrefix(path, "/search/") {
t.Fatalf("path tmdb id should avoid fuzzy search; paths=%v", paths)
}
}
if len(res.Items) != 1 || res.Items[0].Category != "国产剧" || res.Items[0].MediaType != "tv" {
t.Fatalf("organize item = %#v, want 国产剧/tv", res.Items)
}
}
func TestOrganizeDirectoryEpisodeMarkerOverridesMovieSourceFolder(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
+7 -5
View File
@@ -49,7 +49,9 @@ func (o *OrganizerService) OrganizeDirectory(ctx context.Context, opts OrganizeO
if _, ok := ParseCloudLibraryMount(requestedDest); ok {
return nil, errors.New("organize destination must be a local writable media directory; enable cloud transfer in external storage when writing to cloud")
}
dest := normalizeOrganizeDestinationRoot(resolveMappedDestinationPath(requestedDest))
resolvedDest := resolveMappedDestinationPath(requestedDest)
mediaTypeOverride, mediaCategoryOverride := o.effectiveOrganizeOverrides(opts, resolvedDest)
dest := normalizeOrganizeDestinationRoot(resolvedDest)
if dest == "" || dest == "." {
return nil, errors.New("destination path required")
}
@@ -84,8 +86,8 @@ func (o *OrganizerService) OrganizeDirectory(ctx context.Context, opts OrganizeO
SourceRoot: filepath.Dir(source),
DestRoot: dest,
Mode: mode,
MediaTypeOverride: opts.MediaType,
MediaCategoryOverride: opts.MediaCategory,
MediaTypeOverride: mediaTypeOverride,
MediaCategoryOverride: mediaCategoryOverride,
DryRun: opts.DryRun,
AllowReplaceExisting: opts.AllowReplaceExisting,
MetadataCache: metadataCache,
@@ -120,8 +122,8 @@ func (o *OrganizerService) OrganizeDirectory(ctx context.Context, opts OrganizeO
SourceRoot: source,
DestRoot: dest,
Mode: mode,
MediaTypeOverride: opts.MediaType,
MediaCategoryOverride: opts.MediaCategory,
MediaTypeOverride: mediaTypeOverride,
MediaCategoryOverride: mediaCategoryOverride,
DryRun: opts.DryRun,
AllowReplaceExisting: opts.AllowReplaceExisting,
MetadataCache: metadataCache,
@@ -245,6 +245,73 @@ func TestOrganizeDirectoryKeepsHigherResolutionExisting(t *testing.T) {
}
}
func TestOrganizeDirectoryDoesNotReclassifySameEpisodeVersionInTargetDir(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "downloads")
dest := filepath.Join(root, "media")
source := filepath.Join(src, "南部档案.S01E16.1080p.IQ.WEB-DL.H264.AAC-UBWEB.mkv")
writeOrgFile(t, source, "download-1080p")
repos := newOrganizerTestRepo(t)
if err := repos.Setting.Set(t.Context(), "organize.tv_format", "{title}/Season {season:02}/{title} - {episode_tag}{% if video_format %}-{{video_format}}{% endif %}{fileExt}"); err != nil {
t.Fatal(err)
}
lib := model.Library{Name: "国产剧", Path: filepath.Join(dest, "电视剧", "国产剧"), Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
existing := filepath.Join(lib.Path, "南部档案", "Season 01", "南部档案 - S01E16-2160p.IQ.WEB-DL.H265.DDP5.1-UBWEB.mkv")
writeOrgFile(t, existing, "library-2160p")
row := model.Media{
LibraryID: lib.ID,
Title: "南部档案",
Path: existing,
Container: "mkv",
SeasonNum: 1,
EpisodeNum: 16,
Width: 3840,
Height: 2160,
TMDbID: 123456,
ScrapeStatus: "matched",
}
if err := repos.Media.Upsert(t.Context(), &row); err != nil {
t.Fatal(err)
}
org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
res, err := org.OrganizeDirectory(t.Context(), OrganizeOptions{
SourcePath: src,
DestPath: dest,
MediaType: "tv",
MediaCategory: "国产剧",
TransferMode: TransferCopy,
AllowReplaceExisting: false,
})
if err != nil {
t.Fatalf("organize directory: %v", err)
}
if res.Reclassified != 0 || res.Replaced != 0 || res.Organized != 0 || res.Skipped != 1 {
t.Fatalf("result = %+v, want skipped duplicate without reclassify", res)
}
if _, err := os.Stat(existing); err != nil {
t.Fatalf("existing higher version should stay at original path: %v", err)
}
wrongRename := filepath.Join(lib.Path, "南部档案", "Season 01", "南部档案 - S01E16-1080p.IQ.WEB-DL.H264.AAC-UBWEB.mkv")
if _, err := os.Stat(wrongRename); !os.IsNotExist(err) {
t.Fatalf("existing version must not be renamed to incoming release path, stat err=%v", err)
}
var got model.Media
if err := repos.DB.First(&got, "path = ?", existing).Error; err != nil {
t.Fatal(err)
}
if got.Title != "南部档案" || got.TMDbID != 123456 || got.ScrapeStatus != "matched" {
t.Fatalf("metadata changed after duplicate organize: title=%q tmdb=%d status=%q", got.Title, got.TMDbID, got.ScrapeStatus)
}
}
// TestOrganizeDirectoryTVEpisodeDedup verifies per-episode dedup for TV media.
func TestOrganizeDirectoryTVEpisodeDedup(t *testing.T) {
root := t.TempDir()
@@ -125,8 +125,24 @@ func (o *OrganizerService) directoryCategoryTypes() map[string]organizeDirectory
addAlias("外语电影", "euus_movie", "欧美电影", "movie")
addAlias("外国电影", "euus_movie", "欧美电影", "movie")
addConfigured("domestic_tv", "国产剧", "tv")
addAlias("国剧", "domestic_tv", "国产剧", "tv")
addAlias("大陆剧", "domestic_tv", "国产剧", "tv")
addAlias("华语剧", "domestic_tv", "国产剧", "tv")
addAlias("国产电视剧", "domestic_tv", "国产剧", "tv")
addAlias("大陆电视剧", "domestic_tv", "国产剧", "tv")
addAlias("华语电视剧", "domestic_tv", "国产剧", "tv")
addAlias("港剧", "domestic_tv", "国产剧", "tv")
addAlias("台剧", "domestic_tv", "国产剧", "tv")
addAlias("港台剧", "domestic_tv", "国产剧", "tv")
addConfigured("euus_tv", "欧美剧", "tv")
addAlias("欧美电视剧", "euus_tv", "欧美剧", "tv")
addAlias("美剧", "euus_tv", "欧美剧", "tv")
addAlias("英剧", "euus_tv", "欧美剧", "tv")
addConfigured("jk_tv", "日韩剧", "tv")
addAlias("日韩电视剧", "jk_tv", "日韩剧", "tv")
addAlias("日剧", "jk_tv", "日韩剧", "tv")
addAlias("韩剧", "jk_tv", "日韩剧", "tv")
addAlias("泰剧", "jk_tv", "日韩剧", "tv")
addConfigured("cn_anime", "国漫", "anime")
addConfigured("jp_anime", "日番", "anime")
addConfigured("kr_anime", "韩漫", "anime")
@@ -31,6 +31,9 @@ func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sour
if o == nil || o.scraper == nil || !o.scraper.AnyEnabled() {
return nil
}
if match := o.lookupOrganizeMetadataByPathHints(ctx, src, sourceRoot, normalizedType, title, year, lookupSeason, lookupEpisode, seriesLike); match != nil {
return match
}
libType := normalizeOrganizeMediaType(mediaType)
if libType == "" {
libType = organizeLibraryModelType(mediaType)
@@ -99,6 +102,55 @@ func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sour
return nil
}
func (o *OrganizerService) lookupOrganizeMetadataByPathHints(ctx context.Context, src, sourceRoot, mediaType, title string, year, season, episode int, seriesLike bool) *Match {
if o == nil || o.scraper == nil {
return nil
}
meta, hints := pathHintMetadata(src, seriesLike)
if !hints.useful() {
return nil
}
if meta != nil {
if strings.TrimSpace(title) == "" {
title = strings.TrimSpace(meta.Title)
}
if year <= 0 {
year = meta.Year
}
}
libType := normalizeOrganizeMediaType(mediaType)
if libType == "" {
libType = organizeLibraryModelType(mediaType)
}
lib := &model.Library{Path: sourceRoot, Type: libType, Enabled: true}
media := &model.Media{
Title: title,
Year: year,
Path: src,
SeasonNum: season,
EpisodeNum: episode,
TMDbID: hints.TMDbID,
BangumiID: hints.BangumiID,
DoubanID: strings.TrimSpace(hints.DoubanID),
TheTVDBID: strings.TrimSpace(hints.TheTVDBID),
}
match := o.scraper.matchFromMediaExternalIDs(ctx, media, lib)
if match == nil || strings.TrimSpace(match.Title) == "" {
return nil
}
if o.log != nil {
o.log.Info("organize metadata matched by path id before rename",
zap.String("source", src),
zap.String("title", match.Title),
zap.String("media_type", match.MediaType),
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
}
func (o *OrganizerService) lookupOrganizeAdultMetadata(ctx context.Context, src, mediaType, title string) *Match {
if o == nil || o.scraper == nil || o.scraper.adult == nil || !o.scraper.adult.Enabled() {
return nil
@@ -0,0 +1,76 @@
package service
import (
"context"
"os"
"path/filepath"
"strings"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
func (o *OrganizerService) persistOrganizedSourceMetadata(ctx context.Context, plan organizeSourceFilePlan) {
if o == nil || o.repo == nil || o.repo.Media == nil || plan.MetadataMatch == nil {
return
}
libraryID := strings.TrimSpace(plan.TargetLibraryID)
if libraryID == "" {
return
}
media := organizedSourceMediaFromPlan(libraryID, plan)
if info, err := os.Stat(plan.Target.Path); err == nil && !info.IsDir() {
media.SizeBytes = info.Size()
}
if fileID, ok := fileIdentity(plan.Target.Path); ok {
media.FileID = fileID
}
if err := o.repo.Media.Upsert(ctx, media); err != nil && o.log != nil {
o.log.Warn("persist organized metadata failed",
zap.String("path", plan.Target.Path),
zap.String("library_id", libraryID),
zap.Error(err))
}
}
func organizedSourceMediaFromPlan(libraryID string, plan organizeSourceFilePlan) *model.Media {
match := plan.MetadataMatch
media := &model.Media{
LibraryID: libraryID,
Title: strings.TrimSpace(firstNonEmpty(match.Title, plan.Identity.ParsedTitle, plan.Identity.Title)),
OriginalName: strings.TrimSpace(match.OriginalName),
Overview: match.Overview,
PosterURL: match.PosterURL,
BackdropURL: match.BackdropURL,
Year: firstPositiveInt(match.Year, plan.Identity.Year),
Rating: match.Rating,
Path: plan.Target.Path,
Container: strings.TrimPrefix(strings.ToLower(filepath.Ext(plan.Target.Path)), "."),
SeasonNum: plan.Identity.Season,
EpisodeNum: plan.Identity.Episode,
TMDbID: match.TMDbID,
BangumiID: match.BangumiID,
DoubanID: strings.TrimSpace(match.DoubanID),
TheTVDBID: strings.TrimSpace(match.TheTVDBID),
Genres: strings.Join(match.Genres, ","),
Countries: strings.Join(match.Countries, ","),
Languages: strings.Join(match.Languages, ","),
NSFW: match.NSFW,
ScrapeStatus: "matched",
}
if normalizeOrganizeMediaType(plan.Layout.MediaType) == "movie" {
media.SeasonNum = 0
media.EpisodeNum = 0
}
return media
}
func firstPositiveInt(values ...int) int {
for _, value := range values {
if value > 0 {
return value
}
}
return 0
}
@@ -39,6 +39,7 @@ func (o *OrganizerService) reclassifyExistingMedia(ctx context.Context, req orga
return false, nil
}
candidates := reclassifyExistingCandidates(req.Existing, target, req.DestRoot)
candidates = reclassifyMoveCandidates(candidates, target)
if len(candidates) == 0 {
return false, nil
}
@@ -112,6 +113,22 @@ func reclassifyExistingCandidates(existing []string, target, destRoot string) []
return out
}
func reclassifyMoveCandidates(existing []string, target string) []string {
targetDir := filepath.Clean(filepath.Dir(target))
out := make([]string, 0, len(existing))
for _, path := range existing {
cleaned := filepath.Clean(strings.TrimSpace(path))
if cleaned == "" || cleaned == "." {
continue
}
if strings.EqualFold(filepath.Clean(filepath.Dir(cleaned)), targetDir) {
continue
}
out = append(out, cleaned)
}
return out
}
func (o *OrganizerService) cleanupReclassifiedDuplicates(ctx context.Context, req organizeExistingReclassifyRequest, target string, candidates []string) (int, error) {
cleaned := 0
for _, oldPath := range candidates {
@@ -127,6 +127,7 @@ func (o *OrganizerService) writeOrganizedSourceFile(ctx context.Context, req org
o.log.Warn("organize sidecar nfo failed",
zap.String("from", req.Source), zap.String("to", plan.Target.Path), zap.Error(err))
}
o.persistOrganizedSourceMetadata(ctx, plan)
req.Result.Organized++
return nil
}
@@ -56,12 +56,12 @@ func (o *OrganizerService) organizeCategoryAliases(mediaType, category string) m
add("美漫", categoryName(categories, "us_anime", "美漫"))
case normalizeOrganizeCategoryKey(categoryName(categories, "other_anime", "其他")), "其他", "其他动漫", "其它动漫":
add("其他", categoryName(categories, "other_anime", "其他"))
case normalizeOrganizeCategoryKey(categoryName(categories, "domestic_tv", "国产剧")), "国产剧", "国剧", "大陆剧", "国产电视剧":
add("国产剧", categoryName(categories, "domestic_tv", "国产剧"))
case normalizeOrganizeCategoryKey(categoryName(categories, "euus_tv", "欧美剧")), "欧美剧", "欧美电视剧":
add("欧美剧", categoryName(categories, "euus_tv", "欧美剧"))
case normalizeOrganizeCategoryKey(categoryName(categories, "jk_tv", "日韩剧")), "日韩剧", "日剧", "韩剧":
add("日韩剧", categoryName(categories, "jk_tv", "日韩剧"))
case normalizeOrganizeCategoryKey(categoryName(categories, "domestic_tv", "国产剧")), "国产剧", "国剧", "大陆剧", "华语剧", "国产电视剧", "大陆电视剧", "华语电视剧", "港剧", "台剧", "港台剧":
add("国产剧", "国剧", "大陆剧", "华语剧", "国产电视剧", "大陆电视剧", "华语电视剧", "港剧", "台剧", "港台剧", categoryName(categories, "domestic_tv", "国产剧"))
case normalizeOrganizeCategoryKey(categoryName(categories, "euus_tv", "欧美剧")), "欧美剧", "欧美电视剧", "美剧", "英剧":
add("欧美剧", "欧美电视剧", "美剧", "英剧", categoryName(categories, "euus_tv", "欧美剧"))
case normalizeOrganizeCategoryKey(categoryName(categories, "jk_tv", "日韩剧")), "日韩剧", "日韩电视剧", "日剧", "韩剧", "泰剧":
add("日韩剧", "日韩电视剧", "日剧", "韩剧", "泰剧", categoryName(categories, "jk_tv", "日韩剧"))
case normalizeOrganizeCategoryKey(categoryName(categories, "variety", "综艺")), "综艺", "真人秀":
add("综艺", categoryName(categories, "variety", "综艺"))
case normalizeOrganizeCategoryKey(categoryName(categories, "documentary", "纪录片")), "纪录片", "纪录":
+7 -3
View File
@@ -41,7 +41,9 @@ func (o *OrganizerService) resolveOrganizeMediaRequest(ctx context.Context, medi
if _, ok := ParseCloudLibraryMount(lib.Path); ok {
return organizeMediaRequest{}, errors.New("local organize cannot use cloud libraries directly; use external storage scan/mount for cloud media or enable cloud transfer to write to cloud")
}
baseRoot := normalizeOrganizeDestinationRoot(o.resolveBaseRoot(ctx, lib, opts.DestPath))
requestedBaseRoot := o.resolveBaseRoot(ctx, lib, opts.DestPath)
mediaType, mediaCategory := o.effectiveOrganizeOverrides(opts, requestedBaseRoot)
baseRoot := normalizeOrganizeDestinationRoot(requestedBaseRoot)
if _, ok := ParseCloudLibraryMount(baseRoot); ok {
return organizeMediaRequest{}, errors.New("organize destination must be a local writable media directory; enable cloud transfer in external storage when writing to cloud")
}
@@ -55,12 +57,13 @@ func (o *OrganizerService) resolveOrganizeMediaRequest(ctx context.Context, medi
return organizeMediaRequest{}, err
}
}
o.refreshOrganizeMediaMetadata(ctx, m, lib, opts.MediaType)
return organizeMediaRequest{
media: m,
library: lib,
baseRoot: baseRoot,
mediaType: strings.TrimSpace(opts.MediaType),
mediaCategory: strings.TrimSpace(opts.MediaCategory),
mediaType: mediaType,
mediaCategory: mediaCategory,
dryRun: opts.DryRun,
transferMode: o.resolveTransferMode(ctx, opts.TransferMode),
}, nil
@@ -149,6 +152,7 @@ func (o *OrganizerService) applyOrganizeMedia(ctx context.Context, req organizeM
updates := map[string]any{
"path": dst.path,
}
addOrganizedMediaMetadataUpdates(updates, *m)
if normalizeOrganizeMediaType(dst.mediaType) == "movie" {
updates["season_num"] = 0
updates["episode_num"] = 0
@@ -0,0 +1,117 @@
package service
import (
"context"
"strings"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
func (o *OrganizerService) refreshOrganizeMediaMetadata(ctx context.Context, media *model.Media, lib *model.Library, requestedType string) {
if o == nil || media == nil || lib == nil || !organizeMediaNeedsMetadataRefresh(*media) {
return
}
mediaType := normalizeOrganizeMediaType(requestedType)
if mediaType == "" {
mediaType = normalizeOrganizeMediaType(lib.Type)
}
match := o.lookupReclassifyMetadata(ctx, *media, *lib, mediaType)
if match == nil {
return
}
refreshed := mediaWithReclassifyMatch(*media, match)
*media = refreshed
if o.log != nil {
o.log.Info("organize media metadata refreshed before rename",
zap.String("media", media.ID),
zap.String("path", media.Path),
zap.String("title", media.Title),
zap.Int("tmdb_id", media.TMDbID),
zap.Int("bangumi_id", media.BangumiID),
zap.String("douban_id", media.DoubanID),
zap.String("thetvdb_id", media.TheTVDBID))
}
}
func organizeMediaNeedsMetadataRefresh(media model.Media) bool {
if strings.TrimSpace(media.ScrapeStatus) != "matched" {
return true
}
if organizeMediaTitleLooksLikeRelease(media.Title) {
return true
}
return media.TMDbID <= 0 &&
media.BangumiID <= 0 &&
strings.TrimSpace(media.DoubanID) == "" &&
strings.TrimSpace(media.TheTVDBID) == ""
}
func organizeMediaTitleLooksLikeRelease(title string) bool {
title = strings.TrimSpace(title)
if title == "" || organizeWeakFileTitle(title) {
return true
}
if season, episode := ParseEpisode(title); season > 0 || episode > 0 {
return true
}
normalized := strings.ToLower(strings.NewReplacer(".", " ", "_", " ", "-", " ").Replace(title))
for _, field := range strings.Fields(normalized) {
if _, ok := releaseBoundaryTokenSet[field]; ok {
return true
}
}
return false
}
func addOrganizedMediaMetadataUpdates(updates map[string]any, media model.Media) {
if updates == nil || strings.TrimSpace(media.ScrapeStatus) != "matched" {
return
}
setNonEmptyUpdate(updates, "title", media.Title)
setNonEmptyUpdate(updates, "original_name", media.OriginalName)
setNonEmptyUpdate(updates, "episode_title", media.EpisodeTitle)
setNonEmptyUpdate(updates, "poster_url", media.PosterURL)
setNonEmptyUpdate(updates, "backdrop_url", media.BackdropURL)
setNonEmptyUpdate(updates, "overview", media.Overview)
setNonEmptyUpdate(updates, "languages", media.Languages)
setNonEmptyUpdate(updates, "countries", media.Countries)
setNonEmptyUpdate(updates, "genres", media.Genres)
if media.Year > 0 {
updates["year"] = media.Year
}
if media.Rating > 0 {
updates["rating"] = media.Rating
}
if media.TMDbID > 0 {
updates["tm_db_id"] = media.TMDbID
}
if media.BangumiID > 0 {
updates["bangumi_id"] = media.BangumiID
}
setNonEmptyUpdate(updates, "douban_id", media.DoubanID)
setNonEmptyUpdate(updates, "thetvdb_id", media.TheTVDBID)
if media.NSFW {
updates["nsfw"] = true
}
updates["scrape_status"] = "matched"
}
func (o *OrganizerService) persistOrganizedMediaMetadata(ctx context.Context, media *model.Media) error {
if o == nil || o.repo == nil || o.repo.DB == nil || media == nil {
return nil
}
updates := map[string]any{}
addOrganizedMediaMetadataUpdates(updates, *media)
if len(updates) == 0 {
return nil
}
return o.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("id = ?", media.ID).Updates(updates).Error
}
func setNonEmptyUpdate(updates map[string]any, key, value string) {
if strings.TrimSpace(value) != "" {
updates[key] = value
}
}
+6 -2
View File
@@ -70,7 +70,9 @@ func (o *OrganizerService) categoryPhysicalRootDirForType(mediaType, category st
categoryName(categories, "variety", "综艺"),
categoryName(categories, "documentary", "纪录片"),
categoryName(categories, "children", "儿童"),
"国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "综艺", "真人秀", "纪录片", "纪录", "儿童", "少儿", "未分类",
"国产剧", "国剧", "大陆剧", "华语剧", "国产电视剧", "大陆电视剧", "华语电视剧", "港剧", "台剧", "港台剧",
"欧美剧", "欧美电视剧", "美剧", "英剧", "日韩剧", "日韩电视剧", "日剧", "韩剧", "泰剧",
"综艺", "真人秀", "纪录片", "纪录", "儿童", "少儿", "未分类",
) {
return "电视剧"
}
@@ -124,7 +126,9 @@ func (o *OrganizerService) categoryPhysicalRootDir(category string) string {
categoryName(categories, "variety", "综艺"),
categoryName(categories, "documentary", "纪录片"),
categoryName(categories, "children", "儿童"),
"国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "综艺", "真人秀", "纪录片", "纪录", "儿童", "少儿", "未分类",
"国产剧", "国剧", "大陆剧", "华语剧", "国产电视剧", "大陆电视剧", "华语电视剧", "港剧", "台剧", "港台剧",
"欧美剧", "欧美电视剧", "美剧", "英剧", "日韩剧", "日韩电视剧", "日剧", "韩剧", "泰剧",
"综艺", "真人秀", "纪录片", "纪录", "儿童", "少儿", "未分类",
):
return "电视剧"
case match(
@@ -69,7 +69,7 @@ func (o *OrganizerService) ReclassifyMisclassifiedMedia(ctx context.Context, opt
if !ok {
continue
}
changed, err := o.reclassifyScannedMedia(ctx, rows[i], lib, typeHints[rows[i].ID], opts.DryRun, res)
changed, err := o.reclassifyScannedMedia(ctx, rows[i], lib, typeHints[rows[i].ID], OrganizeOptions{}, opts.DryRun, res)
if err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("%s: %s", rows[i].Title, err.Error()))
if o.log != nil {
@@ -103,7 +103,7 @@ func normalizeReclassifyMediaTypeHints(values map[string]string) map[string]stri
return out
}
func (o *OrganizerService) reclassifyScannedMedia(ctx context.Context, media model.Media, lib model.Library, mediaTypeHint string, dryRun bool, res *OrganizeResult) (bool, error) {
func (o *OrganizerService) reclassifyScannedMedia(ctx context.Context, media model.Media, lib model.Library, mediaTypeHint string, opts OrganizeOptions, dryRun bool, res *OrganizeResult) (bool, error) {
if res == nil || !lib.Enabled || strings.TrimSpace(media.Path) == "" {
return false, nil
}
@@ -114,11 +114,20 @@ func (o *OrganizerService) reclassifyScannedMedia(ctx context.Context, media mod
return false, nil
}
requestedBaseRoot := o.resolveBaseRoot(ctx, &lib, opts.DestPath)
overrideType, overrideCategory := o.effectiveOrganizeOverrides(opts, requestedBaseRoot)
explicitType := overrideType != ""
explicitCategory := overrideCategory != ""
mediaType := normalizeOrganizeMediaType(lib.Type)
if mediaTypeHint != "" {
mediaType = mediaTypeHint
}
if explicitType {
mediaType = overrideType
}
metadataMatch := organizeMatchFromMedia(&media)
metadataRefreshed := false
if metadataMatch != nil && mediaTypeHint != "" {
metadataMatch.MediaType = mediaTypeHint
}
@@ -128,17 +137,28 @@ func (o *OrganizerService) reclassifyScannedMedia(ctx context.Context, media mod
return false, nil
}
media = mediaWithReclassifyMatch(media, metadataMatch)
metadataRefreshed = true
}
if matchType := normalizeOrganizeMediaType(metadataMatchMediaType(metadataMatch)); matchType != "" {
mediaType = matchType
if !explicitType {
if matchType := normalizeOrganizeMediaType(metadataMatchMediaType(metadataMatch)); matchType != "" {
mediaType = matchType
}
}
category := overrideCategory
if category == "" {
category = o.classifyMedia(ctx, &media, mediaType)
}
category := o.classifyMedia(ctx, &media, mediaType)
if category == "" {
return false, nil
}
if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(category); impliedType != "" {
mediaType = impliedType
category = normalizedCategory
if !explicitType {
mediaType = impliedType
}
}
if explicitCategory && overrideCategory != "" {
category = overrideCategory
}
if mediaType == "" {
mediaType = normalizeOrganizeMediaType(lib.Type)
@@ -147,7 +167,7 @@ func (o *OrganizerService) reclassifyScannedMedia(ctx context.Context, media mod
return false, nil
}
baseRoot := normalizeOrganizeDestinationRoot(o.resolveBaseRoot(ctx, &lib, ""))
baseRoot := normalizeOrganizeDestinationRoot(requestedBaseRoot)
targetLibrary, matched := o.organizeLibraryForLayout(ctx, baseRoot, mediaType, category)
if !matched || strings.TrimSpace(targetLibrary.ID) == "" || strings.TrimSpace(targetLibrary.Path) == "" {
targetRoot := categoryRoot(o.organizeRoot(baseRoot, mediaType, category), category)
@@ -160,6 +180,22 @@ func (o *OrganizerService) reclassifyScannedMedia(ctx context.Context, media mod
}
}
if strings.EqualFold(targetLibrary.ID, lib.ID) && pathWithin(media.Path, targetLibrary.Path) {
if metadataRefreshed && !dryRun {
if err := o.persistOrganizedMediaMetadata(ctx, &media); err != nil {
return false, err
}
if o.log != nil {
o.log.Info("media metadata refreshed without reclassify",
zap.String("media", media.ID),
zap.String("path", media.Path),
zap.String("title", media.Title),
zap.Int("tmdb_id", media.TMDbID),
zap.Int("bangumi_id", media.BangumiID),
zap.String("douban_id", media.DoubanID),
zap.String("thetvdb_id", media.TheTVDBID))
}
return true, nil
}
return false, nil
}
if pathWithin(media.Path, targetLibrary.Path) {
@@ -25,7 +25,7 @@ func TestOrganizeDirectoryReclassifiesExistingWrongCategoryMedia(t *testing.T) {
"results": []map[string]any{{
"id": 292696,
"name": "莫离",
"original_name": "莫离",
"original_name": "The First Jasmine",
"original_language": "zh",
"origin_country": []string{"CN"},
"genre_ids": []int{18},
+3 -1
View File
@@ -164,7 +164,9 @@ func (s *ScannerService) scrapeOrganizeTargets(ctx context.Context, targets []mo
// Organize is an explicit ingest workflow: after rename/classification,
// previously failed no_match rows should be retried so the operator does
// not need to run a separate manual scrape.
result, err := s.scraper.EnrichLibraryDetailedWithOptions(ctx, lib.ID, skipEpisodeArtworkOptions(true))
options := skipEpisodeArtworkOptions(true)
options.RefreshWeakMatched = true
result, err := s.scraper.EnrichLibraryDetailedWithOptions(ctx, lib.ID, options)
if err != nil {
summary.Error = err.Error()
} else {
+251
View File
@@ -100,6 +100,212 @@ func TestOrganizeDirectoryUsesScraperMatchBeforeRename(t *testing.T) {
if len(res.Items) != 1 || res.Items[0].Target != want || res.Items[0].Title != "间谍过家家" {
t.Fatalf("organize preview did not use scraper metadata: %#v", res.Items)
}
var media model.Media
if err := repos.DB.First(&media, "path = ?", want).Error; err != nil {
t.Fatalf("organized metadata should be persisted before scan: %v", err)
}
if media.Title != "间谍过家家" || media.TMDbID != 12345 || media.ScrapeStatus != "matched" {
t.Fatalf("persisted media = title=%q tmdb=%d status=%q, want localized matched metadata", media.Title, media.TMDbID, media.ScrapeStatus)
}
}
func TestOrganizeMediaRefreshesMetadataBeforeRename(t *testing.T) {
scraper, repos, closeServer := newTestScraper(t)
defer closeServer()
root := t.TempDir()
src := filepath.Join(root, "downloads")
dest := filepath.Join(root, "media", "电视剧")
sourceFile := filepath.Join(src, "Spy.x.Family.S01E01.2022.1080p.mkv")
writeOrgFile(t, sourceFile, "episode")
lib := model.Library{
Name: "剧集",
Path: dest,
Type: "tv",
Enabled: true,
}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
media := model.Media{
LibraryID: lib.ID,
Title: "Spy x Family S01E01 2022 1080p",
Path: sourceFile,
Container: "mkv",
SeasonNum: 1,
EpisodeNum: 1,
ScrapeStatus: "pending",
}
if err := repos.Media.Upsert(t.Context(), &media); err != nil {
t.Fatal(err)
}
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
organizer.SetScraper(scraper)
dst, err := organizer.OrganizeMediaWithOptions(t.Context(), media.ID, OrganizeOptions{
TransferMode: TransferCopy,
})
if err != nil {
t.Fatalf("organize media: %v", err)
}
want := filepath.Join(dest, "间谍过家家", "Season 01", "间谍过家家 - S01E01.mkv")
if dst != want {
t.Fatalf("dst = %q, want %q", dst, want)
}
var got model.Media
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
t.Fatal(err)
}
if got.Title != "间谍过家家" || got.TMDbID != 12345 || got.ScrapeStatus != "matched" {
t.Fatalf("media = title=%q tmdb=%d status=%q, want localized matched metadata", got.Title, got.TMDbID, got.ScrapeStatus)
}
if got.Path != want {
t.Fatalf("media path = %q, want %q", got.Path, want)
}
}
func TestOrganizeMediaRefreshesMatchedReleaseTitleBeforeRename(t *testing.T) {
scraper, repos, closeServer := newTestScraper(t)
defer closeServer()
root := t.TempDir()
src := filepath.Join(root, "downloads")
dest := filepath.Join(root, "media", "电视剧")
sourceFile := filepath.Join(src, "Spy.x.Family.S01E01.2022.1080p.WEB-DL.mkv")
writeOrgFile(t, sourceFile, "episode")
lib := model.Library{Name: "剧集", Path: dest, Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
media := model.Media{
LibraryID: lib.ID,
Title: "Spy.x.Family.S01E01.2022.1080p.WEB-DL",
Path: sourceFile,
Container: "mkv",
SeasonNum: 1,
EpisodeNum: 1,
TMDbID: 12345,
ScrapeStatus: "matched",
}
if err := repos.Media.Upsert(t.Context(), &media); err != nil {
t.Fatal(err)
}
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
organizer.SetScraper(scraper)
dst, err := organizer.OrganizeMediaWithOptions(t.Context(), media.ID, OrganizeOptions{
TransferMode: TransferCopy,
})
if err != nil {
t.Fatalf("organize media: %v", err)
}
want := filepath.Join(dest, "间谍过家家", "Season 01", "间谍过家家 - S01E01.mkv")
if dst != want {
t.Fatalf("dst = %q, want %q", dst, want)
}
var got model.Media
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
t.Fatal(err)
}
if got.Title != "间谍过家家" || got.TMDbID != 12345 || got.OriginalName != "SPY×FAMILY" {
t.Fatalf("media = title=%q original=%q tmdb=%d, want refreshed localized metadata", got.Title, got.OriginalName, got.TMDbID)
}
}
func TestOrganizeMediaPersistsMetadataWhenAlreadyInPlace(t *testing.T) {
scraper, repos, closeServer := newTestScraper(t)
defer closeServer()
root := t.TempDir()
libRoot := filepath.Join(root, "media", "电视剧")
mediaPath := filepath.Join(libRoot, "间谍过家家", "Season 01", "间谍过家家 - S01E01.mkv")
writeOrgFile(t, mediaPath, "episode")
lib := model.Library{Name: "剧集", Path: libRoot, Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
media := model.Media{
LibraryID: lib.ID,
Title: "Spy.x.Family.S01E01.2022.1080p.WEB-DL",
Path: mediaPath,
Container: "mkv",
SeasonNum: 1,
EpisodeNum: 1,
ScrapeStatus: "matched",
}
if err := repos.Media.Upsert(t.Context(), &media); err != nil {
t.Fatal(err)
}
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
organizer.SetScraper(scraper)
dst, err := organizer.OrganizeMediaWithOptions(t.Context(), media.ID, OrganizeOptions{
TransferMode: TransferCopy,
})
if err != nil {
t.Fatalf("organize media already in place: %v", err)
}
if dst != mediaPath {
t.Fatalf("dst = %q, want existing path %q", dst, mediaPath)
}
var got model.Media
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
t.Fatal(err)
}
if got.Title != "间谍过家家" || got.TMDbID != 12345 || got.OriginalName != "SPY×FAMILY" || got.ScrapeStatus != "matched" {
t.Fatalf("metadata not persisted for already-in-place media: title=%q original=%q tmdb=%d status=%q", got.Title, got.OriginalName, got.TMDbID, got.ScrapeStatus)
}
}
func TestOrganizeLibraryPersistsMetadataForInPlaceWeakRows(t *testing.T) {
scraper, repos, closeServer := newTestScraper(t)
defer closeServer()
root := t.TempDir()
libRoot := filepath.Join(root, "media", "电视剧", "欧美剧")
mediaPath := filepath.Join(libRoot, "间谍过家家", "Season 01", "间谍过家家 - S01E01.mkv")
writeOrgFile(t, mediaPath, "episode")
lib := model.Library{Name: "欧美剧", Path: libRoot, Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
media := model.Media{
LibraryID: lib.ID,
Title: "Spy.x.Family.S01E01.2022.1080p.WEB-DL",
Path: mediaPath,
Container: "mkv",
SeasonNum: 1,
EpisodeNum: 1,
ScrapeStatus: "matched",
}
if err := repos.Media.Upsert(t.Context(), &media); err != nil {
t.Fatal(err)
}
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
organizer.SetScraper(scraper)
res, err := organizer.OrganizeLibraryWithOptions(t.Context(), lib.ID, OrganizeOptions{})
if err != nil {
t.Fatalf("organize library: %v", err)
}
if res.Organized != 0 || res.Reclassified != 0 {
t.Fatalf("result = %+v, want metadata-only refresh without move", res)
}
var got model.Media
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
t.Fatal(err)
}
if got.Title != "间谍过家家" || got.TMDbID != 12345 || got.OriginalName != "SPY×FAMILY" || got.ScrapeStatus != "matched" {
t.Fatalf("metadata not persisted for in-place library row: title=%q original=%q tmdb=%d status=%q", got.Title, got.OriginalName, got.TMDbID, got.ScrapeStatus)
}
}
func TestOrganizeScanAndScrapeRetriesNoMatchRows(t *testing.T) {
@@ -147,6 +353,51 @@ func TestOrganizeScanAndScrapeRetriesNoMatchRows(t *testing.T) {
}
}
func TestOrganizeScanAndScrapeRepairsWeakMatchedReleaseTitle(t *testing.T) {
scraper, repos, closeServer := newTestScraper(t)
defer closeServer()
root := t.TempDir()
libRoot := filepath.Join(root, "media", "电视剧")
mediaPath := filepath.Join(libRoot, "Spy.x.Family", "Season 01", "Spy.x.Family.S01E01.2022.1080p.WEB-DL.mkv")
writeOrgFile(t, mediaPath, "episode")
lib := model.Library{
Name: "剧集",
Path: libRoot,
Type: "tv",
Enabled: true,
}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
media := model.Media{
LibraryID: lib.ID,
Title: "Spy.x.Family.S01E01.2022.1080p.WEB-DL",
Path: mediaPath,
SeasonNum: 1,
EpisodeNum: 1,
ScrapeStatus: "matched",
}
if err := repos.Media.Upsert(t.Context(), &media); err != nil {
t.Fatal(err)
}
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, scraper)
_, scrapes := scanner.ScanAndScrapeLibrariesForPath(t.Context(), libRoot, "", true)
if len(scrapes) != 1 || scrapes[0].Matched != 1 || scrapes[0].Processed != 1 || scrapes[0].Error != "" || scrapes[0].Skipped {
t.Fatalf("scrapes = %#v, want one repaired matched release row", scrapes)
}
var got model.Media
if err := repos.DB.First(&got, "path = ?", mediaPath).Error; err != nil {
t.Fatal(err)
}
if got.Title != "间谍过家家" || got.TMDbID != 12345 || got.ScrapeStatus != "matched" {
t.Fatalf("media = title=%q tmdb=%d status=%q, want localized matched metadata", got.Title, got.TMDbID, got.ScrapeStatus)
}
}
func TestOrganizeResultNeedsVisibilitySyncIgnoresScannedDuplicates(t *testing.T) {
if OrganizeResultNeedsVisibilitySync(&OrganizeResult{
Skipped: 1,
+36
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"path/filepath"
"strings"
"github.com/ShukeBta/MediaStationGo/internal/model"
@@ -84,3 +85,38 @@ func (o *OrganizerService) autoAddLibraryEnabled(ctx context.Context) bool {
}
return parseBoolSetting(v, true)
}
func (o *OrganizerService) effectiveOrganizeOverrides(opts OrganizeOptions, explicitDest string) (string, string) {
mediaType := normalizeOrganizeMediaType(opts.MediaType)
category := sanitizeFilename(strings.TrimSpace(opts.MediaCategory))
hasExplicitLayout := mediaType != "" || category != ""
if category != "" {
if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(category); impliedType != "" {
category = normalizedCategory
if mediaType == "" {
mediaType = impliedType
}
}
return mediaType, category
}
if hasExplicitLayout {
if layout := o.organizeLayoutFromDestPath(explicitDest); layout.Category != "" {
category = sanitizeFilename(layout.Category)
if mediaType == "" {
mediaType = layout.MediaType
}
}
}
return mediaType, category
}
func (o *OrganizerService) organizeLayoutFromDestPath(dest string) organizeDirectoryLayout {
dest = filepath.Clean(strings.TrimSpace(dest))
if dest == "" || dest == "." {
return organizeDirectoryLayout{}
}
if mediaType, category := o.mediaTypeForDirectoryCategory(filepath.Base(dest)); mediaType != "" && category != "" {
return organizeDirectoryLayout{MediaType: mediaType, Category: category}
}
return organizeDirectoryLayout{}
}
+5
View File
@@ -32,7 +32,12 @@ var videoExtensions = map[string]struct{}{
".avi": {},
".mov": {},
".webm": {},
".flv": {},
".wmv": {},
".ts": {},
".m2ts": {},
".mts": {},
".vob": {},
".rmvb": {},
".rm": {},
".3gp": {},
+9
View File
@@ -76,6 +76,7 @@ func (s *ScannerService) maybeGenerateSTRMAfterScan(libraryID string) {
Enabled: true,
IncludeLocal: true,
Overwrite: true,
PreserveTree: s.autoSTRMPreserveTree(ctx),
SkipSettingsSave: true,
}
if outDir, scope := s.autoSTRMOutputDir(ctx); outDir != "" {
@@ -103,3 +104,11 @@ func (s *ScannerService) autoSTRMOutputDir(ctx context.Context) (string, string)
scope, _ := s.repo.Setting.Get(ctx, "strm.output_scope")
return resolveMappedDestinationPath(strings.TrimSpace(outDir)), strings.ToLower(strings.TrimSpace(scope))
}
func (s *ScannerService) autoSTRMPreserveTree(ctx context.Context) bool {
if s == nil || s.repo == nil || s.repo.Setting == nil {
return false
}
value, err := s.repo.Setting.Get(ctx, "strm.preserve_tree")
return err == nil && parseBoolSetting(value, false)
}
+21 -1
View File
@@ -154,7 +154,7 @@ func (s *ScraperService) scrapeCandidateRows(ctx context.Context, libraryID stri
statusFilter += " OR scrape_status = ?"
statusArgs = append(statusArgs, "no_match")
}
if options.IncludeMatched {
if options.IncludeMatched || options.RefreshWeakMatched {
statusFilter += " OR scrape_status = ?"
statusArgs = append(statusArgs, "matched")
}
@@ -168,9 +168,29 @@ func (s *ScraperService) scrapeCandidateRows(ctx context.Context, libraryID stri
Find(&rows).Error; err != nil {
return nil, err
}
if options.RefreshWeakMatched && !options.IncludeMatched {
rows = filterWeakMatchedScrapeRows(rows)
}
return rows, nil
}
func filterWeakMatchedScrapeRows(rows []model.Media) []model.Media {
out := rows[:0]
for _, row := range rows {
if shouldScrapeCandidateRow(row) {
out = append(out, row)
}
}
return out
}
func shouldScrapeCandidateRow(media model.Media) bool {
if strings.TrimSpace(media.ScrapeStatus) != "matched" {
return true
}
return organizeMediaTitleLooksLikeRelease(media.Title)
}
func (s *ScraperService) notifyScrapeFailed(m model.Media, err error) {
if s == nil || s.notify == nil || err == nil {
return
+73
View File
@@ -23,6 +23,7 @@ func (s *ScraperService) matchFromMediaExternalIDs(ctx context.Context, m *model
}
if match := s.manualTMDbMatchByID(ctx, m.TMDbID, normalizeMediaType(mediaType, m.Title, "")); match != nil {
if s.mediaExternalIDMatchTrusted(m, lib, match, "tmdb") {
preferExistingLocalizedEpisodeTitle(m, lib, match)
return match
}
}
@@ -30,6 +31,7 @@ func (s *ScraperService) matchFromMediaExternalIDs(ctx context.Context, m *model
if strings.TrimSpace(m.DoubanID) != "" && s.douban != nil && s.douban.Enabled() {
if match, err := s.douban.GetMatchByID(ctx, strings.TrimSpace(m.DoubanID)); err == nil && match != nil {
if s.mediaExternalIDMatchTrusted(m, lib, match, "douban") {
preferExistingLocalizedEpisodeTitle(m, lib, match)
return match
}
} else if err != nil {
@@ -39,6 +41,7 @@ func (s *ScraperService) matchFromMediaExternalIDs(ctx context.Context, m *model
if m.BangumiID > 0 && s.bangumi != nil && s.bangumi.Enabled() {
if match, err := s.bangumi.GetSubject(ctx, m.BangumiID); err == nil && match != nil {
if s.mediaExternalIDMatchTrusted(m, lib, match, "bangumi") {
preferExistingLocalizedEpisodeTitle(m, lib, match)
return match
}
} else if err != nil {
@@ -48,6 +51,7 @@ func (s *ScraperService) matchFromMediaExternalIDs(ctx context.Context, m *model
if strings.TrimSpace(m.TheTVDBID) != "" && s.thetvdb != nil && s.thetvdb.Enabled() {
if match, err := s.thetvdb.GetSeriesMatchByID(ctx, strings.TrimSpace(m.TheTVDBID)); err == nil && match != nil {
if s.mediaExternalIDMatchTrusted(m, lib, match, "thetvdb") {
preferExistingLocalizedEpisodeTitle(m, lib, match)
return match
}
} else if err != nil {
@@ -61,9 +65,15 @@ func (s *ScraperService) mediaExternalIDMatchTrusted(m *model.Media, lib *model.
if match == nil || strings.TrimSpace(match.Title) == "" {
return false
}
if mediaPathHintMatchesExternalID(m, lib, match, source) {
return true
}
if !mediaIsEpisodic(m, lib) {
return true
}
if mediaExternalIDSourceMatches(m, match, source) && mediaExternalIDLanguageFallbackTrusted(m, match) {
return true
}
for _, candidate := range scrapeQueryCandidates(m, lib) {
if unsafeAutomaticEpisodeQuery(candidate) {
continue
@@ -86,6 +96,69 @@ func (s *ScraperService) mediaExternalIDMatchTrusted(m *model.Media, lib *model.
return false
}
func mediaExternalIDSourceMatches(m *model.Media, match *Match, source string) bool {
if m == nil || match == nil {
return false
}
switch strings.ToLower(strings.TrimSpace(source)) {
case "tmdb":
return m.TMDbID > 0 && match.TMDbID == m.TMDbID
case "bangumi":
return m.BangumiID > 0 && match.BangumiID == m.BangumiID
case "douban":
return strings.TrimSpace(m.DoubanID) != "" &&
strings.TrimSpace(match.DoubanID) == strings.TrimSpace(m.DoubanID)
case "thetvdb":
return strings.TrimSpace(m.TheTVDBID) != "" &&
strings.TrimSpace(match.TheTVDBID) == strings.TrimSpace(m.TheTVDBID)
default:
return false
}
}
func mediaPathHintMatchesExternalID(m *model.Media, lib *model.Library, match *Match, source string) bool {
if m == nil || match == nil {
return false
}
_, hints := pathHintMetadata(m.Path, mediaIsEpisodic(m, lib))
switch strings.ToLower(strings.TrimSpace(source)) {
case "tmdb":
return hints.TMDbID > 0 && hints.TMDbID == match.TMDbID
case "bangumi":
return hints.BangumiID > 0 && hints.BangumiID == match.BangumiID
case "douban":
return strings.TrimSpace(hints.DoubanID) != "" &&
strings.TrimSpace(hints.DoubanID) == strings.TrimSpace(match.DoubanID)
case "thetvdb":
return strings.TrimSpace(hints.TheTVDBID) != "" &&
strings.TrimSpace(hints.TheTVDBID) == strings.TrimSpace(match.TheTVDBID)
default:
return false
}
}
func mediaExternalIDLanguageFallbackTrusted(m *model.Media, match *Match) bool {
if m == nil || match == nil || containsCJK(match.Title) {
return false
}
title := strings.TrimSpace(m.Title)
return title != "" &&
containsCJK(title) &&
!unsafeAutomaticEpisodeQuery(title) &&
!organizeMediaTitleLooksLikeRelease(title)
}
func preferExistingLocalizedEpisodeTitle(m *model.Media, lib *model.Library, match *Match) {
if !mediaIsEpisodic(m, lib) || !mediaExternalIDLanguageFallbackTrusted(m, match) {
return
}
title := strings.TrimSpace(m.Title)
if strings.TrimSpace(match.OriginalName) == "" {
match.OriginalName = strings.TrimSpace(match.Title)
}
match.Title = title
}
func (s *ScraperService) applyFanartArtwork(ctx context.Context, match *Match) {
if s == nil || s.fanart == nil || !s.fanart.Enabled() || match == nil {
return
+54
View File
@@ -0,0 +1,54 @@
package service
import (
"testing"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
func TestMediaExternalIDMatchTrustsExactEpisodeID(t *testing.T) {
scraper := &ScraperService{log: zap.NewNop()}
media := &model.Media{
Title: "电锯人",
Path: "/media/anime/电锯人/Season 01/电锯人 - S01E01.mkv",
TMDbID: 114410,
SeasonNum: 1,
EpisodeNum: 1,
}
lib := &model.Library{Type: "anime", Path: "/media/anime"}
match := &Match{
Title: "チェンソーマン",
OriginalName: "チェンソーマン",
TMDbID: 114410,
}
if !scraper.mediaExternalIDMatchTrusted(media, lib, match, "tmdb") {
t.Fatal("exact external id match should be trusted for episodic media")
}
}
func TestPreferExistingLocalizedMediaTitleKeepsCleanChineseTitle(t *testing.T) {
media := &model.Media{Title: "电锯人", SeasonNum: 1, EpisodeNum: 1}
lib := &model.Library{Type: "anime"}
match := &Match{Title: "チェンソーマン"}
preferExistingLocalizedEpisodeTitle(media, lib, match)
if match.Title != "电锯人" || match.OriginalName != "チェンソーマン" {
t.Fatalf("match title=%q original=%q, want localized title with original preserved", match.Title, match.OriginalName)
}
}
func TestPreferExistingLocalizedMediaTitleIgnoresReleaseTitle(t *testing.T) {
media := &model.Media{Title: "电锯人.S01E01.1080p.WEB-DL", SeasonNum: 1, EpisodeNum: 1}
lib := &model.Library{Type: "anime"}
match := &Match{Title: "チェンソーマン"}
preferExistingLocalizedEpisodeTitle(media, lib, match)
if match.Title != "チェンソーマン" {
t.Fatalf("release-like media title should not replace provider title, got %q", match.Title)
}
}
+1
View File
@@ -3,6 +3,7 @@ package service
type ScrapeOptions struct {
RetryNoMatch bool
IncludeMatched bool
RefreshWeakMatched bool
EpisodeArtwork *bool
DeferEpisodeDetails bool
}
+17 -3
View File
@@ -26,7 +26,7 @@ func TestOrganizeMetadataTrustsCleanedBroadcastReleaseQuery(t *testing.T) {
}
}
func TestOrganizeMetadataTrustsChineseReleaseAliasWithoutOriginalName(t *testing.T) {
func TestOrganizeMetadataRejectsChineseReleaseAliasWithoutOriginalName(t *testing.T) {
match := &Match{
Title: "莫离",
Languages: []string{"zh"},
@@ -34,8 +34,22 @@ func TestOrganizeMetadataTrustsChineseReleaseAliasWithoutOriginalName(t *testing
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")
if organizeMetadataMatchTrusted("the first jasmine", 2026, match) {
t.Fatal("multi-word English release alias must not be trusted without title or original-name evidence")
}
}
func TestOrganizeMetadataRejectsLooseChineseOriginEnglishAlias(t *testing.T) {
match := &Match{
Title: "镖人",
OriginalName: "Biao Ren",
Languages: []string{"zh"},
Countries: []string{"CN"},
Year: 2023,
TMDbID: 107463,
}
if organizeMetadataMatchTrusted("blades of the guardians", 2023, match) {
t.Fatal("unrelated English query must not trust Chinese-origin metadata just because it has multiple Latin tokens")
}
}
+1
View File
@@ -45,6 +45,7 @@ type TorrentItem struct {
ID string `json:"id"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Labels string `json:"labels,omitempty"`
Category string `json:"category"`
Size int64 `json:"size"`
Seeders int `json:"seeders"`
@@ -9,6 +9,11 @@ import (
"time"
)
var (
nexusPHPFreeLabelRE = regexp.MustCompile(`(?i)(class="[^"]*(?:free|free2|twoupfree|free_download)[^"]*"|促销|免费)`)
nexusPHPRiskLabelRE = regexp.MustCompile(`(?i)(?:class|title|alt)=["'][^"']*\bhr\b[^"']*["']`)
)
// parseNexusPHPHTML 解析 NexusPHP 种子列表 HTML。
func parseNexusPHPHTML(html, siteName, baseURL string) (*SiteSearchResult, error) {
result := &SiteSearchResult{
@@ -56,6 +61,7 @@ func parseNexusPHPRow(row, baseURL string) TorrentItem {
item.ID = link.query.Get("id")
item.Title = nexusPHPTitleFromLink(*link)
item.Subtitle = nexusPHPSubtitle(row)
item.Labels = nexusPHPRowLabels(row)
item.DetailURL = resolveSiteURL(baseURL, link.href)
}
if link := firstNexusPHPLink(row, "download.php"); link != nil {
@@ -166,6 +172,28 @@ func nexusPHPSubtitle(row string) string {
return ""
}
func nexusPHPRowLabels(row string) string {
labels := make([]string, 0, 4)
lower := strings.ToLower(row)
add := func(label string) {
for _, existing := range labels {
if existing == label {
return
}
}
labels = append(labels, label)
}
if nexusPHPFreeLabelRE.MatchString(row) {
add("free")
}
if strings.Contains(lower, "hit and run") || strings.Contains(lower, "hit&run") || strings.Contains(lower, "h&r") ||
nexusPHPRiskLabelRE.MatchString(row) ||
strings.Contains(row, "禁转") || strings.Contains(row, "禁止转载") || strings.Contains(row, "禁下") || strings.Contains(row, "禁止下载") {
add("HR")
}
return strings.Join(labels, " ")
}
func nexusPHPIntByClass(row, className string) (int, bool) {
pattern := regexp.MustCompile(`(?is)<td\b[^>]*(?:class|id)\s*=\s*["'][^"']*` + regexp.QuoteMeta(className) + `[^"']*["'][^>]*>(.*?)</td>`)
if match := pattern.FindStringSubmatch(row); len(match) >= 2 {
+25
View File
@@ -266,6 +266,31 @@ func TestParseNexusPHPHTMLModernRows(t *testing.T) {
}
}
func TestParseNexusPHPHTMLCapturesRiskAndPromotionLabels(t *testing.T) {
page := `
<table class="torrents">
<tr class="torrent">
<td><a href="/details.php?id=456" title="Some Show S01E01 1080p WEB-DL">Some Show</a></td>
<td><img class="pro_free" alt="免费" /><span title="HR">H&R</span></td>
<td><a href="/download.php?id=456">下载</a></td>
</tr>
</table>`
result, err := parseNexusPHPHTML(page, "Nexus", "https://pt.example")
if err != nil {
t.Fatal(err)
}
if len(result.Items) != 1 {
t.Fatalf("items = %#v", result.Items)
}
item := result.Items[0]
if !item.Free {
t.Fatalf("item.Free = false, want free promotion detected: %#v", item)
}
if !strings.Contains(item.Labels, "HR") || !strings.Contains(item.Labels, "free") {
t.Fatalf("labels = %q, want HR and free", item.Labels)
}
}
func TestParseNexusPHPHTMLIgnoresUserDetailsLinks(t *testing.T) {
page := `
<table>
+2
View File
@@ -20,6 +20,7 @@ type SearchResult struct {
SiteID string `json:"site_id"`
Title string `json:"title"`
Subtitle string `json:"subtitle,omitempty"`
Labels string `json:"labels,omitempty"`
TorrentURL string `json:"torrent_url"`
DownloadURL string `json:"download_url"`
Category string `json:"category,omitempty"`
@@ -202,6 +203,7 @@ func siteSearchResultsFromItems(site model.Site, result *SiteSearchResult, keywo
SiteID: site.ID,
Title: item.Title,
Subtitle: item.Subtitle,
Labels: item.Labels,
TorrentURL: item.DetailURL,
DownloadURL: item.DownloadURL,
Category: item.Category,
+8
View File
@@ -19,6 +19,7 @@ type GenerateSTRMOptions struct {
Enabled bool `json:"enabled"`
Overwrite bool `json:"overwrite"`
IncludeLocal bool `json:"include_local"`
PreserveTree bool `json:"preserve_tree"`
PlaybackToken string `json:"-"`
SkipSettingsSave bool `json:"-"`
}
@@ -127,6 +128,7 @@ func (s *STRMService) GenerateForAllLibraries(ctx context.Context, opts Generate
if baseOutputDir != "" && baseOutputDir != "." && s.repo.Setting != nil {
_ = s.repo.Setting.Set(ctx, "strm.output_dir", baseOutputDir)
_ = s.repo.Setting.Set(ctx, "strm.output_scope", "all")
_ = s.repo.Setting.Set(ctx, "strm.preserve_tree", strconv.FormatBool(opts.PreserveTree))
result.OutputDir = baseOutputDir
}
return result, nil
@@ -160,6 +162,7 @@ func (s *STRMService) saveSTRMGenerationSettings(ctx context.Context, outputDir
_ = s.repo.Setting.Set(ctx, "strm.auto_generate_enabled", strconv.FormatBool(opts.Enabled))
_ = s.repo.Setting.Set(ctx, "strm.output_dir", outputDir)
_ = s.repo.Setting.Set(ctx, "strm.output_scope", "library")
_ = s.repo.Setting.Set(ctx, "strm.preserve_tree", strconv.FormatBool(opts.PreserveTree))
}
func (s *STRMService) librarySTRMMedia(ctx context.Context, libraryID string) ([]model.Media, error) {
@@ -193,6 +196,11 @@ func (s *STRMService) generateOne(ctx context.Context, lib model.Library, media
return item
}
rel := s.strmRelativePath(lib, media)
if opts.PreserveTree {
if treeRel := s.strmTreeRelativePath(media); treeRel != "" {
rel = treeRel
}
}
if rel == "" {
item.Action = "skipped"
item.Reason = "cannot build file name"
-2
View File
@@ -177,8 +177,6 @@ func strmCanonicalCategory(part string) string {
return "综艺"
case "少儿":
return "儿童"
case "未分类":
return "欧美剧"
case "国产动漫":
return "国漫"
case "番剧", "日漫", "日本动漫", "日本动画":
+84
View File
@@ -217,6 +217,11 @@ func TestSTRMLibraryOutputSubdirUsesLibraryCategoryPath(t *testing.T) {
lib: model.Library{Name: "欧美剧", Path: `F:\media\电视剧\欧美剧`, Type: "tv"},
want: filepath.Join("电视剧", "欧美剧"),
},
{
name: "uncategorized tv category stays uncategorized",
lib: model.Library{Name: "OpenList · 未分类", Path: BuildCloudLibraryPath("openlist", "/电视剧/未分类", "/电视剧/未分类"), Type: "tv"},
want: filepath.Join("电视剧", "未分类"),
},
{
name: "fallback to type root",
lib: model.Library{Name: "Archive", Path: `F:\archive`, Type: "movie"},
@@ -314,6 +319,85 @@ func TestGenerateSTRMForLibraryUsesPathEpisodeFallback(t *testing.T) {
assertFileContains(t, filepath.Join(libraryOutDir, "南部档案", "Season 01", "南部档案 - S01E02.strm"), "http://nas.example:18080/api/stream/ep-2?token=strm-token")
}
func TestGenerateSTRMForLibraryPreservesSourceTree(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)
}
media := 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",
}
if err := repos.DB.Create(&media).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,
PreserveTree: true,
PlaybackToken: "strm-token",
})
if err != nil {
t.Fatal(err)
}
if res.Generated != 1 || res.Skipped != 0 {
t.Fatalf("result = %#v, want generated=1 skipped=0", res)
}
wantPath := filepath.Join(outDir, "电视剧", "国产剧", "南部档案", "Season 01", "Archives.The.Nanyang.Mystery.S01E01.strm")
assertFileContains(t, wantPath, "http://nas.example:18080/api/stream/ep-1?token=strm-token")
if got, err := repos.Setting.Get(t.Context(), "strm.preserve_tree"); err != nil || got != "true" {
t.Fatalf("strm.preserve_tree = %q, %v; want true", got, err)
}
}
func TestGenerateSTRMForLibraryCanSkipLocalMedia(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{})
repos := repository.New(db)
lib := model.Library{Name: "电影", Path: "cloud://openlist/电影", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
rows := []model.Media{
{Base: model.Base{ID: "cloud-media"}, LibraryID: lib.ID, Title: "云盘电影", Year: 2026, Path: "cloud://openlist/电影/云盘电影.mkv", STRMURL: "/api/cloud/play/openlist?ref=movie"},
{Base: model.Base{ID: "local-media"}, LibraryID: lib.ID, Title: "本地电影", Year: 2025, Path: filepath.Join(t.TempDir(), "本地电影.mkv")},
}
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: false,
PlaybackToken: "strm-token",
})
if err != nil {
t.Fatal(err)
}
if res.Generated != 1 || res.Skipped != 1 {
t.Fatalf("result = %#v, want generated=1 skipped=1", res)
}
assertFileContains(t, filepath.Join(outDir, "电影", "云盘电影 (2026)", "云盘电影 (2026).strm"), "http://nas.example:18080/api/stream/cloud-media?token=strm-token")
if _, err := os.Stat(filepath.Join(outDir, "电影", "本地电影 (2025)", "本地电影 (2025).strm")); !os.IsNotExist(err) {
t.Fatalf("local media strm should not exist, stat err=%v", err)
}
}
func TestGenerateSTRMForAllLibrariesWritesPerLibraryFolders(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{})
repos := repository.New(db)
+150
View File
@@ -0,0 +1,150 @@
package service
import (
"os"
"path/filepath"
"testing"
"go.uber.org/zap"
)
func TestGenerateSTRMFromTreeOverwriteAndTraversal(t *testing.T) {
outDir := filepath.Join(t.TempDir(), "strm")
existing := filepath.Join(outDir, "Movies", "Movie.strm")
if err := os.MkdirAll(filepath.Dir(existing), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(existing, []byte("old\n"), 0o644); err != nil {
t.Fatal(err)
}
svc := NewSTRMService(zap.NewNop(), nil, nil)
res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
Provider: "openlist",
Paths: []string{"Movies/Movie.mkv", "../escape.mkv"},
OutputDir: outDir,
})
if err != nil {
t.Fatal(err)
}
if res.Generated != 0 || res.Skipped != 1 || len(res.Errors) != 1 {
t.Fatalf("result = %#v, want existing skipped and traversal rejected", res)
}
if got := readSTRM(t, existing); got != "old" {
t.Fatalf("existing strm = %q, want unchanged", got)
}
res, err = svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
Provider: "openlist",
Paths: []string{"Movies/Movie.mkv"},
OutputDir: outDir,
Overwrite: true,
})
if err != nil {
t.Fatal(err)
}
if res.Updated != 1 {
t.Fatalf("updated = %d, want 1", res.Updated)
}
if got := readSTRM(t, existing); got == "old" {
t.Fatalf("existing strm should be overwritten, got %q", got)
}
}
func TestGenerateSTRMFromTreeCleanupStaleFiles(t *testing.T) {
outDir := filepath.Join(t.TempDir(), "strm")
current := filepath.Join(outDir, "Shows", "Show.S01E01.strm")
stale := filepath.Join(outDir, "Shows", "Show.S01E02.strm")
for _, file := range []string{current, stale} {
if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(file, []byte("old\n"), 0o644); err != nil {
t.Fatal(err)
}
}
svc := NewSTRMService(zap.NewNop(), nil, nil)
res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
Provider: "openlist",
Paths: []string{"Shows/Show.S01E01.mkv"},
OutputDir: outDir,
Cleanup: true,
})
if err != nil {
t.Fatal(err)
}
if res.Skipped != 1 || res.Cleaned != 1 || len(res.Errors) != 0 {
t.Fatalf("result = %#v, want current skipped and one stale file cleaned", res)
}
if _, err := os.Stat(current); err != nil {
t.Fatalf("current strm should remain: %v", err)
}
if _, err := os.Stat(stale); !os.IsNotExist(err) {
t.Fatalf("stale strm should be removed, stat err=%v", err)
}
}
func TestGenerateSTRMFromTreeCleanupWithOutputPrefixStaysInPrefix(t *testing.T) {
outDir := filepath.Join(t.TempDir(), "strm")
current := filepath.Join(outDir, "电影", "欧美电影", "Movie.strm")
staleInPrefix := filepath.Join(outDir, "电影", "欧美电影", "Old.strm")
otherCategory := filepath.Join(outDir, "电视剧", "国产剧", "Show.strm")
for _, file := range []string{current, staleInPrefix, otherCategory} {
if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(file, []byte("old\n"), 0o644); err != nil {
t.Fatal(err)
}
}
svc := NewSTRMService(zap.NewNop(), nil, nil)
res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
Provider: "openlist",
Paths: []string{"Movie.mkv"},
OutputPrefix: "电影/欧美电影",
OutputDir: outDir,
Cleanup: true,
})
if err != nil {
t.Fatal(err)
}
if res.Skipped != 1 || res.Cleaned != 1 || len(res.Errors) != 0 {
t.Fatalf("result = %#v, want one stale file cleaned inside prefix only", res)
}
if _, err := os.Stat(staleInPrefix); !os.IsNotExist(err) {
t.Fatalf("stale prefixed strm should be removed, stat err=%v", err)
}
if _, err := os.Stat(otherCategory); err != nil {
t.Fatalf("other category strm should remain: %v", err)
}
}
func TestGenerateSTRMFromTreeCleanupSkipsWhenNoValidSources(t *testing.T) {
outDir := filepath.Join(t.TempDir(), "strm")
existing := filepath.Join(outDir, "Movies", "Movie.strm")
if err := os.MkdirAll(filepath.Dir(existing), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(existing, []byte("keep\n"), 0o644); err != nil {
t.Fatal(err)
}
svc := NewSTRMService(zap.NewNop(), nil, nil)
res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
Provider: "openlist",
Paths: []string{"Movies/poster.jpg"},
OutputDir: outDir,
Cleanup: true,
})
if err != nil {
t.Fatal(err)
}
if res.Cleaned != 0 || len(res.Errors) != 0 {
t.Fatalf("result = %#v, want cleanup skipped without valid media sources", res)
}
if got := readSTRM(t, existing); got != "keep" {
t.Fatalf("existing strm = %q, want kept", got)
}
}
+141
View File
@@ -0,0 +1,141 @@
package service
import (
"context"
"errors"
"os"
"path"
"path/filepath"
"strings"
)
type GenerateSTRMTreeOptions struct {
Provider string `json:"provider"`
TreeText string `json:"tree_text,omitempty"`
Paths []string `json:"paths,omitempty"`
SourceRoot string `json:"source_root,omitempty"`
OutputPrefix string `json:"output_prefix,omitempty"`
OutputDir string `json:"output_dir"`
BaseURL string `json:"base_url,omitempty"`
Overwrite bool `json:"overwrite"`
Cleanup bool `json:"cleanup"`
}
type strmTreeSource struct {
Provider string
Path string
RefPath string
}
func (s *STRMService) GenerateFromTree(ctx context.Context, opts GenerateSTRMTreeOptions) (*GenerateSTRMResult, error) {
provider := normalizeSTRMTreeProvider(opts.Provider)
if provider == "" {
return nil, errors.New("provider required")
}
outputDir := resolveMappedDestinationPath(strings.TrimSpace(opts.OutputDir))
if outputDir == "" || outputDir == "." {
return nil, errors.New("output_dir required")
}
if err := os.MkdirAll(outputDir, 0o755); err != nil { // #nosec G301 -- STRM output directories must be readable by media players.
return nil, err
}
result := &GenerateSTRMResult{LibraryID: provider, OutputDir: outputDir}
expectedFiles := make(map[string]struct{})
for _, source := range collectSTRMTreeSources(opts) {
select {
case <-ctx.Done():
return result, ctx.Err()
default:
}
item := generateTreeSTRMItem(outputDir, source, opts)
if item.FilePath != "" && item.Action != "error" {
expectedFiles[filepath.Clean(item.FilePath)] = struct{}{}
}
result.addItem(item)
}
if opts.Cleanup && len(expectedFiles) > 0 {
cleanupDir := outputDir
if prefix, err := strmTreeOutputPrefixPath(opts.OutputPrefix); err == nil && prefix != "" {
cleanupDir = filepath.Join(outputDir, prefix)
}
cleaned, err := removeStaleSTRMFiles(cleanupDir, expectedFiles)
result.Cleaned += cleaned
if err != nil {
result.Errors = append(result.Errors, err.Error())
}
}
return result, nil
}
func generateTreeSTRMItem(outputDir string, source strmTreeSource, opts GenerateSTRMTreeOptions) GenerateSTRMItem {
relSource := strmTreeRelativeSource(source.Path, opts.SourceRoot)
relPath, err := strmTreeOutputRelativePath(relSource)
item := GenerateSTRMItem{Title: strings.TrimSuffix(path.Base(source.Path), path.Ext(source.Path))}
if err != nil {
item.Action = "error"
item.Reason = err.Error()
return item
}
prefix, err := strmTreeOutputPrefixPath(opts.OutputPrefix)
if err != nil {
item.Action = "error"
item.Reason = err.Error()
return item
}
filePath := filepath.Join(outputDir, prefix, relPath)
item.FilePath = filePath
item.URL = absolutizeSTRMURL(BuildRelativeCloudPlayURL(source.Provider, strmTreeCloudRef(source.cloudRefPath(), opts.SourceRoot)), opts.BaseURL)
if _, err := os.Stat(filePath); err == nil && !opts.Overwrite {
item.Action = "skipped"
item.Reason = "target exists"
return item
}
action := "generated"
if _, err := os.Stat(filePath); err == nil {
action = "updated"
}
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { // #nosec G301 -- STRM output directories must be readable by media players.
item.Action = "error"
item.Reason = err.Error()
return item
}
if err := os.WriteFile(filePath, []byte(item.URL+"\n"), 0o644); err != nil { // #nosec G306 -- STRM files are media sidecars intended to be readable by players.
item.Action = "error"
item.Reason = err.Error()
return item
}
item.Action = action
return item
}
func collectSTRMTreeSources(opts GenerateSTRMTreeOptions) []strmTreeSource {
fallbackProvider := normalizeSTRMTreeProvider(opts.Provider)
out := make([]strmTreeSource, 0, len(opts.Paths))
seen := map[string]struct{}{}
add := func(value string) {
source := normalizeSTRMTreeSourceWithProvider(value, fallbackProvider)
if source.Provider == "" || source.Path == "" || !strmTreeSourceIsVideo(source.Path) {
return
}
key := strings.ToLower(source.Provider) + "\x00" + strings.ToLower(source.Path) + "\x00" + strings.ToLower(source.cloudRefPath())
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
out = append(out, source)
}
for _, value := range opts.Paths {
add(value)
}
for _, value := range parseSTRMTreeText(opts.TreeText) {
add(value)
}
return out
}
func (s strmTreeSource) cloudRefPath() string {
if strings.TrimSpace(s.RefPath) != "" {
return s.RefPath
}
return s.Path
}
+83
View File
@@ -0,0 +1,83 @@
package service
import (
"os"
"path/filepath"
"strings"
"testing"
"go.uber.org/zap"
)
func TestGenerateSTRMFromTreePaths(t *testing.T) {
outDir := filepath.Join(t.TempDir(), "strm")
svc := NewSTRMService(zap.NewNop(), nil, nil)
res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
Provider: "115",
Paths: []string{"/电视剧/国产剧/南部档案/Season 01/Archives.S01E01.mkv", "/电视剧/国产剧/南部档案/poster.jpg", "/电视剧/国产剧/南部档案/Existing.strm"},
TreeText: "电视剧\n└── 国产剧\n └── 南部档案\n └── Existing.Tree.strm",
SourceRoot: "/电视剧",
OutputDir: outDir,
BaseURL: "https://media.example.com",
})
if err != nil {
t.Fatal(err)
}
if res.Generated != 1 || res.Skipped != 0 || len(res.Errors) != 0 {
t.Fatalf("result = %#v, want one generated video and ignored sidecar", res)
}
path := filepath.Join(outDir, "国产剧", "南部档案", "Season 01", "Archives.S01E01.strm")
got := readSTRM(t, path)
if !strings.HasPrefix(got, "https://media.example.com/api/cloud/play/cloud115?") {
t.Fatalf("strm url = %q, want cloud115 play url", got)
}
if !strings.Contains(got, "ref=%2F%E7%94%B5%E8%A7%86%E5%89%A7%2F%E5%9B%BD%E4%BA%A7%E5%89%A7%2F%E5%8D%97%E9%83%A8%E6%A1%A3%E6%A1%88%2FSeason+01%2FArchives.S01E01.mkv") {
t.Fatalf("strm url = %q, missing encoded source ref", got)
}
if _, err := os.Stat(filepath.Join(outDir, "国产剧", "南部档案", "Existing.strm")); !os.IsNotExist(err) {
t.Fatalf("existing .strm source should be ignored by tree generator, stat err=%v", err)
}
if _, err := os.Stat(filepath.Join(outDir, "电视剧", "国产剧", "南部档案", "Existing.Tree.strm")); !os.IsNotExist(err) {
t.Fatalf("tree .strm source should be ignored by tree generator, stat err=%v", err)
}
}
func TestGenerateSTRMFromTreeSupportsCommonVideoExtensions(t *testing.T) {
outDir := filepath.Join(t.TempDir(), "strm")
svc := NewSTRMService(zap.NewNop(), nil, nil)
res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
Provider: "openlist",
Paths: []string{
"/Movies/BluRay.Stream.2026.m2ts",
"/Movies/Camera.Source.2026.MTS",
"/Movies/DVD.Feature.2026.vob",
"/Movies/Legacy.Video.2026.wmv",
"/Movies/Web.Legacy.2026.flv",
"/Movies/Disc.Image.2026.iso",
},
OutputDir: outDir,
})
if err != nil {
t.Fatal(err)
}
if res.Generated != 5 || len(res.Errors) != 0 {
t.Fatalf("result = %#v, want five common video sources generated and iso ignored", res)
}
for _, name := range []string{
"BluRay.Stream.2026",
"Camera.Source.2026",
"DVD.Feature.2026",
"Legacy.Video.2026",
"Web.Legacy.2026",
} {
got := readSTRM(t, filepath.Join(outDir, "Movies", name+".strm"))
if !strings.Contains(got, "/api/cloud/play/openlist?") {
t.Fatalf("%s strm url = %q, want cloud play url", name, got)
}
}
if _, err := os.Stat(filepath.Join(outDir, "Movies", "Disc.Image.2026.strm")); !os.IsNotExist(err) {
t.Fatalf("iso source should stay ignored by tree generator, stat err=%v", err)
}
}
+284
View File
@@ -0,0 +1,284 @@
package service
import (
"net/url"
"path"
"regexp"
"strings"
)
var strmTreeListPrefixRE = regexp.MustCompile(`^\s*(?:[-*+•·]\s+|\d{1,4}[\.)、]\s+)`)
var strmTreeURLPathQueryKeys = []string{"path", "ref", "file", "filepath", "filename", "name"}
func normalizeSTRMTreeProvider(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "115", "115pan", "pan115", "cloud115":
return "cloud115"
case "alist", "openlist", "webdav", "clouddrive2", "s3":
return strings.ToLower(strings.TrimSpace(raw))
default:
return strings.ToLower(strings.TrimSpace(raw))
}
}
func normalizeSTRMTreeSource(raw string) string {
value := strings.TrimSpace(strings.Trim(raw, `"'`))
if value == "" {
return ""
}
if strings.HasPrefix(strings.ToLower(value), "cloud://") {
parts := strmLibraryPathParts(value)
if len(parts) == 0 {
return ""
}
return "/" + path.Join(parts...)
}
if parsed, err := url.Parse(value); err == nil && parsed.Scheme != "" && parsed.Scheme != "file" {
if ref := strmTreeSourceFromURL(parsed); ref != "" {
value = ref
}
}
value = trimSTRMTreeMediaMetadata(value)
value = strings.ReplaceAll(value, "\\", "/")
value = pathAfterWindowsDrivePrefix(value)
value = cleanSTRMTreePathDecorations(value)
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if strmTreeHasUnsafeSegment(value) {
return strings.Trim(value, "/")
}
if strings.HasPrefix(value, "/") {
return path.Clean(value)
}
return strings.TrimPrefix(path.Clean("/"+value), "/")
}
func normalizeSTRMTreeSourceWithProvider(raw, fallbackProvider string) strmTreeSource {
provider := normalizeSTRMTreeProvider(fallbackProvider)
value := strings.TrimSpace(strings.Trim(raw, `"'`))
if value == "" {
return strmTreeSource{}
}
if info, ok := ParseCloudLibraryMount(value); ok {
source := normalizeSTRMTreeSource(info.DisplayDir)
if source == "" {
return strmTreeSource{}
}
ref := normalizeSTRMTreeSource(info.ScanDir)
if ref == "" {
ref = source
}
return strmTreeSource{Provider: normalizeSTRMTreeProvider(info.Provider), Path: source, RefPath: ref}
}
if parsed, err := url.Parse(value); err == nil {
if cloudProvider, ref := strmTreeCloudPlaySourceFromURL(parsed); cloudProvider != "" && ref != "" {
source := normalizeSTRMTreeSource(ref)
if source == "" {
return strmTreeSource{}
}
return strmTreeSource{Provider: normalizeSTRMTreeProvider(cloudProvider), Path: source}
}
}
source := normalizeSTRMTreeSource(value)
if source == "" {
return strmTreeSource{}
}
return strmTreeSource{Provider: provider, Path: source}
}
func strmTreeCloudPlaySourceFromURL(parsed *url.URL) (string, string) {
if parsed == nil {
return "", ""
}
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
if len(parts) < 4 || !strings.EqualFold(parts[0], "api") || !strings.EqualFold(parts[1], "cloud") || !strings.EqualFold(parts[2], "play") {
return "", ""
}
provider := strings.TrimSpace(parts[3])
if decoded, err := url.PathUnescape(provider); err == nil {
provider = decoded
}
// /api/cloud/play is generated by this app with url.Values, where '+'
// represents a space. Decode the standard query view first so re-importing
// existing STRM links does not turn "My Movie.mkv" into "My+Movie.mkv".
for _, value := range parsed.Query()["ref"] {
if ref := strmTreeURLQueryMediaSource(value); ref != "" {
return provider, ref
}
}
for _, value := range strmTreeRawQueryValues(parsed.RawQuery, "ref") {
if ref := strmTreeURLQueryMediaSource(value); ref != "" {
return provider, ref
}
}
return "", ""
}
func strmTreeSourceFromURL(parsed *url.URL) string {
if parsed == nil {
return ""
}
for _, key := range strmTreeURLPathQueryKeys {
for _, value := range strmTreeRawQueryValues(parsed.RawQuery, key) {
if ref := strmTreeURLQueryMediaSource(value); ref != "" {
return ref
}
}
for _, value := range parsed.Query()[key] {
if ref := strmTreeURLQueryMediaSource(value); ref != "" {
return ref
}
}
}
if parsed.Path != "" && strmTreeURLQueryMediaSource(parsed.Path) != "" {
return parsed.Path
}
return ""
}
func strmTreeRawQueryValues(rawQuery, key string) []string {
key = strings.ToLower(strings.TrimSpace(key))
if rawQuery == "" || key == "" {
return nil
}
values := make([]string, 0, 1)
for _, pair := range strings.Split(rawQuery, "&") {
if pair == "" {
continue
}
rawKey, rawValue, _ := strings.Cut(pair, "=")
decodedKey, err := url.QueryUnescape(rawKey)
if err != nil {
decodedKey = rawKey
}
if strings.ToLower(strings.TrimSpace(decodedKey)) != key {
continue
}
decodedValue, err := url.PathUnescape(rawValue)
if err != nil {
decodedValue = rawValue
}
values = append(values, decodedValue)
}
return values
}
func strmTreeURLQueryMediaSource(raw string) string {
value := strings.TrimSpace(strings.Trim(raw, `"'`))
if value == "" {
return ""
}
if parsed, err := url.Parse(value); err == nil && parsed.Scheme != "" && parsed.Scheme != "file" {
value = parsed.Path
}
value = trimSTRMTreeMediaMetadata(value)
if strmTreeSourceIsVideo(value) {
return value
}
return ""
}
func strmTreeHasUnsafeSegment(value string) bool {
for _, part := range strings.Split(strings.ReplaceAll(value, "\\", "/"), "/") {
if strings.TrimSpace(part) == ".." {
return true
}
}
return false
}
func strmTreeSourceIsVideo(source string) bool {
ext := strings.ToLower(path.Ext(trimSTRMTreeMediaMetadata(cleanSTRMTreeItemName(source))))
if ext == ".strm" {
return false
}
_, ok := videoExtensions[ext]
return ok
}
func cleanSTRMTreePathDecorations(value string) string {
if value == "" {
return ""
}
leadingSlash := strings.HasPrefix(value, "/")
parts := strings.Split(value, "/")
for i, part := range parts {
if part == "" {
continue
}
parts[i] = cleanSTRMTreeItemName(part)
}
cleaned := strings.Join(parts, "/")
if leadingSlash && !strings.HasPrefix(cleaned, "/") {
cleaned = "/" + cleaned
}
return cleaned
}
func cleanSTRMTreeItemName(value string) string {
value = strings.TrimSpace(strings.Trim(value, `"'`))
for {
before := value
value = strings.TrimSpace(strmTreeListPrefixRE.ReplaceAllString(value, ""))
value = strings.TrimSpace(strings.TrimLeft(value, "\ufeff📁📂📄📃🎬🎞🎥📺🗂️"))
lower := strings.ToLower(value)
for _, marker := range []string{
"[file]", "[f]", "[文件]", "[檔案]", "[档案]",
"[dir]", "[d]", "[folder]", "[目录]", "[目錄]", "[文件夹]", "[資料夾]",
"(file)", "(f)", "(文件)", "(檔案)", "(档案)",
"(dir)", "(d)", "(folder)", "(目录)", "(目錄)", "(文件夹)", "(資料夾)",
} {
if strings.HasPrefix(lower, marker) {
value = strings.TrimLeft(strings.TrimSpace(value[len(marker):]), "-_:: ")
break
}
}
if value == before {
return value
}
}
}
func trimSTRMTreeMediaMetadata(value string) string {
value = strings.TrimSpace(value)
lower := strings.ToLower(value)
bestEnd := -1
for ext := range videoExtensions {
from := 0
for {
idx := strings.Index(lower[from:], ext)
if idx < 0 {
break
}
idx += from
end := idx + len(ext)
if strmTreeMediaExtBoundary(value, end) && end > bestEnd {
bestEnd = end
}
from = idx + 1
if from >= len(lower) {
break
}
}
}
if bestEnd > 0 && bestEnd < len(value) {
return strings.TrimSpace(value[:bestEnd])
}
return value
}
func strmTreeMediaExtBoundary(value string, end int) bool {
if end >= len(value) {
return true
}
for _, r := range value[end:] {
if r == '(' || r == '[' || r == '{' || r == '<' || r == '(' || r == '【' || r == ',' || r == ',' {
return true
}
return r == ' ' || r == '\t' || r == '\r' || r == '\n'
}
return true
}
+165
View File
@@ -0,0 +1,165 @@
package service
import (
"path"
"strings"
)
func parseSTRMTreeText(raw string) []string {
var out []string
stack := make([]string, 0, 8)
plainIndents := make([]int, 0, 8)
rootOffset := 0
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimRight(line, " \t\r")
if strings.TrimSpace(line) == "" {
continue
}
if item, level, ok := parseSTRMTreeLine(line); ok {
plainIndents = plainIndents[:0]
level += rootOffset
stack = stack[:min(level, len(stack))]
if strmTreeItemIsVideoSource(item) {
out = append(out, strmTreeJoinedSource(stack, item))
continue
}
stack = append(stack, item)
continue
}
if item, level, ok := parseSTRMTreeContinuationLine(line, len(plainIndents) > 0); ok {
plainIndents = plainIndents[:0]
level += rootOffset
stack = stack[:min(level, len(stack))]
out = append(out, path.Join(append(append([]string{}, stack...), item)...))
continue
}
item := cleanSTRMTreeItemName(line)
if indent := strmTreePlainIndent(line); indent > 0 && !strings.ContainsAny(item, `/\`) {
for len(plainIndents) > 0 && indent <= plainIndents[len(plainIndents)-1] {
plainIndents = plainIndents[:len(plainIndents)-1]
}
level := rootOffset + len(plainIndents)
stack = stack[:min(level, len(stack))]
if strmTreeItemIsVideoSource(item) {
out = append(out, strmTreeJoinedSource(stack, item))
continue
}
stack = append(stack, item)
plainIndents = append(plainIndents, indent)
continue
}
if strmTreeItemIsVideoSource(item) || strings.ContainsAny(item, `/\`) {
plainIndents = plainIndents[:0]
out = append(out, item)
continue
}
stack = []string{item}
plainIndents = plainIndents[:0]
rootOffset = 1
}
return out
}
func parseSTRMTreeLine(line string) (string, int, bool) {
if idx := strings.Index(line, "──"); idx >= 0 {
prefix := line[:idx]
level := strmTreeIndentLevelWithWidth(prefix, 4)
item := cleanSTRMTreeItemName(strings.Trim(strings.TrimSpace(line[idx+len("──"):]), "─- "))
return item, level, item != ""
}
if idx := strings.Index(line, "─"); idx >= 0 {
prefix := line[:idx]
level := strmTreeIndentLevelWithWidth(prefix, 3)
item := cleanSTRMTreeItemName(strings.Trim(strings.TrimSpace(line[idx+len("─"):]), "─- "))
return item, level, item != ""
}
for _, marker := range []string{"|--", "+--", "`--"} {
if idx := strings.Index(line, marker); idx >= 0 {
item := cleanSTRMTreeItemName(line[idx+len(marker):])
return item, strmTreeIndentLevel(line[:idx]), item != ""
}
}
return "", 0, false
}
func parseSTRMTreeContinuationLine(line string, plainTreeActive bool) (string, int, bool) {
prefixLen := 0
hasGuide := false
for _, r := range line {
switch r {
case ' ', '\t', '│', '|':
if r == '│' || r == '|' {
hasGuide = true
}
prefixLen += len(string(r))
default:
if plainTreeActive && !hasGuide {
return "", 0, false
}
item := cleanSTRMTreeItemName(line[prefixLen:])
if item == "" || !strmTreeItemIsVideoSource(item) {
return "", 0, false
}
return item, strmTreeIndentLevel(line[:prefixLen]), true
}
}
return "", 0, false
}
func strmTreeIndentLevel(prefix string) int {
return strmTreeIndentLevelWithWidth(prefix, 4)
}
func strmTreeIndentLevelWithWidth(prefix string, width int) int {
if prefix == "" {
return 0
}
if width <= 0 {
width = 4
}
verticals := strings.Count(prefix, "│") + strings.Count(prefix, "|")
runeLen := len([]rune(strings.ReplaceAll(strings.ReplaceAll(prefix, "│", " "), "|", " ")))
byWidth := 0
if runeLen > 0 {
byWidth = (runeLen - 1) / width
}
if verticals > byWidth {
return verticals
}
return byWidth
}
func strmTreePlainIndent(line string) int {
indent := 0
for _, r := range line {
switch r {
case ' ':
indent++
case '\t':
indent += 4
default:
return indent
}
}
return indent
}
func strmTreeItemIsVideoSource(item string) bool {
if strmTreeSourceIsVideo(item) {
return true
}
source := normalizeSTRMTreeSourceWithProvider(item, "openlist")
return source.Path != "" && strmTreeSourceIsVideo(source.Path)
}
func strmTreeJoinedSource(stack []string, item string) string {
if strmTreeItemIsAbsoluteSource(item) {
return item
}
return path.Join(append(append([]string{}, stack...), item)...)
}
func strmTreeItemIsAbsoluteSource(item string) bool {
value := strings.ToLower(strings.TrimSpace(item))
return strings.Contains(value, "://") || strings.HasPrefix(value, "/api/")
}
+234
View File
@@ -0,0 +1,234 @@
package service
import (
"path/filepath"
"strings"
"testing"
"go.uber.org/zap"
)
func TestGenerateSTRMFromTreeTextPreservesRootTree(t *testing.T) {
outDir := filepath.Join(t.TempDir(), "strm")
svc := NewSTRMService(zap.NewNop(), nil, nil)
tree := strings.Join([]string{
"电视剧",
"├── 国产剧",
"│ └── 南部档案",
"│ ├── Archives.S01E01.mkv",
"│ └── Archives.S01E01.nfo",
}, "\n")
res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
Provider: "openlist",
TreeText: tree,
OutputDir: outDir,
})
if err != nil {
t.Fatal(err)
}
if res.Generated != 1 || len(res.Errors) != 0 {
t.Fatalf("result = %#v, want one generated video", res)
}
got := readSTRM(t, filepath.Join(outDir, "电视剧", "国产剧", "南部档案", "Archives.S01E01.strm"))
if got != "/api/cloud/play/openlist?ref=%2F%E7%94%B5%E8%A7%86%E5%89%A7%2F%E5%9B%BD%E4%BA%A7%E5%89%A7%2F%E5%8D%97%E9%83%A8%E6%A1%A3%E6%A1%88%2FArchives.S01E01.mkv" {
t.Fatalf("strm url = %q", got)
}
}
func TestGenerateSTRMFromTreeTextSupportsSingleLineTreeMarkers(t *testing.T) {
outDir := filepath.Join(t.TempDir(), "strm")
svc := NewSTRMService(zap.NewNop(), nil, nil)
tree := strings.Join([]string{
"动漫",
"├─ 国漫",
"│ └─ 凡人修仙传",
"│ └─ Season 01",
"│ └─ Mortal.Journey.S01E01.mp4",
}, "\n")
res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
Provider: "115",
TreeText: tree,
OutputDir: outDir,
})
if err != nil {
t.Fatal(err)
}
if res.Generated != 1 || len(res.Errors) != 0 {
t.Fatalf("result = %#v, want one generated video", res)
}
got := readSTRM(t, filepath.Join(outDir, "动漫", "国漫", "凡人修仙传", "Season 01", "Mortal.Journey.S01E01.strm"))
if !strings.Contains(got, "/api/cloud/play/cloud115?") {
t.Fatalf("strm url = %q, want cloud115 play url", got)
}
}
func TestGenerateSTRMFromTreeTextSupportsConnectorURLSources(t *testing.T) {
outDir := filepath.Join(t.TempDir(), "strm")
svc := NewSTRMService(zap.NewNop(), nil, nil)
tree := strings.Join([]string{
"电影",
"├── https://media.example.com/api/cloud/play/openlist?ref=%2FMovies%2FLinked.Movie.2026.mkv",
"└── cloud://openlist/%E7%94%B5%E5%BD%B1/%E5%88%AB%E5%90%8D/Cloud.Query.2026.mkv?dir=%2Factual%2Fcloud%2FCloud.Query.2026.mkv",
}, "\n")
res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
Provider: "115",
TreeText: tree,
OutputDir: outDir,
})
if err != nil {
t.Fatal(err)
}
if res.Generated != 2 || len(res.Errors) != 0 {
t.Fatalf("result = %#v, want two generated videos from connector URL rows", res)
}
linked := readSTRM(t, filepath.Join(outDir, "Movies", "Linked.Movie.2026.strm"))
if !strings.Contains(linked, "/api/cloud/play/openlist?") || !strings.Contains(linked, "ref=%2FMovies%2FLinked.Movie.2026.mkv") {
t.Fatalf("cloud play connector url = %q, want preserved provider/ref", linked)
}
cloud := readSTRM(t, filepath.Join(outDir, "电影", "别名", "Cloud.Query.2026.strm"))
if !strings.Contains(cloud, "/api/cloud/play/openlist?") || !strings.Contains(cloud, "ref=%2Factual%2Fcloud%2FCloud.Query.2026.mkv") {
t.Fatalf("cloud mount connector url = %q, want display path output and scan dir ref", cloud)
}
}
func TestGenerateSTRMFromTreeTextSupportsPlainIndentedTree(t *testing.T) {
outDir := filepath.Join(t.TempDir(), "strm")
svc := NewSTRMService(zap.NewNop(), nil, nil)
tree := strings.Join([]string{
"电视剧",
" 欧美剧",
" House of the Dragon",
" Season 03",
" House.of.the.Dragon.S03E01.mkv",
" House.of.the.Dragon.S03E02.mkv",
" The Last of Us",
" Season 02",
" The.Last.of.Us.S02E01.mkv",
}, "\n")
res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
Provider: "openlist",
TreeText: tree,
OutputDir: outDir,
})
if err != nil {
t.Fatal(err)
}
if res.Generated != 3 || len(res.Errors) != 0 {
t.Fatalf("result = %#v, want three generated videos from plain indented tree", res)
}
got := readSTRM(t, filepath.Join(outDir, "电视剧", "欧美剧", "House of the Dragon", "Season 03", "House.of.the.Dragon.S03E01.strm"))
if !strings.Contains(got, "House.of.the.Dragon.S03E01.mkv") {
t.Fatalf("strm url = %q, want first plain-indented source ref", got)
}
got = readSTRM(t, filepath.Join(outDir, "电视剧", "欧美剧", "The Last of Us", "Season 02", "The.Last.of.Us.S02E01.strm"))
if !strings.Contains(got, "The.Last.of.Us.S02E01.mkv") {
t.Fatalf("strm url = %q, want sibling folder source ref", got)
}
}
func TestGenerateSTRMFromTreeTextSupportsWindowsTreeFileRows(t *testing.T) {
outDir := filepath.Join(t.TempDir(), "strm")
svc := NewSTRMService(zap.NewNop(), nil, nil)
tree := strings.Join([]string{
"电视剧",
"├─欧美剧",
"│ ├─House of the Dragon",
"│ │ House.of.the.Dragon.S03E01.mkv",
"│ │ House.of.the.Dragon.S03E02.mkv",
"│ └─The Last of Us",
"│ The.Last.of.Us.S02E01.mkv",
}, "\n")
res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
Provider: "openlist",
TreeText: tree,
OutputDir: outDir,
})
if err != nil {
t.Fatal(err)
}
if res.Generated != 3 || len(res.Errors) != 0 {
t.Fatalf("result = %#v, want three generated videos from Windows tree rows", res)
}
got := readSTRM(t, filepath.Join(outDir, "电视剧", "欧美剧", "House of the Dragon", "House.of.the.Dragon.S03E02.strm"))
if !strings.Contains(got, "House.of.the.Dragon.S03E02.mkv") {
t.Fatalf("strm url = %q, want vertical-prefix file row ref", got)
}
got = readSTRM(t, filepath.Join(outDir, "电视剧", "欧美剧", "The Last of Us", "The.Last.of.Us.S02E01.strm"))
if !strings.Contains(got, "The.Last.of.Us.S02E01.mkv") {
t.Fatalf("strm url = %q, want blank-prefix sibling file row ref", got)
}
}
func TestGenerateSTRMFromTreeStripsDecoratedTreeNames(t *testing.T) {
outDir := filepath.Join(t.TempDir(), "strm")
svc := NewSTRMService(zap.NewNop(), nil, nil)
tree := strings.Join([]string{
"📁 电视剧",
"├── [目录] 欧美剧",
"│ └── (folder) House of the Dragon",
"│ │ 📄 House.of.the.Dragon.S03E01.mkv",
}, "\n")
res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
Provider: "openlist",
TreeText: tree,
Paths: []string{"/[目录] 动漫/[folder] 日番/[文件] Frieren.S01E01.mp4"},
SourceRoot: "/动漫",
OutputDir: outDir,
})
if err != nil {
t.Fatal(err)
}
if res.Generated != 2 || len(res.Errors) != 0 {
t.Fatalf("result = %#v, want two generated videos with decorations stripped", res)
}
showPath := filepath.Join(outDir, "电视剧", "欧美剧", "House of the Dragon", "House.of.the.Dragon.S03E01.strm")
show := readSTRM(t, showPath)
if strings.Contains(showPath, "目录") || strings.Contains(showPath, "folder") || strings.Contains(showPath, "📄") {
t.Fatalf("decorated local path was not cleaned: %q", showPath)
}
if !strings.Contains(show, "House.of.the.Dragon.S03E01.mkv") || strings.Contains(show, "%5B") || strings.Contains(show, "%F0%9F") {
t.Fatalf("decorated tree ref was not cleaned: %q", show)
}
episode := readSTRM(t, filepath.Join(outDir, "日番", "Frieren.S01E01.strm"))
if !strings.Contains(episode, "ref=%2F%E5%8A%A8%E6%BC%AB%2F%E6%97%A5%E7%95%AA%2FFrieren.S01E01.mp4") {
t.Fatalf("decorated direct path ref was not cleaned: %q", episode)
}
}
func TestGenerateSTRMFromTreeStripsExportedFileMetadata(t *testing.T) {
outDir := filepath.Join(t.TempDir(), "strm")
svc := NewSTRMService(zap.NewNop(), nil, nil)
tree := strings.Join([]string{
"电影",
"└── 欧美电影",
" └── Dune.Part.Two.2024.2160p.WEB-DL.mkv 18.6 GB 2024-04-01 12:30",
}, "\n")
res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
Provider: "openlist",
TreeText: tree,
Paths: []string{"/电视剧/欧美剧/Show/Season 01/Show.S01E01.mp4 (2.1 GB)"},
SourceRoot: "/电视剧",
OutputDir: outDir,
})
if err != nil {
t.Fatal(err)
}
if res.Generated != 2 || len(res.Errors) != 0 {
t.Fatalf("result = %#v, want two generated videos with metadata suffix stripped", res)
}
movie := readSTRM(t, filepath.Join(outDir, "电影", "欧美电影", "Dune.Part.Two.2024.2160p.WEB-DL.strm"))
if !strings.Contains(movie, "Dune.Part.Two.2024.2160p.WEB-DL.mkv") || strings.Contains(movie, "18.6") {
t.Fatalf("movie strm url = %q, want clean media ref without size metadata", movie)
}
episode := readSTRM(t, filepath.Join(outDir, "欧美剧", "Show", "Season 01", "Show.S01E01.strm"))
if !strings.Contains(episode, "Show.S01E01.mp4") || strings.Contains(episode, "2.1") {
t.Fatalf("episode strm url = %q, want clean media ref without size metadata", episode)
}
}
+76
View File
@@ -0,0 +1,76 @@
package service
import (
"errors"
"fmt"
"path"
"path/filepath"
"strings"
)
func strmTreeRelativeSource(source, sourceRoot string) string {
source = normalizeSTRMTreeSource(source)
root := normalizeRemotePath(sourceRoot)
if root != "/" && strings.HasPrefix(source, root+"/") {
return strings.TrimPrefix(strings.TrimPrefix(source, root), "/")
}
return strings.TrimPrefix(source, "/")
}
func strmTreeCloudRef(source, sourceRoot string) string {
source = normalizeSTRMTreeSource(source)
if strings.HasPrefix(source, "/") {
return source
}
if strings.TrimSpace(sourceRoot) != "" {
return joinRemotePath(sourceRoot, source)
}
return normalizeRemotePath(source)
}
func strmTreeOutputRelativePath(source string) (string, error) {
parts := strings.Split(strings.Trim(strings.ReplaceAll(source, "\\", "/"), "/"), "/")
if len(parts) == 0 {
return "", errors.New("empty source path")
}
out := make([]string, 0, len(parts))
for i, part := range parts {
part = strings.TrimSpace(part)
if part == "" || part == "." || part == ".." {
return "", fmt.Errorf("unsafe path segment %q", part)
}
if i == len(parts)-1 {
ext := strings.ToLower(path.Ext(part))
if _, ok := videoExtensions[ext]; !ok {
return "", fmt.Errorf("unsupported media extension %q", ext)
}
part = strings.TrimSuffix(part, path.Ext(part)) + ".strm"
}
safe := sanitizeFilename(part)
if safe == "" {
return "", errors.New("empty sanitized path segment")
}
out = append(out, safe)
}
return filepath.Join(out...), nil
}
func strmTreeOutputPrefixPath(prefix string) (string, error) {
parts := strings.Split(strings.Trim(strings.ReplaceAll(prefix, "\\", "/"), "/"), "/")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if part == "." || part == ".." {
return "", fmt.Errorf("unsafe output prefix segment %q", part)
}
safe := sanitizeFilename(part)
if safe == "" {
return "", errors.New("empty sanitized output prefix segment")
}
out = append(out, safe)
}
return filepath.Join(out...), nil
}
+251
View File
@@ -0,0 +1,251 @@
package service
import (
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"go.uber.org/zap"
)
func TestGenerateSTRMFromTreeUsesMediaPathFromURLQuery(t *testing.T) {
outDir := filepath.Join(t.TempDir(), "strm")
svc := NewSTRMService(zap.NewNop(), nil, nil)
res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
Provider: "openlist",
Paths: []string{
"https://openlist.example.com/api/fs/get?path=%2FMovies%2FDune.Part.Two.2024.mkv",
"https://openlist.example.com/api/fs/get?path=%2FMovies%2FA%2BB.2026.mkv",
"https://openlist.example.com/api/fs/get?path=/Movies/A+B.Raw.2026.mkv",
"https://openlist.example.com/api/raw?ref=/Shows/Some.Show/S01E01.mp4",
"https://cdn.example.com/media/Movies/Direct.Movie.2026.mkv?token=secret",
"https://cdn.example.com/media/Movies/A+B.Direct.2026.mkv?token=secret",
"https://openlist.example.com/api/fs/get?id=12345",
},
OutputDir: outDir,
})
if err != nil {
t.Fatal(err)
}
if res.Generated != 6 || len(res.Errors) != 0 {
t.Fatalf("result = %#v, want six generated videos and non-media API URL ignored", res)
}
movie := readSTRM(t, filepath.Join(outDir, "Movies", "Dune.Part.Two.2024.strm"))
if !strings.Contains(movie, "ref=%2FMovies%2FDune.Part.Two.2024.mkv") || strings.Contains(movie, "api%2Ffs%2Fget") {
t.Fatalf("movie strm url = %q, want query media path as cloud ref", movie)
}
plusMovie := readSTRM(t, filepath.Join(outDir, "Movies", "A+B.2026.strm"))
if !strings.Contains(plusMovie, "ref=%2FMovies%2FA%2BB.2026.mkv") || strings.Contains(plusMovie, "A+B.2026.mkv") {
t.Fatalf("plus movie strm url = %q, want literal plus preserved and encoded in ref", plusMovie)
}
rawPlusMovie := readSTRM(t, filepath.Join(outDir, "Movies", "A+B.Raw.2026.strm"))
if !strings.Contains(rawPlusMovie, "ref=%2FMovies%2FA%2BB.Raw.2026.mkv") || strings.Contains(rawPlusMovie, "A+B.Raw.2026.mkv") {
t.Fatalf("raw plus movie strm url = %q, want raw query plus preserved and encoded in ref", rawPlusMovie)
}
show := readSTRM(t, filepath.Join(outDir, "Shows", "Some.Show", "S01E01.strm"))
if !strings.Contains(show, "ref=%2FShows%2FSome.Show%2FS01E01.mp4") {
t.Fatalf("show strm url = %q, want ref query media path", show)
}
direct := readSTRM(t, filepath.Join(outDir, "media", "Movies", "Direct.Movie.2026.strm"))
if !strings.Contains(direct, "ref=%2Fmedia%2FMovies%2FDirect.Movie.2026.mkv") {
t.Fatalf("direct url strm = %q, want normal URL path media ref", direct)
}
plusDirect := readSTRM(t, filepath.Join(outDir, "media", "Movies", "A+B.Direct.2026.strm"))
if !strings.Contains(plusDirect, "ref=%2Fmedia%2FMovies%2FA%2BB.Direct.2026.mkv") {
t.Fatalf("plus direct url strm = %q, want URL path plus preserved", plusDirect)
}
if _, err := os.Stat(filepath.Join(outDir, "api", "fs", "get.strm")); !os.IsNotExist(err) {
t.Fatalf("non-media API URL should not generate STRM, stat err=%v", err)
}
}
func TestGenerateSTRMFromTreePreservesSourceProvider(t *testing.T) {
outDir := filepath.Join(t.TempDir(), "strm")
svc := NewSTRMService(zap.NewNop(), nil, nil)
res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
Provider: "115",
Paths: []string{
"cloud://openlist/Movies/OpenList.Movie.2026.mkv",
"/api/cloud/play/cloud115?ref=%2FShows%2FCloud115.Show.S01E01.mkv",
"/api/cloud/play/openlist?ref=%2FMovies%2FMy+Space.Movie.2026.mkv",
"https://media.example.com/api/cloud/play/openlist?ref=%2FMovies%2FRemote.OpenList.Movie.2026.mkv",
"/Movies/Fallback.115.Movie.2026.mkv",
},
OutputDir: outDir,
})
if err != nil {
t.Fatal(err)
}
if res.Generated != 5 || len(res.Errors) != 0 {
t.Fatalf("result = %#v, want five generated videos", res)
}
openlist := readSTRM(t, filepath.Join(outDir, "Movies", "OpenList.Movie.2026.strm"))
if !strings.Contains(openlist, "/api/cloud/play/openlist?") || !strings.Contains(openlist, "ref=%2FMovies%2FOpenList.Movie.2026.mkv") {
t.Fatalf("cloud:// source url = %q, want openlist provider and original ref", openlist)
}
cloud115 := readSTRM(t, filepath.Join(outDir, "Shows", "Cloud115.Show.S01E01.strm"))
if !strings.Contains(cloud115, "/api/cloud/play/cloud115?") || !strings.Contains(cloud115, "ref=%2FShows%2FCloud115.Show.S01E01.mkv") {
t.Fatalf("cloud play source url = %q, want cloud115 provider preserved", cloud115)
}
spaceMoviePath := filepath.Join(outDir, "Movies", "My Space.Movie.2026.strm")
spaceMovie := readSTRM(t, spaceMoviePath)
if _, err := os.Stat(filepath.Join(outDir, "Movies", "My+Space.Movie.2026.strm")); !os.IsNotExist(err) {
t.Fatalf("cloud play source should not create literal-plus local path, stat err=%v", err)
}
spaceURL, err := url.Parse(spaceMovie)
if err != nil {
t.Fatalf("parse cloud play source url %q: %v", spaceMovie, err)
}
if got := spaceURL.Query().Get("ref"); got != "/Movies/My Space.Movie.2026.mkv" {
t.Fatalf("cloud play source ref = %q, want decoded space path", got)
}
remoteOpenlist := readSTRM(t, filepath.Join(outDir, "Movies", "Remote.OpenList.Movie.2026.strm"))
if !strings.Contains(remoteOpenlist, "/api/cloud/play/openlist?") || !strings.Contains(remoteOpenlist, "ref=%2FMovies%2FRemote.OpenList.Movie.2026.mkv") {
t.Fatalf("absolute cloud play source url = %q, want openlist provider preserved", remoteOpenlist)
}
fallback := readSTRM(t, filepath.Join(outDir, "Movies", "Fallback.115.Movie.2026.strm"))
if !strings.Contains(fallback, "/api/cloud/play/cloud115?") || !strings.Contains(fallback, "ref=%2FMovies%2FFallback.115.Movie.2026.mkv") {
t.Fatalf("plain source url = %q, want fallback cloud115 provider", fallback)
}
}
func TestGenerateSTRMFromTreeCloudMountUsesScanDirForRef(t *testing.T) {
outDir := filepath.Join(t.TempDir(), "strm")
svc := NewSTRMService(zap.NewNop(), nil, nil)
res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
Provider: "115",
Paths: []string{
"cloud://openlist/%E7%94%B5%E5%BD%B1/%E5%88%AB%E5%90%8D%E7%9B%AE%E5%BD%95/Alias.Movie.2026.mkv?dir=%2Factual%2Fcloud%2Fmovies%2FAlias.Movie.2026.mkv",
},
OutputDir: outDir,
})
if err != nil {
t.Fatal(err)
}
if res.Generated != 1 || len(res.Errors) != 0 {
t.Fatalf("result = %#v, want one generated video", res)
}
got := readSTRM(t, filepath.Join(outDir, "电影", "别名目录", "Alias.Movie.2026.strm"))
if !strings.Contains(got, "/api/cloud/play/openlist?") {
t.Fatalf("strm url = %q, want provider from cloud mount", got)
}
if !strings.Contains(got, "ref=%2Factual%2Fcloud%2Fmovies%2FAlias.Movie.2026.mkv") {
t.Fatalf("strm url = %q, want dir scan path as playable ref", got)
}
if strings.Contains(got, "%E5%88%AB%E5%90%8D%E7%9B%AE%E5%BD%95") {
t.Fatalf("strm url = %q, display path leaked into playable ref", got)
}
}
func TestGenerateSTRMFromTreeDoesNotDedupeDifferentProviders(t *testing.T) {
outDir := filepath.Join(t.TempDir(), "strm")
svc := NewSTRMService(zap.NewNop(), nil, nil)
res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
Provider: "115",
Paths: []string{"cloud://openlist/Movies/Same.Movie.mkv", "cloud://cloud115/Movies/Same.Movie.mkv"},
OutputDir: outDir,
Overwrite: true,
})
if err != nil {
t.Fatal(err)
}
if res.Generated != 1 || res.Updated != 1 || len(res.Errors) != 0 {
t.Fatalf("result = %#v, want both provider-specific sources processed", res)
}
got := readSTRM(t, filepath.Join(outDir, "Movies", "Same.Movie.strm"))
if !strings.Contains(got, "/api/cloud/play/cloud115?") || !strings.Contains(got, "ref=%2FMovies%2FSame.Movie.mkv") {
t.Fatalf("final strm url = %q, want second provider write to prove it was not deduped", got)
}
}
func TestGenerateSTRMFromTreeStripsPathListPrefixes(t *testing.T) {
outDir := filepath.Join(t.TempDir(), "strm")
svc := NewSTRMService(zap.NewNop(), nil, nil)
tree := strings.Join([]string{
"- /Movies/Dune.Part.Two.2024.mkv",
"* https://openlist.example.com/api/fs/get?path=%2FShows%2FSome.Show%2FS01E01.mp4",
"1. /Anime/Frieren/Frieren.S01E01.mp4",
"2) /Anime/Frieren/Frieren.S01E02.mp4",
"• /Documentaries/Earth.2026.mkv",
}, "\n")
res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
Provider: "openlist",
TreeText: tree,
OutputDir: outDir,
})
if err != nil {
t.Fatal(err)
}
if res.Generated != 5 || len(res.Errors) != 0 {
t.Fatalf("result = %#v, want five generated videos with list prefixes stripped", res)
}
moviePath := filepath.Join(outDir, "Movies", "Dune.Part.Two.2024.strm")
movie := readSTRM(t, moviePath)
if strings.Contains(moviePath, "- ") || !strings.Contains(movie, "ref=%2FMovies%2FDune.Part.Two.2024.mkv") {
t.Fatalf("markdown bullet prefix leaked into movie path/ref: path=%q url=%q", moviePath, movie)
}
show := readSTRM(t, filepath.Join(outDir, "Shows", "Some.Show", "S01E01.strm"))
if strings.Contains(show, "api%2Ffs%2Fget") || !strings.Contains(show, "ref=%2FShows%2FSome.Show%2FS01E01.mp4") {
t.Fatalf("bullet URL query source was not cleaned: %q", show)
}
if _, err := os.Stat(filepath.Join(outDir, "1. ", "Anime", "Frieren", "Frieren.S01E01.strm")); !os.IsNotExist(err) {
t.Fatalf("numbered prefix should not create a literal prefix directory, stat err=%v", err)
}
if got := readSTRM(t, filepath.Join(outDir, "Documentaries", "Earth.2026.strm")); !strings.Contains(got, "ref=%2FDocumentaries%2FEarth.2026.mkv") {
t.Fatalf("round bullet source ref = %q", got)
}
}
func TestGenerateSTRMFromTreeOutputPrefixOnlyAffectsLocalPath(t *testing.T) {
outDir := filepath.Join(t.TempDir(), "strm")
svc := NewSTRMService(zap.NewNop(), nil, nil)
res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
Provider: "openlist",
Paths: []string{"/cloud/Dune.Part.Two.2024.mkv"},
SourceRoot: "/cloud",
OutputPrefix: "电影/欧美电影",
OutputDir: outDir,
})
if err != nil {
t.Fatal(err)
}
if res.Generated != 1 || len(res.Errors) != 0 {
t.Fatalf("result = %#v, want one generated video", res)
}
got := readSTRM(t, filepath.Join(outDir, "电影", "欧美电影", "Dune.Part.Two.2024.strm"))
if strings.Contains(got, "%E7%94%B5%E5%BD%B1") || strings.Contains(got, "%E6%AC%A7%E7%BE%8E%E7%94%B5%E5%BD%B1") {
t.Fatalf("strm url = %q, output prefix should not be injected into cloud ref", got)
}
if !strings.Contains(got, "ref=%2Fcloud%2FDune.Part.Two.2024.mkv") {
t.Fatalf("strm url = %q, want original cloud ref", got)
}
}
func TestGenerateSTRMFromTreeRejectsUnsafeOutputPrefix(t *testing.T) {
outDir := filepath.Join(t.TempDir(), "strm")
svc := NewSTRMService(zap.NewNop(), nil, nil)
res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
Provider: "openlist",
Paths: []string{"Movies/Movie.mkv"},
OutputPrefix: "../escape",
OutputDir: outDir,
})
if err != nil {
t.Fatal(err)
}
if res.Generated != 0 || len(res.Errors) != 1 {
t.Fatalf("result = %#v, want unsafe prefix rejected", res)
}
if _, err := os.Stat(filepath.Join(outDir, "..", "escape", "Movies", "Movie.strm")); !os.IsNotExist(err) {
t.Fatalf("unsafe prefixed strm should not be written, stat err=%v", err)
}
}
+46
View File
@@ -114,6 +114,52 @@ func (s *STRMService) strmRelativePath(lib model.Library, media model.Media) str
return filepath.Join(safe, safe+".strm")
}
func (s *STRMService) strmTreeRelativePath(media model.Media) string {
parts := strmLibraryPathParts(media.Path)
if len(parts) == 0 {
return ""
}
parts = strmDropCategoryPrefix(parts)
if len(parts) == 0 {
return ""
}
last := parts[len(parts)-1]
ext := filepath.Ext(last)
if ext == "" {
return ""
}
parts[len(parts)-1] = strings.TrimSuffix(last, ext) + ".strm"
clean := make([]string, 0, len(parts))
for _, part := range parts {
if safe := sanitizeFilename(part); safe != "" {
clean = append(clean, safe)
}
}
if len(clean) == 0 {
return ""
}
return filepath.Join(clean...)
}
func strmDropCategoryPrefix(parts []string) []string {
if len(parts) == 0 {
return nil
}
for i, part := range parts {
if strmCanonicalRoot(part) == "" && strmCategoryRoot(part) == "" {
continue
}
next := i + 1
if strmCanonicalRoot(part) != "" && next < len(parts) && strmCategoryRoot(parts[next]) != "" {
next++
}
if next < len(parts) {
return append([]string(nil), parts[next:]...)
}
}
return append([]string(nil), parts...)
}
func absolutizeSTRMURL(raw, baseURL string) string {
raw = strings.TrimSpace(raw)
if raw == "" || strings.HasPrefix(raw, "//") {
+2 -2
View File
@@ -81,7 +81,7 @@ func (s *SubscriptionService) archiveCompletedSubscription(ctx context.Context,
}
func subscriptionShouldArchive(sub *model.Subscription, availability LocalAvailability) bool {
if sub == nil || sub.WashEnabled || sub.ArchivedAt != nil {
if sub == nil || subscriptionAllowsWash(sub) || sub.ArchivedAt != nil {
return false
}
mediaType := normalizeMediaType(sub.MediaType, sub.Name+" "+sub.Filter, "")
@@ -128,7 +128,7 @@ func maxAvailabilityEpisode(keys map[string]struct{}) int {
}
func subscriptionArchiveReason(sub *model.Subscription, availability LocalAvailability) string {
if sub != nil && sub.WashEnabled {
if subscriptionAllowsWash(sub) {
return ""
}
if availability.HasSeriesPack {
@@ -185,6 +185,7 @@ func TestSubscriptionArchiveKeepsWashSubscriptionActive(t *testing.T) {
FeedURL: "site-search://search?keyword=Dune",
Filter: "Dune 2021",
MediaType: "movie",
Resolution: "2160p",
WashEnabled: true,
Enabled: true,
}
@@ -2,11 +2,14 @@ package service
import (
"regexp"
"strconv"
"strings"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
var looseSubscriptionEpisodeRE = regexp.MustCompile(`(?i)(?:^|[\s._\-\[\(])0?(\d{1,3})(?:v\d+)?(?:$|[\s._\-\]\)])`)
func collectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, allowQueryMismatch bool, stats *siteSearchSelectionStats) []siteSearchCandidate {
candidates := make([]siteSearchCandidate, 0, len(results))
for _, item := range results {
@@ -20,7 +23,7 @@ func collectSiteSearchCandidates(results []SearchResult, sub *model.Subscription
continue
}
}
if !matchesSubscriptionRules(sub, matchText) {
if !matchesSubscriptionRules(sub, matchText) || !matchesSubscriptionTorrentRules(sub, item) {
stats.RuleMismatch++
continue
}
@@ -37,8 +40,13 @@ func collectSiteSearchCandidates(results []SearchResult, sub *model.Subscription
stats.Seen++
continue
}
refs := subscriptionCandidateEpisodeRefs(sub, matchText)
season, episode := ParseEpisode(matchText)
episodes := episodeNumbersFromRefs(episodeRefsFromTitle(matchText), season)
if episode <= 0 && len(refs) > 0 {
season = refs[0].Season
episode = refs[0].Episode
}
episodes := episodeNumbersFromRefs(refs, season)
score := subscriptionCandidateScore(sub, item)
stats.Prepared++
candidates = append(candidates, siteSearchCandidate{
@@ -78,7 +86,7 @@ func shouldRelaxSiteSearchQueryMatch(sub *model.Subscription, local LocalAvailab
}
func subscriptionSearchResultText(item SearchResult) string {
return strings.TrimSpace(strings.Join([]string{item.Title, item.Subtitle}, " "))
return strings.TrimSpace(strings.Join([]string{item.Title, item.Subtitle, item.Labels}, " "))
}
func selectRSSSubscriptionCandidates(items []rssItem, sub *model.Subscription, filter *regexp.Regexp, seenSet map[string]struct{}, local LocalAvailability) []siteSearchCandidate {
@@ -94,7 +102,8 @@ func selectRSSSubscriptionCandidates(items []rssItem, sub *model.Subscription, f
if filter != nil && !filter.MatchString(title) {
continue
}
if !matchesSubscriptionRules(sub, title) {
searchItem := SearchResult{Title: title}
if !matchesSubscriptionRules(sub, title) || !matchesSubscriptionTorrentRules(sub, searchItem) {
continue
}
download := strings.TrimSpace(item.Enclosure.URL)
@@ -108,9 +117,14 @@ func selectRSSSubscriptionCandidates(items []rssItem, sub *model.Subscription, f
if _, ok := seenSet[guid]; ok {
continue
}
searchItem := SearchResult{Title: title, DownloadURL: download}
searchItem.DownloadURL = download
refs := subscriptionCandidateEpisodeRefs(sub, title)
season, episode := ParseEpisode(title)
episodes := episodeNumbersFromRefs(episodeRefsFromTitle(title), season)
if episode <= 0 && len(refs) > 0 {
season = refs[0].Season
episode = refs[0].Episode
}
episodes := episodeNumbersFromRefs(refs, season)
candidates = append(candidates, siteSearchCandidate{
Item: searchItem,
Download: download,
@@ -125,6 +139,74 @@ func selectRSSSubscriptionCandidates(items []rssItem, sub *model.Subscription, f
return selectPreparedSubscriptionCandidates(candidates, sub, local)
}
func subscriptionCandidateEpisodeRefs(sub *model.Subscription, text string) []episodeRef {
if refs := episodeRefsFromTitle(text); len(refs) > 0 {
return refs
}
if sub == nil || !isSubscriptionSeriesType(sub.MediaType) || isSeriesPackTitle(text) || patSeasonOnly.MatchString(text) {
return nil
}
episode := inferLooseSubscriptionEpisode(maskSubscriptionTitleQueries(sub, text))
if episode <= 0 {
return nil
}
return []episodeRef{{Season: 1, Episode: episode}}
}
func maskSubscriptionTitleQueries(sub *model.Subscription, text string) string {
if sub == nil || strings.TrimSpace(text) == "" {
return text
}
out := text
outFold := strings.ToLower(out)
for _, query := range subscriptionTitleMatchQueries(sub) {
query = strings.TrimSpace(query)
if query == "" {
continue
}
queryFold := strings.ToLower(query)
for {
idx := strings.Index(outFold, queryFold)
if idx < 0 {
break
}
out = out[:idx] + strings.Repeat(" ", len(query)) + out[idx+len(query):]
outFold = strings.ToLower(out)
}
}
return out
}
func inferLooseSubscriptionEpisode(text string) int {
for _, match := range looseSubscriptionEpisodeRE.FindAllStringSubmatchIndex(text, -1) {
if len(match) < 4 || match[2] < 0 || match[3] < 0 {
continue
}
if isDecimalFractionMatch(text, match[2]) {
continue
}
value, err := strconv.Atoi(text[match[2]:match[3]])
if err != nil || !looksLikeLooseEpisodeNumber(value) {
continue
}
return value
}
return 0
}
func isDecimalFractionMatch(text string, digitStart int) bool {
return digitStart >= 2 && text[digitStart-1] == '.' && text[digitStart-2] >= '0' && text[digitStart-2] <= '9'
}
func looksLikeLooseEpisodeNumber(value int) bool {
switch {
case value <= 0, value > 200:
return false
default:
return true
}
}
func episodeNumbersFromRefs(refs []episodeRef, fallbackSeason int) []int {
if len(refs) == 0 {
return nil
@@ -0,0 +1,299 @@
package service
import (
"testing"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
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{
{Title: "Inception 2010 1080p", DownloadURL: "https://pt/download/1080", Seeders: 90},
{Title: "Inception 2010 2160p", DownloadURL: "https://pt/download/2160", Seeders: 80},
}
got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
if len(got) != 1 || got[0].Download != "https://pt/download/2160" {
t.Fatalf("selected %#v, want default best single result when wash disabled", got)
}
}
func TestSelectSiteSearchCandidatesWashNeedsExplicitUpgradeCriteria(t *testing.T) {
sub := &model.Subscription{Name: "Inception 自动订阅", Filter: "Inception 2010", MediaType: "movie", WashEnabled: true, WashPriority: "resolution"}
local := LocalAvailability{LocalMediaCount: 1, InLibrary: true}
results := []SearchResult{
{Title: "Inception 2010 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/1080", Seeders: 90},
{Title: "Inception 2010 2160p WEB-DL H264 AAC", DownloadURL: "https://pt/download/2160", Seeders: 80},
}
got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, local)
if len(got) != 0 {
t.Fatalf("selected %#v, want no wash download without explicit upgrade criteria", got)
}
sub.Resolution = "2160p"
got = selectSiteSearchCandidates(results, sub, map[string]struct{}{}, local)
if len(got) != 1 || got[0].Download != "https://pt/download/2160" {
t.Fatalf("selected %#v, want explicit 2160p wash candidate", got)
}
}
func TestSelectSiteSearchCandidatesWashWithoutCriteriaUsesDefaultQuality(t *testing.T) {
sub := &model.Subscription{Name: "Dune 自动订阅", Filter: "Dune 2021", MediaType: "movie", WashEnabled: true, WashPriority: "quality"}
results := []SearchResult{
{Title: "Dune 2021 2160p REMUX H264 AAC", DownloadURL: "https://pt/download/remux", Seeders: 80},
{Title: "Dune 2021 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/webdl", Seeders: 60},
}
got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
if len(got) != 1 || got[0].Download != "https://pt/download/webdl" {
t.Fatalf("selected %#v, want default compatible WEB-DL when wash has no explicit criteria", got)
}
}
func TestSelectSiteSearchCandidatesDefaultsToOnePreferredVersionPerEpisode(t *testing.T) {
sub := &model.Subscription{Name: "House of the Dragon 自动订阅", Filter: "House of the Dragon", MediaType: "tv"}
results := []SearchResult{
{Title: "House of the Dragon S03E01 1080p HDTV", DownloadURL: "https://pt/download/e01-hdtv", Seeders: 50000},
{Title: "House of the Dragon S03E01 1080p WEB-DL", DownloadURL: "https://pt/download/e01-webdl-1080", Seeders: 100},
{Title: "House of the Dragon S03E01 2160p WEB-DL", DownloadURL: "https://pt/download/e01-webdl-2160", Seeders: 80},
{Title: "House of the Dragon S03E02 720p HDTV", DownloadURL: "https://pt/download/e02-hdtv", Seeders: 500},
{Title: "House of the Dragon S03E02 1080p WEBRip", DownloadURL: "https://pt/download/e02-webrip", Seeders: 60},
}
got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
if len(got) != 2 {
t.Fatalf("selected %d candidates, want one per episode", len(got))
}
if got[0].Download != "https://pt/download/e01-webdl-2160" {
t.Fatalf("episode 1 selected %q, want best WEB-DL version", got[0].Download)
}
if got[1].Download != "https://pt/download/e02-webrip" {
t.Fatalf("episode 2 selected %q, want WEBRip over high-seeder HDTV", got[1].Download)
}
}
func TestSelectSiteSearchCandidatesDefaultQualityRecognizesWebDLVariants(t *testing.T) {
sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"}
results := []SearchResult{
{Title: "Some Show S01E01 1080p BluRay H264 AAC", DownloadURL: "https://pt/download/e01-bluray", Seeders: 900},
{Title: "Some Show S01E01 2160p WEB.DL H264 AAC", DownloadURL: "https://pt/download/e01-webdotdl", Seeders: 40},
{Title: "Some Show S01E01 1080p WEB DL H264 AAC", DownloadURL: "https://pt/download/e01-webdl", Seeders: 50},
}
got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
if len(got) != 1 || got[0].Download != "https://pt/download/e01-webdotdl" {
t.Fatalf("selected %#v, want one best WEB-DL variant", got)
}
}
func TestSelectSiteSearchCandidatesDefaultPrefersWebDLBeforeResolution(t *testing.T) {
sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"}
results := []SearchResult{
{Title: "Some Show S01E01 2160p BluRay H264 AAC", DownloadURL: "https://pt/download/e01-bluray-2160", Seeders: 900},
{Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-webdl-1080", Seeders: 50},
}
got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
if len(got) != 1 || got[0].Download != "https://pt/download/e01-webdl-1080" {
t.Fatalf("selected %#v, want one compatible WEB-DL version before higher-resolution BluRay", got)
}
}
func TestSelectSiteSearchCandidatesDefaultPrefersFreeWithinSameQualityBand(t *testing.T) {
sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"}
results := []SearchResult{
{Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-nonfree", Seeders: 5000},
{Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-free", Seeders: 80, Free: true},
}
got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
if len(got) != 1 || got[0].Download != "https://pt/download/e01-free" {
t.Fatalf("selected %#v, want free candidate within same quality/resolution band", got)
}
}
func TestSelectSiteSearchCandidatesDefaultDoesNotLetFreeOverrideBetterQuality(t *testing.T) {
sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"}
results := []SearchResult{
{Title: "Some Show S01E01 1080p HDTV H264 AAC", DownloadURL: "https://pt/download/e01-free-hdtv", Seeders: 80, Free: true},
{Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-webdl", Seeders: 50},
}
got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
if len(got) != 1 || got[0].Download != "https://pt/download/e01-webdl" {
t.Fatalf("selected %#v, want WEB-DL quality to stay ahead of free HDTV", got)
}
}
func TestSelectSiteSearchCandidatesDefaultDoesNotLetFreeOverrideBetterResolution(t *testing.T) {
sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"}
results := []SearchResult{
{Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-free-1080", Seeders: 80, Free: true},
{Title: "Some Show S01E01 2160p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-2160", Seeders: 50},
}
got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
if len(got) != 1 || got[0].Download != "https://pt/download/e01-2160" {
t.Fatalf("selected %#v, want better resolution to stay ahead of free lower-resolution release", got)
}
}
func TestSelectSiteSearchCandidatesDefaultsToOneLooseNumberedEpisode(t *testing.T) {
sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"}
results := []SearchResult{
{Title: "Some Show 01 1080p HDTV H264 AAC", DownloadURL: "https://pt/download/e01-hdtv", Seeders: 5000},
{Title: "Some Show 01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-webdl-1080", Seeders: 100},
{Title: "Some Show 01 2160p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-webdl-2160", Seeders: 80},
}
got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
if len(got) != 1 {
t.Fatalf("selected %d candidates, want one preferred loose-numbered episode", len(got))
}
if got[0].Download != "https://pt/download/e01-webdl-2160" || got[0].Episode != 1 {
t.Fatalf("selected %#v, want episode 1 best WEB-DL version", got)
}
}
func TestSelectSiteSearchCandidatesDoesNotTreatTitleNumberAsLooseEpisode(t *testing.T) {
sub := &model.Subscription{Name: "问心2 自动订阅", Filter: "问心2 2023", MediaType: "tv"}
results := []SearchResult{
{Title: "问心2 2023 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/season", Seeders: 100},
}
got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
if len(got) != 1 {
t.Fatalf("selected %#v, want one fallback candidate", got)
}
if got[0].Episode != 0 {
t.Fatalf("episode = %d, want title number not treated as episode", got[0].Episode)
}
}
func TestSelectSiteSearchCandidatesRejectsRiskyLabelsFromSiteResult(t *testing.T) {
sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"}
results := []SearchResult{
{Title: "Some Show S01E01 1080p WEB-DL H264 AAC", Labels: "HR", DownloadURL: "https://pt/download/e01-hr", Seeders: 900},
{Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-safe", Seeders: 80},
}
got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
if len(got) != 1 || got[0].Download != "https://pt/download/e01-safe" {
t.Fatalf("selected %#v, want non-HR candidate only", got)
}
}
func TestSelectSiteSearchCandidatesWashPriorityDoesNotLetFreeOverrideResolution(t *testing.T) {
sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv", WashEnabled: true, WashPriority: "resolution"}
results := []SearchResult{
{Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-free-1080", Seeders: 80, Free: true},
{Title: "Some Show S01E01 2160p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-2160", Seeders: 50},
}
got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
if len(got) != 1 || got[0].Download != "https://pt/download/e01-2160" {
t.Fatalf("selected %#v, want wash resolution priority to stay ahead of free lower-resolution release", got)
}
}
func TestSelectSiteSearchCandidatesRejectsDefaultCompatibilityVersions(t *testing.T) {
sub := &model.Subscription{Name: "House of the Dragon 自动订阅", Filter: "House of the Dragon", MediaType: "tv"}
results := []SearchResult{
{Title: "House of the Dragon S03E01 2160p WEB-DL HEVC 10bit DoVi Atmos", DownloadURL: "https://pt/download/e01-dovi", Seeders: 900},
{Title: "House of the Dragon S03E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-webdl", Seeders: 80},
{Title: "House of the Dragon S03E01 1080p HDTV H264 AAC", DownloadURL: "https://pt/download/e01-hdtv", Seeders: 5000},
}
got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
if len(got) != 1 || got[0].Download != "https://pt/download/e01-webdl" {
t.Fatalf("selected %#v, want compatible WEB-DL only", got)
}
}
func TestSelectSiteSearchCandidatesKeepsCompatibilityExcludesWithCustomExcludeWords(t *testing.T) {
sub := &model.Subscription{
Name: "House of the Dragon 自动订阅",
Filter: "House of the Dragon",
MediaType: "tv",
ExcludeWords: "官中,无字幕",
}
results := []SearchResult{
{Title: "House of the Dragon S03E01 2160p WEB-DL HEVC 10bit DoVi Atmos", DownloadURL: "https://pt/download/e01-dovi", Seeders: 900},
{Title: "House of the Dragon S03E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-webdl", Seeders: 80},
}
got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
if len(got) != 1 || got[0].Download != "https://pt/download/e01-webdl" {
t.Fatalf("selected %#v, want custom exclude words to keep default compatible WEB-DL guard", got)
}
}
func TestSelectSiteSearchCandidatesAvoidsOverlappingEpisodeRanges(t *testing.T) {
sub := &model.Subscription{Name: "House of the Dragon 自动订阅", Filter: "House of the Dragon", MediaType: "tv", WashEnabled: true, WashPriority: "quality"}
availability := LocalAvailability{
LocalMediaCount: 1,
TotalEpisodes: 3,
MissingEpisodes: []int{1, 2, 3},
ExistingEpisodeKeys: map[string]struct{}{},
}
results := []SearchResult{
{Title: "House of the Dragon S03E01-E02 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-e02-pack", Seeders: 90},
{Title: "House of the Dragon S03E02 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e02-duplicate", Seeders: 80},
{Title: "House of the Dragon S03E03 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e03", Seeders: 70},
}
got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability)
if len(got) != 2 {
t.Fatalf("selected %d candidates, want pack plus non-overlapping episode", len(got))
}
if got[0].Download != "https://pt/download/e01-e02-pack" || got[1].Download != "https://pt/download/e03" {
t.Fatalf("selected %#v, want overlapping E02 duplicate skipped", got)
}
}
func TestSelectSiteSearchCandidatesAppliesQualityRules(t *testing.T) {
sub := &model.Subscription{
Name: "Dune 自动订阅",
Filter: "Dune 2021",
MediaType: "movie",
Resolution: "2160p",
Quality: "remux",
Effects: "hdr",
ExcludeWords: "cam,ts",
}
results := []SearchResult{
{Title: "Dune 2021 2160p WEB-DL HDR", DownloadURL: "https://pt/download/web", Seeders: 100},
{Title: "Dune 2021 2160p UHD BluRay REMUX HDR", DownloadURL: "https://pt/download/remux", Seeders: 30},
{Title: "Dune 2021 2160p REMUX HDR CAM", DownloadURL: "https://pt/download/cam", Seeders: 200},
}
got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
if len(got) != 1 || got[0].Download != "https://pt/download/remux" {
t.Fatalf("selected %#v, want filtered remux", got)
}
}
@@ -29,7 +29,7 @@ func selectPreparedSubscriptionCandidatesWithStats(candidates []siteSearchCandid
mediaType := normalizeMediaType(sub.MediaType, sub.Name+" "+sub.Filter, "")
if !isSubscriptionSeriesType(mediaType) {
// 非洗版订阅成功下载一次即满足,媒体库/下载中已存在则不再重复下载。
if (sub == nil || !sub.WashEnabled) && local.LocalMediaCount > 0 {
if !subscriptionAllowsWash(sub) && local.LocalMediaCount > 0 {
if stats != nil {
stats.LocalAlreadySatisfied = true
}
@@ -1,18 +1,10 @@
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) {
@@ -56,12 +48,12 @@ func TestSelectSiteSearchCandidatesQueuesDistinctEpisodesWhenNoPack(t *testing.T
func TestSelectSiteSearchCandidatesKeepsMovieSingleBest(t *testing.T) {
sub := &model.Subscription{Name: "Inception 自动订阅", Filter: "Inception 2010", MediaType: "movie", WashPriority: "seeders"}
results := []SearchResult{
{Title: "Inception 2010 1080p", DownloadURL: "https://pt/download/1080", Seeders: 90},
{Title: "Inception 2010 2160p", DownloadURL: "https://pt/download/2160", Seeders: 80},
{Title: "Inception 2010 1080p HDTV", DownloadURL: "https://pt/download/1080-hdtv", Seeders: 900},
{Title: "Inception 2010 2160p WEB-DL", DownloadURL: "https://pt/download/2160-webdl", Seeders: 80},
}
got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
if len(got) != 1 || got[0].Download != "https://pt/download/1080" {
if len(got) != 1 || got[0].Download != "https://pt/download/2160-webdl" {
t.Fatalf("selected %#v, want movie best only", got)
}
}
@@ -197,277 +189,3 @@ func TestDedupeSiteSearchResultsKeepsMatchedSearchKeyword(t *testing.T) {
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{
{Title: "Inception 2010 1080p", DownloadURL: "https://pt/download/1080", Seeders: 90},
{Title: "Inception 2010 2160p", DownloadURL: "https://pt/download/2160", Seeders: 80},
}
got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
if len(got) != 1 || got[0].Download != "https://pt/download/1080" {
t.Fatalf("selected %#v, want seeders best when wash disabled", got)
}
}
func TestSelectSiteSearchCandidatesAppliesQualityRules(t *testing.T) {
sub := &model.Subscription{
Name: "Dune 自动订阅",
Filter: "Dune 2021",
MediaType: "movie",
Resolution: "2160p",
Quality: "remux",
Effects: "hdr",
ExcludeWords: "cam,ts",
}
results := []SearchResult{
{Title: "Dune 2021 2160p WEB-DL HDR", DownloadURL: "https://pt/download/web", Seeders: 100},
{Title: "Dune 2021 2160p UHD BluRay REMUX HDR", DownloadURL: "https://pt/download/remux", Seeders: 30},
{Title: "Dune 2021 2160p REMUX HDR CAM", DownloadURL: "https://pt/download/cam", Seeders: 200},
}
got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
if len(got) != 1 || got[0].Download != "https://pt/download/remux" {
t.Fatalf("selected %#v, want filtered remux", got)
}
}
func TestSiteSearchKeywordCanUseIMDB(t *testing.T) {
sub := &model.Subscription{Name: "沙丘 自动订阅", Filter: "Dune 2021", SearchMode: "imdb", IMDBID: "tt1160419"}
if got := siteSearchKeyword(sub); got != "tt1160419" {
t.Fatalf("keyword = %q, want imdb id", got)
}
}
func TestSiteSearchKeywordsIncludeAliasesAndCleanedKeywords(t *testing.T) {
sub := &model.Subscription{
Name: "真人快打2 自动订阅",
FeedURL: "site-search://search?keyword=%E7%9C%9F%E4%BA%BA%E5%BF%AB%E6%89%932%202026&alias=Mortal%20Kombat%20II%202026",
Filter: "真人快打2 2026",
}
got := siteSearchKeywords(sub)
for _, want := range []string{"真人快打2 2026", "Mortal Kombat II 2026", "真人快打2", "Mortal Kombat II"} {
if !containsString(got, want) {
t.Fatalf("keywords = %#v, missing %q", got, want)
}
}
if got[0] != "真人快打2 2026" {
t.Fatalf("primary keyword = %q, want feed keyword first", got[0])
}
}
func TestSiteSearchKeywordsUseCleanMetadataAliases(t *testing.T) {
sub := &model.Subscription{
Name: "玩具总动员 4 自动订阅",
Filter: "玩具总动员 4 2019",
OriginalName: "Toy Story 4",
Year: 2019,
}
got := siteSearchKeywords(sub)
for _, want := range []string{"玩具总动员 4 2019", "Toy Story 4", "Toy Story 4 2019", "玩具总动员 4"} {
if !containsString(got, want) {
t.Fatalf("keywords = %#v, missing %q", got, want)
}
}
for _, unwanted := range []string{"玩具总动员 4 自动订阅", "玩具总动员 4 自动订阅 2019", "玩具总动员 4 2019 2019"} {
if containsString(got, unwanted) {
t.Fatalf("keywords = %#v, should not contain %q", got, unwanted)
}
}
}
func containsString(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func TestStableSiteSearchGUIDIgnoresPrivateTokenChanges(t *testing.T) {
item := SearchResult{
SiteID: "mteam",
Title: "Some Show S01E01 1080p",
Category: "TV",
Size: 1024,
}
first := stableSiteSearchGUID(item, "https://pt.example/download?id=123&passkey=old")
second := stableSiteSearchGUID(item, "https://pt.example/download?id=123&passkey=new")
if first != second {
t.Fatalf("stableSiteSearchGUID changed with token: %q != %q", first, second)
}
if strings.Contains(first, "passkey") || strings.Contains(first, "old") || strings.Contains(first, "new") {
t.Fatalf("stableSiteSearchGUID leaked private token: %q", first)
}
}
func TestSelectSiteSearchCandidatesWithStatsExplainsFiltering(t *testing.T) {
sub := &model.Subscription{Name: "Stats Show 自动订阅", Filter: "Stats Show", MediaType: "tv"}
seenItem := SearchResult{Title: "Stats Show S01E02 1080p", DownloadURL: "https://pt/download/seen", Seeders: 50}
seenGUID := stableSiteSearchGUID(seenItem, seenItem.DownloadURL)
results := []SearchResult{
{Title: "Different Show S01E01 1080p", DownloadURL: "https://pt/download/wrong", Seeders: 90},
{Title: "Stats Show S01E01 CAM", DownloadURL: "https://pt/download/cam", Seeders: 80},
{Title: "Stats Show S01E02 1080p", Seeders: 70},
seenItem,
{Title: "Stats Show S01E03 1080p", DownloadURL: "https://pt/download/right", Seeders: 60},
}
got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{seenGUID: {}}, LocalAvailability{})
if len(got) != 1 || got[0].Download != "https://pt/download/right" {
t.Fatalf("selected %#v, want only unfiltered candidate", got)
}
if stats.Total != 5 ||
stats.QueryMismatch != 1 ||
stats.RuleMismatch != 1 ||
stats.MissingDownload != 1 ||
stats.Seen != 1 ||
stats.Prepared != 1 ||
stats.Selected != 1 {
t.Fatalf("unexpected stats: %#v", stats)
}
if len(stats.QueryMismatchExamples) != 1 || stats.QueryMismatchExamples[0] != "Different Show S01E01 1080p" {
t.Fatalf("query mismatch examples = %#v", stats.QueryMismatchExamples)
}
}
@@ -0,0 +1,88 @@
package service
import (
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
func TestSubscriptionRunOneDoesNotUseDeletedDownloader(t *testing.T) {
rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml")
_, _ = w.Write([]byte(`<?xml version="1.0"?>
<rss><channel>
<item>
<title>Deleted Downloader Show S01E01 1080p</title>
<guid>deleted-downloader-episode-1</guid>
<link>magnet:?xt=urn:btih:cccccccccccccccccccccccccccccccccccccccc&amp;dn=Deleted+Downloader+Show+S01E01</link>
</item>
</channel></rss>`))
}))
defer rss.Close()
var qbCalls int32
qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&qbCalls, 1)
switch r.URL.Path {
case "/api/v2/auth/login":
_, _ = w.Write([]byte("Ok."))
case "/api/v2/torrents/info":
_, _ = w.Write([]byte(`[]`))
case "/api/v2/torrents/add":
_, _ = w.Write([]byte("Ok."))
default:
http.NotFound(w, r)
}
}))
defer qb.Close()
db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{})
repos := repository.New(db)
client := &model.DownloadClient{Name: "qB deleted", Type: "qbittorrent", Host: qb.URL, Username: "admin", Password: "admin", IsDefault: true, Enabled: true}
if err := repos.DownloadClient.Create(t.Context(), client); err != nil {
t.Fatal(err)
}
if err := repos.Setting.Set(t.Context(), settingDownloadClientsManaged, "true"); err != nil {
t.Fatal(err)
}
if err := repos.DownloadClient.Delete(t.Context(), client.ID); err != nil {
t.Fatal(err)
}
downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop()))
sub := &model.Subscription{
Name: "Deleted Downloader Show 自动订阅",
FeedURL: rss.URL,
Filter: "Deleted Downloader Show",
MediaType: "tv",
SavePath: "/downloads/tv",
}
if err := repos.Subscription.Create(t.Context(), sub); err != nil {
t.Fatal(err)
}
queued, err := svc.runOne(t.Context(), sub)
if err != nil {
t.Fatal(err)
}
if queued != 0 {
t.Fatalf("queued = %d, want 0 when default downloader was deleted", queued)
}
if got := atomic.LoadInt32(&qbCalls); got != 0 {
t.Fatalf("qB calls = %d, want 0 after downloader deletion", got)
}
rows, err := repos.Download.List(t.Context())
if err != nil {
t.Fatal(err)
}
if len(rows) != 0 {
t.Fatalf("download rows = %d, want 0", len(rows))
}
}
@@ -7,14 +7,12 @@ import (
// defaultExcludeWords 是默认过滤的「垃圾版本」排除清单,对所有订阅生效。
// 拉丁词在 containsAnyExcludeToken 里按词边界匹配以避免子串误伤。
const defaultExcludeWords = "cam,ts,tc,telesync,telecine,hdcam,hdts,枪版,抢先,抢鲜,预告,trailer,sample"
const defaultExcludeWords = "cam,ts,tc,telesync,telecine,hdcam,hdts,枪版,抢先,抢鲜,预告,trailer,sample,hr,h&r,hit and run,hit&run,hit-and-run,禁转,禁止转载,禁下,禁止下载"
// defaultCompatibilityExcludeWords 是面向自动订阅的兼容性默认排除清单。
// 仅在用户未真正自定义排除词时启用,避免默认命中 DoVi/H.265/10bit/杜比音轨等版本。
const defaultCompatibilityExcludeWords = "dovi,dv,dolby vision,dolby,杜比视界,杜比,h265,h.265,h-265,h_265,h 265,hevc,x265,10bit,10-bit,10 bit,hi10p,atmos,truehd,ddp,dd+,eac3"
const legacyFrontendExcludeWords = "cam,ts,tc,枪版"
func containsAnyToken(titleFold, csv string) bool {
for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool {
return r == ',' || r == '/' || r == '|' || r == ';' || r == ','
@@ -0,0 +1,35 @@
package service
import (
"testing"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
func TestSelectRSSSubscriptionCandidatesDefaultKeepsOnlyBestWebDLPerEpisode(t *testing.T) {
sub := &model.Subscription{Name: "House of the Dragon 自动订阅", Filter: "House of the Dragon", MediaType: "tv"}
items := []rssItem{
rssSubscriptionItem("House of the Dragon S03E01 1080p WEB-DL H264 AAC", "https://pt/download/e01-1080"),
rssSubscriptionItem("House of the Dragon S03E01 2160p WEB-DL H264 AAC", "https://pt/download/e01-2160"),
rssSubscriptionItem("House of the Dragon S03E01 720p WEBRip H264 AAC", "https://pt/download/e01-720"),
rssSubscriptionItem("House of the Dragon S03E02 1080p HDTV H264 AAC", "https://pt/download/e02-hdtv"),
rssSubscriptionItem("House of the Dragon S03E02 1080p WEB-DL H264 AAC", "https://pt/download/e02-webdl"),
}
got := selectRSSSubscriptionCandidates(items, sub, compileFilter(sub.Filter), nil, LocalAvailability{})
if len(got) != 2 {
t.Fatalf("selected %d candidates, want one best release per episode", len(got))
}
if got[0].Download != "https://pt/download/e01-2160" {
t.Fatalf("episode 1 selected %q, want 2160p WEB-DL", got[0].Download)
}
if got[1].Download != "https://pt/download/e02-webdl" {
t.Fatalf("episode 2 selected %q, want WEB-DL over HDTV", got[1].Download)
}
}
func rssSubscriptionItem(title, download string) rssItem {
item := rssItem{Title: title, Link: download, GUID: download}
item.Enclosure.URL = download
return item
}
+2 -2
View File
@@ -60,7 +60,7 @@ func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscriptio
seenSet: seenSet,
availability: mergeLocalAvailability(SubscriptionLocalAvailability(ctx, s.repo, sub), s.pendingDownloadAvailability(ctx, sub)),
availabilityQuery: availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)),
washOff: !sub.WashEnabled,
washOff: !subscriptionAllowsWash(sub),
}
candidates := selectRSSSubscriptionCandidates(feed.Channel.Items, sub, filter, runState.seenSet, runState.availability)
queued = s.enqueueRSSSubscriptionCandidates(ctx, sub, candidates, runState)
@@ -94,7 +94,7 @@ func (s *SubscriptionService) enqueueRSSSubscriptionCandidate(ctx context.Contex
Overview: sub.Overview,
MediaType: mediaType,
MediaCategory: mediaCategory,
AllowExistingLibrary: sub.WashEnabled,
AllowExistingLibrary: subscriptionAllowsWash(sub),
}); err != nil {
if IsDownloadDedupError(err) {
if s.subscriptionCandidateConfirmedAvailable(ctx, sub, candidate) {
+56 -6
View File
@@ -14,7 +14,7 @@ func matchesSubscriptionRules(sub *model.Subscription, title string) bool {
if sub == nil {
return true
}
if shouldApplyDefaultCompatibilityExcludes(sub.ExcludeWords) && containsAnyExcludeToken(titleFold, defaultCompatibilityExcludeWords) {
if compatibilityExcludes := defaultCompatibilityExcludesForSubscription(sub); compatibilityExcludes != "" && containsAnyExcludeToken(titleFold, compatibilityExcludes) {
return false
}
if sub.ExcludeWords != "" && containsAnyExcludeToken(titleFold, sub.ExcludeWords) {
@@ -35,13 +35,42 @@ func matchesSubscriptionRules(sub *model.Subscription, title string) bool {
return true
}
func shouldApplyDefaultCompatibilityExcludes(excludeWords string) bool {
normalized := normalizeExcludeWords(excludeWords)
return normalized == "" || normalized == normalizeExcludeWords(legacyFrontendExcludeWords)
func defaultCompatibilityExcludesForSubscription(sub *model.Subscription) string {
if sub == nil {
return defaultCompatibilityExcludeWords
}
requested := strings.ToLower(strings.Join([]string{sub.Effects, sub.Quality}, ","))
if strings.TrimSpace(requested) == "" {
return defaultCompatibilityExcludeWords
}
tokens := excludeWordTokens(defaultCompatibilityExcludeWords)
out := make([]string, 0, len(tokens))
for _, token := range tokens {
if token == "" || compatibilityTokenRequested(requested, token) {
continue
}
out = append(out, token)
}
return strings.Join(out, ",")
}
func normalizeExcludeWords(csv string) string {
return strings.Join(excludeWordTokens(csv), ",")
func compatibilityTokenRequested(requested, token string) bool {
switch token {
case "dovi", "dv", "dolby vision", "杜比视界":
return containsAnyEffect(requested, "dolby-vision") || containsAnyToken(requested, "dovi,dv,dolby vision,杜比视界")
case "dolby", "杜比":
return containsAnyEffect(requested, "dolby-vision") || containsAnyToken(requested, "dolby,dolby vision,杜比,杜比视界,atmos,dolby atmos,杜比全景声")
case "atmos":
return containsAnyToken(requested, "atmos,dolby atmos,杜比全景声")
case "h265", "h.265", "h-265", "h_265", "h 265", "hevc", "x265":
return containsAnyToken(requested, "h265,h.265,h-265,h_265,h 265,hevc,x265")
case "10bit", "10-bit", "10 bit", "hi10p":
return containsAnyToken(requested, "10bit,10-bit,10 bit,hi10p")
case "truehd", "ddp", "dd+", "eac3":
return containsAnyToken(requested, "truehd,ddp,dd+,eac3")
default:
return containsAnyToken(requested, token)
}
}
func isSubscriptionSeriesType(mediaType string) bool {
@@ -52,3 +81,24 @@ func isSubscriptionSeriesType(mediaType string) bool {
return false
}
}
func subscriptionAllowsWash(sub *model.Subscription) bool {
if sub == nil || !sub.WashEnabled {
return false
}
return subscriptionHasExplicitUpgradeCriteria(sub)
}
func subscriptionHasExplicitUpgradeCriteria(sub *model.Subscription) bool {
if sub == nil {
return false
}
if value := strings.TrimSpace(strings.ToLower(sub.Resolution)); value != "" && value != "best" {
return true
}
if value := strings.TrimSpace(strings.ToLower(sub.Quality)); value != "" && value != "best" {
return true
}
return strings.TrimSpace(sub.Effects) != "" ||
strings.TrimSpace(sub.ReleaseGroups) != ""
}
+43 -5
View File
@@ -75,6 +75,21 @@ func TestMatchesSubscriptionRulesDefaultExcludesJunkReleases(t *testing.T) {
}
}
func TestMatchesSubscriptionRulesDefaultExcludesRiskyTorrentLabels(t *testing.T) {
sub := &model.Subscription{}
for _, title := range []string{
"Some Show S01E01 1080p WEB-DL HR",
"Some Show S01E01 1080p WEB-DL H&R",
"Some Show S01E01 1080p WEB-DL Hit and Run",
"Some Show S01E01 1080p WEB-DL 禁转",
"Some Show S01E01 1080p WEB-DL 禁止下载",
} {
if matchesSubscriptionRules(sub, title) {
t.Errorf("expected default rules to exclude risky torrent label %q", title)
}
}
}
func TestMatchesSubscriptionRulesDefaultExcludesCompatibilityReleases(t *testing.T) {
cases := []struct {
name string
@@ -82,6 +97,7 @@ func TestMatchesSubscriptionRulesDefaultExcludesCompatibilityReleases(t *testing
}{
{name: "empty exclude words", sub: &model.Subscription{}},
{name: "legacy frontend defaults", sub: &model.Subscription{ExcludeWords: "cam,ts,tc,枪版"}},
{name: "custom exclude words", sub: &model.Subscription{ExcludeWords: "官中,无字幕"}},
}
for _, c := range cases {
@@ -89,6 +105,7 @@ func TestMatchesSubscriptionRulesDefaultExcludesCompatibilityReleases(t *testing
for _, title := range []string{
"Some Movie 2024 2160p DoVi H.265 10bit",
"Some Movie 2024 2160p H-265",
"Some Movie 2024 2160p H 265 10 bit",
"Some Movie 2024 1080p HEVC",
"Some Movie 2024 1080p x265",
"Some Movie 2024 2160p Dolby Vision Atmos",
@@ -103,17 +120,38 @@ func TestMatchesSubscriptionRulesDefaultExcludesCompatibilityReleases(t *testing
}
}
func TestMatchesSubscriptionRulesCustomExcludeWordsCanOptOutOfCompatibilityDefaults(t *testing.T) {
func TestMatchesSubscriptionRulesCustomExcludeWordsKeepCompatibilityDefaults(t *testing.T) {
sub := &model.Subscription{ExcludeWords: "sample"}
title := "Some Movie 2024 2160p DoVi HEVC 10bit"
if !matchesSubscriptionRules(sub, title) {
t.Fatalf("custom exclude words should not force default compatibility excludes for %q", title)
if matchesSubscriptionRules(sub, title) {
t.Fatalf("custom exclude words should keep default compatibility excludes for %q", title)
}
if matchesSubscriptionRules(sub, "Some Movie 2024 1080p SAMPLE") {
t.Fatal("custom exclude words should still apply")
}
}
func TestMatchesSubscriptionRulesExplicitEffectsCanRequestCompatibilityFormats(t *testing.T) {
sub := &model.Subscription{Effects: "dolby vision"}
title := "Some Movie 2024 2160p DoVi WEB-DL"
if !matchesSubscriptionRules(sub, title) {
t.Fatalf("explicit requested effects should allow compatibility format for %q", title)
}
}
func TestMatchesSubscriptionRulesExplicitAtmosDoesNotAllowOtherCompatibilityFormats(t *testing.T) {
sub := &model.Subscription{Effects: "atmos"}
if !matchesSubscriptionRules(sub, "Some Movie 2024 1080p WEB-DL Atmos") {
t.Fatal("explicit atmos should allow an Atmos-only release")
}
if !matchesSubscriptionRules(sub, "Some Movie 2024 1080p WEB-DL Dolby Atmos") {
t.Fatal("explicit atmos should allow Dolby Atmos wording")
}
if matchesSubscriptionRules(sub, "Some Movie 2024 2160p WEB-DL HEVC 10bit DoVi Atmos") {
t.Fatal("explicit atmos should not also allow DoVi/HEVC/10bit")
}
}
func TestMatchesSubscriptionRulesWordBoundaryAvoidsFalsePositives(t *testing.T) {
sub := &model.Subscription{}
// "ts" / "cam" / "tc" 作为子串出现在合法标题里时不应被默认排除误伤。
@@ -142,8 +180,8 @@ func TestSelectSiteSearchCandidatesSkipsExistingMovieWhenNotWashing(t *testing.T
}
}
func TestSelectSiteSearchCandidatesAllowsMovieWashUpgrade(t *testing.T) {
sub := &model.Subscription{Name: "Inception 自动订阅", Filter: "Inception 2010", MediaType: "movie", WashEnabled: true, WashPriority: "resolution"}
func TestSelectSiteSearchCandidatesAllowsMovieWashUpgradeWithExplicitCriteria(t *testing.T) {
sub := &model.Subscription{Name: "Inception 自动订阅", Filter: "Inception 2010", MediaType: "movie", Resolution: "2160p", WashEnabled: true, WashPriority: "resolution"}
results := []SearchResult{
{Title: "Inception 2010 2160p REMUX", DownloadURL: "https://pt/download/2160", Seeders: 80},
{Title: "Inception 2010 1080p WEB-DL", DownloadURL: "https://pt/download/1080", Seeders: 200},
+119 -28
View File
@@ -78,6 +78,8 @@ func TestSubscriptionRunOneRSSWashQueuesOnlyBestMovieVariant(t *testing.T) {
FeedURL: rss.URL,
Filter: "Dune 2021",
MediaType: "movie",
Resolution: "2160p",
Quality: "remux",
WashEnabled: true,
WashPriority: "resolution",
SavePath: "/downloads/movies",
@@ -100,29 +102,52 @@ func TestSubscriptionRunOneRSSWashQueuesOnlyBestMovieVariant(t *testing.T) {
}
}
func TestSubscriptionRunOneDoesNotUseDeletedDownloader(t *testing.T) {
func TestSubscriptionRunOneRSSDefaultQueuesOnlyBestEpisodeVariant(t *testing.T) {
rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml")
_, _ = w.Write([]byte(`<?xml version="1.0"?>
<rss><channel>
<item>
<title>Deleted Downloader Show S01E01 1080p</title>
<guid>deleted-downloader-episode-1</guid>
<link>magnet:?xt=urn:btih:cccccccccccccccccccccccccccccccccccccccc&amp;dn=Deleted+Downloader+Show+S01E01</link>
<title>House of the Dragon S03E01 2160p BluRay H264 AAC</title>
<guid>hotd-e01-bluray</guid>
<link>magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&amp;dn=House+of+the+Dragon+S03E01+2160p+BluRay</link>
</item>
<item>
<title>House of the Dragon S03E01 1080p WEB-DL H264 AAC</title>
<guid>hotd-e01-webdl</guid>
<link>magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&amp;dn=House+of+the+Dragon+S03E01+1080p+WEB-DL</link>
</item>
<item>
<title>House of the Dragon S03E01 720p HDTV H264 AAC</title>
<guid>hotd-e01-hdtv</guid>
<link>magnet:?xt=urn:btih:cccccccccccccccccccccccccccccccccccccccc&amp;dn=House+of+the+Dragon+S03E01+720p+HDTV</link>
</item>
</channel></rss>`))
}))
defer rss.Close()
var qbCalls int32
var addCalls int32
var addedURLs []string
addedHashes := make([]string, 0, 3)
qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&qbCalls, 1)
switch r.URL.Path {
case "/api/v2/auth/login":
_, _ = w.Write([]byte("Ok."))
case "/api/v2/torrents/info":
_, _ = w.Write([]byte(`[]`))
if len(addedHashes) == 0 {
_, _ = w.Write([]byte(`[]`))
return
}
var items []string
for _, hash := range addedHashes {
items = append(items, `{"hash":"`+hash+`","name":"House of the Dragon S03E01","state":"downloading","progress":0.1}`)
}
_, _ = w.Write([]byte(`[` + strings.Join(items, ",") + `]`))
case "/api/v2/torrents/add":
call := atomic.AddInt32(&addCalls, 1)
_ = r.ParseMultipartForm(10 << 20)
addedURLs = append(addedURLs, r.FormValue("urls"))
addedHashes = append(addedHashes, strings.Repeat(fmt.Sprintf("%x", call), 40))
_, _ = w.Write([]byte("Ok."))
default:
http.NotFound(w, r)
@@ -132,45 +157,111 @@ func TestSubscriptionRunOneDoesNotUseDeletedDownloader(t *testing.T) {
db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{})
repos := repository.New(db)
client := &model.DownloadClient{Name: "qB deleted", Type: "qbittorrent", Host: qb.URL, Username: "admin", Password: "admin", IsDefault: true, Enabled: true}
if err := repos.DownloadClient.Create(t.Context(), client); err != nil {
t.Fatal(err)
}
if err := repos.Setting.Set(t.Context(), settingDownloadClientsManaged, "true"); err != nil {
t.Fatal(err)
}
if err := repos.DownloadClient.Delete(t.Context(), client.ID); err != nil {
t.Fatal(err)
}
configureTestDefaultQB(t, repos, qb.URL)
downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop()))
sub := &model.Subscription{
Name: "Deleted Downloader Show 自动订阅",
Name: "House of the Dragon 自动订阅",
FeedURL: rss.URL,
Filter: "Deleted Downloader Show",
Filter: "House of the Dragon",
MediaType: "tv",
SavePath: "/downloads/tv",
}
if err := repos.Subscription.Create(t.Context(), sub); err != nil {
t.Fatal(err)
}
queued, err := svc.runOne(t.Context(), sub)
if err != nil {
t.Fatal(err)
}
if queued != 0 {
t.Fatalf("queued = %d, want 0 when default downloader was deleted", queued)
if queued != 1 {
t.Fatalf("queued = %d, want one best episode variant", queued)
}
if got := atomic.LoadInt32(&qbCalls); got != 0 {
t.Fatalf("qB calls = %d, want 0 after downloader deletion", got)
if got := atomic.LoadInt32(&addCalls); got != 1 {
t.Fatalf("qb add calls = %d, want 1", got)
}
rows, err := repos.Download.List(t.Context())
if len(addedURLs) != 1 || !strings.Contains(addedURLs[0], "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") {
t.Fatalf("added %#v, want 1080p WEB-DL variant only", addedURLs)
}
}
func TestSubscriptionRunOneRSSCustomExcludeStillSkipsIncompatibleVariants(t *testing.T) {
rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml")
_, _ = w.Write([]byte(`<?xml version="1.0"?>
<rss><channel>
<item>
<title>House of the Dragon S03E01 2160p WEB-DL HEVC 10bit DoVi Atmos</title>
<guid>hotd-e01-dovi</guid>
<link>magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&amp;dn=House+of+the+Dragon+S03E01+2160p+DoVi</link>
</item>
<item>
<title>House of the Dragon S03E01 1080p WEB-DL H264 AAC</title>
<guid>hotd-e01-webdl</guid>
<link>magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&amp;dn=House+of+the+Dragon+S03E01+1080p+WEB-DL</link>
</item>
</channel></rss>`))
}))
defer rss.Close()
var addCalls int32
var addedURLs []string
addedHashes := make([]string, 0, 2)
qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v2/auth/login":
_, _ = w.Write([]byte("Ok."))
case "/api/v2/torrents/info":
if len(addedHashes) == 0 {
_, _ = w.Write([]byte(`[]`))
return
}
var items []string
for _, hash := range addedHashes {
items = append(items, `{"hash":"`+hash+`","name":"House of the Dragon S03E01","state":"downloading","progress":0.1}`)
}
_, _ = w.Write([]byte(`[` + strings.Join(items, ",") + `]`))
case "/api/v2/torrents/add":
call := atomic.AddInt32(&addCalls, 1)
_ = r.ParseMultipartForm(10 << 20)
addedURLs = append(addedURLs, r.FormValue("urls"))
addedHashes = append(addedHashes, strings.Repeat(fmt.Sprintf("%x", call), 40))
_, _ = w.Write([]byte("Ok."))
default:
http.NotFound(w, r)
}
}))
defer qb.Close()
db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{})
repos := repository.New(db)
configureTestDefaultQB(t, repos, qb.URL)
downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop()))
sub := &model.Subscription{
Name: "House of the Dragon 自动订阅",
FeedURL: rss.URL,
Filter: "House of the Dragon",
MediaType: "tv",
SavePath: "/downloads/tv",
ExcludeWords: "官中,无字幕",
}
if err := repos.Subscription.Create(t.Context(), sub); err != nil {
t.Fatal(err)
}
queued, err := svc.runOne(t.Context(), sub)
if err != nil {
t.Fatal(err)
}
if len(rows) != 0 {
t.Fatalf("download rows = %d, want 0", len(rows))
if queued != 1 {
t.Fatalf("queued = %d, want one compatible WEB-DL variant", queued)
}
if got := atomic.LoadInt32(&addCalls); got != 1 {
t.Fatalf("qb add calls = %d, want 1", got)
}
if len(addedURLs) != 1 || !strings.Contains(addedURLs[0], "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") {
t.Fatalf("added %#v, want compatible 1080p WEB-DL only", addedURLs)
}
}
+36 -6
View File
@@ -7,12 +7,25 @@ import (
"github.com/ShukeBta/MediaStationGo/internal/model"
)
var (
dolbyVisionTokenRE = regexp.MustCompile(`\bdv\b`)
webDLTokenRE = regexp.MustCompile(`\bweb[\s._-]?dl\b`)
webRipTokenRE = regexp.MustCompile(`\bweb[\s._-]?rip\b`)
bluRayTokenRE = regexp.MustCompile(`\b(?:blu[\s._-]?ray|bdrip|bdremux|uhd[\s._-]?blu[\s._-]?ray)\b`)
)
const (
defaultSubscriptionFreePromotionScore = 50_000
washSubscriptionFreePromotionScore = 25
)
func subscriptionCandidateScore(sub *model.Subscription, item SearchResult) int {
title := strings.ToLower(subscriptionSearchResultText(item))
score := item.Seeders
if sub == nil || !sub.WashEnabled {
if !subscriptionAllowsWash(sub) {
score += detectDefaultSubscriptionQualityScore(title)*1_000_000 + detectResolutionScore(title)*100_000
if item.Free {
score += 25
score += defaultSubscriptionFreePromotionScore
}
return score
}
@@ -37,7 +50,7 @@ func subscriptionCandidateScore(sub *model.Subscription, item SearchResult) int
score += resolutionScore*500 + qualityScore*300 + effectScore*150
}
if item.Free {
score += 25
score += washSubscriptionFreePromotionScore
}
return score
}
@@ -52,7 +65,7 @@ func containsAnyEffect(titleFold, csv string) bool {
}
switch token {
case "dolby-vision", "dolby vision", "dv":
if strings.Contains(titleFold, "dolby vision") || strings.Contains(titleFold, "dovi") || regexp.MustCompile(`\bdv\b`).MatchString(titleFold) {
if strings.Contains(titleFold, "dolby vision") || strings.Contains(titleFold, "dovi") || dolbyVisionTokenRE.MatchString(titleFold) {
return true
}
default:
@@ -80,9 +93,11 @@ func titleMatchesResolution(titleFold, resolution string) bool {
func titleMatchesQuality(titleFold, quality string) bool {
switch strings.ToLower(strings.TrimSpace(quality)) {
case "webdl", "web-dl":
return strings.Contains(titleFold, "web-dl") || strings.Contains(titleFold, "webdl")
return webDLTokenRE.MatchString(titleFold)
case "webrip", "web-rip":
return webRipTokenRE.MatchString(titleFold)
case "bluray", "blu-ray":
return strings.Contains(titleFold, "bluray") || strings.Contains(titleFold, "blu-ray") || strings.Contains(titleFold, "bdrip")
return bluRayTokenRE.MatchString(titleFold)
case "remux":
return strings.Contains(titleFold, "remux")
case "hdtv":
@@ -92,6 +107,21 @@ func titleMatchesQuality(titleFold, quality string) bool {
}
}
func detectDefaultSubscriptionQualityScore(titleFold string) int {
switch {
case titleMatchesQuality(titleFold, "web-dl"):
return 5
case titleMatchesQuality(titleFold, "web-rip"):
return 4
case titleMatchesQuality(titleFold, "bluray"), titleMatchesQuality(titleFold, "remux"):
return 3
case titleMatchesQuality(titleFold, "hdtv"):
return 2
default:
return 1
}
}
func detectResolutionScore(titleFold string) int {
switch {
case titleMatchesResolution(titleFold, "2160p"):
@@ -68,7 +68,7 @@ func (s *SubscriptionService) enqueueSiteSearchCandidate(ctx context.Context, su
MediaType: mediaType,
MediaCategory: mediaCategory,
SourceCategory: item.Category,
AllowExistingLibrary: sub.WashEnabled,
AllowExistingLibrary: subscriptionAllowsWash(sub),
}); err != nil {
if IsDownloadDedupError(err) {
if s.subscriptionCandidateConfirmedAvailable(ctx, sub, candidate) {
@@ -0,0 +1,110 @@
package service
import (
"strings"
"testing"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
func TestSiteSearchKeywordCanUseIMDB(t *testing.T) {
sub := &model.Subscription{Name: "沙丘 自动订阅", Filter: "Dune 2021", SearchMode: "imdb", IMDBID: "tt1160419"}
if got := siteSearchKeyword(sub); got != "tt1160419" {
t.Fatalf("keyword = %q, want imdb id", got)
}
}
func TestSiteSearchKeywordsIncludeAliasesAndCleanedKeywords(t *testing.T) {
sub := &model.Subscription{
Name: "真人快打2 自动订阅",
FeedURL: "site-search://search?keyword=%E7%9C%9F%E4%BA%BA%E5%BF%AB%E6%89%932%202026&alias=Mortal%20Kombat%20II%202026",
Filter: "真人快打2 2026",
}
got := siteSearchKeywords(sub)
for _, want := range []string{"真人快打2 2026", "Mortal Kombat II 2026", "真人快打2", "Mortal Kombat II"} {
if !containsString(got, want) {
t.Fatalf("keywords = %#v, missing %q", got, want)
}
}
if got[0] != "真人快打2 2026" {
t.Fatalf("primary keyword = %q, want feed keyword first", got[0])
}
}
func TestSiteSearchKeywordsUseCleanMetadataAliases(t *testing.T) {
sub := &model.Subscription{
Name: "玩具总动员 4 自动订阅",
Filter: "玩具总动员 4 2019",
OriginalName: "Toy Story 4",
Year: 2019,
}
got := siteSearchKeywords(sub)
for _, want := range []string{"玩具总动员 4 2019", "Toy Story 4", "Toy Story 4 2019", "玩具总动员 4"} {
if !containsString(got, want) {
t.Fatalf("keywords = %#v, missing %q", got, want)
}
}
for _, unwanted := range []string{"玩具总动员 4 自动订阅", "玩具总动员 4 自动订阅 2019", "玩具总动员 4 2019 2019"} {
if containsString(got, unwanted) {
t.Fatalf("keywords = %#v, should not contain %q", got, unwanted)
}
}
}
func TestStableSiteSearchGUIDIgnoresPrivateTokenChanges(t *testing.T) {
item := SearchResult{
SiteID: "mteam",
Title: "Some Show S01E01 1080p",
Category: "TV",
Size: 1024,
}
first := stableSiteSearchGUID(item, "https://pt.example/download?id=123&passkey=old")
second := stableSiteSearchGUID(item, "https://pt.example/download?id=123&passkey=new")
if first != second {
t.Fatalf("stableSiteSearchGUID changed with token: %q != %q", first, second)
}
if strings.Contains(first, "passkey") || strings.Contains(first, "old") || strings.Contains(first, "new") {
t.Fatalf("stableSiteSearchGUID leaked private token: %q", first)
}
}
func TestSelectSiteSearchCandidatesWithStatsExplainsFiltering(t *testing.T) {
sub := &model.Subscription{Name: "Stats Show 自动订阅", Filter: "Stats Show", MediaType: "tv"}
seenItem := SearchResult{Title: "Stats Show S01E02 1080p", DownloadURL: "https://pt/download/seen", Seeders: 50}
seenGUID := stableSiteSearchGUID(seenItem, seenItem.DownloadURL)
results := []SearchResult{
{Title: "Different Show S01E01 1080p", DownloadURL: "https://pt/download/wrong", Seeders: 90},
{Title: "Stats Show S01E01 CAM", DownloadURL: "https://pt/download/cam", Seeders: 80},
{Title: "Stats Show S01E02 1080p", Seeders: 70},
seenItem,
{Title: "Stats Show S01E03 1080p", DownloadURL: "https://pt/download/right", Seeders: 60},
}
got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{seenGUID: {}}, LocalAvailability{})
if len(got) != 1 || got[0].Download != "https://pt/download/right" {
t.Fatalf("selected %#v, want only unfiltered candidate", got)
}
if stats.Total != 5 ||
stats.QueryMismatch != 1 ||
stats.RuleMismatch != 1 ||
stats.MissingDownload != 1 ||
stats.Seen != 1 ||
stats.Prepared != 1 ||
stats.Selected != 1 {
t.Fatalf("unexpected stats: %#v", stats)
}
if len(stats.QueryMismatchExamples) != 1 || stats.QueryMismatchExamples[0] != "Different Show S01E01 1080p" {
t.Fatalf("query mismatch examples = %#v", stats.QueryMismatchExamples)
}
}
func containsString(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
@@ -0,0 +1,129 @@
package service
import (
"errors"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
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")
}
}
@@ -0,0 +1,56 @@
package service
import (
"strings"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
const bytesPerGiB = 1024 * 1024 * 1024
func matchesSubscriptionTorrentRules(sub *model.Subscription, item SearchResult) bool {
if sub == nil {
return true
}
if sub.MinSeeders > 0 && item.Seeders < sub.MinSeeders {
return false
}
if sub.MaxSeeders > 0 && item.Seeders > sub.MaxSeeders {
return false
}
if !subscriptionSizeInRange(item.Size, sub.MinSizeGB, sub.MaxSizeGB) {
return false
}
if sub.FreeOnly && !subscriptionResultIsFree(item) {
return false
}
return true
}
func subscriptionSizeInRange(sizeBytes int64, minGB, maxGB float64) bool {
if minGB <= 0 && maxGB <= 0 {
return true
}
if sizeBytes <= 0 {
return false
}
sizeGB := float64(sizeBytes) / bytesPerGiB
if minGB > 0 && sizeGB < minGB {
return false
}
if maxGB > 0 && sizeGB > maxGB {
return false
}
return true
}
func subscriptionResultIsFree(item SearchResult) bool {
if item.Free {
return true
}
text := strings.ToLower(subscriptionSearchResultText(item))
return strings.Contains(text, "freeleech") ||
strings.Contains(text, "2xfree") ||
strings.Contains(text, "免费") ||
matchesWordBoundary(text, "free")
}
@@ -0,0 +1,59 @@
package service
import (
"testing"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
func TestSelectSiteSearchCandidatesAppliesSeederSizeAndFreeRules(t *testing.T) {
sub := &model.Subscription{
Name: "Some Show 自动订阅",
Filter: "Some Show",
MediaType: "tv",
MinSeeders: 10,
MaxSeeders: 100,
MinSizeGB: 1,
MaxSizeGB: 8,
FreeOnly: true,
}
results := []SearchResult{
{Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/low-seed", Seeders: 3, Size: 2 * bytesPerGiB, Free: true},
{Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/hot", Seeders: 500, Size: 2 * bytesPerGiB, Free: true},
{Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/small", Seeders: 50, Size: bytesPerGiB / 2, Free: true},
{Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/nonfree", Seeders: 50, Size: 2 * bytesPerGiB},
{Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/right", Seeders: 50, Size: 2 * bytesPerGiB, Free: true},
}
got, stats := selectSiteSearchCandidatesWithStats(results, sub, nil, LocalAvailability{})
if len(got) != 1 || got[0].Download != "https://pt/download/right" {
t.Fatalf("selected %#v, want only torrent matching seed/size/free rules", got)
}
if stats.RuleMismatch != 4 || stats.Prepared != 1 || stats.Selected != 1 {
t.Fatalf("stats = %#v, want four rule mismatches and one selected", stats)
}
}
func TestSubscriptionTorrentRulesRecognizeFreeLabels(t *testing.T) {
sub := &model.Subscription{FreeOnly: true}
for _, item := range []SearchResult{
{Title: "Some Movie 2026 1080p WEB-DL FREE"},
{Title: "Some Movie 2026 1080p WEB-DL FreeLeech"},
{Title: "Some Movie 2026 1080p WEB-DL 免费"},
{Title: "Some Movie 2026 1080p WEB-DL", Free: true},
} {
if !matchesSubscriptionTorrentRules(sub, item) {
t.Fatalf("expected free rule to accept %#v", item)
}
}
if matchesSubscriptionTorrentRules(sub, SearchResult{Title: "Some Movie 2026 1080p WEB-DL"}) {
t.Fatal("free-only rule accepted non-free result")
}
}
func TestSubscriptionTorrentRulesRejectUnknownSizeWhenSizeRangeConfigured(t *testing.T) {
sub := &model.Subscription{MinSizeGB: 1}
if matchesSubscriptionTorrentRules(sub, SearchResult{Title: "Some Movie 2026 1080p WEB-DL"}) {
t.Fatal("size range accepted result without size metadata")
}
}
+5
View File
@@ -33,6 +33,11 @@ export interface DiscoverSection {
export interface DiscoverFeedMeta {
page: number
has_next: boolean
duration_ms?: number
error?: string
warning?: string
stale?: boolean
disabled?: boolean
}
export interface DiscoverFeedResult {
+17
View File
@@ -7,6 +7,7 @@ export type GenerateSTRMInput = {
enabled?: boolean
overwrite?: boolean
include_local?: boolean
preserve_tree?: boolean
}
export type GenerateSTRMResult = {
@@ -27,6 +28,18 @@ export type GenerateSTRMResult = {
}>
}
export type GenerateSTRMTreeInput = {
provider: string
tree_text?: string
paths?: string[]
source_root?: string
output_prefix?: string
output_dir: string
base_url?: string
overwrite?: boolean
cleanup?: boolean
}
export const strmAPI = {
set: (mediaID: string, url: string) =>
api.put(`/media/${mediaID}/strm`, { url }).then((r) => r.data),
@@ -37,4 +50,8 @@ export const strmAPI = {
api
.post<GenerateSTRMResult>('/strm/generate', input, { timeout: BATCH_REQUEST_TIMEOUT })
.then((r) => r.data),
generateFromTree: (input: GenerateSTRMTreeInput) =>
api
.post<GenerateSTRMResult>('/strm/generate-from-tree', input, { timeout: BATCH_REQUEST_TIMEOUT })
.then((r) => r.data),
}
+5
View File
@@ -71,6 +71,11 @@ export const subscriptionsAPI = {
effects?: string
release_groups?: string
exclude_words?: string
min_seeders?: number
max_seeders?: number
min_size_gb?: number
max_size_gb?: number
free_only?: boolean
wash_enabled?: boolean
wash_priority?: string
total_episodes?: number
+51 -5
View File
@@ -7,8 +7,10 @@ import { DiscoverEmptySelection, DiscoverHeader, DiscoverResults } from './Disco
import {
defaultSections,
discoverStorageKey,
readCachedDiscoverRows,
readSavedSections,
serializeSavedSections,
writeCachedDiscoverRow,
} from './discoverPageModel'
export function DiscoverPage() {
@@ -38,8 +40,11 @@ export function DiscoverPage() {
const available = new Set(items.map((item) => item.key))
const fallback = defaultSections.filter((key) => available.has(key))
const nextSelected = saved.length > 0 ? saved : fallback
const cached = readCachedDiscoverRows(nextSelected)
setSelected(nextSelected)
setRowPages(Object.fromEntries(nextSelected.map((key) => [key, 1])))
setRows(cached.rows)
setRowCanNext(cached.rowCanNext)
setSectionsReady(true)
})
.catch(() => {
@@ -93,14 +98,31 @@ export function DiscoverPage() {
.feed([key], page)
.then((feed) => {
if (cancelled) return
setRows((current) => ({ ...current, [key]: feed.items[key] ?? [] }))
setRowCanNext((current) => ({ ...current, [key]: Boolean(feed.meta[key]?.has_next) }))
const error = feed.meta[key]?.error
const nextItems = feed.items[key] ?? []
const nextCanNext = Boolean(feed.meta[key]?.has_next)
setRows((current) => {
if (error && nextItems.length === 0 && (current[key]?.length ?? 0) > 0) {
return current
}
return { ...current, [key]: nextItems }
})
setRowCanNext((current) => {
if (error && nextItems.length === 0 && key in current) {
return current
}
return { ...current, [key]: nextCanNext }
})
if (!error) {
writeCachedDiscoverRow(key, page, nextItems, nextCanNext)
}
setRowErrors((current) => updateDiscoverRowError(current, key, error))
})
.catch((err) => {
if (cancelled) return
const message = err instanceof Error ? err.message : String(err)
setRows((current) => ({ ...current, [key]: [] }))
setRowCanNext((current) => ({ ...current, [key]: false }))
const message = discoverRequestErrorMessage(err)
setRows((current) => ((current[key]?.length ?? 0) > 0 ? current : { ...current, [key]: [] }))
setRowCanNext((current) => (key in current ? current : { ...current, [key]: false }))
setRowErrors((current) => ({ ...current, [key]: message }))
})
.finally(() => {
@@ -191,3 +213,27 @@ export function DiscoverPage() {
</div>
)
}
function updateDiscoverRowError(
current: Record<string, string>,
key: string,
error?: string,
): Record<string, string> {
if (error) return { ...current, [key]: error }
if (!(key in current)) return current
const next = { ...current }
delete next[key]
return next
}
function discoverRequestErrorMessage(err: unknown): string {
const raw = err instanceof Error ? err.message : String(err)
const lower = raw.toLowerCase()
if (lower.includes('timeout') || lower.includes('deadline')) {
return '推荐源请求超时,已跳过本次加载'
}
if (lower.includes('network')) {
return '推荐源网络不可用,已跳过本次加载'
}
return '推荐源暂时不可用,已跳过本次加载'
}
+4 -3
View File
@@ -152,9 +152,10 @@ function DiscoverRowErrors({
sectionLabel: SectionLabel
}) {
return (
<div className="flex items-start gap-3 rounded-2xl border border-amber-500/20 bg-amber-500/10 p-4">
<AlertTriangle className="mt-0.5 h-5 w-5 flex-shrink-0 text-amber-400" />
<div className="space-y-1 text-sm text-amber-200">
<div className="flex items-start gap-3 rounded-lg border border-amber-300/70 bg-amber-50 px-3 py-2 text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-500" />
<div className="space-y-1 text-xs">
<p className="font-semibold">部分推荐源暂不可用,其他已加载内容不受影响。</p>
{Object.entries(rowErrors).map(([key, message]) => (
<p key={key}>{sectionLabel(key)}:{message}</p>
))}
+4
View File
@@ -23,6 +23,8 @@ export type StrmGenerateSectionProps = {
autoGenerate: boolean
savingSettings: boolean
overwrite: boolean
includeLocal: boolean
preserveTree: boolean
generating: boolean
generateResult: GenerateSTRMResult | null
playbackStatus: string
@@ -36,6 +38,8 @@ export type StrmGenerateSectionProps = {
setRedirectProxyEnabled: (value: boolean) => void
setAutoGenerate: (value: boolean) => void
setOverwrite: (value: boolean) => void
setIncludeLocal: (value: boolean) => void
setPreserveTree: (value: boolean) => void
}
export function StrmGenerateSection(props: StrmGenerateSectionProps) {
+33 -9
View File
@@ -142,12 +142,16 @@ type StrmGenerateFormProps = Pick<
| 'baseURL'
| 'outputDir'
| 'overwrite'
| 'includeLocal'
| 'preserveTree'
| 'generating'
| 'onGenerate'
| 'setGenerateLibraryID'
| 'setBaseURL'
| 'setOutputDir'
| 'setOverwrite'
| 'setIncludeLocal'
| 'setPreserveTree'
>
export function StrmGenerateForm({
@@ -156,12 +160,16 @@ export function StrmGenerateForm({
baseURL,
outputDir,
overwrite,
includeLocal,
preserveTree,
generating,
onGenerate,
setGenerateLibraryID,
setBaseURL,
setOutputDir,
setOverwrite,
setIncludeLocal,
setPreserveTree,
}: StrmGenerateFormProps) {
return (
<form onSubmit={onGenerate} className="grid gap-3 md:grid-cols-4">
@@ -180,16 +188,13 @@ export function StrmGenerateForm({
>
使用当前访问地址
</button>
<label className="flex items-center gap-2 rounded-2xl border border-gray-200 bg-white/70 px-3 py-2 text-sm text-ink-50">
<input
type="checkbox"
checked={overwrite}
onChange={(e) => setOverwrite(e.target.checked)}
/>
覆盖已存在
</label>
<div className="grid gap-2 md:col-span-4 md:grid-cols-3">
<CompactOption checked={overwrite} label="覆盖已存在" onChange={setOverwrite} />
<CompactOption checked={includeLocal} label="包含本地媒体" onChange={setIncludeLocal} />
<CompactOption checked={preserveTree} label="保留目录树" onChange={setPreserveTree} />
</div>
<input
className="input-base md:col-span-3"
className="input-base md:col-span-4"
placeholder="输出目录可留空,默认写入 data/strm/分类/子分类"
value={outputDir}
onChange={(e) => setOutputDir(e.target.value)}
@@ -202,6 +207,25 @@ export function StrmGenerateForm({
)
}
type CompactOptionProps = {
checked: boolean
label: string
onChange: (value: boolean) => void
}
function CompactOption({ checked, label, onChange }: CompactOptionProps) {
return (
<label className="flex min-h-10 items-center gap-2 rounded-2xl border border-gray-200 bg-white/70 px-3 py-2 text-sm text-ink-50">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
{label}
</label>
)
}
type LibrarySelectProps = {
libraries: Library[]
value: string
+7
View File
@@ -2,6 +2,7 @@ import { StrmAttachSection } from './StrmAttachSection'
import { StrmGenerateSection } from './StrmGenerateSection'
import { StrmImportSection } from './StrmImportSection'
import { StrmPageHeader } from './StrmPageHeader'
import { StrmTreeGenerateSection } from './StrmTreeGenerateSection'
import { useStrmPage } from './useStrmPage'
// StrmPage exposes the URL-as-file admin tooling backed by the Go server:
@@ -28,6 +29,8 @@ export function StrmPage() {
autoGenerate={strm.generate.autoGenerate}
savingSettings={strm.generate.savingSettings}
overwrite={strm.generate.overwrite}
includeLocal={strm.generate.includeLocal}
preserveTree={strm.generate.preserveTree}
generating={strm.generate.generating}
generateResult={strm.generate.generateResult}
playbackStatus={strm.generate.playbackStatus}
@@ -41,8 +44,12 @@ export function StrmPage() {
setRedirectProxyEnabled={strm.generate.setRedirectProxyEnabled}
setAutoGenerate={strm.generate.setAutoGenerate}
setOverwrite={strm.generate.setOverwrite}
setIncludeLocal={strm.generate.setIncludeLocal}
setPreserveTree={strm.generate.setPreserveTree}
/>
<StrmTreeGenerateSection {...strm.treeGenerate} />
<StrmImportSection
libraries={strm.libraries}
libraryID={strm.importForm.libraryID}
+182
View File
@@ -0,0 +1,182 @@
import { FolderTree, Loader2, Upload, X } from 'lucide-react'
import { currentOrigin } from './strmPageModel'
import { StrmGenerateResultPanel } from './StrmGenerateSectionParts'
import type { useStrmTreeGenerateForm } from './useStrmTreeGenerateForm'
type StrmTreeGenerateSectionProps = ReturnType<typeof useStrmTreeGenerateForm>
const outputPrefixPresets = [
'电影/演唱会',
'电影/纪录片',
'电影/动画电影',
'电影/华语电影',
'电影/日韩电影',
'电影/欧美电影',
'电视剧/纪录片',
'电视剧/儿童',
'电视剧/综艺',
'电视剧/国产剧',
'电视剧/日韩剧',
'电视剧/欧美剧',
'动漫/日番',
'动漫/国漫',
'动漫/韩漫',
'动漫/美漫',
'动漫/其他',
'成人',
]
export function StrmTreeGenerateSection({
baseURL,
cleanup,
generating,
onGenerate,
onImportTreeFile,
outputDir,
outputPrefix,
overwrite,
pathsText,
provider,
result,
setBaseURL,
setCleanup,
setOutputDir,
setOutputPrefix,
setOverwrite,
setPathsText,
setProvider,
setSourceRoot,
setTreeText,
sourceRoot,
treeText,
}: StrmTreeGenerateSectionProps) {
return (
<section className="glass-panel space-y-4">
<div>
<h2 className="font-display text-lg font-semibold text-ink-600">目录树生成 STRM</h2>
<p className="text-sm text-ink-50">从网盘目录树或路径列表直接生成 .strm 文件。</p>
</div>
<form onSubmit={onGenerate} className="grid gap-3 md:grid-cols-4">
<select className="input-base" value={provider} onChange={(e) => setProvider(e.target.value)}>
<option value="openlist">OpenList</option>
<option value="alist">Alist</option>
<option value="cloud115">115</option>
<option value="webdav">WebDAV</option>
<option value="clouddrive2">CloudDrive2</option>
</select>
<input
className="input-base"
placeholder="/电视剧"
value={sourceRoot}
onChange={(e) => setSourceRoot(e.target.value)}
/>
<input
required
className="input-base"
placeholder="输出目录"
value={outputDir}
onChange={(e) => setOutputDir(e.target.value)}
/>
<select
className="input-base"
value={outputPrefixPresets.includes(outputPrefix) ? outputPrefix : ''}
onChange={(e) => setOutputPrefix(e.target.value)}
>
<option value="">选择输出分类</option>
<optgroup label="电影">
<option value="电影/演唱会">演唱会</option>
<option value="电影/纪录片">纪录片</option>
<option value="电影/动画电影">动画电影</option>
<option value="电影/华语电影">华语电影</option>
<option value="电影/日韩电影">日韩电影</option>
<option value="电影/欧美电影">欧美电影</option>
</optgroup>
<optgroup label="电视剧">
<option value="电视剧/纪录片">纪录片</option>
<option value="电视剧/儿童">儿童</option>
<option value="电视剧/综艺">综艺</option>
<option value="电视剧/国产剧">国产剧</option>
<option value="电视剧/日韩剧">日韩剧</option>
<option value="电视剧/欧美剧">欧美剧</option>
</optgroup>
<optgroup label="动漫">
<option value="动漫/日番">日番</option>
<option value="动漫/国漫">国漫</option>
<option value="动漫/韩漫">韩漫</option>
<option value="动漫/美漫">美漫</option>
<option value="动漫/其他">其他</option>
</optgroup>
<option value="成人">成人</option>
</select>
<input
className="input-base md:col-span-2"
placeholder="输出分类,如 电影/欧美电影"
value={outputPrefix}
onChange={(e) => setOutputPrefix(e.target.value)}
/>
<input
className="input-base md:col-span-3"
placeholder="http://NAS-IP:18080 或 https://media.example.com"
value={baseURL}
onChange={(e) => setBaseURL(e.target.value)}
/>
<button
type="button"
className="rounded-2xl border border-primary-400/40 px-3 py-2 text-sm text-brand-500 transition hover:bg-primary-400/10"
onClick={() => setBaseURL(currentOrigin())}
>
使用当前访问地址
</button>
<textarea
className="input-base min-h-44 md:col-span-4"
placeholder={'电视剧\n├── 国产剧\n│ └── 南部档案\n│ └── Archives.S01E01.mkv'}
value={treeText}
onChange={(e) => setTreeText(e.target.value)}
/>
<textarea
className="input-base min-h-32 md:col-span-4"
placeholder={'/电视剧/国产剧/南部档案/Season 01/Archives.S01E01.mkv\ncloud://openlist/电影/欧美电影/Dune.Part.Two.2024.mkv'}
value={pathsText}
onChange={(e) => setPathsText(e.target.value)}
/>
<div className="flex flex-wrap items-center gap-2 md:col-span-4">
<label className="inline-flex min-h-10 cursor-pointer items-center gap-2 rounded-2xl border border-gray-200 bg-white/70 px-3 py-2 text-sm font-medium text-ink-500 transition hover:border-primary-300 hover:text-brand-500">
<Upload size={16} />
导入文本
<input className="sr-only" type="file" accept=".txt,.tree,text/plain" onChange={onImportTreeFile} />
</label>
<button
type="button"
className="inline-flex min-h-10 items-center gap-2 rounded-2xl border border-gray-200 bg-white/70 px-3 py-2 text-sm font-medium text-ink-500 transition hover:border-red-200 hover:text-red-500 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!treeText.trim() && !pathsText.trim()}
onClick={() => {
setTreeText('')
setPathsText('')
}}
>
<X size={16} />
清空
</button>
</div>
<label className="flex min-h-10 items-center gap-2 rounded-2xl border border-gray-200 bg-white/70 px-3 py-2 text-sm text-ink-50 md:col-span-4">
<input type="checkbox" checked={overwrite} onChange={(e) => setOverwrite(e.target.checked)} />
覆盖已存在
</label>
<label className="flex min-h-10 items-center gap-2 rounded-2xl border border-gray-200 bg-white/70 px-3 py-2 text-sm text-ink-50 md:col-span-4">
<input type="checkbox" checked={cleanup} onChange={(e) => setCleanup(e.target.checked)} />
清理不在当前目录树中的旧 STRM
</label>
<button
type="submit"
disabled={generating || (!treeText.trim() && !pathsText.trim()) || !outputDir.trim()}
className="neon-button md:col-span-4"
>
{generating ? <Loader2 size={16} className="animate-spin" /> : <FolderTree size={16} />}
{generating ? '生成中...' : '按目录树生成'}
</button>
</form>
<StrmGenerateResultPanel result={result} />
</section>
)
}
+36
View File
@@ -114,6 +114,42 @@ export function SubscriptionForm({ values, editing, onSubmit, onCancelEdit, onCh
value={values.excludeWords}
onChange={(e) => onChange('excludeWords', e.target.value)}
/>
<input
className="input-base"
inputMode="numeric"
placeholder="最少做种数"
value={values.minSeeders}
onChange={(e) => onChange('minSeeders', e.target.value)}
/>
<input
className="input-base"
inputMode="numeric"
placeholder="最多做种数"
value={values.maxSeeders}
onChange={(e) => onChange('maxSeeders', e.target.value)}
/>
<input
className="input-base"
inputMode="decimal"
placeholder="最小体积 GB"
value={values.minSizeGB}
onChange={(e) => onChange('minSizeGB', e.target.value)}
/>
<input
className="input-base"
inputMode="decimal"
placeholder="最大体积 GB"
value={values.maxSizeGB}
onChange={(e) => onChange('maxSizeGB', e.target.value)}
/>
<label className="flex items-center gap-2 rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm text-ink-100">
<input
type="checkbox"
checked={values.freeOnly}
onChange={(e) => onChange('freeOnly', e.target.checked)}
/>
只下载免费资源
</label>
<button type="submit" className="neon-button md:col-span-1">
{editing ? <Save size={16} /> : <Plus size={16} />}
{editing ? '保存' : '添加'}
+19
View File
@@ -74,6 +74,11 @@ export function SubscriptionsPage() {
effects: formValues.effects,
release_groups: formValues.releaseGroups,
exclude_words: formValues.excludeWords,
min_seeders: numericRuleValue(formValues.minSeeders),
max_seeders: numericRuleValue(formValues.maxSeeders),
min_size_gb: numericRuleValue(formValues.minSizeGB),
max_size_gb: numericRuleValue(formValues.maxSizeGB),
free_only: formValues.freeOnly,
wash_enabled: formValues.washEnabled,
wash_priority: formValues.washPriority,
priority: 50,
@@ -114,6 +119,11 @@ export function SubscriptionsPage() {
effects: s.effects || '',
releaseGroups: s.release_groups || '',
excludeWords: s.exclude_words || '',
minSeeders: stringRuleValue(s.min_seeders),
maxSeeders: stringRuleValue(s.max_seeders),
minSizeGB: stringRuleValue(s.min_size_gb),
maxSizeGB: stringRuleValue(s.max_size_gb),
freeOnly: Boolean(s.free_only),
washEnabled: Boolean(s.wash_enabled),
washPriority: s.wash_priority || 'balanced',
})
@@ -246,3 +256,12 @@ function apiErrorMessage(err: unknown, fallback: string): string {
if ((err as { code?: string })?.code === 'ECONNABORTED') return '请求超时,请检查服务或网络'
return fallback
}
function numericRuleValue(value: string): number {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
}
function stringRuleValue(value?: number): string {
return value && value > 0 ? String(value) : ''
}
+94
View File
@@ -10,9 +10,24 @@ export const defaultSections = [
]
export const discoverStorageKey = 'mediastation.discover.sections'
export const discoverRowsStorageKey = 'mediastation.discover.rows'
const discoverStorageVersion = 3
const discoverRowsStorageVersion = 1
const discoverRowsCacheMaxAgeMs = 6 * 60 * 60 * 1000
const legacyDefaultAdditions = ['tmdb_latest_movie', 'tmdb_latest_tv']
interface CachedDiscoverRow {
page: number
has_next: boolean
items: DiscoverItem[]
}
interface CachedDiscoverRowsPayload {
version: number
saved_at: number
rows: Record<string, CachedDiscoverRow>
}
export const defaultSectionDefs: DiscoverSection[] = [
{ key: 'tmdb_trending_day', label: 'TMDb 今日趋势', provider: 'tmdb' },
{ key: 'tmdb_latest_movie', label: 'TMDb 最新电影', provider: 'tmdb' },
@@ -51,6 +66,85 @@ export function serializeSavedSections(selected: string[]): string {
return JSON.stringify({ version: discoverStorageVersion, selected })
}
export function readCachedDiscoverRows(selected: string[]): {
rows: Record<string, DiscoverItem[]>
rowCanNext: Record<string, boolean>
} {
try {
const raw = window.localStorage.getItem(discoverRowsStorageKey)
if (!raw) return { rows: {}, rowCanNext: {} }
const parsed = JSON.parse(raw) as Partial<CachedDiscoverRowsPayload>
if (
parsed.version !== discoverRowsStorageVersion ||
typeof parsed.saved_at !== 'number' ||
Date.now() - parsed.saved_at > discoverRowsCacheMaxAgeMs ||
!parsed.rows
) {
return { rows: {}, rowCanNext: {} }
}
const allowed = new Set(selected)
const rows: Record<string, DiscoverItem[]> = {}
const rowCanNext: Record<string, boolean> = {}
for (const [key, row] of Object.entries(parsed.rows)) {
if (!allowed.has(key) || row.page !== 1 || !Array.isArray(row.items) || row.items.length === 0) {
continue
}
rows[key] = row.items
rowCanNext[key] = Boolean(row.has_next)
}
return { rows, rowCanNext }
} catch {
return { rows: {}, rowCanNext: {} }
}
}
export function writeCachedDiscoverRow(
key: string,
page: number,
items: DiscoverItem[],
hasNext: boolean,
) {
if (page !== 1 || items.length === 0) return
try {
const current = readRawDiscoverRowsCache()
current.rows[key] = {
page,
has_next: hasNext,
items,
}
current.saved_at = Date.now()
window.localStorage.setItem(discoverRowsStorageKey, JSON.stringify(current))
} catch {
// Best-effort UI cache only; failing to persist should never break Discover.
}
}
function readRawDiscoverRowsCache(): CachedDiscoverRowsPayload {
try {
const raw = window.localStorage.getItem(discoverRowsStorageKey)
if (!raw) return emptyDiscoverRowsCache()
const parsed = JSON.parse(raw) as Partial<CachedDiscoverRowsPayload>
if (parsed.version !== discoverRowsStorageVersion || !parsed.rows) {
return emptyDiscoverRowsCache()
}
return {
version: discoverRowsStorageVersion,
saved_at: typeof parsed.saved_at === 'number' ? parsed.saved_at : Date.now(),
rows: parsed.rows,
}
} catch {
return emptyDiscoverRowsCache()
}
}
function emptyDiscoverRowsCache(): CachedDiscoverRowsPayload {
return {
version: discoverRowsStorageVersion,
saved_at: Date.now(),
rows: {},
}
}
function sanitizeSectionKeys(keys: unknown[], allowed: Set<string>): string[] {
return keys.filter((key): key is string => typeof key === 'string' && allowed.has(key))
}
-1
View File
@@ -145,7 +145,6 @@ function canonicalCategory(part: string) {
if (key === '日剧' || key === '韩剧') return '日韩剧'
if (key === '真人秀') return '综艺'
if (key === '少儿') return '儿童'
if (key === '未分类') return '欧美剧'
if (key === '国产动漫') return '国漫'
if (['番剧', '日漫', '日本动漫', '日本动画'].includes(key)) return '日番'
if (key === '韩国动漫' || key === '韩国动画') return '韩漫'

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