2026-08-06 22:33:56 +12:00
package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"testing"
"time"
)
2026-08-14 09:40:03 +12:00
// A canned API for a local React console preview with no gateway behind it.
//
// In one terminal:
2026-08-06 22:33:56 +12:00
//
// cd server && ADMIN_PREVIEW=1 go test ./internal/api -run TestAdminPreview -timeout 0
//
2026-08-14 09:40:03 +12:00
// In another:
2026-08-06 22:33:56 +12:00
//
2026-08-14 09:40:03 +12:00
// cd admin-ui && npm run dev
//
// Vite owns the React shell at http://127.0.0.1:5180/admin/ and proxies its /admin/api/*
// requests here. The preview therefore fakes only data, leaving the same built console
// responsible for every route and asset it will own in deployment. It skips unless
// ADMIN_PREVIEW is set, so an ordinary `go test ./...` never blocks on this server.
2026-08-06 22:33:56 +12:00
func TestAdminPreview ( t * testing . T ) {
if os . Getenv ( "ADMIN_PREVIEW" ) == "" {
t . Skip ( "set ADMIN_PREVIEW=1 to serve the console locally" )
}
address := os . Getenv ( "ADMIN_PREVIEW_ADDR" )
if address == "" {
address = "127.0.0.1:7777"
}
mux := http . NewServeMux ()
mux . HandleFunc ( "/admin/api/" , func ( w http . ResponseWriter , r * http . Request ) {
2026-08-14 09:40:03 +12:00
body , ok := adminPreviewData ()[ r . URL . Path ]
2026-08-06 22:33:56 +12:00
if ! ok {
body = map [ string ] any {}
}
w . Header (). Set ( "Content-Type" , "application/json" )
_ = json . NewEncoder ( w ). Encode ( body )
})
2026-08-14 09:40:03 +12:00
fmt . Printf ( "\ncanned admin API → http://%s; open Vite at http://127.0.0.1:5180/admin/ (ctrl-c to stop)\n\n" , address )
2026-08-06 22:33:56 +12:00
if err := http . ListenAndServe ( address , mux ); err != nil {
t . Fatal ( err )
}
}
// Enough of a household for every page to draw itself: a library, two viewers, two
// televisions, a run that succeeded and one that did not.
func adminPreviewData () map [ string ] any {
now := time . Now (). UTC ()
stamp := func ( d time . Duration ) string { return now . Add ( - d ). Format ( time . RFC3339 ) }
// One set of each presence state, because the dot is three-valued and a preview that
// only ever showed green would not be a preview of it.
build := func ( version string , age time . Duration ) map [ string ] any {
return map [ string ] any { "version" : version ,
"firstSeen" : stamp ( age + 24 * time . Hour ), "lastSeen" : stamp ( age )}
}
clients := [] any {
map [ string ] any { "deviceId" : "tv-1" , "deviceName" : "Living room" , "username" : "matt" ,
"version" : "0.1.74" , "lastSeen" : stamp ( 30 * time . Second ),
"capabilities" : [] string { "server_features_v1" , "install_permission_v1" },
"versions" : [] any { build ( "0.1.74" , 30 * time . Second ), build ( "0.1.72" , 9 * 24 * time . Hour ),
build ( "0.1.68" , 40 * 24 * time . Hour )}},
map [ string ] any { "deviceId" : "tv-2" , "deviceName" : "Bedroom" , "username" : "sam" ,
"version" : "0.1.71" , "lastSeen" : stamp ( 2 * 24 * time . Hour ), "capabilities" : [] string {},
"versions" : [] any { build ( "0.1.71" , 2 * 24 * time . Hour )}},
map [ string ] any { "deviceId" : "tv-3" , "deviceName" : "Spare room" , "username" : "sam" ,
"version" : "0.1.62" , "lastSeen" : stamp ( 70 * 24 * time . Hour ),
"capabilities" : [] string { "server_features_v1" },
"versions" : [] any { build ( "0.1.62" , 70 * 24 * time . Hour )}},
}
features := [] any {
map [ string ] any { "key" : "subtitle_download" , "label" : "Download subtitles" ,
"description" : "Fetch a missing subtitle from the player." , "enabled" : true , "source" : "default" },
map [ string ] any { "key" : "install_permission_prompt" , "label" : "Install permission step" ,
"description" : "Show the sideloading permission screen during setup." , "enabled" : true , "source" : "override" },
map [ string ] any { "key" : "media_requests" , "label" : "Request it" ,
"description" : "Let viewers ask for a title the library does not have." , "enabled" : false , "source" : "default" },
}
status := map [ string ] any {
"serverVersion" : "preview" ,
"library" : map [ string ] any { "total" : 18422 , "lastSynced" : stamp ( 42 * time . Minute ),
"byType" : map [ string ] any { "Movie" : 4210 , "Series" : 612 , "Episode" : 13600 }},
"requestUsers" : [] any { "matt" , "sam" , "alex" , "ros" },
"clients" : clients ,
"features" : map [ string ] any { "revision" : 12 , "safeMode" : false , "features" : features },
"maintenance" : map [ string ] any { "enabled" : false , "message" : "" },
"updatePolicy" : map [ string ] any { "enabled" : true , "latestVersion" : "0.1.74" ,
"minimumVersion" : "" , "downloadUrl" : "https://example.invalid/memby.apk" },
"playbackPolicy" : map [ string ] any { "prerollEnabled" : true , "prerollDurationMs" : 6500 },
"syncRunning" : false ,
"syncEvery" : "1h" ,
"radarrReady" : true ,
"sonarrReady" : false ,
"mdblist" : map [ string ] any { "enabled" : true , "cachedTitles" : 3121 , "staleTitles" : 44 ,
"apiKeyConfigured" : true , "sources" : [] any {
map [ string ] any { "key" : "imdb" , "label" : "IMDb" , "enabled" : true },
map [ string ] any { "key" : "tmdb" , "label" : "TMDb" , "enabled" : true },
map [ string ] any { "key" : "tomatoes" , "label" : "Rotten Tomatoes" , "enabled" : false },
}},
"forYou" : map [ string ] any { "candidates" : 2400 , "profiles" : 5 , "tracearrSessions" : 812 ,
"lastFullImport" : stamp ( 30 * time . Hour )},
"forYouRunning" : false ,
"runs" : [] any {
map [ string ] any { "startedAt" : stamp ( 42 * time . Minute ), "kind" : "incremental" ,
"status" : "success" , "itemsUpserted" : 24 },
map [ string ] any { "startedAt" : stamp ( 102 * time . Minute ), "kind" : "incremental" ,
"status" : "running" , "itemsUpserted" : 0 },
map [ string ] any { "startedAt" : stamp ( 162 * time . Minute ), "kind" : "full" ,
"status" : "failed" , "itemsUpserted" : 0 , "error" : "dial tcp: connection refused" },
},
}
return map [ string ] any {
2026-08-19 14:25:44 +12:00
"/admin/api/status" : status ,
"/admin/api/runtime" : adminPreviewRuntime ( now ),
"/admin/api/runtime/goroutines" : adminPreviewGoroutines ( now ),
2026-08-06 22:33:56 +12:00
"/admin/api/accounts" : map [ string ] any {
"accounts" : [] any {
map [ string ] any { "userId" : "u-1" , "username" : "matt" , "onboardingCompleted" : true ,
"preferencesRevision" : 12 , "devices" : [] any { clients [ 0 ]}},
map [ string ] any { "userId" : "u-2" , "username" : "sam jones" , "onboardingCompleted" : false ,
"preferencesRevision" : 3 , "devices" : [] any { clients [ 1 ], clients [ 2 ]}},
},
"preferenceCatalogue" : [] any {},
},
"/admin/api/events" : map [ string ] any { "next" : 1 , "hasMore" : false , "dropped" : 0 , "events" : [] any {
map [ string ] any { "occurredAt" : stamp ( 9 * time . Second ), "level" : "INFO" , "message" : "playback started" ,
"attributes" : map [ string ] any { "component" : "playback" , "user" : "matt" , "device" : "Living room" ,
"client" : "0.1.74" , "title" : "Arrival" }},
map [ string ] any { "occurredAt" : stamp ( 24 * time . Second ), "level" : "DEBUG" , "message" : "search" ,
"attributes" : map [ string ] any { "component" : "search" , "user" : "sam" , "query" : "arr" }},
map [ string ] any { "occurredAt" : stamp ( 51 * time . Second ), "level" : "WARN" , "message" : "emby slow to answer" ,
"attributes" : map [ string ] any { "component" : "emby" , "duration" : "4.2s" , "path" : "/Items" }},
map [ string ] any { "occurredAt" : stamp ( 2 * time . Minute ), "level" : "ERROR" , "message" : "library sync failed" ,
"attributes" : map [ string ] any { "component" : "library" , "error" : "dial tcp: connection refused" }},
}},
"/admin/api/engagement" : map [ string ] any { "rows" : [] any {
map [ string ] any { "rowId" : "continue" , "title" : "Continue watching" , "impressions" : 1840 ,
"focuses" : 620 , "selections" : 210 , "averageDwellMs" : 2400 },
map [ string ] any { "rowId" : "favorites" , "title" : "Favourites" , "impressions" : 1610 ,
"focuses" : 300 , "selections" : 74 , "averageDwellMs" : 1800 },
}},
2026-08-10 20:39:26 +12:00
"/admin/api/journeys" : map [ string ] any {
"days" : 30 , "retentionDays" : 90 ,
"stats" : map [ string ] any { "events" : 184 , "journeys" : 28 , "viewers" : 2 ,
"completed" : 21 , "abandoned" : 5 , "active" : 2 , "averageSteps" : 6.6 ,
"averageTimeMs" : 1140000 , "completionRate" : 0.81 },
"users" : [] any {
map [ string ] any { "userId" : "u-1" , "username" : "matt" , "events" : 120 , "journeys" : 18 , "lastActiveAt" : stamp ( 9 * time . Minute )},
map [ string ] any { "userId" : "u-2" , "username" : "sam" , "events" : 64 , "journeys" : 10 , "lastActiveAt" : stamp ( 3 * time . Hour )},
},
"features" : [] any {
map [ string ] any { "feature" : "playback" , "uses" : 31 , "lastUsedAt" : stamp ( 9 * time . Minute )},
map [ string ] any { "feature" : "search" , "uses" : 12 , "lastUsedAt" : stamp ( 3 * time . Hour )},
},
"actions" : [] any {
map [ string ] any { "category" : "content" , "action" : "open" , "events" : 44 , "journeys" : 22 },
map [ string ] any { "category" : "playback" , "action" : "start" , "events" : 24 , "journeys" : 18 },
},
"paths" : [] any {
map [ string ] any { "from" : "home" , "to" : "details" , "count" : 31 },
map [ string ] any { "from" : "details" , "to" : "playback" , "count" : 18 },
},
},
2026-08-28 23:00:02 +12:00
"/admin/api/requests" : map [ string ] any { "requests" : [] any {
map [ string ] any { "userId" : "u-1" , "username" : "matt" , "mediaType" : "movie" ,
"foreignId" : 693134 , "title" : "Dune: Part Two" , "year" : 2024 ,
"requestedAt" : stamp ( 2 * time . Hour ), "status" : "downloading" ,
"statusLabel" : "Downloading" , "statusDetail" : "About 12 minutes left" ,
"progress" : 64 },
map [ string ] any { "userId" : "u-2" , "username" : "sam" , "mediaType" : "series" ,
"foreignId" : 371980 , "title" : "Severance" , "year" : 2022 ,
"requestedAt" : stamp ( 30 * time . Hour ), "status" : "available" ,
"statusLabel" : "Ready to watch" , "statusDetail" : "In your library" ,
"embyItemId" : "abc123" },
map [ string ] any { "userId" : "u-1" , "username" : "matt" , "mediaType" : "movie" ,
"foreignId" : 1032823 , "title" : "Memory" , "year" : 2023 ,
"requestedAt" : stamp ( 9 * 24 * time . Hour ), "status" : "requested" ,
"statusLabel" : "Requested" , "statusDetail" : "Searching for a copy" },
}},
2026-08-09 08:25:50 +12:00
// A prefix among the terms and an unattributed row in the log, because both are
// ordinary here and a preview showing neither would not be a preview of this page.
"/admin/api/searches" : map [ string ] any {
"days" : 7 , "retentionDays" : searchWindowDays ,
"termLimit" : searchTermLimit , "eventLimit" : searchEventLimit ,
"totals" : map [ string ] any { "searches" : 214 , "queries" : 96 , "viewers" : 2 },
"terms" : [] any {
map [ string ] any { "query" : "severance" , "searches" : 18 , "viewers" : 2 , "lastAt" : stamp ( 40 * time . Minute )},
map [ string ] any { "query" : "dune" , "searches" : 11 , "viewers" : 1 , "lastAt" : stamp ( 3 * time . Hour )},
map [ string ] any { "query" : "sev" , "searches" : 9 , "viewers" : 2 , "lastAt" : stamp ( 40 * time . Minute )},
map [ string ] any { "query" : "the bear" , "searches" : 4 , "viewers" : 1 , "lastAt" : stamp ( 2 * 24 * time . Hour )},
},
"recent" : [] any {
map [ string ] any { "occurredAt" : stamp ( 40 * time . Minute ), "userId" : "u-1" ,
"username" : "matt" , "query" : "severance" },
map [ string ] any { "occurredAt" : stamp ( 3 * time . Hour ), "userId" : "u-2" ,
"username" : "sam" , "query" : "dune" },
map [ string ] any { "occurredAt" : stamp ( 26 * time . Hour ), "userId" : "u-9" ,
"username" : "" , "query" : "the bear" },
},
},
2026-08-06 22:33:56 +12:00
}
}
2026-08-19 14:25:44 +12:00
// A gateway with a mild, deliberate upward drift in both goroutines and heap, so the
// preview shows what a trend that has earned a "watch" actually looks like — the flat case
// is the one that draws itself.
func adminPreviewRuntime ( now time . Time ) map [ string ] any {
const points = 90
samples := make ([] any , 0 , points )
for index := 0 ; index < points ; index ++ {
at := now . Add ( - time . Duration ( points - 1 - index ) * time . Minute )
samples = append ( samples , map [ string ] any {
"at" : at . Format ( time . RFC3339 ),
"goroutines" : 62 + index / 4 + index % 3 ,
"heapInuse" : 33_000_000 + index * 90_000 ,
"sys" : 92_274_688 ,
"numGc" : 220 + index ,
})
}
trend := func ( direction string , perHour , first , latest , low , high float64 ) map [ string ] any {
return map [ string ] any {
"direction" : direction , "perHour" : perHour , "first" : first , "latest" : latest ,
"min" : low , "max" : high , "spanSeconds" : float64 (( points - 1 ) * 60 ), "points" : points ,
}
}
return map [ string ] any {
"at" : now . Format ( time . RFC3339 ), "uptimeSeconds" : 61_240 .0 ,
"goroutines" : 84 , "gomaxprocs" : 4 , "threads" : 17 , "goVersion" : "go1.26" ,
"memory" : map [ string ] any {
"heapAlloc" : 39_000_000 , "heapInuse" : 41_943_040 , "heapIdle" : 24_000_000 ,
"heapReleased" : 8_000_000 , "stackInuse" : 1_800_000 , "sys" : 223_100_000 ,
"nextGc" : 62_914_560 , "numGc" : 311 , "pauseTotalMs" : 812.4 , "pauseRecentMs" : 1.9 ,
"memoryLimit" : 402_653_184 , "configuredLimit" : "384MiB" ,
"heapShare" : 0.104 , "gcPerHour" : 60.0 ,
},
"workers" : [] any {
map [ string ] any { "name" : "HTTP listener" , "component" : "HTTP server" ,
"state" : "running" , "started" : now . Add ( - 17 * time . Hour ). Format ( time . RFC3339 ), "starts" : 1 },
map [ string ] any { "name" : "Emby health probe" , "component" : "Emby" ,
"state" : "running" , "started" : now . Add ( - 17 * time . Hour ). Format ( time . RFC3339 ), "starts" : 1 },
map [ string ] any { "name" : "Library ingest" , "component" : "Library sync" ,
"state" : "running" , "started" : now . Add ( - 17 * time . Hour ). Format ( time . RFC3339 ), "starts" : 1 },
map [ string ] any { "name" : "Task scheduler" , "component" : "Scheduled jobs" ,
"state" : "running" , "started" : now . Add ( - 17 * time . Hour ). Format ( time . RFC3339 ), "starts" : 1 },
map [ string ] any { "name" : "Startup library sync" , "component" : "Library sync" ,
"state" : "finished" , "started" : now . Add ( - 17 * time . Hour ). Format ( time . RFC3339 ),
"stopped" : now . Add ( - 17 * time . Hour ). Add ( 4 * time . Minute ). Format ( time . RFC3339 ), "starts" : 1 },
},
"process" : map [ string ] any { "pid" : 1 , "cpuSeconds" : 431.8 , "cpuPercent" : 3.4 ,
"cpuKnown" : true , "openFiles" : 41 , "openSockets" : 22 , "fileLimit" : 1048576 ,
"filesKnown" : true },
"samples" : samples ,
"goroutineTrend" : trend ( "rising" , 14 , 62 , 84 , 62 , 86 ),
"heapTrend" : trend ( "rising" , 5_400_000 , 33_000_000 , 41_943_040 , 33_000_000 , 41_943_040 ),
"reservedTrend" : trend ( "steady" , 0 , 92_274_688 , 92_274_688 , 92_274_688 , 92_274_688 ),
"sampleEverySeconds" : 60.0 ,
"health" : map [ string ] any {
"level" : "watch" ,
"summary" : "2 things to look at, in Goroutines and Memory." ,
"areas" : [] any { "Goroutines" , "Memory" },
"notes" : [] any {
map [ string ] any { "level" : "watch" , "area" : "Memory" ,
"message" : "Heap in use has risen by about 5.1 MB an hour over the last 1.5 hours. If it does not fall after a collection, something is holding on to it." },
map [ string ] any { "level" : "watch" , "area" : "Goroutines" ,
"message" : "Goroutines have risen by about 14 an hour over the last 1.5 hours, from 62 to 84. Open the breakdown to see which component they belong to." },
},
},
}
}
func adminPreviewGoroutines ( now time . Time ) map [ string ] any {
category := func ( key , label , description string , count int , states [] any ) map [ string ] any {
return map [ string ] any { "category" : key , "label" : label , "description" : description ,
"count" : count , "states" : states }
}
state := func ( name string , count int ) any {
return map [ string ] any { "state" : name , "count" : count }
}
return map [ string ] any {
"at" : now . Format ( time . RFC3339 ), "total" : 84 , "collectedInMs" : 6.2 , "dumpBytes" : 98_304 ,
"categories" : [] any {
category ( "running" , "Running" , "On a processor now, or queued for one." , 3 ,
[] any { state ( "running" , 2 ), state ( "runnable" , 1 )}),
category ( "io" , "Network / I/O" ,
"Waiting on a network read or write — Emby, Postgres, Redis, or a television." , 26 ,
[] any { state ( "IO wait" , 26 )}),
category ( "waiting" , "Waiting / idle" ,
"Parked waiting for work or for a lock. Idle, and costing almost nothing." , 41 ,
[] any { state ( "select" , 29 ), state ( "chan receive" , 9 ), state ( "semacquire" , 3 )}),
category ( "timers" , "Timers / scheduled work" , "Asleep until a scheduled time." , 8 ,
[] any { state ( "sleep" , 8 )}),
category ( "runtime" , "Go runtime / collection" ,
"The Go runtime's own housekeeping. This handful is always present." , 6 ,
[] any { state ( "GC worker (idle)" , 4 ), state ( "finalizer wait" , 1 ), state ( "force gc (idle)" , 1 )}),
},
"components" : [] any {
map [ string ] any { "component" : "HTTP server" , "count" : 24 , "longestWaitMinutes" : 3 },
map [ string ] any { "component" : "Database pool" , "count" : 18 , "longestWaitMinutes" : 940 },
map [ string ] any { "component" : "Emby" , "count" : 11 , "longestWaitMinutes" : 2 },
map [ string ] any { "component" : "Gateway API" , "count" : 9 , "longestWaitMinutes" : 0 },
map [ string ] any { "component" : "Redis" , "count" : 8 , "longestWaitMinutes" : 940 },
map [ string ] any { "component" : "Library sync" , "count" : 6 , "longestWaitMinutes" : 940 },
map [ string ] any { "component" : "Go runtime" , "count" : 6 , "longestWaitMinutes" : 0 },
map [ string ] any { "component" : "Scheduled jobs" , "count" : 2 , "longestWaitMinutes" : 940 },
},
"groups" : [] any {
map [ string ] any { "count" : 24 , "component" : "HTTP server" , "category" : "io" ,
"state" : "IO wait" , "function" : "net/http.(*conn).serve" ,
"file" : "/usr/local/go/src/net/http/server.go:2092" ,
"createdBy" : "net/http.(*Server).Serve" , "longestWaitMinutes" : 3 },
map [ string ] any { "count" : 18 , "component" : "Database pool" , "category" : "waiting" ,
"state" : "select" , "function" : "github.com/jackc/puddle/v2.(*Pool).acquire" ,
"file" : "/root/go/pkg/mod/github.com/jackc/puddle/v2/pool.go:481" ,
"createdBy" : "github.com/jackc/pgx/v5/pgxpool.NewWithConfig" , "longestWaitMinutes" : 940 },
map [ string ] any { "count" : 6 , "component" : "Library sync" , "category" : "waiting" ,
"state" : "chan receive" , "function" : "memby/server/internal/library.(*Ingester).Run" ,
"file" : "/app/internal/library/ingest.go:118" ,
"createdBy" : "main.run" , "longestWaitMinutes" : 940 },
},
"groupsTotal" : 21 ,
}
}