clone

a desktop tv and program guide for over-the-air broadcasts: what's on air, what your tuner gets, live tv, recordings

Read MPEG-TS: programs, audio tracks, one program of an RF channel, errors

FelineStateMachine committed 78940b9
internal/ts is what Airwaves needs to read a whole RF channel itself: its
tables (PAT, each program's PMT with audio languages and described-video
tracks, and ATSC's virtual channel table), one program filtered out of it
as a single-program stream with its own PAT, and a count of the damage in
what arrived (transport errors and continuity gaps). internal/ts/tstest
builds synthetic multiplexes for tests.
commit
78940b98aa2a2463b0db4cd6d1a3553a74a5d008
parent
4b85bfca5a23026c1979eb07e95295d4831ee50d
author
FelineStateMachine <226808055+FelineStateMachine@users.noreply.github.com>
kind
feat risk score 2.7 of 3, Jev's estimate
28 files changed, +1536 -5566
Ddeploy/compose.dvb.yml5-
Ainternal/ts/filter.go132+++
Ainternal/ts/filter_test.go174++++
Ainternal/ts/psi.go446+++++++++++
Ainternal/ts/psi_test.go227++++++
Ainternal/ts/ts.go150++++
Ainternal/ts/ts_test.go136+++
Ainternal/ts/tstest/tstest.go271+++++++
Dinternal/tuner/tuner_test.go13-
Dinternal/tuner/tvheadend.go168----
Dinternal/tvh/setup.go477------------
Dinternal/tvh/setup_test.go66--
Dinternal/tvh/status.go236------
Dinternal/tvh/status_test.go196-----
Dinternal/tvh/tvh.go291-------
Dinternal/tvh/tvhtest/testdata/channel_grid.json765-------------------
Dinternal/tvh/tvhtest/testdata/hardware_device.json310--------
Dinternal/tvh/tvhtest/testdata/hardware_root.json81--
Dinternal/tvh/tvhtest/testdata/mux_grid.json869----------------------
Dinternal/tvh/tvhtest/testdata/network_grid.json25-
Dinternal/tvh/tvhtest/testdata/serverinfo.json13-
Dinternal/tvh/tvhtest/testdata/service_grid.json1183------------------------------
Dinternal/tvh/tvhtest/testdata/status_inputs.json79--
Dinternal/tvh/tvhtest/testdata/status_inputs_idle.json43-
Dinternal/tvh/tvhtest/testdata/status_inputs_measuring.json63--
Dinternal/tvh/tvhtest/testdata/status_subscriptions.json63--
Dinternal/tvh/tvhtest/testdata/status_subscriptions_measuring.json51-
Dinternal/tvh/tvhtest/tvhtest.go569--------------
D deploy/compose.dvb.yml +0 -5
A internal/ts/filter.go +132 -0
@@ -0,0 +1,132 @@
1+package ts2+3+import "io"4+5+// flushAt is how much output a Filter gathers before writing it: seven6+// packets, as an HDHomeRun sends them over UDP.7+const flushAt = 7 * PacketSize8+9+// Filter writes one program of a multiplex as a transport stream of its10+// own, as players and recordings expect: a PAT that lists only that11+// program, then the program's PMT, its elementary streams and its PCR.12+// Other programs, PSIP and null packets are left out. Nothing goes out13+// until the program's PMT has been read, and then the output starts with14+// the PAT and a PMT from its first packet, so it starts clean. A new PAT15+// or PMT is followed.16+type Filter struct {17+ w io.Writer18+ program int19+ d *Demux20+ gen int // d.gen when pass was set21+ pass [8192]bool22+ passed []int // the PIDs set in pass23+ pmtPID int // -1 until the program's PMT is known24+ started bool // a PMT has gone out25+ patCC byte26+ pat [PacketSize]byte27+ buf []byte28+ err error29+}30+31+// NewFilter returns a Filter writing program to w.32+func NewFilter(w io.Writer, program int) *Filter {33+ return &Filter{w: w, program: program, d: NewDemux(), gen: -1, pmtPID: -1}34+}35+36+// Packet takes the multiplex's next packet. Output is written in batches37+// of a few packets; Flush writes what is held. A write error is returned38+// from then on.39+func (f *Filter) Packet(pkt []byte) error {40+ if f.err != nil {41+ return f.err42+ }43+ f.d.Packet(pkt)44+ if f.d.gen != f.gen {45+ f.update()46+ }47+ if f.pmtPID < 0 {48+ return nil49+ }50+ pid := PID(pkt)51+ switch {52+ case pid == 0:53+ if pkt[1]&0x40 != 0 {54+ f.writePAT() // ours, each time the multiplex sends its own55+ }56+ case pid == f.pmtPID:57+ if !f.started && pkt[1]&0x40 == 0 {58+ return nil59+ }60+ f.started = true61+ f.write(pkt)62+ case f.pass[pid] && f.started:63+ f.write(pkt)64+ }65+ return f.err66+}67+68+// Flush writes the output held back.69+func (f *Filter) Flush() error {70+ if f.err != nil || len(f.buf) == 0 {71+ return f.err72+ }73+ _, f.err = f.w.Write(f.buf)74+ f.buf = f.buf[:0]75+ return f.err76+}77+78+// update follows a new PAT or PMT.79+func (f *Filter) update() {80+ f.gen = f.d.gen81+ for _, pid := range f.passed {82+ f.pass[pid] = false83+ }84+ f.passed = f.passed[:0]85+ p, ok := f.d.Program(f.program)86+ if !ok {87+ f.pmtPID, f.started = -1, false88+ return89+ }90+ for _, s := range p.Streams {91+ f.allow(s.PID)92+ }93+ f.allow(p.PCRPID)94+ if p.PMTPID != f.pmtPID {95+ // A PMT that moved, or the first: start on its next section.96+ f.pmtPID, f.started = p.PMTPID, false97+ f.writePAT()98+ }99+}100+101+func (f *Filter) allow(pid int) {102+ if pid > 0 && pid < nullPID && !f.pass[pid] {103+ f.pass[pid] = true104+ f.passed = append(f.passed, pid)105+ }106+}107+108+// writePAT writes a PAT listing only the program.109+func (f *Filter) writePAT() {110+ tsid, _ := f.d.TSID()111+ s := []byte{112+ 0x00, 0xb0, 13, byte(tsid >> 8), byte(tsid), 0xc1 | byte(f.d.patVer&0x1f)<<1, 0, 0,113+ byte(f.program >> 8), byte(f.program), 0xe0 | byte(f.pmtPID>>8), byte(f.pmtPID),114+ }115+ crc := CRC32(s)116+ s = append(s, byte(crc>>24), byte(crc>>16), byte(crc>>8), byte(crc))117+ p := f.pat[:]118+ p[0], p[1], p[2], p[3], p[4] = syncByte, 0x40, 0x00, 0x10|f.patCC, 0119+ f.patCC = (f.patCC + 1) & 0x0f120+ n := 5 + copy(p[5:], s)121+ for i := n; i < PacketSize; i++ {122+ p[i] = 0xff123+ }124+ f.write(p)125+}126+127+func (f *Filter) write(pkt []byte) {128+ f.buf = append(f.buf, pkt...)129+ if len(f.buf) >= flushAt {130+ f.Flush()131+ }132+}
A internal/ts/filter_test.go +174 -0
@@ -0,0 +1,174 @@
1+package ts_test2+3+import (4+ "bytes"5+ "encoding/json"6+ "errors"7+ "os"8+ "os/exec"9+ "path/filepath"10+ "reflect"11+ "slices"12+ "testing"13+14+ "airwaves/internal/ts"15+ "airwaves/internal/ts/tstest"16+)17+18+func filter(t *testing.T, in []byte, program int) []byte {19+ t.Helper()20+ var out bytes.Buffer21+ f := ts.NewFilter(&out, program)22+ for _, p := range packets(in) {23+ if err := f.Packet(p); err != nil {24+ t.Fatal(err)25+ }26+ }27+ if err := f.Flush(); err != nil {28+ t.Fatal(err)29+ }30+ return out.Bytes()31+}32+33+func TestFilter(t *testing.T) {34+ in := denver.Packets(500)35+ out := filter(t, in, 7)36+ if len(out) == 0 || len(out)%ts.PacketSize != 0 {37+ t.Fatalf("%d bytes out", len(out))38+ }39+ pkts := packets(out)40+ // It starts clean: our PAT, then the PMT from its first packet.41+ if ts.PID(pkts[0]) != 0 || ts.PID(pkts[1]) != 0x40 || pkts[1][1]&0x40 == 0 {42+ t.Errorf("starts with PIDs %#x, %#x", ts.PID(pkts[0]), ts.PID(pkts[1]))43+ }44+ allowed := []int{0, 0x40, 0x41, 0x44, 0x45}45+ var c ts.Counter46+ for _, p := range pkts {47+ if !slices.Contains(allowed, ts.PID(p)) {48+ t.Fatalf("PID %#x in program 7's stream", ts.PID(p))49+ }50+ c.Packet(p)51+ }52+ if c.Errors() != 0 {53+ t.Errorf("%d continuity errors in the output", c.Errors())54+ }55+ d := demux(out)56+ progs := d.Programs()57+ want, _ := demux(in).Program(7)58+ if len(progs) != 1 || !reflect.DeepEqual(progs[0], want) || !d.Ready() {59+ t.Errorf("output programs = %+v, want only %+v", progs, want)60+ }61+ if tsid, _ := d.TSID(); tsid != 467 || d.Channels() != nil {62+ t.Errorf("TSID %d, channels %v", tsid, d.Channels())63+ }64+ // Every packet of the program's streams after the start went out.65+ start := slices.IndexFunc(packets(in), func(p []byte) bool { return ts.PID(p) == 0x40 && p[1]&0x40 != 0 })66+ n := 067+ for _, p := range packets(in)[start:] {68+ if slices.Contains(allowed[1:], ts.PID(p)) {69+ n++70+ }71+ }72+ if got := len(pkts) - countPID(pkts, 0); got != n {73+ t.Errorf("%d packets of the program out, want %d", got, n)74+ }75+}76+77+func countPID(pkts [][]byte, pid int) int {78+ n := 079+ for _, p := range pkts {80+ if ts.PID(p) == pid {81+ n++82+ }83+ }84+ return n85+}86+87+// TestFilterWaits sends nothing until the program's PMT has been read.88+func TestFilterWaits(t *testing.T) {89+ var noTables []byte90+ for _, p := range packets(denver.Packets(200)) {91+ if pid := ts.PID(p); pid != 0 && pid != 0x30 && pid != 0x40 && pid != 0x50 && pid != 0x1ffb {92+ noTables = append(noTables, p...)93+ }94+ }95+ if out := filter(t, noTables, 6); len(out) != 0 {96+ t.Errorf("%d bytes out before the tables", len(out))97+ }98+ if out := filter(t, denver.Packets(200), 99); len(out) != 0 {99+ t.Errorf("%d bytes out for a program that isn't there", len(out))100+ }101+ out := filter(t, append(noTables, denver.Packets(100)...), 6)102+ if len(out) == 0 || ts.PID(out) != 0 {103+ t.Error("no output once the tables came")104+ }105+}106+107+// TestFilterFollows keeps up with a PMT that moves.108+func TestFilterFollows(t *testing.T) {109+ moved := denver110+ moved.Programs = slices.Clone(denver.Programs)111+ moved.Programs[0].PMTPID = 0x60112+ pat := patSection(467, 1, [][2]int{{6, 0x60}, {7, 0x40}, {8, 0x50}})113+ in := slices.Concat(denver.Packets(100), tstest.Packetize(0, pat), moved.Packets(100))114+ out := filter(t, in, 6)115+ pkts := packets(out)116+ if countPID(pkts, 0x30) == 0 || countPID(pkts, 0x60) == 0 {117+ t.Fatalf("PMTs on %d and %d packets", countPID(pkts, 0x30), countPID(pkts, 0x60))118+ }119+ // After the move, the output's PAT points at the new PMT.120+ d := demux(out)121+ if p, ok := d.Program(6); !ok || p.PMTPID != 0x60 || len(d.Programs()) != 1 {122+ t.Errorf("program 6 = %+v", p)123+ }124+}125+126+type failing struct{ n int }127+128+func (f *failing) Write(p []byte) (int, error) {129+ f.n++130+ return 0, errors.New("pipe closed")131+}132+133+func TestFilterWriteError(t *testing.T) {134+ w := &failing{}135+ f := ts.NewFilter(w, 6)136+ var err error137+ for _, p := range packets(denver.Packets(300)) {138+ if err = f.Packet(p); err != nil {139+ break140+ }141+ }142+ if err == nil || f.Flush() == nil || f.Packet(denver.Packets(1)) == nil || w.n != 1 {143+ t.Errorf("err %v after %d writes; want it kept, and no more writes", err, w.n)144+ }145+}146+147+// TestFilterFFprobe checks that ffprobe sees one program in the output.148+func TestFilterFFprobe(t *testing.T) {149+ ffprobe, err := exec.LookPath("ffprobe")150+ if err != nil {151+ t.Skip("no ffprobe")152+ }153+ path := filepath.Join(t.TempDir(), "out.ts")154+ if err := os.WriteFile(path, filter(t, denver.Packets(2000), 6), 0o644); err != nil {155+ t.Fatal(err)156+ }157+ raw, err := exec.Command(ffprobe, "-v", "quiet", "-show_programs", "-of", "json", path).Output()158+ if err != nil {159+ t.Fatalf("ffprobe: %v", err)160+ }161+ var probe struct {162+ Programs []struct {163+ ProgramNum int `json:"program_num"`164+ PMTPID int `json:"pmt_pid"`165+ NbStreams int `json:"nb_streams"`166+ } `json:"programs"`167+ }168+ if err := json.Unmarshal(raw, &probe); err != nil {169+ t.Fatal(err)170+ }171+ if len(probe.Programs) != 1 || probe.Programs[0].ProgramNum != 6 || probe.Programs[0].PMTPID != 0x30 {172+ t.Errorf("ffprobe found %+v", probe.Programs)173+ }174+}
A internal/ts/psi.go +446 -0
@@ -0,0 +1,446 @@
1+package ts2+3+import (4+ "cmp"5+ "slices"6+ "strings"7+ "unicode/utf16"8+)9+10+// maxSection bounds a section being collected; ATSC's private sections are11+// at most 4096 bytes.12+const maxSection = 409613+14+// Stream is one elementary stream of a program.15+type Stream struct {16+ PID int17+ // Type is the PMT's stream_type: 0x02 MPEG-2 video, 0x1b H.264, 0x2418+ // HEVC, 0x81 AC-3 and 0x87 E-AC-3 (ATSC), 0x0f AAC, 0x03 and 0x0419+ // MPEG audio, 0x06 private data its descriptors name.20+ Type byte21+ // Lang is the ISO 639-2 language code ("eng", "spa") from the ISO 63922+ // language descriptor or ATSC's AC-3 audio descriptor; empty when the23+ // broadcast gives none.24+ Lang string25+ // Described marks a video description track for blind viewers.26+ Described bool27+28+ codec string // named by a descriptor, for private data29+}30+31+var codecs = map[byte]string{32+ 0x01: "MPEG1VIDEO", 0x02: "MPEG2VIDEO", 0x1b: "H264", 0x24: "HEVC",33+ 0x03: "MPEG2AUDIO", 0x04: "MPEG2AUDIO", 0x0f: "AAC", 0x11: "AAC", 0x81: "AC3", 0x87: "EAC3",34+}35+36+// Codec names the stream's coding ("MPEG2VIDEO", "H264", "HEVC", "AC3",37+// "EAC3", "AAC", "MPEG2AUDIO", "AC4"); "" for data.38+func (s Stream) Codec() string { return cmp.Or(s.codec, codecs[s.Type]) }39+40+// Video reports whether the stream is a picture.41+func (s Stream) Video() bool {42+ switch s.Codec() {43+ case "MPEG1VIDEO", "MPEG2VIDEO", "H264", "HEVC":44+ return true45+ }46+ return false47+}48+49+// Audio reports whether the stream is sound.50+func (s Stream) Audio() bool {51+ switch s.Codec() {52+ case "AC3", "EAC3", "AAC", "MPEG2AUDIO", "AC4":53+ return true54+ }55+ return false56+}57+58+// Program is one program of a multiplex, as its PMT describes it.59+type Program struct {60+ Number int61+ PMTPID int62+ PCRPID int63+ Streams []Stream // in PMT order64+}65+66+// Audio lists the program's audio streams in PMT order: the main track67+// first, as broadcasters list them.68+func (p Program) Audio() []Stream {69+ var out []Stream70+ for _, s := range p.Streams {71+ if s.Audio() {72+ out = append(out, s)73+ }74+ }75+ return out76+}77+78+// VirtualChannel is a channel of ATSC's terrestrial or cable virtual79+// channel table.80+type VirtualChannel struct {81+ Major, Minor int82+ Name string // the short name, "KWGN-DT"83+ Program int // its program number in the multiplex84+ TSID int // the transport stream it's in85+ Hidden bool86+ // ServiceType is 0x02 for digital TV, 0x03 audio only, 0x04 data.87+ ServiceType int88+}89+90+// Demux follows a multiplex's tables: the PAT, each program's PMT and the91+// VCT. Feed it every packet.92+type Demux struct {93+ tables map[int]*assembler // by PID94+ tsid int95+ patVer int // -1 before a PAT96+ pmts map[int]int // PMT PID by program number, from the PAT97+ progs map[int]Program98+ pmtVer map[int]int99+ vct vctState100+ // gen counts changes to the PAT and PMTs, for Filter.101+ gen int102+}103+104+type vctState struct {105+ version int // of parts, -1 for none106+ parts map[int][]VirtualChannel107+ done []VirtualChannel108+ doneVer int109+}110+111+// NewDemux returns a Demux that has seen nothing yet.112+func NewDemux() *Demux {113+ return &Demux{114+ tables: map[int]*assembler{0: newAssembler(0), psipPID: newAssembler(psipPID)},115+ patVer: -1, pmts: map[int]int{}, progs: map[int]Program{}, pmtVer: map[int]int{},116+ vct: vctState{version: -1, doneVer: -1},117+ }118+}119+120+// Packet reads pkt's part of any table. Packets the demodulator flagged as121+// damaged are skipped.122+func (d *Demux) Packet(pkt []byte) {123+ if len(pkt) < PacketSize || pkt[0] != syncByte || pkt[1]&0x80 != 0 {124+ return125+ }126+ if a := d.tables[PID(pkt)]; a != nil {127+ a.packet(pkt, d)128+ }129+}130+131+// TSID is the multiplex's transport stream ID, from its PAT; ok is false132+// before one.133+func (d *Demux) TSID() (int, bool) { return d.tsid, d.patVer >= 0 }134+135+// Program returns a program by number; ok is false until its PMT is read.136+func (d *Demux) Program(number int) (Program, bool) {137+ p, ok := d.progs[number]138+ return p, ok139+}140+141+// Programs lists the programs whose PMT has been read, by number.142+func (d *Demux) Programs() []Program {143+ out := make([]Program, 0, len(d.progs))144+ for _, p := range d.progs {145+ out = append(out, p)146+ }147+ slices.SortFunc(out, func(a, b Program) int { return cmp.Compare(a.Number, b.Number) })148+ return out149+}150+151+// Channels lists the virtual channels of the latest complete VCT; nil152+// before one.153+func (d *Demux) Channels() []VirtualChannel { return slices.Clone(d.vct.done) }154+155+// Ready reports whether the PAT and every PMT it lists have been read.156+func (d *Demux) Ready() bool {157+ if d.patVer < 0 {158+ return false159+ }160+ for n := range d.pmts {161+ if _, ok := d.progs[n]; !ok {162+ return false163+ }164+ }165+ return true166+}167+168+// section takes a whole section that arrived on pid.169+func (d *Demux) section(pid int, s []byte) {170+ // The sections followed all have the long form, with a CRC.171+ if len(s) < 12 || s[1]&0x80 == 0 || s[5]&1 == 0 || CRC32(s) != 0 {172+ return173+ }174+ switch {175+ case s[0] == 0x00 && pid == 0:176+ d.pat(s)177+ case s[0] == 0x02 && pid != 0 && pid != psipPID:178+ d.pmt(pid, s)179+ case (s[0] == 0xc8 || s[0] == 0xc9) && pid == psipPID:180+ d.vctSection(s)181+ }182+}183+184+func (d *Demux) pat(s []byte) {185+ tsid, ver := int(s[3])<<8|int(s[4]), int(s[5]>>1&0x1f)186+ if ver == d.patVer && tsid == d.tsid {187+ return188+ }189+ pmts := map[int]int{}190+ body := s[8 : len(s)-4]191+ for i := 0; i+4 <= len(body); i += 4 {192+ if n := int(body[i])<<8 | int(body[i+1]); n != 0 {193+ pmts[n] = int(body[i+2]&0x1f)<<8 | int(body[i+3])194+ }195+ }196+ d.tsid, d.patVer, d.pmts = tsid, ver, pmts197+ // Programs gone, or whose PMT moved, are read again.198+ for n, p := range d.progs {199+ if pid, ok := pmts[n]; !ok || pid != p.PMTPID {200+ delete(d.progs, n)201+ delete(d.pmtVer, n)202+ }203+ }204+ keep := map[int]bool{0: true, psipPID: true}205+ for _, pid := range pmts {206+ keep[pid] = true207+ if d.tables[pid] == nil {208+ d.tables[pid] = newAssembler(pid)209+ }210+ }211+ for pid := range d.tables {212+ if !keep[pid] {213+ delete(d.tables, pid)214+ }215+ }216+ d.gen++217+}218+219+func (d *Demux) pmt(pid int, s []byte) {220+ if len(s) < 16 {221+ return222+ }223+ number, ver := int(s[3])<<8|int(s[4]), int(s[5]>>1&0x1f)224+ if d.pmts[number] != pid {225+ return // not a program the PAT lists here226+ }227+ if v, ok := d.pmtVer[number]; ok && v == ver {228+ return229+ }230+ p := Program{Number: number, PMTPID: pid, PCRPID: int(s[8]&0x1f)<<8 | int(s[9])}231+ info := int(s[10]&0x0f)<<8 | int(s[11])232+ body := s[12 : len(s)-4]233+ if info > len(body) {234+ return235+ }236+ for body = body[info:]; len(body) >= 5; {237+ n := int(body[3]&0x0f)<<8 | int(body[4])238+ if 5+n > len(body) {239+ return240+ }241+ st := Stream{PID: int(body[1]&0x1f)<<8 | int(body[2]), Type: body[0]}242+ describe(&st, body[5:5+n])243+ p.Streams = append(p.Streams, st)244+ body = body[5+n:]245+ }246+ d.progs[number], d.pmtVer[number] = p, ver247+ d.gen++248+}249+250+// describe reads what a stream's descriptors say of its language, its251+// audience and, for private data, its coding.252+func describe(s *Stream, desc []byte) {253+ for len(desc) >= 2 {254+ tag, n := desc[0], int(desc[1])255+ if 2+n > len(desc) {256+ return257+ }258+ b := desc[2 : 2+n]259+ switch tag {260+ case 0x0a: // ISO 639 language261+ if n >= 4 {262+ s.Lang = cmp.Or(s.Lang, language(b[:3]))263+ s.Described = s.Described || b[3] == 3 // visual impaired commentary264+ }265+ case 0x81: // ATSC AC-3 audio266+ ac3(s, b)267+ case 0x05: // registration268+ if s.Type == 0x06 && n >= 4 {269+ switch string(b[:4]) {270+ case "AC-3":271+ s.codec = "AC3"272+ case "EAC3":273+ s.codec = "EAC3"274+ case "AC-4":275+ s.codec = "AC4"276+ }277+ }278+ case 0x6a, 0x7a: // DVB AC-3, E-AC-3279+ if s.Type == 0x06 {280+ s.codec = map[byte]string{0x6a: "AC3", 0x7a: "EAC3"}[tag]281+ }282+ case 0x7f: // DVB extension: AC-4283+ if s.Type == 0x06 && n >= 1 && b[0] == 0x15 {284+ s.codec = "AC4"285+ }286+ }287+ desc = desc[2+n:]288+ }289+}290+291+// ac3 reads ATSC's AC-3 audio descriptor (A/52 Annex A): its bit stream292+// mode, where 2 is for the visually impaired, and the ISO 639 language at293+// its end, past the optional fields before it.294+func ac3(s *Stream, b []byte) {295+ if len(b) < 3 {296+ return297+ }298+ s.Described = s.Described || b[2]>>5 == 2299+ i := 4 // past langcod300+ if b[2]>>1&0x0f == 0 {301+ i++ // 1+1 mode: langcod2302+ }303+ i++ // mainid or asvcflags304+ if i >= len(b) {305+ return306+ }307+ i += 1 + int(b[i]>>1) // textlen and the text308+ if i >= len(b) {309+ return310+ }311+ if b[i]&0x80 != 0 && i+4 <= len(b) {312+ s.Lang = cmp.Or(s.Lang, language(b[i+1:i+4]))313+ }314+}315+316+// language is a three-letter ISO 639 code in lower case, "" for anything317+// else.318+func language(b []byte) string {319+ l := strings.ToLower(string(b))320+ for _, r := range l {321+ if r < 'a' || r > 'z' {322+ return ""323+ }324+ }325+ return l326+}327+328+func (d *Demux) vctSection(s []byte) {329+ if len(s) < 16 {330+ return331+ }332+ ver, num, last := int(s[5]>>1&0x1f), int(s[6]), int(s[7])333+ if ver == d.vct.doneVer {334+ return335+ }336+ if ver != d.vct.version {337+ d.vct.version, d.vct.parts = ver, map[int][]VirtualChannel{}338+ }339+ body := s[10 : len(s)-4]340+ chans := []VirtualChannel{}341+ for range int(s[9]) {342+ if len(body) < 32 {343+ return344+ }345+ n := int(body[30]&0x03)<<8 | int(body[31])346+ if 32+n > len(body) {347+ return348+ }349+ chans = append(chans, VirtualChannel{350+ Name: shortName(body[:14]),351+ Major: int(body[14]&0x0f)<<6 | int(body[15]>>2),352+ Minor: int(body[15]&0x03)<<8 | int(body[16]),353+ TSID: int(body[22])<<8 | int(body[23]),354+ Program: int(body[24])<<8 | int(body[25]),355+ Hidden: body[26]&0x10 != 0,356+ ServiceType: int(body[27] & 0x3f),357+ })358+ body = body[32+n:]359+ }360+ d.vct.parts[num] = chans361+ var all []VirtualChannel362+ for i := 0; i <= last; i++ {363+ p, ok := d.vct.parts[i]364+ if !ok {365+ return // more sections to come366+ }367+ all = append(all, p...)368+ }369+ d.vct.done, d.vct.doneVer = all, ver370+}371+372+// shortName decodes a VCT short name: seven UTF-16 code units, padded with373+// zeros.374+func shortName(b []byte) string {375+ u := make([]uint16, 0, 7)376+ for i := 0; i+1 < len(b); i += 2 {377+ c := uint16(b[i])<<8 | uint16(b[i+1])378+ if c == 0 {379+ break380+ }381+ u = append(u, c)382+ }383+ return strings.TrimSpace(string(utf16.Decode(u)))384+}385+386+// assembler collects one PID's sections from its packets.387+type assembler struct {388+ pid int389+ cc int // the last packet's continuity counter, -1 before one390+ buf []byte391+ open bool // collecting a section whose start arrived392+}393+394+func newAssembler(pid int) *assembler { return &assembler{pid: pid, cc: -1} }395+396+func (a *assembler) packet(pkt []byte, d *Demux) {397+ pl := payload(pkt)398+ if len(pl) == 0 {399+ return400+ }401+ cc := int(pkt[3] & 0x0f)402+ if cc == a.cc {403+ return // a duplicate404+ }405+ lost := a.cc >= 0 && cc != (a.cc+1)&0x0f406+ a.cc = cc407+ if pkt[1]&0x40 != 0 {408+ ptr := int(pl[0])409+ if 1+ptr > len(pl) {410+ a.open, a.buf = false, a.buf[:0]411+ return412+ }413+ if a.open && !lost {414+ a.buf = append(a.buf, pl[1:1+ptr]...)415+ a.drain(d)416+ }417+ a.buf, a.open = append(a.buf[:0], pl[1+ptr:]...), true418+ } else {419+ if !a.open || lost {420+ a.open, a.buf = false, a.buf[:0]421+ return422+ }423+ a.buf = append(a.buf, pl...)424+ }425+ a.drain(d)426+}427+428+// drain hands d each whole section collected, stopping at stuffing.429+func (a *assembler) drain(d *Demux) {430+ for a.open && len(a.buf) >= 3 {431+ if a.buf[0] == 0xff {432+ a.open, a.buf = false, a.buf[:0]433+ return434+ }435+ n := 3 + (int(a.buf[1]&0x0f)<<8 | int(a.buf[2]))436+ if n > maxSection {437+ a.open, a.buf = false, a.buf[:0]438+ return439+ }440+ if len(a.buf) < n {441+ return442+ }443+ d.section(a.pid, a.buf[:n])444+ a.buf = a.buf[:copy(a.buf, a.buf[n:])]445+ }446+}
A internal/ts/psi_test.go +227 -0
@@ -0,0 +1,227 @@
1+package ts_test2+3+import (4+ "reflect"5+ "testing"6+7+ "airwaves/internal/ts"8+ "airwaves/internal/ts/tstest"9+)10+11+func demux(b []byte) *ts.Demux {12+ d := ts.NewDemux()13+ for _, p := range packets(b) {14+ d.Packet(p)15+ }16+ return d17+}18+19+type stream struct {20+ PID int21+ Codec string22+ Lang string23+ Described bool24+}25+26+func streams(p ts.Program) []stream {27+ var out []stream28+ for _, s := range p.Streams {29+ out = append(out, stream{s.PID, s.Codec(), s.Lang, s.Described})30+ }31+ return out32+}33+34+func TestDemux(t *testing.T) {35+ d := demux(denver.Packets(40))36+ if !d.Ready() {37+ t.Fatal("not ready after the tables")38+ }39+ if tsid, ok := d.TSID(); !ok || tsid != 467 {40+ t.Errorf("TSID = %d, %v", tsid, ok)41+ }42+ progs := d.Programs()43+ if len(progs) != 3 {44+ t.Fatalf("%d programs, want 3", len(progs))45+ }46+ want := map[int][]stream{47+ 6: {{0x31, "MPEG2VIDEO", "", false}, {0x34, "AC3", "eng", false}, {0x35, "AC3", "spa", false}, {0x36, "AC3", "eng", true}},48+ 7: {{0x41, "H264", "", false}, {0x44, "EAC3", "eng", false}, {0x45, "AAC", "spa", true}},49+ 8: {{0x51, "MPEG2VIDEO", "", false}, {0x54, "AC3", "", false}},50+ }51+ for _, p := range progs {52+ if got := streams(p); !reflect.DeepEqual(got, want[p.Number]) {53+ t.Errorf("program %d streams = %+v, want %+v", p.Number, got, want[p.Number])54+ }55+ }56+ p, _ := d.Program(6)57+ if p.PMTPID != 0x30 || p.PCRPID != 0x31 || len(p.Audio()) != 3 || p.Audio()[0].Lang != "eng" {58+ t.Errorf("program 6 = %+v", p)59+ }60+ chans := d.Channels()61+ wantChans := []ts.VirtualChannel{62+ {Major: 2, Minor: 1, Name: "KWGN-DT", Program: 6, TSID: 467, ServiceType: 2},63+ {Major: 2, Minor: 2, Name: "Antenna", Program: 7, TSID: 467, ServiceType: 2},64+ {Major: 2, Minor: 3, Name: "Court", Program: 8, TSID: 467, ServiceType: 2},65+ }66+ if !reflect.DeepEqual(chans, wantChans) {67+ t.Errorf("channels = %+v", chans)68+ }69+}70+71+func TestDemuxNotReady(t *testing.T) {72+ d := ts.NewDemux()73+ if d.Ready() || d.Channels() != nil {74+ t.Error("ready, or with channels, before any packet")75+ }76+ if _, ok := d.TSID(); ok {77+ t.Error("a TSID before the PAT")78+ }79+ // The PAT alone: the PMTs it lists are still to come.80+ d = demux(tstest.Packetize(0, patSection(1, 0, [][2]int{{6, 0x30}})))81+ if d.Ready() {82+ t.Error("ready without the PMT")83+ }84+}85+86+// TestDemuxBigVCT reads a VCT spanning packets.87+func TestDemuxBigVCT(t *testing.T) {88+ m := tstest.Mux{TSID: 1}89+ for i := range 9 {90+ m.Programs = append(m.Programs, tstest.Program{Number: i + 1, PMTPID: 0x100 + i, VideoPID: 0x200 + i, Major: 50, Minor: i + 1, Name: "KTFD"})91+ }92+ chans := demux(m.Packets(60)).Channels()93+ if len(chans) != 9 || chans[8].Minor != 9 || chans[8].Program != 9 {94+ t.Errorf("channels = %+v", chans)95+ }96+}97+98+func patSection(tsid, version int, progs [][2]int) []byte {99+ s := []byte{0x00, 0xb0, 0, byte(tsid >> 8), byte(tsid), 0xc1 | byte(version)<<1, 0, 0}100+ for _, p := range progs {101+ s = append(s, byte(p[0]>>8), byte(p[0]), 0xe0|byte(p[1]>>8), byte(p[1]))102+ }103+ return tstest.Section(s)104+}105+106+// pmtSection is a PMT whose streams are stream_type, PID and descriptors.107+func pmtSection(number, version, pcr int, es ...[]byte) []byte {108+ s := []byte{0x02, 0xb0, 0, byte(number >> 8), byte(number), 0xc1 | byte(version)<<1, 0, 0, 0xe0 | byte(pcr>>8), byte(pcr), 0xf0, 0}109+ for _, e := range es {110+ s = append(s, e...)111+ }112+ return tstest.Section(s)113+}114+115+func es(typ byte, pid int, desc ...byte) []byte {116+ return append([]byte{typ, 0xe0 | byte(pid>>8), byte(pid), 0xf0, byte(len(desc))}, desc...)117+}118+119+func TestDemuxDescriptors(t *testing.T) {120+ pmt := pmtSection(3, 0, 0x31,121+ es(0x02, 0x31),122+ // AC-3 descriptor, 1+1 mode (a second langcod), visually impaired,123+ // with 3 bytes of text and then the language.124+ es(0x81, 0x34, 0x81, 14, 0x08, 0x38, 2<<5|0<<1|1, 0xff, 0xff, 0x00, 3<<1|1, 'A', 'B', 'C', 0xbf, 'S', 'P', 'A'),125+ // AC-3 descriptor with no language, and an ISO 639 one.126+ es(0x81, 0x35, 0x81, 3, 0x08, 0x38, 0x05, 0x0a, 4, 'f', 'r', 'a', 0),127+ // Private data: DVB's AC-4, E-AC-3 and AC-3 descriptors, and128+ // registration.129+ es(0x06, 0x36, 0x7f, 2, 0x15, 0x00),130+ es(0x06, 0x37, 0x7a, 1, 0x00),131+ es(0x06, 0x38, 0x05, 4, 'A', 'C', '-', '3'),132+ es(0x06, 0x39), // data133+ es(0x24, 0x3a),134+ )135+ b := append(tstest.Packetize(0, patSection(1, 0, [][2]int{{3, 0x30}})), tstest.Packetize(0x30, pmt)...)136+ p, ok := demux(b).Program(3)137+ if !ok {138+ t.Fatal("no PMT")139+ }140+ want := []stream{141+ {0x31, "MPEG2VIDEO", "", false},142+ {0x34, "AC3", "spa", true},143+ {0x35, "AC3", "fra", false},144+ {0x36, "AC4", "", false},145+ {0x37, "EAC3", "", false},146+ {0x38, "AC3", "", false},147+ {0x39, "", "", false},148+ {0x3a, "HEVC", "", false},149+ }150+ if got := streams(p); !reflect.DeepEqual(got, want) {151+ t.Errorf("streams = %+v\nwant %+v", got, want)152+ }153+ if !p.Streams[0].Video() || p.Streams[0].Audio() || !p.Streams[3].Audio() || p.Streams[6].Audio() || p.Streams[6].Video() {154+ t.Error("Video and Audio")155+ }156+ if len(p.Audio()) != 5 {157+ t.Errorf("%d audio streams, want 5", len(p.Audio()))158+ }159+}160+161+// TestDemuxSections reads sections that share packets and cross from one162+// packet to the next, found by the pointer field.163+func TestDemuxSections(t *testing.T) {164+ big := func(n, pid int) []byte {165+ var e [][]byte166+ for i := range 20 {167+ e = append(e, es(0x81, pid+i, 0x0a, 4, 'e', 'n', 'g', 0))168+ }169+ return pmtSection(n, 0, pid, e...)170+ }171+ // Two programs' PMTs on one PID, back to back: the first ends, and the172+ // second starts, partway into the second packet.173+ b := append(tstest.Packetize(0, patSection(1, 0, [][2]int{{1, 0x30}, {2, 0x30}})),174+ tstest.Packetize(0x30, big(1, 0x100), big(2, 0x200))...)175+ d := demux(b)176+ p1, ok1 := d.Program(1)177+ p2, ok2 := d.Program(2)178+ if !ok1 || !ok2 || len(p1.Streams) != 20 || len(p2.Streams) != 20 || p2.Streams[19].PID != 0x213 || !d.Ready() {179+ t.Errorf("programs %v %v: %d and %d streams", ok1, ok2, len(p1.Streams), len(p2.Streams))180+ }181+}182+183+func TestDemuxVersions(t *testing.T) {184+ d := ts.NewDemux()185+ next := map[int]byte{} // continuity counters, carried on between sections186+ feed := func(pid int, s []byte) {187+ for _, p := range packets(tstest.Packetize(pid, s)) {188+ p[3] = p[3]&0xf0 | next[pid]189+ next[pid] = (next[pid] + 1) & 0x0f190+ d.Packet(p)191+ }192+ }193+ feed(0, patSection(1, 0, [][2]int{{1, 0x30}, {2, 0x40}}))194+ feed(0x30, pmtSection(1, 0, 0x31, es(0x02, 0x31)))195+ feed(0x40, pmtSection(2, 0, 0x41, es(0x02, 0x41)))196+ if len(d.Programs()) != 2 {197+ t.Fatalf("%d programs", len(d.Programs()))198+ }199+ // A new PMT version adds a stream.200+ feed(0x30, pmtSection(1, 1, 0x31, es(0x02, 0x31), es(0x81, 0x34)))201+ if p, _ := d.Program(1); len(p.Streams) != 2 {202+ t.Errorf("program 1 after its new PMT: %+v", p)203+ }204+ // A new PAT drops program 2.205+ feed(0, patSection(1, 1, [][2]int{{1, 0x30}}))206+ if progs := d.Programs(); len(progs) != 1 || progs[0].Number != 1 {207+ t.Errorf("programs after the new PAT: %+v", progs)208+ }209+ if !d.Ready() {210+ t.Error("not ready")211+ }212+}213+214+func TestDemuxBadCRC(t *testing.T) {215+ pmt := pmtSection(1, 0, 0x31, es(0x02, 0x31))216+ pmt[len(pmt)-1] ^= 1217+ b := append(tstest.Packetize(0, patSection(1, 0, [][2]int{{1, 0x30}})), tstest.Packetize(0x30, pmt)...)218+ if _, ok := demux(b).Program(1); ok {219+ t.Error("read a PMT with a bad CRC")220+ }221+ // A damaged packet is skipped.222+ b = append(tstest.Packetize(0, patSection(1, 0, [][2]int{{1, 0x30}})), tstest.Packetize(0x30, pmtSection(1, 0, 0x31))...)223+ tstest.SetTEI(b, 1)224+ if _, ok := demux(b).Program(1); ok {225+ t.Error("read a PMT from a flagged packet")226+ }227+}
A internal/ts/ts.go +150 -0
@@ -0,0 +1,150 @@
1+// Package ts reads MPEG transport streams as an HDHomeRun sends them: a2+// whole RF channel's multiplex, cut into packets, with its tables followed3+// (PAT, PMTs and ATSC's virtual channel table), one program taken out of it4+// for each viewer or recording, and the damage that arrived counted.5+package ts6+7+// PacketSize is the size of a transport stream packet.8+const PacketSize = 1889+10+const (11+ syncByte = 0x4712+ nullPID = 0x1fff13+ psipPID = 0x1ffb // ATSC's PSIP base PID, which carries the VCT14+)15+16+// PID is a packet's packet identifier.17+func PID(pkt []byte) int { return int(pkt[1]&0x1f)<<8 | int(pkt[2]) }18+19+// payload is a packet's payload, nil if it has none.20+func payload(pkt []byte) []byte {21+ switch pkt[3] >> 4 & 3 {22+ case 1:23+ return pkt[4:]24+ case 3:25+ if n := int(pkt[4]); n < PacketSize-5 {26+ return pkt[5+n:]27+ }28+ }29+ return nil30+}31+32+// Splitter cuts a byte stream into whole packets. When the sync byte isn't33+// where the next packet should start, it looks for two sync bytes a packet34+// apart and carries on from there, counting the resync.35+type Splitter struct {36+ buf []byte37+ lost bool38+ resyncs int6439+}40+41+// Write calls each for every whole packet in p, in order, keeping a partial42+// packet for the next call. pkt is only valid during the call.43+func (s *Splitter) Write(p []byte, each func(pkt []byte)) {44+ s.buf = append(s.buf, p...)45+ b := s.buf46+ i := 047+ for len(b)-i >= PacketSize {48+ // A packet is whole when the next one starts after it, where that49+ // has arrived.50+ next := i+PacketSize >= len(b) || b[i+PacketSize] == syncByte51+ if b[i] == syncByte && next && !s.lost {52+ each(b[i : i+PacketSize])53+ i += PacketSize54+ continue55+ }56+ if !s.lost {57+ s.lost = true58+ s.resyncs++59+ }60+ j := i61+ for j+PacketSize < len(b) && (b[j] != syncByte || b[j+PacketSize] != syncByte) {62+ j++63+ }64+ i = j65+ if j+PacketSize >= len(b) {66+ break // no confirmed start yet; wait for more67+ }68+ s.lost = false69+ }70+ s.buf = s.buf[:copy(s.buf, b[i:])]71+}72+73+// Resyncs counts the times the stream lost packet alignment.74+func (s *Splitter) Resyncs() int64 { return s.resyncs }75+76+// Counter counts packets and the damage in them: packets the demodulator77+// flagged with the transport error indicator, and gaps in a PID's78+// continuity counter (packets lost on the way).79+type Counter struct {80+ Packets, TEI, CC int6481+ // state is by PID: bit 7 seen, bit 6 the last packet was a duplicate,82+ // the low bits its continuity counter.83+ state [8192]uint884+}85+86+// Packet counts pkt. Only packets carrying a payload advance a continuity87+// counter; one duplicate packet in a row is allowed; the adaptation88+// field's discontinuity indicator starts the count over; the null PID89+// isn't followed.90+func (c *Counter) Packet(pkt []byte) {91+ c.Packets++92+ pid := PID(pkt)93+ afc := pkt[3] >> 4 & 394+ tei := pkt[1]&0x80 != 095+ if tei {96+ c.TEI++97+ }98+ if pid == nullPID || afc == 0 {99+ return100+ }101+ cc := pkt[3] & 0x0f102+ // A flagged packet arrived, so its PID's count goes on from it without103+ // counting a gap twice; so does one that starts the count over.104+ if tei || afc&2 != 0 && pkt[4] > 0 && pkt[5]&0x80 != 0 {105+ c.state[pid] = 0x80 | cc106+ return107+ }108+ if afc&1 == 0 {109+ return110+ }111+ st := c.state[pid]112+ last := st & 0x0f113+ switch {114+ case st&0x80 == 0, cc == (last+1)&0x0f:115+ c.state[pid] = 0x80 | cc116+ case cc == last && st&0x40 == 0:117+ c.state[pid] = 0xc0 | cc118+ default:119+ c.CC++120+ c.state[pid] = 0x80 | cc121+ }122+}123+124+// Errors is the damage counted: flagged packets and continuity gaps.125+func (c *Counter) Errors() int64 { return c.TEI + c.CC }126+127+var crcTable = func() (t [256]uint32) {128+ for i := range t {129+ c := uint32(i) << 24130+ for range 8 {131+ if c&0x80000000 != 0 {132+ c = c<<1 ^ 0x04c11db7133+ } else {134+ c <<= 1135+ }136+ }137+ t[i] = c138+ }139+ return t140+}()141+142+// CRC32 is the MPEG-2 CRC that PSI sections end with. Over a whole section,143+// its CRC included, it is 0 when the section arrived intact.144+func CRC32(b []byte) uint32 {145+ crc := uint32(0xffffffff)146+ for _, x := range b {147+ crc = crc<<8 ^ crcTable[byte(crc>>24)^x]148+ }149+ return crc150+}
A internal/ts/ts_test.go +136 -0
@@ -0,0 +1,136 @@
1+package ts_test2+3+import (4+ "bytes"5+ "testing"6+7+ "airwaves/internal/ts"8+ "airwaves/internal/ts/tstest"9+)10+11+// denver is a multiplex like KWGN's: an HD main channel with English,12+// Spanish and described audio, and two subchannels.13+var denver = tstest.Mux{TSID: 467, Programs: []tstest.Program{14+ {Number: 6, PMTPID: 0x30, VideoPID: 0x31, Major: 2, Minor: 1, Name: "KWGN-DT", Audio: []tstest.Audio{15+ {PID: 0x34, Lang: "eng"}, {PID: 0x35, Lang: "spa"}, {PID: 0x36, Lang: "eng", Described: true},16+ }},17+ {Number: 7, PMTPID: 0x40, VideoPID: 0x41, VideoType: 0x1b, Major: 2, Minor: 2, Name: "Antenna", Audio: []tstest.Audio{18+ {PID: 0x44, Type: 0x87, Lang: "eng"}, {PID: 0x45, Type: 0x0f, Lang: "spa", Described: true},19+ }},20+ {Number: 8, PMTPID: 0x50, VideoPID: 0x51, Major: 2, Minor: 3, Name: "Court", Audio: []tstest.Audio{{PID: 0x54}}},21+}}22+23+func packets(b []byte) [][]byte {24+ var out [][]byte25+ for i := 0; i+ts.PacketSize <= len(b); i += ts.PacketSize {26+ out = append(out, b[i:i+ts.PacketSize])27+ }28+ return out29+}30+31+func TestSplitter(t *testing.T) {32+ src := denver.Packets(60)33+ for _, size := range []int{1, 7, 100, 187, 188, 189, 1000, len(src)} {34+ var s ts.Splitter35+ var got []byte36+ for i := 0; i < len(src); i += size {37+ s.Write(src[i:min(i+size, len(src))], func(pkt []byte) { got = append(got, pkt...) })38+ }39+ if !bytes.Equal(got, src) || s.Resyncs() != 0 {40+ t.Errorf("chunks of %d: got %d bytes with %d resyncs, want the %d sent and none", size, len(got), s.Resyncs(), len(src))41+ }42+ }43+}44+45+func TestSplitterResync(t *testing.T) {46+ src := denver.Packets(20)47+ // Half a packet lost, and garbage with a stray sync byte in it.48+ var in []byte49+ in = append(in, src[:5*ts.PacketSize]...)50+ in = append(in, src[5*ts.PacketSize:5*ts.PacketSize+90]...)51+ in = append(in, 0x00, 0x47, 0x12, 0x47, 0x00)52+ in = append(in, src[6*ts.PacketSize:]...)53+ for _, size := range []int{13, 188, len(in)} {54+ var s ts.Splitter55+ var got []byte56+ for i := 0; i < len(in); i += size {57+ s.Write(in[i:min(i+size, len(in))], func(pkt []byte) { got = append(got, pkt...) })58+ }59+ // Every whole packet comes out. Written all at once, the cut one is60+ // seen for what it is; in pieces, it may go out before the bytes61+ // after it show it was cut, taking the start of the next with it.62+ want := len(src) - ts.PacketSize63+ head, tail := src[:5*ts.PacketSize], src[7*ts.PacketSize:]64+ ok := bytes.HasPrefix(got, head) && bytes.HasSuffix(got, tail) && len(got) >= want-ts.PacketSize && len(got) <= want65+ if size == len(in) {66+ ok = bytes.Equal(got, append(append([]byte{}, head...), src[6*ts.PacketSize:]...))67+ }68+ if !ok || s.Resyncs() != 1 {69+ t.Errorf("chunks of %d: got %d packets with %d resyncs, want %d with 1", size, len(got)/ts.PacketSize, s.Resyncs(), want/ts.PacketSize)70+ }71+ }72+}73+74+func TestCounter(t *testing.T) {75+ dup := func(b []byte, i, times int) []byte {76+ p := b[i*ts.PacketSize : (i+1)*ts.PacketSize]77+ var out []byte78+ out = append(out, b[:(i+1)*ts.PacketSize]...)79+ for range times {80+ out = append(out, p...)81+ }82+ return append(out, b[(i+1)*ts.PacketSize:]...)83+ }84+ // A packet of PID 0x100 with the given continuity counter, with or85+ // without a payload, and with the discontinuity indicator set.86+ pkt := func(cc byte, payload, disc bool) []byte {87+ p := make([]byte, ts.PacketSize)88+ p[0], p[1], p[2] = 0x47, 0x01, 0x0089+ p[3] = 0x20 | cc90+ if payload {91+ p[3] |= 0x1092+ }93+ p[4] = 194+ if disc {95+ p[5] = 0x8096+ }97+ return p98+ }99+ null := make([]byte, ts.PacketSize)100+ null[0], null[1], null[2], null[3] = 0x47, 0x1f, 0xff, 0x10101+ tests := []struct {102+ name string103+ in func() []byte104+ tei, cc int64105+ }{106+ {"clean", func() []byte { return denver.Packets(200) }, 0, 0},107+ {"flagged", func() []byte { b := denver.Packets(200); tstest.SetTEI(b, 50); tstest.SetTEI(b, 51); return b }, 2, 0},108+ {"lost", func() []byte { b := denver.Packets(200); tstest.SkipCC(b, 50); return b }, 0, 1},109+ {"duplicate", func() []byte { return dup(denver.Packets(200), 50, 1) }, 0, 0},110+ {"two duplicates", func() []byte { return dup(denver.Packets(200), 50, 2) }, 0, 1},111+ {"discontinuity", func() []byte {112+ return bytes.Join([][]byte{pkt(0, true, false), pkt(1, true, false), pkt(7, true, true), pkt(8, true, false)}, nil)113+ }, 0, 0},114+ {"no payload", func() []byte {115+ return bytes.Join([][]byte{pkt(3, true, false), pkt(3, false, false), pkt(4, true, false)}, nil)116+ }, 0, 0},117+ {"null packets", func() []byte { return bytes.Join([][]byte{null, null, null}, nil) }, 0, 0},118+ }119+ for _, tt := range tests {120+ var c ts.Counter121+ in := tt.in()122+ for _, p := range packets(in) {123+ c.Packet(p)124+ }125+ if c.TEI != tt.tei || c.CC != tt.cc || c.Errors() != tt.tei+tt.cc || c.Packets != int64(len(in)/ts.PacketSize) {126+ t.Errorf("%s: TEI %d, CC %d, %d packets; want %d, %d, %d", tt.name, c.TEI, c.CC, c.Packets, tt.tei, tt.cc, len(in)/ts.PacketSize)127+ }128+ }129+}130+131+func TestCRC32(t *testing.T) {132+ // The check value of CRC-32/MPEG-2.133+ if got := ts.CRC32([]byte("123456789")); got != 0x0376e6e7 {134+ t.Errorf("CRC32 = %#x, want 0x376e6e7", got)135+ }136+}
A internal/ts/tstest/tstest.go +271 -0
@@ -0,0 +1,271 @@
1+// Package tstest builds synthetic MPEG transport streams for tests: a2+// multiplex's tables (PAT, PMTs and ATSC's VCT) and packets of its3+// programs' streams, with correct continuity counters.4+package tstest5+6+import (7+ "cmp"8+ "unicode/utf16"9+10+ "airwaves/internal/ts"11+)12+13+// Audio is an audio stream of a program.14+type Audio struct {15+ PID int16+ // Type is the stream_type; zero is 0x81, ATSC's AC-3, described with17+ // ATSC's AC-3 audio descriptor. Other types get an ISO 639 language18+ // descriptor when they have a language or are described.19+ Type byte20+ Lang string21+ Described bool22+}23+24+// Program is a program of a multiplex.25+type Program struct {26+ Number, PMTPID, VideoPID int27+ // VideoType is the video's stream_type; zero is 0x02, MPEG-2 video.28+ VideoType byte29+ Audio []Audio30+ // Major and Minor are the program's virtual channel in the VCT, Name31+ // its short name; it has none when Major is zero.32+ Major, Minor int33+ Name string34+}35+36+// Mux is a multiplex, as one RF channel carries it.37+type Mux struct {38+ TSID int39+ Programs []Program40+ // NoVCT leaves out the VCT, as a broadcast without PSIP would.41+ NoVCT bool42+}43+44+// Packets returns n packets of the multiplex: its tables first and again45+// every 20 packets or so, and between them packets of each program's46+// streams in turn. Video packets carry a PCR every eighth packet.47+func (m Mux) Packets(n int) []byte {48+ g := &gen{cc: map[int]byte{}}49+ var pids []int50+ for _, p := range m.Programs {51+ if p.VideoPID > 0 {52+ pids = append(pids, p.VideoPID)53+ }54+ for _, a := range p.Audio {55+ pids = append(pids, a.PID)56+ }57+ }58+ video := map[int]bool{}59+ for _, p := range m.Programs {60+ video[p.VideoPID] = true61+ }62+ for i := 0; len(g.out) < n*ts.PacketSize; i++ {63+ if i%20 == 0 || len(pids) == 0 {64+ g.section(0, m.pat())65+ for _, p := range m.Programs {66+ g.section(p.PMTPID, pmt(p))67+ }68+ if !m.NoVCT {69+ g.section(0x1ffb, m.vct())70+ }71+ continue72+ }73+ pid := pids[i%len(pids)]74+ g.es(pid, video[pid] && i%8 == 1)75+ }76+ return g.out[:n*ts.PacketSize]77+}78+79+// Packetize carries sections, back to back, in packets of pid: a packet80+// where a section starts has the payload unit start flag and a pointer to81+// it, and the last packet is padded with stuffing. Continuity counters82+// start at 0.83+func Packetize(pid int, sections ...[]byte) []byte {84+ g := &gen{cc: map[int]byte{}}85+ g.section(pid, sections...)86+ return g.out87+}88+89+// Section finishes a PSI section: it sets the section length from len(s)90+// and appends the CRC. s holds the table ID, two bytes for the flags and91+// the length, and the rest of the section without its CRC.92+func Section(s []byte) []byte {93+ n := len(s) + 4 - 394+ s[1] = s[1]&0xf0 | byte(n>>8)&0x0f95+ s[2] = byte(n)96+ crc := ts.CRC32(s)97+ return append(s, byte(crc>>24), byte(crc>>16), byte(crc>>8), byte(crc))98+}99+100+// SetTEI sets the transport error indicator of packet i, as a demodulator101+// does for a packet it couldn't correct.102+func SetTEI(b []byte, i int) { b[i*ts.PacketSize+1] |= 0x80 }103+104+// SkipCC advances the continuity counter of packet i, and of every later105+// packet of its PID that carries a payload, by one: as if a packet had been106+// lost just before packet i, which is one gap.107+func SkipCC(b []byte, i int) {108+ pid := ts.PID(b[i*ts.PacketSize:])109+ for j := i * ts.PacketSize; j+ts.PacketSize <= len(b); j += ts.PacketSize {110+ p := b[j : j+ts.PacketSize]111+ if ts.PID(p) == pid && p[3]&0x10 != 0 {112+ p[3] = p[3]&0xf0 | (p[3]+1)&0x0f113+ }114+ }115+}116+117+type gen struct {118+ out []byte119+ cc map[int]byte120+ pcr uint64121+}122+123+func (g *gen) packet(pid int, start bool, af, data []byte) {124+ p := make([]byte, ts.PacketSize)125+ p[0], p[1], p[2] = 0x47, byte(pid>>8)&0x1f, byte(pid)126+ if start {127+ p[1] |= 0x40128+ }129+ p[3] = 0x10 | g.cc[pid]130+ g.cc[pid] = (g.cc[pid] + 1) & 0x0f131+ i := 4132+ if af != nil {133+ p[3] |= 0x20134+ p[4] = byte(len(af))135+ i += 1 + copy(p[5:], af)136+ }137+ i += copy(p[i:], data)138+ for ; i < ts.PacketSize; i++ {139+ p[i] = 0xff140+ }141+ g.out = append(g.out, p...)142+}143+144+func (g *gen) section(pid int, sections ...[]byte) {145+ var data []byte146+ starts := map[int]bool{}147+ for _, s := range sections {148+ starts[len(data)] = true149+ data = append(data, s...)150+ }151+ for pos := 0; pos < len(data); {152+ next := len(data) // the next section start at or after pos153+ for k := pos; k < len(data); k++ {154+ if starts[k] {155+ next = k156+ break157+ }158+ }159+ if next < pos+ts.PacketSize-5 {160+ end := min(pos+ts.PacketSize-5, len(data))161+ g.packet(pid, true, nil, append([]byte{byte(next - pos)}, data[pos:end]...))162+ pos = end163+ continue164+ }165+ // No section starts in this packet; one starting right after it166+ // begins the next, with stuffing between.167+ end := min(pos+ts.PacketSize-4, next)168+ g.packet(pid, false, nil, data[pos:end])169+ pos = end170+ }171+}172+173+// es writes a packet of an elementary stream: filler, with a PCR in its174+// adaptation field when pcr is set.175+func (g *gen) es(pid int, pcr bool) {176+ var af []byte177+ if pcr {178+ g.pcr += 90 * 40 // 40 ms on the 90 kHz clock179+ b := g.pcr180+ af = []byte{0x10, byte(b >> 25), byte(b >> 17), byte(b >> 9), byte(b >> 1), byte(b&1)<<7 | 0x7e, 0}181+ }182+ data := make([]byte, ts.PacketSize)183+ for i := range data {184+ if data[i] = byte(pid) ^ byte(i); data[i] == 0x47 {185+ data[i] = 0 // no sync bytes in the filler, for tests of resyncing186+ }187+ }188+ g.packet(pid, false, af, data)189+}190+191+func (m Mux) pat() []byte {192+ s := []byte{0x00, 0xb0, 0, byte(m.TSID >> 8), byte(m.TSID), 0xc1, 0, 0}193+ for _, p := range m.Programs {194+ s = append(s, byte(p.Number>>8), byte(p.Number), 0xe0|byte(p.PMTPID>>8), byte(p.PMTPID))195+ }196+ return Section(s)197+}198+199+func pmt(p Program) []byte {200+ pcr := p.VideoPID201+ if pcr == 0 {202+ pcr = 0x1fff203+ }204+ s := []byte{0x02, 0xb0, 0, byte(p.Number >> 8), byte(p.Number), 0xc1, 0, 0, 0xe0 | byte(pcr>>8), byte(pcr), 0xf0, 0}205+ es := func(typ byte, pid int, desc []byte) {206+ s = append(s, typ, 0xe0|byte(pid>>8), byte(pid), 0xf0|byte(len(desc)>>8), byte(len(desc)))207+ s = append(s, desc...)208+ }209+ if p.VideoPID > 0 {210+ es(cmp.Or(p.VideoType, 0x02), p.VideoPID, nil)211+ }212+ for _, a := range p.Audio {213+ typ := cmp.Or(a.Type, 0x81)214+ var desc []byte215+ switch {216+ case typ == 0x81:217+ bsmod := byte(0)218+ if a.Described {219+ bsmod = 2220+ }221+ // 48 kHz, 384 kb/s, 2/0, then langcod, mainid, no text.222+ d := []byte{0x08, 0x1c << 1, bsmod<<5 | 0x02<<1 | 1, 0xff, 0x00, 0x01}223+ if a.Lang != "" {224+ d = append(d, 0xbf)225+ d = append(d, a.Lang...)226+ }227+ desc = append([]byte{0x81, byte(len(d))}, d...)228+ case a.Lang != "" || a.Described:229+ kind := byte(0)230+ if a.Described {231+ kind = 3232+ }233+ lang := a.Lang234+ if lang == "" {235+ lang = "und"236+ }237+ desc = append([]byte{0x0a, 4}, append([]byte(lang), kind)...)238+ }239+ es(typ, a.PID, desc)240+ }241+ return Section(s)242+}243+244+func (m Mux) vct() []byte {245+ var chans []Program246+ for _, p := range m.Programs {247+ if p.Major > 0 {248+ chans = append(chans, p)249+ }250+ }251+ s := []byte{0xc8, 0xf0, 0, byte(m.TSID >> 8), byte(m.TSID), 0xc1, 0, 0, 0, byte(len(chans))}252+ for _, p := range chans {253+ name := make([]byte, 14)254+ for i, c := range utf16.Encode([]rune(p.Name)) {255+ if i < 7 {256+ name[2*i], name[2*i+1] = byte(c>>8), byte(c)257+ }258+ }259+ s = append(s, name...)260+ s = append(s,261+ 0xf0|byte(p.Major>>6), byte(p.Major&0x3f)<<2|byte(p.Minor>>8), byte(p.Minor), 0x04, // 8VSB262+ 0, 0, 0, 0, // carrier frequency, no longer used263+ byte(m.TSID>>8), byte(m.TSID), byte(p.Number>>8), byte(p.Number),264+ 0x0d, 0xc0|0x02, // not hidden; digital TV265+ byte(p.Number>>8), byte(p.Number), // source ID266+ 0xfc, 0x00, // no descriptors267+ )268+ }269+ s = append(s, 0xfc, 0x00) // no additional descriptors270+ return Section(s)271+}
D internal/tuner/tuner_test.go +0 -13
D internal/tuner/tvheadend.go +0 -168
@@ -1,168 +0,0 @@
1-package tuner2-3-import (4- "context"5- "errors"6- "fmt"7- "slices"8- "sync"9- "time"10-11- "airwaves/internal/guide"12- "airwaves/internal/tvh"13-)14-15-// Tvheadend streams channels from a Tvheadend server, which owns the16-// physical tuners (and, in demo mode before one is attached, the demo17-// channels).18-type Tvheadend struct {19- tvh *tvh.Client20- // Networks names the Tvheadend networks whose channels are offered;21- // channels of any other network (anything not Airwaves') aren't. Nil22- // offers the antenna network's.23- Networks func() []string24-25- mu sync.Mutex26- lineup *tvh.Lineup27- fetched time.Time28-}29-30-// NewTvheadend wraps a Tvheadend client.31-func NewTvheadend(c *tvh.Client) *Tvheadend { return &Tvheadend{tvh: c} }32-33-// Device implements Tuner.34-func (t *Tvheadend) Device() Device {35- return Device{ID: "tvheadend", Name: "Tvheadend", Kind: "tvheadend", BaseURL: t.tvh.Base, Detail: "Tuners and recordings on the server"}36-}37-38-// Channel is a channel Tvheadend offers, with the service and multiplex39-// it plays from.40-type Channel struct {41- tvh.Channel42- Service tvh.Service43- Mux tvh.Mux44-}45-46-// Input implements Tuner.47-func (t *Tvheadend) Input(ctx context.Context, number string) (Input, error) {48- ch, err := t.Channel(ctx, number)49- if err != nil {50- return Input{}, err51- }52- in := Input{53- Args: []string{"-fflags", "+genpts", "-i", t.tvh.StreamURL(ch.UUID)},54- Video: "0:v:0",55- Audio: []AudioTrack{{Map: "0:a:0"}},56- Broadcast: true,57- }58- if streams, err := t.tvh.ServiceStreams(ctx, ch.Service.UUID); err == nil {59- if audio, note := AudioTracks(streams); audio != nil || note != "" {60- in.Audio, in.Note = audio, note61- }62- }63- return in, nil64-}65-66-// AudioTracks turns a service's streams into playable audio tracks, in67-// broadcast order (main track first). AC-4 (ATSC 3.0) cannot be decoded by68-// ffmpeg and is skipped, keeping stream numbering intact.69-func AudioTracks(streams []tvh.Stream) ([]AudioTrack, string) {70- var out []AudioTrack71- n, skipped := 0, false72- for _, s := range streams {73- switch s.Type {74- case "AC3", "EAC3", "MPEG2AUDIO", "AAC", "MP4A", "AC4":75- default:76- continue77- }78- if s.Type == "AC4" {79- skipped = true80- } else {81- out = append(out, AudioTrack{Map: fmt.Sprintf("0:a:%d", n), Lang: s.Language, Described: s.AudioType == 3})82- }83- n++84- }85- if len(out) == 0 && skipped {86- return nil, "ATSC 3.0 AC-4 audio cannot be decoded; video only"87- }88- return out, ""89-}90-91-// ChannelUUID maps a virtual channel number to a Tvheadend channel.92-func (t *Tvheadend) ChannelUUID(ctx context.Context, number string) (string, error) {93- ch, err := t.Channel(ctx, number)94- if err != nil {95- return "", err96- }97- return ch.UUID, nil98-}99-100-// ErrNotFound is returned for a number that isn't among the channels101-// Tvheadend offers.102-var ErrNotFound = errors.New("not a channel the tuner found")103-104-// Channel finds an offered channel by number.105-func (t *Tvheadend) Channel(ctx context.Context, number string) (Channel, error) {106- chans, err := t.Channels(ctx)107- if err != nil {108- return Channel{}, err109- }110- for _, c := range chans {111- if c.Number == number {112- return c, nil113- }114- }115- return Channel{}, fmt.Errorf("channel %s: %w", number, ErrNotFound)116-}117-118-// Channels lists the channels Tvheadend offers, by number: enabled,119-// numbered and mapped from a service of a multiplex in one of the offered120-// networks. Of two with one number, the first stays.121-func (t *Tvheadend) Channels(ctx context.Context) ([]Channel, error) {122- l, err := t.Lineup(ctx)123- if err != nil {124- return nil, err125- }126- nets := []string{tvh.ATSCNetwork}127- if t.Networks != nil {128- nets = t.Networks()129- }130- var out []Channel131- seen := map[string]bool{}132- for _, c := range l.Channels {133- if !c.Enabled || c.Number == "0" || len(c.Services) == 0 || seen[c.Number] {134- continue135- }136- svc, mux, ok := l.Source(c)137- if !ok || !slices.Contains(nets, mux.Network) {138- continue139- }140- seen[c.Number] = true141- out = append(out, Channel{Channel: c, Service: *svc, Mux: *mux})142- }143- slices.SortStableFunc(out, func(a, b Channel) int { return guide.CompareNumbers(a.Number, b.Number) })144- return out, nil145-}146-147-// Lineup returns Tvheadend's networks, multiplexes, services and channels,148-// fetched at most once a minute.149-func (t *Tvheadend) Lineup(ctx context.Context) (*tvh.Lineup, error) {150- t.mu.Lock()151- defer t.mu.Unlock()152- if t.lineup != nil && time.Since(t.fetched) < time.Minute {153- return t.lineup, nil154- }155- l, err := t.tvh.Lineup(ctx)156- if err != nil {157- return nil, err158- }159- t.lineup, t.fetched = l, time.Now()160- return l, nil161-}162-163-// Invalidate drops the cached lineup.164-func (t *Tvheadend) Invalidate() {165- t.mu.Lock()166- t.lineup = nil167- t.mu.Unlock()168-}
D internal/tvh/setup.go +0 -477
@@ -1,477 +0,0 @@
1-package tvh2-3-import (4- "cmp"5- "context"6- "fmt"7- "net/url"8- "strings"9-)10-11-// Network names Airwaves manages. Anything else in Tvheadend is left alone.12-const (13- DemoNetwork = "Airwaves demo"14- ATSCNetwork = "Airwaves antenna"15-)16-17-// Network is a Tvheadend mux network.18-type Network struct {19- UUID string `json:"uuid"`20- Name string `json:"networkname"`21- NumMux int `json:"num_mux"`22- NumSvc int `json:"num_svc"`23- NumChn int `json:"num_chn"`24-}25-26-// Networks lists all networks.27-func (c *Client) Networks(ctx context.Context) ([]Network, error) {28- var body struct {29- Entries []Network `json:"entries"`30- }31- err := c.get(ctx, "/api/mpegts/network/grid", url.Values{"limit": {"500"}}, &body)32- return body.Entries, err33-}34-35-func (c *Client) network(ctx context.Context, name string) (*Network, error) {36- nets, err := c.Networks(ctx)37- if err != nil {38- return nil, err39- }40- for i := range nets {41- if nets[i].Name == name {42- return &nets[i], nil43- }44- }45- return nil, nil46-}47-48-// Mux is a multiplex: what one RF channel (or one IPTV source) carries.49-type Mux struct {50- UUID string `json:"uuid"`51- Name string `json:"name"` // "605MHz"52- Network string `json:"network"`53- NetworkUUID string `json:"network_uuid"`54- Enabled flexBool `json:"enabled"`55- // FrequencyHz is an antenna multiplex's center frequency; 0 for IPTV.56- FrequencyHz int64 `json:"frequency"`57- // ScanState is 0 when idle, 1 while queued and 2 while Tvheadend scans58- // the multiplex.59- ScanState int `json:"scan_state"`60- ScanResult ScanResult `json:"scan_result"`61- // ScanLast is when a scan last found the multiplex (unix seconds); 062- // when none has.63- ScanLast int64 `json:"scan_last"`64- NumSvc int `json:"num_svc"`65- NumChn int `json:"num_chn"`66- URL string `json:"iptv_url"`67-}68-69-// ScanResult is how Tvheadend's last scan of a multiplex went.70-type ScanResult int71-72-// Scan results, as Tvheadend numbers them.73-const (74- ScanNone ScanResult = 0 // not scanned yet75- ScanOK ScanResult = 1 // locked, and found the services76- ScanFail ScanResult = 2 // no lock, or nothing found77- ScanPartial ScanResult = 3 // locked, with some tables missing78- ScanIgnore ScanResult = 479-)80-81-// Scanning reports whether Tvheadend is scanning the multiplex, or about to.82-func (m Mux) Scanning() bool { return m.ScanState != 0 }83-84-// Muxes lists every multiplex.85-func (c *Client) Muxes(ctx context.Context) ([]Mux, error) {86- var body struct {87- Entries []Mux `json:"entries"`88- }89- if err := c.get(ctx, "/api/mpegts/mux/grid", url.Values{"limit": {"5000"}}, &body); err != nil {90- return nil, err91- }92- return body.Entries, nil93-}94-95-func (c *Client) muxes(ctx context.Context, networkUUID string) ([]Mux, error) {96- all, err := c.Muxes(ctx)97- if err != nil {98- return nil, err99- }100- var out []Mux101- for _, m := range all {102- if m.NetworkUUID == networkUUID {103- out = append(out, m)104- }105- }106- return out, nil107-}108-109-// DemoChannel is one generated channel in the demo network.110-type DemoChannel struct {111- Major, Minor int112- Name string // no spaces; becomes the service and channel name113- // Command is the ffmpeg command line producing MPEG-TS on stdout.114- // Tvheadend splits it on spaces, so arguments must not contain any.115- Command string116-}117-118-// EnsureDemo makes the demo IPTV network hold exactly the given channels.119-// New muxes are scanned by Tvheadend in the background; MapServices picks120-// them up once scanned.121-func (c *Client) EnsureDemo(ctx context.Context, chans []DemoChannel) error {122- net, err := c.network(ctx, DemoNetwork)123- if err != nil {124- return err125- }126- if net == nil {127- var out struct {128- UUID string `json:"uuid"`129- }130- conf := map[string]any{"networkname": DemoNetwork, "max_streams": 6, "skipinitscan": false}131- body := form("conf", conf)132- body.Set("class", "iptv_network")133- if err := c.post(ctx, "/api/mpegts/network/create", body, &out); err != nil {134- return fmt.Errorf("create demo network: %w", err)135- }136- net = &Network{UUID: out.UUID, Name: DemoNetwork}137- }138- existing, err := c.muxes(ctx, net.UUID)139- if err != nil {140- return err141- }142- want := make(map[string]DemoChannel, len(chans))143- for _, ch := range chans {144- want[demoMuxName(ch)] = ch145- }146- have := make(map[string]bool, len(existing))147- for _, m := range existing {148- // Unknown channels, and ones whose command changed, are replaced.149- if ch, ok := want[m.Name]; !ok || m.URL != "pipe://"+ch.Command {150- if err := c.post(ctx, "/api/idnode/delete", url.Values{"uuid": {m.UUID}}, nil); err != nil {151- return err152- }153- continue154- }155- have[m.Name] = true156- }157- for name, ch := range want {158- if have[name] {159- continue160- }161- conf := map[string]any{162- "iptv_url": "pipe://" + ch.Command,163- "iptv_muxname": name,164- "iptv_sname": ch.Name,165- "channel_number": PackNumber(ch.Major, ch.Minor),166- "iptv_respawn": false,167- "epg": 0, // no over-the-air guide to read; avoids idle tuning168- }169- body := form("conf", conf)170- body.Set("uuid", net.UUID)171- if err := c.post(ctx, "/api/mpegts/network/mux_create", body, nil); err != nil {172- return fmt.Errorf("create demo mux %s: %w", name, err)173- }174- }175- return nil176-}177-178-func demoMuxName(ch DemoChannel) string {179- return fmt.Sprintf("Demo %d.%d %s", ch.Major, ch.Minor, ch.Name)180-}181-182-// RemoveNetwork deletes a managed network and the channels mapped from it.183-func (c *Client) RemoveNetwork(ctx context.Context, name string) error {184- net, err := c.network(ctx, name)185- if err != nil || net == nil {186- return err187- }188- svcs, err := c.services(ctx, net.UUID)189- if err != nil {190- return err191- }192- for _, s := range svcs {193- for _, ch := range s.Channels() {194- _ = c.post(ctx, "/api/idnode/delete", url.Values{"uuid": {ch}}, nil)195- }196- }197- return c.post(ctx, "/api/idnode/delete", url.Values{"uuid": {net.UUID}}, nil)198-}199-200-// Service is a program in a multiplex, which Tvheadend maps to a channel.201-type Service struct {202- UUID string `json:"uuid"`203- Name string `json:"svcname"` // as broadcast, "KTVD-HD"204- Network string `json:"network"`205- Multiplex string `json:"multiplex"` // the multiplex's name206- MultiplexUUID string `json:"multiplex_uuid"`207- Enabled bool `json:"enabled"`208- Encrypted bool `json:"encrypted"`209- // Major and Minor are the virtual channel the broadcast gives (PSIP).210- Major int `json:"lcn"`211- Minor int `json:"lcn_minor"`212- Channel any `json:"channel"` // list of channel UUIDs, or a string213-}214-215-// Channels lists the UUIDs of the channels mapped from the service.216-func (s Service) Channels() []string {217- switch v := s.Channel.(type) {218- case string:219- if v != "" {220- return []string{v}221- }222- case []any:223- var out []string224- for _, x := range v {225- if id, ok := x.(string); ok && id != "" {226- out = append(out, id)227- }228- }229- return out230- }231- return nil232-}233-234-// Services lists every service.235-func (c *Client) Services(ctx context.Context) ([]Service, error) {236- var body struct {237- Entries []Service `json:"entries"`238- }239- if err := c.get(ctx, "/api/mpegts/service/grid", url.Values{"limit": {"5000"}}, &body); err != nil {240- return nil, err241- }242- return body.Entries, nil243-}244-245-func (c *Client) services(ctx context.Context, networkUUID string) ([]Service, error) {246- muxes, err := c.muxes(ctx, networkUUID)247- if err != nil {248- return nil, err249- }250- inNet := make(map[string]bool, len(muxes))251- for _, m := range muxes {252- inNet[m.UUID] = true253- }254- all, err := c.Services(ctx)255- if err != nil {256- return nil, err257- }258- var out []Service259- for _, s := range all {260- if inNet[s.MultiplexUUID] {261- out = append(out, s)262- }263- }264- return out, nil265-}266-267-// MapServices turns scanned, unencrypted services in the managed networks268-// into channels. It returns how many were mapped.269-func (c *Client) MapServices(ctx context.Context) (int, error) {270- var todo []string271- for _, name := range []string{DemoNetwork, ATSCNetwork} {272- net, err := c.network(ctx, name)273- if err != nil {274- return 0, err275- }276- if net == nil {277- continue278- }279- svcs, err := c.services(ctx, net.UUID)280- if err != nil {281- return 0, err282- }283- for _, s := range svcs {284- if !s.Encrypted && len(s.Channels()) == 0 {285- todo = append(todo, s.UUID)286- }287- }288- }289- if len(todo) == 0 {290- return 0, nil291- }292- node := map[string]any{293- "services": todo, "encrypted": false, "merge_same_name": false, "check_availability": false,294- "type_tags": false, "provider_tags": false, "network_tags": false,295- }296- return len(todo), c.post(ctx, "/api/service/mapper/save", form("node", node), nil)297-}298-299-// Frontend is a tuner input on the server.300-type Frontend struct {301- UUID string302- Class string // "tvhdhomerun_frontend_atsc_t", "linuxdvb_frontend_atsc_t"303- // Name is how Tvheadend names the input, also in its input status:304- // "HDHomeRun ATSC-T Tuner #0 (169.254.1.2)".305- Name string306- // Model is the device's model as Tvheadend reports it307- // ("hdhomerun_dvr_atsc"), when it does.308- Model string309-}310-311-// treeNode is one entry of Tvheadend's hardware tree. Newer versions send312-// each node's class and parameters along; older ones only the text.313-type treeNode struct {314- UUID string `json:"uuid"`315- Text string `json:"text"`316- Class string `json:"class"`317- Leaf flexBool `json:"leaf"`318- Params []struct {319- ID string `json:"id"`320- Value any `json:"value"`321- } `json:"params"`322-}323-324-func (n treeNode) param(id string) string {325- for _, p := range n.Params {326- if p.ID == id {327- if s, ok := p.Value.(string); ok {328- return s329- }330- }331- }332- return ""333-}334-335-// Frontends finds ATSC tuner frontends: USB or PCIe tuners336-// (linuxdvb_frontend_atsc_t, e.g. both halves of a Hauppauge WinTV-dualHD)337-// and network HDHomeRuns (tvhdhomerun_frontend_atsc_t).338-func (c *Client) Frontends(ctx context.Context) ([]Frontend, error) {339- var out []Frontend340- var walk func(uuid, model string, depth int) error341- walk = func(uuid, model string, depth int) error {342- var nodes []treeNode343- if err := c.get(ctx, "/api/hardware/tree", url.Values{"uuid": {uuid}}, &nodes); err != nil {344- return err345- }346- for _, n := range nodes {347- class := n.Class348- if class == "" {349- var err error350- if class, err = c.class(ctx, n.UUID); err != nil {351- return err352- }353- }354- m := cmp.Or(n.param("deviceModel"), model)355- if strings.Contains(class, "_frontend_atsc") {356- out = append(out, Frontend{UUID: n.UUID, Class: class, Name: cmp.Or(n.param("displayname"), n.Text), Model: m})357- }358- if !bool(n.Leaf) && depth < 3 {359- if err := walk(n.UUID, m, depth+1); err != nil {360- return err361- }362- }363- }364- return nil365- }366- return out, walk("root", "", 0)367-}368-369-// flexBool is a JSON boolean that Tvheadend sometimes writes as 0 or 1370-// (the hardware tree's "leaf").371-type flexBool bool372-373-func (b *flexBool) UnmarshalJSON(raw []byte) error {374- switch string(raw) {375- case "true", "1":376- *b = true377- case "false", "0", "null":378- *b = false379- default:380- return fmt.Errorf("%s is not a boolean", raw)381- }382- return nil383-}384-385-func (c *Client) class(ctx context.Context, uuid string) (string, error) {386- var body struct {387- Entries []struct {388- Class string `json:"class"`389- } `json:"entries"`390- }391- if err := c.get(ctx, "/api/idnode/load", url.Values{"uuid": {uuid}, "meta": {"0"}}, &body); err != nil {392- return "", err393- }394- if len(body.Entries) == 0 {395- return "", nil396- }397- return body.Entries[0].Class, nil398-}399-400-// EnsureATSC creates the antenna network from the US ATSC channel list and401-// attaches every ATSC frontend to it. Tvheadend then scans every RF channel;402-// MapServices adds the stations it finds. It reports whether anything403-// changed.404-func (c *Client) EnsureATSC(ctx context.Context, fes []Frontend) (bool, error) {405- if len(fes) == 0 {406- return false, nil407- }408- net, err := c.network(ctx, ATSCNetwork)409- if err != nil {410- return false, err411- }412- changed := false413- if net == nil {414- scanfile, err := c.usScanfile(ctx)415- if err != nil {416- return false, err417- }418- var out struct {419- UUID string `json:"uuid"`420- }421- conf := map[string]any{"networkname": ATSCNetwork, "scanfile": scanfile, "skipinitscan": false}422- body := form("conf", conf)423- body.Set("class", "dvb_network_atsc_t")424- if err := c.post(ctx, "/api/mpegts/network/create", body, &out); err != nil {425- return false, fmt.Errorf("create antenna network: %w", err)426- }427- net = &Network{UUID: out.UUID}428- changed = true429- }430- for _, fe := range fes {431- node := map[string]any{"uuid": fe.UUID, "enabled": true, "networks": []string{net.UUID}, "ota_epg": true}432- if err := c.post(ctx, "/api/idnode/save", form("node", node), nil); err != nil {433- return changed, fmt.Errorf("enable %s: %w", fe.Name, err)434- }435- }436- return changed, nil437-}438-439-// usScanfile finds the nationwide 8VSB center-frequency list. Tvheadend's440-// keys are truncated, so match on the label.441-func (c *Client) usScanfile(ctx context.Context) (string, error) {442- var body struct {443- Entries []struct {444- Key string `json:"key"`445- Val string `json:"val"`446- } `json:"entries"`447- }448- if err := c.get(ctx, "/api/dvb/scanfile/list", url.Values{"type": {"atsc-t"}}, &body); err != nil {449- return "", err450- }451- for _, e := range body.Entries {452- if strings.HasSuffix(e.Val, "us-ATSC-center-frequencies-8VSB") {453- return e.Key, nil454- }455- }456- return "", fmt.Errorf("tvheadend has no US ATSC scan file")457-}458-459-// RemoveOrphanChannels deletes channels left without any service, for460-// example after demo channels are rebuilt. It reports how many it removed.461-func (c *Client) RemoveOrphanChannels(ctx context.Context) (int, error) {462- chans, err := c.Channels(ctx)463- if err != nil {464- return 0, err465- }466- n := 0467- for _, ch := range chans {468- if len(ch.Services) > 0 {469- continue470- }471- if err := c.post(ctx, "/api/idnode/delete", url.Values{"uuid": {ch.UUID}}, nil); err != nil {472- return n, err473- }474- n++475- }476- return n, nil477-}
D internal/tvh/setup_test.go +0 -66
@@ -1,66 +0,0 @@
1-package tvh2-3-import (4- "context"5- "encoding/json"6- "net/http"7- "net/http/httptest"8- "strings"9- "testing"10-)11-12-// TestFrontendsHDHomeRun walks a hardware tree as Tvheadend 4.3 serves it13-// for a network HDHomeRun, "leaf" a number, down to its ATSC tuners.14-func TestFrontendsHDHomeRun(t *testing.T) {15- tree := map[string]string{16- "root": `[{"uuid":"dev","text":"HDHomeRun(10A0B0C0) - 169.254.1.2","leaf":0}]`,17- "dev": `[{"uuid":"tuner0","text":"HDHomeRun ATSC-T Tuner #0 (169.254.1.2)","leaf":1},{"uuid":"tuner1","text":"HDHomeRun ATSC-T Tuner #1 (169.254.1.2)","leaf":true}]`,18- "tuner0": `[]`, "tuner1": `[]`,19- }20- class := map[string]string{"dev": "tvhdhomerun_client", "tuner0": "tvhdhomerun_frontend_atsc_t", "tuner1": "tvhdhomerun_frontend_atsc_t"}21- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {22- uuid := r.URL.Query().Get("uuid")23- switch r.URL.Path {24- case "/api/hardware/tree":25- w.Write([]byte(tree[uuid]))26- case "/api/idnode/load":27- json.NewEncoder(w).Encode(map[string]any{"entries": []map[string]string{{"class": class[uuid]}}})28- default:29- http.NotFound(w, r)30- }31- }))32- defer srv.Close()33-34- fes, err := New(srv.URL, srv.Client()).Frontends(context.Background())35- if err != nil {36- t.Fatal(err)37- }38- if len(fes) != 2 || fes[0].UUID != "tuner0" || fes[1].Class != "tvhdhomerun_frontend_atsc_t" {39- t.Fatalf("frontends: %+v", fes)40- }41-}42-43-// TestChannelsNumbers reads the channel grid as Tvheadend writes it: a44-// packed integer for a channel numbered by hand, a string for a scanned45-// ATSC channel, and names padded with spaces.46-func TestChannelsNumbers(t *testing.T) {47- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {48- w.Write([]byte(`{"entries":[49- {"uuid":"a","name":"Demo 7.1","number":7000001,"enabled":true},50- {"uuid":"b","name":"Dabl ","number":"4.3","enabled":true,"services":["s"]},51- {"uuid":"c","name":"KTVD","number":"20","enabled":false},52- {"uuid":"d","name":"Unnumbered","enabled":true}]}`))53- }))54- defer srv.Close()55- chans, err := New(srv.URL, srv.Client()).Channels(context.Background())56- if err != nil {57- t.Fatal(err)58- }59- var got []string60- for _, c := range chans {61- got = append(got, c.Number+" "+c.Name)62- }63- if want := []string{"7.1 Demo 7.1", "4.3 Dabl", "20 KTVD", "0 Unnumbered"}; strings.Join(got, "|") != strings.Join(want, "|") {64- t.Errorf("channels: %q, want %q", got, want)65- }66-}
D internal/tvh/status.go +0 -236
@@ -1,236 +0,0 @@
1-package tvh2-3-import (4- "context"5- "fmt"6- "io"7- "net/http"8- "net/url"9- "strconv"10- "strings"11-)12-13-// InputStatus is a tuner input as /api/status/inputs reports it. Tvheadend14-// 4.3 lists idle inputs too, with no Stream, no subscriptions and weight 0.15-type InputStatus struct {16- UUID string `json:"uuid"`17- // Input is the input's name, as Frontend.Name has it.18- Input string `json:"input"`19- // Stream is what the input is tuned to: "605MHz in Airwaves antenna";20- // empty while idle.21- Stream string `json:"stream"`22- Subs int `json:"subs"`23- Weight int `json:"weight"`24- // Signal and SNR are in the units their scales say (see Scale).25- Signal int64 `json:"signal"`26- SignalScale Scale `json:"signal_scale"`27- SNR int64 `json:"snr"`28- SNRScale Scale `json:"snr_scale"`29- // BER and UNC are the driver's own counters, 0 where it has none.30- BER int64 `json:"ber"`31- UNC int64 `json:"unc"`32- // BPS is the data rate the input receives, in bits per second: above33- // zero only while the tuner holds a lock, though a locked input with34- // little to send (a guide scan, a measurement) can read 0 for seconds35- // at a time.36- BPS int64 `json:"bps"`37- // TE and CC count transport and continuity errors in what arrived.38- TE int64 `json:"te"`39- CC int64 `json:"cc"`40- // ECBit/TCBit (bit errors of bits counted) and ECBlock/TCBlock41- // (uncorrected blocks of blocks counted) are filled in by tuners that42- // count them; TCBit and TCBlock are 0 otherwise.43- ECBit int64 `json:"ec_bit"`44- TCBit int64 `json:"tc_bit"`45- ECBlock int64 `json:"ec_block"`46- TCBlock int64 `json:"tc_block"`47-}48-49-// Scale is how a tuner reports signal strength or SNR.50-type Scale int51-52-// Scales, as Tvheadend numbers them.53-const (54- ScaleUnknown Scale = 0 // not reported55- ScaleRelative Scale = 1 // 0 to 65535 for 0 to 100%56- ScaleDecibel Scale = 2 // thousandths of a dB (dBm for strength)57-)58-59-// InUse reports whether something subscribes to the input. An idle input,60-// or one Tvheadend keeps tuned for a moment after its last subscription61-// ended, can be had by any subscription.62-func (in InputStatus) InUse() bool { return in.Subs > 0 }63-64-// Mux names the multiplex and network the input is tuned to, from Stream;65-// ok is false for an input that isn't on a multiplex.66-func (in InputStatus) Mux() (mux, network string, ok bool) {67- i := strings.LastIndex(in.Stream, " in ")68- if i <= 0 {69- return "", "", false70- }71- return in.Stream[:i], in.Stream[i+len(" in "):], true72-}73-74-// Inputs lists the tuner inputs in use, with their signal.75-func (c *Client) Inputs(ctx context.Context) ([]InputStatus, error) {76- var body struct {77- Entries []InputStatus `json:"entries"`78- }79- if err := c.get(ctx, "/api/status/inputs", nil, &body); err != nil {80- return nil, err81- }82- return body.Entries, nil83-}84-85-// Subscription is something using a tuner (or another source) now: a86-// viewer, a recording, or Tvheadend's own guide and channel scans.87-type Subscription struct {88- ID int64 `json:"id"`89- Start int64 `json:"start"`90- // State is "Testing" while Tvheadend waits for data (a tuner that91- // can't lock stays there), "Running" once data arrives, "Bad" when92- // the service failed.93- State string `json:"state"`94- Title string `json:"title"` // "epggrab", "DVR: ...", "HTTP"95- Service string `json:"service"` // "<input>/<network>/<mux>/<service>"96- Errors int `json:"errors"`97- // TotalIn counts the bytes it has received.98- TotalIn int64 `json:"total_in"`99-}100-101-// On reports whether the subscription is to the multiplex mux of network102-// on input.103-func (s Subscription) On(input, network, mux string) bool {104- return strings.HasPrefix(s.Service, input+"/"+network+"/"+mux+"/")105-}106-107-// Running reports whether data has arrived for it: the tuner locked.108-func (s Subscription) Running() bool { return s.State == "Running" }109-110-// Subscriptions lists what is using Tvheadend's inputs now.111-func (c *Client) Subscriptions(ctx context.Context) ([]Subscription, error) {112- var body struct {113- Entries []Subscription `json:"entries"`114- }115- if err := c.get(ctx, "/api/status/subscriptions", nil, &body); err != nil {116- return nil, err117- }118- return body.Entries, nil119-}120-121-// Lineup is Tvheadend's channels with the services and multiplexes they122-// play from.123-type Lineup struct {124- Networks []Network125- Muxes []Mux126- Services []Service127- Channels []Channel128-}129-130-// Lineup fetches the networks, multiplexes, services and channels.131-func (c *Client) Lineup(ctx context.Context) (*Lineup, error) {132- var l Lineup133- var err error134- if l.Networks, err = c.Networks(ctx); err != nil {135- return nil, err136- }137- if l.Muxes, err = c.Muxes(ctx); err != nil {138- return nil, err139- }140- if l.Services, err = c.Services(ctx); err != nil {141- return nil, err142- }143- if l.Channels, err = c.Channels(ctx); err != nil {144- return nil, err145- }146- return &l, nil147-}148-149-// Source finds the service a channel plays from, its first one, and the150-// multiplex that carries it.151-func (l *Lineup) Source(ch Channel) (*Service, *Mux, bool) {152- for _, id := range ch.Services {153- for i := range l.Services {154- if l.Services[i].UUID != id {155- continue156- }157- s := &l.Services[i]158- for j := range l.Muxes {159- if l.Muxes[j].UUID == s.MultiplexUUID {160- return s, &l.Muxes[j], true161- }162- }163- return s, nil, false164- }165- }166- return nil, nil, false167-}168-169-// NetworkMuxes lists the multiplexes of the named network.170-func (l *Lineup) NetworkMuxes(name string) []Mux {171- var out []Mux172- for _, m := range l.Muxes {173- if m.Network == name {174- out = append(out, m)175- }176- }177- return out178-}179-180-// HasNetwork reports whether the named network exists.181-func (l *Lineup) HasNetwork(name string) bool {182- for _, n := range l.Networks {183- if n.Name == name {184- return true185- }186- }187- return false188-}189-190-// OpenMux subscribes to a multiplex as a raw stream of its PAT (PID 0)191-// only, at weight: the tuner tunes and locks if it can, while hardly any192-// data flows. The subscription lasts until the body is closed. A193-// subscription of higher weight (a viewer, a recording) takes the tuner194-// over; weight 10 is the lowest a user's can have, above Tvheadend's own195-// scans (1 to 7).196-func (c *Client) OpenMux(ctx context.Context, muxUUID string, weight int) (io.ReadCloser, error) {197- q := url.Values{"pids": {"0"}, "weight": {strconv.Itoa(weight)}}198- return c.open(ctx, "/stream/mux/"+url.PathEscape(muxUUID)+"?"+q.Encode())199-}200-201-// OpenChannel subscribes to a channel at weight, as OpenMux does, for202-// servers that don't stream whole multiplexes.203-func (c *Client) OpenChannel(ctx context.Context, channelUUID string, weight int) (io.ReadCloser, error) {204- q := url.Values{"profile": {"pass"}, "weight": {strconv.Itoa(weight)}}205- return c.open(ctx, "/stream/channel/"+url.PathEscape(channelUUID)+"?"+q.Encode())206-}207-208-// StreamError is a stream Tvheadend refused.209-type StreamError struct {210- Path string211- Status int212-}213-214-func (e *StreamError) Error() string {215- return fmt.Sprintf("tvheadend %s: HTTP %d", e.Path, e.Status)216-}217-218-func (c *Client) open(ctx context.Context, path string) (io.ReadCloser, error) {219- req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Base+path, nil)220- if err != nil {221- return nil, err222- }223- // The client's timeout would end a stream; ctx ends it instead.224- hc := *c.HTTP225- hc.Timeout = 0226- resp, err := hc.Do(req)227- if err != nil {228- return nil, fmt.Errorf("tvheadend: %w", err)229- }230- if resp.StatusCode != http.StatusOK {231- resp.Body.Close()232- p, _, _ := strings.Cut(path, "?")233- return nil, &StreamError{Path: p, Status: resp.StatusCode}234- }235- return resp.Body, nil236-}
D internal/tvh/status_test.go +0 -196
@@ -1,196 +0,0 @@
1-package tvh_test2-3-import (4- "encoding/json"5- "errors"6- "io"7- "strings"8- "testing"9- "time"10-11- "airwaves/internal/tvh"12- "airwaves/internal/tvh/tvhtest"13-)14-15-// TestInputsShape reads /api/status/inputs as a real Tvheadend 4.3 writes16-// it for an HDHomeRun: both tuners on guide scans, strength and quality on17-// the relative scale.18-func TestInputsShape(t *testing.T) {19- var body struct {20- Entries []tvh.InputStatus `json:"entries"`21- }22- if err := json.Unmarshal(tvhtest.Raw(t, "status_inputs.json"), &body); err != nil {23- t.Fatal(err)24- }25- if len(body.Entries) != 2 {26- t.Fatalf("entries = %d", len(body.Entries))27- }28- in := body.Entries[0]29- mux, net, ok := in.Mux()30- if !ok || mux != "605MHz" || net != "Airwaves antenna" || in.Input != tvhtest.Tuner0 {31- t.Errorf("mux %q, network %q, %v, input %q", mux, net, ok, in.Input)32- }33- if in.Signal != 55704 || in.SignalScale != tvh.ScaleRelative || in.SNR != 54394 || in.SNRScale != tvh.ScaleRelative ||34- in.BPS != 231616 || in.Subs != 1 || in.Weight != 4 || in.TCBit != 0 {35- t.Errorf("input = %+v", in)36- }37- if _, _, ok := (tvh.InputStatus{Stream: ""}).Mux(); ok {38- t.Error("an input on nothing named a multiplex")39- }40-41- var subs struct {42- Entries []tvh.Subscription `json:"entries"`43- }44- if err := json.Unmarshal(tvhtest.Raw(t, "status_subscriptions.json"), &subs); err != nil {45- t.Fatal(err)46- }47- if len(subs.Entries) != 2 || subs.Entries[0].Title != "epggrab" || subs.Entries[0].State != "Running" ||48- !strings.HasSuffix(subs.Entries[0].Service, "/587MHz/Raw PID Subscription") {49- t.Errorf("subscriptions = %+v", subs.Entries)50- }51-}52-53-// TestLineup joins the real grids: 38 channels from 38 services on 8 of the54-// antenna network's 36 multiplexes.55-func TestLineup(t *testing.T) {56- srv := tvhtest.New(t)57- c := tvh.New(srv.URL, srv.Client())58- l, err := c.Lineup(t.Context())59- if err != nil {60- t.Fatal(err)61- }62- if len(l.Networks) != 1 || !l.HasNetwork(tvh.ATSCNetwork) || l.HasNetwork(tvh.DemoNetwork) ||63- len(l.Muxes) != 36 || len(l.Services) != 38 || len(l.Channels) != 38 || len(l.NetworkMuxes(tvh.ATSCNetwork)) != 36 {64- t.Fatalf("lineup: %d networks, %d muxes, %d services, %d channels", len(l.Networks), len(l.Muxes), len(l.Services), len(l.Channels))65- }66- byNumber := map[string]tvh.Channel{}67- for _, ch := range l.Channels {68- byNumber[ch.Number] = ch69- }70- svc, mux, ok := l.Source(byNumber["9.4"])71- if !ok || svc.Name != "KUSA-HD" || svc.Major != 9 || svc.Minor != 4 || mux.Name != "575MHz" || mux.FrequencyHz != 575_000_000 ||72- mux.ScanResult != tvh.ScanOK || mux.ScanLast != 1791129355 || !bool(mux.Enabled) || mux.Scanning() || mux.NumSvc != 6 {73- t.Errorf("9.4: %+v on %+v (%v)", svc, mux, ok)74- }75- failed := 076- for _, m := range l.Muxes {77- if m.ScanResult == tvh.ScanFail {78- failed++79- if m.ScanLast != 0 || m.NumSvc != 0 {80- t.Errorf("failed mux %+v", m)81- }82- }83- }84- if failed != 28 {85- t.Errorf("%d multiplexes failed their scan, want 28", failed)86- }87-}88-89-// TestInputsIdle reads the inputs as a real Tvheadend 4.3 lists them when90-// nothing uses them: listed, with no stream and no subscriptions.91-func TestInputsIdle(t *testing.T) {92- var body struct {93- Entries []tvh.InputStatus `json:"entries"`94- }95- if err := json.Unmarshal(tvhtest.Raw(t, "status_inputs_idle.json"), &body); err != nil {96- t.Fatal(err)97- }98- if len(body.Entries) != 2 {99- t.Fatalf("entries = %d", len(body.Entries))100- }101- for _, in := range body.Entries {102- if _, _, ok := in.Mux(); ok || in.InUse() || in.Weight != 0 || in.Input == "" {103- t.Errorf("idle input %+v", in)104- }105- }106- var subs struct {107- Entries []tvh.Subscription `json:"entries"`108- }109- if err := json.Unmarshal(tvhtest.Raw(t, "status_subscriptions_measuring.json"), &subs); err != nil {110- t.Fatal(err)111- }112- if len(subs.Entries) != 2 || !subs.Entries[0].Running() || subs.Entries[1].Running() || subs.Entries[1].State != "Testing" ||113- !subs.Entries[1].On(tvhtest.Tuner0, tvh.ATSCNetwork, "177MHz") || subs.Entries[0].TotalIn == 0 {114- t.Errorf("subscriptions %+v", subs.Entries)115- }116-}117-118-// TestFrontendsFromTree reads the tuners, their names as the input status119-// gives them and the model, from the hardware tree as Tvheadend 4.3 sends120-// it, class and parameters included, so without a lookup per node.121-func TestFrontendsFromTree(t *testing.T) {122- srv := tvhtest.New(t)123- fes, err := tvh.New(srv.URL, srv.Client()).Frontends(t.Context())124- if err != nil {125- t.Fatal(err)126- }127- if len(fes) != 2 || fes[0].Name != tvhtest.Tuner0 || fes[1].Name != tvhtest.Tuner1 || fes[0].Model != "hdhomerun_dvr_atsc" ||128- fes[0].Class != "tvhdhomerun_frontend_atsc_t" {129- t.Fatalf("frontends: %+v", fes)130- }131- for _, r := range srv.Requests() {132- if strings.Contains(r, "/api/idnode/load") {133- t.Errorf("looked up a class the tree had: %s", r)134- }135- }136-}137-138-// TestOpenMux subscribes to a multiplex at a weight, and the tuner shows it139-// in the input status until the stream is closed.140-func TestOpenMux(t *testing.T) {141- srv := tvhtest.New(t)142- c := tvh.New(srv.URL, srv.Client())143- ctx := t.Context()144- l, err := c.Lineup(ctx)145- if err != nil {146- t.Fatal(err)147- }148- var m tvh.Mux149- for _, x := range l.Muxes {150- if x.Name == "575MHz" {151- m = x152- }153- }154- body, err := c.OpenMux(ctx, m.UUID, 10)155- if err != nil {156- t.Fatal(err)157- }158- buf := make([]byte, 188)159- if _, err := io.ReadFull(body, buf); err != nil || buf[0] != 0x47 {160- t.Fatalf("read %x, %v", buf[:1], err)161- }162- ins, err := c.Inputs(ctx)163- if err != nil || len(ins) != 2 || ins[0].Stream != "575MHz in Airwaves antenna" || ins[0].Weight != 10 || !ins[0].InUse() ||164- ins[1].InUse() || ins[1].Stream != "" {165- t.Fatalf("inputs %+v, %v", ins, err)166- }167- // Locked by the next look.168- if _, err := c.Inputs(ctx); err != nil {169- t.Fatal(err)170- }171- subs, err := c.Subscriptions(ctx)172- if err != nil || len(subs) != 1 || !subs[0].Running() || !subs[0].On(tvhtest.Tuner0, tvh.ATSCNetwork, "575MHz") || subs[0].On(tvhtest.Tuner1, tvh.ATSCNetwork, "575MHz") {173- t.Fatalf("subscriptions %+v, %v", subs, err)174- }175- body.Close()176- deadline := time.Now().Add(2 * time.Second)177- for len(srv.Streams()) > 0 && time.Now().Before(deadline) {178- time.Sleep(10 * time.Millisecond)179- }180- if s := srv.Streams(); len(s) != 0 {181- t.Errorf("still streaming %v", s)182- }183- var found bool184- for _, r := range srv.Requests() {185- found = found || r == "GET /stream/mux/"+m.UUID+"?pids=0&weight=10"186- }187- if !found {188- t.Errorf("requests: %v", srv.Requests())189- }190-191- srv.NoMuxStreams = true192- var se *tvh.StreamError193- if _, err := c.OpenMux(ctx, m.UUID, 10); !errors.As(err, &se) || se.Status != 403 {194- t.Errorf("refused: %v", err)195- }196-}
D internal/tvh/tvh.go +0 -291
@@ -1,291 +0,0 @@
1-// Package tvh is a small client for the Tvheadend HTTP API: channels, live2-// streams, timed DVR entries and network setup.3-//4-// Tvheadend owns the tuners and writes recordings; Airwaves decides what to5-// record and how to present it.6-package tvh7-8-import (9- "bytes"10- "cmp"11- "context"12- "encoding/json"13- "fmt"14- "io"15- "net/http"16- "net/url"17- "strconv"18- "strings"19-)20-21-// numberSplit is how Tvheadend packs "major.minor" into one integer.22-const numberSplit = 1_000_00023-24-// Client talks to one Tvheadend server.25-type Client struct {26- Base string // e.g. http://tvheadend:998127- HTTP *http.Client28-}29-30-// New returns a client for base.31-func New(base string, c *http.Client) *Client {32- return &Client{Base: strings.TrimRight(base, "/"), HTTP: c}33-}34-35-// Info describes the server.36-type Info struct {37- Version string `json:"sw_version"`38- APIVersion int `json:"api_version"`39- Name string `json:"name"`40-}41-42-// ServerInfo checks the server is reachable.43-func (c *Client) ServerInfo(ctx context.Context) (Info, error) {44- var i Info45- return i, c.get(ctx, "/api/serverinfo", nil, &i)46-}47-48-// Channel is a mapped Tvheadend channel.49-type Channel struct {50- UUID string `json:"uuid"`51- Name string `json:"name"`52- Number string `json:"number"` // "7.1"53- Enabled bool `json:"enabled"`54- Services []string `json:"services"`55-}56-57-// Channels lists all channels.58-func (c *Client) Channels(ctx context.Context) ([]Channel, error) {59- var body struct {60- Entries []struct {61- UUID string `json:"uuid"`62- Name string `json:"name"`63- Number gridNumber `json:"number"`64- Enabled bool `json:"enabled"`65- Services []string `json:"services"`66- } `json:"entries"`67- }68- if err := c.get(ctx, "/api/channel/grid", url.Values{"limit": {"5000"}}, &body); err != nil {69- return nil, err70- }71- out := make([]Channel, 0, len(body.Entries))72- for _, e := range body.Entries {73- out = append(out, Channel{UUID: e.UUID, Name: strings.TrimSpace(e.Name), Number: cmp.Or(string(e.Number), "0"), Enabled: e.Enabled, Services: e.Services})74- }75- return out, nil76-}77-78-// Stream is one elementary stream of a service.79-type Stream struct {80- Index int `json:"index"`81- Type string `json:"type"` // "MPEG2VIDEO", "AC3", "EAC3", "AC4", ...82- Language string `json:"language"`83- AudioType int `json:"audio_type"` // 3: visual impaired commentary84-}85-86-// ServiceStreams lists a service's video, audio and subtitle streams, as87-// last seen by Tvheadend, without tuning it.88-func (c *Client) ServiceStreams(ctx context.Context, serviceUUID string) ([]Stream, error) {89- var body struct {90- Streams []Stream `json:"streams"`91- }92- if err := c.get(ctx, "/api/service/streams", url.Values{"uuid": {serviceUUID}}, &body); err != nil {93- return nil, err94- }95- out := body.Streams[:0]96- for _, s := range body.Streams {97- if s.Index > 0 { // PCR and PMT entries have no index98- out = append(out, s)99- }100- }101- return out, nil102-}103-104-// gridNumber is a channel number as the channel grid writes it: a packed105-// integer (7000001), or for a real ATSC channel a string ("7.1").106-type gridNumber string107-108-func (n *gridNumber) UnmarshalJSON(raw []byte) error {109- var s string110- if err := json.Unmarshal(raw, &s); err == nil {111- *n = gridNumber(strings.TrimSpace(s))112- return nil113- }114- var packed int64115- if err := json.Unmarshal(raw, &packed); err != nil {116- return fmt.Errorf("channel number %s: %w", raw, err)117- }118- *n = gridNumber(FormatNumber(packed))119- return nil120-}121-122-// FormatNumber renders a packed channel number: 7000001 → "7.1", 27 → "27".123-func FormatNumber(n int64) string {124- major, minor := n/numberSplit, n%numberSplit125- if n < numberSplit {126- return strconv.FormatInt(n, 10)127- }128- if minor == 0 {129- return strconv.FormatInt(major, 10)130- }131- return fmt.Sprintf("%d.%d", major, minor)132-}133-134-// PackNumber is the inverse of FormatNumber.135-func PackNumber(major, minor int) int64 {136- return int64(major)*numberSplit + int64(minor)137-}138-139-// StreamURL is the raw MPEG-TS stream of a channel.140-func (c *Client) StreamURL(channelUUID string) string {141- return c.Base + "/stream/channel/" + channelUUID + "?profile=pass"142-}143-144-// FileURL is the recorded file of a DVR entry.145-func (c *Client) FileURL(entryUUID string) string {146- return c.Base + "/dvrfile/" + entryUUID147-}148-149-// Entry is a DVR entry, scheduled, recording or finished.150-type Entry struct {151- UUID string `json:"uuid"`152- Title string `json:"disp_title"`153- Subtitle string `json:"disp_subtitle"`154- Description string `json:"disp_description"`155- Channel string `json:"channel"`156- ChannelName string `json:"channelname"`157- Start int64 `json:"start"`158- Stop int64 `json:"stop"`159- StartReal int64 `json:"start_real"`160- StopReal int64 `json:"stop_real"`161- // SchedStatus is "scheduled", "recording", "completed",162- // "completedError" and so on.163- SchedStatus string `json:"sched_status"`164- Status string `json:"status"` // human readable165- Comment string `json:"comment"`166- FileSize int64 `json:"filesize"`167- ErrorCode int `json:"errorcode"`168-}169-170-// NewEntry schedules a recording by time.171-type NewEntry struct {172- Channel string // channel UUID173- Start, Stop int64 // unix seconds174- Title string175- Subtitle string176- Description string177- Comment string178- // Padding in minutes.179- StartExtra, StopExtra int180-}181-182-// Upcoming lists scheduled and in-progress recordings.183-func (c *Client) Upcoming(ctx context.Context) ([]Entry, error) {184- return c.entries(ctx, "grid_upcoming")185-}186-187-// Finished lists completed recordings.188-func (c *Client) Finished(ctx context.Context) ([]Entry, error) {189- return c.entries(ctx, "grid_finished")190-}191-192-// Failed lists recordings that did not complete.193-func (c *Client) Failed(ctx context.Context) ([]Entry, error) {194- return c.entries(ctx, "grid_failed")195-}196-197-func (c *Client) entries(ctx context.Context, grid string) ([]Entry, error) {198- var body struct {199- Entries []Entry `json:"entries"`200- }201- err := c.get(ctx, "/api/dvr/entry/"+grid, url.Values{"limit": {"5000"}}, &body)202- return body.Entries, err203-}204-205-// CreateEntry schedules a recording and returns its UUID.206-func (c *Client) CreateEntry(ctx context.Context, e NewEntry) (string, error) {207- conf := map[string]any{208- "channel": e.Channel,209- "start": e.Start,210- "stop": e.Stop,211- "title": map[string]string{"eng": e.Title},212- "subtitle": map[string]string{"eng": e.Subtitle},213- "description": map[string]string{"eng": e.Description},214- "comment": e.Comment,215- "start_extra": e.StartExtra,216- "stop_extra": e.StopExtra,217- }218- var out struct {219- UUID string `json:"uuid"`220- }221- if err := c.post(ctx, "/api/dvr/entry/create", form("conf", conf), &out); err != nil {222- return "", err223- }224- if out.UUID == "" {225- return "", fmt.Errorf("tvheadend did not create the recording %q", e.Title)226- }227- return out.UUID, nil228-}229-230-// DeleteScheduled removes a recording that has not started.231-func (c *Client) DeleteScheduled(ctx context.Context, uuid string) error {232- return c.post(ctx, "/api/idnode/delete", url.Values{"uuid": {uuid}}, nil)233-}234-235-// Cancel stops a recording in progress and discards it.236-func (c *Client) Cancel(ctx context.Context, uuid string) error {237- return c.post(ctx, "/api/dvr/entry/cancel", url.Values{"uuid": {uuid}}, nil)238-}239-240-// Remove deletes a finished or failed recording and its file.241-func (c *Client) Remove(ctx context.Context, uuid string) error {242- return c.post(ctx, "/api/dvr/entry/remove", url.Values{"uuid": {uuid}}, nil)243-}244-245-func form(key string, v any) url.Values {246- b, _ := json.Marshal(v)247- return url.Values{key: {string(b)}}248-}249-250-func (c *Client) get(ctx context.Context, path string, q url.Values, out any) error {251- u := c.Base + path252- if len(q) > 0 {253- u += "?" + q.Encode()254- }255- req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)256- if err != nil {257- return err258- }259- return c.do(req, out)260-}261-262-func (c *Client) post(ctx context.Context, path string, body url.Values, out any) error {263- req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Base+path, strings.NewReader(body.Encode()))264- if err != nil {265- return err266- }267- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")268- return c.do(req, out)269-}270-271-func (c *Client) do(req *http.Request, out any) error {272- resp, err := c.HTTP.Do(req)273- if err != nil {274- return fmt.Errorf("tvheadend: %w", err)275- }276- defer resp.Body.Close()277- raw, err := io.ReadAll(resp.Body)278- if err != nil {279- return fmt.Errorf("tvheadend: %w", err)280- }281- if resp.StatusCode != http.StatusOK {282- return fmt.Errorf("tvheadend %s: HTTP %d: %s", req.URL.Path, resp.StatusCode, bytes.TrimSpace(raw[:min(len(raw), 200)]))283- }284- if out == nil {285- return nil286- }287- if err := json.Unmarshal(raw, out); err != nil {288- return fmt.Errorf("tvheadend %s: %w", req.URL.Path, err)289- }290- return nil291-}
D internal/tvh/tvhtest/testdata/channel_grid.json +0 -765
@@ -1,765 +0,0 @@
1-{2- "entries": [3- {4- "uuid": "9ba1bb859520d32e45a0bb82c44fee0e",5- "enabled": true,6- "autoname": true,7- "name": "KWGN-DT",8- "number": "2.1",9- "icon": "picon://1_0_0_6_1D3_10000_DDDD0000_0_0_0.png",10- "icon_public_url": "imagecache/44",11- "epgauto": true,12- "epglimit": 0,13- "epggrab": [],14- "dvr_pre_time": 0,15- "dvr_pst_time": 0,16- "epg_running": -1,17- "services": [18- "d3e0d1efd2f9babfdb41a47b36607800"19- ],20- "tags": [],21- "bouquet": ""22- },23- {24- "uuid": "db312e0991f9bb43ba7eb4980775698a",25- "enabled": true,26- "autoname": true,27- "name": "KCNC-TV",28- "number": "4.1",29- "icon": "picon://1_0_0_1_1C9_10000_DDDD0000_0_0_0.png",30- "icon_public_url": "imagecache/38",31- "epgauto": true,32- "epglimit": 0,33- "epggrab": [],34- "dvr_pre_time": 0,35- "dvr_pst_time": 0,36- "epg_running": -1,37- "services": [38- "c2b03cefe9378724647ead9aa93e8d22"39- ],40- "tags": [],41- "bouquet": ""42- },43- {44- "uuid": "c582ec8231fce59f932452b879c29c02",45- "enabled": true,46- "autoname": true,47- "name": "StartTV",48- "number": "4.2",49- "icon": "file:///picons/starttv.png",50- "icon_public_url": "imagecache/37",51- "epgauto": true,52- "epglimit": 0,53- "epggrab": [],54- "dvr_pre_time": 0,55- "dvr_pst_time": 0,56- "epg_running": -1,57- "services": [58- "894e10974ce0ae452c67212ed5811e4b"59- ],60- "tags": [],61- "bouquet": ""62- },63- {64- "uuid": "4a4a8e58de8c9cd3dc4bfc97e6ebecbb",65- "enabled": true,66- "autoname": true,67- "name": "Dabl ",68- "number": "4.3",69- "icon": "picon://1_0_0_3_1C9_10000_DDDD0000_0_0_0.png",70- "icon_public_url": "imagecache/41",71- "epgauto": true,72- "epglimit": 0,73- "epggrab": [],74- "dvr_pre_time": 0,75- "dvr_pst_time": 0,76- "epg_running": -1,77- "services": [78- "9f1ba02d59cb9b3206dae9a447706906"79- ],80- "tags": [],81- "bouquet": ""82- },83- {84- "uuid": "1f20573bd1f045333d53a31e213912a9",85- "enabled": true,86- "autoname": true,87- "name": "MeTV ",88- "number": "4.4",89- "icon": "picon://1_0_0_4_1C9_10000_DDDD0000_0_0_0.png",90- "icon_public_url": "imagecache/40",91- "epgauto": true,92- "epglimit": 0,93- "epggrab": [],94- "dvr_pre_time": 0,95- "dvr_pst_time": 0,96- "epg_running": -1,97- "services": [98- "088dda0508522f57035031c2c1a2e1c1"99- ],100- "tags": [],101- "bouquet": ""102- },103- {104- "uuid": "11c7e5a354ae2a786a3d2761f52ca4bb",105- "enabled": true,106- "autoname": true,107- "name": "Catchy ",108- "number": "4.5",109- "icon": "picon://1_0_0_5_1C9_10000_DDDD0000_0_0_0.png",110- "icon_public_url": "imagecache/39",111- "epgauto": true,112- "epglimit": 0,113- "epggrab": [],114- "dvr_pre_time": 0,115- "dvr_pst_time": 0,116- "epg_running": -1,117- "services": [118- "f3d0461bc09c28d2fcbc913729e5ccd6"119- ],120- "tags": [],121- "bouquet": ""122- },123- {124- "uuid": "a48eb7daf4c16680458ed9b655212cb1",125- "enabled": true,126- "autoname": true,127- "name": "Story ",128- "number": "4.6",129- "icon": "picon://1_0_0_6_1C9_10000_DDDD0000_0_0_0.png",130- "icon_public_url": "imagecache/42",131- "epgauto": true,132- "epglimit": 0,133- "epggrab": [],134- "dvr_pre_time": 0,135- "dvr_pst_time": 0,136- "epg_running": -1,137- "services": [138- "e3af94e9356e90b3cd8e304a35c01684"139- ],140- "tags": [],141- "bouquet": ""142- },143- {144- "uuid": "48737af5fc18cd696caf456ccfa55c81",145- "enabled": true,146- "autoname": true,147- "name": "KRMADT1",148- "number": "6.1",149- "icon": "picon://1_0_0_1_1CB_10000_DDDD0000_0_0_0.png",150- "icon_public_url": "imagecache/35",151- "epgauto": true,152- "epglimit": 0,153- "epggrab": [],154- "dvr_pre_time": 0,155- "dvr_pst_time": 0,156- "epg_running": -1,157- "services": [158- "22c312f15bac109268c7c710ae7e3d50"159- ],160- "tags": [],161- "bouquet": ""162- },163- {164- "uuid": "e1338eee484b3d8cb923a0882054cbb0",165- "enabled": true,166- "autoname": true,167- "name": "PBSKIDS",168- "number": "6.2",169- "icon": "picon://1_0_0_2_1CB_10000_DDDD0000_0_0_0.png",170- "icon_public_url": "imagecache/34",171- "epgauto": true,172- "epglimit": 0,173- "epggrab": [],174- "dvr_pre_time": 0,175- "dvr_pst_time": 0,176- "epg_running": -1,177- "services": [178- "7240cb614ca5b54b71f147253287def0"179- ],180- "tags": [],181- "bouquet": ""182- },183- {184- "uuid": "001853fe86e76154a8e42c7253824bad",185- "enabled": true,186- "autoname": true,187- "name": "Create",188- "number": "6.3",189- "icon": "picon://1_0_0_3_1CB_10000_DDDD0000_0_0_0.png",190- "icon_public_url": "imagecache/36",191- "epgauto": true,192- "epglimit": 0,193- "epggrab": [],194- "dvr_pre_time": 0,195- "dvr_pst_time": 0,196- "epg_running": -1,197- "services": [198- "33dcb45cef71321fcac42e1c93974632"199- ],200- "tags": [],201- "bouquet": ""202- },203- {204- "uuid": "dd8fcfcb3cc53ac991098f3a89935305",205- "enabled": true,206- "autoname": true,207- "name": "World",208- "number": "6.4",209- "icon": "picon://1_0_0_4_1CB_10000_DDDD0000_0_0_0.png",210- "icon_public_url": "imagecache/32",211- "epgauto": true,212- "epglimit": 0,213- "epggrab": [],214- "dvr_pre_time": 0,215- "dvr_pst_time": 0,216- "epg_running": -1,217- "services": [218- "971fa8d4b9e7b068f74fbea2b8131b5a"219- ],220- "tags": [],221- "bouquet": ""222- },223- {224- "uuid": "eb442304fd95c719db3ea629e64eab61",225- "enabled": true,226- "autoname": true,227- "name": "KUSA-HD",228- "number": "9.4",229- "icon": "picon://1_0_0_5_1D1_10000_DDDD0000_0_0_0.png",230- "icon_public_url": "imagecache/58",231- "epgauto": true,232- "epglimit": 0,233- "epggrab": [],234- "dvr_pre_time": 0,235- "dvr_pst_time": 0,236- "epg_running": -1,237- "services": [238- "d25af62736923486a16dbc4851a1817e"239- ],240- "tags": [],241- "bouquet": ""242- },243- {244- "uuid": "990802969f8cb1b3996f62fb08e1b5b4",245- "enabled": true,246- "autoname": true,247- "name": "TBD",248- "number": "9.7",249- "icon": "picon://1_0_0_6_1D1_10000_DDDD0000_0_0_0.png",250- "icon_public_url": "imagecache/61",251- "epgauto": true,252- "epglimit": 0,253- "epggrab": [],254- "dvr_pre_time": 0,255- "dvr_pst_time": 0,256- "epg_running": -1,257- "services": [258- "7cb46ae4dbfaafae4717f1cff4b38ac0"259- ],260- "tags": [],261- "bouquet": ""262- },263- {264- "uuid": "0b9aad3e1d75ab86cbeb5ba55d15a0f3",265- "enabled": true,266- "autoname": true,267- "name": "Univision Denver",268- "number": "14.1",269- "icon": "picon://1_0_0_1_1BB_10000_DDDD0000_0_0_0.png",270- "icon_public_url": "imagecache/51",271- "epgauto": true,272- "epglimit": 0,273- "epggrab": [],274- "dvr_pre_time": 0,275- "dvr_pst_time": 0,276- "epg_running": -1,277- "services": [278- "7d0bb97dd33e4898f003d376c70c48c6"279- ],280- "tags": [],281- "bouquet": ""282- },283- {284- "uuid": "4efb26cadcc26256659820b727f6d5a7",285- "enabled": true,286- "autoname": true,287- "name": "MovieSphere Gold by Lionsgate",288- "number": "14.2",289- "icon": "picon://1_0_0_2_1BB_10000_DDDD0000_0_0_0.png",290- "icon_public_url": "imagecache/54",291- "epgauto": true,292- "epglimit": 0,293- "epggrab": [],294- "dvr_pre_time": 0,295- "dvr_pst_time": 0,296- "epg_running": -1,297- "services": [298- "27d1a645af75c40a86ac9e3577aba002"299- ],300- "tags": [],301- "bouquet": ""302- },303- {304- "uuid": "d8938a1a97ef7da945c65ac3f88a8039",305- "enabled": true,306- "autoname": true,307- "name": "GREAT",308- "number": "14.3",309- "icon": "picon://1_0_0_3_1BB_10000_DDDD0000_0_0_0.png",310- "icon_public_url": "imagecache/48",311- "epgauto": true,312- "epglimit": 0,313- "epggrab": [],314- "dvr_pre_time": 0,315- "dvr_pst_time": 0,316- "epg_running": -1,317- "services": [318- "bd0f91f6971fc9563e5b5d6d72812dcf"319- ],320- "tags": [],321- "bouquet": ""322- },323- {324- "uuid": "fa620714a05ae1f843887bc953366200",325- "enabled": true,326- "autoname": true,327- "name": "Better Tomorrows, Today",328- "number": "14.4",329- "icon": "picon://1_0_0_4_1BB_10000_DDDD0000_0_0_0.png",330- "icon_public_url": "imagecache/46",331- "epgauto": true,332- "epglimit": 0,333- "epggrab": [],334- "dvr_pre_time": 0,335- "dvr_pst_time": 0,336- "epg_running": -1,337- "services": [338- "5c710da05e3bfa8e8b1c92f112cd6431"339- ],340- "tags": [],341- "bouquet": ""342- },343- {344- "uuid": "2f70543839972c243e5567b9f65ece7b",345- "enabled": true,346- "autoname": true,347- "name": "ShopLC ",348- "number": "14.5",349- "icon": "file:///picons/shoplc.png",350- "icon_public_url": "imagecache/50",351- "epgauto": true,352- "epglimit": 0,353- "epggrab": [],354- "dvr_pre_time": 0,355- "dvr_pst_time": 0,356- "epg_running": -1,357- "services": [358- "cf23872345bce1e684ced4893fcf5aad"359- ],360- "tags": [],361- "bouquet": ""362- },363- {364- "uuid": "72d240992e805284e97681c6618c86a9",365- "enabled": true,366- "autoname": true,367- "name": "KTVD-HD",368- "number": "20.1",369- "icon": "picon://1_0_0_3_1D1_10000_DDDD0000_0_0_0.png",370- "icon_public_url": "imagecache/62",371- "epgauto": true,372- "epglimit": 0,373- "epggrab": [],374- "dvr_pre_time": 0,375- "dvr_pst_time": 0,376- "epg_running": -1,377- "services": [378- "617ed6401aed7c80a980556d7a0d7891"379- ],380- "tags": [],381- "bouquet": ""382- },383- {384- "uuid": "5708ec8efb91a5f9192991093c2cf9b9",385- "enabled": true,386- "autoname": true,387- "name": "H & I",388- "number": "20.2",389- "icon": "picon://1_0_0_4_1D1_10000_DDDD0000_0_0_0.png",390- "icon_public_url": "imagecache/59",391- "epgauto": true,392- "epglimit": 0,393- "epggrab": [],394- "dvr_pre_time": 0,395- "dvr_pst_time": 0,396- "epg_running": -1,397- "services": [398- "fdb95ecaa794d10dbbf3baa5dd1a9662"399- ],400- "tags": [],401- "bouquet": ""402- },403- {404- "uuid": "1c42f1c5b2fb3e6555f81dd68acd08b5",405- "enabled": true,406- "autoname": true,407- "name": "ShopLC",408- "number": "20.3",409- "icon": "file:///picons/shoplc.png",410- "icon_public_url": "imagecache/50",411- "epgauto": true,412- "epglimit": 0,413- "epggrab": [],414- "dvr_pre_time": 0,415- "dvr_pst_time": 0,416- "epg_running": -1,417- "services": [418- "6b411daf53c43f3e84eb5e786552a21c"419- ],420- "tags": [],421- "bouquet": ""422- },423- {424- "uuid": "f7dc96d6abb45feb7ee55cdf42b8232a",425- "enabled": true,426- "autoname": true,427- "name": "OUTLAW",428- "number": "20.4",429- "icon": "picon://1_0_0_8_1D1_10000_DDDD0000_0_0_0.png",430- "icon_public_url": "imagecache/66",431- "epgauto": true,432- "epglimit": 0,433- "epggrab": [],434- "dvr_pre_time": 0,435- "dvr_pst_time": 0,436- "epg_running": -1,437- "services": [438- "de4c31eec5d047e390fa7ba473637c69"439- ],440- "tags": [],441- "bouquet": ""442- },443- {444- "uuid": "ba4838da5820c5a2ad448a10b3170d0a",445- "enabled": true,446- "autoname": true,447- "name": "KDEN-DT",448- "number": "25.1",449- "icon": "picon://1_0_0_3_1EB_10000_DDDD0000_0_0_0.png",450- "icon_public_url": "imagecache/55",451- "epgauto": true,452- "epglimit": 0,453- "epggrab": [],454- "dvr_pre_time": 0,455- "dvr_pst_time": 0,456- "epg_running": -1,457- "services": [458- "0b2c0d8b2bd49ea36c2ce66ad8477b4b"459- ],460- "tags": [],461- "bouquet": ""462- },463- {464- "uuid": "61cd1c079d0072d54139e1ee37dd31d6",465- "enabled": true,466- "autoname": true,467- "name": "Exitos ",468- "number": "25.2",469- "icon": "picon://1_0_0_4_1EB_10000_DDDD0000_0_0_0.png",470- "icon_public_url": "imagecache/45",471- "epgauto": true,472- "epglimit": 0,473- "epggrab": [],474- "dvr_pre_time": 0,475- "dvr_pst_time": 0,476- "epg_running": -1,477- "services": [478- "4924d25d6812bb21f7db4ca7a770402e"479- ],480- "tags": [],481- "bouquet": ""482- },483- {484- "uuid": "f64a3b3448a637c96b98254eb4ac28aa",485- "enabled": true,486- "autoname": true,487- "name": "COZI ",488- "number": "25.3",489- "icon": "picon://1_0_0_5_1EB_10000_DDDD0000_0_0_0.png",490- "icon_public_url": "imagecache/49",491- "epgauto": true,492- "epglimit": 0,493- "epggrab": [],494- "dvr_pre_time": 0,495- "dvr_pst_time": 0,496- "epg_running": -1,497- "services": [498- "985a338dcb0347e1f5c587a311782acd"499- ],500- "tags": [],501- "bouquet": ""502- },503- {504- "uuid": "1fd265f1fddc419c9986149dbfcf30bd",505- "enabled": true,506- "autoname": true,507- "name": "CRIMES ",508- "number": "25.4",509- "icon": "picon://1_0_0_6_1EB_10000_DDDD0000_0_0_0.png",510- "icon_public_url": "imagecache/56",511- "epgauto": true,512- "epglimit": 0,513- "epggrab": [],514- "dvr_pre_time": 0,515- "dvr_pst_time": 0,516- "epg_running": -1,517- "services": [518- "972030cb37a60317117a5e3207de5f23"519- ],520- "tags": [],521- "bouquet": ""522- },523- {524- "uuid": "99384d01dd1d7b0eb5430cd404b9f4de",525- "enabled": true,526- "autoname": true,527- "name": "Oxygen",528- "number": "25.5",529- "icon": "file:///picons/oxygen.png",530- "icon_public_url": "imagecache/43",531- "epgauto": true,532- "epglimit": 0,533- "epggrab": [],534- "dvr_pre_time": 0,535- "dvr_pst_time": 0,536- "epg_running": -1,537- "services": [538- "6a48a0f43c195309a57c19a792874209"539- ],540- "tags": [],541- "bouquet": ""542- },543- {544- "uuid": "69fa3e482c3199b00d004ff00ca26e9d",545- "enabled": true,546- "autoname": true,547- "name": "COZI 2 ",548- "number": "25.6",549- "icon": "picon://1_0_0_8_1EB_10000_DDDD0000_0_0_0.png",550- "icon_public_url": "imagecache/52",551- "epgauto": true,552- "epglimit": 0,553- "epggrab": [],554- "dvr_pre_time": 0,555- "dvr_pst_time": 0,556- "epg_running": -1,557- "services": [558- "c140b2af79da24160c3aa8a9178020df"559- ],560- "tags": [],561- "bouquet": ""562- },563- {564- "uuid": "3b437d7e58b015b39fb6cc56eca6088a",565- "enabled": true,566- "autoname": true,567- "name": "KDVR-DT",568- "number": "31.1",569- "icon": "picon://1_0_0_3_1D3_10000_DDDD0000_0_0_0.png",570- "icon_public_url": "imagecache/57",571- "epgauto": true,572- "epglimit": 0,573- "epggrab": [],574- "dvr_pre_time": 0,575- "dvr_pst_time": 0,576- "epg_running": -1,577- "services": [578- "ada0e376fbb2cf9f283dcaf122ded316"579- ],580- "tags": [],581- "bouquet": ""582- },583- {584- "uuid": "530650964dfa607b85770efdbcd0ff6a",585- "enabled": true,586- "autoname": true,587- "name": "Antenna",588- "number": "31.2",589- "icon": "picon://1_0_0_4_1D3_10000_DDDD0000_0_0_0.png",590- "icon_public_url": "imagecache/47",591- "epgauto": true,592- "epglimit": 0,593- "epggrab": [],594- "dvr_pre_time": 0,595- "dvr_pst_time": 0,596- "epg_running": -1,597- "services": [598- "7a265b4601d53de0569d1120af37f28b"599- ],600- "tags": [],601- "bouquet": ""602- },603- {604- "uuid": "24164b4984eaf55b6fbe38dd2bec3663",605- "enabled": true,606- "autoname": true,607- "name": "ROAR-TV",608- "number": "31.3",609- "icon": "picon://1_0_0_5_1D3_10000_DDDD0000_0_0_0.png",610- "icon_public_url": "imagecache/53",611- "epgauto": true,612- "epglimit": 0,613- "epggrab": [],614- "dvr_pre_time": 0,615- "dvr_pst_time": 0,616- "epg_running": -1,617- "services": [618- "3bd985dc5b72f2ff2e7cdcb17871414f"619- ],620- "tags": [],621- "bouquet": ""622- },623- {624- "uuid": "81343fafc243483c90a22b221d474770",625- "enabled": true,626- "autoname": true,627- "name": "UniMas ",628- "number": "50.1",629- "icon": "picon://1_0_0_1_1D7_10000_DDDD0000_0_0_0.png",630- "icon_public_url": "imagecache/31",631- "epgauto": true,632- "epglimit": 0,633- "epggrab": [],634- "dvr_pre_time": 0,635- "dvr_pst_time": 0,636- "epg_running": -1,637- "services": [638- "bf242c3fcf5f91031f09d2240f0d297d"639- ],640- "tags": [],641- "bouquet": ""642- },643- {644- "uuid": "7e34196b38c1f0d8dd833b8cc700c5b1",645- "enabled": true,646- "autoname": true,647- "name": "LATV ",648- "number": "50.2",649- "icon": "picon://1_0_0_2_1D7_10000_DDDD0000_0_0_0.png",650- "icon_public_url": "imagecache/33",651- "epgauto": true,652- "epglimit": 0,653- "epggrab": [],654- "dvr_pre_time": 0,655- "dvr_pst_time": 0,656- "epg_running": -1,657- "services": [658- "3862305d56e8953b2503d1d86bfb1fc0"659- ],660- "tags": [],661- "bouquet": ""662- },663- {664- "uuid": "9fa555c4b5afe976ba0646afb28430f7",665- "enabled": true,666- "autoname": true,667- "name": "KETD-HD",668- "number": "53.1",669- "icon": "picon://1_0_0_1_1BF_10000_DDDD0000_0_0_0.png",670- "icon_public_url": "imagecache/64",671- "epgauto": true,672- "epglimit": 0,673- "epggrab": [],674- "dvr_pre_time": 0,675- "dvr_pst_time": 0,676- "epg_running": -1,677- "services": [678- "d36cf3eea8129c725a83f228cc105336"679- ],680- "tags": [],681- "bouquet": ""682- },683- {684- "uuid": "4475289de8d0136d1636b6c4e65cfbc1",685- "enabled": true,686- "autoname": true,687- "name": "E-NEWS",688- "number": "53.2",689- "icon": "picon://1_0_0_2_1BF_10000_DDDD0000_0_0_0.png",690- "icon_public_url": "imagecache/63",691- "epgauto": true,692- "epglimit": 0,693- "epggrab": [],694- "dvr_pre_time": 0,695- "dvr_pst_time": 0,696- "epg_running": -1,697- "services": [698- "45823f68556b4b3032f374d2345bbab0"699- ],700- "tags": [],701- "bouquet": ""702- },703- {704- "uuid": "ac5e6f525460147bf91918aa9164b78e",705- "enabled": true,706- "autoname": true,707- "name": "Confess",708- "number": "53.3",709- "icon": "picon://1_0_0_3_1BF_10000_DDDD0000_0_0_0.png",710- "icon_public_url": "imagecache/65",711- "epgauto": true,712- "epglimit": 0,713- "epggrab": [],714- "dvr_pre_time": 0,715- "dvr_pst_time": 0,716- "epg_running": -1,717- "services": [718- "853caf4273f10132ce8c638b152368ed"719- ],720- "tags": [],721- "bouquet": ""722- },723- {724- "uuid": "2e3ac514d812e7ec4d8d3f03717b18d4",725- "enabled": true,726- "autoname": true,727- "name": "JTV",728- "number": "53.4",729- "icon": "file:///picons/jtv.png",730- "icon_public_url": "imagecache/60",731- "epgauto": true,732- "epglimit": 0,733- "epggrab": [],734- "dvr_pre_time": 0,735- "dvr_pst_time": 0,736- "epg_running": -1,737- "services": [738- "faa4f276ba271ebaf9b4e37b21cd67a2"739- ],740- "tags": [],741- "bouquet": ""742- },743- {744- "uuid": "8a55576aeb1ef62b52e42c6fe30ea5bf",745- "enabled": true,746- "autoname": true,747- "name": "BUZZR",748- "number": "53.5",749- "icon": "picon://1_0_0_5_1BF_10000_DDDD0000_0_0_0.png",750- "icon_public_url": "imagecache/67",751- "epgauto": true,752- "epglimit": 0,753- "epggrab": [],754- "dvr_pre_time": 0,755- "dvr_pst_time": 0,756- "epg_running": -1,757- "services": [758- "d83381e0b1421cdcbaee5cb3ac2b9434"759- ],760- "tags": [],761- "bouquet": ""762- }763- ],764- "total": 38765-}
D internal/tvh/tvhtest/testdata/hardware_device.json +0 -310
@@ -1,310 +0,0 @@
1-[2- {3- "uuid": "966aa49ec18feb963e17f7fdb1b78db0",4- "id": "966aa49ec18feb963e17f7fdb1b78db0",5- "text": "HDHomeRun ATSC-T Tuner #0 (169.254.1.2)",6- "caption": "HDHomeRun ATSC-T frontend",7- "class": "tvhdhomerun_frontend_atsc_t",8- "event": "mpegts_input",9- "params": [10- {11- "id": "active",12- "type": "bool",13- "caption": "Active",14- "default": false,15- "rdonly": true,16- "nosave": true,17- "noui": true,18- "value": true19- },20- {21- "id": "enabled",22- "type": "bool",23- "caption": "Enabled",24- "description": "Enable/disable tuner/adapter.",25- "default": true,26- "value": true27- },28- {29- "id": "priority",30- "type": "int",31- "caption": "Priority",32- "description": "The tuner priority value (a higher value means to use this tuner out of preference). See Help for details.",33- "default": 1,34- "advanced": true,35- "value": 036- },37- {38- "id": "spriority",39- "type": "int",40- "caption": "Streaming priority",41- "description": "The tuner priority value for streamed channels through HTTP or HTSP (a higher value means to use this tuner out of preference). If not set (zero), the standard priority value is used. See Help for details.",42- "default": 1,43- "advanced": true,44- "value": 045- },46- {47- "id": "displayname",48- "type": "str",49- "caption": "Name",50- "description": "Name of the tuner/adapter.",51- "default": "",52- "value": "HDHomeRun ATSC-T Tuner #0 (169.254.1.2)"53- },54- {55- "id": "ota_epg",56- "type": "bool",57- "caption": "Over-the-air EPG",58- "description": "Enable over-the-air program guide (EPG) scanning on this input device.",59- "default": true,60- "value": true61- },62- {63- "id": "initscan",64- "type": "bool",65- "caption": "Initial scan",66- "description": "Allow the initial scan tuning on this device (scan when Tvheadend starts or when a new multiplex is added automatically). At least one tuner or input should have this settings turned on. See also 'Skip Startup Scan' in the network settings for further details.",67- "default": true,68- "advanced": true,69- "value": true70- },71- {72- "id": "idlescan",73- "type": "bool",74- "caption": "Idle scan",75- "description": "Allow idle scan tuning on this device.",76- "default": true,77- "advanced": true,78- "value": true79- },80- {81- "id": "free_weight",82- "type": "u32",83- "caption": "Free subscription weight",84- "description": "If the subscription weight for the input is below the specified threshold, the tuner is handled as free (according the priority settings). Otherwise, the next tuner (without any subscriptions) is used. Set this value to 10, if you are willing to override scan and epggrab subscriptions.",85- "default": 1,86- "advanced": true,87- "value": 088- },89- {90- "id": "remove_scrambled",91- "type": "bool",92- "caption": "Remove scrambled bits",93- "description": "The scrambled bits in MPEG-TS packets are always cleared. It is a workaround for the special streams which are descrambled, but these bits are not touched.",94- "default": true,95- "expert": true,96- "value": false97- },98- {99- "id": "networks",100- "type": "str",101- "caption": "Networks",102- "description": "Associate this device with one or more networks.",103- "list": 1,104- "enum": {105- "type": "api",106- "uri": "mpegts/input/network_list",107- "event": "mpegts_network",108- "params": {109- "uuid": "966aa49ec18feb963e17f7fdb1b78db0",110- "enum": true111- }112- },113- "value": [114- "67fb94e0bcce720866f6a6253cae95be"115- ]116- },117- {118- "id": "linked",119- "type": "str",120- "caption": "Linked input",121- "description": "Wake up the linked input whenever this adapter is used. The subscriptions are named as \"keep\". Note that this isn't normally needed, and is here simply as a workaround to driver bugs in certain dual tuner cards that otherwise lock the second tuner.",122- "default": "",123- "advanced": true,124- "enum": [125- {126- "key": "",127- "val": "Not linked"128- },129- {130- "key": "9fa2dd024c6683a288ebb77929612508",131- "val": "HDHomeRun ATSC-T Tuner #1 (169.254.1.2)"132- },133- {134- "key": "916eb08d882370004953cf1efbb0bc0f",135- "val": "IPTV thread #2"136- },137- {138- "key": "4b6b13545e6f5c46729026bbfd547328",139- "val": "IPTV thread #1"140- }141- ],142- "value": ""143- },144- {145- "id": "fe_number",146- "type": "int",147- "caption": "Frontend number",148- "default": 0,149- "rdonly": true,150- "nosave": true,151- "value": 0152- }153- ],154- "leaf": 1155- },156- {157- "uuid": "9fa2dd024c6683a288ebb77929612508",158- "id": "9fa2dd024c6683a288ebb77929612508",159- "text": "HDHomeRun ATSC-T Tuner #1 (169.254.1.2)",160- "caption": "HDHomeRun ATSC-T frontend",161- "class": "tvhdhomerun_frontend_atsc_t",162- "event": "mpegts_input",163- "params": [164- {165- "id": "active",166- "type": "bool",167- "caption": "Active",168- "default": false,169- "rdonly": true,170- "nosave": true,171- "noui": true,172- "value": true173- },174- {175- "id": "enabled",176- "type": "bool",177- "caption": "Enabled",178- "description": "Enable/disable tuner/adapter.",179- "default": true,180- "value": true181- },182- {183- "id": "priority",184- "type": "int",185- "caption": "Priority",186- "description": "The tuner priority value (a higher value means to use this tuner out of preference). See Help for details.",187- "default": 1,188- "advanced": true,189- "value": 0190- },191- {192- "id": "spriority",193- "type": "int",194- "caption": "Streaming priority",195- "description": "The tuner priority value for streamed channels through HTTP or HTSP (a higher value means to use this tuner out of preference). If not set (zero), the standard priority value is used. See Help for details.",196- "default": 1,197- "advanced": true,198- "value": 0199- },200- {201- "id": "displayname",202- "type": "str",203- "caption": "Name",204- "description": "Name of the tuner/adapter.",205- "default": "",206- "value": "HDHomeRun ATSC-T Tuner #1 (169.254.1.2)"207- },208- {209- "id": "ota_epg",210- "type": "bool",211- "caption": "Over-the-air EPG",212- "description": "Enable over-the-air program guide (EPG) scanning on this input device.",213- "default": true,214- "value": true215- },216- {217- "id": "initscan",218- "type": "bool",219- "caption": "Initial scan",220- "description": "Allow the initial scan tuning on this device (scan when Tvheadend starts or when a new multiplex is added automatically). At least one tuner or input should have this settings turned on. See also 'Skip Startup Scan' in the network settings for further details.",221- "default": true,222- "advanced": true,223- "value": true224- },225- {226- "id": "idlescan",227- "type": "bool",228- "caption": "Idle scan",229- "description": "Allow idle scan tuning on this device.",230- "default": true,231- "advanced": true,232- "value": true233- },234- {235- "id": "free_weight",236- "type": "u32",237- "caption": "Free subscription weight",238- "description": "If the subscription weight for the input is below the specified threshold, the tuner is handled as free (according the priority settings). Otherwise, the next tuner (without any subscriptions) is used. Set this value to 10, if you are willing to override scan and epggrab subscriptions.",239- "default": 1,240- "advanced": true,241- "value": 0242- },243- {244- "id": "remove_scrambled",245- "type": "bool",246- "caption": "Remove scrambled bits",247- "description": "The scrambled bits in MPEG-TS packets are always cleared. It is a workaround for the special streams which are descrambled, but these bits are not touched.",248- "default": true,249- "expert": true,250- "value": false251- },252- {253- "id": "networks",254- "type": "str",255- "caption": "Networks",256- "description": "Associate this device with one or more networks.",257- "list": 1,258- "enum": {259- "type": "api",260- "uri": "mpegts/input/network_list",261- "event": "mpegts_network",262- "params": {263- "uuid": "9fa2dd024c6683a288ebb77929612508",264- "enum": true265- }266- },267- "value": [268- "67fb94e0bcce720866f6a6253cae95be"269- ]270- },271- {272- "id": "linked",273- "type": "str",274- "caption": "Linked input",275- "description": "Wake up the linked input whenever this adapter is used. The subscriptions are named as \"keep\". Note that this isn't normally needed, and is here simply as a workaround to driver bugs in certain dual tuner cards that otherwise lock the second tuner.",276- "default": "",277- "advanced": true,278- "enum": [279- {280- "key": "",281- "val": "Not linked"282- },283- {284- "key": "966aa49ec18feb963e17f7fdb1b78db0",285- "val": "HDHomeRun ATSC-T Tuner #0 (169.254.1.2)"286- },287- {288- "key": "916eb08d882370004953cf1efbb0bc0f",289- "val": "IPTV thread #2"290- },291- {292- "key": "4b6b13545e6f5c46729026bbfd547328",293- "val": "IPTV thread #1"294- }295- ],296- "value": ""297- },298- {299- "id": "fe_number",300- "type": "int",301- "caption": "Frontend number",302- "default": 0,303- "rdonly": true,304- "nosave": true,305- "value": 1306- }307- ],308- "leaf": 1309- }310-]
D internal/tvh/tvhtest/testdata/hardware_root.json +0 -81
D internal/tvh/tvhtest/testdata/mux_grid.json +0 -869
@@ -1,869 +0,0 @@
1-{2- "entries": [3- {4- "uuid": "cc8f0e4a45d0f9dc3719130218a5df21",5- "delsys": "ATSC-T",6- "frequency": 57000000,7- "modulation": "VSB/8",8- "enabled": 1,9- "epg": 1,10- "network": "Airwaves antenna",11- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",12- "name": "57MHz",13- "onid": 65536,14- "tsid": 65536,15- "scan_state": 0,16- "scan_result": 2,17- "num_svc": 0,18- "num_chn": 0,19- "tsid_zero": false,20- "pmt_06_ac3": 0,21- "eit_tsid_nocheck": false,22- "sid_filter": 0,23- "created": 1791129025,24- "scan_first": 0,25- "scan_last": 026- },27- {28- "uuid": "1c1363d0fa0cd6664b83d2d8a8999ac4",29- "delsys": "ATSC-T",30- "frequency": 63000000,31- "modulation": "VSB/8",32- "enabled": 1,33- "epg": 1,34- "network": "Airwaves antenna",35- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",36- "name": "63MHz",37- "onid": 65536,38- "tsid": 65536,39- "scan_state": 0,40- "scan_result": 2,41- "num_svc": 0,42- "num_chn": 0,43- "tsid_zero": false,44- "pmt_06_ac3": 0,45- "eit_tsid_nocheck": false,46- "sid_filter": 0,47- "created": 1791129025,48- "scan_first": 0,49- "scan_last": 050- },51- {52- "uuid": "ec9f3883c63017da7292ae20eb6ff0ed",53- "delsys": "ATSC-T",54- "frequency": 69000000,55- "modulation": "VSB/8",56- "enabled": 1,57- "epg": 1,58- "network": "Airwaves antenna",59- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",60- "name": "69MHz",61- "onid": 65536,62- "tsid": 65536,63- "scan_state": 0,64- "scan_result": 2,65- "num_svc": 0,66- "num_chn": 0,67- "tsid_zero": false,68- "pmt_06_ac3": 0,69- "eit_tsid_nocheck": false,70- "sid_filter": 0,71- "created": 1791129025,72- "scan_first": 0,73- "scan_last": 074- },75- {76- "uuid": "8e3deaa6d7444d7ee8fa9fd8034fe813",77- "delsys": "ATSC-T",78- "frequency": 79000000,79- "modulation": "VSB/8",80- "enabled": 1,81- "epg": 1,82- "network": "Airwaves antenna",83- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",84- "name": "79MHz",85- "onid": 65536,86- "tsid": 65536,87- "scan_state": 0,88- "scan_result": 2,89- "num_svc": 0,90- "num_chn": 0,91- "tsid_zero": false,92- "pmt_06_ac3": 0,93- "eit_tsid_nocheck": false,94- "sid_filter": 0,95- "created": 1791129025,96- "scan_first": 0,97- "scan_last": 098- },99- {100- "uuid": "5347ce956b25f11c66bd85b235855184",101- "delsys": "ATSC-T",102- "frequency": 85000000,103- "modulation": "VSB/8",104- "enabled": 1,105- "epg": 1,106- "network": "Airwaves antenna",107- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",108- "name": "85MHz",109- "onid": 65536,110- "tsid": 65536,111- "scan_state": 0,112- "scan_result": 2,113- "num_svc": 0,114- "num_chn": 0,115- "tsid_zero": false,116- "pmt_06_ac3": 0,117- "eit_tsid_nocheck": false,118- "sid_filter": 0,119- "created": 1791129025,120- "scan_first": 0,121- "scan_last": 0122- },123- {124- "uuid": "cfac0beb17b014021c7019f87bda6ab6",125- "delsys": "ATSC-T",126- "frequency": 177000000,127- "modulation": "VSB/8",128- "enabled": 1,129- "epg": 1,130- "network": "Airwaves antenna",131- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",132- "name": "177MHz",133- "onid": 65536,134- "tsid": 65536,135- "scan_state": 0,136- "scan_result": 2,137- "num_svc": 0,138- "num_chn": 0,139- "tsid_zero": false,140- "pmt_06_ac3": 0,141- "eit_tsid_nocheck": false,142- "sid_filter": 0,143- "created": 1791129025,144- "scan_first": 0,145- "scan_last": 0146- },147- {148- "uuid": "87630366cd898dbd7458835f6bdc4169",149- "delsys": "ATSC-T",150- "frequency": 183000000,151- "modulation": "VSB/8",152- "enabled": 1,153- "epg": 1,154- "network": "Airwaves antenna",155- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",156- "name": "183MHz",157- "onid": 65536,158- "tsid": 65536,159- "scan_state": 0,160- "scan_result": 2,161- "num_svc": 0,162- "num_chn": 0,163- "tsid_zero": false,164- "pmt_06_ac3": 0,165- "eit_tsid_nocheck": false,166- "sid_filter": 0,167- "created": 1791129025,168- "scan_first": 0,169- "scan_last": 0170- },171- {172- "uuid": "527ed0924f6e2b1f6da90d7dcea5143f",173- "delsys": "ATSC-T",174- "frequency": 189000000,175- "modulation": "VSB/8",176- "enabled": 1,177- "epg": 1,178- "network": "Airwaves antenna",179- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",180- "name": "189MHz",181- "onid": 65536,182- "tsid": 65536,183- "scan_state": 0,184- "scan_result": 2,185- "num_svc": 0,186- "num_chn": 0,187- "tsid_zero": false,188- "pmt_06_ac3": 0,189- "eit_tsid_nocheck": false,190- "sid_filter": 0,191- "created": 1791129025,192- "scan_first": 0,193- "scan_last": 0194- },195- {196- "uuid": "4c7719a178b7a57a8d7adb65911699bd",197- "delsys": "ATSC-T",198- "frequency": 195000000,199- "modulation": "VSB/8",200- "enabled": 1,201- "epg": 1,202- "network": "Airwaves antenna",203- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",204- "name": "195MHz",205- "onid": 65536,206- "tsid": 65536,207- "scan_state": 0,208- "scan_result": 2,209- "num_svc": 0,210- "num_chn": 0,211- "tsid_zero": false,212- "pmt_06_ac3": 0,213- "eit_tsid_nocheck": false,214- "sid_filter": 0,215- "created": 1791129025,216- "scan_first": 0,217- "scan_last": 0218- },219- {220- "uuid": "f139d96524936695136d43da5ca30962",221- "delsys": "ATSC-T",222- "frequency": 201000000,223- "modulation": "VSB/8",224- "enabled": 1,225- "epg": 1,226- "network": "Airwaves antenna",227- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",228- "name": "201MHz",229- "onid": 65536,230- "tsid": 65536,231- "scan_state": 0,232- "scan_result": 2,233- "num_svc": 0,234- "num_chn": 0,235- "tsid_zero": false,236- "pmt_06_ac3": 0,237- "eit_tsid_nocheck": false,238- "sid_filter": 0,239- "created": 1791129025,240- "scan_first": 0,241- "scan_last": 0242- },243- {244- "uuid": "d92dc8a4a35b21f34911c6b265b37e2a",245- "delsys": "ATSC-T",246- "frequency": 207000000,247- "modulation": "VSB/8",248- "enabled": 1,249- "epg": 1,250- "network": "Airwaves antenna",251- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",252- "name": "207MHz",253- "onid": 65536,254- "tsid": 65536,255- "scan_state": 0,256- "scan_result": 2,257- "num_svc": 0,258- "num_chn": 0,259- "tsid_zero": false,260- "pmt_06_ac3": 0,261- "eit_tsid_nocheck": false,262- "sid_filter": 0,263- "created": 1791129025,264- "scan_first": 0,265- "scan_last": 0266- },267- {268- "uuid": "b088480dd476c773506989589d0b493a",269- "delsys": "ATSC-T",270- "frequency": 213000000,271- "modulation": "VSB/8",272- "enabled": 1,273- "epg": 1,274- "network": "Airwaves antenna",275- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",276- "name": "213MHz",277- "onid": 65536,278- "tsid": 65536,279- "scan_state": 0,280- "scan_result": 2,281- "num_svc": 0,282- "num_chn": 0,283- "tsid_zero": false,284- "pmt_06_ac3": 0,285- "eit_tsid_nocheck": false,286- "sid_filter": 0,287- "created": 1791129025,288- "scan_first": 0,289- "scan_last": 0290- },291- {292- "uuid": "08bc64d37f03d8fcd5cfb3ea9c22f68d",293- "delsys": "ATSC-T",294- "frequency": 473000000,295- "modulation": "VSB/8",296- "enabled": 1,297- "epg": 1,298- "network": "Airwaves antenna",299- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",300- "name": "473MHz",301- "onid": 65536,302- "tsid": 65536,303- "scan_state": 0,304- "scan_result": 2,305- "num_svc": 0,306- "num_chn": 0,307- "tsid_zero": false,308- "pmt_06_ac3": 0,309- "eit_tsid_nocheck": false,310- "sid_filter": 0,311- "created": 1791129025,312- "scan_first": 0,313- "scan_last": 0314- },315- {316- "uuid": "e2165ca193af7f189cc20d33fef824c4",317- "delsys": "ATSC-T",318- "frequency": 479000000,319- "modulation": "VSB/8",320- "enabled": 1,321- "epg": 1,322- "network": "Airwaves antenna",323- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",324- "name": "479MHz",325- "onid": 65536,326- "tsid": 447,327- "scan_state": 0,328- "scan_result": 1,329- "num_svc": 5,330- "num_chn": 5,331- "tsid_zero": true,332- "pmt_06_ac3": 0,333- "eit_tsid_nocheck": false,334- "sid_filter": 0,335- "created": 1791129025,336- "scan_first": 1791129370,337- "scan_last": 1791129370338- },339- {340- "uuid": "4e31e57c84b9ed64dda15a8865d3ad2f",341- "delsys": "ATSC-T",342- "frequency": 485000000,343- "modulation": "VSB/8",344- "enabled": 1,345- "epg": 1,346- "network": "Airwaves antenna",347- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",348- "name": "485MHz",349- "onid": 65536,350- "tsid": 65536,351- "scan_state": 0,352- "scan_result": 2,353- "num_svc": 0,354- "num_chn": 0,355- "tsid_zero": false,356- "pmt_06_ac3": 0,357- "eit_tsid_nocheck": false,358- "sid_filter": 0,359- "created": 1791129025,360- "scan_first": 0,361- "scan_last": 0362- },363- {364- "uuid": "e0dc87aaffdbcaa9955a1919e09b73fc",365- "delsys": "ATSC-T",366- "frequency": 491000000,367- "modulation": "VSB/8",368- "enabled": 1,369- "epg": 1,370- "network": "Airwaves antenna",371- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",372- "name": "491MHz",373- "onid": 65536,374- "tsid": 65536,375- "scan_state": 0,376- "scan_result": 2,377- "num_svc": 0,378- "num_chn": 0,379- "tsid_zero": false,380- "pmt_06_ac3": 0,381- "eit_tsid_nocheck": false,382- "sid_filter": 0,383- "created": 1791129025,384- "scan_first": 0,385- "scan_last": 0386- },387- {388- "uuid": "37ff010e6c2d22f07984f2a29378f847",389- "delsys": "ATSC-T",390- "frequency": 497000000,391- "modulation": "VSB/8",392- "enabled": 1,393- "epg": 1,394- "network": "Airwaves antenna",395- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",396- "name": "497MHz",397- "onid": 65536,398- "tsid": 65536,399- "scan_state": 0,400- "scan_result": 2,401- "num_svc": 0,402- "num_chn": 0,403- "tsid_zero": false,404- "pmt_06_ac3": 0,405- "eit_tsid_nocheck": false,406- "sid_filter": 0,407- "created": 1791129025,408- "scan_first": 0,409- "scan_last": 0410- },411- {412- "uuid": "6b82378fa41972b8d8afedb748ca281c",413- "delsys": "ATSC-T",414- "frequency": 503000000,415- "modulation": "VSB/8",416- "enabled": 1,417- "epg": 1,418- "network": "Airwaves antenna",419- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",420- "name": "503MHz",421- "onid": 65536,422- "tsid": 65536,423- "scan_state": 0,424- "scan_result": 2,425- "num_svc": 0,426- "num_chn": 0,427- "tsid_zero": false,428- "pmt_06_ac3": 0,429- "eit_tsid_nocheck": false,430- "sid_filter": 0,431- "created": 1791129025,432- "scan_first": 0,433- "scan_last": 0434- },435- {436- "uuid": "d02da5790aaa8d89570e2ef06651fc59",437- "delsys": "ATSC-T",438- "frequency": 509000000,439- "modulation": "VSB/8",440- "enabled": 1,441- "epg": 1,442- "network": "Airwaves antenna",443- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",444- "name": "509MHz",445- "onid": 65536,446- "tsid": 65536,447- "scan_state": 0,448- "scan_result": 2,449- "num_svc": 0,450- "num_chn": 0,451- "tsid_zero": false,452- "pmt_06_ac3": 0,453- "eit_tsid_nocheck": false,454- "sid_filter": 0,455- "created": 1791129025,456- "scan_first": 0,457- "scan_last": 0458- },459- {460- "uuid": "e9dbd91170ee7552c5c00595424ed082",461- "delsys": "ATSC-T",462- "frequency": 515000000,463- "modulation": "VSB/8",464- "enabled": 1,465- "epg": 1,466- "network": "Airwaves antenna",467- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",468- "name": "515MHz",469- "onid": 65536,470- "tsid": 65536,471- "scan_state": 0,472- "scan_result": 2,473- "num_svc": 0,474- "num_chn": 0,475- "tsid_zero": false,476- "pmt_06_ac3": 0,477- "eit_tsid_nocheck": false,478- "sid_filter": 0,479- "created": 1791129025,480- "scan_first": 0,481- "scan_last": 0482- },483- {484- "uuid": "23d77527fdd4c04aabd35186db06173a",485- "delsys": "ATSC-T",486- "frequency": 521000000,487- "modulation": "VSB/8",488- "enabled": 1,489- "epg": 1,490- "network": "Airwaves antenna",491- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",492- "name": "521MHz",493- "onid": 65536,494- "tsid": 65536,495- "scan_state": 0,496- "scan_result": 2,497- "num_svc": 0,498- "num_chn": 0,499- "tsid_zero": false,500- "pmt_06_ac3": 0,501- "eit_tsid_nocheck": false,502- "sid_filter": 0,503- "created": 1791129025,504- "scan_first": 0,505- "scan_last": 0506- },507- {508- "uuid": "4c6dbfe8c94e7d70dd01401f0e52cbb3",509- "delsys": "ATSC-T",510- "frequency": 527000000,511- "modulation": "VSB/8",512- "enabled": 1,513- "epg": 1,514- "network": "Airwaves antenna",515- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",516- "name": "527MHz",517- "onid": 65536,518- "tsid": 65536,519- "scan_state": 0,520- "scan_result": 2,521- "num_svc": 0,522- "num_chn": 0,523- "tsid_zero": false,524- "pmt_06_ac3": 0,525- "eit_tsid_nocheck": false,526- "sid_filter": 0,527- "created": 1791129025,528- "scan_first": 0,529- "scan_last": 0530- },531- {532- "uuid": "7a5915126baa2798054151a02cd676ef",533- "delsys": "ATSC-T",534- "frequency": 533000000,535- "modulation": "VSB/8",536- "enabled": 1,537- "epg": 1,538- "network": "Airwaves antenna",539- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",540- "name": "533MHz",541- "onid": 65536,542- "tsid": 65536,543- "scan_state": 0,544- "scan_result": 2,545- "num_svc": 0,546- "num_chn": 0,547- "tsid_zero": false,548- "pmt_06_ac3": 0,549- "eit_tsid_nocheck": false,550- "sid_filter": 0,551- "created": 1791129025,552- "scan_first": 0,553- "scan_last": 0554- },555- {556- "uuid": "4906fec4f8c930e70930780be231a0bf",557- "delsys": "ATSC-T",558- "frequency": 539000000,559- "modulation": "VSB/8",560- "enabled": 1,561- "epg": 1,562- "network": "Airwaves antenna",563- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",564- "name": "539MHz",565- "onid": 65536,566- "tsid": 65536,567- "scan_state": 0,568- "scan_result": 2,569- "num_svc": 0,570- "num_chn": 0,571- "tsid_zero": false,572- "pmt_06_ac3": 0,573- "eit_tsid_nocheck": false,574- "sid_filter": 0,575- "created": 1791129025,576- "scan_first": 0,577- "scan_last": 0578- },579- {580- "uuid": "f29c04e1ebe2f1f003e8f9ee7aafa5d5",581- "delsys": "ATSC-T",582- "frequency": 545000000,583- "modulation": "VSB/8",584- "enabled": 1,585- "epg": 1,586- "network": "Airwaves antenna",587- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",588- "name": "545MHz",589- "onid": 65536,590- "tsid": 65536,591- "scan_state": 0,592- "scan_result": 2,593- "num_svc": 0,594- "num_chn": 0,595- "tsid_zero": false,596- "pmt_06_ac3": 0,597- "eit_tsid_nocheck": false,598- "sid_filter": 0,599- "created": 1791129025,600- "scan_first": 0,601- "scan_last": 0602- },603- {604- "uuid": "3f89d078004a8cabe4799c05a669724c",605- "delsys": "ATSC-T",606- "frequency": 551000000,607- "modulation": "VSB/8",608- "enabled": 1,609- "epg": 1,610- "network": "Airwaves antenna",611- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",612- "name": "551MHz",613- "onid": 65536,614- "tsid": 65536,615- "scan_state": 0,616- "scan_result": 2,617- "num_svc": 0,618- "num_chn": 0,619- "tsid_zero": false,620- "pmt_06_ac3": 0,621- "eit_tsid_nocheck": false,622- "sid_filter": 0,623- "created": 1791129025,624- "scan_first": 0,625- "scan_last": 0626- },627- {628- "uuid": "09a90d35f088be35a703e76ae7629417",629- "delsys": "ATSC-T",630- "frequency": 557000000,631- "modulation": "VSB/8",632- "enabled": 1,633- "epg": 1,634- "network": "Airwaves antenna",635- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",636- "name": "557MHz",637- "onid": 65536,638- "tsid": 471,639- "scan_state": 0,640- "scan_result": 1,641- "num_svc": 2,642- "num_chn": 2,643- "tsid_zero": true,644- "pmt_06_ac3": 0,645- "eit_tsid_nocheck": false,646- "sid_filter": 0,647- "created": 1791129025,648- "scan_first": 1791129160,649- "scan_last": 1791129160650- },651- {652- "uuid": "8e6add570a36c55a7ab055d83b5b1ba9",653- "delsys": "ATSC-T",654- "frequency": 563000000,655- "modulation": "VSB/8",656- "enabled": 1,657- "epg": 1,658- "network": "Airwaves antenna",659- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",660- "name": "563MHz",661- "onid": 65536,662- "tsid": 491,663- "scan_state": 0,664- "scan_result": 1,665- "num_svc": 6,666- "num_chn": 6,667- "tsid_zero": true,668- "pmt_06_ac3": 0,669- "eit_tsid_nocheck": false,670- "sid_filter": 0,671- "created": 1791129025,672- "scan_first": 1791129295,673- "scan_last": 1791129295674- },675- {676- "uuid": "3a0b2198227dbff42cee6284c9452fcb",677- "delsys": "ATSC-T",678- "frequency": 569000000,679- "modulation": "VSB/8",680- "enabled": 1,681- "epg": 1,682- "network": "Airwaves antenna",683- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",684- "name": "569MHz",685- "onid": 65536,686- "tsid": 65536,687- "scan_state": 0,688- "scan_result": 2,689- "num_svc": 0,690- "num_chn": 0,691- "tsid_zero": false,692- "pmt_06_ac3": 0,693- "eit_tsid_nocheck": false,694- "sid_filter": 0,695- "created": 1791129025,696- "scan_first": 0,697- "scan_last": 0698- },699- {700- "uuid": "d199bffc3d595dbb51fcf6fb9e384880",701- "delsys": "ATSC-T",702- "frequency": 575000000,703- "modulation": "VSB/8",704- "enabled": 1,705- "epg": 1,706- "network": "Airwaves antenna",707- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",708- "name": "575MHz",709- "onid": 65536,710- "tsid": 465,711- "scan_state": 0,712- "scan_result": 1,713- "num_svc": 6,714- "num_chn": 6,715- "tsid_zero": true,716- "pmt_06_ac3": 0,717- "eit_tsid_nocheck": false,718- "sid_filter": 0,719- "created": 1791129025,720- "scan_first": 1791129355,721- "scan_last": 1791129355722- },723- {724- "uuid": "a445ced91718410b8816ce0f937a0a6b",725- "delsys": "ATSC-T",726- "frequency": 581000000,727- "modulation": "VSB/8",728- "enabled": 1,729- "epg": 1,730- "network": "Airwaves antenna",731- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",732- "name": "581MHz",733- "onid": 65536,734- "tsid": 443,735- "scan_state": 0,736- "scan_result": 1,737- "num_svc": 5,738- "num_chn": 5,739- "tsid_zero": true,740- "pmt_06_ac3": 0,741- "eit_tsid_nocheck": false,742- "sid_filter": 0,743- "created": 1791129025,744- "scan_first": 1791129310,745- "scan_last": 1791129310746- },747- {748- "uuid": "4595b7c94b59a2df0e0b17a6406e9a51",749- "delsys": "ATSC-T",750- "frequency": 587000000,751- "modulation": "VSB/8",752- "enabled": 1,753- "epg": 1,754- "network": "Airwaves antenna",755- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",756- "name": "587MHz",757- "onid": 65536,758- "tsid": 459,759- "scan_state": 0,760- "scan_result": 1,761- "num_svc": 4,762- "num_chn": 4,763- "tsid_zero": true,764- "pmt_06_ac3": 0,765- "eit_tsid_nocheck": false,766- "sid_filter": 0,767- "created": 1791129025,768- "scan_first": 1791129205,769- "scan_last": 1791129205770- },771- {772- "uuid": "f53ad51bd1512efc251e09d3d2bdbad3",773- "delsys": "ATSC-T",774- "frequency": 593000000,775- "modulation": "VSB/8",776- "enabled": 1,777- "epg": 1,778- "network": "Airwaves antenna",779- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",780- "name": "593MHz",781- "onid": 65536,782- "tsid": 65536,783- "scan_state": 0,784- "scan_result": 2,785- "num_svc": 0,786- "num_chn": 0,787- "tsid_zero": false,788- "pmt_06_ac3": 0,789- "eit_tsid_nocheck": false,790- "sid_filter": 0,791- "created": 1791129025,792- "scan_first": 0,793- "scan_last": 0794- },795- {796- "uuid": "5888119613610f7fad476b55424773ab",797- "delsys": "ATSC-T",798- "frequency": 599000000,799- "modulation": "VSB/8",800- "enabled": 1,801- "epg": 1,802- "network": "Airwaves antenna",803- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",804- "name": "599MHz",805- "onid": 65536,806- "tsid": 457,807- "scan_state": 0,808- "scan_result": 1,809- "num_svc": 6,810- "num_chn": 6,811- "tsid_zero": true,812- "pmt_06_ac3": 0,813- "eit_tsid_nocheck": false,814- "sid_filter": 0,815- "created": 1791129025,816- "scan_first": 1791129265,817- "scan_last": 1791129265818- },819- {820- "uuid": "ceadb031fb4aeaa525e2c766ac8993eb",821- "delsys": "ATSC-T",822- "frequency": 605000000,823- "modulation": "VSB/8",824- "enabled": 1,825- "epg": 1,826- "network": "Airwaves antenna",827- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",828- "name": "605MHz",829- "onid": 65536,830- "tsid": 467,831- "scan_state": 0,832- "scan_result": 1,833- "num_svc": 4,834- "num_chn": 4,835- "tsid_zero": true,836- "pmt_06_ac3": 0,837- "eit_tsid_nocheck": false,838- "sid_filter": 0,839- "created": 1791129025,840- "scan_first": 1791129325,841- "scan_last": 1791129325842- },843- {844- "uuid": "f597435314ea8b7ebd0d8bc85ab3abc8",845- "delsys": "ATSC-T",846- "frequency": 611000000,847- "modulation": "VSB/8",848- "enabled": 1,849- "epg": 1,850- "network": "Airwaves antenna",851- "network_uuid": "67fb94e0bcce720866f6a6253cae95be",852- "name": "611MHz",853- "onid": 65536,854- "tsid": 65536,855- "scan_state": 0,856- "scan_result": 2,857- "num_svc": 0,858- "num_chn": 0,859- "tsid_zero": false,860- "pmt_06_ac3": 0,861- "eit_tsid_nocheck": false,862- "sid_filter": 0,863- "created": 1791129025,864- "scan_first": 0,865- "scan_last": 0866- }867- ],868- "total": 36869-}
D internal/tvh/tvhtest/testdata/network_grid.json +0 -25
D internal/tvh/tvhtest/testdata/serverinfo.json +0 -13
D internal/tvh/tvhtest/testdata/service_grid.json +0 -1183
@@ -1,1183 +0,0 @@
1-{2- "entries": [3- {4- "uuid": "d36cf3eea8129c725a83f228cc105336",5- "network": "Airwaves antenna",6- "multiplex": "479MHz",7- "multiplex_uuid": "e2165ca193af7f189cc20d33fef824c4",8- "sid": 1,9- "lcn": 53,10- "lcn_minor": 1,11- "lcn2": 0,12- "srcid": 1,13- "svcname": "KETD-HD",14- "dvb_servicetype": 0,15- "dvb_eit_processing": 0,16- "dvb_subtitle_processing": 0,17- "dvb_ignore_matching_subtitle": false,18- "prefcapid": 0,19- "prefcapid_lock": 0,20- "force_caid": 0,21- "pts_shift": 0,22- "created": 1791129356,23- "last_seen": 1791129356,24- "enabled": true,25- "auto": 0,26- "channel": [27- "9fa555c4b5afe976ba0646afb28430f7"28- ],29- "priority": 0,30- "encrypted": false,31- "caid": "",32- "s_type_user": -133- },34- {35- "uuid": "45823f68556b4b3032f374d2345bbab0",36- "network": "Airwaves antenna",37- "multiplex": "479MHz",38- "multiplex_uuid": "e2165ca193af7f189cc20d33fef824c4",39- "sid": 2,40- "lcn": 53,41- "lcn_minor": 2,42- "lcn2": 0,43- "srcid": 2,44- "svcname": "E-NEWS",45- "dvb_servicetype": 0,46- "dvb_eit_processing": 0,47- "dvb_subtitle_processing": 0,48- "dvb_ignore_matching_subtitle": false,49- "prefcapid": 0,50- "prefcapid_lock": 0,51- "force_caid": 0,52- "pts_shift": 0,53- "created": 1791129356,54- "last_seen": 1791129356,55- "enabled": true,56- "auto": 0,57- "channel": [58- "4475289de8d0136d1636b6c4e65cfbc1"59- ],60- "priority": 0,61- "encrypted": false,62- "caid": "",63- "s_type_user": -164- },65- {66- "uuid": "853caf4273f10132ce8c638b152368ed",67- "network": "Airwaves antenna",68- "multiplex": "479MHz",69- "multiplex_uuid": "e2165ca193af7f189cc20d33fef824c4",70- "sid": 3,71- "lcn": 53,72- "lcn_minor": 3,73- "lcn2": 0,74- "srcid": 4,75- "svcname": "Confess",76- "dvb_servicetype": 0,77- "dvb_eit_processing": 0,78- "dvb_subtitle_processing": 0,79- "dvb_ignore_matching_subtitle": false,80- "prefcapid": 0,81- "prefcapid_lock": 0,82- "force_caid": 0,83- "pts_shift": 0,84- "created": 1791129356,85- "last_seen": 1791129356,86- "enabled": true,87- "auto": 0,88- "channel": [89- "ac5e6f525460147bf91918aa9164b78e"90- ],91- "priority": 0,92- "encrypted": false,93- "caid": "",94- "s_type_user": -195- },96- {97- "uuid": "faa4f276ba271ebaf9b4e37b21cd67a2",98- "network": "Airwaves antenna",99- "multiplex": "479MHz",100- "multiplex_uuid": "e2165ca193af7f189cc20d33fef824c4",101- "sid": 4,102- "lcn": 53,103- "lcn_minor": 4,104- "lcn2": 0,105- "srcid": 5,106- "svcname": "JTV",107- "dvb_servicetype": 0,108- "dvb_eit_processing": 0,109- "dvb_subtitle_processing": 0,110- "dvb_ignore_matching_subtitle": false,111- "prefcapid": 0,112- "prefcapid_lock": 0,113- "force_caid": 0,114- "pts_shift": 0,115- "created": 1791129356,116- "last_seen": 1791129356,117- "enabled": true,118- "auto": 0,119- "channel": [120- "2e3ac514d812e7ec4d8d3f03717b18d4"121- ],122- "priority": 0,123- "encrypted": false,124- "caid": "",125- "s_type_user": -1126- },127- {128- "uuid": "d83381e0b1421cdcbaee5cb3ac2b9434",129- "network": "Airwaves antenna",130- "multiplex": "479MHz",131- "multiplex_uuid": "e2165ca193af7f189cc20d33fef824c4",132- "sid": 5,133- "lcn": 53,134- "lcn_minor": 5,135- "lcn2": 0,136- "srcid": 3,137- "svcname": "BUZZR",138- "dvb_servicetype": 0,139- "dvb_eit_processing": 0,140- "dvb_subtitle_processing": 0,141- "dvb_ignore_matching_subtitle": false,142- "prefcapid": 0,143- "prefcapid_lock": 0,144- "force_caid": 0,145- "pts_shift": 0,146- "created": 1791129356,147- "last_seen": 1791129356,148- "enabled": true,149- "auto": 0,150- "channel": [151- "8a55576aeb1ef62b52e42c6fe30ea5bf"152- ],153- "priority": 0,154- "encrypted": false,155- "caid": "",156- "s_type_user": -1157- },158- {159- "uuid": "bf242c3fcf5f91031f09d2240f0d297d",160- "network": "Airwaves antenna",161- "multiplex": "557MHz",162- "multiplex_uuid": "09a90d35f088be35a703e76ae7629417",163- "sid": 1,164- "lcn": 50,165- "lcn_minor": 1,166- "lcn2": 0,167- "srcid": 1,168- "svcname": "UniMas ",169- "dvb_servicetype": 0,170- "dvb_eit_processing": 0,171- "dvb_subtitle_processing": 0,172- "dvb_ignore_matching_subtitle": false,173- "prefcapid": 0,174- "prefcapid_lock": 0,175- "force_caid": 0,176- "pts_shift": 0,177- "created": 1791129146,178- "last_seen": 1791129146,179- "enabled": true,180- "auto": 0,181- "channel": [182- "81343fafc243483c90a22b221d474770"183- ],184- "priority": 0,185- "encrypted": false,186- "caid": "",187- "s_type_user": -1188- },189- {190- "uuid": "3862305d56e8953b2503d1d86bfb1fc0",191- "network": "Airwaves antenna",192- "multiplex": "557MHz",193- "multiplex_uuid": "09a90d35f088be35a703e76ae7629417",194- "sid": 2,195- "lcn": 50,196- "lcn_minor": 2,197- "lcn2": 0,198- "srcid": 2,199- "svcname": "LATV ",200- "dvb_servicetype": 0,201- "dvb_eit_processing": 0,202- "dvb_subtitle_processing": 0,203- "dvb_ignore_matching_subtitle": false,204- "prefcapid": 0,205- "prefcapid_lock": 0,206- "force_caid": 0,207- "pts_shift": 0,208- "created": 1791129146,209- "last_seen": 1791129146,210- "enabled": true,211- "auto": 0,212- "channel": [213- "7e34196b38c1f0d8dd833b8cc700c5b1"214- ],215- "priority": 0,216- "encrypted": false,217- "caid": "",218- "s_type_user": -1219- },220- {221- "uuid": "0b2c0d8b2bd49ea36c2ce66ad8477b4b",222- "network": "Airwaves antenna",223- "multiplex": "563MHz",224- "multiplex_uuid": "8e6add570a36c55a7ab055d83b5b1ba9",225- "sid": 3,226- "lcn": 25,227- "lcn_minor": 1,228- "lcn2": 0,229- "srcid": 3,230- "svcname": "KDEN-DT",231- "dvb_servicetype": 0,232- "dvb_eit_processing": 0,233- "dvb_subtitle_processing": 0,234- "dvb_ignore_matching_subtitle": false,235- "prefcapid": 0,236- "prefcapid_lock": 0,237- "force_caid": 0,238- "pts_shift": 0,239- "created": 1791129280,240- "last_seen": 1791129280,241- "enabled": true,242- "auto": 0,243- "channel": [244- "ba4838da5820c5a2ad448a10b3170d0a"245- ],246- "priority": 0,247- "encrypted": false,248- "caid": "",249- "s_type_user": -1250- },251- {252- "uuid": "4924d25d6812bb21f7db4ca7a770402e",253- "network": "Airwaves antenna",254- "multiplex": "563MHz",255- "multiplex_uuid": "8e6add570a36c55a7ab055d83b5b1ba9",256- "sid": 4,257- "lcn": 25,258- "lcn_minor": 2,259- "lcn2": 0,260- "srcid": 4,261- "svcname": "Exitos ",262- "dvb_servicetype": 0,263- "dvb_eit_processing": 0,264- "dvb_subtitle_processing": 0,265- "dvb_ignore_matching_subtitle": false,266- "prefcapid": 0,267- "prefcapid_lock": 0,268- "force_caid": 0,269- "pts_shift": 0,270- "created": 1791129280,271- "last_seen": 1791129280,272- "enabled": true,273- "auto": 0,274- "channel": [275- "61cd1c079d0072d54139e1ee37dd31d6"276- ],277- "priority": 0,278- "encrypted": false,279- "caid": "",280- "s_type_user": -1281- },282- {283- "uuid": "985a338dcb0347e1f5c587a311782acd",284- "network": "Airwaves antenna",285- "multiplex": "563MHz",286- "multiplex_uuid": "8e6add570a36c55a7ab055d83b5b1ba9",287- "sid": 5,288- "lcn": 25,289- "lcn_minor": 3,290- "lcn2": 0,291- "srcid": 5,292- "svcname": "COZI ",293- "dvb_servicetype": 0,294- "dvb_eit_processing": 0,295- "dvb_subtitle_processing": 0,296- "dvb_ignore_matching_subtitle": false,297- "prefcapid": 0,298- "prefcapid_lock": 0,299- "force_caid": 0,300- "pts_shift": 0,301- "created": 1791129280,302- "last_seen": 1791129280,303- "enabled": true,304- "auto": 0,305- "channel": [306- "f64a3b3448a637c96b98254eb4ac28aa"307- ],308- "priority": 0,309- "encrypted": false,310- "caid": "",311- "s_type_user": -1312- },313- {314- "uuid": "972030cb37a60317117a5e3207de5f23",315- "network": "Airwaves antenna",316- "multiplex": "563MHz",317- "multiplex_uuid": "8e6add570a36c55a7ab055d83b5b1ba9",318- "sid": 6,319- "lcn": 25,320- "lcn_minor": 4,321- "lcn2": 0,322- "srcid": 6,323- "svcname": "CRIMES ",324- "dvb_servicetype": 0,325- "dvb_eit_processing": 0,326- "dvb_subtitle_processing": 0,327- "dvb_ignore_matching_subtitle": false,328- "prefcapid": 0,329- "prefcapid_lock": 0,330- "force_caid": 0,331- "pts_shift": 0,332- "created": 1791129280,333- "last_seen": 1791129280,334- "enabled": true,335- "auto": 0,336- "channel": [337- "1fd265f1fddc419c9986149dbfcf30bd"338- ],339- "priority": 0,340- "encrypted": false,341- "caid": "",342- "s_type_user": -1343- },344- {345- "uuid": "6a48a0f43c195309a57c19a792874209",346- "network": "Airwaves antenna",347- "multiplex": "563MHz",348- "multiplex_uuid": "8e6add570a36c55a7ab055d83b5b1ba9",349- "sid": 7,350- "lcn": 25,351- "lcn_minor": 5,352- "lcn2": 0,353- "srcid": 7,354- "svcname": "Oxygen",355- "dvb_servicetype": 0,356- "dvb_eit_processing": 0,357- "dvb_subtitle_processing": 0,358- "dvb_ignore_matching_subtitle": false,359- "prefcapid": 0,360- "prefcapid_lock": 0,361- "force_caid": 0,362- "pts_shift": 0,363- "created": 1791129280,364- "last_seen": 1791129280,365- "enabled": true,366- "auto": 0,367- "channel": [368- "99384d01dd1d7b0eb5430cd404b9f4de"369- ],370- "priority": 0,371- "encrypted": false,372- "caid": "",373- "s_type_user": -1374- },375- {376- "uuid": "c140b2af79da24160c3aa8a9178020df",377- "network": "Airwaves antenna",378- "multiplex": "563MHz",379- "multiplex_uuid": "8e6add570a36c55a7ab055d83b5b1ba9",380- "sid": 8,381- "lcn": 25,382- "lcn_minor": 6,383- "lcn2": 0,384- "srcid": 8,385- "svcname": "COZI 2 ",386- "dvb_servicetype": 0,387- "dvb_eit_processing": 0,388- "dvb_subtitle_processing": 0,389- "dvb_ignore_matching_subtitle": false,390- "prefcapid": 0,391- "prefcapid_lock": 0,392- "force_caid": 0,393- "pts_shift": 0,394- "created": 1791129280,395- "last_seen": 1791129280,396- "enabled": true,397- "auto": 0,398- "channel": [399- "69fa3e482c3199b00d004ff00ca26e9d"400- ],401- "priority": 0,402- "encrypted": false,403- "caid": "",404- "s_type_user": -1405- },406- {407- "uuid": "d25af62736923486a16dbc4851a1817e",408- "network": "Airwaves antenna",409- "multiplex": "575MHz",410- "multiplex_uuid": "d199bffc3d595dbb51fcf6fb9e384880",411- "sid": 5,412- "lcn": 9,413- "lcn_minor": 4,414- "lcn2": 0,415- "srcid": 3,416- "svcname": "KUSA-HD",417- "dvb_servicetype": 0,418- "dvb_eit_processing": 0,419- "dvb_subtitle_processing": 0,420- "dvb_ignore_matching_subtitle": false,421- "prefcapid": 0,422- "prefcapid_lock": 0,423- "force_caid": 0,424- "pts_shift": 0,425- "created": 1791129341,426- "last_seen": 1791129341,427- "enabled": true,428- "auto": 0,429- "channel": [430- "eb442304fd95c719db3ea629e64eab61"431- ],432- "priority": 0,433- "encrypted": false,434- "caid": "",435- "s_type_user": -1436- },437- {438- "uuid": "7cb46ae4dbfaafae4717f1cff4b38ac0",439- "network": "Airwaves antenna",440- "multiplex": "575MHz",441- "multiplex_uuid": "d199bffc3d595dbb51fcf6fb9e384880",442- "sid": 6,443- "lcn": 9,444- "lcn_minor": 7,445- "lcn2": 0,446- "srcid": 4,447- "svcname": "TBD",448- "dvb_servicetype": 0,449- "dvb_eit_processing": 0,450- "dvb_subtitle_processing": 0,451- "dvb_ignore_matching_subtitle": false,452- "prefcapid": 0,453- "prefcapid_lock": 0,454- "force_caid": 0,455- "pts_shift": 0,456- "created": 1791129341,457- "last_seen": 1791129341,458- "enabled": true,459- "auto": 0,460- "channel": [461- "990802969f8cb1b3996f62fb08e1b5b4"462- ],463- "priority": 0,464- "encrypted": false,465- "caid": "",466- "s_type_user": -1467- },468- {469- "uuid": "617ed6401aed7c80a980556d7a0d7891",470- "network": "Airwaves antenna",471- "multiplex": "575MHz",472- "multiplex_uuid": "d199bffc3d595dbb51fcf6fb9e384880",473- "sid": 3,474- "lcn": 20,475- "lcn_minor": 1,476- "lcn2": 0,477- "srcid": 1,478- "svcname": "KTVD-HD",479- "dvb_servicetype": 0,480- "dvb_eit_processing": 0,481- "dvb_subtitle_processing": 0,482- "dvb_ignore_matching_subtitle": false,483- "prefcapid": 0,484- "prefcapid_lock": 0,485- "force_caid": 0,486- "pts_shift": 0,487- "created": 1791129341,488- "last_seen": 1791129341,489- "enabled": true,490- "auto": 0,491- "channel": [492- "72d240992e805284e97681c6618c86a9"493- ],494- "priority": 0,495- "encrypted": false,496- "caid": "",497- "s_type_user": -1498- },499- {500- "uuid": "fdb95ecaa794d10dbbf3baa5dd1a9662",501- "network": "Airwaves antenna",502- "multiplex": "575MHz",503- "multiplex_uuid": "d199bffc3d595dbb51fcf6fb9e384880",504- "sid": 4,505- "lcn": 20,506- "lcn_minor": 2,507- "lcn2": 0,508- "srcid": 2,509- "svcname": "H & I",510- "dvb_servicetype": 0,511- "dvb_eit_processing": 0,512- "dvb_subtitle_processing": 0,513- "dvb_ignore_matching_subtitle": false,514- "prefcapid": 0,515- "prefcapid_lock": 0,516- "force_caid": 0,517- "pts_shift": 0,518- "created": 1791129341,519- "last_seen": 1791129341,520- "enabled": true,521- "auto": 0,522- "channel": [523- "5708ec8efb91a5f9192991093c2cf9b9"524- ],525- "priority": 0,526- "encrypted": false,527- "caid": "",528- "s_type_user": -1529- },530- {531- "uuid": "6b411daf53c43f3e84eb5e786552a21c",532- "network": "Airwaves antenna",533- "multiplex": "575MHz",534- "multiplex_uuid": "d199bffc3d595dbb51fcf6fb9e384880",535- "sid": 7,536- "lcn": 20,537- "lcn_minor": 3,538- "lcn2": 0,539- "srcid": 5,540- "svcname": "ShopLC",541- "dvb_servicetype": 0,542- "dvb_eit_processing": 0,543- "dvb_subtitle_processing": 0,544- "dvb_ignore_matching_subtitle": false,545- "prefcapid": 0,546- "prefcapid_lock": 0,547- "force_caid": 0,548- "pts_shift": 0,549- "created": 1791129341,550- "last_seen": 1791129341,551- "enabled": true,552- "auto": 0,553- "channel": [554- "1c42f1c5b2fb3e6555f81dd68acd08b5"555- ],556- "priority": 0,557- "encrypted": false,558- "caid": "",559- "s_type_user": -1560- },561- {562- "uuid": "de4c31eec5d047e390fa7ba473637c69",563- "network": "Airwaves antenna",564- "multiplex": "575MHz",565- "multiplex_uuid": "d199bffc3d595dbb51fcf6fb9e384880",566- "sid": 8,567- "lcn": 20,568- "lcn_minor": 4,569- "lcn2": 0,570- "srcid": 6,571- "svcname": "OUTLAW",572- "dvb_servicetype": 0,573- "dvb_eit_processing": 0,574- "dvb_subtitle_processing": 0,575- "dvb_ignore_matching_subtitle": false,576- "prefcapid": 0,577- "prefcapid_lock": 0,578- "force_caid": 0,579- "pts_shift": 0,580- "created": 1791129341,581- "last_seen": 1791129341,582- "enabled": true,583- "auto": 0,584- "channel": [585- "f7dc96d6abb45feb7ee55cdf42b8232a"586- ],587- "priority": 0,588- "encrypted": false,589- "caid": "",590- "s_type_user": -1591- },592- {593- "uuid": "7d0bb97dd33e4898f003d376c70c48c6",594- "network": "Airwaves antenna",595- "multiplex": "581MHz",596- "multiplex_uuid": "a445ced91718410b8816ce0f937a0a6b",597- "sid": 1,598- "lcn": 14,599- "lcn_minor": 1,600- "lcn2": 0,601- "srcid": 1,602- "svcname": "Univision Denver",603- "dvb_servicetype": 0,604- "dvb_eit_processing": 0,605- "dvb_subtitle_processing": 0,606- "dvb_ignore_matching_subtitle": false,607- "prefcapid": 0,608- "prefcapid_lock": 0,609- "force_caid": 0,610- "pts_shift": 0,611- "created": 1791129303,612- "last_seen": 1791129303,613- "enabled": true,614- "auto": 0,615- "channel": [616- "0b9aad3e1d75ab86cbeb5ba55d15a0f3"617- ],618- "priority": 0,619- "encrypted": false,620- "caid": "",621- "s_type_user": -1622- },623- {624- "uuid": "27d1a645af75c40a86ac9e3577aba002",625- "network": "Airwaves antenna",626- "multiplex": "581MHz",627- "multiplex_uuid": "a445ced91718410b8816ce0f937a0a6b",628- "sid": 2,629- "lcn": 14,630- "lcn_minor": 2,631- "lcn2": 0,632- "srcid": 2,633- "svcname": "MovieSphere Gold by Lionsgate",634- "dvb_servicetype": 0,635- "dvb_eit_processing": 0,636- "dvb_subtitle_processing": 0,637- "dvb_ignore_matching_subtitle": false,638- "prefcapid": 0,639- "prefcapid_lock": 0,640- "force_caid": 0,641- "pts_shift": 0,642- "created": 1791129303,643- "last_seen": 1791129303,644- "enabled": true,645- "auto": 0,646- "channel": [647- "4efb26cadcc26256659820b727f6d5a7"648- ],649- "priority": 0,650- "encrypted": false,651- "caid": "",652- "s_type_user": -1653- },654- {655- "uuid": "bd0f91f6971fc9563e5b5d6d72812dcf",656- "network": "Airwaves antenna",657- "multiplex": "581MHz",658- "multiplex_uuid": "a445ced91718410b8816ce0f937a0a6b",659- "sid": 3,660- "lcn": 14,661- "lcn_minor": 3,662- "lcn2": 0,663- "srcid": 3,664- "svcname": "GREAT",665- "dvb_servicetype": 0,666- "dvb_eit_processing": 0,667- "dvb_subtitle_processing": 0,668- "dvb_ignore_matching_subtitle": false,669- "prefcapid": 0,670- "prefcapid_lock": 0,671- "force_caid": 0,672- "pts_shift": 0,673- "created": 1791129303,674- "last_seen": 1791129303,675- "enabled": true,676- "auto": 0,677- "channel": [678- "d8938a1a97ef7da945c65ac3f88a8039"679- ],680- "priority": 0,681- "encrypted": false,682- "caid": "",683- "s_type_user": -1684- },685- {686- "uuid": "5c710da05e3bfa8e8b1c92f112cd6431",687- "network": "Airwaves antenna",688- "multiplex": "581MHz",689- "multiplex_uuid": "a445ced91718410b8816ce0f937a0a6b",690- "sid": 4,691- "lcn": 14,692- "lcn_minor": 4,693- "lcn2": 0,694- "srcid": 4,695- "svcname": "Better Tomorrows, Today",696- "dvb_servicetype": 0,697- "dvb_eit_processing": 0,698- "dvb_subtitle_processing": 0,699- "dvb_ignore_matching_subtitle": false,700- "prefcapid": 0,701- "prefcapid_lock": 0,702- "force_caid": 0,703- "pts_shift": 0,704- "created": 1791129303,705- "last_seen": 1791129303,706- "enabled": true,707- "auto": 0,708- "channel": [709- "fa620714a05ae1f843887bc953366200"710- ],711- "priority": 0,712- "encrypted": false,713- "caid": "",714- "s_type_user": -1715- },716- {717- "uuid": "cf23872345bce1e684ced4893fcf5aad",718- "network": "Airwaves antenna",719- "multiplex": "581MHz",720- "multiplex_uuid": "a445ced91718410b8816ce0f937a0a6b",721- "sid": 5,722- "lcn": 14,723- "lcn_minor": 5,724- "lcn2": 0,725- "srcid": 5,726- "svcname": "ShopLC ",727- "dvb_servicetype": 0,728- "dvb_eit_processing": 0,729- "dvb_subtitle_processing": 0,730- "dvb_ignore_matching_subtitle": false,731- "prefcapid": 0,732- "prefcapid_lock": 0,733- "force_caid": 0,734- "pts_shift": 0,735- "created": 1791129303,736- "last_seen": 1791129303,737- "enabled": true,738- "auto": 0,739- "channel": [740- "2f70543839972c243e5567b9f65ece7b"741- ],742- "priority": 0,743- "encrypted": false,744- "caid": "",745- "s_type_user": -1746- },747- {748- "uuid": "22c312f15bac109268c7c710ae7e3d50",749- "network": "Airwaves antenna",750- "multiplex": "587MHz",751- "multiplex_uuid": "4595b7c94b59a2df0e0b17a6406e9a51",752- "sid": 1,753- "lcn": 6,754- "lcn_minor": 1,755- "lcn2": 0,756- "srcid": 1,757- "svcname": "KRMADT1",758- "dvb_servicetype": 0,759- "dvb_eit_processing": 0,760- "dvb_subtitle_processing": 0,761- "dvb_ignore_matching_subtitle": false,762- "prefcapid": 0,763- "prefcapid_lock": 0,764- "force_caid": 0,765- "pts_shift": 0,766- "created": 1791129190,767- "last_seen": 1791129190,768- "enabled": true,769- "auto": 0,770- "channel": [771- "48737af5fc18cd696caf456ccfa55c81"772- ],773- "priority": 0,774- "encrypted": false,775- "caid": "",776- "s_type_user": -1777- },778- {779- "uuid": "7240cb614ca5b54b71f147253287def0",780- "network": "Airwaves antenna",781- "multiplex": "587MHz",782- "multiplex_uuid": "4595b7c94b59a2df0e0b17a6406e9a51",783- "sid": 2,784- "lcn": 6,785- "lcn_minor": 2,786- "lcn2": 0,787- "srcid": 2,788- "svcname": "PBSKIDS",789- "dvb_servicetype": 0,790- "dvb_eit_processing": 0,791- "dvb_subtitle_processing": 0,792- "dvb_ignore_matching_subtitle": false,793- "prefcapid": 0,794- "prefcapid_lock": 0,795- "force_caid": 0,796- "pts_shift": 0,797- "created": 1791129190,798- "last_seen": 1791129190,799- "enabled": true,800- "auto": 0,801- "channel": [802- "e1338eee484b3d8cb923a0882054cbb0"803- ],804- "priority": 0,805- "encrypted": false,806- "caid": "",807- "s_type_user": -1808- },809- {810- "uuid": "33dcb45cef71321fcac42e1c93974632",811- "network": "Airwaves antenna",812- "multiplex": "587MHz",813- "multiplex_uuid": "4595b7c94b59a2df0e0b17a6406e9a51",814- "sid": 3,815- "lcn": 6,816- "lcn_minor": 3,817- "lcn2": 0,818- "srcid": 3,819- "svcname": "Create",820- "dvb_servicetype": 0,821- "dvb_eit_processing": 0,822- "dvb_subtitle_processing": 0,823- "dvb_ignore_matching_subtitle": false,824- "prefcapid": 0,825- "prefcapid_lock": 0,826- "force_caid": 0,827- "pts_shift": 0,828- "created": 1791129190,829- "last_seen": 1791129190,830- "enabled": true,831- "auto": 0,832- "channel": [833- "001853fe86e76154a8e42c7253824bad"834- ],835- "priority": 0,836- "encrypted": false,837- "caid": "",838- "s_type_user": -1839- },840- {841- "uuid": "971fa8d4b9e7b068f74fbea2b8131b5a",842- "network": "Airwaves antenna",843- "multiplex": "587MHz",844- "multiplex_uuid": "4595b7c94b59a2df0e0b17a6406e9a51",845- "sid": 4,846- "lcn": 6,847- "lcn_minor": 4,848- "lcn2": 0,849- "srcid": 4,850- "svcname": "World",851- "dvb_servicetype": 0,852- "dvb_eit_processing": 0,853- "dvb_subtitle_processing": 0,854- "dvb_ignore_matching_subtitle": false,855- "prefcapid": 0,856- "prefcapid_lock": 0,857- "force_caid": 0,858- "pts_shift": 0,859- "created": 1791129190,860- "last_seen": 1791129190,861- "enabled": true,862- "auto": 0,863- "channel": [864- "dd8fcfcb3cc53ac991098f3a89935305"865- ],866- "priority": 0,867- "encrypted": false,868- "caid": "",869- "s_type_user": -1870- },871- {872- "uuid": "c2b03cefe9378724647ead9aa93e8d22",873- "network": "Airwaves antenna",874- "multiplex": "599MHz",875- "multiplex_uuid": "5888119613610f7fad476b55424773ab",876- "sid": 1,877- "lcn": 4,878- "lcn_minor": 1,879- "lcn2": 0,880- "srcid": 1,881- "svcname": "KCNC-TV",882- "dvb_servicetype": 0,883- "dvb_eit_processing": 0,884- "dvb_subtitle_processing": 0,885- "dvb_ignore_matching_subtitle": false,886- "prefcapid": 0,887- "prefcapid_lock": 0,888- "force_caid": 0,889- "pts_shift": 0,890- "created": 1791129251,891- "last_seen": 1791129251,892- "enabled": true,893- "auto": 0,894- "channel": [895- "db312e0991f9bb43ba7eb4980775698a"896- ],897- "priority": 0,898- "encrypted": false,899- "caid": "",900- "s_type_user": -1901- },902- {903- "uuid": "894e10974ce0ae452c67212ed5811e4b",904- "network": "Airwaves antenna",905- "multiplex": "599MHz",906- "multiplex_uuid": "5888119613610f7fad476b55424773ab",907- "sid": 2,908- "lcn": 4,909- "lcn_minor": 2,910- "lcn2": 0,911- "srcid": 2,912- "svcname": "StartTV",913- "dvb_servicetype": 0,914- "dvb_eit_processing": 0,915- "dvb_subtitle_processing": 0,916- "dvb_ignore_matching_subtitle": false,917- "prefcapid": 0,918- "prefcapid_lock": 0,919- "force_caid": 0,920- "pts_shift": 0,921- "created": 1791129251,922- "last_seen": 1791129251,923- "enabled": true,924- "auto": 0,925- "channel": [926- "c582ec8231fce59f932452b879c29c02"927- ],928- "priority": 0,929- "encrypted": false,930- "caid": "",931- "s_type_user": -1932- },933- {934- "uuid": "9f1ba02d59cb9b3206dae9a447706906",935- "network": "Airwaves antenna",936- "multiplex": "599MHz",937- "multiplex_uuid": "5888119613610f7fad476b55424773ab",938- "sid": 3,939- "lcn": 4,940- "lcn_minor": 3,941- "lcn2": 0,942- "srcid": 3,943- "svcname": "Dabl ",944- "dvb_servicetype": 0,945- "dvb_eit_processing": 0,946- "dvb_subtitle_processing": 0,947- "dvb_ignore_matching_subtitle": false,948- "prefcapid": 0,949- "prefcapid_lock": 0,950- "force_caid": 0,951- "pts_shift": 0,952- "created": 1791129251,953- "last_seen": 1791129251,954- "enabled": true,955- "auto": 0,956- "channel": [957- "4a4a8e58de8c9cd3dc4bfc97e6ebecbb"958- ],959- "priority": 0,960- "encrypted": false,961- "caid": "",962- "s_type_user": -1963- },964- {965- "uuid": "088dda0508522f57035031c2c1a2e1c1",966- "network": "Airwaves antenna",967- "multiplex": "599MHz",968- "multiplex_uuid": "5888119613610f7fad476b55424773ab",969- "sid": 4,970- "lcn": 4,971- "lcn_minor": 4,972- "lcn2": 0,973- "srcid": 4,974- "svcname": "MeTV ",975- "dvb_servicetype": 0,976- "dvb_eit_processing": 0,977- "dvb_subtitle_processing": 0,978- "dvb_ignore_matching_subtitle": false,979- "prefcapid": 0,980- "prefcapid_lock": 0,981- "force_caid": 0,982- "pts_shift": 0,983- "created": 1791129251,984- "last_seen": 1791129251,985- "enabled": true,986- "auto": 0,987- "channel": [988- "1f20573bd1f045333d53a31e213912a9"989- ],990- "priority": 0,991- "encrypted": false,992- "caid": "",993- "s_type_user": -1994- },995- {996- "uuid": "f3d0461bc09c28d2fcbc913729e5ccd6",997- "network": "Airwaves antenna",998- "multiplex": "599MHz",999- "multiplex_uuid": "5888119613610f7fad476b55424773ab",1000- "sid": 5,1001- "lcn": 4,1002- "lcn_minor": 5,1003- "lcn2": 0,1004- "srcid": 5,1005- "svcname": "Catchy ",1006- "dvb_servicetype": 0,1007- "dvb_eit_processing": 0,1008- "dvb_subtitle_processing": 0,1009- "dvb_ignore_matching_subtitle": false,1010- "prefcapid": 0,1011- "prefcapid_lock": 0,1012- "force_caid": 0,1013- "pts_shift": 0,1014- "created": 1791129251,1015- "last_seen": 1791129251,1016- "enabled": true,1017- "auto": 0,1018- "channel": [1019- "11c7e5a354ae2a786a3d2761f52ca4bb"1020- ],1021- "priority": 0,1022- "encrypted": false,1023- "caid": "",1024- "s_type_user": -11025- },1026- {1027- "uuid": "e3af94e9356e90b3cd8e304a35c01684",1028- "network": "Airwaves antenna",1029- "multiplex": "599MHz",1030- "multiplex_uuid": "5888119613610f7fad476b55424773ab",1031- "sid": 6,1032- "lcn": 4,1033- "lcn_minor": 6,1034- "lcn2": 0,1035- "srcid": 6,1036- "svcname": "Story ",1037- "dvb_servicetype": 0,1038- "dvb_eit_processing": 0,1039- "dvb_subtitle_processing": 0,1040- "dvb_ignore_matching_subtitle": false,1041- "prefcapid": 0,1042- "prefcapid_lock": 0,1043- "force_caid": 0,1044- "pts_shift": 0,1045- "created": 1791129251,1046- "last_seen": 1791129251,1047- "enabled": true,1048- "auto": 0,1049- "channel": [1050- "a48eb7daf4c16680458ed9b655212cb1"1051- ],1052- "priority": 0,1053- "encrypted": false,1054- "caid": "",1055- "s_type_user": -11056- },1057- {1058- "uuid": "d3e0d1efd2f9babfdb41a47b36607800",1059- "network": "Airwaves antenna",1060- "multiplex": "605MHz",1061- "multiplex_uuid": "ceadb031fb4aeaa525e2c766ac8993eb",1062- "sid": 6,1063- "lcn": 2,1064- "lcn_minor": 1,1065- "lcn2": 0,1066- "srcid": 4,1067- "svcname": "KWGN-DT",1068- "dvb_servicetype": 0,1069- "dvb_eit_processing": 0,1070- "dvb_subtitle_processing": 0,1071- "dvb_ignore_matching_subtitle": false,1072- "prefcapid": 0,1073- "prefcapid_lock": 0,1074- "force_caid": 0,1075- "pts_shift": 0,1076- "created": 1791129310,1077- "last_seen": 1791129310,1078- "enabled": true,1079- "auto": 0,1080- "channel": [1081- "9ba1bb859520d32e45a0bb82c44fee0e"1082- ],1083- "priority": 0,1084- "encrypted": false,1085- "caid": "",1086- "s_type_user": -11087- },1088- {1089- "uuid": "ada0e376fbb2cf9f283dcaf122ded316",1090- "network": "Airwaves antenna",1091- "multiplex": "605MHz",1092- "multiplex_uuid": "ceadb031fb4aeaa525e2c766ac8993eb",1093- "sid": 3,1094- "lcn": 31,1095- "lcn_minor": 1,1096- "lcn2": 0,1097- "srcid": 1,1098- "svcname": "KDVR-DT",1099- "dvb_servicetype": 0,1100- "dvb_eit_processing": 0,1101- "dvb_subtitle_processing": 0,1102- "dvb_ignore_matching_subtitle": false,1103- "prefcapid": 0,1104- "prefcapid_lock": 0,1105- "force_caid": 0,1106- "pts_shift": 0,1107- "created": 1791129310,1108- "last_seen": 1791129310,1109- "enabled": true,1110- "auto": 0,1111- "channel": [1112- "3b437d7e58b015b39fb6cc56eca6088a"1113- ],1114- "priority": 0,1115- "encrypted": false,1116- "caid": "",1117- "s_type_user": -11118- },1119- {1120- "uuid": "7a265b4601d53de0569d1120af37f28b",1121- "network": "Airwaves antenna",1122- "multiplex": "605MHz",1123- "multiplex_uuid": "ceadb031fb4aeaa525e2c766ac8993eb",1124- "sid": 4,1125- "lcn": 31,1126- "lcn_minor": 2,1127- "lcn2": 0,1128- "srcid": 2,1129- "svcname": "Antenna",1130- "dvb_servicetype": 0,1131- "dvb_eit_processing": 0,1132- "dvb_subtitle_processing": 0,1133- "dvb_ignore_matching_subtitle": false,1134- "prefcapid": 0,1135- "prefcapid_lock": 0,1136- "force_caid": 0,1137- "pts_shift": 0,1138- "created": 1791129310,1139- "last_seen": 1791129310,1140- "enabled": true,1141- "auto": 0,1142- "channel": [1143- "530650964dfa607b85770efdbcd0ff6a"1144- ],1145- "priority": 0,1146- "encrypted": false,1147- "caid": "",1148- "s_type_user": -11149- },1150- {1151- "uuid": "3bd985dc5b72f2ff2e7cdcb17871414f",1152- "network": "Airwaves antenna",1153- "multiplex": "605MHz",1154- "multiplex_uuid": "ceadb031fb4aeaa525e2c766ac8993eb",1155- "sid": 5,1156- "lcn": 31,1157- "lcn_minor": 3,1158- "lcn2": 0,1159- "srcid": 3,1160- "svcname": "ROAR-TV",1161- "dvb_servicetype": 0,1162- "dvb_eit_processing": 0,1163- "dvb_subtitle_processing": 0,1164- "dvb_ignore_matching_subtitle": false,1165- "prefcapid": 0,1166- "prefcapid_lock": 0,1167- "force_caid": 0,1168- "pts_shift": 0,1169- "created": 1791129310,1170- "last_seen": 1791129310,1171- "enabled": true,1172- "auto": 0,1173- "channel": [1174- "24164b4984eaf55b6fbe38dd2bec3663"1175- ],1176- "priority": 0,1177- "encrypted": false,1178- "caid": "",1179- "s_type_user": -11180- }1181- ],1182- "total": 381183-}
D internal/tvh/tvhtest/testdata/status_inputs.json +0 -79

Not shown: this page already shows 6000 lines of diff.

D internal/tvh/tvhtest/testdata/status_inputs_idle.json +0 -43

Not shown: this page already shows 6000 lines of diff.

D internal/tvh/tvhtest/testdata/status_inputs_measuring.json +0 -63

Not shown: this page already shows 6000 lines of diff.

D internal/tvh/tvhtest/testdata/status_subscriptions.json +0 -63

Not shown: this page already shows 6000 lines of diff.

D internal/tvh/tvhtest/testdata/status_subscriptions_measuring.json +0 -51

Not shown: this page already shows 6000 lines of diff.

D internal/tvh/tvhtest/tvhtest.go +0 -569

Not shown: this page already shows 6000 lines of diff.