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.
@@ -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)
@@ -0,0 +1,11 @@
1
+ from abc import ABC
2
+
3
+ from pydantic import ConfigDict
4
+ from pydantic_xml import BaseXmlModel
5
+
6
+
7
+ class XmlModel(BaseXmlModel, ABC):
8
+ model_config = ConfigDict(
9
+ use_attribute_docstrings=True,
10
+ extra="forbid",
11
+ )
File without changes