App v0.2.26 and gateway 0.1.20
Client: seek controls, Bazarr subtitle download and cast panel in the player; MDBList ratings strip; episode and schedule detail pages; series pace estimate; what's new panel; install-permission onboarding step; synced per-profile preferences; Emby outage banner. Gateway: rebuilt admin console (one fragment per page), preference history and restore, merged Continue Watching, Emby health probe, subtitle selection and Bazarr download, structured request logging with per-request identity, and embedded build version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2675e6d82b
commit
4a4df7a73c
@@ -0,0 +1,156 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user