Publish current app and server
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
@@ -36,6 +37,22 @@ type fakeCuratedLibrary struct {
|
||||
byGenre map[string][]json.RawMessage
|
||||
}
|
||||
|
||||
type fakeGenreLibrary struct {
|
||||
*fakeCuratedLibrary
|
||||
genresByType map[string][]string
|
||||
}
|
||||
|
||||
func (f *fakeGenreLibrary) LibraryGenres(
|
||||
_ context.Context,
|
||||
itemTypes []string,
|
||||
_ int,
|
||||
) ([]string, error) {
|
||||
if len(itemTypes) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return f.genresByType[itemTypes[0]], nil
|
||||
}
|
||||
|
||||
type fakeForYouLibrary struct {
|
||||
items []json.RawMessage
|
||||
}
|
||||
@@ -125,10 +142,12 @@ func (f *fakeSource) NextUp(
|
||||
return &emby.ItemsResult{Items: f.nextUp}, nil
|
||||
}
|
||||
|
||||
func TestAbandonedShowsRequireAnEmbyNextUpAndRespectSeasonProgress(t *testing.T) {
|
||||
func TestAbandonedShowsRequireAnEmbyNextUpAndStayInTheFirstSeason(t *testing.T) {
|
||||
now := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC)
|
||||
catalogue := Decode([]json.RawMessage{
|
||||
json.RawMessage(`{"Id":"early","Name":"Early Show","Type":"Series"}`),
|
||||
json.RawMessage(`{"Id":"finished-first","Name":"Finished First","Type":"Series"}`),
|
||||
json.RawMessage(`{"Id":"unknown-season","Name":"Unknown Season","Type":"Series"}`),
|
||||
json.RawMessage(`{"Id":"deep","Name":"Deep Show","Type":"Series"}`),
|
||||
json.RawMessage(`{"Id":"complete","Name":"Complete Show","Type":"Series"}`),
|
||||
json.RawMessage(`{"Id":"recent","Name":"Recent Show","Type":"Series"}`),
|
||||
@@ -143,12 +162,18 @@ func TestAbandonedShowsRequireAnEmbyNextUpAndRespectSeasonProgress(t *testing.T)
|
||||
}
|
||||
sessions := []tracearr.Session{
|
||||
session("Early Show", 1, 2, 40),
|
||||
session("Finished First", 1, 10, 50),
|
||||
session("Deep Show", 2, 8, 60),
|
||||
session("Complete Show", 4, 10, 50),
|
||||
session("Recent Show", 1, 3, 5),
|
||||
}
|
||||
unknown := session("Unknown Season", 1, 2, 30)
|
||||
unknown.SeasonNumber = nil
|
||||
sessions = append(sessions, unknown)
|
||||
nextUp := Decode([]json.RawMessage{
|
||||
json.RawMessage(`{"Id":"early-next","Type":"Episode","SeriesId":"early","ParentIndexNumber":1,"IndexNumber":3,"RunTimeTicks":18000000000}`),
|
||||
json.RawMessage(`{"Id":"finished-next","Type":"Episode","SeriesId":"finished-first","ParentIndexNumber":2,"IndexNumber":1,"RunTimeTicks":18000000000}`),
|
||||
json.RawMessage(`{"Id":"unknown-next","Type":"Episode","SeriesId":"unknown-season","ParentIndexNumber":1,"IndexNumber":3,"RunTimeTicks":18000000000}`),
|
||||
json.RawMessage(`{"Id":"deep-next","Type":"Episode","SeriesId":"deep","ParentIndexNumber":3,"IndexNumber":1,"RunTimeTicks":36000000000}`),
|
||||
json.RawMessage(`{"Id":"recent-next","Type":"Episode","SeriesId":"recent","ParentIndexNumber":1,"IndexNumber":4}`),
|
||||
})
|
||||
@@ -160,20 +185,21 @@ func TestAbandonedShowsRequireAnEmbyNextUpAndRespectSeasonProgress(t *testing.T)
|
||||
compatibilityProfile{directCodecs: map[string]int{}, transcodeCodecs: map[string]int{}},
|
||||
now,
|
||||
)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("pickup candidates = %+v, want only two abandoned unfinished shows", got)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("pickup candidates = %+v, want three early-show abandonments", got)
|
||||
}
|
||||
if got[0].ItemID != "deep" || got[1].ItemID != "early" {
|
||||
t.Fatalf("pickup order = %q, %q; later-season commitment should lead", got[0].ItemID, got[1].ItemID)
|
||||
byID := map[string]PreparedCandidate{}
|
||||
for _, candidate := range got {
|
||||
byID[candidate.ItemID] = candidate
|
||||
}
|
||||
if got[0].RecommendationReason != "You made it through season 2 · season 3 is waiting" {
|
||||
t.Fatalf("later-season reason = %q", got[0].RecommendationReason)
|
||||
if byID["early"].RecommendationReason != "You left this in season 1 · pick it up again" {
|
||||
t.Fatalf("season-one reason = %q", byID["early"].RecommendationReason)
|
||||
}
|
||||
if got[1].RecommendationReason != "You left this in season 1 · pick it up again" {
|
||||
t.Fatalf("season-one reason = %q", got[1].RecommendationReason)
|
||||
if byID["finished-first"].RecommendationReason != "You finished season 1 · season 2 is waiting" {
|
||||
t.Fatalf("season-two waiting reason = %q", byID["finished-first"].RecommendationReason)
|
||||
}
|
||||
if got[0].RuntimeMinutes != 60 || got[1].RuntimeMinutes != 30 {
|
||||
t.Fatalf("next-episode runtimes = %d, %d", got[0].RuntimeMinutes, got[1].RuntimeMinutes)
|
||||
if byID["unknown-season"].RecommendationReason != "You left this in season 1 · pick it up again" {
|
||||
t.Fatalf("inferred first-season reason = %q", byID["unknown-season"].RecommendationReason)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,6 +535,28 @@ func TestRecommendationSessionsDiscardPrerolls(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpisodeEvidenceUsesParentSeriesMetadata(t *testing.T) {
|
||||
seriesRaw, _ := json.Marshal(map[string]any{
|
||||
"Id": "series-1", "Name": "The Show", "Type": "Series",
|
||||
"People": []map[string]string{{"Name": "Lead Actor", "Type": "Actor"}},
|
||||
"Studios": []map[string]string{{"Name": "Great Studio"}},
|
||||
"Genres": []string{"Drama"},
|
||||
})
|
||||
episodeRaw, _ := json.Marshal(map[string]any{
|
||||
"Id": "episode-1", "Name": "Pilot", "Type": "Episode",
|
||||
"SeriesId": "series-1", "SeriesName": "The Show",
|
||||
})
|
||||
series := Decode([]json.RawMessage{seriesRaw})[0]
|
||||
episode := Decode([]json.RawMessage{episodeRaw})[0]
|
||||
|
||||
got := newCatalogueIndex([]Item{series}).evidenceItem(episode)
|
||||
|
||||
if got.ID != "series-1" || len(got.People) != 1 ||
|
||||
len(got.Studios) != 1 || len(got.Genres) != 1 {
|
||||
t.Fatalf("episode evidence was not enriched from its series: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRowsDropsRowsShorterThanTheMinimum(t *testing.T) {
|
||||
source := &fakeSource{
|
||||
itemsByFilter: map[string][]json.RawMessage{
|
||||
@@ -731,6 +779,65 @@ func TestCuratedRowsFallBackToRatingForANewUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogueGenresExpandMovieAndShowShelves(t *testing.T) {
|
||||
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{}}
|
||||
engine := testEngine(source)
|
||||
engine.Library = &fakeGenreLibrary{
|
||||
fakeCuratedLibrary: &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{
|
||||
"Western": {
|
||||
raw("western-1", "Western One", "Movie", "Western"),
|
||||
raw("western-2", "Western Two", "Movie", "Western"),
|
||||
},
|
||||
"Reality": {
|
||||
raw("reality-1", "Reality One", "Series", "Reality"),
|
||||
raw("reality-2", "Reality Two", "Series", "Reality"),
|
||||
},
|
||||
}},
|
||||
genresByType: map[string][]string{
|
||||
"Movie": {"Western"},
|
||||
"Series": {"Reality"},
|
||||
},
|
||||
}
|
||||
|
||||
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "new"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, row := range rows {
|
||||
got[row.ID] = true
|
||||
}
|
||||
if !got["curated:movies:genre:western"] || !got["curated:shows:genre:reality"] {
|
||||
t.Fatalf("catalogue-backed genre rows missing: %v", rowTitles(rows))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiversifyRankedIsStableAndOnlyReordersWithinBands(t *testing.T) {
|
||||
items := make([]Item, 0, 12)
|
||||
for i := 0; i < 12; i++ {
|
||||
items = append(items, Item{ID: strconv.Itoa(i)})
|
||||
}
|
||||
first := diversifyRanked(items, "user:day", 5)
|
||||
second := diversifyRanked(items, "user:day", 5)
|
||||
if fmt.Sprint(first) != fmt.Sprint(second) {
|
||||
t.Fatal("same user/day seed must produce stable poster ordering")
|
||||
}
|
||||
for index, item := range first {
|
||||
if index/5 != mustAtoi(t, item.ID)/5 {
|
||||
t.Fatalf("item %s escaped its relevance band: %+v", item.ID, first)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustAtoi(t *testing.T, value string) int {
|
||||
t.Helper()
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// A failing similarity lookup is one dead row, not a dead home screen.
|
||||
func TestBuildRowsSurvivesASimilarLookupFailure(t *testing.T) {
|
||||
source := &fakeSource{
|
||||
|
||||
Reference in New Issue
Block a user