refactor!: uses TypeScript, better API usage (#38)

This commit is contained in:
Jef LeCompte
2025-07-18 20:35:06 -07:00
parent 81ae3ac3f6
commit fb28b7d6e6
20 changed files with 4455 additions and 1747 deletions

34
src/config.ts Normal file
View File

@@ -0,0 +1,34 @@
import { UserAgent } from "./useragents.js";
export const config = {
baseUrl: "https://tvlistings.gracenote.com/api/grid",
lineupId:
process.env.LINEUP_ID ||
process.argv.find((arg) => arg.startsWith("--lineupId="))?.split("=")[1] ||
"USA-lineupId-DEFAULT",
timespan:
process.env.TIMESPAN ||
process.argv.find((arg) => arg.startsWith("--timespan="))?.split("=")[1] ||
"3",
postalCode:
process.env.POSTAL_CODE ||
process.argv
.find((arg) => arg.startsWith("--postalCode="))
?.split("=")[1] ||
"30309",
pref:
process.env.PREF ||
process.argv.find((arg) => arg.startsWith("--pref="))?.split("=")[1] ||
"",
timezone: process.env.TZ || "America/New_York",
userAgent:
process.env.USER_AGENT ||
process.argv.find((arg) => arg.startsWith("--userAgent="))?.split("=")[1] ||
UserAgent,
outputFile:
process.env.OUTPUT_FILE ||
process.argv
.find((arg) => arg.startsWith("--outputFile="))
?.split("=")[1] ||
"xmltv.xml",
};

38
src/index.ts Normal file
View File

@@ -0,0 +1,38 @@
import { writeFileSync } from "node:fs";
import { getTVListings } from "./tvlistings.js";
import { buildXmltv } from "./xmltv.js";
import { config } from "./config.js";
function isHelp() {
if (process.argv.includes("--help")) {
console.log(`
Usage: node build/index.js [options]
Options:
--help Show this help message
--lineupId=ID Lineup ID (default: USA-lineupId-DEFAULT)
--timespan=NUM Timespan in hours (default: 3)
--pref=LIST User preferences, comma separated. Can be m, p, and h (default: empty)
--postalCode=ZIP Postal code (default: 30309)
--userAgent=UA Custom user agent string (default: Uses random if not specified)
--timezone=TZ Timezone (default: America/New_York)
`);
process.exit(0);
}
}
async function main() {
try {
isHelp();
const data = await getTVListings();
const xml = buildXmltv(data);
console.log("Writing XMLTV file");
writeFileSync(config.outputFile, xml, { encoding: "utf-8" });
} catch (err) {
console.error("Error fetching GridApiResponse:", err);
}
}
void main();

237
src/tvlistings.test.ts Normal file
View File

@@ -0,0 +1,237 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { GridApiResponse } from "./tvlistings.js";
import { getTVListings } from "./tvlistings.js";
// Mock fetch globally
const mockFetch = vi.fn();
global.fetch = mockFetch;
const mockGridApiResponse: GridApiResponse = {
channels: [
{
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",
},
],
};
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,
});
const result = await getTVListings();
expect(result).toEqual(mockGridApiResponse);
expect(result.channels).toHaveLength(1);
expect(result.channels[0].callSign).toBe("KOMODT");
});
it("should include a User-Agent header in the request", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockGridApiResponse,
});
await getTVListings();
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);
});
it("should use a random User-Agent from the predefined list", async () => {
const expectedUserAgents = [
"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",
];
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockGridApiResponse,
});
await getTVListings();
const callArgs = mockFetch.mock.calls[0];
const userAgent = callArgs[1].headers["User-Agent"];
expect(expectedUserAgents).toContain(userAgent);
});
it("should throw an error when response is not ok (4xx status)", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: "Not Found",
});
await expect(getTVListings()).rejects.toThrow(
"Failed to fetch: 404 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",
});
await expect(getTVListings()).rejects.toThrow(
"Failed to fetch: 500 Internal Server Error",
);
});
it("should throw an error when response is not ok (3xx status)", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 301,
statusText: "Moved Permanently",
});
await expect(getTVListings()).rejects.toThrow(
"Failed to fetch: 301 Moved Permanently",
);
});
it("should handle empty channels array", async () => {
const emptyResponse: GridApiResponse = {
channels: [],
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => emptyResponse,
});
const result = await getTVListings();
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();
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()).rejects.toThrow("Network error");
});
it("should handle JSON parsing errors", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => {
throw new Error("Invalid JSON");
},
});
await expect(getTVListings()).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");
},
});
await expect(getTVListings()).rejects.toThrow(
"Unexpected token < in JSON at position 0",
);
});
});

117
src/tvlistings.ts Normal file
View File

@@ -0,0 +1,117 @@
import { config } from "./config.js";
export interface Program {
/** "title": "GMA3" */
title: string;
/** "id": "EP059182660025" */
id: string;
/** "tmsId": "EP059182660025" */
tmsId: string;
/** "shortDesc": "BIA performs; comic Zarna Garg; lifestyle contributor Lori Bergamotto; ABC News chief medical correspondent Dr. Tara Narula." */
shortDesc: string;
/** "season": "5" */
season: string;
/** "releaseYear": null */
releaseYear: string | null;
/** "episode": "217" */
episode: string;
/** "episodeTitle": null */
episodeTitle: string | null;
/** "seriesId": "SH05918266" */
seriesId: string;
/** "isGeneric": "0" */
isGeneric: string;
}
export interface Event {
/** "callSign": "KOMODT" */
callSign: string;
/** "duration": "60" */
duration: string;
/** "startTime": "2025-07-18T19:00:00Z" */
startTime: string;
/** "endTime": "2025-07-18T20:00:00Z" */
endTime: string;
/** "thumbnail": "p30687311_b_v13_aa" */
thumbnail: string;
/** "channelNo": "4.1" */
channelNo: string;
/** "filter": ["filter-news"] */
filter: string[];
/** "seriesId": "SH05918266" */
seriesId: string;
/** "rating": "TV-PG" */
rating: string;
/** "flag": ["New"] */
flag: string[];
/** "tags": ["Stereo", "CC"] */
tags: string[];
/** "program": {...} */
program: Program;
}
export interface Channel {
/** "callSign": "KOMODT" */
callSign: string;
/** "affiliateName": "AMERICAN BROADCASTING COMPANY" */
affiliateName: string;
/** "affiliateCallSign": "null" */
affiliateCallSign: string | null;
/** "channelId": "19629" */
channelId: string;
/** "channelNo": "4.1" */
channelNo: string;
/** "events": [...] */
events: Event[];
/** "id": "196290" */
id: string;
/** "stationGenres": [false] */
stationGenres: boolean[];
/** "stationFilters": ["filter-news", "filter-talk"] */
stationFilters: string[];
/** "thumbnail": "//zap2it.tmsimg.com/h3/NowShowing/19629/s28708_ll_h15_ac.png?w=55" */
thumbnail: string;
}
export interface GridApiResponse {
/** "channels": [...] */
channels: Channel[];
}
function buildUrl() {
const params = {
lineupId: config.lineupId,
timespan: config.timespan,
headendId: "lineupId",
country: "USA",
timezone: config.timezone,
postalCode: config.postalCode,
isOverride: "true",
pref: config.pref + "16,128" || "16,128",
aid: "orbebb",
languagecode: "en-us",
time: Math.floor(Date.now() / 1000).toString(),
};
const urlParams = new URLSearchParams(params).toString();
return `${config.baseUrl}?${urlParams}`;
}
export async function getTVListings(): Promise<GridApiResponse> {
console.log("Fetching TV listings");
const response = await fetch(buildUrl(), {
headers: {
"User-Agent": config.userAgent,
},
});
if (!response.ok) {
throw new Error(
`Failed to fetch: ${response.status} ${response.statusText}`,
);
}
return (await response.json()) as GridApiResponse;
}

12
src/useragents.ts Normal file
View File

@@ -0,0 +1,12 @@
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",
];
export const UserAgent =
userAgents[Math.floor(Math.random() * userAgents.length)];

333
src/xmltv.test.ts Normal file
View File

@@ -0,0 +1,333 @@
import { describe, expect, it } from "vitest";
import type { GridApiResponse } from "./tvlistings.js";
import {
buildChannelsXml,
buildProgrammesXml,
buildXmltv,
escapeXml,
formatDate,
} from "./xmltv.js";
const mockData: GridApiResponse = {
channels: [
{
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",
},
],
};
describe("buildXmltv", () => {
it("should generate valid XML structure", () => {
const result = buildXmltv(mockData);
expect(result).toContain('<?xml version="1.0" encoding="UTF-8"?>');
expect(result).toContain('<tv generator-info-name="zap2it-grid">');
expect(result).toContain("</tv>");
});
it("should include channel information", () => {
const result = buildXmltv(mockData);
expect(result).toContain('<channel id="19629">');
expect(result).toContain("<display-name>KOMODT</display-name>");
expect(result).toContain(
"<display-name>AMERICAN BROADCASTING COMPANY</display-name>",
);
expect(result).toContain("<display-name>4.1</display-name>");
});
it("should include programme information", () => {
const result = buildXmltv(mockData);
expect(result).toContain(
'<programme start="20250718190000 +0000" stop="20250718200000 +0000" channel="19629">',
);
expect(result).toContain("<title>GMA3</title>");
expect(result).toContain("<sub-title>Special Episode</sub-title>");
expect(result).toContain(
"<desc>BIA performs; comic Zarna Garg; lifestyle contributor Lori Bergamotto; ABC News chief medical correspondent Dr. Tara Narula.</desc>",
);
});
it("should include rating information", () => {
const result = buildXmltv(mockData);
expect(result).toContain("<rating><value>TV-PG</value></rating>");
});
it("should include categories from flags and tags", () => {
const result = buildXmltv(mockData);
expect(result).toContain("<category>New</category>");
expect(result).toContain("<category>Stereo</category>");
expect(result).toContain("<category>CC</category>");
});
it("should include episode information", () => {
const result = buildXmltv(mockData);
expect(result).toContain('<episode-num system="season">5</episode-num>');
expect(result).toContain('<episode-num system="episode">217</episode-num>');
expect(result).toContain(
'<episode-num system="series">SH05918266</episode-num>',
);
});
it("should handle empty data gracefully", () => {
const emptyData: GridApiResponse = { channels: [] };
const result = buildXmltv(emptyData);
expect(result).toContain('<?xml version="1.0" encoding="UTF-8"?>');
expect(result).toContain('<tv generator-info-name="zap2it-grid">');
expect(result).toContain("</tv>");
expect(result).not.toContain("<channel");
expect(result).not.toContain("<programme");
});
it("should handle missing optional fields", () => {
const minimalData: GridApiResponse = {
channels: [
{
callSign: "TEST",
affiliateName: "",
affiliateCallSign: null,
channelId: "123",
channelNo: "",
events: [
{
callSign: "TEST",
duration: "30",
startTime: "2025-07-18T19:00:00Z",
endTime: "2025-07-18T19:30:00Z",
thumbnail: "",
channelNo: "",
filter: [],
seriesId: "",
rating: "",
flag: [],
tags: [],
program: {
title: "Test Show",
id: "TEST123",
tmsId: "TEST123",
shortDesc: "",
season: "",
releaseYear: null,
episode: "",
episodeTitle: null,
seriesId: "",
isGeneric: "0",
},
},
],
id: "123",
stationGenres: [],
stationFilters: [],
thumbnail: "",
},
],
};
const result = buildXmltv(minimalData);
expect(result).toContain('<channel id="123">');
expect(result).toContain("<display-name>TEST</display-name>");
expect(result).toContain(
'<programme start="20250718190000 +0000" stop="20250718193000 +0000" channel="123">',
);
expect(result).toContain("<title>Test Show</title>");
expect(result).not.toContain("<sub-title>");
expect(result).not.toContain("<desc>");
expect(result).not.toContain("<rating>");
expect(result).not.toContain("<category>");
expect(result).not.toContain("<episode-num");
expect(result).not.toContain("<icon");
});
});
describe("escapeXml", () => {
it("should escape XML special characters", () => {
expect(escapeXml("&")).toBe("&amp;");
expect(escapeXml("<")).toBe("&lt;");
expect(escapeXml(">")).toBe("&gt;");
expect(escapeXml('"')).toBe("&quot;");
expect(escapeXml("'")).toBe("&apos;");
});
it("should handle text with multiple special characters", () => {
expect(escapeXml("A & B < C > D \"E\" 'F'")).toBe(
"A &amp; B &lt; C &gt; D &quot;E&quot; &apos;F&apos;",
);
});
it("should handle normal text without special characters", () => {
expect(escapeXml("Normal text")).toBe("Normal text");
});
it("should handle empty string", () => {
expect(escapeXml("")).toBe("");
});
});
describe("formatDate", () => {
it("should format ISO date string correctly", () => {
expect(formatDate("2025-07-18T19:00:00Z")).toBe("20250718190000 +0000");
expect(formatDate("2025-12-31T23:59:59Z")).toBe("20251231235959 +0000");
});
it("should handle different times", () => {
expect(formatDate("2025-01-01T00:00:00Z")).toBe("20250101000000 +0000");
expect(formatDate("2025-06-15T12:30:45Z")).toBe("20250615123045 +0000");
});
it("should handle edge cases", () => {
expect(formatDate("2025-02-28T23:59:59Z")).toBe("20250228235959 +0000");
expect(formatDate("2025-03-01T00:00:00Z")).toBe("20250301000000 +0000");
});
});
describe("buildChannelsXml", () => {
it("should build channel XML correctly", () => {
const result = buildChannelsXml(mockData);
expect(result).toContain('<channel id="19629">');
expect(result).toContain("<display-name>KOMODT</display-name>");
expect(result).toContain(
"<display-name>AMERICAN BROADCASTING COMPANY</display-name>",
);
expect(result).toContain("<display-name>4.1</display-name>");
expect(result).toContain(
'<icon src="https://zap2it.tmsimg.com/h3/NowShowing/19629/s28708_ll_h15_ac.png?w=55" />',
);
});
it("should handle channels without optional fields", () => {
const minimalChannel: GridApiResponse = {
channels: [
{
callSign: "TEST",
affiliateName: "",
affiliateCallSign: null,
channelId: "123",
channelNo: "",
events: [],
id: "123",
stationGenres: [],
stationFilters: [],
thumbnail: "",
},
],
};
const result = buildChannelsXml(minimalChannel);
expect(result).toContain('<channel id="123">');
expect(result).toContain("<display-name>TEST</display-name>");
expect(result).not.toContain("<icon");
});
});
describe("buildProgrammesXml", () => {
it("should build programme XML correctly", () => {
const result = buildProgrammesXml(mockData);
expect(result).toContain(
'<programme start="20250718190000 +0000" stop="20250718200000 +0000" channel="19629">',
);
expect(result).toContain("<title>GMA3</title>");
expect(result).toContain("<sub-title>Special Episode</sub-title>");
expect(result).toContain(
"<desc>BIA performs; comic Zarna Garg; lifestyle contributor Lori Bergamotto; ABC News chief medical correspondent Dr. Tara Narula.</desc>",
);
expect(result).toContain("<rating><value>TV-PG</value></rating>");
expect(result).toContain("<category>New</category>");
expect(result).toContain("<category>Stereo</category>");
expect(result).toContain("<category>CC</category>");
expect(result).toContain('<episode-num system="season">5</episode-num>');
expect(result).toContain('<episode-num system="episode">217</episode-num>');
expect(result).toContain(
'<episode-num system="series">SH05918266</episode-num>',
);
expect(result).toContain('<icon src="p30687311_b_v13_aa" />');
});
it("should handle programmes without optional fields", () => {
const minimalProgramme: GridApiResponse = {
channels: [
{
callSign: "TEST",
affiliateName: "",
affiliateCallSign: null,
channelId: "123",
channelNo: "",
events: [
{
callSign: "TEST",
duration: "30",
startTime: "2025-07-18T19:00:00Z",
endTime: "2025-07-18T19:30:00Z",
thumbnail: "",
channelNo: "",
filter: [],
seriesId: "",
rating: "",
flag: [],
tags: [],
program: {
title: "Test Show",
id: "TEST123",
tmsId: "TEST123",
shortDesc: "",
season: "",
releaseYear: null,
episode: "",
episodeTitle: null,
seriesId: "",
isGeneric: "0",
},
},
],
id: "123",
stationGenres: [],
stationFilters: [],
thumbnail: "",
},
],
};
const result = buildProgrammesXml(minimalProgramme);
expect(result).toContain(
'<programme start="20250718190000 +0000" stop="20250718193000 +0000" channel="123">',
);
expect(result).toContain("<title>Test Show</title>");
expect(result).not.toContain("<sub-title>");
expect(result).not.toContain("<desc>");
expect(result).not.toContain("<rating>");
expect(result).not.toContain("<category>");
expect(result).not.toContain("<episode-num");
expect(result).not.toContain("<icon");
});
});

138
src/xmltv.ts Normal file
View File

@@ -0,0 +1,138 @@
import type { GridApiResponse } from "./tvlistings.js";
export function escapeXml(unsafe: string): string {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
export function formatDate(dateStr: string): string {
// Input: "2025-07-18T19:00:00Z"
// Output: "20250718190000 +0000"
const d = new Date(dateStr);
const pad = (n: number) => n.toString().padStart(2, "0");
const YYYY = d.getUTCFullYear();
const MM = pad(d.getUTCMonth() + 1);
const DD = pad(d.getUTCDate());
const hh = pad(d.getUTCHours());
const mm = pad(d.getUTCMinutes());
const ss = pad(d.getUTCSeconds());
return `${YYYY}${MM}${DD}${hh}${mm}${ss} +0000`;
}
export function buildChannelsXml(data: GridApiResponse): string {
let xml = "";
for (const channel of data.channels) {
xml += ` <channel id="${escapeXml(channel.channelId)}">\n`;
xml += ` <display-name>${escapeXml(channel.callSign)}</display-name>\n`;
if (channel.affiliateName) {
xml += ` <display-name>${escapeXml(
channel.affiliateName,
)}</display-name>\n`;
}
if (channel.channelNo) {
xml += ` <display-name>${escapeXml(
channel.channelNo,
)}</display-name>\n`;
}
if (channel.thumbnail) {
xml += ` <icon src="${escapeXml(
channel.thumbnail.startsWith("http")
? channel.thumbnail
: "https:" + channel.thumbnail,
)}" />\n`;
}
xml += " </channel>\n";
}
return xml;
}
export function buildProgrammesXml(data: GridApiResponse): string {
let xml = "";
for (const channel of data.channels) {
for (const event of channel.events) {
xml += ` <programme start="${formatDate(
event.startTime,
)}" stop="${formatDate(event.endTime)}" channel="${escapeXml(
channel.channelId,
)}">\n`;
xml += ` <title>${escapeXml(event.program.title)}</title>\n`;
if (event.program.episodeTitle) {
xml += ` <sub-title>${escapeXml(
event.program.episodeTitle,
)}</sub-title>\n`;
}
if (event.program.shortDesc) {
xml += ` <desc>${escapeXml(event.program.shortDesc)}</desc>\n`;
}
if (event.rating) {
xml += ` <rating><value>${escapeXml(
event.rating,
)}</value></rating>\n`;
}
if (event.flag && event.flag.length > 0) {
for (const flag of event.flag) {
xml += ` <category>${escapeXml(flag)}</category>\n`;
}
}
if (event.tags && event.tags.length > 0) {
for (const tag of event.tags) {
xml += ` <category>${escapeXml(tag)}</category>\n`;
}
}
if (event.program.season) {
xml += ` <episode-num system="season">${escapeXml(
event.program.season,
)}</episode-num>\n`;
}
if (event.program.episode) {
xml += ` <episode-num system="episode">${escapeXml(
event.program.episode,
)}</episode-num>\n`;
}
if (event.program.seriesId) {
xml += ` <episode-num system="series">${escapeXml(
event.program.seriesId,
)}</episode-num>\n`;
}
if (event.thumbnail) {
xml += ` <icon src="${escapeXml(event.thumbnail)}" />\n`;
}
xml += " </programme>\n";
}
}
return xml;
}
export function buildXmltv(data: GridApiResponse): string {
console.log("Building XMLTV file");
let xml =
'<?xml version="1.0" encoding="UTF-8"?>\n<tv generator-info-name="zap2it-grid">\n';
xml += buildChannelsXml(data);
xml += buildProgrammesXml(data);
xml += "</tv>\n";
return xml;
}