Turns a ytdl-sub output directory into a valid, servable podcast RSS feed.
  • Python 98.1%
  • Just 1.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Marvin8 06a177afdc
All checks were successful
/ pr-review (push) Has been skipped
/ gitleaks (push) Successful in 17s
/ checks (push) Successful in 1m38s
/ publish (push) Has been skipped
/ pr-review (release) Has been skipped
/ gitleaks (release) Successful in 15s
/ deploy-docs (push) Successful in 1m7s
/ checks (release) Successful in 1m39s
/ deploy-docs (release) Has been skipped
/ publish (release) Successful in 27s
🔖 bump: version 0.4.0 → 0.4.1
2026-08-30 09:27:14 +10:00
.forgejo/workflows 🚚 migrate: Codeberg → forge.marvin8.zone 2026-08-07 07:12:09 +10:00
docs ✏️ clarify chapters sidecar naming and location in docs 2026-08-20 19:34:42 +10:00
src/sub2pod 🐛 harden chapters parsing against malformed info.json shapes 2026-08-20 18:50:19 +10:00
tests 🐛 harden chapters parsing against malformed info.json shapes 2026-08-20 18:50:19 +10:00
.editorconfig 🔧 chore: align .editorconfig with mature project standard (indent_size, py max_line_length, json/rst/html sections) 2026-07-20 09:09:17 +10:00
.gitignore 🙈 added site folder to gitignore 2026-07-10 09:35:27 +10:00
.gitleaks.toml 🔧 chore: scaffolding alignment — gitleaks.toml title, ruff target-version py312, .editorconfig, uv_build>=0.11.0 2026-07-19 18:17:18 +10:00
.pre-commit-config.yaml ⬆️ bump deps 2026-08-30 09:26:50 +10:00
.python-version 🐛 parse <description> and <upload_date> from ytdl-sub episode NFOs 2026-06-18 12:38:53 +10:00
AGENTS.md ✏️ clarify chapters sidecar naming and location in docs 2026-08-20 19:34:42 +10:00
AI-DECLARATION.md 📝 fix AI-DECLARATION.md — copilot level, DeepSeek tool, deployment none 2026-07-20 06:45:55 +10:00
CHANGELOG.md 📝 docs: update changelog for 0.4.1 2026-08-30 09:27:13 +10:00
CLAUDE.md 📝 docs: fix AGENTS.md heading to match filename; make CLAUDE.md a symlink 2026-07-19 18:17:41 +10:00
docs_hooks.py 📝 set up MkDocs docs deployment to grebedoc 2026-07-10 08:49:36 +10:00
justfile 🔧 add config to automatically update version number in 2026-08-20 19:58:00 +10:00
LICENSE.md 🔧 chore: add ruff.toml (TID252, expanded rules), LICENSE.md, README badges; bump ty→0.0.59 2026-07-20 07:24:29 +10:00
mkdocs.yml 🚚 migrate: Codeberg → forge.marvin8.zone 2026-08-07 07:12:09 +10:00
noxfile.py ⬆️ bump deps 2026-08-30 09:26:50 +10:00
pylock.toml ⬆️ bump deps 2026-08-30 09:26:50 +10:00
pyproject.toml 🔖 bump: version 0.4.0 → 0.4.1 2026-08-30 09:27:14 +10:00
README.md ✏️ clarify chapters sidecar naming and location in docs 2026-08-20 19:34:42 +10:00
Release-Notes.md 🔖 bump: version 0.4.0 → 0.4.1 2026-08-30 09:27:14 +10:00
Release-Notes.md.template 🔍 change tags for release notes 2026-07-10 09:27:13 +10:00
ruff.toml 🔧 chore: add ruff.toml (TID252, expanded rules), LICENSE.md, README badges; bump ty→0.0.59 2026-07-20 07:24:29 +10:00
uv.lock 🔖 bump: version 0.4.0 → 0.4.1 2026-08-30 09:27:14 +10:00

sub2pod

Licence: AGPL-3.0-or-later Python 3.12+ Docs CI Downloads gitleaks pysentry ty complexipy Codestyle: Ruff AI-DECLARATION: copilot

Convert ytdl-sub output directories into standards-compliant podcast RSS feeds. Point it at a directory full of downloaded audio, and it writes feed.xml into each channel subdirectory — ready to serve or submit to podcast directories.

What it does

  • Reads Kodi tvshow.nfo and per-episode .nfo sidecars produced by ytdl-sub
  • Discovers date-prefixed audio files (YYYY-MM-DD - <title>.<ext>) in each channel directory
  • Generates RSS 2.0 + iTunes namespace feed.xml per channel
  • Emits correct MIME types for .opus, .mp3, and .m4a enclosures
  • Produces an opml.xml index at the root level for one-click feed import
  • Falls back to filename parsing when .nfo sidecars are absent
  • Emits Podcast 2.0 <podcast:chapters> for episodes with chapter markers (via the .info.json sidecar)

What it does not do

  • No download or subscription management (that's ytdl-sub's job)
  • No web server — feed files are static XML; serve them however you like
  • No podcast client or player

Installation

From PyPI

pip install sub2pod

From source

git clone https://forge.marvin8.zone/marvin8/sub2pod.git
cd sub2pod
uv sync
uv run sub2pod --help

Usage

sub2pod /path/to/ytdl-sub/output https://podcast.example.com

This walks the parent directory looking for subdirectories containing tvshow.nfo and generates:

Output Location Description
feed.xml Each channel subdirectory RSS 2.0 + iTunes podcast feed
opml.xml Parent directory root OPML index of all feeds

Options

--dry-run    Print paths without writing any files
--verbose    Log per-episode details during generation

Example

# After ytdl-sub downloads new episodes:
sub2pod /home/NAS/ytdl-sub/mark/podcast https://podcasts.example.com

# Preview without writing:
sub2pod /home/NAS/ytdl-sub/mark/podcast https://podcasts.example.com --dry-run

# See what's being generated:
sub2pod /home/NAS/ytdl-sub/mark/podcast https://podcasts.example.com --verbose

How it works

  1. Channel discovery — Walks the parent directory for subdirectories containing tvshow.nfo
  2. Metadata parsing — Reads channel title and genre from tvshow.nfo; reads per-episode title, description, date, runtime, and source URL from .nfo sidecars
  3. Episode discovery — Finds all date-prefixed audio files, pairs them with their .nfo sidecars, sorts newest-first
  4. Feed generation — Builds RSS 2.0 XML with iTunes podcast namespace elements (<itunes:image>, <itunes:duration>, <itunes:category>), writes feed.xml
  5. OPML index — Writes opml.xml at the root listing every channel feed

Input format

Each channel directory is expected to contain:

Links with Friends/
├── tvshow.nfo              ← Channel metadata (title, genre)
├── poster.jpg              ← Podcast artwork → <itunes:image>
├── 2026-05-19 - Episode Title.opus   ← Audio file
├── 2026-05-19 - Episode Title.nfo    ← Episode metadata sidecar
├── 2026-05-19 - Episode Title.info.json  ← yt-dlp metadata sidecar (chapters source, optional)
└── 2026-05-19 - Episode Title-thumb.jpg  ← Episode thumbnail

Example feed

A trimmed feed.xml for a channel with one episode:

<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
  <channel>
    <title>Links with Friends</title>
    <link>https://podcasts.example.com</link>
    <description>Links with Friends</description>
    <language>en</language>
    <itunes:image href="https://podcasts.example.com/Links%20with%20Friends/poster.jpg"/>
    <item>
      <title>Episode Title</title>
      <enclosure url="https://podcasts.example.com/Links%20with%20Friends/2026-05-19%20-%20Episode%20Title.opus" type="audio/ogg; codecs=opus" length="12345678"/>
      <guid isPermaLink="false">https://podcasts.example.com/Links%20with%20Friends/2026-05-19%20-%20Episode%20Title.opus</guid>
      <pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate>
    </item>
  </channel>
</rss>

Troubleshooting

  • No feed.xml is generated — sub2pod only processes subdirectories that contain a tvshow.nfo; ensure each channel directory has one.
  • Malformed base_url — sub2pod requires an http:///https:// URL with a host; invalid values fail with an actionable error.
  • Episodes won't play in Apple Podcasts.opus is unsupported by Apple; re-encode to .mp3/.m4a (see the integration guide).
  • Episode metadata missing — without a .nfo sidecar, sub2pod falls back to parsing the filename; malformed sidecars are skipped with a warning.

Documentation

  • ytdl-sub Integration Guide — Configuring ytdl-sub to produce sub2pod-compatible output: NFO tags, episode thumbnails, trigger strategies, and Opus MIME type guidance.

Licence

Copyright (C) 2026 Marvin8

This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.