227 lines
5.3 KiB
Go
227 lines
5.3 KiB
Go
package main
|
|
|
|
import (
|
|
"compress/gzip"
|
|
"context"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
// fetchEPGSource downloads url into dest as plain XML, transparently
|
|
// gunzipping if the URL ends in .gz or the response Content-Type indicates
|
|
// gzip. Streaming — the body never lives fully in memory.
|
|
func fetchEPGSource(ctx context.Context, client *http.Client, url, dest string) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("epg fetch %s: %s", url, resp.Status)
|
|
}
|
|
|
|
src := io.Reader(resp.Body)
|
|
if responseLooksGzipped(url, resp) {
|
|
gr, err := gzip.NewReader(resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("epg fetch %s: gzip: %w", url, err)
|
|
}
|
|
defer gr.Close()
|
|
src = gr
|
|
}
|
|
|
|
f, err := os.Create(dest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
if _, err := io.Copy(f, src); err != nil {
|
|
return err
|
|
}
|
|
return f.Sync()
|
|
}
|
|
|
|
func responseLooksGzipped(url string, resp *http.Response) bool {
|
|
base := strings.ToLower(strings.SplitN(url, "?", 2)[0])
|
|
if strings.HasSuffix(base, ".gz") {
|
|
return true
|
|
}
|
|
if strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "gzip") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// mergeEPG writes a merged XMLTV stream to dst, drawing from the given
|
|
// sources. Channels not covered by tvgIDMap are dropped; matched channels
|
|
// have their ids rewritten to the canonical (playlist) form. When multiple
|
|
// sources cover the same channel, the first one wins and programmes from
|
|
// other sources for that channel are dropped.
|
|
//
|
|
// Each source is read twice (channels, then programmes), which is why the
|
|
// argument is io.ReadSeeker rather than io.Reader.
|
|
func mergeEPG(sources []io.ReadSeeker, tvgIDMap map[string]string, dst io.Writer) error {
|
|
if _, err := io.WriteString(dst, xml.Header); err != nil {
|
|
return err
|
|
}
|
|
|
|
enc := xml.NewEncoder(dst)
|
|
tv := xml.StartElement{
|
|
Name: xml.Name{Local: "tv"},
|
|
Attr: []xml.Attr{{Name: xml.Name{Local: "generator-info-name"}, Value: "pz8-relay"}},
|
|
}
|
|
if err := enc.EncodeToken(tv); err != nil {
|
|
return err
|
|
}
|
|
|
|
ownership := make(map[string]int)
|
|
|
|
for i, src := range sources {
|
|
if _, err := src.Seek(0, io.SeekStart); err != nil {
|
|
return err
|
|
}
|
|
if err := emitChannels(src, enc, tvgIDMap, ownership, i); err != nil {
|
|
return fmt.Errorf("source %d channels: %w", i, err)
|
|
}
|
|
}
|
|
|
|
for i, src := range sources {
|
|
if _, err := src.Seek(0, io.SeekStart); err != nil {
|
|
return err
|
|
}
|
|
if err := emitProgrammes(src, enc, tvgIDMap, ownership, i); err != nil {
|
|
return fmt.Errorf("source %d programmes: %w", i, err)
|
|
}
|
|
}
|
|
|
|
if err := enc.EncodeToken(tv.End()); err != nil {
|
|
return err
|
|
}
|
|
return enc.Close()
|
|
}
|
|
|
|
func emitChannels(r io.Reader, enc *xml.Encoder, tvgIDMap map[string]string, ownership map[string]int, sourceIdx int) error {
|
|
dec := xml.NewDecoder(r)
|
|
for {
|
|
tok, err := dec.Token()
|
|
if err == io.EOF {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
start, ok := tok.(xml.StartElement)
|
|
if !ok || start.Name.Local != "channel" {
|
|
continue
|
|
}
|
|
idIdx := findAttr(start.Attr, "id")
|
|
if idIdx < 0 {
|
|
if err := dec.Skip(); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
canonical, matched := tvgIDMap[normalizeChannelID(start.Attr[idIdx].Value)]
|
|
if !matched {
|
|
if err := dec.Skip(); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if _, taken := ownership[canonical]; taken {
|
|
if err := dec.Skip(); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
ownership[canonical] = sourceIdx
|
|
start.Attr[idIdx].Value = canonical
|
|
if err := copyElement(dec, enc, start); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
func emitProgrammes(r io.Reader, enc *xml.Encoder, tvgIDMap map[string]string, ownership map[string]int, sourceIdx int) error {
|
|
dec := xml.NewDecoder(r)
|
|
for {
|
|
tok, err := dec.Token()
|
|
if err == io.EOF {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
start, ok := tok.(xml.StartElement)
|
|
if !ok || start.Name.Local != "programme" {
|
|
continue
|
|
}
|
|
chIdx := findAttr(start.Attr, "channel")
|
|
if chIdx < 0 {
|
|
if err := dec.Skip(); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
canonical, matched := tvgIDMap[normalizeChannelID(start.Attr[chIdx].Value)]
|
|
if !matched {
|
|
if err := dec.Skip(); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if owner, ok := ownership[canonical]; !ok || owner != sourceIdx {
|
|
if err := dec.Skip(); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
start.Attr[chIdx].Value = canonical
|
|
if err := copyElement(dec, enc, start); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
func findAttr(attrs []xml.Attr, name string) int {
|
|
for i, a := range attrs {
|
|
if a.Name.Local == name {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// copyElement emits start and every token up to its matching end element,
|
|
// using depth tracking to handle nested elements.
|
|
func copyElement(dec *xml.Decoder, enc *xml.Encoder, start xml.StartElement) error {
|
|
if err := enc.EncodeToken(start); err != nil {
|
|
return err
|
|
}
|
|
depth := 1
|
|
for depth > 0 {
|
|
tok, err := dec.Token()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := enc.EncodeToken(tok); err != nil {
|
|
return err
|
|
}
|
|
switch tok.(type) {
|
|
case xml.StartElement:
|
|
depth++
|
|
case xml.EndElement:
|
|
depth--
|
|
}
|
|
}
|
|
return nil
|
|
}
|