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.
- {sub2pod-0.3.0 → sub2pod-0.3.2}/PKG-INFO +11 -4
- {sub2pod-0.3.0 → sub2pod-0.3.2}/README.md +9 -2
- {sub2pod-0.3.0 → sub2pod-0.3.2}/pyproject.toml +19 -25
- {sub2pod-0.3.0 → sub2pod-0.3.2}/src/sub2pod/__init__.py +2 -0
- {sub2pod-0.3.0 → sub2pod-0.3.2}/src/sub2pod/cli.py +12 -5
- {sub2pod-0.3.0 → sub2pod-0.3.2}/src/sub2pod/discovery.py +32 -1
- {sub2pod-0.3.0 → sub2pod-0.3.2}/src/sub2pod/feed.py +101 -18
- {sub2pod-0.3.0 → sub2pod-0.3.2}/src/sub2pod/nfo.py +55 -0
- {sub2pod-0.3.0 → sub2pod-0.3.2}/src/sub2pod/opml.py +25 -3
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: sub2pod
|
|
3
|
-
Version: 0.3.
|
|
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.
|
|
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
|
-
[](
|
|
18
|
+
[](LICENSE.md)
|
|
19
19
|
[](https://www.python.org/)
|
|
20
|
-
[](https://sub2pod.marvin8.zone/latest/)
|
|
21
|
+
[](https://codeberg.org/marvin8/sub2pod/actions)
|
|
22
|
+
[](https://pepy.tech/project/sub2pod)
|
|
23
|
+
[](https://github.com/gitleaks/gitleaks)
|
|
24
|
+
[](https://pysentry.com)
|
|
25
|
+
[](https://github.com/astral-sh/ty)
|
|
26
|
+
[](https://github.com/rohaquinlop/complexipy)
|
|
21
27
|
[](https://docs.astral.sh/ruff/)
|
|
28
|
+
[](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
|
-
[](
|
|
3
|
+
[](LICENSE.md)
|
|
4
4
|
[](https://www.python.org/)
|
|
5
|
-
[](https://sub2pod.marvin8.zone/latest/)
|
|
6
|
+
[](https://codeberg.org/marvin8/sub2pod/actions)
|
|
7
|
+
[](https://pepy.tech/project/sub2pod)
|
|
8
|
+
[](https://github.com/gitleaks/gitleaks)
|
|
9
|
+
[](https://pysentry.com)
|
|
10
|
+
[](https://github.com/astral-sh/ty)
|
|
11
|
+
[](https://github.com/rohaquinlop/complexipy)
|
|
6
12
|
[](https://docs.astral.sh/ruff/)
|
|
13
|
+
[](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.
|
|
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.
|
|
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.
|
|
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~=
|
|
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.
|
|
28
|
-
"pysentry-rs~=0.4.
|
|
29
|
-
"ruff~=0.15.
|
|
30
|
-
"tryke~=0.0.
|
|
31
|
-
"ty~=0.0.
|
|
32
|
-
"uv~=0.11.
|
|
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 = ":
|
|
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
|
+
"""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
|
-
|
|
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
|