157 lines
4.7 KiB
Go
157 lines
4.7 KiB
Go
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)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|