diff --git a/README.md b/README.md index 4aa640d0..60571f01 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,15 @@ Podsync - is a simple, free service that lets you listen to any YouTube / Vimeo channels, playlists or user videos in podcast format. +## Fork note (wrxco) + +This fork intentionally keeps upstream behavior unchanged except for one feature set: + +- configurable `filename_template` for downloaded media and RSS enclosure paths +- optional one-time filename migration CLI (`--migrate-filenames`, `--migrate-filenames-dry-run`) + +All other behavior is intended to match upstream `mxpv/podsync`. + Podcast applications have a rich functionality for content delivery - automatic download of new episodes, remembering last played position, sync between devices and offline listening. This functionality is not available on YouTube and Vimeo. So the aim of Podsync is to make your life easier and enable you to view/listen to content on @@ -130,6 +139,22 @@ $ make $ ./bin/podsync --config config.toml ``` +### 🗂️ One-time filename migration + +If you changed `filename_template` and want to migrate already-downloaded files: + +``` +$ ./bin/podsync --config config.toml --migrate-filenames +``` + +Preview only (no writes): + +``` +$ ./bin/podsync --config config.toml --migrate-filenames --migrate-filenames-dry-run +``` + +Note: when `storage.type = "s3"`, only dry-run mode is supported currently. Non-dry-run migration requires readable legacy files and should be run against local storage. + ### 🐛 How to debug Use the editor [Visual Studio Code](https://code.visualstudio.com/) and install the official [Go](https://marketplace.visualstudio.com/items?itemName=golang.go) extension. Afterwards you can execute "Run & Debug" ▶︎ "Debug Podsync" to debug the application. The required configuration is already prepared (see `.vscode/launch.json`). diff --git a/cmd/podsync/config.go b/cmd/podsync/config.go index 3023b96c..389bebcd 100644 --- a/cmd/podsync/config.go +++ b/cmd/podsync/config.go @@ -125,6 +125,14 @@ func (c *Config) validate() error { if f.URL == "" { result = multierror.Append(result, errors.Errorf("URL is required for %q", id)) } + if err := feed.ValidateFilenameTemplate(f.FilenameTemplate); err != nil { + result = multierror.Append(result, errors.Wrapf(err, "invalid filename_template for %q", id)) + } + if f.Format == model.FormatCustom { + if err := feed.ValidateCustomExtension(f.CustomFormat.Extension); err != nil { + result = multierror.Append(result, errors.Wrapf(err, "invalid custom_format.extension for %q", id)) + } + } } return result.ErrorOrNil() diff --git a/cmd/podsync/config_test.go b/cmd/podsync/config_test.go index 5568adcb..ad31e8b8 100644 --- a/cmd/podsync/config_test.go +++ b/cmd/podsync/config_test.go @@ -33,6 +33,7 @@ timeout = 15 [feeds.XYZ] url = "https://youtube.com/watch?v=ygIUF678y40" page_size = 48 + filename_template = "{{pub_date}}_{{title}}_{{id}}" update_period = "5h" format = "audio" quality = "low" @@ -76,6 +77,7 @@ timeout = 15 assert.True(t, ok) assert.Equal(t, "https://youtube.com/watch?v=ygIUF678y40", feed.URL) assert.EqualValues(t, 48, feed.PageSize) + assert.EqualValues(t, "{{pub_date}}_{{title}}_{{id}}", feed.FilenameTemplate) assert.EqualValues(t, 5*time.Hour, feed.UpdatePeriod) assert.EqualValues(t, "audio", feed.Format) assert.EqualValues(t, "low", feed.Quality) @@ -105,6 +107,65 @@ timeout = 15 assert.EqualValues(t, 15, config.Downloader.Timeout) } +func TestFilenameTemplateValidation(t *testing.T) { + const file = ` +[server] +data_dir = "/data" + +[feeds] + [feeds.A] + url = "https://youtube.com/watch?v=ygIUF678y40" + filename_template = "{{bad_token}}_{{id}}" +` + path := setup(t, file) + defer os.Remove(path) + + _, err := LoadConfig(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid filename_template") +} + +func TestCustomFormatExtensionValidation(t *testing.T) { + t.Run("rejects invalid extension", func(t *testing.T) { + const file = ` +[server] +data_dir = "/data" + +[feeds] + [feeds.A] + url = "https://youtube.com/watch?v=ygIUF678y40" + format = "custom" + [feeds.A.custom_format] + extension = "../mp3" +` + path := setup(t, file) + defer os.Remove(path) + + _, err := LoadConfig(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid custom_format.extension") + }) + + t.Run("accepts normalized extension", func(t *testing.T) { + const file = ` +[server] +data_dir = "/data" + +[feeds] + [feeds.A] + url = "https://youtube.com/watch?v=ygIUF678y40" + format = "custom" + [feeds.A.custom_format] + extension = ".M4A" +` + path := setup(t, file) + defer os.Remove(path) + + _, err := LoadConfig(path) + assert.NoError(t, err) + }) +} + func TestLoadEmptyKeyList(t *testing.T) { const file = ` [tokens] diff --git a/cmd/podsync/main.go b/cmd/podsync/main.go index 8e318a08..b0165c3f 100644 --- a/cmd/podsync/main.go +++ b/cmd/podsync/main.go @@ -12,6 +12,7 @@ import ( "github.com/jessevdk/go-flags" "github.com/mxpv/podsync/pkg/feed" "github.com/mxpv/podsync/pkg/model" + "github.com/mxpv/podsync/services/migrate" "github.com/mxpv/podsync/services/update" "github.com/mxpv/podsync/services/web" "github.com/robfig/cron/v3" @@ -25,10 +26,12 @@ import ( ) type Opts struct { - ConfigPath string `long:"config" short:"c" default:"config.toml" env:"PODSYNC_CONFIG_PATH"` - Headless bool `long:"headless"` - Debug bool `long:"debug"` - NoBanner bool `long:"no-banner"` + ConfigPath string `long:"config" short:"c" default:"config.toml" env:"PODSYNC_CONFIG_PATH"` + Headless bool `long:"headless"` + MigrateFilenames bool `long:"migrate-filenames" description:"Migrate existing downloaded filenames to current filename_template and exit"` + MigrateFilenamesDryRun bool `long:"migrate-filenames-dry-run" description:"Preview filename migration without writing changes (requires --migrate-filenames)"` + Debug bool `long:"debug"` + NoBanner bool `long:"no-banner"` } const banner = ` @@ -71,6 +74,9 @@ func main() { if opts.Debug { log.SetLevel(log.DebugLevel) } + if opts.MigrateFilenamesDryRun && !opts.MigrateFilenames { + log.Fatal("--migrate-filenames-dry-run requires --migrate-filenames") + } if !opts.NoBanner { log.Info(banner) @@ -107,11 +113,6 @@ func main() { } } - downloader, err := ytdl.New(ctx, cfg.Downloader) - if err != nil { - log.WithError(err).Fatal("youtube-dl error") - } - database, err := db.NewBadger(&cfg.Database) if err != nil { log.WithError(err).Fatal("failed to open database") @@ -135,6 +136,33 @@ func main() { log.WithError(err).Fatal("failed to open storage") } + if opts.MigrateFilenames { + if cfg.Storage.Type == "s3" && !opts.MigrateFilenamesDryRun { + log.Fatal("--migrate-filenames is not supported with storage.type = \"s3\"; use --migrate-filenames-dry-run or migrate with local storage") + } + + migration := migrate.New(cfg.Feeds, database, storage, opts.MigrateFilenamesDryRun) + result, err := migration.Run(ctx) + if err != nil { + log.WithError(err).Fatal("filename migration failed") + } + log.WithFields(log.Fields{ + "feeds": result.Feeds, + "episodes": result.Episodes, + "migrated": result.Migrated, + "already_good": result.AlreadyGood, + "missing_old": result.MissingOld, + "skipped_existing_target": result.SkippedDueToExistingTarget, + "dry_run": opts.MigrateFilenamesDryRun, + }).Info("filename migration completed") + return + } + + downloader, err := ytdl.New(ctx, cfg.Downloader) + if err != nil { + log.WithError(err).Fatal("youtube-dl error") + } + // Run updater thread log.Debug("creating key providers") keys := map[model.Provider]feed.KeyProvider{} diff --git a/config.toml.example b/config.toml.example index 0ee44632..93806a0f 100644 --- a/config.toml.example +++ b/config.toml.example @@ -111,6 +111,11 @@ vimeo = [ # Multiple keys will be rotated. # unexpected behaviour. You should only use this if you know what you are doing, and have read up on youtube-dl's options! youtube_dl_args = ["--write-sub", "--embed-subs", "--sub-lang", "en,en-US,en-GB"] + # Optional filename template for downloaded media and RSS enclosure links (without extension). + # Supported tokens: {{id}}, {{title}}, {{pub_date}}, {{feed_id}} + # Example output: 2026-02-08_My_Video_Title_dQw4w9WgXcQ.mp4 + filename_template = "{{pub_date}}_{{title}}_{{id}}" + # When set to true, podcasts indexers such as iTunes or Google Podcasts will not index this podcast private_feed = true diff --git a/pkg/feed/config.go b/pkg/feed/config.go index 8897a136..65f0ed9c 100644 --- a/pkg/feed/config.go +++ b/pkg/feed/config.go @@ -51,6 +51,9 @@ type Config struct { PrivateFeed bool `toml:"private_feed"` // Playlist sort PlaylistSort model.Sorting `toml:"playlist_sort"` + // FilenameTemplate controls output media filename (without extension) + // Supported tokens: {{id}}, {{title}}, {{pub_date}}, {{feed_id}} + FilenameTemplate string `toml:"filename_template"` } type CustomFormat struct { diff --git a/pkg/feed/xml.go b/pkg/feed/xml.go index 27c3eb24..e27a9d59 100644 --- a/pkg/feed/xml.go +++ b/pkg/feed/xml.go @@ -3,6 +3,7 @@ package feed import ( "context" "fmt" + "regexp" "sort" "strconv" "strings" @@ -17,6 +18,24 @@ import ( // sort.Interface implementation type timeSlice []*model.Episode +const defaultFilenameTemplate = "{{id}}" + +var ( + filenameTemplateTokenPattern = regexp.MustCompile(`{{\s*([a-z_]+)\s*}}`) + filenameTemplatePlaceholderPattern = regexp.MustCompile(`{{\s*([^{}]*)\s*}}`) + filenameTemplateTokenNamePattern = regexp.MustCompile(`^[a-z_]+$`) + validExtensionPattern = regexp.MustCompile(`^[a-z0-9]+$`) + invalidFilenameCharsPattern = regexp.MustCompile(`[^A-Za-z0-9._ -]+`) + multiWhitespacePattern = regexp.MustCompile(`\s+`) +) + +var filenameTemplateAllowedTokens = map[string]struct{}{ + "id": {}, + "title": {}, + "pub_date": {}, + "feed_id": {}, +} + func (p timeSlice) Len() int { return len(p) } @@ -168,19 +187,15 @@ func Build(_ctx context.Context, feed *model.Feed, cfg *Config, hostname string) } func EpisodeName(feedConfig *Config, episode *model.Episode) string { - ext := "mp4" - if feedConfig.Format == model.FormatAudio { - ext = "mp3" - } - if feedConfig.Format == model.FormatCustom { - ext = feedConfig.CustomFormat.Extension - } + return fmt.Sprintf("%s.%s", EpisodeBaseName(feedConfig, episode), episodeExtension(feedConfig)) +} - return fmt.Sprintf("%s.%s", episode.ID, ext) +func LegacyEpisodeName(feedConfig *Config, episode *model.Episode) string { + return fmt.Sprintf("%s.%s", episode.ID, episodeExtension(feedConfig)) } func EnclosureFromExtension(feedConfig *Config) itunes.EnclosureType { - ext := feedConfig.CustomFormat.Extension + ext := normalizeExtension(feedConfig.CustomFormat.Extension) switch ext { case "m4a": @@ -201,3 +216,103 @@ func EnclosureFromExtension(feedConfig *Config) itunes.EnclosureType { return -1 } } + +func EpisodeBaseName(feedConfig *Config, episode *model.Episode) string { + template := strings.TrimSpace(feedConfig.FilenameTemplate) + if template == "" { + template = defaultFilenameTemplate + } + + pubDate := "0000-00-00" + if !episode.PubDate.IsZero() { + pubDate = episode.PubDate.UTC().Format("2006-01-02") + } + + replacements := map[string]string{ + "id": episode.ID, + "title": episode.Title, + "pub_date": pubDate, + "feed_id": feedConfig.ID, + } + + rendered := filenameTemplateTokenPattern.ReplaceAllStringFunc(template, func(token string) string { + match := filenameTemplateTokenPattern.FindStringSubmatch(token) + if len(match) < 2 { + return "" + } + return replacements[match[1]] + }) + + name := sanitizeFilename(rendered) + if name == "" { + name = sanitizeFilename(episode.ID) + } + if name == "" { + name = "episode" + } + return name +} + +func ValidateFilenameTemplate(template string) error { + template = strings.TrimSpace(template) + if template == "" { + return nil + } + + matches := filenameTemplatePlaceholderPattern.FindAllStringSubmatch(template, -1) + for _, match := range matches { + if len(match) < 2 { + continue + } + token := strings.TrimSpace(match[1]) + if !filenameTemplateTokenNamePattern.MatchString(token) { + return errors.Errorf("unknown filename template token %q", token) + } + if _, ok := filenameTemplateAllowedTokens[token]; !ok { + return errors.Errorf("unknown filename template token %q", token) + } + } + + return nil +} + +func ValidateCustomExtension(extension string) error { + normalized := normalizeExtension(extension) + if normalized == "" { + return errors.New("custom format extension cannot be empty") + } + if !validExtensionPattern.MatchString(normalized) { + return errors.Errorf("custom format extension %q must contain only letters and numbers", extension) + } + return nil +} + +func episodeExtension(feedConfig *Config) string { + defaultExt := "mp4" + if feedConfig.Format == model.FormatAudio { + defaultExt = "mp3" + } + + ext := defaultExt + if feedConfig.Format == model.FormatCustom { + ext = normalizeExtension(feedConfig.CustomFormat.Extension) + } + if ext == "" || !validExtensionPattern.MatchString(ext) { + ext = defaultExt + } + return ext +} + +func normalizeExtension(extension string) string { + normalized := strings.TrimSpace(extension) + normalized = strings.TrimPrefix(normalized, ".") + return strings.ToLower(normalized) +} + +func sanitizeFilename(value string) string { + cleaned := strings.TrimSpace(value) + cleaned = invalidFilenameCharsPattern.ReplaceAllString(cleaned, "") + cleaned = multiWhitespacePattern.ReplaceAllString(cleaned, "_") + cleaned = strings.Trim(cleaned, "._- ") + return cleaned +} diff --git a/pkg/feed/xml_test.go b/pkg/feed/xml_test.go index 00a803dc..21b5df9e 100644 --- a/pkg/feed/xml_test.go +++ b/pkg/feed/xml_test.go @@ -3,6 +3,7 @@ package feed import ( "context" "testing" + "time" itunes "github.com/eduncan911/podcast" "github.com/mxpv/podsync/pkg/model" @@ -46,3 +47,75 @@ func TestBuildXML(t *testing.T) { assert.EqualValues(t, out.Items[0].Enclosure.URL, "http://localhost/test/1.mp4") assert.EqualValues(t, out.Items[0].Enclosure.Type, itunes.MP4) } + +func TestBuildXMLWithFilenameTemplate(t *testing.T) { + feed := model.Feed{ + Episodes: []*model.Episode{ + { + ID: "video123", + Status: model.EpisodeDownloaded, + Title: "A title / with chars", + Description: "description", + PubDate: time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC), + }, + }, + } + + cfg := Config{ + ID: "test", + Format: model.FormatVideo, + FilenameTemplate: "{{pub_date}}_{{title}}_{{id}}", + } + + out, err := Build(context.Background(), &feed, &cfg, "http://localhost/") + require.NoError(t, err) + require.Len(t, out.Items, 1) + require.NotNil(t, out.Items[0].Enclosure) + assert.Equal(t, "video123", out.Items[0].GUID) + assert.Equal(t, "http://localhost/test/2025-12-31_A_title_with_chars_video123.mp4", out.Items[0].Enclosure.URL) +} + +func TestEpisodeNameTemplate(t *testing.T) { + cfg := &Config{ + ID: "test", + Format: model.FormatVideo, + FilenameTemplate: "{{pub_date}}_{{title}}_{{id}}", + } + + episode := &model.Episode{ + ID: "abc123", + Title: "My / Video: Title?", + PubDate: time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC), + } + + assert.Equal(t, "2026-02-08_My_Video_Title_abc123.mp4", EpisodeName(cfg, episode)) +} + +func TestValidateFilenameTemplate(t *testing.T) { + assert.NoError(t, ValidateFilenameTemplate("")) + assert.NoError(t, ValidateFilenameTemplate("{{pub_date}}_{{title}}_{{id}}")) + assert.Error(t, ValidateFilenameTemplate("{{unknown}}_{{id}}")) + assert.Error(t, ValidateFilenameTemplate("{{ID}}_{{id}}")) + assert.Error(t, ValidateFilenameTemplate("{{pub-date}}_{{id}}")) +} + +func TestValidateCustomExtension(t *testing.T) { + assert.NoError(t, ValidateCustomExtension(".M4A")) + assert.Error(t, ValidateCustomExtension("")) + assert.Error(t, ValidateCustomExtension("../mp3")) +} + +func TestEpisodeNameWithCustomExtensionNormalization(t *testing.T) { + cfg := &Config{ + ID: "test", + Format: model.FormatCustom, + CustomFormat: CustomFormat{ + Extension: ".M4A", + }, + } + episode := &model.Episode{ID: "abc123", Title: "Title"} + assert.Equal(t, "abc123.m4a", EpisodeName(cfg, episode)) + + cfg.CustomFormat.Extension = "../bad" + assert.Equal(t, "abc123.mp4", EpisodeName(cfg, episode)) +} diff --git a/pkg/ytdl/ytdl.go b/pkg/ytdl/ytdl.go index 70c7c526..789e94d4 100644 --- a/pkg/ytdl/ytdl.go +++ b/pkg/ytdl/ytdl.go @@ -220,8 +220,9 @@ func (dl *YoutubeDl) Download(ctx context.Context, feedConfig *feed.Config, epis } }() + baseName := feed.EpisodeBaseName(feedConfig, episode) // filePath with YoutubeDl template format - filePath := filepath.Join(tmpDir, fmt.Sprintf("%s.%s", episode.ID, "%(ext)s")) + filePath := filepath.Join(tmpDir, fmt.Sprintf("%s.%s", baseName, "%(ext)s")) args := buildArgs(feedConfig, episode, filePath) @@ -242,16 +243,8 @@ func (dl *YoutubeDl) Download(ctx context.Context, feedConfig *feed.Config, epis return nil, errors.New(output) } - ext := "mp4" - if feedConfig.Format == model.FormatAudio { - ext = "mp3" - } - if feedConfig.Format == model.FormatCustom { - ext = feedConfig.CustomFormat.Extension - } - // filePath now with the final extension - filePath = filepath.Join(tmpDir, fmt.Sprintf("%s.%s", episode.ID, ext)) + filePath = filepath.Join(tmpDir, feed.EpisodeName(feedConfig, episode)) f, err := os.Open(filePath) if err != nil { return nil, errors.Wrap(err, "failed to open downloaded file") diff --git a/services/migrate/migrate.go b/services/migrate/migrate.go new file mode 100644 index 00000000..c3be3090 --- /dev/null +++ b/services/migrate/migrate.go @@ -0,0 +1,137 @@ +package migrate + +import ( + "context" + "fmt" + "os" + "sort" + + "github.com/hashicorp/go-multierror" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + + "github.com/mxpv/podsync/pkg/db" + "github.com/mxpv/podsync/pkg/feed" + "github.com/mxpv/podsync/pkg/fs" + "github.com/mxpv/podsync/pkg/model" +) + +type Service struct { + feeds map[string]*feed.Config + db db.Storage + fs fs.Storage + dryRun bool +} + +type Result struct { + Feeds int + Episodes int + Migrated int + AlreadyGood int + MissingOld int + SkippedDueToExistingTarget int +} + +func New(feeds map[string]*feed.Config, db db.Storage, storage fs.Storage, dryRun bool) *Service { + return &Service{feeds: feeds, db: db, fs: storage, dryRun: dryRun} +} + +func (s *Service) Run(ctx context.Context) (*Result, error) { + result := &Result{Feeds: len(s.feeds)} + var allErr *multierror.Error + + feedIDs := make([]string, 0, len(s.feeds)) + for feedID := range s.feeds { + feedIDs = append(feedIDs, feedID) + } + sort.Strings(feedIDs) + + for _, feedID := range feedIDs { + cfg := s.feeds[feedID] + logger := log.WithField("feed_id", feedID) + logger.Infof("starting filename migration (dry_run=%t)", s.dryRun) + + err := s.db.WalkEpisodes(ctx, feedID, func(episode *model.Episode) error { + if episode.Status != model.EpisodeDownloaded { + return nil + } + + result.Episodes++ + newName := feed.EpisodeName(cfg, episode) + newPath := fmt.Sprintf("%s/%s", feedID, newName) + legacyName := feed.LegacyEpisodeName(cfg, episode) + legacyPath := fmt.Sprintf("%s/%s", feedID, legacyName) + + newSize, newErr := s.fs.Size(ctx, newPath) + if newErr == nil { + result.AlreadyGood++ + return s.updateEpisode(feedID, episode.ID, newSize) + } + if !os.IsNotExist(newErr) { + return errors.Wrapf(newErr, "failed to stat target file %q", newPath) + } + + if _, legacyErr := s.fs.Size(ctx, legacyPath); legacyErr != nil { + if os.IsNotExist(legacyErr) { + result.MissingOld++ + return nil + } + return errors.Wrapf(legacyErr, "failed to stat legacy file %q", legacyPath) + } + + if s.dryRun { + result.Migrated++ + return nil + } + + if _, existingErr := s.fs.Size(ctx, newPath); existingErr == nil { + result.SkippedDueToExistingTarget++ + return nil + } else if !os.IsNotExist(existingErr) { + return errors.Wrapf(existingErr, "failed to stat target file %q during migration", newPath) + } + + legacyFile, err := s.fs.Open(legacyPath) + if err != nil { + return errors.Wrapf(err, "failed to open legacy file %q", legacyPath) + } + + size, err := s.fs.Create(ctx, newPath, legacyFile) + closeErr := legacyFile.Close() + if err != nil { + return errors.Wrapf(err, "failed to create migrated file %q", newPath) + } + if closeErr != nil { + return errors.Wrapf(closeErr, "failed to close legacy file %q", legacyPath) + } + + if err := s.fs.Delete(ctx, legacyPath); err != nil && !os.IsNotExist(err) { + return errors.Wrapf(err, "failed to delete legacy file %q", legacyPath) + } + + if err := s.updateEpisode(feedID, episode.ID, size); err != nil { + return err + } + + result.Migrated++ + return nil + }) + if err != nil { + allErr = multierror.Append(allErr, errors.Wrapf(err, "feed %s migration failed", feedID)) + } + } + + return result, allErr.ErrorOrNil() +} + +func (s *Service) updateEpisode(feedID string, episodeID string, size int64) error { + if s.dryRun { + return nil + } + + return s.db.UpdateEpisode(feedID, episodeID, func(episode *model.Episode) error { + episode.Size = size + episode.Status = model.EpisodeDownloaded + return nil + }) +} diff --git a/services/migrate/migrate_test.go b/services/migrate/migrate_test.go new file mode 100644 index 00000000..a7df9704 --- /dev/null +++ b/services/migrate/migrate_test.go @@ -0,0 +1,215 @@ +package migrate + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/mxpv/podsync/pkg/db" + "github.com/mxpv/podsync/pkg/feed" + "github.com/mxpv/podsync/pkg/fs" + "github.com/mxpv/podsync/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type testDB struct { + episodes map[string]map[string]*model.Episode +} + +func newTestDB() *testDB { + return &testDB{episodes: map[string]map[string]*model.Episode{}} +} + +func (t *testDB) Close() error { return nil } +func (t *testDB) Version() (int, error) { return 1, nil } +func (t *testDB) AddFeed(_ context.Context, _ string, _ *model.Feed) error { + return errors.New("not implemented") +} +func (t *testDB) GetFeed(_ context.Context, _ string) (*model.Feed, error) { + return nil, errors.New("not implemented") +} +func (t *testDB) WalkFeeds(_ context.Context, _ func(feed *model.Feed) error) error { return nil } +func (t *testDB) DeleteFeed(_ context.Context, _ string) error { return errors.New("not implemented") } +func (t *testDB) DeleteEpisode(_ string, _ string) error { return errors.New("not implemented") } + +func (t *testDB) GetEpisode(_ context.Context, feedID string, episodeID string) (*model.Episode, error) { + if f, ok := t.episodes[feedID]; ok { + if ep, ok := f[episodeID]; ok { + return ep, nil + } + } + return nil, os.ErrNotExist +} + +func (t *testDB) UpdateEpisode(feedID string, episodeID string, cb func(episode *model.Episode) error) error { + ep, err := t.GetEpisode(context.Background(), feedID, episodeID) + if err != nil { + return err + } + return cb(ep) +} + +func (t *testDB) WalkEpisodes(_ context.Context, feedID string, cb func(episode *model.Episode) error) error { + for _, ep := range t.episodes[feedID] { + if err := cb(ep); err != nil { + return err + } + } + return nil +} + +var _ db.Storage = (*testDB)(nil) + +type flakySizeStorage struct { + fs.Storage + targetPath string + targetChecks int +} + +func (s *flakySizeStorage) Size(ctx context.Context, name string) (int64, error) { + if name == s.targetPath { + s.targetChecks++ + if s.targetChecks == 1 { + return 0, os.ErrNotExist + } + if s.targetChecks == 2 { + return 0, errors.New("transient stat failure") + } + } + return s.Storage.Size(ctx, name) +} + +func TestRunMigratesLegacyFilename(t *testing.T) { + ctx := context.Background() + tmpDir := t.TempDir() + + storage, err := fs.NewLocal(tmpDir, false) + require.NoError(t, err) + + tdb := newTestDB() + feedID := "A" + episode := &model.Episode{ + ID: "abc123", + Title: "Title / Needs Cleanup", + PubDate: time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC), + Status: model.EpisodeDownloaded, + } + tdb.episodes[feedID] = map[string]*model.Episode{episode.ID: episode} + + cfg := &feed.Config{ + ID: feedID, + Format: model.FormatVideo, + FilenameTemplate: "{{pub_date}}_{{title}}_{{id}}", + } + + legacyName := feed.LegacyEpisodeName(cfg, episode) + legacyPath := filepath.Join(feedID, legacyName) + _, err = storage.Create(ctx, legacyPath, strings.NewReader("video-bytes")) + require.NoError(t, err) + + svc := New(map[string]*feed.Config{feedID: cfg}, tdb, storage, false) + result, err := svc.Run(ctx) + require.NoError(t, err) + require.NotNil(t, result) + + newPath := filepath.Join(feedID, feed.EpisodeName(cfg, episode)) + _, err = storage.Size(ctx, newPath) + require.NoError(t, err) + + _, err = storage.Size(ctx, legacyPath) + require.Error(t, err) + assert.True(t, os.IsNotExist(err)) + + assert.Equal(t, int64(len("video-bytes")), episode.Size) + assert.Equal(t, model.EpisodeDownloaded, episode.Status) + assert.Equal(t, 1, result.Migrated) +} + +func TestRunDryRunDoesNotWrite(t *testing.T) { + ctx := context.Background() + tmpDir := t.TempDir() + + storage, err := fs.NewLocal(tmpDir, false) + require.NoError(t, err) + + tdb := newTestDB() + feedID := "B" + episode := &model.Episode{ + ID: "xyz999", + Title: "Another Title", + PubDate: time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC), + Status: model.EpisodeDownloaded, + } + tdb.episodes[feedID] = map[string]*model.Episode{episode.ID: episode} + + cfg := &feed.Config{ + ID: feedID, + Format: model.FormatVideo, + FilenameTemplate: "{{pub_date}}_{{title}}_{{id}}", + } + + legacyPath := filepath.Join(feedID, feed.LegacyEpisodeName(cfg, episode)) + _, err = storage.Create(ctx, legacyPath, strings.NewReader("video-bytes")) + require.NoError(t, err) + + svc := New(map[string]*feed.Config{feedID: cfg}, tdb, storage, true) + result, err := svc.Run(ctx) + require.NoError(t, err) + require.NotNil(t, result) + + newPath := filepath.Join(feedID, feed.EpisodeName(cfg, episode)) + _, err = storage.Size(ctx, newPath) + require.Error(t, err) + assert.True(t, os.IsNotExist(err)) + + _, err = storage.Size(ctx, legacyPath) + require.NoError(t, err) + + assert.Equal(t, int64(0), episode.Size) + assert.Equal(t, 1, result.Migrated) +} + +func TestRunFailsOnUnexpectedSecondTargetStatError(t *testing.T) { + ctx := context.Background() + tmpDir := t.TempDir() + + baseStorage, err := fs.NewLocal(tmpDir, false) + require.NoError(t, err) + + tdb := newTestDB() + feedID := "C" + episode := &model.Episode{ + ID: "retry111", + Title: "Retry Test", + PubDate: time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC), + Status: model.EpisodeDownloaded, + } + tdb.episodes[feedID] = map[string]*model.Episode{episode.ID: episode} + + cfg := &feed.Config{ + ID: feedID, + Format: model.FormatVideo, + FilenameTemplate: "{{pub_date}}_{{title}}_{{id}}", + } + + legacyPath := filepath.Join(feedID, feed.LegacyEpisodeName(cfg, episode)) + _, err = baseStorage.Create(ctx, legacyPath, strings.NewReader("video-bytes")) + require.NoError(t, err) + + newPath := filepath.Join(feedID, feed.EpisodeName(cfg, episode)) + storage := &flakySizeStorage{Storage: baseStorage, targetPath: newPath} + + svc := New(map[string]*feed.Config{feedID: cfg}, tdb, storage, false) + _, err = svc.Run(ctx) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to stat target file") + assert.Contains(t, err.Error(), "during migration") + + _, err = baseStorage.Size(ctx, legacyPath) + require.NoError(t, err) +}