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 +128 -0
- sub2pod-0.2.0/README.md +113 -0
- sub2pod-0.2.0/pyproject.toml +78 -0
- sub2pod-0.2.0/src/sub2pod/__init__.py +3 -0
- sub2pod-0.2.0/src/sub2pod/cli.py +46 -0
- sub2pod-0.2.0/src/sub2pod/discovery.py +46 -0
- sub2pod-0.2.0/src/sub2pod/feed.py +165 -0
- sub2pod-0.2.0/src/sub2pod/nfo.py +70 -0
- sub2pod-0.2.0/src/sub2pod/opml.py +37 -0
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)
|
|
19
|
+
[](https://www.python.org/)
|
|
20
|
+
[](https://ci.codeberg.org/marvin8/sub2pod)
|
|
21
|
+
[](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.
|
sub2pod-0.2.0/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# sub2pod
|
|
2
|
+
|
|
3
|
+
[](LICENCE)
|
|
4
|
+
[](https://www.python.org/)
|
|
5
|
+
[](https://ci.codeberg.org/marvin8/sub2pod)
|
|
6
|
+
[](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,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
|