2026-08-06 22:33:56 +12:00
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/bazarr"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Matching an Emby item onto a Bazarr id is where a wrong answer is worst: it downloads a
|
|
|
|
|
// subtitle for one film and writes it beside another. These pin the rule.
|
|
|
|
|
|
|
|
|
|
func TestBazarrMovieMatchesOnTitleAndYear(t *testing.T) {
|
|
|
|
|
movies := []bazarr.Movie{
|
|
|
|
|
{RadarrID: 1, Title: "The Thing", Year: "1982"},
|
|
|
|
|
{RadarrID: 2, Title: "The Thing", Year: "2011"},
|
|
|
|
|
{RadarrID: 3, Title: "Arrival", Year: "2016"},
|
|
|
|
|
}
|
|
|
|
|
if got := bazarrMovieFor("The Thing", 2011, movies); got == nil || got.RadarrID != 2 {
|
|
|
|
|
t.Fatalf("remake matched %+v", got)
|
|
|
|
|
}
|
|
|
|
|
if got := bazarrMovieFor("the thing!", 1982, movies); got == nil || got.RadarrID != 1 {
|
|
|
|
|
t.Fatalf("punctuation and case should not matter, got %+v", got)
|
|
|
|
|
}
|
|
|
|
|
if got := bazarrMovieFor("Arrival", 0, movies); got == nil || got.RadarrID != 3 {
|
|
|
|
|
t.Fatalf("an unknown year should still match a unique title, got %+v", got)
|
|
|
|
|
}
|
|
|
|
|
if got := bazarrMovieFor("Dune", 2021, movies); got != nil {
|
|
|
|
|
t.Fatalf("expected no match, got %+v", got)
|
|
|
|
|
}
|
|
|
|
|
if got := bazarrMovieFor("", 2016, movies); got != nil {
|
|
|
|
|
t.Fatalf("a blank title must never match, got %+v", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Bazarr carries the year as a string and leaves it empty often enough that treating an
|
|
|
|
|
// empty one as a disagreement would reject correct matches.
|
|
|
|
|
func TestBazarrMovieAcceptsAnEntryWithNoYear(t *testing.T) {
|
|
|
|
|
movies := []bazarr.Movie{{RadarrID: 4, Title: "Arrival", Year: ""}}
|
|
|
|
|
if got := bazarrMovieFor("Arrival", 2016, movies); got == nil || got.RadarrID != 4 {
|
|
|
|
|
t.Fatalf("got %+v", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// With two entries under one title, a year that both sides know must win over the first
|
|
|
|
|
// row that merely failed to disagree.
|
|
|
|
|
func TestBazarrMoviePrefersAnExactYearOverAYearlessFallback(t *testing.T) {
|
|
|
|
|
movies := []bazarr.Movie{
|
|
|
|
|
{RadarrID: 5, Title: "The Thing", Year: ""},
|
|
|
|
|
{RadarrID: 6, Title: "The Thing", Year: "1982"},
|
|
|
|
|
}
|
|
|
|
|
if got := bazarrMovieFor("The Thing", 1982, movies); got == nil || got.RadarrID != 6 {
|
|
|
|
|
t.Fatalf("got %+v", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBazarrSeriesMatchesOnTitleAndYear(t *testing.T) {
|
|
|
|
|
series := []bazarr.Series{
|
|
|
|
|
{SonarrSeriesID: 1, Title: "Battlestar Galactica", Year: "1978"},
|
|
|
|
|
{SonarrSeriesID: 2, Title: "Battlestar Galactica", Year: "2004"},
|
|
|
|
|
}
|
|
|
|
|
if got := bazarrSeriesFor("Battlestar Galactica", 2004, series); got == nil || got.SonarrSeriesID != 2 {
|
|
|
|
|
t.Fatalf("got %+v", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBazarrEpisodeMatchesOnNumbersNotTitles(t *testing.T) {
|
|
|
|
|
episodes := []bazarr.Episode{
|
|
|
|
|
{SonarrEpisodeID: 10, Season: 3, Episode: 3, Title: "Something"},
|
|
|
|
|
{SonarrEpisodeID: 11, Season: 3, Episode: 4, Title: "Quite different in Sonarr"},
|
|
|
|
|
{SonarrEpisodeID: 12, Season: 0, Episode: 4, Title: "A special"},
|
|
|
|
|
}
|
|
|
|
|
if got := bazarrEpisodeFor(episodes, 3, 4); got == nil || got.SonarrEpisodeID != 11 {
|
|
|
|
|
t.Fatalf("got %+v", got)
|
|
|
|
|
}
|
|
|
|
|
// Season 0 is specials, a real season — it must not be confused with "no season".
|
|
|
|
|
if got := bazarrEpisodeFor(episodes, 0, 4); got == nil || got.SonarrEpisodeID != 12 {
|
|
|
|
|
t.Fatalf("specials should match, got %+v", got)
|
|
|
|
|
}
|
|
|
|
|
if got := bazarrEpisodeFor(episodes, 3, 9); got != nil {
|
|
|
|
|
t.Fatalf("expected no match, got %+v", got)
|
|
|
|
|
}
|
|
|
|
|
if got := bazarrEpisodeFor(episodes, 3, 0); got != nil {
|
|
|
|
|
t.Fatalf("a missing episode number must never match, got %+v", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The viewer's language comes first because it is the only thing they asked for; within a
|
|
|
|
|
// language a plain track beats a forced or hearing-impaired one, for the same reason
|
|
|
|
|
// selectSubtitle ranks them that way.
|
|
|
|
|
func TestRankSubtitleCandidatesPutsTheChosenLanguageFirst(t *testing.T) {
|
|
|
|
|
found := []bazarr.Subtitle{
|
|
|
|
|
{Language: "eng", Score: 99, Token: "en-plain"},
|
|
|
|
|
{Language: "ita", Score: 40, Token: "it-plain"},
|
|
|
|
|
{Language: "ita", Score: 98, Token: "it-forced", Forced: true},
|
|
|
|
|
{Language: "ita", Score: 95, Token: "it-sdh", HearingImpaired: true},
|
|
|
|
|
}
|
2026-08-09 08:25:50 +12:00
|
|
|
results := rankMergedCandidates(bazarrCandidates(found), "it")
|
2026-08-06 22:33:56 +12:00
|
|
|
if len(results) != 4 {
|
|
|
|
|
t.Fatalf("results = %+v", results)
|
|
|
|
|
}
|
|
|
|
|
order := []string{results[0].Token, results[1].Token, results[2].Token, results[3].Token}
|
|
|
|
|
want := []string{"it-plain", "it-sdh", "it-forced", "en-plain"}
|
|
|
|
|
for i := range want {
|
|
|
|
|
if order[i] != want[i] {
|
|
|
|
|
t.Fatalf("order = %v, want %v", order, want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRankSubtitleCandidatesFallsBackToScoreWithNoPreference(t *testing.T) {
|
|
|
|
|
found := []bazarr.Subtitle{
|
|
|
|
|
{Language: "eng", Score: 55, Token: "low"},
|
|
|
|
|
{Language: "eng", Score: 92, Token: "high"},
|
|
|
|
|
}
|
|
|
|
|
for _, language := range []string{"", "auto"} {
|
2026-08-09 08:25:50 +12:00
|
|
|
results := rankMergedCandidates(bazarrCandidates(found), language)
|
2026-08-06 22:33:56 +12:00
|
|
|
if len(results) != 2 || results[0].Token != "high" {
|
|
|
|
|
t.Fatalf("language %q gave %+v", language, results)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A row with no token cannot be downloaded, so offering it would be a button that does
|
|
|
|
|
// nothing.
|
|
|
|
|
func TestRankSubtitleCandidatesDropsTokenlessRowsAndCaps(t *testing.T) {
|
|
|
|
|
found := []bazarr.Subtitle{{Language: "eng", Token: " "}}
|
|
|
|
|
for i := 0; i < maxSubtitleResults+5; i++ {
|
|
|
|
|
found = append(found, bazarr.Subtitle{Language: "eng", Score: i, Token: "t"})
|
|
|
|
|
}
|
2026-08-09 08:25:50 +12:00
|
|
|
results := rankMergedCandidates(bazarrCandidates(found), "en")
|
2026-08-06 22:33:56 +12:00
|
|
|
if len(results) > maxSubtitleResults {
|
|
|
|
|
t.Fatalf("len = %d", len(results))
|
|
|
|
|
}
|
|
|
|
|
for _, result := range results {
|
|
|
|
|
if result.Token == " " {
|
|
|
|
|
t.Fatal("a tokenless row was offered")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 08:25:50 +12:00
|
|
|
func TestMergedCandidateLabelNamesTheLanguageAndKind(t *testing.T) {
|
|
|
|
|
label := func(subtitle bazarr.Subtitle) string {
|
|
|
|
|
return mergedCandidateLabel(bazarrCandidates([]bazarr.Subtitle{subtitle})[0])
|
|
|
|
|
}
|
2026-08-06 22:33:56 +12:00
|
|
|
for _, testCase := range []struct {
|
|
|
|
|
subtitle bazarr.Subtitle
|
|
|
|
|
want string
|
|
|
|
|
}{
|
2026-08-09 08:25:50 +12:00
|
|
|
{bazarr.Subtitle{Language: "ita", Score: 97, Token: "t"}, "Italian · 97% match"},
|
|
|
|
|
{bazarr.Subtitle{Language: "eng", Forced: true, Score: 80, Token: "t"}, "English · Forced · 80% match"},
|
|
|
|
|
{bazarr.Subtitle{Language: "eng", HearingImpaired: true, Token: "t"}, "English · Hearing impaired"},
|
2026-08-06 22:33:56 +12:00
|
|
|
// A language Memby has no entry for must still name itself rather than go blank.
|
2026-08-09 08:25:50 +12:00
|
|
|
{bazarr.Subtitle{Language: "mi", Token: "t"}, "MI"},
|
|
|
|
|
{bazarr.Subtitle{Token: "t"}, "Unknown"},
|
2026-08-06 22:33:56 +12:00
|
|
|
// Bazarr has been seen to return scores above 100; a "112% match" reads as a bug.
|
2026-08-09 08:25:50 +12:00
|
|
|
{bazarr.Subtitle{Language: "eng", Score: 112, Token: "t"}, "English · 100% match"},
|
2026-08-06 22:33:56 +12:00
|
|
|
} {
|
2026-08-09 08:25:50 +12:00
|
|
|
if got := label(testCase.subtitle); got != testCase.want {
|
2026-08-06 22:33:56 +12:00
|
|
|
t.Errorf("label = %q, want %q", got, testCase.want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The downloaded track has no marker saying it is new, so it is identified by what was
|
|
|
|
|
// asked for. Failing to identify it is allowed — the client then applies its own rule.
|
|
|
|
|
func TestNewestSubtitleIDPrefersTheMatchingExternalTrack(t *testing.T) {
|
|
|
|
|
subtitles := []playableSubtitle{
|
|
|
|
|
{ID: "1", Language: "eng", DeliveryMethod: "External"},
|
|
|
|
|
{ID: "2", Language: "ita", DeliveryMethod: "Encode"},
|
|
|
|
|
{ID: "3", Language: "ita", DeliveryMethod: "External", IsForced: true},
|
|
|
|
|
{ID: "4", Language: "ita", DeliveryMethod: "External"},
|
|
|
|
|
}
|
|
|
|
|
if got := newestSubtitleID(subtitles, subtitleCandidate{Language: "it"}); got != "4" {
|
|
|
|
|
t.Errorf("plain Italian = %q, want 4", got)
|
|
|
|
|
}
|
|
|
|
|
if got := newestSubtitleID(subtitles, subtitleCandidate{Language: "it", Forced: true}); got != "3" {
|
|
|
|
|
t.Errorf("forced Italian = %q, want 3", got)
|
|
|
|
|
}
|
|
|
|
|
if got := newestSubtitleID(subtitles, subtitleCandidate{Language: "de"}); got != "" {
|
|
|
|
|
t.Errorf("absent language = %q, want empty", got)
|
|
|
|
|
}
|
|
|
|
|
if got := newestSubtitleID(subtitles, subtitleCandidate{}); got != "" {
|
|
|
|
|
t.Errorf("no language = %q, want empty", got)
|
|
|
|
|
}
|
|
|
|
|
}
|