podryk 0.1.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.
- podryk-0.1.0/LICENSE.txt +21 -0
- podryk-0.1.0/PKG-INFO +176 -0
- podryk-0.1.0/README.md +150 -0
- podryk-0.1.0/pyproject.toml +82 -0
- podryk-0.1.0/src/podryk/__init__.py +18 -0
- podryk-0.1.0/src/podryk/models/__init__.py +0 -0
- podryk-0.1.0/src/podryk/models/enum.py +174 -0
- podryk-0.1.0/src/podryk/models/episode.py +118 -0
- podryk-0.1.0/src/podryk/models/field_types.py +156 -0
- podryk-0.1.0/src/podryk/models/namespaces.py +25 -0
- podryk-0.1.0/src/podryk/models/podcast.py +159 -0
- podryk-0.1.0/src/podryk/models/sub_types.py +99 -0
- podryk-0.1.0/src/podryk/models/xml_model.py +11 -0
- podryk-0.1.0/src/podryk/py.typed +0 -0
podryk-0.1.0/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Julien Hadley Jack
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
podryk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: podryk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A podcast feed generator
|
|
5
|
+
Keywords: podcast,rss,feed,podcasting,audio
|
|
6
|
+
Author: Julien Hadley Jack
|
|
7
|
+
Author-email: Julien Hadley Jack <git@jlhj.de>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE.txt
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
17
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Dist: content-types>=0.3.0
|
|
20
|
+
Requires-Dist: pydantic-xml[lxml]>=2.18.0
|
|
21
|
+
Requires-Python: >=3.13
|
|
22
|
+
Project-URL: Homepage, https://github.com/julien-hadleyjack/podryk
|
|
23
|
+
Project-URL: Repository, https://github.com/julien-hadleyjack/podryk
|
|
24
|
+
Project-URL: Issues, https://github.com/julien-hadleyjack/podryk/issues
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# podryk: A podcast feed generator
|
|
28
|
+
|
|
29
|
+
An RSS 2.0 feed writer for Python for generating Podcast feeds. Supported features:
|
|
30
|
+
|
|
31
|
+
- [RSS 2.0](http://www.rssboard.org/rss-2-0)
|
|
32
|
+
- [iTunes](https://help.apple.com/itc/podcasts_connect/#/itcb54353390) (Apple Podcast Connect)
|
|
33
|
+
- [Podlove](https://podlove.org/simple-chapters/): chapters
|
|
34
|
+
- [Podcasting 2.0](https://podcasting2.org/docs/podcast-namespace): transcripts & text records
|
|
35
|
+
|
|
36
|
+
Podryk is opinionated: If there is multiple conflicting specifications for a feature (like chapters), only one will be implemented.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
You can install the python library with:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install podryk
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
An example with only required attributes:
|
|
49
|
+
|
|
50
|
+
<!-- [[[cog
|
|
51
|
+
from scripts.cog_readme import print_example
|
|
52
|
+
from test_readme import test_minimal_feed
|
|
53
|
+
print_example(test_minimal_feed)
|
|
54
|
+
]]] -->
|
|
55
|
+
```python
|
|
56
|
+
from podryk import Enclosure, Episode, Guid, Podcast
|
|
57
|
+
|
|
58
|
+
feed = Podcast(
|
|
59
|
+
canonical_link="https://example.com/canonical.rss",
|
|
60
|
+
title="Podcast title",
|
|
61
|
+
description="Podcast description",
|
|
62
|
+
link="https://example.com/episode.html",
|
|
63
|
+
language="en",
|
|
64
|
+
explicit=False,
|
|
65
|
+
image="https://example.com/podcast.png",
|
|
66
|
+
episodes=[
|
|
67
|
+
Episode(
|
|
68
|
+
title="Episode title",
|
|
69
|
+
guid=Guid(guid="12345678-1234-5678-1234-567812345678"),
|
|
70
|
+
enclosure=Enclosure(
|
|
71
|
+
url="https://example.com/audio.mp3",
|
|
72
|
+
length=30000,
|
|
73
|
+
type="audio/mpeg",
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
],
|
|
77
|
+
).to_feed()
|
|
78
|
+
```
|
|
79
|
+
<!-- [[[end]]] -->
|
|
80
|
+
|
|
81
|
+
An example with all possible attributes:
|
|
82
|
+
|
|
83
|
+
<!-- [[[cog
|
|
84
|
+
from test_readme import test_full_feed
|
|
85
|
+
print_example(test_full_feed)
|
|
86
|
+
]]] -->
|
|
87
|
+
```python
|
|
88
|
+
from datetime import datetime, timedelta, timezone
|
|
89
|
+
|
|
90
|
+
from podryk import (
|
|
91
|
+
Chapter,
|
|
92
|
+
Enclosure,
|
|
93
|
+
Episode,
|
|
94
|
+
EpisodeType,
|
|
95
|
+
Guid,
|
|
96
|
+
Podcast,
|
|
97
|
+
PodcastCategory,
|
|
98
|
+
PodcastType,
|
|
99
|
+
TextRecord,
|
|
100
|
+
Transcript,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
feed = Podcast(
|
|
104
|
+
canonical_link="https://example.com/canonical.rss",
|
|
105
|
+
title="Podcast title",
|
|
106
|
+
description="Podcast description",
|
|
107
|
+
link="https://example.com/episode.html",
|
|
108
|
+
language="en",
|
|
109
|
+
copyright="Copyright notice",
|
|
110
|
+
categories=[
|
|
111
|
+
PodcastCategory.FILM_REVIEWS,
|
|
112
|
+
PodcastCategory.FILM_INTERVIEWS,
|
|
113
|
+
],
|
|
114
|
+
explicit=True,
|
|
115
|
+
image="https://example.com/podcast.png",
|
|
116
|
+
author="Podcast author",
|
|
117
|
+
type=PodcastType.SERIAL,
|
|
118
|
+
complete=False,
|
|
119
|
+
locked=False,
|
|
120
|
+
guid="3595bd1c-50a4-504d-baf4-99de513b3737",
|
|
121
|
+
text_records=[TextRecord(purpose="verify", content="S6lpp-7ZCn8-dZfGc-OoyaG")],
|
|
122
|
+
episodes=[
|
|
123
|
+
Episode(
|
|
124
|
+
title="Episode title",
|
|
125
|
+
guid=Guid(guid="12345678-1234-5678-1234-567812345678"),
|
|
126
|
+
enclosure=Enclosure(
|
|
127
|
+
url="https://example.com/audio.mp3",
|
|
128
|
+
length=30000,
|
|
129
|
+
type="audio/mpeg",
|
|
130
|
+
),
|
|
131
|
+
link="https://example.com/episode.html",
|
|
132
|
+
publication_date=datetime(2014, 6, 20, 10, 35, tzinfo=timezone.utc),
|
|
133
|
+
description="Episode description",
|
|
134
|
+
duration=timedelta(hours=1, minutes=10, seconds=50),
|
|
135
|
+
image="https://example.com/episode.png",
|
|
136
|
+
explicit=False,
|
|
137
|
+
season_number=2,
|
|
138
|
+
episode_number=30,
|
|
139
|
+
type=EpisodeType.FULL,
|
|
140
|
+
block=False,
|
|
141
|
+
transcripts=[
|
|
142
|
+
Transcript(url="https://example.com/episode.vtt", type="text/vtt"),
|
|
143
|
+
],
|
|
144
|
+
chapters=[
|
|
145
|
+
Chapter(start=timedelta(seconds=10), title="Episode chapter 1"),
|
|
146
|
+
Chapter(
|
|
147
|
+
start=timedelta(minutes=2),
|
|
148
|
+
title="Episode chapter 2",
|
|
149
|
+
href="https://example.com/chapter.html",
|
|
150
|
+
),
|
|
151
|
+
],
|
|
152
|
+
)
|
|
153
|
+
],
|
|
154
|
+
).to_feed()
|
|
155
|
+
```
|
|
156
|
+
<!-- [[[end]]] -->
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
## Miscellaneous
|
|
161
|
+
|
|
162
|
+
Podryk implements a subset from the following Podcast specifications:
|
|
163
|
+
|
|
164
|
+
- [PSP-1: The Podcast RSS Standard](https://github.com/Podcast-Standards-Project/PSP-1-Podcast-RSS-Specification)
|
|
165
|
+
- [Podcasting 2.0](https://podcasting2.org/docs/podcast-namespace)
|
|
166
|
+
- [Spotify's Podcast Delivery Specification](https://support.spotify.com/us/creators/article/podcast-specification-doc/)
|
|
167
|
+
- [iTunes' Podcast RSS feed requirements](https://podcasters.apple.com/support/823-podcast-requirements)
|
|
168
|
+
|
|
169
|
+
Create a ticket if you find any deviations from the mentioned specifications.
|
|
170
|
+
|
|
171
|
+
You can validate your podcast feed using one of these services:
|
|
172
|
+
|
|
173
|
+
- https://www.castfeedvalidator.com/
|
|
174
|
+
- https://podba.se/validate/
|
|
175
|
+
- https://podcasters.apple.com/support/829-validate-your-podcast
|
|
176
|
+
|
podryk-0.1.0/README.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# podryk: A podcast feed generator
|
|
2
|
+
|
|
3
|
+
An RSS 2.0 feed writer for Python for generating Podcast feeds. Supported features:
|
|
4
|
+
|
|
5
|
+
- [RSS 2.0](http://www.rssboard.org/rss-2-0)
|
|
6
|
+
- [iTunes](https://help.apple.com/itc/podcasts_connect/#/itcb54353390) (Apple Podcast Connect)
|
|
7
|
+
- [Podlove](https://podlove.org/simple-chapters/): chapters
|
|
8
|
+
- [Podcasting 2.0](https://podcasting2.org/docs/podcast-namespace): transcripts & text records
|
|
9
|
+
|
|
10
|
+
Podryk is opinionated: If there is multiple conflicting specifications for a feature (like chapters), only one will be implemented.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
You can install the python library with:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install podryk
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
An example with only required attributes:
|
|
23
|
+
|
|
24
|
+
<!-- [[[cog
|
|
25
|
+
from scripts.cog_readme import print_example
|
|
26
|
+
from test_readme import test_minimal_feed
|
|
27
|
+
print_example(test_minimal_feed)
|
|
28
|
+
]]] -->
|
|
29
|
+
```python
|
|
30
|
+
from podryk import Enclosure, Episode, Guid, Podcast
|
|
31
|
+
|
|
32
|
+
feed = Podcast(
|
|
33
|
+
canonical_link="https://example.com/canonical.rss",
|
|
34
|
+
title="Podcast title",
|
|
35
|
+
description="Podcast description",
|
|
36
|
+
link="https://example.com/episode.html",
|
|
37
|
+
language="en",
|
|
38
|
+
explicit=False,
|
|
39
|
+
image="https://example.com/podcast.png",
|
|
40
|
+
episodes=[
|
|
41
|
+
Episode(
|
|
42
|
+
title="Episode title",
|
|
43
|
+
guid=Guid(guid="12345678-1234-5678-1234-567812345678"),
|
|
44
|
+
enclosure=Enclosure(
|
|
45
|
+
url="https://example.com/audio.mp3",
|
|
46
|
+
length=30000,
|
|
47
|
+
type="audio/mpeg",
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
],
|
|
51
|
+
).to_feed()
|
|
52
|
+
```
|
|
53
|
+
<!-- [[[end]]] -->
|
|
54
|
+
|
|
55
|
+
An example with all possible attributes:
|
|
56
|
+
|
|
57
|
+
<!-- [[[cog
|
|
58
|
+
from test_readme import test_full_feed
|
|
59
|
+
print_example(test_full_feed)
|
|
60
|
+
]]] -->
|
|
61
|
+
```python
|
|
62
|
+
from datetime import datetime, timedelta, timezone
|
|
63
|
+
|
|
64
|
+
from podryk import (
|
|
65
|
+
Chapter,
|
|
66
|
+
Enclosure,
|
|
67
|
+
Episode,
|
|
68
|
+
EpisodeType,
|
|
69
|
+
Guid,
|
|
70
|
+
Podcast,
|
|
71
|
+
PodcastCategory,
|
|
72
|
+
PodcastType,
|
|
73
|
+
TextRecord,
|
|
74
|
+
Transcript,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
feed = Podcast(
|
|
78
|
+
canonical_link="https://example.com/canonical.rss",
|
|
79
|
+
title="Podcast title",
|
|
80
|
+
description="Podcast description",
|
|
81
|
+
link="https://example.com/episode.html",
|
|
82
|
+
language="en",
|
|
83
|
+
copyright="Copyright notice",
|
|
84
|
+
categories=[
|
|
85
|
+
PodcastCategory.FILM_REVIEWS,
|
|
86
|
+
PodcastCategory.FILM_INTERVIEWS,
|
|
87
|
+
],
|
|
88
|
+
explicit=True,
|
|
89
|
+
image="https://example.com/podcast.png",
|
|
90
|
+
author="Podcast author",
|
|
91
|
+
type=PodcastType.SERIAL,
|
|
92
|
+
complete=False,
|
|
93
|
+
locked=False,
|
|
94
|
+
guid="3595bd1c-50a4-504d-baf4-99de513b3737",
|
|
95
|
+
text_records=[TextRecord(purpose="verify", content="S6lpp-7ZCn8-dZfGc-OoyaG")],
|
|
96
|
+
episodes=[
|
|
97
|
+
Episode(
|
|
98
|
+
title="Episode title",
|
|
99
|
+
guid=Guid(guid="12345678-1234-5678-1234-567812345678"),
|
|
100
|
+
enclosure=Enclosure(
|
|
101
|
+
url="https://example.com/audio.mp3",
|
|
102
|
+
length=30000,
|
|
103
|
+
type="audio/mpeg",
|
|
104
|
+
),
|
|
105
|
+
link="https://example.com/episode.html",
|
|
106
|
+
publication_date=datetime(2014, 6, 20, 10, 35, tzinfo=timezone.utc),
|
|
107
|
+
description="Episode description",
|
|
108
|
+
duration=timedelta(hours=1, minutes=10, seconds=50),
|
|
109
|
+
image="https://example.com/episode.png",
|
|
110
|
+
explicit=False,
|
|
111
|
+
season_number=2,
|
|
112
|
+
episode_number=30,
|
|
113
|
+
type=EpisodeType.FULL,
|
|
114
|
+
block=False,
|
|
115
|
+
transcripts=[
|
|
116
|
+
Transcript(url="https://example.com/episode.vtt", type="text/vtt"),
|
|
117
|
+
],
|
|
118
|
+
chapters=[
|
|
119
|
+
Chapter(start=timedelta(seconds=10), title="Episode chapter 1"),
|
|
120
|
+
Chapter(
|
|
121
|
+
start=timedelta(minutes=2),
|
|
122
|
+
title="Episode chapter 2",
|
|
123
|
+
href="https://example.com/chapter.html",
|
|
124
|
+
),
|
|
125
|
+
],
|
|
126
|
+
)
|
|
127
|
+
],
|
|
128
|
+
).to_feed()
|
|
129
|
+
```
|
|
130
|
+
<!-- [[[end]]] -->
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
## Miscellaneous
|
|
135
|
+
|
|
136
|
+
Podryk implements a subset from the following Podcast specifications:
|
|
137
|
+
|
|
138
|
+
- [PSP-1: The Podcast RSS Standard](https://github.com/Podcast-Standards-Project/PSP-1-Podcast-RSS-Specification)
|
|
139
|
+
- [Podcasting 2.0](https://podcasting2.org/docs/podcast-namespace)
|
|
140
|
+
- [Spotify's Podcast Delivery Specification](https://support.spotify.com/us/creators/article/podcast-specification-doc/)
|
|
141
|
+
- [iTunes' Podcast RSS feed requirements](https://podcasters.apple.com/support/823-podcast-requirements)
|
|
142
|
+
|
|
143
|
+
Create a ticket if you find any deviations from the mentioned specifications.
|
|
144
|
+
|
|
145
|
+
You can validate your podcast feed using one of these services:
|
|
146
|
+
|
|
147
|
+
- https://www.castfeedvalidator.com/
|
|
148
|
+
- https://podba.se/validate/
|
|
149
|
+
- https://podcasters.apple.com/support/829-validate-your-podcast
|
|
150
|
+
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "podryk"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A podcast feed generator"
|
|
5
|
+
keywords = ["podcast", "rss", "feed", "podcasting", "audio"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Julien Hadley Jack", email = "git@jlhj.de" }
|
|
9
|
+
]
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = [
|
|
12
|
+
"LICENSE.txt",
|
|
13
|
+
]
|
|
14
|
+
requires-python = ">=3.13"
|
|
15
|
+
dependencies = [
|
|
16
|
+
"content-types>=0.3.0",
|
|
17
|
+
"pydantic-xml[lxml]>=2.18.0",
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
26
|
+
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
|
27
|
+
"Topic :: Multimedia :: Sound/Audio",
|
|
28
|
+
"Typing :: Typed",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/julien-hadleyjack/podryk"
|
|
33
|
+
Repository = "https://github.com/julien-hadleyjack/podryk"
|
|
34
|
+
Issues = "https://github.com/julien-hadleyjack/podryk/issues"
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["uv_build>=0.9.6,<0.10.0"]
|
|
38
|
+
build-backend = "uv_build"
|
|
39
|
+
|
|
40
|
+
[dependency-groups]
|
|
41
|
+
build = [
|
|
42
|
+
"python-semantic-release>=10.5.3",
|
|
43
|
+
]
|
|
44
|
+
dev = [
|
|
45
|
+
"cogapp>=3.6.0",
|
|
46
|
+
"coverage>=7.13.3",
|
|
47
|
+
"pytest>=9.0.2",
|
|
48
|
+
"pytest-cov>=7.0.0",
|
|
49
|
+
"pytest-github-report>=0.0.1",
|
|
50
|
+
"ruff>=0.15.0",
|
|
51
|
+
"syrupy>=5.1.0",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
[tool.uv]
|
|
55
|
+
required-version = ">=0.9.6"
|
|
56
|
+
|
|
57
|
+
[tool.pytest.ini_options]
|
|
58
|
+
addopts = ["--snapshot-patch-pycharm-diff"]
|
|
59
|
+
|
|
60
|
+
[tool.ruff]
|
|
61
|
+
line-length = 120
|
|
62
|
+
|
|
63
|
+
[tool.semantic_release]
|
|
64
|
+
build_command = """
|
|
65
|
+
uv lock --upgrade-package "$PACKAGE_NAME"
|
|
66
|
+
git add uv.lock
|
|
67
|
+
uv build
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
[tool.semantic_release.changelog]
|
|
71
|
+
changelog_file = "CHANGELOG.md"
|
|
72
|
+
exclude_commit_patterns = [
|
|
73
|
+
'''chore(?:\([^)]*?\))?: .+''',
|
|
74
|
+
'''ci(?:\([^)]*?\))?: .+''',
|
|
75
|
+
'''refactor(?:\([^)]*?\))?: .+''',
|
|
76
|
+
'''style(?:\([^)]*?\))?: .+''',
|
|
77
|
+
'''test(?:\([^)]*?\))?: .+''',
|
|
78
|
+
'''docs(?:\([^)]*?\))?: .+''',
|
|
79
|
+
'''build(?:\([^)]*?\))?: .+''',
|
|
80
|
+
'''build\((?!deps\): .+)''',
|
|
81
|
+
'''project scaffold''',
|
|
82
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from podryk.models.enum import EpisodeType, Explicit, PodcastCategory, PodcastType
|
|
2
|
+
from podryk.models.episode import Episode
|
|
3
|
+
from podryk.models.podcast import Podcast
|
|
4
|
+
from podryk.models.sub_types import Chapter, Enclosure, Guid, TextRecord, Transcript
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"Podcast",
|
|
8
|
+
"Episode",
|
|
9
|
+
"PodcastType",
|
|
10
|
+
"EpisodeType",
|
|
11
|
+
"Explicit",
|
|
12
|
+
"PodcastCategory",
|
|
13
|
+
"Guid",
|
|
14
|
+
"Enclosure",
|
|
15
|
+
"Transcript",
|
|
16
|
+
"Chapter",
|
|
17
|
+
"TextRecord",
|
|
18
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
from enum import Enum, StrEnum, auto, unique
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@unique
|
|
5
|
+
class Explicit(StrEnum):
|
|
6
|
+
YES = auto()
|
|
7
|
+
NO = auto()
|
|
8
|
+
CLEAN = auto()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@unique
|
|
12
|
+
class PodcastType(StrEnum):
|
|
13
|
+
EPISODIC = auto()
|
|
14
|
+
SERIAL = auto()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@unique
|
|
18
|
+
class EpisodeType(StrEnum):
|
|
19
|
+
FULL = auto()
|
|
20
|
+
"""A complete podcast episode"""
|
|
21
|
+
|
|
22
|
+
TRAILER = auto()
|
|
23
|
+
"""A short promotional or preview episode for a podcast"""
|
|
24
|
+
|
|
25
|
+
BONUS = auto()
|
|
26
|
+
"""
|
|
27
|
+
Additional content that is unlike a typical episode
|
|
28
|
+
(e.g., behind-the-scenes or a promotional episode for another podcast)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@unique
|
|
33
|
+
class AtomLinkRel(StrEnum):
|
|
34
|
+
ALTERNATE = auto()
|
|
35
|
+
RELATED = auto()
|
|
36
|
+
SELF = auto()
|
|
37
|
+
ENCLOSURE = auto()
|
|
38
|
+
VIA = auto()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@unique
|
|
42
|
+
class PodcastCategory(Enum):
|
|
43
|
+
ARTS = ("Arts", None)
|
|
44
|
+
BOOKS = ("Arts", "Books")
|
|
45
|
+
DESIGN = ("Arts", "Design")
|
|
46
|
+
FASHION_AND_BEAUTY = ("Arts", "Fashion & Beauty")
|
|
47
|
+
FOOD = ("Arts", "Food")
|
|
48
|
+
PERFORMING_ARTS = ("Arts", "Performing Arts")
|
|
49
|
+
VISUAL_ARTS = ("Arts", "Visual Arts")
|
|
50
|
+
|
|
51
|
+
BUSINESS = ("Business", None)
|
|
52
|
+
CAREERS = ("Business", "Careers")
|
|
53
|
+
ENTREPRENEURSHIP = ("Business", "Entrepreneurship")
|
|
54
|
+
INVESTING = ("Business", "Investing")
|
|
55
|
+
MANAGEMENT = ("Business", "Management")
|
|
56
|
+
MARKETING = ("Business", "Marketing")
|
|
57
|
+
NON_PROFIT = ("Business", "Non-Profit")
|
|
58
|
+
|
|
59
|
+
COMEDY = ("Comedy", None)
|
|
60
|
+
COMEDY_INTERVIEWS = ("Comedy", "Comedy Interviews")
|
|
61
|
+
IMPROV = ("Comedy", "Improv")
|
|
62
|
+
STAND_UP = ("Comedy", "Stand-Up")
|
|
63
|
+
|
|
64
|
+
EDUCATION = ("Education", None)
|
|
65
|
+
COURSES = ("Education", "Courses")
|
|
66
|
+
HOW_TO = ("Education", "How To")
|
|
67
|
+
LANGUAGE_LEARNING = ("Education", "Language Learning")
|
|
68
|
+
SELF_IMPROVEMENT = ("Education", "Self-Improvement")
|
|
69
|
+
|
|
70
|
+
FICTION = ("Fiction", None)
|
|
71
|
+
COMEDY_FICTION = ("Fiction", "Comedy Fiction")
|
|
72
|
+
DRAMA = ("Fiction", "Drama")
|
|
73
|
+
SCIENCE_FICTION = ("Fiction", "Science Fiction")
|
|
74
|
+
|
|
75
|
+
GOVERNMENT = ("Government", None)
|
|
76
|
+
|
|
77
|
+
HISTORY = ("History", None)
|
|
78
|
+
|
|
79
|
+
HEALTH_AND_FITNESS = ("Health & Fitness", None)
|
|
80
|
+
ALTERNATIVE_HEALTH = ("Health & Fitness", "Alternative Health")
|
|
81
|
+
FITNESS = ("Health & Fitness", "Fitness")
|
|
82
|
+
MEDICINE = ("Health & Fitness", "Medicine")
|
|
83
|
+
MENTAL_HEALTH = ("Health & Fitness", "Mental Health")
|
|
84
|
+
NUTRITION = ("Health & Fitness", "Nutrition")
|
|
85
|
+
SEXUALITY = ("Health & Fitness", "Sexuality")
|
|
86
|
+
|
|
87
|
+
KIDS_AND_FAMILY = ("Kids & Family", None)
|
|
88
|
+
EDUCATION_FOR_KIDS = ("Kids & Family", "Education for Kids")
|
|
89
|
+
PARENTING = ("Kids & Family", "Parenting")
|
|
90
|
+
PETS_AND_ANIMALS = ("Kids & Family", "Pets & Animals")
|
|
91
|
+
STORIES_FOR_KIDS = ("Kids & Family", "Stories for Kids")
|
|
92
|
+
|
|
93
|
+
LEISURE = ("Leisure", None)
|
|
94
|
+
ANIMATION_AND_MANGA = ("Leisure", "Animation & Manga")
|
|
95
|
+
AUTOMOTIVE = ("Leisure", "Automotive")
|
|
96
|
+
AVIATION = ("Leisure", "Aviation")
|
|
97
|
+
CRAFTS = ("Leisure", "Crafts")
|
|
98
|
+
GAMES = ("Leisure", "Games")
|
|
99
|
+
HOBBIES = ("Leisure", "Hobbies")
|
|
100
|
+
HOME_AND_GARDEN = ("Leisure", "Home & Garden")
|
|
101
|
+
VIDEO_GAMES = ("Leisure", "Video Games")
|
|
102
|
+
|
|
103
|
+
MUSIC = ("Music", None)
|
|
104
|
+
MUSIC_COMMENTARY = ("Music", "Music Commentary")
|
|
105
|
+
MUSIC_HISTORY = ("Music", "Music History")
|
|
106
|
+
MUSIC_INTERVIEWS = ("Music", "Music Interviews")
|
|
107
|
+
|
|
108
|
+
NEWS = ("News", None)
|
|
109
|
+
BUSINESS_NEWS = ("News", "Business News")
|
|
110
|
+
DAILY_NEWS = ("News", "Daily News")
|
|
111
|
+
ENTERTAINMENT_NEWS = ("News", "Entertainment News")
|
|
112
|
+
NEWS_COMMENTARY = ("News", "News Commentary")
|
|
113
|
+
POLITICS = ("News", "Politics")
|
|
114
|
+
SPORTS_NEWS = ("News", "Sports News")
|
|
115
|
+
TECH_NEWS = ("News", "Tech News")
|
|
116
|
+
|
|
117
|
+
RELIGION_AND_SPIRITUALITY = ("Religion & Spirituality", None)
|
|
118
|
+
BUDDHISM = ("Religion & Spirituality", "Buddhism")
|
|
119
|
+
CHRISTIANITY = ("Religion & Spirituality", "Christianity")
|
|
120
|
+
HINDUISM = ("Religion & Spirituality", "Hinduism")
|
|
121
|
+
ISLAM = ("Religion & Spirituality", "Islam")
|
|
122
|
+
JUDAISM = ("Religion & Spirituality", "Judaism")
|
|
123
|
+
RELIGION = ("Religion & Spirituality", "Religion")
|
|
124
|
+
SPIRITUALITY = ("Religion & Spirituality", "Spirituality")
|
|
125
|
+
|
|
126
|
+
SCIENCE = ("Science", None)
|
|
127
|
+
ASTRONOMY = ("Science", "Astronomy")
|
|
128
|
+
CHEMISTRY = ("Science", "Chemistry")
|
|
129
|
+
EARTH_SCIENCES = ("Science", "Earth Sciences")
|
|
130
|
+
LIFE_SCIENCES = ("Science", "Life Sciences")
|
|
131
|
+
MATHEMATICS = ("Science", "Mathematics")
|
|
132
|
+
NATURAL_SCIENCES = ("Science", "Natural Sciences")
|
|
133
|
+
NATURE = ("Science", "Nature")
|
|
134
|
+
PHYSICS = ("Science", "Physics")
|
|
135
|
+
SOCIAL_SCIENCES = ("Science", "Social Sciences")
|
|
136
|
+
|
|
137
|
+
SOCIETY_AND_CULTURE = ("Society & Culture", None)
|
|
138
|
+
DOCUMENTARY = ("Society & Culture", "Documentary")
|
|
139
|
+
PERSONAL_JOURNALS = ("Society & Culture", "Personal Journals")
|
|
140
|
+
PHILOSOPHY = ("Society & Culture", "Philosophy")
|
|
141
|
+
PLACES_AND_TRAVEL = ("Society & Culture", "Places & Travel")
|
|
142
|
+
RELATIONSHIPS = ("Society & Culture", "Relationships")
|
|
143
|
+
|
|
144
|
+
SPORTS = ("Sports", None)
|
|
145
|
+
BASEBALL = ("Sports", "Baseball")
|
|
146
|
+
BASKETBALL = ("Sports", "Basketball")
|
|
147
|
+
CRICKET = ("Sports", "Cricket")
|
|
148
|
+
FANTASY_SPORTS = ("Sports", "Fantasy Sports")
|
|
149
|
+
FOOTBALL = ("Sports", "Football")
|
|
150
|
+
GOLF = ("Sports", "Golf")
|
|
151
|
+
HOCKEY = ("Sports", "Hockey")
|
|
152
|
+
RUGBY = ("Sports", "Rugby")
|
|
153
|
+
RUNNING = ("Sports", "Running")
|
|
154
|
+
SOCCER = ("Sports", "Soccer")
|
|
155
|
+
SWIMMING = ("Sports", "Swimming")
|
|
156
|
+
TENNIS = ("Sports", "Tennis")
|
|
157
|
+
VOLLEYBALL = ("Sports", "Volleyball")
|
|
158
|
+
WILDERNESS = ("Sports", "Wilderness")
|
|
159
|
+
WRESTLING = ("Sports", "Wrestling")
|
|
160
|
+
|
|
161
|
+
TECHNOLOGY = ("Technology", None)
|
|
162
|
+
|
|
163
|
+
TRUE_CRIME = ("True Crime", None)
|
|
164
|
+
|
|
165
|
+
TV_AND_FILM = ("TV & Film", None)
|
|
166
|
+
AFTER_SHOWS = ("TV & Film", "After Shows")
|
|
167
|
+
FILM_HISTORY = ("TV & Film", "Film History")
|
|
168
|
+
FILM_INTERVIEWS = ("TV & Film", "Film Interviews")
|
|
169
|
+
FILM_REVIEWS = ("TV & Film", "Film Reviews")
|
|
170
|
+
TV_REVIEWS = ("TV & Film", "TV Reviews")
|
|
171
|
+
|
|
172
|
+
def __init__(self, category: str | None, sub_category: str):
|
|
173
|
+
self.category = category
|
|
174
|
+
self.sub_category = sub_category
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from datetime import timedelta
|
|
2
|
+
|
|
3
|
+
from pydantic import Field, PositiveInt
|
|
4
|
+
from pydantic_xml import attr, computed_element, element, wrapped
|
|
5
|
+
|
|
6
|
+
from podryk.models.enum import EpisodeType
|
|
7
|
+
from podryk.models.field_types import (
|
|
8
|
+
URL,
|
|
9
|
+
CData,
|
|
10
|
+
DateTime,
|
|
11
|
+
DurationInSeconds,
|
|
12
|
+
YesBool,
|
|
13
|
+
)
|
|
14
|
+
from podryk.models.namespaces import NAMESPACES, Namespace
|
|
15
|
+
from podryk.models.sub_types import (
|
|
16
|
+
Chapter,
|
|
17
|
+
Chapters,
|
|
18
|
+
Enclosure,
|
|
19
|
+
Guid,
|
|
20
|
+
Transcript,
|
|
21
|
+
)
|
|
22
|
+
from podryk.models.xml_model import XmlModel
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Episode(XmlModel, tag="item", nsmap=NAMESPACES):
|
|
26
|
+
title: str = element()
|
|
27
|
+
"""
|
|
28
|
+
The title for the podcast episode.
|
|
29
|
+
|
|
30
|
+
The title value is a string containing a concise name for your episode.
|
|
31
|
+
Title values should not include season or episode numbers, as there are specific elements to capture those values.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
enclosure: Enclosure = element()
|
|
35
|
+
"""
|
|
36
|
+
The audio/video episode content, file size, and file type information.
|
|
37
|
+
|
|
38
|
+
Supported file formats include MP3 (.mp3) and MPEG-4 (.m4a, .m4v, .mp4).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
guid: Guid = element()
|
|
42
|
+
"""
|
|
43
|
+
The globally unique identifier (GUID) for a podcast episode.
|
|
44
|
+
|
|
45
|
+
Each episode must have a unique GUID that never changes. Values are case-sensitive strings.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
# Optional fields
|
|
49
|
+
link: URL | None = element(default=None)
|
|
50
|
+
"""
|
|
51
|
+
The URL of a web page associated with the podcast episode.
|
|
52
|
+
|
|
53
|
+
Useful when an episode has a corresponding webpage.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
publication_date: DateTime | None = element(tag="pubDate", default=None)
|
|
57
|
+
"""The release date and time of an episode."""
|
|
58
|
+
|
|
59
|
+
description: CData[str | None] = element(default=None)
|
|
60
|
+
"""
|
|
61
|
+
The description of the podcast episode.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
# Fields from itunes namespace
|
|
65
|
+
|
|
66
|
+
duration: DurationInSeconds[timedelta | None] = element(ns=Namespace.ITUNES, default=None)
|
|
67
|
+
"""The duration of a podcast episode in seconds."""
|
|
68
|
+
|
|
69
|
+
image: URL | None = wrapped(
|
|
70
|
+
"image",
|
|
71
|
+
ns=Namespace.ITUNES,
|
|
72
|
+
entity=attr(name="href", default=None),
|
|
73
|
+
)
|
|
74
|
+
"""
|
|
75
|
+
The episode-specific artwork.
|
|
76
|
+
|
|
77
|
+
Verify the web server hosting your image allows HTTP head requests.
|
|
78
|
+
|
|
79
|
+
Image must be a minimum size of 1400 x 1400 pixels and a maximum size of 3000 x 3000 pixels,
|
|
80
|
+
in JPEG or PNG format, 72 dpi, with appropriate file extensions (.jpg, .png), and in the sRGB colorspace.
|
|
81
|
+
File type extension must match the actual file type of the image file.
|
|
82
|
+
"""
|
|
83
|
+
explicit: bool | None = element(ns=Namespace.ITUNES, default=None)
|
|
84
|
+
"""
|
|
85
|
+
The parental advisory information for a podcast episode.
|
|
86
|
+
|
|
87
|
+
The value can be true, indicating the presence of explicit content, or false,
|
|
88
|
+
indicating that a podcast doesn’t contain explicit language or adult content.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
season_number: PositiveInt | None = element(tag="season", ns=Namespace.ITUNES, default=None)
|
|
92
|
+
"""The chronological number associated with a podcast episode's season."""
|
|
93
|
+
|
|
94
|
+
episode_number: PositiveInt | None = element(tag="episode", ns=Namespace.ITUNES, default=None)
|
|
95
|
+
"""
|
|
96
|
+
The chronological number that is associated with a podcast episode.
|
|
97
|
+
|
|
98
|
+
This is required for serial podcasts.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
type: EpisodeType | None = element(tag="episodeType", ns=Namespace.ITUNES, default=None)
|
|
102
|
+
"""Defines the type of content for a specific podcast episode."""
|
|
103
|
+
|
|
104
|
+
block: YesBool[bool | None] = element(ns=Namespace.ITUNES, default=None)
|
|
105
|
+
"""Prevents a specific episode from appearing in podcast listening applications."""
|
|
106
|
+
|
|
107
|
+
# Fields from podcast namespace
|
|
108
|
+
|
|
109
|
+
transcripts: list[Transcript] | None = element(default=None)
|
|
110
|
+
"""A link to a transcript or closed captions file. Multiple tags can be present for multiple formats."""
|
|
111
|
+
|
|
112
|
+
# Fields from podlove namespace
|
|
113
|
+
|
|
114
|
+
chapters: list[Chapter] | None = Field(exclude=True, default=None)
|
|
115
|
+
|
|
116
|
+
@computed_element
|
|
117
|
+
def _chapters(self) -> Chapters | None:
|
|
118
|
+
return Chapters(chapters=self.chapters) if self.chapters else None
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
from datetime import datetime, timedelta
|
|
2
|
+
from email.utils import format_datetime
|
|
3
|
+
from typing import Annotated, TypeVar
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
import content_types
|
|
7
|
+
from lxml.etree import CDATA
|
|
8
|
+
from pydantic import (
|
|
9
|
+
AfterValidator,
|
|
10
|
+
AwareDatetime,
|
|
11
|
+
BeforeValidator,
|
|
12
|
+
HttpUrl,
|
|
13
|
+
PlainSerializer,
|
|
14
|
+
StringConstraints,
|
|
15
|
+
)
|
|
16
|
+
from pydantic_xml import BaseXmlModel, XmlFieldSerializer
|
|
17
|
+
from pydantic_xml.element import XmlElementWriter
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _bool_to_yes_no(value: bool | None) -> str:
|
|
21
|
+
return "yes" if value else "no"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _bool_to_yes(value: bool | None) -> str | None:
|
|
25
|
+
return "yes" if value else None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _bool_to_true_false(value: bool | None) -> str:
|
|
29
|
+
return "true" if value else "false"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _timedelta_to_seconds(value: timedelta | None) -> str | None:
|
|
33
|
+
return None if value is None else round(value.total_seconds())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _convert_timedelta(value: timedelta | int | None) -> timedelta | None:
|
|
37
|
+
if isinstance(value, timedelta):
|
|
38
|
+
return value
|
|
39
|
+
elif isinstance(value, int):
|
|
40
|
+
return timedelta(seconds=value)
|
|
41
|
+
else:
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _convert_uuid(value: UUID | str | None) -> UUID | None:
|
|
46
|
+
if isinstance(value, UUID):
|
|
47
|
+
return value
|
|
48
|
+
elif isinstance(value, str):
|
|
49
|
+
return UUID(value)
|
|
50
|
+
else:
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _timedelta_to_npt(value: timedelta | None) -> str | None:
|
|
55
|
+
"""Format a timedelta as a Normal Play Time (HH:MM:SS.mmm)."""
|
|
56
|
+
if value is None:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
total_seconds = int(value.total_seconds())
|
|
60
|
+
|
|
61
|
+
hours, remainder = divmod(total_seconds, 3_600)
|
|
62
|
+
minutes, seconds = divmod(remainder, 60)
|
|
63
|
+
milliseconds = value.microseconds // 1_000
|
|
64
|
+
|
|
65
|
+
return f"{hours:02}:{minutes:02}:{seconds:02}.{milliseconds:03}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _datetime_to_rfc2822_string(value: datetime | None) -> str | None:
|
|
69
|
+
return None if value is None else format_datetime(value)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _validate_media_type(media_type: str | None) -> str:
|
|
73
|
+
if (
|
|
74
|
+
media_type
|
|
75
|
+
and media_type not in content_types.EXTENSION_TO_CONTENT_TYPE.values()
|
|
76
|
+
):
|
|
77
|
+
raise ValueError(f"{media_type} not a recognized media type")
|
|
78
|
+
return media_type
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _string_to_cdata(
|
|
82
|
+
_: BaseXmlModel, element: XmlElementWriter, value: str | None, field_name: str
|
|
83
|
+
) -> None:
|
|
84
|
+
if value:
|
|
85
|
+
sub_element = element.make_element(tag=field_name, nsmap=None)
|
|
86
|
+
# noinspection PyTypeChecker
|
|
87
|
+
sub_element.set_text(CDATA(value))
|
|
88
|
+
element.append_element(sub_element)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _check_byte_size(max_size: int):
|
|
92
|
+
def wrapper(value: str | None) -> str | None:
|
|
93
|
+
actual_size = len(value.encode()) if value else 0
|
|
94
|
+
if actual_size > max_size:
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"String size of {actual_size} bytes exceeds maximum of {max_size} bytes"
|
|
97
|
+
)
|
|
98
|
+
return value
|
|
99
|
+
|
|
100
|
+
return wrapper
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _check_check_positive_timedelta(value: timedelta | None) -> timedelta | None:
|
|
104
|
+
if value is not None and value < timedelta():
|
|
105
|
+
raise ValueError(f"Duration must be positive: {value}")
|
|
106
|
+
return value
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _check_uuid_v5(value: UUID | None):
|
|
110
|
+
if value is not None and value.version != 5:
|
|
111
|
+
raise ValueError(
|
|
112
|
+
f"UUID version 5 expected [version={value.version}, value={value}]"
|
|
113
|
+
)
|
|
114
|
+
return value
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_BoolType = TypeVar("_BoolType", bool, bool | None, default=bool)
|
|
118
|
+
_StringType = TypeVar("_StringType", str, str | None, default=str)
|
|
119
|
+
_DurationType = TypeVar(
|
|
120
|
+
"_DurationType", timedelta | int, timedelta | int | None, default=timedelta | int
|
|
121
|
+
)
|
|
122
|
+
_UUIDv5Type = TypeVar("_UUIDv5Type", UUID | str, UUID | str, default=UUID | str)
|
|
123
|
+
|
|
124
|
+
URL = Annotated[str, HttpUrl]
|
|
125
|
+
YesNoBool = Annotated[_BoolType, PlainSerializer(_bool_to_yes_no)]
|
|
126
|
+
YesBool = Annotated[_BoolType, PlainSerializer(_bool_to_yes)]
|
|
127
|
+
MediaType = Annotated[_StringType, AfterValidator(_validate_media_type)]
|
|
128
|
+
CData = Annotated[
|
|
129
|
+
_StringType,
|
|
130
|
+
XmlFieldSerializer(_string_to_cdata),
|
|
131
|
+
AfterValidator(_check_byte_size(max_size=4000)),
|
|
132
|
+
]
|
|
133
|
+
DurationInSeconds = Annotated[
|
|
134
|
+
_DurationType,
|
|
135
|
+
BeforeValidator(_convert_timedelta),
|
|
136
|
+
AfterValidator(_check_check_positive_timedelta),
|
|
137
|
+
PlainSerializer(_timedelta_to_seconds),
|
|
138
|
+
]
|
|
139
|
+
DateTime = Annotated[
|
|
140
|
+
datetime, AwareDatetime, PlainSerializer(_datetime_to_rfc2822_string)
|
|
141
|
+
]
|
|
142
|
+
Duration = Annotated[
|
|
143
|
+
_DurationType,
|
|
144
|
+
BeforeValidator(_convert_timedelta),
|
|
145
|
+
AfterValidator(_check_check_positive_timedelta),
|
|
146
|
+
PlainSerializer(_timedelta_to_npt),
|
|
147
|
+
]
|
|
148
|
+
Language = Annotated[
|
|
149
|
+
_StringType, StringConstraints(pattern=r"^[a-z]{2,3}(?:-[a-z]{2})?$", to_lower=True)
|
|
150
|
+
]
|
|
151
|
+
UUIDv5 = Annotated[
|
|
152
|
+
_UUIDv5Type,
|
|
153
|
+
BeforeValidator(_convert_uuid),
|
|
154
|
+
# can't use pydantic.types.UuidVersion(5) because it doesn't support nullable types
|
|
155
|
+
AfterValidator(_check_uuid_v5),
|
|
156
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# noinspection HttpUrlsUsage
|
|
2
|
+
class Namespace(object):
|
|
3
|
+
ITUNES = "itunes"
|
|
4
|
+
PODCAST = "podcast"
|
|
5
|
+
ATOM = "atom"
|
|
6
|
+
CONTENT = "content"
|
|
7
|
+
"""Vocabulary for handling encoded content."""
|
|
8
|
+
|
|
9
|
+
MEDIA = "media"
|
|
10
|
+
DC_TERMS = "dcterms"
|
|
11
|
+
"""Dublin Core metadata terms to describe discovery/availability."""
|
|
12
|
+
|
|
13
|
+
CHAPTERS = "psc"
|
|
14
|
+
"""Podlove's Simple Chapter for episode chapters."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
NAMESPACES = {
|
|
18
|
+
Namespace.ITUNES: "http://www.itunes.com/dtds/podcast-1.0.dtd",
|
|
19
|
+
Namespace.PODCAST: "https://podcastindex.org/namespace/1.0",
|
|
20
|
+
Namespace.ATOM: "http://www.w3.org/2005/Atom",
|
|
21
|
+
Namespace.CONTENT: "http://purl.org/rss/1.0/modules/content/",
|
|
22
|
+
Namespace.MEDIA: "http://search.yahoo.com/mrss/",
|
|
23
|
+
Namespace.DC_TERMS: "http://purl.org/dc/terms/",
|
|
24
|
+
Namespace.CHAPTERS: "http://podlove.org/simple-chapters",
|
|
25
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
from pydantic_xml import BaseXmlModel, attr, computed_element, element, wrapped
|
|
6
|
+
|
|
7
|
+
from podryk.models.enum import PodcastCategory, PodcastType
|
|
8
|
+
from podryk.models.episode import Episode
|
|
9
|
+
from podryk.models.field_types import URL, CData, Language, UUIDv5, YesBool, YesNoBool
|
|
10
|
+
from podryk.models.namespaces import NAMESPACES, Namespace
|
|
11
|
+
from podryk.models.sub_types import AtomLink, Category, TextRecord
|
|
12
|
+
from podryk.models.xml_model import XmlModel
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Podcast(XmlModel, tag="channel", nsmap=NAMESPACES):
|
|
16
|
+
canonical_link: URL = Field(exclude=True)
|
|
17
|
+
"""The declared canonical feed URL for the podcast."""
|
|
18
|
+
|
|
19
|
+
title: str = element()
|
|
20
|
+
"""
|
|
21
|
+
The podcast title. A string containing the name of a podcast and nothing else.
|
|
22
|
+
|
|
23
|
+
Including keywords in an attempt to improve a podcast's search ranking,
|
|
24
|
+
may result in being blocked from certain directories.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
description: CData = element()
|
|
28
|
+
"""Text that describes a podcast to potential listeners."""
|
|
29
|
+
|
|
30
|
+
link: URL = element()
|
|
31
|
+
"""The website or web page associated with a podcast."""
|
|
32
|
+
|
|
33
|
+
language: Language = element()
|
|
34
|
+
"""The language that is spoken on the podcast, specified in the ISO 639 format."""
|
|
35
|
+
|
|
36
|
+
episodes: List[Episode] = element(min_length=1)
|
|
37
|
+
"""Episodes in the podcast."""
|
|
38
|
+
|
|
39
|
+
copyright: str | None = element(default=None)
|
|
40
|
+
"""
|
|
41
|
+
The copyright details for a podcast.
|
|
42
|
+
|
|
43
|
+
Should not include the word "Copyright", the © symbol, and/or a year.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
# Fields from itunes namespace
|
|
47
|
+
|
|
48
|
+
categories: List[PodcastCategory] = element(exclude=True, default_factory=list)
|
|
49
|
+
"""
|
|
50
|
+
The category that best fits a podcast, selected from the list of Apple Podcasts categories:
|
|
51
|
+
https://podcasters.apple.com/support/1691-apple-podcasts-categories
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
explicit: bool = element(ns=Namespace.ITUNES)
|
|
55
|
+
"""The parental advisory information for a podcast."""
|
|
56
|
+
|
|
57
|
+
image: URL | None = wrapped(
|
|
58
|
+
"image",
|
|
59
|
+
ns=Namespace.ITUNES,
|
|
60
|
+
entity=attr(name="href", default=None),
|
|
61
|
+
)
|
|
62
|
+
"""
|
|
63
|
+
The artwork for the podcast, specified by providing a URL linking to it.
|
|
64
|
+
|
|
65
|
+
Verify the web server hosting your image allows HTTP head requests.
|
|
66
|
+
|
|
67
|
+
Image must be a minimum size of 1400 x 1400 pixels and a maximum size of 3000 x 3000 pixels,
|
|
68
|
+
in JPEG or PNG format, 72 dpi, with appropriate file extensions (.jpg, .png), and in the RGB colorspace.
|
|
69
|
+
File type extension must match the actual file type of the image file.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
author: str | None = element(ns=Namespace.ITUNES, default=None)
|
|
73
|
+
"""The group, person, or people responsible for creating the podcast."""
|
|
74
|
+
|
|
75
|
+
type: PodcastType | None = element(ns=Namespace.ITUNES, default=None)
|
|
76
|
+
"""
|
|
77
|
+
Specifies the podcast as either episodic or serial.
|
|
78
|
+
|
|
79
|
+
Episodic is the default and assumed if this element is not present. This element is required for serial podcasts.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
complete: YesBool[bool | None] = element(ns=Namespace.ITUNES, default=None)
|
|
83
|
+
"""Specifies that a podcast is complete and will not post any more episodes in the future."""
|
|
84
|
+
|
|
85
|
+
# Fields from podcast namespace
|
|
86
|
+
|
|
87
|
+
locked: YesNoBool[bool | None] = element(ns=Namespace.PODCAST, default=None)
|
|
88
|
+
"""Tells podcast hosting platforms whether they are allowed to import this feed."""
|
|
89
|
+
|
|
90
|
+
guid: UUIDv5[UUID | str] | None = element(
|
|
91
|
+
ns=Namespace.PODCAST, default=None
|
|
92
|
+
)
|
|
93
|
+
"""
|
|
94
|
+
The globally unique identifier (GUID) for a podcast.
|
|
95
|
+
|
|
96
|
+
The value is a UUIDv5, and generated from the RSS feed URL,
|
|
97
|
+
with the protocol scheme and trailing slashes stripped off,
|
|
98
|
+
combined with a unique "podcast" namespace which has a UUID of ead4c236-bf58-58c6-a2c6-a6b28d128cb6.
|
|
99
|
+
|
|
100
|
+
A podcast should be assigned a <podcast:guid> once in its lifetime,
|
|
101
|
+
using its current feed URL at the time of assignment as the seed value.
|
|
102
|
+
That GUID is then meant to follow the podcast from then on, for the duration of its existence,
|
|
103
|
+
even if the feed URL changes. This means that when a podcast moves from one hosting platform to another,
|
|
104
|
+
its <podcast:guid> should be discovered by the new host and imported into the new platform for inclusion
|
|
105
|
+
into the feed.
|
|
106
|
+
|
|
107
|
+
Using this pattern, podcasts can maintain a consistent identity across the open podcasting ecosystem without
|
|
108
|
+
the need for a central authority.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
text_records: list[TextRecord] | None = element(default=None)
|
|
112
|
+
"""
|
|
113
|
+
A free-form text field to present a string in a podcast feed.
|
|
114
|
+
|
|
115
|
+
One use case of this is to verify ownership. For example, a show owner may be asked
|
|
116
|
+
to add a unique text field to prove that they control the feed (and therefore the show).
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
@computed_element
|
|
120
|
+
def _canonical_link(self) -> AtomLink:
|
|
121
|
+
if isinstance(self.canonical_link, AtomLink):
|
|
122
|
+
return self.canonical_link
|
|
123
|
+
else:
|
|
124
|
+
return AtomLink(href=self.canonical_link)
|
|
125
|
+
|
|
126
|
+
@computed_element
|
|
127
|
+
def _categories(self) -> list[Category] | None:
|
|
128
|
+
results: dict[str, Category] = {}
|
|
129
|
+
for category in self.categories:
|
|
130
|
+
category_name, sub_category_name = category.category, category.sub_category
|
|
131
|
+
|
|
132
|
+
# Add parent category
|
|
133
|
+
if category_name not in results:
|
|
134
|
+
results[category.category] = Category(text=category_name)
|
|
135
|
+
|
|
136
|
+
parent = results[category.category]
|
|
137
|
+
|
|
138
|
+
# Add sub category
|
|
139
|
+
if sub_category_name and all(
|
|
140
|
+
sub_category.text != sub_category
|
|
141
|
+
for sub_category in parent.sub_categories
|
|
142
|
+
):
|
|
143
|
+
parent.sub_categories.append(Category(text=sub_category_name))
|
|
144
|
+
|
|
145
|
+
return list(results.values())
|
|
146
|
+
|
|
147
|
+
def to_feed(self) -> bytes:
|
|
148
|
+
return _PodcastFeed(channel=self).to_xml(
|
|
149
|
+
xml_declaration=True,
|
|
150
|
+
pretty_print=True,
|
|
151
|
+
encoding="UTF-8",
|
|
152
|
+
exclude_none=True,
|
|
153
|
+
skip_empty=True,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class _PodcastFeed(BaseXmlModel, tag="rss", nsmap=NAMESPACES):
|
|
158
|
+
version: str = attr(default="2.0")
|
|
159
|
+
channel: Podcast = element()
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
from pydantic import (
|
|
6
|
+
HttpUrl,
|
|
7
|
+
TypeAdapter,
|
|
8
|
+
ValidationError,
|
|
9
|
+
model_validator,
|
|
10
|
+
)
|
|
11
|
+
from pydantic_xml import attr, element
|
|
12
|
+
|
|
13
|
+
from podryk.models.enum import AtomLinkRel
|
|
14
|
+
from podryk.models.field_types import URL, Duration, MediaType
|
|
15
|
+
from podryk.models.namespaces import NAMESPACES, Namespace
|
|
16
|
+
from podryk.models.xml_model import XmlModel
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Guid(XmlModel, tag="guid"):
|
|
20
|
+
"""Globally unique identifier for an episode."""
|
|
21
|
+
|
|
22
|
+
is_permalink: bool | None = attr(name="isPermaLink")
|
|
23
|
+
guid: str | URL | uuid.UUID
|
|
24
|
+
|
|
25
|
+
@model_validator(mode="before")
|
|
26
|
+
@classmethod
|
|
27
|
+
def set_permalink_default(cls, data: dict) -> dict:
|
|
28
|
+
guid = data.get("guid")
|
|
29
|
+
is_permalink = data.get("is_permalink")
|
|
30
|
+
|
|
31
|
+
if is_permalink is None:
|
|
32
|
+
try:
|
|
33
|
+
TypeAdapter(HttpUrl).validate_python(guid)
|
|
34
|
+
data["is_permalink"] = None
|
|
35
|
+
except ValidationError:
|
|
36
|
+
data["is_permalink"] = False
|
|
37
|
+
|
|
38
|
+
return data
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Enclosure(XmlModel, tag="enclosure"):
|
|
42
|
+
"""The audio/video episode content, file size, and file type information."""
|
|
43
|
+
|
|
44
|
+
url: URL = attr()
|
|
45
|
+
"""Location of the media file."""
|
|
46
|
+
|
|
47
|
+
length: int = attr()
|
|
48
|
+
"""The length of the file in bytes."""
|
|
49
|
+
|
|
50
|
+
type: MediaType = attr()
|
|
51
|
+
"""MIME type of the media file."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Transcript(XmlModel, tag="transcript", ns=Namespace.PODCAST):
|
|
55
|
+
url: URL = attr()
|
|
56
|
+
type: MediaType = attr()
|
|
57
|
+
language: str | None = attr(default=None)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Chapter(XmlModel, tag="chapter", ns=Namespace.CHAPTERS):
|
|
61
|
+
start: Duration = attr()
|
|
62
|
+
"""Refers to a point in time relative to the start of the media file."""
|
|
63
|
+
title: str = attr()
|
|
64
|
+
"""Title of the chapter."""
|
|
65
|
+
href: URL | None = attr(default=None)
|
|
66
|
+
"""External link that provides related information to the chapter."""
|
|
67
|
+
|
|
68
|
+
image: URL | None = attr(default=None)
|
|
69
|
+
"""External link to an image associated with the chapter. The image should have a 1:1 aspect ratio."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Chapters(XmlModel, tag="chapters", ns=Namespace.CHAPTERS):
|
|
73
|
+
version: str = attr(default="1.2")
|
|
74
|
+
chapters: list[Chapter] = element()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class AtomLink(XmlModel, tag="link", ns=Namespace.ATOM):
|
|
78
|
+
href: URL = attr()
|
|
79
|
+
rel: AtomLinkRel = attr(default=AtomLinkRel.SELF)
|
|
80
|
+
type: MediaType = attr(default="application/rss+xml")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TextRecord(XmlModel, tag="txt", ns=Namespace.PODCAST):
|
|
84
|
+
"""
|
|
85
|
+
A free-form text field to present a string in a podcast feed.
|
|
86
|
+
|
|
87
|
+
One use case of this is to verify ownership. For example, a show owner may be asked
|
|
88
|
+
to add a unique text field to prove that they control the feed (and therefore the show).
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
purpose: str | None = attr(default=None)
|
|
92
|
+
"""A service specific string to denote the purpose for the field."""
|
|
93
|
+
|
|
94
|
+
content: str
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Category(XmlModel, tag="category", ns=Namespace.ITUNES, nsmap=NAMESPACES):
|
|
98
|
+
text: str = attr()
|
|
99
|
+
sub_categories: list[Category] = element(default_factory=list)
|
|
File without changes
|