diff --git a/internal/trackers/impl/unit3d/additional/rules.go b/internal/trackers/impl/unit3d/additional/rules.go index 133b5557..75ccea64 100644 --- a/internal/trackers/impl/unit3d/additional/rules.go +++ b/internal/trackers/impl/unit3d/additional/rules.go @@ -37,6 +37,7 @@ var trackerRuleFactories = map[string]func() RuleSet{ "DP": rulesDP, "HHD": rulesHHD, "LST": rulesLST, + "LT": rulesLT, "LUME": rulesLUME, "MNS": rulesMNS, "OE": rulesOE, @@ -279,3 +280,13 @@ func rulesZNTH() RuleSet { AdultMessage: "Porn/xxx is not allowed at ZNTH.", } } + +func rulesLT() RuleSet { + return RuleSet{ + Language: &LanguageRule{ + Languages: languagesSpanish, + RequireAudio: true, + RequireSubs: true, + }, + } +} diff --git a/internal/trackers/impl/unit3d/lt.go b/internal/trackers/impl/unit3d/lt.go new file mode 100644 index 00000000..2714dd67 --- /dev/null +++ b/internal/trackers/impl/unit3d/lt.go @@ -0,0 +1,76 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package unit3d + +import ( + "strings" + + "github.com/autobrr/upbrr/pkg/api" +) + +func siteLTProfile() unit3DSiteProfile { + return unit3DSiteProfile{ + resolveCategoryID: resolveUnit3DLTCategoryID, + } +} + +var ltAsianCountries = map[string]bool{ + "AE": true, "AF": true, "AM": true, "AZ": true, "BD": true, "BH": true, "BN": true, "BT": true, "CN": true, "CY": true, "GE": true, "HK": true, "ID": true, "IL": true, "IN": true, + "IQ": true, "IR": true, "JO": true, "JP": true, "KG": true, "KH": true, "KP": true, "KR": true, "KW": true, "KZ": true, "LA": true, "LB": true, "LK": true, "MM": true, "MN": true, + "MO": true, "MV": true, "MY": true, "NP": true, "OM": true, "PH": true, "PK": true, "PS": true, "QA": true, "SA": true, "SG": true, "SY": true, "TH": true, "TJ": true, "TL": true, + "TM": true, "TR": true, "TW": true, "UZ": true, "VN": true, "YE": true, +} + +func resolveUnit3DLTCategoryID(meta api.PreparedMetadata) string { + category := resolveUnit3DCategory(meta) + + categoryID := "1" // Default MOVIE + if category == "TV" { + categoryID = "2" // Default TV + } + + if category == "TV" { + if meta.Anime { + return "5" + } + + keywords := "" + overview := "" + genres := "" + var originCountries []string + + if meta.ExternalMetadata.TMDB != nil { + keywords = strings.ToLower(meta.ExternalMetadata.TMDB.Keywords) + overview = strings.ToLower(meta.ExternalMetadata.TMDB.Overview) + genres = strings.ToLower(meta.ExternalMetadata.TMDB.Genres) + originCountries = meta.ExternalMetadata.TMDB.OriginCountry + } + + soapKeywords := []string{"telenovela", "novela", "soap", "culebrón", "culebron"} + hasSoap := false + for _, kw := range soapKeywords { + if strings.Contains(keywords, kw) || strings.Contains(overview, kw) { + hasSoap = true + break + } + } + if hasSoap { + return "8" + } + + hasAsianCountry := false + for _, c := range originCountries { + if ltAsianCountries[strings.ToUpper(c)] { + hasAsianCountry = true + break + } + } + + if strings.Contains(genres, "drama") && hasAsianCountry { + return "20" + } + } + + return categoryID +} diff --git a/internal/trackers/impl/unit3d/names.go b/internal/trackers/impl/unit3d/names.go index 0bf1fe23..69f85244 100644 --- a/internal/trackers/impl/unit3d/names.go +++ b/internal/trackers/impl/unit3d/names.go @@ -4,6 +4,9 @@ package unit3d import ( + "encoding/json" + "fmt" + "os" "regexp" "strconv" "strings" @@ -23,6 +26,23 @@ var noGroupTagPattern = regexp.MustCompile(`(?i)-(nogrp|nogroup|unknown|-unk-)`) var ( languageTagLookupOnce sync.Once languageTagLookup map[string]language.Tag + + markerPattern = regexp.MustCompile(`(?i)\.(19\d\d|20\d\d|3d|480p|576p|720p|1080p|1080i|2160p|4k|mkv|avi|mp4|remux|bluray|blu-ray|web-dl|webdl|web|hdtv|dvdrip|dvd|bd25|bd50|uhd)\b`) + akaPattern = regexp.MustCompile(`(?i)[. _-]aka[. _-]`) + multipleDotsPattern = regexp.MustCompile(`\.{2,}`) + multipleSpacesPattern = regexp.MustCompile(`\s{2,}`) + + audioLatinoCheck = map[string]bool{ + "es-419": true, "es-mx": true, "es-ar": true, "es-cl": true, "es-ve": true, + "es-bo": true, "es-co": true, "es-cr": true, "es-do": true, "es-ec": true, + "es-sv": true, "es-gt": true, "es-hn": true, "es-ni": true, "es-pa": true, + "es-py": true, "es-pe": true, "es-pr": true, "es-uy": true, + } + audioCastilianCheck = map[string]bool{ + "es": true, "es-es": true, + } + latinoKeywords = []string{"latino", "latin america"} + castilianKeywords = []string{"castellano"} ) func buildUnit3DName(tracker string, meta api.PreparedMetadata, cfg config.TrackerConfig) string { @@ -49,6 +69,8 @@ func buildUnit3DName(tracker string, meta api.PreparedMetadata, cfg config.Track return BuildCBRName(meta, cfg.TagForCustomRelease) case "LDU": return buildLDUName(name, meta) + case "LT": + return buildLTName(name, meta) case "RF": return addNoGroupSuffix(name, meta, "NoGroup") case "SAM": @@ -474,3 +496,179 @@ func normalizeZNTHAlphaNum(value string) string { func isZNTHAlphaNum(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) } + +func buildLTName(name string, meta api.PreparedMetadata) string { + aka := "" + title := "" + origTitle := "" + origLang := "" + if meta.ExternalMetadata.TMDB != nil { + aka = meta.ExternalMetadata.TMDB.RetrievedAKA + title = meta.ExternalMetadata.TMDB.Title + origTitle = meta.ExternalMetadata.TMDB.OriginalTitle + origLang = meta.ExternalMetadata.TMDB.OriginalLanguage + } + if title == "" && meta.ExternalMetadata.IMDB != nil { + title = meta.ExternalMetadata.IMDB.Title + } + if origTitle == "" { + origTitle = title + } + ltName := name + ltName = strings.ReplaceAll(ltName, "Dual-Audio", "") + ltName = strings.ReplaceAll(ltName, "Dubbed", "") + + // Find the end of the title block (start of year, resolution, source, etc.) + titleEndIdx := len(ltName) + if loc := markerPattern.FindStringIndex(ltName); loc != nil { + titleEndIdx = loc[0] + } + + titleBlock := ltName[:titleEndIdx] + restOfName := ltName[titleEndIdx:] + + // Determine the correct target title to use + targetTitle := title + if origLang == "es" { + // Use Spanish title for Spanish original series/movies + if aka != "" { + akaClean := strings.TrimSpace(strings.ReplaceAll(aka, "AKA", "")) + if akaClean != "" { + targetTitle = akaClean + } + } else if origTitle != "" { + targetTitle = origTitle + } + } + + if targetTitle != "" { + targetTitleDotted := strings.ReplaceAll(targetTitle, " ", ".") + // If the title block contains AKA, or we need to replace a mismatched title: + if akaPattern.MatchString(titleBlock) || !strings.EqualFold(strings.ReplaceAll(titleBlock, ".", " "), targetTitle) { + titleBlock = targetTitleDotted + } + } + + ltName = titleBlock + restOfName + + isDisc := false + if normalizeUnit3DTypeCandidate(meta.Type) == "DISC" || normalizeUnit3DTypeCandidate(meta.Release.Type) == "DISC" || meta.DiscType != "" { + isDisc = true + } + + if !isDisc { + type rawMediaInfoDoc struct { + Media struct { + Track []map[string]any `json:"track"` + } `json:"media"` + } + + var tracks []map[string]any + if meta.MediaInfoJSONPath != "" { + if payload, err := os.ReadFile(meta.MediaInfoJSONPath); err == nil { + var doc rawMediaInfoDoc + if err := json.Unmarshal(payload, &doc); err == nil { + tracks = doc.Media.Track + } + } + } + + hasSpanishAudio := false + hasLatino := false + hasCastilian := false + + hasTracks := false + if len(tracks) > 0 { + for _, track := range tracks { + trackType := "" + if val, ok := track["@type"]; ok { + trackType = strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", val))) + } + if trackType != "audio" { + continue + } + hasTracks = true + lang := strings.ToLower(namesTrackString(track, "Language", "Language_String", "Language_String2", "Language_String3")) + titleText := strings.ToLower(namesTrackString(track, "Title", "Title_String", "Title_String2", "Title_String3")) + + if strings.Contains(titleText, "commentary") { + continue + } + + isLatinoTitle := false + for _, kw := range latinoKeywords { + if strings.Contains(titleText, kw) { + isLatinoTitle = true + break + } + } + isCastilianTitle := false + for _, kw := range castilianKeywords { + if strings.Contains(titleText, kw) { + isCastilianTitle = true + break + } + } + + if audioLatinoCheck[lang] || (lang == "es" && isLatinoTitle) { + hasLatino = true + hasSpanishAudio = true + } else if (lang == "es" && isCastilianTitle) || audioCastilianCheck[lang] { + hasCastilian = true + hasSpanishAudio = true + } + } + } + + if !hasTracks { + // Fallback to PreparedMetadata.AudioLanguages if MediaInfo JSON wasn't parsed + for _, lang := range meta.AudioLanguages { + if strings.EqualFold(lang, "Spanish") || strings.EqualFold(lang, "es") { + hasSpanishAudio = true + hasLatino = true + break + } + } + } + + tag := strings.TrimSpace(meta.Tag) + // insertTagBracket inserts the label just before "-" so the result + // is "… [LABEL]-TAG" rather than "…- [LABEL]TAG". + insertTagBracket := func(s, label string) string { + if tag != "" { + sep := "-" + tag + if idx := strings.LastIndex(s, sep); idx != -1 { + return s[:idx] + " [" + label + "]" + s[idx:] + } + } + return s + " [" + label + "]" + } + if hasSpanishAudio { + if !hasLatino && hasCastilian { + if !strings.Contains(ltName, "[CAST]") { + ltName = insertTagBracket(ltName, "CAST") + } + } + } else { + if !strings.Contains(ltName, "[SUBS]") { + ltName = insertTagBracket(ltName, "SUBS") + } + } + } + + ltName = multipleDotsPattern.ReplaceAllString(ltName, ".") + ltName = multipleSpacesPattern.ReplaceAllString(ltName, " ") + return strings.Trim(ltName, ". ") +} + +func namesTrackString(track map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := track[key]; ok { + trimmed := strings.TrimSpace(fmt.Sprintf("%v", value)) + if trimmed != "" { + return trimmed + } + } + } + return "" +} diff --git a/internal/trackers/impl/unit3d/profiles.go b/internal/trackers/impl/unit3d/profiles.go index 40a9c713..c5f60655 100644 --- a/internal/trackers/impl/unit3d/profiles.go +++ b/internal/trackers/impl/unit3d/profiles.go @@ -28,6 +28,7 @@ var unit3DSiteProfiles = map[string]unit3DSiteProfile{ "ITT": siteITTProfile(), "LDU": siteLDUProfile(), "LST": siteLSTProfile(), + "LT": siteLTProfile(), "OE": siteOEProfile(), "OTW": siteOTWProfile(), "PT": sitePTProfile(), diff --git a/internal/trackers/impl/unit3d/upload_test.go b/internal/trackers/impl/unit3d/upload_test.go index 6f924ce9..48e4220b 100644 --- a/internal/trackers/impl/unit3d/upload_test.go +++ b/internal/trackers/impl/unit3d/upload_test.go @@ -1887,3 +1887,216 @@ func TestResolveUnit3DTypeIDForTrackerTIKOverrideDiscType(t *testing.T) { t.Fatalf("expected TIK BD66 override type_id=4, got %q", got) } } + +func TestBuildUnit3DNameLT(t *testing.T) { + t.Run("cleans Dual-Audio and Dubbed and AKA", func(t *testing.T) { + meta := api.PreparedMetadata{ + ReleaseName: "Movie.2024.Dual-Audio.Dubbed.1080p.Bluray-GRP", + Tag: "GRP", + ExternalMetadata: api.ExternalMetadata{ + TMDB: &api.TMDBMetadata{ + RetrievedAKA: "AKA Alternative Title", + }, + }, + } + got := buildUnit3DName("LT", meta, config.TrackerConfig{}) + if strings.Contains(got, "Dual-Audio") || strings.Contains(got, "Dubbed") || strings.Contains(got, "AKA") { + t.Fatalf("expected Dual-Audio, Dubbed, and AKA to be removed, got %q", got) + } + }) + + t.Run("replaces English title with Spanish AKA when origLang is es", func(t *testing.T) { + meta := api.PreparedMetadata{ + ReleaseName: "Original.Title.2024.1080p.Bluray-GRP", + Tag: "GRP", + ExternalMetadata: api.ExternalMetadata{ + TMDB: &api.TMDBMetadata{ + Title: "Original Title", + OriginalLanguage: "es", + RetrievedAKA: "AKA Titulo en Español", + }, + }, + } + got := buildUnit3DName("LT", meta, config.TrackerConfig{}) + if !strings.Contains(got, "Titulo.en.Español") { + t.Fatalf("expected English title replaced by Spanish AKA, got %q", got) + } + }) + + t.Run("replaces Spanish title with English title when origLang is not es (foreign)", func(t *testing.T) { + meta := api.PreparedMetadata{ + ReleaseName: "Spanish.Title.2024.1080p.Bluray-GRP", + Tag: "GRP", + ExternalMetadata: api.ExternalMetadata{ + TMDB: &api.TMDBMetadata{ + Title: "English Title", + OriginalLanguage: "en", + RetrievedAKA: "Spanish Title", + }, + }, + } + got := buildUnit3DName("LT", meta, config.TrackerConfig{}) + if !strings.Contains(got, "English.Title") || strings.Contains(got, "Spanish.Title") { + t.Fatalf("expected Spanish title replaced by English title, got %q", got) + } + }) + + t.Run("removes AKA tag from Title.AKA.SpanishTitle pattern for foreign movies (keeping English)", func(t *testing.T) { + meta := api.PreparedMetadata{ + ReleaseName: "English.Title.AKA.Spanish.Title.2024.1080p.Bluray-GRP", + Tag: "GRP", + ExternalMetadata: api.ExternalMetadata{ + TMDB: &api.TMDBMetadata{ + Title: "English Title", + OriginalTitle: "English Title", + OriginalLanguage: "en", + RetrievedAKA: "AKA Spanish Title", + }, + }, + } + got := buildUnit3DName("LT", meta, config.TrackerConfig{}) + if !strings.Contains(got, "English.Title") || strings.Contains(got, "Spanish.Title") || strings.Contains(got, "AKA") { + t.Fatalf("expected English title kept and AKA/Spanish title removed, got %q", got) + } + }) + + t.Run("removes AKA tag from EnglishTitle.AKA.SpanishTitle pattern for Spanish original movies (keeping Spanish)", func(t *testing.T) { + meta := api.PreparedMetadata{ + ReleaseName: "English.Title.AKA.Spanish.Title.2024.1080p.Bluray-GRP", + Tag: "GRP", + ExternalMetadata: api.ExternalMetadata{ + TMDB: &api.TMDBMetadata{ + Title: "English Title", + OriginalTitle: "Spanish Title", + OriginalLanguage: "es", + RetrievedAKA: "AKA Spanish Title", + }, + }, + } + got := buildUnit3DName("LT", meta, config.TrackerConfig{}) + if !strings.Contains(got, "Spanish.Title") || strings.Contains(got, "English.Title") || strings.Contains(got, "AKA") { + t.Fatalf("expected Spanish title kept and AKA/English title removed, got %q", got) + } + }) + + t.Run("adds CAST tag when Castilian-only audio", func(t *testing.T) { + tempDir := t.TempDir() + filePath := filepath.Join(tempDir, "mediainfo.json") + mediaInfoJSON := `{ + "media": { + "track": [ + {"@type": "General"}, + {"@type": "Video"}, + {"@type": "Audio", "Language": "es", "Title": "Castellano"} + ] + } + }` + if err := os.WriteFile(filePath, []byte(mediaInfoJSON), 0644); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + + meta := api.PreparedMetadata{ + ReleaseName: "Movie.2024.1080p.Bluray-GRP", + Tag: "GRP", + MediaInfoJSONPath: filePath, + ExternalMetadata: api.ExternalMetadata{ + TMDB: &api.TMDBMetadata{ + Title: "Movie", + }, + }, + } + got := buildUnit3DName("LT", meta, config.TrackerConfig{}) + expected := "Movie.2024.1080p.Bluray [CAST]-GRP" + if got != expected { + t.Fatalf("expected name to be %q, got %q", expected, got) + } + }) + + t.Run("adds SUBS tag when no Spanish audio", func(t *testing.T) { + tempDir := t.TempDir() + filePath := filepath.Join(tempDir, "mediainfo.json") + mediaInfoJSON := `{ + "media": { + "track": [ + {"@type": "General"}, + {"@type": "Video"}, + {"@type": "Audio", "Language": "en"} + ] + } + }` + if err := os.WriteFile(filePath, []byte(mediaInfoJSON), 0644); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + + meta := api.PreparedMetadata{ + ReleaseName: "Movie.2024.1080p.Bluray-GRP", + Tag: "GRP", + MediaInfoJSONPath: filePath, + ExternalMetadata: api.ExternalMetadata{ + TMDB: &api.TMDBMetadata{ + Title: "Movie", + }, + }, + } + got := buildUnit3DName("LT", meta, config.TrackerConfig{}) + expected := "Movie.2024.1080p.Bluray [SUBS]-GRP" + if got != expected { + t.Fatalf("expected name to be %q, got %q", expected, got) + } + }) +} + +func TestResolveUnit3DLTCategoryID(t *testing.T) { + t.Run("default movie and TV categories", func(t *testing.T) { + metaMovie := api.PreparedMetadata{Type: "MOVIE"} + if got := resolveUnit3DCategoryIDForTracker("LT", metaMovie); got != "1" { + t.Fatalf("expected MOVIE category_id=1, got %q", got) + } + + metaTV := api.PreparedMetadata{ + Release: api.ReleaseInfo{Category: "TV"}, + } + if got := resolveUnit3DCategoryIDForTracker("LT", metaTV); got != "2" { + t.Fatalf("expected TV category_id=2, got %q", got) + } + }) + + t.Run("Anime category", func(t *testing.T) { + meta := api.PreparedMetadata{ + Release: api.ReleaseInfo{Category: "TV"}, + Anime: true, + } + if got := resolveUnit3DCategoryIDForTracker("LT", meta); got != "5" { + t.Fatalf("expected Anime category_id=5, got %q", got) + } + }) + + t.Run("Telenovela category", func(t *testing.T) { + meta := api.PreparedMetadata{ + Release: api.ReleaseInfo{Category: "TV"}, + ExternalMetadata: api.ExternalMetadata{ + TMDB: &api.TMDBMetadata{ + Keywords: "some keywords, novela, drama", + }, + }, + } + if got := resolveUnit3DCategoryIDForTracker("LT", meta); got != "8" { + t.Fatalf("expected Telenovela category_id=8, got %q", got) + } + }) + + t.Run("Turkish & Asian drama category", func(t *testing.T) { + meta := api.PreparedMetadata{ + Release: api.ReleaseInfo{Category: "TV"}, + ExternalMetadata: api.ExternalMetadata{ + TMDB: &api.TMDBMetadata{ + Genres: "action, drama, comedy", + OriginCountry: []string{"TR"}, + }, + }, + } + if got := resolveUnit3DCategoryIDForTracker("LT", meta); got != "20" { + t.Fatalf("expected Turkish drama category_id=20, got %q", got) + } + }) +}