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:
ponzischeme89
2026-08-06 22:33:56 +12:00
co-authored by Claude Opus 5
parent 2675e6d82b
commit 4a4df7a73c
257 changed files with 24868 additions and 3108 deletions
@@ -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)
}
}
}