feat: idnorm and playlist support
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Trailing dotted suffixes that EPG providers commonly append to channel ids
|
||||
// (e.g. "Italia 1.it"). Conservative allow-list to avoid eating real id chars.
|
||||
var idSuffixes = []string{".it", ".uk", ".de", ".com", ".us", ".fr", ".es", ".tv"}
|
||||
|
||||
// Trailing video-quality markers commonly appended by either the playlist or
|
||||
// the EPG (e.g. open-epg uses "Italia 1 HD.it" while the playlist may say
|
||||
// "Italia 1"). Stripped symmetrically from both sides. Order matters: longer
|
||||
// markers first so " full hd" wins over a partial " hd" match.
|
||||
var qualitySuffixes = []string{" full hd", " uhd", " fhd", " hd", " 4k", " sd"}
|
||||
|
||||
// normalizeChannelID returns a stable lookup key for a channel identifier,
|
||||
// collapsing cosmetic divergences across sources:
|
||||
//
|
||||
// "Italia 1" / "Italia 1.it" / "Italia 1 HD.it" / "italia1" → "italia1"
|
||||
func normalizeChannelID(id string) string {
|
||||
s := strings.ToLower(strings.TrimSpace(id))
|
||||
|
||||
for _, suf := range idSuffixes {
|
||||
if strings.HasSuffix(s, suf) {
|
||||
s = s[:len(s)-len(suf)]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Strip trailing quality markers, possibly stacked ("Italia 1 Full HD HD").
|
||||
for {
|
||||
matched := false
|
||||
for _, suf := range qualitySuffixes {
|
||||
if strings.HasSuffix(s, suf) {
|
||||
s = s[:len(s)-len(suf)]
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// buildIDMap turns a list of playlist tvg-ids into a normalized→canonical
|
||||
// lookup. The canonical form is the playlist's exact tvg-id string; the EPG
|
||||
// merger uses it to rewrite incoming channel/programme references.
|
||||
func buildIDMap(tvgIDs []string) map[string]string {
|
||||
m := make(map[string]string, len(tvgIDs))
|
||||
for _, id := range tvgIDs {
|
||||
key := normalizeChannelID(id)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := m[key]; !exists {
|
||||
m[key] = id
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeChannelID(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"Italia 1", "italia1"},
|
||||
{"Italia 1.it", "italia1"},
|
||||
{"italia 1", "italia1"},
|
||||
{" Italia 1 ", "italia1"},
|
||||
{"Italia-1", "italia1"},
|
||||
{"Rai 1", "rai1"},
|
||||
{"Rai 1.it", "rai1"},
|
||||
{"ESPN.com", "espn"},
|
||||
{"Channel 4.uk", "channel4"},
|
||||
|
||||
// Quality-marker stripping — open-epg shape vs. playlist shape.
|
||||
{"Italia 1 HD.it", "italia1"},
|
||||
{"Rai 1 HD.it", "rai1"},
|
||||
{"TV8 HD.it", "tv8"},
|
||||
{"NOVE HD", "nove"},
|
||||
{"NOVE HD.it", "nove"},
|
||||
{"20Mediaset HD.it", "20mediaset"},
|
||||
{"Italia 1 Full HD", "italia1"},
|
||||
{"Italia 1 4K", "italia1"},
|
||||
|
||||
// Channels that legitimately contain "HD" — no stripping unless trailing.
|
||||
{"HD", "hd"},
|
||||
{"HD Network", "hdnetwork"},
|
||||
{"Sky HD Italia", "skyhditalia"},
|
||||
|
||||
{"", ""},
|
||||
{" ", ""},
|
||||
{".com", ""},
|
||||
{"Foo.Bar", "foobar"}, // ".bar" is not a known suffix
|
||||
{"foo.it.something", "fooitsomething"}, // mid-string ".it" is NOT stripped — only trailing suffix
|
||||
{"Caffè 24", "caffè24"}, // unicode letter survives
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
if got := normalizeChannelID(c.in); got != c.want {
|
||||
t.Errorf("normalizeChannelID(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildIDMap(t *testing.T) {
|
||||
ids := []string{"Italia 1", "Rai 1", "Italia 1", "", " "}
|
||||
m := buildIDMap(ids)
|
||||
|
||||
if got, want := m["italia1"], "Italia 1"; got != want {
|
||||
t.Errorf("italia1 -> %q, want %q", got, want)
|
||||
}
|
||||
if got, want := m["rai1"], "Rai 1"; got != want {
|
||||
t.Errorf("rai1 -> %q, want %q", got, want)
|
||||
}
|
||||
if len(m) != 2 {
|
||||
t.Errorf("len(m) = %d, want 2 (empties dropped, dupes deduped)", len(m))
|
||||
}
|
||||
|
||||
// First occurrence wins for the canonical mapping.
|
||||
first := buildIDMap([]string{"Italia 1", "italia 1"})
|
||||
if first["italia1"] != "Italia 1" {
|
||||
t.Errorf("first-wins broken: got %q", first["italia1"])
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
tvgIDRe = regexp.MustCompile(`tvg-id="([^"]*)"`)
|
||||
urlTvgRe = regexp.MustCompile(`url-tvg="([^"]*)"`)
|
||||
)
|
||||
|
||||
// processPlaylist streams in→out, rewriting the #EXTM3U header so url-tvg
|
||||
// points at epgURL. Returns the playlist's tvg-ids (in order, with duplicates)
|
||||
// and the URL the playlist already declared in url-tvg (empty if absent).
|
||||
//
|
||||
// If epgURL is empty the header is passed through unchanged. The playlist
|
||||
// must start with #EXTM3U after any leading blank lines, otherwise an error
|
||||
// is returned without writing the header.
|
||||
func processPlaylist(in io.Reader, out io.Writer, epgURL string) (tvgIDs []string, originalEPG string, err error) {
|
||||
r := bufio.NewReader(in)
|
||||
headerDone := false
|
||||
|
||||
for {
|
||||
line, readErr := r.ReadString('\n')
|
||||
|
||||
if line != "" {
|
||||
switch {
|
||||
case !headerDone && strings.TrimSpace(line) == "":
|
||||
// leading blank line — write through, keep looking for header
|
||||
|
||||
case !headerDone:
|
||||
if !strings.HasPrefix(strings.TrimSpace(line), "#EXTM3U") {
|
||||
return nil, "", errors.New("playlist: missing #EXTM3U header")
|
||||
}
|
||||
line, originalEPG = rewriteHeader(line, epgURL)
|
||||
headerDone = true
|
||||
|
||||
default:
|
||||
if strings.HasPrefix(line, "#EXTINF:") {
|
||||
if m := tvgIDRe.FindStringSubmatch(line); m != nil {
|
||||
if id := strings.TrimSpace(m[1]); id != "" {
|
||||
tvgIDs = append(tvgIDs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if _, werr := out.Write([]byte(line)); werr != nil {
|
||||
return nil, "", werr
|
||||
}
|
||||
}
|
||||
|
||||
if readErr == io.EOF {
|
||||
if !headerDone {
|
||||
return nil, "", errors.New("playlist: missing #EXTM3U header")
|
||||
}
|
||||
return tvgIDs, originalEPG, nil
|
||||
}
|
||||
if readErr != nil {
|
||||
return nil, "", readErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func rewriteHeader(line, epgURL string) (rewritten, original string) {
|
||||
if m := urlTvgRe.FindStringSubmatch(line); m != nil {
|
||||
original = m[1]
|
||||
if epgURL != "" {
|
||||
line = urlTvgRe.ReplaceAllLiteralString(line, `url-tvg="`+epgURL+`"`)
|
||||
}
|
||||
return line, original
|
||||
}
|
||||
if epgURL == "" {
|
||||
return line, ""
|
||||
}
|
||||
body, eol := splitEOL(line)
|
||||
return body + ` url-tvg="` + epgURL + `"` + eol, ""
|
||||
}
|
||||
|
||||
func splitEOL(s string) (body, eol string) {
|
||||
n := len(s)
|
||||
for n > 0 && (s[n-1] == '\n' || s[n-1] == '\r') {
|
||||
n--
|
||||
}
|
||||
return s[:n], s[n:]
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProcessPlaylist_AppendsURLTvg(t *testing.T) {
|
||||
in := "#EXTM3U\n" +
|
||||
`#EXTINF:-1 tvg-id="Rai 1" tvg-name="Rai 1",Rai 1` + "\n" +
|
||||
"http://example.com/stream\n"
|
||||
|
||||
var out bytes.Buffer
|
||||
ids, orig, err := processPlaylist(strings.NewReader(in), &out, "https://relay/epg")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if orig != "" {
|
||||
t.Errorf("originalEPG = %q, want empty", orig)
|
||||
}
|
||||
if got, want := ids, []string{"Rai 1"}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("tvgIDs = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
want := `#EXTM3U url-tvg="https://relay/epg"` + "\n" +
|
||||
`#EXTINF:-1 tvg-id="Rai 1" tvg-name="Rai 1",Rai 1` + "\n" +
|
||||
"http://example.com/stream\n"
|
||||
if out.String() != want {
|
||||
t.Errorf("output mismatch.\n got: %q\nwant: %q", out.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessPlaylist_ReplacesExistingURLTvg(t *testing.T) {
|
||||
in := `#EXTM3U url-tvg="http://upstream.example/epg.xml"` + "\n" +
|
||||
`#EXTINF:-1 tvg-id="Italia 1",Italia 1` + "\n" +
|
||||
"http://example.com/stream\n"
|
||||
|
||||
var out bytes.Buffer
|
||||
ids, orig, err := processPlaylist(strings.NewReader(in), &out, "https://relay/epg")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if orig != "http://upstream.example/epg.xml" {
|
||||
t.Errorf("originalEPG = %q, want upstream URL captured", orig)
|
||||
}
|
||||
if got, want := ids, []string{"Italia 1"}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("tvgIDs = %v, want %v", got, want)
|
||||
}
|
||||
if !strings.Contains(out.String(), `url-tvg="https://relay/epg"`) {
|
||||
t.Errorf("expected rewritten url-tvg in output, got: %q", out.String())
|
||||
}
|
||||
if strings.Contains(out.String(), "upstream.example") {
|
||||
t.Errorf("upstream URL leaked into output: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessPlaylist_EmptyEPGURLLeavesHeaderAlone(t *testing.T) {
|
||||
in := `#EXTM3U url-tvg="http://upstream.example/epg.xml"` + "\n" +
|
||||
"http://example.com/stream\n"
|
||||
|
||||
var out bytes.Buffer
|
||||
_, orig, err := processPlaylist(strings.NewReader(in), &out, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if orig != "http://upstream.example/epg.xml" {
|
||||
t.Errorf("originalEPG = %q, want captured", orig)
|
||||
}
|
||||
if out.String() != in {
|
||||
t.Errorf("output should match input verbatim when epgURL is empty.\n got: %q\nwant: %q", out.String(), in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessPlaylist_PreservesCRLF(t *testing.T) {
|
||||
in := "#EXTM3U\r\n" +
|
||||
`#EXTINF:-1 tvg-id="Rai 1",Rai 1` + "\r\n" +
|
||||
"http://example.com/stream\r\n"
|
||||
|
||||
var out bytes.Buffer
|
||||
if _, _, err := processPlaylist(strings.NewReader(in), &out, "https://relay/epg"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(out.String(), `#EXTM3U url-tvg="https://relay/epg"`+"\r\n") {
|
||||
t.Errorf("CRLF not preserved in header: %q", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "http://example.com/stream\r\n") {
|
||||
t.Errorf("CRLF not preserved in body: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessPlaylist_StreamLinesPassThrough(t *testing.T) {
|
||||
// All non-header lines must come through byte-for-byte; only the first
|
||||
// non-empty line (the EXTM3U header) is rewritten.
|
||||
in := "#EXTM3U\n" +
|
||||
`#EXTINF:-1 tvg-id="A",A` + "\n" +
|
||||
"http://example.com/a\n" +
|
||||
`#EXTINF:-1 tvg-id="",B` + "\n" +
|
||||
"http://example.com/b\n"
|
||||
|
||||
var out bytes.Buffer
|
||||
ids, _, err := processPlaylist(strings.NewReader(in), &out, "https://relay/epg")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got, want := ids, []string{"A"}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("tvgIDs = %v, want %v (empty tvg-id must be skipped)", got, want)
|
||||
}
|
||||
for _, must := range []string{
|
||||
`#EXTINF:-1 tvg-id="A",A` + "\n",
|
||||
"http://example.com/a\n",
|
||||
`#EXTINF:-1 tvg-id="",B` + "\n",
|
||||
"http://example.com/b\n",
|
||||
} {
|
||||
if !strings.Contains(out.String(), must) {
|
||||
t.Errorf("output missing line %q", must)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessPlaylist_SkipsLeadingBlankLines(t *testing.T) {
|
||||
in := "\n\n#EXTM3U\n#EXTINF:-1 tvg-id=\"X\",X\nhttp://example.com/x\n"
|
||||
|
||||
var out bytes.Buffer
|
||||
ids, _, err := processPlaylist(strings.NewReader(in), &out, "https://relay/epg")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(ids, []string{"X"}) {
|
||||
t.Errorf("tvgIDs = %v", ids)
|
||||
}
|
||||
if !strings.HasPrefix(out.String(), "\n\n#EXTM3U "+`url-tvg="https://relay/epg"`+"\n") {
|
||||
t.Errorf("leading blanks should be preserved before rewritten header: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessPlaylist_RejectsMissingHeader(t *testing.T) {
|
||||
in := "garbage\n#EXTINF:-1 tvg-id=\"X\",X\n"
|
||||
var out bytes.Buffer
|
||||
_, _, err := processPlaylist(strings.NewReader(in), &out, "https://relay/epg")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing #EXTM3U header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessPlaylist_RejectsEmptyInput(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
_, _, err := processPlaylist(strings.NewReader(""), &out, "https://relay/epg")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty input")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user