diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 2ca475f..c1c1e67 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1,2 @@ -github: jef -custom: ["https://www.paypal.me/jxf"] +github: jesmannstl +custom: ["https://www.paypal.me/jesmannstl"] diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yml similarity index 92% rename from .github/workflows/ci.yaml rename to .github/workflows/ci.yml index ab9fcb8..008e50b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: run: docker build . build-lint-test: - name: Build, Lint, and Test + name: Build and Lint runs-on: ubuntu-latest steps: - name: Checkout repository @@ -38,9 +38,6 @@ jobs: - name: Lint run: npm run lint - - name: Unit tests - run: npm run test:run - - name: Integration tests run: | node dist/index.js --lineupId=USA-DITV751-X --timespan=3 --postalCode=80020 --outputFile=dtv.xml diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000..6a65d7d --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,18 @@ +name: Docker Image CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Build the Docker image + run: docker build . --file Dockerfile --tag zap2xml:$(date +%s) diff --git a/.github/workflows/nightly-release.yaml b/.github/workflows/nightly-release.yaml index 1b6e97e..0866e7d 100644 --- a/.github/workflows/nightly-release.yaml +++ b/.github/workflows/nightly-release.yaml @@ -31,10 +31,10 @@ jobs: run: | docker build \ -t "ghcr.io/${GITHUB_REPOSITORY}:${GITHUB_SHA:0:7}" \ - -t "ghcr.io/${GITHUB_REPOSITORY}:nightly" . + -t "ghcr.io/${GITHUB_REPOSITORY}:latest" . - name: Release Docker image if: steps.code-change.outputs.should-run == 'true' run: | docker push "ghcr.io/${GITHUB_REPOSITORY}:${GITHUB_SHA:0:7}" - docker push "ghcr.io/${GITHUB_REPOSITORY}:nightly" + docker push "ghcr.io/${GITHUB_REPOSITORY}:latest" diff --git a/CHANGELOG.md b/CHANGELOG.md index dddb788..23d3803 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,52 @@ # Changelog +## (2025-08-07) + +* Updated channel logo no longer has fixed width so can display in better quality + +## (2025-08-06) + +* Added Valid Country Codes that can be used +* Added `--mediaportal` option to use `` before others so Media Portal will display Season/Episode properly + + +## (2025-08-05) + +### Changes since previous release + +These changes are currently on the [jesmannstl/zap2xml](https://github.com/jesmannstl/zap2xml) fork + +* Added Category if available (Movie, Sports, News, Talk, Family etc) +* Added Category "Series" to all programs that did not return a category +* Added additional Season Episode formats for various players +* Added year as Season for programs that only list an episode number like daily cable news +* Added tag to all programs without an aired date normalized to America/New York +* Added xmltv\_ns with the date aired as Season YYYY Episode MMYY to Non Movie or Sports with no other Season/Episode like local news so would have the ability to record as Series is most players. +* Added URL to program details from old Perl function. +* Added --appendAsterisk to add \* to title on programs that are New and/or Live +* Added tag to programs that are not and/or +* Updated affiliateId after orbebb stopped working +* Updated Docker with these changes use APPEND\_ASTERISK: TRUE for the --appendAsterisk option + + + +## [2.2.1](https://github.com/jesmannstl/zap2xml/compare/v2.2.0...v2.2.1) (2025-08-10) + + +### Bug Fixes + +* series and episode info ([6e6fa26](https://github.com/jesmannstl/zap2xml/commit/6e6fa26b75c3b47f9e29f3131daabac0401144c0)) + ## [2.2.0](https://github.com/jef/zap2xml/compare/v2.1.1...v2.2.0) (2025-07-22) + ### Features * update rating, new, stereo, and cc ([e077f27](https://github.com/jef/zap2xml/commit/e077f2721c78d278db14037776ebdeb4cdee660d)) + ### Bug Fixes * add thumbnails for programs ([3ab0370](https://github.com/jef/zap2xml/commit/3ab0370d725c029d64441febb981eeec04f2e1ef)) @@ -15,19 +54,22 @@ * headendId when OTA, add tests ([1696b15](https://github.com/jef/zap2xml/commit/1696b15712753039d896a6fcbe3145331f9b5b76)) + ### Continuous Integration * clean up and conventions ([#52](https://github.com/jef/zap2xml/issues/52)) ([60321a3](https://github.com/jef/zap2xml/commit/60321a37e6410f120be4c8198d39896b8ebea017)) + ### Documentation * add FAQ ([4ac37de](https://github.com/jef/zap2xml/commit/4ac37de08e6e4adaeb060465a246558bdc6c2bb7)) -* include so links to wiki, update SLEEP_TIME default ([b5cec7c](https://github.com/jef/zap2xml/commit/b5cec7c951da794041820407860bcee8e0c5b24a)) +* include so links to wiki, update SLEEP\_TIME default ([b5cec7c](https://github.com/jef/zap2xml/commit/b5cec7c951da794041820407860bcee8e0c5b24a)) ## [2.1.1](https://github.com/jef/zap2xml/compare/v2.1.0...v2.1.1) (2025-07-19) + ### Documentation * fix defaults ([a655a3e](https://github.com/jef/zap2xml/commit/a655a3e84c2bc7191803d48d581c20b340f3c4e6)) @@ -36,6 +78,7 @@ ## [2.1.0](https://github.com/jef/zap2xml/compare/v2.0.0...v2.1.0) (2025-07-19) + ### Features * support upto 15 days of listings ([ee8c32d](https://github.com/jef/zap2xml/commit/ee8c32dfbb319225b181e8c0d956a56e8473d8cd)) @@ -43,6 +86,7 @@ ## [2.0.0](https://github.com/jef/zap2xml/compare/v1.0.3...v2.0.0) (2025-07-19) + ### ⚠ BREAKING CHANGES * uses TypeScript, better API usage ([#38](https://github.com/jef/zap2xml/issues/38)) @@ -53,12 +97,14 @@ * make user agent configurable ([9aae5cd](https://github.com/jef/zap2xml/commit/9aae5cd1e5575e12d56cf04bb550c20fc63e636d)) + ### Miscellaneous * remove node-fetch, fix lint issues ([4c22bfa](https://github.com/jef/zap2xml/commit/4c22bfa9e22273893622f476b33319901bd1c810)) * update configs, signify version bump ([5e00aa2](https://github.com/jef/zap2xml/commit/5e00aa2dfc642d3c8a33fb2254178986bedd87a8)) + ### Continuous Integration * add release-type ([f8549b0](https://github.com/jef/zap2xml/commit/f8549b00b63aefe3593188ef16e86bf0b06a00dc)) @@ -69,6 +115,7 @@ * use v3, update changelog-types ([19b49a0](https://github.com/jef/zap2xml/commit/19b49a0c4fb9d0f0a6e841055c888d558652dc86)) + ### Documentation * add build for easier arg additions ([dada5b3](https://github.com/jef/zap2xml/commit/dada5b3154a2cb0ad7c4f3dcf2a71dcfc34c3705)) @@ -76,6 +123,7 @@ * update lineup id info ([206be57](https://github.com/jef/zap2xml/commit/206be57e8fc44ca33de683dd8776a9e164ef404f)) + ### Refactoring * uses TypeScript, better API usage ([#38](https://github.com/jef/zap2xml/issues/38)) ([fb28b7d](https://github.com/jef/zap2xml/commit/fb28b7d6e6b7316e76637005cc38bee1a44ec8b0)) @@ -83,6 +131,7 @@ ### [1.0.3](https://www.github.com/jef/zap2xml/compare/v1.0.2...v1.0.3) (2025-07-15) + ### Bug Fixes * command line errors ([#11](https://www.github.com/jef/zap2xml/issues/11)) ([ac2fd43](https://www.github.com/jef/zap2xml/commit/ac2fd43215f474b051cfeb94d0845752aa4c5ced)) @@ -91,6 +140,7 @@ ### [1.0.2](https://www.github.com/jef/zap2xml/compare/v1.0.1...v1.0.2) (2025-04-02) + ### Bug Fixes * gracenote.com local URLs throughout code ([#17](https://www.github.com/jef/zap2xml/issues/17)) ([ec67964](https://www.github.com/jef/zap2xml/commit/ec67964282b3b1a391b7fe2190181c562701b89b)) @@ -98,6 +148,7 @@ ### [1.0.1](https://www.github.com/jef/zap2xml/compare/v1.0.0...v1.0.1) (2025-04-02) + ### Bug Fixes * update zap2it URL ([#13](https://www.github.com/jef/zap2xml/issues/13)) ([a41eab9](https://www.github.com/jef/zap2xml/commit/a41eab9f222f1625c4e20a29068bf81562a38829)) @@ -105,6 +156,7 @@ ## 1.0.0 (2021-04-24) + ### Features * update docker and pipeline ([e31071b](https://www.github.com/jef/zap2xml/commit/e31071bda880b57cabc174591e6b92a639735436)) diff --git a/Dockerfile b/Dockerfile index 115ded9..d8d4426 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,8 +12,11 @@ COPY rollup.config.ts rollup.config.ts COPY entrypoint.sh entrypoint.sh COPY src/ src/ +# Fix line endings for the entrypoint script and make it executable +RUN sed -i 's/\r$//' /app/entrypoint.sh && chmod +x /app/entrypoint.sh + RUN npm run build RUN ls -l /app -ENTRYPOINT ["/bin/sh", "-c", "/app/entrypoint.sh"] +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/README.md b/README.md index 10e5d9b..5e515e1 100644 --- a/README.md +++ b/README.md @@ -1,91 +1,51 @@ -# zap2xml - -Automate TV guides to XMLTV format. Easy to use, up-to-date. See below for getting started. - -I also _somewhat_ maintain a version of the original in the [historical-perl branch](https://github.com/jef/zap2xml/tree/historical-perl) if you're interested in that. - -## How to use - -### Node.js - -```bash -npm i && npm run build && node dist/index.js -``` - -See [Command line arguments](#command-line-arguments) for configuration options. - -### Docker - -| Tag | Description | -| ------- | ----------------------- | -| latest | Stable zap2xml releases | -| nightly | HEAD zap2xml release | - -#### docker-compose - -```yaml -services: - zap2xml: - container_name: zap2xml - image: ghcr.io/jef/zap2xml:latest - environment: - OUTPUT_FILE: /xmltv/xmltv.xml - volumes: - - ./xmltv:/xmltv - restart: unless-stopped -``` - -See [Environment variables](#environment-variables) for configuration options. - -## Configuration - -### Environment variables - -| Variable | Description | Default | -| ------------- | --------------------------------------------------------------------------------------------------------------- | -------------------------------- | -| `LINEUP_ID` | Lineup ID; Read more in the [Wiki](https://github.com/jef/zap2xml/wiki/Retrieving-Lineup-ID) | `USA-lineupId-DEFAULT` (Attenna) | -| `TIMESPAN` | Timespan in hours (up to 360 = 15 days, default: 6) | 6 | -| `PREF` | User Preferences, comma separated list. `m` for showing music, `p` for showing pay-per-view, `h` for showing HD | (empty) | -| `COUNTRY` | Country code (default: `USA`) | USA | -| `POSTAL_CODE` | Postal code of where shows are available. | 30309 | -| `USER_AGENT` | Custom user agent string for HTTP requests. | Uses random if not specified | -| `TZ` | Timezone | System default | -| `SLEEP_TIME` | Sleep time before next run in seconds (default: 21600, Only used with Docker.) | 21600 | -| `OUTPUT_FILE` | Output file name (default: xmltv.xml) | xmltv.xml | - -### Command line arguments - -| Argument | Description | Default | -| -------------- | --------------------------------------------------------------------------------------------------------------- | -------------------------------- | -| `--lineupId` | Lineup ID; Read more in the [Wiki](https://github.com/jef/zap2xml/wiki/Retrieving-Lineup-ID) | `USA-lineupId-DEFAULT` (Attenna) | -| `--timespan` | Timespan in hours (up to 360 = 15 days, default: 6) | 6 | -| `--pref` | User Preferences, comma separated list. `m` for showing music, `p` for showing pay-per-view, `h` for showing HD | (empty) | -| `--country` | Country code (default: `USA`) | USA | -| `--postalCode` | Postal code of where shows are available. | 30309 | -| `--userAgent` | Custom user agent string for HTTP requests. | Uses random if not specified | -| `--timezone` | Timezone | System default | -| `--outputFile` | Output file name (default: xmltv.xml) | xmltv.xml | - -## Setup and running in intervals - -### Running natively - -You can run zap2xml natively on your system. It is recommended to use a task scheduler to run it in intervals. - -Here are some links to get you started on your machine: - -- Linux and Raspberry Pi: https://github.com/jef/zap2xml/wiki/Running-on-Linux-and-Raspberry-Pi -- macOS: https://github.com/jef/zap2xml/wiki/Running-on-macOS -- Windows: https://github.com/jef/zap2xml/wiki/Running-on-Windows - -If you want to run zap2xml in intervals, you can use a task scheduler like `cron` on Linux or the Task Scheduler on Windows. Each of the wiki pages above has a section on how to set up zap2xml to run in intervals. - -### Running in Docker - -You can run zap2xml in a Docker container. The `SLEEP_TIME` environment variable can be used to set the interval between runs. The default is 21600 seconds (6 hours). - -## FAQ - -### How do I get my Lineup ID? - -Visit https://github.com/jef/zap2xml/wiki/Retrieving-Lineup-ID +# zap2xml + +Automate TV guides to XMLTV format. Easy to use, up-to-date. See below for getting started. + +I also *somewhat* maintain a version of the original in the [historical-perl branch](https://github.com/jesmannstl/zap2xml/tree/historical-perl) if you're interested in that. + +## First Time [Installation in node.js](https://github.com/jesmannstl/zap2xml/wiki/Installation), [How to Run](https://github.com/jesmannstl/zap2xml/wiki/How-to-Run), [Scheduling](https://github.com/jesmannstl/zap2xml/wiki/Scheduling) or [using Docker](https://github.com/jesmannstl/zap2xml/wiki/Using-Docker) see [Wiki](https://github.com/jesmannstl/zap2xml/wiki) for instructions + +### Need help? [Finding a lineup](https://github.com/jesmannstl/zap2xml/wiki/Finding-a-Lineup-ID) or for [Dish and DirecTV lineups](https://github.com/jesmannstl/zap2xml/wiki/US-Dish-Directv-Lineups). Other help? Drop a line in the [Discussions](https://github.com/jesmannstl/zap2xml/discussions) + +# Recent updates + +# (2025-08-09) + +* Restored `` tag that Plex uses that was missing. +* Fixed Sorting so output is listed by Channel ID (common station/gracenote id) then by date/time. + +# (2025-08-07) + +* Reordered Program fields to match original Perl script output +* `--postalCode` not required as long as Country and lineup Id correct except Over the Air +* Moved `` above `` to match original Perl output. Corrected where Movie Release Year is properly displayed. +* Added `` tag. +* Updated channel logo no longer has fixed width so can display in better quality + +# (2025-08-06) + +* Added Valid Country Codes that can be used +* Added `--mediaportal` option to use `` before others so Media Portal will display Season/Episode properly + +# (2025-08-05) + +# Changes since previous release + +These changes are currently on the [jesmannstl/zap2xml](https://github.com/jesmannstl/zap2xml) fork + +* Added Category if available (Movie, Sports, News, Talk, Family etc) +* Added Category "Series" to all programs that did not return a category +* Added additional Season Episode formats for various players +* Added year as Season for programs that only list an episode number like daily cable news +* Added tag to all programs without an aired date normalized to America/New York +* Added xmltv_ns with the date aired as Season YYYY Episode MMYY to Non Movie or Sports with no other Season/Episode like local news so would have the ability to record as Series is most players. +* Added URL to program details from old Perl function. +* Added --appendAsterisk to add * to title on programs that are New and/or Live +* Added tag to programs that are not and/or +* Updated affiliateId after orbebb stopped working + +Updated Docker with these changes use APPEND_ASTERISK: TRUE for the --appendAsterisk option + + + diff --git a/Valid Country Codes.md b/Valid Country Codes.md new file mode 100644 index 0000000..1815afa --- /dev/null +++ b/Valid Country Codes.md @@ -0,0 +1,49 @@ +# **Valid Country Codes** + + + +| Code | Country | Postal Code Format | +| ---- | ---------------------------- | ------------------- | +| USA | United States | 78701 | +| CAN | Canada | K1P5W1 | +| ARG | Argentina | A4190 | +| BLZ | Belize | BZ (Only Code) | +| BRA | Brazil | 01419 | +| COL | Colombia | 153001 | +| CRI | Costa Rica | 10101 | +| ECU | Ecuador | 170950 | +| GTM | Guatemala | 01001 | +| GUY | Guyana | GY (Only Code) | +| HND | Honduras | HN (Only Code) | +| MEX | Mexico | 41701 | +| PAN | Panama | 1001 | +| PER | Peru | 23006 | +| URY | Uruguay | 11000 | +| VEN | Venezuela | 1010 | +| AIA | Anguilla | AI-2640 (Only Code) | +| ATG | Antigua/Barbuda | AG (Only Code) | +| ABW | Aruba | AW (Only Code) | +| BHS | Bahamas | BS (Only Code) | +| BRB | Barbados | BB14001 | +| BMU | Bermuda | CR01 | +| VGB | British Virgin Islands | VG1110 | +| CYM | Cayman Islands | KY1-0001 | +| CUW | Curacao | CW (Only Code) | +| DMA | Dominica | DM (Only Code) | +| DOM | Dominican Republic | 10101 | +| GRD | Grenada | GD (Only Code) | +| JAM | Jamaica | JMAAW01 | +| USA | Puerto Rico | 00601 (use USA Zip) | +| MAF | Saint Martin | 97150 (Only Code) | +| VCT | Saint Vincent \& Grenadines | VC0100 | +| KNA | St. Kitts and Nevis | KN (Only Code) | +| LCA | St. Lucia | LC (Only Code) | +| TTO | Trinidad and Tobago | TT (Only Code) | +| TCA | Turks and Caicos | TKCA1ZZ (Only Code) | + +Still need the lineup unless OTA working on possibly getting a database of lineups in the future + + + +Unfortunately Gracenote supported lineups in Europe are unavailable + diff --git a/entrypoint.sh b/entrypoint.sh index 3c34671..c847efa 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -4,8 +4,11 @@ SLEEP_TIME=${SLEEP_TIME:-21600} while true; do DATE=$(date) + echo "Starting zap2xml at: $DATE" node dist/index.js + EXIT_CODE=$? + echo "Application exited with code: $EXIT_CODE" echo "Last run time: $DATE" echo "Will run in $((SLEEP_TIME / 60)) minutes" sleep "$SLEEP_TIME" -done +done \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d74cbe6..e9109f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,15 @@ { "name": "@jef/zap2xml", - "version": "2.2.0", + "version": "2.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@jef/zap2xml", - "version": "2.2.0", + "version": "2.2.1", + "dependencies": { + "commander": "^11.1.0" + }, "devDependencies": { "@eslint/js": "^9.31.0", "@rollup/plugin-commonjs": "^28.0.6", @@ -1781,6 +1784,10 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz" + }, "node_modules/commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", @@ -3539,4 +3546,4 @@ } } } -} +} diff --git a/package.json b/package.json index 78c3d4d..a4dcb18 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jef/zap2xml", - "version": "2.2.0", + "version": "2.2.1", "description": "JavaScript implementation of zap2xml", "type": "module", "exports": { @@ -41,8 +41,10 @@ "typescript-eslint": "^8.37.0", "vitest": "^3.2.4" }, - "dependencies": {}, + "dependencies": { + "commander": "^11.1.0" + }, "volta": { "node": "22.17.1" } -} +} diff --git a/src/config.ts b/src/config.ts index 57f536b..a5458ae 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,68 +1,98 @@ -import { UserAgent } from "./useragents.js"; - -export function processLineupId(): string { - const lineupId = - process.env["LINEUP_ID"] || - process.argv.find((arg) => arg.startsWith("--lineupId="))?.split("=")[1] || - "USA-lineupId-DEFAULT"; - - if (lineupId.includes("OTA")) { - return "USA-lineupId-DEFAULT"; - } - - return lineupId; -} - -export function getHeadendId(lineupId: string): string { - if (lineupId.includes("OTA")) { - return "lineupId"; - } - - const match = lineupId.match(/^(USA|CAN)-(.*?)(?:-[A-Z]+)?$/); - - return match?.[2] || "lineup"; -} - -export function getConfig() { - const lineupId = processLineupId(); - const headendId = getHeadendId(lineupId); - - return { - baseUrl: "https://tvlistings.gracenote.com/api/grid", - lineupId, - headendId, - timespan: - process.env["TIMESPAN"] || - process.argv - .find((arg) => arg.startsWith("--timespan=")) - ?.split("=")[1] || - "6", - country: - process.env["COUNTRY"] || - process.argv.find((arg) => arg.startsWith("--country="))?.split("=")[1] || - "USA", - 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", - }; -} +import { UserAgent } from "./useragents.js"; + +// Inject CLI flags from environment variables +if (process.env["APPEND_ASTERISK"] === "true") { + process.argv.push("--appendAsterisk"); +} + +if (process.env["MEDIA_PORTAL"] === "true") { + process.argv.push("--mediaportal"); +} + +const validCountries = [ + "ABW", "AIA", "ARG", "ATG", "BHS", "BLZ", "BRA", "BRB", "BMU", "CAN", "COL", "CRI", + "CUW", "CYM", "DMA", "DOM", "ECU", "GRD", "GTM", "GUY", "HND", "JAM", "KNA", "LCA", + "MAF", "MEX", "PAN", "PER", "TCA", "TTO", "URY", "USA", "VCT", "VEN", "VGB" +]; + +export function processLineupId(): string { + const country = + process.env["COUNTRY"] || + process.argv.find((arg) => arg.startsWith("--country="))?.split("=")[1] || + "USA"; + + const lineupId = + process.env["LINEUP_ID"] || + process.argv.find((arg) => arg.startsWith("--lineupId="))?.split("=")[1] || + `${country}-lineupId-DEFAULT`; + + if (!validCountries.includes(country)) { + throw new Error(`Invalid country code: ${country}`); + } + + if (lineupId.includes("OTA")) { + return `${country}-lineupId-DEFAULT`; + } + + return lineupId; +} + +export function getHeadendId(lineupId: string): string { + if (lineupId.includes("OTA")) { + return "lineupId"; + } + + const match = lineupId.match(/^(?:[A-Z]{3})-(.*?)(?:-[A-Z]+)?$/); + + return match?.[1] || "lineup"; +} + +export function getConfig() { + const lineupId = processLineupId(); + const headendId = getHeadendId(lineupId); + + const country = + process.env["COUNTRY"] || + process.argv.find((arg) => arg.startsWith("--country="))?.split("=")[1] || + "USA"; + + if (!validCountries.includes(country)) { + throw new Error(`Invalid country code: ${country}`); + } + + return { + baseUrl: "https://tvlistings.gracenote.com/api/grid", + lineupId, + headendId, + timespan: + process.env["TIMESPAN"] || + process.argv + .find((arg) => arg.startsWith("--timespan=")) + ?.split("=")[1] || + "72", + country, + postalCode: + process.env["POSTAL_CODE"] || + process.argv + .find((arg) => arg.startsWith("--postalCode=")) + ?.split("=")[1] || + "-", + 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", + }; +} diff --git a/src/index.ts b/src/index.ts index 95daec5..225fb94 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,41 +1,50 @@ -import { writeFileSync } from "node:fs"; -import { getTVListings } from "./tvlistings.js"; -import { buildXmltv } from "./xmltv.js"; -import { getConfig } from "./config.js"; - -const config = getConfig(); - -function isHelp() { - if (process.argv.includes("--help")) { - console.log(` -Usage: node dist/index.js [options] - -Options: ---help Show this help message ---lineupId=ID Lineup ID (default: USA-lineupId-DEFAULT) ---timespan=NUM Timespan in hours (up to 360 = 15 days, default: 6) ---pref=LIST User preferences, comma separated. Can be m, p, and h (default: empty)' ---country=CON Country code (default: USA) ---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(); +import { writeFileSync } from "node:fs"; +import { getTVListings } from "./tvlistings.js"; +import { buildXmltv } from "./xmltv.js"; +import { getConfig } from "./config.js"; + +const config = getConfig(); + +function isHelp() { + if (process.argv.includes("--help")) { + console.log(` +Usage: node dist/index.js [options] + +Options: +--help Show this help message +--lineupId=ID Lineup ID (default: USA-lineupId-DEFAULT) +--timespan=NUM Timespan in hours (up to 360 = 15 days, default: 6) +--pref=LIST User preferences, comma separated. Can be m, p, and h (default: empty)' +--country=CON Country code (default: USA) +--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(); + + console.log("Building XMLTV file"); + console.log(`Config: Country=${config.country}, PostalCode=${config.postalCode}, OutputFile=${config.outputFile}`); + + console.log("Fetching TV listings..."); + const data = await getTVListings(); + console.log(`Successfully fetched ${data.channels.length} channels`); + + console.log("Building XMLTV content..."); + const xml = buildXmltv(data); + + console.log(`Writing XMLTV to ${config.outputFile}...`); + writeFileSync(config.outputFile, xml, { encoding: "utf-8" }); + console.log("XMLTV file created successfully!"); + } catch (err) { + console.error("Error fetching or building XMLTV:", err); + process.exit(1); + } +} + +void main(); \ No newline at end of file diff --git a/src/tvlistings.ts b/src/tvlistings.ts index 6a353ce..9a8d5fa 100644 --- a/src/tvlistings.ts +++ b/src/tvlistings.ts @@ -1,151 +1,158 @@ -import { getConfig } from "./config.js"; - -const config = getConfig(); - -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(time: number, timespan: number): string { - const params = { - lineupId: config.lineupId, - timespan: timespan.toString(), - headendId: config.headendId, - country: config.country, - timezone: config.timezone, - postalCode: config.postalCode, - isOverride: "true", - pref: config.pref + "16,128" || "16,128", - aid: "orbebb", - languagecode: "en-us", - time: time.toString(), - device: "X", - userId: "-", - }; - - const urlParams = new URLSearchParams(params).toString(); - - return `${config.baseUrl}?${urlParams}`; -} - -export async function getTVListings(): Promise { - console.log("Fetching TV listings"); - - const totalHours = parseInt(config.timespan, 10); - const chunkHours = 6; // Gracenote allows up to 6 hours per request - const now = Math.floor(Date.now() / 1000); // Current time in UNIX timestamp - const channelsMap: Map = new Map(); - - const fetchPromises: Promise[] = []; - - for (let offset = 0; offset < totalHours; offset += chunkHours) { - const time = now + offset * 3600; - const url = buildUrl(time, chunkHours); - - const fetchPromise = fetch(url, { - headers: { - "User-Agent": config.userAgent || "", - }, - }).then(async (response) => { - if (!response.ok) { - throw new Error( - `Failed to fetch: ${response.status} ${response.statusText}`, - ); - } - const chunkData = (await response.json()) as GridApiResponse; - - for (const newChannel of chunkData.channels) { - if (!channelsMap.has(newChannel.channelId)) { - // Clone channel with its events - channelsMap.set(newChannel.channelId, { - ...newChannel, - events: [...newChannel.events], - }); - } else { - const existingChannel = channelsMap.get(newChannel.channelId)!; - existingChannel.events.push(...newChannel.events); - } - } - }); - - fetchPromises.push(fetchPromise); - } - - await Promise.all(fetchPromises); - - return { channels: Array.from(channelsMap.values()) }; -} +import { getConfig } from "./config.js"; + +const config = getConfig(); + +export interface Program { + title: string; + id: string; + tmsId: string; + shortDesc: string | null; + season: string | null; + releaseYear: string | null; + episode: string | null; + episodeTitle: string | null; + seriesId: string; + isGeneric: string; + originalAirDate?: string; + episodeAirDate?: string; + genres?: string[]; +} + +export interface Event { + callSign: string; + duration: string; + startTime: string; + endTime: string; + thumbnail: string | null; + channelNo: string; + filter?: string[]; + seriesId: string; + rating: string | null; + flag: string[]; + tags: string[]; + program: Program; +} + +export interface Channel { + callSign: string; + affiliateName: string | null; + affiliateCallSign: string | null; + channelId: string; + channelNo: string | null; + events: Event[]; + id: string; + stationGenres: boolean[]; + stationFilters: string[]; + thumbnail: string | null; +} + +export interface GridApiResponse { + channels: Channel[]; +} + +function buildUrl(time: number, timespan: number): string { + const params = { + lineupId: config.lineupId, + timespan: timespan.toString(), + headendId: config.headendId, + country: config.country, + timezone: config.timezone, + postalCode: config.postalCode, + isOverride: "true", + pref: config.pref + "16,128" || "16,128", + aid: "chi", + languagecode: "en-us", + time: time.toString(), + device: config.lineupId.includes("X") ? "X" : "-", + userId: "-", + }; + + const urlParams = new URLSearchParams(params).toString(); + + return `${config.baseUrl}?${urlParams}`; +} + +export async function getTVListings(): Promise { + const totalHours = parseInt(config.timespan, 10); + const chunkHours = 6; + const now = Math.floor(Date.now() / 1000); + const channelsMap: Map = new Map(); + + console.log(`Fetching ${totalHours} hours of TV listings in ${chunkHours}-hour chunks...`); + + const fetchPromises: Promise[] = []; + + for (let offset = 0; offset < totalHours; offset += chunkHours) { + const time = now + offset * 3600; + const url = buildUrl(time, chunkHours); + + console.log(`Fetching chunk ${offset / chunkHours + 1}/${Math.ceil(totalHours / chunkHours)}: ${url}`); + + const fetchPromise = fetch(url, { + headers: { + "User-Agent": config.userAgent || "", + }, + }) + .then(async (response) => { + if (!response.ok) { + const errorBody = await response.text(); + throw new Error( + `Failed to fetch URL ${url}: ${response.status} ${response.statusText} - ${errorBody.substring(0, 200)}...`, + ); + } + return response.json() as Promise; + }) + .then((chunkData: GridApiResponse) => { + console.log(`Chunk ${offset / chunkHours + 1} returned ${chunkData.channels.length} channels`); + for (const newChannel of chunkData.channels) { + const processedEvents = newChannel.events.map(event => { + const newProgram = { ...event.program }; + const currentGenres = new Set(newProgram.genres || []); + + if (event.filter && event.filter.length > 0) { + event.filter.forEach(filterTag => { + const genre = filterTag.replace(/filter-/i, '').toLowerCase(); + if (genre) { + currentGenres.add(genre); + } + }); + } + + const isMovie = newProgram.id?.startsWith('MV'); + + if (currentGenres.size === 0 && !isMovie) { + if (newProgram.seriesId && newProgram.seriesId !== '0') { + currentGenres.add('series'); + } + } + + newProgram.genres = Array.from(currentGenres); + + return { ...event, program: newProgram }; + }); + + if (!channelsMap.has(newChannel.channelId)) { + channelsMap.set(newChannel.channelId, { + ...newChannel, + events: processedEvents, + }); + } else { + const existingChannel = channelsMap.get(newChannel.channelId)!; + existingChannel.events.push(...processedEvents); + } + } + }) + .catch(fetchError => { + console.error(`Error fetching chunk ${offset / chunkHours + 1}:`, fetchError); + throw fetchError; + }); + + fetchPromises.push(fetchPromise); + } + + console.log("Waiting for all chunks to complete..."); + await Promise.all(fetchPromises); + + console.log(`Completed fetching TV listings. Total unique channels: ${channelsMap.size}`); + return { channels: Array.from(channelsMap.values()) }; +} diff --git a/src/xmltv.ts b/src/xmltv.ts index c2d1ca8..161ba47 100644 --- a/src/xmltv.ts +++ b/src/xmltv.ts @@ -1,167 +1,295 @@ -import type { GridApiResponse } from "./tvlistings.js"; - -export function escapeXml(unsafe: string): string { - return unsafe - .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 += ` \n`; - xml += ` ${escapeXml(channel.callSign)}\n`; - - if (channel.affiliateName) { - xml += ` ${escapeXml( - channel.affiliateName, - )}\n`; - } - - if (channel.channelNo) { - xml += ` ${escapeXml( - channel.channelNo, - )}\n`; - } - - if (channel.thumbnail) { - xml += ` \n`; - } - - xml += " \n"; - } - return xml; -} - -export function buildProgramsXml(data: GridApiResponse): string { - let xml = ""; - - for (const channel of data.channels) { - for (const event of channel.events) { - xml += ` \n`; - - xml += ` ${escapeXml(event.program.title)}\n`; - - if (event.program.episodeTitle) { - xml += ` ${escapeXml( - event.program.episodeTitle, - )}\n`; - } - - if (event.program.shortDesc) { - xml += ` ${escapeXml(event.program.shortDesc)}\n`; - } - - if (event.rating) { - xml += ` ${escapeXml( - event.rating, - )}\n`; - } - - if (event.flag && event.flag.length > 0) { - if (event.flag.includes("New")) { - xml += ` \n`; - } - - if (event.flag.includes("Live")) { - xml += ` \n`; - } - - if (event.flag.includes("Premiere")) { - xml += ` \n`; - } - - if (event.flag.includes("Finale")) { - xml += ` \n`; - } - } - - if ( - !event.flag || - (event.flag && - event.flag.length > 0 && - !event.flag.includes("New") && - !event.flag.includes("Live")) - ) { - xml += ` \n`; - } - - if (event.tags && event.tags.length > 0) { - if (event.tags.includes("Stereo")) { - xml += ` \n"; - } - } - - return xml; -} - -export function buildXmltv(data: GridApiResponse): string { - console.log("Building XMLTV file"); - - let xml = '\n'; - xml += - '\n'; - xml += buildChannelsXml(data); - xml += buildProgramsXml(data); - xml += "\n"; - - return xml; -} +import type { GridApiResponse } from "./tvlistings.js"; +import { Command } from "commander"; + +export function escapeXml(unsafe: string): string { + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function formatDate(dateStr: string): string { + 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`; +} + +const cli = new Command(); +cli + .option("--appendAsterisk", "Append * to titles with or ") + .option("--mediaportal", "Prioritize xmltv_ns episode-num tags") + .option("--lineupId ", "Lineup ID") + .option("--timespan ", "Timespan in hours (up to 360)", "6") + .option("--pref ", "User Preferences, e.g. m,p,h") + .option("--country ", "Country code", "USA") + .option("--postalCode ", "Postal code", "30309") + .option("--userAgent ", "Custom user agent string") + .option("--timezone ", "Timezone") + .option("--outputFile ", "Output file name", "xmltv.xml"); +cli.parse(process.argv); +const options = cli.opts() as { [key: string]: any }; + +// Helper to mimic Perl dd_progid emission: (..########)(####) -> XX########.#### +function toDdProgid(rawId: string | undefined | null): string | null { + if (!rawId) return null; + const m = rawId.match(/^(.{2}\d{8})(\d{4})$/); + return m ? `${m[1]}.${m[2]}` : null; +} + +export function buildChannelsXml(data: GridApiResponse): string { + let xml = ""; + + // Sort channels by channelId for deterministic order + const sortedChannels = [...data.channels].sort((a, b) => + a.channelId.localeCompare(b.channelId, undefined, { numeric: true, sensitivity: "base" }) + ); + + for (const channel of sortedChannels) { + xml += ` \n`; + xml += ` ${escapeXml(channel.callSign)}\n`; + + if (channel.affiliateName) { + xml += ` ${escapeXml(channel.affiliateName)}\n`; + } + + if (channel.channelNo) { + xml += ` ${escapeXml(channel.channelNo)}\n`; + } + + if (channel.thumbnail) { + let src = channel.thumbnail.startsWith("http") + ? channel.thumbnail + : "https:" + channel.thumbnail; + // Strip any query string like ?w=55 + const queryIndex = src.indexOf("?"); + if (queryIndex !== -1) { + src = src.substring(0, queryIndex); + } + xml += ` \n`; + } + + xml += " \n"; + } + return xml; +} + +export function buildProgramsXml(data: GridApiResponse): string { + let xml = ""; + + const matchesPreviouslyShownPattern = (programId: string): boolean => { + return /^EP|^SH|^\d/.test(programId); + }; + + const convOAD = (originalAirDate: string): string => { + return originalAirDate.replace(/-/g, ""); + }; + + // Sort channels by channelId so blocks group by channel + const sortedChannels = [...data.channels].sort((a, b) => + a.channelId.localeCompare(b.channelId, undefined, { numeric: true, sensitivity: "base" }) + ); + + for (const channel of sortedChannels) { + // Sort events by startTime within each channel + const sortedEvents = [...channel.events].sort( + (a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime() + ); + + for (const event of sortedEvents) { + xml += ` \n`; + + const isNew = event.flag?.includes("New"); + const isLive = event.flag?.includes("Live"); + let title = event.program.title; + if (options["appendAsterisk"] && (isNew || isLive)) { + title += " *"; + } + xml += ` ${escapeXml(title)}\n`; + + if (event.program.episodeTitle) { + xml += ` ${escapeXml(event.program.episodeTitle)}\n`; + } + + if (event.program.shortDesc) { + xml += ` ${escapeXml(event.program.shortDesc)}\n`; + } + + // Date logic: releaseYear first, else current date from startTime (America/New_York) + if (event.program.releaseYear) { + xml += ` ${escapeXml(event.program.releaseYear)}\n`; + } else { + const nyFormatter = new Intl.DateTimeFormat("en-US", { + timeZone: "America/New_York", + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + const parts = nyFormatter.formatToParts(new Date(event.startTime)); + const year = parseInt(parts.find((p) => p.type === "year")?.value || "1970", 10); + const mm = parts.find((p) => p.type === "month")?.value || "01"; + const dd = parts.find((p) => p.type === "day")?.value || "01"; + xml += ` ${year}${mm}${dd}\n`; + } + + const genreSet = new Set(event.program.genres?.map((g) => g.toLowerCase()) || []); + + if (event.program.genres && event.program.genres.length > 0) { + const sortedGenres = [...event.program.genres].sort((a, b) => a.localeCompare(b)); + for (const genre of sortedGenres) { + const capitalizedGenre = genre.charAt(0).toUpperCase() + genre.slice(1); + xml += ` ${escapeXml(capitalizedGenre)}\n`; + } + } + + // Add after categories + if (event.duration) { + xml += ` ${escapeXml(event.duration)}\n`; + } + + if (event.thumbnail) { + const src = event.thumbnail.startsWith("http") + ? event.thumbnail + : "https://zap2it.tmsimg.com/assets/" + event.thumbnail + ".jpg"; + xml += ` \n`; + } + + if (event.program.seriesId && (event.program as any).tmsId) { + const encodedUrl = `https://tvlistings.gracenote.com//overview.html?programSeriesId=${event.program.seriesId}&tmsId=${(event.program as any).tmsId}`; + xml += ` ${encodedUrl}\n`; + } + + const skipXmltvNs = genreSet.has("movie") || genreSet.has("sports"); + const episodeNumTags: string[] = []; + + // ---- dd_progid (Perl behavior) — compute once, independent of season/episode presence + const ddProgid = toDdProgid(event.program.id); + if (ddProgid) { + episodeNumTags.push(` ${escapeXml(ddProgid)}\n`); + } + // ---------------------------------------------------------------------- + + if (event.program.season && event.program.episode && !skipXmltvNs) { + const onscreen = `S${event.program.season.padStart(2, "0")}E${event.program.episode.padStart(2, "0")}`; + episodeNumTags.push(` ${escapeXml(onscreen)}\n`); + episodeNumTags.push(` ${escapeXml(onscreen)}\n`); + + const seasonNum = parseInt(event.program.season, 10); + const episodeNum = parseInt(event.program.episode, 10); + if (!isNaN(seasonNum) && !isNaN(episodeNum) && seasonNum >= 1 && episodeNum >= 1) { + const xmltvNsTag = ` ${seasonNum - 1}.${episodeNum - 1}.\n`; + if (options["mediaportal"]) { + episodeNumTags.unshift(xmltvNsTag); + } else { + episodeNumTags.push(xmltvNsTag); + } + } + } else if (!event.program.season && event.program.episode && !skipXmltvNs) { + const nyFormatter = new Intl.DateTimeFormat("en-US", { + timeZone: "America/New_York", + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + const parts = nyFormatter.formatToParts(new Date(event.startTime)); + const year = parseInt(parts.find((p) => p.type === "year")?.value || "1970", 10); + const episodeIdx = parseInt(event.program.episode, 10); + if (!isNaN(episodeIdx)) { + const xmltvNsTag = ` ${year - 1}.${episodeIdx - 1}.0/1\n`; + if (options["mediaportal"]) { + episodeNumTags.unshift(xmltvNsTag); + } else { + episodeNumTags.push(xmltvNsTag); + } + } + } else if (!event.program.episode && event.program.id) { + // No season/episode — xmltv_ns based on MMDD (only if not movie/sports) + const nyFormatter = new Intl.DateTimeFormat("en-US", { + timeZone: "America/New_York", + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + const parts = nyFormatter.formatToParts(new Date(event.startTime)); + const year = parseInt(parts.find((p) => p.type === "year")?.value || "1970", 10); + const mm = parts.find((p) => p.type === "month")?.value || "01"; + const dd = parts.find((p) => p.type === "day")?.value || "01"; + + if (!skipXmltvNs) { + const mmddNum = parseInt(`${mm}${dd}`, 10); + const mmddMinusOne = (mmddNum - 1).toString().padStart(4, "0"); + const xmltvNsTag = ` ${year - 1}.${mmddMinusOne}.\n`; + if (options["mediaportal"]) { + episodeNumTags.unshift(xmltvNsTag); + } else { + episodeNumTags.push(xmltvNsTag); + } + } + } + + xml += episodeNumTags.join(""); + + if (event.program.originalAirDate || event.program.episodeAirDate) { + const airDate = new Date(event.program.episodeAirDate || event.program.originalAirDate || ""); + if (!isNaN(airDate.getTime())) { + xml += ` ${airDate + .toISOString() + .replace("T", " ") + .split(".")[0]}\n`; + } + } + + if (isNew) xml += ` \n`; + if (isLive) xml += ` \n`; + if (event.flag?.includes("Premiere")) xml += ` \n`; + if (event.flag?.includes("Finale")) xml += ` \n`; + + if (!isNew && !isLive && event.program.id && matchesPreviouslyShownPattern(event.program.id)) { + xml += ` \n`; + } + + if (event.tags && event.tags.length > 0) { + if (event.tags.includes("Stereo")) { + xml += ` \n"; + } + } + + return xml; +} + +export function buildXmltv(data: GridApiResponse): string { + console.log("Building XMLTV file"); + + let xml = '\n'; + xml += '\n'; + xml += buildChannelsXml(data); + xml += buildProgramsXml(data); + xml += "\n"; + + return xml; +}