From 362ae15867521691acb89f7f7609f6934e6dd19d Mon Sep 17 00:00:00 2001 From: Dmitry Sergeev Date: Tue, 1 Sep 2026 21:58:20 +0300 Subject: [PATCH] JSON playlist loader --- cmd/mxl-player/playlist_file.go | 100 +++++++++++++++ cmd/mxl-player/playlist_file_test.go | 174 +++++++++++++++++++++++++++ 2 files changed, 274 insertions(+) create mode 100644 cmd/mxl-player/playlist_file.go create mode 100644 cmd/mxl-player/playlist_file_test.go diff --git a/cmd/mxl-player/playlist_file.go b/cmd/mxl-player/playlist_file.go new file mode 100644 index 0000000..2a6efb7 --- /dev/null +++ b/cmd/mxl-player/playlist_file.go @@ -0,0 +1,100 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "time" + + "mxl-player/internal/playback" +) + +type playlistFile struct { + Entries []playlistFileEntry `json:"entries"` + Loop bool `json:"loop"` +} + +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, + } + 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..9505baa --- /dev/null +++ b/cmd/mxl-player/playlist_file_test.go @@ -0,0 +1,174 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "mxl-player/internal/playback" +) + +func TestDecodePlaylistFile(t *testing.T) { + input := `{ + "loop": true, + "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, + 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 { + 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 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) + } +}