diff --git a/README.md b/README.md index 9e79b01..2549f30 100644 --- a/README.md +++ b/README.md @@ -6,30 +6,36 @@ Automate TV guides to XMLTV format. Easy to use, up-to-date. See below for getti A version of the original Perl implementation is preserved in the [historical-perl branch](https://github.com/JCBird1012/zap2xml/tree/historical-perl) if you're interested in that. -## First Time [Installation in node.js](https://github.com/JCBird1012/zap2xml/wiki/Installation), [How to Run](https://github.com/JCBird1012/zap2xml/wiki/How-to-Run), [Scheduling](https://github.com/JCBird1012/zap2xml/wiki/Scheduling) or [using Docker](https://github.com/JCBird1012/zap2xml/wiki/Using-Docker) see [Wiki](https://github.com/JCBird1012/zap2xml/wiki) for instructions +## Getting started -### Need help? [Finding a lineup](https://github.com/JCBird1012/zap2xml/wiki/Finding-a-Lineup-ID) or for [Dish and DirecTV lineups](https://github.com/JCBird1012/zap2xml/wiki/US-Dish-Directv-Lineups). Other help? Drop a line in the [Discussions](https://github.com/JCBird1012/zap2xml/discussions) +See the [Wiki](https://github.com/JCBird1012/zap2xml/wiki) for guides on [Installation in node.js](https://github.com/JCBird1012/zap2xml/wiki/Installation), [How to Run](https://github.com/JCBird1012/zap2xml/wiki/How-to-Run), [Scheduling](https://github.com/JCBird1012/zap2xml/wiki/Scheduling), and [using Docker](https://github.com/JCBird1012/zap2xml/wiki/Using-Docker). -# Recent updates +Need help? See [Finding a lineup](https://github.com/JCBird1012/zap2xml/wiki/Finding-a-Lineup-ID) or [Dish and DirecTV lineups](https://github.com/JCBird1012/zap2xml/wiki/US-Dish-Directv-Lineups), or drop a line in the [Discussions](https://github.com/JCBird1012/zap2xml/discussions). -# (2025-08-20) +## Multi-listing YAML config + +Use `--config=path/to/listings.yml` to fetch multiple TV lineups in a single run. See the [Multi-Listing Config](https://github.com/JCBird1012/zap2xml/wiki/Multi-Listing-Config) wiki page for the full YAML format, precedence rules, and Docker setup. + +## Recent Updates + +### (2025-08-20) * Changed default Sort option to the Channel Number in Lineup if available * Added '--stationid' to sort the previous way by StationID * Added `--sortname` to sort by the Call Sign -# (2025-08-18) +### (2025-08-18) * Changed URL pull to match output from page when stopped working * Added Display Name with Channel Number and Call Sign to mirror previous Perl Script * Added `--nextpvr` option to list Channel Number Call Sign first -# (2025-08-09) +### (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) +### (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 @@ -37,26 +43,26 @@ A version of the original Perl implementation is preserved in the [historical-pe * Added `` tag. * Updated channel logo no longer has fixed width so can display in better quality -# (2025-08-06) +### (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 +### Changes from jef's [original](https://github.com/jef/zap2xml) (by [jesmannstl](https://github.com/jesmannstl/zap2xml)) * 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 `` 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 +* 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 + +### Changes in this fork from jessman's [fork](https://github.com/jesmannstl/zap2xml) -Updated Docker with these changes use APPEND_ASTERISK: TRUE for the --appendAsterisk option +* Docker image uses [Bun](https://bun.com) runtime instead of Node.js - smaller image, and probably some (likely unnoticeable with typical use) performance/memory usage improvements! +* Ability to fetch multiple lineups using a single `zap2xml` instance - no longer have to run multiple containers to fetch multiple lineups! (see [Multi-Listing Config](https://github.com/JCBird1012/zap2xml/wiki/Multi-Listing-Config)) diff --git a/bun.lock b/bun.lock index e6afd0f..90bb1ae 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,8 @@ "": { "name": "@jef/zap2xml", "dependencies": { - "commander": "^11.1.0", + "valibot": "^1.4.0", + "yaml": "^2.9.0", }, "devDependencies": { "@eslint/js": "^9.31.0", @@ -116,55 +117,55 @@ "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.3", "", { "os": "android", "cpu": "arm" }, "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.4", "", { "os": "android", "cpu": "arm" }, "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ=="], - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.3", "", { "os": "android", "cpu": "arm64" }, "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw=="], + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.4", "", { "os": "android", "cpu": "arm64" }, "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw=="], - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g=="], + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA=="], - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw=="], + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg=="], - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ=="], + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g=="], - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA=="], + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw=="], - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g=="], + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA=="], - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w=="], + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w=="], - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA=="], + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg=="], - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg=="], + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A=="], - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA=="], + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ=="], - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg=="], + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw=="], - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ=="], + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg=="], - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA=="], + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A=="], - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw=="], + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA=="], - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ=="], + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw=="], - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig=="], + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA=="], + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ=="], - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA=="], + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg=="], - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q=="], + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA=="], - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.3", "", { "os": "none", "cpu": "arm64" }, "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg=="], + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.4", "", { "os": "none", "cpu": "arm64" }, "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg=="], - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg=="], + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw=="], - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA=="], + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA=="], - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A=="], + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw=="], - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA=="], + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="], "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], @@ -172,11 +173,11 @@ "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="], + "@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], @@ -246,8 +247,6 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], - "commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="], "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], @@ -312,8 +311,6 @@ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], - "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], @@ -404,9 +401,7 @@ "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], - "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], - - "rollup": ["rollup@4.60.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.3", "@rollup/rollup-android-arm64": "4.60.3", "@rollup/rollup-darwin-arm64": "4.60.3", "@rollup/rollup-darwin-x64": "4.60.3", "@rollup/rollup-freebsd-arm64": "4.60.3", "@rollup/rollup-freebsd-x64": "4.60.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.3", "@rollup/rollup-linux-arm64-gnu": "4.60.3", "@rollup/rollup-linux-arm64-musl": "4.60.3", "@rollup/rollup-linux-loong64-gnu": "4.60.3", "@rollup/rollup-linux-loong64-musl": "4.60.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.3", "@rollup/rollup-linux-ppc64-musl": "4.60.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.3", "@rollup/rollup-linux-riscv64-musl": "4.60.3", "@rollup/rollup-linux-s390x-gnu": "4.60.3", "@rollup/rollup-linux-x64-gnu": "4.60.3", "@rollup/rollup-linux-x64-musl": "4.60.3", "@rollup/rollup-openbsd-x64": "4.60.3", "@rollup/rollup-openharmony-arm64": "4.60.3", "@rollup/rollup-win32-arm64-msvc": "4.60.3", "@rollup/rollup-win32-ia32-msvc": "4.60.3", "@rollup/rollup-win32-x64-gnu": "4.60.3", "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A=="], + "rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="], "semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], @@ -446,18 +441,18 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], - "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript-eslint": ["typescript-eslint@8.59.3", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.3", "@typescript-eslint/parser": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/utils": "8.59.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg=="], - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "valibot": ["valibot@1.4.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-iC/x7fVcSyOwlm/VSt7RlHnzNGLGvR9GnxdifUeWoCJo0q4ZZvrVkIHC6faTlkxG47I2Y4UrFquPuVHCrOnrLg=="], + "vite": ["vite@7.3.3", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA=="], "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], @@ -470,6 +465,8 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -484,6 +481,8 @@ "@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "rollup/@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], diff --git a/package.json b/package.json index 948133f..df333ac 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "vitest": "^3.2.4" }, "dependencies": { - "commander": "^11.1.0" + "valibot": "^1.4.0", + "yaml": "^2.9.0" } } diff --git a/src/config.test.ts b/src/config.test.ts index 903e32e..3d65410 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,5 +1,56 @@ -import { describe, it, expect } from "vitest"; -import { processLineupId, getHeadendId } from "./config.js"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { getConfig, getHeadendId, processLineupId } from "./config.js"; + +const originalArgv = [...process.argv]; +const trackedEnvVars = [ + "APPEND_ASTERISK", + "CONFIG_FILE", + "COUNTRY", + "LINEUP_ID", + "MEDIA_PORTAL", + "NEXTPVR", + "OUTPUT_FILE", + "POSTAL_CODE", + "PREF", + "SORTNAME", + "STATIONID", + "TIMESPAN", + "TZ", + "USER_AGENT", +]; + +const tempDirs: string[] = []; + +function clearEnv(): void { + for (const key of trackedEnvVars) { + delete process.env[key]; + } +} + +function createConfigFile(contents: string): string { + const dir = mkdtempSync(join(tmpdir(), "zap2xml-config-")); + const filePath = join(dir, "listings.yml"); + writeFileSync(filePath, contents, { encoding: "utf-8" }); + tempDirs.push(dir); + return filePath; +} + +beforeEach(() => { + process.argv = [...originalArgv]; + clearEnv(); +}); + +afterEach(() => { + process.argv = [...originalArgv]; + clearEnv(); + + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); describe("processLineupId", () => { it("returns env LINEUP_ID if set", () => { @@ -11,7 +62,12 @@ describe("processLineupId", () => { it("returns argv --lineupId if set", () => { process.argv.push("--lineupId=USA-54321"); expect(processLineupId()).toBe("USA-54321"); - process.argv = process.argv.filter((arg) => !arg.startsWith("--lineupId=")); + }); + + it("prefers argv --lineupId over env LINEUP_ID", () => { + process.env.LINEUP_ID = "USA-11111"; + process.argv.push("--lineupId=USA-54321"); + expect(processLineupId()).toBe("USA-54321"); }); it("returns default if nothing set", () => { @@ -38,3 +94,134 @@ describe("getHeadendId", () => { expect(getHeadendId("")).toBe("lineup"); }); }); + +describe("getConfig", () => { + it("merges YAML defaults, listing overrides, env, and CLI overrides", () => { + const configFile = createConfigFile(` +defaults: + timespan: 72 + country: USA + appendAsterisk: true + userAgent: Config User Agent + +listings: + - name: atlanta + lineupId: USA-GA42500-X + postalCode: "30309" + outputFile: atlanta.xml + + - name: ottawa + lineupId: CAN-0008861-X + country: CAN + postalCode: K1A0B1 + timespan: 48 + outputFile: ottawa.xml +`); + + process.env.USER_AGENT = "Env User Agent"; + process.argv.push(`--config=${configFile}`, "--timespan=24", "--sortname"); + + const config = getConfig(); + + expect(config.configFile).toBe(configFile); + expect(config.listings).toHaveLength(2); + expect(config.listings[0]).toMatchObject({ + name: "atlanta", + country: "USA", + timespan: "24", + appendAsterisk: true, + sortname: true, + userAgent: "Env User Agent", + outputFile: "atlanta.xml", + }); + expect(config.listings[1]).toMatchObject({ + name: "ottawa", + country: "CAN", + timespan: "24", + appendAsterisk: true, + sortname: true, + userAgent: "Env User Agent", + outputFile: "ottawa.xml", + }); + }); + + it("coerces YAML scalar values and applies listing overrides", () => { + const configFile = createConfigFile(` +defaults: + timespan: 72 + postalCode: 30309 + appendAsterisk: true + mediaportal: false + +listings: + - lineupId: USA-GA42500-X + outputFile: atlanta.xml + timespan: 48 + stationid: true +`); + + process.argv.push(`--config=${configFile}`); + + const config = getConfig(); + + expect(config.listings).toHaveLength(1); + expect(config.listings[0]).toMatchObject({ + lineupId: "USA-GA42500-X", + country: "USA", + postalCode: "30309", + timespan: "48", + appendAsterisk: true, + mediaportal: false, + stationid: true, + outputFile: "atlanta.xml", + }); + }); + + it("rejects listing-specific CLI overrides when using YAML", () => { + const configFile = createConfigFile(` +listings: + - lineupId: USA-GA42500-X + country: USA + postalCode: "30309" + outputFile: atlanta.xml +`); + + process.argv.push(`--config=${configFile}`, "--lineupId=USA-OVERRIDE"); + + expect(() => getConfig()).toThrow("Cannot combine --config with listing-specific overrides"); + }); + + it("rejects duplicate output files in multi-listing mode", () => { + const configFile = createConfigFile(` +defaults: + outputFile: xmltv.xml + +listings: + - lineupId: USA-GA42500-X + country: USA + postalCode: "30309" + - lineupId: CAN-0008861-X + country: CAN + postalCode: K1A0B1 +`); + + process.argv.push(`--config=${configFile}`); + + expect(() => getConfig()).toThrow("Multiple listings resolve to the same outputFile"); + }); + + it("rejects invalid YAML field values", () => { + const configFile = createConfigFile(` +listings: + - lineupId: USA-GA42500-X + country: USA + postalCode: "30309" + outputFile: atlanta.xml + appendAsterisk: maybe +`); + + process.argv.push(`--config=${configFile}`); + + expect(() => getConfig()).toThrow(/Invalid config file .*Expected \(boolean \| "" \| string\)/); + }); +}); diff --git a/src/config.ts b/src/config.ts index a5458ae..9566220 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,35 +1,232 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import * as v from "valibot"; +import { parse } from "yaml"; 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" -]; +] as const; -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}`); +const defaultBaseUrl = "https://tvlistings.gracenote.com/api/grid"; +const listingSpecificCliOptions = ["lineupId", "country", "postalCode", "outputFile"] as const; +const listingSpecificEnvVars = ["LINEUP_ID", "COUNTRY", "POSTAL_CODE", "OUTPUT_FILE"] as const; + +export interface XmltvOptions { + appendAsterisk: boolean; + mediaportal: boolean; + nextpvr: boolean; + stationid: boolean; + sortname: boolean; +} + +export interface ListingConfig extends XmltvOptions { + name?: string; + baseUrl: string; + lineupId: string; + headendId: string; + timespan: string; + country: string; + postalCode: string; + pref: string; + timezone: string; + userAgent: string; + outputFile: string; +} + +export interface RuntimeConfig { + configFile?: string; + listings: ListingConfig[]; +} + +type ListingConfigInput = Partial>; + +interface ListingsFileConfig { + defaults?: ListingConfigInput; + listings: ListingConfigInput[]; +} + +const StringLikeSchema = v.pipe( + v.union([v.string(), v.number()]), + v.transform((value) => value.toString()), +); + +const BooleanStringSchema = v.pipe( + v.string(), + v.transform((value) => value.trim().toLowerCase()), + v.check((value) => value === "true" || value === "false", "Invalid boolean value"), + v.transform((value) => value === "true"), +); + +const OptionalStringInputSchema = v.optional(StringLikeSchema); + +const OptionalBooleanInputSchema = v.pipe( + v.optional(v.union([v.boolean(), v.literal(""), BooleanStringSchema])), + v.transform((value) => (value === "" ? undefined : value)), +); + +function stringConfigField(defaultValue: string) { + return v.pipe( + v.optional(StringLikeSchema, defaultValue), + v.transform((value) => (value === "" ? defaultValue : value)), + ); +} + +function optionalStringConfigField() { + return v.pipe( + v.optional(StringLikeSchema), + v.transform((value) => (value === "" ? undefined : value)), + ); +} + +function booleanConfigField(defaultValue: boolean) { + return v.pipe( + v.optional(v.union([v.boolean(), v.literal(""), BooleanStringSchema]), defaultValue), + v.transform((value) => (value === "" ? defaultValue : value)), + ); +} + +const ListingInputSchema = v.strictObject({ + name: OptionalStringInputSchema, + baseUrl: OptionalStringInputSchema, + lineupId: OptionalStringInputSchema, + timespan: OptionalStringInputSchema, + country: OptionalStringInputSchema, + postalCode: OptionalStringInputSchema, + pref: OptionalStringInputSchema, + timezone: OptionalStringInputSchema, + userAgent: OptionalStringInputSchema, + outputFile: OptionalStringInputSchema, + appendAsterisk: OptionalBooleanInputSchema, + mediaportal: OptionalBooleanInputSchema, + nextpvr: OptionalBooleanInputSchema, + stationid: OptionalBooleanInputSchema, + sortname: OptionalBooleanInputSchema, +}); + +const ListingConfigSchema = v.pipe( + v.strictObject({ + name: optionalStringConfigField(), + baseUrl: stringConfigField(defaultBaseUrl), + lineupId: v.optional(StringLikeSchema), + timespan: stringConfigField("72"), + country: v.pipe( + stringConfigField("USA"), + v.check( + (country) => validCountries.includes(country as (typeof validCountries)[number]), + "Invalid country code", + ), + ), + postalCode: stringConfigField("-"), + pref: stringConfigField(""), + timezone: stringConfigField("America/New_York"), + userAgent: stringConfigField(UserAgent), + outputFile: stringConfigField("xmltv.xml"), + appendAsterisk: booleanConfigField(false), + mediaportal: booleanConfigField(false), + nextpvr: booleanConfigField(false), + stationid: booleanConfigField(false), + sortname: booleanConfigField(false), + }), + v.transform((input): ListingConfig => { + const lineupId = normalizeLineupId( + input.lineupId === undefined || input.lineupId === "" ? `${input.country}-lineupId-DEFAULT` : input.lineupId, + input.country, + ); + + return { + ...input, + lineupId, + headendId: getHeadendId(lineupId), + }; + }), +); + +const ConfigFileSchema = v.strictObject({ + defaults: v.optional(ListingInputSchema), + listings: v.pipe( + v.array(ListingInputSchema), + v.check((listings) => listings.length > 0, "Expected at least one listing"), + ), +}); + +function getArgIndex(name: string): number { + const prefix = `--${name}=`; + return process.argv.findIndex((arg) => arg === `--${name}` || arg.startsWith(prefix)); +} + +function hasArg(name: string): boolean { + return getArgIndex(name) !== -1; +} + +function getArgValue(name: string): string | undefined { + const index = getArgIndex(name); + + if (index === -1) { + return undefined; + } + + const arg = process.argv[index]!; + if (arg === `--${name}`) { + const nextArg = process.argv[index + 1]; + return nextArg && !nextArg.startsWith("--") ? nextArg : undefined; + } + + return arg.substring(`--${name}=`.length); +} + +function getEnvValue(name: string): string | undefined { + const value = process.env[name]; + return value === undefined || value === "" ? undefined : value; +} + +function compactDefinedValues>(input: T): Partial { + return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined)) as Partial; +} + +function toSchemaError(source: string, error: unknown): Error { + if (error instanceof v.ValiError) { + return new Error(`Invalid ${source}: ${error.issues[0]?.message ?? "schema validation failed"}`); } + return error instanceof Error ? error : new Error(String(error)); +} + +function parseListingInput(input: unknown, source: string): ListingConfigInput { + try { + return v.parse(ListingInputSchema, input, { abortEarly: true }); + } catch (error) { + throw toSchemaError(source, error); + } +} + +function buildListingConfig(input: ListingConfigInput): ListingConfig { + try { + return v.parse(ListingConfigSchema, input, { abortEarly: true }); + } catch (error) { + throw toSchemaError("listing config", error); + } +} + +function loadConfigFile(configFilePath: string): { path: string; config: ListingsFileConfig } { + const resolvedPath = resolve(configFilePath); + const fileContents = readFileSync(resolvedPath, { encoding: "utf-8" }); + const parsed = parse(fileContents); + + try { + const config = v.parse(ConfigFileSchema, parsed, { abortEarly: true }); + return { + path: resolvedPath, + config, + }; + } catch (error) { + throw toSchemaError(`config file ${resolvedPath}`, error); + } +} + +function normalizeLineupId(lineupId: string, country: string): string { if (lineupId.includes("OTA")) { return `${country}-lineupId-DEFAULT`; } @@ -37,6 +234,116 @@ export function processLineupId(): string { return lineupId; } +function getGlobalEnvOverrides(): ListingConfigInput { + return parseListingInput( + compactDefinedValues({ + timespan: getEnvValue("TIMESPAN"), + pref: getEnvValue("PREF"), + timezone: getEnvValue("TZ"), + userAgent: getEnvValue("USER_AGENT"), + appendAsterisk: getEnvValue("APPEND_ASTERISK"), + mediaportal: getEnvValue("MEDIA_PORTAL"), + nextpvr: getEnvValue("NEXTPVR"), + stationid: getEnvValue("STATIONID"), + sortname: getEnvValue("SORTNAME"), + }), + "environment overrides", + ); +} + +function getGlobalCliOverrides(): ListingConfigInput { + return parseListingInput( + compactDefinedValues({ + timespan: getArgValue("timespan"), + pref: getArgValue("pref"), + timezone: getArgValue("timezone"), + userAgent: getArgValue("userAgent"), + appendAsterisk: hasArg("appendAsterisk") ? true : undefined, + mediaportal: hasArg("mediaportal") ? true : undefined, + nextpvr: hasArg("nextpvr") ? true : undefined, + stationid: hasArg("stationid") ? true : undefined, + sortname: hasArg("sortname") ? true : undefined, + }), + "CLI overrides", + ); +} + +function getSingleListingEnvOverrides(): ListingConfigInput { + return parseListingInput( + compactDefinedValues({ + lineupId: getEnvValue("LINEUP_ID"), + country: getEnvValue("COUNTRY"), + postalCode: getEnvValue("POSTAL_CODE"), + outputFile: getEnvValue("OUTPUT_FILE"), + }), + "single listing environment overrides", + ); +} + +function getSingleListingCliOverrides(): ListingConfigInput { + return parseListingInput( + compactDefinedValues({ + lineupId: getArgValue("lineupId"), + country: getArgValue("country"), + postalCode: getArgValue("postalCode"), + outputFile: getArgValue("outputFile"), + }), + "single listing CLI overrides", + ); +} + +function assertNoListingSpecificOverrides(): void { + const conflicts: string[] = []; + + for (const option of listingSpecificCliOptions) { + if (hasArg(option)) { + conflicts.push(`--${option}`); + } + } + + for (const envVar of listingSpecificEnvVars) { + if (getEnvValue(envVar) !== undefined) { + conflicts.push(envVar); + } + } + + if (conflicts.length > 0) { + throw new Error( + `Cannot combine --config with listing-specific overrides (${conflicts.join(", ")}). Move those values into the YAML listings.`, + ); + } +} + +function assertDistinctOutputFiles(listings: ListingConfig[]): void { + if (listings.length < 2) { + return; + } + + const seen = new Map(); + + for (const listing of listings) { + const label = listing.name || listing.lineupId; + const previous = seen.get(listing.outputFile); + + if (previous) { + throw new Error( + `Multiple listings resolve to the same outputFile "${listing.outputFile}" (${previous}, ${label}). Set unique outputFile values for each listing.`, + ); + } + + seen.set(listing.outputFile, label); + } +} + +export function processLineupId(): string { + return buildListingConfig( + compactDefinedValues({ + country: getArgValue("country") ?? getEnvValue("COUNTRY"), + lineupId: getArgValue("lineupId") ?? getEnvValue("LINEUP_ID"), + }), + ).lineupId; +} + export function getHeadendId(lineupId: string): string { if (lineupId.includes("OTA")) { return "lineupId"; @@ -47,52 +354,39 @@ export function getHeadendId(lineupId: string): string { return match?.[1] || "lineup"; } -export function getConfig() { - const lineupId = processLineupId(); - const headendId = getHeadendId(lineupId); +export function getConfig(): RuntimeConfig { + const configFile = getArgValue("config") ?? getEnvValue("CONFIG_FILE"); + + if (configFile) { + assertNoListingSpecificOverrides(); + + const { path, config } = loadConfigFile(configFile); + const globalEnvOverrides = getGlobalEnvOverrides(); + const globalCliOverrides = getGlobalCliOverrides(); - const country = - process.env["COUNTRY"] || - process.argv.find((arg) => arg.startsWith("--country="))?.split("=")[1] || - "USA"; + const listings = config.listings.map((listingInput) => + buildListingConfig({ + ...(config.defaults ?? {}), + ...listingInput, + ...globalEnvOverrides, + ...globalCliOverrides, + }), + ); - if (!validCountries.includes(country)) { - throw new Error(`Invalid country code: ${country}`); + assertDistinctOutputFiles(listings); + + return { + configFile: path, + listings, + }; } - 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", - }; + const listing = buildListingConfig({ + ...getGlobalEnvOverrides(), + ...getSingleListingEnvOverrides(), + ...getGlobalCliOverrides(), + ...getSingleListingCliOverrides(), + }); + + return { listings: [listing] }; } diff --git a/src/index.ts b/src/index.ts index 225fb94..6104009 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,22 +3,30 @@ 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) +--help Show this help message +--config=FILE YAML config file for one or more listings +--lineupId=ID Lineup ID (default: COUNTRY-lineupId-DEFAULT) +--timespan=NUM Timespan in hours (up to 360 = 15 days, default: 72) +--pref=LIST User preferences, comma separated. Can be m, p, and h +--country=CON Country code (default: USA) +--postalCode=ZIP Postal code (default: -) +--userAgent=UA Custom user agent string (default: random from bundled list) +--timezone=TZ Timezone (default: America/New_York) +--outputFile=FILE Output file name for single-listing mode (default: xmltv.xml) +--appendAsterisk Append * to titles with or +--mediaportal Prioritize xmltv_ns episode-num tags +--nextpvr Move "channelNo callsign" display-name to first position +--stationid Sort channels by station ID (legacy behavior) +--sortname Sort channels alphabetically by call sign/name + +When using --config, keep lineupId, country, postalCode, and outputFile in YAML. +Global flags like timespan, userAgent, and XML output flags still override every listing. `); process.exit(0); } @@ -27,24 +35,36 @@ Options: async function main() { try { isHelp(); + const runtimeConfig = getConfig(); + + if (runtimeConfig.configFile) { + console.log(`Loaded config file: ${runtimeConfig.configFile}`); + } + + console.log(`Building XMLTV for ${runtimeConfig.listings.length} listing(s)`); + + for (const listing of runtimeConfig.listings) { + const label = listing.name || listing.lineupId; + console.log(`Processing listing: ${label}`); + console.log( + `Config: Country=${listing.country}, PostalCode=${listing.postalCode}, OutputFile=${listing.outputFile}`, + ); + + console.log("Fetching TV listings..."); + const data = await getTVListings(listing); + console.log(`Successfully fetched ${data.channels.length} channels`); + + console.log("Building XMLTV content..."); + const xml = buildXmltv(data, listing); - 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!"); + console.log(`Writing XMLTV to ${listing.outputFile}...`); + writeFileSync(listing.outputFile, xml, { encoding: "utf-8" }); + console.log(`XMLTV file created successfully for ${label}!`); + } } catch (err) { console.error("Error fetching or building XMLTV:", err); process.exit(1); } } -void main(); \ No newline at end of file +void main(); diff --git a/src/tvlistings.test.ts b/src/tvlistings.test.ts index f167317..cccd084 100644 --- a/src/tvlistings.test.ts +++ b/src/tvlistings.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ListingConfig } from "./config.js"; import type { GridApiResponse } from "./tvlistings.js"; import { getTVListings } from "./tvlistings.js"; @@ -6,6 +7,24 @@ import { getTVListings } from "./tvlistings.js"; const mockFetch = vi.fn(); global.fetch = mockFetch; +const mockConfig: ListingConfig = { + baseUrl: "https://tvlistings.gracenote.com/api/grid", + lineupId: "USA-GA42500-X", + headendId: "GA42500", + timespan: "6", + country: "USA", + postalCode: "30309", + pref: "", + timezone: "America/New_York", + userAgent: "UnitTestAgent/1.0", + outputFile: "xmltv.xml", + appendAsterisk: false, + mediaportal: false, + nextpvr: false, + stationid: false, + sortname: false, +}; + const mockGridApiResponse: GridApiResponse = { channels: [ { @@ -67,11 +86,11 @@ describe("getTVListings", () => { json: async () => mockGridApiResponse, }); - const result = await getTVListings(); + const result = await getTVListings(mockConfig); - expect(result).toEqual(mockGridApiResponse); expect(result.channels).toHaveLength(1); expect(result.channels[0].callSign).toBe("KOMODT"); + expect(result.channels[0].events[0].program.genres).toEqual(["news"]); }); it("should include a User-Agent header in the request", async () => { @@ -80,7 +99,7 @@ describe("getTVListings", () => { json: async () => mockGridApiResponse, }); - await getTVListings(); + await getTVListings(mockConfig); const callArgs = mockFetch.mock.calls[0]; const headers = callArgs[1].headers; @@ -89,27 +108,17 @@ describe("getTVListings", () => { 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", - ]; - + it("should use the configured User-Agent", async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => mockGridApiResponse, }); - await getTVListings(); + await getTVListings(mockConfig); const callArgs = mockFetch.mock.calls[0]; const userAgent = callArgs[1].headers["User-Agent"]; - expect(expectedUserAgents).toContain(userAgent); + expect(userAgent).toBe(mockConfig.userAgent); }); it("should throw an error when response is not ok (4xx status)", async () => { @@ -117,10 +126,11 @@ describe("getTVListings", () => { ok: false, status: 404, statusText: "Not Found", + text: async () => "Requested lineup was not found", }); - await expect(getTVListings()).rejects.toThrow( - "Failed to fetch: 404 Not Found", + await expect(getTVListings(mockConfig)).rejects.toThrow( + /Failed to fetch URL .*: 404 Not Found - Requested lineup was not found\.\.\./, ); }); @@ -129,10 +139,11 @@ describe("getTVListings", () => { ok: false, status: 500, statusText: "Internal Server Error", + text: async () => "Upstream service failed", }); - await expect(getTVListings()).rejects.toThrow( - "Failed to fetch: 500 Internal Server Error", + await expect(getTVListings(mockConfig)).rejects.toThrow( + /Failed to fetch URL .*: 500 Internal Server Error - Upstream service failed\.\.\./, ); }); @@ -141,10 +152,11 @@ describe("getTVListings", () => { ok: false, status: 301, statusText: "Moved Permanently", + text: async () => "Redirects are not followed here", }); - await expect(getTVListings()).rejects.toThrow( - "Failed to fetch: 301 Moved Permanently", + await expect(getTVListings(mockConfig)).rejects.toThrow( + /Failed to fetch URL .*: 301 Moved Permanently - Redirects are not followed here\.\.\./, ); }); @@ -158,7 +170,7 @@ describe("getTVListings", () => { json: async () => emptyResponse, }); - const result = await getTVListings(); + const result = await getTVListings(mockConfig); expect(result).toEqual(emptyResponse); expect(result.channels).toHaveLength(0); }); @@ -198,7 +210,7 @@ describe("getTVListings", () => { json: async () => multiChannelResponse, }); - const result = await getTVListings(); + const result = await getTVListings(mockConfig); expect(result.channels).toHaveLength(2); expect(result.channels[0].callSign).toBe("KOMODT"); expect(result.channels[1].callSign).toBe("KOMODT2"); @@ -208,7 +220,7 @@ describe("getTVListings", () => { const networkError = new Error("Network error"); mockFetch.mockRejectedValueOnce(networkError); - await expect(getTVListings()).rejects.toThrow("Network error"); + await expect(getTVListings(mockConfig)).rejects.toThrow("Network error"); }); it("should handle JSON parsing errors", async () => { @@ -219,7 +231,7 @@ describe("getTVListings", () => { }, }); - await expect(getTVListings()).rejects.toThrow("Invalid JSON"); + await expect(getTVListings(mockConfig)).rejects.toThrow("Invalid JSON"); }); it("should handle malformed JSON response", async () => { @@ -230,7 +242,7 @@ describe("getTVListings", () => { }, }); - await expect(getTVListings()).rejects.toThrow( + await expect(getTVListings(mockConfig)).rejects.toThrow( "Unexpected token < in JSON at position 0", ); }); diff --git a/src/tvlistings.ts b/src/tvlistings.ts index ba5d353..fbbc3fb 100644 --- a/src/tvlistings.ts +++ b/src/tvlistings.ts @@ -1,6 +1,4 @@ -import { getConfig } from "./config.js"; - -const config = getConfig(); +import type { ListingConfig } from "./config.js"; export interface Program { title: string; @@ -50,7 +48,7 @@ export interface GridApiResponse { channels: Channel[]; } -function buildUrl(time: number, timespan: number): string { +function buildUrl(config: ListingConfig, time: number, timespan: number): string { // Build query string in a fixed order; timezone is intentionally left blank. const orderedParams: Array<[string, string]> = [ ["lineupId", config.lineupId], @@ -75,7 +73,7 @@ function buildUrl(time: number, timespan: number): string { return `${config.baseUrl}?${query}`; } -export async function getTVListings(): Promise { +export async function getTVListings(config: ListingConfig): Promise { const totalHours = parseInt(config.timespan, 10); const chunkHours = 6; const now = Math.floor(Date.now() / 1000); @@ -87,7 +85,7 @@ export async function getTVListings(): Promise { for (let offset = 0; offset < totalHours; offset += chunkHours) { const time = now + offset * 3600; - const url = buildUrl(time, chunkHours); + const url = buildUrl(config, time, chunkHours); console.log(`Fetching chunk ${offset / chunkHours + 1}/${Math.ceil(totalHours / chunkHours)}: ${url}`); @@ -124,9 +122,9 @@ export async function getTVListings(): Promise { const isMovie = newProgram.id?.startsWith('MV'); if (currentGenres.size === 0 && !isMovie) { - if (newProgram.seriesId && newProgram.seriesId !== '0') { - currentGenres.add('series'); - } + if (newProgram.seriesId && newProgram.seriesId !== '0') { + currentGenres.add('series'); + } } newProgram.genres = Array.from(currentGenres); diff --git a/src/useragents.ts b/src/useragents.ts index fccae0e..ab72f79 100644 --- a/src/useragents.ts +++ b/src/useragents.ts @@ -9,4 +9,4 @@ const userAgents = [ ]; export const UserAgent = - userAgents[Math.floor(Math.random() * userAgents.length)]; + userAgents[Math.floor(Math.random() * userAgents.length)]!; diff --git a/src/xmltv.test.ts b/src/xmltv.test.ts index c324d9b..4b0a8f5 100644 --- a/src/xmltv.test.ts +++ b/src/xmltv.test.ts @@ -53,12 +53,41 @@ const mockData: GridApiResponse = { ], }; +const orderedChannelsData: GridApiResponse = { + channels: [ + { + callSign: "ZZZ", + affiliateName: "Zulu", + affiliateCallSign: null, + channelId: "200", + channelNo: "2", + events: [], + id: "2000", + stationGenres: [], + stationFilters: [], + thumbnail: "", + }, + { + callSign: "AAA", + affiliateName: "Alpha", + affiliateCallSign: null, + channelId: "100", + channelNo: "10", + events: [], + id: "1000", + stationGenres: [], + stationFilters: [], + thumbnail: "", + }, + ], +}; + describe("buildXmltv", () => { it("should generate valid XML structure", () => { const result = buildXmltv(mockData); expect(result).toContain(''); expect(result).toContain( - '', + '', ); expect(result).toContain(""); }); @@ -92,20 +121,25 @@ describe("buildXmltv", () => { ); }); - it("should include categories from flags and tags", () => { + it("should include flags and tag-derived output", () => { const result = buildXmltv(mockData); expect(result).toContain(""); expect(result).toContain(''); expect(result).not.toContain(" { ); expect(result).toContain("4.1"); expect(result).toContain( - '', + '', ); }); + it("should put channel number first when nextpvr is enabled", () => { + const result = buildChannelsXml(mockData, { nextpvr: true }); + + expect(result.indexOf("4.1 KOMODT")).toBeLessThan( + result.indexOf("KOMODT"), + ); + }); + + it("should sort channels by call sign when sortname is enabled", () => { + const result = buildChannelsXml(orderedChannelsData, { sortname: true }); + + expect(result.indexOf('')).toBeLessThan(result.indexOf('')); + }); + + it("should sort channels by channel ID when stationid is enabled", () => { + const result = buildChannelsXml(orderedChannelsData, { stationid: true }); + + expect(result.indexOf('')).toBeLessThan(result.indexOf('')); + }); + it("should handle channels without optional fields", () => { const minimalChannel: GridApiResponse = { channels: [ @@ -274,14 +328,23 @@ describe("buildProgramsXml", () => { ); expect(result).toContain(""); expect(result).toContain(''); expect(result).not.toContain(" 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") - .option("--nextpvr", "Move \"channelNo callsign\" display-name to first position") - .option("--stationid", "Sort channels by station ID (legacy behavior)") - .option("--sortname", "Sort channels alphabetically by call sign/name"); -cli.parse(process.argv); -const options = cli.opts() as { [key: string]: any }; -const useNextPvr = Boolean(options["nextpvr"]) || ((process.env["NEXTPVR"] || "").toLowerCase() === "true"); -const useStationId = Boolean(options["stationid"]) || ((process.env["STATIONID"] || "").toLowerCase() === "true"); -const useSortName = Boolean(options["sortname"]) || ((process.env["SORTNAME"] || "").toLowerCase() === "true"); +const defaultXmltvOptions: XmltvOptions = { + appendAsterisk: false, + mediaportal: false, + nextpvr: false, + stationid: false, + sortname: false, +}; + +function resolveXmltvOptions(optionOverrides: Partial = {}): XmltvOptions { + return { + ...defaultXmltvOptions, + ...optionOverrides, + }; +} // Helper: parse channel numbers like "2.1", "10-2", "702" into number segments function parseChannelNo(no: string | null | undefined): number[] { @@ -53,15 +47,15 @@ function parseChannelNo(no: string | null | undefined): number[] { } // Shared comparator honoring --sortname, --stationid, else numeric channelNo -function channelComparator(a: any, b: any): number { - if (useSortName) { +function channelComparator(a: Channel, b: Channel, options: XmltvOptions): number { + if (options.sortname) { const aName = (a.callSign || a.affiliateName || "").toString(); const bName = (b.callSign || b.affiliateName || "").toString(); const c = aName.localeCompare(bName, undefined, { sensitivity: "base", numeric: true }); if (c !== 0) return c; return a.channelId.localeCompare(b.channelId, undefined, { numeric: true, sensitivity: "base" }); } - if (useStationId) { + if (options.stationid) { return a.channelId.localeCompare(b.channelId, undefined, { numeric: true, sensitivity: "base" }); } const aNo = (a.channelNo || "").trim(); @@ -94,34 +88,35 @@ function toDdProgid(rawId: string | undefined | null): string | null { return m ? `${m[1]}.${m[2]}` : null; } -// TF 10/2025 Impement an internal deepStrictEqual function to compare to objects. +// TF 10/2025 Implement an internal deepStrictEqual function to compare to objects. // Helper to compare two objects. -function isIdentical(obj1: any, obj2: any): boolean { +function isIdentical(obj1: Event, obj2: Event): boolean { try { - assert.deepStrictEqual(obj1,obj2); - } catch(error) { - return false + assert.deepStrictEqual(obj1, obj2); + } catch { + return false; } -return true + return true; } -export function buildChannelsXml(data: GridApiResponse): string { +export function buildChannelsXml(data: GridApiResponse, optionOverrides: Partial = {}): string { let xml = ""; + const options = resolveXmltvOptions(optionOverrides); // Sort channels by channelId for deterministic order - const sortedChannels = [...data.channels].sort(channelComparator); + const sortedChannels = [...data.channels].sort((a, b) => channelComparator(a, b, options)); for (const channel of sortedChannels) { xml += ` \n`; // Build display-name list with optional NextPVR ordering { const displayNames: string[] = []; - if (useNextPvr && channel.channelNo) { + if (options.nextpvr && channel.channelNo) { displayNames.push(`${channel.channelNo} ${channel.callSign}`); } displayNames.push(channel.callSign); - if (!useNextPvr && channel.channelNo) { + if (!options.nextpvr && channel.channelNo) { displayNames.push(`${channel.channelNo} ${channel.callSign}`); } if (channel.affiliateName) { @@ -156,8 +151,9 @@ export function buildChannelsXml(data: GridApiResponse): string { return xml; } -export function buildProgramsXml(data: GridApiResponse): string { +export function buildProgramsXml(data: GridApiResponse, optionOverrides: Partial = {}): string { let xml = ""; + const options = resolveXmltvOptions(optionOverrides); const matchesPreviouslyShownPattern = (programId: string): boolean => { return /^EP|^SH|^\d/.test(programId); }; @@ -167,21 +163,21 @@ export function buildProgramsXml(data: GridApiResponse): string { }; // Sort channels by channelId so blocks group by channel - const sortedChannels = [...data.channels].sort(channelComparator); + const sortedChannels = [...data.channels].sort((a, b) => channelComparator(a, b, options)); 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() ); -// TF 10/2025 Impement duplicate checking and skip duplicate entries. + // TF 10/2025 Impement duplicate checking and skip duplicate entries. //for (const event of sortedEvents) { - for (let i=0; i0) { - let pevent = sortedEvents[(i-1)]!; + if (i > 0) { + const pevent = sortedEvents[(i - 1)]!; if (isIdentical(event, pevent)) { continue } @@ -194,7 +190,7 @@ export function buildProgramsXml(data: GridApiResponse): string { const isNew = event.flag?.includes("New"); const isLive = event.flag?.includes("Live"); let title = event.program.title; - if (options["appendAsterisk"] && (isNew || isLive)) { + if (options.appendAsterisk && (isNew || isLive)) { title += " *"; } xml += ` ${escapeXml(title)}\n`; @@ -246,8 +242,8 @@ export function buildProgramsXml(data: GridApiResponse): string { 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}`; + if (event.program.seriesId && event.program.tmsId) { + const encodedUrl = `https://tvlistings.gracenote.com//overview.html?programSeriesId=${event.program.seriesId}&tmsId=${event.program.tmsId}`; xml += ` ${encodedUrl}\n`; } @@ -270,7 +266,7 @@ export function buildProgramsXml(data: GridApiResponse): string { 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"]) { + if (options.mediaportal) { episodeNumTags.unshift(xmltvNsTag); } else { episodeNumTags.push(xmltvNsTag); @@ -288,7 +284,7 @@ export function buildProgramsXml(data: GridApiResponse): string { const episodeIdx = parseInt(event.program.episode, 10); if (!isNaN(episodeIdx)) { const xmltvNsTag = ` ${year - 1}.${episodeIdx - 1}.0/1\n`; - if (options["mediaportal"]) { + if (options.mediaportal) { episodeNumTags.unshift(xmltvNsTag); } else { episodeNumTags.push(xmltvNsTag); @@ -311,7 +307,7 @@ export function buildProgramsXml(data: GridApiResponse): string { const mmddNum = parseInt(`${mm}${dd}`, 10); const mmddMinusOne = (mmddNum - 1).toString().padStart(4, "0"); const xmltvNsTag = ` ${year - 1}.${mmddMinusOne}.\n`; - if (options["mediaportal"]) { + if (options.mediaportal) { episodeNumTags.unshift(xmltvNsTag); } else { episodeNumTags.push(xmltvNsTag); @@ -365,13 +361,13 @@ export function buildProgramsXml(data: GridApiResponse): string { return xml; } -export function buildXmltv(data: GridApiResponse): string { +export function buildXmltv(data: GridApiResponse, optionOverrides: Partial = {}): string { console.log("Building XMLTV file"); let xml = '\n'; - xml += '\n'; - xml += buildChannelsXml(data); - xml += buildProgramsXml(data); + xml += '\n'; + xml += buildChannelsXml(data, optionOverrides); + xml += buildProgramsXml(data, optionOverrides); xml += "\n"; return xml;