Skip to content

Commit 52f711b

Browse files
cursoragentigorlazarevru
andcommitted
feat: add stream status foundation for admin diagnostics
Introduce a normalized stream snapshot store and GET /api/streams/status, separate from chat connector state. The admin Systems panel shows a compact per-platform strip with unknown stream state and nullable viewers until platform monitors land. Co-authored-by: igorlazarevru <igorlazarevru@gmail.com>
1 parent e2a5a7b commit 52f711b

24 files changed

Lines changed: 1309 additions & 16 deletions

.agents/skills/api-conventions/SKILL.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ description: HTTP and WebSocket API conventions for comm-relay — routes, snake
1616
| `/healthz` | GET | Liveness |
1717
| `/api/status` | GET | Connector connection status (JSON) |
1818
| `/api/diagnostics` | GET | Runtime info, message counts, connector statuses, app version |
19-
| `/api/support/open` | POST | Open allowlisted support URLs in the system browser |
19+
| `/api/streams/status` | GET | Normalized stream diagnostics snapshots and viewer aggregate |
20+
| `/api/support/open` | POST | Open allowlisted support URLs in the browser |
2021
| `/api/config` | GET/POST | Read/update settings (snake_case JSON) |
2122
| `/api/youtube/oauth/start` | POST | Begin YouTube OAuth (open system browser) |
2223
| `/oauth/youtube/start` | GET | Legacy OAuth start (opens browser, redirects admin to pending) |

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
### Добавлено
1010

11+
- В панели Systems админки отображается компактная сводка по эфирам на Twitch, YouTube и VK: состояние чата и суммарные зрители (пока без данных о самом стриме).
1112
- В админке и OBS dock можно выбрать язык интерфейса (русский / английский) в **Interface → Language**.
1213
- README на русском и английском (`README.en.md`), ссылки на авторские стримы и темы сообщений для OBS под ваш стрим через Telegram.
1314

docs/task-tracker.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,11 @@
4444
| 20 | CR-020 | done | Product polish | Add 7TV emote provider | [CR-020-7tv-emote-provider.md](tasks/CR-020-7tv-emote-provider.md) |
4545
| 21 | CR-021 | done | Product polish | Add safe image link previews | [CR-021-safe-image-link-previews.md](tasks/CR-021-safe-image-link-previews.md) |
4646
| 22 | CR-022 | done | Product polish | Add rich chat admin controls and diagnostics | [CR-022-rich-chat-admin-controls.md](tasks/CR-022-rich-chat-admin-controls.md) |
47+
| 23 | CR-023 | done | Stream diagnostics | Add stream status foundation (model, API, admin strip) | [CR-023-stream-status-foundation.md](tasks/CR-023-stream-status-foundation.md) |
4748

4849
## Current Notes
4950

51+
- CR-023: Stream diagnostics foundation — `internal/streamstatus` snapshots + in-memory history, `GET /api/streams/status`, admin Systems strip. Chat health from connector registry; stream state stays `unknown` until platform monitors (next tasks). Viewers are JSON `null`, not `0`.
5052
- CR-022: Added `overlay.emotes` toggles (Twitch/FFZ/BTTV/7TV) and admin Rich chat dialog for emote providers plus `overlay.image_previews` limits/allowlist. Connectors, enricher, and refresher honor toggles. `PATCH /api/config` returns structured `fields` for validation errors. Systems panel shows emote cache counts, last refresh, and provider errors from diagnostics.
5153
- CR-021: Added `internal/imagelink` URL validation (HTTPS-only, host allowlist, private/localhost rejection, image extensions) and fragment enrichment on Twitch/YouTube/VK connectors. Config `overlay.image_previews` defaults to disabled with a conservative host list; overlay renders `image_link` fragments via DOM `<img>` with `referrerpolicy="no-referrer"` and bounded CSS. Backend never fetches user URLs.
5254
- CR-020: Added 7TV `Fetcher` in `internal/emote/seventv` (v3 global + Twitch channel endpoints, CDN `2x.webp` URLs), periodic refresh and third-party lookup with channel 7TV before FFZ/BTTV. Provider failures keep plain chat text; cache health on `GET /api/diagnostics`.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# CR-023: Stream Status Foundation
2+
3+
Status: `done`
4+
5+
## Goal
6+
7+
Добавить нормализованную модель состояния эфира, отдельную от чата, и показать её в админке без сбора зрителей и HLS.
8+
9+
## Context
10+
11+
Research: [Platform stream diagnostics](../research/platform-stream-diagnostics.md).
12+
13+
`GET /api/diagnostics` и `internal/connector/status` описывают chat connector, не видео. Нельзя смешивать live/offline эфира с connected/reconnecting чата. Эта задача — фундамент (как CR-018 для эмоутов): контракт, store, API и полоса в админке. Guest-адаптеры платформ — следующие задачи.
14+
15+
## Scope
16+
17+
- Пакет `internal/streamstatus`: `Snapshot`, capability flags, nullable viewers, независимые слои metadata/chat/playback/ingest/probe.
18+
- In-memory store: текущий snapshot по платформе и кольцевой буфер истории (~30–60 мин).
19+
- `GET /api/streams/status` — текущие snapshots и агрегат; chat слой из `status.Registry`.
20+
- Без мониторов: `state=unknown`, capabilities только `chat_health`, viewers `null` (не `0`), playback/ingest `supported: false`.
21+
- Компактная полоса в админке (Systems), рендер по capabilities.
22+
- Redaction: в JSON нет signed URL и секретов; `0` ≠ unknown; timeout/отсутствие монитора ≠ offline.
23+
24+
## Out Of Scope
25+
26+
- Twitch GraphQL / Helix / HLS.
27+
- YouTube page viewers, API key, `liveStreams.list`.
28+
- VK `public_video_stream` и HLS.
29+
- `POST /api/streams/probe`, событие в `/ws`, overlay.
30+
- Расширение `GET /api/diagnostics` историей эфира.
31+
- Изменение `ChatMessage` и остановка chat connector при ошибке monitor-а.
32+
33+
## Acceptance Criteria
34+
35+
- `GET /api/streams/status` отдаёт twitch/youtube/vk snapshots с `state=unknown` и `viewers.current=null` до появления мониторов.
36+
- Chat state берётся из существующего connector registry и не подменяет stream state.
37+
- Админка показывает полосу эфиров; для unknown viewers — тире, не ноль.
38+
- JSON не содержит signed playback URL и токенов.
39+
- Ошибка/отсутствие stream monitor не меняет chat connector и не публикует `ChatMessage`.
40+
41+
## Checks
42+
43+
- `gofmt` on touched Go files
44+
- `go test ./internal/streamstatus ./internal/api ./internal/bootstrap`
45+
- `golangci-lint run ./internal/streamstatus/... ./internal/api/... ./internal/bootstrap/...`
46+
- `npm run lint` and `npm run test:i18n` if `web/**/*.js` changed
47+
48+
## Completion
49+
50+
- `internal/streamstatus`: Snapshot, in-memory store/history, Compose from config + connector registry, signed-URL/token redaction.
51+
- `GET /api/streams/status` returns twitch/youtube/vk with `state=unknown` and null viewers until monitors exist; chat state comes from `status.Registry`.
52+
- Admin Systems strip polls the new endpoint and renders by capabilities (unknown viewers as em dash, not 0).
53+
- Checks: `go test ./...`, `golangci-lint run ./internal/streamstatus/... ./internal/api/... ./internal/bootstrap/...`, `npm run lint`, `npm run test:i18n`.
54+
55+
## Notes For Agent
56+
57+
- API: GET для чтения допустим; мутации только `POST /api/<resource>/<action>`. Не использовать PUT/PATCH/DELETE и `{id}` в `/api/`.
58+
- UI строить по `capabilities`, не `if (platform === ...)`.
59+
- Cross-platform total подписывать как сумму по платформам, не как уникальных зрителей.

internal/api/server.go

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,20 @@ import (
99
"github.com/mechastrider/comm-relay/internal/connector/status"
1010
"github.com/mechastrider/comm-relay/internal/emote"
1111
"github.com/mechastrider/comm-relay/internal/runtime"
12+
"github.com/mechastrider/comm-relay/internal/streamstatus"
1213
)
1314

1415
// Options configures the HTTP handler.
1516
type Options struct {
1617
// WebRoot overrides embedded static assets with files from disk (for local UI dev).
17-
WebRoot string
18-
Hub *Hub
19-
Store *config.Store
20-
History *MessageHistory
21-
Registry *status.Registry
22-
Runtime *runtime.Info
23-
EmoteCache *emote.Cache
18+
WebRoot string
19+
Hub *Hub
20+
Store *config.Store
21+
History *MessageHistory
22+
Registry *status.Registry
23+
Runtime *runtime.Info
24+
EmoteCache *emote.Cache
25+
StreamStatus *streamstatus.Store
2426
}
2527

2628
// NewHandler returns the root HTTP handler for CommRelay.
@@ -52,7 +54,13 @@ func NewHandler(opts Options) (http.Handler, error) {
5254

5355
configHandler := newConfigHandler(opts.Store)
5456
statusHandler := newStatusHandler(opts.Store, registry)
57+
streamStore := opts.StreamStatus
58+
if streamStore == nil {
59+
streamStore = streamstatus.NewStore(streamstatus.StoreOptions{})
60+
}
61+
5562
diagnosticsHandler := newDiagnosticsHandler(opts.Store, registry, opts.Hub, rt, opts.EmoteCache)
63+
streamsStatusHandler := newStreamsStatusHandler(opts.Store, registry, streamStore)
5664
messagesHandler := newMessagesHandler(opts.History, opts.Hub)
5765
oauthState := newOAuthStateStore()
5866
youtubeOAuth := newYouTubeOAuthHandler(opts.Store, oauthState)
@@ -65,6 +73,7 @@ func NewHandler(opts Options) (http.Handler, error) {
6573
mux.HandleFunc("POST /api/config/update", configHandler.handleUpdate)
6674
mux.HandleFunc("GET /api/status", statusHandler.handleGet)
6775
mux.HandleFunc("GET /api/diagnostics", diagnosticsHandler.handleGet)
76+
mux.HandleFunc("GET /api/streams/status", streamsStatusHandler.handleGet)
6877
mux.HandleFunc("POST /api/youtube/oauth/start", youtubeOAuth.handleStartAPI)
6978
mux.HandleFunc("POST /api/support/open", supportOpen.handleOpen)
7079
mux.HandleFunc("GET /oauth/youtube/start", youtubeOAuth.handleStart)

internal/api/server_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,11 @@ func TestNewHandlerRoutes(t *testing.T) {
9595
require.Contains(t, jsRec.Body.String(), "/api/messages/recent")
9696
require.Contains(t, jsRec.Body.String(), "/ws")
9797
})
98+
99+
t.Run("streams status", func(t *testing.T) {
100+
rec := httptest.NewRecorder()
101+
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/streams/status", nil))
102+
require.Equal(t, http.StatusOK, rec.Code)
103+
require.Contains(t, rec.Body.String(), `"platforms"`)
104+
})
98105
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package api
2+
3+
import (
4+
"net/http"
5+
"time"
6+
7+
"github.com/mechastrider/comm-relay/internal/config"
8+
"github.com/mechastrider/comm-relay/internal/connector/status"
9+
"github.com/mechastrider/comm-relay/internal/streamstatus"
10+
)
11+
12+
type streamsStatusHandler struct {
13+
store *config.Store
14+
registry *status.Registry
15+
streamStore *streamstatus.Store
16+
}
17+
18+
func newStreamsStatusHandler(store *config.Store, registry *status.Registry, streamStore *streamstatus.Store) *streamsStatusHandler {
19+
return &streamsStatusHandler{
20+
store: store,
21+
registry: registry,
22+
streamStore: streamStore,
23+
}
24+
}
25+
26+
func (h *streamsStatusHandler) handleGet(w http.ResponseWriter, r *http.Request) {
27+
cfg := h.store.Snapshot()
28+
resp := streamstatus.Compose(cfg, h.registry, h.streamStore, time.Now())
29+
writeJSON(w, http.StatusOK, resp)
30+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/mechastrider/comm-relay/internal/bus"
12+
"github.com/mechastrider/comm-relay/internal/connector/status"
13+
"github.com/mechastrider/comm-relay/internal/streamstatus"
14+
)
15+
16+
func TestStreamsStatus_WhenGet_ExpectThreePlatformsWithUnknownState(t *testing.T) {
17+
t.Parallel()
18+
19+
store := testConfigStore(t)
20+
registry := status.NewRegistry()
21+
streamStore := streamstatus.NewStore(streamstatus.StoreOptions{})
22+
23+
handler, err := NewHandler(Options{
24+
Hub: mustTestHub(t),
25+
Store: store,
26+
History: NewMessageHistory(0),
27+
Registry: registry,
28+
StreamStatus: streamStore,
29+
})
30+
require.NoError(t, err)
31+
32+
rec := httptest.NewRecorder()
33+
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/streams/status", nil))
34+
require.Equal(t, http.StatusOK, rec.Code)
35+
36+
var payload struct {
37+
ViewersTotal struct {
38+
Current *int `json:"current"`
39+
Source string `json:"source"`
40+
} `json:"viewers_total"`
41+
Platforms []struct {
42+
Platform string `json:"platform"`
43+
State string `json:"state"`
44+
Viewers struct {
45+
Current *int `json:"current"`
46+
} `json:"viewers"`
47+
Chat struct {
48+
State string `json:"state"`
49+
} `json:"chat"`
50+
} `json:"platforms"`
51+
}
52+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
53+
require.Nil(t, payload.ViewersTotal.Current)
54+
require.Equal(t, "local_samples", payload.ViewersTotal.Source)
55+
require.Len(t, payload.Platforms, 3)
56+
require.Equal(t, "twitch", payload.Platforms[0].Platform)
57+
require.Equal(t, "youtube", payload.Platforms[1].Platform)
58+
require.Equal(t, "vk", payload.Platforms[2].Platform)
59+
60+
for _, platform := range payload.Platforms {
61+
require.Equal(t, "unknown", platform.State)
62+
require.Nil(t, platform.Viewers.Current)
63+
}
64+
}
65+
66+
func TestStreamsStatus_WhenConnectorConnected_ExpectChatStateOnly(t *testing.T) {
67+
t.Parallel()
68+
69+
store := testConfigStore(t)
70+
updated := store.Snapshot()
71+
updated.Twitch.Enabled = true
72+
updated.Twitch.Channel = "streamer"
73+
require.NoError(t, store.Replace(updated))
74+
75+
registry := status.NewRegistry()
76+
registry.SetTwitch(status.Snapshot{State: status.StateConnected})
77+
78+
handler, err := NewHandler(Options{
79+
Hub: mustTestHub(t),
80+
Store: store,
81+
History: NewMessageHistory(0),
82+
Registry: registry,
83+
StreamStatus: streamstatus.NewStore(streamstatus.StoreOptions{}),
84+
})
85+
require.NoError(t, err)
86+
87+
rec := httptest.NewRecorder()
88+
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/streams/status", nil))
89+
require.Equal(t, http.StatusOK, rec.Code)
90+
91+
var payload struct {
92+
Platforms []struct {
93+
State string `json:"state"`
94+
Chat struct {
95+
State string `json:"state"`
96+
} `json:"chat"`
97+
} `json:"platforms"`
98+
}
99+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
100+
require.Equal(t, "unknown", payload.Platforms[0].State)
101+
require.Equal(t, "connected", payload.Platforms[0].Chat.State)
102+
}
103+
104+
func mustTestHub(t *testing.T) *Hub {
105+
t.Helper()
106+
107+
hub, err := NewHub(bus.New(0))
108+
require.NoError(t, err)
109+
return hub
110+
}

internal/bootstrap/app.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"github.com/mechastrider/comm-relay/internal/emote/seventv"
2323
"github.com/mechastrider/comm-relay/internal/emote/ytemoji"
2424
"github.com/mechastrider/comm-relay/internal/runtime"
25+
"github.com/mechastrider/comm-relay/internal/streamstatus"
2526
)
2627

2728
// App runs CommRelay HTTP services and connectors until stopped.
@@ -77,15 +78,17 @@ func New(opts Options) (*App, error) {
7778
emoteRefresher := emote.NewRefresher(emoteCache, store)
7879
youtubeEmojiCatalog := ytemoji.NewCatalog()
7980
youtubeEmojiRefresher := ytemoji.NewRefresher(youtubeEmojiCatalog, emoteHTTP)
81+
streamStatusStore := streamstatus.NewStore(streamstatus.StoreOptions{})
8082

8183
handler, err := api.NewHandler(api.Options{
82-
WebRoot: webRoot,
83-
Hub: hub,
84-
Store: store,
85-
History: history,
86-
Registry: statusRegistry,
87-
Runtime: runtimeInfo,
88-
EmoteCache: emoteCache,
84+
WebRoot: webRoot,
85+
Hub: hub,
86+
Store: store,
87+
History: history,
88+
Registry: statusRegistry,
89+
Runtime: runtimeInfo,
90+
EmoteCache: emoteCache,
91+
StreamStatus: streamStatusStore,
8992
})
9093
if err != nil {
9194
return nil, errors.Errorf("create handler: %w", err)

0 commit comments

Comments
 (0)