feat: idnorm and playlist support

This commit is contained in:
2026-05-06 11:05:20 +02:00
parent 6c90d765e7
commit 1c7de7174d
4 changed files with 383 additions and 0 deletions
+72
View File
@@ -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
}
+68
View File
@@ -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
View File
@@ -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:]
}
+153
View File
@@ -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")
}
}