Skip to content

Commit c8a2fef

Browse files
committed
updated sptream_spec parser and composer
- changed type to media_type - changed pid to stream_id - support group_id and group_index
1 parent 3a96f92 commit c8a2fef

2 files changed

Lines changed: 157 additions & 87 deletions

File tree

src/ffmpegio/utils/__init__.py

Lines changed: 125 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -102,52 +102,107 @@ def parse_stream_spec(
102102
"""
103103

104104
if isinstance(spec, str):
105-
out = {}
106-
if file_index:
107-
m = re.match(r"(\d+)(?::|$)", spec)
108-
if m:
109-
out["file_index"] = int(m[1])
110-
spec = spec[m.end() :]
105+
106+
out: StreamSpec = {}
107+
spec_parts = spec.split(":")
108+
nspecs = len(spec_parts)
109+
i = 0 # current index
110+
111+
def get_int(s, name):
112+
try:
113+
v = int(
114+
s,
115+
(
116+
10
117+
if s[0] != "0" and len(s) > 1
118+
else 16 if s.startswith("0x") or s.startswith("0X") else 8
119+
),
120+
)
121+
assert v >= 0
122+
except Exception as e:
123+
raise ValueError(f"Invalid {name} ({s})") from e
124+
return v
125+
126+
def get_id(i, name):
127+
128+
try:
129+
s = spec_parts[i + 1]
130+
except IndexError as e:
131+
raise ValueError(f"Missing {name}") from e
111132
else:
112-
raise ValueError("Missing file index.")
113-
114-
while len(spec):
115-
if spec.startswith("p:"):
116-
_, v, *r = spec.split(":", 2)
117-
out["program_id"] = int(v)
118-
spec = r[0] if len(r) else ""
119-
elif spec[0] in "vVadt" and (len(spec) == 1 or spec[1] == ":"):
120-
out["type"], *r = spec.split(":", 1)
121-
spec = r[0] if len(r) else ""
133+
return get_int(s, name)
134+
135+
# add file index only if expected
136+
if file_index:
137+
out["file_index"] = get_int(spec_parts[0], "file index")
138+
i += 1
139+
140+
# process the optional parts
141+
while i < nspecs:
142+
spec = spec_parts[i]
143+
# optional specifiers first
144+
if spec in get_args(MediaType):
145+
out["media_type"] = spec
146+
i += 1
147+
elif spec == "g":
148+
i += 1
149+
spec = spec_parts[i]
150+
if spec == "i":
151+
out["group_id"] = get_id(i, "group_id")
152+
i += 2
153+
elif spec.startswith("#"):
154+
out["group_id"] = get_int(spec[1:], "group_id")
155+
i += 1
156+
else:
157+
out["group_index"] = get_int(spec, "group index")
158+
i += 1
159+
elif spec == "p":
160+
out["program_id"] = get_id(i, "program_id")
161+
i += 2
122162
else:
163+
# final primary specifier
164+
if spec.startswith("#"):
165+
out["stream_id"] = get_int(spec[1:], "stream_id")
166+
elif spec == "i":
167+
out["stream_id"] = get_id(i, "stream_id")
168+
i += 1
169+
elif spec == "u":
170+
out["usable"] = True
171+
elif spec == "m":
172+
try:
173+
key, *value = spec_parts[i + 1 :]
174+
assert len(value) <= 1
175+
except (IndexError, AssertionError) as e:
176+
raise ValueError(
177+
f"Invalid metadata tag specifier: {':'.join(spec_parts[i:])}"
178+
) from e
179+
else:
180+
i = nspecs - 1
181+
out["tag"] = (key, value[0]) if len(value) else key
182+
else:
183+
try:
184+
out["index"] = get_int(spec, "stream_index")
185+
except ValueError as e:
186+
raise ValueError(f"Unknown stream specifier: {spec}") from e
123187
break
124-
if not spec:
125-
return out
126-
127-
try:
128-
out["index"] = int(spec)
129-
except:
130-
m = re.match(
131-
r"#(\d+)$|i\:(\d+)$|m\:(.+?)(?:\:(.+?))?$|(u)$|#(0x[\da-f]+)$|i\:(0x[\da-f]+)$",
132-
spec,
133-
)
134-
if not m:
135-
raise ValueError("Invalid stream specifier.")
136-
137-
if m[1] or m[2]:
138-
out["pid"] = int(m[1] or m[2])
139-
elif m[3] is not None:
140-
out["tag"] = m[3] if m[4] is None else (m[3], m[4])
141-
elif m[5]:
142-
out["usable"] = True
143-
elif m[6] or m[7]:
144-
out["pid"] = m[6] or m[7]
188+
189+
if i + 1 < nspecs:
190+
raise ValueError(f"Not all specifiers resolved: {':'.join(spec_parts[i:])}")
191+
145192
return out
146-
else:
147-
if file_index:
148-
return {"file_index": int(spec[0]), "index": int(spec[1])}
149-
else:
150-
return {"index": int(spec)}
193+
194+
if file_index:
195+
if not (
196+
isinstance(spec, Sequence)
197+
and len(spec) == 2
198+
and all(isinstance(v, int) and v >= 0 for v in spec)
199+
):
200+
raise ValueError("Invalid stream specifier")
201+
return {"file_index": int(spec[0]), "index": int(spec[1])}
202+
203+
if not (isinstance(spec, int) and spec >= 0):
204+
raise ValueError("Invalid stream specifier")
205+
return {"index": int(spec)}
151206

152207

153208
def is_stream_spec(spec, file_index: bool | None = None) -> bool:
@@ -160,21 +215,23 @@ def is_stream_spec(spec, file_index: bool | None = None) -> bool:
160215
try:
161216
parse_stream_spec(spec, True if file_index is None else file_index)
162217
return True
163-
except:
218+
except ValueError:
164219
if file_index is None:
165220
try:
166221
parse_stream_spec(spec, False)
167222
return True
168-
except:
223+
except ValueError:
169224
pass
170225
return False
171226

172227

173228
def stream_spec(
174229
index: int | None = None,
175-
type: MediaType | None = None,
230+
media_type: MediaType | None = None,
231+
group_index: int | None = None,
232+
group_id: int | None = None,
176233
program_id: int | None = None,
177-
pid: int | None = None,
234+
stream_id: int | None = None,
178235
tag: str | tuple[str, str] | None = None,
179236
usable: bool | None = None,
180237
file_index: int | None = None,
@@ -188,19 +245,22 @@ def stream_spec(
188245
streams as detected by libavformat except when a program ID is also
189246
specified. In this case it is based on the ordering of the streams in the
190247
program., defaults to None
191-
:param type: One of following: ’v’ or ’V’ for video, ’a’ for audio, ’s’ for
248+
:param media_type: One of following: ’v’ or ’V’ for video, ’a’ for audio, ’s’ for
192249
subtitle, ’d’ for data, and ’t’ for attachments. ’v’ matches all video
193250
streams, ’V’ only matches video streams which are not attached pictures,
194251
video thumbnails or cover arts. If additional stream specifier is used, then
195252
it matches streams which both have this type and match the additional stream
196253
specifier. Otherwise, it matches all streams of the specified type, defaults
197254
to None
198-
:type type: str, optional
255+
:param group_index: Matches streams which are in the group with this group index.
256+
Can be combined with other stream_specifiers, except for `group_index`.
257+
:param group_index: Matches streams which are in the group with this group id.
258+
Can be combined with other stream_specifiers, except for `group_id`.
199259
:param program_id: Selects streams which are in the program with this id. If
200260
additional_stream_specifier is used, then it matches streams which both are
201261
part of the program and match the additional_stream_specifier, defaults to
202262
None
203-
:param pid: stream id given by the container (e.g. PID in MPEG-TS
263+
:param stream_id: stream id given by the container (e.g. PID in MPEG-TS
204264
container), defaults to None
205265
:param tag: metadata tag key having the specified value. If value is not
206266
given, matches streams that contain the given tag with any value, defaults
@@ -220,30 +280,31 @@ def stream_spec(
220280
221281
"""
222282

223-
# nothing specified
224-
if all(
225-
[k is None for k in (index, type, program_id, pid, tag, usable, file_index)]
226-
):
227-
return [] if no_join else ""
283+
if sum(v is not None for v in (index, stream_id, tag, usable)) > 1:
284+
raise ValueError('Only one of "index", "tag", or "usable" may be specified.')
285+
286+
if sum(v is not None for v in (group_index, group_id)) > 1:
287+
raise ValueError('Only one of "group_index" or "group_id" may be specified.')
228288

229289
spec = [] if file_index is None else [str(file_index)]
230290

231-
if type is not None:
232-
spec.append(
233-
dict(video="v", audio="a", subtitle="s", data="d", attachment="t").get(
234-
type, type
235-
)
236-
)
291+
if media_type is not None:
292+
if media_type not in get_args(MediaType):
293+
raise ValueError(f"Unknown {media_type=}.")
294+
spec.append(media_type)
295+
296+
if group_index is not None:
297+
spec.append(f"g:{group_index}")
298+
elif group_id is not None:
299+
spec.append(f"g:#{group_id}")
237300

238301
if program_id is not None:
239302
spec.append(f"p:{program_id}")
240303

241-
if sum([k is not None for k in (index, pid, tag, usable)]) > 1:
242-
raise Exception("Multiple mutually exclusive specifiers are given.")
243304
if index is not None:
244305
spec.append(str(index))
245-
elif pid is not None:
246-
spec.append(f"#{pid}")
306+
elif stream_id is not None:
307+
spec.append(f"#{stream_id}")
247308
elif tag is not None:
248309
spec.append(f"m:{tag}" if isinstance(tag, str) else f"m:{tag[0]}:{tag[1]}")
249310
elif usable is not None and usable:

tests/test_utils.py

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -35,34 +35,43 @@ def test_string_escaping():
3535
assert utils.unescape(esc) == raw
3636

3737

38-
def test_parse_stream_spec():
39-
assert utils.parse_stream_spec(1) == {"index": 1}
40-
assert utils.parse_stream_spec("1") == {"index": 1}
41-
assert utils.parse_stream_spec("v") == {"type": "v"}
42-
assert utils.parse_stream_spec("p:1") == {"program_id": 1}
43-
assert utils.parse_stream_spec("p:1:V") == {"program_id": 1, "type": "V"}
44-
assert utils.parse_stream_spec("p:1:a:#6") == {
45-
"program_id": 1,
46-
"type": "a",
47-
"pid": 6,
48-
}
49-
assert utils.parse_stream_spec("d:i:6") == {"type": "d", "pid": 6}
50-
assert utils.parse_stream_spec("t:m:key") == {"type": "t", "tag": "key"}
51-
assert utils.parse_stream_spec("m:key:value") == {"tag": ("key", "value")}
52-
assert utils.parse_stream_spec("u") == {"usable": True}
53-
54-
assert utils.parse_stream_spec("0:1", True) == {"index": 1, "file_index": 0}
55-
assert utils.parse_stream_spec([0, 1], True) == {"index": 1, "file_index": 0}
38+
@pytest.mark.parametrize(
39+
("arg", "file_index", "ret"),
40+
[
41+
(1, False, {"index": 1}),
42+
("1", False, {"index": 1}),
43+
("v", False, {"media_type": "v"}),
44+
("p:1", False, {"program_id": 1}),
45+
("p:1:V", False, {"program_id": 1, "media_type": "V"}),
46+
(
47+
"p:1:a:#6",
48+
False,
49+
{
50+
"program_id": 1,
51+
"media_type": "a",
52+
"stream_id": 6,
53+
},
54+
),
55+
("d:i:6", False, {"media_type": "d", "stream_id": 6}),
56+
("t:m:key", False, {"media_type": "t", "tag": "key"}),
57+
("m:key:value", False, {"tag": ("key", "value")}),
58+
("u", False, {"usable": True}),
59+
("0:1", True, {"index": 1, "file_index": 0}),
60+
([0, 1], True, {"index": 1, "file_index": 0}),
61+
],
62+
)
63+
def test_parse_stream_spec(arg, file_index, ret):
64+
assert utils.parse_stream_spec(arg, file_index) == ret
5665

5766

5867
def test_stream_spec():
5968
assert utils.stream_spec() == ""
6069
assert utils.stream_spec(0) == "0"
61-
assert utils.stream_spec(type="a") == "a"
62-
assert utils.stream_spec(1, type="v") == "v:1"
63-
assert utils.stream_spec(program_id="1") == "p:1"
64-
assert utils.stream_spec(1, type="v", program_id="1") == "v:p:1:1"
65-
assert utils.stream_spec(pid=342) == "#342"
70+
assert utils.stream_spec(media_type="a") == "a"
71+
assert utils.stream_spec(1, media_type="v") == "v:1"
72+
assert utils.stream_spec(program_id=1) == "p:1"
73+
assert utils.stream_spec(1, media_type="v", program_id=1) == "v:p:1:1"
74+
assert utils.stream_spec(stream_id=342) == "#342"
6675
assert utils.stream_spec(tag="creation_time") == "m:creation_time"
6776
assert (
6877
utils.stream_spec(tag=("creation_time", "2018-05-26T19:36:24.000000Z"))

0 commit comments

Comments
 (0)