diff --git a/docs/api.md b/docs/api.md index f2f342a..5dd45ff 100644 --- a/docs/api.md +++ b/docs/api.md @@ -541,7 +541,7 @@ are not re-exported at all. | Days | `toDayString`, `dayToDate`, `addDays`, `diffDays`, `dayRange` | | Options parsing | `parseOptions`, `OptionError` | | XMLTV | `escapeXml`, `escapeXmlText`, `escapeXmlAttribute`, `serializeChannel`, `serializeProgramme`, `writeXmltvStream`, `writeXmltvToFile`, `parseXmltvStream`, `parseXmltvFile`, `validateXmltv`, every [date helper](./xmltv.md#dates) and the [zone helpers](./xmltv.md#named-zones) | -| Episode numbers, genres | `parseXmltvNsEpisodeNum`, `formatXmltvNsEpisodeNum`, `parseOnscreenEpisodeNum`, `formatOnscreenEpisodeNum`, `parseDdProgidEpisodeNum`, `formatDdProgidEpisodeNum`, `DVB_GENRES`, `genreKey`, `genreOf` | +| Episode numbers, genres | `parseXmltvNsEpisodeNum`, `formatXmltvNsEpisodeNum`, `parseOnscreenEpisodeNum`, `formatOnscreenEpisodeNum`, `parseDdProgidEpisodeNum`, `formatDdProgidEpisodeNum`, `parseTitleNumbering`, `stripTitleSeason`, `DVB_GENRES`, `genreKey`, `genreOf` | | Output profiles | `OUTPUT_PROFILE_NAMES`, `OUTPUT_PROFILES`, `resolveProfile` | | M3U | `parseM3uStream`, `parseM3uFile`, `parseM3uString`, `serializeM3uEntry`, `serializeM3uHeader`, `writeM3uStream`, `writeM3uToFile`, `M3uParseStream`, `M3uSerializeStream` | | Channels | `parseChannelsXml`, `serializeChannelsXml`, `serializeChannelsXmlEntry`, `matchChannels`, `timeshiftOf`, `timeshiftName` | @@ -553,8 +553,8 @@ are not re-exported at all. Plus the types for all of the above (`EpgConfig`, `SiteConfig`, `GrabberChannel`, `XmltvProgramme`, `XmltvChannel`, `GuideServer`, …), and -`ProgrammeBuilder`, `ChannelBuilder` and `ProgrammeOptions` as types — the -builders themselves are values on `epg-tools/xmltv` only. +`ProgrammeBuilder`, `ChannelBuilder`, `ProgrammeOptions` and `NumberedOptions` +as types — the builders themselves are values on `epg-tools/xmltv` only. ### `epg-tools/xmltv` @@ -568,6 +568,7 @@ class its serializer throws, whose module imports nothing. Full detail in - **Dates** — `parseXmltvDate`, `formatXmltvDate`, `xmltvDate`, `getXmltvOffset`, `setXmltvOffset`, `getXmltvPrecision`, `setXmltvPrecision`, `XMLTV_OFFSET`, `XMLTV_PRECISION`, `XmltvDateError` - **Zones** — `zonedXmltvDate`, `xmltvZone`, `setXmltvZone`, `xmltvZoneOffset` — see [named zones](./xmltv.md#named-zones) - **Episode numbers** — `parseXmltvNsEpisodeNum`, `formatXmltvNsEpisodeNum`, `parseOnscreenEpisodeNum`, `formatOnscreenEpisodeNum`, `parseDdProgidEpisodeNum`, `formatDdProgidEpisodeNum` — see [episode numbers](./xmltv.md#episode-numbers) +- **Numbering in titles** — `parseTitleNumbering`, `stripTitleSeason` — see [numbering in titles](./xmltv.md#numbering-in-titles) - **Genres** — `DVB_GENRES`, `genreOf`, and `genreKey`, the folding `genreOf` matches by — see [genres](./xmltv.md#genres) - **Output profiles** — `OUTPUT_PROFILES`, `OUTPUT_PROFILE_NAMES`, `resolveProfile` — see [output profiles](./configuration.md#output-profiles) - **Validate** — `validateXmltv` — see [validating a guide](./configuration.md#validating-a-guide) @@ -577,7 +578,8 @@ class its serializer throws, whose module imports nothing. Full detail in means the classes: since 0.7.0 `ProgrammeBuilder`, `ChannelBuilder` and `ProgrammeOptions` are re-exported *as types* from the root and from `epg-tools/grabber`, so a helper a site splits out of its `parseDay` can say -what it takes. +what it takes — and `NumberedOptions` with them, for a `numbered` shared +between sites. ### `epg-tools/m3u` @@ -877,7 +879,7 @@ again. `SiteStateHandle`, `StateKey`, `TrackedMap`, `channelsMaxAgeMs`, `DEFAULT_CHANNELS_MAX_AGE_DAYS`, `UnchangedError`, `isUnchanged`, `fellShort`, `resolveAllowance`, and `ProgrammeBuilder` / `ChannelBuilder` / -`ProgrammeOptions` as types. +`ProgrammeOptions` / `NumberedOptions` as types. `*` this subpath only — not re-exported from the root. diff --git a/docs/site-config.md b/docs/site-config.md index 15db351..6571944 100644 --- a/docs/site-config.md +++ b/docs/site-config.md @@ -701,6 +701,23 @@ out and said through the site's `warn`, since a programme short a `` is still a programme, and one lost over it is a hole in the listings. That is this builder's doing: one made on its own, with nowhere to say so, throws instead. +Some sources write the episode into the title: "Duel S01E99", "Komisár Rex III +(13)". For those, pass `numbered`: + +```ts +parseDay({ payload, programme }) { + return payload.items.map((item) => + programme(new Date(item.start), item.title, { numbered: true }).episode(item.episode)); +} +``` + +The title is written without its numbering, and the numbers and any sub-title +after them are written too. What the source gives in fields of its own wins, +and the title fills in the rest: `.episode(item.episode)` above writes the +source's episode in the season the title said. A source with its own words for a season passes them as +`{ numbered: { patterns } }`. See [Numbering in +titles](./xmltv.md#numbering-in-titles). + The whole builder is available, so the provider extensions the parser preserves can be written back out the same way: `.extra()` for non-DTD child elements, `.extraAttributes()` for attributes, and the trailing `extraAttributes` diff --git a/docs/xmltv.md b/docs/xmltv.md index 149bccb..7210a4a 100644 --- a/docs/xmltv.md +++ b/docs/xmltv.md @@ -318,6 +318,119 @@ The three fields partition the id, and `rootId` deliberately excludes the prefix — the same digits appear as `SH01006886.0000` for the series, so `` `SH${rootId}0000` `` is how one is derived from the other. +### Numbering in titles + +Many sources give no episode number of their own and write it into the title +instead. `parseTitleNumbering` takes it off and leaves the series' title: + +```ts +parseTitleNumbering('Duel S01E99'); // { title: 'Duel', season: 1, episode: 99 } +parseTitleNumbering('Komisár Rex III (13)'); // { title: 'Komisár Rex', season: 3, episode: 13 } +parseTitleNumbering('Filter (6/12)'); // { title: 'Filter', episode: 6, episodes: 12 } +parseTitleNumbering('Big Coast - Season 12 - Duncanby Lodge'); +// { title: 'Big Coast', season: 12, subTitle: 'Duncanby Lodge' } +parseTitleNumbering('Spinal Tap II'); // { title: 'Spinal Tap II' } +``` + +A title is read only where it says outright what its numbers are. + +- A numeral at the end is as often a sequel as a season, so "Spinal Tap II" + stays. +- A year in brackets stays: "Hrdinové (2019)". So does a span of years, as a + sports season is written: "Extraliga (2023/24)". +- An `E` and a digit with no `S` before it stays: "Poprask na silnici E4" is a + road. +- A word made of a numeral's letters is not a numeral: "CIVIL (2)" is episode 2 + of "CIVIL". Nor is a numeral in small letters, nor one letter other than `I` + or `V`: "Malcolm X (2)" and "Vitamin C (3)" are names. +- A season or episode of nought is left out: "Duel S00E05" is episode 5, with + no season. A title that names no season or episode above nought stays: + "Duel S00E00", "Show (0/12)". + +What a title says cannot always be told from its form: + +- A king's name ends in a numeral as a season does, so "Louis XIV (3)" is read + as season 14. +- A film can be an episode by name, so "Star Wars: Episode 1 - The Phantom + Menace" is read as episode 1 of "Star Wars", with a sub-title. +- A number in brackets is read as an episode, so a source that puts an age + rating there, "(18)", has to take it off before. +- A number in brackets from 1900 to 2099 is taken for a year, so a daily soap's + "(1999)" stays. A pattern of the source's own reads it, since those are + tried first — see below. + +The built-in forms are English or need no language: + +- `S01E02`, and the rest of the grammar + [`parseOnscreenEpisodeNum`](#episode-numbers) reads, with the season + required: `S01 Ep 5`, `S1/3E2/10`, `S01E02 P1/2` +- `Season 4 Episode 5`, `Season 4, Episode 5` and `Episode 4` +- a Roman season before `(13)` +- `(6/12)` +- `: Season 1` and ` - Season 1` + +The words are read in any case: `s01e02` and `season 1` too. The separator +before the numbering may be a colon, a comma or a dash, `-`, `–` or `—`. Where a form +can be followed by a sub-title after a dash, it is read too. + +A reading is `{ title, season?, seasons?, episode?, episodes?, part?, parts?, +subTitle? }`, its numbers 1-based, as `.episode()` takes them. + +A source's own words go in as `patterns`. They are tried first, and the +built-in forms after them. Each pattern is a regular expression with named +groups: + +- `title`, which is required: a pattern without one throws the first time the + patterns are used, whichever reads the title, since it could never read + anything; +- any of `season`, `seasons`, `episode`, `episodes`, `part`, `parts` and + `subTitle`. + +A pattern is matched against the whole title, as though it began with `^` and +ended with `$`, so a match never leaves part of the title out. A text of more +than 500 characters is no title, and is not read. A number may be +digits or a Roman numeral, with space around it and an ordinal's dot after it: +`3.` and `I.` are 3 and 1. If a group holds a word that is no number, that +pattern passes the title on to the next one. A group that matched nothing is a +number not given, and a match that gives no season or episode is no reading: +the next pattern is tried, so a pattern whose numbers are optional cannot +stand in for the built-in ones. + +```ts +const soap = /(?.+?)\s*\((?<episode>\d+)\)/u; + +parseTitleNumbering('Ulice (1999)', { patterns: [soap] }); // { title: 'Ulice', episode: 1999 } +``` + +```ts +const hungarian = [ + /^(?<title>.+?)(?:\s+-)?\s+(?<season>\d+)\.\s*évad$/u, + /^(?<title>.+?)(?:\s+-)?\s+(?<season>\S+)\.\s*sorozat$/u, +]; + +parseTitleNumbering('Magyar népmesék - I. sorozat', { patterns: hungarian }); +// { title: 'Magyar népmesék', season: 1 } +``` + +A [builder](#builders) reads its title this way as it is made, with `numbered`. + +Some sources give the season in its own field and repeat it at the end of the +title. `stripTitleSeason(title, season, options?)` takes off just that season, +and hands the title back trimmed. A title that is neither text nor a number — +`null`, as an API sends one — comes back as it came, from either function, +rather than as the text "null". Here a bare numeral is read too, because the +season given says what it is: + +```ts +stripTitleSeason('Zločin III', 3); // 'Zločin' +stripTitleSeason('Spinal Tap II', 1); // 'Spinal Tap II' — not that season +stripTitleSeason('Duel S01E05', 1); // 'Duel S01E05' — an episode as well +``` + +`numbered` does not do this, since it reads nothing a source gives apart. A +site that has the season in a field strips the title itself: +`programme(start, stripTitleSeason(item.title, item.season))`. + ### Genres `DVB_GENRES` is the ETSI EN 300 468 vocabulary with the `content_nibble` that @@ -504,6 +617,60 @@ number above zero — is left empty rather than guessed: `.episode(177)` is season made up as `1` would be a claim the source never made, and one a consumer shows; before 0.8.0 it was the default. +`numbered: true` takes off the numbering a source wrote into the title: + +- the title is what [`parseTitleNumbering`](#numbering-in-titles) leaves; +- the numbers are written as `.episode()` would write them; +- a sub-title after the numbers is written as `.subTitle()` would, in the + title's language. + +The title is rewritten as the builder is made. A title nothing reads stays +exactly as it was given. + +What the source's own fields say wins, and the title's numbers fill in: + +- `.episode()` takes the title's numbers for whatever it leaves out. + `.episode(item.episode)` on "Komisár Rex III (13)" is season 3, episode 13 — + the season the title no longer says. A number the call does give wins. +- Each number fills in where the call agrees with what it belongs to. How many + seasons there are is the series', and fills in for any call. The season + fills in for a call that names none: `.episode(113)` on "Komisár Rex III + (13)", from a source numbering episodes across the series, is season 3, + episode 113. The episode and how many there are are the season's, so a call + naming another season gets neither: `.episode(null, 4)` is season 4 alone, + not season 4's episode 13. The part is the episode's, so a call naming + another episode gets none. +- `fromTitle: false` turns that off, so a call's numbers stand alone in place + of the title's. Put it on `numbered` for the whole builder, or on one + `.episode()` call; the call's own says what that call does. +- Where no `.episode()` wrote numbers, the title's are written as the + programme is built — after a call told not to fill in that had none to + give, too, since they were taken off the title and would otherwise be + nowhere. An `xmltv_ns` or `onscreen` entry the site wrote with + `.episodeNum()` stands in for them; one in another system, such as + `dd_progid`, says something else, and the title's are written beside it. +- The title's sub-title is written where the site gives no `.subTitle()` with + any text: one written from a field the source left empty does not stand in + for it. + +`.build()` adds what the title says to the programme it returns, not to the +builder, so a `.subTitle()` or `.episode()` after one build still wins at the +next. + +```ts +programme(start, item.title, { numbered: { fromTitle: false } }).episode(item.episode); +programme(start, item.title, { numbered: true }).episode(item.episode, null, { fromTitle: false }); +``` + +`numbered: { patterns }` reads a source's own forms first. Only the title +passed to the constructor is read, never one added later with `.title()`. + +```ts +ProgrammeBuilder.of('one.example.tv', '20260717200000 +0200', 'Duel - S01E02 - Pilot', { + numbered: true, +}).build(); // title 'Duel', sub-title 'Pilot', episode-num S01E02 +``` + A time the builder cannot read is handled by what the field is. `start` is required, so a bad one always throws. The rest can be done without, so the constructor's `warn` decides: with one, an unreadable `stop`, `date`, diff --git a/src/grabber/main.ts b/src/grabber/main.ts index 47a3d2f..1dfc968 100644 --- a/src/grabber/main.ts +++ b/src/grabber/main.ts @@ -86,4 +86,9 @@ export type * from './types.js'; * these are the types `ParseContext` next door is written in terms of, and * having them anywhere else was the part that did not work. */ -export type { ChannelBuilder, ProgrammeBuilder, ProgrammeOptions } from '../xmltv/builder.js'; +export type { + ChannelBuilder, + NumberedOptions, + ProgrammeBuilder, + ProgrammeOptions, +} from '../xmltv/builder.js'; diff --git a/src/main.ts b/src/main.ts index 7684bd2..e3a9861 100644 --- a/src/main.ts +++ b/src/main.ts @@ -101,6 +101,8 @@ export { parseDdProgidEpisodeNum, parseOnscreenEpisodeNum, parseXmltvNsEpisodeNum, + parseTitleNumbering, + stripTitleSeason, DVB_GENRES, genreKey, genreOf, @@ -126,6 +128,8 @@ export type { OutputProfileName, ProfileRef, RepeatableRef, + TitleNumbering, + TitleNumberingOptions, FindingCode, FindingSeverity, ValidateOptions, @@ -275,7 +279,12 @@ export type { SiteConnection, } from './grabber/main.js'; export type * from './grabber/types.js'; -export type { ChannelBuilder, ProgrammeBuilder, ProgrammeOptions } from './xmltv/builder.js'; +export type { + ChannelBuilder, + NumberedOptions, + ProgrammeBuilder, + ProgrammeOptions, +} from './xmltv/builder.js'; export { createGuideHandler, grabEvery, serveGuide } from './serve/main.js'; export type { diff --git a/src/xmltv/builder.ts b/src/xmltv/builder.ts index e7a9d48..576eb3f 100644 --- a/src/xmltv/builder.ts +++ b/src/xmltv/builder.ts @@ -12,6 +12,11 @@ import { writeXmltvStream, } from './serialize.js'; import type { SerializeOptions, XmltvStreamInput } from './serialize.js'; +import { + readTitleNumbering, + type TitleNumbering, + type TitleNumberingOptions, +} from './title-numbering.js'; import type { XmltvActor, XmltvAudio, @@ -100,6 +105,19 @@ export interface ProgrammeBuilderBase { clumpidx?: string; /** The first `<title>`; the tuple form carries its own lang and attributes. */ title: string | [string, TextOptions]; + /** + * Take the episode numbering a source writes into `title` off it, into + * `<episode-num>` and, after " - ", a `<sub-title>`: "Duel S01E99" is "Duel", + * season 1, episode 99. `true` reads the built-in forms, `{ patterns }` a + * source's own before them — see `parseTitleNumbering`. + * + * The title is rewritten at once. What the source's own fields say wins, and + * the title's fills in: an `.episode()` call takes the title's numbers for + * whatever it leaves out — unless `fromTitle` says not to — and without one + * they are written as the programme is built. The title's sub-title is + * written where the site gives none. + */ + numbered?: boolean | NumberedOptions; /** Default `lang` applied by every method below unless it's given its own. */ lang?: string; /** @@ -191,6 +209,25 @@ export interface EpisodeOptions { part?: number; /** Total parts in the episode (default `1`). */ parts?: number; + /** + * Whether this call fills in what it leaves out from the numbers `numbered` + * read off the title. `false` writes this call's numbers alone, and the + * title's are not written beside them — unless the call writes none. The builder's + * {@link NumberedOptions.fromTitle} unless given, which is `true` unless given. + */ + fromTitle?: boolean; +} + +/** How a builder reads the numbering its title carries — {@link ProgrammeBuilderBase.numbered}. */ +export interface NumberedOptions extends TitleNumberingOptions { + /** + * Whether an `.episode()` call fills in what it leaves out from the title's + * numbers. `false` has a call's numbers stand alone, in place of the + * title's: `.episode(13)` on "Komisár Rex III (13)" is then episode 13 with + * no season. A call's own `fromTitle` says otherwise for that call. `true` + * unless given. + */ + fromTitle?: boolean; } /** @@ -351,6 +388,34 @@ abstract class XmltvElementBuilder extends ExtraAttributesBuilder { } } +/** The numbers a title carries, apart from the title itself. */ +type TitleNumbers = Omit<TitleNumbering, 'title' | 'subTitle'>; + +/** + * No numbers: what a builder fills in from where `numbered` read none off the + * title, or `fromTitle` says not to. + */ +const NO_TITLE_NUMBERS: Readonly<TitleNumbers> = Object.freeze({}); + +/** No options, for {@link ProgrammeBuilder.build}'s numbers, which have none of a call's. */ +const NO_EPISODE_OPTIONS: Readonly<EpisodeOptions> = Object.freeze({}); + +/** What `numbered` read off a builder's title, and what the site has done since. */ +interface TitleRead { + /** The numbers, which an `episode()` call fills in from and `build()` writes where the site wrote none. */ + readonly numbers: Readonly<TitleNumbers>; + /** The sub-title, written where the site gives none. */ + readonly subTitle: XmltvTextValue | undefined; + /** Whether an `episode()` call fills in from the numbers — {@link NumberedOptions.fromTitle}. */ + readonly fill: boolean; + /** + * Whether the site gave numbers of its own — an `episode()` call, or an + * `xmltv_ns` or `onscreen` entry — so `build()` writes none of the title's + * beside them. + */ + given: boolean; +} + /** * Fluent helper for building a fully-typed {@link XmltvProgramme}: construct * with the required base fields, chain the optional/repeatable ones, and @@ -367,6 +432,12 @@ export class ProgrammeBuilder extends XmltvElementBuilder { readonly #programme: XmltvProgramme; /** See {@link ProgrammeBuilderBase.timeZone}. */ readonly #timeZone: string | undefined; + /** + * What `numbered` read off the title — see {@link ProgrammeBuilderBase.numbered}. + * Nothing for a builder not asked to, which then does no more than it ever + * did. + */ + #titleRead: TitleRead | undefined; /** Positional shorthand for `new ProgrammeBuilder({ channel, start, title, ...options })`. */ static of( @@ -385,7 +456,7 @@ export class ProgrammeBuilder extends XmltvElementBuilder { this.#programme = { channel: base.channel, start: this.#zoned(base.start, xmltvDate(base.start)), - title: [this.textValue(base.title)], + title: [this.#numberedTitle(this.textValue(base.title), base.numbered)], }; if (base.stop !== undefined) this.stop(base.stop); @@ -396,6 +467,43 @@ export class ProgrammeBuilder extends XmltvElementBuilder { if (base.clumpidx) this.clumpidx(base.clumpidx); } + /** + * The title, without the numbering `numbered` reads off it — which is kept + * for {@link episode} and {@link build} to write. A title nothing reads stays + * as it was given, untrimmed, and one that is not a string, as a JS caller can + * hand over, is not made one. + */ + #numberedTitle( + title: XmltvTextValue, + numbered: ProgrammeBuilderBase['numbered'], + ): XmltvTextValue { + if (!numbered || typeof title.value !== 'string') { + return title; + } + + const numbering = readTitleNumbering( + title.value, + numbered === true ? undefined : numbered.patterns, + ); + + if (numbering === undefined) { + return title; + } + + this.#titleRead = { + numbers: numbering, + // In the title's language, a title of none included. + subTitle: + numbering.subTitle === undefined + ? undefined + : this.text(numbering.subTitle, title.lang ?? ''), + fill: numbered === true || numbered.fromTitle !== false, + given: false, + }; + + return { ...title, value: numbering.title }; + } + /** * A bare instant in {@link ProgrammeBuilderBase.timeZone}'s offset — unless * the call named an offset of its own. @@ -557,6 +665,10 @@ export class ProgrammeBuilder extends XmltvElementBuilder { /** Explicit escape hatch for an `episode-num` system not covered by {@link episode}. */ episodeNum(system: string, value: string, extraAttributes?: ExtraAttributes): this { + if (this.#titleRead !== undefined && (system === 'xmltv_ns' || system === 'onscreen')) { + this.#titleRead.given = true; + } + (this.#programme.episodeNum ??= []).push( this.extraAttrs<XmltvEpisodeNum>({ system, value }, extraAttributes), ); @@ -576,16 +688,31 @@ export class ProgrammeBuilder extends XmltvElementBuilder { * alone is `74..` with no `onscreen`, and neither is nothing at all. A season * made up as `1` would be a claim the source never made, and one a consumer * shows. + * + * On a builder whose title `numbered` read, a call fills in what it leaves + * out from the title's numbers: `.episode(13)` on "Komisár Rex III (13)" is + * season 3, episode 13 — the season the title no longer says. */ episode( episode?: number | string | null, season?: number | string | null, opts: EpisodeOptions = {}, ): this { - const numbers = this.#episodeNumbers(episode, season, opts); + const numbers = this.#episodeNumbers( + episode, + season, + opts, + this.#titleRead !== undefined && (opts.fromTitle ?? this.#titleRead.fill) + ? this.#titleRead.numbers + : NO_TITLE_NUMBERS, + ); if (numbers.length > 0) { (this.#programme.episodeNum ??= []).push(...numbers); + + if (this.#titleRead !== undefined) { + this.#titleRead.given = true; + } } return this; @@ -780,8 +907,43 @@ export class ProgrammeBuilder extends XmltvElementBuilder { return this; } + /** + * The programme. Where `numbered` read the title, what it read and the site + * gave nothing of its own for is added to a copy — the title's sub-title + * where there is none, its numbers where neither `.episode()` nor an + * `xmltv_ns` or `onscreen` entry gave any — so the builder itself holds only + * what the site wrote, and whatever it adds after a build still wins. + */ build(): XmltvProgramme { - return this.#programme; + const programme = this.#programme; + + // Nothing read off the title — every builder not asked to — and the + // programme is what the site wrote, as it always was. + if (this.#titleRead === undefined) { + return programme; + } + + // The site's own, where one says anything: one it wrote from a field the + // source left empty is none, and does not stand in for the title's. + const subTitle = programme.subTitle?.some(({ value }) => value) + ? undefined + : this.#titleRead.subTitle; + const numbers = this.#titleRead.given + ? [] + : this.#episodeNumbers(undefined, undefined, NO_EPISODE_OPTIONS, this.#titleRead.numbers); + + if (subTitle === undefined && numbers.length === 0) { + return programme; + } + + return { + ...programme, + // A copy each time, so one build's changed by its caller is not the next's. + ...(subTitle === undefined ? {} : { subTitle: [{ ...subTitle }] }), + ...(numbers.length === 0 + ? {} + : { episodeNum: [...(programme.episodeNum ?? []), ...numbers] }), + }; } // --- internal helpers --- @@ -896,7 +1058,6 @@ export class ProgrammeBuilder extends XmltvElementBuilder { return this.extraAttrs(rating, extraAttributes); } - /** Builds both `xmltv_ns` (0-based) and `onscreen` entries from 1-based season/episode numbers. */ /** * A 1-based number as the 0-based index both wire formats use — or nothing, * for one the source did not give: missing, `null`, not a whole number, or @@ -912,13 +1073,45 @@ export class ProgrammeBuilder extends XmltvElementBuilder { return Number.isInteger(number) && number > 0 ? number - 1 : undefined; } + /** + * The `xmltv_ns` and `onscreen` entries for the 1-based numbers a call + * gives, filled in from `title`'s for what it leaves out — each where the + * call agrees with what that number belongs to: + * + * - how many seasons there are is the series', and fills in for any call; + * - the season fills in for a call that names none; + * - the episode, and how many there are, are the season's, and fill in only + * for a call naming no other season — season 3's episode 13 is not + * season 4's; + * - the part, and how many there are, are the episode's, and fill in only + * for a call naming no other episode either. + */ #episodeNumbers( episode: number | string | null | undefined, season: number | string | null | undefined, - opts: EpisodeOptions, + given: EpisodeOptions, + title: Readonly<TitleNumbers>, ): XmltvEpisodeNum[] { - const seasonIndex = this.#index(season); - const episodeIndex = this.#index(episode); + const givenSeason = this.#index(season); + const givenEpisode = this.#index(episode); + const seasonAgrees = + givenSeason === undefined || title.season === undefined || givenSeason === title.season - 1; + const episodeAgrees = + seasonAgrees && + (givenEpisode === undefined || + title.episode === undefined || + givenEpisode === title.episode - 1); + const seasonIndex = givenSeason ?? this.#index(title.season); + const episodeIndex = givenEpisode ?? (seasonAgrees ? this.#index(title.episode) : undefined); + const seasons = given.seasons ?? title.seasons; + const episodes = given.episodes ?? (seasonAgrees ? title.episodes : undefined); + const parts = given.parts ?? (episodeAgrees ? title.parts : undefined); + // A call giving how many parts and not which is at part 1, as it always + // was. A title's that says how many and not which — "P0/2" — is not known. + const part = + given.part ?? + (episodeAgrees ? title.part : undefined) ?? + (given.parts === undefined ? undefined : 1); if (seasonIndex === undefined && episodeIndex === undefined) { return []; @@ -927,18 +1120,26 @@ export class ProgrammeBuilder extends XmltvElementBuilder { // The 1-based numbers a caller gives, as the 0-based indices both wire // formats use — and an empty slot where it gave none, which is how // `xmltv_ns` says "not known". An unspecified part means "part 1 of 1" - // (`0/1`), of an episode: with no episode there is no part to number. + // (`0/1`), of an episode: with no episode there is no part to number. A + // part with no total has none made up: "part 2 of 1" is no part. const numbers: EpisodeNumbers = { season: { ...(seasonIndex === undefined ? {} : { index: seasonIndex }), - ...(opts.seasons === undefined ? {} : { total: opts.seasons }), + ...(seasons === undefined ? {} : { total: seasons }), }, episode: { ...(episodeIndex === undefined ? {} : { index: episodeIndex }), - ...(opts.episodes === undefined ? {} : { total: opts.episodes }), + ...(episodes === undefined ? {} : { total: episodes }), }, part: - episodeIndex === undefined ? {} : { index: (opts.part ?? 1) - 1, total: opts.parts ?? 1 }, + episodeIndex === undefined + ? {} + : part === undefined && parts === undefined + ? { index: 0, total: 1 } + : { + ...(part === undefined ? {} : { index: part - 1 }), + ...(parts === undefined ? {} : { total: parts }), + }, }; // Undefined where there is no episode to name: `onscreen` has no form for a // season alone. diff --git a/src/xmltv/episode-num.ts b/src/xmltv/episode-num.ts index aa4c387..81f3822 100644 --- a/src/xmltv/episode-num.ts +++ b/src/xmltv/episode-num.ts @@ -156,6 +156,12 @@ export function formatXmltvNsEpisodeNum(numbers: EpisodeNumbers): string { return [numbers.season, numbers.episode, numbers.part].map(dimension).join('.'); } +/** The `S1/3 ` that may start an `onscreen` value — its season, of how many. */ +const ONSCREEN_SEASON = String.raw`S(?<season>\d{1,4})(?:\/(?<seasons>\d{1,4}))?[\s._]*`; + +/** The `E2/10 P1/2` an `onscreen` value ends in — its episode, of how many, and its part. */ +const ONSCREEN_EPISODE = String.raw`EP?\s*(?<episode>\d{1,5})(?:\/(?<episodes>\d{1,5}))?(?:[\s._]*P(?<part>\d{1,3})(?:\/(?<parts>\d{1,3}))?)?`; + /** * The one `onscreen` grammar worth reading, which is WebGrab+Plus's: * `[Sn[/St]] En[/Et] [Pn[/Pt]]` — letters required, spaces optional. @@ -181,8 +187,15 @@ export function formatXmltvNsEpisodeNum(numbers: EpisodeNumbers): string { * - **Anything trailing** — `S1E2 1` is not a real form, and Kodi's * strip-then-match approach would silently read it as episode 21. */ -const ONSCREEN = - /^(?:S(?<season>\d{1,4})(?:\/(?<seasons>\d{1,4}))?[\s._]*)?EP?\s*(?<episode>\d{1,5})(?:\/(?<episodes>\d{1,5}))?(?:[\s._]*P(?<part>\d{1,3})(?:\/(?<parts>\d{1,3}))?)?$/i; +const ONSCREEN = new RegExp(`^(?:${ONSCREEN_SEASON})?${ONSCREEN_EPISODE}$`, 'i'); + +/** + * The `onscreen` grammar as a title ends in it, unanchored, for + * `parseTitleNumbering`: the season required, since an `E` and a digit at the + * end of a title with no `S` before them is as often a road — "Poprask na + * silnici E4" — as an episode. Its groups are the numbers' own names. + */ +export const ONSCREEN_IN_TITLE = `${ONSCREEN_SEASON}${ONSCREEN_EPISODE}`; /** * A 1-based number from an `onscreen` value, as a 0-based index. diff --git a/src/xmltv/main.ts b/src/xmltv/main.ts index 3c9055c..5cb86a8 100644 --- a/src/xmltv/main.ts +++ b/src/xmltv/main.ts @@ -50,6 +50,8 @@ export { parseXmltvNsEpisodeNum, } from './episode-num.js'; export type { DdProgid, DdProgidType, EpisodeField, EpisodeNumbers } from './episode-num.js'; +export { parseTitleNumbering, stripTitleSeason } from './title-numbering.js'; +export type { TitleNumbering, TitleNumberingOptions } from './title-numbering.js'; export { DVB_GENRES, genreKey, genreOf } from './genres.js'; export type { Genre } from './genres.js'; export { OUTPUT_PROFILE_NAMES, OUTPUT_PROFILES, resolveProfile } from './profile.js'; @@ -83,6 +85,7 @@ export type { EpisodeOptions, IconOptions, ImageOptions, + NumberedOptions, PersonOptions, PreviouslyShownOptions, ProgrammeBuilderBase, diff --git a/src/xmltv/title-numbering.ts b/src/xmltv/title-numbering.ts new file mode 100644 index 0000000..4c4fc29 --- /dev/null +++ b/src/xmltv/title-numbering.ts @@ -0,0 +1,372 @@ +/** + * The episode numbering a source writes into a title rather than beside it — + * "Duel S01E99", "Komisár Rex III (13)", "Robogobo: Season 1" — taken off, so + * the title is the series' and the numbers can go where XMLTV keeps them. + * + * The guards matter as much as the patterns. A numeral at the end of a title is + * as often a sequel as a season ("Spinal Tap II"), a number in brackets as often + * a year ("(2019)") and an `E` and a digit a road ("Poprask na silnici E4"), so + * a title is only read where it says outright what its numbers are. + * + * What is built in is English, or no language at all: a source's own words for + * a season — "Sezóna", "évad" — are a pattern it is given, tried before these. + */ + +import { ONSCREEN_IN_TITLE } from './episode-num.js'; + +/** + * A title with the numbering its source wrote into it taken off. The numbers + * are 1-based, and are what `ProgrammeBuilder.episode()` takes. + */ +export interface TitleNumbering { + /** What is left: the series' own title. */ + title: string; + season?: number; + /** How many seasons there are, from "S1/3E2". */ + seasons?: number; + episode?: number; + /** How many episodes there are, from "(6/12)". */ + episodes?: number; + /** Which part of a multi-part episode, from "S01E02 P1/2". */ + part?: number; + /** How many parts the episode is in. */ + parts?: number; + /** What follows the numbering after " - ": "Season 2 Episode 5 - Monday". */ + subTitle?: string; +} + +/** What {@link parseTitleNumbering} and {@link stripTitleSeason} read a title with. */ +export interface TitleNumberingOptions { + /** + * Forms of a source's own, tried before the built-in ones — each a regular + * expression with named groups: `title`, which is required, and any of + * `season`, `seasons`, `episode`, `episodes`, `part`, `parts` and + * `subTitle`. It is matched against the whole title, as though it began with + * `^` and ended with `$`, and one without a `title` group throws the first + * time it is used. + * + * A number may be digits or a well-formed Roman numeral, with space around + * it and an ordinal's dot after it — "3." and "I." are 3 and 1. Nought is left + * out, and a reading with no season or episode above nought is none. A word + * that is no number has the pattern pass the title on rather than read it, as + * "CIVIL (2)" is not season `CIVIL`. + * + * Tried first, a pattern is also how a source reads what the built-in ones + * leave alone: a daily soap's "(1999)", which they take for a year, is + * `/(?<title>.+?)\s*\((?<episode>\d+)\)/u`. + * + * ```ts + * { patterns: [/^(?<title>.+?)\s+(?<season>\d+)\.\s*évad$/u] } + * ``` + */ + patterns?: readonly RegExp[]; +} + +/** + * Between a title and its numbering: a colon, a comma, or a dash — "-", "–" or + * "—" — after a space. + */ +const SEPARATOR = String.raw`(?::|,|\s+[-–—])`; + +/** A sub-title after the numbering, after a dash. */ +const SUB_TITLE = String.raw`(?:\s+[-–—]\s+(?<subTitle>.+))?`; + +/** + * "(13)" or "(6/12)": an episode, of how many. Not a year, "(2019)", nor a + * season's span of two, "(2023/24)". + */ +const EPISODE_OF = String.raw`\((?!(?:19|20)\d\d[)/])(?<episode>\d{1,4})(?:\/(?<episodes>\d+))?\)`; + +/** + * The forms read out of the box, in the order they are tried: + * + * - "Duel S01E99", "Duel - s01e02 - Pilot", "Duel S01 Ep 5", "Duel S1/3E2/10 + * P1/2" — the `onscreen` grammar `parseOnscreenEpisodeNum` reads, with the + * season it leaves optional required. + * - "Walker, Texas Ranger Season 4 Episode 5", "Show: Season 2, Episode 5", + * "TrimZZ: Episode 4". + * - "Komisár Rex III (13)", "Kobra 11 XXIII. (3)" — a season numeral and the + * episode. A numeral of one letter is a season only as `I` or `V`: "Malcolm + * X", "Vitamin C" and "Generation X" are names. + * - "M*A*S*H (49)", "Filter (6/6)" — the episode, of how many. + * - "Robogobo: Season 1", "Big Coast - Season 12 - Duncanby Lodge" — the + * season, after a separator. + * + * The words are read in any case; a Roman numeral only in capitals, since + * "civil" is no more a numeral than "CIVIL" is. + */ +const BUILT_IN: readonly RegExp[] = [ + new RegExp(String.raw`^(?<title>.+?)${SEPARATOR}?\s+${ONSCREEN_IN_TITLE}${SUB_TITLE}$`, 'iu'), + new RegExp( + String.raw`^(?<title>.+?)(?<!\bSeason\s+\d+)${SEPARATOR}?\s+(?:Season\s+(?<season>\d+)${SEPARATOR}?\s+)?Episode\s+(?<episode>\d+)${SUB_TITLE}$`, + 'iu', + ), + new RegExp( + String.raw`^(?<title>.+?)${SEPARATOR}?\s+(?<season>[IVXLC]{2,}|[IV])\.?\s*${EPISODE_OF}$`, + 'u', + ), + new RegExp(String.raw`^(?<title>.+?)${SEPARATOR}?\s*${EPISODE_OF}$`, 'u'), + new RegExp(String.raw`^(?<title>.+?)${SEPARATOR}\s+Season\s+(?<season>\d+)${SUB_TITLE}$`, 'iu'), +]; + +/** + * A numeral ending a title, read only by {@link stripTitleSeason}: a season + * where a source says apart which one it is, a sequel's name where it does not. + */ +const TRAILING_NUMERAL = /^(?<title>.+?)\s+(?<season>[IVXLC]+)\.?$/u; + +const NUMBERS = ['season', 'seasons', 'episode', 'episodes', 'part', 'parts'] as const; + +/** The longest text read as a title — see {@link read}. */ +const LONGEST_TITLE = 500; + +/** A pattern's named groups, as a match has them. */ +type Groups = Record<string, string | undefined>; + +/** + * What one pattern read: the numbering, and the groups it read it from — which + * still say what a nought the numbering leaves out was. + */ +interface Reading { + numbering: TitleNumbering; + groups: Groups; +} + +/** + * A title, and the numbering at its end where it says outright what that is. + * A title nothing reads comes back as the only field, trimmed. + * + * ```ts + * parseTitleNumbering('Komisár Rex III (13)'); // { title: 'Komisár Rex', season: 3, episode: 13 } + * parseTitleNumbering('Spinal Tap II'); // { title: 'Spinal Tap II' } + * ``` + * + * A number is taken too, as an API gives a title of "13", and a `null` or + * `undefined` one comes back as it came — the type says so. + */ +export function parseTitleNumbering<T extends string | number | null | undefined>( + title: T, + options?: TitleNumberingOptions, +): Omit<TitleNumbering, 'title'> & { title: string | Extract<T, null | undefined> } { + // Neither, as an API can send a title, is handed back as it came rather + // than made the text "null" — as a builder keeps it. + if (typeof title !== 'string' && typeof title !== 'number') { + return { title: title as Extract<T, null | undefined> }; + } + + const full = String(title).trim(); + + return readTitleNumbering(full, options?.patterns) ?? { title: full }; +} + +/** + * {@link parseTitleNumbering}, with nothing where nothing reads the title — so + * a builder can leave the title it was given as it was given. + */ +export function readTitleNumbering( + title: string, + patterns?: readonly RegExp[], +): TitleNumbering | undefined { + return read(title.trim(), patterns)?.numbering; +} + +/** + * A title, trimmed, without the season a source also gives apart, where the + * title repeats it at its end: "Zločin III", "Kobra 11 XXIII.", "Robogobo: + * Season 1" for season 3, 23 and 1. A source's own forms are read too, before + * these. + * + * Only that season, and only where it is all the title says: "Spinal Tap II" + * from a source that says season 1 is a sequel's name, "Duel S01E05" has an + * episode in it as well, and a title with no season given stays whole. A + * `null` or `undefined` title comes back as it came, as the type says. + */ +export function stripTitleSeason<T extends string | number | null | undefined>( + title: T, + season: number | string | null | undefined, + options?: TitleNumberingOptions, +): string | Extract<T, null | undefined> { + // Neither, as an API can send a title, is handed back as it came — see + // parseTitleNumbering. + if (typeof title !== 'string' && typeof title !== 'number') { + return title as Extract<T, null | undefined>; + } + + const full = String(title).trim(); + const wanted = Number(season); + + if (!Number.isInteger(wanted) || wanted <= 0) { + return full; + } + + const reading = read(full, options?.patterns, TRAILING_NUMERAL); + + return reading !== undefined && + reading.numbering.season === wanted && + reading.numbering.subTitle === undefined && + NUMBERS.every((key) => key === 'season' || written(reading.groups[key]) === undefined) + ? reading.numbering.title + : full; +} + +/** + * What the first pattern to read `full` read — a source's own, the built-in + * ones, then `last` — or nothing where none does. + */ +function read( + full: string, + own: readonly RegExp[] | undefined, + last?: RegExp, +): Reading | undefined { + // Each of a source's patterns checked before any reads, so one that cannot + // read anything says so on the first title, not on the first that the + // patterns before it leave to it. + for (const pattern of own ?? []) { + whole(pattern); + } + + // No title is this long, and a text that is — a description in the wrong + // field, a run of padding — is one a pattern's `.+?\s+` would take time + // that grows with the square of its length to give up on. + if (full.length > LONGEST_TITLE) { + return undefined; + } + + for (const pattern of own ?? []) { + const reading = readWith(whole(pattern), full); + + if (reading !== undefined) { + return reading; + } + } + + for (const pattern of BUILT_IN) { + const reading = readWith(pattern, full); + + if (reading !== undefined) { + return reading; + } + } + + return last === undefined ? undefined : readWith(last, full); +} + +/** What one pattern reads `full` as, if anything. */ +function readWith(pattern: RegExp, full: string): Reading | undefined { + const groups = pattern.exec(full)?.groups; + + return groups === undefined ? undefined : numbered(groups); +} + +/** A source's global or sticky pattern, as one that keeps no place between titles. */ +const WHOLE = new WeakMap<RegExp, RegExp>(); + +/** + * A source's pattern as one over the whole title, compiled the first time it + * is used: anchored at both ends, so a match is the whole title or none — one + * that read part of it would drop the rest with nothing said — and without the + * flags that would have it go on from where it stopped on the last title, or + * match one line of it. The caller's own pattern, and its `lastIndex`, are + * left alone. + * + * One with nowhere to put the title is a mistake in it, which would read + * nothing, ever, with nothing said — so it is refused here, as it is first + * used, rather than the first time it would have matched. + */ +function whole(pattern: RegExp): RegExp { + let compiled = WHOLE.get(pattern); + + if (compiled === undefined) { + if (!pattern.source.includes('(?<title>')) { + throw new TypeError( + `A title-numbering pattern needs a (?<title>…) group, and ${String(pattern)} has none`, + ); + } + + compiled = new RegExp(`^(?:${pattern.source})$`, pattern.flags.replace(/[gmy]/g, '')); + WHOLE.set(pattern, compiled); + } + + return compiled; +} + +/** + * A number as a group wrote it, without the space around it and an ordinal's + * dot — or nothing where the group took no part or matched nothing, which is + * a number not given. + */ +function written(group: string | undefined): string | undefined { + const number = group?.trim().replace(/\.$/, ''); + + return number ? number : undefined; +} + +/** + * What one pattern read, or nothing where it read no title, a number that is + * not one, or no season or episode above nought — which hands the title on to + * the next pattern. + */ +function numbered(groups: Groups): Reading | undefined { + const title = groups['title']?.trim(); + + if (!title) { + return undefined; + } + + const numbering: TitleNumbering = { title }; + + for (const key of NUMBERS) { + const number = written(groups[key]); + + if (number === undefined) { + continue; + } + + const value = /^\d+$/.test(number) ? Number(number) : romanToNumber(number); + + if (value === undefined) { + return undefined; + } + + // Nought is no number to write — "S00E05" is a special, its season none — + // and the rest of what the pattern read stands. + if (value > 0 && Number.isSafeInteger(value)) { + numbering[key] = value; + } + } + + // A reading names a season or an episode. Without one there is nothing to + // write — a total, a part, a nought, or a pattern whose numbers are all + // optional and took no part — and taking anything off the title would lose + // what it said: "Duel S00E00" and "Show (0/12)" stay as they are. + if (numbering.season === undefined && numbering.episode === undefined) { + return undefined; + } + + const subTitle = groups['subTitle']?.trim(); + + if (subTitle) { + numbering.subTitle = subTitle; + } + + return { numbering, groups }; +} + +const ROMAN_DIGITS: Readonly<Record<string, number>> = { I: 1, V: 5, X: 10, L: 50, C: 100 }; + +/** A well-formed numeral up to 399, so a word of those letters — "CIVIL" — is not one. */ +const ROMAN = /^C{0,3}(?:XC|XL|L?X{0,3})(?:IX|IV|V?I{0,3})$/; + +/** A Roman numeral's value — "XXV" is 25 — or nothing for anything else. */ +function romanToNumber(roman: string): number | undefined { + if (roman === '' || !ROMAN.test(roman)) { + return undefined; + } + + const digits = [...roman].map((letter) => ROMAN_DIGITS[letter] ?? 0); + + return digits.reduce( + (sum, value, i) => ((digits[i + 1] ?? 0) > value ? sum - value : sum + value), + 0, + ); +} diff --git a/test/builder.test.ts b/test/builder.test.ts index 4231da0..308fc81 100644 --- a/test/builder.test.ts +++ b/test/builder.test.ts @@ -18,6 +18,7 @@ import { writeXmltvStream, zonedXmltvDate, } from '../src/xmltv/main.js'; +import type { NumberedOptions } from '../src/xmltv/main.js'; async function collect(input: Parameters<typeof writeXmltvStream>[0]): Promise<string> { let out = ''; @@ -192,6 +193,347 @@ describe('ProgrammeBuilder', () => { }); }); + describe('a title with its numbering in it', () => { + const start = '20260717200000 +0000'; + + it('is written as its numbers, its sub-title and the title left', () => { + const built = new ProgrammeBuilder({ + channel: 'c', + start, + title: 'Morning Season 2 Episode 5 - Monday', + lang: 'en', + numbered: true, + }).build(); + + expect(built.title).toEqual([{ value: 'Morning', lang: 'en' }]); + expect(built.subTitle).toEqual([{ value: 'Monday', lang: 'en' }]); + expect(built.episodeNum).toEqual([ + { system: 'xmltv_ns', value: '1.4.0/1' }, + { system: 'onscreen', value: 'S02E05' }, + ]); + }); + + it('keeps what the title says of itself, and gives the sub-title its language', () => { + const built = new ProgrammeBuilder({ + channel: 'c', + start, + title: ['Duel - S01E02 - Pilot', { lang: 'en', extraAttributes: { id: '7' } }], + lang: 'sk', + numbered: true, + }).build(); + + expect(built.title).toEqual([{ value: 'Duel', lang: 'en', extraAttributes: { id: '7' } }]); + expect(built.subTitle).toEqual([{ value: 'Pilot', lang: 'en' }]); + }); + + it('reads a title with space around it', () => { + const built = ProgrammeBuilder.of('c', start, ' Duel S01E99 ', { numbered: true }).build(); + + expect(built.title).toEqual([{ value: 'Duel' }]); + }); + + it('writes how many episodes there are', () => { + const built = ProgrammeBuilder.of('c', start, 'Filter (6/12)', { numbered: true }).build(); + + expect(built.title).toEqual([{ value: 'Filter' }]); + expect(built.subTitle).toBeUndefined(); + expect(built.episodeNum).toEqual([ + { system: 'xmltv_ns', value: '.5/12.0/1' }, + { system: 'onscreen', value: 'E6' }, + ]); + }); + + it("reads a source's own patterns before the built-in ones", () => { + const patterns = [/^(?<title>.+?)\s+(?<season>\d+)\.\s*évad$/u]; + const built = ProgrammeBuilder.of('c', start, 'Herbárium 2. évad', { + numbered: { patterns }, + }).build(); + + expect(built.title).toEqual([{ value: 'Herbárium' }]); + expect(built.episodeNum).toEqual([{ system: 'xmltv_ns', value: '1..' }]); + }); + + it('leaves a title alone where nothing reads it, or it is not asked to', () => { + const spinalTap = ProgrammeBuilder.of('c', start, 'Spinal Tap II', { + numbered: true, + }).build(); + const duel = ProgrammeBuilder.of('c', start, 'Duel S01E99', { numbered: false }).build(); + + expect(spinalTap).toEqual(ProgrammeBuilder.of('c', start, 'Spinal Tap II').build()); + expect(duel.title).toEqual([{ value: 'Duel S01E99' }]); + expect(duel.episodeNum).toBeUndefined(); + }); + + it('leaves a title nothing reads as it was given, untrimmed and unmade', () => { + const spaced = ProgrammeBuilder.of('c', start, ' News ', { numbered: true }).build(); + // As a JS caller can hand it over, from an API that sent `"title": null`. + const missing = ProgrammeBuilder.of('c', start, null as unknown as string, { + numbered: true, + }).build(); + + expect(spaced.title).toEqual([{ value: ' News ' }]); + expect(missing.title).toEqual([{ value: null }]); + }); + + it("gives the source's own numbers and sub-title over the title's", () => { + const built = ProgrammeBuilder.of('c', start, 'Duel - S01E02 - Pilot', { numbered: true }) + .episode(3, 1) + .subTitle('The Pilot') + .build(); + + expect(built.title).toEqual([{ value: 'Duel' }]); + expect(built.subTitle).toEqual([{ value: 'The Pilot' }]); + expect(built.episodeNum).toEqual([ + { system: 'xmltv_ns', value: '0.2.0/1' }, + { system: 'onscreen', value: 'S01E03' }, + ]); + }); + + it("writes none of the title's beside an xmltv_ns or onscreen entry of the site's", () => { + const built = ProgrammeBuilder.of('c', start, 'Duel S01E02', { numbered: true }) + .episodeNum('xmltv_ns', '0.4.') + .build(); + + expect(built.episodeNum).toEqual([{ system: 'xmltv_ns', value: '0.4.' }]); + }); + + it("writes the title's where the source's own say something else", () => { + const built = ProgrammeBuilder.of('c', start, 'Duel S01E02', { numbered: true }) + .episodeNum('dd_progid', 'EP01006886.0028') + .build(); + + expect(built.episodeNum).toEqual([ + { system: 'dd_progid', value: 'EP01006886.0028' }, + { system: 'xmltv_ns', value: '0.1.0/1' }, + { system: 'onscreen', value: 'S01E02' }, + ]); + }); + + it("fills in what the source's own numbers leave out from the title's", () => { + const rex = ProgrammeBuilder.of('c', start, 'Komisár Rex III (13)', { numbered: true }); + const show = ProgrammeBuilder.of('c', start, 'Show (13/20)', { numbered: true }); + const part = ProgrammeBuilder.of('c', start, 'Duel S01E02 P1/2', { numbered: true }); + + // The season the title no longer says. + expect(rex.episode(13, null).build().episodeNum).toEqual([ + { system: 'xmltv_ns', value: '2.12.0/1' }, + { system: 'onscreen', value: 'S03E13' }, + ]); + // The episode, and how many there are. + expect(show.episode(null, 3).build().episodeNum).toEqual([ + { system: 'xmltv_ns', value: '2.12/20.0/1' }, + { system: 'onscreen', value: 'S03E13' }, + ]); + // A number the call gives wins, and one it gives as nothing is not given. + expect( + ProgrammeBuilder.of('c', start, 'Show (13/20)', { numbered: true }) + .episode(13, 3, { episodes: 26 }) + .build().episodeNum, + ).toEqual([ + { system: 'xmltv_ns', value: '2.12/26.0/1' }, + { system: 'onscreen', value: 'S03E13' }, + ]); + expect( + ProgrammeBuilder.of('c', start, 'Komisár Rex III (13)', { numbered: true }) + .episode(13, 0) + .build().episodeNum, + ).toEqual(rex.build().episodeNum); + // The episode, and its part. + expect(part.episode(null, 1).build().episodeNum).toEqual([ + { system: 'xmltv_ns', value: '0.1.0/2' }, + { system: 'onscreen', value: 'S01E02 P1/2' }, + ]); + }); + + it("fills in only what belongs with the call's own numbers", () => { + const numbers = (title: string, ...args: Parameters<ProgrammeBuilder['episode']>) => + ProgrammeBuilder.of('c', start, title, { numbered: true }) + .episode(...args) + .build().episodeNum; + + // Not part 1 of 2 of S02E03, which was S01E02's. + expect(numbers('Duel S01E02 P1/2', 3, 2)).toEqual([ + { system: 'xmltv_ns', value: '1.2.0/1' }, + { system: 'onscreen', value: 'S02E03' }, + ]); + // Not episode 13 of season 4, which was season 3's. + expect(numbers('Komisár Rex III (13)', null, 4)).toEqual([ + { system: 'xmltv_ns', value: '3..' }, + ]); + // Not season 1's total of 10 episodes for season 2 — but the series' + // 3 seasons. + expect(numbers('Duel S1/3E2/10', 5, 2)).toEqual([ + { system: 'xmltv_ns', value: '1/3.4.0/1' }, + { system: 'onscreen', value: 'S02E05' }, + ]); + // The season, for an episode numbered across the whole series. + expect(numbers('Komisár Rex III (13)', 113)).toEqual([ + { system: 'xmltv_ns', value: '2.112.0/1' }, + { system: 'onscreen', value: 'S03E113' }, + ]); + // The season, and not S01E02's part for S01E03. + expect(numbers('Duel S01E02 P1/2', 3)).toEqual([ + { system: 'xmltv_ns', value: '0.2.0/1' }, + { system: 'onscreen', value: 'S01E03' }, + ]); + // How many episodes the season has, for another of its episodes. + expect(numbers('Show (13/20)', 14, 3)).toEqual([ + { system: 'xmltv_ns', value: '2.13/20.0/1' }, + { system: 'onscreen', value: 'S03E14' }, + ]); + }); + + describe('told not to fill in from the title', () => { + const rex = (numbered: true | NumberedOptions) => + ProgrammeBuilder.of('c', start, 'Komisár Rex III (13)', { numbered }); + const e13 = [ + { system: 'xmltv_ns', value: '.12.0/1' }, + { system: 'onscreen', value: 'E13' }, + ]; + const s03e13 = [ + { system: 'xmltv_ns', value: '2.12.0/1' }, + { system: 'onscreen', value: 'S03E13' }, + ]; + + it("writes a call's numbers alone, for the builder", () => { + const built = rex({ fromTitle: false }).episode(13).build(); + + expect(built.title).toEqual([{ value: 'Komisár Rex' }]); + expect(built.episodeNum).toEqual(e13); + }); + + it("still writes the title's where no call gives any", () => { + expect(rex({ fromTitle: false }).build().episodeNum).toEqual(s03e13); + }); + + it('reads its patterns all the same', () => { + const built = ProgrammeBuilder.of('c', start, 'Herbárium 2. évad', { + numbered: { patterns: [/^(?<title>.+?)\s+(?<season>\d+)\.\s*évad$/u], fromTitle: false }, + }).build(); + + expect(built.title).toEqual([{ value: 'Herbárium' }]); + }); + + it("writes a call's numbers alone, for that call", () => { + expect(rex(true).episode(13, null, { fromTitle: false }).build().episodeNum).toEqual(e13); + }); + + it('lets a call fill in where the builder says not to', () => { + expect( + rex({ fromTitle: false }).episode(13, null, { fromTitle: true }).build().episodeNum, + ).toEqual(s03e13); + }); + }); + + it('fills in on every call', () => { + const built = ProgrammeBuilder.of('c', start, 'Komisár Rex III (13)', { numbered: true }) + .episode(13) + .episode(null, 3) + .episode(14) + .build(); + + expect(built.episodeNum).toEqual([ + { system: 'xmltv_ns', value: '2.12.0/1' }, + { system: 'onscreen', value: 'S03E13' }, + { system: 'xmltv_ns', value: '2.12.0/1' }, + { system: 'onscreen', value: 'S03E13' }, + { system: 'xmltv_ns', value: '2.13.0/1' }, + { system: 'onscreen', value: 'S03E14' }, + ]); + }); + + it("still writes the title's numbers after a call that gave none, told not to fill in", () => { + const built = ProgrammeBuilder.of('c', start, 'Komisár Rex III (13)', { + numbered: { fromTitle: false }, + }) + // As an API sends it: `"episode": null`. + .episode(null) + .build(); + + // Taken off the title, so written here or nowhere. + expect(built.title).toEqual([{ value: 'Komisár Rex' }]); + expect(built.episodeNum).toEqual([ + { system: 'xmltv_ns', value: '2.12.0/1' }, + { system: 'onscreen', value: 'S03E13' }, + ]); + }); + + it("writes the title's sub-title over one of the site's that says nothing", () => { + const built = ProgrammeBuilder.of('c', start, 'Duel - S01E02 - Pilot', { numbered: true }) + // As an API sends it: `"episodeTitle": ""`. + .subTitle('') + .build(); + + expect(built.subTitle).toEqual([{ value: 'Pilot' }]); + }); + + it('writes a part with no total as that part, of a number not known', () => { + const numbers = (title: string) => + ProgrammeBuilder.of('c', start, title, { numbered: true }).build().episodeNum; + + expect(numbers('Duel S01E02 P2')).toEqual([ + { system: 'xmltv_ns', value: '0.1.1' }, + { system: 'onscreen', value: 'S01E02' }, + ]); + // How many parts, and not which. + expect(numbers('Duel S01E02 P0/2')).toEqual([ + { system: 'xmltv_ns', value: '0.1./2' }, + { system: 'onscreen', value: 'S01E02' }, + ]); + }); + + it('hands each build a sub-title of its own', () => { + const builder = ProgrammeBuilder.of('c', start, 'Duel - S01E02 - Pilot', { numbered: true }); + + builder.build().subTitle![0]!.value = 'Changed'; + + expect(builder.build().subTitle).toEqual([{ value: 'Pilot' }]); + }); + + it("gives the source's own over the title's after a build too", () => { + const builder = ProgrammeBuilder.of('c', start, 'Duel - S01E02 - Pilot', { numbered: true }); + + expect(builder.build().subTitle).toEqual([{ value: 'Pilot' }]); + + const built = builder.episode(3, 1).subTitle('The Pilot').build(); + + expect(built.subTitle).toEqual([{ value: 'The Pilot' }]); + expect(built.episodeNum).toEqual([ + { system: 'xmltv_ns', value: '0.2.0/1' }, + { system: 'onscreen', value: 'S01E03' }, + ]); + }); + + it('leaves a title whose numbers say nothing to write as it is', () => { + const built = ProgrammeBuilder.of('c', start, 'Show (0/12)', { numbered: true }).build(); + + expect(built.title).toEqual([{ value: 'Show (0/12)' }]); + expect(built.episodeNum).toBeUndefined(); + }); + + it('writes them once, however often it is built', () => { + const builder = ProgrammeBuilder.of('c', start, 'Duel - S01E02 - Pilot', { numbered: true }); + + builder.build(); + + expect(builder.build().episodeNum).toHaveLength(2); + expect(builder.build().subTitle).toHaveLength(1); + }); + + it('gives the sub-title no language where the title has none', () => { + const built = new ProgrammeBuilder({ + channel: 'c', + start, + title: ['Duel - S01E02 - Pilot', { lang: '' }], + lang: 'sk', + numbered: true, + }).build(); + + expect(built.title).toEqual([{ value: 'Duel' }]); + expect(built.subTitle).toEqual([{ value: 'Pilot' }]); + }); + }); + it('adds xmltv_ns episode/season totals and part counts via options', () => { const build = (opts: Parameters<ProgrammeBuilder['episode']>[2]) => new ProgrammeBuilder({ channel: 'c', start: '20260717200000 +0000', title: 'T' }) @@ -212,6 +554,8 @@ describe('ProgrammeBuilder', () => { expect(xmltvNs({ parts: 2 })).toEqual({ system: 'xmltv_ns', value: '1.5.0/2' }); // An explicit later part. expect(xmltvNs({ part: 2, parts: 2 })).toEqual({ system: 'xmltv_ns', value: '1.5.1/2' }); + // A part with no total has none made up: "part 2 of 1" is no part. + expect(xmltvNs({ part: 2 })).toEqual({ system: 'xmltv_ns', value: '1.5.1' }); // onscreen stays plain for a single part, and marks a 1-based part with `P` // when multi-part. `P` and not a parenthesised ratio: a `(n/m)` after an diff --git a/test/grabber.test.ts b/test/grabber.test.ts index 8e9ec8a..00d0252 100644 --- a/test/grabber.test.ts +++ b/test/grabber.test.ts @@ -486,6 +486,33 @@ describe('grab', () => { expect(written[1]!.new).toBe(true); }); + it('takes the numbering a title carries off it when asked', async () => { + const cache = new MemoryCache(); + + const config = makeConfig({ + channels: [{ xmltvId: 'one.example', siteId: '1', lang: 'sk' }], + parseDay({ programme, day }) { + return [ + programme(new Date(`${day}T06:00:00.000Z`), 'Komisár Rex III (13)', { numbered: true }), + ]; + }, + }); + + await grab([config], { cache, now: NOW }); + + const [written] = cache.get({ + site: 'example.com', + channelId: 'one.example', + day: TODAY, + })!.programmes; + + expect(written!.title).toEqual([{ value: 'Komisár Rex', lang: 'sk' }]); + expect(written!.episodeNum).toEqual([ + { system: 'xmltv_ns', value: '2.12.0/1' }, + { system: 'onscreen', value: 'S03E13' }, + ]); + }); + it('takes builders and plain programmes in the same parse', async () => { const cache = new MemoryCache(); diff --git a/test/readme-examples.ts b/test/readme-examples.ts index ae1161a..cd71a91 100644 --- a/test/readme-examples.ts +++ b/test/readme-examples.ts @@ -57,6 +57,8 @@ import { xmltvDate, ProgrammeBuilder, XmltvDocumentBuilder, + parseTitleNumbering, + stripTitleSeason, } from '../src/xmltv/main.js'; import type { ParseContext, @@ -196,6 +198,19 @@ export const built = defineSiteConfig({ }, }); +export const numberedSite = defineSiteConfig({ + site: 'example.tv', + channels: [{ xmltvId: 'one.example.tv', siteId: '101' }], + async request({ http }) { + return http.get('epg').json<{ items: RawItem[] }>(); + }, + parseDay({ payload, programme }) { + return payload.items.map((item) => + programme(new Date(item.start), item.title, { numbered: true }).episode(item.episode), + ); + }, +}); + // --- docs/site-config.md: Rate limits and backoff -------------------------- export const paced = defineSiteConfig({ site: 'example.tv', @@ -875,6 +890,34 @@ export const tolerant = (xml: string): number => // --- docs/xmltv.md: Serializing --------------------------------------------- export const pretty = writeXmltvStream({ meta: {}, channels: [], programmes: [] }, { indent: 2 }); +// --- docs/xmltv.md: Numbering in titles ------------------------------------ +export const numberings = [ + parseTitleNumbering('Duel S01E99'), + parseTitleNumbering('Komisár Rex III (13)'), + parseTitleNumbering('Filter (6/12)'), + parseTitleNumbering('Big Coast - Season 12 - Duncanby Lodge'), + parseTitleNumbering('Spinal Tap II'), +]; + +const hungarian = [ + /^(?<title>.+?)(?:\s+-)?\s+(?<season>\d+)\.\s*évad$/u, + /^(?<title>.+?)(?:\s+-)?\s+(?<season>\S+)\.\s*sorozat$/u, +]; + +export const sorozat = parseTitleNumbering('Magyar népmesék - I. sorozat', { + patterns: hungarian, +}); + +export const soap = parseTitleNumbering('Ulice (1999)', { + patterns: [/(?<title>.+?)\s*\((?<episode>\d+)\)/u], +}); + +export const stripped = [ + stripTitleSeason('Zločin III', 3), + stripTitleSeason('Spinal Tap II', 1), + stripTitleSeason('Duel S01E05', 1), +]; + // --- docs/xmltv.md: Builders ------------------------------------------------ export const programme = new ProgrammeBuilder({ channel: 'one.example.tv', @@ -889,6 +932,24 @@ export const programme = new ProgrammeBuilder({ .actor('Jane Doe', { role: 'Presenter' }) .build(); +export const numbered = ProgrammeBuilder.of( + 'one.example.tv', + '20260717200000 +0200', + 'Duel - S01E02 - Pilot', + { numbered: true }, +).build(); + +export const notFromTitle = (item: { start: Date; title: string; episode: number }) => [ + ProgrammeBuilder.of('one.example.tv', item.start, item.title, { + numbered: { fromTitle: false }, + }).episode(item.episode), + ProgrammeBuilder.of('one.example.tv', item.start, item.title, { numbered: true }).episode( + item.episode, + null, + { fromTitle: false }, + ), +]; + export const document = new XmltvDocumentBuilder() .generatorInfo('epg-tools', 'https://github.com/Ruby184/epg-tools') .sourceInfo('Example TV', 'https://example.tv') diff --git a/test/title-numbering.test.ts b/test/title-numbering.test.ts new file mode 100644 index 0000000..5391c91 --- /dev/null +++ b/test/title-numbering.test.ts @@ -0,0 +1,363 @@ +import { describe, expect, it } from 'vitest'; +import { parseTitleNumbering, stripTitleSeason } from '../src/xmltv/main.js'; + +/** A source's own words for a season and an episode, as homepi's sites write them. */ +const SLOVAK = [ + /^(?<title>.+?)(?::|\s+-)?\s+(?:Sez[oó]na\s+(?<season>\d+)\s+)?Epiz[oó]da\s+(?<episode>\d+)(?:\s+-\s+(?<subTitle>.+))?$/u, + /^(?<title>.+?)(?::|\s+-)\s+Sez[oó]na\s+(?<season>\d+)$/u, +]; + +const HUNGARIAN = [ + /^(?<title>.+?)(?:\s+-)?\s+(?<season>\d+)\.\s*évad$/u, + /^(?<title>.+?)(?:\s+-)?\s+(?<season>\S+)\.\s*sorozat$/u, +]; + +describe('a title’s numbering', () => { + it.each([ + ['Duel S01E99', { title: 'Duel', season: 1, episode: 99 }], + ['Duel - S01E02 - Pilot', { title: 'Duel', season: 1, episode: 2, subTitle: 'Pilot' }], + // The rest of the onscreen grammar: an "Ep", totals and a part. + ['Duel S01 Ep 5', { title: 'Duel', season: 1, episode: 5 }], + ['Duel S01.E02', { title: 'Duel', season: 1, episode: 2 }], + ['Duel S01E02/12', { title: 'Duel', season: 1, episode: 2, episodes: 12 }], + ['Duel S1/3E2 P1/2', { title: 'Duel', season: 1, seasons: 3, episode: 2, part: 1, parts: 2 }], + ['TrimZZ: Episode 4', { title: 'TrimZZ', episode: 4 }], + ['Single Minded - Episode 1', { title: 'Single Minded', episode: 1 }], + [ + 'Walker, Texas Ranger Season 4 Episode 5', + { title: 'Walker, Texas Ranger', season: 4, episode: 5 }, + ], + [ + 'Morning Season 2 Episode 5 - Monday', + { title: 'Morning', season: 2, episode: 5, subTitle: 'Monday' }, + ], + ['Komisár Rex III (13)', { title: 'Komisár Rex', season: 3, episode: 13 }], + ['Kobra 11 XXV (3)', { title: 'Kobra 11', season: 25, episode: 3 }], + ['Kobra 11 XXIII. (3)', { title: 'Kobra 11', season: 23, episode: 3 }], + ['Hospital CLV (2)', { title: 'Hospital', season: 155, episode: 2 }], + ['Hospital XIX (2)', { title: 'Hospital', season: 19, episode: 2 }], + ['Hospital XLIV (2)', { title: 'Hospital', season: 44, episode: 2 }], + ['M*A*S*H (49)', { title: 'M*A*S*H', episode: 49 }], + // A word of a numeral's letters, or one written as no numeral is, stays + // in the title. + ['CIVIL (2)', { title: 'CIVIL', episode: 2 }], + ['Agent CIVIL (2)', { title: 'Agent CIVIL', episode: 2 }], + ['Agent IIII (2)', { title: 'Agent IIII', episode: 2 }], + ['Filter (6/6)', { title: 'Filter', episode: 6, episodes: 6 }], + ['Грузія в серці (31)', { title: 'Грузія в серці', episode: 31 }], + ['Robogobo: Season 1', { title: 'Robogobo', season: 1 }], + [ + 'Big Coast - Season 12 - Duncanby Lodge', + { title: 'Big Coast', season: 12, subTitle: 'Duncanby Lodge' }, + ], + // A separator between a season and its episode, and a dash of any width. + ['Show - Season 2 - Episode 5', { title: 'Show', season: 2, episode: 5 }], + ['Show: Season 2, Episode 5', { title: 'Show', season: 2, episode: 5 }], + [ + 'Big Coast - Season 12 - Episode 3 - Lodge', + { title: 'Big Coast', season: 12, episode: 3, subTitle: 'Lodge' }, + ], + ['Duel – S01E02 – Pilot', { title: 'Duel', season: 1, episode: 2, subTitle: 'Pilot' }], + ['Duel — S01E02', { title: 'Duel', season: 1, episode: 2 }], + ['Show - (13)', { title: 'Show', episode: 13 }], + ['Komisár Rex – III (13)', { title: 'Komisár Rex', season: 3, episode: 13 }], + // A comma before the numbering is no part of the title. + ['Show, Episode 5', { title: 'Show', episode: 5 }], + ['Duel, S01E02', { title: 'Duel', season: 1, episode: 2 }], + // The words in any case. + ['Duel s01e05', { title: 'Duel', season: 1, episode: 5 }], + ['Robogobo: season 1', { title: 'Robogobo', season: 1 }], + ['Show - EPISODE 4', { title: 'Show', episode: 4 }], + // A numeral of one letter is a season as I or V, and a name otherwise; a + // numeral in small letters is a word. + ['Komisár Rex V (3)', { title: 'Komisár Rex', season: 5, episode: 3 }], + ['Malcolm X (2)', { title: 'Malcolm X', episode: 2 }], + ['Vitamin C (3)', { title: 'Vitamin C', episode: 3 }], + ['Agent iii (2)', { title: 'Agent iii', episode: 2 }], + // A special's season is none, and its episode still one. + ['Duel S00E05', { title: 'Duel', episode: 5 }], + [' Duel S01E99 ', { title: 'Duel', season: 1, episode: 99 }], + [13, { title: '13' }], + ])('%s', (raw, numbering) => { + expect(parseTitleNumbering(raw)).toEqual(numbering); + }); + + it.each([ + // A film's sequel, not a season; a road, not an episode. + 'Spinal Tap II', + 'Poprask na silnici E4', + 'Studio 6 II', + // A year, a season with no separator, a series' name. + 'Hrdinové (2019)', + 'Rocky II (1979)', + // A season and episode with no series before them: taking them off would + // leave nothing of the title but the season. + 'Season 2 Episode 5 - Monday', + // A season's span of years. + 'Extraliga (2023/24)', + 'Liga majstrov (2023/2024)', + // Noughts alone, which leave nothing to write in place of what they said. + 'Duel S00E00', + 'Show (0)', + // A total alone, which names no episode to write. + 'Show (0/12)', + 'Robogobo: Season 0', + '450kg sestry VI: A Taste Of Season 6', + 'Slovensko - Ukrajina', + // Not English, so nothing built in reads it. + 'MacGyver Sezóna 3 Epizoda 19', + 'Bor, mámor, szerelem 3. évad', + 'Magyar népmesék - I. sorozat', + ])('leaves %s as it is', (raw) => { + expect(parseTitleNumbering(raw)).toEqual({ title: raw }); + }); + + it('reads no title longer than any title is', () => { + const long = `${'x'.repeat(500)} S01E02`; + // Padding that would cost a pattern time growing with its square. + const padded = `a${' '.repeat(50_000)}b`; + const started = performance.now(); + + expect(parseTitleNumbering(long)).toEqual({ title: long }); + expect(parseTitleNumbering(padded)).toEqual({ title: padded }); + expect(performance.now() - started).toBeLessThan(100); + }); + + it('trims a title it leaves as it is', () => { + expect(parseTitleNumbering(' Spinal Tap II ')).toEqual({ title: 'Spinal Tap II' }); + }); + + describe("a source's own patterns", () => { + const patterns = [...SLOVAK, ...HUNGARIAN]; + + it.each([ + ['MacGyver Sezóna 3 Epizoda 19', { title: 'MacGyver', season: 3, episode: 19 }], + [ + 'Ráno Sezóna 2 Epizóda 5 - Pondelok', + { title: 'Ráno', season: 2, episode: 5, subTitle: 'Pondelok' }, + ], + ['Apokalypsa - Hitler Epizoda 1', { title: 'Apokalypsa - Hitler', episode: 1 }], + ['Bor, mámor, szerelem 3. évad', { title: 'Bor, mámor, szerelem', season: 3 }], + ['Magyar népmesék - I. sorozat', { title: 'Magyar népmesék', season: 1 }], + ['Kérem a következőt! I. sorozat', { title: 'Kérem a következőt!', season: 1 }], + // And the built-in ones after them. + ['Duel S01E99', { title: 'Duel', season: 1, episode: 99 }], + ['Komisár Rex III (13)', { title: 'Komisár Rex', season: 3, episode: 13 }], + ])('read %s', (raw, numbering) => { + expect(parseTitleNumbering(raw, { patterns })).toEqual(numbering); + }); + + it.each([ + 'NA ŠÍRAVE - Ako dopadla letná sezóna 2026?', + 'Csoóri sorozat', + // A word where a number goes passes the title on, and nothing after reads it. + 'Ez a Nagy. sorozat', + ])('leave %s as it is', (raw) => { + expect(parseTitleNumbering(raw, { patterns })).toEqual({ title: raw }); + }); + + it('are tried before the built-in ones', () => { + const season = /^(?<title>.+?)\s+\((?<season>\d+)\)$/u; + + expect(parseTitleNumbering('M*A*S*H (2)', { patterns: [season] })).toEqual({ + title: 'M*A*S*H', + season: 2, + }); + }); + + it('pass a title on where a number they found is not one', () => { + const word = /^(?<title>.+?)\s+(?<episode>\S+)\s+\((?<episodes>\d+)\)$/u; + + expect(parseTitleNumbering('Hospital Nine (12)', { patterns: [word] })).toEqual({ + title: 'Hospital Nine', + episode: 12, + }); + }); + + it('pass a title on where they read no title', () => { + const untitled = /^(?<title>\s*)S(?<season>\d+)$/u; + + expect(parseTitleNumbering('S01', { patterns: [untitled] })).toEqual({ title: 'S01' }); + }); + + it('read every title from its start, a global one too, and leave its place alone', () => { + const global = /^(?<title>.+?)\s+#(?<episode>\d+)$/gu; + + global.lastIndex = 5; + + expect(parseTitleNumbering('Show #3', { patterns: [global] })).toEqual({ + title: 'Show', + episode: 3, + }); + expect(parseTitleNumbering('Show #4', { patterns: [global] })).toEqual({ + title: 'Show', + episode: 4, + }); + expect(global.lastIndex).toBe(5); + }); + + it('may be frozen', () => { + const frozen = Object.freeze(/^(?<title>.+?)\s+#(?<episode>\d+)$/u); + + expect(parseTitleNumbering('Show #3', { patterns: [frozen] })).toEqual({ + title: 'Show', + episode: 3, + }); + }); + + it('read a title only where they match the whole of it', () => { + // Unanchored, this finds "szerelem 3. évad" and would drop "Bor, mámor,". + const part = /(?<title>\S+)\s+(?<season>\d+)\.\s*évad/u; + + expect(parseTitleNumbering('Bor, mámor, szerelem 3. évad', { patterns: [part] })).toEqual({ + title: 'Bor, mámor, szerelem 3. évad', + }); + }); + + it('read a number with space around it and an ordinal dot after it', () => { + const spaced = /^(?<title>.+?)(?<season>\s+\d+\.)\s*évad$/u; + const roman = /^(?<title>.+?)\s+(?<season>\S+\.)\s*sorozat$/u; + + expect(parseTitleNumbering('Bor 3. évad', { patterns: [spaced] })).toEqual({ + title: 'Bor', + season: 3, + }); + expect(parseTitleNumbering('Kérem a következőt! I. sorozat', { patterns: [roman] })).toEqual({ + title: 'Kérem a következőt!', + season: 1, + }); + }); + + it('read no title from a total alone', () => { + const parts = /^(?<title>.+?)\s+\((?<episodes>\d+) dielov\)$/u; + + expect(parseTitleNumbering('Show (12 dielov)', { patterns: [parts] })).toEqual({ + title: 'Show (12 dielov)', + }); + }); + + it('need a title group, and say so the first time they are used', () => { + const show = /^(?<show>.+?)\s+(?<season>\d+)\.\s*évad$/u; + + expect(() => parseTitleNumbering('Bor 3. évad', { patterns: [show] })).toThrow( + /needs a \(\?<title>…\) group/, + ); + // A title it would never have matched says so as well. + expect(() => parseTitleNumbering('Duel S01E02', { patterns: [show] })).toThrow( + /needs a \(\?<title>…\) group/, + ); + // And one a pattern before it reads. + const good = /^(?<title>.+?)\s+#(?<episode>\d+)$/u; + + expect(() => parseTitleNumbering('Show #3', { patterns: [good, show] })).toThrow( + /needs a \(\?<title>…\) group/, + ); + }); + + it('are matched against the whole title, unanchored ones too', () => { + // Its first match, "Show #3", is not the whole title; anchored, it reads it. + const hash = /(?<title>.+?)\s+#(?<episode>\d+)/u; + + expect(parseTitleNumbering('Show #3 #4', { patterns: [hash] })).toEqual({ + title: 'Show #3', + episode: 4, + }); + }); + + it('do not stand in for the built-in ones where they read no number', () => { + // Every title matches it, its season being optional — and none is read. + const optional = /^(?<title>.+?)(?:\s+(?<season>\d+)\.\s*évad)?$/u; + + expect(parseTitleNumbering('Duel S01E02', { patterns: [optional] })).toEqual({ + title: 'Duel', + season: 1, + episode: 2, + }); + expect(stripTitleSeason('Zločin III', 3, { patterns: [optional] })).toBe('Zločin'); + }); + + it("read what the built-in ones leave alone, as a daily soap's year-like episode", () => { + const soap = /(?<title>.+?)\s*\((?<episode>\d+)\)/u; + + expect(parseTitleNumbering('Ulice (1999)')).toEqual({ title: 'Ulice (1999)' }); + expect(parseTitleNumbering('Ulice (1999)', { patterns: [soap] })).toEqual({ + title: 'Ulice', + episode: 1999, + }); + }); + + it('take a group that matched nothing as a number not given', () => { + const optional = /^(?<title>.+?)\s+(?<season>\d+)\.\s*évad\s*(?<episode>\d*)$/u; + + expect(parseTitleNumbering('Show 3. évad', { patterns: [optional] })).toEqual({ + title: 'Show', + season: 3, + }); + }); + }); +}); + +describe('a season the source gives apart', () => { + it.each([ + ['Zločin III', 3, 'Zločin'], + ['Kobra 11 XXIII.', 23, 'Kobra 11'], + ['Robogobo: Season 1', 1, 'Robogobo'], + ['Big Coast - Season 12', 12, 'Big Coast'], + ['Zločin III', '3', 'Zločin'], + ])('comes off %s', (title, season, stripped) => { + expect(stripTitleSeason(title, season)).toBe(stripped); + }); + + it.each([ + ['Herbárium 1. évad', 1, 'Herbárium'], + ['Anyai örökség - 1. évad', 1, 'Anyai örökség'], + ['Magyar népmesék - I. sorozat', 1, 'Magyar népmesék'], + ['Zločin III', 3, 'Zločin'], + ])("comes off %s by a source's own pattern too", (title, season, stripped) => { + expect(stripTitleSeason(title, season, { patterns: HUNGARIAN })).toBe(stripped); + }); + + it.each([ + // Another season, or none given. + ['Spinal Tap II', 1], + ['Zločin III', null], + ['Zločin III', undefined], + ['Bedekr XII', '0'], + ['Bedekr XII', 'XII'], + // More than the season: an episode, a sub-title. + ['Duel S01E05', 1], + ['Big Coast - Season 12 - Duncanby Lodge', 12], + // Digits at the end are a name, a numeral a season. + ['Studio 6', 6], + // Not English, and no pattern given for it. + ['Herbárium 1. évad', 1], + // An episode, though of nought. + ['Duel S01E00', 1], + ])('stays on %s for season %s', (title, season) => { + expect(stripTitleSeason(title, season)).toBe(title); + }); + + it('reads a numeral of one letter, where the season is given', () => { + expect(stripTitleSeason('Malcolm X', 10)).toBe('Malcolm'); + }); + + it('comes back trimmed, whether or not a season comes off', () => { + expect(stripTitleSeason(' Zločin III ', 3)).toBe('Zločin'); + expect(stripTitleSeason(' Zločin ', 3)).toBe('Zločin'); + expect(stripTitleSeason(' Zločin ', null)).toBe('Zločin'); + }); + + it('takes a title given as a number', () => { + expect(stripTitleSeason(13, 1)).toBe('13'); + }); + + it('hands back a title that is neither text nor a number as it came, and says so', () => { + // As an API sends one: `"title": null`. + const title = null as string | null; + const stripped: string | null = stripTitleSeason(title, 3); + const parsed: { title: string | null; season?: number } = parseTitleNumbering(title); + + expect(stripped).toBeNull(); + expect(parsed).toEqual({ title: null }); + }); +});