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..579a00b --- /dev/null +++ b/src/merge.test.ts @@ -0,0 +1,249 @@ +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("treats empty-string metadata as missing so it is still inherited", () => { + const result = mergeListings([ + listing("a", [ + makeChannel({ + channelId: "1", + callSign: "", + channelNo: "", + affiliateName: "", + affiliateCallSign: "", + thumbnail: "", + }), + ]), + listing("b", [ + makeChannel({ + channelId: "1", + callSign: "WXYZ", + channelNo: "4.1", + affiliateName: "ABC", + affiliateCallSign: "WXYZ-TV", + thumbnail: "//example.com/logo.png", + }), + ]), + ]); + + const channel = result.data.channels[0]!; + expect(channel.callSign).toBe("WXYZ"); + expect(channel.channelNo).toBe("4.1"); + expect(channel.affiliateName).toBe("ABC"); + expect(channel.affiliateCallSign).toBe("WXYZ-TV"); + 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..c6e138e --- /dev/null +++ b/src/merge.ts @@ -0,0 +1,135 @@ +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. + * + * These use `||` rather than `??` because the rest of the codebase treats an + * empty string as absent for these fields (`buildChannelsXml` guards each with + * a truthiness check, and `channelComparator` normalizes channelNo via + * `(a.channelNo || "").trim()`). A feed that reports "" instead of null must + * still inherit the real value, otherwise the merged channel would silently + * lose its number, affiliate name, or icon. + */ +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('