From 0b7a54d7dafef45b7e018ad2e3f238bb4dcf60b4 Mon Sep 17 00:00:00 2001 From: Nathan Coad Date: Sat, 26 Sep 2026 13:24:11 +1000 Subject: [PATCH] Fix Drone YAML, add regex filters and per-playlist buffer mode (backend) - .drone.yml: two command strings contained ": " and were parsed as maps by Drone's YAML loader ("cannot unmarshal !!map into string"). Quoted. - Filters: new type "regex-filter". The rule is a Go regular expression tried against the channel name, the group title and the raw attribute line; case-insensitive unless the filter is marked case sensitive. Patterns are validated when the filter is saved and when rules are rebuilt, and compiled once (cached). The two fixed {include}/!{exclude} patterns are compiled at package level instead of per stream. - Per-playlist buffer: a playlist's "buffer" parameter ("-", "xteve", "ffmpeg", "vlc") overrides the global setting for that playlist in the stream handler, the buffer start, the tuner lookup and the external process buffer. Anything else falls back to the global setting. (UI controls for both follow with the Phase 5 frontend work.) - Tests: TestRegexFilter, TestBufferModeFor. --- .drone.yml | 4 +-- src/buffer.go | 6 ++-- src/buffer_mode.go | 26 +++++++++++++++++ src/data.go | 28 ++++++++++++++++++ src/filter_test.go | 72 ++++++++++++++++++++++++++++++++++++++++++++++ src/m3u.go | 57 ++++++++++++++++++++++++++++++++---- src/webserver.go | 14 +++++---- 7 files changed, 190 insertions(+), 17 deletions(-) create mode 100644 src/buffer_mode.go create mode 100644 src/filter_test.go diff --git a/.drone.yml b/.drone.yml index 5afeab0..1d68e89 100644 --- a/.drone.yml +++ b/.drone.yml @@ -8,7 +8,7 @@ steps: commands: - go mod download - go vet ./... - - test -z "$(gofmt -l .)" || (echo "gofmt needed:" && gofmt -l . && exit 1) + - 'test -z "$(gofmt -l .)" || (echo "gofmt needed" && gofmt -l . && exit 1)' - go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./... - go test ./... @@ -26,7 +26,7 @@ steps: - XTEVE_VERSION="$(grep -m1 '^#### ' changelog-beta.md | cut -d' ' -f2 | sed 's/-beta$//')" - test -n "$XTEVE_VERSION" || (echo "Could not parse version from changelog-beta.md" && exit 1) - SRC_VERSION="$(grep -m1 '^var Version = ' xteve.go | cut -d'"' -f2)" - - test "$SRC_VERSION" = "$XTEVE_VERSION" || (echo "Version drift: xteve.go has $SRC_VERSION, changelog-beta.md has $XTEVE_VERSION" && exit 1) + - 'test "$SRC_VERSION" = "$XTEVE_VERSION" || (echo "Version drift - xteve.go has $SRC_VERSION, changelog-beta.md has $XTEVE_VERSION" && exit 1)' - echo "Building xTeVe version $XTEVE_VERSION from changelog-beta.md" - go build -v -ldflags "-X main.Version=$XTEVE_VERSION" ./... diff --git a/src/buffer.go b/src/buffer.go index 40dd0cb..8b36370 100644 --- a/src/buffer.go +++ b/src/buffer.go @@ -63,7 +63,7 @@ func bufferingStream(playlistID, streamingURL, channelName string, w http.Respon if isNew { - switch Settings.Buffer { + switch bufferModeFor(playlistID) { case "xteve": go connectToStreamingServer(streamID, playlistID) @@ -1074,7 +1074,7 @@ func thirdPartyBuffer(streamID int, playlistID string) { var path, options, bufferType string - switch Settings.Buffer { + switch bufferModeFor(playlistID) { case "ffmpeg": path, options, bufferType = Settings.FFmpegPath, Settings.FFmpegOptions, "FFMPEG" @@ -1259,7 +1259,7 @@ func thirdPartyBuffer(streamID int, playlistID string) { func getTuner(id, playlistType string) (tuner int) { - switch Settings.Buffer { + switch bufferModeFor(id) { case "-": tuner = Settings.Tuner diff --git a/src/buffer_mode.go b/src/buffer_mode.go new file mode 100644 index 0000000..3ae0ef9 --- /dev/null +++ b/src/buffer_mode.go @@ -0,0 +1,26 @@ +package src + +// bufferModeFor : buffer used for a playlist. A playlist can override the +// global "buffer" setting with its own "buffer" parameter ("-", "xteve", +// "ffmpeg" or "vlc"); anything else means "use the global setting". +func bufferModeFor(playlistID string) string { + + if len(playlistID) > 0 { + + var playlistType string + switch playlistID[0:1] { + case "M": + playlistType = "m3u" + case "H": + playlistType = "hdhr" + } + + switch v := getProviderParameter(playlistID, playlistType, "buffer"); v { + case "-", "xteve", "ffmpeg", "vlc": + return v + } + + } + + return Settings.Buffer +} diff --git a/src/data.go b/src/data.go index 31d69cb..b07392e 100644 --- a/src/data.go +++ b/src/data.go @@ -534,6 +534,23 @@ func saveFilter(request RequestStruct) (settings SettingsStruct, err error) { return } + // Regular expressions are checked before anything is saved. + var filterType, _ = data.(map[string]any)["type"].(string) + if filterType == "" { + if old, ok := filterMap[dataID].(map[string]any); ok { + filterType, _ = old["type"].(string) + } + } + if filterType == "regex-filter" { + if reErr := validateRegexFilter(filter); reErr != nil { + err = fmt.Errorf("invalid regular expression: %v", reErr) + if newFilter { + delete(filterMap, dataID) + } + return + } + } + } if oldData, ok := filterMap[dataID].(map[string]any); ok { @@ -839,6 +856,17 @@ func createFilterRules() (err error) { Data.Filter = append(Data.Filter, dataFilter) + case "regex-filter": + if err = validateRegexFilter(filter.Filter); err != nil { + return fmt.Errorf("filter %q: invalid regular expression: %v", filter.Name, err) + } + + dataFilter.CaseSensitive = filter.CaseSensitive + dataFilter.Rule = filter.Filter + dataFilter.Type = filter.Type + + Data.Filter = append(Data.Filter, dataFilter) + case "group-title": if len(filter.Include) > 0 { include = fmt.Sprintf(" {%s}", filter.Include) diff --git a/src/filter_test.go b/src/filter_test.go new file mode 100644 index 0000000..ea06219 --- /dev/null +++ b/src/filter_test.go @@ -0,0 +1,72 @@ +package src + +import "testing" + +func streamFor(name, group string) map[string]string { + return map[string]string{ + "name": name, + "group-title": group, + "_values": "tvg-id=\"" + name + "\" group-title=\"" + group + "\"," + name, + } +} + +func TestRegexFilter(t *testing.T) { + Data.Filter = []Filter{{Type: "regex-filter", Rule: `^(BBC|ITV) \d+$`, CaseSensitive: false}} + regexFilterCache.Range(func(k, v any) bool { regexFilterCache.Delete(k); return true }) + + cases := map[string]bool{ + "BBC 1": true, + "itv 2": true, // case-insensitive by default + "BBC News": false, + "Sky One": false, + "BBC 1 HD": false, + "Radio BBC": false, + } + // Group title is matched too. + Data.Filter = append(Data.Filter, Filter{Type: "regex-filter", Rule: `^Sports$`}) + cases["Sports"] = false // name "Sports" is only in group below + if !filterThisStream(streamFor("Golf", "Sports")) { + t.Error("regex should match the group title") + } + delete(cases, "Sports") + for name, want := range cases { + if got := filterThisStream(streamFor(name, "UK")); got != want { + t.Errorf("%q: got %v, want %v", name, got, want) + } + } + + Data.Filter = []Filter{{Type: "regex-filter", Rule: `^bbc`, CaseSensitive: true}} + if filterThisStream(streamFor("BBC 1", "UK")) { + t.Error("case-sensitive rule must not match different case") + } + + Data.Filter = []Filter{{Type: "regex-filter", Rule: `(`, CaseSensitive: false}} + if filterThisStream(streamFor("BBC 1", "UK")) { + t.Error("invalid pattern must never match") + } + if validateRegexFilter(`(`) == nil { + t.Error("validateRegexFilter accepted an invalid pattern") + } + + Data.Filter = nil +} + +func TestBufferModeFor(t *testing.T) { + Settings.Buffer = "xteve" + Settings.Files.M3U = map[string]any{ + "M-a": map[string]any{"name": "A", "buffer": "ffmpeg"}, + "M-b": map[string]any{"name": "B", "buffer": "default"}, + "M-c": map[string]any{"name": "C"}, + } + Settings.Files.HDHR = map[string]any{ + "H-d": map[string]any{"name": "D", "buffer": "-"}, + } + t.Cleanup(func() { Settings.Buffer = "-" }) + + cases := map[string]string{"M-a": "ffmpeg", "M-b": "xteve", "M-c": "xteve", "H-d": "-", "M-missing": "xteve", "": "xteve"} + for id, want := range cases { + if got := bufferModeFor(id); got != want { + t.Errorf("%q: got %q, want %q", id, got, want) + } + } +} diff --git a/src/m3u.go b/src/m3u.go index 8d5d81c..0a5a22e 100644 --- a/src/m3u.go +++ b/src/m3u.go @@ -8,6 +8,7 @@ import ( "sort" "strconv" "strings" + "sync" m3u "xteve/src/internal/m3u-parser" ) @@ -34,12 +35,47 @@ func parsePlaylist(filename, fileType string) (channels []any, err error) { } // Streams filtern +// Patterns for the {include} and !{exclude} parts of a filter rule. +var filterIncludeRe = regexp.MustCompile(`[{]+[^.]+[}]`) +var filterExcludeRe = regexp.MustCompile(`!+[{]+[^.]+[}]`) + +// regexFilterCache : compiled "regex-filter" rules, keyed by rule and case mode. +var regexFilterCache sync.Map + +// compileRegexFilter : compiles (and caches) a regex-filter rule. Case +// insensitive rules get the (?i) flag. Returns nil for an invalid pattern. +func compileRegexFilter(rule string, caseSensitive bool) *regexp.Regexp { + + var key = fmt.Sprintf("%t:%s", caseSensitive, rule) + + if v, ok := regexFilterCache.Load(key); ok { + return v.(*regexp.Regexp) + } + + var pattern = rule + if !caseSensitive { + pattern = "(?i)" + rule + } + + re, err := regexp.Compile(pattern) + if err != nil { + return nil + } + + regexFilterCache.Store(key, re) + return re +} + +// validateRegexFilter : error for an invalid regex-filter rule. +func validateRegexFilter(rule string) error { + _, err := regexp.Compile(rule) + return err +} + func filterThisStream(s any) (status bool) { status = false var stream = s.(map[string]string) - var regexpYES = `[{]+[^.]+[}]` - var regexpNO = `!+[{]+[^.]+[}]` for _, filter := range Data.Filter { @@ -53,6 +89,17 @@ func filterThisStream(s any) (status bool) { var streamValues = strings.Replace(stream["_values"], "\r", "", -1) + // Regular expression, tried against the channel name, then the group + // title, then the raw attribute line (so anchors work on the name). + if filter.Type == "regex-filter" { + if re := compileRegexFilter(filter.Rule, filter.CaseSensitive); re != nil { + if re.MatchString(stream["name"]) || re.MatchString(stream["group-title"]) || re.MatchString(streamValues) { + return true + } + } + continue + } + if v, ok := stream["group-title"]; ok { group = v } @@ -62,8 +109,7 @@ func filterThisStream(s any) (status bool) { } // Unerwünschte Streams !{DEU} - r := regexp.MustCompile(regexpNO) - val := r.FindStringSubmatch(filter.Rule) + val := filterExcludeRe.FindStringSubmatch(filter.Rule) if len(val) == 1 { @@ -74,8 +120,7 @@ func filterThisStream(s any) (status bool) { } // Muss zusätzlich erfüllt sein {DEU} - r = regexp.MustCompile(regexpYES) - val = r.FindStringSubmatch(filter.Rule) + val = filterIncludeRe.FindStringSubmatch(filter.Rule) if len(val) == 1 { diff --git a/src/webserver.go b/src/webserver.go index 625397e..a05a524 100644 --- a/src/webserver.go +++ b/src/webserver.go @@ -164,16 +164,18 @@ func Stream(w http.ResponseWriter, r *http.Request) { return } + var bufferMode = bufferModeFor(streamInfo.PlaylistID) + // If an UDPxy host is set, and the stream URL is multicast (i.e. starts with 'udp://@'), // then streamInfo.URL needs to be rewritten to point to UDPxy. if Settings.UDPxy != "" && strings.HasPrefix(streamInfo.URL, "udp://@") { streamInfo.URL = fmt.Sprintf("http://%s/udp/%s/", Settings.UDPxy, strings.TrimPrefix(streamInfo.URL, "udp://@")) } - switch Settings.Buffer { + switch bufferMode { case "-": - showInfo(fmt.Sprintf("Buffer:false [%s]", Settings.Buffer)) + showInfo(fmt.Sprintf("Buffer:false [%s]", bufferMode)) case "xteve": if strings.Contains(streamInfo.URL, "rtsp://") || strings.Contains(streamInfo.URL, "rtp://") { @@ -187,14 +189,14 @@ func Stream(w http.ResponseWriter, r *http.Request) { return } - showInfo(fmt.Sprintf("Buffer:true [%s]", Settings.Buffer)) + showInfo(fmt.Sprintf("Buffer:true [%s]", bufferMode)) default: - showInfo(fmt.Sprintf("Buffer:true [%s]", Settings.Buffer)) + showInfo(fmt.Sprintf("Buffer:true [%s]", bufferMode)) } - if Settings.Buffer != "-" { + if bufferMode != "-" { showInfo(fmt.Sprintf("Buffer Size:%d KB", Settings.BufferSize)) } @@ -202,7 +204,7 @@ func Stream(w http.ResponseWriter, r *http.Request) { showInfo(fmt.Sprintf("Client User-Agent:%s", r.Header.Get("User-Agent"))) // Prüfen ob der Buffer verwendet werden soll - switch Settings.Buffer { + switch bufferMode { case "-": showInfo("Streaming URL:" + streamInfo.URL)