1use broadcast_common::impl_spec_display;
11
12const IDENT_RESERVED: &str = "RepresentationID";
13const IDENT_NUMBER: &str = "Number";
14const IDENT_TIME: &str = "Time";
15const IDENT_BANDWIDTH: &str = "Bandwidth";
16const IDENT_SUB_NUMBER: &str = "SubNumber";
17const TAG_ZERO_FLAG: char = '0';
18const TAG_CONVERSION: char = 'd';
19const DELIMITER: char = '$';
20const TAG_START: char = '%';
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24#[non_exhaustive]
25pub struct TemplateUse {
26 pub representation_id: bool,
28 pub number: bool,
30 pub time: bool,
32 pub bandwidth: bool,
34 pub sub_number: bool,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40#[non_exhaustive]
41pub enum TemplateFault {
42 UnterminatedIdentifier,
44 UnknownIdentifier,
46 FormatOnRepresentationId,
48 BadFormatTag,
50 WidthTooLarge,
52 NumberAndTime,
54 MissingNumberOrTime,
56 DynamicIdentifierInInitialization,
58}
59
60pub const MAX_FORMAT_WIDTH: usize = 20;
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68#[non_exhaustive]
69pub enum TemplateError {
70 Invalid(TemplateFault),
72 MissingNumber,
74 MissingTime,
76}
77
78impl TemplateError {
79 #[must_use]
83 pub fn fault(self) -> TemplateFault {
84 match self {
85 Self::Invalid(f) => f,
86 Self::MissingNumber | Self::MissingTime => TemplateFault::MissingNumberOrTime,
87 }
88 }
89
90 #[must_use]
92 pub fn name(&self) -> &'static str {
93 match self {
94 Self::Invalid(_) => "invalid",
95 Self::MissingNumber => "missing_number",
96 Self::MissingTime => "missing_time",
97 }
98 }
99}
100impl_spec_display!(TemplateError);
101
102impl TemplateFault {
103 #[must_use]
105 pub fn name(&self) -> &'static str {
106 match self {
107 Self::UnterminatedIdentifier => "unterminated_identifier",
108 Self::UnknownIdentifier => "unknown_identifier",
109 Self::FormatOnRepresentationId => "format_on_representation_id",
110 Self::BadFormatTag => "bad_format_tag",
111 Self::WidthTooLarge => "width_too_large",
112 Self::NumberAndTime => "number_and_time",
113 Self::MissingNumberOrTime => "missing_number_or_time",
114 Self::DynamicIdentifierInInitialization => "dynamic_identifier_in_initialization",
115 }
116 }
117}
118impl_spec_display!(TemplateFault);
119
120fn check_tag(tag: &str) -> Result<(), TemplateFault> {
121 let inner = tag
122 .strip_suffix(TAG_CONVERSION)
123 .ok_or(TemplateFault::BadFormatTag)?;
124 let digits = inner
125 .strip_prefix(TAG_ZERO_FLAG)
126 .ok_or(TemplateFault::BadFormatTag)?;
127 if !digits.bytes().all(|b| b.is_ascii_digit()) {
128 return Err(TemplateFault::BadFormatTag);
129 }
130 if !digits.is_empty() {
131 let width: usize = digits.parse().map_err(|_| TemplateFault::WidthTooLarge)?;
132 if width > MAX_FORMAT_WIDTH {
133 return Err(TemplateFault::WidthTooLarge);
134 }
135 }
136 Ok(())
137}
138
139pub fn scan(template: &str) -> Result<TemplateUse, TemplateFault> {
144 let mut used = TemplateUse::default();
145 let mut rest = template;
146 while let Some(at) = rest.find(DELIMITER) {
147 let tail = &rest[at + 1..];
148 if let Some(after) = tail.strip_prefix(DELIMITER) {
149 rest = after;
150 continue;
151 }
152 let end = tail
153 .find(DELIMITER)
154 .ok_or(TemplateFault::UnterminatedIdentifier)?;
155 let ident = &tail[..end];
156 let (name, tag) = match ident.split_once(TAG_START) {
157 Some((n, t)) => (n, Some(format!("{TAG_START}{t}"))),
158 None => (ident, None),
159 };
160 match name {
161 IDENT_RESERVED => {
162 if tag.is_some() {
163 return Err(TemplateFault::FormatOnRepresentationId);
164 }
165 used.representation_id = true;
166 }
167 IDENT_NUMBER | IDENT_TIME | IDENT_BANDWIDTH | IDENT_SUB_NUMBER => {
168 if let Some(t) = &tag {
169 check_tag(&t[TAG_START.len_utf8()..])?;
170 }
171 match name {
172 IDENT_NUMBER => used.number = true,
173 IDENT_TIME => used.time = true,
174 IDENT_BANDWIDTH => used.bandwidth = true,
175 _ => used.sub_number = true,
176 }
177 }
178 _ => return Err(TemplateFault::UnknownIdentifier),
179 }
180 rest = &tail[end + 1..];
181 }
182 Ok(used)
183}
184
185pub fn scan_media(template: &str) -> Result<TemplateUse, TemplateFault> {
190 let used = scan(template)?;
191 if used.number && used.time {
192 return Err(TemplateFault::NumberAndTime);
193 }
194 if !used.number && !used.time {
195 return Err(TemplateFault::MissingNumberOrTime);
196 }
197 Ok(used)
198}
199
200pub fn scan_initialization(template: &str) -> Result<TemplateUse, TemplateFault> {
205 let used = scan(template)?;
206 if used.number || used.time {
207 return Err(TemplateFault::DynamicIdentifierInInitialization);
208 }
209 Ok(used)
210}
211
212pub fn substitute(
220 template: &str,
221 representation_id: &str,
222 number: Option<u64>,
223 time: Option<u64>,
224 bandwidth: u64,
225) -> Result<String, TemplateError> {
226 let parts = parts(template).map_err(TemplateError::Invalid)?;
227 let mut out = String::with_capacity(template.len());
228 for part in &parts {
229 match part {
230 Part::Literal(text) => out.push_str(text),
231 Part::Escape => out.push(DELIMITER),
232 Part::RepresentationId => out.push_str(representation_id),
233 Part::Number(width) => {
234 let value = number.ok_or(TemplateError::MissingNumber)?;
235 out.push_str(&pad(value, *width));
236 }
237 Part::Time(width) => {
238 let value = time.ok_or(TemplateError::MissingTime)?;
239 out.push_str(&pad(value, *width));
240 }
241 Part::Bandwidth(width) => out.push_str(&pad(bandwidth, *width)),
242 }
243 }
244 Ok(out)
245}
246
247fn pad(value: u64, width: Option<usize>) -> String {
250 let digits = value.to_string();
251 let width = width.unwrap_or(1).min(MAX_FORMAT_WIDTH);
252 if width <= digits.len() {
253 return digits;
254 }
255 let mut out = String::with_capacity(width);
256 out.extend(core::iter::repeat_n('0', width - digits.len()));
257 out.push_str(&digits);
258 out
259}
260
261#[derive(Debug, Clone, PartialEq, Eq)]
264enum Part<'a> {
265 Literal(&'a str),
266 Escape,
267 RepresentationId,
268 Number(Option<usize>),
269 Time(Option<usize>),
270 Bandwidth(Option<usize>),
271}
272
273fn number_width(ident: &str) -> Option<usize> {
274 let (_, digits) = ident.split_once(TAG_START)?;
275 let digits = digits.strip_suffix(TAG_CONVERSION).unwrap_or(digits);
276 digits.parse().ok().map(|w: usize| w.min(MAX_FORMAT_WIDTH))
277}
278
279fn parts(template: &str) -> Result<Vec<Part<'_>>, TemplateFault> {
280 scan(template)?;
281 let mut out: Vec<Part<'_>> = Vec::new();
282 let mut rest = template;
283 while let Some(at) = rest.find(DELIMITER) {
284 if at > 0 {
285 out.push(Part::Literal(&rest[..at]));
286 }
287 let tail = &rest[at + 1..];
288 if let Some(after) = tail.strip_prefix(DELIMITER) {
289 out.push(Part::Escape);
290 rest = after;
291 continue;
292 }
293 let end = tail
294 .find(DELIMITER)
295 .ok_or(TemplateFault::UnterminatedIdentifier)?;
296 let ident = &tail[..end];
297 let name = match ident.split_once(TAG_START) {
298 Some((n, _)) => n,
299 None => ident,
300 };
301 out.push(match name {
302 IDENT_RESERVED => Part::RepresentationId,
303 IDENT_NUMBER => Part::Number(number_width(ident)),
304 IDENT_TIME => Part::Time(number_width(ident)),
305 IDENT_BANDWIDTH => Part::Bandwidth(number_width(ident)),
306 _ => return Err(TemplateFault::UnknownIdentifier),
307 });
308 rest = &tail[end + 1..];
309 }
310 if !rest.is_empty() {
311 out.push(Part::Literal(rest));
312 }
313 Ok(out)
314}
315
316#[derive(Debug, Clone, PartialEq, Eq, Default)]
319#[non_exhaustive]
320pub struct TemplateMatch {
321 pub representation_id: String,
324 pub number: Option<u64>,
326 pub time: Option<u64>,
328 pub bandwidth: Option<u64>,
330}
331
332#[must_use]
340pub fn match_url(
341 template: &str,
342 url: &str,
343 known_representation_ids: &[&str],
344) -> Option<TemplateMatch> {
345 let parts = parts(template).ok()?;
346 let mut ids: Vec<&str> = known_representation_ids.to_vec();
347 ids.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b)));
348 ids.dedup();
349 let mut solutions: Vec<TemplateMatch> = Vec::new();
350 let search = Search {
351 parts: &parts,
352 ids: &ids,
353 url,
354 };
355 walk(&search, 0, 0, Reading::default(), &mut solutions);
356 if solutions.len() == 1 {
357 solutions.pop()
358 } else {
359 None
360 }
361}
362
363#[derive(Debug, Clone, Default)]
364struct Reading {
365 matched: TemplateMatch,
366 id: bool,
367 number: bool,
368 time: bool,
369 bandwidth: bool,
370}
371
372#[derive(Clone, Copy)]
373struct Search<'s, 'p> {
374 parts: &'s [Part<'p>],
375 ids: &'s [&'s str],
376 url: &'s str,
377}
378
379fn walk(
380 s: &Search<'_, '_>,
381 pi: usize,
382 pos: usize,
383 current: Reading,
384 solutions: &mut Vec<TemplateMatch>,
385) {
386 let Search {
387 parts, ids, url, ..
388 } = *s;
389 if solutions.len() >= 2 {
390 return;
391 }
392 let Some(part) = parts.get(pi) else {
393 if pos == url.len() {
394 solutions.push(current.matched);
395 }
396 return;
397 };
398 let rest = &url[pos..];
399 match part {
400 Part::Literal(lit) => {
401 if let Some(after) = rest.strip_prefix(*lit) {
402 let at = url.len() - after.len();
403 walk(s, pi + 1, at, current, solutions);
404 }
405 }
406 Part::Escape => {
407 if let Some(after) = rest.strip_prefix(DELIMITER) {
408 let at = url.len() - after.len();
409 walk(s, pi + 1, at, current, solutions);
410 }
411 }
412 Part::RepresentationId => {
413 for id in ids {
414 let Some(after) = rest.strip_prefix(*id) else {
415 continue;
416 };
417 if current.id && current.matched.representation_id != *id {
418 continue;
419 }
420 let mut next = current.clone();
421 next.id = true;
422 next.matched.representation_id = (*id).to_owned();
423 walk(s, pi + 1, url.len() - after.len(), next, solutions);
424 if solutions.len() >= 2 {
425 return;
426 }
427 }
428 }
429 Part::Number(width) => numeric(s, pi, pos, *width, ¤t, solutions, Field::Number),
430 Part::Time(width) => numeric(s, pi, pos, *width, ¤t, solutions, Field::Time),
431 Part::Bandwidth(width) => {
432 numeric(s, pi, pos, *width, ¤t, solutions, Field::Bandwidth)
433 }
434 }
435}
436
437#[derive(Debug, Clone, Copy, PartialEq, Eq)]
438enum Field {
439 Number,
440 Time,
441 Bandwidth,
442}
443
444impl Reading {
445 fn set(&mut self, field: Field, value: u64) -> Option<&mut Self> {
446 let slot = match field {
447 Field::Number => &mut self.matched.number,
448 Field::Time => &mut self.matched.time,
449 Field::Bandwidth => &mut self.matched.bandwidth,
450 };
451 match *slot {
452 Some(seen) if seen != value => return None,
453 _ => *slot = Some(value),
454 }
455 match field {
456 Field::Number => self.number = true,
457 Field::Time => self.time = true,
458 Field::Bandwidth => self.bandwidth = true,
459 }
460 Some(self)
461 }
462}
463
464fn digit_run(rest: &str) -> usize {
465 rest.find(|c: char| !c.is_ascii_digit())
466 .unwrap_or(rest.len())
467}
468
469#[must_use]
472pub fn format_width(n: u64, width: usize) -> String {
473 let width = width.clamp(1, MAX_FORMAT_WIDTH);
474 let digits = n.to_string();
475 if digits.len() >= width {
476 digits
477 } else {
478 let mut out = String::with_capacity(width);
479 for _ in 0..(width - digits.len()) {
480 out.push('0');
481 }
482 out.push_str(&digits);
483 out
484 }
485}
486
487fn numeric(
488 s: &Search<'_, '_>,
489 pi: usize,
490 pos: usize,
491 width: Option<usize>,
492 current: &Reading,
493 solutions: &mut Vec<TemplateMatch>,
494 field: Field,
495) {
496 let rest = &s.url[pos..];
497 let max_len = digit_run(rest);
498 let w = width.map_or(1, |w| w.clamp(1, MAX_FORMAT_WIDTH));
499 let min_len = w.min(max_len).max(1);
500 if max_len == 0 || min_len > max_len {
501 return;
502 }
503 for len in (min_len..=max_len).rev() {
504 let Some(sub) = rest.get(..len) else {
505 continue;
506 };
507 let Ok(value) = sub.parse::<u64>() else {
508 continue;
509 };
510 if format_width(value, w) != sub {
511 continue;
512 }
513 let mut next = current.clone();
514 if next.set(field, value).is_none() {
515 continue;
516 }
517 walk(s, pi + 1, pos + len, next, solutions);
518 if solutions.len() >= 2 {
519 return;
520 }
521 }
522}
523
524#[cfg(test)]
525mod tests {
526 use super::{TemplateError, TemplateFault, substitute};
527
528 #[test]
529 fn time_template_without_a_time_is_an_error() {
530 assert_eq!(
531 substitute("chunk-$Time$.m4s", "r1", None, None, 1),
532 Err(TemplateError::MissingTime)
533 );
534 assert_eq!(
535 substitute("chunk-$Time$.m4s", "r1", Some(3), None, 1),
536 Err(TemplateError::MissingTime)
537 );
538 }
539
540 #[test]
541 fn number_template_without_a_number_is_an_error() {
542 assert_eq!(
543 substitute("chunk-$Number%05d$.m4s", "r1", None, None, 1),
544 Err(TemplateError::MissingNumber)
545 );
546 assert_eq!(
547 substitute("chunk-$Number%05d$.m4s", "r1", None, Some(3), 1),
548 Err(TemplateError::MissingNumber)
549 );
550 }
551
552 #[test]
553 fn invalid_template_is_an_error() {
554 assert_eq!(
555 substitute("chunk-$Number.m4s", "r1", Some(1), None, 1),
556 Err(TemplateError::Invalid(
557 TemplateFault::UnterminatedIdentifier
558 ))
559 );
560 }
561
562 #[test]
563 fn supplied_values_render() {
564 assert_eq!(
565 substitute("chunk-$Number%05d$-$Time$.m4s", "r1", Some(7), Some(11), 1),
566 Ok("chunk-00007-11.m4s".to_owned())
567 );
568 assert_eq!(
569 substitute("$RepresentationID$/init.mp4", "r1", None, None, 1),
570 Ok("r1/init.mp4".to_owned())
571 );
572 }
573}