cs-mediainfo 20260531__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,161 @@
1
+ Metadata-Version: 2.4
2
+ Name: cs-mediainfo
3
+ Version: 20260531
4
+ Summary: Simple minded facilities for media information inferred from filenames. This contains mostly lexical functions for extracting information from strings or constructing media filenames from metadata and a few classes like `EpisodeInfo` and `SeriesEpisodeInfo` for common descriptions.
5
+ Keywords: python2,python3
6
+ Author-email: Cameron Simpson <cs@cskk.id.au>
7
+ Description-Content-Type: text/markdown
8
+ Classifier: Programming Language :: Python
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
14
+ Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
15
+ Requires-Dist: cs.deco>=20260525
16
+ Requires-Dist: cs.gimmicks>=20260311
17
+ Requires-Dist: cs.lex>=20260526
18
+ Requires-Dist: cs.pfx>=20250914
19
+ Requires-Dist: cs.tagset>=20260531
20
+ Project-URL: MonoRepo Commits, https://bitbucket.org/cameron_simpson/css/commits/branch/main
21
+ Project-URL: Monorepo Git Mirror, https://github.com/cameron-simpson/css
22
+ Project-URL: Monorepo Hg/Mercurial Mirror, https://hg.sr.ht/~cameron-simpson/css
23
+ Project-URL: Source, https://github.com/cameron-simpson/css/blob/main/lib/python/cs/mediainfo.py
24
+
25
+ Simple minded facilities for media information inferred from filenames.
26
+ This contains mostly lexical functions for extracting information from strings
27
+ or constructing media filenames from metadata and a few classes
28
+ like `EpisodeInfo` and `SeriesEpisodeInfo` for common descriptions.
29
+
30
+ *Latest release 20260531*:
31
+ Bugfix a parse return value, small doc updates.
32
+
33
+ The default filename parsing rules are based on my personal convention,
34
+ which is to name media files as:
35
+
36
+ series_name--episode_info--title--source--etc-etc.ext
37
+
38
+ where the components are:
39
+ * `series_name`:
40
+ the programme series name downcased and with whitespace replaced by dashes;
41
+ in the case of standalone items like movies this is often the studio.
42
+ * `episode_info`: a structured field with episode information:
43
+ `s`_n_ is a series/season,
44
+ `e`_n_` is an episode number within the season,
45
+ `x`_n_` is a "extra" - addition material supplied with the season,
46
+ etc.
47
+ * `title`: the episode title downcased and with whitespace replaced by dashes.
48
+ * `source`: the source of the media.
49
+ * `ext`: filename extension such as `mp4`.
50
+
51
+ As you may imagine,
52
+ as a rule I dislike mixed case filenames
53
+ and filenames with embedded whitespace.
54
+ I also like a media filename to contain enough information
55
+ to identify the file contents in a compact and human readable form.
56
+
57
+ Short summary:
58
+ * `EpisodeDatumDefn`: An `EpisodeInfo` marker definition with the following components: - `name`: the marker name, such as `"series"` or `"episode"` - `prefix`: the stub used in a filename, such as `"s"` or `"e"` - `re`: a regular expression to match the `prefix` an some digits.
59
+ * `EpisodeInfo`: Trite class for episodic information, used to store, match or transcribe series/season, episode, etc values.
60
+ * `main`: Main command line running some test code.
61
+ * `parse_name`: Parse the descriptive part of a filename (the portion remaining after stripping the file extension) and yield `(part,fields)` for each part as delineated by `sep`.
62
+ * `part_to_title`: Convert a filename part into a title string.
63
+ * `pathname_info`: Parse information from the basename of a file pathname. Return a mapping of field => values in the order parsed.
64
+ * `scrub_title`: Strip redundant text from the start of an episode title.
65
+ * `SeriesEpisodeInfo`: Episode information from a TV series episode.
66
+ * `title_to_part`: Convert a title string into a filename part. This is lossy; the `part_to_title` function cannot completely reverse this.
67
+
68
+ Module contents:
69
+ - <a name="EpisodeDatumDefn"></a>`class EpisodeDatumDefn(EpisodeDatumDefn)`: An `EpisodeInfo` marker definition with the following components:
70
+ - `name`: the marker name, such as `"series"` or `"episode"`
71
+ - `prefix`: the stub used in a filename, such as `"s"` or `"e"`
72
+ - `re`: a regular expression to match the `prefix` an some digits
73
+
74
+ *`EpisodeDatumDefn.parse(self, s, offset=0)`*:
75
+ Parse an episode datum from a string, return the value and new offset.
76
+ Raise `ValueError` if the string doesn't match this definition.
77
+
78
+ Parameters:
79
+ * `s`: the string
80
+ * `offset`: parse offset, default 0
81
+ - <a name="EpisodeInfo"></a>`class EpisodeInfo(types.SimpleNamespace)`: Trite class for episodic information, used to store, match
82
+ or transcribe series/season, episode, etc values.
83
+
84
+ *`EpisodeInfo.__getitem__(self, name)`*:
85
+ We can look up values by name.
86
+
87
+ *`EpisodeInfo.as_dict(self)`*:
88
+ Return the episode info as a `dict`.
89
+
90
+ *`EpisodeInfo.as_tags(self, prefix=None)`*:
91
+ Generator yielding the episode info as `Tag`s.
92
+
93
+ *`EpisodeInfo.from_filename_part(s, offset=0)`*:
94
+ Factory to return an `EpisodeInfo` from a filename episode field.
95
+
96
+ Parameters:
97
+ * `s`: the string containing the episode information
98
+ * `offset`: the start of the episode information, default 0
99
+
100
+ The episode information must extend to the end of the string
101
+ because the factory returns just the information. See the
102
+ `parse_filename_part` class method for the core parse.
103
+
104
+ *`EpisodeInfo.get(self, name, default=None)`*:
105
+ Look up value by name with default.
106
+
107
+ *`EpisodeInfo.parse_filename_part(s, offset=0)`*:
108
+ Parse episode information from a string,
109
+ returning the matched fields and the new offset.
110
+
111
+ Parameters:
112
+ `s`: the string containing the episode information.
113
+ `offset`: the starting offset of the information, default 0.
114
+
115
+ *`EpisodeInfo.season`*:
116
+ .season property, synonym for .series
117
+ - <a name="main"></a>`main(argv=None)`: Main command line running some test code.
118
+ - <a name="parse_name"></a>`parse_name(name, sep='--')`: Parse the descriptive part of a filename
119
+ (the portion remaining after stripping the file extension)
120
+ and yield `(part,fields)` for each part as delineated by `sep`.
121
+ - <a name="part_to_title"></a>`part_to_title(part)`: Convert a filename part into a title string.
122
+
123
+ Example:
124
+
125
+ >>> part_to_title('episode-name')
126
+ 'Episode Name'
127
+ - <a name="pathname_info"></a>`pathname_info(pathname)`: Parse information from the basename of a file pathname.
128
+ Return a mapping of field => values in the order parsed.
129
+ - <a name="scrub_title"></a>`scrub_title(title: str, *, season=None, episode=None) -> str`: Strip redundant text from the start of an episode title.
130
+
131
+ I frequently get "title" strings with leading season/episode information.
132
+ This function cleans up these strings to return the unadorned title.
133
+ - <a name="SeriesEpisodeInfo"></a>`class SeriesEpisodeInfo(cs.deco.Promotable)`: Episode information from a TV series episode.
134
+
135
+ *`SeriesEpisodeInfo.as_dict(self)`*:
136
+ Return the non-`None` values as a `dict`.
137
+ Note that this uses `dataclasses.asdict()` and as such is a deep copy.
138
+
139
+ *`SeriesEpisodeInfo.from_str(episode_title: str, series=None)`*:
140
+ Infer a `SeriesEpisodeInfo` from an episode title.
141
+
142
+ This recognises the common `'sSSeEE - Episode Title'` format
143
+ and variants like `Series Name - sSSeEE - Episode Title'`
144
+ or `'sSSeEE - Episode Title - Part: One'`.
145
+ - <a name="title_to_part"></a>`title_to_part(title)`: Convert a title string into a filename part.
146
+ This is lossy; the `part_to_title` function cannot completely reverse this.
147
+
148
+ Example:
149
+
150
+ >>> title_to_part('Episode Name')
151
+ 'episode-name'
152
+
153
+ # Release Log
154
+
155
+
156
+
157
+ *Release 20260531*:
158
+ Bugfix a parse return value, small doc updates.
159
+
160
+ *Release 20240519*:
161
+ Initial PyPI release, particularly for SeriesEpisodeInfo which I use in cs.app.playon.
@@ -0,0 +1,186 @@
1
+ [project]
2
+ name = "cs-mediainfo"
3
+ description = "Simple minded facilities for media information inferred from filenames. This contains mostly lexical functions for extracting information from strings or constructing media filenames from metadata and a few classes like `EpisodeInfo` and `SeriesEpisodeInfo` for common descriptions."
4
+ authors = [
5
+ { name = "Cameron Simpson", email = "cs@cskk.id.au" },
6
+ ]
7
+ keywords = [
8
+ "python2",
9
+ "python3",
10
+ ]
11
+ dependencies = [
12
+ "cs.deco>=20260525",
13
+ "cs.gimmicks>=20260311",
14
+ "cs.lex>=20260526",
15
+ "cs.pfx>=20250914",
16
+ "cs.tagset>=20260531",
17
+ ]
18
+ classifiers = [
19
+ "Programming Language :: Python",
20
+ "Programming Language :: Python :: 3",
21
+ "Development Status :: 4 - Beta",
22
+ "Intended Audience :: Developers",
23
+ "Operating System :: OS Independent",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
26
+ ]
27
+ version = "20260531"
28
+
29
+ [project.license]
30
+ text = "GNU General Public License v3 or later (GPLv3+)"
31
+
32
+ [project.urls]
33
+ "Monorepo Hg/Mercurial Mirror" = "https://hg.sr.ht/~cameron-simpson/css"
34
+ "Monorepo Git Mirror" = "https://github.com/cameron-simpson/css"
35
+ "MonoRepo Commits" = "https://bitbucket.org/cameron_simpson/css/commits/branch/main"
36
+ Source = "https://github.com/cameron-simpson/css/blob/main/lib/python/cs/mediainfo.py"
37
+
38
+ [project.readme]
39
+ text = """
40
+ Simple minded facilities for media information inferred from filenames.
41
+ This contains mostly lexical functions for extracting information from strings
42
+ or constructing media filenames from metadata and a few classes
43
+ like `EpisodeInfo` and `SeriesEpisodeInfo` for common descriptions.
44
+
45
+ *Latest release 20260531*:
46
+ Bugfix a parse return value, small doc updates.
47
+
48
+ The default filename parsing rules are based on my personal convention,
49
+ which is to name media files as:
50
+
51
+ series_name--episode_info--title--source--etc-etc.ext
52
+
53
+ where the components are:
54
+ * `series_name`:
55
+ the programme series name downcased and with whitespace replaced by dashes;
56
+ in the case of standalone items like movies this is often the studio.
57
+ * `episode_info`: a structured field with episode information:
58
+ `s`_n_ is a series/season,
59
+ `e`_n_` is an episode number within the season,
60
+ `x`_n_` is a \"extra\" - addition material supplied with the season,
61
+ etc.
62
+ * `title`: the episode title downcased and with whitespace replaced by dashes.
63
+ * `source`: the source of the media.
64
+ * `ext`: filename extension such as `mp4`.
65
+
66
+ As you may imagine,
67
+ as a rule I dislike mixed case filenames
68
+ and filenames with embedded whitespace.
69
+ I also like a media filename to contain enough information
70
+ to identify the file contents in a compact and human readable form.
71
+
72
+ Short summary:
73
+ * `EpisodeDatumDefn`: An `EpisodeInfo` marker definition with the following components: - `name`: the marker name, such as `\"series\"` or `\"episode\"` - `prefix`: the stub used in a filename, such as `\"s\"` or `\"e\"` - `re`: a regular expression to match the `prefix` an some digits.
74
+ * `EpisodeInfo`: Trite class for episodic information, used to store, match or transcribe series/season, episode, etc values.
75
+ * `main`: Main command line running some test code.
76
+ * `parse_name`: Parse the descriptive part of a filename (the portion remaining after stripping the file extension) and yield `(part,fields)` for each part as delineated by `sep`.
77
+ * `part_to_title`: Convert a filename part into a title string.
78
+ * `pathname_info`: Parse information from the basename of a file pathname. Return a mapping of field => values in the order parsed.
79
+ * `scrub_title`: Strip redundant text from the start of an episode title.
80
+ * `SeriesEpisodeInfo`: Episode information from a TV series episode.
81
+ * `title_to_part`: Convert a title string into a filename part. This is lossy; the `part_to_title` function cannot completely reverse this.
82
+
83
+ Module contents:
84
+ - <a name=\"EpisodeDatumDefn\"></a>`class EpisodeDatumDefn(EpisodeDatumDefn)`: An `EpisodeInfo` marker definition with the following components:
85
+ - `name`: the marker name, such as `\"series\"` or `\"episode\"`
86
+ - `prefix`: the stub used in a filename, such as `\"s\"` or `\"e\"`
87
+ - `re`: a regular expression to match the `prefix` an some digits
88
+
89
+ *`EpisodeDatumDefn.parse(self, s, offset=0)`*:
90
+ Parse an episode datum from a string, return the value and new offset.
91
+ Raise `ValueError` if the string doesn't match this definition.
92
+
93
+ Parameters:
94
+ * `s`: the string
95
+ * `offset`: parse offset, default 0
96
+ - <a name=\"EpisodeInfo\"></a>`class EpisodeInfo(types.SimpleNamespace)`: Trite class for episodic information, used to store, match
97
+ or transcribe series/season, episode, etc values.
98
+
99
+ *`EpisodeInfo.__getitem__(self, name)`*:
100
+ We can look up values by name.
101
+
102
+ *`EpisodeInfo.as_dict(self)`*:
103
+ Return the episode info as a `dict`.
104
+
105
+ *`EpisodeInfo.as_tags(self, prefix=None)`*:
106
+ Generator yielding the episode info as `Tag`s.
107
+
108
+ *`EpisodeInfo.from_filename_part(s, offset=0)`*:
109
+ Factory to return an `EpisodeInfo` from a filename episode field.
110
+
111
+ Parameters:
112
+ * `s`: the string containing the episode information
113
+ * `offset`: the start of the episode information, default 0
114
+
115
+ The episode information must extend to the end of the string
116
+ because the factory returns just the information. See the
117
+ `parse_filename_part` class method for the core parse.
118
+
119
+ *`EpisodeInfo.get(self, name, default=None)`*:
120
+ Look up value by name with default.
121
+
122
+ *`EpisodeInfo.parse_filename_part(s, offset=0)`*:
123
+ Parse episode information from a string,
124
+ returning the matched fields and the new offset.
125
+
126
+ Parameters:
127
+ `s`: the string containing the episode information.
128
+ `offset`: the starting offset of the information, default 0.
129
+
130
+ *`EpisodeInfo.season`*:
131
+ .season property, synonym for .series
132
+ - <a name=\"main\"></a>`main(argv=None)`: Main command line running some test code.
133
+ - <a name=\"parse_name\"></a>`parse_name(name, sep='--')`: Parse the descriptive part of a filename
134
+ (the portion remaining after stripping the file extension)
135
+ and yield `(part,fields)` for each part as delineated by `sep`.
136
+ - <a name=\"part_to_title\"></a>`part_to_title(part)`: Convert a filename part into a title string.
137
+
138
+ Example:
139
+
140
+ >>> part_to_title('episode-name')
141
+ 'Episode Name'
142
+ - <a name=\"pathname_info\"></a>`pathname_info(pathname)`: Parse information from the basename of a file pathname.
143
+ Return a mapping of field => values in the order parsed.
144
+ - <a name=\"scrub_title\"></a>`scrub_title(title: str, *, season=None, episode=None) -> str`: Strip redundant text from the start of an episode title.
145
+
146
+ I frequently get \"title\" strings with leading season/episode information.
147
+ This function cleans up these strings to return the unadorned title.
148
+ - <a name=\"SeriesEpisodeInfo\"></a>`class SeriesEpisodeInfo(cs.deco.Promotable)`: Episode information from a TV series episode.
149
+
150
+ *`SeriesEpisodeInfo.as_dict(self)`*:
151
+ Return the non-`None` values as a `dict`.
152
+ Note that this uses `dataclasses.asdict()` and as such is a deep copy.
153
+
154
+ *`SeriesEpisodeInfo.from_str(episode_title: str, series=None)`*:
155
+ Infer a `SeriesEpisodeInfo` from an episode title.
156
+
157
+ This recognises the common `'sSSeEE - Episode Title'` format
158
+ and variants like `Series Name - sSSeEE - Episode Title'`
159
+ or `'sSSeEE - Episode Title - Part: One'`.
160
+ - <a name=\"title_to_part\"></a>`title_to_part(title)`: Convert a title string into a filename part.
161
+ This is lossy; the `part_to_title` function cannot completely reverse this.
162
+
163
+ Example:
164
+
165
+ >>> title_to_part('Episode Name')
166
+ 'episode-name'
167
+
168
+ # Release Log
169
+
170
+
171
+
172
+ *Release 20260531*:
173
+ Bugfix a parse return value, small doc updates.
174
+
175
+ *Release 20240519*:
176
+ Initial PyPI release, particularly for SeriesEpisodeInfo which I use in cs.app.playon."""
177
+ content-type = "text/markdown"
178
+
179
+ [build-system]
180
+ build-backend = "flit_core.buildapi"
181
+ requires = [
182
+ "flit_core >=3.2,<4",
183
+ ]
184
+
185
+ [tool.flit.module]
186
+ name = "cs.mediainfo"
@@ -0,0 +1,393 @@
1
+ #!/usr/bin/env python3
2
+ #
3
+ # Assorted facilities for media information.
4
+ # - Cameron Simpson <cs@cskk.id.au> 09may2018
5
+ #
6
+
7
+ '''
8
+ Simple minded facilities for media information inferred from filenames.
9
+ This contains mostly lexical functions for extracting information from strings
10
+ or constructing media filenames from metadata and a few classes
11
+ like `EpisodeInfo` and `SeriesEpisodeInfo` for common descriptions.
12
+
13
+ The default filename parsing rules are based on my personal convention,
14
+ which is to name media files as:
15
+
16
+ series_name--episode_info--title--source--etc-etc.ext
17
+
18
+ where the components are:
19
+ * `series_name`:
20
+ the programme series name downcased and with whitespace replaced by dashes;
21
+ in the case of standalone items like movies this is often the studio.
22
+ * `episode_info`: a structured field with episode information:
23
+ `s`_n_ is a series/season,
24
+ `e`_n_` is an episode number within the season,
25
+ `x`_n_` is a "extra" - addition material supplied with the season,
26
+ etc.
27
+ * `title`: the episode title downcased and with whitespace replaced by dashes.
28
+ * `source`: the source of the media.
29
+ * `ext`: filename extension such as `mp4`.
30
+
31
+ As you may imagine,
32
+ as a rule I dislike mixed case filenames
33
+ and filenames with embedded whitespace.
34
+ I also like a media filename to contain enough information
35
+ to identify the file contents in a compact and human readable form.
36
+ '''
37
+
38
+ from collections import namedtuple, defaultdict
39
+ from dataclasses import asdict, dataclass
40
+ from os.path import basename, splitext
41
+ import re
42
+ import sys
43
+ from types import SimpleNamespace as NS
44
+ from typing import Optional
45
+
46
+ from cs.deco import Promotable
47
+ from cs.gimmicks import warning
48
+ from cs.lex import cutsuffix, get_prefix_n, get_suffix_part
49
+ from cs.pfx import Pfx, pfx
50
+ from cs.tagset import Tag
51
+
52
+ __version__ = '20260531'
53
+
54
+ DISTINFO = {
55
+ 'keywords': ["python2", "python3"],
56
+ 'classifiers': [
57
+ "Programming Language :: Python",
58
+ "Programming Language :: Python :: 3",
59
+ ],
60
+ 'install_requires': [
61
+ 'cs.deco',
62
+ 'cs.gimmicks',
63
+ 'cs.lex',
64
+ 'cs.pfx',
65
+ 'cs.tagset',
66
+ ],
67
+ }
68
+
69
+ USAGE = r'''Usage: %s media-filenames...'''
70
+
71
+ def main(argv=None):
72
+ ''' Main command line running some test code.
73
+ '''
74
+ if argv is None:
75
+ argv = sys.argv
76
+ argv = list(argv)
77
+ cmd = argv.pop(0)
78
+ usage = USAGE % (cmd,)
79
+ if not argv:
80
+ warning("%s: missing media-filenames", cmd)
81
+ print(usage)
82
+ return 2
83
+ for pathname in argv:
84
+ with Pfx("%r", pathname):
85
+ name, _ = splitext(basename(pathname))
86
+ for part, fields in parse_name(name):
87
+ print(" ", part, repr(fields))
88
+ print(" ", name, pathname_info(pathname))
89
+ return 0
90
+
91
+ def title_to_part(title):
92
+ ''' Convert a title string into a filename part.
93
+ This is lossy; the `part_to_title` function cannot completely reverse this.
94
+
95
+ Example:
96
+
97
+ >>> title_to_part('Episode Name')
98
+ 'episode-name'
99
+ '''
100
+ return re.sub(r'[\s-]+', '-', title).strip().lower()
101
+
102
+ def part_to_title(part):
103
+ ''' Convert a filename part into a title string.
104
+
105
+ Example:
106
+
107
+ >>> part_to_title('episode-name')
108
+ 'Episode Name'
109
+ '''
110
+ return part.strip('- \t\r\n').replace('-', ' ').title()
111
+
112
+ class EpisodeDatumDefn(namedtuple('EpisodeDatumDefn', 'name prefix re')):
113
+ ''' An `EpisodeInfo` marker definition with the following components:
114
+ - `name`: the marker name, such as `"series"` or `"episode"`
115
+ - `prefix`: the stub used in a filename, such as `"s"` or `"e"`
116
+ - `re`: a regular expression to match the `prefix` an some digits
117
+ '''
118
+
119
+ def __new__(cls, name, prefix):
120
+ r = re.compile(prefix + r'(\d+)', re.I)
121
+ return super(EpisodeDatumDefn, cls).__new__(cls, name, prefix, r)
122
+
123
+ def parse(self, s, offset=0):
124
+ ''' Parse an episode datum from a string, return the value and new offset.
125
+ Raise `ValueError` if the string doesn't match this definition.
126
+
127
+ Parameters:
128
+ * `s`: the string
129
+ * `offset`: parse offset, default 0
130
+ '''
131
+ m = self.re.match(s, offset)
132
+ if m:
133
+ return int(m.group(1)), m.end()
134
+ raise ValueError(
135
+ '%s: unparsed episode datum: %r' % (type(self).__name__, s[offset:])
136
+ )
137
+
138
+ class EpisodeInfo(NS):
139
+ ''' Trite class for episodic information, used to store, match
140
+ or transcribe series/season, episode, etc values.
141
+ '''
142
+
143
+ MARKERS = [
144
+ EpisodeDatumDefn('series', 's'),
145
+ EpisodeDatumDefn('episode', 'e'),
146
+ EpisodeDatumDefn('part', 'pt'),
147
+ EpisodeDatumDefn('part', 'p'),
148
+ EpisodeDatumDefn('scene', 'sc'),
149
+ EpisodeDatumDefn('extra', 'x'),
150
+ EpisodeDatumDefn('extra', 'ex'),
151
+ ]
152
+
153
+ def __init__(self, series=None, episode=None, part=None, scene=None):
154
+ self.series = series
155
+ self.episode = episode
156
+ self.part = part
157
+ self.scene = scene
158
+
159
+ def as_dict(self):
160
+ ''' Return the episode info as a `dict`.
161
+ '''
162
+ d = {}
163
+ for attr in 'series', 'episode', 'part', 'scene':
164
+ value = getattr(self, attr)
165
+ if value is not None:
166
+ d[attr] = value
167
+ return d
168
+
169
+ def as_tags(self, prefix=None):
170
+ ''' Generator yielding the episode info as `Tag`s.
171
+ '''
172
+ for field, value in self.as_dict().items():
173
+ yield Tag(field, value, prefix=prefix)
174
+
175
+ def __str__(self):
176
+ marks = []
177
+ for marker in self.MARKERS:
178
+ value = self.get(marker.name)
179
+ if value is not None:
180
+ marks.append(marker.prefix + '%02d' % value)
181
+ return ''.join(marks)
182
+
183
+ def __getitem__(self, name):
184
+ ''' We can look up values by name.
185
+ '''
186
+ try:
187
+ value = getattr(self, name)
188
+ except AttributeError as e:
189
+ raise KeyError(name) from e
190
+ return value
191
+
192
+ def get(self, name, default=None):
193
+ ''' Look up value by name with default.
194
+ '''
195
+ try:
196
+ value = self[name]
197
+ except KeyError:
198
+ return default
199
+ return value
200
+
201
+ @classmethod
202
+ def from_filename_part(cls, s, offset=0):
203
+ ''' Factory to return an `EpisodeInfo` from a filename episode field.
204
+
205
+ Parameters:
206
+ * `s`: the string containing the episode information
207
+ * `offset`: the start of the episode information, default 0
208
+
209
+ The episode information must extend to the end of the string
210
+ because the factory returns just the information. See the
211
+ `parse_filename_part` class method for the core parse.
212
+ '''
213
+ fields, offset = cls.parse_filename_part(s, offset=offset)
214
+ if offset < len(s):
215
+ raise ValueError("unparsed filename part: %r" % (s[offset:],))
216
+ return cls(**fields)
217
+
218
+ @classmethod
219
+ @pfx
220
+ def parse_filename_part(cls, s, offset=0):
221
+ ''' Parse episode information from a string,
222
+ returning the matched fields and the new offset.
223
+
224
+ Parameters:
225
+ `s`: the string containing the episode information.
226
+ `offset`: the starting offset of the information, default 0.
227
+ '''
228
+ start_offset = offset
229
+ fields = {}
230
+ while offset < len(s):
231
+ offset0 = offset
232
+ for defn in cls.MARKERS:
233
+ if defn.name in fields:
234
+ # support for different definitions with a common prefix
235
+ # earlier definitions are matched first
236
+ continue
237
+ try:
238
+ value, offset = defn.parse(s, offset)
239
+ except ValueError:
240
+ pass
241
+ else:
242
+ fields[defn.name] = value
243
+ break
244
+ # parse fails, stop trying for more information
245
+ if offset == offset0:
246
+ break
247
+ if offset == start_offset:
248
+ # no component info, try other things
249
+ if len(s) == 4 and s.isdigit() and s.startswith(('19', '20')):
250
+ fields['year'] = int(s)
251
+ elif len(s) == 6 and s[1:-1].isdigit() and s.startswith(('19', '20'), 1):
252
+ fields['year'] = int(s[1:-1])
253
+ return fields, offset
254
+
255
+ @property
256
+ def season(self):
257
+ ''' .season property, synonym for .series
258
+ '''
259
+ return self.series
260
+
261
+ @season.setter
262
+ def season(self, n):
263
+ ''' .season property, synonym for .series
264
+ '''
265
+ self.series = n
266
+
267
+ @pfx
268
+ def parse_name(name, sep='--'):
269
+ ''' Parse the descriptive part of a filename
270
+ (the portion remaining after stripping the file extension)
271
+ and yield `(part,fields)` for each part as delineated by `sep`.
272
+ '''
273
+ unstructured_sections = ('series_name', 'episode_name', 'source_name')
274
+ unstructured_index = 0
275
+ for part0 in name.split(sep):
276
+ with Pfx(part0):
277
+ part = part0.strip('- \t\r\n')
278
+ if not part:
279
+ # ignore empty parts
280
+ continue
281
+ # look for series/episode/scene/part markers
282
+ fields, offset = EpisodeInfo.parse_filename_part(part)
283
+ if offset == 0:
284
+ # nothing parsed, consider the unstructured sections
285
+ if unstructured_index < len(unstructured_sections):
286
+ fields = {unstructured_sections[unstructured_index]: part}
287
+ unstructured_index += 1
288
+ else:
289
+ fields = None
290
+ elif offset < len(part):
291
+ warning(
292
+ "parse_name %r: part %r: unparsed: %r", name, part0, part[offset:]
293
+ )
294
+ yield part0, fields
295
+
296
+ def pathname_info(pathname):
297
+ ''' Parse information from the basename of a file pathname.
298
+ Return a mapping of field => values in the order parsed.
299
+ '''
300
+ info = defaultdict(list)
301
+ name, _ = splitext(basename(pathname))
302
+ for _, fields in parse_name(name):
303
+ if fields:
304
+ for field, value in fields.items():
305
+ if field in info:
306
+ warning(
307
+ "discard %r=%r: already have %r=%r", field, value, field,
308
+ info[field]
309
+ )
310
+ else:
311
+ info[field].append(value)
312
+ return info
313
+
314
+ def scrub_title(title: str, *, season=None, episode=None) -> str:
315
+ ''' Strip redundant text from the start of an episode title.
316
+
317
+ I frequently get "title" strings with leading season/episode information.
318
+ This function cleans up these strings to return the unadorned title.
319
+ '''
320
+ title = title.strip()
321
+ if season:
322
+ spfx, _, offset = get_prefix_n(title, 's', n=season)
323
+ if spfx:
324
+ assert title.startswith(f's{season:02d}')
325
+ title = title[offset:]
326
+ if episode:
327
+ epfx, _, offset = get_prefix_n(title, 'e', n=episode)
328
+ if epfx:
329
+ assert title.startswith(f'e{episode:02d}')
330
+ title = title[offset:]
331
+ title = title.lstrip(' -')
332
+ if episode:
333
+ epfx, _, offset = get_prefix_n(title.lower(), 'episode ', n=episode)
334
+ if epfx:
335
+ title = title[offset:]
336
+ title = title.lstrip(' -')
337
+ return title
338
+
339
+ @dataclass
340
+ class SeriesEpisodeInfo(Promotable):
341
+ ''' Episode information from a TV series episode.
342
+ '''
343
+
344
+ series: str
345
+ season: int
346
+ episode: int
347
+ episode_title: Optional[str] = None
348
+ episode_part: Optional[int] = None
349
+
350
+ @classmethod
351
+ def from_str(cls, episode_title: str, series=None):
352
+ ''' Infer a `SeriesEpisodeInfo` from an episode title.
353
+
354
+ This recognises the common `'sSSeEE - Episode Title'` format
355
+ and variants like `Series Name - sSSeEE - Episode Title'`
356
+ or `'sSSeEE - Episode Title - Part: One'`.
357
+ '''
358
+ if not series:
359
+ # look for leading "series - sSSeEE"
360
+ m = re.match(
361
+ r'(?P<series>\S.*\S)\s+(-\s+)?(?=s\d+e\d+\s)', episode_title, re.I
362
+ )
363
+ if m:
364
+ series = m.group('series')
365
+ episode_title = episode_title[m.end():]
366
+ # strip leading "sSSeEE - " prefix
367
+ _, season, offset = get_prefix_n(episode_title.lower(), 's')
368
+ _, episode, offset = get_prefix_n(
369
+ episode_title.lower(), 'e', offset=offset
370
+ )
371
+ if offset > 0:
372
+ # strip the sSSeEE and any following spaces or dashes
373
+ episode_title = episode_title[offset:].lstrip(' -')
374
+ # strip the trailing part info eg ": Part One"
375
+ part_suffix, episode_part = get_suffix_part(episode_title)
376
+ if part_suffix:
377
+ episode_title = cutsuffix(episode_title, part_suffix)
378
+ return cls(
379
+ series=series,
380
+ season=season,
381
+ episode=episode,
382
+ episode_title=episode_title,
383
+ episode_part=episode_part,
384
+ )
385
+
386
+ def as_dict(self):
387
+ ''' Return the non-`None` values as a `dict`.
388
+ Note that this uses `dataclasses.asdict()` and as such is a deep copy.
389
+ '''
390
+ return {k: v for k, v in asdict(self).items() if v is not None}
391
+
392
+ if __name__ == '__main__':
393
+ sys.exit(main(sys.argv))