JSON playlist loader
This commit is contained in:
@@ -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}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user