Skip to content

Commit 6d5c2e5

Browse files
committed
Re-include older episodes when max_age is expanded
Increasing a feed's max_age now pulls in older episodes that were previously out of range, instead of silently ignoring them. This required decoupling discovery depth from page_size and making episode re-inclusion work off the database rather than the API window. - Discovery depth is driven by max_age: a normal update still does a shallow scan (page_size most-recent episodes), but when max_age is expanded beyond what was previously scanned, the YouTube builder performs a one-time deep scan that pages back to the max_age cutoff. A per-feed ScannedThrough high-water mark gates this so it costs nothing in steady state, and it is bounded by a 20-page backstop. Brand-new feeds stay shallow. - Previously cleaned (soft-deleted) episodes that match the filters again are re-queued for download off the database records directly, independent of the page_size API window. Only episodes that would survive the keep_last cleanup are re-queued, to avoid a re-download/re-clean loop. - Cleanup no longer clears an episode's title/description when soft-deleting it, so a re-included episode keeps its metadata. For episodes cleaned by older versions (which wiped metadata), the title/description are recovered from the current feed query. - The feed build skips a downloaded episode with an empty title instead of aborting the whole feed, so one malformed item can no longer take down the entire feed. - A startup warning is logged when clean.keep_last exceeds page_size and no max_age is configured. Currently implemented for YouTube.
1 parent bcdbc51 commit 6d5c2e5

11 files changed

Lines changed: 849 additions & 21 deletions

File tree

CLAUDE.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,12 @@ Understanding how episodes flow through the system:
3838

3939
### Discovery Phase
4040
- Episodes are discovered during feed updates via platform APIs (YouTube Data API v3, Vimeo API, SoundCloud, Twitch)
41-
- `updateFeed()` in `services/update/updater.go:99-154` queries the platform API
41+
- `updateFeed()` in `services/update/updater.go:99-169` queries the platform API
4242
- New episodes are stored in BadgerDB with status `EpisodeNew`
4343
- Episodes matching feed URL are identified by provider-specific parsing in `pkg/builder/`
44+
- **Discovery depth (`max_age`-driven deep scan)**: normal updates do a shallow scan (the `page_size` most-recent episodes). To discover **never-before-seen** older episodes after `max_age` is increased, the updater triggers a one-time deep scan that pages the channel back to the `max_age` cutoff instead of stopping at `page_size`. The decision lives in `discoveryWindow()` (`services/update/updater.go`): each feed record stores `ScannedThrough`, a high-water mark of the oldest publish date discovery has paged to; a deep scan runs only when the `max_age` cutoff is older than `ScannedThrough` (i.e. `max_age` was actually expanded), so it costs nothing in steady state. The cutoff is passed to the builder via `feed.Config.DiscoverSince` (runtime-only field); the YouTube builder honors it in `queryItemsSince()`, bounded by `maxDeepScanPages` (20) as a quota backstop. `nextScannedThrough()` advances the mark to the cutoff only once every matching in-range episode is downloaded/cleaned/errored, so multi-cycle catch-up (bounded by `page_size` downloads per cycle) isn't truncated by episode pruning. Brand-new feeds stay shallow (no surprise back-catalog download). Currently implemented for YouTube only.
45+
- Previously cleaned episodes (`EpisodeCleaned`) that match the current filters again (e.g. after `max_age` is increased) are re-queued: status is reset to `EpisodeNew` (`resurrectEpisodes()` in `services/update/updater.go`). This works **off the database records directly, so it is independent of the `page_size` API window** — a cleaned episode no longer needs to be returned by the current feed query to be re-included. Cleaned records retain their metadata (see cleanup phase), so filters are evaluated against the stored title/description. Episodes cleaned by older podsync versions had their title/description wiped; for those the metadata is recovered from the current feed query (the deep-discovery scan brings in-range episodes back into the result with fresh metadata). An episode that still has no title — not in the DB and not in the feed query — is left cleaned until a later cycle surfaces it. When `keep_last` is configured, only episodes that would survive the next cleanup are re-queued (ranked by `PubDate` via `keepLastSurvivors()`), to avoid a re-download/re-clean loop.
46+
- Feed XML build (`feed.Build()` in `pkg/feed/xml.go`) skips any downloaded episode with an empty title (logging a warning) instead of aborting the entire feed build for one malformed item. (Original podsync aborted the whole feed on any empty title/description.)
4447

4548
### Download Phase
4649
- `fetchEpisodes()` iterates episodes with status `EpisodeNew` or `EpisodeError`
@@ -51,12 +54,13 @@ Understanding how episodes flow through the system:
5154
- On failure: status set to `EpisodeError`, retry attempted next cycle
5255

5356
### Cleanup Phase (Important!)
54-
- `cleanup()` in `updater.go:373-441` runs AFTER each successful update cycle
57+
- `cleanup()` in `updater.go:456-524` runs AFTER each successful update cycle
5558
- **Only triggered if `clean.keep_last` is configured** (global or per-feed)
5659
- Keeps most recent N episodes by PubDate (descending order)
57-
- Deleted episodes have status changed to `EpisodeCleaned` and title/description cleared
60+
- Deleted episodes have status changed to `EpisodeCleaned`; their title/description are **retained** so the record can be resurrected later (e.g. after `max_age` is increased) without losing metadata. Cleaned episodes are excluded from the feed by status, so keeping metadata has no effect on output.
5861
- **Files are deleted from storage but database records are retained**
5962
- This is a soft-delete: episodes remain in the database forever
63+
- **`keep_last` vs `page_size`**: a shallow update only sees `page_size` episodes, so on its own it can't fill a larger retention window. This is fine when `max_age` is set (the deep-discovery scan covers the look-back — see Discovery Phase) or when episodes were already discovered earlier. A startup warning is logged only when `clean.keep_last > page_size` **and** no `max_age` is configured (`applyDefaults()` in `cmd/podsync/config.go`).
6064

6165
### Episode Removal from Database
6266
- Episodes are removed from the database only if they:

cmd/podsync/config.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ func (c *Config) applyDefaults(configPath string) {
167167
c.Database.Dir = filepath.Join(filepath.Dir(configPath), "db")
168168
}
169169

170-
for _, _feed := range c.Feeds {
170+
for id, _feed := range c.Feeds {
171171
if _feed.UpdatePeriod == 0 {
172172
_feed.UpdatePeriod = model.DefaultUpdatePeriod
173173
}
@@ -196,6 +196,18 @@ func (c *Config) applyDefaults(configPath string) {
196196
if _feed.Clean == nil && c.Cleanup != nil {
197197
_feed.Clean = c.Cleanup
198198
}
199+
200+
// keep_last greater than page_size means a normal (shallow) update only ever sees
201+
// page_size episodes, so the retention window can never be filled. When max_age is set
202+
// the updater performs a one-time deep discovery to cover it, so the mismatch is fine;
203+
// only warn when there is no max_age to drive that deeper look-back.
204+
if _feed.Clean != nil && _feed.Clean.KeepLast > _feed.PageSize && _feed.Filters.MaxAge <= 0 {
205+
log.Warnf(
206+
"feed %q: clean.keep_last (%d) is greater than page_size (%d) and no max_age is set; "+
207+
"set page_size >= keep_last (or configure max_age) so the retention window can be filled",
208+
id, _feed.Clean.KeepLast, _feed.PageSize,
209+
)
210+
}
199211
}
200212
}
201213

cmd/podsync/config_test.go

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
package main
22

33
import (
4+
"bytes"
45
"os"
56
"testing"
67
"time"
78

8-
"github.com/mxpv/podsync/services/web"
9+
log "github.com/sirupsen/logrus"
910
"github.com/stretchr/testify/assert"
1011
"github.com/stretchr/testify/require"
1112

1213
"github.com/mxpv/podsync/pkg/model"
14+
"github.com/mxpv/podsync/services/web"
1315
)
1416

1517
func TestLoadConfig(t *testing.T) {
@@ -215,6 +217,81 @@ data_dir = "/data"
215217
assert.EqualValues(t, feed.Format, "video")
216218
}
217219

220+
func TestKeepLastPageSizeWarning(t *testing.T) {
221+
t.Run("warns when keep_last exceeds page_size and no max_age", func(t *testing.T) {
222+
var buf bytes.Buffer
223+
log.SetOutput(&buf)
224+
t.Cleanup(func() { log.SetOutput(os.Stderr) })
225+
226+
const file = `
227+
[server]
228+
data_dir = "/data"
229+
230+
[feeds]
231+
[feeds.A]
232+
url = "https://youtube.com/watch?v=ygIUF678y40"
233+
page_size = 5
234+
clean = { keep_last = 20 }
235+
`
236+
path := setup(t, file)
237+
defer os.Remove(path)
238+
239+
config, err := LoadConfig(path)
240+
require.NoError(t, err)
241+
require.NotNil(t, config)
242+
assert.Contains(t, buf.String(), "clean.keep_last")
243+
})
244+
245+
t.Run("does not warn when max_age is configured", func(t *testing.T) {
246+
var buf bytes.Buffer
247+
log.SetOutput(&buf)
248+
t.Cleanup(func() { log.SetOutput(os.Stderr) })
249+
250+
const file = `
251+
[server]
252+
data_dir = "/data"
253+
254+
[feeds]
255+
[feeds.A]
256+
url = "https://youtube.com/watch?v=ygIUF678y40"
257+
page_size = 5
258+
clean = { keep_last = 20 }
259+
filters = { max_age = 30 }
260+
`
261+
path := setup(t, file)
262+
defer os.Remove(path)
263+
264+
config, err := LoadConfig(path)
265+
require.NoError(t, err)
266+
require.NotNil(t, config)
267+
assert.NotContains(t, buf.String(), "clean.keep_last")
268+
})
269+
270+
t.Run("does not warn when keep_last is within page_size", func(t *testing.T) {
271+
var buf bytes.Buffer
272+
log.SetOutput(&buf)
273+
t.Cleanup(func() { log.SetOutput(os.Stderr) })
274+
275+
const file = `
276+
[server]
277+
data_dir = "/data"
278+
279+
[feeds]
280+
[feeds.A]
281+
url = "https://youtube.com/watch?v=ygIUF678y40"
282+
page_size = 50
283+
clean = { keep_last = 20 }
284+
`
285+
path := setup(t, file)
286+
defer os.Remove(path)
287+
288+
config, err := LoadConfig(path)
289+
require.NoError(t, err)
290+
require.NotNil(t, config)
291+
assert.NotContains(t, buf.String(), "clean.keep_last")
292+
})
293+
}
294+
218295
func TestHttpServerListenAddress(t *testing.T) {
219296
const file = `
220297
[server]

pkg/builder/youtube.go

Lines changed: 70 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -130,9 +130,10 @@ func (yt *YouTubeBuilder) listPlaylists(ctx context.Context, id, channelID strin
130130

131131
// Cost: 3 units (call: 1, snippet: 2)
132132
// See https://developers.google.com/youtube/v3/docs/playlistItems/list#part
133-
func (yt *YouTubeBuilder) listPlaylistItems(ctx context.Context, feed *model.Feed, pageToken string) ([]*youtube.PlaylistItem, string, error) {
133+
func (yt *YouTubeBuilder) listPlaylistItems(ctx context.Context, feed *model.Feed, pageToken string, fullPage bool) ([]*youtube.PlaylistItem, string, error) {
134134
count := maxYoutubeResults
135-
if count > feed.PageSize {
135+
// During a deep (max_age-driven) scan we page through full result sets regardless of PageSize.
136+
if !fullPage && count > feed.PageSize {
136137
// If we need less than 50
137138
count = feed.PageSize
138139
}
@@ -412,15 +413,19 @@ func (yt *YouTubeBuilder) queryVideoDescriptions(ctx context.Context, playlist m
412413
// Cost:
413414
// ASC mode = (3 units + 5 units) * X pages = 8 units per page
414415
// DESC mode = 3 units * (number of pages in the entire playlist) + 5 units
415-
func (yt *YouTubeBuilder) queryItems(ctx context.Context, feed *model.Feed) error {
416+
func (yt *YouTubeBuilder) queryItems(ctx context.Context, feed *model.Feed, since time.Time) error {
417+
if !since.IsZero() {
418+
return yt.queryItemsSince(ctx, feed, since)
419+
}
420+
416421
var (
417422
token string
418423
count int
419424
allSnippets []*youtube.PlaylistItemSnippet
420425
)
421426

422427
for {
423-
items, pageToken, err := yt.listPlaylistItems(ctx, feed, token)
428+
items, pageToken, err := yt.listPlaylistItems(ctx, feed, token, false)
424429
if err != nil {
425430
return err
426431
}
@@ -450,17 +455,69 @@ func (yt *YouTubeBuilder) queryItems(ctx context.Context, feed *model.Feed) erro
450455
}
451456
}
452457

458+
return yt.queryDescriptionsForSnippets(ctx, allSnippets, feed)
459+
}
460+
461+
// maxDeepScanPages bounds a max_age-driven discovery pass as a quota backstop.
462+
const maxDeepScanPages = 20
463+
464+
// queryItemsSince pages back through the channel until episodes are older than the cutoff
465+
// (a max_age-driven deep scan), instead of stopping at PageSize. It is bounded by
466+
// maxDeepScanPages to guard against a misconfigured max_age draining API quota.
467+
func (yt *YouTubeBuilder) queryItemsSince(ctx context.Context, feed *model.Feed, since time.Time) error {
468+
var (
469+
token string
470+
pages int
471+
allSnippets []*youtube.PlaylistItemSnippet
472+
)
473+
474+
for {
475+
items, pageToken, err := yt.listPlaylistItems(ctx, feed, token, true)
476+
if err != nil {
477+
return err
478+
}
479+
480+
token = pageToken
481+
pages++
482+
483+
if len(items) == 0 {
484+
break
485+
}
486+
487+
reachedCutoff := false
488+
for _, item := range items {
489+
// Keep only episodes within the requested window; the page that crosses the
490+
// cutoff is where we stop.
491+
if date, err := yt.parseDate(item.Snippet.PublishedAt); err == nil {
492+
if date.Before(since) {
493+
reachedCutoff = true
494+
continue
495+
}
496+
}
497+
allSnippets = append(allSnippets, item.Snippet)
498+
}
499+
500+
if reachedCutoff || token == "" {
501+
break
502+
}
503+
504+
if pages >= maxDeepScanPages {
505+
log.Warnf("deep scan for feed %q reached the %d-page cap before covering max_age; older episodes were not discovered (consider lowering max_age)", feed.ItemID, maxDeepScanPages)
506+
break
507+
}
508+
}
509+
510+
return yt.queryDescriptionsForSnippets(ctx, allSnippets, feed)
511+
}
512+
513+
func (yt *YouTubeBuilder) queryDescriptionsForSnippets(ctx context.Context, allSnippets []*youtube.PlaylistItemSnippet, feed *model.Feed) error {
453514
snippets := map[string]*youtube.PlaylistItemSnippet{}
454515
for _, snippet := range allSnippets {
455516
snippets[snippet.ResourceId.VideoId] = snippet
456517
}
457518

458519
// Query video descriptions from the list of ids
459-
if err := yt.queryVideoDescriptions(ctx, snippets, feed); err != nil {
460-
return err
461-
}
462-
463-
return nil
520+
return yt.queryVideoDescriptions(ctx, snippets, feed)
464521
}
465522

466523
func (yt *YouTubeBuilder) Build(ctx context.Context, cfg *feed.Config) (*model.Feed, error) {
@@ -491,13 +548,14 @@ func (yt *YouTubeBuilder) Build(ctx context.Context, cfg *feed.Config) (*model.F
491548
return nil, err
492549
}
493550

494-
if err := yt.queryItems(ctx, _feed); err != nil {
551+
if err := yt.queryItems(ctx, _feed, cfg.DiscoverSince); err != nil {
495552
return nil, err
496553
}
497554

498555
// YT API client gets 50 episodes per query.
499-
// Round up to page size.
500-
if len(_feed.Episodes) > _feed.PageSize {
556+
// Round up to page size, unless a deep (max_age-driven) scan is requested, in which case
557+
// we keep every episode within the window so the caller can catch up the back-catalog.
558+
if cfg.DiscoverSince.IsZero() && len(_feed.Episodes) > _feed.PageSize {
501559
_feed.Episodes = _feed.Episodes[:_feed.PageSize]
502560
}
503561

pkg/feed/config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ type Config struct {
6262
// FilenameTemplate controls output media filename (without extension)
6363
// Supported tokens: {{id}}, {{title}}, {{pub_date}}, {{feed_id}}
6464
FilenameTemplate string `toml:"filename_template"`
65+
// DiscoverSince is a runtime-only hint (not configurable) set by the updater to request a
66+
// deep discovery pass: the builder pages back through the channel until episodes are older
67+
// than this date instead of stopping at PageSize. Zero means the default shallow discovery.
68+
DiscoverSince time.Time `toml:"-"`
6569
}
6670

6771
type CustomFormat struct {

pkg/feed/xml.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
itunes "github.com/eduncan911/podcast"
1313
"github.com/pkg/errors"
14+
log "github.com/sirupsen/logrus"
1415

1516
"github.com/mxpv/podsync/pkg/model"
1617
)
@@ -167,6 +168,14 @@ func Build(_ctx context.Context, feed *model.Feed, cfg *Config, hostname string)
167168

168169
item.AddEnclosure(downloadURL, enclosureType, episode.Size)
169170

171+
// p.AddItem requires a non-empty title. An episode without one cannot be
172+
// represented in the feed (e.g. metadata was never populated), so skip it
173+
// instead of failing the whole feed build for every other episode.
174+
if item.Title == "" {
175+
log.Warnf("skipping episode %q in feed %q: missing title", episode.ID, cfg.ID)
176+
continue
177+
}
178+
170179
// p.AddItem requires description to be not empty, use workaround
171180
if item.Description == "" {
172181
item.Description = " "

pkg/feed/xml_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,32 @@ func TestBuildXML(t *testing.T) {
4848
assert.EqualValues(t, out.Items[0].Enclosure.Type, itunes.MP4)
4949
}
5050

51+
func TestBuildXMLSkipsEpisodeWithoutTitle(t *testing.T) {
52+
feed := model.Feed{
53+
Episodes: []*model.Episode{
54+
{
55+
ID: "no-title",
56+
Status: model.EpisodeDownloaded,
57+
Description: "description",
58+
},
59+
{
60+
ID: "ok",
61+
Status: model.EpisodeDownloaded,
62+
Title: "title",
63+
Description: "description",
64+
},
65+
},
66+
}
67+
68+
cfg := Config{ID: "test"}
69+
70+
out, err := Build(context.Background(), &feed, &cfg, "http://localhost/")
71+
// A single episode with a missing title must not fail the whole feed build
72+
require.NoError(t, err)
73+
require.Len(t, out.Items, 1)
74+
assert.Equal(t, "ok", out.Items[0].GUID)
75+
}
76+
5177
func TestBuildXMLWithFilenameTemplate(t *testing.T) {
5278
feed := model.Feed{
5379
Episodes: []*model.Episode{

pkg/model/feed.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ type Feed struct {
6565
UpdatedAt time.Time `json:"updated_at"`
6666
PlaylistSort Sorting `json:"playlist_sort"`
6767
PrivateFeed bool `json:"private_feed"`
68+
// ScannedThrough is the oldest publish date discovery has paged back to. It is a high-water
69+
// mark used to decide when max_age has been expanded beyond what was ever scanned, so a deep
70+
// (max_age-driven) discovery pass is only triggered once per expansion instead of every cycle.
71+
ScannedThrough time.Time `json:"scanned_through"`
6872
}
6973

7074
type EpisodeStatus string

services/update/matcher.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ func matchFilters(episode *model.Episode, filters *feed.Filters) bool {
4242
return false
4343
}
4444

45+
return matchDurationAndAge(episode, filters, logger)
46+
}
47+
48+
// matchDurationAndAge evaluates the filters that depend only on metadata that is always
49+
// present on an episode record (duration and publish date). It is used as a cheap pre-filter
50+
// for cleaned episodes, whose title/description may have to be fetched separately.
51+
func matchDurationAndAge(episode *model.Episode, filters *feed.Filters, logger log.FieldLogger) bool {
4552
if filters.MaxDuration > 0 && episode.Duration > filters.MaxDuration {
4653
logger.WithField("filter", "max_duration").Infof("skipping due to duration filter (%ds)", episode.Duration)
4754
return false

0 commit comments

Comments
 (0)