refactor!: uses TypeScript, better API usage (#38)
This commit is contained in:
33
.github/workflows/ci.yaml
vendored
33
.github/workflows/ci.yaml
vendored
@@ -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
|
||||
|
||||
8
.github/workflows/release-please-config.json
vendored
Normal file
8
.github/workflows/release-please-config.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"packages": {
|
||||
".": {
|
||||
"release-type": "simple",
|
||||
"package-name": "zap2xml"
|
||||
}
|
||||
}
|
||||
}
|
||||
7
.github/workflows/release.yaml
vendored
7
.github/workflows/release.yaml
vendored
@@ -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
8
.gitignore
vendored
@@ -1,4 +1,6 @@
|
||||
.zap2xmlrc
|
||||
build/
|
||||
node_modules/
|
||||
|
||||
config/
|
||||
xmltv/
|
||||
compose.yaml
|
||||
|
||||
xmltv.xml
|
||||
|
||||
@@ -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
|
||||
25
Dockerfile
25
Dockerfile
@@ -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
108
README.md
@@ -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 |
|
||||
|
||||
@@ -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
15
eslint.config.mjs
Normal 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
3373
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
package.json
Normal file
35
package.json
Normal 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
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;
|
||||
}
|
||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal 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
1596
zap2xml.pl
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user