sub2pod 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sub2pod-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.3
2
+ Name: sub2pod
3
+ Version: 0.2.0
4
+ Summary: Convert ytdl-sub output directories into podcast RSS feeds.
5
+ Author: Marvin8
6
+ Author-email: Marvin8 <marvin8@tuta.io>
7
+ License: AGPL-3.0-or-later
8
+ Requires-Dist: cyclopts~=4.16.0
9
+ Requires-Dist: lxml~=6.1.1
10
+ Requires-Python: >=3.12
11
+ Project-URL: Issues, https://codeberg.org/marvin8/sub2pod/issues
12
+ Project-URL: Source, https://codeberg.org/marvin8/sub2pod
13
+ Project-URL: Changelog, https://codeberg.org/marvin8/sub2pod/raw/branch/main/CHANGELOG.md
14
+ Description-Content-Type: text/markdown
15
+
16
+ # sub2pod
17
+
18
+ [![Licence: AGPL-3.0-or-later](https://img.shields.io/badge/licence-AGPL--3.0--or--later-blue)](LICENCE)
19
+ [![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue)](https://www.python.org/)
20
+ [![Woodpecker CI](https://ci.codeberg.org/api/badges/marvin8/sub2pod/status.svg)](https://ci.codeberg.org/marvin8/sub2pod)
21
+ [![Codestyle: Ruff](https://img.shields.io/badge/codestyle-ruff-green)](https://docs.astral.sh/ruff/)
22
+
23
+ Convert [ytdl-sub](https://github.com/jmbannon/ytdl-sub) output directories into
24
+ standards-compliant podcast RSS feeds. Point it at a directory full of downloaded
25
+ audio, and it writes `feed.xml` into each channel subdirectory — ready to serve or
26
+ submit to podcast directories.
27
+
28
+ ## What it does
29
+
30
+ - Reads Kodi `tvshow.nfo` and per-episode `.nfo` sidecars produced by ytdl-sub
31
+ - Discovers date-prefixed audio files (`YYYY-MM-DD - <title>.<ext>`) in each channel directory
32
+ - Generates RSS 2.0 + iTunes namespace `feed.xml` per channel
33
+ - Emits correct MIME types for `.opus`, `.mp3`, and `.m4a` enclosures
34
+ - Produces an `opml.xml` index at the root level for one-click feed import
35
+ - Falls back to filename parsing when `.nfo` sidecars are absent
36
+
37
+ ## What it does not do
38
+
39
+ - No download or subscription management (that's ytdl-sub's job)
40
+ - No web server — feed files are static XML; serve them however you like
41
+ - No podcast client or player
42
+
43
+ ## Installation
44
+
45
+ ### From PyPI
46
+
47
+ ```bash
48
+ pip install sub2pod
49
+ ```
50
+
51
+ ### From source
52
+
53
+ ```bash
54
+ git clone https://codeberg.org/marvin8/sub2pod.git
55
+ cd sub2pod
56
+ uv sync
57
+ uv run sub2pod --help
58
+ ```
59
+
60
+ ## Usage
61
+
62
+ ```bash
63
+ sub2pod /path/to/ytdl-sub/output https://podcast.example.com
64
+ ```
65
+
66
+ This walks the parent directory looking for subdirectories containing `tvshow.nfo`
67
+ and generates:
68
+
69
+ | Output | Location | Description |
70
+ |--------|----------|-------------|
71
+ | `feed.xml` | Each channel subdirectory | RSS 2.0 + iTunes podcast feed |
72
+ | `opml.xml` | Parent directory root | OPML index of all feeds |
73
+
74
+ ### Options
75
+
76
+ ```
77
+ --dry-run Print paths without writing any files
78
+ --verbose Log per-episode details during generation
79
+ ```
80
+
81
+ ### Example
82
+
83
+ ```bash
84
+ # After ytdl-sub downloads new episodes:
85
+ sub2pod /home/NAS/ytdl-sub/mark/podcast https://podcasts.example.com
86
+
87
+ # Preview without writing:
88
+ sub2pod /home/NAS/ytdl-sub/mark/podcast https://podcasts.example.com --dry-run
89
+
90
+ # See what's being generated:
91
+ sub2pod /home/NAS/ytdl-sub/mark/podcast https://podcasts.example.com --verbose
92
+ ```
93
+
94
+ ## How it works
95
+
96
+ 1. **Channel discovery** — Walks the parent directory for subdirectories containing `tvshow.nfo`
97
+ 2. **Metadata parsing** — Reads channel title and genre from `tvshow.nfo`; reads per-episode title, description, date, runtime, and source URL from `.nfo` sidecars
98
+ 3. **Episode discovery** — Finds all date-prefixed audio files, pairs them with their `.nfo` sidecars, sorts newest-first
99
+ 4. **Feed generation** — Builds RSS 2.0 XML with iTunes podcast namespace elements (`<itunes:image>`, `<itunes:duration>`, `<itunes:category>`), writes `feed.xml`
100
+ 5. **OPML index** — Writes `opml.xml` at the root listing every channel feed
101
+
102
+ ### Input format
103
+
104
+ Each channel directory is expected to contain:
105
+
106
+ ```
107
+ Links with Friends/
108
+ ├── tvshow.nfo ← Channel metadata (title, genre)
109
+ ├── poster.jpg ← Podcast artwork → <itunes:image>
110
+ ├── 2026-05-19 - Episode Title.opus ← Audio file
111
+ ├── 2026-05-19 - Episode Title.nfo ← Episode metadata sidecar
112
+ └── 2026-05-19 - Episode Title-thumb.jpg ← Episode thumbnail
113
+ ```
114
+
115
+ ## Documentation
116
+
117
+ - [ytdl-sub Integration Guide](docs/ytdl-sub-integration.md) — Configuring ytdl-sub
118
+ to produce sub2pod-compatible output: NFO tags, episode thumbnails, trigger
119
+ strategies, and Opus MIME type guidance.
120
+
121
+ ## Licence
122
+
123
+ Copyright (C) 2026 Marvin8
124
+
125
+ This program is free software: you can redistribute it and/or modify it under
126
+ the terms of the GNU Affero General Public License as published by the Free
127
+ Software Foundation, either version 3 of the License, or (at your option) any
128
+ later version.
@@ -0,0 +1,113 @@
1
+ # sub2pod
2
+
3
+ [![Licence: AGPL-3.0-or-later](https://img.shields.io/badge/licence-AGPL--3.0--or--later-blue)](LICENCE)
4
+ [![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue)](https://www.python.org/)
5
+ [![Woodpecker CI](https://ci.codeberg.org/api/badges/marvin8/sub2pod/status.svg)](https://ci.codeberg.org/marvin8/sub2pod)
6
+ [![Codestyle: Ruff](https://img.shields.io/badge/codestyle-ruff-green)](https://docs.astral.sh/ruff/)
7
+
8
+ Convert [ytdl-sub](https://github.com/jmbannon/ytdl-sub) output directories into
9
+ standards-compliant podcast RSS feeds. Point it at a directory full of downloaded
10
+ audio, and it writes `feed.xml` into each channel subdirectory — ready to serve or
11
+ submit to podcast directories.
12
+
13
+ ## What it does
14
+
15
+ - Reads Kodi `tvshow.nfo` and per-episode `.nfo` sidecars produced by ytdl-sub
16
+ - Discovers date-prefixed audio files (`YYYY-MM-DD - <title>.<ext>`) in each channel directory
17
+ - Generates RSS 2.0 + iTunes namespace `feed.xml` per channel
18
+ - Emits correct MIME types for `.opus`, `.mp3`, and `.m4a` enclosures
19
+ - Produces an `opml.xml` index at the root level for one-click feed import
20
+ - Falls back to filename parsing when `.nfo` sidecars are absent
21
+
22
+ ## What it does not do
23
+
24
+ - No download or subscription management (that's ytdl-sub's job)
25
+ - No web server — feed files are static XML; serve them however you like
26
+ - No podcast client or player
27
+
28
+ ## Installation
29
+
30
+ ### From PyPI
31
+
32
+ ```bash
33
+ pip install sub2pod
34
+ ```
35
+
36
+ ### From source
37
+
38
+ ```bash
39
+ git clone https://codeberg.org/marvin8/sub2pod.git
40
+ cd sub2pod
41
+ uv sync
42
+ uv run sub2pod --help
43
+ ```
44
+
45
+ ## Usage
46
+
47
+ ```bash
48
+ sub2pod /path/to/ytdl-sub/output https://podcast.example.com
49
+ ```
50
+
51
+ This walks the parent directory looking for subdirectories containing `tvshow.nfo`
52
+ and generates:
53
+
54
+ | Output | Location | Description |
55
+ |--------|----------|-------------|
56
+ | `feed.xml` | Each channel subdirectory | RSS 2.0 + iTunes podcast feed |
57
+ | `opml.xml` | Parent directory root | OPML index of all feeds |
58
+
59
+ ### Options
60
+
61
+ ```
62
+ --dry-run Print paths without writing any files
63
+ --verbose Log per-episode details during generation
64
+ ```
65
+
66
+ ### Example
67
+
68
+ ```bash
69
+ # After ytdl-sub downloads new episodes:
70
+ sub2pod /home/NAS/ytdl-sub/mark/podcast https://podcasts.example.com
71
+
72
+ # Preview without writing:
73
+ sub2pod /home/NAS/ytdl-sub/mark/podcast https://podcasts.example.com --dry-run
74
+
75
+ # See what's being generated:
76
+ sub2pod /home/NAS/ytdl-sub/mark/podcast https://podcasts.example.com --verbose
77
+ ```
78
+
79
+ ## How it works
80
+
81
+ 1. **Channel discovery** — Walks the parent directory for subdirectories containing `tvshow.nfo`
82
+ 2. **Metadata parsing** — Reads channel title and genre from `tvshow.nfo`; reads per-episode title, description, date, runtime, and source URL from `.nfo` sidecars
83
+ 3. **Episode discovery** — Finds all date-prefixed audio files, pairs them with their `.nfo` sidecars, sorts newest-first
84
+ 4. **Feed generation** — Builds RSS 2.0 XML with iTunes podcast namespace elements (`<itunes:image>`, `<itunes:duration>`, `<itunes:category>`), writes `feed.xml`
85
+ 5. **OPML index** — Writes `opml.xml` at the root listing every channel feed
86
+
87
+ ### Input format
88
+
89
+ Each channel directory is expected to contain:
90
+
91
+ ```
92
+ Links with Friends/
93
+ ├── tvshow.nfo ← Channel metadata (title, genre)
94
+ ├── poster.jpg ← Podcast artwork → <itunes:image>
95
+ ├── 2026-05-19 - Episode Title.opus ← Audio file
96
+ ├── 2026-05-19 - Episode Title.nfo ← Episode metadata sidecar
97
+ └── 2026-05-19 - Episode Title-thumb.jpg ← Episode thumbnail
98
+ ```
99
+
100
+ ## Documentation
101
+
102
+ - [ytdl-sub Integration Guide](docs/ytdl-sub-integration.md) — Configuring ytdl-sub
103
+ to produce sub2pod-compatible output: NFO tags, episode thumbnails, trigger
104
+ strategies, and Opus MIME type guidance.
105
+
106
+ ## Licence
107
+
108
+ Copyright (C) 2026 Marvin8
109
+
110
+ This program is free software: you can redistribute it and/or modify it under
111
+ the terms of the GNU Affero General Public License as published by the Free
112
+ Software Foundation, either version 3 of the License, or (at your option) any
113
+ later version.
@@ -0,0 +1,78 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.10.0,<0.12.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "sub2pod"
7
+ version = "0.2.0"
8
+ authors = [
9
+ { name = "Marvin8", email = "marvin8@tuta.io" },
10
+ ]
11
+ description = "Convert ytdl-sub output directories into podcast RSS feeds."
12
+ readme = "README.md"
13
+ license = { text = "AGPL-3.0-or-later" }
14
+ requires-python = ">=3.12"
15
+ dependencies = [
16
+ "cyclopts~=4.16.0",
17
+ "lxml~=6.1.1",
18
+ ]
19
+
20
+ [dependency-groups]
21
+ dev = [
22
+ "bump-my-version~=1.3.0",
23
+ "complexipy~=5.6.1",
24
+ "deptry~=0.25.1",
25
+ "git-cliff~=2.13.1",
26
+ "nox-uv~=0.8.0",
27
+ "prek~=0.4.5",
28
+ "pysentry-rs~=0.4.6",
29
+ "ruff~=0.15.17",
30
+ "tryke~=0.0.29",
31
+ "ty~=0.0.49",
32
+ "uv~=0.11.21",
33
+ "zaojun~=1.6.0",
34
+ ]
35
+
36
+ [project.scripts]
37
+ sub2pod = "sub2pod.cli:app"
38
+
39
+ [project.urls]
40
+ Issues = "https://codeberg.org/marvin8/sub2pod/issues"
41
+ Source = "https://codeberg.org/marvin8/sub2pod"
42
+ Changelog = "https://codeberg.org/marvin8/sub2pod/raw/branch/main/CHANGELOG.md"
43
+
44
+ [tool.ruff]
45
+ line-length = 88
46
+
47
+ [tool.ruff.lint]
48
+ select = ["E", "F", "I", "UP", "B"]
49
+
50
+ [tool.ruff.lint.isort]
51
+ force-single-line = true
52
+
53
+ [tool.tryke]
54
+ src = ["src", "."]
55
+
56
+ [tool.bumpversion]
57
+ commit = true
58
+ tag = true
59
+ tag_name = "{new_version}"
60
+ parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
61
+ serialize = ["{major}.{minor}.{patch}"]
62
+ message = ":wrench: bump: version {current_version} → {new_version}"
63
+ pre_commit_hooks = ["uv sync", "git add uv.lock"]
64
+
65
+ [[tool.bumpversion.files]]
66
+ filename = "pyproject.toml"
67
+ search = 'version = "{current_version}"'
68
+ replace = 'version = "{new_version}"'
69
+
70
+ [tool.zaojun]
71
+ cache = true
72
+ groups = true
73
+ min-age = 7
74
+ check-licenses = true
75
+
76
+ [[tool.zaojun.license-ignore]]
77
+ package = "git-cliff"
78
+ reason = "Manually checked, it is licensed as Apache-2.0 OR MIT"
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("sub2pod")
@@ -0,0 +1,46 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ from pathlib import Path
3
+
4
+ import cyclopts
5
+
6
+ from sub2pod.discovery import discover_episodes
7
+ from sub2pod.feed import write_feed
8
+ from sub2pod.nfo import ChannelInfo
9
+ from sub2pod.nfo import parse_channel_nfo
10
+ from sub2pod.opml import write_opml
11
+
12
+ app = cyclopts.App()
13
+
14
+
15
+ @app.default
16
+ def main(
17
+ parent_dir: Path,
18
+ base_url: str,
19
+ *,
20
+ dry_run: bool = False,
21
+ verbose: bool = False,
22
+ ) -> None:
23
+ channel_dirs = sorted(
24
+ child
25
+ for child in parent_dir.iterdir()
26
+ if child.is_dir() and (child / "tvshow.nfo").exists()
27
+ )
28
+ channels: list[tuple[ChannelInfo, Path]] = []
29
+ for channel_dir in channel_dirs:
30
+ channel_info = parse_channel_nfo(channel_dir / "tvshow.nfo")
31
+ episodes = discover_episodes(channel_dir)
32
+ feed_path = channel_dir / "feed.xml"
33
+ if verbose:
34
+ print(f"{channel_info.title}: {len(episodes)} episodes")
35
+ for episode in episodes:
36
+ print(f" {episode.date} — {episode.title}")
37
+ if dry_run:
38
+ print(str(feed_path))
39
+ else:
40
+ write_feed(channel_dir, channel_info, episodes, base_url)
41
+ channels.append((channel_info, channel_dir))
42
+ opml_path = parent_dir / "opml.xml"
43
+ if dry_run:
44
+ print(str(opml_path))
45
+ else:
46
+ write_opml(parent_dir, channels, base_url)
@@ -0,0 +1,46 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ from dataclasses import dataclass
3
+ from datetime import date
4
+ from pathlib import Path
5
+
6
+ from sub2pod.nfo import EpisodeInfo
7
+ from sub2pod.nfo import parse_episode_filename
8
+ from sub2pod.nfo import parse_episode_nfo
9
+
10
+ _AUDIO_SUFFIXES = frozenset({".opus", ".mp3", ".m4a"})
11
+
12
+
13
+ @dataclass
14
+ class Episode:
15
+ audio_path: Path
16
+ date: date
17
+ title: str
18
+ info: EpisodeInfo | None
19
+
20
+
21
+ def discover_episodes(channel_dir: Path) -> list[Episode]:
22
+ episodes: list[Episode] = []
23
+ for audio_path in channel_dir.iterdir():
24
+ if not audio_path.is_file() or audio_path.suffix.lower() not in _AUDIO_SUFFIXES:
25
+ continue
26
+ parsed = parse_episode_filename(audio_path.name)
27
+ if parsed is None:
28
+ continue
29
+ episode_date, episode_title = parsed
30
+ nfo_path = audio_path.with_suffix(".nfo")
31
+ info = None
32
+ if nfo_path.exists():
33
+ try:
34
+ info = parse_episode_nfo(nfo_path)
35
+ except Exception:
36
+ pass
37
+ episodes.append(
38
+ Episode(
39
+ audio_path=audio_path,
40
+ date=episode_date,
41
+ title=episode_title,
42
+ info=info,
43
+ )
44
+ )
45
+ episodes.sort(key=lambda episode: episode.date, reverse=True)
46
+ return episodes
@@ -0,0 +1,165 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ from datetime import UTC
3
+ from datetime import date
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from urllib.parse import quote
7
+
8
+ from lxml import etree # ty: ignore[unresolved-import]
9
+
10
+ from sub2pod.discovery import Episode
11
+ from sub2pod.nfo import ChannelInfo
12
+
13
+ _ITUNES_NS = "http://www.itunes.com/dtds/podcast-1.0.dtd"
14
+ _ITUNES = f"{{{_ITUNES_NS}}}"
15
+
16
+ _MIME_TYPE_BY_SUFFIX: dict[str, str] = {
17
+ ".opus": "audio/ogg; codecs=opus",
18
+ ".mp3": "audio/mpeg",
19
+ ".m4a": "audio/mp4",
20
+ }
21
+
22
+ _ARTWORK_CANDIDATES = ("poster.jpg", "fanart.jpg")
23
+
24
+
25
+ def find_channel_artwork(channel_dir: Path) -> Path | None:
26
+ for candidate in _ARTWORK_CANDIDATES:
27
+ artwork_path = channel_dir / candidate
28
+ if artwork_path.exists():
29
+ return artwork_path
30
+ return None
31
+
32
+
33
+ def mime_type_for_audio_file(audio_path: Path) -> str:
34
+ suffix = audio_path.suffix.lower()
35
+ mime_type = _MIME_TYPE_BY_SUFFIX.get(suffix)
36
+ if mime_type is None:
37
+ raise ValueError(f"Unrecognised audio suffix: {audio_path.suffix!r}")
38
+ return mime_type
39
+
40
+
41
+ def format_pub_date(episode_date: date) -> str:
42
+ midnight_utc = datetime(
43
+ episode_date.year,
44
+ episode_date.month,
45
+ episode_date.day,
46
+ tzinfo=UTC,
47
+ )
48
+ return midnight_utc.strftime("%a, %d %b %Y %H:%M:%S +0000")
49
+
50
+
51
+ def format_itunes_duration(runtime_minutes: int) -> str:
52
+ total_seconds = runtime_minutes * 60
53
+ hours = total_seconds // 3600
54
+ remaining_seconds = total_seconds % 3600
55
+ minutes = remaining_seconds // 60
56
+ seconds = remaining_seconds % 60
57
+ return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
58
+
59
+
60
+ def build_enclosure_url(base_url: str, channel_dir: Path, audio_path: Path) -> str:
61
+ encoded_channel = quote(channel_dir.name, safe="")
62
+ encoded_filename = quote(audio_path.name, safe="")
63
+ return f"{base_url.rstrip('/')}/{encoded_channel}/{encoded_filename}"
64
+
65
+
66
+ def _populate_channel_metadata(
67
+ channel_element: etree._Element,
68
+ channel_info: ChannelInfo,
69
+ channel_dir: Path,
70
+ base_url: str,
71
+ episodes: list[Episode],
72
+ ) -> None:
73
+ etree.SubElement(channel_element, "title").text = channel_info.title
74
+ etree.SubElement(channel_element, "link").text = base_url
75
+ etree.SubElement(channel_element, "description").text = channel_info.title
76
+ etree.SubElement(channel_element, "language").text = "en"
77
+
78
+ artwork_path = find_channel_artwork(channel_dir)
79
+ if artwork_path is not None:
80
+ artwork_url = (
81
+ f"{base_url.rstrip('/')}/{quote(channel_dir.name, safe='')}"
82
+ f"/{quote(artwork_path.name, safe='')}"
83
+ )
84
+ itunes_image = etree.SubElement(channel_element, f"{_ITUNES}image")
85
+ itunes_image.set("href", artwork_url)
86
+
87
+ if channel_info.genre is not None:
88
+ itunes_category = etree.SubElement(channel_element, f"{_ITUNES}category")
89
+ itunes_category.set("text", channel_info.genre)
90
+
91
+ if episodes:
92
+ etree.SubElement(channel_element, "pubDate").text = format_pub_date(
93
+ episodes[0].date
94
+ )
95
+
96
+
97
+ def _append_episode_item(
98
+ channel_element: etree._Element,
99
+ episode: Episode,
100
+ channel_dir: Path,
101
+ base_url: str,
102
+ ) -> None:
103
+ item_element = etree.SubElement(channel_element, "item")
104
+
105
+ episode_title = episode.info.title if episode.info is not None else episode.title
106
+ etree.SubElement(item_element, "title").text = episode_title
107
+
108
+ if episode.info is not None and episode.info.plot is not None:
109
+ etree.SubElement(item_element, "description").text = episode.info.plot
110
+
111
+ pub_date = (
112
+ episode.info.aired
113
+ if (episode.info is not None and episode.info.aired is not None)
114
+ else episode.date
115
+ )
116
+ etree.SubElement(item_element, "pubDate").text = format_pub_date(pub_date)
117
+
118
+ enclosure_url = build_enclosure_url(base_url, channel_dir, episode.audio_path)
119
+ mime_type = mime_type_for_audio_file(episode.audio_path)
120
+ file_size = episode.audio_path.stat().st_size if episode.audio_path.exists() else 0
121
+ enclosure_element = etree.SubElement(item_element, "enclosure")
122
+ enclosure_element.set("url", enclosure_url)
123
+ enclosure_element.set("type", mime_type)
124
+ enclosure_element.set("length", str(file_size))
125
+
126
+ guid_element = etree.SubElement(item_element, "guid")
127
+ guid_element.text = enclosure_url
128
+ guid_element.set("isPermaLink", "false")
129
+
130
+ if episode.info is not None and episode.info.original_url is not None:
131
+ etree.SubElement(item_element, "link").text = episode.info.original_url
132
+
133
+ if episode.info is not None and episode.info.runtime is not None:
134
+ duration_element = etree.SubElement(item_element, f"{_ITUNES}duration")
135
+ duration_element.text = format_itunes_duration(episode.info.runtime)
136
+
137
+
138
+ def build_feed_xml(
139
+ channel_dir: Path,
140
+ channel_info: ChannelInfo,
141
+ episodes: list[Episode],
142
+ base_url: str,
143
+ ) -> bytes:
144
+ nsmap = {"itunes": _ITUNES_NS}
145
+ root = etree.Element("rss", attrib={"version": "2.0"}, nsmap=nsmap)
146
+ channel_element = etree.SubElement(root, "channel")
147
+ _populate_channel_metadata(
148
+ channel_element, channel_info, channel_dir, base_url, episodes
149
+ )
150
+ for episode in episodes:
151
+ _append_episode_item(channel_element, episode, channel_dir, base_url)
152
+ return etree.tostring(
153
+ root, xml_declaration=True, encoding="UTF-8", pretty_print=True
154
+ )
155
+
156
+
157
+ def write_feed(
158
+ channel_dir: Path,
159
+ channel_info: ChannelInfo,
160
+ episodes: list[Episode],
161
+ base_url: str,
162
+ ) -> Path:
163
+ feed_path = channel_dir / "feed.xml"
164
+ feed_path.write_bytes(build_feed_xml(channel_dir, channel_info, episodes, base_url))
165
+ return feed_path
@@ -0,0 +1,70 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ import re
3
+ from dataclasses import dataclass
4
+ from datetime import date
5
+ from pathlib import Path
6
+
7
+ from lxml import etree # ty: ignore[unresolved-import]
8
+
9
+
10
+ @dataclass
11
+ class ChannelInfo:
12
+ title: str
13
+ genre: str | None
14
+
15
+
16
+ @dataclass
17
+ class EpisodeInfo:
18
+ title: str
19
+ plot: str | None
20
+ aired: date | None
21
+ runtime: int | None
22
+ original_url: str | None
23
+
24
+
25
+ def parse_channel_nfo(nfo_path: Path) -> ChannelInfo:
26
+ root = etree.parse(nfo_path).getroot()
27
+ return ChannelInfo(
28
+ title=root.findtext("title") or "",
29
+ genre=root.findtext("genre"),
30
+ )
31
+
32
+
33
+ def parse_episode_nfo(nfo_path: Path) -> EpisodeInfo:
34
+ root = etree.parse(nfo_path).getroot()
35
+ aired_text = root.findtext("aired") or root.findtext("upload_date")
36
+ runtime_text = root.findtext("runtime")
37
+ aired = None
38
+ if aired_text:
39
+ try:
40
+ aired = date.fromisoformat(aired_text)
41
+ except ValueError:
42
+ pass
43
+
44
+ runtime = None
45
+ if runtime_text:
46
+ try:
47
+ runtime = int(runtime_text)
48
+ except ValueError:
49
+ pass
50
+
51
+ return EpisodeInfo(
52
+ title=root.findtext("title") or "",
53
+ plot=root.findtext("plot") or root.findtext("description"),
54
+ aired=aired,
55
+ runtime=runtime,
56
+ original_url=root.findtext("webpage_url"),
57
+ )
58
+
59
+
60
+ EPISODE_FILENAME_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2}) - (.+)\.[^.]+$")
61
+
62
+
63
+ def parse_episode_filename(filename: str) -> tuple[date, str] | None:
64
+ match = EPISODE_FILENAME_PATTERN.match(filename)
65
+ if not match:
66
+ return None
67
+ try:
68
+ return date.fromisoformat(match.group(1)), match.group(2)
69
+ except ValueError:
70
+ return None
@@ -0,0 +1,37 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ from pathlib import Path
3
+ from urllib.parse import quote
4
+
5
+ from lxml import etree # ty: ignore[unresolved-import]
6
+
7
+ from sub2pod.nfo import ChannelInfo
8
+
9
+
10
+ def build_opml_xml(
11
+ channels: list[tuple[ChannelInfo, Path]],
12
+ base_url: str,
13
+ ) -> bytes:
14
+ root = etree.Element("opml", attrib={"version": "2.0"})
15
+ head = etree.SubElement(root, "head")
16
+ etree.SubElement(head, "title").text = "Podcasts"
17
+ body = etree.SubElement(root, "body")
18
+ for channel_info, channel_dir in channels:
19
+ feed_url = f"{base_url.rstrip('/')}/{quote(channel_dir.name, safe='')}/feed.xml"
20
+ outline = etree.SubElement(body, "outline")
21
+ outline.set("type", "rss")
22
+ outline.set("text", channel_info.title)
23
+ outline.set("title", channel_info.title)
24
+ outline.set("xmlUrl", feed_url)
25
+ return etree.tostring(
26
+ root, xml_declaration=True, encoding="UTF-8", pretty_print=True
27
+ )
28
+
29
+
30
+ def write_opml(
31
+ parent_dir: Path,
32
+ channels: list[tuple[ChannelInfo, Path]],
33
+ base_url: str,
34
+ ) -> Path:
35
+ opml_path = parent_dir / "opml.xml"
36
+ opml_path.write_bytes(build_opml_xml(channels, base_url))
37
+ return opml_path