// Podcast fetcher (plan podcast-integration.md §7): fetch → parse → // upsert-without-delete. The fetcher is the SOLE writer of feed/episode // metadata (D9) — user-facing podcast writes are limited to // podcast_subscriptions rows and the reader_settings order override. // // RLS identity (invariant 12): fetches run detached from any request, // so every governed statement runs under the FIRST-ADMIN identity // (scanner fallback precedent). With the D20 admin escape that identity // sees and updates every feed/episode row. NEVER a bypass flag, never // the bootstrap superuser. // // Upsert semantics: episodes are created/updated METADATA-ONLY, keyed // on (source_feed_id, episode_guid); rows for items that vanish from // the XML are NEVER deleted (history accumulates — D14 search covers // gone episodes). Download state is owned by the download worker; the // upsert cannot clobber it (guarded in the query itself). package services import ( "context" "errors" "fmt" "log" "net/url" "os" "path/filepath" "regexp" "strconv" "strings" "sync" "time" "bookhoard/internal/database" "bookhoard/internal/rls" "github.com/jackc/pgx/v5/pgtype" ) // PodcastError is a status-carrying service error: handlers map it to // the HTTP response directly (services stay echo-free). type PodcastError struct { Status int Message string } func (e *PodcastError) Error() string { return e.Message } func errNotFound(msg string) error { return &PodcastError{Status: 404, Message: msg} } func errForbidden(msg string) error { return &PodcastError{Status: 403, Message: msg} } func errBadRequest(msg string) error { return &PodcastError{Status: 400, Message: msg} } // PodcastService carries the fetcher/worker dependencies. One instance // per server (handlers + poller share it so the per-feed locks dedupe). type PodcastService struct { db *database.Queries adminMu sync.Mutex adminID pgtype.UUID adminOnce bool // Per-feed mutexes: a poll and a download for the same feed must // not race (e.g. both writing the episode row). Keyed by feed UUID. feedLocksMu sync.Mutex feedLocks map[string]*sync.Mutex } func NewPodcastService(db *database.Queries) *PodcastService { return &PodcastService{ db: db, feedLocks: make(map[string]*sync.Mutex), } } // feedLock returns the per-feed mutex (creates on first use). func (s *PodcastService) feedLock(feedID string) *sync.Mutex { s.feedLocksMu.Lock() defer s.feedLocksMu.Unlock() if m, ok := s.feedLocks[feedID]; ok { return m } m := &sync.Mutex{} s.feedLocks[feedID] = m return m } // adminIdentity resolves the acting identity for detached podcast work: // the first admin, resolved once per service lifetime. Errors surface // to the caller (no admin → no fetch — same contract as the scanner). func (s *PodcastService) adminIdentity() (pgtype.UUID, error) { s.adminMu.Lock() defer s.adminMu.Unlock() if !s.adminOnce && !s.adminID.Valid { id, err := s.db.GetFirstAdmin(context.Background()) if err != nil { return pgtype.UUID{}, fmt.Errorf("podcasts: no admin identity available for detached work: %w", err) } s.adminID = id s.adminOnce = true } if !s.adminID.Valid { return pgtype.UUID{}, fmt.Errorf("podcasts: no admin identity available for detached work") } return s.adminID, nil } // adminCtx wraps ctx with the first-admin identity (invariant 12). func (s *PodcastService) adminCtx(ctx context.Context) (context.Context, error) { id, err := s.adminIdentity() if err != nil { return nil, err } return rls.WithUser(ctx, id), nil } // AddFeedResult carries what the add-feed flow did (the handler turns // it into the response / serial feed hint). type AddFeedResult struct { Feed *database.PodcastFeeds // Created is true when this call created the feed row; false when // an existing row was reused (URL dedupe or guid migration match). Created bool // Subscribed is true when the requesting user's subscription was // created by this call (false = already subscribed). Subscribed bool // EpisodeCount is the number of episode rows the fetch upserted. EpisodeCount int // FetchError, when set, means the feed row exists but the fetch // failed — the subscription stands, the error surfaces to the user. FetchError string } // AddFeed implements POST /api/podcasts/feeds (plan §8): fetch+parse the // user-supplied URL, create-or-reuse the feed row, upsert metadata-only // episodes, subscribe the creator. Authorization on explicit arguments // (D20 scoping model): the target library must be visible to the actor // and, for non-admins, allow member feeds. func (s *PodcastService) AddFeed(ctx context.Context, userID pgtype.UUID, isAdmin bool, libraryID pgtype.UUID, rawFeedURL string) (*AddFeedResult, error) { // ---- explicit-argument authorization (the ONLY Go-level gate) ---- lib, err := s.db.GetLibrary(ctx, libraryID) if err != nil { // Invisible-or-nonexistent: 404 semantics (existence leak). return nil, errNotFound("library not found") } if !isAdmin { if lib.TypeName != LibraryTypePodcasts { return nil, errNotFound("library not found") } if !lib.AllowMemberFeeds { return nil, errForbidden("this library does not allow members to add feeds") } } feedURL := strings.TrimSpace(rawFeedURL) if feedURL == "" { return nil, errBadRequest("feed URL is required") } // The fetch/upsert runs under the first-admin identity (detached- // equivalent); the request context never carries special powers into // feed writes. actx, err := s.adminCtx(ctx) if err != nil { return nil, err } res := &AddFeedResult{} // ---- fetch + parse (SSRF-guarded; plan §7) ---- fetched, err := guardFetch(ctx, feedURL, "", "", maxFeedSizeBytes) if err != nil { return nil, fmt.Errorf("could not fetch feed: %w", classifyFetchError(err)) } parsed, err := ParsePodcastFeed(fetched.Body) if err != nil { return nil, fmt.Errorf("could not parse feed: %w", err) } // Follow itunes:new-feed-url once at add time so the row is born // pointing at the publisher's current home (migration semantics). finalURL := fetched.FinalURL if neu := strings.TrimSpace(parsed.Channel.ITunesNewFeedURL); neu != "" && neu != finalURL { if retried, err := guardFetch(ctx, neu, "", "", maxFeedSizeBytes); err == nil { if reparsed, err := ParsePodcastFeed(retried.Body); err == nil { parsed = reparsed finalURL = retried.FinalURL } } } title := parsed.Channel.Title if title == "" { title = "Untitled feed" } // ---- create-or-reuse (UNIQUE(library_id, feed_url) + guid dedupe) ---- feed, err := s.db.CreatePodcastFeed(actx, database.CreatePodcastFeedParams{ LibraryID: libraryID, FeedUrl: finalURL, Title: truncate(title, 500), CreatedByAdminID: pgtype.UUID{}, // fetcher owns feed metadata; creator attribution is not user metadata }) if err != nil { return nil, fmt.Errorf("could not create feed: %w", err) } res.Feed = &feed res.Created = true // Guid migration dedupe: a row with the same canonical podcast_guid // under a different URL reuses THAT row (do not fork the feed). if guid := strings.TrimSpace(parsed.Channel.PodcastGUID); guid != "" { if feed.PodcastGuid.String == "" || feed.PodcastGuid.String != guid { if existing, err := s.db.GetPodcastFeedByGuid(actx, database.GetPodcastFeedByGuidParams{ LibraryID: feed.LibraryID, PodcastGuid: pgtypeText(guid), }); err == nil && existing.ID != feed.ID { // Attach the subscriber to the canonical row; drop the // freshly created stub (nothing subscribed to it yet). _, _ = s.db.DeletePodcastFeed(actx, feed.ID) feed = existing res.Feed = &existing // Re-point the canonical row at the fetched URL if it moved. if feedUrlForComparison(existing.FeedUrl) != feedUrlForComparison(finalURL) { _ = s.db.UpdatePodcastFeedRedirect(actx, database.UpdatePodcastFeedRedirectParams{ ID: existing.ID, FeedUrl: finalURL, }) } } } } if _, err := s.db.SubscribePodcastFeed(actx, database.SubscribePodcastFeedParams{ UserID: userID, FeedID: feed.ID, }); err == nil { res.Subscribed = true } // ---- metadata + episodes ---- if err := s.applyFetch(actx, &feed, parsed, fetched); err != nil { // Feed exists + subscribed, but the metadata pass failed: not // fatal for the request; the poller reconciles later. res.FetchError = err.Error() return res, nil } updated, err := s.db.GetPodcastFeed(actx, feed.ID) if err == nil { res.Feed = &updated } return res, nil } // RefreshFeed implements POST /api/podcasts/feeds/:id/refresh: a manual // poll. The per-feed lock serializes against poller ticks and downloads. func (s *PodcastService) RefreshFeed(ctx context.Context, feedID pgtype.UUID) error { actx, err := s.adminCtx(ctx) if err != nil { return err } feed, err := s.db.GetPodcastFeed(actx, feedID) if err != nil { return errNotFound("feed not found") } return s.PollFeed(actx, &feed) } // PollFeed fetches + reconciles ONE feed (caller supplies the acting // identity in ctx). Safe to call concurrently per feed — the per-feed // lock serializes poll/download. func (s *PodcastService) PollFeed(ctx context.Context, feed *database.PodcastFeeds) error { lock := s.feedLock(feed.ID.String()) lock.Lock() defer lock.Unlock() // Complete feeds back off: they are fetched only on explicit // refresh (the poller skips them entirely — this path is manual). fetched, err := guardFetch(ctx, feed.FeedUrl, feed.Etag.String, feed.LastModified.String, maxFeedSizeBytes) if err != nil { if feed.LastFetchedAt.Valid { // A previously-good feed with a transient failure: keep the // old state; the poller retries next tick. return fmt.Errorf("fetch %s: %w", feed.Title, classifyFetchError(err)) } return fmt.Errorf("fetch %s: %w", feed.Title, classifyFetchError(err)) } if fetched.NotModified { _, _ = s.db.UpdatePodcastFeedFetch(ctx, database.UpdatePodcastFeedFetchParams{ ID: feed.ID, Etag: pgtypeText(feed.Etag.String), LastModified: pgtypeText(feed.LastModified.String), LastBuildDate: feed.LastBuildDate, }) return nil } parsed, err := ParsePodcastFeed(fetched.Body) if err != nil { return fmt.Errorf("parse %s: %w", feed.Title, err) } // itunes:new-feed-url: the publisher MOVED the feed. Rewrite the // stored URL (row, subscriptions and episodes stay put) and refetch // the new location once so this poll applies its content. if neu := strings.TrimSpace(parsed.Channel.ITunesNewFeedURL); neu != "" && neu != feed.FeedUrl { _ = s.db.UpdatePodcastFeedRedirect(ctx, database.UpdatePodcastFeedRedirectParams{ID: feed.ID, FeedUrl: neu}) if retried, err := guardFetch(ctx, neu, "", "", maxFeedSizeBytes); err == nil { if reparsed, err := ParsePodcastFeed(retried.Body); err == nil { parsed = reparsed fetched = retried } } } else if fetched.FinalURL != "" && feedUrlForComparison(fetched.FinalURL) != feedUrlForComparison(feed.FeedUrl) { // A permanent HTTP redirect chain moved the feed; persist the // final URL so conditional validators stop bouncing. _ = s.db.UpdatePodcastFeedRedirect(ctx, database.UpdatePodcastFeedRedirectParams{ID: feed.ID, FeedUrl: fetched.FinalURL}) } if err := s.applyFetch(ctx, feed, parsed, fetched); err != nil { return err } return nil } // applyFetch writes channel metadata, upserts episodes metadata-only, // touches validators, and downloads artwork. Write-after-success rule // (invariant 7): failures here never clear existing rows. func (s *PodcastService) applyFetch(ctx context.Context, feed *database.PodcastFeeds, parsed *PodcastFeedXML, fetched *fetchResult) error { // --- channel metadata (fetcher-owned) --- title := parsed.Channel.Title if title == "" { title = feed.Title // never blank out a title on a broken doc } desc := stripHTML(parsed.Channel.Description) host := parsed.Channel.ITunesAuthor publisher := parsed.Channel.ITunesOwner.Name tagSet := map[string]bool{} if cat := strings.TrimSpace(parsed.Channel.ITunesCategory.Text); cat != "" { tagSet[cat] = true } if sub := strings.TrimSpace(parsed.Channel.ITunesCategory.Subcategory.Text); sub != "" { tagSet[sub] = true } tags := sortedStringSet(tagSet) feedType := strings.TrimSpace(parsed.Channel.ITunesType) if feedType != "serial" && feedType != "episodic" { feedType = "" // unknown → column stays NULL } medium := strings.TrimSpace(parsed.Channel.PodcastMedium) if _, err := s.db.UpdatePodcastFeedMetadata(ctx, database.UpdatePodcastFeedMetadataParams{ ID: feed.ID, Title: truncate(title, 500), Description: pgtypeText(desc), Host: pgtypeText(truncate(host, 500)), Publisher: pgtypeText(truncate(publisher, 500)), OwnerName: pgtypeText(truncate(parsed.Channel.ITunesOwner.Name, 500)), OwnerEmail: pgtypeText(truncate(parsed.Channel.ITunesOwner.Email, 500)), Language: pgtypeText(truncate(parsed.Channel.Language, 50)), Tags: tags, Explicit: parseExplicit(parsed.Channel.ITunesExplicit), FeedType: pgtypeText(feedType), Medium: pgtypeText(truncate(medium, 20)), IsComplete: parseExplicit(parsed.Channel.ITunesComplete), PodcastGuid: pgtypeText(strings.TrimSpace(parsed.Channel.PodcastGUID)), }); err != nil { return fmt.Errorf("feed metadata update: %w", err) } // itunes:complete flips the poller's back-off flag (complete feeds // poll rarely); explicit refresh still works. // --- episodes (metadata-only upsert, positions from THIS parse) --- root, err := s.downloadRootAbs(ctx, feed) if err != nil { return fmt.Errorf("download root: %w", err) } feedSlug := feedSlug(title, feed.ID) count := 0 for i := range parsed.Channel.Items { if err := s.upsertEpisode(ctx, feed, root, feedSlug, &parsed.Channel.Items[i], i+1); err != nil { // One bad item must not sink the batch. log.Printf("[PODCAST] episode upsert failed for feed %s item %d: %v", feed.ID, i+1, err) continue } count++ } // --- validators + fetch timestamp --- var lastBuildDate pgtype.Timestamptz if t, ok := parseFeedTime(parsed.Channel.LastBuildDate); ok { lastBuildDate = pgtypeTimestamptz(t) } if _, err := s.db.UpdatePodcastFeedFetch(ctx, database.UpdatePodcastFeedFetchParams{ ID: feed.ID, Etag: pgtypeText(fetched.ETag), LastModified: pgtypeText(fetched.LastModified), LastBuildDate: lastBuildDate, }); err != nil { return fmt.Errorf("fetch touch: %w", err) } // --- artwork (best-effort; feed renders fine without it) --- s.refreshArtwork(ctx, feed, root, parsed) return nil } // upsertEpisode maps one XML item onto a media_items row (plan §5 field // inventory). Positions are 1-based, renumbered per fetch; file_path is // the PLANNED final layout — the upsert only applies it to rows that // are not yet downloaded (query-side guard). func (s *PodcastService) upsertEpisode(ctx context.Context, feed *database.PodcastFeeds, downloadRoot, feedSlug string, item *PodcastItemXML, position int) error { title := item.EpisodeTitle() if title == "" { title = fmt.Sprintf("Episode %d", position) } guid := strings.TrimSpace(item.GUID) if guid == "" { // GUID-less feeds exist; fall back to a stable identity from the // enclosure URL (the next-best immutable per-episode key). if item.Enclosure.URL != "" { guid = "enc:" + item.Enclosure.URL } else { return fmt.Errorf("item %q has neither guid nor enclosure; skipping", title) } } ext := enclosureExtension(item.Enclosure.URL, item.Enclosure.Type) relPath := episodeRelPath(feedSlug, position, title, ext) var published pgtype.Date if t, ok := parseFeedTime(item.PubDate); ok { published = pgtypeDate(t) } authors := []string{} if a := item.EpisodeAuthor(); a != "" { authors = append(authors, truncate(a, 255)) } hosts, guests := item.HostsAndGuests() _, err := s.db.UpsertPodcastEpisode(ctx, database.UpsertPodcastEpisodeParams{ LibraryID: feed.LibraryID, Title: truncate(title, 255), Subtitle: pgtypeText(truncate(item.Subtitle, 500)), Description: pgtypeText(stripHTML(item.EpisodeDescription())), Authors: authors, AuthorsSearch: lowerSearchSlice(authors), FilePath: relPath, FileSize: pgtypeInt8(item.Enclosure.Length), MimeType: pgtypeText(truncate(item.Enclosure.Type, 100)), CoverImagePath: pgtypeText(artworkRelPath(feedSlug, artworkExtFromURL(item.ITunesImage.Href))), Explicit: item.EpisodeExplicit(), DatePublished: published, Language: pgtypeText(truncate(feed.Language.String, 10)), Tags: feed.Tags, Genre: pgtypeText(strings.Join(feed.Tags, ", ")), SourceFeedID: feed.ID, EpisodeGuid: pgtypeText(truncate(guid, 500)), FeedPosition: pgtypeInt4(int32(position)), SeasonNumber: pgtypeInt4FromStr(item.ITunesSeason), EpisodeNumber: pgtypeInt4FromStr(item.ITunesEpisode), EnclosureUrl: pgtypeText(item.Enclosure.URL), DeclaredDurationSeconds: pgtypeInt4(int32(parseITunesDuration(item.ITunesDuration))), TranscriptUrl: pgtypeText(item.Transcript.URL), ChaptersUrl: pgtypeText(item.Chapters.URL), Hosts: truncateSlice(hosts, 255), Guests: truncateSlice(guests, 255), }) _ = downloadRoot // layout is relative; abs root resolves at download time return err } // refreshArtwork downloads the channel image once per changed URL. // Artwork is feed-level: episodes point at the same relative path. func (s *PodcastService) refreshArtwork(ctx context.Context, feed *database.PodcastFeeds, downloadRoot string, parsed *PodcastFeedXML) { imgURL := strings.TrimSpace(parsed.Channel.ITunesImage.Href) if imgURL == "" { return } target := filepath.Join(downloadRoot, artworkRelPath(feedSlug(feed.Title, feed.ID), artworkExtFromURL(imgURL))) if feed.ArtworkPath.Valid { if existing := filepath.Join(downloadRoot, feed.ArtworkPath.String); existing == target { if _, err := os.Stat(existing); err == nil { return // unchanged } } } if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return } res, err := guardFetch(ctx, imgURL, "", "", maxArtworkSizeBytes) if err != nil || len(res.Body) == 0 { log.Printf("[PODCAST] artwork fetch failed for feed %s: %v", feed.ID, err) return } if err := os.WriteFile(target, res.Body, 0o644); err != nil { log.Printf("[PODCAST] artwork write failed for feed %s: %v", feed.ID, err) return } rel, err := filepath.Rel(downloadRoot, target) if err != nil { return } _ = s.db.UpdatePodcastFeedArtwork(ctx, database.UpdatePodcastFeedArtworkParams{ ID: feed.ID, ArtworkPath: pgtypeText(rel), }) } // DeleteFeedFiles removes a feed's episode files + artwork from the // download root (admin feed-removal path; ?files=false skips it). // Best-effort per file — the row cascade proceeds regardless. func (s *PodcastService) DeleteFeedFiles(ctx context.Context, feed *database.PodcastFeeds, episodes []database.MediaItems) { root, err := s.downloadRootAbs(ctx, feed) if err != nil { return } for _, ep := range episodes { if ep.FilePath != "" { _ = os.Remove(filepath.Join(root, ep.FilePath)) // Embedded-cover sibling (extractEmbeddedAudioCover convention). base := filepath.Join(root, strings.TrimSuffix(ep.FilePath, filepath.Ext(ep.FilePath))) _ = os.Remove(base + ".cover.jpg") } } if feed.ArtworkPath.Valid { _ = os.Remove(filepath.Join(root, feed.ArtworkPath.String)) } // The feed directory itself: remove when empty (other feeds never // share a slug — the feed-id suffix guarantees that). _ = os.Remove(filepath.Join(root, feedSlug(feed.Title, feed.ID))) } // downloadRootAbs resolves the podcasts library's single download root // (purpose='podcast') as an absolute filesystem path. func (s *PodcastService) downloadRootAbs(ctx context.Context, feed *database.PodcastFeeds) (string, error) { folders, err := s.db.GetPodcastLibraryFolder(ctx, feed.LibraryID) if err != nil || len(folders) == 0 { return "", fmt.Errorf("library %s has no download root registered", feed.LibraryID) } return folders[0].FolderPath, nil } // episodeRelPath is the pinned episode layout (plan §6): // /-. — relative to the // download root. getFullFilePath/ServeFile/Range streaming resolve it // unchanged. func episodeRelPath(feedSlug string, position int, title, ext string) string { return fmt.Sprintf("%s/%04d-%s%s", feedSlug, position, slugify(title, 80), ext) } // artworkRelPath is the feed-level artwork location (feed-level, so // every episode row can point at the same file). func artworkRelPath(feedSlug, ext string) string { return fmt.Sprintf("%s/artwork%s", feedSlug, ext) } // feedSlug derives the stable per-feed directory name: slugged title + // short feed-id suffix so two identically-titled feeds never collide. func feedSlug(title string, feedID pgtype.UUID) string { base := slugify(title, 60) suffix := "feed" if feedID.Valid { s := feedID.String() if len(s) >= 8 { suffix = s[:8] } } if base == "" { base = "feed" } return base + "-" + suffix } var slugStrip = regexp.MustCompile(`[^a-z0-9]+`) // slugify lowercases, strips non-alphanumerics to dashes, and clamps // the length (file-name-safe; unicode letters drop — file layout is // an internal key, never display text). func slugify(s string, maxLen int) string { s = strings.ToLower(strings.TrimSpace(s)) s = slugStrip.ReplaceAllString(s, "-") s = strings.Trim(s, "-") if len(s) > maxLen { s = strings.TrimRight(s[:maxLen], "-") } return s } // enclosureExtension picks a file extension from the URL, then the // MIME type, then a safe default. func enclosureExtension(rawURL, mimeType string) string { if u, err := urlParse(rawURL); err == nil { if ext := strings.ToLower(filepath.Ext(u.Path)); ext == ".mp3" || ext == ".m4a" || ext == ".m4b" || ext == ".flac" || ext == ".ogg" || ext == ".oga" || ext == ".opus" || ext == ".wav" || ext == ".aac" { return ext } } switch { case strings.Contains(mimeType, "mp4") || strings.Contains(mimeType, "m4a"): return ".m4a" case strings.Contains(mimeType, "mpeg") || strings.Contains(mimeType, "mp3"): return ".mp3" case strings.Contains(mimeType, "flac"): return ".flac" case strings.Contains(mimeType, "ogg"): return ".ogg" case strings.Contains(mimeType, "wav"): return ".wav" } return ".mp3" } // artworkExtFromURL keeps the source extension for artwork (jpg/png/webp). func artworkExtFromURL(rawURL string) string { if u, err := urlParse(rawURL); err == nil { switch strings.ToLower(filepath.Ext(u.Path)) { case ".png": return ".png" case ".webp": return ".webp" case ".gif": return ".gif" } } return ".jpg" } // stripHTML removes tags and collapses whitespace — feed descriptions // are HTML; storage wants text (the TUI-ish surfaces render it plain). func stripHTML(s string) string { if s == "" { return "" } var b strings.Builder inTag := false for _, r := range s { switch { case r == '<': inTag = true case r == '>': inTag = false case !inTag: b.WriteRune(r) } } out := b.String() // Collapse runs of whitespace. out = strings.Join(strings.Fields(out), " ") return strings.TrimSpace(out) } // truncate clamps a string to max runes (VARCHAR safety at the DB edge). func truncate(s string, max int) string { s = strings.TrimSpace(s) runes := []rune(s) if len(runes) <= max { return s } return string(runes[:max]) } // truncateSlice clamps each element and drops empties. func truncateSlice(in []string, max int) []string { out := make([]string, 0, len(in)) for _, s := range in { if t := truncate(s, max); t != "" { out = append(out, t) } } return out } // lowerSearchSlice mirrors authors into authors_search (scanner convention). func lowerSearchSlice(in []string) []string { out := make([]string, 0, len(in)) for _, s := range in { out = append(out, strings.ToLower(s)) } return out } // sortedStringSet → deterministic TEXT[] writes. func sortedStringSet(set map[string]bool) []string { out := make([]string, 0, len(set)) for k := range set { if strings.TrimSpace(k) == "" { continue } out = append(out, strings.TrimSpace(k)) } // tiny insertion sort — tag sets are 1–5 entries for i := 1; i < len(out); i++ { for j := i; j > 0 && out[j] < out[j-1]; j-- { out[j], out[j-1] = out[j-1], out[j] } } return out } // classifyFetchError maps guard/HTTP errors to user-facing text (the // add-feed dialog and the download_error column both render it). func classifyFetchError(err error) error { if errors.Is(err, ErrBlockedPrivateAddress) { return fmt.Errorf("blocked: private/loopback addresses are not fetchable") } return err } // urlParse local helper so podcast files don't each import net/url. func urlParse(raw string) (*url.URL, error) { return url.Parse(raw) } // ---- pgtype helpers shared by the podcast files ---- func pgtypeText(s string) pgtype.Text { if strings.TrimSpace(s) == "" { return pgtype.Text{} } return pgtype.Text{String: s, Valid: true} } func pgtypeInt4(v int32) pgtype.Int4 { if v == 0 { return pgtype.Int4{} } return pgtype.Int4{Int32: v, Valid: true} } func pgtypeInt4FromStr(s string) pgtype.Int4 { n, err := strconv.Atoi(strings.TrimSpace(s)) if err != nil || n <= 0 { return pgtype.Int4{} } return pgtype.Int4{Int32: int32(n), Valid: true} } func pgtypeInt8(v int64) pgtype.Int8 { if v <= 0 { return pgtype.Int8{} } return pgtype.Int8{Int64: v, Valid: true} } func pgtypeTimestamptz(t time.Time) pgtype.Timestamptz { return pgtype.Timestamptz{Time: t, Valid: true} } func pgtypeDate(t time.Time) pgtype.Date { return pgtype.Date{Time: t.UTC().Truncate(24 * time.Hour), Valid: true} } // feedUrlForComparison normalizes trailing slashes for the redirect check. func feedUrlForComparison(u string) string { return strings.TrimRight(strings.TrimSpace(u), "/") }