diff --git a/.gitignore b/.gitignore index 378eac2..66f2601 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ build +imgui.ini diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md new file mode 100644 index 0000000..5888fba --- /dev/null +++ b/REFACTORING_PLAN.md @@ -0,0 +1,578 @@ +# MXL Player refactoring plan + +## Goal + +Build a player with two persistent logical feed slots: video and audio. Each +slot may be configured, started, stopped, replaced, reconnected, or removed +while the program is running. + +When both slots are configured, the user may enable or disable synchronization +at any time. Synchronization is a runtime relationship between the slots, not a +startup mode. + +The design must leave a clean extension point for an `mxlfabrics` reader after +the local MXL player is stable. It must also support a later playlist layer +without moving playlist timing or selection into media readers. + +## Required behaviour + +### No configured feeds + +- Start the normal GUI and renderer. +- Show a placeholder, simple shader, or empty player surface. +- Allow the user to enter either or both UUIDs. + +### One configured feed + +- An audio UUID starts audio playback. +- A video UUID starts video playback. +- A failed feed reconnects according to its retry policy. +- The user may stop and later resume the feed without clearing its UUID. +- The user may replace or remove the UUID at runtime. + +### Two configured feeds, synchronization disabled + +- Audio and video run as independent workers. +- Failure of one feed must not stop, close, or restart the other. +- Each feed has its own state, last error, and retry counter. +- The user may stop or resume either feed independently. +- Synchronization can be enabled at runtime. + +### Two configured feeds, synchronization enabled + +- Both feeds are read through one synchronization group. +- Failure of either member fails the group attempt. +- Reconnection recreates the complete group. +- Synchronization can be disabled at runtime. The two feeds then continue as + independent workers. +- The user may stop both feeds together. Stopping only one member dismantles + the group and lets the other member continue independently. + +### Stop, resume, and remove semantics + +- `StopVideo` stops video reading and retrying but retains the video UUID. +- `StopAudio` stops audio reading and retrying but retains the audio UUID. +- `StopAll` stops both feeds and all retry activity but retains both UUIDs. +- A stopped feed may be resumed without re-entering its UUID. +- Removing a feed stops it and clears its UUID. +- Stopping one detached feed has no effect on the other feed. +- Stopping one member of a synchronized group disables synchronization, + stops the selected feed, and continues the other feed independently. +- Stopping both members of a synchronized group closes the group atomically. +- Stop commands cancel active reads and retry backoff promptly. + +### Future playlist behaviour + +A playlist is an ordered list of playback entries. Each entry describes a +complete desired session state and may contain: + +- synchronized video and audio UUIDs; +- independent video and audio UUIDs; +- video only; +- audio only; +- an optional per-entry playback duration. + +Example: + +```text +1. synchronized video A + audio A, 10 seconds +2. synchronized video B + audio B, 15 seconds +3. video C, 10 seconds +4. video D, 20 seconds +5. audio E, 30 seconds +``` + +The user may select any entry manually. Automatic playback advances after the +current entry's duration and loops from the final entry back to the first. + +Playlist orchestration belongs above `SessionController`. Selecting an entry +must translate its desired state into the same controller operations used by +CLI and GUI controls. Source readers, workers, retry supervisors, renderer, and +audio output must not know that a playlist exists. + +## Decisions to confirm before implementation + +- `MaxAttempts == 0` means retry indefinitely. +- The initial connection counts as attempt 1. +- Retry counters reset only after useful media has been received for a defined + stability period, not merely after a reader opens. +- Enabling or disabling synchronization starts a new lifecycle and resets the + applicable retry counters. +- Explicitly removing either feed disables requested synchronization. +- Explicitly stopping one member of a synchronized group disables requested + synchronization. Resuming that feed does not silently recreate the group; + the user can enable synchronization again. +- Requesting synchronization with only one configured feed records the request, + continues independent playback, and waits for the second feed. +- Exhausting attempts leaves the UUID configured and the unit in `Failed` until + the user retries, replaces the UUID, changes synchronization, or removes it. + +These are proposed defaults. Change them here before implementing the affected +stage if different behaviour is desired. + +## Target architecture + +```text +CLI initial values GUI runtime commands + | | + +----------------+-----------------+ + v + SessionController + desired vs actual state + | + +-----------+-----------+ + | | + sync disabled sync enabled + VideoWorker SyncGroupWorker + AudioWorker (video + audio) + | | + +-----------+-----------+ + v + stable output layer + video sink / audio sink +``` + +### Ownership rules + +- `SessionController` is the only owner allowed to change playback topology. +- A worker exclusively owns its reader and closes it when the worker stops. +- Independent audio and video workers never close each other's resources. +- A synchronized worker owns and closes the entire group. +- GUI and renderer consume status or media; they never inspect live readers. +- Every topology change increments a generation number. Late events from an old + generation are ignored. + +### Package direction + +```text +internal/source + Local MXL reader adapters and media metadata. + No retry, GUI, SDL, Vulkan, or application policy. + +internal/playback + Feed configuration, commands, state, retry supervisor, independent workers, + synchronized worker, and session controller. + +internal/output (optional once extraction is useful) + Stable video and audio sink interfaces and their adapters. + +cmd/mxl-player + Process initialization, CLI translation, GUI event translation, and wiring. +``` + +Do not introduce a single broad media-source interface. Video, audio, and a +synchronized pair have different results and should use small explicit +interfaces. + +## Runtime state + +Each independently supervised playback unit uses: + +```text +Idle -> Connecting -> Playing -> Reconnecting -> Failed + ^ | | | + +---------+------------+------------+-> Stopping -> Idle +``` + +`Idle` may still have a configured UUID. Configuration and running state are +separate: a configured slot can be stopped without being removed. + +Synchronization additionally distinguishes desired and actual state: + +```text +Disabled +TransitioningOn +Enabled +TransitioningOff +WaitingForSecondFeed +``` + +The GUI should receive immutable status snapshots containing at least: + +- configured UUID; +- desired and actual synchronization state; +- playback state per active unit; +- current attempt and configured limit; +- last error; +- last successful media time; +- received and dropped media counters. + +## Retry policy + +Retry belongs above `internal/source`. + +One attempt is: + +```text +open reader(s) -> receive media -> terminal read error -> close reader(s) +``` + +The supervisor then either stops or waits using cancellable capped exponential +backoff with small jitter before opening again. + +Requirements: + +- Apply the same policy to initial connection and later disconnection. +- Allow finite and infinite attempts. +- Cancel immediately on shutdown, UUID replacement, or topology change. +- Cancel immediately when the user stops the supervised feed. +- Never run two retry loops for the same playback unit. +- Retain and publish the final error after attempt exhaustion. +- Classify configuration/format errors separately from transient availability + errors. Whether permanent errors should retry is decided explicitly. + +## Runtime synchronization transitions + +### Enable synchronization + +1. Record synchronization as desired and increment the topology generation. +2. Cancel both independent workers. +3. Wait until both workers close their readers. +4. Clear stale queued audio. +5. Create the synchronized worker with the existing UUIDs. +6. Establish a new shared timeline and publish `Enabled` after valid paired data. +7. If opening or reading fails, retry the group as one unit. + +The renderer may retain the last video frame or show a placeholder during the +transition. + +### Disable synchronization + +1. Record synchronization as disabled and increment the topology generation. +2. Cancel and close the synchronized worker and group. +3. Clear audio belonging to the old synchronized timeline. +4. Start independent workers for both configured slots. +5. Let either worker begin playing without waiting for the other. + +## Future playlist model + +The exact public types can be chosen later, but the intended model is: + +```go +type PlaylistEntry struct { + Name string + VideoUUID string + AudioUUID string + SyncRequested bool + Duration time.Duration +} + +type Playlist struct { + Entries []PlaylistEntry + Loop bool +} +``` + +The playlist controller owns: + +- current entry index; +- manual selection; +- automatic advance timer; +- loop behaviour; +- pause/resume of automatic advance; +- applying one entry atomically to the session controller. + +Before implementing playlists, decide when an entry's duration begins. The +recommended rule is when its requested playback topology first reaches +`Playing`, so connection and retry time do not consume the viewing period. +Also decide whether an entry that exhausts retries waits for user action or +automatically advances; this should be configurable rather than implicit. + +## Implementation stages + +Only begin a stage after the previous stage's acceptance criteria pass. Keep +each stage small enough for one focused review and commit. + +### Stage 0 — Behaviour contract and baseline + +Work: + +- Confirm or edit the decisions in this document. +- Record the current CLI examples and observable behaviour. +- Run the existing build/tests and record known failures. +- Identify commands used for a local audio feed, video feed, and paired feeds. + +Acceptance criteria: + +- Behaviour choices are unambiguous. +- A repeatable baseline command is documented. +- No application code changes. + +### Stage 1 — Pure configuration model + +Work: + +- Introduce configuration types for domain, two optional feed slots, desired + synchronization, and retry policy. +- Make validation pure: return errors instead of printing or exiting. +- Permit no-feed startup. +- Require a domain only when at least one connection is requested. +- Translate CLI arguments into the same configuration later used by GUI actions. + +Acceptance criteria: + +- Unit tests cover zero, one, and two feeds plus invalid domains/policies. +- Existing playable CLI combinations still translate correctly. +- No playback lifecycle is moved yet. + +### Stage 2 — Normalize low-level source APIs + +Work: + +- Use consistent `VideoSource`, `AudioSource`, and `SyncSource` naming. +- Make every blocking read context-aware and bounded. +- Remove duplicate read paths where safe. +- Centralize audio-fragment extraction and video-frame construction. +- Define classifiable source errors. +- Make MXL payload ownership and copying rules explicit. +- Make a synchronized read fail when either member cannot provide its part. + +Acceptance criteria: + +- Source tests cover cancellation and important MXL error classification. +- A read cannot hide forever inside an internal retry loop. +- Existing audio-only, video-only, and synchronized smoke tests still work. + +### Stage 3 — Retry supervisor + +Work: + +- Implement a reusable supervisor around open, run, close, and retry. +- Add finite/infinite limits and cancellable capped backoff. +- Publish state transitions and attempt counts. +- Add deterministic timing hooks so tests do not sleep in real time. + +Acceptance criteria: + +- Tests cover initial failure, runtime failure, exhaustion, successful recovery, + counter reset, and cancellation during backoff. +- The supervisor has no SDL, Vulkan, ImGui, or concrete MXL dependency. + +### Stage 4 — Independent video worker + +Work: + +- Move video reader ownership and retry lifecycle into a video worker. +- Deliver owned frames through a bounded latest-frame channel or video sink. +- Stop writing directly into renderer staging memory from the source worker. + +Acceptance criteria: + +- Video reconnects without application restart. +- Replacing or removing its UUID cancels the old reader promptly. +- Slow rendering does not create an unbounded live-stream backlog. + +### Stage 5 — Independent audio worker + +Work: + +- Move audio reader ownership and retry lifecycle into an audio worker. +- Centralize channel interleaving. +- Use bounded ordered buffering and explicit backpressure. +- Define when the SDL queue is cleared during reconnect/replacement. + +Acceptance criteria: + +- Audio reconnects without application restart. +- Audio is correct for mono, stereo, and more than two channels. +- Cancellation cannot leave stale samples playing indefinitely. + +### Stage 6 — Session controller and independent dual playback + +Work: + +- Add typed controller commands rather than overloaded channel values. +- Own video and audio slot configuration in the controller. +- Track configured and running/desired-active state separately for each slot. +- Add topology generations and ignore stale worker events. +- Run both independent workers when both UUIDs are configured. + +Acceptance criteria: + +- Video failure does not interrupt audio. +- Audio failure does not interrupt video. +- Either UUID can be replaced while the other feed stays alive. +- Either feed can be stopped and resumed while the other stays alive. +- Stopping both feeds leaves the application and configured UUIDs intact. +- Race detector finds no shared-state races in controller/worker tests. + +### Stage 7 — Synchronized worker + +Work: + +- Move sync-group ownership into one supervised worker. +- Treat missing video or audio data as failure of the group attempt. +- Recreate all synchronized resources on reconnect. +- Publish paired media from one established timeline. + +Acceptance criteria: + +- Failure of either member restarts the complete group. +- Retry exhaustion stops the group cleanly and retains both configured UUIDs. +- Cancellation releases the group and both readers promptly. + +### Stage 8 — Runtime sync toggle + +Work: + +- Implement atomic enable and disable transitions described above. +- Preserve configured UUIDs across both transitions. +- Handle toggle commands during connection, playback, and retry backoff. +- Handle per-feed and stop-all commands in detached and synchronized playback. +- Define and implement behaviour when only one slot is configured. + +Acceptance criteria: + +- Detached feeds can be joined without restarting the application. +- A sync group can be detached into independent feeds without restarting. +- Rapid repeated toggles cannot leave duplicate workers or readers. +- Stopping one synchronized member detaches the group and preserves playback of + the other member. +- Stopping both synchronized members closes the group without starting new + independent workers. +- Old-generation errors cannot change the new topology. + +### Stage 9 — Stable dynamic outputs and idle startup + +Work: + +- Initialize GUI and rendering without requiring a successful source open. +- Show a placeholder when no video frame is available. +- Reconfigure video resources when dimensions/stride/format change. +- Reconfigure the audio stream when rate/channel/device properties change. + +Acceptance criteria: + +- `mxl-player` with no feed opens a usable GUI. +- The user can move among idle, audio-only, video-only, detached dual-feed, and + synchronized playback without restarting. +- Output resources do not depend on the initial CLI topology. + +### Stage 10 — GUI and CLI integration + +Work: + +- Convert GUI actions into controller commands. +- Add fields/actions for both UUIDs, reconnect, remove, retry, and sync toggle. +- Add independent start/stop controls and a stop-all action. +- Display desired/actual sync state and independent/group retry status. +- Add CLI options for retry limits and initial synchronization preference. + +Acceptance criteria: + +- CLI values only establish initial desired state. +- Every important runtime operation is available in the GUI. +- GUI never reads or closes source pointers directly. + +### Stage 11 — Resilience verification + +Test at least: + +- missing producer on initial connection; +- producer disappearance during playback; +- independent audio-only and video-only failure; +- one failed detached feed while the other remains alive; +- failure of either synchronized member; +- finite exhaustion and infinite retry cancellation; +- UUID replacement during reads and backoff; +- stopping or resuming either feed during reads and retry backoff; +- stopping both feeds while detached and synchronized; +- synchronization toggles during reads and backoff; +- shutdown during blocked reads; +- resolution, rate, and channel-count changes; +- mono, stereo, and multichannel audio; +- long runtime and repeated connect/disconnect cycles; +- `go test -race` for testable non-GPU packages. + +Acceptance criteria: + +- The specified failure boundaries hold in every test. +- No known goroutine, MXL reader, sync group, SDL stream, or Vulkan resource leak. +- Player state remains understandable after every exhausted retry sequence. + +### Stage 12 — Reader backend extension point + +Work: + +- Introduce small reader factories only after local MXL behaviour is solid. +- Keep playback workers dependent on reader interfaces/factories, not `go-mxl`. +- Implement local MXL as the first backend. +- Add `mxlfabrics` as a second backend without changing controller semantics. + +Acceptance criteria: + +- Backend selection does not alter retry or synchronization semantics. +- Fake readers can drive all controller and supervisor tests. +- Local MXL remains the reference implementation. + +### Stage 13 — Simple playlist + +Work: + +- Add ordered playlist entries containing optional video/audio UUIDs, + synchronization preference, and per-entry duration. +- Apply entry changes atomically through `SessionController`. +- Add manual previous, next, and direct-entry selection. +- Add timed automatic advance and optional looping. +- Add pause/resume for automatic playlist advancement. +- Expose the current entry and remaining time in the GUI. +- Define behaviour for stopped playback, retry exhaustion, invalid entries, and + manual selection while an automatic timer is active. + +Acceptance criteria: + +- A playlist may freely mix synchronized pairs, independent pairs, video-only, + and audio-only entries. +- Manual selection works regardless of the current playback topology. +- Timed entries advance in order and loop without leaking old workers/readers. +- Entry duration follows the documented start rule and uses cancellable timers. +- An old entry's timers and worker events cannot affect a newly selected entry. +- Playlist logic contains no direct MXL, SDL, Vulkan, or renderer operations. + +## Suggested command API + +Names may change, but commands must have one explicit meaning: + +```go +SetVideo(uuid string) +SetAudio(uuid string) +StartVideo() +StartAudio() +StartAll() +StopVideo() +StopAudio() +StopAll() +RemoveVideo() +RemoveAudio() +EnableSync() +DisableSync() +ReconnectVideo() +ReconnectAudio() +RetryNow() +Stop() +``` + +In synchronized operation, reconnecting either slot means reconnecting the +group. The UI should make that consequence visible. + +## Out of scope for the initial player refactoring + +- Playlist implementation before the controller, dynamic topology, and output + lifecycle are stable. The planned playlist work is Stage 13. +- Automatic multi-UUID failure-based failover selection. +- Snapshot, waveform, vectorscope, and advanced diagnostics. +- A large generic media framework. +- `mxlfabrics` implementation before the local player passes resilience tests. +- Unrelated renderer or GUI redesign. + +## Working method + +For every stage: + +1. Agree on the narrow change and expected behaviour. +2. Make the change without pulling later-stage responsibilities forward. +3. Format and run focused tests. +4. Run the relevant manual smoke test. +5. Review ownership, cancellation, and error handling. +6. Record discoveries or revised decisions in this document. +7. Commit the stage separately when accepted. diff --git a/cmd/mxl-audio/main.go b/cmd/mxl-audio/main.go deleted file mode 100644 index d1bde61..0000000 --- a/cmd/mxl-audio/main.go +++ /dev/null @@ -1,144 +0,0 @@ -package main - -import ( - "context" - "flag" - "fmt" - "log" - "os" - "os/signal" - "syscall" - "time" - - "mxl-player/internal/sdl" - "mxl-player/internal/source" -) - -func main() { - domain := flag.String("d", "/dev/shm/mxl", "MXL domain") - flowID := flag.String("a", "", "MXL audio flow UUID") - list := flag.Bool("l", false, "List playback audio devices and exit") - audioDeviceId := flag.Uint("ad", uint(sdl.AudioDeviceDefaultPlayback), "Audio device SDL id") - flag.Parse() - - if err := sdl.Load(); err != nil { - log.Fatal(err) - } - if !sdl.Init(sdl.InitVideo | sdl.InitAudio) { - log.Fatalf("SDL_Init: %s", sdl.GetError()) - } - defer sdl.Quit() - - if *list { - for _, d := range sdl.GetAudioPlaybackDevices() { - fmt.Printf("%d: %s\n", d.ID, d.Name) - } - return - } - if *flowID == "" { - log.Fatal("missing -a ") - } - - src, err := source.OpenAudio(*domain, *flowID) - if err != nil { - log.Fatal(err) - } - defer src.Close() - - rate := src.Rate() - chans := src.Channels() - fmt.Printf("audio: %dch %d/%d Hz\n", chans, rate.Num, rate.Den) - - stream := sdl.OpenAudioDeviceStream(uint32(*audioDeviceId), sdl.AudioSpec{ - Format: sdl.AudioF32, - Channels: int32(chans), - Freq: int32(rate.Num / rate.Den), - }) - if stream == 0 { - log.Fatalf("OpenAudioDeviceStream: %s", sdl.GetError()) - } - defer sdl.DestroyAudioStream(stream) - - if !sdl.ResumeAudioStreamDevice(stream) { - log.Fatalf("ResumeAudioStreamDevice: %s", sdl.GetError()) - } - - // ~10ms batch: sampleRate / 100 - batch := uint64(rate.Num / (100 * rate.Den)) - if batch == 0 { - batch = 1 - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - stop := make(chan os.Signal, 1) - signal.Notify(stop, os.Interrupt, syscall.SIGTERM) - - go func() { - <-stop - cancel() - }() - - var debugCount int - for { - select { - case <-ctx.Done(): - fmt.Println("\nstopped") - return - default: - } - - // Backpressure: if SDL has > 200ms buffered, wait for it to drain. - queued := sdl.GetAudioStreamQueued(stream) - maxQueued := int32(rate.Num/(100*rate.Den)) * int32(chans) * 4 * 20 // 200ms - if queued > maxQueued { - time.Sleep(10 * time.Millisecond) - continue - } - - f, err := src.NextAudio(ctx, batch, 20*time.Millisecond) - if err != nil { - if ctx.Err() != nil { - fmt.Println("\nstopped") - return - } - log.Printf("audio read: %v", err) - continue - } - - // Debug: scan for non-zero samples - if debugCount < 5 { - sizes := make([]int, len(f.Samples)) - nonZero := 0 - for i, s := range f.Samples { - sizes[i] = len(s) - for _, b := range s { - if b != 0 { - nonZero++ - } - } - } - fmt.Printf("read idx=%d batch=%d sampleSizes=%v queued=%d nonZeroBytes=%d\n", - f.Index, batch, sizes, queued, nonZero) - debugCount++ - } - - // Interleave per-channel Float32 into a single buffer. - frameBytes := int(batch) * int(chans) * 4 - interleaved := make([]byte, frameBytes) - for ch := uint64(0); ch < chans; ch++ { - srcBytes := f.Samples[ch] - for i := uint64(0); i < batch; i++ { - srcOff := i * 4 - dstOff := (i*chans + ch) * 4 - if srcOff+4 <= uint64(len(srcBytes)) { - copy(interleaved[dstOff:dstOff+4], srcBytes[srcOff:srcOff+4]) - } - } - } - - if !sdl.PutAudioStreamData(stream, interleaved) { - log.Printf("PutAudioStreamData: %s", sdl.GetError()) - } - } -} diff --git a/cmd/mxl-player/config.go b/cmd/mxl-player/config.go new file mode 100644 index 0000000..56592eb --- /dev/null +++ b/cmd/mxl-player/config.go @@ -0,0 +1,39 @@ +package main + +import ( + "mxl-player/internal/playback" + "time" +) + +const ( + defaultInitialRetryDelay = 500 * time.Millisecond + defaultMaxRetryDelay = 10 * time.Second +) + +func resolveDomain(shared, override string) string { + if override != "" { + return override + } + return shared +} + +func (a appArgs) playbackConfig() playback.SessionConfig { + return playback.SessionConfig{ + Video: playback.FeedConfig{ + Domain: resolveDomain(a.Domain, a.VideoDomain), + UUID: a.VideoFlowId, + Active: a.VideoFlowId != "", + }, + Audio: playback.FeedConfig{ + Domain: resolveDomain(a.Domain, a.AudioDomain), + UUID: a.AudioFlowId, + Active: a.AudioFlowId != "", + }, + SyncRequested: a.SyncRequested, + Retry: playback.RetryPolicy{ + MaxAttempts: a.MaxAttempts, + InitialDelay: defaultInitialRetryDelay, + MaxDelay: defaultMaxRetryDelay, + }, + } +} diff --git a/cmd/mxl-player/config_test.go b/cmd/mxl-player/config_test.go new file mode 100644 index 0000000..06b0f8c --- /dev/null +++ b/cmd/mxl-player/config_test.go @@ -0,0 +1,141 @@ +package main + +import "testing" + +func TestAppArgsPlaybackConfig(t *testing.T) { + tests := []struct { + name string + args appArgs + wantVideoDomain string + wantAudioDomain string + wantVideoActive bool + wantAudioActive bool + wantSyncRequested bool + wantMaxAttempts int + }{ + { + name: "shared domain applies to both feeds", + args: appArgs{ + Domain: "/dev/shm/mxl", + VideoFlowId: "video-uuid", + AudioFlowId: "audio-uuid", + }, + wantVideoDomain: "/dev/shm/mxl", + wantAudioDomain: "/dev/shm/mxl", + wantVideoActive: true, + wantAudioActive: true, + }, + { + name: "video domain overrides shared domain", + args: appArgs{ + Domain: "/dev/shm/default", + VideoDomain: "/dev/shm/video", + VideoFlowId: "video-uuid", + AudioFlowId: "audio-uuid", + }, + wantVideoDomain: "/dev/shm/video", + wantAudioDomain: "/dev/shm/default", + wantVideoActive: true, + wantAudioActive: true, + }, + { + name: "audio domain overrides shared domain", + args: appArgs{ + Domain: "/dev/shm/default", + AudioDomain: "/dev/shm/audio", + VideoFlowId: "video-uuid", + AudioFlowId: "audio-uuid", + }, + wantVideoDomain: "/dev/shm/default", + wantAudioDomain: "/dev/shm/audio", + wantVideoActive: true, + wantAudioActive: true, + }, + { + name: "audio and video use different domain overrides", + args: appArgs{ + Domain: "/dev/shm/default", + VideoDomain: "/dev/shm/video", + AudioDomain: "/dev/shm/audio", + VideoFlowId: "video-uuid", + AudioFlowId: "audio-uuid", + SyncRequested: true, + MaxAttempts: 5, + }, + wantVideoDomain: "/dev/shm/video", + wantAudioDomain: "/dev/shm/audio", + wantVideoActive: true, + wantAudioActive: true, + wantSyncRequested: true, + wantMaxAttempts: 5, + }, + { + name: "no UUIDs produce inactive slots", + args: appArgs{ + Domain: "/dev/shm/mxl", + }, + wantVideoDomain: "/dev/shm/mxl", + wantAudioDomain: "/dev/shm/mxl", + }, + { + name: "video UUID activates only video", + args: appArgs{ + Domain: "/dev/shm/mxl", + VideoFlowId: "video-uuid", + }, + wantVideoDomain: "/dev/shm/mxl", + wantAudioDomain: "/dev/shm/mxl", + wantVideoActive: true, + }, + { + name: "audio UUID activates only audio", + args: appArgs{ + Domain: "/dev/shm/mxl", + AudioFlowId: "audio-uuid", + }, + wantVideoDomain: "/dev/shm/mxl", + wantAudioDomain: "/dev/shm/mxl", + wantAudioActive: true, + }, + { + name: "unlimited attempts and disabled sync are preserved", + args: appArgs{ + Domain: "/dev/shm/mxl", + VideoFlowId: "video-uuid", + SyncRequested: false, + MaxAttempts: 0, + }, + wantVideoDomain: "/dev/shm/mxl", + wantAudioDomain: "/dev/shm/mxl", + wantVideoActive: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.args.playbackConfig() + + if got.Video.Domain != tt.wantVideoDomain { + t.Errorf("video domain = %q, want %q", got.Video.Domain, tt.wantVideoDomain) + } + if got.Audio.Domain != tt.wantAudioDomain { + t.Errorf("audio domain = %q, want %q", got.Audio.Domain, tt.wantAudioDomain) + } + if got.Video.Active != tt.wantVideoActive { + t.Errorf("video active = %t, want %t", got.Video.Active, tt.wantVideoActive) + } + if got.Audio.Active != tt.wantAudioActive { + t.Errorf("audio active = %t, want %t", got.Audio.Active, tt.wantAudioActive) + } + if got.SyncRequested != tt.wantSyncRequested { + t.Errorf("sync requested = %t, want %t", got.SyncRequested, tt.wantSyncRequested) + } + if got.Retry.MaxAttempts != tt.wantMaxAttempts { + t.Errorf("max attempts = %d, want %d", got.Retry.MaxAttempts, tt.wantMaxAttempts) + } + if err := got.Validate(); err != nil { + t.Fatalf("playbackConfig().Validate() returned error: %v", err) + } + }) + } +} diff --git a/cmd/mxl-player/main.go b/cmd/mxl-player/main.go index 73ff4eb..9d7e21d 100644 --- a/cmd/mxl-player/main.go +++ b/cmd/mxl-player/main.go @@ -7,9 +7,9 @@ import ( "io" "log" "mxl-player/internal/imgui" + "mxl-player/internal/playback" "mxl-player/internal/renderer" "mxl-player/internal/sdl" - "mxl-player/internal/source" "os" "runtime" "time" @@ -27,23 +27,40 @@ const ( WIN_HEIGHT int32 = 720 ) +const ( + placeholderWidth uint32 = 1 + placeholderHeight uint32 = 1 + placeholderStride uint32 = 4 +) + +const ( + initialRetryDelay = 500 * time.Millisecond + maxRetryDelay = 5 * time.Second +) + type appArgs struct { - ShowHelp bool - Domain string - VideoFlowId string - AudioFlowId string - IsFullscreen bool - PlaybackId uint32 - GpuId uint32 - IsVerbose bool - ListAudio bool - ListGPU bool + ShowHelp bool + Domain string + VideoDomain string + AudioDomain string + VideoFlowId string + AudioFlowId string + IsFullscreen bool + PlaybackId uint32 + GpuId uint32 + IsVerbose bool + ListAudio bool + ListGPU bool + SyncRequested bool + MaxAttempts int + PlaylistPath string } func printCliHelp(fs *pflag.FlagSet) { fmt.Printf("%s %s\n", APP_NAME, APP_VER) - fmt.Println("Usage: mxl-player -d (-v | -a ) [-f] [-h]") + fmt.Println("Usage: mxl-player [-d ] [-v ] [-a ] [options]") fmt.Println(" [-g ] [-p ] [--verbose]") + fmt.Println(" or: mxl-player [--playlist (-v | -a ) [-f] [-h]") + fmt.Fprintln(w, "Usage: mxl-player [-d ] [-v ] [-a ] [options]") fmt.Fprintln(w, "Try 'mxl-player -h' for more information.") } func checkMXLargs(args appArgs) { - if args.Domain == "" { - fmt.Fprintln(os.Stderr, "You should provide valid MXL domain and UUID of at least one flow") - printUsage(os.Stderr) - os.Exit(2) + checkDomain := func(label, domain string) { + if domain == "" { + fmt.Fprintf(os.Stderr, "%s domain is required when its UUID is configured\n", label) + printUsage(os.Stderr) + os.Exit(2) + } + info, err := os.Stat(domain) + if err != nil || !info.IsDir() { + fmt.Fprintf(os.Stderr, "Invalid %s MXL domain: %s\n", label, domain) + fmt.Fprintln(os.Stderr, "Domain must be a directory in tmpfs") + printUsage(os.Stderr) + os.Exit(2) + } } - fi, err := os.Stat(args.Domain) - if err != nil || !fi.IsDir() { - fmt.Fprintln(os.Stderr, "Invalid MXL domain:", args.Domain) - fmt.Fprintln(os.Stderr, "Domain must be a directory in tmpfs") - printUsage(os.Stderr) - os.Exit(2) + if args.VideoFlowId != "" { + checkDomain("video", args.VideoDomain) } - if args.VideoFlowId == "" && args.AudioFlowId == "" { - fmt.Fprintln(os.Stderr, "You must provide at least 1 MXL flow UUID") - printUsage(os.Stderr) - os.Exit(2) + if args.AudioFlowId != "" { + checkDomain("audio", args.AudioDomain) } } @@ -81,13 +101,42 @@ func main() { // timelapse audio: 5fbec3b1-1b0f-417d-9059-8b94a47197ec // f1 video: 5fbec3b1-1b0f-417d-9059-8b94a47197ef // f1 audio: 5fbec3b1-1b0f-417d-9059-8b94a47197eb + // sync video: 2618979d-76a5-45e0-83cb-0f192978d1cd + // sync audio: 9d2a041b-01cf-4ee4-bffa-188fe093c99b var args appArgs flagSet := pflag.NewFlagSet(APP_NAME, pflag.ContinueOnError) + flagSet.SortFlags = false flagSet.Usage = func() { printUsage(os.Stderr) } flagSet.BoolVarP(&args.ShowHelp, "help", "h", false, "Show help message and exit") - flagSet.StringVarP(&args.Domain, "domain", "d", "", "MXL domain directory") + flagSet.StringVarP( + &args.Domain, + "domain", + "d", + "", + "Default MXL domain for feeds without a specific domain", + ) + flagSet.StringVar(&args.VideoDomain, "video-domain", "", "MXL domain for the video feed") + flagSet.StringVar(&args.AudioDomain, "audio-domain", "", "MXL domain for the audio feed") flagSet.StringVarP(&args.VideoFlowId, "video", "v", "", "Video flow UUID") flagSet.StringVarP(&args.AudioFlowId, "audio", "a", "", "Audio flow UUID") + flagSet.BoolVarP( + &args.SyncRequested, + "sync", "s", + false, + "Start configured audio and video as a synchronized group", + ) + flagSet.IntVar( + &args.MaxAttempts, + "max-attempts", + 0, + "Maximum connection attempts per playback lifecycle; 0 retries indefinitely", + ) + flagSet.StringVar( + &args.PlaylistPath, + "playlist", + "", + "Load playlist from a JSON file", + ) flagSet.BoolVarP(&args.IsFullscreen, "fullscreen", "f", false, "Run app in fullscreen mode") flagSet.Uint32VarP(&args.GpuId, "gpu-id", "g", 0, "GPU id [TODO]") flagSet.Uint32VarP(&args.PlaybackId, "playback-id", "p", 0, "Playback audio device id") @@ -104,11 +153,39 @@ func main() { printCliHelp(flagSet) return } + if args.MaxAttempts < 0 { + fmt.Fprintln(os.Stderr, "--max-attempts cannot be negative") + printUsage(os.Stderr) + os.Exit(2) + } + retryPolicy := playback.RetryPolicy{ + MaxAttempts: args.MaxAttempts, + InitialDelay: initialRetryDelay, + MaxDelay: maxRetryDelay, + } + if err := retryPolicy.Validate(); err != nil { + fmt.Fprintln(os.Stderr, "invalid retry configuration:", err) + os.Exit(2) + } + configuredPlaylist := playback.Playlist{} + hasPlaylist := args.PlaylistPath != "" + if hasPlaylist { + playlist, err := loadPlaylistFile(args.PlaylistPath) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + configuredPlaylist = playlist + } + if args.VideoDomain == "" { + args.VideoDomain = args.Domain + } + if args.AudioDomain == "" { + args.AudioDomain = args.Domain + } if !args.ListAudio && !args.ListGPU { checkMXLargs(args) } - // end of cli args parse - runtime.LockOSThread() if err := sdl.Load(); err != nil { panic(err) @@ -143,6 +220,19 @@ func main() { // ImGui init gui := imgui.New() defer gui.Destroy() + fontConfig := cimgui.NewFontConfig() + font := gui.IO().Fonts().AddFontFromFileTTFV( + "/home/itten/Downloads/JetBrainsMono/JetBrainsMonoNLNerdFontMono-Regular.ttf", + 18, + fontConfig, + nil, + ) + fontConfig.Destroy() + if font == nil || font.CData == nil { + log.Fatal("failed to load ImGui font") + } + gui.IO().SetFontDefault(font) + // sdl keys handler sdl.StartTextInput(windowHandler) defer sdl.StopTextInput(windowHandler) // fin on ImGui init @@ -165,7 +255,7 @@ func main() { Layers: vkLayers, }) if err != nil { - log.Fatalf(err.Error()) + log.Fatalf("%s", err) panic(err) } defer vkInstance.Destroy() @@ -212,396 +302,187 @@ func main() { } defer vkDevice.Destroy() - var ( - syncSrc *source.SyncSource - videoSrc *source.Source - audioSrc *source.AudioSource - audioStream uintptr - audioBatch uint64 - aChans uint64 + // Create renderer + r, err := renderer.New(renderer.Config{ + PhysDevice: vkPhysDevice, + Device: vkDevice, + Queue: vkQueue, + Surface: vkSurf, + Window: windowHandler, + GraphicsFamily: gfx, + VideoWidth: placeholderWidth, + VideoHeight: placeholderHeight, + VideoStride: placeholderStride, + }) + if err != nil { + panic(err) + } + defer r.Destroy() + // Create GUI backend + guiBackend, err := imgui.NewVulkanBackend( + vkPhysDevice, + vkDevice, + vkQueue, + r.CmdPool(), + r.RenderPass(), ) - - interleaveAudio := func(samples [][]byte) []byte { - frameBytes := int(audioBatch) * int(aChans) * 4 - out := make([]byte, frameBytes) - for ch := uint64(0); ch < aChans; ch++ { - srcBytes := samples[ch] - for i := uint64(0); i < audioBatch; i++ { - srcOff := i * 4 - dstOff := (i*aChans + ch) * 4 - if srcOff+4 <= uint64(len(srcBytes)) { - copy(out[dstOff:dstOff+4], srcBytes[srcOff:srcOff+4]) - } - } - } - return out + if err != nil { + panic(err) } - - switch { - case args.VideoFlowId != "" && args.AudioFlowId != "": - syncSrc, err = source.OpenSync(args.Domain, args.VideoFlowId, args.AudioFlowId) - if err != nil { - log.Fatalf("sync source: %v", err) - } - aChans = syncSrc.Channels() - audioBatch = uint64(syncSrc.AudioRate().Num) / uint64(syncSrc.Rate().Num) - if audioBatch == 0 { - audioBatch = 1 - } - audioStream = sdl.OpenAudioDeviceStream(sdlAudioDevice, sdl.AudioSpec{ - Format: sdl.AudioF32, - Channels: int32(aChans), - Freq: int32(syncSrc.AudioRate().Num / syncSrc.AudioRate().Den), - }) - if audioStream == 0 { - log.Fatalf("audio: %s", sdl.GetError()) - } - sdl.ResumeAudioStreamDevice(audioStream) - fmt.Printf("sync: video %dx%d audio %dch batch=%d\n", - syncSrc.Width(), syncSrc.Height(), aChans, audioBatch) - case args.VideoFlowId != "": - videoSrc, err = source.Open(args.Domain, args.VideoFlowId) - if err != nil { - log.Fatalf("source: %v", err) - } - fmt.Printf("video: %dx%d stride=%d\n", videoSrc.Width(), videoSrc.Height(), videoSrc.Stride()) - - default: - audioSrc, err = source.OpenAudio(args.Domain, args.AudioFlowId) - if err != nil { - log.Fatalf("audio source: %v", err) - } - aChans = audioSrc.Channels() - audioBatch = uint64(audioSrc.Rate().Num) / (100 * uint64(audioSrc.Rate().Den)) - if audioBatch == 0 { - audioBatch = 1 - } - audioStream = sdl.OpenAudioDeviceStream(sdlAudioDevice, sdl.AudioSpec{ - Format: sdl.AudioF32, - Channels: int32(aChans), - Freq: int32(audioSrc.Rate().Num / audioSrc.Rate().Den), - }) - if audioStream == 0 { - log.Fatalf("audio: %s", sdl.GetError()) - } - sdl.ResumeAudioStreamDevice(audioStream) - fmt.Printf("audio: %dch %d/%d Hz\n", aChans, audioSrc.Rate().Num, audioSrc.Rate().Den) + defer guiBackend.Destroy() + r.ImGuiDraw = func(cmd vk.CommandBuffer) { + guiBackend.RecordDraw(cmd, gui.LastDrawData()) } - - defer func() { - if syncSrc != nil { - _ = syncSrc.Close() - } - if videoSrc != nil { - _ = videoSrc.Close() - } - if audioSrc != nil { - _ = audioSrc.Close() - } - }() - if audioStream != 0 { - defer sdl.DestroyAudioStream(audioStream) - } - - var r *renderer.Renderer - if args.VideoFlowId != "" { - var w, h, stride uint32 - if syncSrc != nil { - w, h, stride = syncSrc.Width(), syncSrc.Height(), syncSrc.Stride() - } else { - w, h, stride = videoSrc.Width(), videoSrc.Height(), videoSrc.Stride() - } - r, err = renderer.New(renderer.Config{ - PhysDevice: vkPhysDevice, - Device: vkDevice, - Queue: vkQueue, - Surface: vkSurf, - Window: windowHandler, - GraphicsFamily: gfx, - VideoWidth: w, - VideoHeight: h, - VideoStride: stride, - }) - if err != nil { - panic(err) - } - defer r.Destroy() - guiBackend, err := imgui.NewVulkanBackend( - vkPhysDevice, vkDevice, vkQueue, r.CmdPool(), r.RenderPass()) - if err != nil { - panic(err) - } - defer guiBackend.Destroy() - r.ImGuiDraw = func(cmd vk.CommandBuffer) { - guiBackend.RecordDraw(cmd, gui.LastDrawData()) - } - defer vkDevice.WaitIdle() + if err := r.StageFrame( + []byte{0, 0, 0, 255}, + placeholderWidth, + placeholderHeight, + placeholderStride, + ); err != nil { + panic(err) } + defer vkDevice.WaitIdle() // GUI state (accessible from doReconnect + goroutine) var ( - domainStr string = args.Domain - videoStr string = args.VideoFlowId - audioStr string = args.AudioFlowId - showStats bool = true + videoDomainStr string = args.VideoDomain + audioDomainStr string = args.AudioDomain + videoStr string = args.VideoFlowId + audioStr string = args.AudioFlowId + showStats bool = false ) + videoActive := args.VideoFlowId != "" + audioActive := args.AudioFlowId != "" ctx, cancel := context.WithCancel(context.Background()) defer cancel() - type reconnectParams struct { - domain string - video string - audio string + player, err := newPlayerPlayback(sdlAudioDevice, retryPolicy) + if err != nil { + panic(err) } - // One control channel: grant (empty params) or reconnect (with params). - control := make(chan reconnectParams, 1) - staged := make(chan uint64) + videoBridge := player.Video + statusStore := player.Status + syncRequested := args.SyncRequested - reopen := func(params reconnectParams) error { - // Close current sources - if syncSrc != nil { - _ = syncSrc.Close() - syncSrc = nil - } - if videoSrc != nil { - _ = videoSrc.Close() - videoSrc = nil - } - if audioSrc != nil { - _ = audioSrc.Close() - audioSrc = nil - } - // Try once. Return error if fails — caller loops back to select - // and can pick up new reconnect params or a new grant. - if params.video != "" && params.audio != "" { - s, e := source.OpenSync(params.domain, params.video, params.audio) - if e == nil { - if r != nil { - newSize := vk.DeviceSize(s.Stride()) * vk.DeviceSize(s.Height()) - if newSize != r.FrameSize() { - if e = r.RecreateBuffers(newSize); e != nil { - return e - } - } - } - syncSrc = s - aChans = s.Channels() - audioBatch = uint64(s.AudioRate().Num) / uint64(s.Rate().Num) - if audioBatch == 0 { - audioBatch = 1 - } - return nil - } - return e - } else if params.video != "" { - s, e := source.Open(params.domain, params.video) - if e == nil { - if r != nil { - newSize := vk.DeviceSize(s.Stride()) * vk.DeviceSize(s.Height()) - if newSize != r.FrameSize() { - if e = r.RecreateBuffers(newSize); e != nil { - return e - } - } - } - videoSrc = s - return nil - } - return e - } else if params.audio != "" { - s, e := source.OpenAudio(params.domain, params.audio) - if e == nil { - audioSrc = s - aChans = s.Channels() - audioBatch = uint64(s.Rate().Num) / (100 * uint64(s.Rate().Den)) - if audioBatch == 0 { - audioBatch = 1 - } - return nil - } - return e - } - return fmt.Errorf("reopen: no flow specified") - } - - doReconnect := func() { + enqueueCommand := func(command playback.SessionCommand) { select { - case <-control: + case player.Commands <- command: default: + log.Printf("playback command queue is full; ignoring command %d", command.Kind) + } + } + doReconnect := func() { + videoActive = videoStr != "" + audioActive = audioStr != "" + + videoConfig := playback.FeedConfig{} + if videoActive { + videoConfig = playback.FeedConfig{ + Domain: videoDomainStr, + UUID: videoStr, + Active: true, + } + } + audioConfig := playback.FeedConfig{} + if audioActive { + audioConfig = playback.FeedConfig{ + Domain: audioDomainStr, + UUID: audioStr, + Active: true, + } + } + + enqueueCommand(playback.SessionCommand{ + Kind: playback.CommandSetSession, + Session: playback.SessionConfig{ + Video: videoConfig, + Audio: audioConfig, + SyncRequested: syncRequested, + }, + }) + } + drawUnitStatus := func(label string, unit playback.Unit) { + status, ok := statusStore.Snapshot(unit) + if !ok { + cimgui.Text(fmt.Sprintf("%s: not started", label)) + return + } + cimgui.Text(fmt.Sprintf("%s: %s", label, status.State)) + cimgui.Text(fmt.Sprintf( + "Attempt: %d, failed: %d", + status.Attempt, + status.FailedAttempts, + )) + if status.RetryIn > 0 { + cimgui.Text(fmt.Sprintf( + "Retry in: %s", + status.RetryIn.Round(time.Millisecond), + )) + } + if status.Err != nil { + cimgui.TextWrapped(status.Err.Error()) } - control <- reconnectParams{domain: domainStr, video: videoStr, audio: audioStr} } + playbackDone := make(chan error, 1) go func() { - // Audio-only mode: independent loop, no grant/staged handshake. - if audioSrc != nil && syncSrc == nil && videoSrc == nil { - for { - select { - case <-ctx.Done(): - return - case params := <-control: - if params.video != "" || params.audio != "" { - if rerr := reopen(params); rerr != nil { - if errors.Is(rerr, context.Canceled) { - return - } - log.Printf("source: reopen failed: %v, retrying", rerr) - select { - case <-time.After(500 * time.Millisecond): - case <-ctx.Done(): - return - } - select { - case control <- params: - default: - } - } - } - continue - default: - } - queued := sdl.GetAudioStreamQueued(audioStream) - maxQueued := int32(audioBatch) * int32(aChans) * 4 * 20 - if queued > maxQueued { - select { - case <-time.After(10 * time.Millisecond): - case <-ctx.Done(): - return - } - continue - } - f, err := audioSrc.NextAudio(ctx, audioBatch, 20*time.Millisecond) - if err != nil { - if errors.Is(err, context.Canceled) { - return - } - log.Printf("source: %v", err) - params := reconnectParams{domain: domainStr, video: videoStr, audio: audioStr} - select { - case <-control: - default: - } - select { - case <-time.After(500 * time.Millisecond): - case <-ctx.Done(): - return - } - select { - case control <- params: - default: - } - continue - } - if f.Samples != nil && audioStream != 0 { - sdl.PutAudioStreamData(audioStream, interleaveAudio(f.Samples)) - } - } - } - - // Video (with or without sync) mode: grant/staged handshake. - for { - params := <-control - if params.video != "" || params.audio != "" { - // Reconnect request from Connect button or auto-retry. - select { - case <-control: // drain any pending grant - default: - } - if rerr := reopen(params); rerr != nil { - if errors.Is(rerr, context.Canceled) { - return - } - log.Printf("source: reopen failed: %v, retrying", rerr) - select { - case <-time.After(500 * time.Millisecond): - case <-ctx.Done(): - return - } - select { - case control <- params: - default: - } - } - continue - } - - var payload []byte - var grainIdx uint64 - - if syncSrc != nil { - vFrame, aFrame, err := syncSrc.NextSync(ctx, audioBatch, 200*time.Millisecond) - if err != nil { - if errors.Is(err, context.Canceled) { - return - } - log.Printf("source: %v", err) - // Drain any pending grant, then send reconnect. - select { - case <-control: - default: - } - select { - case control <- reconnectParams{domain: domainStr, video: videoStr, audio: audioStr}: - case <-ctx.Done(): - return - } - continue - } - payload = vFrame.Payload - grainIdx = vFrame.Index - if aFrame.Samples != nil && audioStream != 0 { - sdl.PutAudioStreamData(audioStream, interleaveAudio(aFrame.Samples)) - } - - } else if videoSrc != nil { - f, err := videoSrc.NextCtx(ctx, 200*time.Millisecond) - if err != nil { - if errors.Is(err, context.Canceled) { - return - } - log.Printf("source: %v", err) - select { - case <-control: - default: - } - select { - case control <- reconnectParams{domain: domainStr, video: videoStr, audio: audioStr}: - case <-ctx.Done(): - return - } - continue - } - payload = f.Payload - grainIdx = f.Index - } - - if r != nil { - vk.CopyToMapped(r.StagingMapped(), payload) - } - select { - case staged <- grainIdx: - case <-ctx.Done(): - return - } - } + playbackDone <- player.Controller.Run( + ctx, + args.playbackConfig(), + player.Commands, + ) }() + var playlistRuntime *playerPlaylist + var playlistDone chan error + if hasPlaylist { + playlistRuntime, err = newPlayerPlaylist( + configuredPlaylist, + retryPolicy, + player, + ) + if err != nil { + panic(err) + } + playlistDone = make(chan error, 1) + go func() { + playlistDone <- playlistRuntime.Run(ctx) + }() + + if shouldAutoStartPlaylist(args, configuredPlaylist) && + !playlistRuntime.Select(0) { + log.Print("playlist command queue is full") + } + } + running := true resized := false - granted := false fullscreen := args.IsFullscreen if fullscreen { sdl.SetWindowFullscreen(windowHandler, true) } var ( - fps float64 - lastIndex uint64 - dropped uint64 - frameCount uint64 - lastReport time.Time - lastFrame time.Time + displayedVideoWidth uint32 = placeholderWidth + displayedVideoHeight uint32 = placeholderHeight + displayedVideoStride uint32 = placeholderStride + + fps float64 + dropTracker videoDropTracker + dropped uint64 + droppedTotal uint64 + frameCount uint64 + lastReport time.Time + lastFrame time.Time + renderLoopDT time.Duration ) lastFrame = time.Now() + lastReport = lastFrame + + // ImGui + var ( + settingWindowWidth float32 = 700 + settingsWindowState bool = true + ) for running { frameStart := time.Now() @@ -628,6 +509,8 @@ func main() { sdl.SetWindowFullscreen(windowHandler, fullscreen) resized = true case sdl.KeyF1: + settingsWindowState = !settingsWindowState + case sdl.KeyF2: showStats = !showStats } } @@ -646,37 +529,54 @@ func main() { } resized = false } - if !granted { - select { - case control <- reconnectParams{}: - granted = true - case <-ctx.Done(): - running = false - continue - } - } var shownIndex uint64 + var shownGeneration uint64 + var shownSource playback.FeedConfig hasFrame := false - select { - case shownIndex = <-staged: - granted = false + + frameCtx, frameCancel := context.WithTimeout(ctx, 100*time.Millisecond) + pendingFrame, frameErr := videoBridge.Next(frameCtx) + frameCancel() + + if pendingFrame != nil { + var stageErr error + if r != nil { + stageErr = r.StageFrame( + pendingFrame.Frame.Payload, + pendingFrame.Frame.Width, + pendingFrame.Frame.Height, + pendingFrame.Frame.Stride, + ) + } + + // Release the borrowed payload before reacting to a staging error + pendingFrame.Complete(stageErr) + + if stageErr != nil { + panic(stageErr) + } + + shownIndex = pendingFrame.Frame.Index + shownGeneration = pendingFrame.Generation + shownSource = pendingFrame.Source + displayedVideoWidth = pendingFrame.Frame.Width + displayedVideoHeight = pendingFrame.Frame.Height + displayedVideoStride = pendingFrame.Frame.Stride hasFrame = true - case <-ctx.Done(): - running = false - continue - case <-time.After(100 * time.Millisecond): - // No frame staged. Reset granted so we re-grant on next iteration. - granted = false + } else if frameErr != nil && + !errors.Is(frameErr, context.DeadlineExceeded) && + !errors.Is(frameErr, context.Canceled) { + panic(frameErr) } + snapshot, hasSnapshot := player.Controller.Snapshot() + // stats if hasFrame { - if lastIndex != 0 && shownIndex > lastIndex { - if g := shownIndex - lastIndex - 1; g > 0 { - dropped += g - } + if gap := dropTracker.Observe(shownGeneration, shownSource, shownIndex); gap > 0 { + dropped += gap + droppedTotal += gap } - lastIndex = shownIndex frameCount++ if now := time.Now(); now.Sub(lastReport) >= time.Second { dt := now.Sub(lastReport).Seconds() @@ -691,49 +591,433 @@ func main() { // end of stats if r != nil { gui.BeginFrame(time.Since(lastFrame), int32(r.Extent().Width), int32(r.Extent().Height)) - // test widget - // cimgui.Begin("Test") if showStats { - cimgui.SetNextWindowPos(cimgui.Vec2{X: 10, Y: 10}) - cimgui.SetNextWindowSize(cimgui.Vec2{X: 200, Y: 200}) - cimgui.BeginV("Stats", &showStats, - cimgui.WindowFlagsNoTitleBar|cimgui.WindowFlagsNoResize|cimgui.WindowFlagsNoScrollbar) - cimgui.Text(fmt.Sprintf("FPS: %.1f", fps)) - cimgui.Text(fmt.Sprintf("Dropped: %d", dropped)) - cimgui.Text(fmt.Sprintf("Index: %d", shownIndex)) - if videoSrc != nil { - cimgui.Text(fmt.Sprintf("Video: %dx%d %.2fp", videoSrc.Width(), videoSrc.Height(), - float32(videoSrc.Rate().Num/videoSrc.Rate().Den))) + cimgui.SetNextWindowPos(cimgui.Vec2{X: 0, Y: 0}) + cimgui.SetNextWindowSize(cimgui.Vec2{X: 460, Y: 510}) + cimgui.BeginV("Stats", &showStats, cimgui.WindowFlagsNoResize|cimgui.WindowFlagsNoScrollbar) + mediaStats := player.MediaStats.Snapshot() + cimgui.SeparatorText("Video") + if mediaStats.Video.Available { + label := mediaStats.Video.Label + if label == "" { + label = "(no label)" + } + cimgui.TextWrapped(fmt.Sprintf("Label: %s", label)) + if hasSnapshot && snapshot.Desired.Video.IsConfigured() { + cimgui.TextWrapped(fmt.Sprintf("Domain: %s", snapshot.Desired.Video.Domain)) + cimgui.TextWrapped(fmt.Sprintf("UUID: %s", snapshot.Desired.Video.UUID)) + } + cimgui.Text(fmt.Sprintf( + "Resolution: %dx%d (stride %d)", + mediaStats.Video.Width, + mediaStats.Video.Height, + mediaStats.Video.Stride, + )) + cimgui.Text(fmt.Sprintf( + "FPS: flow %.2f | received %.1f | displayed %.1f", + mediaStats.Video.DeclaredFPS, + mediaStats.Video.ReceivedFPS, + fps, + )) + cimgui.Text(fmt.Sprintf( + "Frame dt: source %.1f ms | render loop %.1f ms", + float64(mediaStats.Video.FrameDT.Microseconds())/1000, + float64(renderLoopDT.Microseconds())/1000, + )) + cimgui.Text(fmt.Sprintf( + "Index: %d | payload: %d bytes", + mediaStats.Video.Index, + mediaStats.Video.PayloadSize, + )) + cimgui.Text(fmt.Sprintf( + "Dropped: %d | invalid: %d", + droppedTotal, + mediaStats.Video.Invalid, + )) + } else { + cimgui.Text("No video frames received") } - if syncSrc != nil { - cimgui.Text(fmt.Sprintf("Video: %dx%d %.2fp", syncSrc.Width(), syncSrc.Height(), - float32(syncSrc.Rate().Num/syncSrc.Rate().Den))) - cimgui.Text(fmt.Sprintf("Audio: %dch %dkHz", syncSrc.Channels(), syncSrc.AudioRate().Num)) + + cimgui.SeparatorText("Audio") + if mediaStats.Audio.Available { + label := mediaStats.Audio.Label + if label == "" { + label = "(no label)" + } + cimgui.TextWrapped(fmt.Sprintf("Label: %s", label)) + if hasSnapshot && snapshot.Desired.Audio.IsConfigured() { + cimgui.TextWrapped(fmt.Sprintf("Domain: %s", snapshot.Desired.Audio.Domain)) + cimgui.TextWrapped(fmt.Sprintf("UUID: %s", snapshot.Desired.Audio.UUID)) + } + cimgui.Text(fmt.Sprintf( + "Format: %.3f kHz, %d channels", + mediaStats.Audio.SampleRateHz/1000, + mediaStats.Audio.Channels, + )) + cimgui.Text(fmt.Sprintf( + "Batch: %d samples (%.3f ms)", + mediaStats.Audio.SampleCount, + float64(mediaStats.Audio.BatchDuration.Microseconds())/1000, + )) + cimgui.Text(fmt.Sprintf("Index: %d", mediaStats.Audio.Index)) + } else { + cimgui.Text("No audio batches received") + } + + cimgui.SeparatorText("Runtime") + if hasSnapshot { + cimgui.Text(fmt.Sprintf( + "Topology: %s | generation %d", + snapshot.Plan.Topology, + snapshot.Generation, + )) + drawCompactStatus := func(label string, unit playback.Unit) { + status, ok := statusStore.Snapshot(unit) + if !ok { + cimgui.Text(fmt.Sprintf("%s: not started", label)) + return + } + cimgui.Text(fmt.Sprintf( + "%s: %s (attempt %d, failed %d)", + label, + status.State, + status.Attempt, + status.FailedAttempts, + )) + } + switch snapshot.Plan.Topology { + case playback.TopologySynchronized: + drawCompactStatus("Sync", playback.UnitSync) + case playback.TopologyIndependent: + if snapshot.Plan.Video.Active { + drawCompactStatus("Video", playback.UnitVideo) + } + if snapshot.Plan.Audio.Active { + drawCompactStatus("Audio", playback.UnitAudio) + } + } + } else { + cimgui.Text("Playback controller is starting") } - cimgui.Text("\nPress F1 to hide stats") - cimgui.Text("Q or Esc to quit") - cimgui.Text("F for fullscreen") cimgui.End() } - cimgui.Begin("Connection") - cimgui.InputTextWithHint("Domain", "/dev/shm/mxl", &domainStr, 0, nil) - cimgui.InputTextWithHint("Video UUID", "", &videoStr, 0, nil) - cimgui.InputTextWithHint("Audio UUID", "", &audioStr, 0, nil) - cimgui.Checkbox("Show stats", &showStats) - if cimgui.Button("Connect") { - doReconnect() + // settings & info window + videoConfigured := videoStr != "" + audioConfigured := audioStr != "" + if hasSnapshot { + videoActive = snapshot.Desired.Video.Active + audioActive = snapshot.Desired.Audio.Active + videoConfigured = snapshot.Desired.Video.IsConfigured() + audioConfigured = snapshot.Desired.Audio.IsConfigured() + syncRequested = snapshot.Desired.SyncRequested + } + + drawSettingsContents := func() { + var collapsingHeaderFlags cimgui.TreeNodeFlags = cimgui.TreeNodeFlagsDefaultOpen + drawFeedsSections := func() { + cimgui.SeparatorText("Video") + cimgui.InputTextWithHint("Video domain", "/dev/shm/mxl", &videoDomainStr, 0, nil) + cimgui.InputTextWithHint("Video UUID", "", &videoStr, 0, nil) + if videoActive { + cimgui.SameLine() + if cimgui.Button("Stop##video") { + videoActive = false + enqueueCommand(playback.SessionCommand{Kind: playback.CommandStopVideo}) + } + } + if !videoActive && videoConfigured { + cimgui.SameLine() + if cimgui.Button("Resume##video") { + videoActive = true + enqueueCommand(playback.SessionCommand{Kind: playback.CommandResumeVideo}) + } + } + if videoConfigured { + cimgui.SameLine() + if cimgui.Button("Remove##video") { + videoActive = false + videoStr = "" + enqueueCommand(playback.SessionCommand{Kind: playback.CommandRemoveVideo}) + } + } + cimgui.SeparatorText("Audio") + cimgui.InputTextWithHint("Audio domain", "/dev/shm/mxl", &audioDomainStr, 0, nil) + cimgui.InputTextWithHint("Audio UUID", "", &audioStr, 0, nil) + if audioActive { + cimgui.SameLine() + if cimgui.Button("Stop##audio") { + audioActive = false + enqueueCommand(playback.SessionCommand{Kind: playback.CommandStopAudio}) + } + } + + if !audioActive && audioConfigured { + cimgui.SameLine() + if cimgui.Button("Resume##audio") { + audioActive = true + enqueueCommand(playback.SessionCommand{Kind: playback.CommandResumeAudio}) + } + } + if audioConfigured { + cimgui.SameLine() + if cimgui.Button("Remove##audio") { + audioActive = false + audioStr = "" + enqueueCommand(playback.SessionCommand{Kind: playback.CommandRemoveAudio}) + } + } + cimgui.SeparatorText("Controls") + if cimgui.Button("Apply feeds") { + doReconnect() + } + cimgui.SameLine() + if videoActive || audioActive { + if cimgui.Button("Stop all") { + enqueueCommand(playback.SessionCommand{Kind: playback.CommandStopAll}) + } + } + if (videoConfigured && !videoActive) || (audioConfigured && !audioActive) { + if videoActive || audioActive { + cimgui.SameLine() + } + if cimgui.Button("Resume all") { + enqueueCommand(playback.SessionCommand{Kind: playback.CommandResumeAll}) + } + } + cimgui.SameLine() + if cimgui.Checkbox("Synchronize", &syncRequested) { + kind := playback.CommandDisableSync + if syncRequested { + kind = playback.CommandEnableSync + } + enqueueCommand(playback.SessionCommand{Kind: kind}) + } + cimgui.SeparatorText("Feeds stats") + cimgui.Checkbox("Show stats", &showStats) + } + if cimgui.CollapsingHeaderTreeNodeFlagsV("Feeds", collapsingHeaderFlags) { + drawFeedsSections() + } + + if playlistRuntime != nil && + cimgui.CollapsingHeaderTreeNodeFlagsV("Playlist", collapsingHeaderFlags) { + playlistSnapshot, hasPlaylistSnapshot := + playlistRuntime.Controller.Snapshot() + + cimgui.TextWrapped(fmt.Sprintf("File: %s", args.PlaylistPath)) + cimgui.Text(fmt.Sprintf("Entries: %d", len(configuredPlaylist.Entries))) + if configuredPlaylist.Loop { + cimgui.Text("End behavior: loop") + } else { + cimgui.Text("End behavior: stop") + } + + preview := "No entry selected" + if hasPlaylistSnapshot && playlistSnapshot.State.HasSelection { + preview = playlistEntryDisplayName( + playlistSnapshot.Entry, + playlistSnapshot.State.CurrentIndex, + ) + } + if cimgui.BeginCombo("Entry##playlist", preview) { + for index, entry := range configuredPlaylist.Entries { + selected := hasPlaylistSnapshot && + playlistSnapshot.State.HasSelection && + playlistSnapshot.State.CurrentIndex == index + label := fmt.Sprintf( + "%s##playlist-entry-%d", + playlistEntryDisplayName(entry, index), + index, + ) + if cimgui.SelectableBoolV( + label, + selected, + cimgui.SelectableFlagsNone, + cimgui.Vec2{}, + ) && !playlistRuntime.Select(index) { + log.Print("playlist command queue is full") + } + if selected { + cimgui.SetItemDefaultFocus() + } + } + cimgui.EndCombo() + } + + if cimgui.Button("Previous##playlist") && !playlistRuntime.Previous() { + log.Print("playlist command queue is full") + } + cimgui.SameLine() + if cimgui.Button("Next##playlist") && !playlistRuntime.Next() { + log.Print("playlist command queue is full") + } + if hasPlaylistSnapshot && + playlistSnapshot.State.HasSelection && + playlistSnapshot.Entry.Duration > 0 && + !playlistSnapshot.Timing.Expired { + cimgui.SameLine() + if playlistSnapshot.Timing.Paused { + if cimgui.Button("Resume timer##playlist") && + !playlistRuntime.Resume() { + log.Print("playlist command queue is full") + } + } else if cimgui.Button("Pause timer##playlist") && + !playlistRuntime.Pause() { + log.Print("playlist command queue is full") + } + } + + if hasPlaylistSnapshot && playlistSnapshot.State.HasSelection { + entry := playlistSnapshot.Entry + cimgui.SeparatorText("Current entry") + cimgui.Text(fmt.Sprintf( + "%d of %d: %s", + playlistSnapshot.State.CurrentIndex+1, + len(configuredPlaylist.Entries), + playlistEntryDisplayName(entry, playlistSnapshot.State.CurrentIndex), + )) + if entry.Video.IsConfigured() { + cimgui.TextWrapped(fmt.Sprintf( + "Video: %s (%s)", + entry.Video.UUID, + entry.Video.Domain, + )) + } + if entry.Audio.IsConfigured() { + cimgui.TextWrapped(fmt.Sprintf( + "Audio: %s (%s)", + entry.Audio.UUID, + entry.Audio.Domain, + )) + } + if entry.SyncRequested { + cimgui.Text("Synchronization: requested") + } else { + cimgui.Text("Synchronization: independent") + } + + switch { + case entry.Duration == 0: + cimgui.Text("Timing: manual advance") + case playlistSnapshot.Timing.Paused: + fraction, remaining := playlistTimingProgress( + playlistSnapshot.Timing, + time.Now(), + ) + cimgui.Text("Timing: paused") + cimgui.ProgressBarV( + fraction, + cimgui.Vec2{X: -1, Y: 0}, + remaining.Round(time.Second).String(), + ) + case playlistSnapshot.Timing.Started: + fraction, remaining := playlistTimingProgress( + playlistSnapshot.Timing, + time.Now(), + ) + cimgui.Text(fmt.Sprintf("Duration: %s", entry.Duration)) + cimgui.ProgressBarV( + fraction, + cimgui.Vec2{X: -1, Y: 0}, + remaining.Round(time.Second).String(), + ) + case playlistSnapshot.Timing.Expired: + cimgui.Text("Timing: finished") + default: + cimgui.Text("Timing: waiting for playback") + } + } + } + + drawHotkeysSection := func() { + cimgui.Text("F1 - show/hide settings") + cimgui.Text("F2 - show/hide stats") + cimgui.Text("F - toggle fullscreen") + cimgui.Text("Q or Esc - quit") + } + if cimgui.CollapsingHeaderTreeNodeFlagsV("Hotkeys", collapsingHeaderFlags) { + drawHotkeysSection() + } + + drawDebugSection := func() { + if hasSnapshot { + cimgui.Text(fmt.Sprintf( + "Topology: %s (generation %d)", + snapshot.Plan.Topology, + snapshot.Generation, + )) + } else { + cimgui.Text("Topology: starting") + } + if hasSnapshot && syncRequested && snapshot.Plan.Topology != playback.TopologySynchronized { + switch { + case !videoConfigured || !audioConfigured: + cimgui.TextWrapped("Sync requested: waiting for both feeds to be configured.") + case !videoActive || !audioActive: + cimgui.TextWrapped("Sync requested: waiting for both feeds to be active.") + case snapshot.Desired.Video.Domain != snapshot.Desired.Audio.Domain: + cimgui.TextWrapped("Sync requested, but native MXL sync requires matching domains. Playing independently.") + default: + cimgui.TextWrapped("Sync requested but currently unavailable. Playing independently.") + } + } + cimgui.Separator() + cimgui.Text("Current playback") + if hasSnapshot { + switch snapshot.Plan.Topology { + case playback.TopologySynchronized: + drawUnitStatus("Synchronized group", playback.UnitSync) + case playback.TopologyIndependent: + if snapshot.Plan.Video.Active { + drawUnitStatus("Video", playback.UnitVideo) + } + if snapshot.Plan.Audio.Active { + drawUnitStatus("Audio", playback.UnitAudio) + } + case playback.TopologyIdle: + cimgui.Text("No active feeds") + } + } else { + cimgui.Text("Playback controller is starting") + } + cimgui.Separator() + if videoActive { + cimgui.Text("Video desired: active") + } else if videoConfigured { + cimgui.Text("Video desired: stopped") + } else { + cimgui.Text("Video desired: not configured") + } + if audioActive { + cimgui.Text("Audio desired: active") + } else if audioConfigured { + cimgui.Text("Audio desired: stopped") + } else { + cimgui.Text("Audio desired: not configured") + } + + } + if cimgui.CollapsingHeaderTreeNodeFlagsV("Debug Info", cimgui.TreeNodeFlagsNone) { + drawDebugSection() + } + } + + if settingsWindowState { + cimgui.SetNextWindowPos(cimgui.Vec2{X: float32(r.Extent().Width) - settingWindowWidth, Y: 0}) + cimgui.SetNextWindowSize(cimgui.Vec2{X: settingWindowWidth, Y: float32(r.Extent().Height)}) + if cimgui.BeginV("Settings & Info", &settingsWindowState, cimgui.WindowFlagsNone) { + drawSettingsContents() + } + cimgui.End() } - cimgui.End() gui.EndFrame() lastFrame = time.Now() // end of test widget - var w, h, stride uint32 - if syncSrc != nil { - w, h, stride = syncSrc.Width(), syncSrc.Height(), syncSrc.Stride() - } else if videoSrc != nil { - w, h, stride = videoSrc.Width(), videoSrc.Height(), videoSrc.Stride() - } - err := r.DrawFrame(w, h, stride) + err := r.DrawFrame( + displayedVideoWidth, + displayedVideoHeight, + displayedVideoStride, + ) if errors.Is(err, renderer.ErrOutOfDate) { if rerr := r.RecreateSwapchain(); rerr != nil { if errors.Is(rerr, renderer.ErrMinimized) { @@ -747,9 +1031,23 @@ func main() { if err != nil { panic(err) } + renderLoopDT = time.Since(frameStart) } else { time.Sleep(10 * time.Millisecond) } } + + cancel() + if err := <-playbackDone; err != nil && !errors.Is(err, context.Canceled) { + log.Printf("playback controller: %v", err) + } + if playlistDone != nil { + if err := <-playlistDone; err != nil && !errors.Is(err, context.Canceled) { + log.Printf("playlist runtime: %v", err) + } + } + if err := player.Close(); err != nil { + log.Printf("close playback: %v", err) + } } diff --git a/cmd/mxl-player/playback_runtime.go b/cmd/mxl-player/playback_runtime.go new file mode 100644 index 0000000..d4deae8 --- /dev/null +++ b/cmd/mxl-player/playback_runtime.go @@ -0,0 +1,139 @@ +package main + +import ( + "log" + + mxladapter "mxl-player/internal/adapter/mxl" + "mxl-player/internal/output" + "mxl-player/internal/playback" +) + +type playerAudioSink interface { + playback.AudioSink + Close() error +} + +var _ playerAudioSink = (*output.SDLAudioSink)(nil) + +type playerPlayback struct { + Controller *playback.SessionController + Commands chan playback.SessionCommand + Video *playback.VideoBridge + Status *playback.StatusStore + MediaStats *playback.MediaStatsStore + Audio playerAudioSink +} + +func newPlayerPlayback( + audioDevice uint32, + retry playback.RetryPolicy, +) (*playerPlayback, error) { + videoBridge := playback.NewVideoBridge() + statusStore := playback.NewStatusStore() + mediaStats := playback.NewMediaStatsStore() + audioSink := output.NewSDLAudioSink(audioDevice) + videoSink := playback.VideoStatsSink{Stats: mediaStats, Sink: videoBridge} + observedAudioSink := playback.AudioStatsSink{Stats: mediaStats, Sink: audioSink} + + observe := func(status playback.Status) { + statusStore.Observe(status) + + if status.Err != nil { + log.Printf( + "%s: state=%v attempt=%d failed=%d: %v", + status.Unit, + status.State, + status.Attempt, + status.FailedAttempts, + status.Err, + ) + return + } + + log.Printf( + "%s: state=%v attempt=%d failed=%d", + status.Unit, + status.State, + status.Attempt, + status.FailedAttempts, + ) + } + + videoWorker, err := playback.NewVideoWorker( + mxladapter.VideoFactory{}, + videoSink, + retry, + mxladapter.ShouldRetry, + observe, + ) + if err != nil { + _ = audioSink.Close() + return nil, err + } + videoSlot, err := playback.NewVideoSlot(videoWorker) + if err != nil { + _ = audioSink.Close() + return nil, err + } + + audioWorker, err := playback.NewAudioWorker( + mxladapter.AudioFactory{}, + observedAudioSink, + retry, + mxladapter.ShouldRetry, + observe, + ) + if err != nil { + _ = audioSink.Close() + return nil, err + } + audioSlot, err := playback.NewAudioSlot(audioWorker) + if err != nil { + _ = audioSink.Close() + return nil, err + } + + syncWorker, err := playback.NewSyncWorker( + mxladapter.SyncFactory{}, + videoSink, + observedAudioSink, + retry, + mxladapter.ShouldRetry, + observe, + ) + if err != nil { + _ = audioSink.Close() + return nil, err + } + syncSlot, err := playback.NewSyncSlot(syncWorker) + if err != nil { + _ = audioSink.Close() + return nil, err + } + + controller, err := playback.NewSessionController( + videoSlot, + audioSlot, + syncSlot, + func(video, audio playback.FeedConfig) bool { + return video.Domain == audio.Domain + }, + ) + if err != nil { + _ = audioSink.Close() + return nil, err + } + + return &playerPlayback{ + Controller: controller, + Commands: make(chan playback.SessionCommand, 32), + Video: videoBridge, + Status: statusStore, + MediaStats: mediaStats, + Audio: audioSink, + }, nil +} + +func (p *playerPlayback) Close() error { + return p.Audio.Close() +} diff --git a/cmd/mxl-player/playback_runtime_test.go b/cmd/mxl-player/playback_runtime_test.go new file mode 100644 index 0000000..4cc50dd --- /dev/null +++ b/cmd/mxl-player/playback_runtime_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "testing" + "time" + + "mxl-player/internal/playback" +) + +func validRuntimeRetryPolicy() playback.RetryPolicy { + return playback.RetryPolicy{ + MaxAttempts: 3, + InitialDelay: time.Millisecond, + MaxDelay: time.Second, + } +} + +func TestNewPlayerPlaybackBuildsCompleteRuntime(t *testing.T) { + runtime, err := newPlayerPlayback(123, validRuntimeRetryPolicy()) + if err != nil { + t.Fatalf("newPlayerPlayback() error = %v", err) + } + if runtime.Controller == nil || runtime.Video == nil || runtime.Status == nil || runtime.Audio == nil { + t.Fatalf("newPlayerPlayback() = %#v", runtime) + } + if runtime.Commands == nil || cap(runtime.Commands) != 32 { + t.Fatalf("command channel = %#v, capacity = %d", runtime.Commands, cap(runtime.Commands)) + } + if err := runtime.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } +} + +func TestNewPlayerPlaybackRejectsInvalidRetryPolicy(t *testing.T) { + runtime, err := newPlayerPlayback(123, playback.RetryPolicy{}) + if runtime != nil { + t.Fatalf("newPlayerPlayback() runtime = %#v, want nil", runtime) + } + if err == nil { + t.Fatal("newPlayerPlayback() error is nil") + } +} diff --git a/cmd/mxl-player/playlist_file.go b/cmd/mxl-player/playlist_file.go new file mode 100644 index 0000000..108280c --- /dev/null +++ b/cmd/mxl-player/playlist_file.go @@ -0,0 +1,111 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "time" + + "mxl-player/internal/playback" +) + +type playlistFile struct { + Entries []playlistFileEntry `json:"entries"` + Loop bool `json:"loop"` + OnFailure string `json:"on_failure"` +} + +type playlistFileEntry struct { + Name string `json:"name"` + Video *playlistFileFeed `json:"video"` + Audio *playlistFileFeed `json:"audio"` + Sync bool `json:"sync"` + Duration string `json:"duration"` +} + +type playlistFileFeed struct { + Domain string `json:"domain"` + UUID string `json:"uuid"` +} + +func loadPlaylistFile(path string) (playback.Playlist, error) { + file, err := os.Open(path) + if err != nil { + return playback.Playlist{}, fmt.Errorf("open playlist %q: %w", path, err) + } + defer file.Close() + + playlist, err := decodePlaylistFile(file) + if err != nil { + return playback.Playlist{}, fmt.Errorf("decode playlist %q: %w", path, err) + } + return playlist, nil +} + +func decodePlaylistFile(reader io.Reader) (playback.Playlist, error) { + decoder := json.NewDecoder(reader) + decoder.DisallowUnknownFields() + + var file playlistFile + if err := decoder.Decode(&file); err != nil { + return playback.Playlist{}, fmt.Errorf("decode JSON: %w", err) + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return playback.Playlist{}, fmt.Errorf("decode JSON: multiple root values") + } + return playback.Playlist{}, fmt.Errorf("decode trailing JSON: %w", err) + } + + playlist := playback.Playlist{ + Entries: make([]playback.PlaylistEntry, len(file.Entries)), + Loop: file.Loop, + } + switch file.OnFailure { + case "", "wait": + playlist.OnFailure = playback.PlaylistFailureWait + case "next": + playlist.OnFailure = playback.PlaylistFailureNext + default: + return playback.Playlist{}, fmt.Errorf( + "on_failure %q: %w", file.OnFailure, playback.ErrPlaylistFailurePolicy, + ) + } + for index, entry := range file.Entries { + duration := time.Duration(0) + if entry.Duration != "" { + parsed, err := time.ParseDuration(entry.Duration) + if err != nil { + return playback.Playlist{}, fmt.Errorf( + "playlist entry %d duration %q: %w", + index, + entry.Duration, + err, + ) + } + duration = parsed + } + + playlist.Entries[index] = playback.PlaylistEntry{ + Name: entry.Name, + Video: playlistFileFeedToPlayback(entry.Video), + Audio: playlistFileFeedToPlayback(entry.Audio), + SyncRequested: entry.Sync, + Duration: duration, + } + } + + if err := playlist.Validate(); err != nil { + return playback.Playlist{}, fmt.Errorf("validate playlist: %w", err) + } + return playlist, nil +} + +func playlistFileFeedToPlayback(feed *playlistFileFeed) playback.PlaylistFeed { + if feed == nil { + return playback.PlaylistFeed{} + } + return playback.PlaylistFeed{Domain: feed.Domain, UUID: feed.UUID} +} diff --git a/cmd/mxl-player/playlist_file_test.go b/cmd/mxl-player/playlist_file_test.go new file mode 100644 index 0000000..1cd15c5 --- /dev/null +++ b/cmd/mxl-player/playlist_file_test.go @@ -0,0 +1,177 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "mxl-player/internal/playback" +) + +func TestDecodePlaylistFile(t *testing.T) { + input := `{ + "loop": true, + "on_failure": "next", + "entries": [ + { + "name": "sync", + "video": {"domain": "/video", "uuid": "video-1"}, + "audio": {"domain": "/audio", "uuid": "audio-1"}, + "sync": true, + "duration": "10s" + }, + { + "name": "video", + "video": {"domain": "/other-video", "uuid": "video-2"}, + "duration": "250ms" + }, + { + "name": "audio", + "audio": {"domain": "/other-audio", "uuid": "audio-3"}, + "duration": "1m" + }, + { + "name": "manual", + "video": {"domain": "/video", "uuid": "video-4"} + } + ] + }` + + got, err := decodePlaylistFile(strings.NewReader(input)) + if err != nil { + t.Fatalf("decodePlaylistFile() error = %v", err) + } + want := playback.Playlist{ + Loop: true, + OnFailure: playback.PlaylistFailureNext, + Entries: []playback.PlaylistEntry{ + { + Name: "sync", + Video: playback.PlaylistFeed{Domain: "/video", UUID: "video-1"}, + Audio: playback.PlaylistFeed{Domain: "/audio", UUID: "audio-1"}, + SyncRequested: true, + Duration: 10 * time.Second, + }, + { + Name: "video", + Video: playback.PlaylistFeed{Domain: "/other-video", UUID: "video-2"}, + Duration: 250 * time.Millisecond, + }, + { + Name: "audio", + Audio: playback.PlaylistFeed{Domain: "/other-audio", UUID: "audio-3"}, + Duration: time.Minute, + }, + { + Name: "manual", + Video: playback.PlaylistFeed{Domain: "/video", UUID: "video-4"}, + }, + }, + } + if len(got.Entries) != len(want.Entries) || got.Loop != want.Loop || got.OnFailure != want.OnFailure { + t.Fatalf("decodePlaylistFile() = %#v, want %#v", got, want) + } + for index := range want.Entries { + if got.Entries[index] != want.Entries[index] { + t.Fatalf("entry %d = %#v, want %#v", index, got.Entries[index], want.Entries[index]) + } + } +} + +func TestDecodePlaylistFileAllowsEmptyPlaylist(t *testing.T) { + got, err := decodePlaylistFile(strings.NewReader(`{"entries": []}`)) + if err != nil { + t.Fatalf("decodePlaylistFile() error = %v", err) + } + if len(got.Entries) != 0 || got.Loop { + t.Fatalf("decodePlaylistFile() = %#v, want empty non-looping playlist", got) + } +} + +func TestDecodePlaylistFileRejectsInvalidInput(t *testing.T) { + tests := []struct { + name string + input string + wantErr error + wantText string + }{ + {name: "empty input", input: ``, wantText: "decode JSON"}, + {name: "malformed JSON", input: `{"entries": [`, wantText: "decode JSON"}, + {name: "unknown field", input: `{"unknown": true}`, wantText: "unknown field"}, + {name: "multiple roots", input: `{"entries": []} {"entries": []}`, wantText: "multiple root values"}, + {name: "invalid failure policy", input: `{"on_failure":"skip","entries":[]}`, wantErr: playback.ErrPlaylistFailurePolicy}, + { + name: "invalid duration", + input: `{"entries":[{"video":{"domain":"/video","uuid":"video"},"duration":"later"}]}`, + wantText: `playlist entry 0 duration "later"`, + }, + { + name: "negative duration", + input: `{"entries":[{"video":{"domain":"/video","uuid":"video"},"duration":"-1s"}]}`, + wantErr: playback.ErrPlaylistDurationNegative, + }, + { + name: "UUID without domain", + input: `{"entries":[{"video":{"uuid":"video"}}]}`, + wantErr: playback.ErrFeedDomainRequired, + }, + { + name: "domain without UUID", + input: `{"entries":[{"audio":{"domain":"/audio"}}]}`, + wantErr: playback.ErrPlaylistFeedUUIDRequired, + }, + { + name: "empty entry", + input: `{"entries":[{}]}`, + wantErr: playback.ErrPlaylistEntryEmpty, + }, + { + name: "sync with one feed", + input: `{"entries":[{"video":{"domain":"/video","uuid":"video"},"sync":true}]}`, + wantErr: playback.ErrPlaylistSyncFeedsRequired, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := decodePlaylistFile(strings.NewReader(test.input)) + if err == nil { + t.Fatal("decodePlaylistFile() error = nil") + } + if test.wantErr != nil && !errors.Is(err, test.wantErr) { + t.Fatalf("decodePlaylistFile() error = %v, want %v", err, test.wantErr) + } + if test.wantText != "" && !strings.Contains(err.Error(), test.wantText) { + t.Fatalf("decodePlaylistFile() error = %q, want text %q", err, test.wantText) + } + }) + } +} + +func TestLoadPlaylistFile(t *testing.T) { + directory := t.TempDir() + path := filepath.Join(directory, "playlist.json") + input := []byte(`{"loop":true,"entries":[{"audio":{"domain":"/audio","uuid":"audio"}}]}`) + if err := os.WriteFile(path, input, 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + got, err := loadPlaylistFile(path) + if err != nil { + t.Fatalf("loadPlaylistFile() error = %v", err) + } + if !got.Loop || len(got.Entries) != 1 || got.Entries[0].Audio.UUID != "audio" { + t.Fatalf("loadPlaylistFile() = %#v", got) + } +} + +func TestLoadPlaylistFileIncludesPathInErrors(t *testing.T) { + path := filepath.Join(t.TempDir(), "missing.json") + _, err := loadPlaylistFile(path) + if err == nil || !strings.Contains(err.Error(), path) { + t.Fatalf("loadPlaylistFile() error = %v, want path %q", err, path) + } +} diff --git a/cmd/mxl-player/playlist_runtime.go b/cmd/mxl-player/playlist_runtime.go new file mode 100644 index 0000000..9aa6ed0 --- /dev/null +++ b/cmd/mxl-player/playlist_runtime.go @@ -0,0 +1,187 @@ +package main + +import ( + "context" + "errors" + "fmt" + "time" + + "mxl-player/internal/playback" +) + +const playlistReadinessInterval = 10 * time.Millisecond + +var ( + ErrPlayerPlaybackRequired = errors.New("player playback is required") + ErrPlayerSessionControllerRequired = errors.New("player session controller is required") + ErrPlayerStatusStoreRequired = errors.New("player status store is required") +) + +type playerPlaylist struct { + Controller *playback.PlaylistController + Coordinator *playback.PlaylistReadinessCoordinator + Commands chan playback.PlaylistCommand + Readiness chan playback.PlaylistReadiness +} + +func newPlayerPlaylist( + playlist playback.Playlist, + retry playback.RetryPolicy, + player *playerPlayback, +) (*playerPlaylist, error) { + if player == nil { + return nil, ErrPlayerPlaybackRequired + } + if player.Controller == nil { + return nil, ErrPlayerSessionControllerRequired + } + if player.Status == nil { + return nil, ErrPlayerStatusStoreRequired + } + + commands := make(chan playback.PlaylistCommand, 32) + readiness := make(chan playback.PlaylistReadiness, 8) + controller, err := playback.NewPlaylistController( + playlist, + retry, + player.Commands, + ) + if err != nil { + return nil, err + } + coordinator, err := playback.NewPlaylistReadinessCoordinator( + controller, + player.Controller, + player.Status, + readiness, + playlistReadinessInterval, + ) + if err != nil { + return nil, err + } + + return &playerPlaylist{ + Controller: controller, + Coordinator: coordinator, + Commands: commands, + Readiness: readiness, + }, nil +} + +func (p *playerPlaylist) Run(ctx context.Context) error { + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + + results := make(chan error, 2) + go func() { + results <- p.Controller.Run(runCtx, p.Commands, p.Readiness) + }() + go func() { + results <- p.Coordinator.Run(runCtx) + }() + + first := <-results + cancel() + second := <-results + + if ctx.Err() != nil { + return ctx.Err() + } + if err := playlistRuntimeError(first); err != nil { + return err + } + if err := playlistRuntimeError(second); err != nil { + return err + } + return nil +} + +func (p *playerPlaylist) Select(index int) bool { + return p.enqueue(playback.PlaylistCommand{ + Kind: playback.PlaylistSelect, + Index: index, + }) +} + +func (p *playerPlaylist) Next() bool { + return p.enqueue(playback.PlaylistCommand{Kind: playback.PlaylistNext}) +} + +func (p *playerPlaylist) Previous() bool { + return p.enqueue(playback.PlaylistCommand{Kind: playback.PlaylistPrevious}) +} + +func (p *playerPlaylist) Pause() bool { + return p.enqueue(playback.PlaylistCommand{Kind: playback.PlaylistPause}) +} + +func (p *playerPlaylist) Resume() bool { + return p.enqueue(playback.PlaylistCommand{Kind: playback.PlaylistResume}) +} + +func (p *playerPlaylist) enqueue(command playback.PlaylistCommand) bool { + select { + case p.Commands <- command: + return true + default: + return false + } +} + +func playlistRuntimeError(err error) error { + if err == nil || errors.Is(err, context.Canceled) { + return nil + } + return err +} + +func shouldAutoStartPlaylist( + args appArgs, + playlist playback.Playlist, +) bool { + return args.PlaylistPath != "" && + args.VideoFlowId == "" && + args.AudioFlowId == "" && + len(playlist.Entries) > 0 +} + +func playlistEntryDisplayName(entry playback.PlaylistEntry, index int) string { + if entry.Name != "" { + return entry.Name + } + return fmt.Sprintf("Entry %d", index+1) +} + +func playlistTimingProgress( + timing playback.PlaylistTimingState, + now time.Time, +) (float32, time.Duration) { + if timing.Duration <= 0 { + return 0, 0 + } + if timing.Expired { + return 1, 0 + } + remaining := timing.Remaining + if timing.Paused { + // Retain the remaining time captured when the owned timer stopped. + } else if !timing.Started { + return 0, timing.Duration + } else { + remaining = timing.Deadline.Sub(now) + } + if remaining < 0 { + remaining = 0 + } + if remaining > timing.Duration { + remaining = timing.Duration + } + fraction := 1 - float32(remaining)/float32(timing.Duration) + if fraction < 0 { + fraction = 0 + } + if fraction > 1 { + fraction = 1 + } + return fraction, remaining +} diff --git a/cmd/mxl-player/playlist_runtime_test.go b/cmd/mxl-player/playlist_runtime_test.go new file mode 100644 index 0000000..19ebc69 --- /dev/null +++ b/cmd/mxl-player/playlist_runtime_test.go @@ -0,0 +1,446 @@ +package main + +import ( + "context" + "errors" + "math" + "testing" + "time" + + "mxl-player/internal/playback" +) + +type playlistTestVideoSlot struct{} + +func (playlistTestVideoSlot) Run( + ctx context.Context, + initial playback.FeedConfig, + commands <-chan playback.FeedConfig, +) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + case _, ok := <-commands: + if !ok { + return nil + } + } + } +} + +type playlistTestAudioSlot struct{} + +func (playlistTestAudioSlot) Run( + ctx context.Context, + initial playback.FeedConfig, + commands <-chan playback.FeedConfig, +) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + case _, ok := <-commands: + if !ok { + return nil + } + } + } +} + +type playlistTestSyncSlot struct{} + +func (playlistTestSyncSlot) Run( + ctx context.Context, + initial playback.SyncPairConfig, + commands <-chan playback.SyncPairConfig, +) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + case _, ok := <-commands: + if !ok { + return nil + } + } + } +} + +func TestNewPlayerPlaylistValidatesPlayer(t *testing.T) { + retry := playlistRuntimeRetry() + tests := []struct { + name string + player *playerPlayback + wantErr error + }{ + {name: "nil player", wantErr: ErrPlayerPlaybackRequired}, + {name: "nil controller", player: &playerPlayback{}, wantErr: ErrPlayerSessionControllerRequired}, + { + name: "nil status store", + player: &playerPlayback{ + Controller: newPlaylistTestSessionController(t), + }, + wantErr: ErrPlayerStatusStoreRequired, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := newPlayerPlaylist(playback.Playlist{}, retry, test.player) + if !errors.Is(err, test.wantErr) { + t.Fatalf("newPlayerPlaylist() error = %v, want %v", err, test.wantErr) + } + if got != nil { + t.Fatalf("newPlayerPlaylist() = %#v, want nil", got) + } + }) + } +} + +func TestNewPlayerPlaylistWiresComponents(t *testing.T) { + player := newPlaylistTestPlayer(t) + runtime, err := newPlayerPlaylist(playback.Playlist{}, playlistRuntimeRetry(), player) + if err != nil { + t.Fatalf("newPlayerPlaylist() error = %v", err) + } + if runtime.Controller == nil || runtime.Coordinator == nil { + t.Fatalf("runtime components = %#v", runtime) + } + if runtime.Commands == nil || runtime.Readiness == nil { + t.Fatalf("runtime channels = %#v", runtime) + } +} + +func TestPlayerPlaylistNavigationHelpers(t *testing.T) { + runtime, err := newPlayerPlaylist( + playback.Playlist{}, + playlistRuntimeRetry(), + newPlaylistTestPlayer(t), + ) + if err != nil { + t.Fatalf("newPlayerPlaylist() error = %v", err) + } + tests := []struct { + send func() bool + want playback.PlaylistCommand + }{ + {send: func() bool { return runtime.Select(4) }, want: playback.PlaylistCommand{Kind: playback.PlaylistSelect, Index: 4}}, + {send: runtime.Next, want: playback.PlaylistCommand{Kind: playback.PlaylistNext}}, + {send: runtime.Previous, want: playback.PlaylistCommand{Kind: playback.PlaylistPrevious}}, + {send: runtime.Pause, want: playback.PlaylistCommand{Kind: playback.PlaylistPause}}, + {send: runtime.Resume, want: playback.PlaylistCommand{Kind: playback.PlaylistResume}}, + } + for _, test := range tests { + if !test.send() { + t.Fatal("navigation helper returned false") + } + if got := <-runtime.Commands; got != test.want { + t.Fatalf("navigation command = %#v, want %#v", got, test.want) + } + } +} + +func TestPlayerPlaylistNavigationQueueFull(t *testing.T) { + runtime, err := newPlayerPlaylist( + playback.Playlist{}, + playlistRuntimeRetry(), + newPlaylistTestPlayer(t), + ) + if err != nil { + t.Fatalf("newPlayerPlaylist() error = %v", err) + } + for range cap(runtime.Commands) { + if !runtime.Next() { + t.Fatal("queue filled before reaching capacity") + } + } + if runtime.Next() { + t.Fatal("Next() = true with full queue") + } +} + +func TestPlayerPlaylistRunCancellationJoinsComponents(t *testing.T) { + runtime, err := newPlayerPlaylist( + playback.Playlist{}, + playlistRuntimeRetry(), + newPlaylistTestPlayer(t), + ) + if err != nil { + t.Fatalf("newPlayerPlaylist() error = %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + go func() { result <- runtime.Run(ctx) }() + cancel() + + select { + case err := <-result: + if !errors.Is(err, context.Canceled) { + t.Fatalf("Run() error = %v, want %v", err, context.Canceled) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for playlist runtime cancellation") + } +} + +func TestPlayerPlaylistTimedEntryAdvances(t *testing.T) { + retry := playlistRuntimeRetry() + player := newPlaylistTestPlayer(t) + playlist := playback.Playlist{ + Entries: []playback.PlaylistEntry{ + { + Video: playback.PlaylistFeed{Domain: "domain", UUID: "video-1"}, + Duration: 15 * time.Millisecond, + }, + { + Video: playback.PlaylistFeed{Domain: "domain", UUID: "video-2"}, + }, + }, + } + runtime, err := newPlayerPlaylist(playlist, retry, player) + if err != nil { + t.Fatalf("newPlayerPlaylist() error = %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + sessionResult := make(chan error, 1) + go func() { + sessionResult <- player.Controller.Run( + ctx, + playback.SessionConfig{Retry: retry}, + player.Commands, + ) + }() + playlistResult := make(chan error, 1) + go func() { playlistResult <- runtime.Run(ctx) }() + + if !runtime.Next() { + t.Fatal("Next() = false") + } + first := waitForPlayerSession(t, player.Controller, func(snapshot playback.SessionSnapshot) bool { + return snapshot.Desired.Video.UUID == "video-1" + }) + player.Status.Observe(playback.Status{ + Unit: playback.UnitVideo, + State: playback.StatePlaying, + Generation: first.Generation, + Feed: first.Plan.Video, + }) + + second := waitForPlayerSession(t, player.Controller, func(snapshot playback.SessionSnapshot) bool { + return snapshot.Desired.Video.UUID == "video-2" + }) + if second.Desired.Audio.IsConfigured() { + t.Fatalf("advanced session audio = %#v, want unconfigured", second.Desired.Audio) + } + + cancel() + if err := waitForPlayerRuntimeResult(t, playlistResult); !errors.Is(err, context.Canceled) { + t.Fatalf("playlist Run() error = %v, want %v", err, context.Canceled) + } + if err := waitForPlayerRuntimeResult(t, sessionResult); !errors.Is(err, context.Canceled) { + t.Fatalf("session Run() error = %v, want %v", err, context.Canceled) + } +} + +func TestShouldAutoStartPlaylist(t *testing.T) { + playlist := playback.Playlist{Entries: []playback.PlaylistEntry{ + {Video: playback.PlaylistFeed{Domain: "domain", UUID: "video"}}, + }} + tests := []struct { + name string + args appArgs + playlist playback.Playlist + want bool + }{ + { + name: "playlist only", + args: appArgs{PlaylistPath: "playlist.json"}, + playlist: playlist, + want: true, + }, + { + name: "direct video", + args: appArgs{PlaylistPath: "playlist.json", VideoFlowId: "video"}, + playlist: playlist, + }, + { + name: "direct audio", + args: appArgs{PlaylistPath: "playlist.json", AudioFlowId: "audio"}, + playlist: playlist, + }, + { + name: "both direct feeds", + args: appArgs{ + PlaylistPath: "playlist.json", + VideoFlowId: "video", + AudioFlowId: "audio", + }, + playlist: playlist, + }, + { + name: "empty playlist", + args: appArgs{PlaylistPath: "playlist.json"}, + playlist: playback.Playlist{}, + }, + { + name: "no playlist flag", + args: appArgs{}, + playlist: playlist, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := shouldAutoStartPlaylist(test.args, test.playlist); got != test.want { + t.Fatalf("shouldAutoStartPlaylist() = %v, want %v", got, test.want) + } + }) + } +} + +func TestPlaylistEntryDisplayName(t *testing.T) { + tests := []struct { + entry playback.PlaylistEntry + index int + want string + }{ + {entry: playback.PlaylistEntry{Name: "News"}, index: 0, want: "News"}, + {entry: playback.PlaylistEntry{}, index: 0, want: "Entry 1"}, + {entry: playback.PlaylistEntry{}, index: 4, want: "Entry 5"}, + } + for _, test := range tests { + if got := playlistEntryDisplayName(test.entry, test.index); got != test.want { + t.Fatalf("playlistEntryDisplayName() = %q, want %q", got, test.want) + } + } +} + +func TestPlaylistTimingProgress(t *testing.T) { + now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC) + tests := []struct { + name string + timing playback.PlaylistTimingState + wantFraction float32 + wantRemaining time.Duration + }{ + {name: "manual"}, + { + name: "waiting", + timing: playback.PlaylistTimingState{Duration: 10 * time.Second}, + wantRemaining: 10 * time.Second, + }, + { + name: "half complete", + timing: playback.PlaylistTimingState{ + Duration: 10 * time.Second, + Started: true, + Deadline: now.Add(5 * time.Second), + }, + wantFraction: 0.5, + wantRemaining: 5 * time.Second, + }, + { + name: "expired", + timing: playback.PlaylistTimingState{Duration: 10 * time.Second, Expired: true}, + wantFraction: 1, + }, + { + name: "paused", + timing: playback.PlaylistTimingState{ + Duration: 10 * time.Second, + Paused: true, + Remaining: 6 * time.Second, + }, + wantFraction: 0.4, + wantRemaining: 6 * time.Second, + }, + { + name: "deadline passed", + timing: playback.PlaylistTimingState{ + Duration: 10 * time.Second, + Started: true, + Deadline: now.Add(-time.Second), + }, + wantFraction: 1, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fraction, remaining := playlistTimingProgress(test.timing, now) + if math.Abs(float64(fraction-test.wantFraction)) > 0.000001 || + remaining != test.wantRemaining { + t.Fatalf( + "playlistTimingProgress() = %v, %v; want %v, %v", + fraction, + remaining, + test.wantFraction, + test.wantRemaining, + ) + } + }) + } +} + +func newPlaylistTestPlayer(t *testing.T) *playerPlayback { + t.Helper() + return &playerPlayback{ + Controller: newPlaylistTestSessionController(t), + Commands: make(chan playback.SessionCommand, 32), + Status: playback.NewStatusStore(), + } +} + +func newPlaylistTestSessionController(t *testing.T) *playback.SessionController { + t.Helper() + controller, err := playback.NewSessionController( + playlistTestVideoSlot{}, + playlistTestAudioSlot{}, + playlistTestSyncSlot{}, + func(video, audio playback.FeedConfig) bool { return video.Domain == audio.Domain }, + ) + if err != nil { + t.Fatalf("NewSessionController() error = %v", err) + } + return controller +} + +func playlistRuntimeRetry() playback.RetryPolicy { + return playback.RetryPolicy{ + MaxAttempts: 1, + InitialDelay: time.Millisecond, + MaxDelay: time.Millisecond, + } +} + +func waitForPlayerSession( + t *testing.T, + controller *playback.SessionController, + predicate func(playback.SessionSnapshot) bool, +) playback.SessionSnapshot { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if snapshot, ok := controller.Snapshot(); ok && predicate(snapshot) { + return snapshot + } + time.Sleep(time.Millisecond) + } + snapshot, _ := controller.Snapshot() + t.Fatalf("timed out waiting for session snapshot; latest = %#v", snapshot) + return playback.SessionSnapshot{} +} + +func waitForPlayerRuntimeResult(t *testing.T, result <-chan error) error { + t.Helper() + select { + case err := <-result: + return err + case <-time.After(time.Second): + t.Fatal("timed out waiting for runtime result") + return nil + } +} diff --git a/cmd/mxl-player/video_drop_tracker.go b/cmd/mxl-player/video_drop_tracker.go new file mode 100644 index 0000000..ca5c44e --- /dev/null +++ b/cmd/mxl-player/video_drop_tracker.go @@ -0,0 +1,37 @@ +package main + +import "mxl-player/internal/playback" + +// videoDropTracker counts gaps only within one playback generation. Frame +// indices belong to their source and cannot be compared across feed changes. +type videoDropTracker struct { + generation uint64 + source playback.FeedConfig + lastIndex uint64 + hasIndex bool +} + +func (t *videoDropTracker) Observe( + generation uint64, + source playback.FeedConfig, + index uint64, +) uint64 { + if !t.hasIndex || + generation != t.generation || + !sameVideoSource(source, t.source) || + index <= t.lastIndex { + t.generation = generation + t.source = source + t.lastIndex = index + t.hasIndex = true + return 0 + } + + dropped := index - t.lastIndex - 1 + t.lastIndex = index + return dropped +} + +func sameVideoSource(a, b playback.FeedConfig) bool { + return a.Domain == b.Domain && a.UUID == b.UUID +} diff --git a/cmd/mxl-player/video_drop_tracker_test.go b/cmd/mxl-player/video_drop_tracker_test.go new file mode 100644 index 0000000..5239f53 --- /dev/null +++ b/cmd/mxl-player/video_drop_tracker_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "testing" + + "mxl-player/internal/playback" +) + +func TestVideoDropTracker(t *testing.T) { + tests := []struct { + name string + observations [][2]uint64 + want []uint64 + }{ + { + name: "counts gaps within generation", + observations: [][2]uint64{{1, 10}, {1, 11}, {1, 15}}, + want: []uint64{0, 0, 3}, + }, + { + name: "higher index from new generation resets baseline", + observations: [][2]uint64{{1, 10}, {2, 1000000}, {2, 1000001}}, + want: []uint64{0, 0, 0}, + }, + { + name: "lower index from new generation resets baseline", + observations: [][2]uint64{{1, 100}, {2, 5}, {2, 7}}, + want: []uint64{0, 0, 1}, + }, + { + name: "index restart within generation resets baseline", + observations: [][2]uint64{{1, 100}, {1, 0}, {1, 1}}, + want: []uint64{0, 0, 0}, + }, + { + name: "zero is a valid first index", + observations: [][2]uint64{{1, 0}, {1, 2}}, + want: []uint64{0, 1}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var tracker videoDropTracker + source := playback.FeedConfig{Domain: "/mxl", UUID: "video"} + for index, observation := range test.observations { + got := tracker.Observe(observation[0], source, observation[1]) + if got != test.want[index] { + t.Fatalf("Observe(%d, %d) = %d, want %d", + observation[0], observation[1], got, test.want[index]) + } + } + }) + } +} + +func TestVideoDropTrackerResetsWhenSourceChanges(t *testing.T) { + var tracker videoDropTracker + first := playback.FeedConfig{Domain: "/mxl", UUID: "first"} + second := playback.FeedConfig{Domain: "/mxl", UUID: "second"} + + if got := tracker.Observe(1, first, 10); got != 0 { + t.Fatalf("first Observe() = %d, want 0", got) + } + if got := tracker.Observe(1, second, 1000000); got != 0 { + t.Fatalf("source-changing Observe() = %d, want 0", got) + } + if got := tracker.Observe(1, second, 1000002); got != 1 { + t.Fatalf("same-source Observe() = %d, want 1", got) + } +} diff --git a/cmd/mxl-reader/main.go b/cmd/mxl-reader/main.go deleted file mode 100644 index d7a1236..0000000 --- a/cmd/mxl-reader/main.go +++ /dev/null @@ -1,61 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "log" - "mxl-player/internal/source" - "os" - "os/signal" - "syscall" - "time" -) - -func main() { - domain := flag.String("domain", "/dev/shm/mxl", "MXL domain") - flowID := flag.String("flow", "5fbec3b1-1b0f-417d-9059-8b94a47197ed", "Flow UUID") - timeout := flag.Duration("timeout", 200*time.Millisecond, "Per-grain read timeout") - count := flag.Int("count", 10, "Stop after N grains (0 = run forever)") - flag.Parse() - - src, err := source.Open(*domain, *flowID) - if err != nil { - log.Fatal(err) - } - defer src.Close() - fmt.Printf("format=%s rate=%d/%d stride=%d grainCount=%d\n", - src.Format(), - src.Rate().Num, - src.Rate().Den, - src.Stride(), - src.GrainCount()) - stop := make(chan os.Signal, 1) - signal.Notify(stop, os.Interrupt, syscall.SIGTERM) - - grains := 0 - for { - select { - case <-stop: - fmt.Printf("\nstopping: %d grains read\n", grains) - return - default: - } - - f, err := src.Next(*timeout) - if err != nil { - fmt.Fprintln(os.Stderr, err) - return - } - lines := uint32(0) - if src.Stride() > 0 { - lines = f.Size / src.Stride() - } - fmt.Printf("idx=%d size=%d (%dx%d, %d lines) invalid=%v\n", - f.Index, f.Size, f.Width, f.Height, lines, f.Invalid) - grains++ - if *count > 0 && grains >= *count { - fmt.Printf("done: %d grains read\n", grains) - return - } - } -} diff --git a/cmd/mxl-sync/main.go b/cmd/mxl-sync/main.go deleted file mode 100644 index 0665380..0000000 --- a/cmd/mxl-sync/main.go +++ /dev/null @@ -1,116 +0,0 @@ -package main - -import ( - "errors" - "flag" - "fmt" - "log" - "os" - "os/signal" - "syscall" - "time" - - "github.com/qvest-digital/go-mxl/mxl" -) - -func main() { - domain := flag.String("d", "/dev/shm/mxl", "MXL domain") - videoFlow := flag.String("v", "5fbec3b1-1b0f-417d-9059-8b94a47197ed", "Video flow UUID") - audioFlow := flag.String("a", "5fbec3b1-1b0f-417d-9059-8b94a47197ec", "Audio flow UUID") - flag.Parse() - if *videoFlow == "" || *audioFlow == "" { - log.Fatal("need both -v and -a ") - } - - inst, err := mxl.NewInstance(*domain, "") - if err != nil { - log.Fatal(err) - } - defer inst.Close() - - vr, err := inst.NewReader(*videoFlow) - if err != nil { - log.Fatal(err) - } - defer vr.Close() - - ar, err := inst.NewReader(*audioFlow) - if err != nil { - log.Fatal(err) - } - defer ar.Close() - - vInfo, _ := vr.Info() - aInfo, _ := ar.Info() - vRate := vInfo.Config.Common.GrainRate - aRate := aInfo.Config.Common.GrainRate - aChans := aInfo.Config.Continuous.ChannelCount - fmt.Printf("video: %dx%d %d/%d | audio: %dch %d/%d\n", - vInfo.Config.Discrete.SliceSizes[0], - vInfo.Config.Discrete.GrainCount, - vRate.Num, vRate.Den, - aChans, aRate.Num, aRate.Den) - - group, err := inst.NewSyncGroup() - if err != nil { - log.Fatal(err) - } - defer group.Close() - if err := group.AddReader(vr); err != nil { - log.Fatal(err) - } - if err := group.AddReader(ar); err != nil { - log.Fatal(err) - } - - stop := make(chan os.Signal, 1) - signal.Notify(stop, os.Interrupt, syscall.SIGTERM) - - idx := mxl.CurrentIndex(vRate) - audioBatch := uint64(aRate.Num / (100 * aRate.Den)) // ~10ms - if audioBatch == 0 { - audioBatch = 1 - } - - var ticks int - for { - select { - case <-stop: - fmt.Printf("\nstopped after %d ticks\n", ticks) - return - default: - } - - ts := mxl.IndexToTimestamp(vRate, idx) - err := group.WaitForDataAt(ts, 500*time.Millisecond) - switch { - case err == nil: - // Read video grain - g, gerr := vr.GetGrain(idx, 50*time.Millisecond) - // Read audio samples at the same timestamp - aIdx := mxl.TimestampToIndex(aRate, ts) - _, aerr := ar.GetSamples(aIdx, int(audioBatch), 50*time.Millisecond) - if gerr != nil { - log.Printf("grain: %v", gerr) - } else if aerr != nil { - log.Printf("samples: %v", aerr) - } else { - fmt.Printf("tick idx=%d ts=%d grainSize=%d audioIdx=%d\n", - idx, ts, g.GrainSize, aIdx) - ticks++ - if ticks >= 10 { - fmt.Println("done") - return - } - } - idx++ - case errors.Is(err, mxl.ErrTimeout), errors.Is(err, mxl.ErrOutOfRangeEarly): - time.Sleep(5 * time.Millisecond) - case errors.Is(err, mxl.ErrOutOfRangeLate): - log.Printf("fell behind, resyncing") - idx = mxl.CurrentIndex(vRate) - default: - log.Fatalf("WaitForDataAt: %v", err) - } - } -} diff --git a/flow-def/video-4k.json b/flow-def/video-4k.json new file mode 100644 index 0000000..e850f18 --- /dev/null +++ b/flow-def/video-4k.json @@ -0,0 +1,41 @@ +{ + "description": "sample for mxl reader go player", + "id": "5fbec3b1-1b0f-417d-9059-8b94a47197ed", + "tags": { + "urn:x-nmos:tag:grouphint/v1.0": [ + "mxl-gst-testsrc pattern" + ] + }, + "format": "urn:x-nmos:format:video", + "label": "SMPTE bars test video", + "parents": [], + "media_type": "video/v210", + "grain_rate": { + "numerator": 25, + "denominator": 1 + }, + "frame_width": 1920, + "frame_height": 1080, + "interlace_mode": "progressive", + "colorspace": "BT709", + "components": [ + { + "name": "Y", + "width": 1920, + "height": 1080, + "bit_depth": 10 + }, + { + "name": "Cb", + "width": 960, + "height": 1080, + "bit_depth": 10 + }, + { + "name": "Cr", + "width": 960, + "height": 1080, + "bit_depth": 10 + } + ] +} diff --git a/imgui.ini b/imgui.ini index 54a2a14..d88865f 100644 --- a/imgui.ini +++ b/imgui.ini @@ -3,18 +3,12 @@ Pos=60,60 Size=400,400 Collapsed=0 -[Window][Test] -Pos=60,60 -Size=251,92 +[Window][Settings & Info] +Pos=580,0 +Size=700,720 Collapsed=0 [Window][Stats] -Pos=10,10 -Size=200,200 -Collapsed=0 - -[Window][Connection] -Pos=425,351 -Size=523,153 +Size=460,510 Collapsed=0 diff --git a/internal/adapter/mxl/audio.go b/internal/adapter/mxl/audio.go new file mode 100644 index 0000000..e5821b9 --- /dev/null +++ b/internal/adapter/mxl/audio.go @@ -0,0 +1,212 @@ +package mxladapter + +import ( + "context" + "errors" + "fmt" + "math/bits" + "time" + + "mxl-player/internal/playback" + "mxl-player/internal/source" + + mxl "github.com/qvest-digital/go-mxl/mxl" +) + +const ( + DefaultAudioReadTimeout = 20 * time.Millisecond + DefaultAudioBatchDuration = 10 * time.Millisecond + DefaultAudioUnavailableAfter = 2 * time.Second + DefaultAudioTemporaryDelay = 10 * time.Millisecond +) + +var ErrInvalidAudioBatch = errors.New("invalid audio batch configuration") + +type AudioFactory struct { + ReadTimeout time.Duration + BatchDuration time.Duration + UnavailableAfter time.Duration +} + +type localAudioSource interface { + ReadAudioOnceCtx(context.Context, uint64, time.Duration) (source.AudioFrame, error) + Rate() mxl.Rational + Channels() uint64 + Close() error +} + +type audioReader struct { + source localAudioSource + readTimeout time.Duration + batch uint64 + channels uint64 + rateNumerator int64 + rateDenominator int64 + unavailableAfter time.Duration + retryDelay time.Duration + now func() time.Time + wait temporaryWaitFunc +} + +var _ playback.AudioReaderFactory = AudioFactory{} +var _ playback.AudioReader = (*audioReader)(nil) + +func audioBatchSize( + rateNumerator int64, + rateDenominator int64, + duration time.Duration, +) (uint64, error) { + if rateNumerator <= 0 || rateDenominator <= 0 || duration <= 0 { + return 0, fmt.Errorf( + "%w: rate=%d/%d duration=%s", + ErrInvalidAudioBatch, + rateNumerator, + rateDenominator, + duration, + ) + } + + denominator := uint64(rateDenominator) + seconds := uint64(time.Second) + if denominator > ^uint64(0)/seconds { + return 0, fmt.Errorf("%w: denominator overflow", ErrInvalidAudioBatch) + } + denominator *= seconds + + high, low := bits.Mul64(uint64(rateNumerator), uint64(duration)) + if high >= denominator { + return 0, fmt.Errorf("%w: sample count overflow", ErrInvalidAudioBatch) + } + batch, _ := bits.Div64(high, low, denominator) + if batch == 0 { + batch = 1 + } + return batch, nil +} + +func (f AudioFactory) OpenAudio( + ctx context.Context, + config playback.FeedConfig, +) (playback.AudioReader, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if err := config.Validate(); err != nil { + return nil, &source.SourceError{ + Op: "validate audio feed", + Kind: source.ErrorKindInvalidConfig, + Err: err, + } + } + if !config.IsConfigured() { + return nil, &source.SourceError{ + Op: "validate audio feed", + Kind: source.ErrorKindInvalidConfig, + Err: errors.New("audio feed is not configured"), + } + } + + src, err := source.OpenAudio(config.Domain, config.UUID) + if err != nil { + return nil, fmt.Errorf("open local MXL audio: %w", err) + } + closeOnError := true + defer func() { + if closeOnError { + _ = src.Close() + } + }() + + if err := ctx.Err(); err != nil { + return nil, err + } + + readTimeout := f.ReadTimeout + if readTimeout <= 0 { + readTimeout = DefaultAudioReadTimeout + } + batchDuration := f.BatchDuration + if batchDuration <= 0 { + batchDuration = DefaultAudioBatchDuration + } + unavailableAfter := f.UnavailableAfter + if unavailableAfter <= 0 { + unavailableAfter = DefaultAudioUnavailableAfter + } + + rate := src.Rate() + batch, err := audioBatchSize(rate.Num, rate.Den, batchDuration) + if err != nil { + return nil, &source.SourceError{ + Op: "calculate audio batch", + Kind: source.ErrorKindInvalidConfig, + Err: err, + } + } + + reader := &audioReader{ + source: src, + readTimeout: readTimeout, + batch: batch, + channels: src.Channels(), + rateNumerator: rate.Num, + rateDenominator: rate.Den, + unavailableAfter: unavailableAfter, + retryDelay: DefaultAudioTemporaryDelay, + now: time.Now, + wait: waitForTemporaryRetry, + } + closeOnError = false + return reader, nil +} + +func (r *audioReader) ReadAudio(ctx context.Context) (playback.AudioFrame, error) { + var unavailableSince time.Time + + for { + frame, err := r.source.ReadAudioOnceCtx(ctx, r.batch, r.readTimeout) + if err == nil { + return playback.AudioFrame{ + Index: frame.Index, + SampleCount: frame.SampleCount, + Channels: frame.Channels, + Label: frame.Label, + SampleRateNumerator: r.rateNumerator, + SampleRateDenominator: r.rateDenominator, + Samples: frame.Samples, + }, nil + } + if ctx.Err() != nil { + return playback.AudioFrame{}, ctx.Err() + } + if source.KindOf(err) != source.ErrorKindTemporary { + return playback.AudioFrame{}, err + } + + now := r.now() + if unavailableSince.IsZero() { + unavailableSince = now + } else if now.Sub(unavailableSince) >= r.unavailableAfter { + return playback.AudioFrame{}, &source.SourceError{ + Op: "read local MXL audio", + Kind: source.ErrorKindUnavailable, + Err: fmt.Errorf( + "no audio data for %s: %w", + r.unavailableAfter, + err, + ), + } + } + + if err := r.wait(ctx, r.retryDelay); err != nil { + if ctx.Err() != nil { + return playback.AudioFrame{}, ctx.Err() + } + return playback.AudioFrame{}, err + } + } +} + +func (r *audioReader) Close() error { + return r.source.Close() +} diff --git a/internal/adapter/mxl/audio_test.go b/internal/adapter/mxl/audio_test.go new file mode 100644 index 0000000..412cbdc --- /dev/null +++ b/internal/adapter/mxl/audio_test.go @@ -0,0 +1,264 @@ +package mxladapter + +import ( + "context" + "errors" + "math" + "testing" + "time" + + "mxl-player/internal/playback" + "mxl-player/internal/source" + + mxl "github.com/qvest-digital/go-mxl/mxl" +) + +type localAudioReadResult struct { + frame source.AudioFrame + err error +} + +type fakeLocalAudioSource struct { + results []localAudioReadResult + calls int + batches []uint64 + timeouts []time.Duration + closed bool + closeErr error + rate mxl.Rational + channels uint64 +} + +func (s *fakeLocalAudioSource) ReadAudioOnceCtx( + _ context.Context, + batch uint64, + timeout time.Duration, +) (source.AudioFrame, error) { + s.batches = append(s.batches, batch) + s.timeouts = append(s.timeouts, timeout) + if s.calls >= len(s.results) { + return source.AudioFrame{}, errors.New("unexpected local audio read") + } + result := s.results[s.calls] + s.calls++ + return result.frame, result.err +} + +func (s *fakeLocalAudioSource) Rate() mxl.Rational { return s.rate } +func (s *fakeLocalAudioSource) Channels() uint64 { return s.channels } + +func (s *fakeLocalAudioSource) Close() error { + s.closed = true + return s.closeErr +} + +func temporaryAudioError(cause error) error { + return &source.SourceError{ + Op: "read audio", + Kind: source.ErrorKindTemporary, + Err: cause, + } +} + +func TestAudioBatchSize(t *testing.T) { + tests := []struct { + name string + num int64 + den int64 + duration time.Duration + want uint64 + wantErr bool + }{ + {name: "ten milliseconds at 48kHz", num: 48000, den: 1, duration: 10 * time.Millisecond, want: 480}, + {name: "fraction rounds down", num: 30000, den: 1001, duration: time.Second, want: 29}, + {name: "minimum one sample", num: 1, den: 1, duration: time.Nanosecond, want: 1}, + {name: "zero numerator", den: 1, duration: time.Second, wantErr: true}, + {name: "zero denominator", num: 48000, duration: time.Second, wantErr: true}, + {name: "zero duration", num: 48000, den: 1, wantErr: true}, + {name: "result overflow", num: math.MaxInt64, den: 1, duration: time.Duration(math.MaxInt64), wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := audioBatchSize(tt.num, tt.den, tt.duration) + if tt.wantErr { + if !errors.Is(err, ErrInvalidAudioBatch) { + t.Fatalf("audioBatchSize() error = %v, want %v", err, ErrInvalidAudioBatch) + } + return + } + if err != nil { + t.Fatalf("audioBatchSize() error = %v", err) + } + if got != tt.want { + t.Fatalf("audioBatchSize() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestAudioFactoryOpenAudioCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + reader, err := (AudioFactory{}).OpenAudio(ctx, playback.FeedConfig{}) + if reader != nil { + t.Fatal("OpenAudio() reader is not nil after cancellation") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("OpenAudio() error = %v, want context.Canceled", err) + } +} + +func TestAudioFactoryOpenAudioRejectsInvalidConfig(t *testing.T) { + configs := []playback.FeedConfig{ + {}, + {Domain: "/audio", Active: true}, + {UUID: "audio", Active: true}, + } + for _, config := range configs { + reader, err := (AudioFactory{}).OpenAudio(context.Background(), config) + if reader != nil { + t.Fatalf("OpenAudio(%#v) reader is not nil", config) + } + if source.KindOf(err) != source.ErrorKindInvalidConfig { + t.Fatalf("OpenAudio(%#v) error kind = %v, want invalid config", config, source.KindOf(err)) + } + if ShouldRetry(err) { + t.Fatalf("ShouldRetry(OpenAudio(%#v)) = true", config) + } + } +} + +func TestAudioReaderTemporaryFailureThenFrameWithoutCopy(t *testing.T) { + samples := [][]byte{{1, 2, 3, 4}, {5, 6, 7, 8}} + want := source.AudioFrame{ + Index: 42, + SampleCount: 1, + Channels: 2, + Samples: samples, + } + localSource := &fakeLocalAudioSource{ + results: []localAudioReadResult{ + {err: temporaryAudioError(errors.New("early"))}, + {frame: want}, + }, + } + waits := 0 + reader := &audioReader{ + source: localSource, + readTimeout: 25 * time.Millisecond, + batch: 480, + channels: 2, + rateNumerator: 48000, + rateDenominator: 1, + unavailableAfter: 2 * time.Second, + retryDelay: 10 * time.Millisecond, + now: func() time.Time { return time.Unix(100, 0) }, + wait: func(context.Context, time.Duration) error { + waits++ + return nil + }, + } + + got, err := reader.ReadAudio(context.Background()) + if err != nil { + t.Fatalf("ReadAudio() error = %v", err) + } + if localSource.calls != 2 || waits != 1 { + t.Fatalf("reads = %d, waits = %d; want 2, 1", localSource.calls, waits) + } + if got.Index != want.Index || got.SampleCount != want.SampleCount || got.Channels != want.Channels { + t.Fatalf("frame metadata = %#v, want %#v", got, want) + } + if got.SampleRateNumerator != 48000 || got.SampleRateDenominator != 1 { + t.Fatalf("sample rate = %d/%d, want 48000/1", got.SampleRateNumerator, got.SampleRateDenominator) + } + for channel := range samples { + if &got.Samples[channel][0] != &samples[channel][0] { + t.Fatalf("channel %d samples were copied", channel) + } + } + for _, batch := range localSource.batches { + if batch != 480 { + t.Fatalf("read batch = %d, want 480", batch) + } + } +} + +func TestAudioReaderProlongedTemporaryFailureBecomesUnavailable(t *testing.T) { + lastCause := errors.New("last timeout") + localSource := &fakeLocalAudioSource{ + results: []localAudioReadResult{ + {err: temporaryAudioError(errors.New("first timeout"))}, + {err: temporaryAudioError(errors.New("second timeout"))}, + {err: temporaryAudioError(lastCause)}, + }, + } + times := []time.Time{time.Unix(100, 0), time.Unix(101, 0), time.Unix(102, 0)} + nowCall := 0 + waits := 0 + reader := &audioReader{ + source: localSource, + readTimeout: 20 * time.Millisecond, + batch: 480, + unavailableAfter: 2 * time.Second, + retryDelay: 10 * time.Millisecond, + now: func() time.Time { + result := times[nowCall] + nowCall++ + return result + }, + wait: func(context.Context, time.Duration) error { + waits++ + return nil + }, + } + + _, err := reader.ReadAudio(context.Background()) + if source.KindOf(err) != source.ErrorKindUnavailable { + t.Fatalf("ReadAudio() error kind = %v, want unavailable", source.KindOf(err)) + } + if !errors.Is(err, lastCause) { + t.Fatalf("ReadAudio() error = %v, want cause %v", err, lastCause) + } + if !ShouldRetry(err) { + t.Fatal("ShouldRetry(ReadAudio()) = false, want true") + } + if localSource.calls != 3 || waits != 2 { + t.Fatalf("reads = %d, waits = %d; want 3, 2", localSource.calls, waits) + } +} + +func TestAudioReaderCancellationDuringTemporaryWait(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + localSource := &fakeLocalAudioSource{ + results: []localAudioReadResult{{err: temporaryAudioError(errors.New("early"))}}, + } + reader := &audioReader{ + source: localSource, + batch: 480, + unavailableAfter: 2 * time.Second, + now: func() time.Time { return time.Unix(100, 0) }, + wait: func(ctx context.Context, _ time.Duration) error { + cancel() + return ctx.Err() + }, + } + + _, err := reader.ReadAudio(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatalf("ReadAudio() error = %v, want context.Canceled", err) + } +} + +func TestAudioReaderCloseDelegates(t *testing.T) { + closeErr := errors.New("close failed") + source := &fakeLocalAudioSource{closeErr: closeErr} + reader := &audioReader{source: source} + + err := reader.Close() + if !errors.Is(err, closeErr) || !source.closed { + t.Fatalf("Close() = %v, closed=%t; want %v, true", err, source.closed, closeErr) + } +} diff --git a/internal/adapter/mxl/retry.go b/internal/adapter/mxl/retry.go new file mode 100644 index 0000000..b54393f --- /dev/null +++ b/internal/adapter/mxl/retry.go @@ -0,0 +1,26 @@ +package mxladapter + +import ( + "context" + "errors" + + "mxl-player/internal/source" +) + +// ShouldRetry reports whether an MXL source error should start another attempt. +func ShouldRetry(err error) bool { + if errors.Is(err, context.Canceled) { + return false + } + + switch source.KindOf(err) { + case source.ErrorKindInvalidConfig: + return false + case source.ErrorKindTemporary, + source.ErrorKindUnavailable, + source.ErrorKindUnknown: + return true + default: + return true + } +} diff --git a/internal/adapter/mxl/retry_test.go b/internal/adapter/mxl/retry_test.go new file mode 100644 index 0000000..4d8f05b --- /dev/null +++ b/internal/adapter/mxl/retry_test.go @@ -0,0 +1,74 @@ +package mxladapter + +import ( + "context" + "errors" + "fmt" + "testing" + + "mxl-player/internal/source" +) + +func TestShouldRetrySourceError(t *testing.T) { + baseErr := errors.New("source failed") + classified := func(kind source.ErrorKind) error { + return &source.SourceError{ + Op: "read media", + Kind: kind, + Err: baseErr, + } + } + + tests := []struct { + name string + err error + want bool + }{ + { + name: "temporary source error is retryable", + err: classified(source.ErrorKindTemporary), + want: true, + }, + { + name: "unavailable source is retryable", + err: classified(source.ErrorKindUnavailable), + want: true, + }, + { + name: "invalid configuration is not retryable", + err: classified(source.ErrorKindInvalidConfig), + want: false, + }, + { + name: "ordinary unknown error is retryable", + err: baseErr, + want: true, + }, + { + name: "wrapped invalid configuration is not retryable", + err: fmt.Errorf( + "worker failed: %w", + classified(source.ErrorKindInvalidConfig), + ), + want: false, + }, + { + name: "context cancellation is not retryable", + err: context.Canceled, + want: false, + }, + { + name: "wrapped context cancellation is not retryable", + err: fmt.Errorf("worker stopped: %w", context.Canceled), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ShouldRetry(tt.err); got != tt.want { + t.Errorf("ShouldRetry() = %t, want %t", got, tt.want) + } + }) + } +} diff --git a/internal/adapter/mxl/sync.go b/internal/adapter/mxl/sync.go new file mode 100644 index 0000000..a9431e9 --- /dev/null +++ b/internal/adapter/mxl/sync.go @@ -0,0 +1,161 @@ +package mxladapter + +import ( + "context" + "errors" + "fmt" + "time" + + "mxl-player/internal/playback" + "mxl-player/internal/source" + + mxl "github.com/qvest-digital/go-mxl/mxl" +) + +const ( + DefaultSyncReadTimeout = 200 * time.Millisecond +) + +var ErrNativeSyncDifferentDomains = errors.New( + "native MXL synchronization requires matching domains", +) + +type SyncFactory struct { + ReadTimeout time.Duration + open func(string, string, string) (localSyncSource, error) +} + +type localSyncSource interface { + NextSync( + context.Context, + time.Duration, + ) (source.Frame, source.AudioFrame, error) + + AudioRate() mxl.Rational + Close() error +} + +type syncReader struct { + source localSyncSource + readTimeout time.Duration + rateNumerator int64 + rateDenominator int64 +} + +var _ playback.SyncReaderFactory = SyncFactory{} +var _ playback.SyncReader = (*syncReader)(nil) + +func (f SyncFactory) OpenSync( + ctx context.Context, + videoConfig playback.FeedConfig, + audioConfig playback.FeedConfig, +) (playback.SyncReader, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if err := videoConfig.Validate(); err != nil { + return nil, &source.SourceError{ + Op: "validate sync video feed", + Kind: source.ErrorKindInvalidConfig, + Err: err, + } + } + if err := audioConfig.Validate(); err != nil { + return nil, &source.SourceError{ + Op: "validate sync audio feed", + Kind: source.ErrorKindInvalidConfig, + Err: err, + } + } + if !videoConfig.IsConfigured() { + return nil, &source.SourceError{ + Op: "validate sync video feed", + Kind: source.ErrorKindInvalidConfig, + Err: errors.New("sync video feed is not configured"), + } + } + if !audioConfig.IsConfigured() { + return nil, &source.SourceError{ + Op: "validate sync audio feed", + Kind: source.ErrorKindInvalidConfig, + Err: errors.New("sync audio feed is not configured"), + } + } + + if videoConfig.Domain != audioConfig.Domain { + return nil, &source.SourceError{ + Op: "validate native MXL sync group", + Kind: source.ErrorKindInvalidConfig, + Err: ErrNativeSyncDifferentDomains, + } + } + + open := f.open + if open == nil { + open = func(domain, videoUUID, audioUUID string) (localSyncSource, error) { + return source.OpenSameDomainSync(domain, videoUUID, audioUUID) + } + } + src, err := open( + videoConfig.Domain, + videoConfig.UUID, + audioConfig.UUID, + ) + if err != nil { + return nil, fmt.Errorf("open native MXL sync group: %w", err) + } + + if err := ctx.Err(); err != nil { + _ = src.Close() + return nil, err + } + + readTimeout := f.ReadTimeout + if readTimeout <= 0 { + readTimeout = DefaultSyncReadTimeout + } + audioRate := src.AudioRate() + + return &syncReader{ + source: src, + readTimeout: readTimeout, + rateNumerator: audioRate.Num, + rateDenominator: audioRate.Den, + }, nil +} + +func (r *syncReader) ReadSync( + ctx context.Context, +) (playback.SyncFrame, error) { + video, audio, err := r.source.NextSync(ctx, r.readTimeout) + if err != nil { + return playback.SyncFrame{}, err + } + return playback.SyncFrame{ + Video: playback.VideoFrame{ + Index: video.Index, + Width: video.Width, + Height: video.Height, + Stride: video.Stride, + Size: video.Size, + Invalid: video.Invalid, + Label: video.Label, + FrameRateNumerator: video.FrameRateNumerator, + FrameRateDenominator: video.FrameRateDenominator, + Payload: video.Payload, + }, + Audio: playback.AudioFrame{ + Index: audio.Index, + SampleCount: audio.SampleCount, + Channels: audio.Channels, + Label: audio.Label, + SampleRateNumerator: r.rateNumerator, + SampleRateDenominator: r.rateDenominator, + Samples: audio.Samples, + }, + }, nil +} + +func (r *syncReader) Close() error { + return r.source.Close() +} diff --git a/internal/adapter/mxl/sync_test.go b/internal/adapter/mxl/sync_test.go new file mode 100644 index 0000000..f859207 --- /dev/null +++ b/internal/adapter/mxl/sync_test.go @@ -0,0 +1,125 @@ +package mxladapter + +import ( + "context" + "errors" + "testing" + "time" + + "mxl-player/internal/playback" + "mxl-player/internal/source" + + mxl "github.com/qvest-digital/go-mxl/mxl" +) + +type fakeLocalSyncSource struct { + video source.Frame + audio source.AudioFrame + readErr error + rate mxl.Rational + timeout time.Duration + closed bool + closeError error +} + +func (s *fakeLocalSyncSource) NextSync( + _ context.Context, + timeout time.Duration, +) (source.Frame, source.AudioFrame, error) { + s.timeout = timeout + return s.video, s.audio, s.readErr +} + +func (s *fakeLocalSyncSource) AudioRate() mxl.Rational { return s.rate } +func (s *fakeLocalSyncSource) Close() error { + s.closed = true + return s.closeError +} + +func syncFeedConfigs() (playback.FeedConfig, playback.FeedConfig) { + return playback.FeedConfig{Domain: "/mxl", UUID: "video", Active: true}, + playback.FeedConfig{Domain: "/mxl", UUID: "audio", Active: true} +} + +func TestSyncFactoryRejectsDifferentDomains(t *testing.T) { + video, audio := syncFeedConfigs() + audio.Domain = "/other" + reader, err := (SyncFactory{}).OpenSync(context.Background(), video, audio) + if reader != nil { + t.Fatal("OpenSync() reader is not nil") + } + if !errors.Is(err, ErrNativeSyncDifferentDomains) { + t.Fatalf("OpenSync() error = %v, want %v", err, ErrNativeSyncDifferentDomains) + } + if source.KindOf(err) != source.ErrorKindInvalidConfig { + t.Fatalf("error kind = %v, want invalid config", source.KindOf(err)) + } + if ShouldRetry(err) { + t.Fatal("ShouldRetry() = true for different domains") + } +} + +func TestSyncFactoryUsesDefaultsAndForwardsFeeds(t *testing.T) { + fake := &fakeLocalSyncSource{rate: mxl.Rational{Num: 48_000, Den: 1}} + var domain, videoUUID, audioUUID string + factory := SyncFactory{open: func(d, v, a string) (localSyncSource, error) { + domain, videoUUID, audioUUID = d, v, a + return fake, nil + }} + video, audio := syncFeedConfigs() + + reader, err := factory.OpenSync(context.Background(), video, audio) + if err != nil { + t.Fatalf("OpenSync() error = %v", err) + } + if domain != video.Domain || videoUUID != video.UUID || audioUUID != audio.UUID { + t.Fatalf("open args = %q %q %q", domain, videoUUID, audioUUID) + } + got := reader.(*syncReader) + if got.readTimeout != DefaultSyncReadTimeout { + t.Fatalf("reader timeout=%s, want %s", got.readTimeout, DefaultSyncReadTimeout) + } +} + +func TestSyncReaderConvertsPairWithoutCopying(t *testing.T) { + videoPayload := []byte{1, 2, 3} + audioSamples := [][]byte{{4, 5, 6, 7}} + fake := &fakeLocalSyncSource{ + video: source.Frame{Index: 10, Width: 20, Height: 30, Payload: videoPayload}, + audio: source.AudioFrame{Index: 40, SampleCount: 1, Channels: 1, Samples: audioSamples}, + rate: mxl.Rational{Num: 48_000, Den: 1}, + } + reader := &syncReader{ + source: fake, readTimeout: 7 * time.Millisecond, + rateNumerator: 48_000, rateDenominator: 1, + } + + frame, err := reader.ReadSync(context.Background()) + if err != nil { + t.Fatal(err) + } + if fake.timeout != 7*time.Millisecond { + t.Fatalf("NextSync() timeout=%s", fake.timeout) + } + if frame.Video.Index != 10 || frame.Audio.Index != 40 || frame.Audio.SampleRateNumerator != 48_000 { + t.Fatalf("frame = %+v", frame) + } + if &frame.Video.Payload[0] != &videoPayload[0] || &frame.Audio.Samples[0][0] != &audioSamples[0][0] { + t.Fatal("sync payload was copied") + } +} + +func TestSyncFactoryReturnsPreCanceledContextWithoutOpening(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + opened := false + factory := SyncFactory{open: func(string, string, string) (localSyncSource, error) { + opened = true + return nil, nil + }} + video, audio := syncFeedConfigs() + reader, err := factory.OpenSync(ctx, video, audio) + if reader != nil || !errors.Is(err, context.Canceled) || opened { + t.Fatalf("reader=%v error=%v opened=%t", reader, err, opened) + } +} diff --git a/internal/adapter/mxl/video.go b/internal/adapter/mxl/video.go new file mode 100644 index 0000000..cb867a6 --- /dev/null +++ b/internal/adapter/mxl/video.go @@ -0,0 +1,161 @@ +package mxladapter + +import ( + "context" + "errors" + "fmt" + "time" + + "mxl-player/internal/playback" + "mxl-player/internal/source" +) + +const ( + DefaultVideoReadTimeout = 200 * time.Millisecond + DefaultVideoUnavailableAfter = 2 * time.Second + DefaultTemporaryRetryDelay = 10 * time.Millisecond +) + +type VideoFactory struct { + ReadTimeout time.Duration + UnavailableAfter time.Duration +} + +type videoReader struct { + source localVideoSource + readTimeout time.Duration + unavailableAfter time.Duration + retryDelay time.Duration + now func() time.Time + wait temporaryWaitFunc +} + +type localVideoSource interface { + ReadOnceCtx(context.Context, time.Duration) (source.Frame, error) + Close() error +} + +type temporaryWaitFunc func(context.Context, time.Duration) error + +func waitForTemporaryRetry(ctx context.Context, delay time.Duration) error { + timer := time.NewTimer(delay) + defer timer.Stop() + + select { + case <-timer.C: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +var _ playback.VideoReaderFactory = VideoFactory{} +var _ playback.VideoReader = (*videoReader)(nil) + +func (f VideoFactory) OpenVideo( + ctx context.Context, + config playback.FeedConfig, +) (playback.VideoReader, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if err := config.Validate(); err != nil { + return nil, &source.SourceError{ + Op: "validate video feed", + Kind: source.ErrorKindInvalidConfig, + Err: err, + } + } + if !config.IsConfigured() { + return nil, &source.SourceError{ + Op: "validate video feed", + Kind: source.ErrorKindInvalidConfig, + Err: errors.New("video feed is not configured"), + } + } + + src, err := source.Open(config.Domain, config.UUID) + if err != nil { + return nil, fmt.Errorf("open local MXL video: %w", err) + } + + if err := ctx.Err(); err != nil { + _ = src.Close() + return nil, err + } + + readTimeout := f.ReadTimeout + if readTimeout <= 0 { + readTimeout = DefaultVideoReadTimeout + } + + unavailableAfter := f.UnavailableAfter + if unavailableAfter <= 0 { + unavailableAfter = DefaultVideoUnavailableAfter + } + + return &videoReader{ + source: src, + readTimeout: readTimeout, + unavailableAfter: unavailableAfter, + retryDelay: DefaultTemporaryRetryDelay, + now: time.Now, + wait: waitForTemporaryRetry, + }, nil +} + +func (r *videoReader) ReadVideo( + ctx context.Context, +) (playback.VideoFrame, error) { + var unavailableSince time.Time + + for { + frame, err := r.source.ReadOnceCtx(ctx, r.readTimeout) + if err == nil { + return playback.VideoFrame{ + Index: frame.Index, + Width: frame.Width, + Height: frame.Height, + Stride: frame.Stride, + Size: frame.Size, + Invalid: frame.Invalid, + Label: frame.Label, + FrameRateNumerator: frame.FrameRateNumerator, + FrameRateDenominator: frame.FrameRateDenominator, + Payload: frame.Payload, + }, nil + } + if ctx.Err() != nil { + return playback.VideoFrame{}, ctx.Err() + } + if source.KindOf(err) != source.ErrorKindTemporary { + return playback.VideoFrame{}, err + } + + now := r.now() + if unavailableSince.IsZero() { + unavailableSince = now + } else if now.Sub(unavailableSince) >= r.unavailableAfter { + return playback.VideoFrame{}, &source.SourceError{ + Op: "read local MXL video", + Kind: source.ErrorKindUnavailable, + Err: fmt.Errorf( + "no video data for %s: %w", + r.unavailableAfter, + err, + ), + } + } + + if err := r.wait(ctx, r.retryDelay); err != nil { + if ctx.Err() != nil { + return playback.VideoFrame{}, ctx.Err() + } + return playback.VideoFrame{}, err + } + } +} + +func (r *videoReader) Close() error { + return r.source.Close() +} diff --git a/internal/adapter/mxl/video_integration_test.go b/internal/adapter/mxl/video_integration_test.go new file mode 100644 index 0000000..c0c638d --- /dev/null +++ b/internal/adapter/mxl/video_integration_test.go @@ -0,0 +1,95 @@ +package mxladapter + +import ( + "context" + "errors" + "os" + "testing" + "time" + + "mxl-player/internal/playback" +) + +type cancelingVideoSink struct { + cancel context.CancelFunc + width uint32 + height uint32 + payloadSize int + got bool +} + +func (s *cancelingVideoSink) ConsumeVideo( + _ context.Context, + frame playback.VideoFrame, +) error { + s.width = frame.Width + s.height = frame.Height + s.payloadSize = len(frame.Payload) + s.got = true + s.cancel() + return nil +} + +func TestVideoWorkerIntegration(t *testing.T) { + domain := os.Getenv("MXL_TEST_VIDEO_DOMAIN") + uuid := os.Getenv("MXL_TEST_VIDEO_UUID") + if domain == "" || uuid == "" { + t.Skip("set MXL_TEST_VIDEO_DOMAIN and MXL_TEST_VIDEO_UUID") + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + sink := &cancelingVideoSink{cancel: cancel} + var statuses []playback.Status + + worker, err := playback.NewVideoWorker( + VideoFactory{}, + sink, + playback.RetryPolicy{ + MaxAttempts: 1, + InitialDelay: 100 * time.Millisecond, + MaxDelay: time.Second, + }, + ShouldRetry, + func(status playback.Status) { + statuses = append(statuses, status) + }, + ) + if err != nil { + t.Fatalf("NewVideoWorker() error = %v", err) + } + + err = worker.Run(ctx, playback.FeedConfig{ + Domain: domain, + UUID: uuid, + Active: true, + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Run() error = %v, want context.Canceled", err) + } + if !sink.got { + t.Fatal("worker did not deliver a video frame") + } + if sink.width == 0 || sink.height == 0 { + t.Fatalf( + "invalid frame dimensions: %dx%d", + sink.width, + sink.height, + ) + } + if sink.payloadSize == 0 { + t.Fatal("video frame payload is empty") + } + + foundPlaying := false + for _, status := range statuses { + if status.State == playback.StatePlaying { + foundPlaying = true + break + } + } + if !foundPlaying { + t.Fatalf("statuses contain no Playing transition: %+v", statuses) + } +} diff --git a/internal/adapter/mxl/video_test.go b/internal/adapter/mxl/video_test.go new file mode 100644 index 0000000..156e7c7 --- /dev/null +++ b/internal/adapter/mxl/video_test.go @@ -0,0 +1,300 @@ +package mxladapter + +import ( + "context" + "errors" + "testing" + "time" + + "mxl-player/internal/playback" + "mxl-player/internal/source" +) + +type localVideoReadResult struct { + frame source.Frame + err error +} + +type fakeLocalVideoSource struct { + results []localVideoReadResult + calls int + timeouts []time.Duration + closeErr error + closed bool +} + +func (s *fakeLocalVideoSource) ReadOnceCtx( + _ context.Context, + timeout time.Duration, +) (source.Frame, error) { + s.timeouts = append(s.timeouts, timeout) + if s.calls >= len(s.results) { + return source.Frame{}, errors.New("unexpected local video read") + } + result := s.results[s.calls] + s.calls++ + return result.frame, result.err +} + +func (s *fakeLocalVideoSource) Close() error { + s.closed = true + return s.closeErr +} + +func temporaryVideoError(cause error) error { + return &source.SourceError{ + Op: "read video", + Kind: source.ErrorKindTemporary, + Err: cause, + } +} + +func TestVideoFactoryOpenVideoCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + reader, err := (VideoFactory{}).OpenVideo(ctx, playback.FeedConfig{}) + if reader != nil { + t.Fatal("OpenVideo() reader is not nil after cancellation") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("OpenVideo() error = %v, want context.Canceled", err) + } +} + +func TestVideoFactoryOpenVideoRejectsInvalidConfig(t *testing.T) { + tests := []struct { + name string + config playback.FeedConfig + }{ + { + name: "feed is not configured", + config: playback.FeedConfig{}, + }, + { + name: "active feed has no UUID", + config: playback.FeedConfig{ + Domain: "/dev/shm/mxl", + Active: true, + }, + }, + { + name: "configured feed has no domain", + config: playback.FeedConfig{ + UUID: "video-uuid", + Active: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader, err := (VideoFactory{}).OpenVideo( + context.Background(), + tt.config, + ) + + if reader != nil { + t.Fatal("OpenVideo() reader is not nil for invalid config") + } + if err == nil { + t.Fatal("OpenVideo() error is nil for invalid config") + } + if got := source.KindOf(err); got != source.ErrorKindInvalidConfig { + t.Fatalf( + "source.KindOf(OpenVideo()) = %v, want %v", + got, + source.ErrorKindInvalidConfig, + ) + } + if ShouldRetry(err) { + t.Fatal("ShouldRetry(OpenVideo()) = true for invalid config") + } + }) + } +} + +func TestVideoReaderTemporaryFailureThenFrame(t *testing.T) { + temporaryErr := errors.New("video is early") + payload := []byte{1, 2, 3, 4} + wantFrame := source.Frame{ + Index: 42, + Width: 1920, + Height: 1080, + Stride: 5120, + Size: 5120 * 1080, + Invalid: false, + Payload: payload, + } + localSource := &fakeLocalVideoSource{ + results: []localVideoReadResult{ + {err: temporaryVideoError(temporaryErr)}, + {frame: wantFrame}, + }, + } + waits := 0 + reader := &videoReader{ + source: localSource, + readTimeout: 250 * time.Millisecond, + unavailableAfter: 2 * time.Second, + retryDelay: 10 * time.Millisecond, + now: func() time.Time { return time.Unix(100, 0) }, + wait: func(context.Context, time.Duration) error { + waits++ + return nil + }, + } + + got, err := reader.ReadVideo(context.Background()) + if err != nil { + t.Fatalf("ReadVideo() error = %v, want nil", err) + } + if localSource.calls != 2 { + t.Errorf("source read calls = %d, want 2", localSource.calls) + } + if waits != 1 { + t.Errorf("temporary wait calls = %d, want 1", waits) + } + if len(localSource.timeouts) != 2 || + localSource.timeouts[0] != 250*time.Millisecond || + localSource.timeouts[1] != 250*time.Millisecond { + t.Errorf("source timeouts = %v, want [250ms 250ms]", localSource.timeouts) + } + if got.Index != wantFrame.Index || + got.Width != wantFrame.Width || + got.Height != wantFrame.Height || + got.Stride != wantFrame.Stride || + got.Size != wantFrame.Size || + got.Invalid != wantFrame.Invalid { + t.Errorf("video frame = %+v, want metadata from %+v", got, wantFrame) + } + if len(got.Payload) == 0 || &got.Payload[0] != &payload[0] { + t.Fatal("video payload was copied") + } +} + +func TestVideoReaderProlongedTemporaryFailureBecomesUnavailable(t *testing.T) { + lastCause := errors.New("video timeout") + localSource := &fakeLocalVideoSource{ + results: []localVideoReadResult{ + {err: temporaryVideoError(errors.New("first timeout"))}, + {err: temporaryVideoError(errors.New("second timeout"))}, + {err: temporaryVideoError(lastCause)}, + }, + } + times := []time.Time{ + time.Unix(100, 0), + time.Unix(101, 0), + time.Unix(102, 0), + } + nowCall := 0 + waits := 0 + reader := &videoReader{ + source: localSource, + readTimeout: 200 * time.Millisecond, + unavailableAfter: 2 * time.Second, + retryDelay: 10 * time.Millisecond, + now: func() time.Time { + result := times[nowCall] + nowCall++ + return result + }, + wait: func(context.Context, time.Duration) error { + waits++ + return nil + }, + } + + _, err := reader.ReadVideo(context.Background()) + if err == nil { + t.Fatal("ReadVideo() error is nil after prolonged unavailability") + } + if got := source.KindOf(err); got != source.ErrorKindUnavailable { + t.Fatalf("source.KindOf(ReadVideo()) = %v, want %v", got, source.ErrorKindUnavailable) + } + if !errors.Is(err, lastCause) { + t.Errorf("ReadVideo() error = %v, want cause %v", err, lastCause) + } + if !ShouldRetry(err) { + t.Error("ShouldRetry(ReadVideo()) = false, want true") + } + if localSource.calls != 3 { + t.Errorf("source read calls = %d, want 3", localSource.calls) + } + if waits != 2 { + t.Errorf("temporary wait calls = %d, want 2", waits) + } +} + +func TestVideoReaderReturnsNonTemporaryErrorImmediately(t *testing.T) { + unavailableErr := &source.SourceError{ + Op: "read video", + Kind: source.ErrorKindUnavailable, + Err: errors.New("flow invalid"), + } + localSource := &fakeLocalVideoSource{ + results: []localVideoReadResult{{err: unavailableErr}}, + } + reader := &videoReader{ + source: localSource, + readTimeout: 200 * time.Millisecond, + unavailableAfter: 2 * time.Second, + retryDelay: 10 * time.Millisecond, + now: func() time.Time { + t.Fatal("clock called for non-temporary error") + return time.Time{} + }, + wait: func(context.Context, time.Duration) error { + t.Fatal("wait called for non-temporary error") + return nil + }, + } + + _, err := reader.ReadVideo(context.Background()) + if !errors.Is(err, unavailableErr) { + t.Fatalf("ReadVideo() error = %v, want %v", err, unavailableErr) + } + if localSource.calls != 1 { + t.Errorf("source read calls = %d, want 1", localSource.calls) + } +} + +func TestVideoReaderCancellationDuringTemporaryWait(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + localSource := &fakeLocalVideoSource{ + results: []localVideoReadResult{{err: temporaryVideoError(errors.New("early"))}}, + } + reader := &videoReader{ + source: localSource, + readTimeout: 200 * time.Millisecond, + unavailableAfter: 2 * time.Second, + retryDelay: 10 * time.Millisecond, + now: func() time.Time { return time.Unix(100, 0) }, + wait: func(ctx context.Context, _ time.Duration) error { + cancel() + return ctx.Err() + }, + } + + _, err := reader.ReadVideo(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatalf("ReadVideo() error = %v, want context.Canceled", err) + } + if localSource.calls != 1 { + t.Errorf("source read calls = %d, want 1", localSource.calls) + } +} + +func TestVideoReaderCloseDelegatesToSource(t *testing.T) { + closeErr := errors.New("close failed") + localSource := &fakeLocalVideoSource{closeErr: closeErr} + reader := &videoReader{source: localSource} + + err := reader.Close() + if !errors.Is(err, closeErr) { + t.Fatalf("Close() error = %v, want %v", err, closeErr) + } + if !localSource.closed { + t.Fatal("local source was not closed") + } +} diff --git a/internal/output/audio.go b/internal/output/audio.go new file mode 100644 index 0000000..5b09370 --- /dev/null +++ b/internal/output/audio.go @@ -0,0 +1,65 @@ +package output + +import ( + "errors" + + "mxl-player/internal/playback" +) + +var ( + ErrInvalidAudioFrame = errors.New("invalid audio frame") + ErrAudioPayloadTooSmall = errors.New("audio channel payload is too small") +) + +func InterleaveF32(frame playback.AudioFrame) ([]byte, error) { + if frame.Channels == 0 || + frame.SampleCount == 0 || + frame.SampleRateNumerator <= 0 || + frame.SampleRateDenominator <= 0 { + return nil, ErrInvalidAudioFrame + } + + if uint64(len(frame.Samples)) != frame.Channels { + return nil, ErrInvalidAudioFrame + } + + if frame.SampleCount > ^uint64(0)/4 { + return nil, ErrInvalidAudioFrame + } + bytesPerChannel := frame.SampleCount * 4 + + if frame.Channels > ^uint64(0)/bytesPerChannel { + return nil, ErrInvalidAudioFrame + } + totalBytes := frame.Channels * bytesPerChannel + + maxInt := uint64(^uint(0) >> 1) + if totalBytes > maxInt { + return nil, ErrInvalidAudioFrame + } + + for _, samples := range frame.Samples { + if bytesPerChannel > uint64(len(samples)) { + return nil, ErrAudioPayloadTooSmall + } + } + + result := make([]byte, int(totalBytes)) + + for sample := uint64(0); sample < frame.SampleCount; sample++ { + for channel := uint64(0); channel < frame.Channels; channel++ { + sourceOffset := sample * 4 + destinationOffset := (sample*frame.Channels + channel) * 4 + + sourceStart := int(sourceOffset) + destinationStart := int(destinationOffset) + + copy( + result[destinationStart:destinationStart+4], + frame.Samples[channel][sourceStart:sourceStart+4], + ) + } + } + + return result, nil +} diff --git a/internal/output/audio_test.go b/internal/output/audio_test.go new file mode 100644 index 0000000..651ee4f --- /dev/null +++ b/internal/output/audio_test.go @@ -0,0 +1,116 @@ +package output + +import ( + "bytes" + "errors" + "testing" + + "mxl-player/internal/playback" +) + +func validAudioFrame() playback.AudioFrame { + return playback.AudioFrame{ + Index: 42, + SampleCount: 2, + Channels: 2, + SampleRateNumerator: 48000, + SampleRateDenominator: 1, + Samples: [][]byte{ + {1, 2, 3, 4, 5, 6, 7, 8}, + {9, 10, 11, 12, 13, 14, 15, 16}, + }, + } +} + +func TestInterleaveF32OrdersSamplesByFrameThenChannel(t *testing.T) { + frame := validAudioFrame() + want := []byte{ + 1, 2, 3, 4, + 9, 10, 11, 12, + 5, 6, 7, 8, + 13, 14, 15, 16, + } + + got, err := InterleaveF32(frame) + if err != nil { + t.Fatalf("InterleaveF32() error = %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("InterleaveF32() = %v, want %v", got, want) + } +} + +func TestInterleaveF32IgnoresBytesAfterSampleCount(t *testing.T) { + frame := validAudioFrame() + frame.SampleCount = 1 + + got, err := InterleaveF32(frame) + if err != nil { + t.Fatalf("InterleaveF32() error = %v", err) + } + want := []byte{1, 2, 3, 4, 9, 10, 11, 12} + if !bytes.Equal(got, want) { + t.Fatalf("InterleaveF32() = %v, want %v", got, want) + } +} + +func TestInterleaveF32RejectsInvalidMetadata(t *testing.T) { + tests := []struct { + name string + mutate func(*playback.AudioFrame) + }{ + {name: "zero samples", mutate: func(f *playback.AudioFrame) { f.SampleCount = 0 }}, + {name: "zero channels", mutate: func(f *playback.AudioFrame) { f.Channels = 0 }}, + {name: "zero rate numerator", mutate: func(f *playback.AudioFrame) { f.SampleRateNumerator = 0 }}, + {name: "negative rate numerator", mutate: func(f *playback.AudioFrame) { f.SampleRateNumerator = -1 }}, + {name: "zero rate denominator", mutate: func(f *playback.AudioFrame) { f.SampleRateDenominator = 0 }}, + {name: "wrong channel count", mutate: func(f *playback.AudioFrame) { f.Channels = 3 }}, + {name: "sample size overflow", mutate: func(f *playback.AudioFrame) { f.SampleCount = ^uint64(0) }}, + { + name: "allocation size overflow", + mutate: func(f *playback.AudioFrame) { + f.SampleCount = uint64(^uint(0)>>1) / 4 + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + frame := validAudioFrame() + tt.mutate(&frame) + result, err := InterleaveF32(frame) + if result != nil { + t.Fatalf("InterleaveF32() result = %v, want nil", result) + } + if !errors.Is(err, ErrInvalidAudioFrame) { + t.Fatalf("InterleaveF32() error = %v, want %v", err, ErrInvalidAudioFrame) + } + }) + } +} + +func TestInterleaveF32RejectsShortChannel(t *testing.T) { + frame := validAudioFrame() + frame.Samples[1] = frame.Samples[1][:7] + + result, err := InterleaveF32(frame) + if result != nil { + t.Fatalf("InterleaveF32() result = %v, want nil", result) + } + if !errors.Is(err, ErrAudioPayloadTooSmall) { + t.Fatalf("InterleaveF32() error = %v, want %v", err, ErrAudioPayloadTooSmall) + } +} + +func TestInterleaveF32ReturnsIndependentOutput(t *testing.T) { + frame := validAudioFrame() + result, err := InterleaveF32(frame) + if err != nil { + t.Fatalf("InterleaveF32() error = %v", err) + } + + frame.Samples[0][0] = 99 + if result[0] != 1 { + t.Fatalf("output changed with borrowed input: first byte = %d, want 1", result[0]) + } +} diff --git a/internal/output/sdl_audio.go b/internal/output/sdl_audio.go new file mode 100644 index 0000000..590725c --- /dev/null +++ b/internal/output/sdl_audio.go @@ -0,0 +1,204 @@ +package output + +import ( + "context" + "errors" + "fmt" + "math" + "time" + + "mxl-player/internal/playback" + "mxl-player/internal/sdl" +) + +const ( + defaultMaxQueuedBatches = int32(20) + defaultAudioQueuePoll = 10 * time.Millisecond +) + +var ( + ErrInvalidAudioFormat = errors.New("invalid audio output format") + ErrOpenAudioStream = errors.New("open SDL audio stream") + ErrResumeAudioStream = errors.New("resume SDL audio stream") + ErrQueueAudioData = errors.New("queue SDL audio data") +) + +type audioBackend interface { + Open(uint32, sdl.AudioSpec) uintptr + Resume(uintptr) bool + Put(uintptr, []byte) bool + Queued(uintptr) int32 + Destroy(uintptr) + Error() string +} + +type systemAudioBackend struct{} + +func (systemAudioBackend) Open(device uint32, spec sdl.AudioSpec) uintptr { + return sdl.OpenAudioDeviceStream(device, spec) +} +func (systemAudioBackend) Resume(stream uintptr) bool { return sdl.ResumeAudioStreamDevice(stream) } +func (systemAudioBackend) Put(stream uintptr, data []byte) bool { + return sdl.PutAudioStreamData(stream, data) +} +func (systemAudioBackend) Queued(stream uintptr) int32 { return sdl.GetAudioStreamQueued(stream) } +func (systemAudioBackend) Destroy(stream uintptr) { sdl.DestroyAudioStream(stream) } +func (systemAudioBackend) Error() string { return sdl.GetError() } + +type audioFormat struct { + channels int32 + frequency int32 +} + +type audioQueueWaitFunc func(context.Context, time.Duration) error + +type SDLAudioSink struct { + deviceID uint32 + backend audioBackend + stream uintptr + format audioFormat + maxQueuedBatches int32 + queuePoll time.Duration + wait audioQueueWaitFunc +} + +var _ playback.AudioSink = (*SDLAudioSink)(nil) + +func NewSDLAudioSink(deviceID uint32) *SDLAudioSink { + return &SDLAudioSink{ + deviceID: deviceID, + backend: systemAudioBackend{}, + maxQueuedBatches: defaultMaxQueuedBatches, + queuePoll: defaultAudioQueuePoll, + wait: waitForAudioQueue, + } +} + +func waitForAudioQueue(ctx context.Context, delay time.Duration) error { + timer := time.NewTimer(delay) + defer timer.Stop() + + select { + case <-timer.C: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func audioOutputFormat(frame playback.AudioFrame) (audioFormat, error) { + if frame.Channels == 0 || frame.Channels > math.MaxInt32 || + frame.SampleRateNumerator <= 0 || frame.SampleRateDenominator <= 0 || + frame.SampleRateNumerator%frame.SampleRateDenominator != 0 { + return audioFormat{}, fmt.Errorf( + "%w: channels=%d rate=%d/%d", + ErrInvalidAudioFormat, + frame.Channels, + frame.SampleRateNumerator, + frame.SampleRateDenominator, + ) + } + + frequency := frame.SampleRateNumerator / frame.SampleRateDenominator + if frequency <= 0 || frequency > math.MaxInt32 { + return audioFormat{}, fmt.Errorf( + "%w: frequency=%d", + ErrInvalidAudioFormat, + frequency, + ) + } + + return audioFormat{ + channels: int32(frame.Channels), + frequency: int32(frequency), + }, nil +} + +func (s *SDLAudioSink) ConsumeAudio( + ctx context.Context, + frame playback.AudioFrame, +) error { + if err := ctx.Err(); err != nil { + return err + } + + format, err := audioOutputFormat(frame) + if err != nil { + return err + } + interleaved, err := InterleaveF32(frame) + if err != nil { + return err + } + + if s.stream == 0 || s.format != format { + if err := s.recreateStream(format); err != nil { + return err + } + } + + if s.maxQueuedBatches <= 0 || len(interleaved) > math.MaxInt32/int(s.maxQueuedBatches) { + return fmt.Errorf( + "%w: batch bytes=%d queue batches=%d", + ErrInvalidAudioFormat, + len(interleaved), + s.maxQueuedBatches, + ) + } + maxQueuedBytes := int32(len(interleaved)) * s.maxQueuedBatches + + for { + queued := s.backend.Queued(s.stream) + if queued < 0 { + return fmt.Errorf("%w: query queued bytes: %s", ErrQueueAudioData, s.backend.Error()) + } + if queued <= maxQueuedBytes { + break + } + if err := s.wait(ctx, s.queuePoll); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return err + } + } + + if !s.backend.Put(s.stream, interleaved) { + return fmt.Errorf("%w: %s", ErrQueueAudioData, s.backend.Error()) + } + return nil +} + +func (s *SDLAudioSink) recreateStream(format audioFormat) error { + if s.stream != 0 { + s.backend.Destroy(s.stream) + s.stream = 0 + s.format = audioFormat{} + } + + stream := s.backend.Open(s.deviceID, sdl.AudioSpec{ + Format: sdl.AudioF32, + Channels: format.channels, + Freq: format.frequency, + }) + if stream == 0 { + return fmt.Errorf("%w: %s", ErrOpenAudioStream, s.backend.Error()) + } + if !s.backend.Resume(stream) { + s.backend.Destroy(stream) + return fmt.Errorf("%w: %s", ErrResumeAudioStream, s.backend.Error()) + } + + s.stream = stream + s.format = format + return nil +} + +func (s *SDLAudioSink) Close() error { + if s.stream != 0 { + s.backend.Destroy(s.stream) + s.stream = 0 + } + s.format = audioFormat{} + return nil +} diff --git a/internal/output/sdl_audio_test.go b/internal/output/sdl_audio_test.go new file mode 100644 index 0000000..85e9109 --- /dev/null +++ b/internal/output/sdl_audio_test.go @@ -0,0 +1,213 @@ +package output + +import ( + "bytes" + "context" + "errors" + "testing" + "time" + + "mxl-player/internal/playback" + "mxl-player/internal/sdl" +) + +type fakeAudioBackend struct { + nextStream uintptr + openSpecs []sdl.AudioSpec + openDevice []uint32 + resumeOK bool + putOK bool + puts [][]byte + queued []int32 + queueCalls int + destroyed []uintptr + errText string +} + +func (b *fakeAudioBackend) Open(device uint32, spec sdl.AudioSpec) uintptr { + b.openDevice = append(b.openDevice, device) + b.openSpecs = append(b.openSpecs, spec) + return b.nextStream +} +func (b *fakeAudioBackend) Resume(uintptr) bool { return b.resumeOK } +func (b *fakeAudioBackend) Put(_ uintptr, data []byte) bool { + b.puts = append(b.puts, append([]byte(nil), data...)) + return b.putOK +} +func (b *fakeAudioBackend) Queued(uintptr) int32 { + if len(b.queued) == 0 { + return 0 + } + index := b.queueCalls + if index >= len(b.queued) { + index = len(b.queued) - 1 + } + b.queueCalls++ + return b.queued[index] +} +func (b *fakeAudioBackend) Destroy(stream uintptr) { + b.destroyed = append(b.destroyed, stream) +} +func (b *fakeAudioBackend) Error() string { return b.errText } + +func newTestSDLAudioSink(backend audioBackend) *SDLAudioSink { + return &SDLAudioSink{ + deviceID: 7, + backend: backend, + maxQueuedBatches: 2, + queuePoll: time.Millisecond, + wait: func(context.Context, time.Duration) error { return nil }, + } +} + +func TestAudioOutputFormat(t *testing.T) { + tests := []struct { + name string + frame playback.AudioFrame + want audioFormat + wantErr bool + }{ + { + name: "valid", + frame: playback.AudioFrame{Channels: 2, SampleRateNumerator: 48000, SampleRateDenominator: 1}, + want: audioFormat{channels: 2, frequency: 48000}, + }, + {name: "zero channels", frame: playback.AudioFrame{SampleRateNumerator: 48000, SampleRateDenominator: 1}, wantErr: true}, + {name: "zero numerator", frame: playback.AudioFrame{Channels: 2, SampleRateDenominator: 1}, wantErr: true}, + {name: "zero denominator", frame: playback.AudioFrame{Channels: 2, SampleRateNumerator: 48000}, wantErr: true}, + {name: "fractional frequency", frame: playback.AudioFrame{Channels: 2, SampleRateNumerator: 30000, SampleRateDenominator: 1001}, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := audioOutputFormat(tt.frame) + if tt.wantErr { + if !errors.Is(err, ErrInvalidAudioFormat) { + t.Fatalf("audioOutputFormat() error = %v, want %v", err, ErrInvalidAudioFormat) + } + return + } + if err != nil || got != tt.want { + t.Fatalf("audioOutputFormat() = %#v, %v; want %#v, nil", got, err, tt.want) + } + }) + } +} + +func TestSDLAudioSinkOpensInterleavesAndReusesStream(t *testing.T) { + backend := &fakeAudioBackend{nextStream: 11, resumeOK: true, putOK: true} + sink := newTestSDLAudioSink(backend) + frame := validAudioFrame() + + if err := sink.ConsumeAudio(context.Background(), frame); err != nil { + t.Fatalf("first ConsumeAudio() error = %v", err) + } + if err := sink.ConsumeAudio(context.Background(), frame); err != nil { + t.Fatalf("second ConsumeAudio() error = %v", err) + } + if len(backend.openSpecs) != 1 { + t.Fatalf("open calls = %d, want 1", len(backend.openSpecs)) + } + wantSpec := sdl.AudioSpec{Format: sdl.AudioF32, Channels: 2, Freq: 48000} + if backend.openDevice[0] != 7 || backend.openSpecs[0] != wantSpec { + t.Fatalf("open = device %d spec %#v, want 7 %#v", backend.openDevice[0], backend.openSpecs[0], wantSpec) + } + wantData := []byte{1, 2, 3, 4, 9, 10, 11, 12, 5, 6, 7, 8, 13, 14, 15, 16} + if len(backend.puts) != 2 || !bytes.Equal(backend.puts[0], wantData) { + t.Fatalf("queued data = %v, want %v twice", backend.puts, wantData) + } +} + +func TestSDLAudioSinkRecreatesStreamOnFormatChange(t *testing.T) { + backend := &fakeAudioBackend{nextStream: 11, resumeOK: true, putOK: true} + sink := newTestSDLAudioSink(backend) + first := validAudioFrame() + if err := sink.ConsumeAudio(context.Background(), first); err != nil { + t.Fatalf("first ConsumeAudio() error = %v", err) + } + + backend.nextStream = 12 + second := first + second.SampleRateNumerator = 96000 + if err := sink.ConsumeAudio(context.Background(), second); err != nil { + t.Fatalf("second ConsumeAudio() error = %v", err) + } + if len(backend.openSpecs) != 2 || len(backend.destroyed) != 1 || backend.destroyed[0] != 11 { + t.Fatalf("opens=%d destroyed=%v, want 2 and [11]", len(backend.openSpecs), backend.destroyed) + } +} + +func TestSDLAudioSinkReportsOpenResumeAndPutFailures(t *testing.T) { + frame := validAudioFrame() + + t.Run("open", func(t *testing.T) { + backend := &fakeAudioBackend{errText: "open failed"} + err := newTestSDLAudioSink(backend).ConsumeAudio(context.Background(), frame) + if !errors.Is(err, ErrOpenAudioStream) { + t.Fatalf("ConsumeAudio() error = %v, want %v", err, ErrOpenAudioStream) + } + }) + + t.Run("resume", func(t *testing.T) { + backend := &fakeAudioBackend{nextStream: 11, errText: "resume failed"} + sink := newTestSDLAudioSink(backend) + err := sink.ConsumeAudio(context.Background(), frame) + if !errors.Is(err, ErrResumeAudioStream) || sink.stream != 0 { + t.Fatalf("ConsumeAudio() error=%v stream=%d, want resume error and zero stream", err, sink.stream) + } + if len(backend.destroyed) != 1 || backend.destroyed[0] != 11 { + t.Fatalf("destroyed = %v, want [11]", backend.destroyed) + } + }) + + t.Run("put", func(t *testing.T) { + backend := &fakeAudioBackend{nextStream: 11, resumeOK: true, errText: "put failed"} + err := newTestSDLAudioSink(backend).ConsumeAudio(context.Background(), frame) + if !errors.Is(err, ErrQueueAudioData) { + t.Fatalf("ConsumeAudio() error = %v, want %v", err, ErrQueueAudioData) + } + }) +} + +func TestSDLAudioSinkWaitsForQueueAndHonorsCancellation(t *testing.T) { + backend := &fakeAudioBackend{ + nextStream: 11, + resumeOK: true, + putOK: true, + queued: []int32{100, 100}, + } + sink := newTestSDLAudioSink(backend) + ctx, cancel := context.WithCancel(context.Background()) + waits := 0 + sink.wait = func(ctx context.Context, _ time.Duration) error { + waits++ + cancel() + return ctx.Err() + } + + err := sink.ConsumeAudio(ctx, validAudioFrame()) + if !errors.Is(err, context.Canceled) { + t.Fatalf("ConsumeAudio() error = %v, want context.Canceled", err) + } + if waits != 1 || len(backend.puts) != 0 { + t.Fatalf("waits=%d puts=%d, want 1 and 0", waits, len(backend.puts)) + } +} + +func TestSDLAudioSinkCloseIsIdempotent(t *testing.T) { + backend := &fakeAudioBackend{nextStream: 11, resumeOK: true, putOK: true} + sink := newTestSDLAudioSink(backend) + if err := sink.ConsumeAudio(context.Background(), validAudioFrame()); err != nil { + t.Fatalf("ConsumeAudio() error = %v", err) + } + + if err := sink.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + if err := sink.Close(); err != nil { + t.Fatalf("second Close() error = %v", err) + } + if len(backend.destroyed) != 1 || sink.stream != 0 || sink.format != (audioFormat{}) { + t.Fatalf("destroyed=%v stream=%d format=%#v", backend.destroyed, sink.stream, sink.format) + } +} diff --git a/internal/playback/audio.go b/internal/playback/audio.go new file mode 100644 index 0000000..3972edd --- /dev/null +++ b/internal/playback/audio.go @@ -0,0 +1,43 @@ +package playback + +import "context" + +// AudioFrame contains deinterleaved F32 audio samples. +// +// Samples contains one byte slice per channel. Each channel contains +// SampleCount float32 samples. +// +// The sample payload may borrow source-owned memory. AudioSink must finish +// reading it before ConsumeAudio returns. +type AudioFrame struct { + Index uint64 + SampleCount uint64 + Channels uint64 + Label string + + SampleRateNumerator int64 + SampleRateDenominator int64 + + Samples [][]byte +} + +// AudioReader reads batches from one audio feed. +// +// ReadAudio must not be called again until the previous frame has been +// consumed. +type AudioReader interface { + ReadAudio(context.Context) (AudioFrame, error) + Close() error +} + +// AudioReaderFactory opens a reader for the configured audio feed. +type AudioReaderFactory interface { + OpenAudio(context.Context, FeedConfig) (AudioReader, error) +} + +// AudioSink synchronously consumes one borrowed audio batch. +// +// ConsumeAudio must not retain frame.Samples or their underlying byte slices. +type AudioSink interface { + ConsumeAudio(context.Context, AudioFrame) error +} diff --git a/internal/playback/audio_attempt.go b/internal/playback/audio_attempt.go new file mode 100644 index 0000000..ba316d6 --- /dev/null +++ b/internal/playback/audio_attempt.go @@ -0,0 +1,55 @@ +package playback + +import ( + "context" + "errors" + "fmt" +) + +type audioSinkError struct { + err error +} + +func (e *audioSinkError) Error() string { + return fmt.Sprintf("consume audio: %v", e.err) +} + +func (e *audioSinkError) Unwrap() error { + return e.err +} + +func runAudioAttempt( + ctx context.Context, + factory AudioReaderFactory, + sink AudioSink, + config FeedConfig, +) (resultErr error) { + reader, err := factory.OpenAudio(ctx, config) + if err != nil { + return fmt.Errorf("open audio: %w", err) + } + + defer func() { + if closeErr := reader.Close(); closeErr != nil { + closeErr = fmt.Errorf("close audio: %w", closeErr) + resultErr = errors.Join(resultErr, closeErr) + } + }() + + for { + frame, err := reader.ReadAudio(ctx) + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return fmt.Errorf("read audio: %w", err) + } + + if err := sink.ConsumeAudio(ctx, frame); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return &audioSinkError{err: err} + } + } +} diff --git a/internal/playback/audio_attempt_test.go b/internal/playback/audio_attempt_test.go new file mode 100644 index 0000000..5d43330 --- /dev/null +++ b/internal/playback/audio_attempt_test.go @@ -0,0 +1,204 @@ +package playback + +import ( + "context" + "errors" + "testing" +) + +type fakeAudioFactory struct { + reader AudioReader + err error + calls int +} + +func (f *fakeAudioFactory) OpenAudio( + context.Context, + FeedConfig, +) (AudioReader, error) { + f.calls++ + return f.reader, f.err +} + +type fakeAudioReader struct { + frames []AudioFrame + readErr error + closeErr error + readCalls int + closed bool + read func(context.Context) (AudioFrame, error) +} + +func (r *fakeAudioReader) ReadAudio(ctx context.Context) (AudioFrame, error) { + r.readCalls++ + if r.read != nil { + return r.read(ctx) + } + if len(r.frames) == 0 { + return AudioFrame{}, r.readErr + } + frame := r.frames[0] + r.frames = r.frames[1:] + return frame, nil +} + +func (r *fakeAudioReader) Close() error { + r.closed = true + return r.closeErr +} + +type fakeAudioSink struct { + frames []AudioFrame + err error +} + +func (s *fakeAudioSink) ConsumeAudio(_ context.Context, frame AudioFrame) error { + s.frames = append(s.frames, frame) + return s.err +} + +func TestRunAudioAttemptOpenFailure(t *testing.T) { + openErr := errors.New("open failed") + factory := &fakeAudioFactory{err: openErr} + sink := &fakeAudioSink{} + + err := runAudioAttempt(context.Background(), factory, sink, FeedConfig{}) + + if !errors.Is(err, openErr) { + t.Fatalf("runAudioAttempt() error = %v, want %v", err, openErr) + } + if factory.calls != 1 { + t.Errorf("factory calls = %d, want 1", factory.calls) + } + if len(sink.frames) != 0 { + t.Fatalf("consumed frame count = %d, want 0", len(sink.frames)) + } +} + +func TestRunAudioAttemptConsumesFrameWithoutCopyThenReturnsReadFailure(t *testing.T) { + readErr := errors.New("read failed") + wantFrame := AudioFrame{ + Index: 42, + SampleCount: 2, + Channels: 2, + SampleRateNumerator: 48000, + SampleRateDenominator: 1, + Samples: [][]byte{ + {1, 2, 3, 4}, + {5, 6, 7, 8}, + }, + } + reader := &fakeAudioReader{ + frames: []AudioFrame{wantFrame}, + readErr: readErr, + } + sink := &fakeAudioSink{} + + err := runAudioAttempt( + context.Background(), + &fakeAudioFactory{reader: reader}, + sink, + FeedConfig{}, + ) + + if !errors.Is(err, readErr) { + t.Fatalf("runAudioAttempt() error = %v, want %v", err, readErr) + } + if !reader.closed { + t.Fatal("reader was not closed") + } + if reader.readCalls != 2 { + t.Errorf("read calls = %d, want 2", reader.readCalls) + } + if len(sink.frames) != 1 { + t.Fatalf("consumed frame count = %d, want 1", len(sink.frames)) + } + got := sink.frames[0] + if got.Index != wantFrame.Index || + got.SampleCount != wantFrame.SampleCount || + got.Channels != wantFrame.Channels || + got.SampleRateNumerator != wantFrame.SampleRateNumerator || + got.SampleRateDenominator != wantFrame.SampleRateDenominator { + t.Errorf("consumed frame metadata = %+v, want %+v", got, wantFrame) + } + for channel := range wantFrame.Samples { + if &got.Samples[channel][0] != &wantFrame.Samples[channel][0] { + t.Fatalf("channel %d samples were copied", channel) + } + } +} + +func TestRunAudioAttemptSinkFailureStopsReadingAndCloses(t *testing.T) { + sinkErr := errors.New("audio output unavailable") + reader := &fakeAudioReader{ + frames: []AudioFrame{ + {Index: 1, Samples: [][]byte{{1}}}, + {Index: 2, Samples: [][]byte{{2}}}, + }, + } + + err := runAudioAttempt( + context.Background(), + &fakeAudioFactory{reader: reader}, + &fakeAudioSink{err: sinkErr}, + FeedConfig{}, + ) + + if !errors.Is(err, sinkErr) { + t.Fatalf("runAudioAttempt() error = %v, want %v", err, sinkErr) + } + var typedErr *audioSinkError + if !errors.As(err, &typedErr) { + t.Fatalf("runAudioAttempt() error type = %T, want *audioSinkError", err) + } + if reader.readCalls != 1 { + t.Errorf("read calls = %d, want 1", reader.readCalls) + } + if !reader.closed { + t.Fatal("reader was not closed") + } +} + +func TestRunAudioAttemptCanceledRead(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + reader := &fakeAudioReader{ + read: func(ctx context.Context) (AudioFrame, error) { + cancel() + return AudioFrame{}, ctx.Err() + }, + } + + err := runAudioAttempt( + ctx, + &fakeAudioFactory{reader: reader}, + &fakeAudioSink{}, + FeedConfig{}, + ) + + if !errors.Is(err, context.Canceled) { + t.Fatalf("runAudioAttempt() error = %v, want context.Canceled", err) + } + if !reader.closed { + t.Fatal("reader was not closed") + } +} + +func TestRunAudioAttemptJoinsReadAndCloseErrors(t *testing.T) { + readErr := errors.New("read failed") + closeErr := errors.New("close failed") + reader := &fakeAudioReader{readErr: readErr, closeErr: closeErr} + + err := runAudioAttempt( + context.Background(), + &fakeAudioFactory{reader: reader}, + &fakeAudioSink{}, + FeedConfig{}, + ) + + if !errors.Is(err, readErr) { + t.Errorf("runAudioAttempt() error does not contain read error: %v", err) + } + if !errors.Is(err, closeErr) { + t.Errorf("runAudioAttempt() error does not contain close error: %v", err) + } +} diff --git a/internal/playback/audio_slot.go b/internal/playback/audio_slot.go new file mode 100644 index 0000000..4708c22 --- /dev/null +++ b/internal/playback/audio_slot.go @@ -0,0 +1,93 @@ +package playback + +import ( + "context" + "errors" + "fmt" +) + +var ErrAudioWorkerRequired = errors.New("audio worker is required") + +type AudioSlot struct { + worker *AudioWorker +} + +func NewAudioSlot(worker *AudioWorker) (*AudioSlot, error) { + if worker == nil { + return nil, ErrAudioWorkerRequired + } + return &AudioSlot{worker: worker}, nil +} + +func (s *AudioSlot) Run( + ctx context.Context, + initial FeedConfig, + commands <-chan FeedConfig, +) error { + if err := initial.Validate(); err != nil { + return fmt.Errorf("validate initial audio config: %w", err) + } + + var ( + workerCancel context.CancelFunc + workerDone chan error + ) + + start := func(config FeedConfig) { + workerCtx, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + + workerCancel = cancel + workerDone = done + + go func() { + done <- s.worker.Run(workerCtx, config) + }() + } + + stop := func() { + if workerCancel == nil { + return + } + + workerCancel() + <-workerDone + + workerCancel = nil + workerDone = nil + } + + if initial.Active { + start(initial) + } + + for { + select { + case <-ctx.Done(): + stop() + return ctx.Err() + + case config, ok := <-commands: + if !ok { + stop() + return nil + } + + if err := config.Validate(); err != nil { + // Ignore invalid commands without disturbing the current worker. + continue + } + + stop() + if config.Active { + start(config) + } + + case <-workerDone: + // The worker stopped naturally or exhausted its retries. + workerCancel() + workerCancel = nil + workerDone = nil + } + } +} diff --git a/internal/playback/audio_slot_test.go b/internal/playback/audio_slot_test.go new file mode 100644 index 0000000..ea306bd --- /dev/null +++ b/internal/playback/audio_slot_test.go @@ -0,0 +1,202 @@ +package playback + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +type slotAudioFactory struct { + opened chan FeedConfig + + mu sync.Mutex + active int + maxActive int + closeCount int +} + +func newSlotAudioFactory() *slotAudioFactory { + return &slotAudioFactory{opened: make(chan FeedConfig, 8)} +} + +func (f *slotAudioFactory) OpenAudio( + _ context.Context, + config FeedConfig, +) (AudioReader, error) { + f.mu.Lock() + f.active++ + if f.active > f.maxActive { + f.maxActive = f.active + } + f.mu.Unlock() + f.opened <- config + return &slotAudioReader{factory: f}, nil +} + +func (f *slotAudioFactory) counts() (active, maxActive, closeCount int) { + f.mu.Lock() + defer f.mu.Unlock() + return f.active, f.maxActive, f.closeCount +} + +type slotAudioReader struct { + factory *slotAudioFactory +} + +func (r *slotAudioReader) ReadAudio(ctx context.Context) (AudioFrame, error) { + <-ctx.Done() + return AudioFrame{}, ctx.Err() +} + +func (r *slotAudioReader) Close() error { + r.factory.mu.Lock() + defer r.factory.mu.Unlock() + r.factory.active-- + r.factory.closeCount++ + return nil +} + +func newAudioSlotWorker(t *testing.T, factory AudioReaderFactory) *AudioWorker { + t.Helper() + return newAudioWorkerForTest( + t, + factory, + &fakeAudioSink{}, + 1, + func(error) bool { return false }, + nil, + ) +} + +func receiveAudioSlotOpen(t *testing.T, opened <-chan FeedConfig) FeedConfig { + t.Helper() + select { + case config := <-opened: + return config + case <-time.After(time.Second): + t.Fatal("audio worker did not open") + return FeedConfig{} + } +} + +func TestNewAudioSlotRequiresWorker(t *testing.T) { + slot, err := NewAudioSlot(nil) + if slot != nil { + t.Fatalf("NewAudioSlot(nil) slot = %#v, want nil", slot) + } + if !errors.Is(err, ErrAudioWorkerRequired) { + t.Fatalf("NewAudioSlot(nil) error = %v, want %v", err, ErrAudioWorkerRequired) + } +} + +func TestAudioSlotStartsAndJoinsInitialWorker(t *testing.T) { + factory := newSlotAudioFactory() + slot, err := NewAudioSlot(newAudioSlotWorker(t, factory)) + if err != nil { + t.Fatalf("NewAudioSlot() error = %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + want := FeedConfig{Domain: "/audio", UUID: "first", Active: true} + + go func() { done <- slot.Run(ctx, want, make(chan FeedConfig)) }() + if got := receiveAudioSlotOpen(t, factory.opened); got != want { + t.Fatalf("opened config = %#v, want %#v", got, want) + } + cancel() + + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("Run() error = %v, want %v", err, context.Canceled) + } + case <-time.After(time.Second): + t.Fatal("Run() did not stop after cancellation") + } + active, maxActive, closeCount := factory.counts() + if active != 0 || maxActive != 1 || closeCount != 1 { + t.Fatalf("reader counts = %d, %d, %d; want 0, 1, 1", active, maxActive, closeCount) + } +} + +func TestAudioSlotReplacesWithoutOverlapAndStops(t *testing.T) { + factory := newSlotAudioFactory() + slot, err := NewAudioSlot(newAudioSlotWorker(t, factory)) + if err != nil { + t.Fatalf("NewAudioSlot() error = %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + commands := make(chan FeedConfig) + done := make(chan error, 1) + first := FeedConfig{Domain: "/audio", UUID: "first", Active: true} + second := FeedConfig{Domain: "/audio", UUID: "second", Active: true} + + go func() { done <- slot.Run(ctx, first, commands) }() + receiveAudioSlotOpen(t, factory.opened) + commands <- second + if got := receiveAudioSlotOpen(t, factory.opened); got != second { + t.Fatalf("replacement config = %#v, want %#v", got, second) + } + commands <- FeedConfig{Domain: "/audio", UUID: "second", Active: false} + + deadline := time.Now().Add(time.Second) + for { + active, maxActive, closeCount := factory.counts() + if active == 0 && closeCount == 2 { + if maxActive != 1 { + t.Fatalf("maximum active readers = %d, want 1", maxActive) + } + break + } + if time.Now().After(deadline) { + t.Fatalf("reader counts = %d, %d, %d; want 0, 1, 2", active, maxActive, closeCount) + } + time.Sleep(time.Millisecond) + } + + close(commands) + select { + case err := <-done: + if err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + case <-time.After(time.Second): + t.Fatal("Run() did not stop after commands closed") + } +} + +func TestAudioSlotIgnoresInvalidCommand(t *testing.T) { + factory := newSlotAudioFactory() + slot, err := NewAudioSlot(newAudioSlotWorker(t, factory)) + if err != nil { + t.Fatalf("NewAudioSlot() error = %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + commands := make(chan FeedConfig) + done := make(chan error, 1) + + go func() { + done <- slot.Run( + ctx, + FeedConfig{Domain: "/audio", UUID: "first", Active: true}, + commands, + ) + }() + receiveAudioSlotOpen(t, factory.opened) + commands <- FeedConfig{UUID: "invalid", Active: true} + + select { + case config := <-factory.opened: + t.Fatalf("invalid command opened config %#v", config) + case <-time.After(20 * time.Millisecond): + } + active, _, closeCount := factory.counts() + if active != 1 || closeCount != 0 { + t.Fatalf("invalid command disturbed reader: active %d, closed %d", active, closeCount) + } + cancel() + <-done +} diff --git a/internal/playback/audio_worker.go b/internal/playback/audio_worker.go new file mode 100644 index 0000000..bc8bc80 --- /dev/null +++ b/internal/playback/audio_worker.go @@ -0,0 +1,186 @@ +package playback + +import ( + "context" + "errors" + "fmt" +) + +var ( + ErrAudioFactoryRequired = errors.New("audio reader factory is required") + ErrAudioSinkRequired = errors.New("audio sink is required") + ErrAudioRetryDeciderRequired = errors.New("audio decider is required") + ErrAudioFeedInactive = errors.New("audio feed is not active") +) + +type AudioWorker struct { + factory AudioReaderFactory + sink AudioSink + retry RetryPolicy + shouldRetry retryDecider + observer StatusObserver + wait waitFunc +} + +func NewAudioWorker( + factory AudioReaderFactory, + sink AudioSink, + retry RetryPolicy, + shouldRetry func(error) bool, + observer StatusObserver, +) (*AudioWorker, error) { + if factory == nil { + return nil, ErrAudioFactoryRequired + } + if sink == nil { + return nil, ErrAudioSinkRequired + } + if shouldRetry == nil { + return nil, ErrAudioRetryDeciderRequired + } + if err := retry.Validate(); err != nil { + return nil, fmt.Errorf("validate audio retry policy: %w", err) + } + + return &AudioWorker{ + factory: factory, + sink: sink, + retry: retry, + shouldRetry: shouldRetry, + observer: observer, + wait: waitForRetry, + }, nil +} + +type stabilityAudioSink struct { + sink AudioSink + onStable func() + stable bool +} + +func (s *stabilityAudioSink) ConsumeAudio( + ctx context.Context, + frame AudioFrame, +) error { + err := s.sink.ConsumeAudio(ctx, frame) + if err == nil && !s.stable { + s.stable = true + if s.onStable != nil { + s.onStable() + } + } + return err +} + +func (w *AudioWorker) emit(ctx context.Context, config FeedConfig, status Status) { + status.Generation = generationFromContext(ctx) + status.Feed = config + if w.observer != nil { + w.observer(status) + } +} + +func (w *AudioWorker) Run( + ctx context.Context, + config FeedConfig, +) error { + if err := config.Validate(); err != nil { + return fmt.Errorf("validate audio config: %w", err) + } + if !config.Active { + return ErrAudioFeedInactive + } + + attemptNumber := 0 + var latestRetry retryEvent + + attempt := func(ctx context.Context) (bool, error) { + attemptNumber++ + + state := StateConnecting + if attemptNumber > 1 { + state = StateReconnecting + } + w.emit(ctx, config, Status{ + Unit: UnitAudio, + State: state, + Attempt: attemptNumber, + }) + + attemptSink := &stabilityAudioSink{ + sink: w.sink, + onStable: func() { + w.emit(ctx, config, Status{ + Unit: UnitAudio, + State: StatePlaying, + Attempt: attemptNumber, + }) + }, + } + + err := runAudioAttempt(ctx, w.factory, attemptSink, config) + return attemptSink.stable, err + } + + decide := func(err error) bool { + var sinkErr *audioSinkError + if errors.As(err, &sinkErr) { + return false + } + return w.shouldRetry(err) + } + + observeRetry := func(event retryEvent) { + latestRetry = event + if !event.WillRetry { + return + } + + w.emit(ctx, config, Status{ + Unit: UnitAudio, + State: StateReconnecting, + Attempt: attemptNumber + 1, + FailedAttempts: event.FailedAttempts, + RetryIn: event.RetryIn, + Err: event.Err, + }) + } + + err := runWithRetry( + ctx, + w.retry, + attempt, + decide, + w.wait, + observeRetry, + ) + + if ctx.Err() != nil { + w.emit(ctx, config, Status{ + Unit: UnitAudio, + State: StateStopping, + }) + w.emit(ctx, config, Status{ + Unit: UnitAudio, + State: StateIdle, + }) + return ctx.Err() + } + + if err != nil { + w.emit(ctx, config, Status{ + Unit: UnitAudio, + State: StateFailed, + Attempt: attemptNumber, + FailedAttempts: latestRetry.FailedAttempts, + Err: err, + }) + return err + } + + w.emit(ctx, config, Status{ + Unit: UnitAudio, + State: StateIdle, + }) + return nil +} diff --git a/internal/playback/audio_worker_test.go b/internal/playback/audio_worker_test.go new file mode 100644 index 0000000..7c78975 --- /dev/null +++ b/internal/playback/audio_worker_test.go @@ -0,0 +1,267 @@ +package playback + +import ( + "context" + "errors" + "testing" + "time" +) + +type queuedAudioFactory struct { + readers []AudioReader + errs []error + calls int +} + +func (f *queuedAudioFactory) OpenAudio( + context.Context, + FeedConfig, +) (AudioReader, error) { + index := f.calls + f.calls++ + if index < len(f.errs) && f.errs[index] != nil { + return nil, f.errs[index] + } + if index < len(f.readers) { + return f.readers[index], nil + } + return nil, errors.New("unexpected audio open") +} + +func newAudioWorkerForTest( + t *testing.T, + factory AudioReaderFactory, + sink AudioSink, + maxAttempts int, + shouldRetry func(error) bool, + observer StatusObserver, +) *AudioWorker { + t.Helper() + worker, err := NewAudioWorker( + factory, + sink, + testRetryPolicy(maxAttempts), + shouldRetry, + observer, + ) + if err != nil { + t.Fatalf("NewAudioWorker() error = %v", err) + } + worker.wait = func(context.Context, time.Duration) error { return nil } + return worker +} + +func TestNewAudioWorkerValidatesDependencies(t *testing.T) { + factory := &fakeAudioFactory{} + sink := &fakeAudioSink{} + retry := testRetryPolicy(3) + decider := func(error) bool { return true } + + tests := []struct { + name string + factory AudioReaderFactory + sink AudioSink + retry RetryPolicy + decider func(error) bool + wantErr error + }{ + {name: "factory", sink: sink, retry: retry, decider: decider, wantErr: ErrAudioFactoryRequired}, + {name: "sink", factory: factory, retry: retry, decider: decider, wantErr: ErrAudioSinkRequired}, + {name: "decider", factory: factory, sink: sink, retry: retry, wantErr: ErrAudioRetryDeciderRequired}, + { + name: "retry policy", + factory: factory, + sink: sink, + retry: RetryPolicy{}, + decider: decider, + wantErr: ErrInvalidRetryDelay, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + worker, err := NewAudioWorker( + tt.factory, + tt.sink, + tt.retry, + tt.decider, + nil, + ) + if worker != nil { + t.Fatalf("NewAudioWorker() worker = %#v, want nil", worker) + } + if !errors.Is(err, tt.wantErr) { + t.Fatalf("NewAudioWorker() error = %v, want %v", err, tt.wantErr) + } + }) + } +} + +func TestAudioWorkerRejectsInactiveFeed(t *testing.T) { + worker := newAudioWorkerForTest( + t, + &fakeAudioFactory{}, + &fakeAudioSink{}, + 1, + func(error) bool { return false }, + nil, + ) + + err := worker.Run( + context.Background(), + FeedConfig{Domain: "/audio", UUID: "audio", Active: false}, + ) + if !errors.Is(err, ErrAudioFeedInactive) { + t.Fatalf("Run() error = %v, want %v", err, ErrAudioFeedInactive) + } +} + +func TestAudioWorkerStatusesInheritGeneration(t *testing.T) { + openErr := errors.New("unavailable") + var statuses []Status + worker := newAudioWorkerForTest( + t, + &queuedAudioFactory{errs: []error{openErr}}, + &fakeAudioSink{}, + 1, + func(error) bool { return true }, + func(status Status) { statuses = append(statuses, status) }, + ) + config := FeedConfig{Domain: "/audio", UUID: "audio", Active: true} + _ = worker.Run(withGeneration(context.Background(), 8), config) + if len(statuses) == 0 { + t.Fatal("no statuses emitted") + } + for _, status := range statuses { + if status.Generation != 8 { + t.Fatalf("status generation = %d, want 8: %+v", status.Generation, status) + } + if status.Feed != config { + t.Fatalf("status feed = %#v, want %#v", status.Feed, config) + } + } +} + +func TestAudioWorkerPublishesPlayingThenFailed(t *testing.T) { + readErr := errors.New("audio disappeared") + reader := &fakeAudioReader{ + frames: []AudioFrame{{Index: 1, Samples: [][]byte{{1, 2, 3, 4}}}}, + readErr: readErr, + } + var statuses []Status + worker := newAudioWorkerForTest( + t, + &fakeAudioFactory{reader: reader}, + &fakeAudioSink{}, + 1, + func(error) bool { return false }, + func(status Status) { statuses = append(statuses, status) }, + ) + + err := worker.Run( + context.Background(), + FeedConfig{Domain: "/audio", UUID: "audio", Active: true}, + ) + if !errors.Is(err, readErr) { + t.Fatalf("Run() error = %v, want %v", err, readErr) + } + wantStates := []State{StateConnecting, StatePlaying, StateFailed} + if len(statuses) != len(wantStates) { + t.Fatalf("status count = %d, want %d: %#v", len(statuses), len(wantStates), statuses) + } + for i, want := range wantStates { + if statuses[i].Unit != UnitAudio || statuses[i].State != want { + t.Errorf("status[%d] = %#v, want audio/%v", i, statuses[i], want) + } + } +} + +func TestAudioWorkerRetriesUpToAttemptLimit(t *testing.T) { + openErr := errors.New("audio unavailable") + factory := &queuedAudioFactory{errs: []error{openErr, openErr, openErr}} + var statuses []Status + worker := newAudioWorkerForTest( + t, + factory, + &fakeAudioSink{}, + 3, + func(error) bool { return true }, + func(status Status) { statuses = append(statuses, status) }, + ) + + err := worker.Run( + context.Background(), + FeedConfig{Domain: "/audio", UUID: "audio", Active: true}, + ) + if !errors.Is(err, openErr) { + t.Fatalf("Run() error = %v, want %v", err, openErr) + } + if factory.calls != 3 { + t.Fatalf("open calls = %d, want 3", factory.calls) + } + last := statuses[len(statuses)-1] + if last.State != StateFailed || last.Attempt != 3 || last.FailedAttempts != 3 { + t.Fatalf("last status = %#v, want failed attempt 3", last) + } +} + +func TestAudioWorkerDoesNotRetrySinkFailure(t *testing.T) { + sinkErr := errors.New("output failed") + factory := &fakeAudioFactory{ + reader: &fakeAudioReader{frames: []AudioFrame{{Index: 1}}}, + } + worker := newAudioWorkerForTest( + t, + factory, + &fakeAudioSink{err: sinkErr}, + 3, + func(error) bool { return true }, + nil, + ) + + err := worker.Run( + context.Background(), + FeedConfig{Domain: "/audio", UUID: "audio", Active: true}, + ) + if !errors.Is(err, sinkErr) { + t.Fatalf("Run() error = %v, want %v", err, sinkErr) + } + if factory.calls != 1 { + t.Fatalf("open calls = %d, want 1", factory.calls) + } +} + +func TestAudioWorkerCancellationPublishesStoppingAndIdle(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + reader := &fakeAudioReader{ + read: func(ctx context.Context) (AudioFrame, error) { + cancel() + <-ctx.Done() + return AudioFrame{}, ctx.Err() + }, + } + var statuses []Status + worker := newAudioWorkerForTest( + t, + &fakeAudioFactory{reader: reader}, + &fakeAudioSink{}, + 1, + func(error) bool { return true }, + func(status Status) { statuses = append(statuses, status) }, + ) + + err := worker.Run( + ctx, + FeedConfig{Domain: "/audio", UUID: "audio", Active: true}, + ) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Run() error = %v, want %v", err, context.Canceled) + } + if len(statuses) < 3 { + t.Fatalf("statuses = %#v, want connecting, stopping, idle", statuses) + } + last := statuses[len(statuses)-2:] + if last[0].State != StateStopping || last[1].State != StateIdle { + t.Fatalf("final statuses = %#v, want stopping then idle", last) + } +} diff --git a/internal/playback/config.go b/internal/playback/config.go new file mode 100644 index 0000000..7073989 --- /dev/null +++ b/internal/playback/config.go @@ -0,0 +1,78 @@ +package playback + +import ( + "errors" + "fmt" + "time" +) + +var ( + ErrFeedDomainRequired = errors.New("feed domain is required when its UUID is configured") + ErrInvalidMaxAttempts = errors.New("max attempts cannot be negative") + ErrInvalidRetryDelay = errors.New("retry delay must be positive") + ErrInvalidRetryRange = errors.New("maximum retry delay cannot be less than initial retry delay") + ErrActiveFeedNotConfigured = errors.New("feed cannot be active without a UUID") +) + +type FeedConfig struct { + Domain string + UUID string + Active bool +} + +type RetryPolicy struct { + MaxAttempts int // 0 = unlimited + InitialDelay time.Duration + MaxDelay time.Duration +} + +type SessionConfig struct { + Video FeedConfig + Audio FeedConfig + SyncRequested bool + Retry RetryPolicy +} + +func (f FeedConfig) IsConfigured() bool { + return f.UUID != "" +} + +func (f FeedConfig) Validate() error { + if f.Active && !f.IsConfigured() { + return ErrActiveFeedNotConfigured + } + if f.IsConfigured() && f.Domain == "" { + return ErrFeedDomainRequired + } + return nil +} + +func (p RetryPolicy) Validate() error { + if p.MaxAttempts < 0 { + return ErrInvalidMaxAttempts + } + if p.InitialDelay <= 0 || p.MaxDelay <= 0 { + return ErrInvalidRetryDelay + } + if p.MaxDelay < p.InitialDelay { + return ErrInvalidRetryRange + } + return nil +} + +func (c SessionConfig) HasFeeds() bool { + return c.Video.IsConfigured() || c.Audio.IsConfigured() +} + +func (c SessionConfig) Validate() error { + if err := c.Video.Validate(); err != nil { + return fmt.Errorf("video: %w", err) + } + if err := c.Audio.Validate(); err != nil { + return fmt.Errorf("audio: %w", err) + } + if err := c.Retry.Validate(); err != nil { + return fmt.Errorf("retry: %w", err) + } + return nil +} diff --git a/internal/playback/config_test.go b/internal/playback/config_test.go new file mode 100644 index 0000000..87e4f4e --- /dev/null +++ b/internal/playback/config_test.go @@ -0,0 +1,214 @@ +package playback + +import ( + "errors" + "testing" + "time" +) + +func TestSessionConfigValidate(t *testing.T) { + validRetry := RetryPolicy{ + MaxAttempts: 5, + InitialDelay: 500 * time.Millisecond, + MaxDelay: 10 * time.Second, + } + + tests := []struct { + name string + config SessionConfig + wantErr error + }{ + { + name: "audio and video in different domains", + config: SessionConfig{ + Video: FeedConfig{ + Domain: "/dev/shm/mxl-video", + UUID: "video-uuid", + Active: true, + }, + Audio: FeedConfig{ + Domain: "/dev/shm/mxl-audio", + UUID: "audio-uuid", + Active: true, + }, + SyncRequested: true, + Retry: validRetry, + }, + wantErr: nil, + }, + { + name: "audio and video in same domain", + config: SessionConfig{ + Video: FeedConfig{ + Domain: "/dev/shm/mxl", + UUID: "video-uuid", + Active: true, + }, + Audio: FeedConfig{ + Domain: "/dev/shm/mxl", + UUID: "audio-uuid", + Active: true, + }, + SyncRequested: true, + Retry: validRetry, + }, + wantErr: nil, + }, + { + name: "video only", + config: SessionConfig{ + Video: FeedConfig{ + Domain: "/dev/shm/mxl", + UUID: "video-uuid", + Active: true, + }, + Retry: validRetry, + }, + wantErr: nil, + }, + { + name: "audio only", + config: SessionConfig{ + Audio: FeedConfig{ + Domain: "/dev/shm/mxl", + UUID: "audio-uuid", + Active: true, + }, + Retry: validRetry, + }, + wantErr: nil, + }, + { + name: "video UUID without domain", + config: SessionConfig{ + Video: FeedConfig{ + UUID: "video-uuid", + Active: true, + }, + Retry: validRetry, + }, + wantErr: ErrFeedDomainRequired, + }, + { + name: "audio without domain", + config: SessionConfig{ + Audio: FeedConfig{ + Domain: "", + UUID: "audio-uuid", + Active: true, + }, + SyncRequested: true, + Retry: validRetry, + }, + wantErr: ErrFeedDomainRequired, + }, + { + name: "sync with only one feed", + config: SessionConfig{ + Video: FeedConfig{ + Domain: "/dev/shm/mxl-video", + UUID: "video-uuid", + Active: true, + }, + SyncRequested: true, + Retry: validRetry, + }, + wantErr: nil, + }, + { + name: "empty player with valid retry policy", + config: SessionConfig{ + Retry: validRetry, + }, + wantErr: nil, + }, + { + name: "negative MaxAttempts", + config: SessionConfig{ + Retry: RetryPolicy{ + MaxAttempts: -100, + InitialDelay: 500 * time.Millisecond, + MaxDelay: 10 * time.Second, + }, + }, + wantErr: ErrInvalidMaxAttempts, + }, + { + name: "zero InitialDelay", + config: SessionConfig{ + Retry: RetryPolicy{ + MaxAttempts: 0, + InitialDelay: 0, + MaxDelay: 10 * time.Second, + }, + }, + wantErr: ErrInvalidRetryDelay, + }, + { + name: "zero MaxDelay", + config: SessionConfig{ + Retry: RetryPolicy{ + MaxAttempts: 0, + InitialDelay: 500 * time.Millisecond, + MaxDelay: 0, + }, + }, + wantErr: ErrInvalidRetryDelay, + }, + { + name: "MaxDelay smaller than InitialDelay", + config: SessionConfig{ + Retry: RetryPolicy{ + MaxAttempts: 0, + InitialDelay: 500 * time.Millisecond, + MaxDelay: 200 * time.Millisecond, + }, + }, + wantErr: ErrInvalidRetryRange, + }, + { + name: "configured video is stopped", + config: SessionConfig{ + Video: FeedConfig{ + Domain: "/dev/shm/mxl-video", + UUID: "video-uuid", + Active: false, + }, + Retry: validRetry, + }, + wantErr: nil, + }, + { + name: "active video without UUID", + config: SessionConfig{ + Video: FeedConfig{ + Domain: "/dev/shm/mxl-video", + Active: true, + }, + Retry: validRetry, + }, + wantErr: ErrActiveFeedNotConfigured, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + + if tt.wantErr == nil { + if err != nil { + t.Fatalf("Validate() returned unexpected error: %v", err) + } + return + } + + if !errors.Is(err, tt.wantErr) { + t.Fatalf( + "Validate() error = %v, want error matching %v", + err, + tt.wantErr, + ) + } + }) + } +} diff --git a/internal/playback/media_stats.go b/internal/playback/media_stats.go new file mode 100644 index 0000000..68c4215 --- /dev/null +++ b/internal/playback/media_stats.go @@ -0,0 +1,136 @@ +package playback + +import ( + "context" + "sync" + "time" +) + +type VideoMediaStats struct { + Available bool + Label string + Index uint64 + Width uint32 + Height uint32 + Stride uint32 + PayloadSize uint32 + DeclaredFPS float64 + ReceivedFPS float64 + FrameDT time.Duration + Invalid uint64 +} + +type AudioMediaStats struct { + Available bool + Label string + Index uint64 + SampleRateHz float64 + Channels uint64 + SampleCount uint64 + BatchDuration time.Duration +} + +type MediaStatsSnapshot struct { + Video VideoMediaStats + Audio AudioMediaStats +} + +type MediaStatsStore struct { + mu sync.RWMutex + snapshot MediaStatsSnapshot + now func() time.Time + + lastVideoAt time.Time + videoWindowAt time.Time + videoWindowCount uint64 +} + +func NewMediaStatsStore() *MediaStatsStore { + return &MediaStatsStore{now: time.Now} +} + +func (s *MediaStatsStore) ObserveVideo(frame VideoFrame) { + now := s.now() + s.mu.Lock() + defer s.mu.Unlock() + + stats := &s.snapshot.Video + stats.Available = true + stats.Label = frame.Label + stats.Index = frame.Index + stats.Width = frame.Width + stats.Height = frame.Height + stats.Stride = frame.Stride + stats.PayloadSize = frame.Size + if frame.FrameRateDenominator > 0 { + stats.DeclaredFPS = float64(frame.FrameRateNumerator) / + float64(frame.FrameRateDenominator) + } + if !s.lastVideoAt.IsZero() { + stats.FrameDT = now.Sub(s.lastVideoAt) + } + s.lastVideoAt = now + if frame.Invalid { + stats.Invalid++ + } + if s.videoWindowAt.IsZero() { + s.videoWindowAt = now + } + s.videoWindowCount++ + if elapsed := now.Sub(s.videoWindowAt); elapsed >= time.Second { + stats.ReceivedFPS = float64(s.videoWindowCount) / elapsed.Seconds() + s.videoWindowAt = now + s.videoWindowCount = 0 + } +} + +func (s *MediaStatsStore) ObserveAudio(frame AudioFrame) { + s.mu.Lock() + defer s.mu.Unlock() + + stats := &s.snapshot.Audio + stats.Available = true + stats.Label = frame.Label + stats.Index = frame.Index + stats.Channels = frame.Channels + stats.SampleCount = frame.SampleCount + if frame.SampleRateDenominator > 0 { + stats.SampleRateHz = float64(frame.SampleRateNumerator) / + float64(frame.SampleRateDenominator) + } + if stats.SampleRateHz > 0 { + stats.BatchDuration = time.Duration( + float64(time.Second) * float64(frame.SampleCount) / stats.SampleRateHz, + ) + } +} + +func (s *MediaStatsStore) Snapshot() MediaStatsSnapshot { + s.mu.RLock() + defer s.mu.RUnlock() + return s.snapshot +} + +type VideoStatsSink struct { + Stats *MediaStatsStore + Sink VideoSink +} + +func (s VideoStatsSink) ConsumeVideo(ctx context.Context, frame VideoFrame) error { + if s.Stats != nil { + s.Stats.ObserveVideo(frame) + } + return s.Sink.ConsumeVideo(ctx, frame) +} + +type AudioStatsSink struct { + Stats *MediaStatsStore + Sink AudioSink +} + +func (s AudioStatsSink) ConsumeAudio(ctx context.Context, frame AudioFrame) error { + if s.Stats != nil { + s.Stats.ObserveAudio(frame) + } + return s.Sink.ConsumeAudio(ctx, frame) +} diff --git a/internal/playback/media_stats_test.go b/internal/playback/media_stats_test.go new file mode 100644 index 0000000..f936898 --- /dev/null +++ b/internal/playback/media_stats_test.go @@ -0,0 +1,116 @@ +package playback + +import ( + "context" + "testing" + "time" +) + +func TestMediaStatsStoreObservesVideo(t *testing.T) { + store := NewMediaStatsStore() + now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC) + store.now = func() time.Time { return now } + frame := VideoFrame{ + Index: 10, + Width: 1920, + Height: 1080, + Stride: 5120, + Size: 5_529_600, + Label: "Main video", + FrameRateNumerator: 30000, + FrameRateDenominator: 1001, + } + store.ObserveVideo(frame) + now = now.Add(40 * time.Millisecond) + frame.Index++ + frame.Invalid = true + store.ObserveVideo(frame) + + got := store.Snapshot().Video + if !got.Available || got.Label != "Main video" || got.Index != 11 { + t.Fatalf("video stats = %#v", got) + } + if got.Width != 1920 || got.Height != 1080 || got.Stride != 5120 { + t.Fatalf("video dimensions = %#v", got) + } + if got.DeclaredFPS < 29.96 || got.DeclaredFPS > 29.98 { + t.Fatalf("declared FPS = %v", got.DeclaredFPS) + } + if got.FrameDT != 40*time.Millisecond || got.Invalid != 1 { + t.Fatalf("video timing = %#v", got) + } +} + +func TestMediaStatsStoreCalculatesReceivedFPS(t *testing.T) { + store := NewMediaStatsStore() + now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC) + store.now = func() time.Time { return now } + store.ObserveVideo(VideoFrame{}) + now = now.Add(500 * time.Millisecond) + store.ObserveVideo(VideoFrame{}) + now = now.Add(500 * time.Millisecond) + store.ObserveVideo(VideoFrame{}) + + if got := store.Snapshot().Video.ReceivedFPS; got != 3 { + t.Fatalf("received FPS = %v, want 3", got) + } +} + +func TestMediaStatsStoreObservesAudio(t *testing.T) { + store := NewMediaStatsStore() + store.ObserveAudio(AudioFrame{ + Index: 100, + SampleCount: 480, + Channels: 2, + Label: "Programme audio", + SampleRateNumerator: 48_000, + SampleRateDenominator: 1, + }) + + got := store.Snapshot().Audio + if !got.Available || got.Label != "Programme audio" || got.Index != 100 { + t.Fatalf("audio stats = %#v", got) + } + if got.SampleRateHz != 48_000 || got.Channels != 2 || got.SampleCount != 480 { + t.Fatalf("audio format = %#v", got) + } + if got.BatchDuration != 10*time.Millisecond { + t.Fatalf("batch duration = %v, want 10ms", got.BatchDuration) + } +} + +type recordingVideoStatsSink struct{ frame VideoFrame } + +func (s *recordingVideoStatsSink) ConsumeVideo(_ context.Context, frame VideoFrame) error { + s.frame = frame + return nil +} + +type recordingAudioStatsSink struct{ frame AudioFrame } + +func (s *recordingAudioStatsSink) ConsumeAudio(_ context.Context, frame AudioFrame) error { + s.frame = frame + return nil +} + +func TestStatsSinksObserveAndForwardFrames(t *testing.T) { + store := NewMediaStatsStore() + videoDownstream := &recordingVideoStatsSink{} + audioDownstream := &recordingAudioStatsSink{} + video := VideoFrame{Index: 7, Label: "video"} + audio := AudioFrame{Index: 8, Label: "audio"} + + if err := (VideoStatsSink{Stats: store, Sink: videoDownstream}).ConsumeVideo(context.Background(), video); err != nil { + t.Fatalf("ConsumeVideo() error = %v", err) + } + if err := (AudioStatsSink{Stats: store, Sink: audioDownstream}).ConsumeAudio(context.Background(), audio); err != nil { + t.Fatalf("ConsumeAudio() error = %v", err) + } + if videoDownstream.frame.Index != video.Index || audioDownstream.frame.Index != audio.Index { + t.Fatalf("forwarded frames = %#v, %#v", videoDownstream.frame, audioDownstream.frame) + } + snapshot := store.Snapshot() + if snapshot.Video.Label != "video" || snapshot.Audio.Label != "audio" { + t.Fatalf("stats snapshot = %#v", snapshot) + } +} diff --git a/internal/playback/playlist.go b/internal/playback/playlist.go new file mode 100644 index 0000000..5825dda --- /dev/null +++ b/internal/playback/playlist.go @@ -0,0 +1,121 @@ +package playback + +import ( + "errors" + "fmt" + "time" +) + +var ( + ErrPlaylistFeedUUIDRequired = errors.New("playlist feed UUID is required when its domain is configured") + ErrPlaylistEntryEmpty = errors.New("playlist entry must contain at least one feed") + ErrPlaylistSyncFeedsRequired = errors.New("synchronized playlist entry requires both video and audio feeds") + ErrPlaylistDurationNegative = errors.New("playlist entry duration cannot be negative") + ErrPlaylistFailurePolicy = errors.New("invalid playlist failure policy") +) + +type PlaylistFailurePolicy uint8 + +const ( + PlaylistFailureWait PlaylistFailurePolicy = iota + PlaylistFailureNext +) + +func (p PlaylistFailurePolicy) String() string { + switch p { + case PlaylistFailureWait: + return "wait" + case PlaylistFailureNext: + return "next" + default: + return fmt.Sprintf("PlaylistFailurePolicy(%d)", uint8(p)) + } +} + +func (p PlaylistFailurePolicy) Validate() error { + if p != PlaylistFailureWait && p != PlaylistFailureNext { + return ErrPlaylistFailurePolicy + } + return nil +} + +type PlaylistFeed struct { + Domain string + UUID string +} + +type PlaylistEntry struct { + Name string + Video PlaylistFeed + Audio PlaylistFeed + SyncRequested bool + Duration time.Duration +} + +type Playlist struct { + Entries []PlaylistEntry + Loop bool + OnFailure PlaylistFailurePolicy +} + +func (f PlaylistFeed) IsConfigured() bool { + return f.UUID != "" +} + +func (f PlaylistFeed) Validate() error { + if f.UUID != "" && f.Domain == "" { + return ErrFeedDomainRequired + } + if f.Domain != "" && f.UUID == "" { + return ErrPlaylistFeedUUIDRequired + } + return nil +} + +func (e PlaylistEntry) Validate() error { + if err := e.Video.Validate(); err != nil { + return fmt.Errorf("video: %w", err) + } + if err := e.Audio.Validate(); err != nil { + return fmt.Errorf("audio: %w", err) + } + if !e.Video.IsConfigured() && !e.Audio.IsConfigured() { + return ErrPlaylistEntryEmpty + } + if e.SyncRequested && (!e.Video.IsConfigured() || !e.Audio.IsConfigured()) { + return ErrPlaylistSyncFeedsRequired + } + if e.Duration < 0 { + return ErrPlaylistDurationNegative + } + return nil +} + +func (p Playlist) Validate() error { + if err := p.OnFailure.Validate(); err != nil { + return err + } + for index, entry := range p.Entries { + if err := entry.Validate(); err != nil { + return fmt.Errorf("playlist entry %d: %w", index, err) + } + } + return nil +} + +func (e PlaylistEntry) SessionConfig(retry RetryPolicy) SessionConfig { + return SessionConfig{ + Video: FeedConfig{ + Domain: e.Video.Domain, + UUID: e.Video.UUID, + Active: e.Video.UUID != "", + }, + Audio: FeedConfig{ + Domain: e.Audio.Domain, + UUID: e.Audio.UUID, + Active: e.Audio.UUID != "", + }, + SyncRequested: e.SyncRequested, + Retry: retry, + } +} diff --git a/internal/playback/playlist_controller.go b/internal/playback/playlist_controller.go new file mode 100644 index 0000000..8446bba --- /dev/null +++ b/internal/playback/playlist_controller.go @@ -0,0 +1,305 @@ +package playback + +import ( + "context" + "errors" + "sync" + "time" +) + +type PlaylistEventKind uint8 + +const ( + PlaylistEventReady PlaylistEventKind = iota + PlaylistEventFailed +) + +type PlaylistEvent struct { + Revision uint64 + Kind PlaylistEventKind +} + +// PlaylistReadiness is retained as an alias for callers that only publish +// ready events. Its zero Kind is PlaylistEventReady. +type PlaylistReadiness = PlaylistEvent + +type playlistTimer interface { + C() <-chan time.Time + Stop() bool +} + +type playlistTimerFactory func(time.Duration) playlistTimer + +type realPlaylistTimer struct { + timer *time.Timer +} + +func (t realPlaylistTimer) C() <-chan time.Time { return t.timer.C } +func (t realPlaylistTimer) Stop() bool { return t.timer.Stop() } + +type PlaylistController struct { + playlist Playlist + retry RetryPolicy + sessions chan<- SessionCommand + now func() time.Time + newTimer playlistTimerFactory + + mu sync.RWMutex + snapshot PlaylistSnapshot + hasSnapshot bool +} + +type PlaylistSnapshot struct { + State PlaylistState + Entry PlaylistEntry + Revision uint64 + Timing PlaylistTimingState +} + +var ( + ErrNilSessionCommandChannel = errors.New("session-command channel is nil") +) + +func NewPlaylistController( + playlist Playlist, + retry RetryPolicy, + sessions chan<- SessionCommand, +) (*PlaylistController, error) { + if err := playlist.Validate(); err != nil { + return nil, err + } + if err := retry.Validate(); err != nil { + return nil, err + } + if sessions == nil { + return nil, ErrNilSessionCommandChannel + } + return &PlaylistController{ + playlist: playlist, + retry: retry, + sessions: sessions, + now: time.Now, + newTimer: func(duration time.Duration) playlistTimer { + return realPlaylistTimer{timer: time.NewTimer(duration)} + }, + }, nil +} + +func (c *PlaylistController) Run( + ctx context.Context, + commands <-chan PlaylistCommand, + readiness <-chan PlaylistReadiness, +) error { + state := PlaylistState{} + revision := uint64(0) + timing := PlaylistTimingState{} + var timer playlistTimer + var timerC <-chan time.Time + var timerRevision uint64 + c.publish(state, revision, timing) + + stopTimer := func() { + stopPlaylistTimer(timer) + timer = nil + timerC = nil + } + defer stopTimer() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + + case command, ok := <-commands: + if !ok { + return nil + } + if command.Kind == PlaylistPause { + nextTiming, changed := PausePlaylistTiming( + timing, + revision, + c.now(), + ) + if changed { + stopTimer() + timing = nextTiming + c.publish(state, revision, timing) + } + continue + } + if command.Kind == PlaylistResume { + nextTiming, changed := ResumePlaylistTiming( + timing, + revision, + c.now(), + ) + if changed { + timing = nextTiming + if timing.Started { + timerRevision = timing.Revision + timer = c.newTimer(timing.Remaining) + timerC = timer.C() + } + c.publish(state, revision, timing) + } + continue + } + + next, sessionCommand, apply, err := ApplyPlaylistSelection( + c.playlist, + state, + command, + c.retry, + ) + if err != nil { + continue + } + if apply { + stopTimer() + select { + case <-ctx.Done(): + return ctx.Err() + case c.sessions <- sessionCommand: + } + revision++ + entry, _ := next.Entry(c.playlist) + timing = NewPlaylistTiming(revision, entry.Duration) + } + + state = next + c.publish(state, revision, timing) + + case ready, ok := <-readiness: + if !ok { + readiness = nil + continue + } + if ready.Kind == PlaylistEventFailed { + if ready.Revision != revision { + continue + } + stopTimer() + // A failed entry must not retain a live or apparently active + // duration clock, even when the policy is to wait. + timing = NewPlaylistTiming(revision, timing.Duration) + if c.playlist.OnFailure != PlaylistFailureNext { + c.publish(state, revision, timing) + continue + } + next, sessionCommand, apply, err := ApplyPlaylistSelection( + c.playlist, + state, + PlaylistCommand{Kind: PlaylistNext}, + c.retry, + ) + if err != nil || !apply { + c.publish(state, revision, timing) + continue + } + select { + case <-ctx.Done(): + return ctx.Err() + case c.sessions <- sessionCommand: + } + revision++ + state = next + entry, _ := next.Entry(c.playlist) + timing = NewPlaylistTiming(revision, entry.Duration) + c.publish(state, revision, timing) + continue + } + if timing.Paused && + ready.Revision == timing.Revision && + timing.Duration > 0 && + !timing.Expired { + timing.Ready = true + c.publish(state, revision, timing) + continue + } + nextTiming, started := StartPlaylistTiming( + timing, + ready.Revision, + c.now(), + ) + if !started { + continue + } + timing = nextTiming + timerRevision = timing.Revision + timer = c.newTimer(timing.Duration) + timerC = timer.C() + c.publish(state, revision, timing) + + case firedAt := <-timerC: + firedRevision := timerRevision + timer = nil + timerC = nil + nextTiming, expired := ExpirePlaylistTiming( + timing, + firedRevision, + firedAt, + ) + if !expired { + continue + } + timing = nextTiming + + next, sessionCommand, apply, err := ApplyPlaylistSelection( + c.playlist, + state, + PlaylistCommand{Kind: PlaylistNext}, + c.retry, + ) + if err != nil { + c.publish(state, revision, timing) + continue + } + if apply { + select { + case <-ctx.Done(): + return ctx.Err() + case c.sessions <- sessionCommand: + } + revision++ + entry, _ := next.Entry(c.playlist) + timing = NewPlaylistTiming(revision, entry.Duration) + } + state = next + c.publish(state, revision, timing) + } + } +} + +func (c *PlaylistController) Snapshot() (PlaylistSnapshot, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + return c.snapshot, c.hasSnapshot +} + +func (c *PlaylistController) publish( + state PlaylistState, + revision uint64, + timing PlaylistTimingState, +) { + entry, _ := state.Entry(c.playlist) + + c.mu.Lock() + c.snapshot = PlaylistSnapshot{ + State: state, + Entry: entry, + Revision: revision, + Timing: timing, + } + c.hasSnapshot = true + c.mu.Unlock() +} + +func stopPlaylistTimer(timer playlistTimer) { + if timer == nil || timer.Stop() { + return + } + select { + case <-timer.C(): + default: + } +} diff --git a/internal/playback/playlist_controller_test.go b/internal/playback/playlist_controller_test.go new file mode 100644 index 0000000..886b055 --- /dev/null +++ b/internal/playback/playlist_controller_test.go @@ -0,0 +1,368 @@ +package playback + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +func TestNewPlaylistControllerValidatesConfiguration(t *testing.T) { + validPlaylist := navigationPlaylist(false) + validRetry := validPlaylistRetryPolicy() + validSessions := make(chan SessionCommand) + tests := []struct { + name string + playlist Playlist + retry RetryPolicy + sessions chan<- SessionCommand + wantErr error + }{ + { + name: "invalid playlist", + playlist: Playlist{Entries: []PlaylistEntry{ + {}, + }}, + retry: validRetry, + sessions: validSessions, + wantErr: ErrPlaylistEntryEmpty, + }, + { + name: "invalid retry", + playlist: validPlaylist, + retry: RetryPolicy{}, + sessions: validSessions, + wantErr: ErrInvalidRetryDelay, + }, + { + name: "nil session commands", + playlist: validPlaylist, + retry: validRetry, + wantErr: ErrNilSessionCommandChannel, + }, + { + name: "valid", + playlist: validPlaylist, + retry: validRetry, + sessions: validSessions, + }, + { + name: "empty playlist is valid", + playlist: Playlist{}, + retry: validRetry, + sessions: validSessions, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + controller, err := NewPlaylistController(test.playlist, test.retry, test.sessions) + if !errors.Is(err, test.wantErr) { + t.Fatalf("NewPlaylistController() error = %v, want %v", err, test.wantErr) + } + if test.wantErr != nil && controller != nil { + t.Fatalf("NewPlaylistController() controller = %#v, want nil", controller) + } + if test.wantErr == nil && controller == nil { + t.Fatal("NewPlaylistController() controller is nil") + } + }) + } +} + +func TestPlaylistControllerPublishesInitialSnapshot(t *testing.T) { + controller, commands, _, cancel, result := startPlaylistController(t, navigationPlaylist(false), 0) + defer cancel() + + snapshot := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return !snapshot.State.HasSelection + }) + if snapshot.Entry != (PlaylistEntry{}) { + t.Fatalf("initial entry = %#v, want zero value", snapshot.Entry) + } + + close(commands) + if err := waitForPlaylistResult(t, result); err != nil { + t.Fatalf("Run() error = %v", err) + } +} + +func TestPlaylistControllerForwardsManualCommands(t *testing.T) { + controller, commands, sessions, cancel, result := startPlaylistController(t, navigationPlaylist(true), 4) + defer cancel() + + tests := []struct { + command PlaylistCommand + wantIndex int + wantUUID string + }{ + {command: PlaylistCommand{Kind: PlaylistSelect, Index: 1}, wantIndex: 1, wantUUID: "audio-2"}, + {command: PlaylistCommand{Kind: PlaylistNext}, wantIndex: 2, wantUUID: "video-3"}, + {command: PlaylistCommand{Kind: PlaylistPrevious}, wantIndex: 1, wantUUID: "audio-2"}, + {command: PlaylistCommand{Kind: PlaylistSelect, Index: 1}, wantIndex: 1, wantUUID: "audio-2"}, + } + + for _, test := range tests { + commands <- test.command + session := receivePlaylistSession(t, sessions) + if session.Kind != CommandSetSession { + t.Fatalf("session kind = %v, want %v", session.Kind, CommandSetSession) + } + gotUUID := session.Session.Video.UUID + if gotUUID == "" { + gotUUID = session.Session.Audio.UUID + } + if gotUUID != test.wantUUID { + t.Fatalf("session UUID = %q, want %q", gotUUID, test.wantUUID) + } + waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.State.HasSelection && snapshot.State.CurrentIndex == test.wantIndex + }) + } + + close(commands) + if err := waitForPlaylistResult(t, result); err != nil { + t.Fatalf("Run() error = %v", err) + } +} + +func TestPlaylistControllerNonLoopingBoundarySendsNothing(t *testing.T) { + _, commands, sessions, cancel, result := startPlaylistController(t, navigationPlaylist(false), 2) + defer cancel() + + commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 2} + _ = receivePlaylistSession(t, sessions) + commands <- PlaylistCommand{Kind: PlaylistNext} + + select { + case command := <-sessions: + t.Fatalf("unexpected session command at boundary: %#v", command) + case <-time.After(20 * time.Millisecond): + } + + close(commands) + if err := waitForPlaylistResult(t, result); err != nil { + t.Fatalf("Run() error = %v", err) + } +} + +func TestPlaylistControllerIgnoresInvalidCommand(t *testing.T) { + _, commands, sessions, cancel, result := startPlaylistController(t, navigationPlaylist(false), 2) + defer cancel() + + commands <- PlaylistCommand{} + commands <- PlaylistCommand{Kind: PlaylistNext} + if session := receivePlaylistSession(t, sessions); session.Session.Video.UUID != "video-1" { + t.Fatalf("session after invalid command = %#v", session) + } + + close(commands) + if err := waitForPlaylistResult(t, result); err != nil { + t.Fatalf("Run() error = %v", err) + } +} + +func TestPlaylistControllerCommitsStateAfterSessionDelivery(t *testing.T) { + sessions := make(chan SessionCommand) + controller, err := NewPlaylistController(navigationPlaylist(false), validPlaylistRetryPolicy(), sessions) + if err != nil { + t.Fatalf("NewPlaylistController() error = %v", err) + } + commands := make(chan PlaylistCommand, 1) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + result := make(chan error, 1) + go func() { result <- controller.Run(ctx, commands, nil) }() + + waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return !snapshot.State.HasSelection + }) + commands <- PlaylistCommand{Kind: PlaylistNext} + time.Sleep(time.Millisecond) + snapshot, ok := controller.Snapshot() + if !ok || snapshot.State.HasSelection { + t.Fatalf("snapshot before delivery = %#v, %v; want no selection", snapshot, ok) + } + + _ = receivePlaylistSession(t, sessions) + waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.State.HasSelection && snapshot.State.CurrentIndex == 0 + }) + + close(commands) + if err := waitForPlaylistResult(t, result); err != nil { + t.Fatalf("Run() error = %v", err) + } +} + +func TestPlaylistControllerClosedCommandsReturnsNil(t *testing.T) { + _, commands, _, cancel, result := startPlaylistController(t, navigationPlaylist(false), 0) + defer cancel() + close(commands) + + if err := waitForPlaylistResult(t, result); err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } +} + +func TestPlaylistControllerCancellationWhileReceiving(t *testing.T) { + _, _, _, cancel, result := startPlaylistController(t, navigationPlaylist(false), 0) + cancel() + + if err := waitForPlaylistResult(t, result); !errors.Is(err, context.Canceled) { + t.Fatalf("Run() error = %v, want %v", err, context.Canceled) + } +} + +func TestPlaylistControllerCancellationWhileSending(t *testing.T) { + sessions := make(chan SessionCommand) + controller, err := NewPlaylistController(navigationPlaylist(false), validPlaylistRetryPolicy(), sessions) + if err != nil { + t.Fatalf("NewPlaylistController() error = %v", err) + } + commands := make(chan PlaylistCommand, 1) + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + go func() { result <- controller.Run(ctx, commands, nil) }() + + commands <- PlaylistCommand{Kind: PlaylistNext} + cancel() + if err := waitForPlaylistResult(t, result); !errors.Is(err, context.Canceled) { + t.Fatalf("Run() error = %v, want %v", err, context.Canceled) + } +} + +func TestPlaylistControllerSnapshotConcurrentReads(t *testing.T) { + controller, commands, _, cancel, result := startPlaylistController(t, navigationPlaylist(true), 64) + defer cancel() + + var readers sync.WaitGroup + for range 8 { + readers.Add(1) + go func() { + defer readers.Done() + for range 100 { + _, _ = controller.Snapshot() + } + }() + } + for range 32 { + commands <- PlaylistCommand{Kind: PlaylistNext} + } + readers.Wait() + close(commands) + if err := waitForPlaylistResult(t, result); err != nil { + t.Fatalf("Run() error = %v", err) + } +} + +func TestPlaylistControllerSnapshotRevision(t *testing.T) { + controller, commands, sessions, cancel, result := startPlaylistController(t, navigationPlaylist(false), 8) + defer cancel() + + initial := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return !snapshot.State.HasSelection + }) + if initial.Revision != 0 { + t.Fatalf("initial revision = %d, want 0", initial.Revision) + } + + commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 1} + _ = receivePlaylistSession(t, sessions) + selected := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Revision == 1 + }) + if selected.State.CurrentIndex != 1 { + t.Fatalf("selected state = %#v, want index 1", selected.State) + } + + commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 1} + _ = receivePlaylistSession(t, sessions) + waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Revision == 2 + }) + + commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 2} + _ = receivePlaylistSession(t, sessions) + waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Revision == 3 + }) + + commands <- PlaylistCommand{Kind: PlaylistNext} + time.Sleep(time.Millisecond) + boundary, ok := controller.Snapshot() + if !ok || boundary.Revision != 3 { + t.Fatalf("boundary snapshot = %#v, %v; want revision 3", boundary, ok) + } + + commands <- PlaylistCommand{} + time.Sleep(time.Millisecond) + invalid, ok := controller.Snapshot() + if !ok || invalid.Revision != 3 { + t.Fatalf("invalid-command snapshot = %#v, %v; want revision 3", invalid, ok) + } + + close(commands) + if err := waitForPlaylistResult(t, result); err != nil { + t.Fatalf("Run() error = %v", err) + } +} + +func startPlaylistController( + t *testing.T, + playlist Playlist, + sessionBuffer int, +) (*PlaylistController, chan PlaylistCommand, chan SessionCommand, context.CancelFunc, <-chan error) { + t.Helper() + sessions := make(chan SessionCommand, sessionBuffer) + controller, err := NewPlaylistController(playlist, validPlaylistRetryPolicy(), sessions) + if err != nil { + t.Fatalf("NewPlaylistController() error = %v", err) + } + commands := make(chan PlaylistCommand, 64) + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + go func() { result <- controller.Run(ctx, commands, nil) }() + return controller, commands, sessions, cancel, result +} + +func waitForPlaylistSnapshot( + t *testing.T, + controller *PlaylistController, + predicate func(PlaylistSnapshot) bool, +) PlaylistSnapshot { + t.Helper() + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if snapshot, ok := controller.Snapshot(); ok && predicate(snapshot) { + return snapshot + } + time.Sleep(time.Millisecond) + } + snapshot, _ := controller.Snapshot() + t.Fatalf("timed out waiting for playlist snapshot; latest = %#v", snapshot) + return PlaylistSnapshot{} +} + +func receivePlaylistSession(t *testing.T, sessions <-chan SessionCommand) SessionCommand { + t.Helper() + select { + case command := <-sessions: + return command + case <-time.After(time.Second): + t.Fatal("timed out waiting for session command") + return SessionCommand{} + } +} + +func waitForPlaylistResult(t *testing.T, result <-chan error) error { + t.Helper() + select { + case err := <-result: + return err + case <-time.After(time.Second): + t.Fatal("timed out waiting for playlist controller") + return nil + } +} diff --git a/internal/playback/playlist_controller_timing_test.go b/internal/playback/playlist_controller_timing_test.go new file mode 100644 index 0000000..56e6ad7 --- /dev/null +++ b/internal/playback/playlist_controller_timing_test.go @@ -0,0 +1,418 @@ +package playback + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +type fakePlaylistTimer struct { + ch chan time.Time + + mu sync.Mutex + stopped bool +} + +func newFakePlaylistTimer() *fakePlaylistTimer { + return &fakePlaylistTimer{ch: make(chan time.Time, 1)} +} + +func (t *fakePlaylistTimer) C() <-chan time.Time { return t.ch } + +func (t *fakePlaylistTimer) Stop() bool { + t.mu.Lock() + defer t.mu.Unlock() + alreadyStopped := t.stopped + t.stopped = true + return !alreadyStopped +} + +func (t *fakePlaylistTimer) isStopped() bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.stopped +} + +func (t *fakePlaylistTimer) fire(at time.Time) { + t.ch <- at +} + +func timedPlaylist(loop bool) Playlist { + return Playlist{ + Entries: []PlaylistEntry{ + { + Name: "first", + Video: PlaylistFeed{Domain: "domain", UUID: "video-1"}, + Duration: 10 * time.Second, + }, + { + Name: "second", + Audio: PlaylistFeed{Domain: "domain", UUID: "audio-2"}, + Duration: 20 * time.Second, + }, + }, + Loop: loop, + } +} + +func TestPlaylistControllerStartsOneTimerForMatchingReadiness(t *testing.T) { + controller, commands, readiness, sessions, timers, now, cancel, result := + startTimedPlaylistController(t, timedPlaylist(false)) + defer cancel() + + commands <- PlaylistCommand{Kind: PlaylistNext} + _ = receivePlaylistSession(t, sessions) + waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Revision == 1 + }) + + readiness <- PlaylistReadiness{Revision: 0} + assertNoPlaylistTimer(t, timers) + readiness <- PlaylistReadiness{Revision: 1} + timer := receiveFakePlaylistTimer(t, timers) + + snapshot := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Timing.Started + }) + if snapshot.Timing.Deadline != now.Add(10*time.Second) { + t.Fatalf("deadline = %v, want %v", snapshot.Timing.Deadline, now.Add(10*time.Second)) + } + readiness <- PlaylistReadiness{Revision: 1} + assertNoPlaylistTimer(t, timers) + if timer.isStopped() { + t.Fatal("timer stopped after duplicate readiness") + } + + close(commands) + if err := waitForPlaylistResult(t, result); err != nil { + t.Fatalf("Run() error = %v", err) + } + if !timer.isStopped() { + t.Fatal("timer was not stopped when commands closed") + } +} + +func TestPlaylistControllerTimerExpiryAdvances(t *testing.T) { + controller, commands, readiness, sessions, timers, now, cancel, result := + startTimedPlaylistController(t, timedPlaylist(false)) + defer cancel() + + commands <- PlaylistCommand{Kind: PlaylistNext} + _ = receivePlaylistSession(t, sessions) + readiness <- PlaylistReadiness{Revision: 1} + timer := receiveFakePlaylistTimer(t, timers) + timer.fire(now.Add(10 * time.Second)) + + session := receivePlaylistSession(t, sessions) + if session.Session.Audio.UUID != "audio-2" || session.Session.Video.IsConfigured() { + t.Fatalf("advanced session = %#v, want audio-only second entry", session.Session) + } + snapshot := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Revision == 2 + }) + if snapshot.State.CurrentIndex != 1 || snapshot.Timing.Started { + t.Fatalf("advanced snapshot = %#v", snapshot) + } + if snapshot.Timing.Duration != 20*time.Second { + t.Fatalf("next duration = %v, want %v", snapshot.Timing.Duration, 20*time.Second) + } + + close(commands) + if err := waitForPlaylistResult(t, result); err != nil { + t.Fatalf("Run() error = %v", err) + } +} + +func TestPlaylistControllerLoopingTimerExpiryWraps(t *testing.T) { + controller, commands, readiness, sessions, timers, now, cancel, result := + startTimedPlaylistController(t, timedPlaylist(true)) + defer cancel() + + commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 1} + _ = receivePlaylistSession(t, sessions) + readiness <- PlaylistReadiness{Revision: 1} + timer := receiveFakePlaylistTimer(t, timers) + waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Timing.Started + }) + timer.fire(now.Add(20 * time.Second)) + + session := receivePlaylistSession(t, sessions) + if session.Session.Video.UUID != "video-1" { + t.Fatalf("wrapped session = %#v, want first entry", session.Session) + } + waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Revision == 2 && snapshot.State.CurrentIndex == 0 + }) + + close(commands) + if err := waitForPlaylistResult(t, result); err != nil { + t.Fatalf("Run() error = %v", err) + } +} + +func TestPlaylistControllerFinalExpiryStopsWithoutCommand(t *testing.T) { + controller, commands, readiness, sessions, timers, now, cancel, result := + startTimedPlaylistController(t, timedPlaylist(false)) + defer cancel() + + commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 1} + _ = receivePlaylistSession(t, sessions) + readiness <- PlaylistReadiness{Revision: 1} + timer := receiveFakePlaylistTimer(t, timers) + waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Timing.Started + }) + timer.fire(now.Add(20 * time.Second)) + + snapshot := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Revision == 1 && !snapshot.Timing.Started + }) + if snapshot.State.CurrentIndex != 1 { + t.Fatalf("final snapshot state = %#v, want final entry", snapshot.State) + } + select { + case command := <-sessions: + t.Fatalf("unexpected session command: %#v", command) + case <-time.After(20 * time.Millisecond): + } + + close(commands) + if err := waitForPlaylistResult(t, result); err != nil { + t.Fatalf("Run() error = %v", err) + } +} + +func TestPlaylistControllerManualSelectionStopsOldTimer(t *testing.T) { + controller, commands, readiness, sessions, timers, now, cancel, result := + startTimedPlaylistController(t, timedPlaylist(false)) + defer cancel() + + commands <- PlaylistCommand{Kind: PlaylistNext} + _ = receivePlaylistSession(t, sessions) + readiness <- PlaylistReadiness{Revision: 1} + oldTimer := receiveFakePlaylistTimer(t, timers) + + commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 1} + _ = receivePlaylistSession(t, sessions) + waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Revision == 2 + }) + if !oldTimer.isStopped() { + t.Fatal("old timer was not stopped by manual selection") + } + oldTimer.fire(now.Add(10 * time.Second)) + select { + case command := <-sessions: + t.Fatalf("stale timer produced session command: %#v", command) + case <-time.After(20 * time.Millisecond): + } + snapshot, _ := controller.Snapshot() + if snapshot.Revision != 2 || snapshot.State.CurrentIndex != 1 { + t.Fatalf("stale timer changed snapshot: %#v", snapshot) + } + + close(commands) + if err := waitForPlaylistResult(t, result); err != nil { + t.Fatalf("Run() error = %v", err) + } +} + +func TestPlaylistControllerZeroDurationDoesNotCreateTimer(t *testing.T) { + playlist := timedPlaylist(false) + playlist.Entries[0].Duration = 0 + _, commands, readiness, sessions, timers, _, cancel, result := + startTimedPlaylistController(t, playlist) + defer cancel() + + commands <- PlaylistCommand{Kind: PlaylistNext} + _ = receivePlaylistSession(t, sessions) + readiness <- PlaylistReadiness{Revision: 1} + assertNoPlaylistTimer(t, timers) + + close(commands) + if err := waitForPlaylistResult(t, result); err != nil { + t.Fatalf("Run() error = %v", err) + } +} + +func TestPlaylistControllerCancellationStopsTimer(t *testing.T) { + _, commands, readiness, sessions, timers, _, cancel, result := + startTimedPlaylistController(t, timedPlaylist(false)) + + commands <- PlaylistCommand{Kind: PlaylistNext} + _ = receivePlaylistSession(t, sessions) + readiness <- PlaylistReadiness{Revision: 1} + timer := receiveFakePlaylistTimer(t, timers) + cancel() + if err := waitForPlaylistResult(t, result); !errors.Is(err, context.Canceled) { + t.Fatalf("Run() error = %v, want %v", err, context.Canceled) + } + if !timer.isStopped() { + t.Fatal("timer was not stopped on cancellation") + } +} + +func TestPlaylistControllerPauseAndResumeTimer(t *testing.T) { + controller, commands, readiness, sessions, timers, now, cancel, result := + startTimedPlaylistController(t, timedPlaylist(false)) + defer cancel() + + commands <- PlaylistCommand{Kind: PlaylistNext} + _ = receivePlaylistSession(t, sessions) + readiness <- PlaylistReadiness{Revision: 1} + oldTimer := receiveFakePlaylistTimer(t, timers) + waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Timing.Started + }) + + commands <- PlaylistCommand{Kind: PlaylistPause} + paused := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Timing.Paused + }) + if paused.Revision != 1 || paused.Timing.Started { + t.Fatalf("paused snapshot = %#v", paused) + } + if !oldTimer.isStopped() { + t.Fatal("Pause did not stop active timer") + } + oldTimer.fire(now.Add(10 * time.Second)) + select { + case command := <-sessions: + t.Fatalf("paused stale timer sent session command: %#v", command) + case <-time.After(20 * time.Millisecond): + } + + commands <- PlaylistCommand{Kind: PlaylistResume} + _ = receiveFakePlaylistTimer(t, timers) + resumed := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Timing.Started && !snapshot.Timing.Paused + }) + if resumed.Revision != 1 { + t.Fatalf("resume changed revision: %#v", resumed) + } + select { + case command := <-sessions: + t.Fatalf("pause/resume sent session command: %#v", command) + default: + } + + close(commands) + if err := waitForPlaylistResult(t, result); err != nil { + t.Fatalf("Run() error = %v", err) + } +} + +func TestPlaylistControllerManualSelectionClearsPause(t *testing.T) { + controller, commands, _, sessions, _, _, cancel, result := + startTimedPlaylistController(t, timedPlaylist(false)) + defer cancel() + + commands <- PlaylistCommand{Kind: PlaylistNext} + _ = receivePlaylistSession(t, sessions) + commands <- PlaylistCommand{Kind: PlaylistPause} + waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Timing.Paused + }) + + commands <- PlaylistCommand{Kind: PlaylistNext} + _ = receivePlaylistSession(t, sessions) + next := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Revision == 2 + }) + if next.Timing.Paused || next.State.CurrentIndex != 1 { + t.Fatalf("new selection retained pause: %#v", next) + } + + close(commands) + if err := waitForPlaylistResult(t, result); err != nil { + t.Fatalf("Run() error = %v", err) + } +} + +func TestPlaylistControllerRecordsQueuedReadinessWhilePaused(t *testing.T) { + controller, commands, readiness, sessions, timers, _, cancel, result := + startTimedPlaylistController(t, timedPlaylist(false)) + defer cancel() + + commands <- PlaylistCommand{Kind: PlaylistNext} + _ = receivePlaylistSession(t, sessions) + commands <- PlaylistCommand{Kind: PlaylistPause} + waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Timing.Paused + }) + + readiness <- PlaylistReadiness{Revision: 1} + waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Timing.Paused && snapshot.Timing.Ready + }) + assertNoPlaylistTimer(t, timers) + + commands <- PlaylistCommand{Kind: PlaylistResume} + _ = receiveFakePlaylistTimer(t, timers) + waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Timing.Started && !snapshot.Timing.Paused + }) + + close(commands) + if err := waitForPlaylistResult(t, result); err != nil { + t.Fatalf("Run() error = %v", err) + } +} + +func startTimedPlaylistController( + t *testing.T, + playlist Playlist, +) ( + *PlaylistController, + chan PlaylistCommand, + chan PlaylistReadiness, + chan SessionCommand, + chan *fakePlaylistTimer, + time.Time, + context.CancelFunc, + <-chan error, +) { + t.Helper() + sessions := make(chan SessionCommand, 16) + controller, err := NewPlaylistController(playlist, validPlaylistRetryPolicy(), sessions) + if err != nil { + t.Fatalf("NewPlaylistController() error = %v", err) + } + now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC) + controller.now = func() time.Time { return now } + timers := make(chan *fakePlaylistTimer, 16) + controller.newTimer = func(time.Duration) playlistTimer { + timer := newFakePlaylistTimer() + timers <- timer + return timer + } + commands := make(chan PlaylistCommand, 16) + readiness := make(chan PlaylistReadiness, 16) + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + go func() { result <- controller.Run(ctx, commands, readiness) }() + return controller, commands, readiness, sessions, timers, now, cancel, result +} + +func receiveFakePlaylistTimer(t *testing.T, timers <-chan *fakePlaylistTimer) *fakePlaylistTimer { + t.Helper() + select { + case timer := <-timers: + return timer + case <-time.After(time.Second): + t.Fatal("timed out waiting for playlist timer") + return nil + } +} + +func assertNoPlaylistTimer(t *testing.T, timers <-chan *fakePlaylistTimer) { + t.Helper() + select { + case timer := <-timers: + t.Fatalf("unexpected playlist timer: %#v", timer) + case <-time.After(20 * time.Millisecond): + } +} diff --git a/internal/playback/playlist_failure_test.go b/internal/playback/playlist_failure_test.go new file mode 100644 index 0000000..20269c5 --- /dev/null +++ b/internal/playback/playlist_failure_test.go @@ -0,0 +1,181 @@ +package playback + +import ( + "context" + "testing" + "time" +) + +func TestIsSessionFailed(t *testing.T) { + const generation = 7 + video := FeedConfig{Domain: "/video", UUID: "video", Active: true} + audio := FeedConfig{Domain: "/audio", UUID: "audio", Active: true} + failedFeed := func(unit Unit, feed FeedConfig) Status { + return Status{ + Unit: unit, State: StateFailed, Generation: generation, Feed: feed, + } + } + independent := SessionSnapshot{ + Generation: generation, + Plan: SessionPlan{ + Topology: TopologyIndependent, + Video: video, + Audio: audio, + }, + } + synchronized := SessionSnapshot{ + Generation: generation, + Plan: SessionPlan{ + Topology: TopologySynchronized, + Sync: SyncPairConfig{Video: video, Audio: audio}, + }, + } + + tests := []struct { + name string + session SessionSnapshot + statuses PlaybackStatusSnapshot + want bool + }{ + { + name: "video failure in independent pair", + session: independent, + statuses: PlaybackStatusSnapshot{ + Generation: generation, + Video: failedFeed(UnitVideo, video), HasVideo: true, + }, + want: true, + }, + { + name: "audio failure in independent pair", + session: independent, + statuses: PlaybackStatusSnapshot{ + Generation: generation, + Audio: failedFeed(UnitAudio, audio), HasAudio: true, + }, + want: true, + }, + { + name: "sync failure", + session: synchronized, + statuses: PlaybackStatusSnapshot{ + Generation: generation, + Sync: Status{ + Unit: UnitSync, State: StateFailed, Generation: generation, + Pair: SyncPairConfig{Video: video, Audio: audio}, + }, + HasSync: true, + }, + want: true, + }, + { + name: "stale snapshot generation", + session: independent, + statuses: PlaybackStatusSnapshot{ + Generation: generation - 1, + Video: failedFeed(UnitVideo, video), HasVideo: true, + }, + }, + { + name: "wrong source", + session: independent, + statuses: PlaybackStatusSnapshot{ + Generation: generation, + Video: failedFeed(UnitVideo, FeedConfig{ + Domain: "/video", UUID: "other", Active: true, + }), + HasVideo: true, + }, + }, + { + name: "inactive failed unit is ignored", + session: SessionSnapshot{ + Generation: generation, + Plan: SessionPlan{ + Topology: TopologyIndependent, + Video: video, + Audio: FeedConfig{Domain: audio.Domain, UUID: audio.UUID}, + }, + }, + statuses: PlaybackStatusSnapshot{ + Generation: generation, + Audio: failedFeed(UnitAudio, audio), HasAudio: true, + }, + }, + { + name: "reconnecting has not exhausted retries", + session: independent, + statuses: PlaybackStatusSnapshot{ + Generation: generation, + Video: Status{ + Unit: UnitVideo, State: StateReconnecting, + Generation: generation, Feed: video, + }, + HasVideo: true, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := IsSessionFailed(test.session, test.statuses); got != test.want { + t.Fatalf("IsSessionFailed() = %v, want %v", got, test.want) + } + }) + } +} + +func TestPlaylistControllerFailurePolicy(t *testing.T) { + for _, test := range []struct { + name string + policy PlaylistFailurePolicy + wantAdvance bool + }{ + {name: "wait", policy: PlaylistFailureWait}, + {name: "next", policy: PlaylistFailureNext, wantAdvance: true}, + } { + t.Run(test.name, func(t *testing.T) { + playlist := navigationPlaylist(false) + playlist.OnFailure = test.policy + sessions := make(chan SessionCommand, 4) + controller, err := NewPlaylistController( + playlist, validPlaylistRetryPolicy(), sessions, + ) + if err != nil { + t.Fatalf("NewPlaylistController() error = %v", err) + } + commands := make(chan PlaylistCommand, 2) + events := make(chan PlaylistReadiness, 2) + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + go func() { result <- controller.Run(ctx, commands, events) }() + + commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 0} + <-sessions + events <- PlaylistEvent{Revision: 1, Kind: PlaylistEventFailed} + + if test.wantAdvance { + select { + case <-sessions: + case <-time.After(time.Second): + t.Fatal("failure did not advance playlist") + } + snapshot, _ := controller.Snapshot() + if snapshot.State.CurrentIndex != 1 || snapshot.Revision != 2 { + t.Fatalf("snapshot = %#v, want index 1 revision 2", snapshot) + } + } else { + select { + case command := <-sessions: + t.Fatalf("unexpected session command: %#v", command) + case <-time.After(20 * time.Millisecond): + } + } + + cancel() + if err := <-result; err != context.Canceled { + t.Fatalf("Run() error = %v, want context canceled", err) + } + }) + } +} diff --git a/internal/playback/playlist_navigation.go b/internal/playback/playlist_navigation.go new file mode 100644 index 0000000..b0043b1 --- /dev/null +++ b/internal/playback/playlist_navigation.go @@ -0,0 +1,132 @@ +package playback + +import "errors" + +type PlaylistCommandKind uint8 + +const ( + PlaylistSelect PlaylistCommandKind = iota + 1 + PlaylistNext + PlaylistPrevious + PlaylistPause + PlaylistResume +) + +type PlaylistCommand struct { + Kind PlaylistCommandKind + Index int +} + +type PlaylistState struct { + CurrentIndex int + HasSelection bool +} + +var ( + ErrPlaylistEmpty = errors.New("playlist is empty") + ErrPlaylistIndexOutOfRange = errors.New("playlist index is out of range") + ErrPlaylistNoSelection = errors.New("playlist has no selected entry") + ErrUnknownPlaylistCommand = errors.New("unknown playlist command") +) + +func ApplyPlaylistCommand( + playlist Playlist, + current PlaylistState, + command PlaylistCommand, +) (PlaylistState, error) { + if err := playlist.Validate(); err != nil { + return current, err + } + if len(playlist.Entries) == 0 { + return current, ErrPlaylistEmpty + } + + lastIndex := len(playlist.Entries) - 1 + switch command.Kind { + case PlaylistSelect: + if command.Index < 0 || command.Index > lastIndex { + return current, ErrPlaylistIndexOutOfRange + } + return PlaylistState{CurrentIndex: command.Index, HasSelection: true}, nil + + case PlaylistNext: + if !current.HasSelection { + return PlaylistState{CurrentIndex: 0, HasSelection: true}, nil + } + if current.CurrentIndex < 0 || current.CurrentIndex > lastIndex { + return current, ErrPlaylistIndexOutOfRange + } + if current.CurrentIndex == lastIndex { + if playlist.Loop { + return PlaylistState{CurrentIndex: 0, HasSelection: true}, nil + } + return current, nil + } + return PlaylistState{CurrentIndex: current.CurrentIndex + 1, HasSelection: true}, nil + + case PlaylistPrevious: + if !current.HasSelection { + index := 0 + if playlist.Loop { + index = lastIndex + } + return PlaylistState{CurrentIndex: index, HasSelection: true}, nil + } + if current.CurrentIndex < 0 || current.CurrentIndex > lastIndex { + return current, ErrPlaylistIndexOutOfRange + } + if current.CurrentIndex == 0 { + if playlist.Loop { + return PlaylistState{CurrentIndex: lastIndex, HasSelection: true}, nil + } + return current, nil + } + return PlaylistState{CurrentIndex: current.CurrentIndex - 1, HasSelection: true}, nil + + default: + return current, ErrUnknownPlaylistCommand + } +} + +func (s PlaylistState) Entry(playlist Playlist) (PlaylistEntry, bool) { + if !s.HasSelection || s.CurrentIndex < 0 || s.CurrentIndex >= len(playlist.Entries) { + return PlaylistEntry{}, false + } + return playlist.Entries[s.CurrentIndex], true +} + +func ApplyPlaylistSelection( + playlist Playlist, + current PlaylistState, + command PlaylistCommand, + retry RetryPolicy, +) ( + next PlaylistState, + sessionCommand SessionCommand, + apply bool, + err error, +) { + next, err = ApplyPlaylistCommand(playlist, current, command) + if err != nil { + return current, SessionCommand{}, false, err + } + + apply = command.Kind == PlaylistSelect || next != current + if !apply { + return next, SessionCommand{}, false, nil + } + + entry, ok := next.Entry(playlist) + if !ok { + return current, SessionCommand{}, false, ErrPlaylistNoSelection + } + session := entry.SessionConfig(retry) + if err := session.Validate(); err != nil { + return current, SessionCommand{}, false, err + } + + return next, SessionCommand{ + Kind: CommandSetSession, + Session: session, + }, true, nil +} diff --git a/internal/playback/playlist_navigation_test.go b/internal/playback/playlist_navigation_test.go new file mode 100644 index 0000000..ad60d05 --- /dev/null +++ b/internal/playback/playlist_navigation_test.go @@ -0,0 +1,191 @@ +package playback + +import ( + "errors" + "testing" +) + +func navigationPlaylist(loop bool) Playlist { + return Playlist{ + Entries: []PlaylistEntry{ + {Name: "first", Video: PlaylistFeed{Domain: "domain", UUID: "video-1"}}, + {Name: "second", Audio: PlaylistFeed{Domain: "domain", UUID: "audio-2"}}, + {Name: "third", Video: PlaylistFeed{Domain: "domain", UUID: "video-3"}}, + }, + Loop: loop, + } +} + +func TestApplyPlaylistCommandSelect(t *testing.T) { + current := PlaylistState{CurrentIndex: 1, HasSelection: true} + + got, err := ApplyPlaylistCommand( + navigationPlaylist(false), + current, + PlaylistCommand{Kind: PlaylistSelect, Index: 2}, + ) + if err != nil { + t.Fatalf("ApplyPlaylistCommand() error = %v", err) + } + want := PlaylistState{CurrentIndex: 2, HasSelection: true} + if got != want { + t.Fatalf("ApplyPlaylistCommand() = %#v, want %#v", got, want) + } +} + +func TestApplyPlaylistCommandWithoutSelection(t *testing.T) { + tests := []struct { + name string + loop bool + command PlaylistCommandKind + want int + }{ + {name: "next", command: PlaylistNext, want: 0}, + {name: "previous without loop", command: PlaylistPrevious, want: 0}, + {name: "previous with loop", loop: true, command: PlaylistPrevious, want: 2}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := ApplyPlaylistCommand( + navigationPlaylist(test.loop), + PlaylistState{}, + PlaylistCommand{Kind: test.command}, + ) + if err != nil { + t.Fatalf("ApplyPlaylistCommand() error = %v", err) + } + want := PlaylistState{CurrentIndex: test.want, HasSelection: true} + if got != want { + t.Fatalf("ApplyPlaylistCommand() = %#v, want %#v", got, want) + } + }) + } +} + +func TestApplyPlaylistCommandNavigation(t *testing.T) { + tests := []struct { + name string + loop bool + current int + command PlaylistCommandKind + want int + }{ + {name: "next", current: 1, command: PlaylistNext, want: 2}, + {name: "previous", current: 1, command: PlaylistPrevious, want: 0}, + {name: "next stops at end", current: 2, command: PlaylistNext, want: 2}, + {name: "previous stops at beginning", current: 0, command: PlaylistPrevious, want: 0}, + {name: "next wraps", loop: true, current: 2, command: PlaylistNext, want: 0}, + {name: "previous wraps", loop: true, current: 0, command: PlaylistPrevious, want: 2}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + current := PlaylistState{CurrentIndex: test.current, HasSelection: true} + got, err := ApplyPlaylistCommand( + navigationPlaylist(test.loop), + current, + PlaylistCommand{Kind: test.command}, + ) + if err != nil { + t.Fatalf("ApplyPlaylistCommand() error = %v", err) + } + want := PlaylistState{CurrentIndex: test.want, HasSelection: true} + if got != want { + t.Fatalf("ApplyPlaylistCommand() = %#v, want %#v", got, want) + } + }) + } +} + +func TestApplyPlaylistCommandErrorsLeaveStateUnchanged(t *testing.T) { + current := PlaylistState{CurrentIndex: 1, HasSelection: true} + tests := []struct { + name string + playlist Playlist + command PlaylistCommand + wantErr error + }{ + { + name: "empty playlist", + playlist: Playlist{}, + command: PlaylistCommand{Kind: PlaylistNext}, + wantErr: ErrPlaylistEmpty, + }, + { + name: "negative selection", + playlist: navigationPlaylist(false), + command: PlaylistCommand{Kind: PlaylistSelect, Index: -1}, + wantErr: ErrPlaylistIndexOutOfRange, + }, + { + name: "selection past end", + playlist: navigationPlaylist(false), + command: PlaylistCommand{Kind: PlaylistSelect, Index: 3}, + wantErr: ErrPlaylistIndexOutOfRange, + }, + { + name: "stale current index", + playlist: navigationPlaylist(false), + command: PlaylistCommand{Kind: PlaylistNext}, + wantErr: ErrPlaylistIndexOutOfRange, + }, + { + name: "unknown command", + playlist: navigationPlaylist(false), + command: PlaylistCommand{}, + wantErr: ErrUnknownPlaylistCommand, + }, + { + name: "invalid playlist", + playlist: Playlist{Entries: []PlaylistEntry{ + {}, + }}, + command: PlaylistCommand{Kind: PlaylistNext}, + wantErr: ErrPlaylistEntryEmpty, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + state := current + if test.name == "stale current index" { + state.CurrentIndex = 4 + } + got, err := ApplyPlaylistCommand(test.playlist, state, test.command) + if !errors.Is(err, test.wantErr) { + t.Fatalf("ApplyPlaylistCommand() error = %v, want %v", err, test.wantErr) + } + if got != state { + t.Fatalf("ApplyPlaylistCommand() = %#v, want unchanged %#v", got, state) + } + }) + } +} + +func TestPlaylistStateEntry(t *testing.T) { + playlist := navigationPlaylist(false) + tests := []struct { + name string + state PlaylistState + want string + ok bool + }{ + {name: "no selection", state: PlaylistState{}}, + {name: "selected", state: PlaylistState{CurrentIndex: 1, HasSelection: true}, want: "second", ok: true}, + {name: "negative stale index", state: PlaylistState{CurrentIndex: -1, HasSelection: true}}, + {name: "stale index", state: PlaylistState{CurrentIndex: 3, HasSelection: true}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + entry, ok := test.state.Entry(playlist) + if ok != test.ok { + t.Fatalf("Entry() ok = %v, want %v", ok, test.ok) + } + if entry.Name != test.want { + t.Fatalf("Entry() name = %q, want %q", entry.Name, test.want) + } + }) + } +} diff --git a/internal/playback/playlist_readiness.go b/internal/playback/playlist_readiness.go new file mode 100644 index 0000000..6670ea5 --- /dev/null +++ b/internal/playback/playlist_readiness.go @@ -0,0 +1,267 @@ +package playback + +import ( + "context" + "errors" + "time" +) + +type PlaylistSnapshotSource interface { + Snapshot() (PlaylistSnapshot, bool) +} + +type SessionSnapshotSource interface { + Snapshot() (SessionSnapshot, bool) +} + +type PlaybackStatusSnapshotSource interface { + SnapshotAll() PlaybackStatusSnapshot +} + +type playlistReadinessTicker interface { + C() <-chan time.Time + Stop() +} + +type playlistReadinessTickerFactory func(time.Duration) playlistReadinessTicker + +type realPlaylistReadinessTicker struct { + ticker *time.Ticker +} + +func (t realPlaylistReadinessTicker) C() <-chan time.Time { return t.ticker.C } +func (t realPlaylistReadinessTicker) Stop() { t.ticker.Stop() } + +var ( + ErrPlaylistSnapshotSourceRequired = errors.New("playlist snapshot source is required") + ErrSessionSnapshotSourceRequired = errors.New("session snapshot source is required") + ErrStatusSnapshotSourceRequired = errors.New("playback status snapshot source is required") + ErrPlaylistReadinessOutputRequired = errors.New("playlist readiness output channel is required") + ErrPlaylistReadinessInterval = errors.New("playlist readiness interval must be positive") +) + +type PlaylistReadinessCoordinator struct { + playlist PlaylistSnapshotSource + session SessionSnapshotSource + statuses PlaybackStatusSnapshotSource + output chan<- PlaylistReadiness + interval time.Duration + + newTicker playlistReadinessTickerFactory +} + +func NewPlaylistReadinessCoordinator( + playlist PlaylistSnapshotSource, + session SessionSnapshotSource, + statuses PlaybackStatusSnapshotSource, + output chan<- PlaylistReadiness, + interval time.Duration, +) (*PlaylistReadinessCoordinator, error) { + if playlist == nil { + return nil, ErrPlaylistSnapshotSourceRequired + } + if session == nil { + return nil, ErrSessionSnapshotSourceRequired + } + if statuses == nil { + return nil, ErrStatusSnapshotSourceRequired + } + if output == nil { + return nil, ErrPlaylistReadinessOutputRequired + } + if interval <= 0 { + return nil, ErrPlaylistReadinessInterval + } + + return &PlaylistReadinessCoordinator{ + playlist: playlist, + session: session, + statuses: statuses, + output: output, + interval: interval, + newTicker: func(interval time.Duration) playlistReadinessTicker { + return realPlaylistReadinessTicker{ticker: time.NewTicker(interval)} + }, + }, nil +} + +func (c *PlaylistReadinessCoordinator) Run(ctx context.Context) error { + ticker := c.newTicker(c.interval) + defer ticker.Stop() + + var emittedReadyRevision uint64 + var emittedFailedRevision uint64 + for { + select { + case <-ctx.Done(): + return ctx.Err() + + case <-ticker.C(): + playlistSnapshot, ok := c.playlist.Snapshot() + if !ok || + !playlistSnapshot.State.HasSelection || + playlistSnapshot.Revision == 0 { + continue + } + + sessionSnapshot, ok := c.session.Snapshot() + if !ok || !PlaylistEntryMatchesSession( + playlistSnapshot.Entry, + sessionSnapshot.Desired, + ) { + continue + } + statuses := c.statuses.SnapshotAll() + if IsSessionFailed(sessionSnapshot, statuses) { + if playlistSnapshot.Revision == emittedFailedRevision { + continue + } + failed := PlaylistEvent{ + Revision: playlistSnapshot.Revision, + Kind: PlaylistEventFailed, + } + select { + case <-ctx.Done(): + return ctx.Err() + case c.output <- failed: + emittedFailedRevision = playlistSnapshot.Revision + } + continue + } + if playlistSnapshot.Entry.Duration <= 0 || + playlistSnapshot.Timing.Started || + playlistSnapshot.Timing.Paused || + playlistSnapshot.Revision == emittedReadyRevision { + continue + } + if !IsSessionPlaying(sessionSnapshot, statuses) { + continue + } + + ready := PlaylistReadiness{Revision: playlistSnapshot.Revision} + select { + case <-ctx.Done(): + return ctx.Err() + case c.output <- ready: + emittedReadyRevision = playlistSnapshot.Revision + } + } + } +} + +func PlaylistEntryMatchesSession(entry PlaylistEntry, session SessionConfig) bool { + if err := entry.Validate(); err != nil { + return false + } + return playlistFeedMatchesSession(entry.Video, session.Video) && + playlistFeedMatchesSession(entry.Audio, session.Audio) && + entry.SyncRequested == session.SyncRequested +} + +func playlistFeedMatchesSession(playlist PlaylistFeed, session FeedConfig) bool { + if !playlist.IsConfigured() { + return !session.IsConfigured() && !session.Active + } + return session.Active && + playlist.Domain == session.Domain && + playlist.UUID == session.UUID +} + +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, + session.Plan.Video, + ) { + return false + } + if session.Plan.Audio.Active && !statusIsPlaying( + statuses.Audio, + statuses.HasAudio, + session.Generation, + session.Plan.Audio, + ) { + return false + } + return true + + case TopologySynchronized: + return statuses.HasSync && + statuses.Sync.Generation == session.Generation && + statuses.Sync.State == StatePlaying && + sameSyncSource(statuses.Sync.Pair, session.Plan.Sync) + + case TopologyIdle: + return false + + default: + return false + } +} + +func IsSessionFailed( + session SessionSnapshot, + statuses PlaybackStatusSnapshot, +) bool { + if statuses.Generation != session.Generation { + return false + } + + switch session.Plan.Topology { + case TopologyIndependent: + return (session.Plan.Video.Active && statusIsFailed( + statuses.Video, statuses.HasVideo, session.Generation, session.Plan.Video, + )) || (session.Plan.Audio.Active && statusIsFailed( + statuses.Audio, statuses.HasAudio, session.Generation, session.Plan.Audio, + )) + case TopologySynchronized: + return statuses.HasSync && + statuses.Sync.Generation == session.Generation && + statuses.Sync.State == StateFailed && + sameSyncSource(statuses.Sync.Pair, session.Plan.Sync) + default: + return false + } +} + +func statusIsFailed(status Status, present bool, generation uint64, feed FeedConfig) bool { + return present && + status.Generation == generation && + status.State == StateFailed && + sameFeedSource(status.Feed, feed) +} + +func statusIsPlaying( + status Status, + present bool, + generation uint64, + feed FeedConfig, +) bool { + return present && + status.Generation == generation && + status.State == StatePlaying && + sameFeedSource(status.Feed, feed) +} + +func sameFeedSource(a, b FeedConfig) bool { + return a.Domain == b.Domain && a.UUID == b.UUID +} + +func sameSyncSource(a, b SyncPairConfig) bool { + return sameFeedSource(a.Video, b.Video) && + sameFeedSource(a.Audio, b.Audio) +} diff --git a/internal/playback/playlist_readiness_coordinator_test.go b/internal/playback/playlist_readiness_coordinator_test.go new file mode 100644 index 0000000..9620560 --- /dev/null +++ b/internal/playback/playlist_readiness_coordinator_test.go @@ -0,0 +1,369 @@ +package playback + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +type fakePlaylistSnapshotSource struct { + mu sync.RWMutex + snapshot PlaylistSnapshot + ok bool +} + +func (s *fakePlaylistSnapshotSource) Snapshot() (PlaylistSnapshot, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.snapshot, s.ok +} + +func (s *fakePlaylistSnapshotSource) set(snapshot PlaylistSnapshot, ok bool) { + s.mu.Lock() + s.snapshot = snapshot + s.ok = ok + s.mu.Unlock() +} + +type fakeSessionSnapshotSource struct { + mu sync.RWMutex + snapshot SessionSnapshot + ok bool +} + +func (s *fakeSessionSnapshotSource) Snapshot() (SessionSnapshot, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.snapshot, s.ok +} + +func (s *fakeSessionSnapshotSource) set(snapshot SessionSnapshot, ok bool) { + s.mu.Lock() + s.snapshot = snapshot + s.ok = ok + s.mu.Unlock() +} + +type fakePlaybackStatusSnapshotSource struct { + mu sync.RWMutex + snapshot PlaybackStatusSnapshot +} + +func (s *fakePlaybackStatusSnapshotSource) SnapshotAll() PlaybackStatusSnapshot { + s.mu.RLock() + defer s.mu.RUnlock() + return s.snapshot +} + +func (s *fakePlaybackStatusSnapshotSource) set(snapshot PlaybackStatusSnapshot) { + s.mu.Lock() + s.snapshot = snapshot + s.mu.Unlock() +} + +type fakePlaylistReadinessTicker struct { + ch chan time.Time + + mu sync.Mutex + stopped bool +} + +func newFakePlaylistReadinessTicker() *fakePlaylistReadinessTicker { + return &fakePlaylistReadinessTicker{ch: make(chan time.Time, 16)} +} + +func (t *fakePlaylistReadinessTicker) C() <-chan time.Time { return t.ch } +func (t *fakePlaylistReadinessTicker) Stop() { + t.mu.Lock() + t.stopped = true + t.mu.Unlock() +} +func (t *fakePlaylistReadinessTicker) tick() { t.ch <- time.Now() } +func (t *fakePlaylistReadinessTicker) isStopped() bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.stopped +} + +func TestNewPlaylistReadinessCoordinatorValidatesDependencies(t *testing.T) { + playlist := &fakePlaylistSnapshotSource{} + session := &fakeSessionSnapshotSource{} + statuses := &fakePlaybackStatusSnapshotSource{} + output := make(chan PlaylistReadiness) + tests := []struct { + name string + playlist PlaylistSnapshotSource + session SessionSnapshotSource + statuses PlaybackStatusSnapshotSource + output chan<- PlaylistReadiness + interval time.Duration + wantErr error + }{ + {name: "playlist", session: session, statuses: statuses, output: output, interval: time.Millisecond, wantErr: ErrPlaylistSnapshotSourceRequired}, + {name: "session", playlist: playlist, statuses: statuses, output: output, interval: time.Millisecond, wantErr: ErrSessionSnapshotSourceRequired}, + {name: "statuses", playlist: playlist, session: session, output: output, interval: time.Millisecond, wantErr: ErrStatusSnapshotSourceRequired}, + {name: "output", playlist: playlist, session: session, statuses: statuses, interval: time.Millisecond, wantErr: ErrPlaylistReadinessOutputRequired}, + {name: "interval", playlist: playlist, session: session, statuses: statuses, output: output, wantErr: ErrPlaylistReadinessInterval}, + {name: "valid", playlist: playlist, session: session, statuses: statuses, output: output, interval: time.Millisecond}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + coordinator, err := NewPlaylistReadinessCoordinator( + test.playlist, + test.session, + test.statuses, + test.output, + test.interval, + ) + if !errors.Is(err, test.wantErr) { + t.Fatalf("NewPlaylistReadinessCoordinator() error = %v, want %v", err, test.wantErr) + } + if test.wantErr != nil && coordinator != nil { + t.Fatalf("coordinator = %#v, want nil", coordinator) + } + }) + } +} + +func TestPlaylistEntryMatchesSession(t *testing.T) { + entry := PlaylistEntry{ + Video: PlaylistFeed{Domain: "video-domain", UUID: "video"}, + Audio: PlaylistFeed{Domain: "audio-domain", UUID: "audio"}, + SyncRequested: true, + } + matching := entry.SessionConfig(validPlaylistRetryPolicy()) + tests := []struct { + name string + entry PlaylistEntry + session SessionConfig + want bool + }{ + {name: "matching", entry: entry, session: matching, want: true}, + {name: "retry ignored", entry: entry, session: func() SessionConfig { value := matching; value.Retry.MaxAttempts = 99; return value }(), want: true}, + {name: "wrong video UUID", entry: entry, session: func() SessionConfig { value := matching; value.Video.UUID = "other"; return value }()}, + {name: "wrong audio domain", entry: entry, session: func() SessionConfig { value := matching; value.Audio.Domain = "other"; return value }()}, + {name: "inactive video", entry: entry, session: func() SessionConfig { value := matching; value.Video.Active = false; return value }()}, + {name: "wrong sync request", entry: entry, session: func() SessionConfig { value := matching; value.SyncRequested = false; return value }()}, + { + name: "absent audio matches unconfigured inactive", + entry: PlaylistEntry{Video: entry.Video}, + session: PlaylistEntry{Video: entry.Video}.SessionConfig(validPlaylistRetryPolicy()), + want: true, + }, + { + name: "absent audio rejects configured audio", + entry: PlaylistEntry{Video: entry.Video}, + session: SessionConfig{ + Video: matching.Video, + Audio: matching.Audio, + Retry: matching.Retry, + }, + }, + {name: "invalid entry", entry: PlaylistEntry{}, session: matching}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := PlaylistEntryMatchesSession(test.entry, test.session); got != test.want { + t.Fatalf("PlaylistEntryMatchesSession() = %v, want %v", got, test.want) + } + }) + } +} + +func TestPlaylistReadinessCoordinatorEmitsOncePerRevision(t *testing.T) { + playlist, session, statuses := readyVideoSnapshots(1) + output := make(chan PlaylistReadiness, 4) + coordinator, ticker, cancel, result := startReadinessCoordinator( + t, + playlist, + session, + statuses, + output, + ) + _ = coordinator + defer cancel() + + ticker.tick() + if got := receivePlaylistReadiness(t, output); got.Revision != 1 { + t.Fatalf("readiness revision = %d, want 1", got.Revision) + } + ticker.tick() + assertNoPlaylistReadiness(t, output) + + next := playlistSnapshotForVideo(2) + playlist.set(next, true) + ticker.tick() + if got := receivePlaylistReadiness(t, output); got.Revision != 2 { + t.Fatalf("readiness revision = %d, want 2", got.Revision) + } + + cancel() + if err := waitForPlaylistResult(t, result); !errors.Is(err, context.Canceled) { + t.Fatalf("Run() error = %v, want %v", err, context.Canceled) + } + if !ticker.isStopped() { + t.Fatal("ticker was not stopped") + } +} + +func TestPlaylistReadinessCoordinatorWaitsForAllConditions(t *testing.T) { + playlist, session, statuses := readyVideoSnapshots(1) + output := make(chan PlaylistReadiness, 1) + _, ticker, cancel, result := startReadinessCoordinator(t, playlist, session, statuses, output) + defer cancel() + + tests := []struct { + name string + mutate func() + }{ + {name: "no playlist snapshot", mutate: func() { playlist.set(PlaylistSnapshot{}, false) }}, + {name: "no selection", mutate: func() { + value := playlistSnapshotForVideo(1) + value.State.HasSelection = false + playlist.set(value, true) + }}, + {name: "zero revision", mutate: func() { value := playlistSnapshotForVideo(1); value.Revision = 0; playlist.set(value, true) }}, + {name: "zero duration", mutate: func() { value := playlistSnapshotForVideo(1); value.Entry.Duration = 0; playlist.set(value, true) }}, + {name: "already started", mutate: func() { value := playlistSnapshotForVideo(1); value.Timing.Started = true; playlist.set(value, true) }}, + {name: "paused", mutate: func() { value := playlistSnapshotForVideo(1); value.Timing.Paused = true; playlist.set(value, true) }}, + {name: "session mismatch", mutate: func() { + playlist.set(playlistSnapshotForVideo(1), true) + value, _ := session.Snapshot() + value.Desired.Video.UUID = "other" + session.set(value, true) + }}, + {name: "stale statuses", mutate: func() { + playlist.set(playlistSnapshotForVideo(1), true) + _, validSession, _ := readyVideoSnapshots(1) + value, _ := validSession.Snapshot() + session.set(value, true) + current := statuses.SnapshotAll() + current.Generation = 2 + current.Video.Generation = 2 + statuses.set(current) + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + validPlaylist, validSession, validStatuses := readyVideoSnapshots(1) + playlist.set(validPlaylist.snapshot, true) + session.set(validSession.snapshot, true) + statuses.set(validStatuses.snapshot) + test.mutate() + ticker.tick() + assertNoPlaylistReadiness(t, output) + }) + } + + cancel() + _ = waitForPlaylistResult(t, result) +} + +func TestPlaylistReadinessCoordinatorCancellationWhileBlockedSending(t *testing.T) { + playlist, session, statuses := readyVideoSnapshots(1) + output := make(chan PlaylistReadiness) + _, ticker, cancel, result := startReadinessCoordinator(t, playlist, session, statuses, output) + + ticker.tick() + time.Sleep(time.Millisecond) + cancel() + if err := waitForPlaylistResult(t, result); !errors.Is(err, context.Canceled) { + t.Fatalf("Run() error = %v, want %v", err, context.Canceled) + } +} + +func readyVideoSnapshots(revision uint64) ( + *fakePlaylistSnapshotSource, + *fakeSessionSnapshotSource, + *fakePlaybackStatusSnapshotSource, +) { + playlist := &fakePlaylistSnapshotSource{snapshot: playlistSnapshotForVideo(revision), ok: true} + entry := playlist.snapshot.Entry + desired := entry.SessionConfig(validPlaylistRetryPolicy()) + session := &fakeSessionSnapshotSource{ + snapshot: SessionSnapshot{ + Desired: desired, + Plan: SessionPlan{Topology: TopologyIndependent, Video: desired.Video}, + Generation: 5, + }, + ok: true, + } + statuses := &fakePlaybackStatusSnapshotSource{ + snapshot: PlaybackStatusSnapshot{ + Generation: 5, + Video: Status{ + Unit: UnitVideo, + State: StatePlaying, + Generation: 5, + Feed: desired.Video, + }, + HasVideo: true, + }, + } + return playlist, session, statuses +} + +func playlistSnapshotForVideo(revision uint64) PlaylistSnapshot { + entry := PlaylistEntry{ + Name: "video", + Video: PlaylistFeed{Domain: "domain", UUID: "video"}, + Duration: 10 * time.Second, + } + return PlaylistSnapshot{ + State: PlaylistState{CurrentIndex: 0, HasSelection: true}, + Entry: entry, + Revision: revision, + Timing: NewPlaylistTiming(revision, entry.Duration), + } +} + +func startReadinessCoordinator( + t *testing.T, + playlist PlaylistSnapshotSource, + session SessionSnapshotSource, + statuses PlaybackStatusSnapshotSource, + output chan<- PlaylistReadiness, +) (*PlaylistReadinessCoordinator, *fakePlaylistReadinessTicker, context.CancelFunc, <-chan error) { + t.Helper() + coordinator, err := NewPlaylistReadinessCoordinator( + playlist, + session, + statuses, + output, + time.Millisecond, + ) + if err != nil { + t.Fatalf("NewPlaylistReadinessCoordinator() error = %v", err) + } + ticker := newFakePlaylistReadinessTicker() + coordinator.newTicker = func(time.Duration) playlistReadinessTicker { return ticker } + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + go func() { result <- coordinator.Run(ctx) }() + return coordinator, ticker, cancel, result +} + +func receivePlaylistReadiness(t *testing.T, output <-chan PlaylistReadiness) PlaylistReadiness { + t.Helper() + select { + case readiness := <-output: + return readiness + case <-time.After(time.Second): + t.Fatal("timed out waiting for playlist readiness") + return PlaylistReadiness{} + } +} + +func assertNoPlaylistReadiness(t *testing.T, output <-chan PlaylistReadiness) { + t.Helper() + select { + case readiness := <-output: + t.Fatalf("unexpected playlist readiness: %#v", readiness) + case <-time.After(20 * time.Millisecond): + } +} diff --git a/internal/playback/playlist_readiness_test.go b/internal/playback/playlist_readiness_test.go new file mode 100644 index 0000000..2992b63 --- /dev/null +++ b/internal/playback/playlist_readiness_test.go @@ -0,0 +1,280 @@ +package playback + +import "testing" + +func TestIsSessionPlaying(t *testing.T) { + const generation = 4 + playing := func(unit Unit) Status { + status := Status{Unit: unit, State: StatePlaying, Generation: generation} + switch unit { + case UnitVideo: + status.Feed = FeedConfig{UUID: "video"} + case UnitAudio: + status.Feed = FeedConfig{UUID: "audio"} + } + return status + } + 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: "video status has wrong UUID", + session: SessionSnapshot{ + Generation: generation, + Plan: SessionPlan{ + Topology: TopologyIndependent, + Video: FeedConfig{Domain: "domain", UUID: "video", Active: true}, + }, + }, + statuses: PlaybackStatusSnapshot{ + Generation: generation, + Video: Status{ + Unit: UnitVideo, + State: StatePlaying, + Generation: generation, + Feed: FeedConfig{Domain: "domain", UUID: "other"}, + }, + HasVideo: true, + }, + }, + { + name: "audio status has wrong domain", + session: SessionSnapshot{ + Generation: generation, + Plan: SessionPlan{ + Topology: TopologyIndependent, + Audio: FeedConfig{Domain: "audio-domain", UUID: "audio", Active: true}, + }, + }, + statuses: PlaybackStatusSnapshot{ + Generation: generation, + Audio: Status{ + Unit: UnitAudio, + State: StatePlaying, + Generation: generation, + Feed: FeedConfig{Domain: "other-domain", UUID: "audio"}, + }, + HasAudio: true, + }, + }, + { + name: "sync status has wrong audio source", + session: SessionSnapshot{ + Generation: generation, + Plan: SessionPlan{ + Topology: TopologySynchronized, + Sync: SyncPairConfig{ + Video: FeedConfig{Domain: "domain", UUID: "video"}, + Audio: FeedConfig{Domain: "domain", UUID: "audio"}, + }, + }, + }, + statuses: PlaybackStatusSnapshot{ + Generation: generation, + Sync: Status{ + Unit: UnitSync, + State: StatePlaying, + Generation: generation, + Pair: SyncPairConfig{ + Video: FeedConfig{Domain: "domain", UUID: "video"}, + Audio: FeedConfig{Domain: "domain", UUID: "other-audio"}, + }, + }, + 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/playlist_selection_test.go b/internal/playback/playlist_selection_test.go new file mode 100644 index 0000000..635e0bc --- /dev/null +++ b/internal/playback/playlist_selection_test.go @@ -0,0 +1,189 @@ +package playback + +import ( + "errors" + "testing" + "time" +) + +func TestApplyPlaylistSelectionFirstNext(t *testing.T) { + retry := validPlaylistRetryPolicy() + + next, command, apply, err := ApplyPlaylistSelection( + navigationPlaylist(false), + PlaylistState{}, + PlaylistCommand{Kind: PlaylistNext}, + retry, + ) + if err != nil { + t.Fatalf("ApplyPlaylistSelection() error = %v", err) + } + if !apply { + t.Fatal("ApplyPlaylistSelection() apply = false, want true") + } + wantState := PlaylistState{CurrentIndex: 0, HasSelection: true} + if next != wantState { + t.Fatalf("ApplyPlaylistSelection() state = %#v, want %#v", next, wantState) + } + wantCommand := SessionCommand{ + Kind: CommandSetSession, + Session: SessionConfig{ + Video: FeedConfig{Domain: "domain", UUID: "video-1", Active: true}, + Retry: retry, + }, + } + if command != wantCommand { + t.Fatalf("ApplyPlaylistSelection() command = %#v, want %#v", command, wantCommand) + } +} + +func TestApplyPlaylistSelectionUsesCompleteEntrySession(t *testing.T) { + retry := validPlaylistRetryPolicy() + playlist := Playlist{Entries: []PlaylistEntry{ + { + Video: PlaylistFeed{Domain: "video-domain", UUID: "video"}, + Audio: PlaylistFeed{Domain: "audio-domain", UUID: "audio"}, + SyncRequested: true, + }, + {Video: PlaylistFeed{Domain: "next-domain", UUID: "next-video"}}, + }} + + _, command, apply, err := ApplyPlaylistSelection( + playlist, + PlaylistState{CurrentIndex: 1, HasSelection: true}, + PlaylistCommand{Kind: PlaylistSelect, Index: 0}, + retry, + ) + if err != nil { + t.Fatalf("ApplyPlaylistSelection() error = %v", err) + } + if !apply { + t.Fatal("ApplyPlaylistSelection() apply = false, want true") + } + want := SessionConfig{ + Video: FeedConfig{Domain: "video-domain", UUID: "video", Active: true}, + Audio: FeedConfig{Domain: "audio-domain", UUID: "audio", Active: true}, + SyncRequested: true, + Retry: retry, + } + if command.Kind != CommandSetSession || command.Session != want { + t.Fatalf("ApplyPlaylistSelection() command = %#v, want session %#v", command, want) + } +} + +func TestApplyPlaylistSelectionVideoOnlyClearsAudio(t *testing.T) { + _, command, apply, err := ApplyPlaylistSelection( + navigationPlaylist(false), + PlaylistState{CurrentIndex: 1, HasSelection: true}, + PlaylistCommand{Kind: PlaylistSelect, Index: 0}, + validPlaylistRetryPolicy(), + ) + if err != nil { + t.Fatalf("ApplyPlaylistSelection() error = %v", err) + } + if !apply { + t.Fatal("ApplyPlaylistSelection() apply = false, want true") + } + if command.Session.Audio != (FeedConfig{}) { + t.Fatalf("ApplyPlaylistSelection() audio = %#v, want zero value", command.Session.Audio) + } +} + +func TestApplyPlaylistSelectionApplicationDecision(t *testing.T) { + tests := []struct { + name string + loop bool + current int + command PlaylistCommand + want int + apply bool + }{ + {name: "reselect current", current: 1, command: PlaylistCommand{Kind: PlaylistSelect, Index: 1}, want: 1, apply: true}, + {name: "move next", current: 1, command: PlaylistCommand{Kind: PlaylistNext}, want: 2, apply: true}, + {name: "next stops at end", current: 2, command: PlaylistCommand{Kind: PlaylistNext}, want: 2}, + {name: "previous stops at beginning", current: 0, command: PlaylistCommand{Kind: PlaylistPrevious}, want: 0}, + {name: "next wraps", loop: true, current: 2, command: PlaylistCommand{Kind: PlaylistNext}, want: 0, apply: true}, + {name: "previous wraps", loop: true, current: 0, command: PlaylistCommand{Kind: PlaylistPrevious}, want: 2, apply: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + next, command, apply, err := ApplyPlaylistSelection( + navigationPlaylist(test.loop), + PlaylistState{CurrentIndex: test.current, HasSelection: true}, + test.command, + validPlaylistRetryPolicy(), + ) + if err != nil { + t.Fatalf("ApplyPlaylistSelection() error = %v", err) + } + wantState := PlaylistState{CurrentIndex: test.want, HasSelection: true} + if next != wantState { + t.Fatalf("ApplyPlaylistSelection() state = %#v, want %#v", next, wantState) + } + if apply != test.apply { + t.Fatalf("ApplyPlaylistSelection() apply = %v, want %v", apply, test.apply) + } + if apply && command.Kind != CommandSetSession { + t.Fatalf("ApplyPlaylistSelection() command kind = %v, want %v", command.Kind, CommandSetSession) + } + if !apply && command != (SessionCommand{}) { + t.Fatalf("ApplyPlaylistSelection() command = %#v, want zero value", command) + } + }) + } +} + +func TestApplyPlaylistSelectionErrorsDoNotApply(t *testing.T) { + current := PlaylistState{CurrentIndex: 1, HasSelection: true} + tests := []struct { + name string + command PlaylistCommand + retry RetryPolicy + wantErr error + }{ + { + name: "invalid command", + command: PlaylistCommand{}, + retry: validPlaylistRetryPolicy(), + wantErr: ErrUnknownPlaylistCommand, + }, + { + name: "invalid retry", + command: PlaylistCommand{Kind: PlaylistNext}, + retry: RetryPolicy{}, + wantErr: ErrInvalidRetryDelay, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + next, command, apply, err := ApplyPlaylistSelection( + navigationPlaylist(false), + current, + test.command, + test.retry, + ) + if !errors.Is(err, test.wantErr) { + t.Fatalf("ApplyPlaylistSelection() error = %v, want %v", err, test.wantErr) + } + if next != current { + t.Fatalf("ApplyPlaylistSelection() state = %#v, want unchanged %#v", next, current) + } + if apply { + t.Fatal("ApplyPlaylistSelection() apply = true, want false") + } + if command != (SessionCommand{}) { + t.Fatalf("ApplyPlaylistSelection() command = %#v, want zero value", command) + } + }) + } +} + +func validPlaylistRetryPolicy() RetryPolicy { + return RetryPolicy{ + MaxAttempts: 3, + InitialDelay: time.Millisecond, + MaxDelay: time.Second, + } +} diff --git a/internal/playback/playlist_test.go b/internal/playback/playlist_test.go new file mode 100644 index 0000000..cd7528e --- /dev/null +++ b/internal/playback/playlist_test.go @@ -0,0 +1,153 @@ +package playback + +import ( + "errors" + "testing" + "time" +) + +func TestPlaylistEntryValidate(t *testing.T) { + tests := []struct { + name string + entry PlaylistEntry + wantErr error + }{ + {name: "video only", entry: PlaylistEntry{Video: PlaylistFeed{Domain: "video-domain", UUID: "video"}}}, + {name: "audio only", entry: PlaylistEntry{Audio: PlaylistFeed{Domain: "audio-domain", UUID: "audio"}}}, + { + name: "independent feeds from different domains", + entry: PlaylistEntry{ + Video: PlaylistFeed{Domain: "video-domain", UUID: "video"}, + Audio: PlaylistFeed{Domain: "audio-domain", UUID: "audio"}, + }, + }, + { + name: "synchronized pair", + entry: PlaylistEntry{ + Video: PlaylistFeed{Domain: "domain", UUID: "video"}, + Audio: PlaylistFeed{Domain: "domain", UUID: "audio"}, + SyncRequested: true, + Duration: 10 * time.Second, + }, + }, + {name: "zero duration", entry: PlaylistEntry{Video: PlaylistFeed{Domain: "domain", UUID: "video"}}}, + {name: "empty entry", entry: PlaylistEntry{}, wantErr: ErrPlaylistEntryEmpty}, + { + name: "video UUID without domain", + entry: PlaylistEntry{Video: PlaylistFeed{UUID: "video"}}, + wantErr: ErrFeedDomainRequired, + }, + { + name: "audio domain without UUID", + entry: PlaylistEntry{Audio: PlaylistFeed{Domain: "audio-domain"}}, + wantErr: ErrPlaylistFeedUUIDRequired, + }, + { + name: "sync without audio", + entry: PlaylistEntry{ + Video: PlaylistFeed{Domain: "domain", UUID: "video"}, + SyncRequested: true, + }, + wantErr: ErrPlaylistSyncFeedsRequired, + }, + { + name: "sync without video", + entry: PlaylistEntry{ + Audio: PlaylistFeed{Domain: "domain", UUID: "audio"}, + SyncRequested: true, + }, + wantErr: ErrPlaylistSyncFeedsRequired, + }, + { + name: "negative duration", + entry: PlaylistEntry{ + Video: PlaylistFeed{Domain: "domain", UUID: "video"}, + Duration: -time.Second, + }, + wantErr: ErrPlaylistDurationNegative, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.entry.Validate() + if !errors.Is(err, test.wantErr) { + t.Fatalf("Validate() error = %v, want %v", err, test.wantErr) + } + }) + } +} + +func TestPlaylistEntrySessionConfig(t *testing.T) { + retry := RetryPolicy{MaxAttempts: 3, InitialDelay: time.Second, MaxDelay: 5 * time.Second} + entry := PlaylistEntry{ + Video: PlaylistFeed{Domain: "video-domain", UUID: "video"}, + Audio: PlaylistFeed{Domain: "audio-domain", UUID: "audio"}, + SyncRequested: true, + Duration: 10 * time.Second, + } + + got := entry.SessionConfig(retry) + want := SessionConfig{ + Video: FeedConfig{Domain: "video-domain", UUID: "video", Active: true}, + Audio: FeedConfig{Domain: "audio-domain", UUID: "audio", Active: true}, + SyncRequested: true, + Retry: retry, + } + if got != want { + t.Fatalf("SessionConfig() = %#v, want %#v", got, want) + } +} + +func TestPlaylistEntrySessionConfigLeavesMissingFeedInactive(t *testing.T) { + entry := PlaylistEntry{Video: PlaylistFeed{Domain: "domain", UUID: "video"}} + + got := entry.SessionConfig(RetryPolicy{}) + if !got.Video.Active { + t.Fatal("SessionConfig() video is inactive, want active") + } + if got.Audio != (FeedConfig{}) { + t.Fatalf("SessionConfig() audio = %#v, want zero value", got.Audio) + } +} + +func TestPlaylistValidate(t *testing.T) { + valid := PlaylistEntry{Video: PlaylistFeed{Domain: "domain", UUID: "video"}} + tests := []struct { + name string + playlist Playlist + wantErr error + }{ + {name: "empty playlist", playlist: Playlist{}}, + {name: "valid entries", playlist: Playlist{Entries: []PlaylistEntry{valid, valid}, Loop: true}}, + { + name: "invalid entry", + playlist: Playlist{Entries: []PlaylistEntry{ + valid, + {Audio: PlaylistFeed{UUID: "audio"}}, + }}, + wantErr: ErrFeedDomainRequired, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.playlist.Validate() + if !errors.Is(err, test.wantErr) { + t.Fatalf("Validate() error = %v, want %v", err, test.wantErr) + } + }) + } +} + +func TestPlaylistValidateReportsEntryIndex(t *testing.T) { + playlist := Playlist{Entries: []PlaylistEntry{ + {Video: PlaylistFeed{Domain: "domain", UUID: "video"}}, + {}, + }} + + err := playlist.Validate() + if err == nil || err.Error() != "playlist entry 1: playlist entry must contain at least one feed" { + t.Fatalf("Validate() error = %v, want indexed entry error", err) + } +} diff --git a/internal/playback/playlist_timing.go b/internal/playback/playlist_timing.go new file mode 100644 index 0000000..c99d7cd --- /dev/null +++ b/internal/playback/playlist_timing.go @@ -0,0 +1,116 @@ +package playback + +import "time" + +type PlaylistTimingState struct { + Revision uint64 + Duration time.Duration + Ready bool + Started bool + Paused bool + Expired bool + Remaining time.Duration + Deadline time.Time +} + +func NewPlaylistTiming( + revision uint64, + duration time.Duration, +) PlaylistTimingState { + return PlaylistTimingState{ + Revision: revision, + Duration: duration, + Remaining: duration, + } +} + +func StartPlaylistTiming( + current PlaylistTimingState, + revision uint64, + now time.Time, +) (PlaylistTimingState, bool) { + if revision != current.Revision || + current.Duration <= 0 || + current.Started || + current.Paused || + current.Expired { + return current, false + } + next := current + if next.Remaining <= 0 { + next.Remaining = next.Duration + } + next.Ready = true + next.Started = true + next.Deadline = now.Add(next.Remaining) + return next, true +} + +func PausePlaylistTiming( + current PlaylistTimingState, + revision uint64, + now time.Time, +) (PlaylistTimingState, bool) { + if revision != current.Revision || + current.Duration <= 0 || + current.Paused || + current.Expired { + return current, false + } + + next := current + if next.Started { + next.Remaining = next.Deadline.Sub(now) + if next.Remaining < 0 { + next.Remaining = 0 + } + if next.Remaining > next.Duration { + next.Remaining = next.Duration + } + next.Started = false + next.Deadline = time.Time{} + } + next.Paused = true + return next, true +} + +func ResumePlaylistTiming( + current PlaylistTimingState, + revision uint64, + now time.Time, +) (PlaylistTimingState, bool) { + if revision != current.Revision || + current.Duration <= 0 || + !current.Paused || + current.Expired { + return current, false + } + + next := current + next.Paused = false + if next.Ready { + next.Started = true + next.Deadline = now.Add(next.Remaining) + } + return next, true +} + +func ExpirePlaylistTiming( + current PlaylistTimingState, + revision uint64, + now time.Time, +) (PlaylistTimingState, bool) { + if revision != current.Revision || + !current.Started || + now.Before(current.Deadline) { + return current, false + } + + next := current + next.Started = false + next.Paused = false + next.Deadline = time.Time{} + next.Expired = true + next.Remaining = 0 + return next, true +} diff --git a/internal/playback/playlist_timing_test.go b/internal/playback/playlist_timing_test.go new file mode 100644 index 0000000..752aec9 --- /dev/null +++ b/internal/playback/playlist_timing_test.go @@ -0,0 +1,204 @@ +package playback + +import ( + "testing" + "time" +) + +func TestNewPlaylistTimingResetsState(t *testing.T) { + got := NewPlaylistTiming(7, 10*time.Second) + want := PlaylistTimingState{ + Revision: 7, + Duration: 10 * time.Second, + Remaining: 10 * time.Second, + } + if got != want { + t.Fatalf("NewPlaylistTiming() = %#v, want %#v", got, want) + } +} + +func TestStartPlaylistTiming(t *testing.T) { + now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC) + current := NewPlaylistTiming(4, 10*time.Second) + + got, started := StartPlaylistTiming(current, 4, now) + if !started { + t.Fatal("StartPlaylistTiming() started = false, want true") + } + want := PlaylistTimingState{ + Revision: 4, + Duration: 10 * time.Second, + Ready: true, + Started: true, + Remaining: 10 * time.Second, + Deadline: now.Add(10 * time.Second), + } + if got != want { + t.Fatalf("StartPlaylistTiming() = %#v, want %#v", got, want) + } +} + +func TestStartPlaylistTimingIgnoresInapplicableReadiness(t *testing.T) { + now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC) + started, ok := StartPlaylistTiming(NewPlaylistTiming(4, time.Second), 4, now) + if !ok { + t.Fatal("initial StartPlaylistTiming() did not start") + } + tests := []struct { + name string + current PlaylistTimingState + revision uint64 + }{ + {name: "zero duration", current: NewPlaylistTiming(4, 0), revision: 4}, + {name: "stale revision", current: NewPlaylistTiming(4, time.Second), revision: 3}, + {name: "future revision", current: NewPlaylistTiming(4, time.Second), revision: 5}, + {name: "already started", current: started, revision: 4}, + {name: "paused", current: PlaylistTimingState{Revision: 4, Duration: time.Second, Paused: true, Remaining: time.Second}, revision: 4}, + {name: "expired", current: PlaylistTimingState{Revision: 4, Duration: time.Second, Expired: true}, revision: 4}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, changed := StartPlaylistTiming(test.current, test.revision, now.Add(time.Second)) + if changed { + t.Fatal("StartPlaylistTiming() changed = true, want false") + } + if got != test.current { + t.Fatalf("StartPlaylistTiming() = %#v, want unchanged %#v", got, test.current) + } + }) + } +} + +func TestExpirePlaylistTiming(t *testing.T) { + now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC) + current, _ := StartPlaylistTiming(NewPlaylistTiming(9, 5*time.Second), 9, now) + + got, expired := ExpirePlaylistTiming(current, 9, now.Add(5*time.Second)) + if !expired { + t.Fatal("ExpirePlaylistTiming() expired = false, want true") + } + want := PlaylistTimingState{ + Revision: 9, + Duration: 5 * time.Second, + Ready: true, + Expired: true, + Remaining: 0, + } + if got != want { + t.Fatalf("ExpirePlaylistTiming() = %#v, want %#v", got, want) + } + + again, expired := ExpirePlaylistTiming(got, 9, now.Add(6*time.Second)) + if expired || again != got { + t.Fatalf("duplicate ExpirePlaylistTiming() = %#v, %v; want unchanged, false", again, expired) + } +} + +func TestExpirePlaylistTimingIgnoresInapplicableEvents(t *testing.T) { + now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC) + current, _ := StartPlaylistTiming(NewPlaylistTiming(4, 10*time.Second), 4, now) + tests := []struct { + name string + revision uint64 + at time.Time + }{ + {name: "early", revision: 4, at: now.Add(9 * time.Second)}, + {name: "stale revision", revision: 3, at: now.Add(10 * time.Second)}, + {name: "future revision", revision: 5, at: now.Add(10 * time.Second)}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, expired := ExpirePlaylistTiming(current, test.revision, test.at) + if expired { + t.Fatal("ExpirePlaylistTiming() expired = true, want false") + } + if got != current { + t.Fatalf("ExpirePlaylistTiming() = %#v, want unchanged %#v", got, current) + } + }) + } +} + +func TestNewPlaylistTimingInvalidatesPreviousDeadline(t *testing.T) { + now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC) + old, _ := StartPlaylistTiming(NewPlaylistTiming(1, time.Second), 1, now) + current := NewPlaylistTiming(2, 2*time.Second) + + got, expired := ExpirePlaylistTiming(current, old.Revision, old.Deadline) + if expired || got != current { + t.Fatalf("old expiry changed new timing: %#v, %v", got, expired) + } +} + +func TestPauseAndResumePlaylistTimingBeforeReadiness(t *testing.T) { + now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC) + current := NewPlaylistTiming(3, 10*time.Second) + + paused, changed := PausePlaylistTiming(current, 3, now) + if !changed || !paused.Paused || paused.Ready || paused.Started { + t.Fatalf("PausePlaylistTiming() = %#v, %v", paused, changed) + } + resumed, changed := ResumePlaylistTiming(paused, 3, now.Add(time.Second)) + if !changed || resumed.Paused || resumed.Ready || resumed.Started { + t.Fatalf("ResumePlaylistTiming() = %#v, %v", resumed, changed) + } + if resumed.Remaining != 10*time.Second { + t.Fatalf("remaining = %v, want 10s", resumed.Remaining) + } +} + +func TestPauseAndResumeActivePlaylistTimingUsesRemaining(t *testing.T) { + now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC) + current, _ := StartPlaylistTiming(NewPlaylistTiming(5, 10*time.Second), 5, now) + + paused, changed := PausePlaylistTiming(current, 5, now.Add(4*time.Second)) + if !changed || !paused.Paused || paused.Started || !paused.Ready { + t.Fatalf("PausePlaylistTiming() = %#v, %v", paused, changed) + } + if paused.Remaining != 6*time.Second || !paused.Deadline.IsZero() { + t.Fatalf("paused timing = %#v, want 6s remaining and no deadline", paused) + } + + resumeAt := now.Add(20 * time.Second) + resumed, changed := ResumePlaylistTiming(paused, 5, resumeAt) + if !changed || resumed.Paused || !resumed.Started || !resumed.Ready { + t.Fatalf("ResumePlaylistTiming() = %#v, %v", resumed, changed) + } + if resumed.Deadline != resumeAt.Add(6*time.Second) { + t.Fatalf("resumed deadline = %v, want %v", resumed.Deadline, resumeAt.Add(6*time.Second)) + } +} + +func TestPauseAndResumePlaylistTimingIgnoreInvalidTransitions(t *testing.T) { + now := time.Now() + base := NewPlaylistTiming(2, time.Second) + paused, _ := PausePlaylistTiming(base, 2, now) + tests := []struct { + name string + state PlaylistTimingState + pause bool + revision uint64 + }{ + {name: "pause wrong revision", state: base, pause: true, revision: 1}, + {name: "duplicate pause", state: paused, pause: true, revision: 2}, + {name: "resume wrong revision", state: paused, revision: 1}, + {name: "duplicate resume", state: base, revision: 2}, + {name: "pause expired", state: PlaylistTimingState{Revision: 2, Duration: time.Second, Expired: true}, pause: true, revision: 2}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var got PlaylistTimingState + var changed bool + if test.pause { + got, changed = PausePlaylistTiming(test.state, test.revision, now) + } else { + got, changed = ResumePlaylistTiming(test.state, test.revision, now) + } + if changed || got != test.state { + t.Fatalf("transition = %#v, %v; want unchanged", got, changed) + } + }) + } +} diff --git a/internal/playback/retry.go b/internal/playback/retry.go new file mode 100644 index 0000000..b9754e3 --- /dev/null +++ b/internal/playback/retry.go @@ -0,0 +1,23 @@ +package playback + +import "time" + +func (p RetryPolicy) canRetry(failedAttempts int) bool { + return p.MaxAttempts == 0 || failedAttempts < p.MaxAttempts +} + +func (p RetryPolicy) retryDelay(failedAttempts int) time.Duration { + delay := p.InitialDelay + + for attempt := 1; attempt < failedAttempts; attempt++ { + if delay >= p.MaxDelay/2 { + return p.MaxDelay + } + delay *= 2 + } + + if delay > p.MaxDelay { + return p.MaxDelay + } + return delay +} diff --git a/internal/playback/retry_test.go b/internal/playback/retry_test.go new file mode 100644 index 0000000..019cce7 --- /dev/null +++ b/internal/playback/retry_test.go @@ -0,0 +1,91 @@ +package playback + +import ( + "testing" + "time" +) + +func TestFiniteAttempts(t *testing.T) { + rp := RetryPolicy{ + MaxAttempts: 3, + InitialDelay: 500 * time.Millisecond, + MaxDelay: 10 * time.Second, + } + if !rp.canRetry(1) { + t.Fatal("MaxAttempts=3, failed=1, but can't retry") + } + if !rp.canRetry(2) { + t.Fatal("MaxAttempts=3, failed=2, but can't retry") + } + if rp.canRetry(3) { + t.Fatal("MaxAttempts=3, failed=3, but can retry") + } +} + +func TestOneAllowedAttempt(t *testing.T) { + rp := RetryPolicy{ + MaxAttempts: 1, + InitialDelay: 500 * time.Millisecond, + MaxDelay: 10 * time.Second, + } + if rp.canRetry(1) { + t.Fatal("MaxAttempts=1, failed=1, but can retry") + } +} + +func TestUnlimitedAttempts(t *testing.T) { + rp := RetryPolicy{ + MaxAttempts: 0, + InitialDelay: 500 * time.Millisecond, + MaxDelay: 10 * time.Second, + } + for failedAttempts := 1; failedAttempts <= 10; failedAttempts++ { + if !rp.canRetry(failedAttempts) { + t.Fatalf( + "canRetry(%d) = false for unlimited policy", + failedAttempts, + ) + } + } +} + +func TestBackoff(t *testing.T) { + rp := RetryPolicy{ + MaxAttempts: 0, + InitialDelay: 500 * time.Millisecond, + MaxDelay: 10 * time.Second, + } + want := []time.Duration{ + 500 * time.Millisecond, + 1 * time.Second, + 2 * time.Second, + 4 * time.Second, + 8 * time.Second, + 10 * time.Second, + 10 * time.Second, + } + for i, wantDelay := range want { + failedAttempts := i + 1 + got := rp.retryDelay(failedAttempts) + if got != wantDelay { + t.Errorf( + "retryDelay(%d) = %s, want %s", + failedAttempts, + got, + wantDelay, + ) + } + } +} + +func TestRetryDelayLargeFailureCount(t *testing.T) { + policy := RetryPolicy{ + MaxAttempts: 0, + InitialDelay: 500 * time.Millisecond, + MaxDelay: 10 * time.Second, + } + + if got := policy.retryDelay(1_000_000); got != policy.MaxDelay { + t.Fatalf("retryDelay() = %s, want cap %s", got, policy.MaxDelay) + } +} diff --git a/internal/playback/session_command.go b/internal/playback/session_command.go new file mode 100644 index 0000000..71d6ed3 --- /dev/null +++ b/internal/playback/session_command.go @@ -0,0 +1,118 @@ +package playback + +import ( + "errors" + "fmt" +) + +type SessionCommandKind uint8 + +const ( + CommandSetVideo SessionCommandKind = iota + 1 + CommandSetAudio + CommandStopVideo + CommandStopAudio + CommandStopAll + CommandResumeVideo + CommandResumeAudio + CommandResumeAll + CommandRemoveVideo + CommandRemoveAudio + CommandEnableSync + CommandDisableSync + CommandSetSession +) + +type SessionCommand struct { + Kind SessionCommandKind + Config FeedConfig // Used only by SetVideo and SetAudio. + Session SessionConfig // Used only by SetSession. +} + +var ( + ErrUnknownSessionCommand = errors.New("unknown session command") + ErrVideoNotConfigured = errors.New("video feed is not configured") + ErrAudioNotConfigured = errors.New("audio feed is not configured") +) + +func ApplySessionCommand( + current SessionConfig, + command SessionCommand, +) (SessionConfig, error) { + next := current + switch command.Kind { + case CommandSetVideo: + if !command.Config.IsConfigured() { + return current, ErrVideoNotConfigured + } + if err := command.Config.Validate(); err != nil { + return current, fmt.Errorf("validate video command: %w", err) + } + next.Video = command.Config + + case CommandSetAudio: + if !command.Config.IsConfigured() { + return current, ErrAudioNotConfigured + } + if err := command.Config.Validate(); err != nil { + return current, fmt.Errorf("validate audio command: %w", err) + } + next.Audio = command.Config + + case CommandStopVideo: + next.Video.Active = false + next.SyncRequested = false + + case CommandStopAudio: + next.Audio.Active = false + next.SyncRequested = false + + case CommandStopAll: + next.Video.Active = false + next.Audio.Active = false + + case CommandResumeVideo: + if !next.Video.IsConfigured() { + return current, ErrVideoNotConfigured + } + next.Video.Active = true + + case CommandResumeAudio: + if !next.Audio.IsConfigured() { + return current, ErrAudioNotConfigured + } + next.Audio.Active = true + + case CommandResumeAll: + next.Video.Active = next.Video.IsConfigured() + next.Audio.Active = next.Audio.IsConfigured() + + case CommandRemoveVideo: + next.Video = FeedConfig{} + next.SyncRequested = false + + case CommandRemoveAudio: + next.Audio = FeedConfig{} + next.SyncRequested = false + + case CommandEnableSync: + next.SyncRequested = true + + case CommandDisableSync: + next.SyncRequested = false + + case CommandSetSession: + next = command.Session + // Retry policy belongs to the running controller configuration, not to + // GUI or playlist session selections. + next.Retry = current.Retry + + default: + return current, ErrUnknownSessionCommand + } + + if err := next.Validate(); err != nil { + return current, fmt.Errorf("validate desired session: %w", err) + } + return next, nil +} diff --git a/internal/playback/session_command_test.go b/internal/playback/session_command_test.go new file mode 100644 index 0000000..bc828f9 --- /dev/null +++ b/internal/playback/session_command_test.go @@ -0,0 +1,217 @@ +package playback + +import ( + "errors" + "testing" + "time" +) + +func validCommandSession() SessionConfig { + return SessionConfig{ + Video: FeedConfig{Domain: "/video", UUID: "video", Active: true}, + Audio: FeedConfig{Domain: "/audio", UUID: "audio", Active: true}, + SyncRequested: true, + Retry: RetryPolicy{ + MaxAttempts: 3, + InitialDelay: time.Millisecond, + MaxDelay: time.Second, + }, + } +} + +func TestApplySessionCommand(t *testing.T) { + base := validCommandSession() + newVideo := FeedConfig{Domain: "/new-video", UUID: "new-video", Active: false} + newAudio := FeedConfig{Domain: "/new-audio", UUID: "new-audio", Active: true} + + tests := []struct { + name string + current SessionConfig + command SessionCommand + want SessionConfig + }{ + { + name: "set video replaces complete config", + current: base, + command: SessionCommand{Kind: CommandSetVideo, Config: newVideo}, + want: func() SessionConfig { c := base; c.Video = newVideo; return c }(), + }, + { + name: "set audio replaces complete config", + current: base, + command: SessionCommand{Kind: CommandSetAudio, Config: newAudio}, + want: func() SessionConfig { c := base; c.Audio = newAudio; return c }(), + }, + { + name: "stop video disables sync", + current: base, + command: SessionCommand{Kind: CommandStopVideo}, + want: func() SessionConfig { c := base; c.Video.Active = false; c.SyncRequested = false; return c }(), + }, + { + name: "stop audio disables sync", + current: base, + command: SessionCommand{Kind: CommandStopAudio}, + want: func() SessionConfig { c := base; c.Audio.Active = false; c.SyncRequested = false; return c }(), + }, + { + name: "stop all preserves sync request", + current: base, + command: SessionCommand{Kind: CommandStopAll}, + want: func() SessionConfig { c := base; c.Video.Active = false; c.Audio.Active = false; return c }(), + }, + { + name: "resume video", + current: func() SessionConfig { c := base; c.Video.Active = false; return c }(), + command: SessionCommand{Kind: CommandResumeVideo}, + want: base, + }, + { + name: "resume audio", + current: func() SessionConfig { c := base; c.Audio.Active = false; return c }(), + command: SessionCommand{Kind: CommandResumeAudio}, + want: base, + }, + { + name: "resume all activates only configured feeds", + current: func() SessionConfig { c := base; c.Video.Active = false; c.Audio = FeedConfig{}; return c }(), + command: SessionCommand{Kind: CommandResumeAll}, + want: func() SessionConfig { c := base; c.Audio = FeedConfig{}; return c }(), + }, + { + name: "remove video clears config and disables sync", + current: base, + command: SessionCommand{Kind: CommandRemoveVideo}, + want: func() SessionConfig { c := base; c.Video = FeedConfig{}; c.SyncRequested = false; return c }(), + }, + { + name: "remove audio clears config and disables sync", + current: base, + command: SessionCommand{Kind: CommandRemoveAudio}, + want: func() SessionConfig { c := base; c.Audio = FeedConfig{}; c.SyncRequested = false; return c }(), + }, + { + name: "enable sync without feeds records request", + current: func() SessionConfig { + c := base + c.Video = FeedConfig{} + c.Audio = FeedConfig{} + c.SyncRequested = false + return c + }(), + command: SessionCommand{Kind: CommandEnableSync}, + want: func() SessionConfig { + c := base + c.Video = FeedConfig{} + c.Audio = FeedConfig{} + c.SyncRequested = true + return c + }(), + }, + { + name: "disable sync", + current: base, + command: SessionCommand{Kind: CommandDisableSync}, + want: func() SessionConfig { c := base; c.SyncRequested = false; return c }(), + }, + { + name: "set complete session atomically and preserve retry", + current: base, + command: SessionCommand{ + Kind: CommandSetSession, + Session: SessionConfig{ + Video: newVideo, + Audio: newAudio, + SyncRequested: false, + Retry: RetryPolicy{ + MaxAttempts: 99, + }, + }, + }, + want: SessionConfig{ + Video: newVideo, + Audio: newAudio, + SyncRequested: false, + Retry: base.Retry, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ApplySessionCommand(tt.current, tt.command) + if err != nil { + t.Fatalf("ApplySessionCommand() error = %v", err) + } + if got != tt.want { + t.Fatalf("ApplySessionCommand() = %#v, want %#v", got, tt.want) + } + if got.Retry != tt.current.Retry { + t.Fatalf("retry changed from %#v to %#v", tt.current.Retry, got.Retry) + } + }) + } +} + +func TestApplySessionCommandFailurePreservesState(t *testing.T) { + base := validCommandSession() + tests := []struct { + name string + current SessionConfig + command SessionCommand + wantErr error + }{ + {"set empty video", base, SessionCommand{Kind: CommandSetVideo}, ErrVideoNotConfigured}, + {"set empty audio", base, SessionCommand{Kind: CommandSetAudio}, ErrAudioNotConfigured}, + {"invalid video", base, SessionCommand{Kind: CommandSetVideo, Config: FeedConfig{UUID: "video"}}, ErrFeedDomainRequired}, + {"invalid audio", base, SessionCommand{Kind: CommandSetAudio, Config: FeedConfig{UUID: "audio"}}, ErrFeedDomainRequired}, + { + "resume missing video", + func() SessionConfig { c := base; c.Video = FeedConfig{}; return c }(), + SessionCommand{Kind: CommandResumeVideo}, + ErrVideoNotConfigured, + }, + { + "resume missing audio", + func() SessionConfig { c := base; c.Audio = FeedConfig{}; return c }(), + SessionCommand{Kind: CommandResumeAudio}, + ErrAudioNotConfigured, + }, + {"unknown command", base, SessionCommand{Kind: 255}, ErrUnknownSessionCommand}, + { + "invalid complete session", + base, + SessionCommand{ + Kind: CommandSetSession, + Session: SessionConfig{ + Video: FeedConfig{UUID: "video", Active: true}, + }, + }, + ErrFeedDomainRequired, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ApplySessionCommand(tt.current, tt.command) + if !errors.Is(err, tt.wantErr) { + t.Fatalf("ApplySessionCommand() error = %v, want %v", err, tt.wantErr) + } + if got != tt.current { + t.Fatalf("failed command changed state from %#v to %#v", tt.current, got) + } + }) + } +} + +func TestApplySessionCommandRejectsInvalidResult(t *testing.T) { + current := validCommandSession() + current.Retry = RetryPolicy{} + got, err := ApplySessionCommand(current, SessionCommand{Kind: CommandDisableSync}) + if !errors.Is(err, ErrInvalidRetryDelay) { + t.Fatalf("ApplySessionCommand() error = %v, want %v", err, ErrInvalidRetryDelay) + } + if got != current { + t.Fatalf("failed command changed state from %#v to %#v", current, got) + } +} diff --git a/internal/playback/session_controller.go b/internal/playback/session_controller.go new file mode 100644 index 0000000..85fb2b4 --- /dev/null +++ b/internal/playback/session_controller.go @@ -0,0 +1,348 @@ +package playback + +import ( + "context" + "errors" + "fmt" + "sync" +) + +type VideoSlotRunner interface { + Run( + context.Context, + FeedConfig, + <-chan FeedConfig, + ) error +} + +type AudioSlotRunner interface { + Run( + context.Context, + FeedConfig, + <-chan FeedConfig, + ) error +} + +type SyncSlotRunner interface { + Run( + context.Context, + SyncPairConfig, + <-chan SyncPairConfig, + ) error +} + +var _ VideoSlotRunner = (*VideoSlot)(nil) +var _ AudioSlotRunner = (*AudioSlot)(nil) +var _ SyncSlotRunner = (*SyncSlot)(nil) + +var ( + ErrVideoSlotRequired = errors.New("video slot is required") + ErrAudioSlotRequired = errors.New("audio slot is required") + ErrSyncSlotRequired = errors.New("sync slot is required") +) + +type SessionSnapshot struct { + Desired SessionConfig + Plan SessionPlan + Generation uint64 +} + +type SessionController struct { + videoSlot VideoSlotRunner + audioSlot AudioSlotRunner + syncSlot SyncSlotRunner + canSync SyncPredicate + + mu sync.RWMutex + snapshot SessionSnapshot + hasSnapshot bool +} + +func NewSessionController( + videoSlot VideoSlotRunner, + audioSlot AudioSlotRunner, + syncSlot SyncSlotRunner, + canSync SyncPredicate, +) (*SessionController, error) { + if videoSlot == nil { + return nil, ErrVideoSlotRequired + } + if audioSlot == nil { + return nil, ErrAudioSlotRequired + } + if syncSlot == nil { + return nil, ErrSyncSlotRequired + } + return &SessionController{ + videoSlot: videoSlot, + audioSlot: audioSlot, + syncSlot: syncSlot, + canSync: canSync, + }, nil +} + +type sessionRuntime struct { + topology SessionTopology + cancel context.CancelFunc + done chan struct{} + result error + + videoCommands chan FeedConfig + audioCommands chan FeedConfig + syncCommands chan SyncPairConfig +} + +var ( + ErrSessionRuntimeStopped = errors.New( + "session slot runtime stopped unexpectedly", + ) +) + +func (c *SessionController) Run( + ctx context.Context, + initial SessionConfig, + commands <-chan SessionCommand, +) error { + plan, err := BuildSessionPlan(initial, c.canSync) + if err != nil { + return fmt.Errorf("build initial session plan: %w", err) + } + + desired := initial + generation := uint64(1) + runtime := c.startSessionRuntime(ctx, plan, generation) + c.publish(SessionSnapshot{ + Desired: initial, + Plan: plan, + Generation: generation, + }) + + for { + var runtimeDone <-chan struct{} + if runtime != nil { + runtimeDone = runtime.done + } + + select { + case <-ctx.Done(): + stopSessionRuntime(runtime) + return ctx.Err() + + case <-runtimeDone: + return unexpectedSessionRuntimeError(runtime.result) + + case command, ok := <-commands: + if !ok { + stopSessionRuntime(runtime) + return nil + } + + nextDesired, err := ApplySessionCommand(desired, command) + if err != nil { + // Invalid commands must not disturb the current valid runtime. + continue + } + nextPlan, err := BuildSessionPlan(nextDesired, c.canSync) + if err != nil { + continue + } + + nextGeneration := generation + if plan.Topology != nextPlan.Topology { + nextGeneration++ + } + nextRuntime, err := c.reconcileSessionRuntime( + ctx, + runtime, + plan, + nextPlan, + nextGeneration, + ) + if err != nil { + stopSessionRuntime(runtime) + if ctx.Err() != nil { + return ctx.Err() + } + return err + } + + generation = nextGeneration + desired = nextDesired + plan = nextPlan + runtime = nextRuntime + c.publish(SessionSnapshot{ + Desired: desired, + Plan: plan, + Generation: generation, + }) + } + } +} + +func (c *SessionController) startSessionRuntime( + ctx context.Context, + plan SessionPlan, + generation uint64, +) *sessionRuntime { + if plan.Topology == TopologyIdle { + return &sessionRuntime{topology: TopologyIdle} + } + + runtimeCtx, cancel := context.WithCancel(withGeneration(ctx, generation)) + runtime := &sessionRuntime{ + topology: plan.Topology, + cancel: cancel, + done: make(chan struct{}), + } + + switch plan.Topology { + case TopologyIndependent: + runtime.videoCommands = make(chan FeedConfig) + runtime.audioCommands = make(chan FeedConfig) + results := make(chan error, 2) + + go func() { + results <- c.videoSlot.Run( + runtimeCtx, + plan.Video, + runtime.videoCommands, + ) + }() + go func() { + results <- c.audioSlot.Run( + runtimeCtx, + plan.Audio, + runtime.audioCommands, + ) + }() + go func() { + first := <-results + cancel() + second := <-results + runtime.result = errors.Join(first, second) + close(runtime.done) + }() + + case TopologySynchronized: + runtime.syncCommands = make(chan SyncPairConfig) + go func() { + runtime.result = c.syncSlot.Run( + runtimeCtx, + plan.Sync, + runtime.syncCommands, + ) + close(runtime.done) + }() + } + + return runtime +} + +func stopSessionRuntime(runtime *sessionRuntime) { + if runtime == nil || runtime.done == nil { + return + } + runtime.cancel() + <-runtime.done +} + +func unexpectedSessionRuntimeError(err error) error { + if err == nil { + return ErrSessionRuntimeStopped + } + return fmt.Errorf("%w: %v", ErrSessionRuntimeStopped, err) +} + +func (c *SessionController) reconcileSessionRuntime( + ctx context.Context, + runtime *sessionRuntime, + current SessionPlan, + next SessionPlan, + nextGeneration uint64, +) (*sessionRuntime, error) { + if current.Topology != next.Topology { + stopSessionRuntime(runtime) + if err := ctx.Err(); err != nil { + return runtime, err + } + return c.startSessionRuntime(ctx, next, nextGeneration), nil + } + + switch next.Topology { + case TopologyIndependent: + if current.Video != next.Video { + if !sendFeedConfig(ctx, runtime.done, runtime.videoCommands, next.Video) { + return runtime, sessionRuntimeSendError(ctx, runtime) + } + } + if current.Audio != next.Audio { + if !sendFeedConfig(ctx, runtime.done, runtime.audioCommands, next.Audio) { + return runtime, sessionRuntimeSendError(ctx, runtime) + } + } + + case TopologySynchronized: + if current.Sync != next.Sync { + if !sendSyncPairConfig(ctx, runtime.done, runtime.syncCommands, next.Sync) { + return runtime, sessionRuntimeSendError(ctx, runtime) + } + } + } + + return runtime, nil +} + +func sendFeedConfig( + ctx context.Context, + done <-chan struct{}, + commands chan<- FeedConfig, + config FeedConfig, +) bool { + select { + case commands <- config: + return true + case <-ctx.Done(): + return false + case <-done: + return false + } +} + +func sendSyncPairConfig( + ctx context.Context, + done <-chan struct{}, + commands chan<- SyncPairConfig, + config SyncPairConfig, +) bool { + select { + case commands <- config: + return true + case <-ctx.Done(): + return false + case <-done: + return false + } +} + +func sessionRuntimeSendError(ctx context.Context, runtime *sessionRuntime) error { + if err := ctx.Err(); err != nil { + return err + } + <-runtime.done + return unexpectedSessionRuntimeError(runtime.result) +} + +func (c *SessionController) Snapshot() (SessionSnapshot, bool) { + c.mu.RLock() + snapshot := c.snapshot + ok := c.hasSnapshot + c.mu.RUnlock() + return snapshot, ok +} + +func (c *SessionController) publish(snapshot SessionSnapshot) { + c.mu.Lock() + c.snapshot = snapshot + c.hasSnapshot = true + c.mu.Unlock() +} diff --git a/internal/playback/session_controller_test.go b/internal/playback/session_controller_test.go new file mode 100644 index 0000000..f0468fc --- /dev/null +++ b/internal/playback/session_controller_test.go @@ -0,0 +1,468 @@ +package playback + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +type stubVideoSlot struct{} + +func (stubVideoSlot) Run(context.Context, FeedConfig, <-chan FeedConfig) error { + return nil +} + +type stubAudioSlot struct{} + +func (stubAudioSlot) Run(context.Context, FeedConfig, <-chan FeedConfig) error { + return nil +} + +type stubSyncSlot struct{} + +func (stubSyncSlot) Run(context.Context, SyncPairConfig, <-chan SyncPairConfig) error { + return nil +} + +func TestNewSessionControllerValidatesSlots(t *testing.T) { + video := stubVideoSlot{} + audio := stubAudioSlot{} + sync := stubSyncSlot{} + tests := []struct { + name string + video VideoSlotRunner + audio AudioSlotRunner + sync SyncSlotRunner + want error + }{ + {"missing video", nil, audio, sync, ErrVideoSlotRequired}, + {"missing audio", video, nil, sync, ErrAudioSlotRequired}, + {"missing sync", video, audio, nil, ErrSyncSlotRequired}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + controller, err := NewSessionController(tt.video, tt.audio, tt.sync, nil) + if controller != nil { + t.Fatalf("NewSessionController() controller = %#v, want nil", controller) + } + if !errors.Is(err, tt.want) { + t.Fatalf("NewSessionController() error = %v, want %v", err, tt.want) + } + }) + } +} + +func TestNewSessionControllerAllowsUnavailableSynchronization(t *testing.T) { + video := stubVideoSlot{} + audio := stubAudioSlot{} + sync := stubSyncSlot{} + controller, err := NewSessionController(video, audio, sync, nil) + if err != nil { + t.Fatalf("NewSessionController() error = %v", err) + } + if controller == nil { + t.Fatal("NewSessionController() controller is nil") + } + if controller.videoSlot != video || controller.audioSlot != audio || controller.syncSlot != sync { + t.Fatalf("NewSessionController() = %#v", controller) + } + if controller.canSync != nil { + t.Fatal("nil sync predicate was not preserved") + } +} + +func TestNewSessionControllerStoresSyncPredicate(t *testing.T) { + predicate := func(FeedConfig, FeedConfig) bool { return true } + controller, err := NewSessionController( + stubVideoSlot{}, stubAudioSlot{}, stubSyncSlot{}, predicate, + ) + if err != nil { + t.Fatalf("NewSessionController() error = %v", err) + } + if controller.canSync == nil || !controller.canSync(FeedConfig{}, FeedConfig{}) { + t.Fatal("sync predicate was not stored") + } +} + +type controllerEvent struct { + unit Unit + action string + generation uint64 + feed FeedConfig + pair SyncPairConfig +} + +type recordingVideoSlot struct{ events chan<- controllerEvent } + +func (s recordingVideoSlot) Run( + ctx context.Context, + initial FeedConfig, + commands <-chan FeedConfig, +) error { + s.events <- controllerEvent{ + unit: UnitVideo, action: "start", + generation: generationFromContext(ctx), feed: initial, + } + for { + select { + case config := <-commands: + s.events <- controllerEvent{unit: UnitVideo, action: "command", feed: config} + case <-ctx.Done(): + s.events <- controllerEvent{unit: UnitVideo, action: "stop"} + return ctx.Err() + } + } +} + +type recordingAudioSlot struct{ events chan<- controllerEvent } + +func (s recordingAudioSlot) Run( + ctx context.Context, + initial FeedConfig, + commands <-chan FeedConfig, +) error { + s.events <- controllerEvent{ + unit: UnitAudio, action: "start", + generation: generationFromContext(ctx), feed: initial, + } + for { + select { + case config := <-commands: + s.events <- controllerEvent{unit: UnitAudio, action: "command", feed: config} + case <-ctx.Done(): + s.events <- controllerEvent{unit: UnitAudio, action: "stop"} + return ctx.Err() + } + } +} + +type recordingSyncSlot struct{ events chan<- controllerEvent } + +func (s recordingSyncSlot) Run( + ctx context.Context, + initial SyncPairConfig, + commands <-chan SyncPairConfig, +) error { + s.events <- controllerEvent{ + unit: UnitSync, action: "start", + generation: generationFromContext(ctx), pair: initial, + } + for { + select { + case config := <-commands: + s.events <- controllerEvent{unit: UnitSync, action: "command", pair: config} + case <-ctx.Done(): + s.events <- controllerEvent{unit: UnitSync, action: "stop"} + return ctx.Err() + } + } +} + +func newRecordingController(t *testing.T, events chan<- controllerEvent) *SessionController { + t.Helper() + controller, err := NewSessionController( + recordingVideoSlot{events}, + recordingAudioSlot{events}, + recordingSyncSlot{events}, + func(FeedConfig, FeedConfig) bool { return true }, + ) + if err != nil { + t.Fatal(err) + } + return controller +} + +func receiveControllerEvent(t *testing.T, events <-chan controllerEvent) controllerEvent { + t.Helper() + select { + case event := <-events: + return event + case <-time.After(time.Second): + t.Fatal("controller event timed out") + return controllerEvent{} + } +} + +func receiveIndependentStarts(t *testing.T, events <-chan controllerEvent) { + t.Helper() + seen := map[Unit]bool{} + for len(seen) < 2 { + event := receiveControllerEvent(t, events) + if event.action != "start" || (event.unit != UnitVideo && event.unit != UnitAudio) { + t.Fatalf("unexpected initial event: %+v", event) + } + seen[event.unit] = true + } +} + +func waitControllerSnapshot( + t *testing.T, + controller *SessionController, + match func(SessionSnapshot) bool, +) SessionSnapshot { + t.Helper() + deadline := time.Now().Add(time.Second) + for { + if snapshot, ok := controller.Snapshot(); ok && match(snapshot) { + return snapshot + } + if time.Now().After(deadline) { + snapshot, ok := controller.Snapshot() + t.Fatalf("snapshot timed out: %#v, available=%t", snapshot, ok) + } + time.Sleep(time.Millisecond) + } +} + +func TestSessionControllerUpdatesOnlyChangedIndependentSlot(t *testing.T) { + events := make(chan controllerEvent, 32) + controller := newRecordingController(t, events) + initial := validCommandSession() + initial.SyncRequested = false + commands := make(chan SessionCommand) + done := make(chan error, 1) + go func() { done <- controller.Run(context.Background(), initial, commands) }() + receiveIndependentStarts(t, events) + + want := FeedConfig{Domain: "/new-video", UUID: "new-video", Active: true} + commands <- SessionCommand{Kind: CommandSetVideo, Config: want} + event := receiveControllerEvent(t, events) + if event.unit != UnitVideo || event.action != "command" || event.feed != want { + t.Fatalf("replacement event = %+v", event) + } + select { + case event := <-events: + t.Fatalf("unchanged audio slot was disturbed: %+v", event) + case <-time.After(20 * time.Millisecond): + } + + close(commands) + select { + case err := <-done: + if err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + case <-time.After(time.Second): + t.Fatal("Run() did not stop") + } +} + +func TestSessionControllerReplacesSynchronizedPairWithOneCommand(t *testing.T) { + events := make(chan controllerEvent, 32) + controller := newRecordingController(t, events) + initial := validCommandSession() + commands := make(chan SessionCommand) + done := make(chan error, 1) + go func() { done <- controller.Run(context.Background(), initial, commands) }() + start := receiveControllerEvent(t, events) + if start.unit != UnitSync || start.action != "start" { + t.Fatalf("initial event = %+v, want sync start", start) + } + + next := initial + next.Video = FeedConfig{Domain: "/next", UUID: "next-video", Active: true} + next.Audio = FeedConfig{Domain: "/next", UUID: "next-audio", Active: true} + commands <- SessionCommand{Kind: CommandSetSession, Session: next} + event := receiveControllerEvent(t, events) + wantPair := SyncPairConfig{Video: next.Video, Audio: next.Audio} + if event.unit != UnitSync || event.action != "command" || event.pair != wantPair { + t.Fatalf("replacement event = %+v, want one sync command for %#v", event, wantPair) + } + select { + case event := <-events: + t.Fatalf("atomic replacement emitted an extra event: %+v", event) + case <-time.After(20 * time.Millisecond): + } + + close(commands) + if err := <-done; err != nil { + t.Fatalf("Run() error = %v", err) + } +} + +func TestSessionControllerStopsIndependentSlotsBeforeStartingSync(t *testing.T) { + events := make(chan controllerEvent, 32) + controller := newRecordingController(t, events) + initial := validCommandSession() + initial.SyncRequested = false + commands := make(chan SessionCommand) + done := make(chan error, 1) + go func() { done <- controller.Run(context.Background(), initial, commands) }() + receiveIndependentStarts(t, events) + + commands <- SessionCommand{Kind: CommandEnableSync} + stopped := map[Unit]bool{} + for { + event := receiveControllerEvent(t, events) + if event.unit == UnitSync && event.action == "start" { + if !stopped[UnitVideo] || !stopped[UnitAudio] { + t.Fatalf("sync started before both independent slots stopped: %v", stopped) + } + if event.generation != 2 { + t.Fatalf("sync runtime generation = %d, want 2", event.generation) + } + break + } + if event.action != "stop" || (event.unit != UnitVideo && event.unit != UnitAudio) { + t.Fatalf("unexpected transition event: %+v", event) + } + stopped[event.unit] = true + } + + close(commands) + select { + case err := <-done: + if err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + case <-time.After(time.Second): + t.Fatal("Run() did not stop") + } +} + +func TestSessionControllerCancellationStopsAndJoinsRuntime(t *testing.T) { + events := make(chan controllerEvent, 32) + controller := newRecordingController(t, events) + initial := validCommandSession() + initial.SyncRequested = false + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- controller.Run(ctx, initial, make(chan SessionCommand)) }() + receiveIndependentStarts(t, events) + cancel() + + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("Run() error = %v, want context.Canceled", err) + } + case <-time.After(time.Second): + t.Fatal("Run() did not stop after cancellation") + } +} + +func TestSessionControllerSnapshotTracksDesiredPlanAndGeneration(t *testing.T) { + events := make(chan controllerEvent, 64) + controller := newRecordingController(t, events) + if snapshot, ok := controller.Snapshot(); ok { + t.Fatalf("Snapshot() before Run = %#v, true; want unavailable", snapshot) + } + + initial := validCommandSession() + initial.SyncRequested = false + commands := make(chan SessionCommand) + done := make(chan error, 1) + go func() { done <- controller.Run(context.Background(), initial, commands) }() + receiveIndependentStarts(t, events) + + snapshot := waitControllerSnapshot(t, controller, func(snapshot SessionSnapshot) bool { + return snapshot.Desired == initial + }) + if snapshot.Generation != 1 || snapshot.Plan.Topology != TopologyIndependent { + t.Fatalf("initial snapshot = %#v", snapshot) + } + + newVideo := FeedConfig{Domain: "/new-video", UUID: "new-video", Active: true} + commands <- SessionCommand{Kind: CommandSetVideo, Config: newVideo} + receiveControllerEvent(t, events) + snapshot = waitControllerSnapshot(t, controller, func(snapshot SessionSnapshot) bool { + return snapshot.Desired.Video == newVideo + }) + if snapshot.Generation != 1 || snapshot.Plan.Topology != TopologyIndependent { + t.Fatalf("same-topology snapshot = %#v", snapshot) + } + + commands <- SessionCommand{Kind: CommandEnableSync} + for { + if event := receiveControllerEvent(t, events); event.unit == UnitSync && event.action == "start" { + break + } + } + snapshot = waitControllerSnapshot(t, controller, func(snapshot SessionSnapshot) bool { + return snapshot.Plan.Topology == TopologySynchronized + }) + if snapshot.Generation != 2 || !snapshot.Desired.SyncRequested { + t.Fatalf("sync snapshot = %#v", snapshot) + } + + commands <- SessionCommand{Kind: CommandDisableSync} + for { + event := receiveControllerEvent(t, events) + if event.action == "start" && (event.unit == UnitVideo || event.unit == UnitAudio) { + break + } + } + snapshot = waitControllerSnapshot(t, controller, func(snapshot SessionSnapshot) bool { + return snapshot.Plan.Topology == TopologyIndependent && !snapshot.Desired.SyncRequested + }) + if snapshot.Generation != 3 { + t.Fatalf("independent snapshot generation = %d, want 3", snapshot.Generation) + } + + close(commands) + if err := <-done; err != nil { + t.Fatalf("Run() error = %v", err) + } +} + +func TestSessionControllerSnapshotRetainsUnavailableSyncRequest(t *testing.T) { + events := make(chan controllerEvent, 32) + controller, err := NewSessionController( + recordingVideoSlot{events}, + recordingAudioSlot{events}, + recordingSyncSlot{events}, + nil, + ) + if err != nil { + t.Fatal(err) + } + initial := validCommandSession() + initial.SyncRequested = false + commands := make(chan SessionCommand) + done := make(chan error, 1) + go func() { done <- controller.Run(context.Background(), initial, commands) }() + receiveIndependentStarts(t, events) + commands <- SessionCommand{Kind: CommandEnableSync} + + snapshot := waitControllerSnapshot(t, controller, func(snapshot SessionSnapshot) bool { + return snapshot.Desired.SyncRequested + }) + if snapshot.Plan.Topology != TopologyIndependent || snapshot.Generation != 1 { + t.Fatalf("unsupported-sync snapshot = %#v", snapshot) + } + close(commands) + if err := <-done; err != nil { + t.Fatal(err) + } +} + +func TestSessionControllerSnapshotConcurrentReads(t *testing.T) { + events := make(chan controllerEvent, 32) + controller := newRecordingController(t, events) + initial := validCommandSession() + initial.SyncRequested = false + commands := make(chan SessionCommand) + done := make(chan error, 1) + go func() { done <- controller.Run(context.Background(), initial, commands) }() + receiveIndependentStarts(t, events) + waitControllerSnapshot(t, controller, func(SessionSnapshot) bool { return true }) + + var readers sync.WaitGroup + for range 8 { + readers.Add(1) + go func() { + defer readers.Done() + for range 1_000 { + controller.Snapshot() + } + }() + } + readers.Wait() + close(commands) + if err := <-done; err != nil { + t.Fatal(err) + } +} diff --git a/internal/playback/session_plan.go b/internal/playback/session_plan.go new file mode 100644 index 0000000..9b799de --- /dev/null +++ b/internal/playback/session_plan.go @@ -0,0 +1,89 @@ +package playback + +import "fmt" + +type SessionTopology uint8 + +const ( + TopologyIdle SessionTopology = iota + TopologyIndependent + TopologySynchronized +) + +func (t SessionTopology) String() string { + switch t { + case TopologyIdle: + return "idle" + case TopologyIndependent: + return "independent" + case TopologySynchronized: + return "synchronized" + default: + return fmt.Sprintf("SessionTopology(%d)", uint8(t)) + } +} + +type SyncPredicate func(video, audio FeedConfig) bool + +type SessionPlan struct { + Topology SessionTopology + + // Desired states for independent slots. + Video FeedConfig + Audio FeedConfig + + // Desired state for the synchronized slot. + Sync SyncPairConfig +} + +func BuildSessionPlan( + desired SessionConfig, + canSync SyncPredicate, +) (SessionPlan, error) { + if err := desired.Validate(); err != nil { + return SessionPlan{}, fmt.Errorf("validate desired session: %w", err) + } + + if desired.SyncRequested && + desired.Video.Active && + desired.Audio.Active && + canSync != nil && + canSync(desired.Video, desired.Audio) { + return SessionPlan{ + Topology: TopologySynchronized, + Video: stoppedFeed(desired.Video), + Audio: stoppedFeed(desired.Audio), + Sync: SyncPairConfig{ + Video: desired.Video, + Audio: desired.Audio, + }, + }, nil + } + + if desired.Video.Active || desired.Audio.Active { + return SessionPlan{ + Topology: TopologyIndependent, + Video: desired.Video, + Audio: desired.Audio, + Sync: SyncPairConfig{ + Video: stoppedFeed(desired.Video), + Audio: stoppedFeed(desired.Audio), + }, + }, nil + } + + return SessionPlan{ + Topology: TopologyIdle, + Video: stoppedFeed(desired.Video), + Audio: stoppedFeed(desired.Audio), + Sync: SyncPairConfig{ + Video: stoppedFeed(desired.Video), + Audio: stoppedFeed(desired.Audio), + }, + }, nil +} + +func stoppedFeed(config FeedConfig) FeedConfig { + config.Active = false + return config +} diff --git a/internal/playback/session_plan_test.go b/internal/playback/session_plan_test.go new file mode 100644 index 0000000..4a792e2 --- /dev/null +++ b/internal/playback/session_plan_test.go @@ -0,0 +1,147 @@ +package playback + +import ( + "errors" + "testing" +) + +func TestSessionTopologyString(t *testing.T) { + tests := []struct { + topology SessionTopology + want string + }{ + {TopologyIdle, "idle"}, + {TopologyIndependent, "independent"}, + {TopologySynchronized, "synchronized"}, + {SessionTopology(99), "SessionTopology(99)"}, + } + for _, tt := range tests { + if got := tt.topology.String(); got != tt.want { + t.Errorf("%d.String() = %q, want %q", tt.topology, got, tt.want) + } + } +} + +func TestBuildSessionPlan(t *testing.T) { + base := validCommandSession() + stoppedVideo := stoppedFeed(base.Video) + stoppedAudio := stoppedFeed(base.Audio) + independent := func(video, audio FeedConfig) SessionPlan { + return SessionPlan{ + Topology: TopologyIndependent, + Video: video, + Audio: audio, + Sync: SyncPairConfig{Video: stoppedFeed(video), Audio: stoppedFeed(audio)}, + } + } + idle := func(video, audio FeedConfig) SessionPlan { + return SessionPlan{ + Topology: TopologyIdle, + Video: stoppedFeed(video), + Audio: stoppedFeed(audio), + Sync: SyncPairConfig{Video: stoppedFeed(video), Audio: stoppedFeed(audio)}, + } + } + + tests := []struct { + name string + desired SessionConfig + canSync SyncPredicate + want SessionPlan + }{ + { + name: "both active with sync disabled stay independent", + desired: func() SessionConfig { c := base; c.SyncRequested = false; return c }(), + canSync: func(FeedConfig, FeedConfig) bool { return true }, + want: independent(base.Video, base.Audio), + }, + { + name: "eligible requested pair is synchronized", + desired: base, + canSync: func(FeedConfig, FeedConfig) bool { return true }, + want: SessionPlan{ + Topology: TopologySynchronized, + Video: stoppedVideo, + Audio: stoppedAudio, + Sync: SyncPairConfig{Video: base.Video, Audio: base.Audio}, + }, + }, + { + name: "ineligible requested pair remains independent", + desired: base, + canSync: func(FeedConfig, FeedConfig) bool { return false }, + want: independent(base.Video, base.Audio), + }, + { + name: "nil capability remains independent", + desired: base, + want: independent(base.Video, base.Audio), + }, + { + name: "video only", + desired: func() SessionConfig { c := base; c.Audio.Active = false; return c }(), + canSync: func(FeedConfig, FeedConfig) bool { return true }, + want: independent(base.Video, stoppedAudio), + }, + { + name: "audio only", + desired: func() SessionConfig { c := base; c.Video.Active = false; return c }(), + canSync: func(FeedConfig, FeedConfig) bool { return true }, + want: independent(stoppedVideo, base.Audio), + }, + { + name: "stopped feeds are idle and retain configuration", + desired: func() SessionConfig { c := base; c.Video.Active = false; c.Audio.Active = false; return c }(), + canSync: func(FeedConfig, FeedConfig) bool { return true }, + want: idle(stoppedVideo, stoppedAudio), + }, + { + name: "empty session is idle", + desired: func() SessionConfig { c := base; c.Video = FeedConfig{}; c.Audio = FeedConfig{}; return c }(), + canSync: func(FeedConfig, FeedConfig) bool { return true }, + want: idle(FeedConfig{}, FeedConfig{}), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := BuildSessionPlan(tt.desired, tt.canSync) + if err != nil { + t.Fatalf("BuildSessionPlan() error = %v", err) + } + if got != tt.want { + t.Fatalf("BuildSessionPlan() = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestBuildSessionPlanRejectsInvalidSession(t *testing.T) { + desired := validCommandSession() + desired.Video = FeedConfig{UUID: "video", Active: true} + got, err := BuildSessionPlan(desired, nil) + if !errors.Is(err, ErrFeedDomainRequired) { + t.Fatalf("BuildSessionPlan() error = %v, want %v", err, ErrFeedDomainRequired) + } + if got != (SessionPlan{}) { + t.Fatalf("BuildSessionPlan() = %#v, want zero plan", got) + } +} + +func TestBuildSessionPlanPassesCompleteFeedsToPredicate(t *testing.T) { + desired := validCommandSession() + called := false + _, err := BuildSessionPlan(desired, func(video, audio FeedConfig) bool { + called = true + if video != desired.Video || audio != desired.Audio { + t.Fatalf("predicate feeds = %#v %#v", video, audio) + } + return true + }) + if err != nil { + t.Fatal(err) + } + if !called { + t.Fatal("sync predicate was not called") + } +} diff --git a/internal/playback/state.go b/internal/playback/state.go new file mode 100644 index 0000000..92e91cd --- /dev/null +++ b/internal/playback/state.go @@ -0,0 +1,85 @@ +package playback + +import ( + "context" + "fmt" + "time" +) + +type Unit uint8 + +const ( + UnitVideo Unit = iota + UnitAudio + UnitSync +) + +type State uint8 + +const ( + StateIdle State = iota + StateConnecting + StatePlaying + StateReconnecting + StateFailed + StateStopping +) + +type Status struct { + Unit Unit + State State + Generation uint64 + + Feed FeedConfig // Video/Audio worker + Pair SyncPairConfig // Sync worker + + Attempt int + FailedAttempts int + RetryIn time.Duration + Err error +} + +type StatusObserver func(Status) + +type generationContextKey struct{} + +func withGeneration(ctx context.Context, generation uint64) context.Context { + return context.WithValue(ctx, generationContextKey{}, generation) +} + +func generationFromContext(ctx context.Context) uint64 { + generation, _ := ctx.Value(generationContextKey{}).(uint64) + return generation +} + +func (u Unit) String() string { + switch u { + case UnitVideo: + return "video" + case UnitAudio: + return "audio" + case UnitSync: + return "sync" + default: + return fmt.Sprintf("Unit(%d)", uint8(u)) + } +} + +func (s State) String() string { + switch s { + case StateIdle: + return "idle" + case StateConnecting: + return "connecting" + case StatePlaying: + return "playing" + case StateReconnecting: + return "reconnecting" + case StateFailed: + return "failed" + case StateStopping: + return "stopping" + default: + return fmt.Sprintf("State(%d)", uint8(s)) + } +} diff --git a/internal/playback/state_test.go b/internal/playback/state_test.go new file mode 100644 index 0000000..2f4093c --- /dev/null +++ b/internal/playback/state_test.go @@ -0,0 +1,90 @@ +package playback + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestGenerationContext(t *testing.T) { + if got := generationFromContext(context.Background()); got != 0 { + t.Fatalf("background generation = %d, want 0", got) + } + ctx := withGeneration(context.Background(), 42) + if got := generationFromContext(ctx); got != 42 { + t.Fatalf("generation = %d, want 42", got) + } +} + +func TestStatusPreservesValues(t *testing.T) { + wantErr := errors.New("producer missing") + status := Status{ + Unit: UnitVideo, + State: StateReconnecting, + Attempt: 2, + FailedAttempts: 1, + RetryIn: time.Second, + Err: wantErr, + } + + if status.Unit != UnitVideo { + t.Errorf("Unit = %v, want %v", status.Unit, UnitVideo) + } + + if !errors.Is(status.Err, wantErr) { + t.Errorf("Err = %v, want %v", status.Err, wantErr) + } + + if status.State != StateReconnecting { + t.Errorf("State = %v, want %v", status.State, StateReconnecting) + } + if status.Attempt != 2 { + t.Errorf("Attempt = %d, want 2", status.Attempt) + } + if status.FailedAttempts != 1 { + t.Errorf("FailedAttempts = %d, want 1", status.FailedAttempts) + } + if status.RetryIn != time.Second { + t.Errorf("RetryIn = %s, want %s", status.RetryIn, time.Second) + } +} + +func TestUnitString(t *testing.T) { + tests := []struct { + unit Unit + want string + }{ + {unit: UnitVideo, want: "video"}, + {unit: UnitAudio, want: "audio"}, + {unit: UnitSync, want: "sync"}, + {unit: Unit(255), want: "Unit(255)"}, + } + + for _, tt := range tests { + if got := tt.unit.String(); got != tt.want { + t.Errorf("Unit(%d).String() = %q, want %q", tt.unit, got, tt.want) + } + } +} + +func TestStateString(t *testing.T) { + tests := []struct { + state State + want string + }{ + {state: StateIdle, want: "idle"}, + {state: StateConnecting, want: "connecting"}, + {state: StatePlaying, want: "playing"}, + {state: StateReconnecting, want: "reconnecting"}, + {state: StateFailed, want: "failed"}, + {state: StateStopping, want: "stopping"}, + {state: State(255), want: "State(255)"}, + } + + for _, tt := range tests { + if got := tt.state.String(); got != tt.want { + t.Errorf("State(%d).String() = %q, want %q", tt.state, got, tt.want) + } + } +} diff --git a/internal/playback/status_store.go b/internal/playback/status_store.go new file mode 100644 index 0000000..3db4254 --- /dev/null +++ b/internal/playback/status_store.go @@ -0,0 +1,65 @@ +package playback + +import "sync" + +type StatusStore struct { + mu sync.RWMutex + generation uint64 + 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), + } +} + +func (s *StatusStore) Observe(status Status) { + s.mu.Lock() + defer s.mu.Unlock() + if status.Generation < s.generation { + return + } + if status.Generation > s.generation { + clear(s.statuses) + s.generation = status.Generation + } + s.statuses[status.Unit] = status +} + +func (s *StatusStore) Snapshot(unit Unit) (Status, bool) { + s.mu.RLock() + status, ok := s.statuses[unit] + s.mu.RUnlock() + 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) + s.mu.Unlock() +} diff --git a/internal/playback/status_store_test.go b/internal/playback/status_store_test.go new file mode 100644 index 0000000..dd9a64c --- /dev/null +++ b/internal/playback/status_store_test.go @@ -0,0 +1,221 @@ +package playback + +import ( + "errors" + "sync" + "testing" +) + +func TestStatusStoreSnapshotUnknownUnit(t *testing.T) { + store := NewStatusStore() + + status, ok := store.Snapshot(UnitVideo) + if ok { + t.Fatalf("Snapshot() = %#v, true; want false", status) + } +} + +func TestStatusStoreKeepsUnitsIndependent(t *testing.T) { + store := NewStatusStore() + wantVideo := Status{ + Unit: UnitVideo, + State: StateReconnecting, + Attempt: 3, + FailedAttempts: 2, + Err: errors.New("video unavailable"), + } + wantAudio := Status{ + Unit: UnitAudio, + State: StatePlaying, + Attempt: 1, + } + + store.Observe(wantVideo) + store.Observe(wantAudio) + + if got, ok := store.Snapshot(UnitVideo); !ok || got != wantVideo { + t.Fatalf("video Snapshot() = %#v, %t; want %#v, true", got, ok, wantVideo) + } + if got, ok := store.Snapshot(UnitAudio); !ok || got != wantAudio { + t.Fatalf("audio Snapshot() = %#v, %t; want %#v, true", got, ok, wantAudio) + } +} + +func TestStatusStoreObserveReplacesLatestStatus(t *testing.T) { + store := NewStatusStore() + store.Observe(Status{Unit: UnitVideo, State: StateConnecting, Attempt: 1}) + want := Status{Unit: UnitVideo, State: StatePlaying, Attempt: 2} + store.Observe(want) + + got, ok := store.Snapshot(UnitVideo) + if !ok || got != want { + t.Fatalf("Snapshot() = %#v, %t; want %#v, true", got, ok, want) + } +} + +func TestStatusStoreClearOnlySelectedUnit(t *testing.T) { + store := NewStatusStore() + wantAudio := Status{Unit: UnitAudio, State: StatePlaying} + store.Observe(Status{Unit: UnitVideo, State: StatePlaying}) + store.Observe(wantAudio) + + store.Clear(UnitVideo) + + if status, ok := store.Snapshot(UnitVideo); ok { + t.Fatalf("video Snapshot() = %#v, true after Clear", status) + } + if got, ok := store.Snapshot(UnitAudio); !ok || got != wantAudio { + t.Fatalf("audio Snapshot() = %#v, %t; want %#v, true", got, ok, wantAudio) + } +} + +func TestStatusStoreConcurrentAccess(t *testing.T) { + store := NewStatusStore() + const iterations = 1000 + + var writers sync.WaitGroup + for _, unit := range []Unit{UnitVideo, UnitAudio, UnitSync} { + unit := unit + writers.Add(1) + go func() { + defer writers.Done() + for attempt := 1; attempt <= iterations; attempt++ { + store.Observe(Status{ + Unit: unit, + State: StatePlaying, + Attempt: attempt, + }) + store.Snapshot(unit) + } + }() + } + writers.Wait() + + for _, unit := range []Unit{UnitVideo, UnitAudio, UnitSync} { + status, ok := store.Snapshot(unit) + if !ok { + t.Fatalf("Snapshot(%v) not found", unit) + } + if status.Attempt != iterations { + t.Fatalf( + "Snapshot(%v) attempt = %d, want %d", + unit, + status.Attempt, + iterations, + ) + } + } +} + +func TestStatusStoreNewGenerationClearsPreviousUnits(t *testing.T) { + store := NewStatusStore() + store.Observe(Status{Unit: UnitVideo, State: StatePlaying, Generation: 1}) + store.Observe(Status{Unit: UnitAudio, State: StatePlaying, Generation: 1}) + want := Status{Unit: UnitSync, State: StateConnecting, Generation: 2} + store.Observe(want) + + if _, ok := store.Snapshot(UnitVideo); ok { + t.Fatal("video status survived generation change") + } + if _, ok := store.Snapshot(UnitAudio); ok { + t.Fatal("audio status survived generation change") + } + if got, ok := store.Snapshot(UnitSync); !ok || got != want { + t.Fatalf("sync Snapshot() = %#v, %t; want %#v, true", got, ok, want) + } +} + +func TestStatusStoreIgnoresOlderGeneration(t *testing.T) { + store := NewStatusStore() + want := Status{Unit: UnitSync, State: StatePlaying, Generation: 3} + store.Observe(want) + store.Observe(Status{Unit: UnitVideo, State: StateIdle, Generation: 2}) + + if _, ok := store.Snapshot(UnitVideo); ok { + t.Fatal("older video status was stored") + } + if got, ok := store.Snapshot(UnitSync); !ok || got != want { + t.Fatalf("sync Snapshot() = %#v, %t; want %#v, true", got, ok, want) + } +} + +func TestStatusStoreKeepsEqualGenerationUnitsIndependent(t *testing.T) { + store := NewStatusStore() + wantVideo := Status{Unit: UnitVideo, State: StatePlaying, Generation: 4} + wantAudio := Status{Unit: UnitAudio, State: StateReconnecting, Generation: 4} + store.Observe(wantVideo) + store.Observe(wantAudio) + + if got, ok := store.Snapshot(UnitVideo); !ok || got != wantVideo { + t.Fatalf("video Snapshot() = %#v, %t", got, ok) + } + if got, ok := store.Snapshot(UnitAudio); !ok || got != wantAudio { + 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() + } + } +} diff --git a/internal/playback/supervisor.go b/internal/playback/supervisor.go new file mode 100644 index 0000000..7f38f6d --- /dev/null +++ b/internal/playback/supervisor.go @@ -0,0 +1,87 @@ +package playback + +import ( + "context" + "time" +) + +// attemptFunc returns whether useful media was received before the attempt ended. +type attemptFunc func(context.Context) (becameStable bool, err error) +type retryDecider func(error) bool +type waitFunc func(context.Context, time.Duration) error + +func waitForRetry(ctx context.Context, delay time.Duration) error { + timer := time.NewTimer(delay) + defer timer.Stop() + + select { + case <-timer.C: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func runWithRetry( + ctx context.Context, + policy RetryPolicy, + attempt attemptFunc, + shouldRetry retryDecider, + wait waitFunc, + observer retryObserver, +) error { + failedAttempts := 0 + + for { + becameStable, err := attempt(ctx) + if err == nil { + return nil + } + if ctx.Err() != nil { + return ctx.Err() + } + + if becameStable { + failedAttempts = 0 + } + + failedAttempts++ + willRetry := shouldRetry(err) && policy.canRetry(failedAttempts) + if !willRetry { + if observer != nil { + observer(retryEvent{ + FailedAttempts: failedAttempts, + Err: err, + WillRetry: false, + }) + } + return err + } + + delay := policy.retryDelay(failedAttempts) + if observer != nil { + observer(retryEvent{ + FailedAttempts: failedAttempts, + Err: err, + RetryIn: delay, + WillRetry: true, + }) + } + + if err := wait(ctx, delay); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return err + } + } +} + +type retryEvent struct { + FailedAttempts int + Err error + RetryIn time.Duration + WillRetry bool +} + +type retryObserver func(retryEvent) diff --git a/internal/playback/supervisor_test.go b/internal/playback/supervisor_test.go new file mode 100644 index 0000000..96efcbf --- /dev/null +++ b/internal/playback/supervisor_test.go @@ -0,0 +1,425 @@ +package playback + +import ( + "context" + "errors" + "reflect" + "testing" + "time" +) + +func testRetryPolicy(maxAttempts int) RetryPolicy { + return RetryPolicy{ + MaxAttempts: maxAttempts, + InitialDelay: 500 * time.Millisecond, + MaxDelay: 10 * time.Second, + } +} + +func TestRunWithRetryFirstAttemptSucceeds(t *testing.T) { + attempts := 0 + err := runWithRetry( + context.Background(), + testRetryPolicy(3), + func(context.Context) (bool, error) { + attempts++ + return false, nil + }, + func(error) bool { + t.Fatal("shouldRetry called after successful attempt") + return false + }, + func(context.Context, time.Duration) error { + t.Fatal("wait called after successful attempt") + return nil + }, + nil, + ) + + if err != nil { + t.Fatalf("runWithRetry() error = %v, want nil", err) + } + if attempts != 1 { + t.Fatalf("attempt count = %d, want 1", attempts) + } +} + +func TestRunWithRetryFailuresThenSuccess(t *testing.T) { + attemptErr := errors.New("attempt failed") + attempts := 0 + var delays []time.Duration + + err := runWithRetry( + context.Background(), + testRetryPolicy(3), + func(context.Context) (bool, error) { + attempts++ + if attempts < 3 { + return false, attemptErr + } + return false, nil + }, + func(error) bool { return true }, + func(_ context.Context, delay time.Duration) error { + delays = append(delays, delay) + return nil + }, + nil, + ) + + if err != nil { + t.Fatalf("runWithRetry() error = %v, want nil", err) + } + if attempts != 3 { + t.Errorf("attempt count = %d, want 3", attempts) + } + wantDelays := []time.Duration{500 * time.Millisecond, time.Second} + if !reflect.DeepEqual(delays, wantDelays) { + t.Errorf("retry delays = %v, want %v", delays, wantDelays) + } +} + +func TestRunWithRetryFiniteAttemptsExhausted(t *testing.T) { + attemptErr := errors.New("attempt failed") + attempts := 0 + waits := 0 + + err := runWithRetry( + context.Background(), + testRetryPolicy(3), + func(context.Context) (bool, error) { + attempts++ + return false, attemptErr + }, + func(error) bool { return true }, + func(context.Context, time.Duration) error { + waits++ + return nil + }, + nil, + ) + + if !errors.Is(err, attemptErr) { + t.Fatalf("runWithRetry() error = %v, want %v", err, attemptErr) + } + if attempts != 3 { + t.Errorf("attempt count = %d, want 3", attempts) + } + if waits != 2 { + t.Errorf("wait count = %d, want 2", waits) + } +} + +func TestRunWithRetryUnlimitedEventuallySucceeds(t *testing.T) { + attemptErr := errors.New("attempt failed") + attempts := 0 + + err := runWithRetry( + context.Background(), + testRetryPolicy(0), + func(context.Context) (bool, error) { + attempts++ + if attempts < 20 { + return false, attemptErr + } + return false, nil + }, + func(error) bool { return true }, + func(context.Context, time.Duration) error { return nil }, + nil, + ) + + if err != nil { + t.Fatalf("runWithRetry() error = %v, want nil", err) + } + if attempts != 20 { + t.Fatalf("attempt count = %d, want 20", attempts) + } +} + +func TestRunWithRetryStopsWhenErrorIsNotRetryable(t *testing.T) { + attemptErr := errors.New("invalid configuration") + attempts := 0 + + err := runWithRetry( + context.Background(), + testRetryPolicy(0), + func(context.Context) (bool, error) { + attempts++ + return false, attemptErr + }, + func(error) bool { return false }, + func(context.Context, time.Duration) error { + t.Fatal("wait called for non-retryable error") + return nil + }, + nil, + ) + + if !errors.Is(err, attemptErr) { + t.Fatalf("runWithRetry() error = %v, want %v", err, attemptErr) + } + if attempts != 1 { + t.Fatalf("attempt count = %d, want 1", attempts) + } +} + +func TestRunWithRetryReturnsCancellationFromAttempt(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + attemptErr := errors.New("attempt failed") + + err := runWithRetry( + ctx, + testRetryPolicy(0), + func(context.Context) (bool, error) { + cancel() + return false, attemptErr + }, + func(error) bool { return true }, + func(context.Context, time.Duration) error { + t.Fatal("wait called after cancellation") + return nil + }, + nil, + ) + + if !errors.Is(err, context.Canceled) { + t.Fatalf("runWithRetry() error = %v, want context.Canceled", err) + } +} + +func TestRunWithRetryReturnsCancellationDuringBackoff(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + attemptErr := errors.New("attempt failed") + + err := runWithRetry( + ctx, + testRetryPolicy(0), + func(context.Context) (bool, error) { return false, attemptErr }, + func(error) bool { return true }, + func(ctx context.Context, _ time.Duration) error { + cancel() + return ctx.Err() + }, + nil, + ) + + if !errors.Is(err, context.Canceled) { + t.Fatalf("runWithRetry() error = %v, want context.Canceled", err) + } +} + +func TestRunWithRetryReturnsWaitError(t *testing.T) { + attemptErr := errors.New("attempt failed") + waitErr := errors.New("wait failed") + + err := runWithRetry( + context.Background(), + testRetryPolicy(0), + func(context.Context) (bool, error) { return false, attemptErr }, + func(error) bool { return true }, + func(context.Context, time.Duration) error { return waitErr }, + nil, + ) + + if !errors.Is(err, waitErr) { + t.Fatalf("runWithRetry() error = %v, want %v", err, waitErr) + } +} + +func TestWaitForRetryReturnsCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := waitForRetry(ctx, time.Hour) + if !errors.Is(err, context.Canceled) { + t.Fatalf("waitForRetry() error = %v, want context.Canceled", err) + } +} + +func TestRetryObserverReportsFailuresBeforeSuccess(t *testing.T) { + attemptErr := errors.New("attempt failed") + attempts := 0 + var events []retryEvent + + err := runWithRetry( + context.Background(), + testRetryPolicy(3), + func(context.Context) (bool, error) { + attempts++ + if attempts < 3 { + return false, attemptErr + } + return false, nil + }, + func(error) bool { return true }, + func(context.Context, time.Duration) error { return nil }, + func(event retryEvent) { + events = append(events, event) + }, + ) + + if err != nil { + t.Fatalf("runWithRetry() error = %v, want nil", err) + } + if len(events) != 2 { + t.Fatalf("event count = %d, want 2", len(events)) + } + + wantDelays := []time.Duration{500 * time.Millisecond, time.Second} + for i, event := range events { + wantAttempts := i + 1 + if event.FailedAttempts != wantAttempts { + t.Errorf("event %d failed attempts = %d, want %d", i, event.FailedAttempts, wantAttempts) + } + if !errors.Is(event.Err, attemptErr) { + t.Errorf("event %d error = %v, want %v", i, event.Err, attemptErr) + } + if event.RetryIn != wantDelays[i] { + t.Errorf("event %d retry delay = %s, want %s", i, event.RetryIn, wantDelays[i]) + } + if !event.WillRetry { + t.Errorf("event %d WillRetry = false, want true", i) + } + } +} + +func TestRetryObserverReportsExhaustion(t *testing.T) { + attemptErr := errors.New("attempt failed") + var events []retryEvent + + err := runWithRetry( + context.Background(), + testRetryPolicy(2), + func(context.Context) (bool, error) { return false, attemptErr }, + func(error) bool { return true }, + func(context.Context, time.Duration) error { return nil }, + func(event retryEvent) { + events = append(events, event) + }, + ) + + if !errors.Is(err, attemptErr) { + t.Fatalf("runWithRetry() error = %v, want %v", err, attemptErr) + } + if len(events) != 2 { + t.Fatalf("event count = %d, want 2", len(events)) + } + if !events[0].WillRetry || events[0].RetryIn != 500*time.Millisecond { + t.Errorf("first event = %+v, want retry after 500ms", events[0]) + } + final := events[1] + if final.FailedAttempts != 2 { + t.Errorf("final failed attempts = %d, want 2", final.FailedAttempts) + } + if final.WillRetry { + t.Error("final WillRetry = true, want false") + } + if final.RetryIn != 0 { + t.Errorf("final retry delay = %s, want 0", final.RetryIn) + } + if !errors.Is(final.Err, attemptErr) { + t.Errorf("final error = %v, want %v", final.Err, attemptErr) + } +} + +func TestRetryObserverNotCalledOnImmediateSuccess(t *testing.T) { + observerCalls := 0 + err := runWithRetry( + context.Background(), + testRetryPolicy(3), + func(context.Context) (bool, error) { return false, nil }, + func(error) bool { return true }, + func(context.Context, time.Duration) error { return nil }, + func(retryEvent) { observerCalls++ }, + ) + + if err != nil { + t.Fatalf("runWithRetry() error = %v, want nil", err) + } + if observerCalls != 0 { + t.Fatalf("observer call count = %d, want 0", observerCalls) + } +} + +func TestRetryObserverNotCalledWhenAttemptCancelsContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + observerCalls := 0 + + err := runWithRetry( + ctx, + testRetryPolicy(0), + func(context.Context) (bool, error) { + cancel() + return false, errors.New("attempt interrupted") + }, + func(error) bool { return true }, + func(context.Context, time.Duration) error { return nil }, + func(retryEvent) { observerCalls++ }, + ) + + if !errors.Is(err, context.Canceled) { + t.Fatalf("runWithRetry() error = %v, want context.Canceled", err) + } + if observerCalls != 0 { + t.Fatalf("observer call count = %d, want 0", observerCalls) + } +} + +func TestRunWithRetryResetsFailuresAfterStableAttempt(t *testing.T) { + attemptErr := errors.New("attempt failed") + attempts := 0 + var events []retryEvent + + err := runWithRetry( + context.Background(), + testRetryPolicy(2), + func(context.Context) (bool, error) { + attempts++ + switch attempts { + case 1: + return false, attemptErr + case 2: + return true, attemptErr + default: + return false, attemptErr + } + }, + func(error) bool { return true }, + func(context.Context, time.Duration) error { return nil }, + func(event retryEvent) { + events = append(events, event) + }, + ) + + if !errors.Is(err, attemptErr) { + t.Fatalf("runWithRetry() error = %v, want %v", err, attemptErr) + } + if attempts != 3 { + t.Fatalf("attempt count = %d, want 3", attempts) + } + if len(events) != 3 { + t.Fatalf("event count = %d, want 3", len(events)) + } + + wantFailedAttempts := []int{1, 1, 2} + wantWillRetry := []bool{true, true, false} + for i, event := range events { + if event.FailedAttempts != wantFailedAttempts[i] { + t.Errorf( + "event %d failed attempts = %d, want %d", + i, + event.FailedAttempts, + wantFailedAttempts[i], + ) + } + if event.WillRetry != wantWillRetry[i] { + t.Errorf( + "event %d WillRetry = %t, want %t", + i, + event.WillRetry, + wantWillRetry[i], + ) + } + } +} diff --git a/internal/playback/sync.go b/internal/playback/sync.go new file mode 100644 index 0000000..4d5a9fa --- /dev/null +++ b/internal/playback/sync.go @@ -0,0 +1,34 @@ +package playback + +import "context" + +// SyncFrame contains one video frame and its corresponding audio batch. +// +// Both payloads may borrow reader-owned memory and are valid only until the +// next ReadSync call or until the reader is closed. +type SyncFrame struct { + Video VideoFrame + Audio AudioFrame +} + +// SyncReader reads synchronized audio/video pairs. +// +// ReadSync must not be called again until both frame payloads have been +// consumed. +type SyncReader interface { + ReadSync(context.Context) (SyncFrame, error) + Close() error +} + +// SyncReaderFactory opens one synchronized reader for two configured feeds. +// +// Native MXL implementations may require the feeds to use the same domain. +// Future manual synchronization may support different domains behind another +// implementation of this interface. +type SyncReaderFactory interface { + OpenSync( + context.Context, + FeedConfig, + FeedConfig, + ) (SyncReader, error) +} diff --git a/internal/playback/sync_attempt.go b/internal/playback/sync_attempt.go new file mode 100644 index 0000000..e36b8b5 --- /dev/null +++ b/internal/playback/sync_attempt.go @@ -0,0 +1,57 @@ +package playback + +import ( + "context" + "errors" + "fmt" +) + +func runSyncAttempt( + ctx context.Context, + factory SyncReaderFactory, + videoSink VideoSink, + audioSink AudioSink, + videoConfig FeedConfig, + audioConfig FeedConfig, +) (resultErr error) { + videoCtx := withVideoSource(ctx, videoConfig) + reader, err := factory.OpenSync( + ctx, + videoConfig, + audioConfig, + ) + if err != nil { + return fmt.Errorf("open sync group: %w", err) + } + + defer func() { + if closeErr := reader.Close(); closeErr != nil { + closeErr = fmt.Errorf("close sync group: %w", closeErr) + resultErr = errors.Join(resultErr, closeErr) + } + }() + + for { + frame, err := reader.ReadSync(ctx) + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return fmt.Errorf("read sync group: %w", err) + } + + if err := videoSink.ConsumeVideo(videoCtx, frame.Video); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return &videoSinkError{err: err} + } + + if err := audioSink.ConsumeAudio(ctx, frame.Audio); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return &audioSinkError{err: err} + } + } +} diff --git a/internal/playback/sync_attempt_test.go b/internal/playback/sync_attempt_test.go new file mode 100644 index 0000000..4a9e1b7 --- /dev/null +++ b/internal/playback/sync_attempt_test.go @@ -0,0 +1,241 @@ +package playback + +import ( + "context" + "errors" + "testing" +) + +type fakeSyncFactory struct { + reader SyncReader + err error + calls int + videoConfig FeedConfig + audioConfig FeedConfig +} + +func (f *fakeSyncFactory) OpenSync( + _ context.Context, + videoConfig FeedConfig, + audioConfig FeedConfig, +) (SyncReader, error) { + f.calls++ + f.videoConfig = videoConfig + f.audioConfig = audioConfig + return f.reader, f.err +} + +type fakeSyncReader struct { + frames []SyncFrame + readErr error + closeErr error + readCalls int + closed bool + read func(context.Context) (SyncFrame, error) +} + +func (r *fakeSyncReader) ReadSync(ctx context.Context) (SyncFrame, error) { + r.readCalls++ + if r.read != nil { + return r.read(ctx) + } + if len(r.frames) == 0 { + return SyncFrame{}, r.readErr + } + frame := r.frames[0] + r.frames = r.frames[1:] + return frame, nil +} + +func (r *fakeSyncReader) Close() error { + r.closed = true + return r.closeErr +} + +type orderedVideoSink struct { + order *[]string + err error + frame VideoFrame +} + +func (s *orderedVideoSink) ConsumeVideo(_ context.Context, frame VideoFrame) error { + *s.order = append(*s.order, "video") + s.frame = frame + return s.err +} + +type orderedAudioSink struct { + order *[]string + err error + frame AudioFrame +} + +func (s *orderedAudioSink) ConsumeAudio(_ context.Context, frame AudioFrame) error { + *s.order = append(*s.order, "audio") + s.frame = frame + return s.err +} + +func TestRunSyncAttemptOpenFailure(t *testing.T) { + openErr := errors.New("open failed") + factory := &fakeSyncFactory{err: openErr} + videoConfig := FeedConfig{Domain: "/mxl", UUID: "video", Active: true} + audioConfig := FeedConfig{Domain: "/mxl", UUID: "audio", Active: true} + + err := runSyncAttempt( + context.Background(), + factory, + &fakeVideoSink{}, + &fakeAudioSink{}, + videoConfig, + audioConfig, + ) + + if !errors.Is(err, openErr) { + t.Fatalf("runSyncAttempt() error = %v, want %v", err, openErr) + } + if factory.calls != 1 || factory.videoConfig != videoConfig || factory.audioConfig != audioConfig { + t.Fatalf( + "factory call = %d, video %#v, audio %#v", + factory.calls, + factory.videoConfig, + factory.audioConfig, + ) + } +} + +func TestRunSyncAttemptConsumesBorrowedPairInOrderThenReturnsReadError(t *testing.T) { + readErr := errors.New("sync read failed") + videoPayload := []byte{1, 2, 3, 4} + audioSamples := [][]byte{{5, 6, 7, 8}} + want := SyncFrame{ + Video: VideoFrame{Index: 10, Payload: videoPayload}, + Audio: AudioFrame{Index: 20, Samples: audioSamples}, + } + reader := &fakeSyncReader{frames: []SyncFrame{want}, readErr: readErr} + var order []string + videoSink := &orderedVideoSink{order: &order} + audioSink := &orderedAudioSink{order: &order} + + err := runSyncAttempt( + context.Background(), + &fakeSyncFactory{reader: reader}, + videoSink, + audioSink, + FeedConfig{}, + FeedConfig{}, + ) + + if !errors.Is(err, readErr) { + t.Fatalf("runSyncAttempt() error = %v, want %v", err, readErr) + } + if len(order) != 2 || order[0] != "video" || order[1] != "audio" { + t.Fatalf("sink order = %v, want [video audio]", order) + } + if &videoSink.frame.Payload[0] != &videoPayload[0] { + t.Fatal("video payload was copied") + } + if &audioSink.frame.Samples[0][0] != &audioSamples[0][0] { + t.Fatal("audio samples were copied") + } + if reader.readCalls != 2 || !reader.closed { + t.Fatalf("reader calls = %d, closed = %t; want 2, true", reader.readCalls, reader.closed) + } +} + +func TestRunSyncAttemptVideoSinkFailureSkipsAudio(t *testing.T) { + sinkErr := errors.New("video output failed") + reader := &fakeSyncReader{frames: []SyncFrame{{}}} + var order []string + + err := runSyncAttempt( + context.Background(), + &fakeSyncFactory{reader: reader}, + &orderedVideoSink{order: &order, err: sinkErr}, + &orderedAudioSink{order: &order}, + FeedConfig{}, + FeedConfig{}, + ) + + if !errors.Is(err, sinkErr) { + t.Fatalf("runSyncAttempt() error = %v, want %v", err, sinkErr) + } + var typedErr *videoSinkError + if !errors.As(err, &typedErr) { + t.Fatalf("runSyncAttempt() error type = %T, want *videoSinkError", err) + } + if len(order) != 1 || order[0] != "video" { + t.Fatalf("sink order = %v, want [video]", order) + } +} + +func TestRunSyncAttemptAudioSinkFailureFollowsVideo(t *testing.T) { + sinkErr := errors.New("audio output failed") + reader := &fakeSyncReader{frames: []SyncFrame{{}}} + var order []string + + err := runSyncAttempt( + context.Background(), + &fakeSyncFactory{reader: reader}, + &orderedVideoSink{order: &order}, + &orderedAudioSink{order: &order, err: sinkErr}, + FeedConfig{}, + FeedConfig{}, + ) + + if !errors.Is(err, sinkErr) { + t.Fatalf("runSyncAttempt() error = %v, want %v", err, sinkErr) + } + var typedErr *audioSinkError + if !errors.As(err, &typedErr) { + t.Fatalf("runSyncAttempt() error type = %T, want *audioSinkError", err) + } + if len(order) != 2 || order[0] != "video" || order[1] != "audio" { + t.Fatalf("sink order = %v, want [video audio]", order) + } +} + +func TestRunSyncAttemptCancellationClosesReader(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + reader := &fakeSyncReader{ + read: func(ctx context.Context) (SyncFrame, error) { + cancel() + return SyncFrame{}, ctx.Err() + }, + } + + err := runSyncAttempt( + ctx, + &fakeSyncFactory{reader: reader}, + &fakeVideoSink{}, + &fakeAudioSink{}, + FeedConfig{}, + FeedConfig{}, + ) + + if !errors.Is(err, context.Canceled) { + t.Fatalf("runSyncAttempt() error = %v, want context.Canceled", err) + } + if !reader.closed { + t.Fatal("reader was not closed") + } +} + +func TestRunSyncAttemptJoinsReadAndCloseErrors(t *testing.T) { + readErr := errors.New("read failed") + closeErr := errors.New("close failed") + reader := &fakeSyncReader{readErr: readErr, closeErr: closeErr} + + err := runSyncAttempt( + context.Background(), + &fakeSyncFactory{reader: reader}, + &fakeVideoSink{}, + &fakeAudioSink{}, + FeedConfig{}, + FeedConfig{}, + ) + + if !errors.Is(err, readErr) || !errors.Is(err, closeErr) { + t.Fatalf("runSyncAttempt() error = %v, want read and close errors", err) + } +} diff --git a/internal/playback/sync_slot.go b/internal/playback/sync_slot.go new file mode 100644 index 0000000..3f4bc06 --- /dev/null +++ b/internal/playback/sync_slot.go @@ -0,0 +1,122 @@ +package playback + +import ( + "context" + "errors" + "fmt" +) + +type SyncPairConfig struct { + Video FeedConfig + Audio FeedConfig +} + +var ( + ErrSyncWorkerRequired = errors.New("sync worker is required") + ErrSyncActivityMismatch = errors.New( + "synchronized video and audio must have matching active states", + ) +) + +func (c SyncPairConfig) Validate() error { + if err := c.Video.Validate(); err != nil { + return fmt.Errorf("video: %w", err) + } + if err := c.Audio.Validate(); err != nil { + return fmt.Errorf("audio: %w", err) + } + if c.Video.Active != c.Audio.Active { + return ErrSyncActivityMismatch + } + return nil +} + +func (c SyncPairConfig) Active() bool { + return c.Video.Active && c.Audio.Active +} + +type SyncSlot struct { + worker *SyncWorker +} + +func NewSyncSlot(worker *SyncWorker) (*SyncSlot, error) { + if worker == nil { + return nil, ErrSyncWorkerRequired + } + return &SyncSlot{ + worker: worker, + }, nil +} + +func (s *SyncSlot) Run( + ctx context.Context, + initial SyncPairConfig, + commands <-chan SyncPairConfig, +) error { + if err := initial.Validate(); err != nil { + return fmt.Errorf("validate initial sync config: %w", err) + } + + var ( + workerCancel context.CancelFunc + workerDone chan error + ) + + start := func(config SyncPairConfig) { + workerCtx, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + + workerCancel = cancel + workerDone = done + + go func() { + done <- s.worker.Run(workerCtx, config.Video, config.Audio) + }() + } + + stop := func() { + if workerCancel == nil { + return + } + + workerCancel() + <-workerDone + + workerCancel = nil + workerDone = nil + } + + if initial.Active() { + start(initial) + } + + for { + select { + case <-ctx.Done(): + stop() + return ctx.Err() + + case config, ok := <-commands: + if !ok { + stop() + return nil + } + + if err := config.Validate(); err != nil { + // Ignore invalid commands without disturbing the current worker. + continue + } + + stop() + if config.Active() { + start(config) + } + + case <-workerDone: + // The worker stopped naturally or exhausted its retries. + workerCancel() + workerCancel = nil + workerDone = nil + } + } +} diff --git a/internal/playback/sync_slot_test.go b/internal/playback/sync_slot_test.go new file mode 100644 index 0000000..b8e9129 --- /dev/null +++ b/internal/playback/sync_slot_test.go @@ -0,0 +1,231 @@ +package playback + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +type slotSyncFactory struct { + opened chan SyncPairConfig + + mu sync.Mutex + active int + maxActive int + closeCount int +} + +func newSlotSyncFactory() *slotSyncFactory { + return &slotSyncFactory{opened: make(chan SyncPairConfig, 8)} +} + +func (f *slotSyncFactory) OpenSync( + _ context.Context, + video FeedConfig, + audio FeedConfig, +) (SyncReader, error) { + f.mu.Lock() + f.active++ + if f.active > f.maxActive { + f.maxActive = f.active + } + f.mu.Unlock() + f.opened <- SyncPairConfig{Video: video, Audio: audio} + return &slotSyncReader{factory: f}, nil +} + +func (f *slotSyncFactory) counts() (active, maxActive, closeCount int) { + f.mu.Lock() + defer f.mu.Unlock() + return f.active, f.maxActive, f.closeCount +} + +type slotSyncReader struct { + factory *slotSyncFactory +} + +func (r *slotSyncReader) ReadSync(ctx context.Context) (SyncFrame, error) { + <-ctx.Done() + return SyncFrame{}, ctx.Err() +} + +func (r *slotSyncReader) Close() error { + r.factory.mu.Lock() + defer r.factory.mu.Unlock() + r.factory.active-- + r.factory.closeCount++ + return nil +} + +func newSlotTestSyncWorker(t *testing.T, factory SyncReaderFactory) *SyncWorker { + t.Helper() + worker, err := NewSyncWorker( + factory, + &fakeVideoSink{}, + &fakeAudioSink{}, + testRetryPolicy(1), + func(error) bool { return false }, + nil, + ) + if err != nil { + t.Fatalf("NewSyncWorker() error = %v", err) + } + return worker +} + +func receiveSyncSlotOpen(t *testing.T, opened <-chan SyncPairConfig) SyncPairConfig { + t.Helper() + select { + case config := <-opened: + return config + case <-time.After(time.Second): + t.Fatal("sync worker did not open") + return SyncPairConfig{} + } +} + +func testSyncPair(name string, active bool) SyncPairConfig { + return SyncPairConfig{ + Video: FeedConfig{Domain: "/video", UUID: name + "-video", Active: active}, + Audio: FeedConfig{Domain: "/audio", UUID: name + "-audio", Active: active}, + } +} + +func TestSyncPairConfigRejectsActivityMismatch(t *testing.T) { + config := testSyncPair("pair", true) + config.Audio.Active = false + if err := config.Validate(); !errors.Is(err, ErrSyncActivityMismatch) { + t.Fatalf("Validate() error = %v, want %v", err, ErrSyncActivityMismatch) + } +} + +func TestNewSyncSlotRequiresWorker(t *testing.T) { + slot, err := NewSyncSlot(nil) + if slot != nil { + t.Fatalf("NewSyncSlot(nil) slot = %#v, want nil", slot) + } + if !errors.Is(err, ErrSyncWorkerRequired) { + t.Fatalf("NewSyncSlot(nil) error = %v, want %v", err, ErrSyncWorkerRequired) + } +} + +func TestSyncSlotStartsInitialActivePair(t *testing.T) { + factory := newSlotSyncFactory() + slot, err := NewSyncSlot(newSlotTestSyncWorker(t, factory)) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + want := testSyncPair("first", true) + go func() { done <- slot.Run(ctx, want, make(chan SyncPairConfig)) }() + + if got := receiveSyncSlotOpen(t, factory.opened); got != want { + t.Fatalf("opened config = %#v, want %#v", got, want) + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("Run() error = %v, want context.Canceled", err) + } + case <-time.After(time.Second): + t.Fatal("Run() did not stop after cancellation") + } + active, _, closed := factory.counts() + if active != 0 || closed != 1 { + t.Fatalf("reader counts = active %d, closed %d; want 0, 1", active, closed) + } +} + +func TestSyncSlotReplacesWithoutOverlappingWorkers(t *testing.T) { + factory := newSlotSyncFactory() + slot, err := NewSyncSlot(newSlotTestSyncWorker(t, factory)) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + commands := make(chan SyncPairConfig) + done := make(chan error, 1) + first := testSyncPair("first", true) + second := testSyncPair("second", true) + go func() { done <- slot.Run(ctx, first, commands) }() + receiveSyncSlotOpen(t, factory.opened) + commands <- second + if got := receiveSyncSlotOpen(t, factory.opened); got != second { + t.Fatalf("replacement = %#v, want %#v", got, second) + } + cancel() + <-done + active, maxActive, closed := factory.counts() + if active != 0 || maxActive != 1 || closed != 2 { + t.Fatalf("counts = active %d, maximum %d, closed %d; want 0, 1, 2", active, maxActive, closed) + } +} + +func TestSyncSlotIgnoresInvalidCommand(t *testing.T) { + factory := newSlotSyncFactory() + slot, err := NewSyncSlot(newSlotTestSyncWorker(t, factory)) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + commands := make(chan SyncPairConfig) + done := make(chan error, 1) + initial := testSyncPair("first", true) + go func() { done <- slot.Run(ctx, initial, commands) }() + receiveSyncSlotOpen(t, factory.opened) + + invalid := testSyncPair("invalid", true) + invalid.Audio.Active = false + commands <- invalid + select { + case config := <-factory.opened: + t.Fatalf("invalid command opened config %#v", config) + case <-time.After(20 * time.Millisecond): + } + active, _, closed := factory.counts() + if active != 1 || closed != 0 { + t.Fatalf("invalid command disturbed reader: active %d, closed %d", active, closed) + } + cancel() + <-done +} + +func TestSyncSlotInactivePairStopsWithoutRestart(t *testing.T) { + factory := newSlotSyncFactory() + slot, err := NewSyncSlot(newSlotTestSyncWorker(t, factory)) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + commands := make(chan SyncPairConfig) + done := make(chan error, 1) + go func() { done <- slot.Run(ctx, testSyncPair("first", true), commands) }() + receiveSyncSlotOpen(t, factory.opened) + commands <- testSyncPair("first", false) + + deadline := time.Now().Add(time.Second) + for { + active, _, closed := factory.counts() + if active == 0 && closed == 1 { + break + } + if time.Now().After(deadline) { + t.Fatal("inactive pair did not stop reader") + } + time.Sleep(time.Millisecond) + } + close(commands) + select { + case err := <-done: + if err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + case <-time.After(time.Second): + t.Fatal("Run() did not stop after commands closed") + } +} diff --git a/internal/playback/sync_worker.go b/internal/playback/sync_worker.go new file mode 100644 index 0000000..04fdd64 --- /dev/null +++ b/internal/playback/sync_worker.go @@ -0,0 +1,186 @@ +package playback + +import ( + "context" + "errors" + "fmt" +) + +var ( + ErrSyncFactoryRequired = errors.New("sync reader factory is required") + ErrSyncVideoSinkRequired = errors.New("sync video sink is required") + ErrSyncAudioSinkRequired = errors.New("sync audio sink is required") + ErrSyncRetryDeciderRequired = errors.New("sync retry decider is required") + ErrSyncFeedsInactive = errors.New("both sync feeds must be active") +) + +type SyncWorker struct { + factory SyncReaderFactory + videoSink VideoSink + audioSink AudioSink + retry RetryPolicy + shouldRetry retryDecider + observer StatusObserver + wait waitFunc +} + +func NewSyncWorker( + factory SyncReaderFactory, + videoSink VideoSink, + audioSink AudioSink, + retry RetryPolicy, + shouldRetry func(error) bool, + observer StatusObserver, +) (*SyncWorker, error) { + if factory == nil { + return nil, ErrSyncFactoryRequired + } + if videoSink == nil { + return nil, ErrSyncVideoSinkRequired + } + if audioSink == nil { + return nil, ErrSyncAudioSinkRequired + } + if err := retry.Validate(); err != nil { + return nil, fmt.Errorf("validate sync retry policy: %w", err) + } + if shouldRetry == nil { + return nil, ErrSyncRetryDeciderRequired + } + + return &SyncWorker{ + factory: factory, + videoSink: videoSink, + audioSink: audioSink, + retry: retry, + shouldRetry: shouldRetry, + observer: observer, + wait: waitForRetry, + }, nil +} + +func (w *SyncWorker) emit( + ctx context.Context, + pair SyncPairConfig, + status Status, +) { + status.Generation = generationFromContext(ctx) + status.Pair = pair + if w.observer != nil { + w.observer(status) + } +} + +func (w *SyncWorker) Run( + ctx context.Context, + videoConfig FeedConfig, + audioConfig FeedConfig, +) error { + if err := videoConfig.Validate(); err != nil { + return fmt.Errorf("validate sync video config: %w", err) + } + if err := audioConfig.Validate(); err != nil { + return fmt.Errorf("validate sync audio config: %w", err) + } + if !videoConfig.Active || !audioConfig.Active { + return ErrSyncFeedsInactive + } + pair := SyncPairConfig{Video: videoConfig, Audio: audioConfig} + + attemptNumber := 0 + var latestRetry retryEvent + + attempt := func(ctx context.Context) (bool, error) { + attemptNumber++ + + state := StateConnecting + if attemptNumber > 1 { + state = StateReconnecting + } + w.emit(ctx, pair, Status{ + Unit: UnitSync, + State: state, + Attempt: attemptNumber, + }) + + attemptAudioSink := &stabilityAudioSink{ + sink: w.audioSink, + onStable: func() { + w.emit(ctx, pair, Status{ + Unit: UnitSync, + State: StatePlaying, + Attempt: attemptNumber, + }) + }, + } + err := runSyncAttempt( + ctx, + w.factory, + w.videoSink, + attemptAudioSink, + videoConfig, + audioConfig, + ) + return attemptAudioSink.stable, err + } + + decide := func(err error) bool { + var videoErr *videoSinkError + if errors.As(err, &videoErr) { + return false + } + var audioErr *audioSinkError + if errors.As(err, &audioErr) { + return false + } + return w.shouldRetry(err) + } + observeRetry := func(event retryEvent) { + latestRetry = event + if !event.WillRetry { + return + } + w.emit(ctx, pair, Status{ + Unit: UnitSync, + State: StateReconnecting, + Attempt: attemptNumber + 1, + FailedAttempts: event.FailedAttempts, + RetryIn: event.RetryIn, + Err: event.Err, + }) + } + err := runWithRetry( + ctx, + w.retry, + attempt, + decide, + w.wait, + observeRetry, + ) + if ctx.Err() != nil { + w.emit(ctx, pair, Status{ + Unit: UnitSync, + State: StateStopping, + }) + w.emit(ctx, pair, Status{ + Unit: UnitSync, + State: StateIdle, + }) + return ctx.Err() + } + if err != nil { + w.emit(ctx, pair, Status{ + Unit: UnitSync, + State: StateFailed, + Attempt: attemptNumber, + FailedAttempts: latestRetry.FailedAttempts, + Err: err, + }) + return err + } + w.emit(ctx, pair, Status{ + Unit: UnitSync, + State: StateIdle, + }) + return nil +} diff --git a/internal/playback/sync_worker_test.go b/internal/playback/sync_worker_test.go new file mode 100644 index 0000000..af7c6dd --- /dev/null +++ b/internal/playback/sync_worker_test.go @@ -0,0 +1,288 @@ +package playback + +import ( + "context" + "errors" + "testing" + "time" +) + +type syncOpenResult struct { + reader SyncReader + err error +} + +type scriptedSyncFactory struct { + results []syncOpenResult + calls int +} + +func (f *scriptedSyncFactory) OpenSync( + context.Context, + FeedConfig, + FeedConfig, +) (SyncReader, error) { + if f.calls >= len(f.results) { + return nil, errors.New("unexpected sync open attempt") + } + result := f.results[f.calls] + f.calls++ + return result.reader, result.err +} + +func activeSyncConfigs() (FeedConfig, FeedConfig) { + return FeedConfig{Domain: "/mxl", UUID: "video", Active: true}, + FeedConfig{Domain: "/mxl", UUID: "audio", Active: true} +} + +func newTestSyncWorker( + t *testing.T, + factory SyncReaderFactory, + videoSink VideoSink, + audioSink AudioSink, + maxAttempts int, + observer StatusObserver, +) *SyncWorker { + t.Helper() + worker, err := NewSyncWorker( + factory, + videoSink, + audioSink, + testRetryPolicy(maxAttempts), + func(error) bool { return true }, + observer, + ) + if err != nil { + t.Fatalf("NewSyncWorker() error = %v", err) + } + worker.wait = func(context.Context, time.Duration) error { return nil } + return worker +} + +func TestNewSyncWorkerValidatesDependencies(t *testing.T) { + factory := &scriptedSyncFactory{} + videoSink := &fakeVideoSink{} + audioSink := &fakeAudioSink{} + retry := testRetryPolicy(3) + decide := func(error) bool { return true } + + tests := []struct { + name string + factory SyncReaderFactory + videoSink VideoSink + audioSink AudioSink + retry RetryPolicy + shouldRetry func(error) bool + wantErr error + }{ + {"missing factory", nil, videoSink, audioSink, retry, decide, ErrSyncFactoryRequired}, + {"missing video sink", factory, nil, audioSink, retry, decide, ErrSyncVideoSinkRequired}, + {"missing audio sink", factory, videoSink, nil, retry, decide, ErrSyncAudioSinkRequired}, + {"invalid retry", factory, videoSink, audioSink, RetryPolicy{}, decide, ErrInvalidRetryDelay}, + {"missing decider", factory, videoSink, audioSink, retry, nil, ErrSyncRetryDeciderRequired}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + worker, err := NewSyncWorker( + tt.factory, tt.videoSink, tt.audioSink, tt.retry, tt.shouldRetry, nil, + ) + if worker != nil { + t.Fatal("NewSyncWorker() worker is not nil") + } + if !errors.Is(err, tt.wantErr) { + t.Fatalf("NewSyncWorker() error = %v, want %v", err, tt.wantErr) + } + }) + } +} + +func TestSyncWorkerRejectsInvalidOrInactiveFeeds(t *testing.T) { + video, audio := activeSyncConfigs() + tests := []struct { + name string + video FeedConfig + audio FeedConfig + want error + }{ + {"invalid video", FeedConfig{Active: true}, audio, ErrActiveFeedNotConfigured}, + {"invalid audio", video, FeedConfig{Active: true}, ErrActiveFeedNotConfigured}, + {"inactive video", FeedConfig{Domain: video.Domain, UUID: video.UUID}, audio, ErrSyncFeedsInactive}, + {"inactive audio", video, FeedConfig{Domain: audio.Domain, UUID: audio.UUID}, ErrSyncFeedsInactive}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + factory := &scriptedSyncFactory{} + worker := newTestSyncWorker(t, factory, &fakeVideoSink{}, &fakeAudioSink{}, 3, nil) + err := worker.Run(context.Background(), tt.video, tt.audio) + if !errors.Is(err, tt.want) { + t.Fatalf("Run() error = %v, want %v", err, tt.want) + } + if factory.calls != 0 { + t.Fatalf("factory calls = %d, want 0", factory.calls) + } + }) + } +} + +func TestSyncWorkerStatusesInheritGeneration(t *testing.T) { + openErr := errors.New("unavailable") + var statuses []Status + worker := newTestSyncWorker( + t, + &scriptedSyncFactory{results: []syncOpenResult{{err: openErr}}}, + &fakeVideoSink{}, + &fakeAudioSink{}, + 1, + func(status Status) { statuses = append(statuses, status) }, + ) + video, audio := activeSyncConfigs() + _ = worker.Run(withGeneration(context.Background(), 9), video, audio) + if len(statuses) == 0 { + t.Fatal("no statuses emitted") + } + for _, status := range statuses { + if status.Generation != 9 { + t.Fatalf("status generation = %d, want 9: %+v", status.Generation, status) + } + wantPair := SyncPairConfig{Video: video, Audio: audio} + if status.Pair != wantPair { + t.Fatalf("status pair = %#v, want %#v", status.Pair, wantPair) + } + } +} + +func TestSyncWorkerExhaustsOpenRetries(t *testing.T) { + openErr := errors.New("sync producer unavailable") + factory := &scriptedSyncFactory{results: []syncOpenResult{{err: openErr}, {err: openErr}}} + var statuses []Status + worker := newTestSyncWorker(t, factory, &fakeVideoSink{}, &fakeAudioSink{}, 2, + func(status Status) { statuses = append(statuses, status) }) + video, audio := activeSyncConfigs() + + err := worker.Run(context.Background(), video, audio) + if !errors.Is(err, openErr) { + t.Fatalf("Run() error = %v, want %v", err, openErr) + } + if factory.calls != 2 { + t.Fatalf("factory calls = %d, want 2", factory.calls) + } + want := []State{StateConnecting, StateReconnecting, StateReconnecting, StateFailed} + if len(statuses) != len(want) { + t.Fatalf("statuses = %+v, want %d entries", statuses, len(want)) + } + for i, state := range want { + if statuses[i].Unit != UnitSync || statuses[i].State != state { + t.Errorf("status %d = %+v, want unit=%v state=%v", i, statuses[i], UnitSync, state) + } + } +} + +func TestSyncWorkerStablePairResetsRetryCounter(t *testing.T) { + readErr := errors.New("sync disconnected") + ctx, cancel := context.WithCancel(context.Background()) + lastReader := &fakeSyncReader{read: func(ctx context.Context) (SyncFrame, error) { + cancel() + return SyncFrame{}, ctx.Err() + }} + factory := &scriptedSyncFactory{results: []syncOpenResult{ + {reader: &fakeSyncReader{frames: []SyncFrame{{}}, readErr: readErr}}, + {reader: &fakeSyncReader{frames: []SyncFrame{{}}, readErr: readErr}}, + {reader: lastReader}, + }} + var statuses []Status + worker := newTestSyncWorker(t, factory, &fakeVideoSink{}, &fakeAudioSink{}, 2, + func(status Status) { statuses = append(statuses, status) }) + video, audio := activeSyncConfigs() + + err := worker.Run(ctx, video, audio) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Run() error = %v, want context.Canceled", err) + } + if factory.calls != 3 { + t.Fatalf("factory calls = %d, want 3", factory.calls) + } + var retryFailures []int + for _, status := range statuses { + if status.State == StateReconnecting && status.RetryIn > 0 { + retryFailures = append(retryFailures, status.FailedAttempts) + } + } + if len(retryFailures) != 2 || retryFailures[0] != 1 || retryFailures[1] != 1 { + t.Fatalf("retry failure counts = %v, want [1 1]", retryFailures) + } +} + +func TestSyncWorkerDoesNotRetrySinkFailures(t *testing.T) { + sinkErr := errors.New("output failed") + tests := []struct { + name string + videoSink VideoSink + audioSink AudioSink + }{ + {"video", &fakeVideoSink{err: sinkErr}, &fakeAudioSink{}}, + {"audio", &fakeVideoSink{}, &fakeAudioSink{err: sinkErr}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader := &fakeSyncReader{frames: []SyncFrame{{}}} + factory := &scriptedSyncFactory{results: []syncOpenResult{{reader: reader}}} + deciderCalls := 0 + worker, err := NewSyncWorker(factory, tt.videoSink, tt.audioSink, testRetryPolicy(0), + func(error) bool { deciderCalls++; return true }, nil) + if err != nil { + t.Fatal(err) + } + worker.wait = func(context.Context, time.Duration) error { return nil } + video, audio := activeSyncConfigs() + err = worker.Run(context.Background(), video, audio) + if !errors.Is(err, sinkErr) { + t.Fatalf("Run() error = %v, want %v", err, sinkErr) + } + if factory.calls != 1 || deciderCalls != 0 || !reader.closed { + t.Fatalf("calls=%d deciderCalls=%d closed=%t", factory.calls, deciderCalls, reader.closed) + } + }) + } +} + +func TestSyncWorkerEmitsPlayingThenStopsOnCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + reader := &fakeSyncReader{ + frames: []SyncFrame{{}}, + read: func(ctx context.Context) (SyncFrame, error) { + cancel() + return SyncFrame{}, ctx.Err() + }, + } + // Preserve the first frame before switching to the cancellation callback. + readCalls := 0 + reader.read = func(ctx context.Context) (SyncFrame, error) { + readCalls++ + if readCalls == 1 { + return SyncFrame{}, nil + } + cancel() + return SyncFrame{}, ctx.Err() + } + factory := &scriptedSyncFactory{results: []syncOpenResult{{reader: reader}}} + var statuses []Status + worker := newTestSyncWorker(t, factory, &fakeVideoSink{}, &fakeAudioSink{}, 3, + func(status Status) { statuses = append(statuses, status) }) + video, audio := activeSyncConfigs() + + err := worker.Run(ctx, video, audio) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Run() error = %v, want context.Canceled", err) + } + want := []State{StateConnecting, StatePlaying, StateStopping, StateIdle} + if len(statuses) != len(want) { + t.Fatalf("statuses = %+v, want %v", statuses, want) + } + for i, state := range want { + if statuses[i].Unit != UnitSync || statuses[i].State != state { + t.Errorf("status %d = %+v, want unit=%v state=%v", i, statuses[i], UnitSync, state) + } + } +} diff --git a/internal/playback/video.go b/internal/playback/video.go new file mode 100644 index 0000000..382be1d --- /dev/null +++ b/internal/playback/video.go @@ -0,0 +1,54 @@ +package playback + +import "context" + +type videoSourceContextKey struct{} + +func withVideoSource(ctx context.Context, source FeedConfig) context.Context { + return context.WithValue(ctx, videoSourceContextKey{}, source) +} + +func videoSourceFromContext(ctx context.Context) FeedConfig { + source, _ := ctx.Value(videoSourceContextKey{}).(FeedConfig) + return source +} + +// VideoFrame contains metadata and borrowed source payload. +// +// Payload is valid only until the next VideoReader.ReadVideo call or until the +// reader is closed. Consumers must finish reading Payload before returning +// control to the worker +type VideoFrame struct { + Index uint64 + Width uint32 + Height uint32 + Stride uint32 + Size uint32 + Invalid bool + Label string + FrameRateNumerator int64 + FrameRateDenominator int64 + Payload []byte +} + +// VideoReader reads frames from a video source. +// +// ReadVideo must not be called again until the previous frame's payload has +// been consumed. +type VideoReader interface { + ReadVideo(context.Context) (VideoFrame, error) + Close() error +} + +// VideoReaderFactory opens a reader for the configured video feed. +type VideoReaderFactory interface { + OpenVideo(context.Context, FeedConfig) (VideoReader, error) +} + +// VideoSink consumes a borrowed video frame. +// +// ConsumeVideo must finish using frame.Payload before returning and must never +// retain it for asynchronous use. +type VideoSink interface { + ConsumeVideo(context.Context, VideoFrame) error +} diff --git a/internal/playback/video_attempt.go b/internal/playback/video_attempt.go new file mode 100644 index 0000000..bdd4ee2 --- /dev/null +++ b/internal/playback/video_attempt.go @@ -0,0 +1,56 @@ +package playback + +import ( + "context" + "errors" + "fmt" +) + +type videoSinkError struct { + err error +} + +func (e *videoSinkError) Error() string { + return fmt.Sprintf("consume video: %v", e.err) +} + +func (e *videoSinkError) Unwrap() error { + return e.err +} + +func runVideoAttempt( + ctx context.Context, + factory VideoReaderFactory, + sink VideoSink, + config FeedConfig, +) (resultErr error) { + ctx = withVideoSource(ctx, config) + reader, err := factory.OpenVideo(ctx, config) + if err != nil { + return fmt.Errorf("open video: %w", err) + } + + defer func() { + if closeErr := reader.Close(); closeErr != nil { + closeErr = fmt.Errorf("close video: %w", closeErr) + resultErr = errors.Join(resultErr, closeErr) + } + }() + + for { + frame, err := reader.ReadVideo(ctx) + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return fmt.Errorf("read video: %w", err) + } + + if err := sink.ConsumeVideo(ctx, frame); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return &videoSinkError{err: err} + } + } +} diff --git a/internal/playback/video_attempt_test.go b/internal/playback/video_attempt_test.go new file mode 100644 index 0000000..b0ac608 --- /dev/null +++ b/internal/playback/video_attempt_test.go @@ -0,0 +1,213 @@ +package playback + +import ( + "context" + "errors" + "testing" +) + +type fakeVideoFactory struct { + reader VideoReader + err error + calls int +} + +func (f *fakeVideoFactory) OpenVideo( + context.Context, + FeedConfig, +) (VideoReader, error) { + f.calls++ + return f.reader, f.err +} + +type fakeVideoReader struct { + frames []VideoFrame + readErr error + closeErr error + readCalls int + closed bool + read func(context.Context) (VideoFrame, error) +} + +func (r *fakeVideoReader) ReadVideo(ctx context.Context) (VideoFrame, error) { + r.readCalls++ + if r.read != nil { + return r.read(ctx) + } + if len(r.frames) == 0 { + return VideoFrame{}, r.readErr + } + frame := r.frames[0] + r.frames = r.frames[1:] + return frame, nil +} + +func (r *fakeVideoReader) Close() error { + r.closed = true + return r.closeErr +} + +type fakeVideoSink struct { + frames []VideoFrame + err error +} + +func (s *fakeVideoSink) ConsumeVideo(_ context.Context, frame VideoFrame) error { + s.frames = append(s.frames, frame) + return s.err +} + +func TestRunVideoAttemptOpenFailure(t *testing.T) { + openErr := errors.New("open failed") + factory := &fakeVideoFactory{err: openErr} + sink := &fakeVideoSink{} + + err := runVideoAttempt( + context.Background(), + factory, + sink, + FeedConfig{}, + ) + + if !errors.Is(err, openErr) { + t.Fatalf("runVideoAttempt() error = %v, want %v", err, openErr) + } + if factory.calls != 1 { + t.Errorf("factory calls = %d, want 1", factory.calls) + } + if len(sink.frames) != 0 { + t.Fatalf("consumed frame count = %d, want 0", len(sink.frames)) + } +} + +func TestRunVideoAttemptConsumesFrameThenReturnsReadFailure(t *testing.T) { + readErr := errors.New("read failed") + wantFrame := VideoFrame{ + Index: 42, + Width: 1920, + Height: 1080, + Stride: 5120, + Size: 5120 * 1080, + Invalid: false, + Payload: []byte{1, 2, 3, 4}, + } + reader := &fakeVideoReader{ + frames: []VideoFrame{wantFrame}, + readErr: readErr, + } + sink := &fakeVideoSink{} + + err := runVideoAttempt( + context.Background(), + &fakeVideoFactory{reader: reader}, + sink, + FeedConfig{}, + ) + + if !errors.Is(err, readErr) { + t.Fatalf("runVideoAttempt() error = %v, want %v", err, readErr) + } + if !reader.closed { + t.Fatal("reader was not closed") + } + if reader.readCalls != 2 { + t.Errorf("read calls = %d, want 2", reader.readCalls) + } + if len(sink.frames) != 1 { + t.Fatalf("consumed frame count = %d, want 1", len(sink.frames)) + } + gotFrame := sink.frames[0] + if gotFrame.Index != wantFrame.Index || + gotFrame.Width != wantFrame.Width || + gotFrame.Height != wantFrame.Height || + gotFrame.Stride != wantFrame.Stride || + gotFrame.Size != wantFrame.Size || + gotFrame.Invalid != wantFrame.Invalid { + t.Errorf("consumed frame metadata = %+v, want %+v", gotFrame, wantFrame) + } + if len(gotFrame.Payload) == 0 { + t.Fatal("consumed payload is empty") + } + if &gotFrame.Payload[0] != &wantFrame.Payload[0] { + t.Fatal("video payload was copied") + } +} + +func TestRunVideoAttemptSinkFailureStopsReadingAndCloses(t *testing.T) { + sinkErr := errors.New("renderer unavailable") + reader := &fakeVideoReader{ + frames: []VideoFrame{ + {Index: 1, Payload: []byte{1}}, + {Index: 2, Payload: []byte{2}}, + }, + } + sink := &fakeVideoSink{err: sinkErr} + + err := runVideoAttempt( + context.Background(), + &fakeVideoFactory{reader: reader}, + sink, + FeedConfig{}, + ) + + if !errors.Is(err, sinkErr) { + t.Fatalf("runVideoAttempt() error = %v, want %v", err, sinkErr) + } + var typedErr *videoSinkError + if !errors.As(err, &typedErr) { + t.Fatalf("runVideoAttempt() error type = %T, want *videoSinkError", err) + } + if reader.readCalls != 1 { + t.Errorf("read calls = %d, want 1", reader.readCalls) + } + if !reader.closed { + t.Fatal("reader was not closed") + } +} + +func TestRunVideoAttemptCanceledRead(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + reader := &fakeVideoReader{ + read: func(ctx context.Context) (VideoFrame, error) { + cancel() + return VideoFrame{}, ctx.Err() + }, + } + + err := runVideoAttempt( + ctx, + &fakeVideoFactory{reader: reader}, + &fakeVideoSink{}, + FeedConfig{}, + ) + + if !errors.Is(err, context.Canceled) { + t.Fatalf("runVideoAttempt() error = %v, want context.Canceled", err) + } + if !reader.closed { + t.Fatal("reader was not closed") + } +} + +func TestRunVideoAttemptJoinsReadAndCloseErrors(t *testing.T) { + readErr := errors.New("read failed") + closeErr := errors.New("close failed") + reader := &fakeVideoReader{ + readErr: readErr, + closeErr: closeErr, + } + + err := runVideoAttempt( + context.Background(), + &fakeVideoFactory{reader: reader}, + &fakeVideoSink{}, + FeedConfig{}, + ) + + if !errors.Is(err, readErr) { + t.Errorf("runVideoAttempt() error does not contain read error: %v", err) + } + if !errors.Is(err, closeErr) { + t.Errorf("runVideoAttempt() error does not contain close error: %v", err) + } +} diff --git a/internal/playback/video_bridge.go b/internal/playback/video_bridge.go new file mode 100644 index 0000000..aa971e6 --- /dev/null +++ b/internal/playback/video_bridge.go @@ -0,0 +1,64 @@ +package playback + +import ( + "context" + "sync" +) + +type PendingVideoFrame struct { + Frame VideoFrame + Generation uint64 + Source FeedConfig + + completeOnce sync.Once + result chan error +} + +type VideoBridge struct { + requests chan *PendingVideoFrame +} + +func NewVideoBridge() *VideoBridge { + return &VideoBridge{ + requests: make(chan *PendingVideoFrame), + } +} + +func (b *VideoBridge) ConsumeVideo( + ctx context.Context, + frame VideoFrame, +) error { + pending := &PendingVideoFrame{ + Frame: frame, + Generation: generationFromContext(ctx), + Source: videoSourceFromContext(ctx), + result: make(chan error, 1), + } + + select { + case b.requests <- pending: + case <-ctx.Done(): + return ctx.Err() + } + + // The render thread now owns temporary access to the borrowed payload. + // We must wait for Complete even if ctx is canceled. + return <-pending.result +} + +func (b *VideoBridge) Next( + ctx context.Context, +) (*PendingVideoFrame, error) { + select { + case pending := <-b.requests: + return pending, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (f *PendingVideoFrame) Complete(err error) { + f.completeOnce.Do(func() { + f.result <- err + }) +} diff --git a/internal/playback/video_bridge_test.go b/internal/playback/video_bridge_test.go new file mode 100644 index 0000000..d66a424 --- /dev/null +++ b/internal/playback/video_bridge_test.go @@ -0,0 +1,146 @@ +package playback + +import ( + "context" + "errors" + "testing" + "time" +) + +const videoBridgeTestTimeout = time.Second + +func TestVideoBridgeDeliversFrameAndCompletionResult(t *testing.T) { + bridge := NewVideoBridge() + wantErr := errors.New("stage frame") + wantFrame := VideoFrame{ + Index: 42, + Width: 1920, + Height: 1080, + Stride: 7680, + Payload: []byte{1, 2, 3}, + } + consumeResult := make(chan error, 1) + + wantSource := FeedConfig{Domain: "/video", UUID: "video", Active: true} + go func() { + ctx := withGeneration(context.Background(), 17) + consumeResult <- bridge.ConsumeVideo(withVideoSource(ctx, wantSource), wantFrame) + }() + + ctx, cancel := context.WithTimeout(context.Background(), videoBridgeTestTimeout) + defer cancel() + pending, err := bridge.Next(ctx) + if err != nil { + t.Fatalf("Next() error = %v", err) + } + if pending.Frame.Index != wantFrame.Index { + t.Fatalf("Next() frame index = %d, want %d", pending.Frame.Index, wantFrame.Index) + } + if pending.Generation != 17 { + t.Fatalf("Next() generation = %d, want 17", pending.Generation) + } + if pending.Source != wantSource { + t.Fatalf("Next() source = %#v, want %#v", pending.Source, wantSource) + } + if &pending.Frame.Payload[0] != &wantFrame.Payload[0] { + t.Fatal("Next() copied the borrowed payload") + } + + pending.Complete(wantErr) + select { + case err := <-consumeResult: + if !errors.Is(err, wantErr) { + t.Fatalf("ConsumeVideo() error = %v, want %v", err, wantErr) + } + case <-time.After(videoBridgeTestTimeout): + t.Fatal("ConsumeVideo() did not return after completion") + } +} + +func TestVideoBridgeConsumeHonorsCancellationBeforeDelivery(t *testing.T) { + bridge := NewVideoBridge() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := bridge.ConsumeVideo(ctx, VideoFrame{}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("ConsumeVideo() error = %v, want %v", err, context.Canceled) + } +} + +func TestVideoBridgeConsumeWaitsForCompletionAfterDelivery(t *testing.T) { + bridge := NewVideoBridge() + ctx, cancel := context.WithCancel(context.Background()) + consumeResult := make(chan error, 1) + + go func() { + consumeResult <- bridge.ConsumeVideo(ctx, VideoFrame{Index: 7}) + }() + + nextCtx, nextCancel := context.WithTimeout(context.Background(), videoBridgeTestTimeout) + defer nextCancel() + pending, err := bridge.Next(nextCtx) + if err != nil { + t.Fatalf("Next() error = %v", err) + } + cancel() + + select { + case err := <-consumeResult: + t.Fatalf("ConsumeVideo() returned before completion: %v", err) + case <-time.After(20 * time.Millisecond): + } + + pending.Complete(nil) + select { + case err := <-consumeResult: + if err != nil { + t.Fatalf("ConsumeVideo() error = %v, want nil", err) + } + case <-time.After(videoBridgeTestTimeout): + t.Fatal("ConsumeVideo() did not return after completion") + } +} + +func TestPendingVideoFrameCompleteIsIdempotent(t *testing.T) { + bridge := NewVideoBridge() + consumeResult := make(chan error, 1) + + go func() { + consumeResult <- bridge.ConsumeVideo(context.Background(), VideoFrame{}) + }() + + ctx, cancel := context.WithTimeout(context.Background(), videoBridgeTestTimeout) + defer cancel() + pending, err := bridge.Next(ctx) + if err != nil { + t.Fatalf("Next() error = %v", err) + } + + firstErr := errors.New("first") + pending.Complete(firstErr) + pending.Complete(errors.New("second")) + + select { + case err := <-consumeResult: + if !errors.Is(err, firstErr) { + t.Fatalf("ConsumeVideo() error = %v, want %v", err, firstErr) + } + case <-time.After(videoBridgeTestTimeout): + t.Fatal("ConsumeVideo() did not return") + } +} + +func TestVideoBridgeNextHonorsCancellation(t *testing.T) { + bridge := NewVideoBridge() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + pending, err := bridge.Next(ctx) + if pending != nil { + t.Fatalf("Next() pending = %#v, want nil", pending) + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("Next() error = %v, want %v", err, context.Canceled) + } +} diff --git a/internal/playback/video_slot.go b/internal/playback/video_slot.go new file mode 100644 index 0000000..b8b8a44 --- /dev/null +++ b/internal/playback/video_slot.go @@ -0,0 +1,92 @@ +package playback + +import ( + "context" + "errors" + "fmt" +) + +var ErrVideoWorkerRequired = errors.New("video worker is required") + +type VideoSlot struct { + worker *VideoWorker +} + +func NewVideoSlot(worker *VideoWorker) (*VideoSlot, error) { + if worker == nil { + return nil, ErrVideoWorkerRequired + } + return &VideoSlot{worker: worker}, nil +} + +func (s *VideoSlot) Run( + ctx context.Context, + initial FeedConfig, + commands <-chan FeedConfig, +) error { + if err := initial.Validate(); err != nil { + return fmt.Errorf("validate initial video config: %w", err) + } + + var ( + workerCancel context.CancelFunc + workerDone chan error + ) + + start := func(config FeedConfig) { + workerCtx, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + + workerCancel = cancel + workerDone = done + + go func() { + done <- s.worker.Run(workerCtx, config) + }() + } + + stop := func() { + if workerCancel == nil { + return + } + + workerCancel() + <-workerDone + + workerCancel = nil + workerDone = nil + } + + if initial.Active { + start(initial) + } + + for { + select { + case <-ctx.Done(): + stop() + return ctx.Err() + + case config, ok := <-commands: + if !ok { + stop() + return nil + } + if err := config.Validate(); err != nil { + // Ignore invalid commands without disturbing the current worker. + continue + } + stop() + if config.Active { + start(config) + } + + case <-workerDone: + // The worker stopped naturally or exhausted its retries. + // Clear its lifecycle, but keep the slot alive for future commands. + workerCancel() + workerCancel = nil + workerDone = nil + } + } +} diff --git a/internal/playback/video_slot_test.go b/internal/playback/video_slot_test.go new file mode 100644 index 0000000..fe92aa3 --- /dev/null +++ b/internal/playback/video_slot_test.go @@ -0,0 +1,233 @@ +package playback + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +type slotVideoFactory struct { + opened chan FeedConfig + + mu sync.Mutex + active int + maxActive int + closeCount int +} + +func newSlotVideoFactory() *slotVideoFactory { + return &slotVideoFactory{opened: make(chan FeedConfig, 8)} +} + +func (f *slotVideoFactory) OpenVideo( + _ context.Context, + config FeedConfig, +) (VideoReader, error) { + f.mu.Lock() + f.active++ + if f.active > f.maxActive { + f.maxActive = f.active + } + f.mu.Unlock() + + f.opened <- config + return &slotVideoReader{factory: f}, nil +} + +func (f *slotVideoFactory) counts() (active, maxActive, closeCount int) { + f.mu.Lock() + defer f.mu.Unlock() + return f.active, f.maxActive, f.closeCount +} + +type slotVideoReader struct { + factory *slotVideoFactory +} + +func (r *slotVideoReader) ReadVideo(ctx context.Context) (VideoFrame, error) { + <-ctx.Done() + return VideoFrame{}, ctx.Err() +} + +func (r *slotVideoReader) Close() error { + r.factory.mu.Lock() + defer r.factory.mu.Unlock() + r.factory.active-- + r.factory.closeCount++ + return nil +} + +func newSlotTestWorker(t *testing.T, factory VideoReaderFactory) *VideoWorker { + t.Helper() + worker, err := NewVideoWorker( + factory, + &fakeVideoSink{}, + testRetryPolicy(1), + func(error) bool { return false }, + nil, + ) + if err != nil { + t.Fatalf("NewVideoWorker() error = %v", err) + } + return worker +} + +func receiveSlotOpen(t *testing.T, opened <-chan FeedConfig) FeedConfig { + t.Helper() + select { + case config := <-opened: + return config + case <-time.After(time.Second): + t.Fatal("video worker did not open") + return FeedConfig{} + } +} + +func TestNewVideoSlotRequiresWorker(t *testing.T) { + slot, err := NewVideoSlot(nil) + if slot != nil { + t.Fatalf("NewVideoSlot(nil) slot = %#v, want nil", slot) + } + if !errors.Is(err, ErrVideoWorkerRequired) { + t.Fatalf("NewVideoSlot(nil) error = %v, want %v", err, ErrVideoWorkerRequired) + } +} + +func TestVideoSlotStartsInitialActiveConfig(t *testing.T) { + factory := newSlotVideoFactory() + slot, err := NewVideoSlot(newSlotTestWorker(t, factory)) + if err != nil { + t.Fatalf("NewVideoSlot() error = %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + want := FeedConfig{Domain: "/video", UUID: "first", Active: true} + + go func() { done <- slot.Run(ctx, want, make(chan FeedConfig)) }() + if got := receiveSlotOpen(t, factory.opened); got != want { + t.Fatalf("opened config = %#v, want %#v", got, want) + } + + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("Run() error = %v, want %v", err, context.Canceled) + } + case <-time.After(time.Second): + t.Fatal("Run() did not stop after cancellation") + } + active, _, closeCount := factory.counts() + if active != 0 || closeCount != 1 { + t.Fatalf("reader counts = active %d, closed %d; want 0, 1", active, closeCount) + } +} + +func TestVideoSlotReplacesWithoutOverlappingWorkers(t *testing.T) { + factory := newSlotVideoFactory() + slot, err := NewVideoSlot(newSlotTestWorker(t, factory)) + if err != nil { + t.Fatalf("NewVideoSlot() error = %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + commands := make(chan FeedConfig) + done := make(chan error, 1) + first := FeedConfig{Domain: "/video", UUID: "first", Active: true} + second := FeedConfig{Domain: "/video", UUID: "second", Active: true} + + go func() { done <- slot.Run(ctx, first, commands) }() + receiveSlotOpen(t, factory.opened) + commands <- second + if got := receiveSlotOpen(t, factory.opened); got != second { + t.Fatalf("replacement config = %#v, want %#v", got, second) + } + + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("Run() did not stop") + } + active, maxActive, closeCount := factory.counts() + if active != 0 || maxActive != 1 || closeCount != 2 { + t.Fatalf( + "reader counts = active %d, maximum %d, closed %d; want 0, 1, 2", + active, maxActive, closeCount, + ) + } +} + +func TestVideoSlotIgnoresInvalidCommand(t *testing.T) { + factory := newSlotVideoFactory() + slot, err := NewVideoSlot(newSlotTestWorker(t, factory)) + if err != nil { + t.Fatalf("NewVideoSlot() error = %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + commands := make(chan FeedConfig) + done := make(chan error, 1) + initial := FeedConfig{Domain: "/video", UUID: "first", Active: true} + + go func() { done <- slot.Run(ctx, initial, commands) }() + receiveSlotOpen(t, factory.opened) + commands <- FeedConfig{UUID: "invalid", Active: true} + + select { + case config := <-factory.opened: + t.Fatalf("invalid command opened config %#v", config) + case <-time.After(20 * time.Millisecond): + } + + active, _, closeCount := factory.counts() + if active != 1 || closeCount != 0 { + t.Fatalf("invalid command disturbed reader: active %d, closed %d", active, closeCount) + } + cancel() + <-done +} + +func TestVideoSlotInactiveCommandStopsWithoutRestart(t *testing.T) { + factory := newSlotVideoFactory() + slot, err := NewVideoSlot(newSlotTestWorker(t, factory)) + if err != nil { + t.Fatalf("NewVideoSlot() error = %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + commands := make(chan FeedConfig) + done := make(chan error, 1) + initial := FeedConfig{Domain: "/video", UUID: "first", Active: true} + + go func() { done <- slot.Run(ctx, initial, commands) }() + receiveSlotOpen(t, factory.opened) + commands <- FeedConfig{Domain: "/video", UUID: "first", Active: false} + + deadline := time.Now().Add(time.Second) + for { + active, _, closeCount := factory.counts() + if active == 0 && closeCount == 1 { + break + } + if time.Now().After(deadline) { + t.Fatal("inactive command did not stop reader") + } + time.Sleep(time.Millisecond) + } + select { + case config := <-factory.opened: + t.Fatalf("inactive command restarted config %#v", config) + case <-time.After(20 * time.Millisecond): + } + + close(commands) + select { + case err := <-done: + if err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + case <-time.After(time.Second): + t.Fatal("Run() did not stop after commands closed") + } +} diff --git a/internal/playback/video_worker.go b/internal/playback/video_worker.go new file mode 100644 index 0000000..7be9a85 --- /dev/null +++ b/internal/playback/video_worker.go @@ -0,0 +1,186 @@ +package playback + +import ( + "context" + "errors" + "fmt" +) + +var ( + ErrVideoFactoryRequired = errors.New("video reader factory is required") + ErrVideoSinkRequired = errors.New("video sink is required") + ErrVideoRetryDeciderRequired = errors.New("video decider is required") + ErrVideoFeedInactive = errors.New("video feed is not active") +) + +type VideoWorker struct { + factory VideoReaderFactory + sink VideoSink + retry RetryPolicy + shouldRetry retryDecider + observer StatusObserver + wait waitFunc +} + +func NewVideoWorker( + factory VideoReaderFactory, + sink VideoSink, + retry RetryPolicy, + shouldRetry func(error) bool, + observer StatusObserver, +) (*VideoWorker, error) { + if factory == nil { + return nil, ErrVideoFactoryRequired + } + if sink == nil { + return nil, ErrVideoSinkRequired + } + if shouldRetry == nil { + return nil, ErrVideoRetryDeciderRequired + } + if err := retry.Validate(); err != nil { + return nil, fmt.Errorf("validate video retry policy: %w", err) + } + + return &VideoWorker{ + factory: factory, + sink: sink, + retry: retry, + shouldRetry: shouldRetry, + observer: observer, + wait: waitForRetry, + }, nil +} + +type stabilityVideoSink struct { + sink VideoSink + onStable func() + stable bool +} + +func (s *stabilityVideoSink) ConsumeVideo( + ctx context.Context, + frame VideoFrame, +) error { + err := s.sink.ConsumeVideo(ctx, frame) + if err == nil && !s.stable { + s.stable = true + if s.onStable != nil { + s.onStable() + } + } + return err +} + +func (w *VideoWorker) emit(ctx context.Context, config FeedConfig, status Status) { + status.Generation = generationFromContext(ctx) + status.Feed = config + if w.observer != nil { + w.observer(status) + } +} + +func (w *VideoWorker) Run( + ctx context.Context, + config FeedConfig, +) error { + if err := config.Validate(); err != nil { + return fmt.Errorf("validate video config: %w", err) + } + if !config.Active { + return ErrVideoFeedInactive + } + + attemptNumber := 0 + var latestRetry retryEvent + + attempt := func(ctx context.Context) (bool, error) { + attemptNumber++ + + state := StateConnecting + if attemptNumber > 1 { + state = StateReconnecting + } + w.emit(ctx, config, Status{ + Unit: UnitVideo, + State: state, + Attempt: attemptNumber, + }) + + attemptSink := &stabilityVideoSink{ + sink: w.sink, + onStable: func() { + w.emit(ctx, config, Status{ + Unit: UnitVideo, + State: StatePlaying, + Attempt: attemptNumber, + }) + }, + } + + err := runVideoAttempt(ctx, w.factory, attemptSink, config) + return attemptSink.stable, err + } + + decide := func(err error) bool { + var sinkErr *videoSinkError + if errors.As(err, &sinkErr) { + return false + } + return w.shouldRetry(err) + } + + observeRetry := func(event retryEvent) { + latestRetry = event + if !event.WillRetry { + return + } + + w.emit(ctx, config, Status{ + Unit: UnitVideo, + State: StateReconnecting, + Attempt: attemptNumber + 1, + FailedAttempts: event.FailedAttempts, + RetryIn: event.RetryIn, + Err: event.Err, + }) + } + + err := runWithRetry( + ctx, + w.retry, + attempt, + decide, + w.wait, + observeRetry, + ) + + if ctx.Err() != nil { + w.emit(ctx, config, Status{ + Unit: UnitVideo, + State: StateStopping, + }) + w.emit(ctx, config, Status{ + Unit: UnitVideo, + State: StateIdle, + }) + return ctx.Err() + } + + if err != nil { + w.emit(ctx, config, Status{ + Unit: UnitVideo, + State: StateFailed, + Attempt: attemptNumber, + FailedAttempts: latestRetry.FailedAttempts, + Err: err, + }) + return err + } + + w.emit(ctx, config, Status{ + Unit: UnitVideo, + State: StateIdle, + }) + return nil +} diff --git a/internal/playback/video_worker_test.go b/internal/playback/video_worker_test.go new file mode 100644 index 0000000..76a48c0 --- /dev/null +++ b/internal/playback/video_worker_test.go @@ -0,0 +1,345 @@ +package playback + +import ( + "context" + "errors" + "testing" + "time" +) + +type videoOpenResult struct { + reader VideoReader + err error +} + +type scriptedVideoFactory struct { + results []videoOpenResult + calls int +} + +func (f *scriptedVideoFactory) OpenVideo( + context.Context, + FeedConfig, +) (VideoReader, error) { + if f.calls >= len(f.results) { + return nil, errors.New("unexpected video open attempt") + } + result := f.results[f.calls] + f.calls++ + return result.reader, result.err +} + +func activeVideoConfig() FeedConfig { + return FeedConfig{ + Domain: "/dev/shm/mxl", + UUID: "video-uuid", + Active: true, + } +} + +func newTestVideoWorker( + t *testing.T, + factory VideoReaderFactory, + sink VideoSink, + maxAttempts int, + shouldRetry func(error) bool, + observer StatusObserver, +) *VideoWorker { + t.Helper() + + worker, err := NewVideoWorker( + factory, + sink, + testRetryPolicy(maxAttempts), + shouldRetry, + observer, + ) + if err != nil { + t.Fatalf("NewVideoWorker() error = %v", err) + } + worker.wait = func(context.Context, time.Duration) error { return nil } + return worker +} + +func TestNewVideoWorkerValidatesDependencies(t *testing.T) { + factory := &scriptedVideoFactory{} + sink := &fakeVideoSink{} + retry := testRetryPolicy(3) + decide := func(error) bool { return true } + + tests := []struct { + name string + factory VideoReaderFactory + sink VideoSink + retry RetryPolicy + shouldRetry func(error) bool + wantErr error + }{ + { + name: "missing factory", + sink: sink, + retry: retry, + shouldRetry: decide, + wantErr: ErrVideoFactoryRequired, + }, + { + name: "missing sink", + factory: factory, + retry: retry, + shouldRetry: decide, + wantErr: ErrVideoSinkRequired, + }, + { + name: "missing retry decider", + factory: factory, + sink: sink, + retry: retry, + wantErr: ErrVideoRetryDeciderRequired, + }, + { + name: "invalid retry policy", + factory: factory, + sink: sink, + retry: RetryPolicy{}, + shouldRetry: decide, + wantErr: ErrInvalidRetryDelay, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + worker, err := NewVideoWorker( + tt.factory, + tt.sink, + tt.retry, + tt.shouldRetry, + nil, + ) + if worker != nil { + t.Fatal("NewVideoWorker() worker is not nil") + } + if !errors.Is(err, tt.wantErr) { + t.Fatalf("NewVideoWorker() error = %v, want %v", err, tt.wantErr) + } + }) + } +} + +func TestVideoWorkerRejectsInactiveFeed(t *testing.T) { + factory := &scriptedVideoFactory{} + var statuses []Status + worker := newTestVideoWorker( + t, + factory, + &fakeVideoSink{}, + 3, + func(error) bool { return true }, + func(status Status) { statuses = append(statuses, status) }, + ) + config := activeVideoConfig() + config.Active = false + + err := worker.Run(context.Background(), config) + if !errors.Is(err, ErrVideoFeedInactive) { + t.Fatalf("Run() error = %v, want %v", err, ErrVideoFeedInactive) + } + if factory.calls != 0 { + t.Errorf("factory calls = %d, want 0", factory.calls) + } + if len(statuses) != 0 { + t.Errorf("status count = %d, want 0", len(statuses)) + } +} + +func TestVideoWorkerStatusesInheritGeneration(t *testing.T) { + openErr := errors.New("unavailable") + var statuses []Status + worker := newTestVideoWorker( + t, + &scriptedVideoFactory{results: []videoOpenResult{{err: openErr}}}, + &fakeVideoSink{}, + 1, + func(error) bool { return true }, + func(status Status) { statuses = append(statuses, status) }, + ) + config := activeVideoConfig() + _ = worker.Run(withGeneration(context.Background(), 7), config) + if len(statuses) == 0 { + t.Fatal("no statuses emitted") + } + for _, status := range statuses { + if status.Generation != 7 { + t.Fatalf("status generation = %d, want 7: %+v", status.Generation, status) + } + if status.Feed != config { + t.Fatalf("status feed = %#v, want %#v", status.Feed, config) + } + } +} + +func TestVideoWorkerExhaustsOpenRetries(t *testing.T) { + openErr := errors.New("producer unavailable") + factory := &scriptedVideoFactory{ + results: []videoOpenResult{ + {err: openErr}, + {err: openErr}, + }, + } + var statuses []Status + worker := newTestVideoWorker( + t, + factory, + &fakeVideoSink{}, + 2, + func(error) bool { return true }, + func(status Status) { statuses = append(statuses, status) }, + ) + + err := worker.Run(context.Background(), activeVideoConfig()) + if !errors.Is(err, openErr) { + t.Fatalf("Run() error = %v, want %v", err, openErr) + } + if factory.calls != 2 { + t.Errorf("factory calls = %d, want 2", factory.calls) + } + + wantStates := []State{ + StateConnecting, + StateReconnecting, + StateReconnecting, + StateFailed, + } + if len(statuses) != len(wantStates) { + t.Fatalf("status count = %d, want %d: %+v", len(statuses), len(wantStates), statuses) + } + for i, want := range wantStates { + if statuses[i].State != want { + t.Errorf("status %d state = %v, want %v", i, statuses[i].State, want) + } + if statuses[i].Unit != UnitVideo { + t.Errorf("status %d unit = %v, want %v", i, statuses[i].Unit, UnitVideo) + } + } + final := statuses[len(statuses)-1] + if final.Attempt != 2 || final.FailedAttempts != 2 { + t.Errorf("final status = %+v, want attempt=2 failedAttempts=2", final) + } + if !errors.Is(final.Err, openErr) { + t.Errorf("final error = %v, want %v", final.Err, openErr) + } +} + +func TestVideoWorkerStablePlaybackResetsRetryCounter(t *testing.T) { + readErr := errors.New("video disconnected") + ctx, cancel := context.WithCancel(context.Background()) + first := &fakeVideoReader{ + frames: []VideoFrame{{Index: 1, Payload: []byte{1}}}, + readErr: readErr, + } + second := &fakeVideoReader{ + frames: []VideoFrame{{Index: 2, Payload: []byte{2}}}, + readErr: readErr, + } + third := &fakeVideoReader{ + read: func(ctx context.Context) (VideoFrame, error) { + cancel() + return VideoFrame{}, ctx.Err() + }, + } + factory := &scriptedVideoFactory{ + results: []videoOpenResult{ + {reader: first}, + {reader: second}, + {reader: third}, + }, + } + var statuses []Status + worker := newTestVideoWorker( + t, + factory, + &fakeVideoSink{}, + 2, + func(error) bool { return true }, + func(status Status) { statuses = append(statuses, status) }, + ) + + err := worker.Run(ctx, activeVideoConfig()) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Run() error = %v, want context.Canceled", err) + } + if factory.calls != 3 { + t.Fatalf("factory calls = %d, want 3", factory.calls) + } + + var playingAttempts []int + var retryFailures []int + for _, status := range statuses { + switch status.State { + case StatePlaying: + playingAttempts = append(playingAttempts, status.Attempt) + case StateReconnecting: + if status.RetryIn > 0 { + retryFailures = append(retryFailures, status.FailedAttempts) + } + } + } + if len(playingAttempts) != 2 || playingAttempts[0] != 1 || playingAttempts[1] != 2 { + t.Errorf("playing attempts = %v, want [1 2]", playingAttempts) + } + if len(retryFailures) != 2 || retryFailures[0] != 1 || retryFailures[1] != 1 { + t.Errorf("retry failure counts = %v, want [1 1]", retryFailures) + } + wantEnding := []State{StateStopping, StateIdle} + if len(statuses) < 2 { + t.Fatalf("status count = %d, want at least 2", len(statuses)) + } + ending := statuses[len(statuses)-2:] + for i, want := range wantEnding { + if ending[i].State != want { + t.Errorf("ending status %d = %v, want %v", i, ending[i].State, want) + } + } +} + +func TestVideoWorkerDoesNotRetrySinkFailure(t *testing.T) { + sinkErr := errors.New("renderer failed") + reader := &fakeVideoReader{ + frames: []VideoFrame{{Index: 1, Payload: []byte{1}}}, + } + factory := &scriptedVideoFactory{ + results: []videoOpenResult{{reader: reader}}, + } + deciderCalls := 0 + var statuses []Status + worker := newTestVideoWorker( + t, + factory, + &fakeVideoSink{err: sinkErr}, + 0, + func(error) bool { + deciderCalls++ + return true + }, + func(status Status) { statuses = append(statuses, status) }, + ) + + err := worker.Run(context.Background(), activeVideoConfig()) + if !errors.Is(err, sinkErr) { + t.Fatalf("Run() error = %v, want %v", err, sinkErr) + } + if factory.calls != 1 { + t.Errorf("factory calls = %d, want 1", factory.calls) + } + if deciderCalls != 0 { + t.Errorf("source retry decider calls = %d, want 0", deciderCalls) + } + if !reader.closed { + t.Fatal("reader was not closed") + } + if len(statuses) != 2 { + t.Fatalf("status count = %d, want 2: %+v", len(statuses), statuses) + } + if statuses[0].State != StateConnecting || statuses[1].State != StateFailed { + t.Errorf("status states = [%v %v], want [Connecting Failed]", statuses[0].State, statuses[1].State) + } +} diff --git a/internal/renderer/renderer.go b/internal/renderer/renderer.go index 8dbbefe..9a7b2bc 100644 --- a/internal/renderer/renderer.go +++ b/internal/renderer/renderer.go @@ -3,17 +3,20 @@ package renderer import ( "errors" "fmt" - "mxl-player/internal/sdl" "unsafe" + "mxl-player/internal/sdl" + "github.com/christerso/vulkan-go/vk" ) -// Raises by swapchain creation, whem windos is minimized -var ErrMinimized = errors.New("window minimized") - -// ErrOutOfDate is returned by DrawFrame when the swapchain needs recreation. -var ErrOutOfDate = errors.New("swapchain out of date") +var ( + // Raises by swapchain creation, whem windos is minimized + ErrMinimized = errors.New("window minimized") + // ErrOutOfDate is returned by DrawFrame when the swapchain needs recreation. + ErrOutOfDate = errors.New("swapchain out of date") + ErrInvalidVideoFrame = errors.New("invalid video frame") +) // shader push-constants block type PushConstants struct { @@ -392,6 +395,64 @@ func (r *Renderer) RecreateBuffers(newSize vk.DeviceSize) error { return nil } +func (r *Renderer) StageFrame( + payload []byte, + width uint32, + height uint32, + stride uint32, +) error { + frameSize, err := validateFramePayload(len(payload), width, height, stride) + if err != nil { + return err + } + + if frameSize != r.FrameSize() { + // RecreateBuffers waits for the device to become idle. + if err := r.RecreateBuffers(frameSize); err != nil { + return fmt.Errorf("resize video buffers: %w", err) + } + } else { + // The previous submitted frame may still read the mapped staging buffer. + if err := r.dev.WaitFence(r.inFlight, ^uint64(0)); err != nil { + return fmt.Errorf("wait before staging video: %w", err) + } + } + + vk.CopyToMapped( + r.StagingMapped(), + payload[:int(frameSize)], + ) + return nil +} + +func validateFramePayload( + payloadLen int, + width uint32, + height uint32, + stride uint32, +) (vk.DeviceSize, error) { + if width == 0 || height == 0 || stride == 0 { + return 0, fmt.Errorf( + "%w: dimensions=%dx%d stride=%d", + ErrInvalidVideoFrame, + width, + height, + stride, + ) + } + + requiredSize := uint64(stride) * uint64(height) + if payloadLen < 0 || requiredSize > uint64(payloadLen) { + return 0, fmt.Errorf( + "%w: payload=%d required=%d", + ErrInvalidVideoFrame, + payloadLen, + requiredSize, + ) + } + return vk.DeviceSize(requiredSize), nil +} + // DrawFrame acquires an image, records commands, submits, and presents. // Returns ErrOutOfDate if the swapchain needs recreation func (r *Renderer) DrawFrame(videoW, videoH, stride uint32) error { diff --git a/internal/renderer/renderer_test.go b/internal/renderer/renderer_test.go new file mode 100644 index 0000000..3ecdcc5 --- /dev/null +++ b/internal/renderer/renderer_test.go @@ -0,0 +1,122 @@ +package renderer + +import ( + "errors" + "strconv" + "testing" + + "github.com/christerso/vulkan-go/vk" +) + +func TestValidateFramePayload(t *testing.T) { + tests := []struct { + name string + payloadLen int + width uint32 + height uint32 + stride uint32 + wantSize vk.DeviceSize + wantErr bool + }{ + { + name: "valid frame", + payloadLen: 5120 * 1080, + width: 1920, + height: 1080, + stride: 5120, + wantSize: vk.DeviceSize(5120 * 1080), + }, + { + name: "payload may be larger than frame", + payloadLen: 5120*1080 + 128, + width: 1920, + height: 1080, + stride: 5120, + wantSize: vk.DeviceSize(5120 * 1080), + }, + { + name: "zero width", + payloadLen: 100, + height: 10, + stride: 10, + wantErr: true, + }, + { + name: "zero height", + payloadLen: 100, + width: 10, + stride: 10, + wantErr: true, + }, + { + name: "zero stride", + payloadLen: 100, + width: 10, + height: 10, + wantErr: true, + }, + { + name: "payload is too small", + payloadLen: 99, + width: 10, + height: 10, + stride: 10, + wantErr: true, + }, + { + name: "negative payload length", + payloadLen: -1, + width: 10, + height: 10, + stride: 10, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := validateFramePayload( + tt.payloadLen, + tt.width, + tt.height, + tt.stride, + ) + + if tt.wantErr { + if !errors.Is(err, ErrInvalidVideoFrame) { + t.Fatalf("validateFramePayload() error = %v, want %v", err, ErrInvalidVideoFrame) + } + return + } + if err != nil { + t.Fatalf("validateFramePayload() error = %v, want nil", err) + } + if got != tt.wantSize { + t.Errorf("validateFramePayload() size = %d, want %d", got, tt.wantSize) + } + }) + } +} + +func TestValidateFramePayloadUses64BitSize(t *testing.T) { + if strconv.IntSize < 64 { + t.Skip("test requires a 64-bit int") + } + + stride := ^uint32(0) + height := uint32(2) + required := uint64(stride) * uint64(height) + + got, err := validateFramePayload( + int(required), + 1, + height, + stride, + ) + if err != nil { + t.Fatalf("validateFramePayload() error = %v, want nil", err) + } + if uint64(got) != required { + t.Fatalf("validateFramePayload() size = %d, want %d", got, required) + } +} diff --git a/internal/sdl/sdl.go b/internal/sdl/sdl.go index 6e50ba1..b8cb2e4 100644 --- a/internal/sdl/sdl.go +++ b/internal/sdl/sdl.go @@ -32,6 +32,7 @@ const ( KeyF uint32 = 0x66 KeyQ uint32 = 0x71 KeyF1 uint32 = 0x4000003A + KeyF2 uint32 = 0x4000003B InitAudio uint32 = 0x00000010 AudioDeviceDefaultPlayback uint32 = 0xFFFFFFFF diff --git a/internal/source/errors.go b/internal/source/errors.go new file mode 100644 index 0000000..bdc262c --- /dev/null +++ b/internal/source/errors.go @@ -0,0 +1,53 @@ +package source + +import ( + "errors" + "fmt" +) + +type ErrorKind uint8 + +const ( + ErrorKindUnknown ErrorKind = iota + ErrorKindTemporary // timeout or temporarily early/late data i.e. wait or resync + ErrorKindUnavailable // producer/flow disappeared + ErrorKindInvalidConfig // wrong media type, invalid rate, etc. +) + +type SourceError struct { + Op string + Kind ErrorKind + Err error +} + +func (e *SourceError) Error() string { + if e.Op == "" { + return e.Err.Error() + } + return fmt.Sprintf("%s: %v", e.Op, e.Err) +} + +func (e *SourceError) Unwrap() error { + return e.Err +} + +// KindOf returns the source error category contained in err. +// It returns ErrorKindUnknown when err has no SourceError in its chain. +func KindOf(err error) ErrorKind { + var sourceErr *SourceError + if errors.As(err, &sourceErr) { + return sourceErr.Kind + } + return ErrorKindUnknown +} + +func wrapError(op string, kind ErrorKind, err error) error { + if err == nil { + return nil + } + return &SourceError{ + Op: op, + Kind: kind, + Err: err, + } +} diff --git a/internal/source/errors_test.go b/internal/source/errors_test.go new file mode 100644 index 0000000..56786f9 --- /dev/null +++ b/internal/source/errors_test.go @@ -0,0 +1,54 @@ +package source + +import ( + "errors" + "fmt" + "testing" +) + +func TestKindOfThroughWrapping(t *testing.T) { + base := errors.New("producer disappeared") + wrapped := wrapError("read video", ErrorKindUnavailable, base) + outer := fmt.Errorf("worker failed: %w", wrapped) + + if got := KindOf(outer); got != ErrorKindUnavailable { + t.Fatalf("KindOf() = %v, want %v", got, ErrorKindUnavailable) + } + if !errors.Is(outer, base) { + t.Fatal("wrapped error does not preserve its cause") + } + if wrapError("nil", 0, nil) != nil { + t.Fatal("nil error is not wrapped as nil") + } +} + +func TestKindOfUnknown(t *testing.T) { + if got := KindOf(errors.New("ordinary error")); got != ErrorKindUnknown { + t.Fatalf("KindOf() = %v, want %v", got, ErrorKindUnknown) + } +} + +func TestSourceErrorWithOperation(t *testing.T) { + err := &SourceError{ + Op: "read video", + Kind: ErrorKindTemporary, + Err: errors.New("timeout"), + } + + const want = "read video: timeout" + if got := err.Error(); got != want { + t.Fatalf("Error() = %q, want %q", got, want) + } +} + +func TestSourceErrorWithoutOperation(t *testing.T) { + err := &SourceError{ + Kind: ErrorKindTemporary, + Err: errors.New("timeout"), + } + + const want = "timeout" + if got := err.Error(); got != want { + t.Fatalf("Error() = %q, want %q", got, want) + } +} diff --git a/internal/source/source.go b/internal/source/source.go index d3a4aca..9670311 100644 --- a/internal/source/source.go +++ b/internal/source/source.go @@ -11,6 +11,7 @@ import ( ) type flowDef struct { + Label string `json:"label"` FrameWidth int `json:"frame_width"` FrameHeight int `json:"frame_height"` MediaType string `json:"media_type"` @@ -21,14 +22,31 @@ type flowDef struct { } `json:"grain_rate"` } +func flowLabel(inst *mxl.Instance, flowID string) string { + definition, err := inst.FlowDef(flowID) + if err != nil { + return "" + } + var metadata struct { + Label string `json:"label"` + } + if json.Unmarshal([]byte(definition), &metadata) != nil { + return "" + } + return metadata.Label +} + type Frame struct { - Index uint64 - Width uint32 - Height uint32 - Stride uint32 - Size uint32 - Invalid bool - Payload []byte + Index uint64 + Width uint32 + Height uint32 + Stride uint32 + Size uint32 + Invalid bool + Label string + FrameRateNumerator int64 + FrameRateDenominator int64 + Payload []byte } type Source struct { @@ -40,48 +58,66 @@ type Source struct { stride uint32 width uint32 height uint32 + label string idx uint64 } func Open(domain, flowID string) (*Source, error) { inst, err := mxl.NewInstance(domain, "") if err != nil { - return nil, fmt.Errorf("NewInstance: %w", err) + return nil, wrapError("new MXL instance", ErrorKindUnavailable, err) } r, err := inst.NewReader(flowID) if err != nil { inst.Close() - return nil, fmt.Errorf("NewReader: %w", err) + return nil, wrapError("open video reader", ErrorKindUnavailable, err) } info, err := r.Info() if err != nil { r.Close() inst.Close() - return nil, fmt.Errorf("Info: %w", err) + return nil, wrapError("get video info", ErrorKindUnavailable, err) } def, err := inst.FlowDef(flowID) if err != nil { r.Close() inst.Close() - return nil, fmt.Errorf("FlowDef: %w", err) + return nil, wrapError("read video flow definition", ErrorKindUnavailable, err) } var fd flowDef if err := json.Unmarshal([]byte(def), &fd); err != nil { r.Close() inst.Close() - return nil, fmt.Errorf("parse flow def: %w", err) + return nil, wrapError("parse video flow definition JSON", ErrorKindInvalidConfig, err) } if fd.FrameWidth == 0 || fd.FrameHeight == 0 { r.Close() inst.Close() - return nil, fmt.Errorf("flow has no video dimensions (not a video flow?)") + return nil, wrapError( + "validate video flow", + ErrorKindInvalidConfig, + errors.New("flow has no video dimensions"), + ) } rate := info.Config.Common.GrainRate idx := mxl.CurrentIndex(rate) if idx == mxl.UndefinedIndex { r.Close() inst.Close() - return nil, fmt.Errorf("invalid grain rate: %d/%d", rate.Num, rate.Den) + return nil, wrapError( + "validate video flow", + ErrorKindInvalidConfig, + fmt.Errorf("invalid grain rate: %d/%d", rate.Num, rate.Den), + ) + } + if len(info.Config.Discrete.SliceSizes) == 0 { + r.Close() + inst.Close() + return nil, wrapError( + "validate video flow", + ErrorKindInvalidConfig, + errors.New("video flow has no slice sizes"), + ) } return &Source{ inst: inst, @@ -92,6 +128,7 @@ func Open(domain, flowID string) (*Source, error) { stride: info.Config.Discrete.SliceSizes[0], width: uint32(fd.FrameWidth), height: uint32(fd.FrameHeight), + label: fd.Label, idx: idx, }, nil } @@ -101,64 +138,95 @@ func (s *Source) Close() error { return s.inst.Close() } -func (s *Source) Next(timeout time.Duration) (Frame, error) { +func (s *Source) NextCtx(ctx context.Context, timeout time.Duration) (Frame, error) { for { - g, err := s.reader.GetGrain(s.idx, timeout) - switch { - case err == nil: - f := Frame{ - Index: g.Index, - Width: s.width, - Height: s.height, - Stride: s.stride, - Size: g.GrainSize, - Invalid: g.Invalid(), - Payload: g.Payload, + frame, err := s.ReadOnceCtx(ctx, timeout) + if err == nil { + return frame, nil + } + if ctx.Err() != nil { + return Frame{}, ctx.Err() + } + if KindOf(err) != ErrorKindTemporary { + return Frame{}, err + } + + if errors.Is(err, mxl.ErrOutOfRangeEarly) { + select { + case <-time.After(10 * time.Millisecond): + case <-ctx.Done(): + return Frame{}, ctx.Err() } - s.idx++ - return f, nil - case errors.Is(err, mxl.ErrTimeout): - s.idx = mxl.CurrentIndex(s.rate) - case errors.Is(err, mxl.ErrOutOfRangeEarly): - time.Sleep(10 * time.Millisecond) - case errors.Is(err, mxl.ErrOutOfRangeLate): - s.idx = mxl.CurrentIndex(s.rate) - default: - return Frame{}, fmt.Errorf("GetGrain: %w", err) } } } -func (s *Source) NextCtx(ctx context.Context, timeout time.Duration) (Frame, error) { - for { - select { - case <-ctx.Done(): - return Frame{}, ctx.Err() - default: - } - g, err := s.reader.GetGrain(s.idx, timeout) - switch { - case err == nil: - f := Frame{ - Index: g.Index, - Width: s.width, - Height: s.height, - Stride: s.stride, - Size: g.GrainSize, - Invalid: g.Invalid(), - Payload: g.Payload, - } - s.idx++ - return f, nil - case errors.Is(err, mxl.ErrTimeout): - s.idx = mxl.CurrentIndex(s.rate) - case errors.Is(err, mxl.ErrOutOfRangeEarly): - time.Sleep(10 * time.Millisecond) - case errors.Is(err, mxl.ErrOutOfRangeLate): - s.idx = mxl.CurrentIndex(s.rate) - default: - return Frame{}, fmt.Errorf("GetGrain: %w", err) +func (s *Source) ReadOnceCtx( + ctx context.Context, + timeout time.Duration, +) (Frame, error) { + if err := ctx.Err(); err != nil { + return Frame{}, err + } + + g, err := s.reader.GetGrain(s.idx, timeout) + if ctx.Err() != nil { + return Frame{}, ctx.Err() + } + + switch { + case err == nil: + frame := Frame{ + Index: g.Index, + Width: s.width, + Height: s.height, + Stride: s.stride, + Size: g.GrainSize, + Invalid: g.Invalid(), + Label: s.label, + FrameRateNumerator: s.rate.Num, + FrameRateDenominator: s.rate.Den, + Payload: g.Payload, } + s.idx++ + return frame, nil + + case errors.Is(err, mxl.ErrTimeout): + s.idx = mxl.CurrentIndex(s.rate) + return Frame{}, wrapError( + "read video", + ErrorKindTemporary, + err, + ) + + case errors.Is(err, mxl.ErrOutOfRangeEarly): + return Frame{}, wrapError( + "read video", + ErrorKindTemporary, + err, + ) + + case errors.Is(err, mxl.ErrOutOfRangeLate): + s.idx = mxl.CurrentIndex(s.rate) + return Frame{}, wrapError( + "read video", + ErrorKindTemporary, + err, + ) + + case errors.Is(err, mxl.ErrFlowInvalid): + return Frame{}, wrapError( + "read video", + ErrorKindUnavailable, + err, + ) + + default: + return Frame{}, wrapError( + "read video", + ErrorKindUnavailable, + err, + ) } } @@ -177,93 +245,176 @@ type AudioSource struct { rate mxl.Rational chans uint64 idx uint64 + label string } type AudioFrame struct { Index uint64 SampleCount uint64 Channels uint64 + Label string Samples [][]byte // per-channel byte slices (F32, deinterleaved) } func OpenAudio(domain, flowID string) (*AudioSource, error) { inst, err := mxl.NewInstance(domain, "") if err != nil { - return nil, fmt.Errorf("NewInstance: %w", err) + return nil, wrapError("new MXL instance", ErrorKindUnavailable, err) } r, err := inst.NewReader(flowID) if err != nil { inst.Close() - return nil, fmt.Errorf("NewReader: %w", err) + return nil, wrapError("open audio reader", ErrorKindUnavailable, err) } info, err := r.Info() if err != nil { r.Close() inst.Close() - return nil, fmt.Errorf("Info: %w", err) + return nil, wrapError("get audio info", ErrorKindUnavailable, err) } if info.Config.Common.Format.IsDiscrete() { r.Close() inst.Close() - return nil, fmt.Errorf("flow is discrete (not audio)") + return nil, wrapError( + "validate audio flow", + ErrorKindInvalidConfig, + errors.New("audio flow is discrete"), + ) + } + channels := uint64(info.Config.Continuous.ChannelCount) + if channels == 0 { + r.Close() + inst.Close() + return nil, wrapError( + "validate audio flow", + ErrorKindInvalidConfig, + errors.New("audio flow has no channels"), + ) + } + rate := info.Config.Common.GrainRate + if rate.Num <= 0 || rate.Den <= 0 { + r.Close() + inst.Close() + return nil, wrapError( + "validate audio flow", + ErrorKindInvalidConfig, + fmt.Errorf("invalid audio rate: %d/%d", rate.Num, rate.Den), + ) } idx := info.Runtime.HeadIndex if idx == 0 { r.Close() inst.Close() - return nil, fmt.Errorf("flow has no head yet (no producer?)") + return nil, wrapError( + "open audio reader", + ErrorKindUnavailable, + errors.New("audio flow has no producer data"), + ) } return &AudioSource{ inst: inst, r: r, info: info, - rate: info.Config.Common.GrainRate, - chans: uint64(info.Config.Continuous.ChannelCount), + rate: rate, + chans: channels, idx: idx, + label: flowLabel(inst, flowID), }, nil } -func (s *AudioSource) NextAudio(ctx context.Context, batch uint64, timeout time.Duration) (AudioFrame, error) { - for { - select { - case <-ctx.Done(): - return AudioFrame{}, ctx.Err() - default: +func (s *AudioSource) ReadAudioOnceCtx( + ctx context.Context, + batch uint64, + timeout time.Duration, +) (AudioFrame, error) { + if err := ctx.Err(); err != nil { + return AudioFrame{}, err + } + + value, err := s.r.GetSamples(s.idx, int(batch), timeout) + if ctxErr := ctx.Err(); ctxErr != nil { + return AudioFrame{}, ctxErr + } + + switch { + case err == nil: + samples := make([][]byte, s.chans) + for channel := uint64(0); channel < s.chans; channel++ { + first, second, _ := value.ChannelFragments(channel) + if len(second) > 0 { + samples[channel] = append(first, second...) + } else { + samples[channel] = first + } } - v, err := s.r.GetSamples(s.idx, int(batch), timeout) - switch { - case err == nil: - samples := make([][]byte, s.chans) - for ch := uint64(0); ch < s.chans; ch++ { - f1, f2, _ := v.ChannelFragments(ch) - if len(f2) > 0 { - samples[ch] = append(f1, f2...) - } else { - samples[ch] = f1 + + frame := AudioFrame{ + Index: s.idx, + SampleCount: batch, + Channels: s.chans, + Label: s.label, + Samples: samples, + } + s.idx += batch + return frame, nil + case errors.Is(err, mxl.ErrOutOfRangeEarly): + return AudioFrame{}, wrapError( + "read audio", + ErrorKindTemporary, + err, + ) + case errors.Is(err, mxl.ErrOutOfRangeLate): + runtimeInfo, runtimeErr := s.r.Runtime() + if runtimeErr != nil { + return AudioFrame{}, wrapError( + "read audio runtime", + ErrorKindUnavailable, + runtimeErr, + ) + } + s.idx = runtimeInfo.HeadIndex + return AudioFrame{}, wrapError( + "read audio", + ErrorKindTemporary, + err, + ) + default: + return AudioFrame{}, wrapError( + "read audio", + ErrorKindUnavailable, + err, + ) + } +} + +func (s *AudioSource) NextAudio( + ctx context.Context, + batch uint64, + timeout time.Duration, +) (AudioFrame, error) { + for { + frame, err := s.ReadAudioOnceCtx(ctx, batch, timeout) + if err == nil { + return frame, nil + } + if ctx.Err() != nil { + return AudioFrame{}, ctx.Err() + } + if KindOf(err) != ErrorKindTemporary { + return AudioFrame{}, err + } + + timer := time.NewTimer(10 * time.Millisecond) + select { + case <-timer.C: + case <-ctx.Done(): + if !timer.Stop() { + select { + case <-timer.C: + default: } } - f := AudioFrame{ - Index: s.idx, - SampleCount: batch, - Channels: s.chans, - Samples: samples, - } - s.idx += batch - return f, nil - case errors.Is(err, mxl.ErrOutOfRangeEarly): - select { - case <-time.After(10 * time.Millisecond): - case <-ctx.Done(): - return AudioFrame{}, ctx.Err() - } - case errors.Is(err, mxl.ErrOutOfRangeLate): - rt, rerr := s.r.Runtime() - if rerr != nil { - return AudioFrame{}, fmt.Errorf("Runtime: %w", rerr) - } - s.idx = rt.HeadIndex - default: - return AudioFrame{}, fmt.Errorf("GetSamples: %w", err) + return AudioFrame{}, ctx.Err() } } } @@ -277,33 +428,37 @@ func (s *AudioSource) Rate() mxl.Rational { return s.rate } func (s *AudioSource) Channels() uint64 { return s.chans } type SyncSource struct { - inst *mxl.Instance - vr *mxl.Reader - ar *mxl.Reader - group *mxl.SyncGroup - rate mxl.Rational // video rate - aRate mxl.Rational - chans uint64 - idx uint64 - width, height, stride uint32 + inst *mxl.Instance + vr *mxl.Reader + ar *mxl.Reader + group *mxl.SyncGroup + rate mxl.Rational // video rate + aRate mxl.Rational + chans uint64 + idx uint64 + width, height, stride uint32 + videoLabel, audioLabel string } -func OpenSync(domain, videoFlow, audioFlow string) (*SyncSource, error) { +// OpenSameDomainSync opens a native MXL synchronization group. +// Both feeds must belong to the supplied domain because go-mxl sync groups +// cannot contain readers from different MXL instances. +func OpenSameDomainSync(domain, videoFlow, audioFlow string) (*SyncSource, error) { inst, err := mxl.NewInstance(domain, "") if err != nil { - return nil, fmt.Errorf("NewInstance: %w", err) + return nil, wrapError("new MXL instance", ErrorKindUnavailable, err) } vr, err := inst.NewReader(videoFlow) if err != nil { inst.Close() - return nil, fmt.Errorf("NewReader(video): %w", err) + return nil, wrapError("open video reader", ErrorKindUnavailable, err) } ar, err := inst.NewReader(audioFlow) if err != nil { vr.Close() inst.Close() - return nil, fmt.Errorf("NewReader(audio): %w", err) + return nil, wrapError("open audio reader", ErrorKindUnavailable, err) } vInfo, err := vr.Info() @@ -311,26 +466,56 @@ func OpenSync(domain, videoFlow, audioFlow string) (*SyncSource, error) { ar.Close() vr.Close() inst.Close() - return nil, fmt.Errorf("Info(video): %w", err) + return nil, wrapError("get video info", ErrorKindUnavailable, err) } if !vInfo.Config.Common.Format.IsDiscrete() { ar.Close() vr.Close() inst.Close() - return nil, fmt.Errorf("video flow is not discrete") + return nil, wrapError( + "validate video flow", + ErrorKindInvalidConfig, + errors.New("video flow is continuous"), + ) } aInfo, err := ar.Info() if err != nil { ar.Close() vr.Close() inst.Close() - return nil, fmt.Errorf("Info(audio): %w", err) + return nil, wrapError("get audio info", ErrorKindUnavailable, err) } if aInfo.Config.Common.Format.IsDiscrete() { ar.Close() vr.Close() inst.Close() - return nil, fmt.Errorf("audio flow is not continuous") + return nil, wrapError( + "validate audio flow", + ErrorKindInvalidConfig, + errors.New("audio flow is discrete"), + ) + } + channels := uint64(aInfo.Config.Continuous.ChannelCount) + if channels == 0 { + ar.Close() + vr.Close() + inst.Close() + return nil, wrapError( + "validate audio flow", + ErrorKindInvalidConfig, + errors.New("audio flow has no channels"), + ) + } + aRate := aInfo.Config.Common.GrainRate + if aRate.Num <= 0 || aRate.Den <= 0 { + ar.Close() + vr.Close() + inst.Close() + return nil, wrapError( + "validate audio flow", + ErrorKindInvalidConfig, + fmt.Errorf("invalid audio rate: %d/%d", aRate.Num, aRate.Den), + ) } def, err := inst.FlowDef(videoFlow) @@ -338,14 +523,38 @@ func OpenSync(domain, videoFlow, audioFlow string) (*SyncSource, error) { ar.Close() vr.Close() inst.Close() - return nil, fmt.Errorf("FlowDef: %w", err) + return nil, wrapError("read video flow definition", ErrorKindUnavailable, err) } var fd flowDef if err := json.Unmarshal([]byte(def), &fd); err != nil { ar.Close() vr.Close() inst.Close() - return nil, fmt.Errorf("parse flow def: %w", err) + return nil, wrapError("parse video flow definition JSON", ErrorKindInvalidConfig, err) + } + if fd.FrameWidth <= 0 || fd.FrameHeight <= 0 { + ar.Close() + vr.Close() + inst.Close() + return nil, wrapError( + "validate video flow", + ErrorKindInvalidConfig, + fmt.Errorf( + "invalid video dimensions: %dx%d", + fd.FrameWidth, + fd.FrameHeight, + ), + ) + } + if len(vInfo.Config.Discrete.SliceSizes) == 0 { + ar.Close() + vr.Close() + inst.Close() + return nil, wrapError( + "validate video flow", + ErrorKindInvalidConfig, + errors.New("video flow has no slice sizes"), + ) } vRate := vInfo.Config.Common.GrainRate @@ -354,7 +563,11 @@ func OpenSync(domain, videoFlow, audioFlow string) (*SyncSource, error) { ar.Close() vr.Close() inst.Close() - return nil, fmt.Errorf("invalid video rate %d/%d", vRate.Num, vRate.Den) + return nil, wrapError( + "validate video flow", + ErrorKindInvalidConfig, + fmt.Errorf("invalid grain rate: %d/%d", vRate.Num, vRate.Den), + ) } group, err := inst.NewSyncGroup() @@ -362,35 +575,49 @@ func OpenSync(domain, videoFlow, audioFlow string) (*SyncSource, error) { ar.Close() vr.Close() inst.Close() - return nil, fmt.Errorf("NewSyncGroup: %w", err) + return nil, wrapError( + "create native sync group", + ErrorKindUnavailable, + err, + ) } if err := group.AddReader(vr); err != nil { group.Close() ar.Close() vr.Close() inst.Close() - return nil, fmt.Errorf("AddReader(video): %w", err) + return nil, wrapError( + "add video reader to native sync group", + ErrorKindUnavailable, + err, + ) } if err := group.AddReader(ar); err != nil { group.Close() ar.Close() vr.Close() inst.Close() - return nil, fmt.Errorf("AddReader(audio): %w", err) + return nil, wrapError( + "add audio reader to native sync group", + ErrorKindUnavailable, + err, + ) } return &SyncSource{ - inst: inst, - vr: vr, - ar: ar, - group: group, - rate: vRate, - aRate: aInfo.Config.Common.GrainRate, - chans: uint64(aInfo.Config.Continuous.ChannelCount), - idx: idx, - width: uint32(fd.FrameWidth), - height: uint32(fd.FrameHeight), - stride: vInfo.Config.Discrete.SliceSizes[0], + inst: inst, + vr: vr, + ar: ar, + group: group, + rate: vRate, + aRate: aRate, + chans: channels, + idx: idx, + width: uint32(fd.FrameWidth), + height: uint32(fd.FrameHeight), + stride: vInfo.Config.Discrete.SliceSizes[0], + videoLabel: fd.Label, + audioLabel: flowLabel(inst, audioFlow), }, nil } @@ -401,8 +628,10 @@ func (s *SyncSource) Close() error { return s.inst.Close() } -// NextSync reads both at a synced timestamp. Returns video Frame + audio AudioFrame -func (s *SyncSource) NextSync(ctx context.Context, audioBatch uint64, timeout time.Duration) (Frame, AudioFrame, error) { +// NextSync reads one video frame and the audio interval between this video +// timestamp and the next. Deriving the interval for every frame preserves +// exact long-term timing for fractional video rates. +func (s *SyncSource) NextSync(ctx context.Context, timeout time.Duration) (Frame, AudioFrame, error) { var timeouts int for { select { @@ -438,31 +667,54 @@ func (s *SyncSource) NextSync(ctx context.Context, audioBatch uint64, timeout ti } // read audio at the same timestamp aIdx := mxl.TimestampToIndex(s.aRate, ts) + nextTimestamp := mxl.IndexToTimestamp(s.rate, s.idx+1) + nextAudioIndex := mxl.TimestampToIndex(s.aRate, nextTimestamp) + if nextAudioIndex <= aIdx { + return Frame{}, AudioFrame{}, wrapError( + "calculate synchronized audio interval", + ErrorKindInvalidConfig, + fmt.Errorf("invalid audio interval: %d..%d", aIdx, nextAudioIndex), + ) + } + audioBatch := nextAudioIndex - aIdx av, aerr := s.ar.GetSamples(aIdx, int(audioBatch), 50*time.Millisecond) + if aerr != nil { + kind := ErrorKindUnavailable + if errors.Is(aerr, mxl.ErrTimeout) || + errors.Is(aerr, mxl.ErrOutOfRangeEarly) || + errors.Is(aerr, mxl.ErrOutOfRangeLate) { + kind = ErrorKindTemporary + } + return Frame{}, AudioFrame{}, wrapError( + "read synchronized audio", + kind, + aerr, + ) + } vFrame := Frame{ Index: g.Index, Width: s.width, Height: s.height, Stride: s.stride, Size: g.GrainSize, Invalid: g.Invalid(), Payload: g.Payload, + Label: s.videoLabel, + FrameRateNumerator: s.rate.Num, + FrameRateDenominator: s.rate.Den, } s.idx++ - var aFrame AudioFrame - if aerr == nil { - samples := make([][]byte, s.chans) - for ch := uint64(0); ch < s.chans; ch++ { - f1, f2, _ := av.ChannelFragments(ch) - if len(f2) > 0 { - samples[ch] = append(f1, f2...) - } else { - samples[ch] = f1 - } - } - aFrame = AudioFrame{ - Index: aIdx, SampleCount: audioBatch, - Channels: s.chans, Samples: samples, + samples := make([][]byte, s.chans) + for ch := uint64(0); ch < s.chans; ch++ { + f1, f2, _ := av.ChannelFragments(ch) + if len(f2) > 0 { + samples[ch] = append(f1, f2...) + } else { + samples[ch] = f1 } } - // even if audio failed, video returns + aFrame := AudioFrame{ + Index: aIdx, SampleCount: audioBatch, + Channels: s.chans, Samples: samples, + Label: s.audioLabel, + } return vFrame, aFrame, nil case errors.Is(err, mxl.ErrTimeout), errors.Is(err, mxl.ErrOutOfRangeEarly), errors.Is(err, mxl.ErrOutOfRangeLate): timeouts++ diff --git a/internal/test.go b/internal/test.go deleted file mode 100644 index 6b4b9fa..0000000 --- a/internal/test.go +++ /dev/null @@ -1,7 +0,0 @@ -package main - -import "mxl-player/internal/imgui" - -func main() { - imgui.New() -} diff --git a/mxl-gst-scripts/loop-feed3.sh b/mxl-gst-scripts/loop-feed3.sh new file mode 100755 index 0000000..a1e2b65 --- /dev/null +++ b/mxl-gst-scripts/loop-feed3.sh @@ -0,0 +1,12 @@ +#!/bin/bash +VIDEO_ID="2618979d-76a5-45e0-83cb-0f192978d1cd" +AUDIO_ID="9d2a041b-01cf-4ee4-bffa-188fe093c99b" +VIDEO_URI=$1 +if [[ -z "${VIDEO_URI}" ]] then + VIDEO_URI="${HOME}/Videos/test-vid/motogp.ts" +fi +export GST_PLUGIN_PATH="${HOME}/.gst-plugin:${GST_PLUGIN_PATH}" +mxl-gst-looping-filesrc -d /dev/shm/mxl -i "${VIDEO_URI}" --video-id "${VIDEO_ID}" --audio-id "${AUDIO_ID}" 2>/dev/null +# sleep 5 +# kill -9 $(pidof "mxl-gst-looping-filesrc") +# echo -e "\ngst-looping-filesrc killed" diff --git a/mxl-player b/mxl-player deleted file mode 100755 index 54440f8..0000000 Binary files a/mxl-player and /dev/null differ diff --git a/notes.md b/notes.md new file mode 100644 index 0000000..ff5cdcc --- /dev/null +++ b/notes.md @@ -0,0 +1,6 @@ +That makes sense. The GUI is likely more responsive because frame staging is now serialized with rendering: +- The background goroutine no longer copies a large frame into Vulkan-mapped memory concurrently with GUI/render work. +- StageFrame waits for the GPU fence before writing, removing CPU/GPU memory contention and undefined synchronization. +- The bridge creates deterministic backpressure: the source cannot begin another frame until the current payload is staged. +- The main thread now controls the complete render sequence instead of coordinating loosely through two channels. +So we fixed both correctness and scheduling stability without adding another frame copy. diff --git a/playlists/sample-list-all.json b/playlists/sample-list-all.json new file mode 100644 index 0000000..6214a86 --- /dev/null +++ b/playlists/sample-list-all.json @@ -0,0 +1,34 @@ +{ + "loop": true, + "on_failure": "next", + "entries": [ + { + "name": "timelapse", + "video": { + "domain": "/dev/shm/mxl", + "uuid": "5fbec3b1-1b0f-417d-9059-8b94a47197ed" + }, + "audio": { + "domain": "/dev/shm/mxl", + "uuid": "5fbec3b1-1b0f-417d-9059-8b94a47197ec" + }, + "sync": true, + "duration": "10s" + }, + { + "name": "F1", + "video": { + "domain": "/dev/shm/mxl", + "uuid": "5fbec3b1-1b0f-417d-9059-8b94a47197ef" + }, + "duration": "15s" + }, + { + "name": "Costa Rica", + "audio": { + "domain": "/dev/shm/mxl", + "uuid": "9d2a041b-01cf-4ee4-bffa-188fe093c99b" + } + } + ] +} diff --git a/playlists/sample-list.json b/playlists/sample-list.json new file mode 100644 index 0000000..ffce9b2 --- /dev/null +++ b/playlists/sample-list.json @@ -0,0 +1,58 @@ +{ + "loop": true, + "on_failure": "next", + "entries": [ + { + "name": "timelapse", + "video": { + "domain": "/dev/shm/mxl", + "uuid": "5fbec3b1-1b0f-417d-9059-8b94a47197ed" + }, + "audio": { + "domain": "/dev/shm/mxl", + "uuid": "5fbec3b1-1b0f-417d-9059-8b94a47197ec" + }, + "sync": true, + "duration": "10s" + }, + { + "name": "fail", + "video": { + "domain": "/dev/shm/mxl", + "uuid": "6fbec3b1-1b0f-417d-9059-8b94a47197ed" + }, + "audio": { + "domain": "/dev/shm/mxl", + "uuid": "6fbec3b1-1b0f-417d-9059-8b94a47197ec" + }, + "sync": true, + "duration": "10s" + }, + { + "name": "F1 Highlights", + "video": { + "domain": "/dev/shm/mxl", + "uuid": "5fbec3b1-1b0f-417d-9059-8b94a47197ef" + }, + "audio": { + "domain": "/dev/shm/mxl", + "uuid": "5fbec3b1-1b0f-417d-9059-8b94a47197eb" + }, + "sync": true, + "duration": "10s" + }, + { + "name": "Costa Rica", + "video": { + "domain": "/dev/shm/mxl", + "uuid": "2618979d-76a5-45e0-83cb-0f192978d1cd" + }, + "audio": { + "domain": "/dev/shm/mxl", + "uuid": "9d2a041b-01cf-4ee4-bffa-188fe093c99b" + }, + "sync": true, + "duration": "10s" + } + ] +}