From 0fb3211213c11da03f7725ee2d37a572d0eb3fe2 Mon Sep 17 00:00:00 2001 From: Jonathan Caicedo Date: Mon, 21 Sep 2026 09:59:42 -0400 Subject: [PATCH 1/2] feat: merge all listings into a single XMLTV file Add --merge to combine every configured listing into one XMLTV document. Channels are deduplicated by channel ID and programmes by channel and start time, so stations carried by more than one lineup appear once. Merging runs on the already-fetched grid data rather than re-parsing the written XML, so it needs no new runtime dependencies, no subprocess, and no Dockerfile change. Dedupe semantics match jesmannstl/tvmerge (--dedupe first|last) with two refinements: start times are normalized to epoch milliseconds so equivalent timestamps in different formats collide, and channel metadata is union-merged so a lineup missing channelNo inherits it from one that has it. * feat: src/merge.ts with mergeListings() and first/last dedupe * feat: merge YAML block plus --merge, --mergeOutputFile, --mergeDedupe, --noKeepIndividual and matching MERGE* env vars * feat: keepIndividual controls whether per-listing files are written * fix: reject a merge outputFile that collides with a listing output * fix: use ebyl.tmsimg.com for channel icons * test: 24 new tests for merge logic and merge config parsing (47 -> 71) * ci: run unit tests and prettier check, add merge integration test * style: standardize on 4-space indentation via prettier --- .github/workflows/ci.yml | 67 +++ .prettierrc | 5 +- README.md | 32 ++ eslint.config.mjs | 28 +- package.json | 2 + rollup.config.ts | 16 +- src/config.test.ts | 597 +++++++++++++++---------- src/config.ts | 932 +++++++++++++++++++++++---------------- src/index.ts | 175 +++++--- src/merge.test.ts | 217 +++++++++ src/merge.ts | 128 ++++++ src/tvlistings.test.ts | 397 +++++++++-------- src/tvlistings.ts | 270 ++++++------ src/useragents.ts | 17 +- src/xmltv.test.ts | 687 ++++++++++++++--------------- src/xmltv.ts | 637 +++++++++++++------------- 16 files changed, 2474 insertions(+), 1733 deletions(-) create mode 100644 src/merge.test.ts create mode 100644 src/merge.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5b18f1..78bea9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,12 @@ jobs: - name: Lint run: bun run lint + - name: Check formatting + run: bun run format:check + + - name: Unit tests + run: bun run test:run + - name: Integration tests run: | bun dist/index.js --lineupId=USA-DITV751-X --timespan=3 --postalCode=80020 --outputFile=dtv.xml @@ -45,3 +51,64 @@ jobs: echo "DTV and OTA outputs are the same, which is unexpected." exit 1 fi + + - name: Merge integration test + run: | + cat > ci-listings.yml <<'YAML' + defaults: + timespan: 3 + country: USA + postalCode: "80020" + listings: + - name: dtv + lineupId: USA-DITV751-X + outputFile: merge-dtv.xml + - name: ota + lineupId: USA-OTA80020 + outputFile: merge-ota.xml + merge: + enabled: true + outputFile: merged.xml + YAML + + bun dist/index.js --config=ci-listings.yml + + for f in merge-dtv.xml merge-ota.xml merged.xml; do + if [ ! -s "$f" ]; then + echo "Expected $f to be written and non-empty." + exit 1 + fi + done + + # The merged file must hold at least as many channels as either input. + dtv=$(grep -c '/dev/null 2>&1; then + xmllint --noout merged.xml + else + echo "xmllint unavailable; skipping well-formedness check." + fi diff --git a/.prettierrc b/.prettierrc index 0967ef4..e9e3a01 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1 +1,4 @@ -{} +{ + "tabWidth": 4, + "printWidth": 120 +} diff --git a/README.md b/README.md index 2549f30..4c45907 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,37 @@ Need help? See [Finding a lineup](https://github.com/JCBird1012/zap2xml/wiki/Fin Use `--config=path/to/listings.yml` to fetch multiple TV lineups in a single run. See the [Multi-Listing Config](https://github.com/JCBird1012/zap2xml/wiki/Multi-Listing-Config) wiki page for the full YAML format, precedence rules, and Docker setup. +## Merging listings into one file + +Add `--merge` to also write every listing combined into a single XMLTV file. Channels are deduplicated by channel ID, and programmes by channel and start time, so stations carried by more than one lineup appear once. + +```yaml +listings: + - name: dtv + lineupId: USA-DITV751-X + postalCode: "80020" + outputFile: dtv.xml + - name: ota + lineupId: USA-OTA80020 + postalCode: "80020" + outputFile: ota.xml + +merge: + enabled: true + outputFile: merged.xml + keepIndividual: true + dedupe: first +``` + +| Option | Env | Description | +| --- | --- | --- | +| `--merge` | `MERGE` | Write all listings combined into one file | +| `--mergeOutputFile=FILE` | `MERGE_OUTPUT_FILE` | Merged file name (implies `--merge`, default `merged.xml`) | +| `--mergeDedupe=MODE` | `MERGE_DEDUPE` | Which duplicate to keep: `first` (default) or `last` | +| `--noKeepIndividual` | `MERGE_KEEP_INDIVIDUAL=false` | Only write the merged file, skipping per-listing files | + +By default the per-listing files are still written alongside the merged one. This works in single-listing mode too, though with one listing the merged file is just a copy. + ## Recent Updates ### (2025-08-20) @@ -66,3 +97,4 @@ Use `--config=path/to/listings.yml` to fetch multiple TV lineups in a single run * Docker image uses [Bun](https://bun.com) runtime instead of Node.js - smaller image, and probably some (likely unnoticeable with typical use) performance/memory usage improvements! * Ability to fetch multiple lineups using a single `zap2xml` instance - no longer have to run multiple containers to fetch multiple lineups! (see [Multi-Listing Config](https://github.com/JCBird1012/zap2xml/wiki/Multi-Listing-Config)) +* `--merge` combines all listings into one deduplicated XMLTV file, so you don't need a separate merge step for multi-lineup setups diff --git a/eslint.config.mjs b/eslint.config.mjs index 35e3375..db5b182 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,18 +5,18 @@ import tseslint from "typescript-eslint"; import eslintConfigPrettier from "eslint-config-prettier/flat"; export default defineConfig([ - { - files: ["**/*.{js,mjs,cjs,ts,mts,cts}"], - plugins: { js }, - extends: ["js/recommended"], - }, - { - files: ["**/*.{js,mjs,cjs,ts,mts,cts}"], - languageOptions: { globals: globals.browser }, - }, - tseslint.configs.recommended, - eslintConfigPrettier, - { - ignores: ["dist"], - }, + { + files: ["**/*.{js,mjs,cjs,ts,mts,cts}"], + plugins: { js }, + extends: ["js/recommended"], + }, + { + files: ["**/*.{js,mjs,cjs,ts,mts,cts}"], + languageOptions: { globals: globals.browser }, + }, + tseslint.configs.recommended, + eslintConfigPrettier, + { + ignores: ["dist"], + }, ]); diff --git a/package.json b/package.json index a6f7ac3..3eca948 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "dev": "bun src/index.ts", "lint": "eslint .", "lint:fix": "eslint . --fix", + "format": "prettier --write \"**/*.{ts,mts,cts,js,mjs,cjs}\"", + "format:check": "prettier --check \"**/*.{ts,mts,cts,js,mjs,cjs}\"", "typecheck": "tsc --noEmit", "build": "bun run typecheck && rollup --config rollup.config.ts --configPlugin @rollup/plugin-typescript" }, diff --git a/rollup.config.ts b/rollup.config.ts index 644064b..dc41f18 100644 --- a/rollup.config.ts +++ b/rollup.config.ts @@ -3,14 +3,14 @@ import nodeResolve from "@rollup/plugin-node-resolve"; import typescript from "@rollup/plugin-typescript"; const config = { - input: "src/index.ts", - output: { - esModule: true, - file: "dist/index.js", - format: "es", - sourcemap: true, - }, - plugins: [typescript(), nodeResolve({ preferBuiltins: true }), commonjs()], + input: "src/index.ts", + output: { + esModule: true, + file: "dist/index.js", + format: "es", + sourcemap: true, + }, + plugins: [typescript(), nodeResolve({ preferBuiltins: true }), commonjs()], }; export default config; diff --git a/src/config.test.ts b/src/config.test.ts index 3d65410..d080da2 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,227 +1,370 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { getConfig, getHeadendId, processLineupId } from "./config.js"; - -const originalArgv = [...process.argv]; -const trackedEnvVars = [ - "APPEND_ASTERISK", - "CONFIG_FILE", - "COUNTRY", - "LINEUP_ID", - "MEDIA_PORTAL", - "NEXTPVR", - "OUTPUT_FILE", - "POSTAL_CODE", - "PREF", - "SORTNAME", - "STATIONID", - "TIMESPAN", - "TZ", - "USER_AGENT", -]; - -const tempDirs: string[] = []; - -function clearEnv(): void { - for (const key of trackedEnvVars) { - delete process.env[key]; - } -} - -function createConfigFile(contents: string): string { - const dir = mkdtempSync(join(tmpdir(), "zap2xml-config-")); - const filePath = join(dir, "listings.yml"); - writeFileSync(filePath, contents, { encoding: "utf-8" }); - tempDirs.push(dir); - return filePath; -} - -beforeEach(() => { - process.argv = [...originalArgv]; - clearEnv(); -}); - -afterEach(() => { - process.argv = [...originalArgv]; - clearEnv(); - - for (const dir of tempDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } -}); - -describe("processLineupId", () => { - it("returns env LINEUP_ID if set", () => { - process.env.LINEUP_ID = "USA-12345"; - expect(processLineupId()).toBe("USA-12345"); - delete process.env.LINEUP_ID; - }); - - it("returns argv --lineupId if set", () => { - process.argv.push("--lineupId=USA-54321"); - expect(processLineupId()).toBe("USA-54321"); - }); - - it("prefers argv --lineupId over env LINEUP_ID", () => { - process.env.LINEUP_ID = "USA-11111"; - process.argv.push("--lineupId=USA-54321"); - expect(processLineupId()).toBe("USA-54321"); - }); - - it("returns default if nothing set", () => { - expect(processLineupId()).toBe("USA-lineupId-DEFAULT"); - }); - - it("returns default if lineupId contains OTA", () => { - process.env.LINEUP_ID = "USA-OTA12345"; - expect(processLineupId()).toBe("USA-lineupId-DEFAULT"); - delete process.env.LINEUP_ID; - }); -}); - -describe("getHeadendId", () => { - it("extracts headend from valid lineupId", () => { - expect(getHeadendId("USA-OTA12345")).toBe("lineupId"); - expect(getHeadendId("USA-NY31587-L")).toBe("NY31587"); - expect(getHeadendId("CAN-OTAT1L0A1")).toBe("lineupId"); - expect(getHeadendId("CAN-0008861-X")).toBe("0008861"); - }); - - it("returns 'lineup' if no match", () => { - expect(getHeadendId("INVALID")).toBe("lineup"); - expect(getHeadendId("")).toBe("lineup"); - }); -}); - -describe("getConfig", () => { - it("merges YAML defaults, listing overrides, env, and CLI overrides", () => { - const configFile = createConfigFile(` -defaults: - timespan: 72 - country: USA - appendAsterisk: true - userAgent: Config User Agent - -listings: - - name: atlanta - lineupId: USA-GA42500-X - postalCode: "30309" - outputFile: atlanta.xml - - - name: ottawa - lineupId: CAN-0008861-X - country: CAN - postalCode: K1A0B1 - timespan: 48 - outputFile: ottawa.xml -`); - - process.env.USER_AGENT = "Env User Agent"; - process.argv.push(`--config=${configFile}`, "--timespan=24", "--sortname"); - - const config = getConfig(); - - expect(config.configFile).toBe(configFile); - expect(config.listings).toHaveLength(2); - expect(config.listings[0]).toMatchObject({ - name: "atlanta", - country: "USA", - timespan: "24", - appendAsterisk: true, - sortname: true, - userAgent: "Env User Agent", - outputFile: "atlanta.xml", - }); - expect(config.listings[1]).toMatchObject({ - name: "ottawa", - country: "CAN", - timespan: "24", - appendAsterisk: true, - sortname: true, - userAgent: "Env User Agent", - outputFile: "ottawa.xml", - }); - }); - - it("coerces YAML scalar values and applies listing overrides", () => { - const configFile = createConfigFile(` -defaults: - timespan: 72 - postalCode: 30309 - appendAsterisk: true - mediaportal: false - -listings: - - lineupId: USA-GA42500-X - outputFile: atlanta.xml - timespan: 48 - stationid: true -`); - - process.argv.push(`--config=${configFile}`); - - const config = getConfig(); - - expect(config.listings).toHaveLength(1); - expect(config.listings[0]).toMatchObject({ - lineupId: "USA-GA42500-X", - country: "USA", - postalCode: "30309", - timespan: "48", - appendAsterisk: true, - mediaportal: false, - stationid: true, - outputFile: "atlanta.xml", - }); - }); - - it("rejects listing-specific CLI overrides when using YAML", () => { - const configFile = createConfigFile(` -listings: - - lineupId: USA-GA42500-X - country: USA - postalCode: "30309" - outputFile: atlanta.xml -`); - - process.argv.push(`--config=${configFile}`, "--lineupId=USA-OVERRIDE"); - - expect(() => getConfig()).toThrow("Cannot combine --config with listing-specific overrides"); - }); - - it("rejects duplicate output files in multi-listing mode", () => { - const configFile = createConfigFile(` -defaults: - outputFile: xmltv.xml - -listings: - - lineupId: USA-GA42500-X - country: USA - postalCode: "30309" - - lineupId: CAN-0008861-X - country: CAN - postalCode: K1A0B1 -`); - - process.argv.push(`--config=${configFile}`); - - expect(() => getConfig()).toThrow("Multiple listings resolve to the same outputFile"); - }); - - it("rejects invalid YAML field values", () => { - const configFile = createConfigFile(` -listings: - - lineupId: USA-GA42500-X - country: USA - postalCode: "30309" - outputFile: atlanta.xml - appendAsterisk: maybe -`); - - process.argv.push(`--config=${configFile}`); - - expect(() => getConfig()).toThrow(/Invalid config file .*Expected \(boolean \| "" \| string\)/); - }); -}); +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { getConfig, getHeadendId, processLineupId } from "./config.js"; + +const originalArgv = [...process.argv]; +const trackedEnvVars = [ + "APPEND_ASTERISK", + "CONFIG_FILE", + "COUNTRY", + "LINEUP_ID", + "MEDIA_PORTAL", + "MERGE", + "MERGE_DEDUPE", + "MERGE_KEEP_INDIVIDUAL", + "MERGE_OUTPUT_FILE", + "NEXTPVR", + "OUTPUT_FILE", + "POSTAL_CODE", + "PREF", + "SORTNAME", + "STATIONID", + "TIMESPAN", + "TZ", + "USER_AGENT", +]; + +const tempDirs: string[] = []; + +function clearEnv(): void { + for (const key of trackedEnvVars) { + delete process.env[key]; + } +} + +function createConfigFile(contents: string): string { + const dir = mkdtempSync(join(tmpdir(), "zap2xml-config-")); + const filePath = join(dir, "listings.yml"); + writeFileSync(filePath, contents, { encoding: "utf-8" }); + tempDirs.push(dir); + return filePath; +} + +beforeEach(() => { + process.argv = [...originalArgv]; + clearEnv(); +}); + +afterEach(() => { + process.argv = [...originalArgv]; + clearEnv(); + + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("processLineupId", () => { + it("returns env LINEUP_ID if set", () => { + process.env.LINEUP_ID = "USA-12345"; + expect(processLineupId()).toBe("USA-12345"); + delete process.env.LINEUP_ID; + }); + + it("returns argv --lineupId if set", () => { + process.argv.push("--lineupId=USA-54321"); + expect(processLineupId()).toBe("USA-54321"); + }); + + it("prefers argv --lineupId over env LINEUP_ID", () => { + process.env.LINEUP_ID = "USA-11111"; + process.argv.push("--lineupId=USA-54321"); + expect(processLineupId()).toBe("USA-54321"); + }); + + it("returns default if nothing set", () => { + expect(processLineupId()).toBe("USA-lineupId-DEFAULT"); + }); + + it("returns default if lineupId contains OTA", () => { + process.env.LINEUP_ID = "USA-OTA12345"; + expect(processLineupId()).toBe("USA-lineupId-DEFAULT"); + delete process.env.LINEUP_ID; + }); +}); + +describe("getHeadendId", () => { + it("extracts headend from valid lineupId", () => { + expect(getHeadendId("USA-OTA12345")).toBe("lineupId"); + expect(getHeadendId("USA-NY31587-L")).toBe("NY31587"); + expect(getHeadendId("CAN-OTAT1L0A1")).toBe("lineupId"); + expect(getHeadendId("CAN-0008861-X")).toBe("0008861"); + }); + + it("returns 'lineup' if no match", () => { + expect(getHeadendId("INVALID")).toBe("lineup"); + expect(getHeadendId("")).toBe("lineup"); + }); +}); + +describe("getConfig", () => { + it("merges YAML defaults, listing overrides, env, and CLI overrides", () => { + const configFile = createConfigFile(` +defaults: + timespan: 72 + country: USA + appendAsterisk: true + userAgent: Config User Agent + +listings: + - name: atlanta + lineupId: USA-GA42500-X + postalCode: "30309" + outputFile: atlanta.xml + + - name: ottawa + lineupId: CAN-0008861-X + country: CAN + postalCode: K1A0B1 + timespan: 48 + outputFile: ottawa.xml +`); + + process.env.USER_AGENT = "Env User Agent"; + process.argv.push(`--config=${configFile}`, "--timespan=24", "--sortname"); + + const config = getConfig(); + + expect(config.configFile).toBe(configFile); + expect(config.listings).toHaveLength(2); + expect(config.listings[0]).toMatchObject({ + name: "atlanta", + country: "USA", + timespan: "24", + appendAsterisk: true, + sortname: true, + userAgent: "Env User Agent", + outputFile: "atlanta.xml", + }); + expect(config.listings[1]).toMatchObject({ + name: "ottawa", + country: "CAN", + timespan: "24", + appendAsterisk: true, + sortname: true, + userAgent: "Env User Agent", + outputFile: "ottawa.xml", + }); + }); + + it("coerces YAML scalar values and applies listing overrides", () => { + const configFile = createConfigFile(` +defaults: + timespan: 72 + postalCode: 30309 + appendAsterisk: true + mediaportal: false + +listings: + - lineupId: USA-GA42500-X + outputFile: atlanta.xml + timespan: 48 + stationid: true +`); + + process.argv.push(`--config=${configFile}`); + + const config = getConfig(); + + expect(config.listings).toHaveLength(1); + expect(config.listings[0]).toMatchObject({ + lineupId: "USA-GA42500-X", + country: "USA", + postalCode: "30309", + timespan: "48", + appendAsterisk: true, + mediaportal: false, + stationid: true, + outputFile: "atlanta.xml", + }); + }); + + it("rejects listing-specific CLI overrides when using YAML", () => { + const configFile = createConfigFile(` +listings: + - lineupId: USA-GA42500-X + country: USA + postalCode: "30309" + outputFile: atlanta.xml +`); + + process.argv.push(`--config=${configFile}`, "--lineupId=USA-OVERRIDE"); + + expect(() => getConfig()).toThrow("Cannot combine --config with listing-specific overrides"); + }); + + it("rejects duplicate output files in multi-listing mode", () => { + const configFile = createConfigFile(` +defaults: + outputFile: xmltv.xml + +listings: + - lineupId: USA-GA42500-X + country: USA + postalCode: "30309" + - lineupId: CAN-0008861-X + country: CAN + postalCode: K1A0B1 +`); + + process.argv.push(`--config=${configFile}`); + + expect(() => getConfig()).toThrow("Multiple listings resolve to the same outputFile"); + }); + + it("rejects invalid YAML field values", () => { + const configFile = createConfigFile(` +listings: + - lineupId: USA-GA42500-X + country: USA + postalCode: "30309" + outputFile: atlanta.xml + appendAsterisk: maybe +`); + + process.argv.push(`--config=${configFile}`); + + expect(() => getConfig()).toThrow(/Invalid config file .*Expected \(boolean \| "" \| string\)/); + }); +}); + +describe("getConfig merge options", () => { + const singleListingYaml = ` +listings: + - name: atlanta + lineupId: USA-GA42500-X + country: USA + postalCode: "30309" + outputFile: atlanta.xml + - name: ottawa + lineupId: CAN-0008861-X + country: CAN + postalCode: K1A0B1 + outputFile: ottawa.xml +`; + + it("defaults to merging disabled", () => { + const config = getConfig(); + + expect(config.merge).toEqual({ + enabled: false, + outputFile: "merged.xml", + keepIndividual: true, + dedupe: "first", + }); + }); + + it("reads the merge block from YAML", () => { + const configFile = createConfigFile(`${singleListingYaml} +merge: + enabled: true + outputFile: all.xml + keepIndividual: false + dedupe: last +`); + + process.argv.push(`--config=${configFile}`); + + expect(getConfig().merge).toEqual({ + enabled: true, + outputFile: "all.xml", + keepIndividual: false, + dedupe: "last", + }); + }); + + it("enables merging via --merge", () => { + process.argv.push("--merge"); + + expect(getConfig().merge.enabled).toBe(true); + }); + + it("treats --mergeOutputFile as implying --merge", () => { + process.argv.push("--mergeOutputFile=combined.xml"); + + const { merge } = getConfig(); + expect(merge.enabled).toBe(true); + expect(merge.outputFile).toBe("combined.xml"); + }); + + it("disables individual files via --noKeepIndividual", () => { + process.argv.push("--merge", "--noKeepIndividual"); + + expect(getConfig().merge.keepIndividual).toBe(false); + }); + + it("reads merge settings from environment variables", () => { + process.env.MERGE = "true"; + process.env.MERGE_OUTPUT_FILE = "env.xml"; + process.env.MERGE_DEDUPE = "last"; + process.env.MERGE_KEEP_INDIVIDUAL = "false"; + + expect(getConfig().merge).toEqual({ + enabled: true, + outputFile: "env.xml", + keepIndividual: false, + dedupe: "last", + }); + }); + + it("lets CLI merge flags override YAML and env", () => { + const configFile = createConfigFile(`${singleListingYaml} +merge: + enabled: true + outputFile: yaml.xml + dedupe: first +`); + + process.env.MERGE_OUTPUT_FILE = "env.xml"; + process.argv.push(`--config=${configFile}`, "--mergeOutputFile=cli.xml", "--mergeDedupe=last"); + + const { merge } = getConfig(); + expect(merge.outputFile).toBe("cli.xml"); + expect(merge.dedupe).toBe("last"); + }); + + it("rejects an invalid dedupe mode", () => { + process.argv.push("--merge", "--mergeDedupe=middle"); + + expect(() => getConfig()).toThrow("Invalid dedupe mode"); + }); + + it("rejects a merge outputFile that collides with a listing output", () => { + const configFile = createConfigFile(`${singleListingYaml} +merge: + enabled: true + outputFile: atlanta.xml +`); + + process.argv.push(`--config=${configFile}`); + + expect(() => getConfig()).toThrow(/Merge outputFile "atlanta.xml" collides with listing "atlanta"/); + }); + + it("allows a colliding merge outputFile when individual files are skipped", () => { + const configFile = createConfigFile(`${singleListingYaml} +merge: + enabled: true + outputFile: atlanta.xml + keepIndividual: false +`); + + process.argv.push(`--config=${configFile}`); + + expect(getConfig().merge.outputFile).toBe("atlanta.xml"); + }); + + it("rejects unknown keys in the merge block", () => { + const configFile = createConfigFile(`${singleListingYaml} +merge: + enabled: true + bogusKey: nope +`); + + process.argv.push(`--config=${configFile}`); + + expect(() => getConfig()).toThrow(/Invalid config file/); + }); +}); diff --git a/src/config.ts b/src/config.ts index 9566220..efcba90 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,392 +1,540 @@ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import * as v from "valibot"; -import { parse } from "yaml"; -import { UserAgent } from "./useragents.js"; - -const validCountries = [ - "ABW", "AIA", "ARG", "ATG", "BHS", "BLZ", "BRA", "BRB", "BMU", "CAN", "COL", "CRI", - "CUW", "CYM", "DMA", "DOM", "ECU", "GRD", "GTM", "GUY", "HND", "JAM", "KNA", "LCA", - "MAF", "MEX", "PAN", "PER", "TCA", "TTO", "URY", "USA", "VCT", "VEN", "VGB" -] as const; - -const defaultBaseUrl = "https://tvlistings.gracenote.com/api/grid"; -const listingSpecificCliOptions = ["lineupId", "country", "postalCode", "outputFile"] as const; -const listingSpecificEnvVars = ["LINEUP_ID", "COUNTRY", "POSTAL_CODE", "OUTPUT_FILE"] as const; - -export interface XmltvOptions { - appendAsterisk: boolean; - mediaportal: boolean; - nextpvr: boolean; - stationid: boolean; - sortname: boolean; -} - -export interface ListingConfig extends XmltvOptions { - name?: string; - baseUrl: string; - lineupId: string; - headendId: string; - timespan: string; - country: string; - postalCode: string; - pref: string; - timezone: string; - userAgent: string; - outputFile: string; -} - -export interface RuntimeConfig { - configFile?: string; - listings: ListingConfig[]; -} - -type ListingConfigInput = Partial>; - -interface ListingsFileConfig { - defaults?: ListingConfigInput; - listings: ListingConfigInput[]; -} - -const StringLikeSchema = v.pipe( - v.union([v.string(), v.number()]), - v.transform((value) => value.toString()), -); - -const BooleanStringSchema = v.pipe( - v.string(), - v.transform((value) => value.trim().toLowerCase()), - v.check((value) => value === "true" || value === "false", "Invalid boolean value"), - v.transform((value) => value === "true"), -); - -const OptionalStringInputSchema = v.optional(StringLikeSchema); - -const OptionalBooleanInputSchema = v.pipe( - v.optional(v.union([v.boolean(), v.literal(""), BooleanStringSchema])), - v.transform((value) => (value === "" ? undefined : value)), -); - -function stringConfigField(defaultValue: string) { - return v.pipe( - v.optional(StringLikeSchema, defaultValue), - v.transform((value) => (value === "" ? defaultValue : value)), - ); -} - -function optionalStringConfigField() { - return v.pipe( - v.optional(StringLikeSchema), - v.transform((value) => (value === "" ? undefined : value)), - ); -} - -function booleanConfigField(defaultValue: boolean) { - return v.pipe( - v.optional(v.union([v.boolean(), v.literal(""), BooleanStringSchema]), defaultValue), - v.transform((value) => (value === "" ? defaultValue : value)), - ); -} - -const ListingInputSchema = v.strictObject({ - name: OptionalStringInputSchema, - baseUrl: OptionalStringInputSchema, - lineupId: OptionalStringInputSchema, - timespan: OptionalStringInputSchema, - country: OptionalStringInputSchema, - postalCode: OptionalStringInputSchema, - pref: OptionalStringInputSchema, - timezone: OptionalStringInputSchema, - userAgent: OptionalStringInputSchema, - outputFile: OptionalStringInputSchema, - appendAsterisk: OptionalBooleanInputSchema, - mediaportal: OptionalBooleanInputSchema, - nextpvr: OptionalBooleanInputSchema, - stationid: OptionalBooleanInputSchema, - sortname: OptionalBooleanInputSchema, -}); - -const ListingConfigSchema = v.pipe( - v.strictObject({ - name: optionalStringConfigField(), - baseUrl: stringConfigField(defaultBaseUrl), - lineupId: v.optional(StringLikeSchema), - timespan: stringConfigField("72"), - country: v.pipe( - stringConfigField("USA"), - v.check( - (country) => validCountries.includes(country as (typeof validCountries)[number]), - "Invalid country code", - ), - ), - postalCode: stringConfigField("-"), - pref: stringConfigField(""), - timezone: stringConfigField("America/New_York"), - userAgent: stringConfigField(UserAgent), - outputFile: stringConfigField("xmltv.xml"), - appendAsterisk: booleanConfigField(false), - mediaportal: booleanConfigField(false), - nextpvr: booleanConfigField(false), - stationid: booleanConfigField(false), - sortname: booleanConfigField(false), - }), - v.transform((input): ListingConfig => { - const lineupId = normalizeLineupId( - input.lineupId === undefined || input.lineupId === "" ? `${input.country}-lineupId-DEFAULT` : input.lineupId, - input.country, - ); - - return { - ...input, - lineupId, - headendId: getHeadendId(lineupId), - }; - }), -); - -const ConfigFileSchema = v.strictObject({ - defaults: v.optional(ListingInputSchema), - listings: v.pipe( - v.array(ListingInputSchema), - v.check((listings) => listings.length > 0, "Expected at least one listing"), - ), -}); - -function getArgIndex(name: string): number { - const prefix = `--${name}=`; - return process.argv.findIndex((arg) => arg === `--${name}` || arg.startsWith(prefix)); -} - -function hasArg(name: string): boolean { - return getArgIndex(name) !== -1; -} - -function getArgValue(name: string): string | undefined { - const index = getArgIndex(name); - - if (index === -1) { - return undefined; - } - - const arg = process.argv[index]!; - if (arg === `--${name}`) { - const nextArg = process.argv[index + 1]; - return nextArg && !nextArg.startsWith("--") ? nextArg : undefined; - } - - return arg.substring(`--${name}=`.length); -} - -function getEnvValue(name: string): string | undefined { - const value = process.env[name]; - return value === undefined || value === "" ? undefined : value; -} - -function compactDefinedValues>(input: T): Partial { - return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined)) as Partial; -} - -function toSchemaError(source: string, error: unknown): Error { - if (error instanceof v.ValiError) { - return new Error(`Invalid ${source}: ${error.issues[0]?.message ?? "schema validation failed"}`); - } - - return error instanceof Error ? error : new Error(String(error)); -} - -function parseListingInput(input: unknown, source: string): ListingConfigInput { - try { - return v.parse(ListingInputSchema, input, { abortEarly: true }); - } catch (error) { - throw toSchemaError(source, error); - } -} - -function buildListingConfig(input: ListingConfigInput): ListingConfig { - try { - return v.parse(ListingConfigSchema, input, { abortEarly: true }); - } catch (error) { - throw toSchemaError("listing config", error); - } -} - -function loadConfigFile(configFilePath: string): { path: string; config: ListingsFileConfig } { - const resolvedPath = resolve(configFilePath); - const fileContents = readFileSync(resolvedPath, { encoding: "utf-8" }); - const parsed = parse(fileContents); - - try { - const config = v.parse(ConfigFileSchema, parsed, { abortEarly: true }); - return { - path: resolvedPath, - config, - }; - } catch (error) { - throw toSchemaError(`config file ${resolvedPath}`, error); - } -} - -function normalizeLineupId(lineupId: string, country: string): string { - if (lineupId.includes("OTA")) { - return `${country}-lineupId-DEFAULT`; - } - - return lineupId; -} - -function getGlobalEnvOverrides(): ListingConfigInput { - return parseListingInput( - compactDefinedValues({ - timespan: getEnvValue("TIMESPAN"), - pref: getEnvValue("PREF"), - timezone: getEnvValue("TZ"), - userAgent: getEnvValue("USER_AGENT"), - appendAsterisk: getEnvValue("APPEND_ASTERISK"), - mediaportal: getEnvValue("MEDIA_PORTAL"), - nextpvr: getEnvValue("NEXTPVR"), - stationid: getEnvValue("STATIONID"), - sortname: getEnvValue("SORTNAME"), - }), - "environment overrides", - ); -} - -function getGlobalCliOverrides(): ListingConfigInput { - return parseListingInput( - compactDefinedValues({ - timespan: getArgValue("timespan"), - pref: getArgValue("pref"), - timezone: getArgValue("timezone"), - userAgent: getArgValue("userAgent"), - appendAsterisk: hasArg("appendAsterisk") ? true : undefined, - mediaportal: hasArg("mediaportal") ? true : undefined, - nextpvr: hasArg("nextpvr") ? true : undefined, - stationid: hasArg("stationid") ? true : undefined, - sortname: hasArg("sortname") ? true : undefined, - }), - "CLI overrides", - ); -} - -function getSingleListingEnvOverrides(): ListingConfigInput { - return parseListingInput( - compactDefinedValues({ - lineupId: getEnvValue("LINEUP_ID"), - country: getEnvValue("COUNTRY"), - postalCode: getEnvValue("POSTAL_CODE"), - outputFile: getEnvValue("OUTPUT_FILE"), - }), - "single listing environment overrides", - ); -} - -function getSingleListingCliOverrides(): ListingConfigInput { - return parseListingInput( - compactDefinedValues({ - lineupId: getArgValue("lineupId"), - country: getArgValue("country"), - postalCode: getArgValue("postalCode"), - outputFile: getArgValue("outputFile"), - }), - "single listing CLI overrides", - ); -} - -function assertNoListingSpecificOverrides(): void { - const conflicts: string[] = []; - - for (const option of listingSpecificCliOptions) { - if (hasArg(option)) { - conflicts.push(`--${option}`); - } - } - - for (const envVar of listingSpecificEnvVars) { - if (getEnvValue(envVar) !== undefined) { - conflicts.push(envVar); - } - } - - if (conflicts.length > 0) { - throw new Error( - `Cannot combine --config with listing-specific overrides (${conflicts.join(", ")}). Move those values into the YAML listings.`, - ); - } -} - -function assertDistinctOutputFiles(listings: ListingConfig[]): void { - if (listings.length < 2) { - return; - } - - const seen = new Map(); - - for (const listing of listings) { - const label = listing.name || listing.lineupId; - const previous = seen.get(listing.outputFile); - - if (previous) { - throw new Error( - `Multiple listings resolve to the same outputFile "${listing.outputFile}" (${previous}, ${label}). Set unique outputFile values for each listing.`, - ); - } - - seen.set(listing.outputFile, label); - } -} - -export function processLineupId(): string { - return buildListingConfig( - compactDefinedValues({ - country: getArgValue("country") ?? getEnvValue("COUNTRY"), - lineupId: getArgValue("lineupId") ?? getEnvValue("LINEUP_ID"), - }), - ).lineupId; -} - -export function getHeadendId(lineupId: string): string { - if (lineupId.includes("OTA")) { - return "lineupId"; - } - - const match = lineupId.match(/^(?:[A-Z]{3})-(.*?)(?:-[A-Z]+)?$/); - - return match?.[1] || "lineup"; -} - -export function getConfig(): RuntimeConfig { - const configFile = getArgValue("config") ?? getEnvValue("CONFIG_FILE"); - - if (configFile) { - assertNoListingSpecificOverrides(); - - const { path, config } = loadConfigFile(configFile); - const globalEnvOverrides = getGlobalEnvOverrides(); - const globalCliOverrides = getGlobalCliOverrides(); - - const listings = config.listings.map((listingInput) => - buildListingConfig({ - ...(config.defaults ?? {}), - ...listingInput, - ...globalEnvOverrides, - ...globalCliOverrides, - }), - ); - - assertDistinctOutputFiles(listings); - - return { - configFile: path, - listings, - }; - } - - const listing = buildListingConfig({ - ...getGlobalEnvOverrides(), - ...getSingleListingEnvOverrides(), - ...getGlobalCliOverrides(), - ...getSingleListingCliOverrides(), - }); - - return { listings: [listing] }; -} +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import * as v from "valibot"; +import { parse } from "yaml"; +import { UserAgent } from "./useragents.js"; +import type { DedupeMode } from "./merge.js"; + +const validCountries = [ + "ABW", + "AIA", + "ARG", + "ATG", + "BHS", + "BLZ", + "BRA", + "BRB", + "BMU", + "CAN", + "COL", + "CRI", + "CUW", + "CYM", + "DMA", + "DOM", + "ECU", + "GRD", + "GTM", + "GUY", + "HND", + "JAM", + "KNA", + "LCA", + "MAF", + "MEX", + "PAN", + "PER", + "TCA", + "TTO", + "URY", + "USA", + "VCT", + "VEN", + "VGB", +] as const; + +const defaultBaseUrl = "https://tvlistings.gracenote.com/api/grid"; +const listingSpecificCliOptions = ["lineupId", "country", "postalCode", "outputFile"] as const; +const listingSpecificEnvVars = ["LINEUP_ID", "COUNTRY", "POSTAL_CODE", "OUTPUT_FILE"] as const; + +export interface XmltvOptions { + appendAsterisk: boolean; + mediaportal: boolean; + nextpvr: boolean; + stationid: boolean; + sortname: boolean; +} + +export interface ListingConfig extends XmltvOptions { + name?: string; + baseUrl: string; + lineupId: string; + headendId: string; + timespan: string; + country: string; + postalCode: string; + pref: string; + timezone: string; + userAgent: string; + outputFile: string; +} + +export interface MergeConfig { + enabled: boolean; + outputFile: string; + /** Whether each listing's own XML file is written alongside the merged one. */ + keepIndividual: boolean; + /** Which record wins for duplicate channel/start pairs. */ + dedupe: DedupeMode; +} + +export interface RuntimeConfig { + configFile?: string; + listings: ListingConfig[]; + merge: MergeConfig; +} + +type ListingConfigInput = Partial>; +/** Raw merge input: `dedupe` is still an arbitrary string until validated. */ +type MergeConfigInput = Partial> & { dedupe?: string }; + +interface ListingsFileConfig { + defaults?: ListingConfigInput; + listings: ListingConfigInput[]; + merge?: MergeConfigInput; +} + +const StringLikeSchema = v.pipe( + v.union([v.string(), v.number()]), + v.transform((value) => value.toString()), +); + +const BooleanStringSchema = v.pipe( + v.string(), + v.transform((value) => value.trim().toLowerCase()), + v.check((value) => value === "true" || value === "false", "Invalid boolean value"), + v.transform((value) => value === "true"), +); + +const OptionalStringInputSchema = v.optional(StringLikeSchema); + +const OptionalBooleanInputSchema = v.pipe( + v.optional(v.union([v.boolean(), v.literal(""), BooleanStringSchema])), + v.transform((value) => (value === "" ? undefined : value)), +); + +function stringConfigField(defaultValue: string) { + return v.pipe( + v.optional(StringLikeSchema, defaultValue), + v.transform((value) => (value === "" ? defaultValue : value)), + ); +} + +function optionalStringConfigField() { + return v.pipe( + v.optional(StringLikeSchema), + v.transform((value) => (value === "" ? undefined : value)), + ); +} + +function booleanConfigField(defaultValue: boolean) { + return v.pipe( + v.optional(v.union([v.boolean(), v.literal(""), BooleanStringSchema]), defaultValue), + v.transform((value) => (value === "" ? defaultValue : value)), + ); +} + +const ListingInputSchema = v.strictObject({ + name: OptionalStringInputSchema, + baseUrl: OptionalStringInputSchema, + lineupId: OptionalStringInputSchema, + timespan: OptionalStringInputSchema, + country: OptionalStringInputSchema, + postalCode: OptionalStringInputSchema, + pref: OptionalStringInputSchema, + timezone: OptionalStringInputSchema, + userAgent: OptionalStringInputSchema, + outputFile: OptionalStringInputSchema, + appendAsterisk: OptionalBooleanInputSchema, + mediaportal: OptionalBooleanInputSchema, + nextpvr: OptionalBooleanInputSchema, + stationid: OptionalBooleanInputSchema, + sortname: OptionalBooleanInputSchema, +}); + +const DedupeModeSchema = v.pipe( + v.optional(StringLikeSchema, "first"), + v.transform((value) => (value === "" ? "first" : value.trim().toLowerCase())), + v.check((value) => value === "first" || value === "last", 'Invalid dedupe mode (expected "first" or "last")'), + v.transform((value) => value as DedupeMode), +); + +const MergeInputSchema = v.strictObject({ + enabled: OptionalBooleanInputSchema, + outputFile: OptionalStringInputSchema, + keepIndividual: OptionalBooleanInputSchema, + dedupe: OptionalStringInputSchema, +}); + +const MergeConfigSchema = v.strictObject({ + enabled: booleanConfigField(false), + outputFile: stringConfigField("merged.xml"), + keepIndividual: booleanConfigField(true), + dedupe: DedupeModeSchema, +}); + +const ListingConfigSchema = v.pipe( + v.strictObject({ + name: optionalStringConfigField(), + baseUrl: stringConfigField(defaultBaseUrl), + lineupId: v.optional(StringLikeSchema), + timespan: stringConfigField("72"), + country: v.pipe( + stringConfigField("USA"), + v.check( + (country) => validCountries.includes(country as (typeof validCountries)[number]), + "Invalid country code", + ), + ), + postalCode: stringConfigField("-"), + pref: stringConfigField(""), + timezone: stringConfigField("America/New_York"), + userAgent: stringConfigField(UserAgent), + outputFile: stringConfigField("xmltv.xml"), + appendAsterisk: booleanConfigField(false), + mediaportal: booleanConfigField(false), + nextpvr: booleanConfigField(false), + stationid: booleanConfigField(false), + sortname: booleanConfigField(false), + }), + v.transform((input): ListingConfig => { + const lineupId = normalizeLineupId( + input.lineupId === undefined || input.lineupId === "" + ? `${input.country}-lineupId-DEFAULT` + : input.lineupId, + input.country, + ); + + return { + ...input, + lineupId, + headendId: getHeadendId(lineupId), + }; + }), +); + +const ConfigFileSchema = v.strictObject({ + defaults: v.optional(ListingInputSchema), + listings: v.pipe( + v.array(ListingInputSchema), + v.check((listings) => listings.length > 0, "Expected at least one listing"), + ), + merge: v.optional(MergeInputSchema), +}); + +function getArgIndex(name: string): number { + const prefix = `--${name}=`; + return process.argv.findIndex((arg) => arg === `--${name}` || arg.startsWith(prefix)); +} + +function hasArg(name: string): boolean { + return getArgIndex(name) !== -1; +} + +function getArgValue(name: string): string | undefined { + const index = getArgIndex(name); + + if (index === -1) { + return undefined; + } + + const arg = process.argv[index]!; + if (arg === `--${name}`) { + const nextArg = process.argv[index + 1]; + return nextArg && !nextArg.startsWith("--") ? nextArg : undefined; + } + + return arg.substring(`--${name}=`.length); +} + +function getEnvValue(name: string): string | undefined { + const value = process.env[name]; + return value === undefined || value === "" ? undefined : value; +} + +function compactDefinedValues>(input: T): Partial { + return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined)) as Partial; +} + +function toSchemaError(source: string, error: unknown): Error { + if (error instanceof v.ValiError) { + return new Error(`Invalid ${source}: ${error.issues[0]?.message ?? "schema validation failed"}`); + } + + return error instanceof Error ? error : new Error(String(error)); +} + +function parseListingInput(input: unknown, source: string): ListingConfigInput { + try { + return v.parse(ListingInputSchema, input, { abortEarly: true }); + } catch (error) { + throw toSchemaError(source, error); + } +} + +function buildListingConfig(input: ListingConfigInput): ListingConfig { + try { + return v.parse(ListingConfigSchema, input, { abortEarly: true }); + } catch (error) { + throw toSchemaError("listing config", error); + } +} + +function parseMergeInput(input: unknown, source: string): MergeConfigInput { + try { + return v.parse(MergeInputSchema, input, { abortEarly: true }) as MergeConfigInput; + } catch (error) { + throw toSchemaError(source, error); + } +} + +function buildMergeConfig(input: MergeConfigInput): MergeConfig { + try { + return v.parse(MergeConfigSchema, input, { abortEarly: true }); + } catch (error) { + throw toSchemaError("merge config", error); + } +} + +function getMergeEnvOverrides(): MergeConfigInput { + return parseMergeInput( + compactDefinedValues({ + enabled: getEnvValue("MERGE"), + outputFile: getEnvValue("MERGE_OUTPUT_FILE"), + keepIndividual: getEnvValue("MERGE_KEEP_INDIVIDUAL"), + dedupe: getEnvValue("MERGE_DEDUPE"), + }), + "merge environment overrides", + ); +} + +function getMergeCliOverrides(): MergeConfigInput { + // --mergeOutputFile implies --merge, so the common case is a single flag. + const outputFile = getArgValue("mergeOutputFile"); + const enabled = hasArg("merge") || hasArg("mergeOutputFile") ? true : undefined; + + return parseMergeInput( + compactDefinedValues({ + enabled, + outputFile, + keepIndividual: hasArg("noKeepIndividual") ? false : undefined, + dedupe: getArgValue("mergeDedupe"), + }), + "merge CLI overrides", + ); +} + +function loadConfigFile(configFilePath: string): { path: string; config: ListingsFileConfig } { + const resolvedPath = resolve(configFilePath); + const fileContents = readFileSync(resolvedPath, { encoding: "utf-8" }); + const parsed = parse(fileContents); + + try { + const config = v.parse(ConfigFileSchema, parsed, { abortEarly: true }); + return { + path: resolvedPath, + config, + }; + } catch (error) { + throw toSchemaError(`config file ${resolvedPath}`, error); + } +} + +function normalizeLineupId(lineupId: string, country: string): string { + if (lineupId.includes("OTA")) { + return `${country}-lineupId-DEFAULT`; + } + + return lineupId; +} + +function getGlobalEnvOverrides(): ListingConfigInput { + return parseListingInput( + compactDefinedValues({ + timespan: getEnvValue("TIMESPAN"), + pref: getEnvValue("PREF"), + timezone: getEnvValue("TZ"), + userAgent: getEnvValue("USER_AGENT"), + appendAsterisk: getEnvValue("APPEND_ASTERISK"), + mediaportal: getEnvValue("MEDIA_PORTAL"), + nextpvr: getEnvValue("NEXTPVR"), + stationid: getEnvValue("STATIONID"), + sortname: getEnvValue("SORTNAME"), + }), + "environment overrides", + ); +} + +function getGlobalCliOverrides(): ListingConfigInput { + return parseListingInput( + compactDefinedValues({ + timespan: getArgValue("timespan"), + pref: getArgValue("pref"), + timezone: getArgValue("timezone"), + userAgent: getArgValue("userAgent"), + appendAsterisk: hasArg("appendAsterisk") ? true : undefined, + mediaportal: hasArg("mediaportal") ? true : undefined, + nextpvr: hasArg("nextpvr") ? true : undefined, + stationid: hasArg("stationid") ? true : undefined, + sortname: hasArg("sortname") ? true : undefined, + }), + "CLI overrides", + ); +} + +function getSingleListingEnvOverrides(): ListingConfigInput { + return parseListingInput( + compactDefinedValues({ + lineupId: getEnvValue("LINEUP_ID"), + country: getEnvValue("COUNTRY"), + postalCode: getEnvValue("POSTAL_CODE"), + outputFile: getEnvValue("OUTPUT_FILE"), + }), + "single listing environment overrides", + ); +} + +function getSingleListingCliOverrides(): ListingConfigInput { + return parseListingInput( + compactDefinedValues({ + lineupId: getArgValue("lineupId"), + country: getArgValue("country"), + postalCode: getArgValue("postalCode"), + outputFile: getArgValue("outputFile"), + }), + "single listing CLI overrides", + ); +} + +function assertNoListingSpecificOverrides(): void { + const conflicts: string[] = []; + + for (const option of listingSpecificCliOptions) { + if (hasArg(option)) { + conflicts.push(`--${option}`); + } + } + + for (const envVar of listingSpecificEnvVars) { + if (getEnvValue(envVar) !== undefined) { + conflicts.push(envVar); + } + } + + if (conflicts.length > 0) { + throw new Error( + `Cannot combine --config with listing-specific overrides (${conflicts.join(", ")}). Move those values into the YAML listings.`, + ); + } +} + +function assertDistinctOutputFiles(listings: ListingConfig[]): void { + if (listings.length < 2) { + return; + } + + const seen = new Map(); + + for (const listing of listings) { + const label = listing.name || listing.lineupId; + const previous = seen.get(listing.outputFile); + + if (previous) { + throw new Error( + `Multiple listings resolve to the same outputFile "${listing.outputFile}" (${previous}, ${label}). Set unique outputFile values for each listing.`, + ); + } + + seen.set(listing.outputFile, label); + } +} + +/** + * The merged file must not clobber a listing's own output. This only matters + * when the individual files are being kept; otherwise they are never written. + */ +function assertMergeOutputDistinct(listings: ListingConfig[], merge: MergeConfig): void { + if (!merge.enabled || !merge.keepIndividual) { + return; + } + + const clash = listings.find((listing) => resolve(listing.outputFile) === resolve(merge.outputFile)); + + if (clash) { + throw new Error( + `Merge outputFile "${merge.outputFile}" collides with listing "${clash.name || clash.lineupId}". Choose a different merge outputFile or disable keepIndividual.`, + ); + } +} + +export function processLineupId(): string { + return buildListingConfig( + compactDefinedValues({ + country: getArgValue("country") ?? getEnvValue("COUNTRY"), + lineupId: getArgValue("lineupId") ?? getEnvValue("LINEUP_ID"), + }), + ).lineupId; +} + +export function getHeadendId(lineupId: string): string { + if (lineupId.includes("OTA")) { + return "lineupId"; + } + + const match = lineupId.match(/^(?:[A-Z]{3})-(.*?)(?:-[A-Z]+)?$/); + + return match?.[1] || "lineup"; +} + +export function getConfig(): RuntimeConfig { + const configFile = getArgValue("config") ?? getEnvValue("CONFIG_FILE"); + + if (configFile) { + assertNoListingSpecificOverrides(); + + const { path, config } = loadConfigFile(configFile); + const globalEnvOverrides = getGlobalEnvOverrides(); + const globalCliOverrides = getGlobalCliOverrides(); + + const listings = config.listings.map((listingInput) => + buildListingConfig({ + ...(config.defaults ?? {}), + ...listingInput, + ...globalEnvOverrides, + ...globalCliOverrides, + }), + ); + + assertDistinctOutputFiles(listings); + + const merge = buildMergeConfig({ + ...(config.merge ?? {}), + ...getMergeEnvOverrides(), + ...getMergeCliOverrides(), + }); + + assertMergeOutputDistinct(listings, merge); + + return { + configFile: path, + listings, + merge, + }; + } + + const listing = buildListingConfig({ + ...getGlobalEnvOverrides(), + ...getSingleListingEnvOverrides(), + ...getGlobalCliOverrides(), + ...getSingleListingCliOverrides(), + }); + + const merge = buildMergeConfig({ + ...getMergeEnvOverrides(), + ...getMergeCliOverrides(), + }); + + assertMergeOutputDistinct([listing], merge); + + return { listings: [listing], merge }; +} diff --git a/src/index.ts b/src/index.ts index 6104009..caac4f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,70 +1,105 @@ -import { writeFileSync } from "node:fs"; -import { getTVListings } from "./tvlistings.js"; -import { buildXmltv } from "./xmltv.js"; -import { getConfig } from "./config.js"; - -function isHelp() { - if (process.argv.includes("--help")) { - console.log(` -Usage: node dist/index.js [options] - -Options: ---help Show this help message ---config=FILE YAML config file for one or more listings ---lineupId=ID Lineup ID (default: COUNTRY-lineupId-DEFAULT) ---timespan=NUM Timespan in hours (up to 360 = 15 days, default: 72) ---pref=LIST User preferences, comma separated. Can be m, p, and h ---country=CON Country code (default: USA) ---postalCode=ZIP Postal code (default: -) ---userAgent=UA Custom user agent string (default: random from bundled list) ---timezone=TZ Timezone (default: America/New_York) ---outputFile=FILE Output file name for single-listing mode (default: xmltv.xml) ---appendAsterisk Append * to titles with or ---mediaportal Prioritize xmltv_ns episode-num tags ---nextpvr Move "channelNo callsign" display-name to first position ---stationid Sort channels by station ID (legacy behavior) ---sortname Sort channels alphabetically by call sign/name - -When using --config, keep lineupId, country, postalCode, and outputFile in YAML. -Global flags like timespan, userAgent, and XML output flags still override every listing. -`); - process.exit(0); - } -} - -async function main() { - try { - isHelp(); - const runtimeConfig = getConfig(); - - if (runtimeConfig.configFile) { - console.log(`Loaded config file: ${runtimeConfig.configFile}`); - } - - console.log(`Building XMLTV for ${runtimeConfig.listings.length} listing(s)`); - - for (const listing of runtimeConfig.listings) { - const label = listing.name || listing.lineupId; - console.log(`Processing listing: ${label}`); - console.log( - `Config: Country=${listing.country}, PostalCode=${listing.postalCode}, OutputFile=${listing.outputFile}`, - ); - - console.log("Fetching TV listings..."); - const data = await getTVListings(listing); - console.log(`Successfully fetched ${data.channels.length} channels`); - - console.log("Building XMLTV content..."); - const xml = buildXmltv(data, listing); - - console.log(`Writing XMLTV to ${listing.outputFile}...`); - writeFileSync(listing.outputFile, xml, { encoding: "utf-8" }); - console.log(`XMLTV file created successfully for ${label}!`); - } - } catch (err) { - console.error("Error fetching or building XMLTV:", err); - process.exit(1); - } -} - -void main(); +import { writeFileSync } from "node:fs"; +import { getTVListings } from "./tvlistings.js"; +import { buildXmltv } from "./xmltv.js"; +import { getConfig } from "./config.js"; +import { mergeListings, type MergeListingInput } from "./merge.js"; + +function isHelp() { + if (process.argv.includes("--help")) { + console.log(` +Usage: node dist/index.js [options] + +Options: +--help Show this help message +--config=FILE YAML config file for one or more listings +--lineupId=ID Lineup ID (default: COUNTRY-lineupId-DEFAULT) +--timespan=NUM Timespan in hours (up to 360 = 15 days, default: 72) +--pref=LIST User preferences, comma separated. Can be m, p, and h +--country=CON Country code (default: USA) +--postalCode=ZIP Postal code (default: -) +--userAgent=UA Custom user agent string (default: random from bundled list) +--timezone=TZ Timezone (default: America/New_York) +--outputFile=FILE Output file name for single-listing mode (default: xmltv.xml) +--appendAsterisk Append * to titles with or +--mediaportal Prioritize xmltv_ns episode-num tags +--nextpvr Move "channelNo callsign" display-name to first position +--stationid Sort channels by station ID (legacy behavior) +--sortname Sort channels alphabetically by call sign/name + +Merge options: +--merge Also write all listings combined into one XMLTV file +--mergeOutputFile=FILE Merged output file name (implies --merge, default: merged.xml) +--mergeDedupe=MODE Which duplicate to keep: first (default) or last +--noKeepIndividual Skip writing the per-listing files; only write the merged file + +When using --config, keep lineupId, country, postalCode, and outputFile in YAML. +Global flags like timespan, userAgent, and XML output flags still override every listing. +Merging dedupes channels by channel ID and programmes by channel and start time. +`); + process.exit(0); + } +} + +async function main() { + try { + isHelp(); + const runtimeConfig = getConfig(); + const { merge } = runtimeConfig; + + if (runtimeConfig.configFile) { + console.log(`Loaded config file: ${runtimeConfig.configFile}`); + } + + console.log(`Building XMLTV for ${runtimeConfig.listings.length} listing(s)`); + + const merged: MergeListingInput[] = []; + + for (const listing of runtimeConfig.listings) { + const label = listing.name || listing.lineupId; + console.log(`Processing listing: ${label}`); + console.log( + `Config: Country=${listing.country}, PostalCode=${listing.postalCode}, OutputFile=${listing.outputFile}`, + ); + + console.log("Fetching TV listings..."); + const data = await getTVListings(listing); + console.log(`Successfully fetched ${data.channels.length} channels`); + + if (merge.enabled) { + merged.push({ label, data }); + } + + if (!merge.enabled || merge.keepIndividual) { + console.log("Building XMLTV content..."); + const xml = buildXmltv(data, listing); + + console.log(`Writing XMLTV to ${listing.outputFile}...`); + writeFileSync(listing.outputFile, xml, { encoding: "utf-8" }); + console.log(`XMLTV file created successfully for ${label}!`); + } + } + + if (merge.enabled) { + console.log(`Merging ${merged.length} listing(s) (dedupe: ${merge.dedupe})...`); + const { data, stats } = mergeListings(merged, merge.dedupe); + console.log( + `Merged into ${stats.channels} channel(s) and ${stats.programmes} programme(s) ` + + `(${stats.mergedChannels} overlapping channel record(s), ` + + `${stats.duplicateProgrammes} duplicate programme(s) dropped)`, + ); + + // XML output flags are global, so the first listing's options apply + // to the combined document. + const xml = buildXmltv(data, runtimeConfig.listings[0]); + + console.log(`Writing merged XMLTV to ${merge.outputFile}...`); + writeFileSync(merge.outputFile, xml, { encoding: "utf-8" }); + console.log(`Merged XMLTV file created successfully at ${merge.outputFile}!`); + } + } catch (err) { + console.error("Error fetching or building XMLTV:", err); + process.exit(1); + } +} + +void main(); diff --git a/src/merge.test.ts b/src/merge.test.ts new file mode 100644 index 0000000..68d2f1b --- /dev/null +++ b/src/merge.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from "vitest"; +import type { Channel, Event, GridApiResponse } from "./tvlistings.js"; +import { mergeListings, programmeKey } from "./merge.js"; + +function makeEvent(overrides: Partial = {}): Event { + return { + callSign: "KOMODT", + duration: "60", + startTime: "2025-07-18T19:00:00Z", + endTime: "2025-07-18T20:00:00Z", + thumbnail: null, + channelNo: "4.1", + seriesId: "SH05918266", + rating: "TV-PG", + flag: [], + tags: [], + ...overrides, + program: { + title: "GMA3", + id: "EP059182660025", + tmsId: "EP059182660025", + shortDesc: null, + season: "5", + releaseYear: null, + episode: "217", + episodeTitle: null, + seriesId: "SH05918266", + isGeneric: "0", + ...overrides.program, + }, + }; +} + +function makeChannel(overrides: Partial = {}): Channel { + return { + callSign: "KOMODT", + affiliateName: null, + affiliateCallSign: null, + channelId: "19629", + channelNo: "4.1", + events: [], + id: "196290", + stationGenres: [], + stationFilters: [], + thumbnail: null, + ...overrides, + }; +} + +function listing(label: string, channels: Channel[]): { label: string; data: GridApiResponse } { + return { label, data: { channels } }; +} + +describe("programmeKey", () => { + it("treats equivalent timestamps in different formats as the same slot", () => { + const a = makeEvent({ startTime: "2025-07-18T19:00:00Z" }); + const b = makeEvent({ startTime: "2025-07-18T15:00:00-04:00" }); + + expect(programmeKey(a)).toBe(programmeKey(b)); + }); + + it("falls back to the raw value for unparseable timestamps", () => { + const event = makeEvent({ startTime: "not-a-date" }); + + expect(programmeKey(event)).toBe("raw:not-a-date"); + }); + + it("distinguishes different start times", () => { + const a = makeEvent({ startTime: "2025-07-18T19:00:00Z" }); + const b = makeEvent({ startTime: "2025-07-18T20:00:00Z" }); + + expect(programmeKey(a)).not.toBe(programmeKey(b)); + }); +}); + +describe("mergeListings", () => { + it("combines distinct channels from multiple listings", () => { + const result = mergeListings([ + listing("atlanta", [makeChannel({ channelId: "1", events: [makeEvent()] })]), + listing("ottawa", [makeChannel({ channelId: "2", events: [makeEvent()] })]), + ]); + + expect(result.data.channels).toHaveLength(2); + expect(result.stats.channels).toBe(2); + expect(result.stats.listings).toBe(2); + expect(result.stats.programmes).toBe(2); + expect(result.stats.duplicateProgrammes).toBe(0); + }); + + it("pools programmes for a channel present in two listings", () => { + const result = mergeListings([ + listing("a", [makeChannel({ channelId: "1", events: [makeEvent({ startTime: "2025-07-18T19:00:00Z" })] })]), + listing("b", [makeChannel({ channelId: "1", events: [makeEvent({ startTime: "2025-07-18T20:00:00Z" })] })]), + ]); + + expect(result.data.channels).toHaveLength(1); + expect(result.data.channels[0]!.events).toHaveLength(2); + expect(result.stats.mergedChannels).toBe(1); + }); + + it("drops duplicate programmes sharing a channel and start time", () => { + const result = mergeListings([ + listing("a", [makeChannel({ channelId: "1", events: [makeEvent()] })]), + listing("b", [makeChannel({ channelId: "1", events: [makeEvent()] })]), + ]); + + expect(result.data.channels[0]!.events).toHaveLength(1); + expect(result.stats.duplicateProgrammes).toBe(1); + expect(result.stats.programmes).toBe(1); + }); + + it("keeps the first duplicate by default", () => { + const result = mergeListings([ + listing("a", [ + makeChannel({ + channelId: "1", + events: [makeEvent({ program: { title: "First" } as Event["program"] })], + }), + ]), + listing("b", [ + makeChannel({ + channelId: "1", + events: [makeEvent({ program: { title: "Second" } as Event["program"] })], + }), + ]), + ]); + + expect(result.data.channels[0]!.events[0]!.program.title).toBe("First"); + }); + + it("keeps the last duplicate when dedupe is 'last'", () => { + const result = mergeListings( + [ + listing("a", [ + makeChannel({ + channelId: "1", + events: [makeEvent({ program: { title: "First" } as Event["program"] })], + }), + ]), + listing("b", [ + makeChannel({ + channelId: "1", + events: [makeEvent({ program: { title: "Second" } as Event["program"] })], + }), + ]), + ], + "last", + ); + + expect(result.data.channels[0]!.events[0]!.program.title).toBe("Second"); + }); + + it("does not dedupe identical start times across different channels", () => { + const result = mergeListings([ + listing("a", [ + makeChannel({ channelId: "1", events: [makeEvent()] }), + makeChannel({ channelId: "2", events: [makeEvent()] }), + ]), + ]); + + expect(result.stats.duplicateProgrammes).toBe(0); + expect(result.stats.programmes).toBe(2); + }); + + it("fills in missing channel metadata from a later listing", () => { + const result = mergeListings([ + listing("a", [makeChannel({ channelId: "1", channelNo: null, affiliateName: null, thumbnail: null })]), + listing("b", [ + makeChannel({ + channelId: "1", + channelNo: "4.1", + affiliateName: "ABC", + thumbnail: "//example.com/logo.png", + }), + ]), + ]); + + const channel = result.data.channels[0]!; + expect(channel.channelNo).toBe("4.1"); + expect(channel.affiliateName).toBe("ABC"); + expect(channel.thumbnail).toBe("//example.com/logo.png"); + }); + + it("does not let a later listing overwrite existing channel metadata", () => { + const result = mergeListings([ + listing("a", [makeChannel({ channelId: "1", channelNo: "4.1", affiliateName: "ABC" })]), + listing("b", [makeChannel({ channelId: "1", channelNo: "9.9", affiliateName: "NBC" })]), + ]); + + const channel = result.data.channels[0]!; + expect(channel.channelNo).toBe("4.1"); + expect(channel.affiliateName).toBe("ABC"); + }); + + it("returns an empty result for no listings", () => { + const result = mergeListings([]); + + expect(result.data.channels).toEqual([]); + expect(result.stats).toEqual({ + listings: 0, + channels: 0, + programmes: 0, + duplicateProgrammes: 0, + mergedChannels: 0, + }); + }); + + it("does not mutate the input listings", () => { + const source = makeChannel({ channelId: "1", events: [makeEvent()] }); + const inputs = [listing("a", [source]), listing("b", [makeChannel({ channelId: "1", events: [makeEvent()] })])]; + + mergeListings(inputs); + + expect(source.events).toHaveLength(1); + expect(inputs[0]!.data.channels[0]!.events).toHaveLength(1); + }); +}); diff --git a/src/merge.ts b/src/merge.ts new file mode 100644 index 0000000..c648f48 --- /dev/null +++ b/src/merge.ts @@ -0,0 +1,128 @@ +import type { Channel, Event, GridApiResponse } from "./tvlistings.js"; + +/** + * Which record wins when two listings provide a programme for the same + * channel and start time. Mirrors tvmerge's `--dedupe first|last`. + */ +export type DedupeMode = "first" | "last"; + +export interface MergeListingInput { + /** Label used in log output (listing name or lineupId). */ + label: string; + data: GridApiResponse; +} + +export interface MergeStats { + listings: number; + channels: number; + programmes: number; + duplicateProgrammes: number; + mergedChannels: number; +} + +export interface MergeResult { + data: GridApiResponse; + stats: MergeStats; +} + +/** + * Dedupe key for a programme. tvmerge keys on `start::channel` only, so two + * listings carrying the same slot collapse into one entry even if their + * metadata differs slightly. Callers group by channel already, so the key is + * just the start time. + * + * Start times are normalized to epoch milliseconds where parseable: separate + * lineups can express the same instant with different strings (trailing "Z" + * versus an explicit offset), and those must collide rather than both survive. + */ +export function programmeKey(event: Event): string { + const parsed = new Date(event.startTime).getTime(); + return Number.isNaN(parsed) ? `raw:${event.startTime}` : `ts:${parsed}`; +} + +/** + * Prefer whichever channel record carries more metadata. Different lineups + * describe the same station with varying completeness (a channelNo in one + * feed, an affiliateName in another), so we keep the richer fields rather + * than blindly taking the first or last. + */ +function mergeChannelMetadata(existing: Channel, incoming: Channel): Channel { + return { + ...existing, + callSign: existing.callSign || incoming.callSign, + affiliateName: existing.affiliateName ?? incoming.affiliateName, + affiliateCallSign: existing.affiliateCallSign ?? incoming.affiliateCallSign, + channelNo: existing.channelNo ?? incoming.channelNo, + thumbnail: existing.thumbnail ?? incoming.thumbnail, + stationGenres: existing.stationGenres?.length ? existing.stationGenres : incoming.stationGenres, + stationFilters: existing.stationFilters?.length ? existing.stationFilters : incoming.stationFilters, + }; +} + +/** + * Combine several listings' grid data into a single response. + * + * Channels are keyed by `channelId`, so the same station appearing in + * multiple lineups yields one `` element with its programmes + * pooled. Programmes are then deduped per channel by start time. + */ +export function mergeListings(inputs: MergeListingInput[], dedupe: DedupeMode = "first"): MergeResult { + const channels = new Map(); + // Per-channel programme index, preserving insertion order for stable output. + const events = new Map>(); + + let duplicateProgrammes = 0; + let mergedChannels = 0; + + for (const { data } of inputs) { + for (const channel of data.channels) { + const id = channel.channelId; + const existing = channels.get(id); + + if (existing) { + channels.set(id, mergeChannelMetadata(existing, channel)); + mergedChannels++; + } else { + channels.set(id, { ...channel, events: [] }); + events.set(id, new Map()); + } + + const channelEvents = events.get(id)!; + + for (const event of channel.events) { + const key = programmeKey(event); + + if (channelEvents.has(key)) { + duplicateProgrammes++; + // "first" keeps the record already stored; "last" overwrites it. + if (dedupe === "last") { + channelEvents.set(key, event); + } + continue; + } + + channelEvents.set(key, event); + } + } + } + + let programmes = 0; + const mergedChannelList: Channel[] = []; + + for (const [id, channel] of channels) { + const channelEvents = Array.from(events.get(id)?.values() ?? []); + programmes += channelEvents.length; + mergedChannelList.push({ ...channel, events: channelEvents }); + } + + return { + data: { channels: mergedChannelList }, + stats: { + listings: inputs.length, + channels: mergedChannelList.length, + programmes, + duplicateProgrammes, + mergedChannels, + }, + }; +} diff --git a/src/tvlistings.test.ts b/src/tvlistings.test.ts index cccd084..9d06f89 100644 --- a/src/tvlistings.test.ts +++ b/src/tvlistings.test.ts @@ -8,242 +8,239 @@ const mockFetch = vi.fn(); global.fetch = mockFetch; const mockConfig: ListingConfig = { - baseUrl: "https://tvlistings.gracenote.com/api/grid", - lineupId: "USA-GA42500-X", - headendId: "GA42500", - timespan: "6", - country: "USA", - postalCode: "30309", - pref: "", - timezone: "America/New_York", - userAgent: "UnitTestAgent/1.0", - outputFile: "xmltv.xml", - appendAsterisk: false, - mediaportal: false, - nextpvr: false, - stationid: false, - sortname: false, + baseUrl: "https://tvlistings.gracenote.com/api/grid", + lineupId: "USA-GA42500-X", + headendId: "GA42500", + timespan: "6", + country: "USA", + postalCode: "30309", + pref: "", + timezone: "America/New_York", + userAgent: "UnitTestAgent/1.0", + outputFile: "xmltv.xml", + appendAsterisk: false, + mediaportal: false, + nextpvr: false, + stationid: false, + sortname: false, }; const mockGridApiResponse: GridApiResponse = { - channels: [ - { - callSign: "KOMODT", - affiliateName: "AMERICAN BROADCASTING COMPANY", - affiliateCallSign: null, - channelId: "19629", - channelNo: "4.1", - events: [ + channels: [ { - callSign: "KOMODT", - duration: "60", - startTime: "2025-07-18T19:00:00Z", - endTime: "2025-07-18T20:00:00Z", - thumbnail: "p30687311_b_v13_aa", - channelNo: "4.1", - filter: ["filter-news"], - seriesId: "SH05918266", - rating: "TV-PG", - flag: ["New"], - tags: ["Stereo", "CC"], - program: { - title: "GMA3", - id: "EP059182660025", - tmsId: "EP059182660025", - shortDesc: - "BIA performs; comic Zarna Garg; lifestyle contributor Lori Bergamotto; ABC News chief medical correspondent Dr. Tara Narula.", - season: "5", - releaseYear: null, - episode: "217", - episodeTitle: "Special Episode", - seriesId: "SH05918266", - isGeneric: "0", - }, + callSign: "KOMODT", + affiliateName: "AMERICAN BROADCASTING COMPANY", + affiliateCallSign: null, + channelId: "19629", + channelNo: "4.1", + events: [ + { + callSign: "KOMODT", + duration: "60", + startTime: "2025-07-18T19:00:00Z", + endTime: "2025-07-18T20:00:00Z", + thumbnail: "p30687311_b_v13_aa", + channelNo: "4.1", + filter: ["filter-news"], + seriesId: "SH05918266", + rating: "TV-PG", + flag: ["New"], + tags: ["Stereo", "CC"], + program: { + title: "GMA3", + id: "EP059182660025", + tmsId: "EP059182660025", + shortDesc: + "BIA performs; comic Zarna Garg; lifestyle contributor Lori Bergamotto; ABC News chief medical correspondent Dr. Tara Narula.", + season: "5", + releaseYear: null, + episode: "217", + episodeTitle: "Special Episode", + seriesId: "SH05918266", + isGeneric: "0", + }, + }, + ], + id: "196290", + stationGenres: [false], + stationFilters: ["filter-news", "filter-talk"], + thumbnail: "//zap2it.tmsimg.com/h3/NowShowing/19629/s28708_ll_h15_ac.png?w=55", }, - ], - id: "196290", - stationGenres: [false], - stationFilters: ["filter-news", "filter-talk"], - thumbnail: - "//zap2it.tmsimg.com/h3/NowShowing/19629/s28708_ll_h15_ac.png?w=55", - }, - ], + ], }; describe("getTVListings", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("should successfully fetch TV listings", async () => { - // Mock successful response - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockGridApiResponse, + beforeEach(() => { + vi.clearAllMocks(); }); - const result = await getTVListings(mockConfig); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should successfully fetch TV listings", async () => { + // Mock successful response + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockGridApiResponse, + }); - expect(result.channels).toHaveLength(1); - expect(result.channels[0].callSign).toBe("KOMODT"); - expect(result.channels[0].events[0].program.genres).toEqual(["news"]); - }); + const result = await getTVListings(mockConfig); - it("should include a User-Agent header in the request", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockGridApiResponse, + expect(result.channels).toHaveLength(1); + expect(result.channels[0].callSign).toBe("KOMODT"); + expect(result.channels[0].events[0].program.genres).toEqual(["news"]); }); - await getTVListings(mockConfig); + it("should include a User-Agent header in the request", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockGridApiResponse, + }); - const callArgs = mockFetch.mock.calls[0]; - const headers = callArgs[1].headers; - expect(headers["User-Agent"]).toBeDefined(); - expect(typeof headers["User-Agent"]).toBe("string"); - expect(headers["User-Agent"].length).toBeGreaterThan(0); - }); + await getTVListings(mockConfig); - it("should use the configured User-Agent", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockGridApiResponse, + const callArgs = mockFetch.mock.calls[0]; + const headers = callArgs[1].headers; + expect(headers["User-Agent"]).toBeDefined(); + expect(typeof headers["User-Agent"]).toBe("string"); + expect(headers["User-Agent"].length).toBeGreaterThan(0); }); - await getTVListings(mockConfig); + it("should use the configured User-Agent", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockGridApiResponse, + }); + + await getTVListings(mockConfig); - const callArgs = mockFetch.mock.calls[0]; - const userAgent = callArgs[1].headers["User-Agent"]; - expect(userAgent).toBe(mockConfig.userAgent); - }); + const callArgs = mockFetch.mock.calls[0]; + const userAgent = callArgs[1].headers["User-Agent"]; + expect(userAgent).toBe(mockConfig.userAgent); + }); - it("should throw an error when response is not ok (4xx status)", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 404, - statusText: "Not Found", - text: async () => "Requested lineup was not found", + it("should throw an error when response is not ok (4xx status)", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: "Not Found", + text: async () => "Requested lineup was not found", + }); + + await expect(getTVListings(mockConfig)).rejects.toThrow( + /Failed to fetch URL .*: 404 Not Found - Requested lineup was not found\.\.\./, + ); }); - await expect(getTVListings(mockConfig)).rejects.toThrow( - /Failed to fetch URL .*: 404 Not Found - Requested lineup was not found\.\.\./, - ); - }); - - it("should throw an error when response is not ok (5xx status)", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - statusText: "Internal Server Error", - text: async () => "Upstream service failed", + it("should throw an error when response is not ok (5xx status)", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + text: async () => "Upstream service failed", + }); + + await expect(getTVListings(mockConfig)).rejects.toThrow( + /Failed to fetch URL .*: 500 Internal Server Error - Upstream service failed\.\.\./, + ); }); - await expect(getTVListings(mockConfig)).rejects.toThrow( - /Failed to fetch URL .*: 500 Internal Server Error - Upstream service failed\.\.\./, - ); - }); - - it("should throw an error when response is not ok (3xx status)", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 301, - statusText: "Moved Permanently", - text: async () => "Redirects are not followed here", + it("should throw an error when response is not ok (3xx status)", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 301, + statusText: "Moved Permanently", + text: async () => "Redirects are not followed here", + }); + + await expect(getTVListings(mockConfig)).rejects.toThrow( + /Failed to fetch URL .*: 301 Moved Permanently - Redirects are not followed here\.\.\./, + ); }); - await expect(getTVListings(mockConfig)).rejects.toThrow( - /Failed to fetch URL .*: 301 Moved Permanently - Redirects are not followed here\.\.\./, - ); - }); + it("should handle empty channels array", async () => { + const emptyResponse: GridApiResponse = { + channels: [], + }; - it("should handle empty channels array", async () => { - const emptyResponse: GridApiResponse = { - channels: [], - }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => emptyResponse, + }); - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => emptyResponse, + const result = await getTVListings(mockConfig); + expect(result).toEqual(emptyResponse); + expect(result.channels).toHaveLength(0); }); - const result = await getTVListings(mockConfig); - expect(result).toEqual(emptyResponse); - expect(result.channels).toHaveLength(0); - }); + it("should handle multiple channels in response", async () => { + const multiChannelResponse: GridApiResponse = { + channels: [ + { + callSign: "KOMODT", + affiliateName: "AMERICAN BROADCASTING COMPANY", + affiliateCallSign: null, + channelId: "19629", + channelNo: "4.1", + events: [], + id: "196290", + stationGenres: [], + stationFilters: [], + thumbnail: "", + }, + { + callSign: "KOMODT2", + affiliateName: "AMERICAN BROADCASTING COMPANY", + affiliateCallSign: null, + channelId: "19630", + channelNo: "4.2", + events: [], + id: "196300", + stationGenres: [], + stationFilters: [], + thumbnail: "", + }, + ], + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => multiChannelResponse, + }); + + const result = await getTVListings(mockConfig); + expect(result.channels).toHaveLength(2); + expect(result.channels[0].callSign).toBe("KOMODT"); + expect(result.channels[1].callSign).toBe("KOMODT2"); + }); - it("should handle multiple channels in response", async () => { - const multiChannelResponse: GridApiResponse = { - channels: [ - { - callSign: "KOMODT", - affiliateName: "AMERICAN BROADCASTING COMPANY", - affiliateCallSign: null, - channelId: "19629", - channelNo: "4.1", - events: [], - id: "196290", - stationGenres: [], - stationFilters: [], - thumbnail: "", - }, - { - callSign: "KOMODT2", - affiliateName: "AMERICAN BROADCASTING COMPANY", - affiliateCallSign: null, - channelId: "19630", - channelNo: "4.2", - events: [], - id: "196300", - stationGenres: [], - stationFilters: [], - thumbnail: "", - }, - ], - }; + it("should handle network errors", async () => { + const networkError = new Error("Network error"); + mockFetch.mockRejectedValueOnce(networkError); - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => multiChannelResponse, + await expect(getTVListings(mockConfig)).rejects.toThrow("Network error"); }); - const result = await getTVListings(mockConfig); - expect(result.channels).toHaveLength(2); - expect(result.channels[0].callSign).toBe("KOMODT"); - expect(result.channels[1].callSign).toBe("KOMODT2"); - }); - - it("should handle network errors", async () => { - const networkError = new Error("Network error"); - mockFetch.mockRejectedValueOnce(networkError); - - await expect(getTVListings(mockConfig)).rejects.toThrow("Network error"); - }); - - it("should handle JSON parsing errors", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => { - throw new Error("Invalid JSON"); - }, + it("should handle JSON parsing errors", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => { + throw new Error("Invalid JSON"); + }, + }); + + await expect(getTVListings(mockConfig)).rejects.toThrow("Invalid JSON"); }); - await expect(getTVListings(mockConfig)).rejects.toThrow("Invalid JSON"); - }); + it("should handle malformed JSON response", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => { + throw new SyntaxError("Unexpected token < in JSON at position 0"); + }, + }); - it("should handle malformed JSON response", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => { - throw new SyntaxError("Unexpected token < in JSON at position 0"); - }, + await expect(getTVListings(mockConfig)).rejects.toThrow("Unexpected token < in JSON at position 0"); }); - - await expect(getTVListings(mockConfig)).rejects.toThrow( - "Unexpected token < in JSON at position 0", - ); - }); }); diff --git a/src/tvlistings.ts b/src/tvlistings.ts index fbbc3fb..4a41fa8 100644 --- a/src/tvlistings.ts +++ b/src/tvlistings.ts @@ -1,159 +1,157 @@ import type { ListingConfig } from "./config.js"; export interface Program { - title: string; - id: string; - tmsId: string; - shortDesc: string | null; - season: string | null; - releaseYear: string | null; - episode: string | null; - episodeTitle: string | null; - seriesId: string; - isGeneric: string; - originalAirDate?: string; - episodeAirDate?: string; - genres?: string[]; + title: string; + id: string; + tmsId: string; + shortDesc: string | null; + season: string | null; + releaseYear: string | null; + episode: string | null; + episodeTitle: string | null; + seriesId: string; + isGeneric: string; + originalAirDate?: string; + episodeAirDate?: string; + genres?: string[]; } export interface Event { - callSign: string; - duration: string; - startTime: string; - endTime: string; - thumbnail: string | null; - channelNo: string; - filter?: string[]; - seriesId: string; - rating: string | null; - flag: string[]; - tags: string[]; - program: Program; + callSign: string; + duration: string; + startTime: string; + endTime: string; + thumbnail: string | null; + channelNo: string; + filter?: string[]; + seriesId: string; + rating: string | null; + flag: string[]; + tags: string[]; + program: Program; } export interface Channel { - callSign: string; - affiliateName: string | null; - affiliateCallSign: string | null; - channelId: string; - channelNo: string | null; - events: Event[]; - id: string; - stationGenres: boolean[]; - stationFilters: string[]; - thumbnail: string | null; + callSign: string; + affiliateName: string | null; + affiliateCallSign: string | null; + channelId: string; + channelNo: string | null; + events: Event[]; + id: string; + stationGenres: boolean[]; + stationFilters: string[]; + thumbnail: string | null; } export interface GridApiResponse { - channels: Channel[]; + channels: Channel[]; } function buildUrl(config: ListingConfig, time: number, timespan: number): string { - // Build query string in a fixed order; timezone is intentionally left blank. - const orderedParams: Array<[string, string]> = [ - ["lineupId", config.lineupId], - ["timespan", timespan.toString()], - ["headendId", config.headendId], - ["country", config.country], - ["timezone", ""], // no longer used - ["device", config.lineupId.includes("X") ? "X" : "-"], - ["postalCode", config.postalCode], - ["isOverride", "true"], - ["time", time.toString()], - ["pref", "16,128"], - ["userId", "-"], - ["aid", "chi"], - ["languagecode", "en-us"], - ]; - - const query = orderedParams - .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) - .join("&"); - - return `${config.baseUrl}?${query}`; + // Build query string in a fixed order; timezone is intentionally left blank. + const orderedParams: Array<[string, string]> = [ + ["lineupId", config.lineupId], + ["timespan", timespan.toString()], + ["headendId", config.headendId], + ["country", config.country], + ["timezone", ""], // no longer used + ["device", config.lineupId.includes("X") ? "X" : "-"], + ["postalCode", config.postalCode], + ["isOverride", "true"], + ["time", time.toString()], + ["pref", "16,128"], + ["userId", "-"], + ["aid", "chi"], + ["languagecode", "en-us"], + ]; + + const query = orderedParams.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&"); + + return `${config.baseUrl}?${query}`; } export async function getTVListings(config: ListingConfig): Promise { - const totalHours = parseInt(config.timespan, 10); - const chunkHours = 6; - const now = Math.floor(Date.now() / 1000); - const channelsMap: Map = new Map(); - - console.log(`Fetching ${totalHours} hours of TV listings in ${chunkHours}-hour chunks...`); - - const fetchPromises: Promise[] = []; - - for (let offset = 0; offset < totalHours; offset += chunkHours) { - const time = now + offset * 3600; - const url = buildUrl(config, time, chunkHours); - - console.log(`Fetching chunk ${offset / chunkHours + 1}/${Math.ceil(totalHours / chunkHours)}: ${url}`); - - const fetchPromise = fetch(url, { - headers: { - "User-Agent": config.userAgent || "", - }, - }) - .then(async (response) => { - if (!response.ok) { - const errorBody = await response.text(); - throw new Error( - `Failed to fetch URL ${url}: ${response.status} ${response.statusText} - ${errorBody.substring(0, 200)}...`, - ); - } - return response.json() as Promise; - }) - .then((chunkData: GridApiResponse) => { - console.log(`Chunk ${offset / chunkHours + 1} returned ${chunkData.channels.length} channels`); - for (const newChannel of chunkData.channels) { - const processedEvents = newChannel.events.map(event => { - const newProgram = { ...event.program }; - const currentGenres = new Set(newProgram.genres || []); - - if (event.filter && event.filter.length > 0) { - event.filter.forEach(filterTag => { - const genre = filterTag.replace(/filter-/i, '').toLowerCase(); - if (genre) { - currentGenres.add(genre); + const totalHours = parseInt(config.timespan, 10); + const chunkHours = 6; + const now = Math.floor(Date.now() / 1000); + const channelsMap: Map = new Map(); + + console.log(`Fetching ${totalHours} hours of TV listings in ${chunkHours}-hour chunks...`); + + const fetchPromises: Promise[] = []; + + for (let offset = 0; offset < totalHours; offset += chunkHours) { + const time = now + offset * 3600; + const url = buildUrl(config, time, chunkHours); + + console.log(`Fetching chunk ${offset / chunkHours + 1}/${Math.ceil(totalHours / chunkHours)}: ${url}`); + + const fetchPromise = fetch(url, { + headers: { + "User-Agent": config.userAgent || "", + }, + }) + .then(async (response) => { + if (!response.ok) { + const errorBody = await response.text(); + throw new Error( + `Failed to fetch URL ${url}: ${response.status} ${response.statusText} - ${errorBody.substring(0, 200)}...`, + ); } - }); - } - - const isMovie = newProgram.id?.startsWith('MV'); - - if (currentGenres.size === 0 && !isMovie) { - if (newProgram.seriesId && newProgram.seriesId !== '0') { - currentGenres.add('series'); - } - } + return response.json() as Promise; + }) + .then((chunkData: GridApiResponse) => { + console.log(`Chunk ${offset / chunkHours + 1} returned ${chunkData.channels.length} channels`); + for (const newChannel of chunkData.channels) { + const processedEvents = newChannel.events.map((event) => { + const newProgram = { ...event.program }; + const currentGenres = new Set(newProgram.genres || []); + + if (event.filter && event.filter.length > 0) { + event.filter.forEach((filterTag) => { + const genre = filterTag.replace(/filter-/i, "").toLowerCase(); + if (genre) { + currentGenres.add(genre); + } + }); + } + + const isMovie = newProgram.id?.startsWith("MV"); + + if (currentGenres.size === 0 && !isMovie) { + if (newProgram.seriesId && newProgram.seriesId !== "0") { + currentGenres.add("series"); + } + } + + newProgram.genres = Array.from(currentGenres); + + return { ...event, program: newProgram }; + }); + + if (!channelsMap.has(newChannel.channelId)) { + channelsMap.set(newChannel.channelId, { + ...newChannel, + events: processedEvents, + }); + } else { + const existingChannel = channelsMap.get(newChannel.channelId)!; + existingChannel.events.push(...processedEvents); + } + } + }) + .catch((fetchError) => { + console.error(`Error fetching chunk ${offset / chunkHours + 1}:`, fetchError); + throw fetchError; + }); - newProgram.genres = Array.from(currentGenres); + fetchPromises.push(fetchPromise); + } - return { ...event, program: newProgram }; - }); + console.log("Waiting for all chunks to complete..."); + await Promise.all(fetchPromises); - if (!channelsMap.has(newChannel.channelId)) { - channelsMap.set(newChannel.channelId, { - ...newChannel, - events: processedEvents, - }); - } else { - const existingChannel = channelsMap.get(newChannel.channelId)!; - existingChannel.events.push(...processedEvents); - } - } - }) - .catch(fetchError => { - console.error(`Error fetching chunk ${offset / chunkHours + 1}:`, fetchError); - throw fetchError; - }); - - fetchPromises.push(fetchPromise); - } - - console.log("Waiting for all chunks to complete..."); - await Promise.all(fetchPromises); - - console.log(`Completed fetching TV listings. Total unique channels: ${channelsMap.size}`); - return { channels: Array.from(channelsMap.values()) }; + console.log(`Completed fetching TV listings. Total unique channels: ${channelsMap.size}`); + return { channels: Array.from(channelsMap.values()) }; } diff --git a/src/useragents.ts b/src/useragents.ts index ab72f79..d730f04 100644 --- a/src/useragents.ts +++ b/src/useragents.ts @@ -1,12 +1,11 @@ const userAgents = [ - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", - "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0", - "Mozilla/5.0 (Linux; Android 13; SM-G991U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36", - "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0", + "Mozilla/5.0 (Linux; Android 13; SM-G991U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", ]; -export const UserAgent = - userAgents[Math.floor(Math.random() * userAgents.length)]!; +export const UserAgent = userAgents[Math.floor(Math.random() * userAgents.length)]!; diff --git a/src/xmltv.test.ts b/src/xmltv.test.ts index 4b0a8f5..aa664bc 100644 --- a/src/xmltv.test.ts +++ b/src/xmltv.test.ts @@ -1,406 +1,379 @@ import { describe, expect, it } from "vitest"; import type { GridApiResponse } from "./tvlistings.js"; -import { - buildChannelsXml, - buildProgramsXml, - buildXmltv, - escapeXml, - formatDate, -} from "./xmltv.js"; +import { buildChannelsXml, buildProgramsXml, buildXmltv, escapeXml, formatDate } from "./xmltv.js"; const mockData: GridApiResponse = { - channels: [ - { - callSign: "KOMODT", - affiliateName: "AMERICAN BROADCASTING COMPANY", - affiliateCallSign: null, - channelId: "19629", - channelNo: "4.1", - events: [ + channels: [ { - callSign: "KOMODT", - duration: "60", - startTime: "2025-07-18T19:00:00Z", - endTime: "2025-07-18T20:00:00Z", - thumbnail: "p30687311_b_v13_aa", - channelNo: "4.1", - filter: ["filter-news"], - seriesId: "SH05918266", - rating: "TV-PG", - flag: ["New"], - tags: ["Stereo", "CC"], - program: { - title: "GMA3", - id: "EP059182660025", - tmsId: "EP059182660025", - shortDesc: - "BIA performs; comic Zarna Garg; lifestyle contributor Lori Bergamotto; ABC News chief medical correspondent Dr. Tara Narula.", - season: "5", - releaseYear: null, - episode: "217", - episodeTitle: "Special Episode", - seriesId: "SH05918266", - isGeneric: "0", - }, + callSign: "KOMODT", + affiliateName: "AMERICAN BROADCASTING COMPANY", + affiliateCallSign: null, + channelId: "19629", + channelNo: "4.1", + events: [ + { + callSign: "KOMODT", + duration: "60", + startTime: "2025-07-18T19:00:00Z", + endTime: "2025-07-18T20:00:00Z", + thumbnail: "p30687311_b_v13_aa", + channelNo: "4.1", + filter: ["filter-news"], + seriesId: "SH05918266", + rating: "TV-PG", + flag: ["New"], + tags: ["Stereo", "CC"], + program: { + title: "GMA3", + id: "EP059182660025", + tmsId: "EP059182660025", + shortDesc: + "BIA performs; comic Zarna Garg; lifestyle contributor Lori Bergamotto; ABC News chief medical correspondent Dr. Tara Narula.", + season: "5", + releaseYear: null, + episode: "217", + episodeTitle: "Special Episode", + seriesId: "SH05918266", + isGeneric: "0", + }, + }, + ], + id: "196290", + stationGenres: [false], + stationFilters: ["filter-news", "filter-talk"], + thumbnail: "//zap2it.tmsimg.com/h3/NowShowing/19629/s28708_ll_h15_ac.png?w=55", }, - ], - id: "196290", - stationGenres: [false], - stationFilters: ["filter-news", "filter-talk"], - thumbnail: - "//zap2it.tmsimg.com/h3/NowShowing/19629/s28708_ll_h15_ac.png?w=55", - }, - ], + ], }; const orderedChannelsData: GridApiResponse = { - channels: [ - { - callSign: "ZZZ", - affiliateName: "Zulu", - affiliateCallSign: null, - channelId: "200", - channelNo: "2", - events: [], - id: "2000", - stationGenres: [], - stationFilters: [], - thumbnail: "", - }, - { - callSign: "AAA", - affiliateName: "Alpha", - affiliateCallSign: null, - channelId: "100", - channelNo: "10", - events: [], - id: "1000", - stationGenres: [], - stationFilters: [], - thumbnail: "", - }, - ], + channels: [ + { + callSign: "ZZZ", + affiliateName: "Zulu", + affiliateCallSign: null, + channelId: "200", + channelNo: "2", + events: [], + id: "2000", + stationGenres: [], + stationFilters: [], + thumbnail: "", + }, + { + callSign: "AAA", + affiliateName: "Alpha", + affiliateCallSign: null, + channelId: "100", + channelNo: "10", + events: [], + id: "1000", + stationGenres: [], + stationFilters: [], + thumbnail: "", + }, + ], }; describe("buildXmltv", () => { - it("should generate valid XML structure", () => { - const result = buildXmltv(mockData); - expect(result).toContain(''); - expect(result).toContain( - '', - ); - expect(result).toContain(""); - }); + it("should generate valid XML structure", () => { + const result = buildXmltv(mockData); + expect(result).toContain(''); + expect(result).toContain( + '', + ); + expect(result).toContain(""); + }); - it("should include channel information", () => { - const result = buildXmltv(mockData); - expect(result).toContain(''); - expect(result).toContain("KOMODT"); - expect(result).toContain( - "AMERICAN BROADCASTING COMPANY", - ); - expect(result).toContain("4.1"); - }); + it("should include channel information", () => { + const result = buildXmltv(mockData); + expect(result).toContain(''); + expect(result).toContain("KOMODT"); + expect(result).toContain("AMERICAN BROADCASTING COMPANY"); + expect(result).toContain("4.1"); + }); - it("should include programme information", () => { - const result = buildXmltv(mockData); - expect(result).toContain( - '', - ); - expect(result).toContain("GMA3"); - expect(result).toContain("Special Episode"); - expect(result).toContain( - "BIA performs; comic Zarna Garg; lifestyle contributor Lori Bergamotto; ABC News chief medical correspondent Dr. Tara Narula.", - ); - }); + it("should include programme information", () => { + const result = buildXmltv(mockData); + expect(result).toContain( + '', + ); + expect(result).toContain("GMA3"); + expect(result).toContain("Special Episode"); + expect(result).toContain( + "BIA performs; comic Zarna Garg; lifestyle contributor Lori Bergamotto; ABC News chief medical correspondent Dr. Tara Narula.", + ); + }); - it("should include rating information", () => { - const result = buildXmltv(mockData); - expect(result).toContain( - 'TV-PG', - ); - }); + it("should include rating information", () => { + const result = buildXmltv(mockData); + expect(result).toContain('TV-PG'); + }); - it("should include flags and tag-derived output", () => { - const result = buildXmltv(mockData); - expect(result).toContain(""); - expect(result).toContain('