Publish current app and server

This commit is contained in:
ponzischeme89
2026-08-02 22:10:19 +12:00
parent a265636139
commit 1ed180c739
203 changed files with 23933 additions and 2788 deletions
+118 -11
View File
@@ -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{