From 532e03ffcaef2fe5582f37cd1ab4cba62884cbeb Mon Sep 17 00:00:00 2001 From: Dmitry Sergeev Date: Tue, 1 Sep 2026 20:05:00 +0300 Subject: [PATCH] generation-aware playlist readiness --- internal/playback/playlist_readiness.go | 52 +++++ internal/playback/playlist_readiness_test.go | 207 +++++++++++++++++++ internal/playback/status_store.go | 24 +++ internal/playback/status_store_test.go | 66 ++++++ 4 files changed, 349 insertions(+) create mode 100644 internal/playback/playlist_readiness.go create mode 100644 internal/playback/playlist_readiness_test.go diff --git a/internal/playback/playlist_readiness.go b/internal/playback/playlist_readiness.go new file mode 100644 index 0000000..49aead4 --- /dev/null +++ b/internal/playback/playlist_readiness.go @@ -0,0 +1,52 @@ +package playback + +func IsSessionPlaying( + session SessionSnapshot, + statuses PlaybackStatusSnapshot, +) bool { + if statuses.Generation != session.Generation { + return false + } + + switch session.Plan.Topology { + case TopologyIndependent: + hasActiveFeed := session.Plan.Video.Active || session.Plan.Audio.Active + if !hasActiveFeed { + return false + } + if session.Plan.Video.Active && !statusIsPlaying( + statuses.Video, + statuses.HasVideo, + session.Generation, + ) { + return false + } + if session.Plan.Audio.Active && !statusIsPlaying( + statuses.Audio, + statuses.HasAudio, + session.Generation, + ) { + return false + } + return true + + case TopologySynchronized: + return statusIsPlaying( + statuses.Sync, + statuses.HasSync, + session.Generation, + ) + + case TopologyIdle: + return false + + default: + return false + } +} + +func statusIsPlaying(status Status, present bool, generation uint64) bool { + return present && + status.Generation == generation && + status.State == StatePlaying +} diff --git a/internal/playback/playlist_readiness_test.go b/internal/playback/playlist_readiness_test.go new file mode 100644 index 0000000..4102b47 --- /dev/null +++ b/internal/playback/playlist_readiness_test.go @@ -0,0 +1,207 @@ +package playback + +import "testing" + +func TestIsSessionPlaying(t *testing.T) { + const generation = 4 + playing := func(unit Unit) Status { + return Status{Unit: unit, State: StatePlaying, Generation: generation} + } + tests := []struct { + name string + session SessionSnapshot + statuses PlaybackStatusSnapshot + want bool + }{ + { + name: "video only playing", + session: SessionSnapshot{ + Generation: generation, + Plan: SessionPlan{ + Topology: TopologyIndependent, + Video: FeedConfig{UUID: "video", Active: true}, + }, + }, + statuses: PlaybackStatusSnapshot{ + Generation: generation, + Video: playing(UnitVideo), + HasVideo: true, + }, + want: true, + }, + { + name: "audio only playing", + session: SessionSnapshot{ + Generation: generation, + Plan: SessionPlan{ + Topology: TopologyIndependent, + Audio: FeedConfig{UUID: "audio", Active: true}, + }, + }, + statuses: PlaybackStatusSnapshot{ + Generation: generation, + Audio: playing(UnitAudio), + HasAudio: true, + }, + want: true, + }, + { + name: "both independent feeds playing", + session: SessionSnapshot{ + Generation: generation, + Plan: SessionPlan{ + Topology: TopologyIndependent, + Video: FeedConfig{UUID: "video", Active: true}, + Audio: FeedConfig{UUID: "audio", Active: true}, + }, + }, + statuses: PlaybackStatusSnapshot{ + Generation: generation, + Video: playing(UnitVideo), + HasVideo: true, + Audio: playing(UnitAudio), + HasAudio: true, + }, + want: true, + }, + { + name: "only video of independent pair playing", + session: SessionSnapshot{ + Generation: generation, + Plan: SessionPlan{ + Topology: TopologyIndependent, + Video: FeedConfig{UUID: "video", Active: true}, + Audio: FeedConfig{UUID: "audio", Active: true}, + }, + }, + statuses: PlaybackStatusSnapshot{ + Generation: generation, + Video: playing(UnitVideo), + HasVideo: true, + Audio: Status{Unit: UnitAudio, State: StateConnecting, Generation: generation}, + HasAudio: true, + }, + }, + { + name: "synchronized unit playing", + session: SessionSnapshot{ + Generation: generation, + Plan: SessionPlan{Topology: TopologySynchronized}, + }, + statuses: PlaybackStatusSnapshot{ + Generation: generation, + Sync: playing(UnitSync), + HasSync: true, + }, + want: true, + }, + { + name: "sync ignores independent playing statuses", + session: SessionSnapshot{ + Generation: generation, + Plan: SessionPlan{Topology: TopologySynchronized}, + }, + statuses: PlaybackStatusSnapshot{ + Generation: generation, + Video: playing(UnitVideo), + HasVideo: true, + Audio: playing(UnitAudio), + HasAudio: true, + }, + }, + { + name: "reconnecting is not playing", + session: SessionSnapshot{ + Generation: generation, + Plan: SessionPlan{ + Topology: TopologyIndependent, + Video: FeedConfig{UUID: "video", Active: true}, + }, + }, + statuses: PlaybackStatusSnapshot{ + Generation: generation, + Video: Status{Unit: UnitVideo, State: StateReconnecting, Generation: generation}, + HasVideo: true, + }, + }, + { + name: "missing status", + session: SessionSnapshot{ + Generation: generation, + Plan: SessionPlan{ + Topology: TopologyIndependent, + Video: FeedConfig{UUID: "video", Active: true}, + }, + }, + statuses: PlaybackStatusSnapshot{Generation: generation}, + }, + { + name: "older status snapshot", + session: SessionSnapshot{ + Generation: generation, + Plan: SessionPlan{Topology: TopologySynchronized}, + }, + statuses: PlaybackStatusSnapshot{ + Generation: generation - 1, + Sync: Status{Unit: UnitSync, State: StatePlaying, Generation: generation - 1}, + HasSync: true, + }, + }, + { + name: "newer status snapshot", + session: SessionSnapshot{ + Generation: generation, + Plan: SessionPlan{Topology: TopologySynchronized}, + }, + statuses: PlaybackStatusSnapshot{ + Generation: generation + 1, + Sync: Status{Unit: UnitSync, State: StatePlaying, Generation: generation + 1}, + HasSync: true, + }, + }, + { + name: "individual status has wrong generation", + session: SessionSnapshot{ + Generation: generation, + Plan: SessionPlan{Topology: TopologySynchronized}, + }, + statuses: PlaybackStatusSnapshot{ + Generation: generation, + Sync: Status{Unit: UnitSync, State: StatePlaying, Generation: generation - 1}, + HasSync: true, + }, + }, + { + name: "idle", + session: SessionSnapshot{ + Generation: generation, + Plan: SessionPlan{Topology: TopologyIdle}, + }, + statuses: PlaybackStatusSnapshot{Generation: generation}, + }, + { + name: "unknown topology", + session: SessionSnapshot{ + Generation: generation, + Plan: SessionPlan{Topology: SessionTopology(255)}, + }, + statuses: PlaybackStatusSnapshot{Generation: generation}, + }, + { + name: "independent without active feeds", + session: SessionSnapshot{ + Generation: generation, + Plan: SessionPlan{Topology: TopologyIndependent}, + }, + statuses: PlaybackStatusSnapshot{Generation: generation}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := IsSessionPlaying(test.session, test.statuses); got != test.want { + t.Fatalf("IsSessionPlaying() = %v, want %v", got, test.want) + } + }) + } +} diff --git a/internal/playback/status_store.go b/internal/playback/status_store.go index d9ce79f..3db4254 100644 --- a/internal/playback/status_store.go +++ b/internal/playback/status_store.go @@ -8,6 +8,19 @@ type StatusStore struct { statuses map[Unit]Status } +type PlaybackStatusSnapshot struct { + Generation uint64 + + Video Status + HasVideo bool + + Audio Status + HasAudio bool + + Sync Status + HasSync bool +} + func NewStatusStore() *StatusStore { return &StatusStore{ statuses: make(map[Unit]Status), @@ -34,6 +47,17 @@ func (s *StatusStore) Snapshot(unit Unit) (Status, bool) { return status, ok } +func (s *StatusStore) SnapshotAll() PlaybackStatusSnapshot { + s.mu.RLock() + defer s.mu.RUnlock() + + snapshot := PlaybackStatusSnapshot{Generation: s.generation} + snapshot.Video, snapshot.HasVideo = s.statuses[UnitVideo] + snapshot.Audio, snapshot.HasAudio = s.statuses[UnitAudio] + snapshot.Sync, snapshot.HasSync = s.statuses[UnitSync] + return snapshot +} + func (s *StatusStore) Clear(unit Unit) { s.mu.Lock() delete(s.statuses, unit) diff --git a/internal/playback/status_store_test.go b/internal/playback/status_store_test.go index fb3a25e..dd9a64c 100644 --- a/internal/playback/status_store_test.go +++ b/internal/playback/status_store_test.go @@ -153,3 +153,69 @@ func TestStatusStoreKeepsEqualGenerationUnitsIndependent(t *testing.T) { t.Fatalf("audio Snapshot() = %#v, %t", got, ok) } } + +func TestStatusStoreSnapshotAll(t *testing.T) { + store := NewStatusStore() + wantVideo := Status{Unit: UnitVideo, State: StatePlaying, Generation: 5} + wantAudio := Status{Unit: UnitAudio, State: StateReconnecting, Generation: 5} + store.Observe(wantVideo) + store.Observe(wantAudio) + + got := store.SnapshotAll() + if got.Generation != 5 { + t.Fatalf("SnapshotAll() generation = %d, want 5", got.Generation) + } + if !got.HasVideo || got.Video != wantVideo { + t.Fatalf("SnapshotAll() video = %#v, %v; want %#v, true", got.Video, got.HasVideo, wantVideo) + } + if !got.HasAudio || got.Audio != wantAudio { + t.Fatalf("SnapshotAll() audio = %#v, %v; want %#v, true", got.Audio, got.HasAudio, wantAudio) + } + if got.HasSync { + t.Fatalf("SnapshotAll() HasSync = true, want false") + } +} + +func TestStatusStoreSnapshotAllClearsOldGenerationUnits(t *testing.T) { + store := NewStatusStore() + store.Observe(Status{Unit: UnitVideo, State: StatePlaying, Generation: 2}) + store.Observe(Status{Unit: UnitAudio, State: StatePlaying, Generation: 2}) + wantSync := Status{Unit: UnitSync, State: StateConnecting, Generation: 3} + store.Observe(wantSync) + + got := store.SnapshotAll() + if got.Generation != 3 { + t.Fatalf("SnapshotAll() generation = %d, want 3", got.Generation) + } + if got.HasVideo || got.HasAudio { + t.Fatalf("SnapshotAll() retained old units: %#v", got) + } + if !got.HasSync || got.Sync != wantSync { + t.Fatalf("SnapshotAll() sync = %#v, %v; want %#v, true", got.Sync, got.HasSync, wantSync) + } +} + +func TestStatusStoreSnapshotAllConcurrentObserve(t *testing.T) { + store := NewStatusStore() + done := make(chan struct{}) + go func() { + defer close(done) + for generation := uint64(1); generation <= 1000; generation++ { + store.Observe(Status{ + Unit: Unit(generation % 3), + State: StatePlaying, + Generation: generation, + }) + } + }() + + for { + select { + case <-done: + _ = store.SnapshotAll() + return + default: + _ = store.SnapshotAll() + } + } +}