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))
|