From 119b86ce4d766a951fa99ba7363a05b13ab2e806 Mon Sep 17 00:00:00 2001 From: Wessie Date: Sun, 25 Feb 2024 17:01:04 +0000 Subject: [PATCH] radio: use Listeners type more consistantly website/api/v1: send no-buffering header for the SSE endpoint website/api/v1: add listener sending, this is currently every value returned by the stream of data. That is currently updated by the non-working loadbalancer sending 0s every 10 seconds. mocks: regenerate for Listeners type usage rpc: fix for Listeners type usage ircbot: use StreamValue for CurrentTrack website: use New functions for the public and admin States, this is to avoid forgetting to set a new field when one is added. website/public: use StreamValue on the home page rendering website/api/php: use StreamValue for most of the api endpoints --- assets/js/radio.js | 9 +++- ircbot/commands.go | 6 +-- manager/api.go | 6 +-- manager/main.go | 2 +- mocks/radio.gen.go | 14 +++--- mocks/templates.gen.go | 96 +++++++++++++++++++++++++++++++++++++ radio.go | 6 +-- rpc/helpers.go | 2 +- rpc/server.go | 2 +- storage/mariadb/track.go | 2 +- storage/mariadb/user.go | 2 +- templates/default/home.tmpl | 7 +-- website/admin/router.go | 23 +++++++++ website/api/php/api.go | 42 ++++++++-------- website/api/v1/sse.go | 25 ++++++++++ website/main.go | 40 ++++++++-------- website/public/home.go | 7 +-- website/public/state.go | 36 +++++++++++--- 18 files changed, 247 insertions(+), 80 deletions(-) diff --git a/assets/js/radio.js b/assets/js/radio.js index 89723d14..3a1a956d 100644 --- a/assets/js/radio.js +++ b/assets/js/radio.js @@ -15,6 +15,12 @@ function now() { htmx.createEventSource = function (url) { console.log(url); es = new EventSource(url); + es.addEventListener("streamer", (event) => { + console.log(event.data); + }); + es.addEventListener("listeners", (event) => { + console.log(event.data, Date.now()); + }); es.addEventListener("metadata", (event) => { console.log(event.data); }); @@ -61,9 +67,10 @@ htmx.on('htmx:load', (event) => { console.log("creating stream player"); stream = new Stream(initStream.getElementsByTagName("source")[0].src); } - if (stream && stream.button()) { + if (stream && stream.button() && !stream.button().dataset.hasclick) { console.log("registering stream play/stop button handler"); stream.button().onclick = stream.playStop; + stream.button().dataset.hasclick = true; if (stream.audio && !stream.audio.paused) { stream.setButton("Stop Stream"); } diff --git a/ircbot/commands.go b/ircbot/commands.go index a4b15cd3..e24fe53c 100644 --- a/ircbot/commands.go +++ b/ircbot/commands.go @@ -8,7 +8,6 @@ import ( radio "github.com/R-a-dio/valkyrie" "github.com/R-a-dio/valkyrie/errors" - "github.com/R-a-dio/valkyrie/util" "github.com/lrstanley/girc" "github.com/rs/zerolog" ) @@ -231,10 +230,7 @@ func (e Event) ArgumentTrack(key string) (*radio.Song, error) { func (e Event) CurrentTrack() (*radio.Song, error) { const op errors.Op = "irc/Event.CurrentTrack" - status, err := util.OneOff(e.Ctx, e.Bot.Manager.CurrentStatus) - if err != nil { - return nil, errors.E(op, err) - } + status := e.Bot.StatusValue.Latest() song, err := e.Storage.Song(e.Ctx).FromMetadata(status.Song.Metadata) if err != nil { diff --git a/manager/api.go b/manager/api.go index 482ffc49..43f61fc9 100644 --- a/manager/api.go +++ b/manager/api.go @@ -151,7 +151,7 @@ func (m *Manager) UpdateSong(ctx context.Context, update *radio.SongUpdate) erro var prev radio.Song var prevInfo radio.SongInfo - var listenerCountDiff *int + var listenerCountDiff *radio.Listeners // critical section to swap our new song with the previous one m.mu.Lock() @@ -162,7 +162,7 @@ func (m *Manager) UpdateSong(ctx context.Context, update *radio.SongUpdate) erro // record listener count and calculate the difference between start/end of song currentListenerCount := m.status.Listeners // update and retrieve listener count of start of song - var startListenerCount int + var startListenerCount radio.Listeners startListenerCount, m.songStartListenerCount = m.songStartListenerCount, currentListenerCount m.mu.Unlock() @@ -239,7 +239,7 @@ func (m *Manager) UpdateListeners(ctx context.Context, listeners radio.Listeners m.listenerStream.Send(listeners) m.mu.Lock() - m.status.Listeners = int(listeners) + m.status.Listeners = listeners m.mu.Unlock() return nil } diff --git a/manager/main.go b/manager/main.go index 4018a97e..92f500b3 100644 --- a/manager/main.go +++ b/manager/main.go @@ -98,7 +98,7 @@ type Manager struct { status radio.Status autoStreamerTimer *time.Timer // listener count at the start of a song - songStartListenerCount int + songStartListenerCount radio.Listeners // streaming support userStream *eventstream.EventStream[radio.User] diff --git a/mocks/radio.gen.go b/mocks/radio.gen.go index 106d9192..e71726f2 100644 --- a/mocks/radio.gen.go +++ b/mocks/radio.gen.go @@ -1948,7 +1948,7 @@ var _ radio.UserStorage = &UserStorageMock{} // PermissionsFunc: func() ([]radio.UserPermission, error) { // panic("mock out the Permissions method") // }, -// RecordListenersFunc: func(n int, user radio.User) error { +// RecordListenersFunc: func(n int64, user radio.User) error { // panic("mock out the RecordListeners method") // }, // UpdateUserFunc: func(user radio.User) (radio.User, error) { @@ -1980,7 +1980,7 @@ type UserStorageMock struct { PermissionsFunc func() ([]radio.UserPermission, error) // RecordListenersFunc mocks the RecordListeners method. - RecordListenersFunc func(n int, user radio.User) error + RecordListenersFunc func(n int64, user radio.User) error // UpdateUserFunc mocks the UpdateUser method. UpdateUserFunc func(user radio.User) (radio.User, error) @@ -2016,7 +2016,7 @@ type UserStorageMock struct { // RecordListeners holds details about calls to the RecordListeners method. RecordListeners []struct { // N is the n argument value. - N int + N int64 // User is the user argument value. User radio.User } @@ -2219,12 +2219,12 @@ func (mock *UserStorageMock) PermissionsCalls() []struct { } // RecordListeners calls RecordListenersFunc. -func (mock *UserStorageMock) RecordListeners(n int, user radio.User) error { +func (mock *UserStorageMock) RecordListeners(n int64, user radio.User) error { if mock.RecordListenersFunc == nil { panic("UserStorageMock.RecordListenersFunc: method is nil but UserStorage.RecordListeners was just called") } callInfo := struct { - N int + N int64 User radio.User }{ N: n, @@ -2241,11 +2241,11 @@ func (mock *UserStorageMock) RecordListeners(n int, user radio.User) error { // // len(mockedUserStorage.RecordListenersCalls()) func (mock *UserStorageMock) RecordListenersCalls() []struct { - N int + N int64 User radio.User } { var calls []struct { - N int + N int64 User radio.User } mock.lockRecordListeners.RLock() diff --git a/mocks/templates.gen.go b/mocks/templates.gen.go index 680c2bfb..6f8c04ab 100644 --- a/mocks/templates.gen.go +++ b/mocks/templates.gen.go @@ -244,3 +244,99 @@ func (mock *ExecutorMock) WithCalls() []struct { mock.lockWith.RUnlock() return calls } + +// Ensure, that TemplateSelectableMock does implement templates.TemplateSelectable. +// If this is not the case, regenerate this file with moq. +var _ templates.TemplateSelectable = &TemplateSelectableMock{} + +// TemplateSelectableMock is a mock implementation of templates.TemplateSelectable. +// +// func TestSomethingThatUsesTemplateSelectable(t *testing.T) { +// +// // make and configure a mocked templates.TemplateSelectable +// mockedTemplateSelectable := &TemplateSelectableMock{ +// TemplateBundleFunc: func() string { +// panic("mock out the TemplateBundle method") +// }, +// TemplateNameFunc: func() string { +// panic("mock out the TemplateName method") +// }, +// } +// +// // use mockedTemplateSelectable in code that requires templates.TemplateSelectable +// // and then make assertions. +// +// } +type TemplateSelectableMock struct { + // TemplateBundleFunc mocks the TemplateBundle method. + TemplateBundleFunc func() string + + // TemplateNameFunc mocks the TemplateName method. + TemplateNameFunc func() string + + // calls tracks calls to the methods. + calls struct { + // TemplateBundle holds details about calls to the TemplateBundle method. + TemplateBundle []struct { + } + // TemplateName holds details about calls to the TemplateName method. + TemplateName []struct { + } + } + lockTemplateBundle sync.RWMutex + lockTemplateName sync.RWMutex +} + +// TemplateBundle calls TemplateBundleFunc. +func (mock *TemplateSelectableMock) TemplateBundle() string { + if mock.TemplateBundleFunc == nil { + panic("TemplateSelectableMock.TemplateBundleFunc: method is nil but TemplateSelectable.TemplateBundle was just called") + } + callInfo := struct { + }{} + mock.lockTemplateBundle.Lock() + mock.calls.TemplateBundle = append(mock.calls.TemplateBundle, callInfo) + mock.lockTemplateBundle.Unlock() + return mock.TemplateBundleFunc() +} + +// TemplateBundleCalls gets all the calls that were made to TemplateBundle. +// Check the length with: +// +// len(mockedTemplateSelectable.TemplateBundleCalls()) +func (mock *TemplateSelectableMock) TemplateBundleCalls() []struct { +} { + var calls []struct { + } + mock.lockTemplateBundle.RLock() + calls = mock.calls.TemplateBundle + mock.lockTemplateBundle.RUnlock() + return calls +} + +// TemplateName calls TemplateNameFunc. +func (mock *TemplateSelectableMock) TemplateName() string { + if mock.TemplateNameFunc == nil { + panic("TemplateSelectableMock.TemplateNameFunc: method is nil but TemplateSelectable.TemplateName was just called") + } + callInfo := struct { + }{} + mock.lockTemplateName.Lock() + mock.calls.TemplateName = append(mock.calls.TemplateName, callInfo) + mock.lockTemplateName.Unlock() + return mock.TemplateNameFunc() +} + +// TemplateNameCalls gets all the calls that were made to TemplateName. +// Check the length with: +// +// len(mockedTemplateSelectable.TemplateNameCalls()) +func (mock *TemplateSelectableMock) TemplateNameCalls() []struct { +} { + var calls []struct { + } + mock.lockTemplateName.RLock() + calls = mock.calls.TemplateName + mock.lockTemplateName.RUnlock() + return calls +} diff --git a/radio.go b/radio.go index 38261a0b..e70bdc7c 100644 --- a/radio.go +++ b/radio.go @@ -60,7 +60,7 @@ type Status struct { // StreamerName is the name given to us by the user that is streaming StreamerName string // Listeners is the current amount of stream listeners - Listeners int + Listeners Listeners // Thread is an URL to a third-party platform related to the current stream Thread string // RequestsEnabled tells you if requests to the automated streamer are enabled @@ -646,7 +646,7 @@ type SongStorage interface { PlayedCount(Song) (int64, error) // AddPlay adds a play to the song. If present, ldiff is the difference in amount // of listeners between song-start and song-end - AddPlay(song Song, ldiff *int) error + AddPlay(song Song, ldiff *Listeners) error // FavoriteCount returns the amount of users that have added this song to // their favorite list @@ -747,7 +747,7 @@ type UserStorage interface { // Permissions returns all available permissions Permissions() ([]UserPermission, error) // RecordListeners records a history of listener count - RecordListeners(int, User) error + RecordListeners(Listeners, User) error } // StatusStorageService is a service able to supply a StatusStorage diff --git a/rpc/helpers.go b/rpc/helpers.go index 259d6ed6..6ccedd27 100644 --- a/rpc/helpers.go +++ b/rpc/helpers.go @@ -51,7 +51,7 @@ func fromProtoStatus(s *StatusResponse) radio.Status { User: fromProtoUser(s.User), Song: fromProtoSong(s.Song), SongInfo: fromProtoSongInfo(s.Info), - Listeners: int(s.ListenerInfo.Listeners), + Listeners: s.ListenerInfo.Listeners, Thread: s.Thread, RequestsEnabled: s.StreamerConfig.RequestsEnabled, StreamerName: s.StreamerName, diff --git a/rpc/server.go b/rpc/server.go index 897bb587..b57bea18 100644 --- a/rpc/server.go +++ b/rpc/server.go @@ -32,7 +32,7 @@ func (as AnnouncerShim) AnnounceSong(ctx context.Context, a *SongAnnouncement) ( err := as.announcer.AnnounceSong(ctx, radio.Status{ Song: fromProtoSong(a.Song), SongInfo: fromProtoSongInfo(a.Info), - Listeners: int(a.ListenerInfo.Listeners), + Listeners: a.ListenerInfo.Listeners, }) return new(emptypb.Empty), err } diff --git a/storage/mariadb/track.go b/storage/mariadb/track.go index 63a0a204..10d40448 100644 --- a/storage/mariadb/track.go +++ b/storage/mariadb/track.go @@ -258,7 +258,7 @@ func (ss SongStorage) PlayedCount(song radio.Song) (int64, error) { } // AddPlay implements radio.SongStorage -func (ss SongStorage) AddPlay(song radio.Song, ldiff *int) error { +func (ss SongStorage) AddPlay(song radio.Song, ldiff *radio.Listeners) error { const op errors.Op = "mariadb/SongStorage.AddPlay" var query = `INSERT INTO eplay (isong, ldiff) VALUES (?, ?);` diff --git a/storage/mariadb/user.go b/storage/mariadb/user.go index a47e35e7..f13e9706 100644 --- a/storage/mariadb/user.go +++ b/storage/mariadb/user.go @@ -387,7 +387,7 @@ func (us UserStorage) Permissions() ([]radio.UserPermission, error) { } // RecordListeners implements radio.UserStorage -func (us UserStorage) RecordListeners(listeners int, user radio.User) error { +func (us UserStorage) RecordListeners(listeners radio.Listeners, user radio.User) error { const op errors.Op = "mariadb/UserStorage.RecordListeners" var query = `INSERT INTO listenlog (listeners, dj) VALUES (?, ?);` diff --git a/templates/default/home.tmpl b/templates/default/home.tmpl index ad5ba788..4e0fda29 100644 --- a/templates/default/home.tmpl +++ b/templates/default/home.tmpl @@ -30,8 +30,6 @@
{{template "nowplaying" .Status}} - -
@@ -84,13 +82,16 @@ Home {{printjson .}}
-

Listeners: {{.Listeners}}

+

{{template "listeners" .Listeners}}

00:00 / {{.Song.Length | MediaDuration}}

{{end}} +{{define "listeners"}} + Listeners: {{.}} +{{end}} {{define "lastplayed"}}

Last Played

diff --git a/website/admin/router.go b/website/admin/router.go index 8eacfe33..dfe0594a 100644 --- a/website/admin/router.go +++ b/website/admin/router.go @@ -15,6 +15,29 @@ import ( "github.com/go-chi/chi/v5" ) +func NewState( + _ context.Context, + cfg config.Config, + dp *daypass.Daypass, + storage radio.StorageService, + siteTmpl *templates.Site, + exec templates.Executor, + sessionManager *scs.SessionManager, + auth vmiddleware.Authentication, + fs afero.Fs, +) State { + return State{ + Config: cfg, + Daypass: dp, + Storage: storage, + Templates: siteTmpl, + TemplateExecutor: exec, + SessionManager: sessionManager, + Authentication: auth, + FS: fs, + } +} + type State struct { config.Config diff --git a/website/api/php/api.go b/website/api/php/api.go index e54d1048..6dbf63f3 100644 --- a/website/api/php/api.go +++ b/website/api/php/api.go @@ -28,7 +28,9 @@ import ( func NewAPI(ctx context.Context, cfg config.Config, storage radio.StorageService, streamer radio.StreamerService, manager radio.ManagerService) (*API, error) { - status, err := newV0Status(ctx, storage, streamer, manager) + statusValue := util.StreamValue(ctx, manager.CurrentStatus) + + status, err := newV0Status(ctx, storage, streamer, statusValue) if err != nil { return nil, err } @@ -38,12 +40,12 @@ func NewAPI(ctx context.Context, cfg config.Config, storage radio.StorageService } api := API{ - Config: cfg, - storage: storage, - streamer: streamer, - manager: manager, - status: status, - search: searcher, + Config: cfg, + storage: storage, + streamer: streamer, + status: status, + search: searcher, + StatusValue: statusValue, } return &api, nil } @@ -54,15 +56,16 @@ type API struct { search radio.SearchService storage radio.StorageService streamer radio.StreamerService - manager radio.ManagerService status *v0Status + + StatusValue *util.Value[radio.Status] } func (a *API) Route(r chi.Router) { r.Use(chiware.SetHeader("Content-Type", "application/json")) r.Method("GET", "/", a.status) r.Get("/ping", func(w http.ResponseWriter, _ *http.Request) { - w.Write([]byte(`{"ping":true}`)) + _, _ = w.Write([]byte(`{"ping":true}`)) }) r.Get("/user-cooldown", a.getUserCooldown) r.Get("/news", a.getNews) @@ -273,14 +276,12 @@ func (sri *searchResponseItem) fromSong(s radio.Song) error { } func (a *API) getCanRequest(w http.ResponseWriter, r *http.Request) { - status, err := util.OneOff(r.Context(), a.manager.CurrentStatus) - if err != nil { - return - } + status := a.StatusValue.Latest() response := canRequestResponse{} // send our response when we return + var err error defer func() { // but not if an error occured if err != nil { @@ -400,12 +401,12 @@ func (a *API) postRequest(w http.ResponseWriter, r *http.Request) { } func newV0Status(ctx context.Context, storage radio.SongStorageService, - streamer radio.StreamerService, manager radio.ManagerService) (*v0Status, error) { + streamer radio.StreamerService, status *util.Value[radio.Status]) (*v0Status, error) { s := v0Status{ songs: storage, streamer: streamer, - manager: manager, + status: status, updatePeriod: time.Second * 2, longUpdatePeriod: time.Second * 10, } @@ -430,8 +431,8 @@ type v0Status struct { songs radio.SongStorageService // streamer for queue contents streamer radio.StreamerService - // manager for overall stream status - manager radio.ManagerService + // status value + status *util.Value[radio.Status] updatePeriod time.Duration longUpdatePeriod time.Duration @@ -447,7 +448,7 @@ type v0StatusJSON struct { type v0StatusMain struct { NowPlaying string `json:"np"` - Listeners int `json:"listeners"` + Listeners int64 `json:"listeners"` BitRate int `json:"bitrate"` IsAFKStream bool `json:"isafkstream"` IsStreamDesk bool `json:"isstreamdesk"` @@ -567,10 +568,7 @@ func (s *v0Status) createStatusJSON(ctx context.Context) (v0StatusJSON, error) { status.ListCreatedOn = now } - ms, err := util.OneOff(ctx, s.manager.CurrentStatus) - if err != nil { - return last, err - } + ms := s.status.Latest() // End might be the zero time, in which case calling Unix // returns a large negative number that we don't want diff --git a/website/api/v1/sse.go b/website/api/v1/sse.go index 1183c885..30eee9b7 100644 --- a/website/api/v1/sse.go +++ b/website/api/v1/sse.go @@ -13,6 +13,7 @@ import ( radio "github.com/R-a-dio/valkyrie" "github.com/R-a-dio/valkyrie/errors" "github.com/R-a-dio/valkyrie/templates" + "github.com/R-a-dio/valkyrie/util" "github.com/R-a-dio/valkyrie/util/sse" "github.com/rs/zerolog" "github.com/rs/zerolog/hlog" @@ -55,6 +56,14 @@ func (a *API) runStatusUpdates(ctx context.Context) error { var previous radio.Status + // we don't care about the actual value, and the goroutine it spawns should keep everything + // alive aslong as the ctx isn't canceled + _ = util.StreamValue(ctx, a.manager.CurrentListeners, func(ctx context.Context, i int64) { + // always send listeners, this acts as a keep-alive for the long-polling but also gives us + // a bit more up to date listener count display + a.sse.SendListeners(i) + }) + for { status, err := statusStream.Next() if err != nil { @@ -120,6 +129,7 @@ const ( const ( EventTime = "time" EventMetadata = "metadata" + EventListeners = "listeners" EventStreamer = "streamer" EventQueue = "queue" EventLastPlayed = "lastplayed" @@ -170,6 +180,7 @@ func (s *Stream) ServeHTTP(w http.ResponseWriter, r *http.Request) { }() w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("X-Accel-Buffering", "no") // send a sync timestamp now := strconv.FormatInt(time.Now().UnixMilli(), 10) @@ -306,6 +317,10 @@ func (s *Stream) SendQueue(data []radio.QueueEntry) { s.SendEvent(EventQueue, s.NewMessage(EventQueue, Queue(data))) } +func (s *Stream) SendListeners(data radio.Listeners) { + s.SendEvent(EventListeners, s.NewMessage(EventListeners, Listeners(data))) +} + // request send over the management channel type request struct { cmd string // required @@ -355,3 +370,13 @@ func (Streamer) TemplateName() string { func (Streamer) TemplateBundle() string { return "home" } + +type Listeners radio.Listeners + +func (Listeners) TemplateName() string { + return "listeners" +} + +func (Listeners) TemplateBundle() string { + return "home" +} diff --git a/website/main.go b/website/main.go index 5475af97..8ebf9eea 100644 --- a/website/main.go +++ b/website/main.go @@ -128,27 +128,29 @@ func Execute(ctx context.Context, cfg config.Config) error { // admin routes r.Get("/logout", authentication.LogoutHandler) // outside so it isn't login restricted - r.Route("/admin", admin.Route(ctx, admin.State{ - Config: cfg, - Daypass: dpass, - Storage: storage, - Templates: siteTemplates, - TemplateExecutor: executor, - SessionManager: sessionManager, - Authentication: authentication, - FS: afero.NewOsFs(), - })) + r.Route("/admin", admin.Route(ctx, admin.NewState( + ctx, + cfg, + dpass, + storage, + siteTemplates, + executor, + sessionManager, + authentication, + afero.NewOsFs(), + ))) // public routes - r.Route("/", public.Route(ctx, public.State{ - Config: cfg, - Daypass: dpass, - Templates: siteTemplates.Executor(), - Manager: manager, - Streamer: streamer, - Storage: storage, - Search: searchService, - })) + r.Route("/", public.Route(ctx, public.NewState( + ctx, + cfg, + dpass, + executor, + manager, + streamer, + storage, + searchService, + ))) // setup the http server conf := cfg.Conf() diff --git a/website/public/home.go b/website/public/home.go index 04c1b9bc..bf22b462 100644 --- a/website/public/home.go +++ b/website/public/home.go @@ -5,7 +5,6 @@ import ( radio "github.com/R-a-dio/valkyrie" "github.com/R-a-dio/valkyrie/errors" - "github.com/R-a-dio/valkyrie/util" "github.com/R-a-dio/valkyrie/website/middleware" "github.com/rs/zerolog/hlog" ) @@ -43,11 +42,7 @@ func (s State) getHome(w http.ResponseWriter, r *http.Request) error { input := NewHomeInput(r) ctx := r.Context() - status, err := util.OneOff(ctx, s.Manager.CurrentStatus) - if err != nil { - return errors.E(op, errors.InternalServer, err) - } - input.Status = status + input.Status = s.StatusValue.Latest() queue, err := s.Streamer.Queue(ctx) if err != nil { diff --git a/website/public/state.go b/website/public/state.go index 57086e7c..11959116 100644 --- a/website/public/state.go +++ b/website/public/state.go @@ -7,21 +7,45 @@ import ( radio "github.com/R-a-dio/valkyrie" "github.com/R-a-dio/valkyrie/config" "github.com/R-a-dio/valkyrie/templates" + "github.com/R-a-dio/valkyrie/util" "github.com/R-a-dio/valkyrie/util/daypass" "github.com/rs/zerolog/hlog" "github.com/go-chi/chi/v5" ) +func NewState( + ctx context.Context, + cfg config.Config, + dp *daypass.Daypass, + exec templates.Executor, + manager radio.ManagerService, + streamer radio.StreamerService, + storage radio.StorageService, + search radio.SearchService) State { + + return State{ + Config: cfg, + Daypass: dp, + Templates: exec, + Manager: manager, + Streamer: streamer, + Storage: storage, + Search: search, + StatusValue: util.StreamValue(ctx, manager.CurrentStatus), + } +} + type State struct { config.Config - Daypass *daypass.Daypass - Templates templates.Executor - Manager radio.ManagerService - Streamer radio.StreamerService - Storage radio.StorageService - Search radio.SearchService + Daypass *daypass.Daypass + Templates templates.Executor + Manager radio.ManagerService + Streamer radio.StreamerService + Storage radio.StorageService + Search radio.SearchService + StatusValue *util.Value[radio.Status] } func (s *State) errorHandler(w http.ResponseWriter, r *http.Request, err error) {