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
|
name: Continuous Integration
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build-docker:
|
||||||
name: Build
|
name: Build Docker
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Build service
|
- name: Build service
|
||||||
run: docker build .
|
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
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup release please
|
- name: Setup release please
|
||||||
uses: google-github-actions/release-please-action@v2
|
uses: googleapis/release-please-action@v4
|
||||||
id: release
|
id: release
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
config-file: .github/workflows/release-please-config.json
|
||||||
release-type: simple
|
|
||||||
changelog-path: CHANGELOG.md
|
|
||||||
package-name: zap2xml
|
|
||||||
|
|
||||||
- name: Login into GitHub Container Registry
|
- name: Login into GitHub Container Registry
|
||||||
if: ${{ steps.release.outputs.release_created }}
|
if: ${{ steps.release.outputs.release_created }}
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,4 +1,6 @@
|
|||||||
.zap2xmlrc
|
build/
|
||||||
|
node_modules/
|
||||||
|
|
||||||
config/
|
compose.yaml
|
||||||
xmltv/
|
|
||||||
|
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 \
|
COPY package.json package.json
|
||||||
perl \
|
COPY package-lock.json package-lock.json
|
||||||
perl-http-cookies \
|
|
||||||
perl-lwp-useragent-determined \
|
|
||||||
perl-json \
|
|
||||||
perl-json-xs \
|
|
||||||
perl-lwp-protocol-https \
|
|
||||||
perl-gd
|
|
||||||
|
|
||||||
WORKDIR /opt
|
RUN npm ci
|
||||||
|
|
||||||
COPY zap2xml.pl zap2xml.pl
|
COPY tsconfig.json tsconfig.json
|
||||||
COPY entrypoint.sh entrypoint.sh
|
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
|
# 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 |
|
| Tag | Description |
|
||||||
| ------- | ----------------------- |
|
| ------- | ----------------------- |
|
||||||
| latest | Stable zap2xml releases |
|
| latest | Stable zap2xml releases |
|
||||||
| nightly | HEAD zap2xml release |
|
| nightly | HEAD zap2xml release |
|
||||||
|
|
||||||
### docker-compose (recommended)
|
#### docker-compose
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
@@ -17,74 +32,37 @@ services:
|
|||||||
container_name: zap2xml
|
container_name: zap2xml
|
||||||
image: ghcr.io/jef/zap2xml:latest
|
image: ghcr.io/jef/zap2xml:latest
|
||||||
environment:
|
environment:
|
||||||
OPT_ARGS: >-
|
OUTPUT_FILE: /xmltv/xmltv.xml
|
||||||
-I -D -C /config/.zap2xmlrc -o /xmltv/xmltv.xml
|
|
||||||
TZ: America/New_York # Consider using your timezone
|
|
||||||
volumes:
|
volumes:
|
||||||
- /path/to/zap2xml/config:/config
|
- ./xmltv:/xmltv
|
||||||
- /path/to/xmltv:/xmltv # nice for mapping other drives to this that may use xmltv.xml
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
```
|
```
|
||||||
|
|
||||||
|
See [Environment variables](#environment-variables) for configuration options.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### Optional environment variables
|
### Environment variables
|
||||||
|
|
||||||
| Variable | Description | Type | Default |
|
| 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` |
|
| `LINEUP_ID` | Lineup ID; You can find this at https://tvlistings.gracenote.com/grid-affiliates.html?aid=orbebb | String | `USA-lineupId-DEFAULT` (Attenna) |
|
||||||
| `SLEEPTIME` | Number of seconds to sleep between runs (useful for scheduling in Docker or cron). | Integer | `43200` |
|
| `TIMESPAN` | Either 3 or 6 hours of shows | Integer | 3 |
|
||||||
| `TZ` | Timezone for program times (affects output XML and Perl's time calculations). | String | System default |
|
| `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 |
|
| Argument | Description | Type | Default |
|
||||||
| ---------------- | --------- | ----------- | ------------------------------------------------------ | ----------------------------- | ------------ |
|
| -------------- | --------------------------------------------------------------------------------------------------------------- | ------- | -------------------------------- |
|
||||||
| `start` | Integer | `0` | Number of days to offset from today for the start date | `start=1` | `-s` |
|
| `--lineupId` | Lineup ID; You can find this at https://tvlistings.gracenote.com/grid-affiliates.html?aid=orbebb | String | `USA-lineupId-DEFAULT` (Attenna) |
|
||||||
| `days` | Integer | `7` | Number of days of program data to fetch | `days=14` | `-d` |
|
| `--timespan` | Either 3 or 6 hours of shows | Integer | 3 |
|
||||||
| `retries` | Integer | `3` | Number of connection retries before failure (max 20) | `retries=5` | `-r` |
|
| `--pref` | User Preferences, comma separated list. `m` for showing music, `p` for showing pay-per-view, `h` for showing HD | String | (empty) |
|
||||||
| `user` | String | (empty) | Username/email for Zap2it account | `user=myemail@example.com` | `-u` |
|
| `--postalCode` | Postal code of where shows are available. | Integer | 30309 |
|
||||||
| `pass` | String | (empty) | Password for Zap2it account | `pass=mypassword` | `-p` |
|
| `--userAgent` | Custom user agent string for HTTP requests. | String | Uses random if not specified |
|
||||||
| `cache` | Directory | `cache` | Directory to store cached data files | `cache=/config/cache` | `-c` |
|
| `--timezone` | Timezone | String | System default |
|
||||||
| `ncdays` | Integer | `0` | Number of days from the end to not cache | `ncdays=2` | `-n` |
|
| `--outputFile` | Output file name (default: xmltv.xml) | String | xmltv.xml |
|
||||||
| `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
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
|
SLEEP_TIME=${SLEEP_TIME:-10800}
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
DATE=$(date)
|
DATE=$(date)
|
||||||
eval /opt/zap2xml.pl "$OPT_ARGS"
|
node build/src/index.js
|
||||||
echo "Last run time: $DATE"
|
echo "Last run time: $DATE"
|
||||||
echo "Will run in $SLEEPTIME seconds"
|
echo "Will run in $((SLEEP_TIME / 60)) minutes"
|
||||||
sleep "$SLEEPTIME"
|
sleep "$SLEEP_TIME"
|
||||||
done
|
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