Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions internal/config/defaults/example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ image_hosting:
seedpool_cdn_api: ""
sharex_url: ""
sharex_api_key: ""
utppm_enabled: false
utppm_api: ""
lostimg_enabled: false
lostimg_api: ""
Expand Down
1 change: 1 addition & 0 deletions internal/config/legacy/converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion internal/imagehosting/policy/policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
1 change: 1 addition & 0 deletions internal/imagehosting/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions internal/imagehosting/uploaders.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions internal/imagehosting/uploaders_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
33 changes: 33 additions & 0 deletions internal/metadata/media_details_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
16 changes: 16 additions & 0 deletions internal/trackers/impl/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion internal/trackers/impl/responsibility_ledger_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
64 changes: 64 additions & 0 deletions internal/trackers/impl/unit3d/sites/utp/description.go
Original file line number Diff line number Diff line change
@@ -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
}
39 changes: 39 additions & 0 deletions internal/trackers/impl/unit3d/sites/utp/description_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading