Skip to content

feat(xmltv): a title's episode numbering is read off it - #32

Merged
Ruby184 merged 1 commit into
mainfrom
feat/title-numbering
Oct 18, 2026
Merged

Ruby184 merged 1 commit into
mainfrom
feat/title-numbering

Conversation

@Ruby184

@Ruby184 Ruby184 commented Oct 18, 2026

Copy link
Copy Markdown
Owner

Summary

Many sources write the episode number into the title instead of a field of its own: "Duel S01E99", "Komisár Rex III (13)", "Robogobo: Season 1". The guide then shows the numbering as part of the series' name. homepi has a parser for this in three of its sites. This PR moves it into epg-tools/xmltv, with English built-in forms and room for a source's own.

What's added

  • parseTitleNumbering(title, { patterns? }) returns { title, season?, seasons?, episode?, episodes?, part?, parts?, subTitle? }.
    • Built-in forms are English or need no language:
      • the onscreen grammar parseOnscreenEpisodeNum reads, with the season required (S01E02, S01 Ep 5, S1/3E2/10, S01E02 P1/2);
      • Season 4 Episode 5 and Episode 4;
      • a Roman season before (13);
      • (6/12);
      • : Season 1.
    • Words and separators: case doesn't matter, and the separator can be a colon, a comma or any dash.
    • A source's own patterns are regexes with named groups. They are matched against the whole title and tried before the built-in forms. A pattern without a title group throws the first time it's used.
  • stripTitleSeason(title, season, { patterns? }) removes a season the source also gives in a field, including a bare trailing numeral ("Zločin III" with season 3 gives "Zločin").
  • programme(start, title, { numbered }) on ProgrammeBuilder and ctx.programme:
    • The title is rewritten.
    • The numbers and sub-title are added to what build() returns where the site gives none of its own.
    • An .episode() call fills in what it leaves out from the title's numbers, each only where the call agrees with what that number belongs to.
    • fromTitle: false, on numbered or on a single call, makes a call's numbers stand alone.
  • New types: TitleNumbering, TitleNumberingOptions and NumberedOptions.

Guards

What the parser leaves alone matters as much as what it reads. These stay as they are:

  • "Spinal Tap II": a sequel, not a season.
  • "Hrdinové (2019)" and "Extraliga (2023/24)": a year and a span of years.
  • "Poprask na silnici E4": a road, not an episode.
  • "CIVIL (2)" and "Agent iii (2)": not numerals.
  • "Malcolm X (2)" and "Vitamin C (3)": one-letter numerals other than I and V are names.
  • "Duel S00E00" and "Show (0/12)": no season or episode above nought.
  • "Season 2 Episode 5 - Monday": taking the numbering off would leave no title.
  • Titles over 500 characters: .+?\s+ backtracking is quadratic on long runs of whitespace.

Known misreadings, documented: "Louis XIV (3)" (a regnal number), "Star Wars: Episode 1 - …" (a film named as an episode), a bracketed age rating "(18)", and a daily soap's "(1999)". A source can read the soap's number with its own pattern, since those are tried first.

How the builder fills in from the title

  • Season count: belongs to the series, so it always fills in.
  • Season: fills in when the call names none. .episode(113) on "Komisár Rex III (13)` gives S03E113.
  • Episode and episodes per season: belong to the season, so they fill in only when the call names no other season. .episode(null, 4) gives season 4 alone, not season 4's episode 13.
  • Part and part count: belong to the episode, so they fill in only when the call names no other episode either.
  • When nothing is written: the title's numbers are added at build() unless an .episode() call wrote numbers or the site added its own xmltv_ns or onscreen entry. They were taken off the title, so a call that wrote nothing doesn't lose them.
  • Sub-title: the title's sub-title is written unless the site gave one with text.
  • build() doesn't change the builder: it adds these to a copy, so a .subTitle() or .episode() after one build still wins at the next.

Other changes

  • .episode() given a part and no total now writes the part with no total. It used to write "part 2 of 1", which is invalid xmltv_ns. This was a bug before this branch.
  • The onscreen grammar in episode-num.ts is split into two fragments, so the title form reuses it instead of keeping a second, narrower copy.
  • NumberedOptions is exported as a type from the root and from epg-tools/grabber, next to ProgrammeOptions.

Performance

A builder without numbered keeps all title state in one field that stays undefined, and its build() returns straight away. Benchmarked with main's builder and this one loaded in the same process and run alternately, 100k programmes with .episode() and .build(): the medians are the same within noise (142.8 ms on main against 140.6 ms here).

Testing

  • New test/title-numbering.test.ts ports homepi's cases. Its Slovak and Hungarian forms run as custom patterns, which also shows the built-in forms leave them alone.
  • Builder tests cover the fill-in rules, fromTitle, building more than once, sub-titles and parts. A grabber test checks that numbered passes through ctx.programme.
  • Doc examples are compiled in test/readme-examples.ts.
  • Mutation checks were run on every change. The only survivors are equivalent mutants.
  • The full suite passes on Node 22.18, 24.15 and 26.12: 1,961 passed, 2 skipped.

Not in this PR

A site-level numbered option, which would also cover Xtream, Schedules Direct and plain programme objects. That's new public API and is left for a separate decision.

Many sources give no episode number of their own and write it into the
title instead: "Duel S01E99", "Komisár Rex III (13)", "Robogobo: Season 1".
A guide built from them shows the numbering as part of the series' name, and
homepi carries a parser for it in three of its sites.

`parseTitleNumbering(title, { patterns? })` takes it off, as `{ title,
season?, seasons?, episode?, episodes?, part?, parts?, subTitle? }`. The
built-in forms are English or need no language: the `onscreen` grammar
`parseOnscreenEpisodeNum` reads, with the season required; "Season 4
Episode 5" and "Episode 4"; a Roman season before "(13)"; "(6/12)"; and
": Season 1". A source's own words are patterns, regular expressions with
named groups matched against the whole title, tried before the built-in
ones. The guards are as much the point as the forms: "Spinal Tap II",
"(2019)", "(2023/24)", "Poprask na silnici E4", "CIVIL (2)" and "Malcolm X
(2)" stay as they are. `stripTitleSeason(title, season)` takes off a season
a source also gives apart, a bare numeral included.

`programme(start, title, { numbered: true })` does it as a programme is
built. The title is rewritten, and the numbers and the sub-title are written
where the site gives none of its own. An `.episode()` call fills in what it
leaves out from the title's numbers, each where the call agrees with what it
belongs to: the season for a call naming none, the episode only for a call
naming no other season. `fromTitle: false`, on `numbered` or on one call,
has a call's numbers stand alone. A builder without `numbered` does no more
than it did.

`.episode()` given a part and no total of parts writes the part with no
total, rather than "part 2 of 1".
@Ruby184
Ruby184 merged commit c7e2655 into main Oct 18, 2026
4 checks passed
@Ruby184
Ruby184 deleted the feat/title-numbering branch October 18, 2026 14:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant