diff --git a/internal/config/config.go b/internal/config/config.go index 882f2e52..234e79fd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -110,6 +110,7 @@ type ImageHostingConfig struct { SeedpoolCDNAPI string `yaml:"seedpool_cdn_api"` ShareXURL string `yaml:"sharex_url"` ShareXAPIKey string `yaml:"sharex_api_key"` + UTPPMEnabled bool `yaml:"utppm_enabled"` UTPPMAPI string `yaml:"utppm_api"` LostimgEnabled bool `yaml:"lostimg_enabled"` LostimgAPI string `yaml:"lostimg_api"` @@ -124,6 +125,8 @@ func (c ImageHostingConfig) HostEnabled(host string) bool { return c.LostimgEnabled case "reelflix": return c.ReelflixEnabled + case "utppm": + return c.UTPPMEnabled default: return false } diff --git a/internal/config/defaults/example.yaml b/internal/config/defaults/example.yaml index 95d6fe10..042b5caa 100644 --- a/internal/config/defaults/example.yaml +++ b/internal/config/defaults/example.yaml @@ -25,6 +25,7 @@ image_hosting: seedpool_cdn_api: "" sharex_url: "" sharex_api_key: "" + utppm_enabled: false utppm_api: "" lostimg_enabled: false lostimg_api: "" diff --git a/internal/config/legacy/converter.go b/internal/config/legacy/converter.go index 561cb757..fab37571 100644 --- a/internal/config/legacy/converter.go +++ b/internal/config/legacy/converter.go @@ -41,6 +41,7 @@ var legacyDefaultSectionByKey = map[string]string{ "seedpool_cdn_api": "image_hosting", "sharex_url": "image_hosting", "sharex_api_key": "image_hosting", + "utppm_enabled": "image_hosting", "utppm_api": "image_hosting", "lostimg_enabled": "image_hosting", "lostimg_api": "image_hosting", diff --git a/internal/imagehosting/policy/policy_test.go b/internal/imagehosting/policy/policy_test.go index 88aa2ec0..6b959baa 100644 --- a/internal/imagehosting/policy/policy_test.go +++ b/internal/imagehosting/policy/policy_test.go @@ -15,7 +15,7 @@ func TestKnownUploadHostsAreDeterministic(t *testing.T) { if !slices.IsSorted(hosts) { t.Fatalf("upload hosts are not sorted: %v", hosts) } - for _, host := range []string{"hdb", "lostimg", "pixhost", "reelflix", "thr"} { + for _, host := range []string{"hdb", "lostimg", "pixhost", "reelflix", "thr", "utppm"} { if !IsUploadHost(host) { t.Errorf("expected upload host %q", host) } diff --git a/internal/imagehosting/service_test.go b/internal/imagehosting/service_test.go index 37f163be..451a48ba 100644 --- a/internal/imagehosting/service_test.go +++ b/internal/imagehosting/service_test.go @@ -705,6 +705,7 @@ func TestImageHostLogTrackerNamesEveryOwnedHost(t *testing.T) { "lostimg": "LST", "reelflix": "RF", "thr": "THR", + "utppm": "UTP", } { if got := service.imageHostLogTracker(host); got != expected { t.Errorf("host %q tracker = %q, want %q", host, got, expected) diff --git a/internal/imagehosting/uploaders.go b/internal/imagehosting/uploaders.go index 69694b6a..37adf040 100644 --- a/internal/imagehosting/uploaders.go +++ b/internal/imagehosting/uploaders.go @@ -818,10 +818,16 @@ func (u *utppmUploader) Upload(ctx context.Context, imagePath string) (uploadRes URL string `json:"url"` URLViewer string `json:"url_viewer"` } `json:"image"` + Error struct { + Message string `json:"message"` + } `json:"error"` } if err := json.Unmarshal(body, &response); err != nil { return uploadResult{}, fmt.Errorf("utppm invalid response: %w", err) } + if response.Image.URL == "" { + return uploadResult{}, fmt.Errorf("utppm upload failed: %s", safeResponseMessage(response.Error.Message)) + } return uploadResult{ ImgURL: response.Image.Medium.URL, diff --git a/internal/imagehosting/uploaders_test.go b/internal/imagehosting/uploaders_test.go index f76c8676..9265a0de 100644 --- a/internal/imagehosting/uploaders_test.go +++ b/internal/imagehosting/uploaders_test.go @@ -829,6 +829,31 @@ func TestReelflixUploaderPostsSourceWithAPIKey(t *testing.T) { } } +func TestUTPPMUploaderRejectsEmptyImageURL(t *testing.T) { + imagePath := filepath.Join(t.TempDir(), "shot.png") + if err := os.WriteFile(imagePath, []byte("synthetic image"), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + client := &http.Client{ + Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{ + "status_code": 200, + "error": {"message": "invalid content"} + }`)), + }, nil + }), + } + + _, err := (&utppmUploader{apiKey: "secret", client: client}).Upload(context.Background(), imagePath) + if err == nil || !strings.Contains(err.Error(), "utppm upload failed") || !strings.Contains(err.Error(), "invalid content") { + t.Fatalf("expected utppm rejection with safe message, got %v", err) + } +} + func TestReadAndCloseResponseBodyClosesBody(t *testing.T) { body := &trackingReadCloser{reader: strings.NewReader("partial response")} resp := &http.Response{ diff --git a/internal/metadata/media_details_test.go b/internal/metadata/media_details_test.go index 64af7b98..97e9b9da 100644 --- a/internal/metadata/media_details_test.go +++ b/internal/metadata/media_details_test.go @@ -1221,3 +1221,36 @@ func TestResolveAudioBloatPolicyWarnsButDoesNotBlockNonEnglishOriginal(t *testin t.Fatalf("expected SPD warning for French bloat, got %#v", warned) } } + +// TestResolveAudioBloatPolicyAllowsUkrainianForUTP proves UTP's allowlist: UTP +// releases always carry Ukrainian plus the original audio and optionally +// English, so those tracks must not count as bloat while other languages still +// do. +func TestResolveAudioBloatPolicyAllowsUkrainianForUTP(t *testing.T) { + blocked, warned := resolveAudioBloatPolicyWithRegistry(preparationstate.State{ + AudioLanguages: []string{"Ukrainian", "English", "Japanese"}, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{OriginalLanguage: "ja"}, + }, + }, []string{"UTP", "BHD"}, antRuleRegistry(t)) + + if blocked != nil { + t.Fatalf("expected no blocked trackers, got %#v", blocked) + } + if _, ok := warned["UTP"]; ok { + t.Fatalf("did not expect UTP warning for Ukrainian audio, got %#v", warned) + } + if got := warned["BHD"]; len(got) != 1 || got[0] != "Ukrainian" { + t.Fatalf("expected BHD warning for Ukrainian bloat, got %#v", warned) + } + + _, warned = resolveAudioBloatPolicyWithRegistry(preparationstate.State{ + AudioLanguages: []string{"French", "Japanese"}, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{OriginalLanguage: "ja"}, + }, + }, []string{"UTP"}, antRuleRegistry(t)) + if got := warned["UTP"]; len(got) != 1 || got[0] != "French" { + t.Fatalf("expected UTP warning for French bloat, got %#v", warned) + } +} diff --git a/internal/trackers/impl/registry_test.go b/internal/trackers/impl/registry_test.go index c2b37d52..eb6d72cf 100644 --- a/internal/trackers/impl/registry_test.go +++ b/internal/trackers/impl/registry_test.go @@ -568,6 +568,21 @@ func TestNewRegistryIncludesBHDPolicies(t *testing.T) { } } +func TestNewRegistryIncludesUTPPolicies(t *testing.T) { + registry, err := NewRegistry() + if err != nil { + t.Fatalf("new registry: %v", err) + } + if rules, ok := registry.LookupRules("UTP"); !ok || !rules.SkipModifiedReleaseCheck { + t.Fatalf("UTP rules = %#v, %t", rules, ok) + } + if policy, ok := registry.LookupAudioPolicy("UTP"); !ok || + !slices.Contains(policy.AllowedLanguages, "ukrainian") || + !slices.Contains(policy.AllowedLanguages, "english") { + t.Fatalf("UTP audio policy = %#v, %t", policy, ok) + } +} + func TestNewRegistryIncludesBTNPolicies(t *testing.T) { registry, err := NewRegistry() if err != nil { @@ -660,6 +675,7 @@ func TestNewRegistryIncludesImageHostPolicies(t *testing.T) { }, {tracker: "LST", conditionalHost: "lostimg"}, {tracker: "RF", conditionalHost: "reelflix"}, + {tracker: "UTP", conditionalHost: "utppm"}, } for _, test := range tests { policy, ok := registry.LookupImageHostPolicy(test.tracker) diff --git a/internal/trackers/impl/responsibility_ledger_test.go b/internal/trackers/impl/responsibility_ledger_test.go index 70c41dd4..c74e4c4c 100644 --- a/internal/trackers/impl/responsibility_ledger_test.go +++ b/internal/trackers/impl/responsibility_ledger_test.go @@ -101,7 +101,7 @@ var trackerResponsibilityLedger = []trackerResponsibilityRow{ unit3DResponsibility("TOS", "canonical", ""), unit3DResponsibility("TTR", "canonical", ""), unit3DResponsibility("ULCX", "ulcx", ""), - unit3DResponsibility("UTP", "canonical", ""), + unit3DResponsibility("UTP", "utp", ""), unit3DResponsibility("YUS", "canonical", ""), unit3DResponsibility("ZNTH", "znth", ""), azFamilyResponsibility("AZ"), diff --git a/internal/trackers/impl/unit3d/sites/utp/description.go b/internal/trackers/impl/unit3d/sites/utp/description.go new file mode 100644 index 00000000..16893384 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/utp/description.go @@ -0,0 +1,64 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package utp + +import ( + "context" + "fmt" + "strings" + + "github.com/autobrr/upbrr/internal/config" + descriptionunit3d "github.com/autobrr/upbrr/internal/description/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// buildDescription renders the shared Unit3D description with UTP's image URLs +// remapped so every screenshot links its full-size original. +func buildDescription( + ctx context.Context, + meta api.UploadSubject, + appConfig config.Config, + trackerConfig config.TrackerConfig, + logger api.Logger, + keptDescription string, + menuImages []api.ScreenshotImage, + screenshots []api.ScreenshotImage, +) (string, error) { + description, err := descriptionunit3d.BuildDescription( + ctx, + api.NewDescriptionSubject(meta), + appConfig, + trackerConfig, + logger, + keptDescription, + swapImageURLs(menuImages), + swapImageURLs(screenshots), + ) + if err != nil { + return "", fmt.Errorf("trackers: %w", err) + } + return description, nil +} + +// swapImageURLs remaps each screenshot so the Unit3D description builder +// renders [url=full][img]medium[/img]: the builder uses WebURL for the [url] +// link target and RawURL for the displayed [img], so the full-size RawURL moves +// to WebURL and the medium ImgURL moves to RawURL. Images without a medium +// thumbnail are left unchanged. The input slice is not mutated. +func swapImageURLs(images []api.ScreenshotImage) []api.ScreenshotImage { + if len(images) == 0 { + return images + } + swapped := make([]api.ScreenshotImage, len(images)) + for i, image := range images { + full := strings.TrimSpace(image.RawURL) + medium := strings.TrimSpace(image.ImgURL) + if full != "" && medium != "" { + image.WebURL = full + image.RawURL = medium + } + swapped[i] = image + } + return swapped +} diff --git a/internal/trackers/impl/unit3d/sites/utp/description_test.go b/internal/trackers/impl/unit3d/sites/utp/description_test.go new file mode 100644 index 00000000..f515ca03 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/utp/description_test.go @@ -0,0 +1,39 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package utp + +import ( + "testing" + + "github.com/autobrr/upbrr/pkg/api" +) + +func TestSwapImageURLs(t *testing.T) { + images := []api.ScreenshotImage{ + { + ImgURL: "https://host.invalid/medium.png", + RawURL: "https://host.invalid/full.png", + WebURL: "https://host.invalid/page", + }, + { + ImgURL: "", + RawURL: "https://host.invalid/full2.png", + WebURL: "https://host.invalid/page2", + }, + } + got := swapImageURLs(images) + + // First image: full moves to WebURL (link), medium moves to RawURL (display). + if got[0].WebURL != "https://host.invalid/full.png" || got[0].RawURL != "https://host.invalid/medium.png" { + t.Fatalf("expected swapped URLs, got web=%q raw=%q", got[0].WebURL, got[0].RawURL) + } + // Second image lacks a medium thumbnail and is left unchanged. + if got[1].WebURL != "https://host.invalid/page2" || got[1].RawURL != "https://host.invalid/full2.png" { + t.Fatalf("expected unchanged URLs, got web=%q raw=%q", got[1].WebURL, got[1].RawURL) + } + // Input slice must not be mutated. + if images[0].WebURL != "https://host.invalid/page" { + t.Fatalf("input slice mutated: %q", images[0].WebURL) + } +} diff --git a/internal/trackers/impl/unit3d/sites/utp/name.go b/internal/trackers/impl/unit3d/sites/utp/name.go new file mode 100644 index 00000000..78ca5b7e --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/utp/name.go @@ -0,0 +1,234 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package utp + +import ( + "strconv" + "strings" + "unicode" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// losslessAudioIndicators lists the codecs UTP keeps in the release name; +// lossy audio (AAC, DD, DD+, ...) is dropped entirely. +var losslessAudioIndicators = []string{"Atmos", "TrueHD", "DTS-HD MA", "DTS:X", "LPCM", "FLAC", "PCM"} + +// buildName reconstructs a UTP-compliant release name from parsed metadata +// components rather than editing the base release name. The token order differs +// between Movie and TV (note the REPACK/Edition swap): +// +// Movie: Title AKA Year Hybrid REPACK Edition Region 3D UHD Source Type Resolution HDR VCodec Audio-Tag +// TV: Title AKA S##E## Year Hybrid Edition REPACK Region 3D UHD Source Type Resolution HDR VCodec Audio-Tag +// +// Naming rules: https://utp.to/pages/33. +func buildName(meta api.UploadSubject, _ config.TrackerConfig) string { + category := unit3d.Category(meta) + releaseType := unit3d.InferType(meta) + + title := utpTitle(meta, category) + aka := utpAKA(meta, title) + year := utpYear(meta.Release.Year) + threeD := strings.TrimSpace(meta.Is3D) + uhd := strings.TrimSpace(meta.UHD) + edition, hybrid := splitHybridEdition(meta) + repack := strings.TrimSpace(meta.Repack) + resolution := strings.TrimSpace(unit3d.Resolution(meta)) + hdr := strings.TrimSpace(meta.HDR) + service := strings.TrimSpace(meta.Service) + audio := utpAudio(meta.Audio) + videoCodec := strings.TrimSpace(meta.VideoCodec) + videoEncode := strings.TrimSpace(meta.VideoEncode) + tag := meta.Tag + region := strings.TrimSpace(meta.Region) + season := strings.TrimSpace(meta.SeasonStr) + episode := strings.TrimSpace(meta.EpisodeStr) + + // The name-suppression toggles are naming-only: they never reach a metadata + // field, so a from-scratch builder has to read them off the overrides. + overrides := meta.ReleaseNameOverrides + if isSet(overrides.NoYear) { + year = "" + } + if isSet(overrides.NoSeason) { + season, episode = "", "" + } + if isSet(overrides.NoAKA) { + aka = "" + } + + sourceTag := strings.TrimSpace(meta.Source) + typeTag := "" + vcodec := videoCodec // Default for DISC/REMUX (AVC, HEVC). + + switch releaseType { + case "REMUX", "ENCODE": + sourceTag = "" // BDRemux/BDRip replaces source. + if releaseType == "REMUX" { + typeTag = "BDRemux" + } else { + typeTag = "BDRip" + vcodec = videoEncode + } + case "WEBDL", "WEBRIP": + sourceTag = service // Service (NF, AMZN, ...) acts as source. + if releaseType == "WEBDL" { + typeTag = "WEB-DL" + } else { + typeTag = "WEBRip" + } + vcodec = videoEncode + case "HDTV": + vcodec = videoEncode + } + // DISC: source_tag stays as meta.Source (e.g. Blu-ray); no type tag is added. + + var name string + switch category { + case "MOVIE": + name = strings.Join([]string{title, aka, year, hybrid, repack, edition, region, threeD, uhd, sourceTag, typeTag, resolution, hdr, vcodec, audio}, " ") + case "TV": + name = strings.Join( + []string{title, aka, season + episode, year, hybrid, edition, repack, region, threeD, uhd, sourceTag, typeTag, resolution, hdr, vcodec, audio}, + " ", + ) + default: + return baseReleaseName(meta) + } + + name = collapseSpaces(name) + if tag != "" { + name += tag + } + return name +} + +// utpTitle resolves the English name UTP requires. The parsed title is only a +// fallback: it is whatever the source directory happened to use, which for +// foreign releases is a romaji or transliterated name rather than the English +// one. +func utpTitle(meta api.UploadSubject, category string) string { + candidates := make([]string, 0, 4) + if category == "TV" && meta.ProviderMetadata.TVDB != nil { + candidates = append(candidates, meta.ProviderMetadata.TVDB.NameEnglish) + } + if meta.ProviderMetadata.TMDB != nil { + candidates = append(candidates, meta.ProviderMetadata.TMDB.Title) + } + if meta.ProviderMetadata.IMDB != nil { + candidates = append(candidates, meta.ProviderMetadata.IMDB.Title) + } + candidates = append(candidates, meta.Release.Title) + + for _, candidate := range candidates { + if value := strings.TrimSpace(candidate); value != "" { + return value + } + } + return "" +} + +// utpAKA returns the "AKA " segment that follows the English +// name: the romaji for anime, otherwise the TMDB original title. TMDB stores +// RetrievedAKA with the "AKA " prefix already applied. +// +// No other source qualifies. IMDb and the parsed release name carry a +// transliteration of the native title rather than the romaji, which is not a +// name UTP accepts: an anime whose romaji already equals its English title must +// get no AKA at all instead of a syllable-by-syllable rendering of the native +// one. +func utpAKA(meta api.UploadSubject, title string) string { + candidates := make([]string, 0, 2) + if meta.ProviderMetadata.TMDB != nil { + candidates = append(candidates, meta.ProviderMetadata.TMDB.RetrievedAKA, meta.ProviderMetadata.TMDB.OriginalTitle) + } + + for _, candidate := range candidates { + value := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(candidate), "AKA ")) + if value == "" || strings.EqualFold(value, strings.TrimSpace(title)) || !isLatinScript(value) { + continue + } + return "AKA " + value + } + return "" +} + +// isLatinScript reports whether every letter in value is Latin, so native +// titles (kanji, Cyrillic, ...) never reach the release name. +func isLatinScript(value string) bool { + for _, r := range value { + if unicode.IsLetter(r) && !unicode.Is(unicode.Latin, r) { + return false + } + } + return true +} + +// utpYear renders the release year, returning an empty string when absent so +// the template collapses the slot away. +func utpYear(year int) string { + if year <= 0 { + return "" + } + return strconv.Itoa(year) +} + +// utpAudio keeps the audio segment only when it names a lossless/object codec, +// then drops Dual-Audio/Dubbed markers and collapses whitespace. Lossy audio is +// omitted from the name entirely. +func utpAudio(audioRaw string) string { + lossless := false + for _, indicator := range losslessAudioIndicators { + if strings.Contains(audioRaw, indicator) { + lossless = true + break + } + } + if !lossless { + return "" + } + audio := strings.ReplaceAll(audioRaw, "Dual-Audio", "") + audio = strings.ReplaceAll(audio, "Dubbed", "") + return collapseSpaces(audio) +} + +// splitHybridEdition separates the hybrid marker from the edition. Hybrid is +// carried inside the edition (metadata folds the parsed token in there), but a +// name that builds from components renders it in its own slot. +func splitHybridEdition(meta api.UploadSubject) (edition string, hybrid string) { + fields := strings.Fields(meta.Edition) + kept := fields[:0] + for _, value := range fields { + if strings.EqualFold(value, "Hybrid") { + hybrid = "Hybrid" + continue + } + kept = append(kept, value) + } + if meta.WebDV { + hybrid = "Hybrid" + } + return strings.Join(kept, " "), hybrid +} + +// baseReleaseName is the generic fallback for categories the UTP template does +// not cover. +func baseReleaseName(meta api.UploadSubject) string { + name := strings.TrimSpace(meta.ReleaseName) + if name == "" { + name = strings.TrimSpace(meta.ReleaseNameNoTag) + } + return collapseSpaces(name) +} + +func collapseSpaces(s string) string { + return strings.Join(strings.Fields(s), " ") +} + +// isSet reports whether an optional override flag is present and enabled. +func isSet(flag *bool) bool { + return flag != nil && *flag +} diff --git a/internal/trackers/impl/unit3d/sites/utp/name_test.go b/internal/trackers/impl/unit3d/sites/utp/name_test.go new file mode 100644 index 00000000..89575adf --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/utp/name_test.go @@ -0,0 +1,451 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package utp + +import ( + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/pkg/api" +) + +// TestBuildName verifies the from-scratch UTP naming reconstruction. Expected +// values are computed by hand-tracing the naming template for each type branch +// and category. +func TestBuildName(t *testing.T) { + tests := []struct { + name string + meta api.UploadSubject + want string + }{ + { + name: "Movie ENCODE drops lossy audio, BDRip type tag, x264 encode", + meta: api.UploadSubject{ + Type: "ENCODE", + Source: "BluRay", + VideoCodec: "H.264", + VideoEncode: "x264", + Audio: "DD+ 5.1", + HDR: "", + Release: api.ReleaseInfo{ + Title: "Example Movie", + Year: 2020, + Resolution: "1080p", + }, + Identity: api.ExternalIdentity{Category: "MOVIE"}, + Tag: "-GRP", + }, + want: "Example Movie 2020 BDRip 1080p x264-GRP", + }, + { + name: "Movie REMUX keeps video_codec and lossless audio, UHD + HDR", + meta: api.UploadSubject{ + Type: "REMUX", + Source: "BluRay", + VideoCodec: "HEVC", + VideoEncode: "x265", + Audio: "TrueHD Atmos 7.1", + HDR: "DV HDR10", + UHD: "UHD", + Release: api.ReleaseInfo{ + Title: "Example Film", + Year: 2019, + Resolution: "2160p", + }, + Identity: api.ExternalIdentity{Category: "MOVIE"}, + Tag: "-TEAM", + }, + want: "Example Film 2019 UHD BDRemux 2160p DV HDR10 HEVC TrueHD Atmos 7.1-TEAM", + }, + { + name: "Movie DISC keeps source, no type tag, lossless DTS-HD MA", + meta: api.UploadSubject{ + Type: "DISC", + Source: "Blu-ray", + VideoCodec: "HEVC", + VideoEncode: "", + Audio: "DTS-HD MA 5.1", + HDR: "HDR10", + UHD: "UHD", + Release: api.ReleaseInfo{ + Title: "Example Feature", + Year: 2015, + Resolution: "2160p", + }, + Identity: api.ExternalIdentity{Category: "MOVIE"}, + Tag: "-DISC", + }, + want: "Example Feature 2015 UHD Blu-ray 2160p HDR10 HEVC DTS-HD MA 5.1-DISC", + }, + { + name: "Movie WEBDL with AKA, Hybrid, REPACK, Edition, Region, service as source", + meta: api.UploadSubject{ + Type: "WEBDL", + Service: "NF", + VideoCodec: "HEVC", + VideoEncode: "HEVC", + Audio: "FLAC 2.0", + HDR: "DV", + Edition: "Director's Cut", + Repack: "REPACK", + Region: "EUR", + WebDV: true, + Release: api.ReleaseInfo{ + Title: "Example Picture", + Year: 2018, + Resolution: "2160p", + }, + Identity: api.ExternalIdentity{Category: "MOVIE"}, + Tag: "-X", + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{RetrievedAKA: "AKA Ejemplo Imagen"}, + }, + }, + want: "Example Picture AKA Ejemplo Imagen 2018 Hybrid REPACK Director's Cut EUR NF WEB-DL 2160p DV HEVC FLAC 2.0-X", + }, + { + name: "Movie WEBRIP strips Dual-Audio marker, keeps Atmos", + meta: api.UploadSubject{ + Type: "WEBRIP", + Service: "HULU", + VideoEncode: "x265", + Audio: "Dual-Audio Atmos", + Release: api.ReleaseInfo{ + Title: "Example Indie", + Year: 2020, + Resolution: "1080p", + }, + Identity: api.ExternalIdentity{Category: "MOVIE"}, + Tag: "", + }, + want: "Example Indie 2020 HULU WEBRip 1080p x265 Atmos", + }, + { + name: "Movie without year drops the year slot", + meta: api.UploadSubject{ + Type: "ENCODE", + VideoEncode: "x264", + Release: api.ReleaseInfo{ + Title: "Example Movie", + Year: 0, + Resolution: "1080p", + }, + Identity: api.ExternalIdentity{Category: "MOVIE"}, + Tag: "-GRP", + }, + want: "Example Movie BDRip 1080p x264-GRP", + }, + { + name: "TV WEBDL, season+episode before year, edition before repack order", + meta: api.UploadSubject{ + Type: "WEBDL", + Service: "AMZN", + VideoEncode: "H.264", + Audio: "DDP5.1", + SeasonStr: "S02", + EpisodeStr: "E05", + Release: api.ReleaseInfo{ + Title: "Example Show", + Year: 2021, + Resolution: "1080p", + }, + Identity: api.ExternalIdentity{Category: "TV"}, + Tag: "-GRP", + }, + want: "Example Show S02E05 2021 AMZN WEB-DL 1080p H.264-GRP", + }, + { + name: "TV HDTV keeps source, video_encode codec", + meta: api.UploadSubject{ + Type: "HDTV", + Source: "HDTV", + VideoEncode: "H.264", + Audio: "AAC", + SeasonStr: "S01", + EpisodeStr: "E10", + Release: api.ReleaseInfo{ + Title: "Example Program", + Year: 2022, + Resolution: "720p", + }, + Identity: api.ExternalIdentity{Category: "TV"}, + Tag: "-Z", + }, + want: "Example Program S01E10 2022 HDTV 720p H.264-Z", + }, + { + name: "TV season pack (episode empty) keeps season only", + meta: api.UploadSubject{ + Type: "WEBDL", + Service: "DSNP", + VideoEncode: "H.265", + Audio: "EAC3", + SeasonStr: "S03", + EpisodeStr: "", + Release: api.ReleaseInfo{ + Title: "Example Series", + Year: 2023, + Resolution: "2160p", + }, + Identity: api.ExternalIdentity{Category: "TV"}, + Tag: "-GRP", + }, + want: "Example Series S03 2023 DSNP WEB-DL 2160p H.265-GRP", + }, + { + // The parsed title is the romaji the source directory was named with, so + // the name must take the English title from the providers and carry the + // romaji as the AKA. + name: "TV REMUX anime uses provider English title with romaji AKA", + meta: api.UploadSubject{ + Type: "REMUX", + Source: "BluRay", + VideoCodec: "AVC", + Audio: "Dual-Audio AAC 2.0", + SeasonStr: "S01", + Release: api.ReleaseInfo{ + Title: "Rei No Sakuhin", + Year: 2026, + Resolution: "1080p", + }, + Identity: api.ExternalIdentity{Category: "TV"}, + Tag: "-GRP", + ProviderMetadata: api.SourceScopedMetadata{ + TVDB: &api.TVDBMetadata{Name: "サンプル作品", NameEnglish: "Example Anime Series"}, + TMDB: &api.TMDBMetadata{ + Title: "Example Anime Series", + OriginalTitle: "サンプル作品", + RetrievedAKA: "AKA Rei No Sakuhin", + }, + }, + }, + want: "Example Anime Series AKA Rei No Sakuhin S01 2026 BDRemux 1080p AVC-GRP", + }, + { + // A BluRay hybrid carries the marker in the edition rather than WebDV, and + // the marker gets its own slot instead of being rendered as an edition. + name: "TV REMUX hybrid renders the edition-carried marker in the hybrid slot", + meta: api.UploadSubject{ + Type: "REMUX", + Source: "BluRay", + VideoCodec: "AVC", + SeasonStr: "S01", + Edition: "Hybrid", + Release: api.ReleaseInfo{ + Title: "Example Series", + Year: 2026, + Resolution: "1080p", + }, + Identity: api.ExternalIdentity{Category: "TV"}, + Tag: "-GRP", + }, + want: "Example Series S01 2026 Hybrid BDRemux 1080p AVC-GRP", + }, + { + // The romaji equals the English title here, so TMDB retrieves no AKA at + // all. The transliterations IMDb and the source name carry are not romaji + // and must not stand in for one. + name: "TV anime without a romaji AKA drops the transliterated original", + meta: api.UploadSubject{ + Type: "REMUX", + Source: "BluRay", + VideoCodec: "AVC", + Audio: "Dual-Audio AAC 2.0", + SeasonStr: "S01", + Release: api.ReleaseInfo{ + Title: "Example Series", + Alt: "Egzâmpuru Shirîzu", + Year: 2026, + Resolution: "1080p", + }, + Identity: api.ExternalIdentity{Category: "TV"}, + Tag: "-GRP", + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{ + Title: "Example Series", + OriginalTitle: "サンプル作品", + Anime: true, + }, + IMDB: &api.IMDBMetadata{Title: "Example Series", AKA: "Egzâmpuru Shirîzu"}, + }, + }, + want: "Example Series S01 2026 BDRemux 1080p AVC-GRP", + }, + { + name: "TV drops non-Latin original title instead of putting it in the name", + meta: api.UploadSubject{ + Type: "REMUX", + Source: "BluRay", + VideoCodec: "AVC", + SeasonStr: "S01", + Release: api.ReleaseInfo{ + Title: "Example Series", + Year: 2026, + Resolution: "1080p", + }, + Identity: api.ExternalIdentity{Category: "TV"}, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{Title: "Example Series", OriginalTitle: "サンプル作品"}, + }, + }, + want: "Example Series S01 2026 BDRemux 1080p AVC", + }, + { + name: "Movie drops Cyrillic original title instead of putting it in the name", + meta: api.UploadSubject{ + Type: "ENCODE", + VideoEncode: "x264", + Release: api.ReleaseInfo{ + Title: "Example Movie", + Year: 2026, + Resolution: "1080p", + }, + Identity: api.ExternalIdentity{Category: "MOVIE"}, + Tag: "-GRP", + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{Title: "Example Movie", OriginalTitle: "Приклад"}, + }, + }, + want: "Example Movie 2026 BDRip 1080p x264-GRP", + }, + { + name: "Unsupported category falls back to the generic base name", + meta: api.UploadSubject{ + Type: "ENCODE", + VideoEncode: "x264", + ReleaseName: "Example.Release.2026.1080p.BluRay.x264-GRP", + Release: api.ReleaseInfo{ + Title: "Example Release", + Year: 2026, + Resolution: "1080p", + }, + Identity: api.ExternalIdentity{Category: "animation"}, + Tag: "-GRP", + }, + want: "Example.Release.2026.1080p.BluRay.x264-GRP", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildName(tt.meta, config.TrackerConfig{}) + if got != tt.want { + t.Fatalf("buildName()\n got: %q\nwant: %q", got, tt.want) + } + }) + } +} + +// TestBuildNameInfersTypeWhenTypeFieldEmpty proves that buildName derives the +// release type from the same source as the type_id (unit3d.InferType) rather +// than the possibly-empty meta.Type. With meta.Type empty but REMUX inferable +// from the release name, the name uses the BDRemux type tag and agrees with the +// type_id (2 = REMUX). +func TestBuildNameInfersTypeWhenTypeFieldEmpty(t *testing.T) { + meta := api.UploadSubject{ + Type: "", + Source: "BluRay", + VideoCodec: "AVC", + Audio: "DTS-HD MA 5.1", + ReleaseName: "Example.Film.2021.1080p.BluRay.REMUX.AVC.DTS-HD.MA.5.1-GRP", + Release: api.ReleaseInfo{ + Title: "Example Film", + Year: 2021, + Resolution: "1080p", + }, + Identity: api.ExternalIdentity{Category: "MOVIE"}, + Tag: "-GRP", + } + if got := buildName(meta, config.TrackerConfig{}); got != "Example Film 2021 BDRemux 1080p AVC DTS-HD MA 5.1-GRP" { + t.Fatalf("buildName() with empty Type: got %q", got) + } + if got := typeID(meta); got != "2" { + t.Fatalf("expected type_id=2 (REMUX) to agree with inferred name, got %q", got) + } +} + +// TestBuildNameTitlePreferenceChain covers the English-title fallback order: +// TVDB English name for TV first, then TMDB, then IMDb, then the parsed title. +func TestBuildNameTitlePreferenceChain(t *testing.T) { + base := api.UploadSubject{ + Type: "WEBDL", + Service: "NF", + VideoEncode: "H.264", + SeasonStr: "S01", + Release: api.ReleaseInfo{ + Title: "Parsed Series", + Year: 2026, + Resolution: "1080p", + }, + Identity: api.ExternalIdentity{Category: "TV"}, + Tag: "-GRP", + } + + full := base + full.ProviderMetadata = api.SourceScopedMetadata{ + TVDB: &api.TVDBMetadata{NameEnglish: "TVDB Series"}, + TMDB: &api.TMDBMetadata{Title: "TMDB Series"}, + IMDB: &api.IMDBMetadata{Title: "IMDB Series"}, + } + if got := buildName(full, config.TrackerConfig{}); got != "TVDB Series S01 2026 NF WEB-DL 1080p H.264-GRP" { + t.Fatalf("TVDB preference: got %q", got) + } + + noTVDB := base + noTVDB.ProviderMetadata = api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{Title: "TMDB Series"}, + IMDB: &api.IMDBMetadata{Title: "IMDB Series"}, + } + if got := buildName(noTVDB, config.TrackerConfig{}); got != "TMDB Series S01 2026 NF WEB-DL 1080p H.264-GRP" { + t.Fatalf("TMDB preference: got %q", got) + } + + imdbOnly := base + imdbOnly.ProviderMetadata = api.SourceScopedMetadata{ + IMDB: &api.IMDBMetadata{Title: "IMDB Series"}, + } + if got := buildName(imdbOnly, config.TrackerConfig{}); got != "IMDB Series S01 2026 NF WEB-DL 1080p H.264-GRP" { + t.Fatalf("IMDB preference: got %q", got) + } + + if got := buildName(base, config.TrackerConfig{}); got != "Parsed Series S01 2026 NF WEB-DL 1080p H.264-GRP" { + t.Fatalf("parsed-title fallback: got %q", got) + } +} + +// TestBuildNameHonoursSuppressionOverrides covers the naming-only toggles. They +// never reach a metadata field, so a from-scratch builder like UTP has to read +// them off ReleaseNameOverrides or it silently ignores the user. +func TestBuildNameHonoursSuppressionOverrides(t *testing.T) { + enabled := true + meta := api.UploadSubject{ + Type: "REMUX", + Source: "BluRay", + VideoCodec: "AVC", + SeasonStr: "S01", + Release: api.ReleaseInfo{ + Title: "Rei No Sakuhin", + Year: 2026, + Resolution: "1080p", + }, + Identity: api.ExternalIdentity{Category: "TV"}, + Tag: "-GRP", + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{ + Title: "Example Anime Series", + RetrievedAKA: "AKA Rei No Sakuhin", + }, + }, + } + meta.ReleaseNameOverrides = api.ReleaseNameOverrides{ + NoYear: &enabled, + NoSeason: &enabled, + NoAKA: &enabled, + } + + want := "Example Anime Series BDRemux 1080p AVC-GRP" + if got := buildName(meta, config.TrackerConfig{}); got != want { + t.Fatalf("buildName()\n got: %q\nwant: %q", got, want) + } +} diff --git a/internal/trackers/impl/unit3d/sites/utp/profile.go b/internal/trackers/impl/unit3d/sites/utp/profile.go index 3a17d23c..6d5b1b90 100644 --- a/internal/trackers/impl/unit3d/sites/utp/profile.go +++ b/internal/trackers/impl/unit3d/sites/utp/profile.go @@ -1,18 +1,30 @@ package utp import ( + "github.com/autobrr/upbrr/internal/trackers" "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" ) -// Profile returns UTP's type and resolution mappings, including fallback IDs -// for unknown values. +// Profile returns UTP's space-delimited release-name construction, image URL +// swap, tracker-owned rules and audio policy, optional owned image host, and +// type and resolution mappings, including fallback IDs for unknown values. func Profile() unit3d.Profile { return unit3d.Profile{ - Name: "UTP", - BaseURL: "https://utp.to", + Name: "UTP", + BaseURL: "https://utp.to", + Rules: Rules(), + AudioPolicy: AudioPolicy(), Site: unit3d.SiteProfile{ + BuildName: buildName, + BuildNameVersion: "v1", + BuildDescription: buildDescription, ResolveTypeID: typeID, ResolveResolutionID: resolutionID, }, + ImageHost: &trackers.ImageHostPolicy{ + ConditionalHost: "utppm", + OwnedHosts: []string{"utppm"}, + EnableWithImageHosting: true, + }, } } diff --git a/internal/trackers/impl/unit3d/sites/utp/rules.go b/internal/trackers/impl/unit3d/sites/utp/rules.go new file mode 100644 index 00000000..1a2d85d0 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/utp/rules.go @@ -0,0 +1,23 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package utp + +import ( + "github.com/autobrr/upbrr/internal/trackers" +) + +// Rules returns UTP's release eligibility requirements. UTP prescribes its own +// space-delimited release naming (utp.to/pages/33, see buildName), so renaming +// away from the dotted scene/P2P name is expected there rather than a +// violation, and the rename signals do not apply. +func Rules() *trackers.RuleSet { + return &trackers.RuleSet{SkipModifiedReleaseCheck: true} +} + +// AudioPolicy allows Ukrainian and English as additional audio languages: UTP +// releases always carry Ukrainian plus the original audio and optionally +// English, so those tracks must not count as bloat. +func AudioPolicy() *trackers.AudioPolicy { + return &trackers.AudioPolicy{AllowedLanguages: []string{"ukrainian", "english"}} +} diff --git a/internal/trackers/impl/unit3d/sites/utp/taxonomy.go b/internal/trackers/impl/unit3d/sites/utp/taxonomy.go index 10509a56..d6e0975a 100644 --- a/internal/trackers/impl/unit3d/sites/utp/taxonomy.go +++ b/internal/trackers/impl/unit3d/sites/utp/taxonomy.go @@ -1,6 +1,8 @@ package utp import ( + "strings" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" "github.com/autobrr/upbrr/pkg/api" ) @@ -25,7 +27,7 @@ func resolutionID(meta api.UploadSubject) string { "2160p": "2", "1080p": "3", "1080i": "4", - }[unit3d.Resolution(meta)]; ok { + }[strings.ToLower(unit3d.Resolution(meta))]; ok { return value } return "11" diff --git a/internal/trackers/impl/unit3d/sites/utp/taxonomy_test.go b/internal/trackers/impl/unit3d/sites/utp/taxonomy_test.go new file mode 100644 index 00000000..361a538f --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/utp/taxonomy_test.go @@ -0,0 +1,56 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package utp + +import ( + "testing" + + "github.com/autobrr/upbrr/pkg/api" +) + +func TestTypeID(t *testing.T) { + cases := map[string]string{ + "DISC": "1", + "REMUX": "2", + "ENCODE": "3", + "WEBDL": "4", + "WEBRIP": "5", + "HDTV": "6", + } + for typeValue, want := range cases { + meta := api.UploadSubject{Type: typeValue} + if got := typeID(meta); got != want { + t.Fatalf("type %q: expected %q, got %q", typeValue, want, got) + } + } + // Unknown type falls back to ENCODE (3). + if got := typeID(api.UploadSubject{Type: "MYSTERY"}); got != "3" { + t.Fatalf("expected unknown type fallback=3, got %q", got) + } +} + +func TestResolutionID(t *testing.T) { + cases := map[string]string{ + "4320p": "1", + "2160p": "2", + "1080p": "3", + "1080i": "4", + // Uppercase variants must still map through the lowercase lookup. + "1080P": "3", + "2160P": "2", + } + for resolution, want := range cases { + meta := api.UploadSubject{Release: api.ReleaseInfo{Resolution: resolution}} + if got := resolutionID(meta); got != want { + t.Fatalf("resolution %q: expected %q, got %q", resolution, want, got) + } + } + // Every other resolution files under Other (11). + for _, resolution := range []string{"720p", "576p", ""} { + meta := api.UploadSubject{Release: api.ReleaseInfo{Resolution: resolution}} + if got := resolutionID(meta); got != "11" { + t.Fatalf("resolution %q: expected fallback=11, got %q", resolution, got) + } + } +} diff --git a/internal/trackers/rule_contract.go b/internal/trackers/rule_contract.go index b2a491fc..e26b8f03 100644 --- a/internal/trackers/rule_contract.go +++ b/internal/trackers/rule_contract.go @@ -122,4 +122,8 @@ type RuleSet struct { BlockGroupUnlessType map[string][]string // RequireSceneNFO requires an NFO for scene releases. RequireSceneNFO bool + // SkipModifiedReleaseCheck exempts the tracker from the generic + // renamed/modified-release rule for trackers whose own naming rules make a + // renamed source expected rather than a violation. + SkipModifiedReleaseCheck bool } diff --git a/internal/trackers/rules.go b/internal/trackers/rules.go index 0df27e2e..9523edec 100644 --- a/internal/trackers/rules.go +++ b/internal/trackers/rules.go @@ -100,34 +100,37 @@ func evaluateRules(ctx context.Context, registry *Registry, tracker string, meta addStrict := func(rule, reason string) { addFailure(rule, reason, api.RuleDispositionStrict) } addWaivable := func(rule, reason string) { addFailure(rule, reason, api.RuleDispositionWaivable) } - // Renamed/modified releases are rejected by every supported tracker. The - // disposition derives from the detection signal so there is one authority: - // the authoritative srrdb comparison remains strict; heuristic rename - // detections may be explicitly authorized as a non-resolution policy - // exception. The signal is diagnostic-only and never enters the disclosed - // failure reason. - if detection := releasepolicy.DetectModifiedRelease(releasepolicy.ModifiedReleaseSubject{ - SourcePath: meta.SourcePath, - VideoPath: meta.VideoPath, - DiscType: meta.DiscType, - PersonalRelease: meta.PersonalRelease, - SceneRenamed: meta.SceneRenamed, - SceneRenamedReason: meta.SceneRenamedReason, - Release: meta.Release, - }); detection.Modified { - disposition := api.RuleDispositionWaivable - if detection.Signal == releasepolicy.ModifiedReleaseSignalSRRDB { - disposition = api.RuleDispositionStrict - } - addFailure("modified_release", detection.Reason, disposition) - if logger != nil { - logger.Debugf("trackers: rule matched tracker=%s rule=modified_release signal=%s disposition=%s", name, detection.Signal, disposition) + rules, ok := registry.LookupRules(name) + + // Renamed/modified releases are rejected by every supported tracker unless + // its own rules declare a rename expected. The disposition derives from the + // detection signal so there is one authority: the authoritative srrdb + // comparison remains strict; heuristic rename detections may be explicitly + // authorized as a non-resolution policy exception. The signal is + // diagnostic-only and never enters the disclosed failure reason. + if !rules.SkipModifiedReleaseCheck { + if detection := releasepolicy.DetectModifiedRelease(releasepolicy.ModifiedReleaseSubject{ + SourcePath: meta.SourcePath, + VideoPath: meta.VideoPath, + DiscType: meta.DiscType, + PersonalRelease: meta.PersonalRelease, + SceneRenamed: meta.SceneRenamed, + SceneRenamedReason: meta.SceneRenamedReason, + Release: meta.Release, + }); detection.Modified { + disposition := api.RuleDispositionWaivable + if detection.Signal == releasepolicy.ModifiedReleaseSignalSRRDB { + disposition = api.RuleDispositionStrict + } + addFailure("modified_release", detection.Reason, disposition) + if logger != nil { + logger.Debugf("trackers: rule matched tracker=%s rule=modified_release signal=%s disposition=%s", name, detection.Signal, disposition) + } } } metadataFailures, metadataEvaluated := evaluateMetadataRequirementsWithRegistry(registry, name, meta) failures = append(failures, metadataFailures...) - rules, ok := registry.LookupRules(name) if !ok && !metadataEvaluated { // Preserve the nil contract for trackers without their own rule set: the // consumer (applyTrackerRules) treats a nil result as "not evaluated, keep diff --git a/internal/trackers/rules_test.go b/internal/trackers/rules_test.go index eb8ca7ac..b71263dd 100644 --- a/internal/trackers/rules_test.go +++ b/internal/trackers/rules_test.go @@ -1057,6 +1057,64 @@ func TestEvaluateRulesModifiedReleaseDebugLog(t *testing.T) { }) } +// TestEvaluateRulesModifiedReleaseSkipExemptsUTP proves the declarative +// SkipModifiedReleaseCheck rule opt-out: UTP prescribes space-delimited release +// naming, so a spaced source folder is expected there rather than a rename +// violation, and neither the heuristic nor the scene rename signal may block. +func TestEvaluateRulesModifiedReleaseSkipExemptsUTP(t *testing.T) { + t.Parallel() + + heuristicRename := api.RuleSubject{ + SourcePath: "/data/movies/Example Movie 2026 2160p MA WEB-DL DDP5 1 HDR H 265-GRP", + Release: api.ReleaseInfo{Group: "GRP"}, + } + sceneRename := api.RuleSubject{ + SourcePath: "/data/movies/Example.Movie.2026.2160p.MA.WEB-DL.DDP5.1.HDR.H.265-GRP", + Release: api.ReleaseInfo{Group: "GRP", Resolution: "2160p"}, + SceneRenamed: true, + SceneRenamedReason: "source does not match its original scene release name (renamed or modified)", + } + + for name, subject := range map[string]api.RuleSubject{"heuristic": heuristicRename, "scene": sceneRename} { + if got := evaluateNonMetadataRulesForTest(context.Background(), "UTP", subject); hasRuleFailure(got, "modified_release") { + t.Fatalf("did not expect %s modified_release failure for UTP, got %#v", name, got) + } + } + // A tracker without the opt-out keeps failing for the same subject. + if got := evaluateNonMetadataRulesForTest(context.Background(), "AITHER", heuristicRename); !hasRuleFailure(got, "modified_release") { + t.Fatalf("expected modified_release failure for AITHER, got %#v", got) + } + + // The skip gate wraps the rule-match debug log too, so an exempt tracker + // emits no diagnostic for a rule that never evaluated, while a tracker + // without the opt-out still does. + registry, err := impl.NewRegistry() + if err != nil { + t.Fatalf("new registry: %v", err) + } + for tracker, wantLog := range map[string]bool{"UTP": false, "AITHER": true} { + logger := &ruleDebugLogger{} + if _, err := trackers.EvaluateRulesWithRegistry( + context.Background(), + registry, + tracker, + withConstructibleTrackerFactsForTest(heuristicRename), + logger, + ); err != nil { + t.Fatalf("EvaluateRulesWithRegistry %s: %v", tracker, err) + } + var logged bool + for _, entry := range logger.debugs { + if strings.Contains(entry, "rule=modified_release") { + logged = true + } + } + if logged != wantLog { + t.Fatalf("%s modified_release rule-match logged = %t, want %t (%q)", tracker, logged, wantLog, logger.debugs) + } + } +} + // TestEvaluateRulesMetadataPolicyReturnsEvaluatedEmpty guards the contract that // a configured metadata policy returns a non-nil empty slice after passing, so // the consumer clears stale stored metadata failures. diff --git a/webui/src/hooks/useSettingsState.test.ts b/webui/src/hooks/useSettingsState.test.ts index a752affa..e1c9e970 100644 --- a/webui/src/hooks/useSettingsState.test.ts +++ b/webui/src/hooks/useSettingsState.test.ts @@ -1548,4 +1548,75 @@ describe("Image hosting settings", () => { expect(payload.ImageHosting?.ReelflixAPI === "secret").toBe(true); expect(screen.queryByLabelText("Image API")).not.toBeInTheDocument(); }); + + it("renders UTPPM config and keeps it out of global host priority", async () => { + installAppOperationMocks({ + GetConfig: async () => + JSON.stringify({ + ImageHosting: { + Host1: "", + Host2: "", + Host3: "", + Host4: "", + Host5: "", + Host6: "", + UTPPMEnabled: false, + UTPPMAPI: "", + }, + }), + GetDefaultConfig: async () => JSON.stringify({}), + ListTrackerCatalog: async () => trackerCatalog(), + GetImageHostPolicyMetadata: async () => ({}), + }); + + render(createElement(ImageHostingHarness)); + + await waitFor(() => expect(screen.getByLabelText("Host 1")).toBeInTheDocument()); + + // UTPPM is UTP-owned, so it must not be selectable in a generic slot. + const hostOne = screen.getByLabelText("Host 1") as HTMLSelectElement; + expect(Array.from(hostOne.options).map((option) => option.value)).not.toContain("utppm"); + + fireEvent.click(screen.getByLabelText("UTPPM enabled")); + fireEvent.change(screen.getByLabelText("UTPPM API key"), { + target: { value: "secret" }, + }); + + await waitFor(() => expect(screen.getByLabelText("UTPPM enabled")).toBeChecked()); + + const payload = readPayload<{ + ImageHosting?: { + UTPPMEnabled?: boolean; + UTPPMAPI?: string; + }; + }>(); + expect(payload.ImageHosting?.UTPPMEnabled).toBe(true); + expect(payload.ImageHosting?.UTPPMAPI === "secret").toBe(true); + }); + + it("drops a stored generic utppm slot selection", async () => { + installAppOperationMocks({ + GetConfig: async () => + JSON.stringify({ + ImageHosting: { + Host1: "utppm", + Host2: "imgbb", + }, + }), + GetDefaultConfig: async () => JSON.stringify({}), + ListTrackerCatalog: async () => trackerCatalog(), + GetImageHostPolicyMetadata: async () => ({}), + }); + + render(createElement(ImageHostingHarness)); + + await waitFor(() => expect(screen.getByLabelText("Host 1")).toBeInTheDocument()); + + // Existing configs that placed utppm in a generic slot lose that selection + // rather than keeping an unselectable value, matching the reelflix precedent. + const hostOne = screen.getByLabelText("Host 1") as HTMLSelectElement; + expect(Array.from(hostOne.options).map((option) => option.value)).not.toContain("utppm"); + expect(hostOne.value).not.toBe("utppm"); + expect((screen.getByLabelText("Host 2") as HTMLSelectElement).value).toBe("imgbb"); + }); }); diff --git a/webui/src/hooks/useSettingsState.tsx b/webui/src/hooks/useSettingsState.tsx index 1ca9393d..5504d5ae 100644 --- a/webui/src/hooks/useSettingsState.tsx +++ b/webui/src/hooks/useSettingsState.tsx @@ -101,7 +101,6 @@ const imageHostOptions = [ { value: "passtheimage", label: "PassTheImage" }, { value: "seedpool_cdn", label: "Seedpool CDN" }, { value: "sharex", label: "ShareX" }, - { value: "utppm", label: "UTPPM" }, ]; const torrentClientTypeOptions = [ @@ -133,12 +132,12 @@ const imageHostKeyMap: Record = { passtheimage: ["PassTheImageAPI"], seedpool_cdn: ["SeedpoolCDNAPI"], sharex: ["ShareXURL", "ShareXAPIKey"], - utppm: ["UTPPMAPI"], }; const conditionalImageHostEnabledKeys: Record = { lostimg: "LostimgEnabled", reelflix: "ReelflixEnabled", + utppm: "UTPPMEnabled", }; const stringField = (key: string, meta: Omit = {}): FieldMeta => ({ @@ -233,6 +232,10 @@ const sectionFieldMeta: Record> = { label: "ReelFliX API key", sensitive: true, }), + UTPPMAPI: stringField("UTPPMAPI", { + label: "UTPPM API key", + sensitive: true, + }), }, MainSettings: { InputHistoryLimit: { key: "InputHistoryLimit", label: "Input history limit", type: "number" }, @@ -1934,6 +1937,22 @@ export const useSettingsState = (options: UseSettingsStateOptions): UseSettingsS ["ImageHosting", "ReelflixAPI"], sectionFieldMeta.ImageHosting.ReelflixAPI, )} +
+ UTPPM enabled + + updateConfigValue(["ImageHosting", "UTPPMEnabled"], event.target.checked) + } + /> +
+ {renderField( + "UTPPMAPI", + (imageCfg.UTPPMAPI as ConfigValue) ?? "", + ["ImageHosting", "UTPPMAPI"], + sectionFieldMeta.ImageHosting.UTPPMAPI, + )}