generation-aware playlist readiness

This commit is contained in:
Dmitry Sergeev
2026-09-01 20:05:00 +03:00
parent b160e3aba2
commit 532e03ffca
4 changed files with 349 additions and 0 deletions
+52
View File
@@ -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
}
@@ -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)
}
})
}
}
+24
View File
@@ -8,6 +8,19 @@ type StatusStore struct {
statuses map[Unit]Status statuses map[Unit]Status
} }
type PlaybackStatusSnapshot struct {
Generation uint64
Video Status
HasVideo bool
Audio Status
HasAudio bool
Sync Status
HasSync bool
}
func NewStatusStore() *StatusStore { func NewStatusStore() *StatusStore {
return &StatusStore{ return &StatusStore{
statuses: make(map[Unit]Status), statuses: make(map[Unit]Status),
@@ -34,6 +47,17 @@ func (s *StatusStore) Snapshot(unit Unit) (Status, bool) {
return status, ok 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) { func (s *StatusStore) Clear(unit Unit) {
s.mu.Lock() s.mu.Lock()
delete(s.statuses, unit) delete(s.statuses, unit)
+66
View File
@@ -153,3 +153,69 @@ func TestStatusStoreKeepsEqualGenerationUnitsIndependent(t *testing.T) {
t.Fatalf("audio Snapshot() = %#v, %t", got, ok) 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()
}
}
}