From 1c7de7174dfe19fa3a7aadb920cba8af4bebed54 Mon Sep 17 00:00:00 2001 From: Domenico Testa Date: Wed, 6 May 2026 11:05:20 +0200 Subject: [PATCH] feat: idnorm and playlist support --- idnorm.go | 72 ++++++++++++++++++++++ idnorm_test.go | 68 +++++++++++++++++++++ playlist.go | 90 ++++++++++++++++++++++++++++ playlist_test.go | 153 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 383 insertions(+) create mode 100644 idnorm.go create mode 100644 idnorm_test.go create mode 100644 playlist.go create mode 100644 playlist_test.go diff --git a/idnorm.go b/idnorm.go new file mode 100644 index 0000000..5862483 --- /dev/null +++ b/idnorm.go @@ -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 +} diff --git a/idnorm_test.go b/idnorm_test.go new file mode 100644 index 0000000..486966a --- /dev/null +++ b/idnorm_test.go @@ -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"]) + } +} diff --git a/playlist.go b/playlist.go new file mode 100644 index 0000000..18e1272 --- /dev/null +++ b/playlist.go @@ -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:] +} diff --git a/playlist_test.go b/playlist_test.go new file mode 100644 index 0000000..9f320f6 --- /dev/null +++ b/playlist_test.go @@ -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") + } +}