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:
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>