package api import ( "encoding/json" "testing" "time" ) func continueRaw(t *testing.T, fields map[string]any) json.RawMessage { t.Helper() raw, err := json.Marshal(fields) if err != nil { t.Fatalf("marshal: %v", err) } return raw } func resumeItem(t *testing.T, id, seriesID, playedAt string) json.RawMessage { t.Helper() return continueRaw(t, map[string]any{ "Id": id, "SeriesId": seriesID, "Type": "Episode", "UserData": map[string]any{"LastPlayedDate": playedAt}, }) } func upNextItem(t *testing.T, id, seriesID string) json.RawMessage { t.Helper() return continueRaw(t, map[string]any{ "Id": id, "SeriesId": seriesID, "Type": "Episode", "UserData": map[string]any{}, }) } func mergedIDs(t *testing.T, items []json.RawMessage) []string { t.Helper() ids := make([]string, 0, len(items)) for _, raw := range items { var item struct { ID string `json:"Id"` } if err := json.Unmarshal(raw, &item); err != nil { t.Fatalf("unmarshal: %v", err) } ids = append(ids, item.ID) } return ids } func equalIDs(got, want []string) bool { if len(got) != len(want) { return false } for i := range got { if got[i] != want[i] { return false } } return true } // The case the merged row exists for: an episode was just finished, so it has left the // resume list, and the next one must be the first card rather than sitting in a second // row further down the launcher. func TestMergeContinueWatchingLeadsWithTheJustFinishedShow(t *testing.T) { resume := []json.RawMessage{ resumeItem(t, "film", "", "2026-08-01T20:00:00.0000000Z"), } nextUp := []json.RawMessage{upNextItem(t, "s2e5", "severance")} played := map[string]time.Time{ "severance": time.Date(2026, time.August, 6, 21, 0, 0, 0, time.UTC), } got := mergedIDs(t, mergeContinueWatching(resume, nextUp, played)) if want := []string{"s2e5", "film"}; !equalIDs(got, want) { t.Fatalf("merged = %v, want %v", got, want) } } // A show being watched right now must not appear twice — once part-way through an // episode and once as the episode after it. func TestMergeContinueWatchingPrefersTheResumeEpisodeOfASeries(t *testing.T) { resume := []json.RawMessage{resumeItem(t, "s1e3", "show", "2026-08-06T19:00:00Z")} nextUp := []json.RawMessage{ upNextItem(t, "s1e4", "show"), upNextItem(t, "other-e1", "other"), } played := map[string]time.Time{ "show": time.Date(2026, time.August, 6, 19, 0, 0, 0, time.UTC), "other": time.Date(2026, time.August, 2, 19, 0, 0, 0, time.UTC), } got := mergedIDs(t, mergeContinueWatching(resume, nextUp, played)) if want := []string{"s1e3", "other-e1"}; !equalIDs(got, want) { t.Fatalf("merged = %v, want %v", got, want) } } // Each source keeps its own order. Emby's ordering within a list is the useful part of // both, so this must be a merge of two sorted lists and never a sort of their union. func TestMergeContinueWatchingKeepsEachSourceOrder(t *testing.T) { resume := []json.RawMessage{ resumeItem(t, "r1", "", "2026-08-06T10:00:00Z"), resumeItem(t, "r2", "", "2026-08-04T10:00:00Z"), resumeItem(t, "r3", "", "2026-08-01T10:00:00Z"), } nextUp := []json.RawMessage{ upNextItem(t, "n1", "alpha"), upNextItem(t, "n2", "beta"), } played := map[string]time.Time{ "alpha": time.Date(2026, time.August, 5, 10, 0, 0, 0, time.UTC), "beta": time.Date(2026, time.August, 3, 10, 0, 0, 0, time.UTC), } got := mergedIDs(t, mergeContinueWatching(resume, nextUp, played)) if want := []string{"r1", "n1", "r2", "n2", "r3"}; !equalIDs(got, want) { t.Fatalf("merged = %v, want %v", got, want) } } // With no play dates to go on — the lookup failed, or Emby wrote something unparseable — // the row is the resume list followed by Next Up, which is the order the launcher had // when they were two rows. Nothing is dropped. func TestMergeContinueWatchingFallsBackToResumeFirst(t *testing.T) { resume := []json.RawMessage{ resumeItem(t, "r1", "", "not a date"), resumeItem(t, "r2", "", ""), } nextUp := []json.RawMessage{ upNextItem(t, "n1", "alpha"), upNextItem(t, "n2", "beta"), } got := mergedIDs(t, mergeContinueWatching(resume, nextUp, nil)) if want := []string{"r1", "r2", "n1", "n2"}; !equalIDs(got, want) { t.Fatalf("merged = %v, want %v", got, want) } } func TestParsePlayedAtAcceptsEmbyTimestamps(t *testing.T) { for _, value := range []string{ "2026-08-06T21:04:05.1234567Z", "2026-08-06T21:04:05Z", "2026-08-06T21:04:05+12:00", "2026-08-06T21:04:05", } { if _, ok := parsePlayedAt(value); !ok { t.Errorf("parsePlayedAt(%q) failed", value) } } for _, value := range []string{"", " ", "0001-01-01", "yesterday"} { if _, ok := parsePlayedAt(value); ok { t.Errorf("parsePlayedAt(%q) unexpectedly succeeded", value) } } }