fix(subscriptions): reject concurrent duplicate rules

Fixes #66
This commit is contained in:
ShukeBta
2026-08-10 19:30:17 +08:00
parent c32f23a626
commit c0d743d89d
18 changed files with 584 additions and 10 deletions
+1
View File
@@ -34,6 +34,7 @@ type DownloadTask struct {
type Subscription struct {
Base
UserID string `gorm:"index;size:36" json:"user_id"`
IdentityKey string `gorm:"size:64" json:"-"`
Name string `gorm:"size:128;not null" json:"name"`
FeedURL string `gorm:"size:2048;not null" json:"feed_url"`
Filter string `gorm:"size:512" json:"filter"`
+90
View File
@@ -0,0 +1,90 @@
package model
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"strings"
)
type subscriptionIdentityPayload struct {
Name string `json:"name"`
FeedURL string `json:"feed_url"`
Filter string `json:"filter"`
MediaType string `json:"media_type"`
MediaCategory string `json:"media_category"`
SavePath string `json:"save_path"`
SearchMode string `json:"search_mode"`
IMDBID string `json:"imdb_id"`
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"`
Priority int `json:"priority"`
}
// SubscriptionIdentityKey identifies one functional subscription rule. It
// intentionally excludes display metadata and runtime/archive state because
// those fields are backfilled or changed automatically after creation.
func SubscriptionIdentityKey(sub *Subscription) string {
if sub == nil {
return ""
}
payload := subscriptionIdentityPayload{
Name: subscriptionIdentityFold(sub.Name),
FeedURL: strings.TrimSpace(sub.FeedURL),
Filter: strings.TrimSpace(sub.Filter),
MediaType: subscriptionIdentityFold(sub.MediaType),
MediaCategory: subscriptionIdentityFold(sub.MediaCategory),
SavePath: strings.TrimSpace(sub.SavePath),
SearchMode: subscriptionIdentityFold(sub.SearchMode),
IMDBID: subscriptionIdentityFold(sub.IMDBID),
Resolution: subscriptionIdentityFold(sub.Resolution),
Quality: subscriptionIdentityFold(sub.Quality),
Effects: subscriptionIdentityList(sub.Effects),
ReleaseGroups: subscriptionIdentityList(sub.ReleaseGroups),
ExcludeWords: subscriptionIdentityList(sub.ExcludeWords),
MinSeeders: sub.MinSeeders,
MaxSeeders: sub.MaxSeeders,
MinSizeGB: sub.MinSizeGB,
MaxSizeGB: sub.MaxSizeGB,
FreeOnly: sub.FreeOnly,
WashEnabled: sub.WashEnabled,
WashPriority: subscriptionIdentityFold(sub.WashPriority),
Priority: sub.Priority,
}
raw, _ := json.Marshal(payload)
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:])
}
func RefreshSubscriptionIdentity(sub *Subscription) string {
key := SubscriptionIdentityKey(sub)
if sub != nil {
sub.IdentityKey = key
}
return key
}
func subscriptionIdentityFold(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func subscriptionIdentityList(value string) string {
parts := strings.Split(value, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
if part = subscriptionIdentityFold(part); part != "" {
out = append(out, part)
}
}
return strings.Join(out, ",")
}
@@ -0,0 +1,45 @@
package model
import "testing"
func TestSubscriptionIdentityKeyNormalizesEquivalentRules(t *testing.T) {
base := Subscription{
Name: " Example Show ",
FeedURL: "site-search://search?keyword=Example",
Filter: " Example.*Show ",
MediaType: "TV",
MediaCategory: " 欧美剧 ",
SearchMode: "KEYWORD",
Resolution: "1080P",
Effects: " HDR, Atmos ",
ExcludeWords: " CAM, TS ",
WashPriority: "Balanced",
Priority: 50,
}
equivalent := base
equivalent.Name = "example show"
equivalent.MediaType = "tv"
equivalent.MediaCategory = "欧美剧"
equivalent.SearchMode = "keyword"
equivalent.Resolution = "1080p"
equivalent.Effects = "hdr,atmos"
equivalent.ExcludeWords = "cam,ts"
equivalent.WashPriority = "balanced"
if got, want := SubscriptionIdentityKey(&equivalent), SubscriptionIdentityKey(&base); got != want {
t.Fatalf("equivalent keys differ: %s != %s", got, want)
}
differentRule := base
differentRule.Resolution = "2160p"
if SubscriptionIdentityKey(&differentRule) == SubscriptionIdentityKey(&base) {
t.Fatal("different resolution should produce a different identity")
}
displayOnly := base
displayOnly.PosterURL = "https://example.test/poster.jpg"
displayOnly.Overview = "new overview"
displayOnly.TotalEpisodes = 24
if SubscriptionIdentityKey(&displayOnly) != SubscriptionIdentityKey(&base) {
t.Fatal("display/runtime metadata should not change identity")
}
}