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

View File

@@ -1,14 +1,41 @@
name: Continuous Integration
on:
pull_request:
branches:
- main
jobs:
build:
name: Build
build-docker:
name: Build Docker
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Build service
run: docker build .
build-lint-test:
name: Build, Lint, and Test
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: package.json
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Lint
run: npm run lint
- name: Test
run: npm run test:run

View File

@@ -0,0 +1,8 @@
{
"packages": {
".": {
"release-type": "simple",
"package-name": "zap2xml"
}
}
}

View File

@@ -14,13 +14,10 @@ jobs:
uses: actions/checkout@v4
- name: Setup release please
uses: google-github-actions/release-please-action@v2
uses: googleapis/release-please-action@v4
id: release
with:
token: ${{ secrets.GITHUB_TOKEN }}
release-type: simple
changelog-path: CHANGELOG.md
package-name: zap2xml
config-file: .github/workflows/release-please-config.json
- name: Login into GitHub Container Registry
if: ${{ steps.release.outputs.release_created }}

8
.gitignore vendored
View File

@@ -1,4 +1,6 @@
.zap2xmlrc
build/
node_modules/
config/
xmltv/
compose.yaml
xmltv.xml

View File

@@ -1,57 +0,0 @@
# Basic settings
# start: Number of days to offset from today for the start date (default: 0, cmd: -s)
start=0
# days: Number of days of program data to fetch (default: 7, cmd: -d)
days=7
# retries: Number of connection retries before failure (default: 3, max: 20, cmd: -r)
retries=3
# Authentication
# user: Username/email for Zap2it account (default: empty, cmd: -u)
user=myemail@example.com
# pass: Password for Zap2it account (default: empty, cmd: -p)
pass=mypassword
# Cache settings
# cache: Directory to store cached data files (default: cache, cmd: -c)
cache=/config/cache
# ncdays: Number of days from the end to not cache (default: 0, cmd: -n)
ncdays=0
# ncsdays: Number of days from the start to not cache (default: 0, cmd: -N)
ncsdays=0
# ncmday: Specific day number to not cache, 1-based relative to start (default: -1, cmd: -B)
ncmday=-1
# Output settings
# outfile: Output XML file path (default: xmltv.xml or xtvd.xml, cmd: -o)
outfile=/xmltv/xmltv.xml
# outformat: Output format - xmltv or xtvd (default: xmltv, cmd: -x forces xtvd)
outformat=xmltv
# Language
# lang: Language code for program data (default: en, cmd: -l)
lang=en
# Media directories
# icon: Directory to store channel icons (default: disabled, cmd: -i)
icon=/config/icons
# trailer: Directory to store movie trailers (default: disabled, cmd: -t)
trailer=/config/trailers
# Network
# proxy: HTTP proxy server URL (default: none, cmd: -P)
proxy=http://localhost:8080
# XTVD format settings (only used when outformat=xtvd)
# lineuptype: Type of lineup - Cable/CableDigital/Satellite/LocalBroadcast (default: none)
lineuptype=Cable
# lineupname: Name of the lineup (default: none)
lineupname=My Cable Provider
# lineuplocation: Location of the lineup (default: none)
lineuplocation=New York, NY
# Alternative authentication (TV Guide)
# lineupid: Lineup ID for TV Guide, alternative to username/password (default: none, cmd: -Y)
lineupid=X:80000
# postalcode: Postal code for TV Guide lineup lookup (default: none, cmd: -Z)
postalcode=01010

View File

@@ -1,19 +1,18 @@
FROM alpine:3.13.5
FROM node:22.17.1-alpine3.22
ENV SLEEPTIME=43200
WORKDIR /app
RUN apk add --no-cache \
perl \
perl-http-cookies \
perl-lwp-useragent-determined \
perl-json \
perl-json-xs \
perl-lwp-protocol-https \
perl-gd
COPY package.json package.json
COPY package-lock.json package-lock.json
WORKDIR /opt
RUN npm ci
COPY zap2xml.pl zap2xml.pl
COPY tsconfig.json tsconfig.json
COPY entrypoint.sh entrypoint.sh
COPY src/ src/
ENTRYPOINT ["./entrypoint.sh"]
RUN npm run build
RUN ls -l /app
ENTRYPOINT ["/bin/sh", "-c", "/app/entrypoint.sh"]

108
README.md
View File

@@ -1,15 +1,30 @@
# zap2xml
See [zap2xml](https://web.archive.org/web/20200426004001/zap2xml.awardspace.info/) for original Perl script and guidance for the configuration file.
See [zap2xml](https://web.archive.org/web/20200426004001/zap2xml.awardspace.info/) for original Perl script and guidance
for the configuration file.
## Docker
## How to use
### Retrieving your Lineup ID
Visit the [Retrieving Lineup ID](https://github.com/jef/zap2xml/wiki/Retrieving-Lineup-ID) in the Wiki.
### Node.js
```bash
npm i && npm run dev
```
See [Command line arguments](#command-line-arguments) for configuration options.
### Docker
| Tag | Description |
| ------- | ----------------------- |
| latest | Stable zap2xml releases |
| nightly | HEAD zap2xml release |
### docker-compose (recommended)
#### docker-compose
```yaml
services:
@@ -17,74 +32,37 @@ services:
container_name: zap2xml
image: ghcr.io/jef/zap2xml:latest
environment:
OPT_ARGS: >-
-I -D -C /config/.zap2xmlrc -o /xmltv/xmltv.xml
TZ: America/New_York # Consider using your timezone
OUTPUT_FILE: /xmltv/xmltv.xml
volumes:
- /path/to/zap2xml/config:/config
- /path/to/xmltv:/xmltv # nice for mapping other drives to this that may use xmltv.xml
- ./xmltv:/xmltv
restart: unless-stopped
```
See [Environment variables](#environment-variables) for configuration options.
## Configuration
### Optional environment variables
### Environment variables
| Variable | Description | Type | Default |
| ------------ | ---------------------------------------------------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------- |
| `USER_AGENT` | Custom user agent string for HTTP requests. | String | `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36` |
| `SLEEPTIME` | Number of seconds to sleep between runs (useful for scheduling in Docker or cron). | Integer | `43200` |
| `TZ` | Timezone for program times (affects output XML and Perl's time calculations). | String | System default |
| Variable | Description | Type | Default |
| ------------- | --------------------------------------------------------------------------------------------------------------- | ------- | -------------------------------- |
| `LINEUP_ID` | Lineup ID; You can find this at https://tvlistings.gracenote.com/grid-affiliates.html?aid=orbebb | String | `USA-lineupId-DEFAULT` (Attenna) |
| `TIMESPAN` | Either 3 or 6 hours of shows | Integer | 3 |
| `PREF` | User Preferences, comma separated list. `m` for showing music, `p` for showing pay-per-view, `h` for showing HD | String | (empty) |
| `POSTAL_CODE` | Postal code of where shows are available. | Integer | 30309 |
| `USER_AGENT` | Custom user agent string for HTTP requests. | String | Uses random if not specified |
| `TZ` | Timezone | String | System default |
| `SLEEP_TIME` | Sleep time before next run in seconds (default: 10800, Only used with Docker.) | Integer | 10800 |
| `OUTPUT_FILE` | Output file name (default: xmltv.xml) | String | xmltv.xml |
### Optional run configurations
### Command line arguments
| Option | Type | Default | Description | Config File | Command Line |
| ---------------- | --------- | ----------- | ------------------------------------------------------ | ----------------------------- | ------------ |
| `start` | Integer | `0` | Number of days to offset from today for the start date | `start=1` | `-s` |
| `days` | Integer | `7` | Number of days of program data to fetch | `days=14` | `-d` |
| `retries` | Integer | `3` | Number of connection retries before failure (max 20) | `retries=5` | `-r` |
| `user` | String | (empty) | Username/email for Zap2it account | `user=myemail@example.com` | `-u` |
| `pass` | String | (empty) | Password for Zap2it account | `pass=mypassword` | `-p` |
| `cache` | Directory | `cache` | Directory to store cached data files | `cache=/config/cache` | `-c` |
| `ncdays` | Integer | `0` | Number of days from the end to not cache | `ncdays=2` | `-n` |
| `ncsdays` | Integer | `0` | Number of days from the start to not cache | `ncsdays=1` | `-N` |
| `ncmday` | Integer | `-1` | Specific day number to not cache (1-based) | `ncmday=3` | `-B` |
| `outfile` | File path | `xmltv.xml` | Output XML file path | `outfile=/xmltv/xmltv.xml` | `-o` |
| `outformat` | String | `xmltv` | Output format (xmltv/xtvd) | `outformat=xtvd` | `-x` |
| `lang` | String | `en` | Language code for program data | `lang=es` | `-l` |
| `icon` | Directory | (disabled) | Directory to store channel icons | `icon=/config/icons` | `-i` |
| `trailer` | Directory | (disabled) | Directory to store movie trailers | `trailer=/config/trailers` | `-t` |
| `proxy` | URL | (none) | HTTP proxy server URL | `proxy=http://localhost:8080` | `-P` |
| `lineuptype` | String | (none) | Type of lineup (XTVD only) | `lineuptype=Cable` | - |
| `lineupname` | String | (none) | Name of the lineup (XTVD only) | `lineupname=My Provider` | - |
| `lineuplocation` | String | (none) | Location of the lineup (XTVD only) | `lineuplocation=New York, NY` | - |
| `lineupid` | String | (none) | Lineup ID for TV Guide | `lineupid=X:80000` | `-Y` |
| `postalcode` | String | (none) | Postal code for TV Guide | `postalcode=01010` | `-Z` |
| `shiftMinutes` | Integer | `0` | Offset program times by minutes | - | `-m` |
| `sleeptime` | Integer | `0` | Sleep between requests (seconds) | - | `-S` |
| `allChan` | Boolean | `false` | Output all channels (not just favorites) | - | `-a` |
| `outputXTVD` | Boolean | `false` | Force XTVD output format | - | `-x` |
| `includeDetails` | Boolean | `false` | Include program details (extra requests) | - | `-D` |
| `includeIcons` | Boolean | `false` | Include program icons (extra requests) | - | `-I` |
| `retainOrder` | Boolean | `false` | Retain website channel order | - | `-b` |
| `quiet` | Boolean | `false` | Quiet mode (no status output) | - | `-q` |
| `wait` | Boolean | `false` | Wait on exit (require keypress) | - | `-w` |
| `hexEncode` | Boolean | `false` | Hex encode HTML entities | - | `-e` |
| `utf8` | Boolean | `false` | UTF-8 encoding (default: ISO-8859-1) | - | `-U` |
| `liveTag` | Boolean | `false` | Output `<live />` tag | - | `-L` |
| `noTBA` | Boolean | `false` | Don't cache files with "TBA" titles | - | `-T` |
| `channelFirst` | Boolean | `false` | Output channel names first | - | `-F` |
| `oldStyle` | Boolean | `false` | Use old tv_grab_na style channel IDs | - | `-O` |
| `appendFlags` | String | (none) | Append flags to program titles | - | `-A` |
| `copyYear` | Boolean | `false` | Copy movie_year to sub-title tags | - | `-M` |
| `addSeries` | Boolean | `false` | Add "series" category to non-movies | - | `-j` |
| `includeXMLTV` | File | (none) | Include XMLTV file in output | - | `-J` |
| `useTVGuide` | Boolean | `false` | Use tvguide.com instead of gracenote.com | - | `-z` |
### Notes
- Configuration file values can be overridden by command line options
- The configuration file supports comments (lines starting with `#`)
- Empty lines are ignored
- Values are trimmed of leading/trailing whitespace
- Boolean options (like `outformat=xtvd`) are case-insensitive
| Argument | Description | Type | Default |
| -------------- | --------------------------------------------------------------------------------------------------------------- | ------- | -------------------------------- |
| `--lineupId` | Lineup ID; You can find this at https://tvlistings.gracenote.com/grid-affiliates.html?aid=orbebb | String | `USA-lineupId-DEFAULT` (Attenna) |
| `--timespan` | Either 3 or 6 hours of shows | Integer | 3 |
| `--pref` | User Preferences, comma separated list. `m` for showing music, `p` for showing pay-per-view, `h` for showing HD | String | (empty) |
| `--postalCode` | Postal code of where shows are available. | Integer | 30309 |
| `--userAgent` | Custom user agent string for HTTP requests. | String | Uses random if not specified |
| `--timezone` | Timezone | String | System default |
| `--outputFile` | Output file name (default: xmltv.xml) | String | xmltv.xml |

View File

@@ -1,9 +1,11 @@
#!/bin/sh
SLEEP_TIME=${SLEEP_TIME:-10800}
while true; do
DATE=$(date)
eval /opt/zap2xml.pl "$OPT_ARGS"
echo "Last run time: $DATE"
echo "Will run in $SLEEPTIME seconds"
sleep "$SLEEPTIME"
DATE=$(date)
node build/src/index.js
echo "Last run time: $DATE"
echo "Will run in $((SLEEP_TIME / 60)) minutes"
sleep "$SLEEP_TIME"
done

15
eslint.config.mjs Normal file
View File

@@ -0,0 +1,15 @@
// @ts-check
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import prettierConfig from "eslint-plugin-prettier/recommended";
export default tseslint.config(
eslint.configs.recommended,
tseslint.configs.strict,
tseslint.configs.stylistic,
prettierConfig,
{
ignores: ["build"],
},
);

3373
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "@jef/zap2xml",
"version": "1.0.0",
"description": "JavaScript implementation of zap2xml",
"exports": "./src/index.ts",
"type": "module",
"engines": {
"node": ">=18"
},
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"build": "tsc",
"dev": "tsx src/index.ts",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"devDependencies": {
"@eslint/js": "^9.31.0",
"@types/node": "^24.0.14",
"eslint": "^9.31.0",
"eslint-plugin-prettier": "^5.5.3",
"prettier": "^3.6.2",
"tsx": "^4.20.3",
"typescript": "^5.8.3",
"typescript-eslint": "^8.37.0",
"vitest": "^3.2.4"
},
"dependencies": {
"node-fetch": "^3.3.2"
},
"volta": {
"node": "22.17.1"
}
}

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;
}

16
tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"rootDir": ".",
"outDir": "build",
"module": "Node16",
"moduleResolution": "Node16",
"target": "ESNext",
"skipLibCheck": true
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.test.ts"
]
}

1596
zap2xml.pl

File diff suppressed because it is too large Load Diff