sub2pod 0.3.0__tar.gz → 0.3.2__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.
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sub2pod
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Convert ytdl-sub output directories into podcast RSS feeds.
5
5
  Author: Marvin8
6
6
  Author-email: Marvin8 <marvin8@tuta.io>
7
7
  License: AGPL-3.0-or-later
8
- Requires-Dist: cyclopts~=4.18.0
8
+ Requires-Dist: cyclopts~=4.21.0
9
9
  Requires-Dist: lxml~=6.1.1
10
10
  Requires-Python: >=3.12
11
11
  Project-URL: Issues, https://codeberg.org/marvin8/sub2pod/issues
@@ -15,10 +15,17 @@ Description-Content-Type: text/markdown
15
15
 
16
16
  # sub2pod
17
17
 
18
- [![Licence: AGPL-3.0-or-later](https://img.shields.io/badge/licence-AGPL--3.0--or--later-blue)](LICENCE)
18
+ [![Licence: AGPL-3.0-or-later](https://img.shields.io/badge/licence-AGPL--3.0--or--later-blue)](LICENSE.md)
19
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)
20
+ [![Docs](https://img.shields.io/badge/docs-sub2pod.marvin8.zone-blue)](https://sub2pod.marvin8.zone/latest/)
21
+ [![CI](https://codeberg.org/marvin8/sub2pod/actions/workflows/ci.yml/badge.svg)](https://codeberg.org/marvin8/sub2pod/actions)
22
+ [![Downloads](https://img.shields.io/pepy/dt/sub2pod)](https://pepy.tech/project/sub2pod)
23
+ [![gitleaks](https://img.shields.io/badge/gitleaks-checked-green)](https://github.com/gitleaks/gitleaks)
24
+ [![pysentry](https://img.shields.io/badge/pysentry-checked-green)](https://pysentry.com)
25
+ [![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)
26
+ [![complexipy](https://img.shields.io/badge/complexipy-checked-blue)](https://github.com/rohaquinlop/complexipy)
21
27
  [![Codestyle: Ruff](https://img.shields.io/badge/codestyle-ruff-green)](https://docs.astral.sh/ruff/)
28
+ [![AI-DECLARATION: copilot](https://img.shields.io/badge/%E4%B7%BC%20AI--DECLARATION-copilot-fee2e2?labelColor=fee2e2)](https://ai-declaration.md)
22
29
 
23
30
  Convert [ytdl-sub](https://github.com/jmbannon/ytdl-sub) output directories into
24
31
  standards-compliant podcast RSS feeds. Point it at a directory full of downloaded
@@ -1,9 +1,16 @@
1
1
  # sub2pod
2
2
 
3
- [![Licence: AGPL-3.0-or-later](https://img.shields.io/badge/licence-AGPL--3.0--or--later-blue)](LICENCE)
3
+ [![Licence: AGPL-3.0-or-later](https://img.shields.io/badge/licence-AGPL--3.0--or--later-blue)](LICENSE.md)
4
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)
5
+ [![Docs](https://img.shields.io/badge/docs-sub2pod.marvin8.zone-blue)](https://sub2pod.marvin8.zone/latest/)
6
+ [![CI](https://codeberg.org/marvin8/sub2pod/actions/workflows/ci.yml/badge.svg)](https://codeberg.org/marvin8/sub2pod/actions)
7
+ [![Downloads](https://img.shields.io/pepy/dt/sub2pod)](https://pepy.tech/project/sub2pod)
8
+ [![gitleaks](https://img.shields.io/badge/gitleaks-checked-green)](https://github.com/gitleaks/gitleaks)
9
+ [![pysentry](https://img.shields.io/badge/pysentry-checked-green)](https://pysentry.com)
10
+ [![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)
11
+ [![complexipy](https://img.shields.io/badge/complexipy-checked-blue)](https://github.com/rohaquinlop/complexipy)
6
12
  [![Codestyle: Ruff](https://img.shields.io/badge/codestyle-ruff-green)](https://docs.astral.sh/ruff/)
13
+ [![AI-DECLARATION: copilot](https://img.shields.io/badge/%E4%B7%BC%20AI--DECLARATION-copilot-fee2e2?labelColor=fee2e2)](https://ai-declaration.md)
7
14
 
8
15
  Convert [ytdl-sub](https://github.com/jmbannon/ytdl-sub) output directories into
9
16
  standards-compliant podcast RSS feeds. Point it at a directory full of downloaded
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["uv_build>=0.10.0,<0.12.0"]
2
+ requires = ["uv_build>=0.11.0,<0.12.0"]
3
3
  build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "sub2pod"
7
- version = "0.3.0"
7
+ version = "0.3.2"
8
8
  authors = [
9
9
  { name = "Marvin8", email = "marvin8@tuta.io" },
10
10
  ]
@@ -13,23 +13,25 @@ readme = "README.md"
13
13
  license = { text = "AGPL-3.0-or-later" }
14
14
  requires-python = ">=3.12"
15
15
  dependencies = [
16
- "cyclopts~=4.18.0",
16
+ "cyclopts~=4.21.0",
17
17
  "lxml~=6.1.1",
18
18
  ]
19
19
 
20
20
  [dependency-groups]
21
21
  dev = [
22
22
  "bump-my-version~=1.4.1",
23
- "complexipy~=5.6.1",
23
+ "complexipy~=6.0.1",
24
24
  "deptry~=0.25.1",
25
- "git-cliff~=2.13.1",
26
25
  "nox-uv~=0.8.0",
27
- "prek~=0.4.5",
28
- "pysentry-rs~=0.4.6",
29
- "ruff~=0.15.18",
30
- "tryke~=0.0.29",
31
- "ty~=0.0.51",
32
- "uv~=0.11.22",
26
+ "prek~=0.4.9",
27
+ "pysentry-rs~=0.4.7",
28
+ "ruff~=0.15.21",
29
+ "tryke~=0.0.30",
30
+ "ty~=0.0.59",
31
+ "uv~=0.11.28",
32
+ "mkdocs~=1.6.1",
33
+ "mkdocs-material~=9.7.6",
34
+ "mike~=2.2.0",
33
35
  ]
34
36
 
35
37
  [project.scripts]
@@ -40,15 +42,6 @@ Issues = "https://codeberg.org/marvin8/sub2pod/issues"
40
42
  Source = "https://codeberg.org/marvin8/sub2pod"
41
43
  Changelog = "https://codeberg.org/marvin8/sub2pod/raw/branch/main/CHANGELOG.md"
42
44
 
43
- [tool.ruff]
44
- line-length = 88
45
-
46
- [tool.ruff.lint]
47
- select = ["E", "F", "I", "UP", "B"]
48
-
49
- [tool.ruff.lint.isort]
50
- force-single-line = true
51
-
52
45
  [tool.tryke]
53
46
  src = ["src", "."]
54
47
 
@@ -58,7 +51,7 @@ tag = true
58
51
  tag_name = "{new_version}"
59
52
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
60
53
  serialize = ["{major}.{minor}.{patch}"]
61
- message = ":wrench: bump: version {current_version} → {new_version}"
54
+ message = ":bookmark: bump: version {current_version} → {new_version}"
62
55
  pre_commit_hooks = ["uv sync", "git add uv.lock"]
63
56
 
64
57
  [[tool.bumpversion.files]]
@@ -66,12 +59,13 @@ filename = "pyproject.toml"
66
59
  search = 'version = "{current_version}"'
67
60
  replace = 'version = "{new_version}"'
68
61
 
62
+ [tool.uv]
63
+ constraint-dependencies = [
64
+ "pydantic-settings>=2.14.2", # GHSA-4xgf-cpjx-pc3j
65
+ ]
66
+
69
67
  [tool.zaojun]
70
68
  cache = true
71
69
  groups = true
72
70
  min-age = 7
73
71
  check-licenses = true
74
-
75
- [[tool.zaojun.license-ignore]]
76
- package = "git-cliff"
77
- reason = "Manually checked, it is licensed as Apache-2.0 OR MIT"
@@ -1,3 +1,5 @@
1
+ """Convert ytdl-sub output directories into podcast RSS feeds."""
2
+
1
3
  from importlib.metadata import version
2
4
 
3
5
  __version__ = version("sub2pod")
@@ -1,3 +1,5 @@
1
+ """CLI entry point — walks a parent directory and generates podcast feeds."""
2
+
1
3
  # SPDX-License-Identifier: AGPL-3.0-or-later
2
4
  from pathlib import Path
3
5
 
@@ -21,12 +23,17 @@ def main(
21
23
  dry_run: bool = False,
22
24
  verbose: bool = False,
23
25
  ) -> None:
26
+ """Walk *parent_dir* for channel directories and generate ``feed.xml`` and ``opml.xml``.
27
+
28
+ Args:
29
+ parent_dir: Directory containing one or more ytdl-sub channel subdirectories.
30
+ base_url: Public base URL where audio files will be served (e.g. ``https://pod.example.com``).
31
+ dry_run: Print output file paths without writing anything.
32
+ verbose: Log channel names and episode titles to stdout.
33
+
34
+ """
24
35
  print(f"sub2pod {__version__}")
25
- channel_dirs = sorted(
26
- child
27
- for child in parent_dir.iterdir()
28
- if child.is_dir() and (child / "tvshow.nfo").exists()
29
- )
36
+ channel_dirs = sorted(child for child in parent_dir.iterdir() if child.is_dir() and (child / "tvshow.nfo").exists())
30
37
  channels: list[tuple[ChannelInfo, Path]] = []
31
38
  for channel_dir in channel_dirs:
32
39
  channel_info = parse_channel_nfo(channel_dir / "tvshow.nfo")
@@ -1,4 +1,7 @@
1
+ """Episode discovery — find audio files and sidecar metadata in a channel directory."""
2
+
1
3
  # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ import logging
2
5
  from dataclasses import dataclass
3
6
  from datetime import date
4
7
  from pathlib import Path
@@ -7,11 +10,24 @@ from sub2pod.nfo import EpisodeInfo
7
10
  from sub2pod.nfo import parse_episode_filename
8
11
  from sub2pod.nfo import parse_episode_nfo
9
12
 
13
+ _logger = logging.getLogger(__name__)
14
+
10
15
  _AUDIO_SUFFIXES = frozenset({".opus", ".mp3", ".m4a"})
11
16
 
12
17
 
13
18
  @dataclass
14
19
  class Episode:
20
+ """A single podcast episode discovered from the filesystem.
21
+
22
+ Attributes:
23
+ audio_path: Absolute path to the audio file.
24
+ date: Publication date parsed from the filename prefix.
25
+ title: Episode title parsed from the filename or overridden by NFO sidecar.
26
+ info: Parsed NFO metadata, or ``None`` if no sidecar exists or parsing fails.
27
+ thumbnail_path: Path to a ``-thumb.jpg`` sidecar image, or ``None``.
28
+
29
+ """
30
+
15
31
  audio_path: Path
16
32
  date: date
17
33
  title: str
@@ -20,6 +36,21 @@ class Episode:
20
36
 
21
37
 
22
38
  def discover_episodes(channel_dir: Path) -> list[Episode]:
39
+ """Scan *channel_dir* for date-prefixed audio files and return them newest-first.
40
+
41
+ Audio files must match the ``YYYY-MM-DD - Title.ext`` naming convention.
42
+ Optional ``.nfo`` sidecars are parsed for richer metadata; a malformed
43
+ sidecar is logged at warning level and the episode falls back to
44
+ filename-only data.
45
+
46
+ Args:
47
+ channel_dir: A ytdl-sub channel output directory containing audio files
48
+ and optional ``.nfo`` / ``-thumb.jpg`` sidecars.
49
+
50
+ Returns:
51
+ Episodes sorted newest-first by date. Empty list if no audio files found.
52
+
53
+ """
23
54
  episodes: list[Episode] = []
24
55
  for audio_path in channel_dir.iterdir():
25
56
  if not audio_path.is_file() or audio_path.suffix.lower() not in _AUDIO_SUFFIXES:
@@ -34,7 +65,7 @@ def discover_episodes(channel_dir: Path) -> list[Episode]:
34
65
  try:
35
66
  info = parse_episode_nfo(nfo_path)
36
67
  except Exception:
37
- pass
68
+ _logger.warning("Failed to parse episode NFO: %s", nfo_path, exc_info=True)
38
69
  thumbnail_candidate = audio_path.with_suffix(".jpg")
39
70
  thumbnail_path = thumbnail_candidate if thumbnail_candidate.exists() else None
40
71
  episodes.append(
@@ -1,3 +1,5 @@
1
+ """RSS 2.0 + iTunes namespace feed builder — generates ``feed.xml`` from channel data."""
2
+
1
3
  # SPDX-License-Identifier: AGPL-3.0-or-later
2
4
  from datetime import UTC
3
5
  from datetime import date
@@ -23,6 +25,16 @@ _ARTWORK_CANDIDATES = ("poster.jpg", "fanart.jpg")
23
25
 
24
26
 
25
27
  def find_channel_artwork(channel_dir: Path) -> Path | None:
28
+ """Return the path to ``poster.jpg`` or ``fanart.jpg`` in *channel_dir*, or ``None``.
29
+
30
+ Args:
31
+ channel_dir: Directory to search for artwork files.
32
+
33
+ Returns:
34
+ Path to the first existing artwork file (``poster.jpg`` preferred),
35
+ or ``None`` if neither exists.
36
+
37
+ """
26
38
  for candidate in _ARTWORK_CANDIDATES:
27
39
  artwork_path = channel_dir / candidate
28
40
  if artwork_path.exists():
@@ -31,6 +43,18 @@ def find_channel_artwork(channel_dir: Path) -> Path | None:
31
43
 
32
44
 
33
45
  def mime_type_for_audio_file(audio_path: Path) -> str:
46
+ """Map an audio file suffix to an RFC 4281 MIME type.
47
+
48
+ Args:
49
+ audio_path: Path to an audio file (only the suffix is inspected).
50
+
51
+ Returns:
52
+ MIME type string (e.g. ``"audio/mpeg"`` for ``.mp3``).
53
+
54
+ Raises:
55
+ ValueError: If the suffix is not a recognised audio format.
56
+
57
+ """
34
58
  suffix = audio_path.suffix.lower()
35
59
  mime_type = _MIME_TYPE_BY_SUFFIX.get(suffix)
36
60
  if mime_type is None:
@@ -39,6 +63,15 @@ def mime_type_for_audio_file(audio_path: Path) -> str:
39
63
 
40
64
 
41
65
  def format_pub_date(episode_date: date) -> str:
66
+ """Format *episode_date* as an RFC 2822 date string at midnight UTC.
67
+
68
+ Args:
69
+ episode_date: The episode's publication date (date only, no time).
70
+
71
+ Returns:
72
+ RFC 2822-formatted string like ``"Fri, 15 Mar 2024 00:00:00 +0000"``.
73
+
74
+ """
42
75
  midnight_utc = datetime(
43
76
  episode_date.year,
44
77
  episode_date.month,
@@ -49,6 +82,15 @@ def format_pub_date(episode_date: date) -> str:
49
82
 
50
83
 
51
84
  def format_itunes_duration(runtime_minutes: int) -> str:
85
+ """Convert *runtime_minutes* to an iTunes duration string (``HH:MM:SS``).
86
+
87
+ Args:
88
+ runtime_minutes: Episode duration in whole minutes.
89
+
90
+ Returns:
91
+ iTunes duration string like ``"01:02:00"`` for 62 minutes.
92
+
93
+ """
52
94
  total_seconds = runtime_minutes * 60
53
95
  hours = total_seconds // 3600
54
96
  remaining_seconds = total_seconds % 3600
@@ -58,6 +100,17 @@ def format_itunes_duration(runtime_minutes: int) -> str:
58
100
 
59
101
 
60
102
  def build_enclosure_url(base_url: str, channel_dir: Path, audio_path: Path) -> str:
103
+ """Build a percent-encoded public URL for an enclosure file.
104
+
105
+ Args:
106
+ base_url: Public base URL (trailing slash normalised).
107
+ channel_dir: The channel directory (its name is URL-encoded).
108
+ audio_path: Path to the media file (its filename is URL-encoded).
109
+
110
+ Returns:
111
+ Full public URL like ``"https://pod.example.com/My%20Show/ep.mp3"``.
112
+
113
+ """
61
114
  encoded_channel = quote(channel_dir.name, safe="")
62
115
  encoded_filename = quote(audio_path.name, safe="")
63
116
  return f"{base_url.rstrip('/')}/{encoded_channel}/{encoded_filename}"
@@ -70,6 +123,16 @@ def _populate_channel_metadata(
70
123
  base_url: str,
71
124
  episodes: list[Episode],
72
125
  ) -> None:
126
+ """Add title, link, description, language, artwork, category, and pubDate to a channel element.
127
+
128
+ Args:
129
+ channel_element: The ``<channel>`` XML element to populate.
130
+ channel_info: Parsed ``tvshow.nfo`` metadata.
131
+ channel_dir: Channel directory (for artwork discovery).
132
+ base_url: Public base URL for artwork link.
133
+ episodes: Episode list (first episode's date used for ``pubDate``).
134
+
135
+ """
73
136
  etree.SubElement(channel_element, "title").text = channel_info.title
74
137
  etree.SubElement(channel_element, "link").text = base_url
75
138
  etree.SubElement(channel_element, "description").text = channel_info.title
@@ -77,10 +140,7 @@ def _populate_channel_metadata(
77
140
 
78
141
  artwork_path = find_channel_artwork(channel_dir)
79
142
  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
- )
143
+ artwork_url = f"{base_url.rstrip('/')}/{quote(channel_dir.name, safe='')}/{quote(artwork_path.name, safe='')}"
84
144
  itunes_image = etree.SubElement(channel_element, f"{_ITUNES}image")
85
145
  itunes_image.set("href", artwork_url)
86
146
 
@@ -89,9 +149,7 @@ def _populate_channel_metadata(
89
149
  itunes_category.set("text", channel_info.genre)
90
150
 
91
151
  if episodes:
92
- etree.SubElement(channel_element, "pubDate").text = format_pub_date(
93
- episodes[0].date
94
- )
152
+ etree.SubElement(channel_element, "pubDate").text = format_pub_date(episodes[0].date)
95
153
 
96
154
 
97
155
  def _append_episode_item(
@@ -100,6 +158,15 @@ def _append_episode_item(
100
158
  channel_dir: Path,
101
159
  base_url: str,
102
160
  ) -> None:
161
+ """Append an ``<item>`` element with title, description, enclosure, pubDate, and optional metadata.
162
+
163
+ Args:
164
+ channel_element: The ``<channel>`` XML element to append the item to.
165
+ episode: Discovered episode with optional info and thumbnail.
166
+ channel_dir: Channel directory (for enclosure URL building).
167
+ base_url: Public base URL for enclosure and image links.
168
+
169
+ """
103
170
  item_element = etree.SubElement(channel_element, "item")
104
171
 
105
172
  episode_title = episode.info.title if episode.info is not None else episode.title
@@ -108,11 +175,7 @@ def _append_episode_item(
108
175
  if episode.info is not None and episode.info.plot is not None:
109
176
  etree.SubElement(item_element, "description").text = episode.info.plot
110
177
 
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
- )
178
+ pub_date = episode.info.aired if (episode.info is not None and episode.info.aired is not None) else episode.date
116
179
  etree.SubElement(item_element, "pubDate").text = format_pub_date(pub_date)
117
180
 
118
181
  enclosure_url = build_enclosure_url(base_url, channel_dir, episode.audio_path)
@@ -146,17 +209,25 @@ def build_feed_xml(
146
209
  episodes: list[Episode],
147
210
  base_url: str,
148
211
  ) -> bytes:
212
+ """Build a complete RSS 2.0 + iTunes podcast feed as UTF-8 XML bytes.
213
+
214
+ Args:
215
+ channel_dir: Channel directory (used for artwork discovery and URL building).
216
+ channel_info: Parsed ``tvshow.nfo`` metadata (title, genre).
217
+ episodes: Episodes to include, sorted newest-first.
218
+ base_url: Public base URL for enclosure and artwork links.
219
+
220
+ Returns:
221
+ UTF-8 encoded XML string with XML declaration.
222
+
223
+ """
149
224
  nsmap = {"itunes": _ITUNES_NS}
150
225
  root = etree.Element("rss", attrib={"version": "2.0"}, nsmap=nsmap)
151
226
  channel_element = etree.SubElement(root, "channel")
152
- _populate_channel_metadata(
153
- channel_element, channel_info, channel_dir, base_url, episodes
154
- )
227
+ _populate_channel_metadata(channel_element, channel_info, channel_dir, base_url, episodes)
155
228
  for episode in episodes:
156
229
  _append_episode_item(channel_element, episode, channel_dir, base_url)
157
- return etree.tostring(
158
- root, xml_declaration=True, encoding="UTF-8", pretty_print=True
159
- )
230
+ return etree.tostring(root, xml_declaration=True, encoding="UTF-8", pretty_print=True)
160
231
 
161
232
 
162
233
  def write_feed(
@@ -165,6 +236,18 @@ def write_feed(
165
236
  episodes: list[Episode],
166
237
  base_url: str,
167
238
  ) -> Path:
239
+ """Build the feed and write it to ``channel_dir/feed.xml``, returning the path.
240
+
241
+ Args:
242
+ channel_dir: Channel directory where ``feed.xml`` will be written.
243
+ channel_info: Parsed ``tvshow.nfo`` metadata.
244
+ episodes: Episodes to include, sorted newest-first.
245
+ base_url: Public base URL for enclosure and artwork links.
246
+
247
+ Returns:
248
+ Path to the written ``feed.xml`` file.
249
+
250
+ """
168
251
  feed_path = channel_dir / "feed.xml"
169
252
  feed_path.write_bytes(build_feed_xml(channel_dir, channel_info, episodes, base_url))
170
253
  return feed_path
@@ -1,3 +1,5 @@
1
+ """Kodi NFO parser — extracts channel and episode metadata from XML sidecars."""
2
+
1
3
  # SPDX-License-Identifier: AGPL-3.0-or-later
2
4
  import re
3
5
  from dataclasses import dataclass
@@ -9,12 +11,31 @@ from lxml import etree # ty: ignore[unresolved-import]
9
11
 
10
12
  @dataclass
11
13
  class ChannelInfo:
14
+ """Metadata parsed from a ``tvshow.nfo`` file.
15
+
16
+ Attributes:
17
+ title: Show title.
18
+ genre: Category string, or ``None`` if absent.
19
+
20
+ """
21
+
12
22
  title: str
13
23
  genre: str | None
14
24
 
15
25
 
16
26
  @dataclass
17
27
  class EpisodeInfo:
28
+ """Metadata parsed from a per-episode ``.nfo`` sidecar.
29
+
30
+ Attributes:
31
+ title: Episode title.
32
+ plot: Episode description / show notes.
33
+ aired: Original air date, or ``None``.
34
+ runtime: Duration in minutes, or ``None``.
35
+ original_url: Source webpage URL (e.g. YouTube), or ``None``.
36
+
37
+ """
38
+
18
39
  title: str
19
40
  plot: str | None
20
41
  aired: date | None
@@ -23,6 +44,16 @@ class EpisodeInfo:
23
44
 
24
45
 
25
46
  def parse_channel_nfo(nfo_path: Path) -> ChannelInfo:
47
+ """Parse a Kodi ``tvshow.nfo`` file and return channel metadata.
48
+
49
+ Args:
50
+ nfo_path: Path to a ``tvshow.nfo`` XML file.
51
+
52
+ Returns:
53
+ Parsed channel metadata. Title defaults to ``""`` if absent;
54
+ genre is ``None`` if absent.
55
+
56
+ """
26
57
  root = etree.parse(nfo_path).getroot()
27
58
  return ChannelInfo(
28
59
  title=root.findtext("title") or "",
@@ -31,6 +62,20 @@ def parse_channel_nfo(nfo_path: Path) -> ChannelInfo:
31
62
 
32
63
 
33
64
  def parse_episode_nfo(nfo_path: Path) -> EpisodeInfo:
65
+ """Parse a per-episode Kodi ``.nfo`` sidecar and return episode metadata.
66
+
67
+ Gracefully handles missing or malformed ``aired`` / ``runtime`` values
68
+ by returning ``None`` for those fields. Supports both Kodi-style
69
+ (``<aired>``, ``<plot>``) and ytdl-sub-style (``<upload_date>``,
70
+ ``<description>``) element names.
71
+
72
+ Args:
73
+ nfo_path: Path to an episode ``.nfo`` XML sidecar.
74
+
75
+ Returns:
76
+ Parsed episode metadata. All optional fields may be ``None``.
77
+
78
+ """
34
79
  root = etree.parse(nfo_path).getroot()
35
80
  aired_text = root.findtext("aired") or root.findtext("upload_date")
36
81
  runtime_text = root.findtext("runtime")
@@ -61,6 +106,16 @@ EPISODE_FILENAME_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2}) - (.+)\.[^.]+$")
61
106
 
62
107
 
63
108
  def parse_episode_filename(filename: str) -> tuple[date, str] | None:
109
+ """Extract a date and title from a ``YYYY-MM-DD - Title.ext`` filename.
110
+
111
+ Args:
112
+ filename: A filename like ``"2024-03-15 - My Episode.opus"``.
113
+
114
+ Returns:
115
+ A ``(date, title)`` tuple, or ``None`` if the filename does not match
116
+ the expected pattern or contains an invalid calendar date.
117
+
118
+ """
64
119
  match = EPISODE_FILENAME_PATTERN.match(filename)
65
120
  if not match:
66
121
  return None
@@ -1,3 +1,5 @@
1
+ """OPML builder — generates an ``opml.xml`` index listing all channel feeds."""
2
+
1
3
  # SPDX-License-Identifier: AGPL-3.0-or-later
2
4
  from pathlib import Path
3
5
  from urllib.parse import quote
@@ -11,6 +13,17 @@ def build_opml_xml(
11
13
  channels: list[tuple[ChannelInfo, Path]],
12
14
  base_url: str,
13
15
  ) -> bytes:
16
+ """Build an OPML 2.0 document listing each channel's ``feed.xml`` URL as an outline.
17
+
18
+ Args:
19
+ channels: List of ``(ChannelInfo, channel_dir)`` tuples.
20
+ base_url: Public base URL (trailing slash normalised) for constructing
21
+ ``xmlUrl`` attributes.
22
+
23
+ Returns:
24
+ UTF-8 encoded OPML 2.0 XML with XML declaration.
25
+
26
+ """
14
27
  root = etree.Element("opml", attrib={"version": "2.0"})
15
28
  head = etree.SubElement(root, "head")
16
29
  etree.SubElement(head, "title").text = "Podcasts"
@@ -22,9 +35,7 @@ def build_opml_xml(
22
35
  outline.set("text", channel_info.title)
23
36
  outline.set("title", channel_info.title)
24
37
  outline.set("xmlUrl", feed_url)
25
- return etree.tostring(
26
- root, xml_declaration=True, encoding="UTF-8", pretty_print=True
27
- )
38
+ return etree.tostring(root, xml_declaration=True, encoding="UTF-8", pretty_print=True)
28
39
 
29
40
 
30
41
  def write_opml(
@@ -32,6 +43,17 @@ def write_opml(
32
43
  channels: list[tuple[ChannelInfo, Path]],
33
44
  base_url: str,
34
45
  ) -> Path:
46
+ """Build the OPML and write it to ``parent_dir/opml.xml``, returning the path.
47
+
48
+ Args:
49
+ parent_dir: Root directory where ``opml.xml`` will be written.
50
+ channels: List of ``(ChannelInfo, channel_dir)`` tuples.
51
+ base_url: Public base URL for constructing ``xmlUrl`` attributes.
52
+
53
+ Returns:
54
+ Path to the written ``opml.xml`` file.
55
+
56
+ """
35
57
  opml_path = parent_dir / "opml.xml"
36
58
  opml_path.write_bytes(build_opml_xml(channels, base_url))
37
59
  return opml_path