// Package service — RSS subscriptions for automated downloads. // // SubscriptionService periodically polls every Subscription row, fetches // the configured RSS / Atom feed, and queues new items into the // DownloadService. Items are deduplicated by GUID stored as a Setting key // "subscription..last_guid" so the same episode is never re-queued. package service import ( "context" "encoding/xml" "errors" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "regexp" "sort" "strings" "time" "unicode" "go.uber.org/zap" "github.com/ShukeBta/MediaStationGo/internal/config" "github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/repository" ) var ( seriesPackRE = regexp.MustCompile(`(?i)(complete|batch|合集|全集|全\s*\d+\s*[集话話期]|整季|全季|s\d{1,2}\s*(?:complete|batch|pack)|season\s*\d{1,2}\s*(?:complete|batch|pack)|s\d{1,2}e\d{1,3}\s*[-~–—]\s*(?:e)?\d{1,3}|第\s*\d+\s*[-~–—]\s*\d+\s*[集话話期])`) seasonOnlyRE = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\s*\d{1,2}(?:[\s._-]|$)|第\s*\d+\s*季`) ) type siteSearchCandidate struct { Item SearchResult Download string GUID string Season int Episode int Pack bool Score int } // SubscriptionService runs the polling loop. type SubscriptionService struct { cfg *config.Config log *zap.Logger repo *repository.Container downloads *DownloadService site *SiteService hub *Hub stop chan struct{} } // NewSubscriptionService is the constructor. func NewSubscriptionService(cfg *config.Config, log *zap.Logger, repo *repository.Container, downloads *DownloadService, site *SiteService, hub *Hub) *SubscriptionService { return &SubscriptionService{ cfg: cfg, log: log, repo: repo, downloads: downloads, site: site, hub: hub, stop: make(chan struct{}), } } // Start runs the polling loop in the background. func (s *SubscriptionService) Start(ctx context.Context) { go s.loop(ctx) } // Stop shuts the loop down. func (s *SubscriptionService) Stop() { close(s.stop) } // rssFeed is the minimal RSS subset we need to decode. type rssFeed struct { XMLName xml.Name `xml:"rss"` Channel struct { Items []struct { Title string `xml:"title"` Link string `xml:"link"` GUID string `xml:"guid"` Description string `xml:"description"` Enclosure struct { URL string `xml:"url,attr"` } `xml:"enclosure"` } `xml:"item"` } `xml:"channel"` } // Create persists a new subscription. func (s *SubscriptionService) Create(ctx context.Context, sub *model.Subscription) error { if sub.Name == "" || sub.FeedURL == "" { return errors.New("name and feed_url required") } normalizeSubscriptionDefaults(sub) enabled := sub.Enabled if err := s.repo.Subscription.Create(ctx, sub); err != nil { return err } if !enabled { if err := s.repo.DB.WithContext(ctx).Model(sub).Update("enabled", false).Error; err != nil { return err } sub.Enabled = false } return nil } func normalizeSubscriptionDefaults(sub *model.Subscription) { if strings.TrimSpace(sub.SearchMode) == "" { sub.SearchMode = "keyword" } if strings.TrimSpace(sub.Resolution) == "" { sub.Resolution = "best" } if strings.TrimSpace(sub.WashPriority) == "" { sub.WashPriority = "balanced" } if sub.Priority == 0 { sub.Priority = 50 } } // List returns every subscription rule. func (s *SubscriptionService) List(ctx context.Context) ([]model.Subscription, error) { return s.repo.Subscription.List(ctx) } // Delete removes a subscription. func (s *SubscriptionService) Delete(ctx context.Context, id string) error { return s.repo.DB.Where("id = ?", id).Delete(&model.Subscription{}).Error } // RunNow forces a poll for one subscription, ignoring its schedule. Used // by the admin UI's "test now" button. func (s *SubscriptionService) RunNow(ctx context.Context, id string) (int, error) { var sub model.Subscription if err := s.repo.DB.Where("id = ?", id).First(&sub).Error; err != nil { return 0, err } return s.runOne(ctx, &sub) } // loop polls every 10 minutes. func (s *SubscriptionService) loop(ctx context.Context) { t := time.NewTicker(10 * time.Minute) defer t.Stop() // First run shortly after startup. first := time.NewTimer(30 * time.Second) defer first.Stop() for { select { case <-ctx.Done(): return case <-s.stop: return case <-first.C: case <-t.C: } s.runAll(ctx) } } func (s *SubscriptionService) runAll(ctx context.Context) { subs, err := s.repo.Subscription.List(ctx) if err != nil { s.log.Warn("subscription list failed", zap.Error(err)) return } for i := range subs { if !subs[i].Enabled { continue } if n, err := s.runOne(ctx, &subs[i]); err != nil { s.log.Warn("subscription run failed", zap.String("name", subs[i].Name), zap.Error(err)) } else if n > 0 { s.log.Info("subscription queued items", zap.String("name", subs[i].Name), zap.Int("count", n)) } } } func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscription) (int, error) { if strings.HasPrefix(strings.ToLower(strings.TrimSpace(sub.FeedURL)), "site-search://") { return s.runSiteSearch(ctx, sub) } feed, err := s.fetch(ctx, sub.FeedURL) if err != nil { return 0, err } filter := compileFilter(sub.Filter) guidKey := fmt.Sprintf("subscription.%s.seen", sub.ID) seenRaw, _ := s.repo.Setting.Get(ctx, guidKey) seen := splitNonEmpty(seenRaw) seenSet := make(map[string]struct{}, len(seen)) for _, g := range seen { seenSet[g] = struct{}{} } queued := 0 for _, item := range feed.Channel.Items { guid := item.GUID if guid == "" { guid = item.Link } if _, ok := seenSet[guid]; ok { continue } if filter != nil && !filter.MatchString(item.Title) { continue } download := item.Enclosure.URL if download == "" { download = item.Link } if download == "" { continue } mediaType, mediaCategory := s.classifySubscriptionItem(ctx, sub, item.Title, "") savePath := s.resolveSubscriptionSavePath(ctx, sub, mediaType, mediaCategory) if s.downloadPathHasCandidate(ctx, sub, item.Title, savePath) { seen = append(seen, guid) seenSet[guid] = struct{}{} continue } if _, err := s.downloads.AddDownloadWithMeta(ctx, sub.UserID, download, savePath, DownloadTaskMeta{ Title: firstNonEmpty(item.Title, sub.Name), PosterURL: sub.PosterURL, BackdropURL: sub.BackdropURL, Overview: sub.Overview, }); err != nil { s.log.Warn("subscription enqueue failed", zap.String("title", item.Title), zap.String("media_type", mediaType), zap.String("media_category", mediaCategory), zap.String("save_path", savePath), zap.Error(err)) continue } queued++ seen = append(seen, guid) } // Remember the last 200 GUIDs so the seen set doesn't grow forever. if len(seen) > 200 { seen = seen[len(seen)-200:] } _ = s.repo.Setting.Set(ctx, guidKey, strings.Join(seen, "\n")) now := time.Now() _ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error if queued > 0 { s.hub.Publish("subscription", map[string]any{ "id": sub.ID, "name": sub.Name, "queued": queued, }) } return queued, nil } func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subscription) (int, error) { if s.site == nil { return 0, errors.New("site search service unavailable") } keyword := siteSearchKeyword(sub) if keyword == "" { return 0, errors.New("site-search subscription keyword required") } results, err := s.site.Search(ctx, keyword) if err != nil { return 0, err } if len(results) == 0 { now := time.Now() _ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error return 0, nil } guidKey := fmt.Sprintf("subscription.%s.seen", sub.ID) seenRaw, _ := s.repo.Setting.Get(ctx, guidKey) seen := splitNonEmpty(seenRaw) seenSet := make(map[string]struct{}, len(seen)) for _, g := range seen { seenSet[g] = struct{}{} } availability := mergeLocalAvailability( SubscriptionLocalAvailability(ctx, s.repo, sub), s.pendingDownloadAvailability(ctx, sub), ) candidates := selectSiteSearchCandidates(results, sub, seenSet, availability) var lastEnqueueErr error queued := 0 var resources []string for _, candidate := range candidates { item := candidate.Item mediaType, mediaCategory := s.classifySubscriptionItem(ctx, sub, item.Title, item.Category) if s.shouldSkipExistingTorrent(ctx, mediaType, candidate) { seen = append(seen, candidate.GUID) seenSet[candidate.GUID] = struct{}{} continue } realURL := s.site.ResolveDownloadURL(ctx, candidate.Download) savePath := s.resolveSubscriptionSavePath(ctx, sub, mediaType, mediaCategory) if s.downloadPathHasCandidate(ctx, sub, candidate.Item.Title, savePath) { seen = append(seen, candidate.GUID) seenSet[candidate.GUID] = struct{}{} continue } if _, err := s.downloads.AddDownloadWithMeta(ctx, sub.UserID, realURL, savePath, DownloadTaskMeta{ Title: firstNonEmpty(item.Title, sub.Name), PosterURL: sub.PosterURL, BackdropURL: sub.BackdropURL, Overview: sub.Overview, }); err != nil { lastEnqueueErr = err s.log.Warn("site-search subscription enqueue failed", zap.String("subscription", sub.Name), zap.String("title", item.Title), zap.String("site_category", item.Category), zap.String("media_type", mediaType), zap.String("media_category", mediaCategory), zap.String("save_path", savePath), zap.Error(err)) continue } queued++ resources = append(resources, item.Title) seen = append(seen, candidate.GUID) seenSet[candidate.GUID] = struct{}{} } if len(seen) > 200 { seen = seen[len(seen)-200:] } _ = s.repo.Setting.Set(ctx, guidKey, strings.Join(seen, "\n")) now := time.Now() _ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error if queued > 0 { s.hub.Publish("subscription", map[string]any{ "id": sub.ID, "name": sub.Name, "queued": queued, "keyword": keyword, "resources": resources, }) return queued, nil } if lastEnqueueErr != nil { return 0, fmt.Errorf("找到 PT 资源但加入下载器失败: %w", lastEnqueueErr) } return 0, nil } func selectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, availability ...LocalAvailability) []siteSearchCandidate { candidates := make([]siteSearchCandidate, 0, len(results)) for _, item := range results { if !matchesSubscriptionRules(sub, item.Title) { continue } download := strings.TrimSpace(item.DownloadURL) if download == "" { download = strings.TrimSpace(item.TorrentURL) } if download == "" { continue } guid := download if _, ok := seenSet[guid]; ok { continue } season, episode := ParseEpisode(item.Title) score := subscriptionCandidateScore(sub, item) candidates = append(candidates, siteSearchCandidate{ Item: item, Download: download, GUID: guid, Season: season, Episode: episode, Pack: isSeriesPackTitle(item.Title), Score: score, }) } if len(candidates) <= 1 { return candidates } sort.SliceStable(candidates, func(i, j int) bool { if candidates[i].Score != candidates[j].Score { return candidates[i].Score > candidates[j].Score } if candidates[i].Item.Seeders != candidates[j].Item.Seeders { return candidates[i].Item.Seeders > candidates[j].Item.Seeders } return candidates[i].Item.Size > candidates[j].Item.Size }) mediaType := normalizeMediaType(sub.MediaType, sub.Name+" "+sub.Filter, "") if !isSubscriptionSeriesType(mediaType) { return candidates[:1] } var local LocalAvailability if len(availability) > 0 { local = availability[0] } if local.LocalMediaCount > 0 { if local.TotalEpisodes > 0 && len(local.MissingEpisodes) == 0 { return nil } missingSet := missingEpisodeSet(local) onlyMissing := make([]siteSearchCandidate, 0, len(candidates)) for _, candidate := range candidates { if candidate.Episode <= 0 { continue } season := candidate.Season if season <= 0 { season = 1 } if _, exists := local.ExistingEpisodeKeys[episodeKey(season, candidate.Episode)]; exists { continue } if local.TotalEpisodes > 0 { if _, missing := missingSet[candidate.Episode]; !missing { continue } } onlyMissing = append(onlyMissing, candidate) } return sortedEpisodeCandidates(onlyMissing) } for _, candidate := range candidates { if candidate.Pack { return []siteSearchCandidate{candidate} } } selected := sortedEpisodeCandidates(candidates) if len(selected) == 0 { return candidates[:1] } return selected } func matchesSubscriptionRules(sub *model.Subscription, title string) bool { if sub == nil { return true } titleFold := strings.ToLower(title) if sub.ExcludeWords != "" && containsAnyToken(titleFold, sub.ExcludeWords) { return false } if sub.ReleaseGroups != "" && !containsAnyToken(titleFold, sub.ReleaseGroups) { return false } if sub.Resolution != "" && sub.Resolution != "best" && !titleMatchesResolution(titleFold, sub.Resolution) { return false } if sub.Quality != "" && sub.Quality != "best" && !titleMatchesQuality(titleFold, sub.Quality) { return false } if sub.Effects != "" && !containsAnyEffect(titleFold, sub.Effects) { return false } return true } func subscriptionCandidateScore(sub *model.Subscription, item SearchResult) int { title := strings.ToLower(item.Title) score := item.Seeders if sub == nil || !sub.WashEnabled { if item.Free { score += 25 } return score } resolutionScore := detectResolutionScore(title) qualityScore := detectQualityScore(title) effectScore := detectEffectScore(title) priority := "balanced" if sub != nil && strings.TrimSpace(sub.WashPriority) != "" { priority = strings.ToLower(strings.TrimSpace(sub.WashPriority)) } switch priority { case "resolution": score += resolutionScore*1000 + qualityScore*100 + effectScore*50 case "quality": score += qualityScore*1000 + resolutionScore*200 + effectScore*50 case "effects": score += effectScore*1000 + resolutionScore*200 + qualityScore*100 case "seeders": score += qualityScore*3 + resolutionScore*2 + effectScore default: score += resolutionScore*500 + qualityScore*300 + effectScore*150 } if item.Free { score += 25 } return score } func containsAnyToken(titleFold, csv string) bool { for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool { return r == ',' || r == '/' || r == '|' || r == ';' || r == ',' }) { token = strings.TrimSpace(token) if token != "" && strings.Contains(titleFold, token) { return true } } return false } func containsAnyEffect(titleFold, csv string) bool { for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool { return r == ',' || r == '/' || r == '|' || r == ';' || r == ',' }) { token = strings.TrimSpace(token) if token == "" { continue } switch token { case "dolby-vision", "dolby vision", "dv": if strings.Contains(titleFold, "dolby vision") || strings.Contains(titleFold, "dovi") || regexp.MustCompile(`\bdv\b`).MatchString(titleFold) { return true } default: if strings.Contains(titleFold, token) { return true } } } return false } func titleMatchesResolution(titleFold, resolution string) bool { switch strings.ToLower(strings.TrimSpace(resolution)) { case "2160p", "4k", "uhd": return strings.Contains(titleFold, "2160p") || strings.Contains(titleFold, "4k") || strings.Contains(titleFold, "uhd") case "1080p": return strings.Contains(titleFold, "1080p") || strings.Contains(titleFold, "fhd") case "720p": return strings.Contains(titleFold, "720p") default: return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(resolution))) } } func titleMatchesQuality(titleFold, quality string) bool { switch strings.ToLower(strings.TrimSpace(quality)) { case "webdl", "web-dl": return strings.Contains(titleFold, "web-dl") || strings.Contains(titleFold, "webdl") case "bluray", "blu-ray": return strings.Contains(titleFold, "bluray") || strings.Contains(titleFold, "blu-ray") || strings.Contains(titleFold, "bdrip") case "remux": return strings.Contains(titleFold, "remux") case "hdtv": return strings.Contains(titleFold, "hdtv") default: return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(quality))) } } func detectResolutionScore(titleFold string) int { switch { case titleMatchesResolution(titleFold, "2160p"): return 4 case titleMatchesResolution(titleFold, "1080p"): return 3 case titleMatchesResolution(titleFold, "720p"): return 2 default: return 1 } } func detectQualityScore(titleFold string) int { switch { case titleMatchesQuality(titleFold, "remux"): return 5 case titleMatchesQuality(titleFold, "bluray"): return 4 case titleMatchesQuality(titleFold, "web-dl"): return 3 case titleMatchesQuality(titleFold, "hdtv"): return 2 default: return 1 } } func detectEffectScore(titleFold string) int { score := 0 if containsAnyEffect(titleFold, "dolby-vision") { score += 4 } if strings.Contains(titleFold, "hdr10+") { score += 3 } else if strings.Contains(titleFold, "hdr") { score += 2 } if strings.Contains(titleFold, "atmos") { score += 2 } return score } func isSubscriptionSeriesType(mediaType string) bool { switch normalizeMediaType(mediaType, "", "") { case "tv", "anime", "variety": return true default: return false } } func isSeriesPackTitle(title string) bool { title = strings.TrimSpace(title) if title == "" { return false } if seriesPackRE.MatchString(title) { return true } _, episode := ParseEpisode(title) return episode == 0 && seasonOnlyRE.MatchString(title) } func (s *SubscriptionService) shouldSkipExistingTorrent(ctx context.Context, mediaType string, candidate siteSearchCandidate) bool { if s == nil || s.downloads == nil { return false } if isSubscriptionSeriesType(mediaType) && !candidate.Pack && candidate.Episode > 0 { return false } return s.downloads.TorrentExistsByName(ctx, candidate.Item.Title) } func (s *SubscriptionService) pendingDownloadAvailability(ctx context.Context, sub *model.Subscription) LocalAvailability { out := LocalAvailability{ ExistingEpisodeKeys: map[string]struct{}{}, MissingEpisodeKeys: map[string]struct{}{}, } if sub != nil { out.TotalEpisodes = sub.TotalEpisodes } root := s.subscriptionBaseSavePath(ctx, sub) query := availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)) if root == "" || query == "" { return out } _ = scanDownloadPath(ctx, root, query, func(_ string, season, episode int) bool { out.LocalMediaCount++ out.InLibrary = true if episode > 0 { out.ExistingEpisodeKeys[episodeKey(season, episode)] = struct{}{} } return true }) mediaType := "" if sub != nil { mediaType = sub.MediaType } if isSubscriptionSeriesType(mediaType) || len(out.ExistingEpisodeKeys) > 0 { out.DownloadedEpisodes = len(out.ExistingEpisodeKeys) out.MissingEpisodes = missingEpisodes(out.ExistingEpisodeKeys, out.TotalEpisodes) for _, episode := range out.MissingEpisodes { out.MissingEpisodeKeys[episodeKey(1, episode)] = struct{}{} } } else if out.LocalMediaCount > 0 { out.DownloadedEpisodes = 1 if out.TotalEpisodes == 0 { out.TotalEpisodes = 1 } } return out } func (s *SubscriptionService) subscriptionBaseSavePath(ctx context.Context, sub *model.Subscription) string { if sub == nil { return "" } base := strings.TrimSpace(sub.SavePath) if base == "" && s != nil && s.repo != nil && s.repo.Setting != nil { base, _ = s.repo.Setting.Get(ctx, "qbittorrent.savepath") } return base } func subscriptionName(sub *model.Subscription) string { if sub == nil { return "" } return sub.Name } func subscriptionFilter(sub *model.Subscription) string { if sub == nil { return "" } return sub.Filter } func mergeLocalAvailability(values ...LocalAvailability) LocalAvailability { out := LocalAvailability{ ExistingEpisodeKeys: map[string]struct{}{}, MissingEpisodeKeys: map[string]struct{}{}, } for _, value := range values { if out.TotalEpisodes == 0 { out.TotalEpisodes = value.TotalEpisodes } out.LocalMediaCount += value.LocalMediaCount out.InLibrary = out.InLibrary || value.InLibrary for key := range value.ExistingEpisodeKeys { out.ExistingEpisodeKeys[key] = struct{}{} } } out.DownloadedEpisodes = len(out.ExistingEpisodeKeys) if out.TotalEpisodes > 0 { out.MissingEpisodes = missingEpisodes(out.ExistingEpisodeKeys, out.TotalEpisodes) for _, episode := range out.MissingEpisodes { out.MissingEpisodeKeys[episodeKey(1, episode)] = struct{}{} } } if out.DownloadedEpisodes == 0 && out.LocalMediaCount > 0 { out.DownloadedEpisodes = out.LocalMediaCount if out.TotalEpisodes == 0 { out.TotalEpisodes = 1 } } return out } func (s *SubscriptionService) downloadPathHasCandidate(ctx context.Context, sub *model.Subscription, title, savePath string) bool { savePath = strings.TrimSpace(savePath) if savePath == "" { savePath = s.subscriptionBaseSavePath(ctx, sub) } query := availabilityQuery(title, subscriptionFilter(sub)) if savePath == "" || query == "" { return false } wantSeason, wantEpisode := ParseEpisode(title) if wantSeason <= 0 { wantSeason = 1 } found := false _ = scanDownloadPath(ctx, savePath, query, func(path string, season, episode int) bool { if wantEpisode <= 0 { found = true return false } if episode <= 0 { return true } if season <= 0 { season = 1 } if episodeKey(season, episode) == episodeKey(wantSeason, wantEpisode) { found = true return false } return true }) return found } func scanDownloadPath(ctx context.Context, root, query string, visit func(path string, season, episode int) bool) error { if strings.TrimSpace(root) == "" { return nil } info, err := os.Stat(root) if err != nil || !info.IsDir() { return nil } normalizedQuery := normalizeAvailabilityComparable(query) if normalizedQuery == "" { return nil } visited := 0 return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { if err != nil { return nil } if ctx.Err() != nil { return ctx.Err() } if d.IsDir() { if path != root && strings.HasPrefix(filepath.Base(path), ".") { return filepath.SkipDir } return nil } if !isDownloadMediaPath(path) { return nil } visited++ if visited > 10000 { return filepath.SkipAll } if !strings.Contains(normalizeAvailabilityComparable(path), normalizedQuery) { return nil } season, episode := ParseEpisode(path) if !visit(path, season, episode) { return filepath.SkipAll } return nil }) } func isDownloadMediaPath(path string) bool { ext := strings.ToLower(filepath.Ext(path)) switch ext { case ".!qb", ".part", ".aria2", ".crdownload": path = strings.TrimSuffix(path, filepath.Ext(path)) ext = strings.ToLower(filepath.Ext(path)) } _, ok := videoExtensions[ext] return ok } func normalizeAvailabilityComparable(value string) string { var b strings.Builder for _, r := range strings.ToLower(value) { if unicode.IsLetter(r) || unicode.IsDigit(r) { b.WriteRune(r) } } return b.String() } func siteSearchKeyword(sub *model.Subscription) string { if sub == nil { return "" } if strings.EqualFold(strings.TrimSpace(sub.SearchMode), "imdb") && strings.TrimSpace(sub.IMDBID) != "" { return strings.TrimSpace(sub.IMDBID) } if u, err := url.Parse(sub.FeedURL); err == nil { if keyword := strings.TrimSpace(u.Query().Get("keyword")); keyword != "" { return keyword } } if keyword := strings.TrimSpace(sub.Filter); keyword != "" { return keyword } return strings.TrimSpace(sub.Name) } func (s *SubscriptionService) fetch(ctx context.Context, feedURL string) (*rssFeed, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", "MediaStationGo/0.1") resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode >= 400 { return nil, fmt.Errorf("rss %s: %d", feedURL, resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } var f rssFeed if err := xml.Unmarshal(body, &f); err != nil { return nil, err } return &f, nil } func compileFilter(pat string) *regexp.Regexp { pat = strings.TrimSpace(pat) if pat == "" { return nil } if r, err := regexp.Compile("(?i)" + pat); err == nil { return r } return nil } func splitNonEmpty(s string) []string { if s == "" { return nil } out := make([]string, 0) for _, p := range strings.Split(s, "\n") { p = strings.TrimSpace(p) if p != "" { out = append(out, p) } } return out }