// 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" "regexp" "strings" "time" "go.uber.org/zap" "github.com/ShukeBta/MediaStationGo/internal/config" "github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/repository" ) // 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") } 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 } // 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 _, err := s.downloads.AddDownload(ctx, sub.UserID, download, savePath); 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{}{} } var lastEnqueueErr error for _, item := range results { download := strings.TrimSpace(item.DownloadURL) if download == "" { download = strings.TrimSpace(item.TorrentURL) } if download == "" { continue } guid := download if _, ok := seenSet[guid]; ok { continue } if s.downloads != nil && s.downloads.TorrentExistsByName(ctx, item.Title) { seen = append(seen, guid) 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 s.hub.Publish("subscription", map[string]any{ "id": sub.ID, "name": sub.Name, "queued": 0, "keyword": keyword, "resource": item.Title, "existing": true, }) return 0, nil } realURL := s.site.ResolveDownloadURL(ctx, download) mediaType, mediaCategory := s.classifySubscriptionItem(ctx, sub, item.Title, item.Category) savePath := s.resolveSubscriptionSavePath(ctx, sub, mediaType, mediaCategory) if _, err := s.downloads.AddDownload(ctx, sub.UserID, realURL, savePath); 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 } seen = append(seen, guid) 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 s.hub.Publish("subscription", map[string]any{ "id": sub.ID, "name": sub.Name, "queued": 1, "keyword": keyword, "resource": item.Title, }) return 1, nil } now := time.Now() _ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error if lastEnqueueErr != nil { return 0, fmt.Errorf("找到 PT 资源但加入下载器失败: %w", lastEnqueueErr) } return 0, nil } func siteSearchKeyword(sub *model.Subscription) string { if sub == nil { return "" } 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 }