refactor!: uses TypeScript, better API usage (#38)
This commit is contained in:
34
src/config.ts
Normal file
34
src/config.ts
Normal 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
38
src/index.ts
Normal 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
237
src/tvlistings.test.ts
Normal 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
117
src/tvlistings.ts
Normal 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
12
src/useragents.ts
Normal 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
333
src/xmltv.test.ts
Normal 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("&");
|
||||
expect(escapeXml("<")).toBe("<");
|
||||
expect(escapeXml(">")).toBe(">");
|
||||
expect(escapeXml('"')).toBe(""");
|
||||
expect(escapeXml("'")).toBe("'");
|
||||
});
|
||||
|
||||
it("should handle text with multiple special characters", () => {
|
||||
expect(escapeXml("A & B < C > D \"E\" 'F'")).toBe(
|
||||
"A & B < C > D "E" 'F'",
|
||||
);
|
||||
});
|
||||
|
||||
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
138
src/xmltv.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { GridApiResponse } from "./tvlistings.js";
|
||||
|
||||
export function escapeXml(unsafe: string): string {
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user