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)