streaming

Media streaming and broadcast systems in Go
Log | Files | Refs | README | LICENSE

commit a805cd1b836e6549099a0ad8c3a88af6404af9bb
parent 716419e5b62a9159acefcb1b070849a3e782643b
Author: Oliver Lowe <o@olowe.co>
Date:   Thu, 27 Jun 2024 14:19:41 +1000

cair: add new package for Cinegy Air HTTP API

Diffstat:
Acair/cair.go | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acair/duration_test.go | 34++++++++++++++++++++++++++++++++++
Acair/playlist.go | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acair/playlist_test.go | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acair/status_test.go | 27+++++++++++++++++++++++++++
Acair/testdata/playlist.xml | 10++++++++++
6 files changed, 389 insertions(+), 0 deletions(-)

diff --git a/cair/cair.go b/cair/cair.go @@ -0,0 +1,113 @@ +// Package cair provides a client to version 24.1 of the Cinegy Air +// HTTP API as documented at +// https://open.cinegy.com/products/air/24.1/cinegy-air-http-api/. +// +// Typical usage starts with creating a Client, then fetching the +// current playlist by calling Playlist on Client. For example, to print +// the names of all future items that are 3 minutes or less (e.g. +// station breaks): +// +// client := &Client{http.DefaultClient, "http://air.example.com:5521") +// playlist, err := client.Playlist("video") +// if err != nil { +// // handle error... +// } +// for _, it := range playlist.Items { +// if it.ScheduledAt.Before(time.Now()) { +// continue +// } +// if it.Duration <= 3*time.Minute { +// fmt.Println(it.Name) +// } +// } +package cair + +import ( + "encoding/xml" + "fmt" + "net/http" + "path" +) + +const DefaultPort = 5521 +const defaultRoot = "http://127.0.0.1:5521" + +// Client is used to communicate with the Cinegy Air API. +type Client struct { + *http.Client // http.DefaultClient if nil. + // Root is a HTTP URL pointing to the root from the Air API is served. + // If empty the client assumes the value "http://127.0.0.1:5521". + Root string +} + +func (c *Client) get(path string) (*http.Response, error) { + if c.Client == nil { + c.Client = http.DefaultClient + } + if c.Root == "" { + c.Root = defaultRoot + } + return c.Get(c.Root + path) +} + +// Playlist retrieves the current Playlist for the named device. +// Supported names include: +// - "video" +// - "titler_0" +// - "logo" +// - "cg_[0-8]" e.g. cg_0, cg_1 ... +// - "cg_logo" +// - "gfx_[0-8]" +// - "audio" +// +// For all supported names, see Cinegy Air 24.1 HTTP API, chapter 1. +func (c *Client) Playlist(device string) (*Playlist, error) { + resp, err := c.get(path.Join("/", device, "list")) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode >= http.StatusBadRequest { + return nil, fmt.Errorf("non-OK status code from engine: %s", resp.Status) + } + return ParsePlaylist(resp.Body) +} + +func (c *Client) Status() (*Status, error) { + resp, err := c.get("/videos/status") + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode >= http.StatusBadRequest { + return nil, fmt.Errorf("non-OK status code from engine: %s", resp.Status) + } + var st Status + if err := xml.NewDecoder(resp.Body).Decode(&st); err != nil { + return nil, fmt.Errorf("decode status: %w", err) + } + return &st, nil +} + +type Status struct { + XMLName xml.Name `xml:"Status"` + Active struct { + ID string `xml:"Id,attr"` + } + Cued struct { + ID string `xml:"Id,attr"` + } + License struct { + State string `xml:",attr"` + } + Output struct { + State string `xml:",attr"` + } + Client clientStatus +} + +type clientStatus struct { + XMLName xml.Name `xml:"Client"` + Connected string `xml:",attr"` + Identity string `xml:",attr"` +} diff --git a/cair/duration_test.go b/cair/duration_test.go @@ -0,0 +1,34 @@ +package cair + +import "time" + +var fakePlaylist []Item = []Item{ + Item{ + Name: "Health insuranace", + Duration: 30 * time.Second, + // started 5 seconds ago + ScheduledAt: time.Now().UTC().Add(-5 * time.Second), + ThirdPartyId: "C00000000", + }, + Item{ + Name: "Delicious beverage", + Duration: 15 * time.Second, + // current item duration minus its progress + ScheduledAt: time.Now().UTC().Add((30 - 5) * time.Second), + ThirdPartyId: "C00000000", + }, + Item{ + Name: "Interesting Series", + Duration: 30 * time.Second, + // current item, minus progress, plus next item + ScheduledAt: time.Now().UTC().Add((30 - 5 + 15) * time.Second), + ThirdPartyId: "P00000000", + }, + Item{ + Name: "The News", + Duration: 15 * time.Minute, + // current item, minus progress, plus next items + ScheduledAt: time.Now().UTC().Add((30 - 5 + 15 + 30) * time.Second), + ThirdPartyId: "T00000000", + }, +} diff --git a/cair/playlist.go b/cair/playlist.go @@ -0,0 +1,142 @@ +package cair + +import ( + "bufio" + "bytes" + "encoding/xml" + "fmt" + "html" + "io" + "os" + "strconv" + "time" +) + +type Playlist struct { + XMLName struct{} `xml:"List"` + Items []Item `xml:"Item"` +} + +type Item struct { + ID string `xml:"Id,attr"` + Name string `xml:",attr"` + Description string `xml:",attr"` + ThirdPartyId string `xml:",attr"` + SubtitleId string `xml:",attr"` + EPGID string `xml:"EpgId,attr"` + ProxyProgress string `xml:",attr"` + ScheduledAt time.Time `xml:",attr"` + Duration time.Duration `xml:",attr"` + OutOfNetwork string `xml:",attr"` +} + +// EndTime calculates the time i should end by inspecting its start time and duration. +func (i *Item) EndTime() (time.Time, error) { + return i.ScheduledAt.Add(i.Duration), nil +} + +func (i *Item) UnmarshalXML(dec *xml.Decoder, start xml.StartElement) error { + type alias Item + aux := &struct { + ScheduledAt string `xml:",attr"` + Duration string `xml:",attr"` + *alias + }{ + alias: (*alias)(i), + } + if err := dec.DecodeElement(aux, &start); err != nil { + return err + } + + t, err := time.Parse(rfc3339Milli, aux.ScheduledAt) + if err != nil { + return fmt.Errorf("parse scheduled at: %w", err) + } + i.ScheduledAt = t + dur, err := parseDuration(aux.Duration) + if err != nil { + return fmt.Errorf("parse duration: %w", err) + } + i.Duration = dur + return nil +} + +const rfc3339Milli = "2006-01-02T15:04:05.000Z07:00" + +// parseDuration parses a time.Duration from timecode string. +// A timecode represents a duration of time. +// A duration of 1 second is represented by the timecode "00:00:01.000". +// 12 hours, 34 minutes, 56 seconds and 789 milliseconds is represented by the timecode +// "12:34:56.789". +func parseDuration(timecode string) (time.Duration, error) { + if len(timecode) != 12 { + return 0, fmt.Errorf("timecode does not have 12 characters") + } + + var duration time.Duration + hours, err := strconv.ParseInt(string(timecode[:2]), 10, 0) + if err != nil { + return 0, fmt.Errorf("parse hours: %v", err) + } + duration += time.Duration(hours) * time.Hour + + if string(timecode[2]) != ":" { + return 0, fmt.Errorf("parse minutes: expected %q, got %q", ":", timecode[3]) + } + minutes, err := strconv.ParseInt(string(timecode[3:5]), 10, 0) + if err != nil { + return 0, fmt.Errorf("parse minutes: %v", err) + } + duration += time.Duration(minutes) * time.Minute + + if string(timecode[5]) != ":" { + return 0, fmt.Errorf("parse seconds: expected %q, got %q", ":", timecode[5]) + } + seconds, err := strconv.ParseInt(string(timecode[6:8]), 10, 0) + if err != nil { + return 0, fmt.Errorf("parse seconds: %v", err) + } + duration += time.Duration(seconds) * time.Second + + if string(timecode[8]) != "." { + return 0, fmt.Errorf("parse milliseconds: expected %q, got %q", ".", timecode[6]) + } + milliseconds, err := strconv.ParseInt(string(timecode[9:]), 10, 0) + if err != nil { + return 0, fmt.Errorf("parse milliseconds: %v", err) + } + duration += time.Duration(milliseconds) * time.Millisecond + + return duration, nil +} + +func playlistFromFile(name string) (*Playlist, error) { + f, err := os.Open(name) + if err != nil { + return nil, err + } + defer f.Close() + return ParsePlaylist(f) +} + +// ParsePlaylist parses a XML-encoded Playlist from r. +func ParsePlaylist(r io.Reader) (*Playlist, error) { + var p Playlist + escaped := escapeAmpersand(r) + if err := xml.NewDecoder(escaped).Decode(&p); err != nil { + return nil, err + } + return &p, nil +} + +// escapeAmpersand returns a new reader which reads from r, escaping all ampersand +// characters according to the XML spec. +// This is a hack to make Playlists encoded as invalid XML from third party sources valid. +func escapeAmpersand(r io.Reader) io.Reader { + buf := &bytes.Buffer{} + scanner := bufio.NewScanner(r) + for scanner.Scan() { + buf.Write(bytes.ReplaceAll(scanner.Bytes(), []byte("&"), []byte(html.EscapeString("&")))) + } + return buf +} diff --git a/cair/playlist_test.go b/cair/playlist_test.go @@ -0,0 +1,63 @@ +package cair + +import ( + "testing" + "time" +) + +func TestParse(t *testing.T) { + _, err := playlistFromFile("testdata/playlist.xml") + if err != nil { + t.Fatalf("parse playlist %s: %v", "testdata/playlist.xml", err) + } +} + +func TestTimecode(t *testing.T) { + tests := []struct { + name string + in string + want time.Duration + }{ + {"zero", "00:00:00.000", 0}, + {"1min15sec", "00:01:15.000", time.Minute + 15*time.Second}, + { + "longest", + "12:34:56.789", + 12*time.Hour + 34*time.Minute + 56*time.Second + 789*time.Millisecond, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseDuration(tt.in) + if err != nil { + t.Errorf("parse duration: %v", err) + } + if got != tt.want { + t.Errorf("got %v; want %v", got, tt.want) + } + }) + } +} + +func TestBadTimecode(t *testing.T) { + tests := []struct { + name string + in string + }{ + {"empty", ""}, + {"garbage", "世界 Hello"}, + {"decimal", "00:12:009999"}, + {"colon", "00:1122.9999"}, + {"letters", "00:ab:00.000"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseDuration(tt.in) + if err == nil { + t.Errorf("parsing %q succeeded", tt.in) + } + t.Log(err) + }) + } +} diff --git a/cair/status_test.go b/cair/status_test.go @@ -0,0 +1,27 @@ +package cair + +import ( + "encoding/xml" + "strings" + "testing" +) + +const tstatus string = `<Status> + <Active Id="{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}"/> + <Cued Id="{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}"/> + <License State="Licensed|Not Licensed|Demo"/> + <Output State="Normal|Black|Bypass|Clean"/> + <Client Connected="y|n" Identity="IdentityString"/> +</Status>` + +func TestStatus(t *testing.T) { + var status Status + if err := xml.NewDecoder(strings.NewReader(tstatus)).Decode(&status); err != nil { + t.Fatal(err) + } + b, err := xml.Marshal(status) + if err != nil { + t.Fatal(err) + } + t.Log(string(b)) +} diff --git a/cair/testdata/playlist.xml b/cair/testdata/playlist.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8" standalone="true"?> +<List> + <Item Duration="00:15:38.200" ScheduledAt="2022-06-30T06:33:03.776Z" ProxyProgress="100" EpgId="30977690" SubtitleId="151615542" ThirdPartyId="T00068883" Description="" Name="Global bike race 1" Id="{34E7F37C-9ED2-4A9F-86C4-1B2D2190F82E}"/> + <Item Duration="00:00:05.000" ScheduledAt="2022-06-30T06:48:41.976Z" ProxyProgress="100" EpgId="30977690" SubtitleId="225019213" ThirdPartyId="E00101118" Description="[DYNAMIC NAVIGATION BOARD]" Name="Coming up" Id="{89C0B4F4-2A54-41AB-B88D-6E1EBA0EF7BC}" OutOfNetwork="y" AvailId="4164841"/> + <Item Duration="00:00:30.000" ScheduledAt="2022-06-30T06:48:46.976Z" ProxyProgress="100" EpgId="30977690" SubtitleId="138838730" ThirdPartyId="P00148614" Description="[PROMO]" Name="Global bike race promo" Id="{282EB03E-5904-4115-BFBA-F53F8DDBFB69}" OutOfNetwork="y" AvailId="4164841"/> + <Item Duration="00:00:30.000" ScheduledAt="2022-06-30T06:49:16.976Z" ProxyProgress="100" EpgId="30977690" SubtitleId="138838729" ThirdPartyId="P00148893" Description="[PROMO]" Name="Cooking show promo" Id="{3D830614-D1A8-42A3-97D6-55982BBAAA76}" OutOfNetwork="y" AvailId="4164841"/> + <Item Duration="00:00:30.000" ScheduledAt="2022-06-30T06:49:46.976Z" ProxyProgress="100" EpgId="30977690" SubtitleId="209120795" ThirdPartyId="C00152398" Description="[PAID]" Name="Car insurance" Id="{E448A8E6-0276-4B43-96DA-E59B81CE0E55}" OutOfNetwork="y" AvailId="4164841"/> + <Item Duration="00:00:45.000" ScheduledAt="2022-06-30T06:50:16.976Z" ProxyProgress="100" EpgId="30977690" SubtitleId="209120794" ThirdPartyId="C00142010" Description="[PAID]" Name="Wildlife charity fund" Id="{DED4135D-1A41-41E0-9903-2E0A492DE2EE}" OutOfNetwork="y" AvailId="4164841"/> + <Item Duration="00:00:15.000" ScheduledAt="2022-06-30T06:51:01.976Z" ProxyProgress="100" EpgId="30977690" SubtitleId="209120792" ThirdPartyId="C00153278" Description="[PAID]" Name="Some other insurance" Id="{2D6D3D6C-F927-40D0-8011-5F93EDFE3881}" OutOfNetwork="y" AvailId="4164841"/> +</List>