Merge branch 'master' into pr/2821

This commit is contained in:
freearhey
2025-08-10 08:00:26 +03:00
665 changed files with 60962 additions and 53945 deletions

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Enforce the usage of CRLF in GitHub Actions per ESLint configuration.
* text eol=crlf

9
.gitignore vendored
View File

@@ -5,11 +5,4 @@
/guide.xml.gz /guide.xml.gz
# macOS # macOS
.DS_Store .DS_Store
# If Yarn is used (yarn.lock)
/.yarn/*
.yarnrc
.yarnrc.yml
.idea

474
README.md
View File

@@ -1,237 +1,237 @@
# EPG [![update](https://github.com/iptv-org/epg/actions/workflows/update.yml/badge.svg)](https://github.com/iptv-org/epg/actions/workflows/update.yml) # EPG [![update](https://github.com/iptv-org/epg/actions/workflows/update.yml/badge.svg)](https://github.com/iptv-org/epg/actions/workflows/update.yml)
Tools for downloading the EPG (Electronic Program Guide) for thousands of TV channels from hundreds of sources. Tools for downloading the EPG (Electronic Program Guide) for thousands of TV channels from hundreds of sources.
## Table of contents ## Table of contents
- ✨ [Installation](#installation) - ✨ [Installation](#installation)
- 🚀 [Usage](#usage) - 🚀 [Usage](#usage)
- 💫 [Update](#update) - 💫 [Update](#update)
- 🐋 [Docker](#docker) - 🐋 [Docker](#docker)
- 📺 [Playlists](#playlists) - 📺 [Playlists](#playlists)
- 🗄 [Database](#database) - 🗄 [Database](#database)
- 👨‍💻 [API](#api) - 👨‍💻 [API](#api)
- 📚 [Resources](#resources) - 📚 [Resources](#resources)
- 💬 [Discussions](#discussions) - 💬 [Discussions](#discussions)
- 🛠 [Contribution](#contribution) - 🛠 [Contribution](#contribution)
- 📄 [License](#license) - 📄 [License](#license)
## Installation ## Installation
First, you need to install [Node.js](https://nodejs.org/en) on your computer. You will also need to install [Git](https://git-scm.com/downloads) to follow these instructions. First, you need to install [Node.js](https://nodejs.org/en) on your computer. You will also need to install [Git](https://git-scm.com/downloads) to follow these instructions.
After that open the [Console](https://en.wikipedia.org/wiki/Windows_Console) (or [Terminal](<https://en.wikipedia.org/wiki/Terminal_(macOS)>) if you have macOS) and type the following command: After that open the [Console](https://en.wikipedia.org/wiki/Windows_Console) (or [Terminal](<https://en.wikipedia.org/wiki/Terminal_(macOS)>) if you have macOS) and type the following command:
```sh ```sh
git clone --depth 1 -b master https://github.com/iptv-org/epg.git git clone --depth 1 -b master https://github.com/iptv-org/epg.git
``` ```
Then navigate to the downloaded `epg` folder: Then navigate to the downloaded `epg` folder:
```sh ```sh
cd epg cd epg
``` ```
And install all the dependencies: And install all the dependencies:
```sh ```sh
npm install npm install
``` ```
## Usage ## Usage
To start the download of the guide, select one of the supported sites from [SITES.md](SITES.md) file and paste its name into the command below: To start the download of the guide, select one of the supported sites from [SITES.md](SITES.md) file and paste its name into the command below:
```sh ```sh
npm run grab --- --site=example.com npm run grab --- --site=example.com
``` ```
Then run it and wait for the guide to finish downloading. When finished, a new `guide.xml` file will appear in the current directory. Then run it and wait for the guide to finish downloading. When finished, a new `guide.xml` file will appear in the current directory.
You can also customize the behavior of the script using this options: You can also customize the behavior of the script using this options:
```sh ```sh
Usage: npm run grab --- [options] Usage: npm run grab --- [options]
Options: Options:
-s, --site <name> Name of the site to parse -s, --site <name> Name of the site to parse
-c, --channels <path> Path to *.channels.xml file (required if the "--site" attribute is -c, --channels <path> Path to *.channels.xml file (required if the "--site" attribute is
not specified) not specified)
-o, --output <path> Path to output file (default: "guide.xml") -o, --output <path> Path to output file (default: "guide.xml")
-l, --lang <codes> Allows you to restrict downloading to channels in specified languages only (example: "en,id") -l, --lang <codes> Allows you to restrict downloading to channels in specified languages only (example: "en,id")
-t, --timeout <milliseconds> Timeout for each request in milliseconds (default: 0) -t, --timeout <milliseconds> Timeout for each request in milliseconds (default: 0)
-d, --delay <milliseconds> Delay between request in milliseconds (default: 0) -d, --delay <milliseconds> Delay between request in milliseconds (default: 0)
-x, --proxy <url> Use the specified proxy (example: "socks5://username:password@127.0.0.1:1234") -x, --proxy <url> Use the specified proxy (example: "socks5://username:password@127.0.0.1:1234")
--days <days> Number of days for which the program will be loaded (defaults to the value from the site config) --days <days> Number of days for which the program will be loaded (defaults to the value from the site config)
--maxConnections <number> Number of concurrent requests (default: 1) --maxConnections <number> Number of concurrent requests (default: 1)
--gzip Specifies whether or not to create a compressed version of the guide (default: false) --gzip Specifies whether or not to create a compressed version of the guide (default: false)
--curl Display each request as CURL (default: false) --curl Display each request as CURL (default: false)
``` ```
### Parallel downloading ### Parallel downloading
By default, the guide for each channel is downloaded one by one, but you can change this behavior by increasing the number of simultaneous requests using the `--maxConnections` attribute: By default, the guide for each channel is downloaded one by one, but you can change this behavior by increasing the number of simultaneous requests using the `--maxConnections` attribute:
```sh ```sh
npm run grab --- --site=example.com --maxConnections=10 npm run grab --- --site=example.com --maxConnections=10
``` ```
But be aware that under heavy load, some sites may start return an error or completely block your access. But be aware that under heavy load, some sites may start return an error or completely block your access.
### Use custom channel list ### Use custom channel list
Create an XML file and copy the descriptions of all the channels you need from the [/sites](sites) into it: Create an XML file and copy the descriptions of all the channels you need from the [/sites](sites) into it:
```xml ```xml
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<channels> <channels>
<channel site="arirang.com" lang="en" xmltv_id="ArirangTV.kr" site_id="CH_K">Arirang TV</channel> <channel site="arirang.com" lang="en" xmltv_id="ArirangTV.kr" site_id="CH_K">Arirang TV</channel>
... ...
</channels> </channels>
``` ```
And then specify the path to that file via the `--channels` attribute: And then specify the path to that file via the `--channels` attribute:
```sh ```sh
npm run grab --- --channels=path/to/custom.channels.xml npm run grab --- --channels=path/to/custom.channels.xml
``` ```
### Run on schedule ### Run on schedule
If you want to download guides on a schedule, you can use [cron](https://en.wikipedia.org/wiki/Cron) or any other task scheduler. Currently, we use a tool called `chronos` for this purpose. If you want to download guides on a schedule, you can use [cron](https://en.wikipedia.org/wiki/Cron) or any other task scheduler. Currently, we use a tool called `chronos` for this purpose.
To start it, you only need to specify the necessary `grab` command and [cron expression](https://crontab.guru/): To start it, you only need to specify the necessary `grab` command and [cron expression](https://crontab.guru/):
```sh ```sh
npx chronos --execute="npm run grab --- --site=example.com" --pattern="0 0,12 * * *" --log npx chronos --execute="npm run grab --- --site=example.com" --pattern="0 0,12 * * *" --log
``` ```
For more info go to [chronos](https://github.com/freearhey/chronos) documentation. For more info go to [chronos](https://github.com/freearhey/chronos) documentation.
### Access the guide by URL ### Access the guide by URL
You can make the guide available via URL by running your own server. The easiest way to do this is to run this command: You can make the guide available via URL by running your own server. The easiest way to do this is to run this command:
```sh ```sh
npx serve npx serve
``` ```
After that, the guide will be available at the link: After that, the guide will be available at the link:
``` ```
http://localhost:3000/guide.xml http://localhost:3000/guide.xml
``` ```
In addition it will be available to other devices on the same local network at the address: In addition it will be available to other devices on the same local network at the address:
``` ```
http://<your_local_ip_address>:3000/guide.xml http://<your_local_ip_address>:3000/guide.xml
``` ```
For more info go to [serve](https://github.com/vercel/serve) documentation. For more info go to [serve](https://github.com/vercel/serve) documentation.
## Update ## Update
If you have downloaded the repository code according to the instructions above, then to update it will be enough to run the command: If you have downloaded the repository code according to the instructions above, then to update it will be enough to run the command:
```sh ```sh
git pull git pull
``` ```
And then update all the dependencies: And then update all the dependencies:
```sh ```sh
npm install npm install
``` ```
## Docker ## Docker
### Build an image ### Build an image
```sh ```sh
docker build -t iptv-org/epg --no-cache . docker build -t iptv-org/epg --no-cache .
``` ```
### Create and run container ### Create and run container
```sh ```sh
docker run -p 3000:3000 -v /path/to/channels.xml:/epg/channels.xml iptv-org/epg docker run -p 3000:3000 -v /path/to/channels.xml:/epg/channels.xml iptv-org/epg
``` ```
By default, the guide will be downloaded every day at 00:00 UTC and saved to the `/epg/public/guide.xml` file inside the container. By default, the guide will be downloaded every day at 00:00 UTC and saved to the `/epg/public/guide.xml` file inside the container.
From the outside, it will be available at this link: From the outside, it will be available at this link:
``` ```
http://localhost:3000/guide.xml http://localhost:3000/guide.xml
``` ```
or or
``` ```
http://<your_local_ip_address>:3000/guide.xml http://<your_local_ip_address>:3000/guide.xml
``` ```
### Environment Variables ### Environment Variables
To fine-tune the execution, you can pass environment variables to the container as follows: To fine-tune the execution, you can pass environment variables to the container as follows:
```sh ```sh
docker run \ docker run \
-p 5000:3000 \ -p 5000:3000 \
-v /path/to/channels.xml:/epg/channels.xml \ -v /path/to/channels.xml:/epg/channels.xml \
-e CRON_SCHEDULE="0 0,12 * * *" \ -e CRON_SCHEDULE="0 0,12 * * *" \
-e MAX_CONNECTIONS=10 \ -e MAX_CONNECTIONS=10 \
-e GZIP=true \ -e GZIP=true \
-e CURL=true \ -e CURL=true \
-e PROXY="socks5://127.0.0.1:1234" \ -e PROXY="socks5://127.0.0.1:1234" \
-e DAYS=14 \ -e DAYS=14 \
-e TIMEOUT=5 \ -e TIMEOUT=5 \
-e DELAY=2 \ -e DELAY=2 \
iptv-org/epg iptv-org/epg
``` ```
| Variable | Description | | Variable | Description |
| --------------- | ------------------------------------------------------------------------------------------------------------------ | | --------------- | ------------------------------------------------------------------------------------------------------------------ |
| CRON_SCHEDULE | A [cron expression](https://crontab.guru/) describing the schedule of the guide loadings (default: "0 0 \* \* \*") | | CRON_SCHEDULE | A [cron expression](https://crontab.guru/) describing the schedule of the guide loadings (default: "0 0 \* \* \*") |
| MAX_CONNECTIONS | Limit on the number of concurrent requests (default: 1) | | MAX_CONNECTIONS | Limit on the number of concurrent requests (default: 1) |
| GZIP | Boolean value indicating whether to create a compressed version of the guide (default: false) | | GZIP | Boolean value indicating whether to create a compressed version of the guide (default: false) |
| CURL | Display each request as CURL (default: false) | | CURL | Display each request as CURL (default: false) |
| PROXY | Use the specified proxy | | PROXY | Use the specified proxy |
| DAYS | Number of days for which the guide will be loaded (defaults to the value from the site config) | | DAYS | Number of days for which the guide will be loaded (defaults to the value from the site config) |
| TIMEOUT | Timeout for each request in milliseconds (default: 0) | | TIMEOUT | Timeout for each request in milliseconds (default: 0) |
| DELAY | Delay between request in milliseconds (default: 0) | | DELAY | Delay between request in milliseconds (default: 0) |
## Database ## Database
All channel data is taken from the [iptv-org/database](https://github.com/iptv-org/database) repository. If you find any errors please open a new [issue](https://github.com/iptv-org/database/issues) there. All channel data is taken from the [iptv-org/database](https://github.com/iptv-org/database) repository. If you find any errors please open a new [issue](https://github.com/iptv-org/database/issues) there.
## API ## API
The API documentation can be found in the [iptv-org/api](https://github.com/iptv-org/api) repository. The API documentation can be found in the [iptv-org/api](https://github.com/iptv-org/api) repository.
## Resources ## Resources
Links to other useful IPTV-related resources can be found in the [iptv-org/awesome-iptv](https://github.com/iptv-org/awesome-iptv) repository. Links to other useful IPTV-related resources can be found in the [iptv-org/awesome-iptv](https://github.com/iptv-org/awesome-iptv) repository.
## Discussions ## Discussions
If you have a question or an idea, you can post it in the [Discussions](https://github.com/orgs/iptv-org/discussions) tab. If you have a question or an idea, you can post it in the [Discussions](https://github.com/orgs/iptv-org/discussions) tab.
## Contribution ## Contribution
Please make sure to read the [Contributing Guide](https://github.com/iptv-org/epg/blob/master/CONTRIBUTING.md) before sending [issue](https://github.com/iptv-org/epg/issues) or a [pull request](https://github.com/iptv-org/epg/pulls). Please make sure to read the [Contributing Guide](https://github.com/iptv-org/epg/blob/master/CONTRIBUTING.md) before sending [issue](https://github.com/iptv-org/epg/issues) or a [pull request](https://github.com/iptv-org/epg/pulls).
And thank you to everyone who has already contributed! And thank you to everyone who has already contributed!
### Backers ### Backers
<a href="https://opencollective.com/iptv-org"><img src="https://opencollective.com/iptv-org/backers.svg?width=890" /></a> <a href="https://opencollective.com/iptv-org"><img src="https://opencollective.com/iptv-org/backers.svg?width=890" /></a>
### Contributors ### Contributors
<a href="https://github.com/iptv-org/epg/graphs/contributors"><img src="https://opencollective.com/iptv-org/contributors.svg?width=890" /></a> <a href="https://github.com/iptv-org/epg/graphs/contributors"><img src="https://opencollective.com/iptv-org/contributors.svg?width=890" /></a>
## License ## License
[![CC0](http://mirrors.creativecommons.org/presskit/buttons/88x31/svg/cc-zero.svg)](LICENSE) [![CC0](http://mirrors.creativecommons.org/presskit/buttons/88x31/svg/cc-zero.svg)](LICENSE)

484
SITES.md
View File

@@ -1,242 +1,242 @@
# Sites # Sites
<table> <table>
<thead> <thead>
<tr><th align="left">Site</th><th align="left" colspan="2">Channels<br>(total / with xmltv-id)</th><th align="left">Status</th><th align="left">Notes</th></tr> <tr><th align="left">Site</th><th align="left" colspan="2">Channels<br>(total / with xmltv-id)</th><th align="left">Status</th><th align="left">Notes</th></tr>
</thead> </thead>
<tbody> <tbody>
<tr><td><a href="sites/9tv.co.il">9tv.co.il</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/9tv.co.il">9tv.co.il</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/abc.net.au">abc.net.au</a></td><td align="right">548</td><td align="right">0</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/abc.net.au">abc.net.au</a></td><td align="right">548</td><td align="right">0</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/allente.dk">allente.dk</a></td><td align="right">74</td><td align="right">43</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/allente.dk">allente.dk</a></td><td align="right">74</td><td align="right">43</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/allente.fi">allente.fi</a></td><td align="right">71</td><td align="right">24</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/allente.fi">allente.fi</a></td><td align="right">71</td><td align="right">24</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/allente.no">allente.no</a></td><td align="right">84</td><td align="right">52</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/allente.no">allente.no</a></td><td align="right">84</td><td align="right">52</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/allente.se">allente.se</a></td><td align="right">92</td><td align="right">91</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/allente.se">allente.se</a></td><td align="right">92</td><td align="right">91</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/andorradifusio.ad">andorradifusio.ad</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/andorradifusio.ad">andorradifusio.ad</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/anteltv.com.uy">anteltv.com.uy</a></td><td align="right">53</td><td align="right">47</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/anteltv.com.uy">anteltv.com.uy</a></td><td align="right">53</td><td align="right">47</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/antennaeurope.gr">antennaeurope.gr</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/antennaeurope.gr">antennaeurope.gr</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/antennapacific.gr">antennapacific.gr</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/antennapacific.gr">antennapacific.gr</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/antennasatellite.gr">antennasatellite.gr</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/antennasatellite.gr">antennasatellite.gr</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/arianaafgtv.com">arianaafgtv.com</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/arianaafgtv.com">arianaafgtv.com</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/arianatelevision.com">arianatelevision.com</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/arianatelevision.com">arianatelevision.com</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/arirang.com">arirang.com</a></td><td align="right">3</td><td align="right">3</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/arirang.com">arirang.com</a></td><td align="right">3</td><td align="right">3</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/artonline.tv">artonline.tv</a></td><td align="right">5</td><td align="right">5</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/artonline.tv">artonline.tv</a></td><td align="right">5</td><td align="right">5</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/awilime.com">awilime.com</a></td><td align="right">111</td><td align="right">0</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/awilime.com">awilime.com</a></td><td align="right">111</td><td align="right">0</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/bein.com">bein.com</a></td><td align="right">160</td><td align="right">160</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/bein.com">bein.com</a></td><td align="right">160</td><td align="right">160</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/beinsports.com">beinsports.com</a></td><td align="right">104</td><td align="right">81</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/beinsports.com">beinsports.com</a></td><td align="right">104</td><td align="right">81</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/berrymedia.co.kr">berrymedia.co.kr</a></td><td align="right">5</td><td align="right">5</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/berrymedia.co.kr">berrymedia.co.kr</a></td><td align="right">5</td><td align="right">5</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/cableplus.com.uy">cableplus.com.uy</a></td><td align="right">171</td><td align="right">47</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/cableplus.com.uy">cableplus.com.uy</a></td><td align="right">171</td><td align="right">47</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/canalplus.com">canalplus.com</a></td><td align="right">11720</td><td align="right">212</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/canalplus.com">canalplus.com</a></td><td align="right">11720</td><td align="right">212</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/cgates.lt">cgates.lt</a></td><td align="right">102</td><td align="right">61</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/cgates.lt">cgates.lt</a></td><td align="right">102</td><td align="right">61</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/chada.ma">chada.ma</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/chada.ma">chada.ma</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/chaines-tv.orange.fr">chaines-tv.orange.fr</a></td><td align="right">295</td><td align="right">146</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/chaines-tv.orange.fr">chaines-tv.orange.fr</a></td><td align="right">295</td><td align="right">146</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/clickthecity.com">clickthecity.com</a></td><td align="right">32</td><td align="right">30</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/clickthecity.com">clickthecity.com</a></td><td align="right">32</td><td align="right">30</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/content.astro.com.my">content.astro.com.my</a></td><td align="right">157</td><td align="right">112</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/content.astro.com.my">content.astro.com.my</a></td><td align="right">157</td><td align="right">112</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/cosmotetv.gr">cosmotetv.gr</a></td><td align="right">108</td><td align="right">0</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/cosmotetv.gr">cosmotetv.gr</a></td><td align="right">108</td><td align="right">0</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/ctc.ru">ctc.ru</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/ctc.ru">ctc.ru</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/cubmu.com">cubmu.com</a></td><td align="right">174</td><td align="right">122</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/cubmu.com">cubmu.com</a></td><td align="right">174</td><td align="right">122</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/cyta.com.cy">cyta.com.cy</a></td><td align="right">116</td><td align="right">0</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/cyta.com.cy">cyta.com.cy</a></td><td align="right">116</td><td align="right">0</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/dens.tv">dens.tv</a></td><td align="right">67</td><td align="right">64</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/dens.tv">dens.tv</a></td><td align="right">67</td><td align="right">64</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/derana.lk">derana.lk</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/derana.lk">derana.lk</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/digea.gr">digea.gr</a></td><td align="right">92</td><td align="right">0</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/digea.gr">digea.gr</a></td><td align="right">92</td><td align="right">0</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/digiturk.com.tr">digiturk.com.tr</a></td><td align="right">108</td><td align="right">107</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/digiturk.com.tr">digiturk.com.tr</a></td><td align="right">108</td><td align="right">107</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/directv.com">directv.com</a></td><td align="right">1043</td><td align="right">696</td><td align="center">🔴</td><td>https://github.com/iptv-org/epg/issues/2284</td></tr> <tr><td><a href="sites/directv.com">directv.com</a></td><td align="right">1043</td><td align="right">696</td><td align="center">🔴</td><td>https://github.com/iptv-org/epg/issues/2284</td></tr>
<tr><td><a href="sites/directv.com.ar">directv.com.ar</a></td><td align="right">412</td><td align="right">229</td><td align="center">🔴</td><td>https://github.com/iptv-org/epg/issues/2339</td></tr> <tr><td><a href="sites/directv.com.ar">directv.com.ar</a></td><td align="right">412</td><td align="right">229</td><td align="center">🔴</td><td>https://github.com/iptv-org/epg/issues/2339</td></tr>
<tr><td><a href="sites/directv.com.uy">directv.com.uy</a></td><td align="right">143</td><td align="right">142</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/directv.com.uy">directv.com.uy</a></td><td align="right">143</td><td align="right">142</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/dishtv.in">dishtv.in</a></td><td align="right">448</td><td align="right">89</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/dishtv.in">dishtv.in</a></td><td align="right">448</td><td align="right">89</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/dna.fi">dna.fi</a></td><td align="right">122</td><td align="right">0</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/dna.fi">dna.fi</a></td><td align="right">122</td><td align="right">0</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/dsmart.com.tr">dsmart.com.tr</a></td><td align="right">104</td><td align="right">90</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/dsmart.com.tr">dsmart.com.tr</a></td><td align="right">104</td><td align="right">90</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/dstv.com">dstv.com</a></td><td align="right">6983</td><td align="right">181</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/dstv.com">dstv.com</a></td><td align="right">6983</td><td align="right">181</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/dtv8.net">dtv8.net</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/dtv8.net">dtv8.net</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/elcinema.com">elcinema.com</a></td><td align="right">262</td><td align="right">226</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/elcinema.com">elcinema.com</a></td><td align="right">262</td><td align="right">226</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/ena.skylifetv.co.kr">ena.skylifetv.co.kr</a></td><td align="right">6</td><td align="right">6</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/ena.skylifetv.co.kr">ena.skylifetv.co.kr</a></td><td align="right">6</td><td align="right">6</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/energeek.cl">energeek.cl</a></td><td align="right">6</td><td align="right">2</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/energeek.cl">energeek.cl</a></td><td align="right">6</td><td align="right">2</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/entertainment.ie">entertainment.ie</a></td><td align="right">109</td><td align="right">95</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/entertainment.ie">entertainment.ie</a></td><td align="right">109</td><td align="right">95</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/epg.112114.xyz">epg.112114.xyz</a></td><td align="right">930</td><td align="right">1</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/epg.112114.xyz">epg.112114.xyz</a></td><td align="right">930</td><td align="right">1</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/epg.iptvx.one">epg.iptvx.one</a></td><td align="right">2862</td><td align="right">747</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/epg.iptvx.one">epg.iptvx.one</a></td><td align="right">2862</td><td align="right">747</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/epg.telemach.ba">epg.telemach.ba</a></td><td align="right">259</td><td align="right">0</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/epg.telemach.ba">epg.telemach.ba</a></td><td align="right">259</td><td align="right">0</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/epg.telemach.me">epg.telemach.me</a></td><td align="right">216</td><td align="right">0</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/epg.telemach.me">epg.telemach.me</a></td><td align="right">216</td><td align="right">0</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/epgmaster.com">epgmaster.com</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/epgmaster.com">epgmaster.com</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/epgshare01.online">epgshare01.online</a></td><td align="right">20971</td><td align="right">17</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/epgshare01.online">epgshare01.online</a></td><td align="right">20971</td><td align="right">17</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/firstmedia.com">firstmedia.com</a></td><td align="right">116</td><td align="right">101</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/firstmedia.com">firstmedia.com</a></td><td align="right">116</td><td align="right">101</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/foxsports.com.au">foxsports.com.au</a></td><td align="right">7</td><td align="right">7</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/foxsports.com.au">foxsports.com.au</a></td><td align="right">7</td><td align="right">7</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/foxtel.com.au">foxtel.com.au</a></td><td align="right">99</td><td align="right">61</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/foxtel.com.au">foxtel.com.au</a></td><td align="right">99</td><td align="right">61</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/freetv.tv">freetv.tv</a></td><td align="right">7</td><td align="right">7</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/freetv.tv">freetv.tv</a></td><td align="right">7</td><td align="right">7</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/freeview.co.uk">freeview.co.uk</a></td><td align="right">171</td><td align="right">100</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/freeview.co.uk">freeview.co.uk</a></td><td align="right">171</td><td align="right">100</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/frikanalen.no">frikanalen.no</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/frikanalen.no">frikanalen.no</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/galamtv.kz">galamtv.kz</a></td><td align="right">27</td><td align="right">22</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/galamtv.kz">galamtv.kz</a></td><td align="right">27</td><td align="right">22</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/gatotv.com">gatotv.com</a></td><td align="right">475</td><td align="right">362</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/gatotv.com">gatotv.com</a></td><td align="right">475</td><td align="right">362</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/getafteritmedia.com">getafteritmedia.com</a></td><td align="right">5</td><td align="right">5</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/getafteritmedia.com">getafteritmedia.com</a></td><td align="right">5</td><td align="right">5</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/gigatv.3bbtv.co.th">gigatv.3bbtv.co.th</a></td><td align="right">79</td><td align="right">38</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/gigatv.3bbtv.co.th">gigatv.3bbtv.co.th</a></td><td align="right">79</td><td align="right">38</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/guiadetv.com">guiadetv.com</a></td><td align="right">124</td><td align="right">0</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/guiadetv.com">guiadetv.com</a></td><td align="right">124</td><td align="right">0</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/guida.tv">guida.tv</a></td><td align="right">88</td><td align="right">88</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/guida.tv">guida.tv</a></td><td align="right">88</td><td align="right">88</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/guidatv.sky.it">guidatv.sky.it</a></td><td align="right">168</td><td align="right">153</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/guidatv.sky.it">guidatv.sky.it</a></td><td align="right">168</td><td align="right">153</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/guidetnt.com">guidetnt.com</a></td><td align="right">69</td><td align="right">69</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/guidetnt.com">guidetnt.com</a></td><td align="right">69</td><td align="right">69</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/horizon.tv">horizon.tv</a></td><td align="right">184</td><td align="right">172</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/horizon.tv">horizon.tv</a></td><td align="right">184</td><td align="right">172</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/hoy.tv">hoy.tv</a></td><td align="right">3</td><td align="right">1</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/hoy.tv">hoy.tv</a></td><td align="right">3</td><td align="right">1</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/i.mjh.nz">i.mjh.nz</a></td><td align="right">6458</td><td align="right">1489</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/i.mjh.nz">i.mjh.nz</a></td><td align="right">6458</td><td align="right">1489</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/i24news.tv">i24news.tv</a></td><td align="right">4</td><td align="right">3</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/i24news.tv">i24news.tv</a></td><td align="right">4</td><td align="right">3</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/iltalehti.fi">iltalehti.fi</a></td><td align="right">142</td><td align="right">44</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/iltalehti.fi">iltalehti.fi</a></td><td align="right">142</td><td align="right">44</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/indihometv.com">indihometv.com</a></td><td align="right">130</td><td align="right">124</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/indihometv.com">indihometv.com</a></td><td align="right">130</td><td align="right">124</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/ionplustv.com">ionplustv.com</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/ionplustv.com">ionplustv.com</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/ipko.tv">ipko.tv</a></td><td align="right">194</td><td align="right">152</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/ipko.tv">ipko.tv</a></td><td align="right">194</td><td align="right">152</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/jiotv.com">jiotv.com</a></td><td align="right">1094</td><td align="right">0</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/jiotv.com">jiotv.com</a></td><td align="right">1094</td><td align="right">0</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/kan.org.il">kan.org.il</a></td><td align="right">3</td><td align="right">3</td><td align="center">🔴</td><td>https://github.com/iptv-org/epg/issues/2273</td></tr> <tr><td><a href="sites/kan.org.il">kan.org.il</a></td><td align="right">3</td><td align="right">3</td><td align="center">🔴</td><td>https://github.com/iptv-org/epg/issues/2273</td></tr>
<tr><td><a href="sites/knr.gl">knr.gl</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/knr.gl">knr.gl</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/kvf.fo">kvf.fo</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/kvf.fo">kvf.fo</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/m.tv.sms.cz">m.tv.sms.cz</a></td><td align="right">1027</td><td align="right">450</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/m.tv.sms.cz">m.tv.sms.cz</a></td><td align="right">1027</td><td align="right">450</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/m.tving.com">m.tving.com</a></td><td align="right">30</td><td align="right">26</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/m.tving.com">m.tving.com</a></td><td align="right">30</td><td align="right">26</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/magticom.ge">magticom.ge</a></td><td align="right">240</td><td align="right">110</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/magticom.ge">magticom.ge</a></td><td align="right">240</td><td align="right">110</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/mako.co.il">mako.co.il</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/mako.co.il">mako.co.il</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/makrodigitaltelevision.com">makrodigitaltelevision.com</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/makrodigitaltelevision.com">makrodigitaltelevision.com</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/maxtvgo.mk">maxtvgo.mk</a></td><td align="right">110</td><td align="right">48</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/maxtvgo.mk">maxtvgo.mk</a></td><td align="right">110</td><td align="right">48</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/mediagenie.co.kr">mediagenie.co.kr</a></td><td align="right">5</td><td align="right">4</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/mediagenie.co.kr">mediagenie.co.kr</a></td><td align="right">5</td><td align="right">4</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/mediaklikk.hu">mediaklikk.hu</a></td><td align="right">8</td><td align="right">8</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/mediaklikk.hu">mediaklikk.hu</a></td><td align="right">8</td><td align="right">8</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/mediasetinfinity.mediaset.it">mediasetinfinity.mediaset.it</a></td><td align="right">13</td><td align="right">13</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/mediasetinfinity.mediaset.it">mediasetinfinity.mediaset.it</a></td><td align="right">13</td><td align="right">13</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/melita.com">melita.com</a></td><td align="right">127</td><td align="right">111</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/melita.com">melita.com</a></td><td align="right">127</td><td align="right">111</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/meo.pt">meo.pt</a></td><td align="right">216</td><td align="right">192</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/meo.pt">meo.pt</a></td><td align="right">216</td><td align="right">192</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/meuguia.tv">meuguia.tv</a></td><td align="right">102</td><td align="right">97</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/meuguia.tv">meuguia.tv</a></td><td align="right">102</td><td align="right">97</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/mewatch.sg">mewatch.sg</a></td><td align="right">25</td><td align="right">24</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/mewatch.sg">mewatch.sg</a></td><td align="right">25</td><td align="right">24</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/mi.tv">mi.tv</a></td><td align="right">2084</td><td align="right">620</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/mi.tv">mi.tv</a></td><td align="right">2084</td><td align="right">620</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/mncvision.id">mncvision.id</a></td><td align="right">276</td><td align="right">223</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/mncvision.id">mncvision.id</a></td><td align="right">276</td><td align="right">223</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/moji.id">moji.id</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/moji.id">moji.id</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/mojmaxtv.hrvatskitelekom.hr">mojmaxtv.hrvatskitelekom.hr</a></td><td align="right">243</td><td align="right">0</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/mojmaxtv.hrvatskitelekom.hr">mojmaxtv.hrvatskitelekom.hr</a></td><td align="right">243</td><td align="right">0</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/mon-programme-tv.be">mon-programme-tv.be</a></td><td align="right">111</td><td align="right">95</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/mon-programme-tv.be">mon-programme-tv.be</a></td><td align="right">111</td><td align="right">95</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/movistarplus.es">movistarplus.es</a></td><td align="right">178</td><td align="right">0</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/movistarplus.es">movistarplus.es</a></td><td align="right">178</td><td align="right">0</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/mtel.ba">mtel.ba</a></td><td align="right">501</td><td align="right">0</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/mtel.ba">mtel.ba</a></td><td align="right">501</td><td align="right">0</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/mts.rs">mts.rs</a></td><td align="right">457</td><td align="right">0</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/mts.rs">mts.rs</a></td><td align="right">457</td><td align="right">0</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/mujtvprogram.cz">mujtvprogram.cz</a></td><td align="right">216</td><td align="right">202</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/mujtvprogram.cz">mujtvprogram.cz</a></td><td align="right">216</td><td align="right">202</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/musor.tv">musor.tv</a></td><td align="right">181</td><td align="right">145</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/musor.tv">musor.tv</a></td><td align="right">181</td><td align="right">145</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/mysky.com.ph">mysky.com.ph</a></td><td align="right">115</td><td align="right">43</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/mysky.com.ph">mysky.com.ph</a></td><td align="right">115</td><td align="right">43</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/mytelly.co.uk">mytelly.co.uk</a></td><td align="right">488</td><td align="right">401</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/mytelly.co.uk">mytelly.co.uk</a></td><td align="right">488</td><td align="right">401</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/mytvsuper.com">mytvsuper.com</a></td><td align="right">108</td><td align="right">99</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/mytvsuper.com">mytvsuper.com</a></td><td align="right">108</td><td align="right">99</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/neo.io">neo.io</a></td><td align="right">337</td><td align="right">241</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/neo.io">neo.io</a></td><td align="right">337</td><td align="right">241</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/nhkworldpremium.com">nhkworldpremium.com</a></td><td align="right">2</td><td align="right">2</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/nhkworldpremium.com">nhkworldpremium.com</a></td><td align="right">2</td><td align="right">2</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/nhl.com">nhl.com</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/nhl.com">nhl.com</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/nostv.pt">nostv.pt</a></td><td align="right">168</td><td align="right">155</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/nostv.pt">nostv.pt</a></td><td align="right">168</td><td align="right">155</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/novacyprus.com">novacyprus.com</a></td><td align="right">29</td><td align="right">24</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/novacyprus.com">novacyprus.com</a></td><td align="right">29</td><td align="right">24</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/novasports.gr">novasports.gr</a></td><td align="right">16</td><td align="right">16</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/novasports.gr">novasports.gr</a></td><td align="right">16</td><td align="right">16</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/nowplayer.now.com">nowplayer.now.com</a></td><td align="right">288</td><td align="right">229</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/nowplayer.now.com">nowplayer.now.com</a></td><td align="right">288</td><td align="right">229</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/nuevosiglo.com.uy">nuevosiglo.com.uy</a></td><td align="right">173</td><td align="right">47</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/nuevosiglo.com.uy">nuevosiglo.com.uy</a></td><td align="right">173</td><td align="right">47</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/nzxmltv.com">nzxmltv.com</a></td><td align="right">532</td><td align="right">118</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/nzxmltv.com">nzxmltv.com</a></td><td align="right">532</td><td align="right">118</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/ontvtonight.com">ontvtonight.com</a></td><td align="right">5177</td><td align="right">532</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/ontvtonight.com">ontvtonight.com</a></td><td align="right">5177</td><td align="right">532</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/opto.sic.pt">opto.sic.pt</a></td><td align="right">4</td><td align="right">4</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/opto.sic.pt">opto.sic.pt</a></td><td align="right">4</td><td align="right">4</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/orangetv.orange.es">orangetv.orange.es</a></td><td align="right">168</td><td align="right">165</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/orangetv.orange.es">orangetv.orange.es</a></td><td align="right">168</td><td align="right">165</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/osn.com">osn.com</a></td><td align="right">118</td><td align="right">98</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/osn.com">osn.com</a></td><td align="right">118</td><td align="right">98</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/pbsguam.org">pbsguam.org</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/pbsguam.org">pbsguam.org</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/pickx.be">pickx.be</a></td><td align="right">404</td><td align="right">391</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/pickx.be">pickx.be</a></td><td align="right">404</td><td align="right">391</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/player.ee.co.uk">player.ee.co.uk</a></td><td align="right">241</td><td align="right">206</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/player.ee.co.uk">player.ee.co.uk</a></td><td align="right">241</td><td align="right">206</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/playtv.unifi.com.my">playtv.unifi.com.my</a></td><td align="right">66</td><td align="right">61</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/playtv.unifi.com.my">playtv.unifi.com.my</a></td><td align="right">66</td><td align="right">61</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/plex.tv">plex.tv</a></td><td align="right">170</td><td align="right">119</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/plex.tv">plex.tv</a></td><td align="right">170</td><td align="right">119</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/pluto.tv">pluto.tv</a></td><td align="right">3302</td><td align="right">0</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/pluto.tv">pluto.tv</a></td><td align="right">3302</td><td align="right">0</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/programacion-tv.elpais.com">programacion-tv.elpais.com</a></td><td align="right">195</td><td align="right">104</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/programacion-tv.elpais.com">programacion-tv.elpais.com</a></td><td align="right">195</td><td align="right">104</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/programacion.tcc.com.uy">programacion.tcc.com.uy</a></td><td align="right">149</td><td align="right">56</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/programacion.tcc.com.uy">programacion.tcc.com.uy</a></td><td align="right">149</td><td align="right">56</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/programetv.ro">programetv.ro</a></td><td align="right">331</td><td align="right">224</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/programetv.ro">programetv.ro</a></td><td align="right">331</td><td align="right">224</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/programme-tv.net">programme-tv.net</a></td><td align="right">295</td><td align="right">197</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/programme-tv.net">programme-tv.net</a></td><td align="right">295</td><td align="right">197</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/programme-tv.vini.pf">programme-tv.vini.pf</a></td><td align="right">58</td><td align="right">2</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/programme-tv.vini.pf">programme-tv.vini.pf</a></td><td align="right">58</td><td align="right">2</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/programme.tvb.com">programme.tvb.com</a></td><td align="right">8</td><td align="right">6</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/programme.tvb.com">programme.tvb.com</a></td><td align="right">8</td><td align="right">6</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/programtv.onet.pl">programtv.onet.pl</a></td><td align="right">590</td><td align="right">362</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/programtv.onet.pl">programtv.onet.pl</a></td><td align="right">590</td><td align="right">362</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/raiplay.it">raiplay.it</a></td><td align="right">17</td><td align="right">13</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/raiplay.it">raiplay.it</a></td><td align="right">17</td><td align="right">13</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/reportv.com.ar">reportv.com.ar</a></td><td align="right">163</td><td align="right">97</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/reportv.com.ar">reportv.com.ar</a></td><td align="right">163</td><td align="right">97</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/rikstv.no">rikstv.no</a></td><td align="right">80</td><td align="right">0</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/rikstv.no">rikstv.no</a></td><td align="right">80</td><td align="right">0</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/rotana.net">rotana.net</a></td><td align="right">32</td><td align="right">28</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/rotana.net">rotana.net</a></td><td align="right">32</td><td align="right">28</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/rtb.gov.bn">rtb.gov.bn</a></td><td align="right">3</td><td align="right">3</td><td align="center">🔴</td><td>https://github.com/iptv-org/epg/issues/2257</td></tr> <tr><td><a href="sites/rtb.gov.bn">rtb.gov.bn</a></td><td align="right">3</td><td align="right">3</td><td align="center">🔴</td><td>https://github.com/iptv-org/epg/issues/2257</td></tr>
<tr><td><a href="sites/rthk.hk">rthk.hk</a></td><td align="right">8</td><td align="right">8</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/rthk.hk">rthk.hk</a></td><td align="right">8</td><td align="right">8</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/rtmklik.rtm.gov.my">rtmklik.rtm.gov.my</a></td><td align="right">8</td><td align="right">6</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/rtmklik.rtm.gov.my">rtmklik.rtm.gov.my</a></td><td align="right">8</td><td align="right">6</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/rtp.pt">rtp.pt</a></td><td align="right">10</td><td align="right">10</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/rtp.pt">rtp.pt</a></td><td align="right">10</td><td align="right">10</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/ruv.is">ruv.is</a></td><td align="right">2</td><td align="right">2</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/ruv.is">ruv.is</a></td><td align="right">2</td><td align="right">2</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/s.mxtv.jp">s.mxtv.jp</a></td><td align="right">2</td><td align="right">2</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/s.mxtv.jp">s.mxtv.jp</a></td><td align="right">2</td><td align="right">2</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/sat.tv">sat.tv</a></td><td align="right">30308</td><td align="right">249</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/sat.tv">sat.tv</a></td><td align="right">30308</td><td align="right">249</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/shahid.mbc.net">shahid.mbc.net</a></td><td align="right">231</td><td align="right">165</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/shahid.mbc.net">shahid.mbc.net</a></td><td align="right">231</td><td align="right">165</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/siba.com.co">siba.com.co</a></td><td align="right">98</td><td align="right">96</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/siba.com.co">siba.com.co</a></td><td align="right">98</td><td align="right">96</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/singtel.com">singtel.com</a></td><td align="right">155</td><td align="right">113</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/singtel.com">singtel.com</a></td><td align="right">155</td><td align="right">113</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/sjonvarp.is">sjonvarp.is</a></td><td align="right">13</td><td align="right">13</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/sjonvarp.is">sjonvarp.is</a></td><td align="right">13</td><td align="right">13</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/sky.co.nz">sky.co.nz</a></td><td align="right">111</td><td align="right">93</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/sky.co.nz">sky.co.nz</a></td><td align="right">111</td><td align="right">93</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/sky.com">sky.com</a></td><td align="right">559</td><td align="right">458</td><td align="center">🟡</td><td>https://github.com/iptv-org/epg/issues/2763</td></tr> <tr><td><a href="sites/sky.com">sky.com</a></td><td align="right">559</td><td align="right">458</td><td align="center">🟡</td><td>https://github.com/iptv-org/epg/issues/2763</td></tr>
<tr><td><a href="sites/sky.de">sky.de</a></td><td align="right">75</td><td align="right">75</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/sky.de">sky.de</a></td><td align="right">75</td><td align="right">75</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/skylife.co.kr">skylife.co.kr</a></td><td align="right">251</td><td align="right">0</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/skylife.co.kr">skylife.co.kr</a></td><td align="right">251</td><td align="right">0</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/skyperfectv.co.jp">skyperfectv.co.jp</a></td><td align="right">137</td><td align="right">130</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/skyperfectv.co.jp">skyperfectv.co.jp</a></td><td align="right">137</td><td align="right">130</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/snrt.ma">snrt.ma</a></td><td align="right">11</td><td align="right">7</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/snrt.ma">snrt.ma</a></td><td align="right">11</td><td align="right">7</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/sporttv.pt">sporttv.pt</a></td><td align="right">9</td><td align="right">8</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/sporttv.pt">sporttv.pt</a></td><td align="right">9</td><td align="right">8</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/starhubtvplus.com">starhubtvplus.com</a></td><td align="right">232</td><td align="right">208</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/starhubtvplus.com">starhubtvplus.com</a></td><td align="right">232</td><td align="right">208</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/startimestv.com">startimestv.com</a></td><td align="right">77</td><td align="right">58</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/startimestv.com">startimestv.com</a></td><td align="right">77</td><td align="right">58</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/stod2.is">stod2.is</a></td><td align="right">12</td><td align="right">8</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/stod2.is">stod2.is</a></td><td align="right">12</td><td align="right">8</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/streamingtvguides.com">streamingtvguides.com</a></td><td align="right">3066</td><td align="right">1</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/streamingtvguides.com">streamingtvguides.com</a></td><td align="right">3066</td><td align="right">1</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/superguidatv.it">superguidatv.it</a></td><td align="right">204</td><td align="right">163</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/superguidatv.it">superguidatv.it</a></td><td align="right">204</td><td align="right">163</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/taiwanplus.com">taiwanplus.com</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/taiwanplus.com">taiwanplus.com</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tapdmv.com">tapdmv.com</a></td><td align="right">39</td><td align="right">7</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tapdmv.com">tapdmv.com</a></td><td align="right">39</td><td align="right">7</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tataplay.com">tataplay.com</a></td><td align="right">785</td><td align="right">401</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tataplay.com">tataplay.com</a></td><td align="right">785</td><td align="right">401</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/telebilbao.es">telebilbao.es</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/telebilbao.es">telebilbao.es</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/teleboy.ch">teleboy.ch</a></td><td align="right">325</td><td align="right">0</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/teleboy.ch">teleboy.ch</a></td><td align="right">325</td><td align="right">0</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/telenet.tv">telenet.tv</a></td><td align="right">260</td><td align="right">91</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/telenet.tv">telenet.tv</a></td><td align="right">260</td><td align="right">91</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/teliatv.ee">teliatv.ee</a></td><td align="right">342</td><td align="right">233</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/teliatv.ee">teliatv.ee</a></td><td align="right">342</td><td align="right">233</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/telkussa.fi">telkussa.fi</a></td><td align="right">66</td><td align="right">32</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/telkussa.fi">telkussa.fi</a></td><td align="right">66</td><td align="right">32</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/telsu.fi">telsu.fi</a></td><td align="right">17</td><td align="right">15</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/telsu.fi">telsu.fi</a></td><td align="right">17</td><td align="right">15</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/thesportplus.com">thesportplus.com</a></td><td align="right">3</td><td align="right">0</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/thesportplus.com">thesportplus.com</a></td><td align="right">3</td><td align="right">0</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tivie.id">tivie.id</a></td><td align="right">45</td><td align="right">44</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tivie.id">tivie.id</a></td><td align="right">45</td><td align="right">44</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tivu.tv">tivu.tv</a></td><td align="right">69</td><td align="right">66</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tivu.tv">tivu.tv</a></td><td align="right">69</td><td align="right">66</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/toonamiaftermath.com">toonamiaftermath.com</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/toonamiaftermath.com">toonamiaftermath.com</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/turksatkablo.com.tr">turksatkablo.com.tr</a></td><td align="right">177</td><td align="right">118</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/turksatkablo.com.tr">turksatkablo.com.tr</a></td><td align="right">177</td><td align="right">118</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tv-programme.telecablesat.fr">tv-programme.telecablesat.fr</a></td><td align="right">268</td><td align="right">250</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tv-programme.telecablesat.fr">tv-programme.telecablesat.fr</a></td><td align="right">268</td><td align="right">250</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tv-spored.siol.net">tv-spored.siol.net</a></td><td align="right">312</td><td align="right">0</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tv-spored.siol.net">tv-spored.siol.net</a></td><td align="right">312</td><td align="right">0</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tv.blue.ch">tv.blue.ch</a></td><td align="right">1030</td><td align="right">565</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tv.blue.ch">tv.blue.ch</a></td><td align="right">1030</td><td align="right">565</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tv.cctv.com">tv.cctv.com</a></td><td align="right">94</td><td align="right">88</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tv.cctv.com">tv.cctv.com</a></td><td align="right">94</td><td align="right">88</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tv.dir.bg">tv.dir.bg</a></td><td align="right">111</td><td align="right">93</td><td align="center">🔴</td><td>https://github.com/iptv-org/epg/issues/2779</td></tr> <tr><td><a href="sites/tv.dir.bg">tv.dir.bg</a></td><td align="right">111</td><td align="right">93</td><td align="center">🔴</td><td>https://github.com/iptv-org/epg/issues/2779</td></tr>
<tr><td><a href="sites/tv.lv">tv.lv</a></td><td align="right">137</td><td align="right">49</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tv.lv">tv.lv</a></td><td align="right">137</td><td align="right">49</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tv.magenta.at">tv.magenta.at</a></td><td align="right">307</td><td align="right">228</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tv.magenta.at">tv.magenta.at</a></td><td align="right">307</td><td align="right">228</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tv.mail.ru">tv.mail.ru</a></td><td align="right">664</td><td align="right">643</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tv.mail.ru">tv.mail.ru</a></td><td align="right">664</td><td align="right">643</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tv.movistar.com.pe">tv.movistar.com.pe</a></td><td align="right">282</td><td align="right">40</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tv.movistar.com.pe">tv.movistar.com.pe</a></td><td align="right">282</td><td align="right">40</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tv.nu">tv.nu</a></td><td align="right">199</td><td align="right">180</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tv.nu">tv.nu</a></td><td align="right">199</td><td align="right">180</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tv.post.lu">tv.post.lu</a></td><td align="right">332</td><td align="right">242</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tv.post.lu">tv.post.lu</a></td><td align="right">332</td><td align="right">242</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tv.sfr.fr">tv.sfr.fr</a></td><td align="right">489</td><td align="right">456</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tv.sfr.fr">tv.sfr.fr</a></td><td align="right">489</td><td align="right">456</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tv.trueid.net">tv.trueid.net</a></td><td align="right">266</td><td align="right">74</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tv.trueid.net">tv.trueid.net</a></td><td align="right">266</td><td align="right">74</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tv.yandex.ru">tv.yandex.ru</a></td><td align="right">97</td><td align="right">67</td><td align="center">🔴</td><td>https://github.com/iptv-org/epg/issues/2803</td></tr> <tr><td><a href="sites/tv.yandex.ru">tv.yandex.ru</a></td><td align="right">97</td><td align="right">67</td><td align="center">🔴</td><td>https://github.com/iptv-org/epg/issues/2803</td></tr>
<tr><td><a href="sites/tv24.co.uk">tv24.co.uk</a></td><td align="right">1072</td><td align="right">39</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tv24.co.uk">tv24.co.uk</a></td><td align="right">1072</td><td align="right">39</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tv24.se">tv24.se</a></td><td align="right">326</td><td align="right">157</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tv24.se">tv24.se</a></td><td align="right">326</td><td align="right">157</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tv2go.t-2.net">tv2go.t-2.net</a></td><td align="right">335</td><td align="right">254</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tv2go.t-2.net">tv2go.t-2.net</a></td><td align="right">335</td><td align="right">254</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tvarenasport.com">tvarenasport.com</a></td><td align="right">14</td><td align="right">12</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tvarenasport.com">tvarenasport.com</a></td><td align="right">14</td><td align="right">12</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tvarenasport.hr">tvarenasport.hr</a></td><td align="right">10</td><td align="right">10</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tvarenasport.hr">tvarenasport.hr</a></td><td align="right">10</td><td align="right">10</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tvcesoir.fr">tvcesoir.fr</a></td><td align="right">135</td><td align="right">133</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tvcesoir.fr">tvcesoir.fr</a></td><td align="right">135</td><td align="right">133</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tvcubana.icrt.cu">tvcubana.icrt.cu</a></td><td align="right">10</td><td align="right">10</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tvcubana.icrt.cu">tvcubana.icrt.cu</a></td><td align="right">10</td><td align="right">10</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tvgids.nl">tvgids.nl</a></td><td align="right">115</td><td align="right">90</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tvgids.nl">tvgids.nl</a></td><td align="right">115</td><td align="right">90</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tvguide.com">tvguide.com</a></td><td align="right">153</td><td align="right">149</td><td align="center">🟡</td><td>https://github.com/iptv-org/epg/issues/2644</td></tr> <tr><td><a href="sites/tvguide.com">tvguide.com</a></td><td align="right">153</td><td align="right">149</td><td align="center">🟡</td><td>https://github.com/iptv-org/epg/issues/2644</td></tr>
<tr><td><a href="sites/tvguide.myjcom.jp">tvguide.myjcom.jp</a></td><td align="right">145</td><td align="right">140</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tvguide.myjcom.jp">tvguide.myjcom.jp</a></td><td align="right">145</td><td align="right">140</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tvhebdo.com">tvhebdo.com</a></td><td align="right">317</td><td align="right">215</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tvhebdo.com">tvhebdo.com</a></td><td align="right">317</td><td align="right">215</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tvheute.at">tvheute.at</a></td><td align="right">53</td><td align="right">53</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tvheute.at">tvheute.at</a></td><td align="right">53</td><td align="right">53</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tvi.iol.pt">tvi.iol.pt</a></td><td align="right">6</td><td align="right">6</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tvi.iol.pt">tvi.iol.pt</a></td><td align="right">6</td><td align="right">6</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tvim.tv">tvim.tv</a></td><td align="right">25</td><td align="right">19</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tvim.tv">tvim.tv</a></td><td align="right">25</td><td align="right">19</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tvinsider.com">tvinsider.com</a></td><td align="right">374</td><td align="right">0</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tvinsider.com">tvinsider.com</a></td><td align="right">374</td><td align="right">0</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tvireland.ie">tvireland.ie</a></td><td align="right">334</td><td align="right">304</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tvireland.ie">tvireland.ie</a></td><td align="right">334</td><td align="right">304</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tvkaista.org">tvkaista.org</a></td><td align="right">149</td><td align="right">0</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tvkaista.org">tvkaista.org</a></td><td align="right">149</td><td align="right">0</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tvmi.mt">tvmi.mt</a></td><td align="right">3</td><td align="right">3</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tvmi.mt">tvmi.mt</a></td><td align="right">3</td><td align="right">3</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tvmusor.hu">tvmusor.hu</a></td><td align="right">99</td><td align="right">67</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tvmusor.hu">tvmusor.hu</a></td><td align="right">99</td><td align="right">67</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tvmustra.hu">tvmustra.hu</a></td><td align="right">188</td><td align="right">0</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tvmustra.hu">tvmustra.hu</a></td><td align="right">188</td><td align="right">0</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tvpassport.com">tvpassport.com</a></td><td align="right">19287</td><td align="right">2509</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tvpassport.com">tvpassport.com</a></td><td align="right">19287</td><td align="right">2509</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tvplus.com.tr">tvplus.com.tr</a></td><td align="right">143</td><td align="right">134</td><td align="center">🟢</td><td>https://github.com/iptv-org/epg/issues/2816</td></tr> <tr><td><a href="sites/tvplus.com.tr">tvplus.com.tr</a></td><td align="right">150</td><td align="right">144</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tvprofil.com">tvprofil.com</a></td><td align="right">5836</td><td align="right">455</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tvprofil.com">tvprofil.com</a></td><td align="right">5836</td><td align="right">455</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/tvtv.us">tvtv.us</a></td><td align="right">2299</td><td align="right">2255</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/tvtv.us">tvtv.us</a></td><td align="right">2299</td><td align="right">2255</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/v3.myafn.dodmedia.osd.mil">v3.myafn.dodmedia.osd.mil</a></td><td align="right">8</td><td align="right">8</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/v3.myafn.dodmedia.osd.mil">v3.myafn.dodmedia.osd.mil</a></td><td align="right">8</td><td align="right">8</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/vidio.com">vidio.com</a></td><td align="right">57</td><td align="right">52</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/vidio.com">vidio.com</a></td><td align="right">57</td><td align="right">52</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/virginmediatelevision.ie">virginmediatelevision.ie</a></td><td align="right">5</td><td align="right">5</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/virginmediatelevision.ie">virginmediatelevision.ie</a></td><td align="right">5</td><td align="right">5</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/virgintvgo.virginmedia.com">virgintvgo.virginmedia.com</a></td><td align="right">238</td><td align="right">195</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/virgintvgo.virginmedia.com">virgintvgo.virginmedia.com</a></td><td align="right">238</td><td align="right">195</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/visionplus.id">visionplus.id</a></td><td align="right">250</td><td align="right">226</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/visionplus.id">visionplus.id</a></td><td align="right">250</td><td align="right">226</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/vivoplay.com.br">vivoplay.com.br</a></td><td align="right">389</td><td align="right">0</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/vivoplay.com.br">vivoplay.com.br</a></td><td align="right">389</td><td align="right">0</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/vtm.be">vtm.be</a></td><td align="right">7</td><td align="right">6</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/vtm.be">vtm.be</a></td><td align="right">7</td><td align="right">6</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/walesi.com.fj">walesi.com.fj</a></td><td align="right">9</td><td align="right">8</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/walesi.com.fj">walesi.com.fj</a></td><td align="right">9</td><td align="right">8</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/watch.sportsnet.ca">watch.sportsnet.ca</a></td><td align="right">8</td><td align="right">8</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/watch.sportsnet.ca">watch.sportsnet.ca</a></td><td align="right">8</td><td align="right">8</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/watchyour.tv">watchyour.tv</a></td><td align="right">40</td><td align="right">24</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/watchyour.tv">watchyour.tv</a></td><td align="right">40</td><td align="right">24</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/wavve.com">wavve.com</a></td><td align="right">77</td><td align="right">76</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/wavve.com">wavve.com</a></td><td align="right">77</td><td align="right">76</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/web.magentatv.de">web.magentatv.de</a></td><td align="right">348</td><td align="right">247</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/web.magentatv.de">web.magentatv.de</a></td><td align="right">348</td><td align="right">247</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/webtv.delta.nl">webtv.delta.nl</a></td><td align="right">247</td><td align="right">218</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/webtv.delta.nl">webtv.delta.nl</a></td><td align="right">247</td><td align="right">218</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/winplay.co">winplay.co</a></td><td align="right">2</td><td align="right">2</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/winplay.co">winplay.co</a></td><td align="right">2</td><td align="right">2</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/worldfishingnetwork.com">worldfishingnetwork.com</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/worldfishingnetwork.com">worldfishingnetwork.com</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/www3.nhk.or.jp">www3.nhk.or.jp</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/www3.nhk.or.jp">www3.nhk.or.jp</a></td><td align="right">1</td><td align="right">1</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/xem.kplus.vn">xem.kplus.vn</a></td><td align="right">77</td><td align="right">0</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/xem.kplus.vn">xem.kplus.vn</a></td><td align="right">77</td><td align="right">0</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/xumo.tv">xumo.tv</a></td><td align="right">350</td><td align="right">33</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/xumo.tv">xumo.tv</a></td><td align="right">350</td><td align="right">33</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/yes.co.il">yes.co.il</a></td><td align="right">174</td><td align="right">0</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/yes.co.il">yes.co.il</a></td><td align="right">174</td><td align="right">0</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/zap.co.ao">zap.co.ao</a></td><td align="right">114</td><td align="right">64</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/zap.co.ao">zap.co.ao</a></td><td align="right">114</td><td align="right">64</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/zap2it.com">zap2it.com</a></td><td align="right">595</td><td align="right">0</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/zap2it.com">zap2it.com</a></td><td align="right">595</td><td align="right">0</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/ziggogo.tv">ziggogo.tv</a></td><td align="right">152</td><td align="right">130</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/ziggogo.tv">ziggogo.tv</a></td><td align="right">152</td><td align="right">130</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/znbc.co.zm">znbc.co.zm</a></td><td align="right">4</td><td align="right">4</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/znbc.co.zm">znbc.co.zm</a></td><td align="right">4</td><td align="right">4</td><td align="center">🟢</td><td></td></tr>
<tr><td><a href="sites/zuragt.mn">zuragt.mn</a></td><td align="right">36</td><td align="right">25</td><td align="center">🟢</td><td></td></tr> <tr><td><a href="sites/zuragt.mn">zuragt.mn</a></td><td align="right">36</td><td align="right">25</td><td align="center">🟢</td><td></td></tr>
</tbody> </tbody>
</table> </table>

View File

@@ -1,55 +1,57 @@
import typescriptEslint from '@typescript-eslint/eslint-plugin' import typescriptEslint from '@typescript-eslint/eslint-plugin'
import globals from 'globals' import stylistic from '@stylistic/eslint-plugin'
import tsParser from '@typescript-eslint/parser' import globals from 'globals'
import path from 'node:path' import tsParser from '@typescript-eslint/parser'
import { fileURLToPath } from 'node:url' import path from 'node:path'
import js from '@eslint/js' import { fileURLToPath } from 'node:url'
import { FlatCompat } from '@eslint/eslintrc' import js from '@eslint/js'
import { FlatCompat } from '@eslint/eslintrc'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename) const __filename = fileURLToPath(import.meta.url)
const compat = new FlatCompat({ const __dirname = path.dirname(__filename)
baseDirectory: __dirname, const compat = new FlatCompat({
recommendedConfig: js.configs.recommended, baseDirectory: __dirname,
allConfig: js.configs.all recommendedConfig: js.configs.recommended,
}) allConfig: js.configs.all
})
export default [
...compat.extends('eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'), export default [
{ ...compat.extends('eslint:recommended', 'plugin:@typescript-eslint/strict', 'plugin:@typescript-eslint/stylistic', 'prettier'),
plugins: { {
'@typescript-eslint': typescriptEslint plugins: {
}, '@typescript-eslint': typescriptEslint,
'@stylistic': stylistic
languageOptions: { },
globals: {
...globals.node, languageOptions: {
...globals.jest globals: {
}, ...globals.node,
...globals.jest
parser: tsParser, },
ecmaVersion: 'latest',
sourceType: 'module' parser: tsParser,
}, ecmaVersion: 'latest',
sourceType: 'module'
rules: { },
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-var-requires': 'off', rules: {
'no-case-declarations': 'off', '@typescript-eslint/no-require-imports': 'off',
'linebreak-style': ['error', 'windows'], '@typescript-eslint/no-var-requires': 'off',
'no-case-declarations': 'off',
quotes: [ '@stylistic/linebreak-style': ['error', 'windows'],
'error',
'single', quotes: [
{ 'error',
avoidEscape: true 'single',
} {
], avoidEscape: true
}
semi: ['error', 'never'] ],
}
}, semi: ['error', 'never']
{ }
ignores: ['tests/__data__/'] },
} {
] ignores: ['tests/__data__/']
}
]

3968
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -38,7 +38,7 @@
"dependencies": { "dependencies": {
"@alex_neo/jest-expect-message": "^1.0.5", "@alex_neo/jest-expect-message": "^1.0.5",
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.30.0", "@eslint/js": "^9.32.0",
"@freearhey/chronos": "^0.0.1", "@freearhey/chronos": "^0.0.1",
"@freearhey/core": "^0.10.2", "@freearhey/core": "^0.10.2",
"@freearhey/search-js": "^0.1.2", "@freearhey/search-js": "^0.1.2",
@@ -46,33 +46,37 @@
"@octokit/core": "^7.0.3", "@octokit/core": "^7.0.3",
"@octokit/plugin-paginate-rest": "^13.1.1", "@octokit/plugin-paginate-rest": "^13.1.1",
"@octokit/plugin-rest-endpoint-methods": "^16.0.0", "@octokit/plugin-rest-endpoint-methods": "^16.0.0",
"@swc/core": "^1.13.0", "@stylistic/eslint-plugin": "^5.2.2",
"@swc/core": "^1.13.2",
"@swc/jest": "^0.2.39", "@swc/jest": "^0.2.39",
"@types/cli-progress": "^3.11.6", "@types/cli-progress": "^3.11.6",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/inquirer": "^9.0.8", "@types/inquirer": "^9.0.8",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/langs": "^2.0.5", "@types/langs": "^2.0.5",
"@types/lodash": "^4.17.19", "@types/lodash.orderby": "^4.6.9",
"@types/node": "^24.0.14", "@types/lodash.sortby": "^4.7.9",
"@types/lodash.startcase": "^4.4.9",
"@types/lodash.uniqby": "^4.7.9",
"@types/node": "^24.1.0",
"@types/node-cleanup": "^2.1.5", "@types/node-cleanup": "^2.1.5",
"@types/numeral": "^2.0.5", "@types/numeral": "^2.0.5",
"@typescript-eslint/eslint-plugin": "^8.35.0", "@typescript-eslint/eslint-plugin": "^8.38.0",
"@typescript-eslint/parser": "^8.35.0", "@typescript-eslint/parser": "^8.38.0",
"axios": "^1.10.0", "axios": "^1.11.0",
"axios-cookiejar-support": "^6.0.4", "axios-cookiejar-support": "^6.0.4",
"chalk": "^5.4.1", "chalk": "^5.4.1",
"cheerio": "^1.1.0", "cheerio": "^1.1.2",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",
"commander": "^14.0.0", "commander": "^14.0.0",
"consola": "^3.4.2", "consola": "^3.4.2",
"cross-env": "^7.0.3", "cross-env": "^10.0.0",
"csv-parser": "^3.2.0", "csv-parser": "^3.2.0",
"cwait": "^1.1.2", "cwait": "^1.1.2",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"epg-grabber": "^0.41.0", "epg-grabber": "^0.41.0",
"epg-parser": "^0.3.1", "epg-parser": "^0.3.1",
"eslint": "^9.30.0", "eslint": "^9.32.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"form-data": "^4.0.4", "form-data": "^4.0.4",
"fs-extra": "^11.3.0", "fs-extra": "^11.3.0",
@@ -80,12 +84,15 @@
"globals": "^16.3.0", "globals": "^16.3.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"iconv-lite": "^0.6.3", "iconv-lite": "^0.6.3",
"inquirer": "^12.7.0", "inquirer": "^12.8.2",
"jest": "^30.0.3", "jest": "^30.0.5",
"jest-offline": "^1.0.1", "jest-offline": "^1.0.1",
"langs": "^2.0.0", "langs": "^2.0.0",
"libxml2-wasm": "^0.5.0", "libxml2-wasm": "^0.5.0",
"lodash": "^4.17.21", "lodash.orderby": "^4.6.0",
"lodash.sortby": "^4.7.0",
"lodash.startcase": "^4.4.0",
"lodash.uniqby": "^4.7.0",
"luxon": "^3.7.1", "luxon": "^3.7.1",
"mockdate": "^3.0.5", "mockdate": "^3.0.5",
"nedb-promises": "^6.2.3", "nedb-promises": "^6.2.3",
@@ -110,6 +117,7 @@
"tsx": "^4.20.3", "tsx": "^4.20.3",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"unzipit": "^1.4.3", "unzipit": "^1.4.3",
"uuid": "^11.1.0",
"wildcard-match": "^5.1.4" "wildcard-match": "^5.1.4"
} }
} }

View File

@@ -1,41 +1,41 @@
import { Logger, Collection, Storage } from '@freearhey/core' import { Logger, Collection, Storage } from '@freearhey/core'
import { SITES_DIR, API_DIR } from '../../constants' import { SITES_DIR, API_DIR } from '../../constants'
import { GuideChannel } from '../../models' import { GuideChannel } from '../../models'
import { ChannelsParser } from '../../core' import { ChannelsParser } from '../../core'
import epgGrabber from 'epg-grabber' import epgGrabber from 'epg-grabber'
import path from 'path' import path from 'path'
async function main() { async function main() {
const logger = new Logger() const logger = new Logger()
logger.start('staring...') logger.start('staring...')
logger.info('loading channels...') logger.info('loading channels...')
const sitesStorage = new Storage(SITES_DIR) const sitesStorage = new Storage(SITES_DIR)
const parser = new ChannelsParser({ const parser = new ChannelsParser({
storage: sitesStorage storage: sitesStorage
}) })
const files: string[] = await sitesStorage.list('**/*.channels.xml') const files: string[] = await sitesStorage.list('**/*.channels.xml')
const channels = new Collection() const channels = new Collection()
for (const filepath of files) { for (const filepath of files) {
const channelList = await parser.parse(filepath) const channelList = await parser.parse(filepath)
channelList.channels.forEach((data: epgGrabber.Channel) => { channelList.channels.forEach((data: epgGrabber.Channel) => {
channels.add(new GuideChannel(data)) channels.add(new GuideChannel(data))
}) })
} }
logger.info(`found ${channels.count()} channel(s)`) logger.info(`found ${channels.count()} channel(s)`)
const output = channels.map((channel: GuideChannel) => channel.toJSON()) const output = channels.map((channel: GuideChannel) => channel.toJSON())
const apiStorage = new Storage(API_DIR) const apiStorage = new Storage(API_DIR)
const outputFilename = 'guides.json' const outputFilename = 'guides.json'
await apiStorage.save('guides.json', output.toJSON()) await apiStorage.save('guides.json', output.toJSON())
logger.info(`saved to "${path.join(API_DIR, outputFilename)}"`) logger.info(`saved to "${path.join(API_DIR, outputFilename)}"`)
} }
main() main()

View File

@@ -1,25 +1,25 @@
import { DATA_DIR } from '../../constants' import { DATA_DIR } from '../../constants'
import { Storage } from '@freearhey/core' import { Storage } from '@freearhey/core'
import { DataLoader } from '../../core' import { DataLoader } from '../../core'
async function main() { async function main() {
const storage = new Storage(DATA_DIR) const storage = new Storage(DATA_DIR)
const loader = new DataLoader({ storage }) const loader = new DataLoader({ storage })
await Promise.all([ await Promise.all([
loader.download('blocklist.json'), loader.download('blocklist.json'),
loader.download('categories.json'), loader.download('categories.json'),
loader.download('channels.json'), loader.download('channels.json'),
loader.download('countries.json'), loader.download('countries.json'),
loader.download('languages.json'), loader.download('languages.json'),
loader.download('regions.json'), loader.download('regions.json'),
loader.download('subdivisions.json'), loader.download('subdivisions.json'),
loader.download('feeds.json'), loader.download('feeds.json'),
loader.download('timezones.json'), loader.download('timezones.json'),
loader.download('guides.json'), loader.download('guides.json'),
loader.download('streams.json'), loader.download('streams.json'),
loader.download('logos.json') loader.download('logos.json')
]) ])
} }
main() main()

View File

@@ -1,216 +1,216 @@
import { Storage, Collection, Logger, Dictionary } from '@freearhey/core' import { Storage, Collection, Logger, Dictionary } from '@freearhey/core'
import type { DataProcessorData } from '../../types/dataProcessor' import type { DataProcessorData } from '../../types/dataProcessor'
import type { DataLoaderData } from '../../types/dataLoader' import type { DataLoaderData } from '../../types/dataLoader'
import { ChannelSearchableData } from '../../types/channel' import { ChannelSearchableData } from '../../types/channel'
import { Channel, ChannelList, Feed } from '../../models' import { Channel, ChannelList, Feed } from '../../models'
import { DataProcessor, DataLoader } from '../../core' import { DataProcessor, DataLoader } from '../../core'
import { select, input } from '@inquirer/prompts' import { select, input } from '@inquirer/prompts'
import { ChannelsParser } from '../../core' import { ChannelsParser } from '../../core'
import { DATA_DIR } from '../../constants' import { DATA_DIR } from '../../constants'
import nodeCleanup from 'node-cleanup' import nodeCleanup from 'node-cleanup'
import sjs from '@freearhey/search-js' import sjs from '@freearhey/search-js'
import epgGrabber from 'epg-grabber' import epgGrabber from 'epg-grabber'
import { Command } from 'commander' import { Command } from 'commander'
import readline from 'readline' import readline from 'readline'
type ChoiceValue = { type: string; value?: Feed | Channel } interface ChoiceValue { type: string; value?: Feed | Channel }
type Choice = { name: string; short?: string; value: ChoiceValue; default?: boolean } interface Choice { name: string; short?: string; value: ChoiceValue; default?: boolean }
if (process.platform === 'win32') { if (process.platform === 'win32') {
readline readline
.createInterface({ .createInterface({
input: process.stdin, input: process.stdin,
output: process.stdout output: process.stdout
}) })
.on('SIGINT', function () { .on('SIGINT', function () {
process.emit('SIGINT') process.emit('SIGINT')
}) })
} }
const program = new Command() const program = new Command()
program.argument('<filepath>', 'Path to *.channels.xml file to edit').parse(process.argv) program.argument('<filepath>', 'Path to *.channels.xml file to edit').parse(process.argv)
const filepath = program.args[0] const filepath = program.args[0]
const logger = new Logger() const logger = new Logger()
const storage = new Storage() const storage = new Storage()
let channelList = new ChannelList({ channels: [] }) let channelList = new ChannelList({ channels: [] })
main(filepath) main(filepath)
nodeCleanup(() => { nodeCleanup(() => {
save(filepath, channelList) save(filepath, channelList)
}) })
export default async function main(filepath: string) { export default async function main(filepath: string) {
if (!(await storage.exists(filepath))) { if (!(await storage.exists(filepath))) {
throw new Error(`File "${filepath}" does not exists`) throw new Error(`File "${filepath}" does not exists`)
} }
logger.info('loading data from api...') logger.info('loading data from api...')
const processor = new DataProcessor() const processor = new DataProcessor()
const dataStorage = new Storage(DATA_DIR) const dataStorage = new Storage(DATA_DIR)
const loader = new DataLoader({ storage: dataStorage }) const loader = new DataLoader({ storage: dataStorage })
const data: DataLoaderData = await loader.load() const data: DataLoaderData = await loader.load()
const { channels, channelsKeyById, feedsGroupedByChannelId }: DataProcessorData = const { channels, channelsKeyById, feedsGroupedByChannelId }: DataProcessorData =
processor.process(data) processor.process(data)
logger.info('loading channels...') logger.info('loading channels...')
const parser = new ChannelsParser({ storage }) const parser = new ChannelsParser({ storage })
channelList = await parser.parse(filepath) channelList = await parser.parse(filepath)
const parsedChannelsWithoutId = channelList.channels.filter( const parsedChannelsWithoutId = channelList.channels.filter(
(channel: epgGrabber.Channel) => !channel.xmltv_id (channel: epgGrabber.Channel) => !channel.xmltv_id
) )
logger.info( logger.info(
`found ${channelList.channels.count()} channels (including ${parsedChannelsWithoutId.count()} without ID)` `found ${channelList.channels.count()} channels (including ${parsedChannelsWithoutId.count()} without ID)`
) )
logger.info('creating search index...') logger.info('creating search index...')
const items = channels.map((channel: Channel) => channel.getSearchable()).all() const items = channels.map((channel: Channel) => channel.getSearchable()).all()
const searchIndex = sjs.createIndex(items, { const searchIndex = sjs.createIndex(items, {
searchable: ['name', 'altNames', 'guideNames', 'streamNames', 'feedFullNames'] searchable: ['name', 'altNames', 'guideNames', 'streamNames', 'feedFullNames']
}) })
logger.info('starting...\n') logger.info('starting...\n')
for (const channel of parsedChannelsWithoutId.all()) { for (const channel of parsedChannelsWithoutId.all()) {
try { try {
channel.xmltv_id = await selectChannel( channel.xmltv_id = await selectChannel(
channel, channel,
searchIndex, searchIndex,
feedsGroupedByChannelId, feedsGroupedByChannelId,
channelsKeyById channelsKeyById
) )
} catch (err) { } catch (err) {
logger.info(err.message) logger.info(err.message)
break break
} }
} }
parsedChannelsWithoutId.forEach((channel: epgGrabber.Channel) => { parsedChannelsWithoutId.forEach((channel: epgGrabber.Channel) => {
if (channel.xmltv_id === '-') { if (channel.xmltv_id === '-') {
channel.xmltv_id = '' channel.xmltv_id = ''
} }
}) })
} }
async function selectChannel( async function selectChannel(
channel: epgGrabber.Channel, channel: epgGrabber.Channel,
searchIndex, searchIndex,
feedsGroupedByChannelId: Dictionary, feedsGroupedByChannelId: Dictionary,
channelsKeyById: Dictionary channelsKeyById: Dictionary
): Promise<string> { ): Promise<string> {
const query = escapeRegex(channel.name) const query = escapeRegex(channel.name)
const similarChannels = searchIndex const similarChannels = searchIndex
.search(query) .search(query)
.map((item: ChannelSearchableData) => channelsKeyById.get(item.id)) .map((item: ChannelSearchableData) => channelsKeyById.get(item.id))
const selected: ChoiceValue = await select({ const selected: ChoiceValue = await select({
message: `Select channel ID for "${channel.name}" (${channel.site_id}):`, message: `Select channel ID for "${channel.name}" (${channel.site_id}):`,
choices: getChannelChoises(new Collection(similarChannels)), choices: getChannelChoises(new Collection(similarChannels)),
pageSize: 10 pageSize: 10
}) })
switch (selected.type) { switch (selected.type) {
case 'skip': case 'skip':
return '-' return '-'
case 'type': { case 'type': {
const typedChannelId = await input({ message: ' Channel ID:' }) const typedChannelId = await input({ message: ' Channel ID:' })
if (!typedChannelId) return '' if (!typedChannelId) return ''
const selectedFeedId = await selectFeed(typedChannelId, feedsGroupedByChannelId) const selectedFeedId = await selectFeed(typedChannelId, feedsGroupedByChannelId)
if (selectedFeedId === '-') return typedChannelId if (selectedFeedId === '-') return typedChannelId
return [typedChannelId, selectedFeedId].join('@') return [typedChannelId, selectedFeedId].join('@')
} }
case 'channel': { case 'channel': {
const selectedChannel = selected.value const selectedChannel = selected.value
if (!selectedChannel) return '' if (!selectedChannel) return ''
const selectedFeedId = await selectFeed(selectedChannel.id || '', feedsGroupedByChannelId) const selectedFeedId = await selectFeed(selectedChannel.id || '', feedsGroupedByChannelId)
if (selectedFeedId === '-') return selectedChannel.id || '' if (selectedFeedId === '-') return selectedChannel.id || ''
return [selectedChannel.id, selectedFeedId].join('@') return [selectedChannel.id, selectedFeedId].join('@')
} }
} }
return '' return ''
} }
async function selectFeed(channelId: string, feedsGroupedByChannelId: Dictionary): Promise<string> { async function selectFeed(channelId: string, feedsGroupedByChannelId: Dictionary): Promise<string> {
const channelFeeds = feedsGroupedByChannelId.has(channelId) const channelFeeds = feedsGroupedByChannelId.has(channelId)
? new Collection(feedsGroupedByChannelId.get(channelId)) ? new Collection(feedsGroupedByChannelId.get(channelId))
: new Collection() : new Collection()
const choices = getFeedChoises(channelFeeds) const choices = getFeedChoises(channelFeeds)
const selected: ChoiceValue = await select({ const selected: ChoiceValue = await select({
message: `Select feed ID for "${channelId}":`, message: `Select feed ID for "${channelId}":`,
choices, choices,
pageSize: 10 pageSize: 10
}) })
switch (selected.type) { switch (selected.type) {
case 'skip': case 'skip':
return '-' return '-'
case 'type': case 'type':
return await input({ message: ' Feed ID:', default: 'SD' }) return await input({ message: ' Feed ID:', default: 'SD' })
case 'feed': case 'feed':
const selectedFeed = selected.value const selectedFeed = selected.value
if (!selectedFeed) return '' if (!selectedFeed) return ''
return selectedFeed.id || '' return selectedFeed.id || ''
} }
return '' return ''
} }
function getChannelChoises(channels: Collection): Choice[] { function getChannelChoises(channels: Collection): Choice[] {
const choises: Choice[] = [] const choises: Choice[] = []
channels.forEach((channel: Channel) => { channels.forEach((channel: Channel) => {
const names = new Collection([channel.name, ...channel.getAltNames().all()]).uniq().join(', ') const names = new Collection([channel.name, ...channel.getAltNames().all()]).uniq().join(', ')
choises.push({ choises.push({
value: { value: {
type: 'channel', type: 'channel',
value: channel value: channel
}, },
name: `${channel.id} (${names})`, name: `${channel.id} (${names})`,
short: `${channel.id}` short: `${channel.id}`
}) })
}) })
choises.push({ name: 'Type...', value: { type: 'type' } }) choises.push({ name: 'Type...', value: { type: 'type' } })
choises.push({ name: 'Skip', value: { type: 'skip' } }) choises.push({ name: 'Skip', value: { type: 'skip' } })
return choises return choises
} }
function getFeedChoises(feeds: Collection): Choice[] { function getFeedChoises(feeds: Collection): Choice[] {
const choises: Choice[] = [] const choises: Choice[] = []
feeds.forEach((feed: Feed) => { feeds.forEach((feed: Feed) => {
let name = `${feed.id} (${feed.name})` let name = `${feed.id} (${feed.name})`
if (feed.isMain) name += ' [main]' if (feed.isMain) name += ' [main]'
choises.push({ choises.push({
value: { value: {
type: 'feed', type: 'feed',
value: feed value: feed
}, },
default: feed.isMain, default: feed.isMain,
name, name,
short: feed.id short: feed.id
}) })
}) })
choises.push({ name: 'Type...', value: { type: 'type' } }) choises.push({ name: 'Type...', value: { type: 'type' } })
choises.push({ name: 'Skip', value: { type: 'skip' } }) choises.push({ name: 'Skip', value: { type: 'skip' } })
return choises return choises
} }
function save(filepath: string, channelList: ChannelList) { function save(filepath: string, channelList: ChannelList) {
if (!storage.existsSync(filepath)) return if (!storage.existsSync(filepath)) return
storage.saveSync(filepath, channelList.toString()) storage.saveSync(filepath, channelList.toString())
logger.info(`\nFile '${filepath}' successfully saved`) logger.info(`\nFile '${filepath}' successfully saved`)
} }
function escapeRegex(string: string) { function escapeRegex(string: string) {
return string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&') return string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&')
} }

View File

@@ -1,109 +1,109 @@
import chalk from 'chalk' import chalk from 'chalk'
import { program } from 'commander' import { program } from 'commander'
import { Storage, File } from '@freearhey/core' import { Storage, File } from '@freearhey/core'
import { XmlDocument, XsdValidator, XmlValidateError, ErrorDetail } from 'libxml2-wasm' import { XmlDocument, XsdValidator, XmlValidateError, ErrorDetail } from 'libxml2-wasm'
const xsd = `<?xml version="1.0" encoding="UTF-8"?> const xsd = `<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified"> <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified">
<xs:element name="channels"> <xs:element name="channels">
<xs:complexType> <xs:complexType>
<xs:sequence> <xs:sequence>
<xs:element minOccurs="0" maxOccurs="unbounded" ref="channel"/> <xs:element minOccurs="0" maxOccurs="unbounded" ref="channel"/>
</xs:sequence> </xs:sequence>
</xs:complexType> </xs:complexType>
</xs:element> </xs:element>
<xs:element name="channel"> <xs:element name="channel">
<xs:complexType mixed="true"> <xs:complexType mixed="true">
<xs:attribute use="required" ref="site"/> <xs:attribute use="required" ref="site"/>
<xs:attribute use="required" ref="lang"/> <xs:attribute use="required" ref="lang"/>
<xs:attribute use="required" ref="site_id"/> <xs:attribute use="required" ref="site_id"/>
<xs:attribute name="xmltv_id" use="required" type="xs:string"/> <xs:attribute name="xmltv_id" use="required" type="xs:string"/>
<xs:attribute name="logo" type="xs:string"/> <xs:attribute name="logo" type="xs:string"/>
<xs:attribute name="lcn" type="xs:string"/> <xs:attribute name="lcn" type="xs:string"/>
</xs:complexType> </xs:complexType>
</xs:element> </xs:element>
<xs:attribute name="site"> <xs:attribute name="site">
<xs:simpleType> <xs:simpleType>
<xs:restriction base="xs:string"> <xs:restriction base="xs:string">
<xs:minLength value="1"/> <xs:minLength value="1"/>
</xs:restriction> </xs:restriction>
</xs:simpleType> </xs:simpleType>
</xs:attribute> </xs:attribute>
<xs:attribute name="site_id"> <xs:attribute name="site_id">
<xs:simpleType> <xs:simpleType>
<xs:restriction base="xs:string"> <xs:restriction base="xs:string">
<xs:minLength value="1"/> <xs:minLength value="1"/>
</xs:restriction> </xs:restriction>
</xs:simpleType> </xs:simpleType>
</xs:attribute> </xs:attribute>
<xs:attribute name="lang"> <xs:attribute name="lang">
<xs:simpleType> <xs:simpleType>
<xs:restriction base="xs:string"> <xs:restriction base="xs:string">
<xs:minLength value="1"/> <xs:minLength value="1"/>
</xs:restriction> </xs:restriction>
</xs:simpleType> </xs:simpleType>
</xs:attribute> </xs:attribute>
</xs:schema>` </xs:schema>`
program.argument('[filepath...]', 'Path to *.channels.xml files to check').parse(process.argv) program.argument('[filepath...]', 'Path to *.channels.xml files to check').parse(process.argv)
async function main() { async function main() {
const storage = new Storage() const storage = new Storage()
let errors: ErrorDetail[] = [] let errors: ErrorDetail[] = []
const files = program.args.length ? program.args : await storage.list('sites/**/*.channels.xml') const files = program.args.length ? program.args : await storage.list('sites/**/*.channels.xml')
for (const filepath of files) { for (const filepath of files) {
const file = new File(filepath) const file = new File(filepath)
if (file.extension() !== 'xml') continue if (file.extension() !== 'xml') continue
const xml = await storage.load(filepath) const xml = await storage.load(filepath)
let localErrors: ErrorDetail[] = [] let localErrors: ErrorDetail[] = []
try { try {
const schema = XmlDocument.fromString(xsd) const schema = XmlDocument.fromString(xsd)
const validator = XsdValidator.fromDoc(schema) const validator = XsdValidator.fromDoc(schema)
const doc = XmlDocument.fromString(xml) const doc = XmlDocument.fromString(xml)
validator.validate(doc) validator.validate(doc)
schema.dispose() schema.dispose()
validator.dispose() validator.dispose()
doc.dispose() doc.dispose()
} catch (_error) { } catch (_error) {
const error = _error as XmlValidateError const error = _error as XmlValidateError
localErrors = localErrors.concat(error.details) localErrors = localErrors.concat(error.details)
} }
xml.split('\n').forEach((line: string, lineIndex: number) => { xml.split('\n').forEach((line: string, lineIndex: number) => {
const found = line.match(/='/) const found = line.match(/='/)
if (found) { if (found) {
const colIndex = found.index || 0 const colIndex = found.index || 0
localErrors.push({ localErrors.push({
line: lineIndex + 1, line: lineIndex + 1,
col: colIndex + 1, col: colIndex + 1,
message: 'Single quotes cannot be used in attributes' message: 'Single quotes cannot be used in attributes'
}) })
} }
}) })
if (localErrors.length) { if (localErrors.length) {
console.log(`\n${chalk.underline(filepath)}`) console.log(`\n${chalk.underline(filepath)}`)
localErrors.forEach((error: ErrorDetail) => { localErrors.forEach((error: ErrorDetail) => {
const position = `${error.line}:${error.col}` const position = `${error.line}:${error.col}`
console.log(` ${chalk.gray(position.padEnd(4, ' '))} ${error.message.trim()}`) console.log(` ${chalk.gray(position.padEnd(4, ' '))} ${error.message.trim()}`)
}) })
errors = errors.concat(localErrors) errors = errors.concat(localErrors)
} }
} }
if (errors.length) { if (errors.length) {
console.log(chalk.red(`\n${errors.length} error(s)`)) console.log(chalk.red(`\n${errors.length} error(s)`))
process.exit(1) process.exit(1)
} }
} }
main() main()

View File

@@ -1,88 +1,86 @@
import { Logger, File, Storage } from '@freearhey/core' import { Logger, File, Storage } from '@freearhey/core'
import { ChannelsParser } from '../../core' import { ChannelsParser } from '../../core'
import { ChannelList } from '../../models' import { ChannelList } from '../../models'
import { pathToFileURL } from 'node:url' import { pathToFileURL } from 'node:url'
import epgGrabber from 'epg-grabber' import epgGrabber from 'epg-grabber'
import { Command } from 'commander' import { Command } from 'commander'
const program = new Command() const program = new Command()
program program
.requiredOption('-c, --config <config>', 'Config file') .requiredOption('-c, --config <config>', 'Config file')
.option('-s, --set [args...]', 'Set custom arguments') .option('-s, --set [args...]', 'Set custom arguments')
.option('-o, --output <output>', 'Output file') .option('-o, --output <output>', 'Output file')
.parse(process.argv) .parse(process.argv)
type ParseOptions = { interface ParseOptions {
config: string config: string
set?: string set?: string
output?: string output?: string
clean?: boolean clean?: boolean
} }
const options: ParseOptions = program.opts() const options: ParseOptions = program.opts()
async function main() { async function main() {
function isPromise(promise: object[] | Promise<object[]>) { function isPromise(promise: object[] | Promise<object[]>) {
return ( return (
!!promise && !!promise &&
typeof promise === 'object' && typeof promise === 'object' &&
typeof (promise as Promise<object[]>).then === 'function' typeof (promise as Promise<object[]>).then === 'function'
) )
} }
const storage = new Storage() const storage = new Storage()
const logger = new Logger() const logger = new Logger()
const parser = new ChannelsParser({ storage }) const parser = new ChannelsParser({ storage })
const file = new File(options.config) const file = new File(options.config)
const dir = file.dirname() const dir = file.dirname()
const config = (await import(pathToFileURL(options.config).toString())).default const config = (await import(pathToFileURL(options.config).toString())).default
const outputFilepath = options.output || `${dir}/${config.site}.channels.xml` const outputFilepath = options.output || `${dir}/${config.site}.channels.xml`
let channelList = new ChannelList({ channels: [] }) let channelList = new ChannelList({ channels: [] })
if (await storage.exists(outputFilepath)) { if (await storage.exists(outputFilepath)) {
channelList = await parser.parse(outputFilepath) channelList = await parser.parse(outputFilepath)
} }
const args: { const args: Record<string, string> = {}
[key: string]: string
} = {} if (Array.isArray(options.set)) {
options.set.forEach((arg: string) => {
if (Array.isArray(options.set)) { const [key, value] = arg.split(':')
options.set.forEach((arg: string) => { args[key] = value
const [key, value] = arg.split(':') })
args[key] = value }
})
} let parsedChannels = config.channels(args)
if (isPromise(parsedChannels)) {
let parsedChannels = config.channels(args) parsedChannels = await parsedChannels
if (isPromise(parsedChannels)) { }
parsedChannels = await parsedChannels parsedChannels = parsedChannels.map((channel: epgGrabber.Channel) => {
} channel.site = config.site
parsedChannels = parsedChannels.map((channel: epgGrabber.Channel) => {
channel.site = config.site return channel
})
return channel
}) const newChannelList = new ChannelList({ channels: [] })
parsedChannels.forEach((channel: epgGrabber.Channel) => {
const newChannelList = new ChannelList({ channels: [] }) if (!channel.site_id) return
parsedChannels.forEach((channel: epgGrabber.Channel) => {
if (!channel.site_id) return const found: epgGrabber.Channel | undefined = channelList.get(channel.site_id)
const found: epgGrabber.Channel | undefined = channelList.get(channel.site_id) if (found) {
channel.xmltv_id = found.xmltv_id
if (found) { channel.lang = found.lang
channel.xmltv_id = found.xmltv_id }
channel.lang = found.lang
} newChannelList.add(channel)
})
newChannelList.add(channel)
}) newChannelList.sort()
newChannelList.sort() await storage.save(outputFilepath, newChannelList.toString())
await storage.save(outputFilepath, newChannelList.toString()) logger.info(`File '${outputFilepath}' successfully saved`)
}
logger.info(`File '${outputFilepath}' successfully saved`)
} main()
main()

View File

@@ -1,100 +1,100 @@
import { ChannelsParser, DataLoader, DataProcessor } from '../../core' import { ChannelsParser, DataLoader, DataProcessor } from '../../core'
import { DataProcessorData } from '../../types/dataProcessor' import { DataProcessorData } from '../../types/dataProcessor'
import { Storage, Dictionary, File } from '@freearhey/core' import { Storage, Dictionary, File } from '@freearhey/core'
import { DataLoaderData } from '../../types/dataLoader' import { DataLoaderData } from '../../types/dataLoader'
import { ChannelList } from '../../models' import { ChannelList } from '../../models'
import { DATA_DIR } from '../../constants' import { DATA_DIR } from '../../constants'
import epgGrabber from 'epg-grabber' import epgGrabber from 'epg-grabber'
import { program } from 'commander' import { program } from 'commander'
import chalk from 'chalk' import chalk from 'chalk'
import langs from 'langs' import langs from 'langs'
program.argument('[filepath]', 'Path to *.channels.xml files to validate').parse(process.argv) program.argument('[filepath...]', 'Path to *.channels.xml files to validate').parse(process.argv)
type ValidationError = { interface ValidationError {
type: 'duplicate' | 'wrong_channel_id' | 'wrong_feed_id' | 'wrong_lang' type: 'duplicate' | 'wrong_channel_id' | 'wrong_feed_id' | 'wrong_lang'
name: string name: string
lang?: string lang?: string
xmltv_id?: string xmltv_id?: string
site_id?: string site_id?: string
logo?: string logo?: string
} }
async function main() { async function main() {
const processor = new DataProcessor() const processor = new DataProcessor()
const dataStorage = new Storage(DATA_DIR) const dataStorage = new Storage(DATA_DIR)
const loader = new DataLoader({ storage: dataStorage }) const loader = new DataLoader({ storage: dataStorage })
const data: DataLoaderData = await loader.load() const data: DataLoaderData = await loader.load()
const { channelsKeyById, feedsKeyByStreamId }: DataProcessorData = processor.process(data) const { channelsKeyById, feedsKeyByStreamId }: DataProcessorData = processor.process(data)
const parser = new ChannelsParser({ const parser = new ChannelsParser({
storage: new Storage() storage: new Storage()
}) })
let totalFiles = 0 let totalFiles = 0
let totalErrors = 0 let totalErrors = 0
let totalWarnings = 0 let totalWarnings = 0
const storage = new Storage() const storage = new Storage()
const files = program.args.length ? program.args : await storage.list('sites/**/*.channels.xml') const files = program.args.length ? program.args : await storage.list('sites/**/*.channels.xml')
for (const filepath of files) { for (const filepath of files) {
const file = new File(filepath) const file = new File(filepath)
if (file.extension() !== 'xml') continue if (file.extension() !== 'xml') continue
const channelList: ChannelList = await parser.parse(filepath) const channelList: ChannelList = await parser.parse(filepath)
const bufferBySiteId = new Dictionary() const bufferBySiteId = new Dictionary()
const errors: ValidationError[] = [] const errors: ValidationError[] = []
channelList.channels.forEach((channel: epgGrabber.Channel) => { channelList.channels.forEach((channel: epgGrabber.Channel) => {
const bufferId: string = channel.site_id const bufferId: string = channel.site_id
if (bufferBySiteId.missing(bufferId)) { if (bufferBySiteId.missing(bufferId)) {
bufferBySiteId.set(bufferId, true) bufferBySiteId.set(bufferId, true)
} else { } else {
errors.push({ type: 'duplicate', ...channel }) errors.push({ type: 'duplicate', ...channel })
totalErrors++ totalErrors++
} }
if (!langs.where('1', channel.lang ?? '')) { if (!langs.where('1', channel.lang ?? '')) {
errors.push({ type: 'wrong_lang', ...channel }) errors.push({ type: 'wrong_lang', ...channel })
totalErrors++ totalErrors++
} }
if (!channel.xmltv_id) return if (!channel.xmltv_id) return
const [channelId, feedId] = channel.xmltv_id.split('@') const [channelId, feedId] = channel.xmltv_id.split('@')
const foundChannel = channelsKeyById.get(channelId) const foundChannel = channelsKeyById.get(channelId)
if (!foundChannel) { if (!foundChannel) {
errors.push({ type: 'wrong_channel_id', ...channel }) errors.push({ type: 'wrong_channel_id', ...channel })
totalWarnings++ totalWarnings++
} }
if (feedId) { if (feedId) {
const foundFeed = feedsKeyByStreamId.get(channel.xmltv_id) const foundFeed = feedsKeyByStreamId.get(channel.xmltv_id)
if (!foundFeed) { if (!foundFeed) {
errors.push({ type: 'wrong_feed_id', ...channel }) errors.push({ type: 'wrong_feed_id', ...channel })
totalWarnings++ totalWarnings++
} }
} }
}) })
if (errors.length) { if (errors.length) {
console.log(chalk.underline(filepath)) console.log(chalk.underline(filepath))
console.table(errors, ['type', 'lang', 'xmltv_id', 'site_id', 'name']) console.table(errors, ['type', 'lang', 'xmltv_id', 'site_id', 'name'])
console.log() console.log()
totalFiles++ totalFiles++
} }
} }
const totalProblems = totalWarnings + totalErrors const totalProblems = totalWarnings + totalErrors
if (totalProblems > 0) { if (totalProblems > 0) {
console.log( console.log(
chalk.red( chalk.red(
`${totalProblems} problems (${totalErrors} errors, ${totalWarnings} warnings) in ${totalFiles} file(s)` `${totalProblems} problems (${totalErrors} errors, ${totalWarnings} warnings) in ${totalFiles} file(s)`
) )
) )
if (totalErrors > 0) { if (totalErrors > 0) {
process.exit(1) process.exit(1)
} }
} }
} }
main() main()

View File

@@ -1,133 +1,133 @@
import { Logger, Timer, Storage, Collection } from '@freearhey/core' import { Logger, Timer, Storage, Collection } from '@freearhey/core'
import { QueueCreator, Job, ChannelsParser } from '../../core' import { QueueCreator, Job, ChannelsParser } from '../../core'
import { Option, program } from 'commander' import { Option, program } from 'commander'
import { SITES_DIR } from '../../constants' import { SITES_DIR } from '../../constants'
import { Channel } from 'epg-grabber' import { Channel } from 'epg-grabber'
import path from 'path' import path from 'path'
import { ChannelList } from '../../models' import { ChannelList } from '../../models'
program program
.addOption(new Option('-s, --site <name>', 'Name of the site to parse')) .addOption(new Option('-s, --site <name>', 'Name of the site to parse'))
.addOption( .addOption(
new Option( new Option(
'-c, --channels <path>', '-c, --channels <path>',
'Path to *.channels.xml file (required if the "--site" attribute is not specified)' 'Path to *.channels.xml file (required if the "--site" attribute is not specified)'
) )
) )
.addOption(new Option('-o, --output <path>', 'Path to output file').default('guide.xml')) .addOption(new Option('-o, --output <path>', 'Path to output file').default('guide.xml'))
.addOption(new Option('-l, --lang <codes>', 'Filter channels by languages (ISO 639-1 codes)')) .addOption(new Option('-l, --lang <codes>', 'Filter channels by languages (ISO 639-1 codes)'))
.addOption( .addOption(
new Option('-t, --timeout <milliseconds>', 'Override the default timeout for each request').env( new Option('-t, --timeout <milliseconds>', 'Override the default timeout for each request').env(
'TIMEOUT' 'TIMEOUT'
) )
) )
.addOption( .addOption(
new Option('-d, --delay <milliseconds>', 'Override the default delay between request').env( new Option('-d, --delay <milliseconds>', 'Override the default delay between request').env(
'DELAY' 'DELAY'
) )
) )
.addOption(new Option('-x, --proxy <url>', 'Use the specified proxy').env('PROXY')) .addOption(new Option('-x, --proxy <url>', 'Use the specified proxy').env('PROXY'))
.addOption( .addOption(
new Option( new Option(
'--days <days>', '--days <days>',
'Override the number of days for which the program will be loaded (defaults to the value from the site config)' 'Override the number of days for which the program will be loaded (defaults to the value from the site config)'
) )
.argParser(value => parseInt(value)) .argParser(value => parseInt(value))
.env('DAYS') .env('DAYS')
) )
.addOption( .addOption(
new Option('--maxConnections <number>', 'Limit on the number of concurrent requests') new Option('--maxConnections <number>', 'Limit on the number of concurrent requests')
.default(1) .default(1)
.env('MAX_CONNECTIONS') .env('MAX_CONNECTIONS')
) )
.addOption( .addOption(
new Option('--gzip', 'Create a compressed version of the guide as well') new Option('--gzip', 'Create a compressed version of the guide as well')
.default(false) .default(false)
.env('GZIP') .env('GZIP')
) )
.addOption(new Option('--curl', 'Display each request as CURL').default(false).env('CURL')) .addOption(new Option('--curl', 'Display each request as CURL').default(false).env('CURL'))
.parse() .parse()
export type GrabOptions = { export interface GrabOptions {
site?: string site?: string
channels?: string channels?: string
output: string output: string
gzip: boolean gzip: boolean
curl: boolean curl: boolean
maxConnections: number maxConnections: number
timeout?: string timeout?: string
delay?: string delay?: string
lang?: string lang?: string
days?: number days?: number
proxy?: string proxy?: string
} }
const options: GrabOptions = program.opts() const options: GrabOptions = program.opts()
async function main() { async function main() {
if (!options.site && !options.channels) if (!options.site && !options.channels)
throw new Error('One of the arguments must be presented: `--site` or `--channels`') throw new Error('One of the arguments must be presented: `--site` or `--channels`')
const logger = new Logger() const logger = new Logger()
logger.start('starting...') logger.start('starting...')
logger.info('config:') logger.info('config:')
logger.tree(options) logger.tree(options)
logger.info('loading channels...') logger.info('loading channels...')
const storage = new Storage() const storage = new Storage()
const parser = new ChannelsParser({ storage }) const parser = new ChannelsParser({ storage })
let files: string[] = [] let files: string[] = []
if (options.site) { if (options.site) {
let pattern = path.join(SITES_DIR, options.site, '*.channels.xml') let pattern = path.join(SITES_DIR, options.site, '*.channels.xml')
pattern = pattern.replace(/\\/g, '/') pattern = pattern.replace(/\\/g, '/')
files = await storage.list(pattern) files = await storage.list(pattern)
} else if (options.channels) { } else if (options.channels) {
files = await storage.list(options.channels) files = await storage.list(options.channels)
} }
let channels = new Collection() let channels = new Collection()
for (const filepath of files) { for (const filepath of files) {
const channelList: ChannelList = await parser.parse(filepath) const channelList: ChannelList = await parser.parse(filepath)
channels = channels.concat(channelList.channels) channels = channels.concat(channelList.channels)
} }
if (options.lang) { if (options.lang) {
channels = channels.filter((channel: Channel) => { channels = channels.filter((channel: Channel) => {
if (!options.lang || !channel.lang) return true if (!options.lang || !channel.lang) return true
return options.lang.includes(channel.lang) return options.lang.includes(channel.lang)
}) })
} }
logger.info(` found ${channels.count()} channel(s)`) logger.info(` found ${channels.count()} channel(s)`)
logger.info('run:') logger.info('run:')
runJob({ logger, channels }) runJob({ logger, channels })
} }
main() main()
async function runJob({ logger, channels }: { logger: Logger; channels: Collection }) { async function runJob({ logger, channels }: { logger: Logger; channels: Collection }) {
const timer = new Timer() const timer = new Timer()
timer.start() timer.start()
const queueCreator = new QueueCreator({ const queueCreator = new QueueCreator({
channels, channels,
logger, logger,
options options
}) })
const queue = await queueCreator.create() const queue = await queueCreator.create()
const job = new Job({ const job = new Job({
queue, queue,
logger, logger,
options options
}) })
await job.run() await job.run()
logger.success(` done in ${timer.format('HH[h] mm[m] ss[s]')}`) logger.success(` done in ${timer.format('HH[h] mm[m] ss[s]')}`)
} }

View File

@@ -1,45 +1,45 @@
import { Logger, Storage } from '@freearhey/core' import { Logger, Storage } from '@freearhey/core'
import { SITES_DIR } from '../../constants' import { SITES_DIR } from '../../constants'
import { pathToFileURL } from 'node:url' import { pathToFileURL } from 'node:url'
import { program } from 'commander' import { program } from 'commander'
import fs from 'fs-extra' import fs from 'fs-extra'
program.argument('<site>', 'Domain name of the site').parse(process.argv) program.argument('<site>', 'Domain name of the site').parse(process.argv)
const domain = program.args[0] const domain = program.args[0]
async function main() { async function main() {
const storage = new Storage(SITES_DIR) const storage = new Storage(SITES_DIR)
const logger = new Logger() const logger = new Logger()
logger.info(`Initializing "${domain}"...\r\n`) logger.info(`Initializing "${domain}"...\r\n`)
const dir = domain const dir = domain
if (await storage.exists(dir)) { if (await storage.exists(dir)) {
throw new Error(`Folder "${dir}" already exists`) throw new Error(`Folder "${dir}" already exists`)
} }
await storage.createDir(dir) await storage.createDir(dir)
logger.info(`Creating "${dir}/${domain}.test.js"...`) logger.info(`Creating "${dir}/${domain}.test.js"...`)
const testTemplate = fs.readFileSync(pathToFileURL('scripts/templates/_test.js'), { const testTemplate = fs.readFileSync(pathToFileURL('scripts/templates/_test.js'), {
encoding: 'utf8' encoding: 'utf8'
}) })
await storage.save(`${dir}/${domain}.test.js`, testTemplate.replace(/<DOMAIN>/g, domain)) await storage.save(`${dir}/${domain}.test.js`, testTemplate.replace(/<DOMAIN>/g, domain))
logger.info(`Creating "${dir}/${domain}.config.js"...`) logger.info(`Creating "${dir}/${domain}.config.js"...`)
const configTemplate = fs.readFileSync(pathToFileURL('scripts/templates/_config.js'), { const configTemplate = fs.readFileSync(pathToFileURL('scripts/templates/_config.js'), {
encoding: 'utf8' encoding: 'utf8'
}) })
await storage.save(`${dir}/${domain}.config.js`, configTemplate.replace(/<DOMAIN>/g, domain)) await storage.save(`${dir}/${domain}.config.js`, configTemplate.replace(/<DOMAIN>/g, domain))
logger.info(`Creating "${dir}/readme.md"...`) logger.info(`Creating "${dir}/readme.md"...`)
const readmeTemplate = fs.readFileSync(pathToFileURL('scripts/templates/_readme.md'), { const readmeTemplate = fs.readFileSync(pathToFileURL('scripts/templates/_readme.md'), {
encoding: 'utf8' encoding: 'utf8'
}) })
await storage.save(`${dir}/readme.md`, readmeTemplate.replace(/<DOMAIN>/g, domain)) await storage.save(`${dir}/readme.md`, readmeTemplate.replace(/<DOMAIN>/g, domain))
logger.info('\r\nDone') logger.info('\r\nDone')
} }
main() main()

View File

@@ -1,76 +1,76 @@
import { IssueLoader, HTMLTable, ChannelsParser } from '../../core' import { IssueLoader, HTMLTable, ChannelsParser } from '../../core'
import { Logger, Storage, Collection } from '@freearhey/core' import { Logger, Storage, Collection } from '@freearhey/core'
import { ChannelList, Issue, Site } from '../../models' import { ChannelList, Issue, Site } from '../../models'
import { SITES_DIR, ROOT_DIR } from '../../constants' import { SITES_DIR, ROOT_DIR } from '../../constants'
import { Channel } from 'epg-grabber' import { Channel } from 'epg-grabber'
async function main() { async function main() {
const logger = new Logger({ level: -999 }) const logger = new Logger({ level: -999 })
const issueLoader = new IssueLoader() const issueLoader = new IssueLoader()
const sitesStorage = new Storage(SITES_DIR) const sitesStorage = new Storage(SITES_DIR)
const sites = new Collection() const sites = new Collection()
logger.info('loading channels...') logger.info('loading channels...')
const channelsParser = new ChannelsParser({ const channelsParser = new ChannelsParser({
storage: sitesStorage storage: sitesStorage
}) })
logger.info('loading list of sites') logger.info('loading list of sites')
const folders = await sitesStorage.list('*/') const folders = await sitesStorage.list('*/')
logger.info('loading issues...') logger.info('loading issues...')
const issues = await issueLoader.load() const issues = await issueLoader.load()
logger.info('putting the data together...') logger.info('putting the data together...')
const brokenGuideReports = issues.filter(issue => const brokenGuideReports = issues.filter(issue =>
issue.labels.find((label: string) => label === 'broken guide') issue.labels.find((label: string) => label === 'broken guide')
) )
for (const domain of folders) { for (const domain of folders) {
const filteredIssues = brokenGuideReports.filter( const filteredIssues = brokenGuideReports.filter(
(issue: Issue) => domain === issue.data.get('site') (issue: Issue) => domain === issue.data.get('site')
) )
const site = new Site({ const site = new Site({
domain, domain,
issues: filteredIssues issues: filteredIssues
}) })
const files = await sitesStorage.list(`${domain}/*.channels.xml`) const files = await sitesStorage.list(`${domain}/*.channels.xml`)
for (const filepath of files) { for (const filepath of files) {
const channelList: ChannelList = await channelsParser.parse(filepath) const channelList: ChannelList = await channelsParser.parse(filepath)
site.totalChannels += channelList.channels.count() site.totalChannels += channelList.channels.count()
site.markedChannels += channelList.channels site.markedChannels += channelList.channels
.filter((channel: Channel) => channel.xmltv_id) .filter((channel: Channel) => channel.xmltv_id)
.count() .count()
} }
sites.add(site) sites.add(site)
} }
logger.info('creating sites table...') logger.info('creating sites table...')
const tableData = new Collection() const tableData = new Collection()
sites.forEach((site: Site) => { sites.forEach((site: Site) => {
tableData.add([ tableData.add([
{ value: `<a href="sites/${site.domain}">${site.domain}</a>` }, { value: `<a href="sites/${site.domain}">${site.domain}</a>` },
{ value: site.totalChannels, align: 'right' }, { value: site.totalChannels, align: 'right' },
{ value: site.markedChannels, align: 'right' }, { value: site.markedChannels, align: 'right' },
{ value: site.getStatus().emoji, align: 'center' }, { value: site.getStatus().emoji, align: 'center' },
{ value: site.getIssues().all().join(', ') } { value: site.getIssues().all().join(', ') }
]) ])
}) })
logger.info('updating sites.md...') logger.info('updating sites.md...')
const table = new HTMLTable(tableData.all(), [ const table = new HTMLTable(tableData.all(), [
{ name: 'Site', align: 'left' }, { name: 'Site', align: 'left' },
{ name: 'Channels<br>(total / with xmltv-id)', colspan: 2, align: 'left' }, { name: 'Channels<br>(total / with xmltv-id)', colspan: 2, align: 'left' },
{ name: 'Status', align: 'left' }, { name: 'Status', align: 'left' },
{ name: 'Notes', align: 'left' } { name: 'Notes', align: 'left' }
]) ])
const rootStorage = new Storage(ROOT_DIR) const rootStorage = new Storage(ROOT_DIR)
const sitesTemplate = await new Storage().load('scripts/templates/_sites.md') const sitesTemplate = await new Storage().load('scripts/templates/_sites.md')
const sitesContent = sitesTemplate.replace('_TABLE_', table.toString()) const sitesContent = sitesTemplate.replace('_TABLE_', table.toString())
await rootStorage.save('SITES.md', sitesContent) await rootStorage.save('SITES.md', sitesContent)
} }
main() main()

View File

@@ -1,9 +1,9 @@
export const ROOT_DIR = process.env.ROOT_DIR || '.' export const ROOT_DIR = process.env.ROOT_DIR || '.'
export const SITES_DIR = process.env.SITES_DIR || './sites' export const SITES_DIR = process.env.SITES_DIR || './sites'
export const GUIDES_DIR = process.env.GUIDES_DIR || './guides' export const GUIDES_DIR = process.env.GUIDES_DIR || './guides'
export const DATA_DIR = process.env.DATA_DIR || './temp/data' export const DATA_DIR = process.env.DATA_DIR || './temp/data'
export const API_DIR = process.env.API_DIR || '.api' export const API_DIR = process.env.API_DIR || '.api'
export const DOT_SITES_DIR = process.env.DOT_SITES_DIR || './.sites' export const DOT_SITES_DIR = process.env.DOT_SITES_DIR || './.sites'
export const TESTING = process.env.NODE_ENV === 'test' ? true : false export const TESTING = process.env.NODE_ENV === 'test' ? true : false
export const OWNER = 'iptv-org' export const OWNER = 'iptv-org'
export const REPO = 'epg' export const REPO = 'epg'

View File

@@ -1,16 +1,16 @@
import axios, { AxiosInstance, AxiosResponse, AxiosRequestConfig } from 'axios' import axios, { AxiosInstance, AxiosResponse, AxiosRequestConfig } from 'axios'
export class ApiClient { export class ApiClient {
instance: AxiosInstance instance: AxiosInstance
constructor() { constructor() {
this.instance = axios.create({ this.instance = axios.create({
baseURL: 'https://iptv-org.github.io/api', baseURL: 'https://iptv-org.github.io/api',
responseType: 'stream' responseType: 'stream'
}) })
} }
get(url: string, options: AxiosRequestConfig): Promise<AxiosResponse> { get(url: string, options: AxiosRequestConfig): Promise<AxiosResponse> {
return this.instance.get(url, options) return this.instance.get(url, options)
} }
} }

View File

@@ -1,22 +1,22 @@
import { parseChannels } from 'epg-grabber' import { parseChannels } from 'epg-grabber'
import { Storage } from '@freearhey/core' import { Storage } from '@freearhey/core'
import { ChannelList } from '../models' import { ChannelList } from '../models'
type ChannelsParserProps = { interface ChannelsParserProps {
storage: Storage storage: Storage
} }
export class ChannelsParser { export class ChannelsParser {
storage: Storage storage: Storage
constructor({ storage }: ChannelsParserProps) { constructor({ storage }: ChannelsParserProps) {
this.storage = storage this.storage = storage
} }
async parse(filepath: string): Promise<ChannelList> { async parse(filepath: string): Promise<ChannelList> {
const content = await this.storage.load(filepath) const content = await this.storage.load(filepath)
const parsed = parseChannels(content) const parsed = parseChannels(content)
return new ChannelList({ channels: parsed }) return new ChannelList({ channels: parsed })
} }
} }

View File

@@ -1,33 +1,32 @@
import { SiteConfig } from 'epg-grabber' import { SiteConfig } from 'epg-grabber'
import _ from 'lodash' import { pathToFileURL } from 'url'
import { pathToFileURL } from 'url'
export class ConfigLoader {
export class ConfigLoader { async load(filepath: string): Promise<SiteConfig> {
async load(filepath: string): Promise<SiteConfig> { const fileUrl = pathToFileURL(filepath).toString()
const fileUrl = pathToFileURL(filepath).toString() const config = (await import(fileUrl)).default
const config = (await import(fileUrl)).default const defaultConfig = {
const defaultConfig = { days: 1,
days: 1, delay: 0,
delay: 0, output: 'guide.xml',
output: 'guide.xml', request: {
request: { method: 'GET',
method: 'GET', maxContentLength: 5242880,
maxContentLength: 5242880, timeout: 30000,
timeout: 30000, withCredentials: true,
withCredentials: true, jar: null,
jar: null, responseType: 'arraybuffer',
responseType: 'arraybuffer', cache: false,
cache: false, headers: null,
headers: null, data: null
data: null },
}, maxConnections: 1,
maxConnections: 1, site: undefined,
site: undefined, url: undefined,
url: undefined, parser: undefined,
parser: undefined, channels: undefined
channels: undefined }
}
return { ...defaultConfig, ...config } as SiteConfig
return _.merge(defaultConfig, config) }
} }
}

View File

@@ -1,103 +1,103 @@
import type { DataLoaderProps, DataLoaderData } from '../types/dataLoader' import type { DataLoaderProps, DataLoaderData } from '../types/dataLoader'
import cliProgress, { MultiBar } from 'cli-progress' import cliProgress, { MultiBar } from 'cli-progress'
import { Storage } from '@freearhey/core' import { Storage } from '@freearhey/core'
import { ApiClient } from './apiClient' import { ApiClient } from './apiClient'
import numeral from 'numeral' import numeral from 'numeral'
export class DataLoader { export class DataLoader {
client: ApiClient client: ApiClient
storage: Storage storage: Storage
progressBar: MultiBar progressBar: MultiBar
constructor(props: DataLoaderProps) { constructor(props: DataLoaderProps) {
this.client = new ApiClient() this.client = new ApiClient()
this.storage = props.storage this.storage = props.storage
this.progressBar = new cliProgress.MultiBar({ this.progressBar = new cliProgress.MultiBar({
stopOnComplete: true, stopOnComplete: true,
hideCursor: true, hideCursor: true,
forceRedraw: true, forceRedraw: true,
barsize: 36, barsize: 36,
format(options, params, payload) { format(options, params, payload) {
const filename = payload.filename.padEnd(18, ' ') const filename = payload.filename.padEnd(18, ' ')
const barsize = options.barsize || 40 const barsize = options.barsize || 40
const percent = (params.progress * 100).toFixed(2) const percent = (params.progress * 100).toFixed(2)
const speed = payload.speed ? numeral(payload.speed).format('0.0 b') + '/s' : 'N/A' const speed = payload.speed ? numeral(payload.speed).format('0.0 b') + '/s' : 'N/A'
const total = numeral(params.total).format('0.0 b') const total = numeral(params.total).format('0.0 b')
const completeSize = Math.round(params.progress * barsize) const completeSize = Math.round(params.progress * barsize)
const incompleteSize = barsize - completeSize const incompleteSize = barsize - completeSize
const bar = const bar =
options.barCompleteString && options.barIncompleteString options.barCompleteString && options.barIncompleteString
? options.barCompleteString.substr(0, completeSize) + ? options.barCompleteString.substr(0, completeSize) +
options.barGlue + options.barGlue +
options.barIncompleteString.substr(0, incompleteSize) options.barIncompleteString.substr(0, incompleteSize)
: '-'.repeat(barsize) : '-'.repeat(barsize)
return `${filename} [${bar}] ${percent}% | ETA: ${params.eta}s | ${total} | ${speed}` return `${filename} [${bar}] ${percent}% | ETA: ${params.eta}s | ${total} | ${speed}`
} }
}) })
} }
async load(): Promise<DataLoaderData> { async load(): Promise<DataLoaderData> {
const [ const [
countries, countries,
regions, regions,
subdivisions, subdivisions,
languages, languages,
categories, categories,
blocklist, blocklist,
channels, channels,
feeds, feeds,
timezones, timezones,
guides, guides,
streams, streams,
logos logos
] = await Promise.all([ ] = await Promise.all([
this.storage.json('countries.json'), this.storage.json('countries.json'),
this.storage.json('regions.json'), this.storage.json('regions.json'),
this.storage.json('subdivisions.json'), this.storage.json('subdivisions.json'),
this.storage.json('languages.json'), this.storage.json('languages.json'),
this.storage.json('categories.json'), this.storage.json('categories.json'),
this.storage.json('blocklist.json'), this.storage.json('blocklist.json'),
this.storage.json('channels.json'), this.storage.json('channels.json'),
this.storage.json('feeds.json'), this.storage.json('feeds.json'),
this.storage.json('timezones.json'), this.storage.json('timezones.json'),
this.storage.json('guides.json'), this.storage.json('guides.json'),
this.storage.json('streams.json'), this.storage.json('streams.json'),
this.storage.json('logos.json') this.storage.json('logos.json')
]) ])
return { return {
countries, countries,
regions, regions,
subdivisions, subdivisions,
languages, languages,
categories, categories,
blocklist, blocklist,
channels, channels,
feeds, feeds,
timezones, timezones,
guides, guides,
streams, streams,
logos logos
} }
} }
async download(filename: string) { async download(filename: string) {
if (!this.storage || !this.progressBar) return if (!this.storage || !this.progressBar) return
const stream = await this.storage.createStream(filename) const stream = await this.storage.createStream(filename)
const progressBar = this.progressBar.create(0, 0, { filename }) const progressBar = this.progressBar.create(0, 0, { filename })
this.client this.client
.get(filename, { .get(filename, {
responseType: 'stream', responseType: 'stream',
onDownloadProgress({ total, loaded, rate }) { onDownloadProgress({ total, loaded, rate }) {
if (total) progressBar.setTotal(total) if (total) progressBar.setTotal(total)
progressBar.update(loaded, { speed: rate }) progressBar.update(loaded, { speed: rate })
} }
}) })
.then(response => { .then(response => {
response.data.pipe(stream) response.data.pipe(stream)
}) })
} }
} }

View File

@@ -1,56 +1,55 @@
import { Channel, Feed, GuideChannel, Logo, Stream } from '../models' import { Channel, Feed, GuideChannel, Logo, Stream } from '../models'
import { DataLoaderData } from '../types/dataLoader' import { DataLoaderData } from '../types/dataLoader'
import { Collection } from '@freearhey/core' import { Collection } from '@freearhey/core'
export class DataProcessor { export class DataProcessor {
constructor() {}
process(data: DataLoaderData) {
process(data: DataLoaderData) { let channels = new Collection(data.channels).map(data => new Channel(data))
let channels = new Collection(data.channels).map(data => new Channel(data)) const channelsKeyById = channels.keyBy((channel: Channel) => channel.id)
const channelsKeyById = channels.keyBy((channel: Channel) => channel.id)
const guideChannels = new Collection(data.guides).map(data => new GuideChannel(data))
const guideChannels = new Collection(data.guides).map(data => new GuideChannel(data)) const guideChannelsGroupedByStreamId = guideChannels.groupBy((channel: GuideChannel) =>
const guideChannelsGroupedByStreamId = guideChannels.groupBy((channel: GuideChannel) => channel.getStreamId()
channel.getStreamId() )
)
const streams = new Collection(data.streams).map(data => new Stream(data))
const streams = new Collection(data.streams).map(data => new Stream(data)) const streamsGroupedById = streams.groupBy((stream: Stream) => stream.getId())
const streamsGroupedById = streams.groupBy((stream: Stream) => stream.getId())
let feeds = new Collection(data.feeds).map(data =>
let feeds = new Collection(data.feeds).map(data => new Feed(data)
new Feed(data) .withGuideChannels(guideChannelsGroupedByStreamId)
.withGuideChannels(guideChannelsGroupedByStreamId) .withStreams(streamsGroupedById)
.withStreams(streamsGroupedById) .withChannel(channelsKeyById)
.withChannel(channelsKeyById) )
) const feedsKeyByStreamId = feeds.keyBy((feed: Feed) => feed.getStreamId())
const feedsKeyByStreamId = feeds.keyBy((feed: Feed) => feed.getStreamId())
const logos = new Collection(data.logos).map(data =>
const logos = new Collection(data.logos).map(data => new Logo(data).withFeed(feedsKeyByStreamId)
new Logo(data).withFeed(feedsKeyByStreamId) )
) const logosGroupedByChannelId = logos.groupBy((logo: Logo) => logo.channelId)
const logosGroupedByChannelId = logos.groupBy((logo: Logo) => logo.channelId) const logosGroupedByStreamId = logos.groupBy((logo: Logo) => logo.getStreamId())
const logosGroupedByStreamId = logos.groupBy((logo: Logo) => logo.getStreamId())
feeds = feeds.map((feed: Feed) => feed.withLogos(logosGroupedByStreamId))
feeds = feeds.map((feed: Feed) => feed.withLogos(logosGroupedByStreamId)) const feedsGroupedByChannelId = feeds.groupBy((feed: Feed) => feed.channelId)
const feedsGroupedByChannelId = feeds.groupBy((feed: Feed) => feed.channelId)
channels = channels.map((channel: Channel) =>
channels = channels.map((channel: Channel) => channel.withFeeds(feedsGroupedByChannelId).withLogos(logosGroupedByChannelId)
channel.withFeeds(feedsGroupedByChannelId).withLogos(logosGroupedByChannelId) )
)
return {
return { guideChannelsGroupedByStreamId,
guideChannelsGroupedByStreamId, feedsGroupedByChannelId,
feedsGroupedByChannelId, logosGroupedByChannelId,
logosGroupedByChannelId, logosGroupedByStreamId,
logosGroupedByStreamId, streamsGroupedById,
streamsGroupedById, feedsKeyByStreamId,
feedsKeyByStreamId, channelsKeyById,
channelsKeyById, guideChannels,
guideChannels, channels,
channels, streams,
streams, feeds,
feeds, logos
logos }
} }
} }
}

View File

@@ -1,14 +1,14 @@
import dayjs from 'dayjs' import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc' import utc from 'dayjs/plugin/utc'
dayjs.extend(utc) dayjs.extend(utc)
const date = {} const date = {}
date.getUTC = function (d = null) { date.getUTC = function (d = null) {
if (typeof d === 'string') return dayjs.utc(d).startOf('d') if (typeof d === 'string') return dayjs.utc(d).startOf('d')
return dayjs.utc().startOf('d') return dayjs.utc().startOf('d')
} }
export default date export default date

View File

@@ -1,105 +1,105 @@
import { EPGGrabber, GrabCallbackData, EPGGrabberMock, SiteConfig, Channel } from 'epg-grabber' import { EPGGrabber, GrabCallbackData, EPGGrabberMock, SiteConfig, Channel } from 'epg-grabber'
import { Logger, Collection } from '@freearhey/core' import { Logger, Collection } from '@freearhey/core'
import { Queue, ProxyParser } from './' import { Queue, ProxyParser } from './'
import { GrabOptions } from '../commands/epg/grab' import { GrabOptions } from '../commands/epg/grab'
import { TaskQueue, PromisyClass } from 'cwait' import { TaskQueue, PromisyClass } from 'cwait'
import { SocksProxyAgent } from 'socks-proxy-agent' import { SocksProxyAgent } from 'socks-proxy-agent'
type GrabberProps = { interface GrabberProps {
logger: Logger logger: Logger
queue: Queue queue: Queue
options: GrabOptions options: GrabOptions
} }
export class Grabber { export class Grabber {
logger: Logger logger: Logger
queue: Queue queue: Queue
options: GrabOptions options: GrabOptions
grabber: EPGGrabber | EPGGrabberMock grabber: EPGGrabber | EPGGrabberMock
constructor({ logger, queue, options }: GrabberProps) { constructor({ logger, queue, options }: GrabberProps) {
this.logger = logger this.logger = logger
this.queue = queue this.queue = queue
this.options = options this.options = options
this.grabber = process.env.NODE_ENV === 'test' ? new EPGGrabberMock() : new EPGGrabber() this.grabber = process.env.NODE_ENV === 'test' ? new EPGGrabberMock() : new EPGGrabber()
} }
async grab(): Promise<{ channels: Collection; programs: Collection }> { async grab(): Promise<{ channels: Collection; programs: Collection }> {
const proxyParser = new ProxyParser() const proxyParser = new ProxyParser()
const taskQueue = new TaskQueue(Promise as PromisyClass, this.options.maxConnections) const taskQueue = new TaskQueue(Promise as PromisyClass, this.options.maxConnections)
const total = this.queue.size() const total = this.queue.size()
const channels = new Collection() const channels = new Collection()
let programs = new Collection() let programs = new Collection()
let i = 1 let i = 1
await Promise.all( await Promise.all(
this.queue.items().map( this.queue.items().map(
taskQueue.wrap( taskQueue.wrap(
async (queueItem: { channel: Channel; config: SiteConfig; date: string }) => { async (queueItem: { channel: Channel; config: SiteConfig; date: string }) => {
const { channel, config, date } = queueItem const { channel, config, date } = queueItem
channels.add(channel) channels.add(channel)
if (this.options.timeout !== undefined) { if (this.options.timeout !== undefined) {
const timeout = parseInt(this.options.timeout) const timeout = parseInt(this.options.timeout)
config.request = { ...config.request, ...{ timeout } } config.request = { ...config.request, ...{ timeout } }
} }
if (this.options.delay !== undefined) { if (this.options.delay !== undefined) {
const delay = parseInt(this.options.delay) const delay = parseInt(this.options.delay)
config.delay = delay config.delay = delay
} }
if (this.options.proxy !== undefined) { if (this.options.proxy !== undefined) {
const proxy = proxyParser.parse(this.options.proxy) const proxy = proxyParser.parse(this.options.proxy)
if ( if (
proxy.protocol && proxy.protocol &&
['socks', 'socks5', 'socks5h', 'socks4', 'socks4a'].includes(String(proxy.protocol)) ['socks', 'socks5', 'socks5h', 'socks4', 'socks4a'].includes(String(proxy.protocol))
) { ) {
const socksProxyAgent = new SocksProxyAgent(this.options.proxy) const socksProxyAgent = new SocksProxyAgent(this.options.proxy)
config.request = { config.request = {
...config.request, ...config.request,
...{ httpAgent: socksProxyAgent, httpsAgent: socksProxyAgent } ...{ httpAgent: socksProxyAgent, httpsAgent: socksProxyAgent }
} }
} else { } else {
config.request = { ...config.request, ...{ proxy } } config.request = { ...config.request, ...{ proxy } }
} }
} }
if (this.options.curl === true) { if (this.options.curl === true) {
config.curl = true config.curl = true
} }
const _programs = await this.grabber.grab( const _programs = await this.grabber.grab(
channel, channel,
date, date,
config, config,
(data: GrabCallbackData, error: Error | null) => { (data: GrabCallbackData, error: Error | null) => {
const { programs, date } = data const { programs, date } = data
this.logger.info( this.logger.info(
` [${i}/${total}] ${channel.site} (${channel.lang}) - ${ ` [${i}/${total}] ${channel.site} (${channel.lang}) - ${
channel.xmltv_id channel.xmltv_id
} - ${date.format('MMM D, YYYY')} (${programs.length} programs)` } - ${date.format('MMM D, YYYY')} (${programs.length} programs)`
) )
if (i < total) i++ if (i < total) i++
if (error) { if (error) {
this.logger.info(` ERR: ${error.message}`) this.logger.info(` ERR: ${error.message}`)
} }
} }
) )
programs = programs.concat(new Collection(_programs)) programs = programs.concat(new Collection(_programs))
} }
) )
) )
) )
return { channels, programs } return { channels, programs }
} }
} }

View File

@@ -1,111 +1,111 @@
import { Collection, Logger, Zip, Storage, StringTemplate } from '@freearhey/core' import { Collection, Logger, Zip, Storage, StringTemplate } from '@freearhey/core'
import epgGrabber from 'epg-grabber' import epgGrabber from 'epg-grabber'
import { OptionValues } from 'commander' import { OptionValues } from 'commander'
import { Channel, Feed, Guide } from '../models' import { Channel, Feed, Guide } from '../models'
import path from 'path' import path from 'path'
import { DataLoader, DataProcessor } from '.' import { DataLoader, DataProcessor } from '.'
import { DataLoaderData } from '../types/dataLoader' import { DataLoaderData } from '../types/dataLoader'
import { DataProcessorData } from '../types/dataProcessor' import { DataProcessorData } from '../types/dataProcessor'
import { DATA_DIR } from '../constants' import { DATA_DIR } from '../constants'
type GuideManagerProps = { interface GuideManagerProps {
options: OptionValues options: OptionValues
logger: Logger logger: Logger
channels: Collection channels: Collection
programs: Collection programs: Collection
} }
export class GuideManager { export class GuideManager {
options: OptionValues options: OptionValues
logger: Logger logger: Logger
channels: Collection channels: Collection
programs: Collection programs: Collection
constructor({ channels, programs, logger, options }: GuideManagerProps) { constructor({ channels, programs, logger, options }: GuideManagerProps) {
this.options = options this.options = options
this.logger = logger this.logger = logger
this.channels = channels this.channels = channels
this.programs = programs this.programs = programs
} }
async createGuides() { async createGuides() {
const pathTemplate = new StringTemplate(this.options.output) const pathTemplate = new StringTemplate(this.options.output)
const processor = new DataProcessor() const processor = new DataProcessor()
const dataStorage = new Storage(DATA_DIR) const dataStorage = new Storage(DATA_DIR)
const loader = new DataLoader({ storage: dataStorage }) const loader = new DataLoader({ storage: dataStorage })
const data: DataLoaderData = await loader.load() const data: DataLoaderData = await loader.load()
const { feedsKeyByStreamId, channelsKeyById }: DataProcessorData = processor.process(data) const { feedsKeyByStreamId, channelsKeyById }: DataProcessorData = processor.process(data)
const groupedChannels = this.channels const groupedChannels = this.channels
.map((channel: epgGrabber.Channel) => { .map((channel: epgGrabber.Channel) => {
if (channel.xmltv_id && !channel.icon) { if (channel.xmltv_id && !channel.icon) {
const foundFeed: Feed = feedsKeyByStreamId.get(channel.xmltv_id) const foundFeed: Feed = feedsKeyByStreamId.get(channel.xmltv_id)
if (foundFeed && foundFeed.hasLogo()) { if (foundFeed && foundFeed.hasLogo()) {
channel.icon = foundFeed.getLogoUrl() channel.icon = foundFeed.getLogoUrl()
} else { } else {
const [channelId] = channel.xmltv_id.split('@') const [channelId] = channel.xmltv_id.split('@')
const foundChannel: Channel = channelsKeyById.get(channelId) const foundChannel: Channel = channelsKeyById.get(channelId)
if (foundChannel && foundChannel.hasLogo()) { if (foundChannel && foundChannel.hasLogo()) {
channel.icon = foundChannel.getLogoUrl() channel.icon = foundChannel.getLogoUrl()
} }
} }
} }
return channel return channel
}) })
.orderBy([ .orderBy([
(channel: epgGrabber.Channel) => channel.index, (channel: epgGrabber.Channel) => channel.index,
(channel: epgGrabber.Channel) => channel.xmltv_id (channel: epgGrabber.Channel) => channel.xmltv_id
]) ])
.uniqBy( .uniqBy(
(channel: epgGrabber.Channel) => `${channel.xmltv_id}:${channel.site}:${channel.lang}` (channel: epgGrabber.Channel) => `${channel.xmltv_id}:${channel.site}:${channel.lang}`
) )
.groupBy((channel: epgGrabber.Channel) => { .groupBy((channel: epgGrabber.Channel) => {
return pathTemplate.format({ lang: channel.lang || 'en', site: channel.site || '' }) return pathTemplate.format({ lang: channel.lang || 'en', site: channel.site || '' })
}) })
const groupedPrograms = this.programs const groupedPrograms = this.programs
.orderBy([ .orderBy([
(program: epgGrabber.Program) => program.channel, (program: epgGrabber.Program) => program.channel,
(program: epgGrabber.Program) => program.start (program: epgGrabber.Program) => program.start
]) ])
.groupBy((program: epgGrabber.Program) => { .groupBy((program: epgGrabber.Program) => {
const lang = const lang =
program.titles && program.titles.length && program.titles[0].lang program.titles && program.titles.length && program.titles[0].lang
? program.titles[0].lang ? program.titles[0].lang
: 'en' : 'en'
return pathTemplate.format({ lang, site: program.site || '' }) return pathTemplate.format({ lang, site: program.site || '' })
}) })
for (const groupKey of groupedPrograms.keys()) { for (const groupKey of groupedPrograms.keys()) {
const guide = new Guide({ const guide = new Guide({
filepath: groupKey, filepath: groupKey,
gzip: this.options.gzip, gzip: this.options.gzip,
channels: new Collection(groupedChannels.get(groupKey)), channels: new Collection(groupedChannels.get(groupKey)),
programs: new Collection(groupedPrograms.get(groupKey)) programs: new Collection(groupedPrograms.get(groupKey))
}) })
await this.save(guide) await this.save(guide)
} }
} }
async save(guide: Guide) { async save(guide: Guide) {
const storage = new Storage(path.dirname(guide.filepath)) const storage = new Storage(path.dirname(guide.filepath))
const xmlFilepath = guide.filepath const xmlFilepath = guide.filepath
const xmlFilename = path.basename(xmlFilepath) const xmlFilename = path.basename(xmlFilepath)
this.logger.info(` saving to "${xmlFilepath}"...`) this.logger.info(` saving to "${xmlFilepath}"...`)
const xmltv = guide.toString() const xmltv = guide.toString()
await storage.save(xmlFilename, xmltv) await storage.save(xmlFilename, xmltv)
if (guide.gzip) { if (guide.gzip) {
const zip = new Zip() const zip = new Zip()
const compressed = zip.compress(xmltv) const compressed = zip.compress(xmltv)
const gzFilepath = `${guide.filepath}.gz` const gzFilepath = `${guide.filepath}.gz`
const gzFilename = path.basename(gzFilepath) const gzFilename = path.basename(gzFilepath)
this.logger.info(` saving to "${gzFilepath}"...`) this.logger.info(` saving to "${gzFilepath}"...`)
await storage.save(gzFilename, compressed) await storage.save(gzFilename, compressed)
} }
} }
} }

View File

@@ -1,55 +1,55 @@
type Column = { interface Column {
name: string name: string
nowrap?: boolean nowrap?: boolean
align?: string align?: string
colspan?: number colspan?: number
} }
type DataItem = { type DataItem = {
value: string value: string
nowrap?: boolean nowrap?: boolean
align?: string align?: string
colspan?: number colspan?: number
}[] }[]
export class HTMLTable { export class HTMLTable {
data: DataItem[] data: DataItem[]
columns: Column[] columns: Column[]
constructor(data: DataItem[], columns: Column[]) { constructor(data: DataItem[], columns: Column[]) {
this.data = data this.data = data
this.columns = columns this.columns = columns
} }
toString() { toString() {
let output = '<table>\r\n' let output = '<table>\r\n'
output += ' <thead>\r\n <tr>' output += ' <thead>\r\n <tr>'
for (const column of this.columns) { for (const column of this.columns) {
const nowrap = column.nowrap ? ' nowrap' : '' const nowrap = column.nowrap ? ' nowrap' : ''
const align = column.align ? ` align="${column.align}"` : '' const align = column.align ? ` align="${column.align}"` : ''
const colspan = column.colspan ? ` colspan="${column.colspan}"` : '' const colspan = column.colspan ? ` colspan="${column.colspan}"` : ''
output += `<th${align}${nowrap}${colspan}>${column.name}</th>` output += `<th${align}${nowrap}${colspan}>${column.name}</th>`
} }
output += '</tr>\r\n </thead>\r\n' output += '</tr>\r\n </thead>\r\n'
output += ' <tbody>\r\n' output += ' <tbody>\r\n'
for (const row of this.data) { for (const row of this.data) {
output += ' <tr>' output += ' <tr>'
for (const item of row) { for (const item of row) {
const nowrap = item.nowrap ? ' nowrap' : '' const nowrap = item.nowrap ? ' nowrap' : ''
const align = item.align ? ` align="${item.align}"` : '' const align = item.align ? ` align="${item.align}"` : ''
const colspan = item.colspan ? ` colspan="${item.colspan}"` : '' const colspan = item.colspan ? ` colspan="${item.colspan}"` : ''
output += `<td${align}${nowrap}${colspan}>${item.value}</td>` output += `<td${align}${nowrap}${colspan}>${item.value}</td>`
} }
output += '</tr>\r\n' output += '</tr>\r\n'
} }
output += ' </tbody>\r\n' output += ' </tbody>\r\n'
output += '</table>' output += '</table>'
return output return output
} }
} }

View File

@@ -1,14 +1,14 @@
export * from './apiClient' export * from './apiClient'
export * from './channelsParser' export * from './channelsParser'
export * from './configLoader' export * from './configLoader'
export * from './dataLoader' export * from './dataLoader'
export * from './dataProcessor' export * from './dataProcessor'
export * from './grabber' export * from './grabber'
export * from './guideManager' export * from './guideManager'
export * from './htmlTable' export * from './htmlTable'
export * from './issueLoader' export * from './issueLoader'
export * from './issueParser' export * from './issueParser'
export * from './job' export * from './job'
export * from './proxyParser' export * from './proxyParser'
export * from './queue' export * from './queue'
export * from './queueCreator' export * from './queueCreator'

View File

@@ -1,37 +1,37 @@
import { restEndpointMethods } from '@octokit/plugin-rest-endpoint-methods' import { restEndpointMethods } from '@octokit/plugin-rest-endpoint-methods'
import { paginateRest } from '@octokit/plugin-paginate-rest' import { paginateRest } from '@octokit/plugin-paginate-rest'
import { TESTING, OWNER, REPO } from '../constants' import { TESTING, OWNER, REPO } from '../constants'
import { Collection } from '@freearhey/core' import { Collection } from '@freearhey/core'
import { Octokit } from '@octokit/core' import { Octokit } from '@octokit/core'
import { IssueParser } from './' import { IssueParser } from './'
const CustomOctokit = Octokit.plugin(paginateRest, restEndpointMethods) const CustomOctokit = Octokit.plugin(paginateRest, restEndpointMethods)
const octokit = new CustomOctokit() const octokit = new CustomOctokit()
export class IssueLoader { export class IssueLoader {
async load(props?: { labels: string[] | string }) { async load(props?: { labels: string[] | string }) {
let labels = '' let labels = ''
if (props && props.labels) { if (props && props.labels) {
labels = Array.isArray(props.labels) ? props.labels.join(',') : props.labels labels = Array.isArray(props.labels) ? props.labels.join(',') : props.labels
} }
let issues: object[] = [] let issues: object[] = []
if (TESTING) { if (TESTING) {
issues = (await import('../../tests/__data__/input/sites_update/issues.mjs')).default issues = (await import('../../tests/__data__/input/sites_update/issues.mjs')).default
} else { } else {
issues = await octokit.paginate(octokit.rest.issues.listForRepo, { issues = await octokit.paginate(octokit.rest.issues.listForRepo, {
owner: OWNER, owner: OWNER,
repo: REPO, repo: REPO,
per_page: 100, per_page: 100,
labels, labels,
state: 'open', state: 'open',
headers: { headers: {
'X-GitHub-Api-Version': '2022-11-28' 'X-GitHub-Api-Version': '2022-11-28'
} }
}) })
} }
const parser = new IssueParser() const parser = new IssueParser()
return new Collection(issues).map(parser.parse) return new Collection(issues).map(parser.parse)
} }
} }

View File

@@ -1,34 +1,34 @@
import { Dictionary } from '@freearhey/core' import { Dictionary } from '@freearhey/core'
import { Issue } from '../models' import { Issue } from '../models'
const FIELDS = new Dictionary({ const FIELDS = new Dictionary({
Site: 'site' Site: 'site'
}) })
export class IssueParser { export class IssueParser {
parse(issue: { number: number; body: string; labels: { name: string }[] }): Issue { parse(issue: { number: number; body: string; labels: { name: string }[] }): Issue {
const fields = issue.body.split('###') const fields = issue.body.split('###')
const data = new Dictionary() const data = new Dictionary()
fields.forEach((field: string) => { fields.forEach((field: string) => {
const parsed = field.split(/\r?\n/).filter(Boolean) const parsed = field.split(/\r?\n/).filter(Boolean)
let _label = parsed.shift() let _label = parsed.shift()
_label = _label ? _label.trim() : '' _label = _label ? _label.trim() : ''
let _value = parsed.join('\r\n') let _value = parsed.join('\r\n')
_value = _value ? _value.trim() : '' _value = _value ? _value.trim() : ''
if (!_label || !_value) return data if (!_label || !_value) return data
const id: string = FIELDS.get(_label) const id: string = FIELDS.get(_label)
const value: string = _value === '_No response_' || _value === 'None' ? '' : _value const value: string = _value === '_No response_' || _value === 'None' ? '' : _value
if (!id) return if (!id) return
data.set(id, value) data.set(id, value)
}) })
const labels = issue.labels.map(label => label.name) const labels = issue.labels.map(label => label.name)
return new Issue({ number: issue.number, labels, data }) return new Issue({ number: issue.number, labels, data })
} }
} }

View File

@@ -1,34 +1,34 @@
import { Logger } from '@freearhey/core' import { Logger } from '@freearhey/core'
import { Queue, Grabber, GuideManager } from '.' import { Queue, Grabber, GuideManager } from '.'
import { GrabOptions } from '../commands/epg/grab' import { GrabOptions } from '../commands/epg/grab'
type JobProps = { interface JobProps {
options: GrabOptions options: GrabOptions
logger: Logger logger: Logger
queue: Queue queue: Queue
} }
export class Job { export class Job {
options: GrabOptions options: GrabOptions
logger: Logger logger: Logger
grabber: Grabber grabber: Grabber
constructor({ queue, logger, options }: JobProps) { constructor({ queue, logger, options }: JobProps) {
this.options = options this.options = options
this.logger = logger this.logger = logger
this.grabber = new Grabber({ logger, queue, options }) this.grabber = new Grabber({ logger, queue, options })
} }
async run() { async run() {
const { channels, programs } = await this.grabber.grab() const { channels, programs } = await this.grabber.grab()
const manager = new GuideManager({ const manager = new GuideManager({
channels, channels,
programs, programs,
options: this.options, options: this.options,
logger: this.logger logger: this.logger
}) })
await manager.createGuides() await manager.createGuides()
} }
} }

View File

@@ -1,31 +1,31 @@
import { URL } from 'node:url' import { URL } from 'node:url'
type ProxyParserResult = { interface ProxyParserResult {
protocol: string | null protocol: string | null
auth?: { auth?: {
username?: string username?: string
password?: string password?: string
} }
host: string host: string
port: number | null port: number | null
} }
export class ProxyParser { export class ProxyParser {
parse(_url: string): ProxyParserResult { parse(_url: string): ProxyParserResult {
const parsed = new URL(_url) const parsed = new URL(_url)
const result: ProxyParserResult = { const result: ProxyParserResult = {
protocol: parsed.protocol.replace(':', '') || null, protocol: parsed.protocol.replace(':', '') || null,
host: parsed.hostname, host: parsed.hostname,
port: parsed.port ? parseInt(parsed.port) : null port: parsed.port ? parseInt(parsed.port) : null
} }
if (parsed.username || parsed.password) { if (parsed.username || parsed.password) {
result.auth = {} result.auth = {}
if (parsed.username) result.auth.username = parsed.username if (parsed.username) result.auth.username = parsed.username
if (parsed.password) result.auth.password = parsed.password if (parsed.password) result.auth.password = parsed.password
} }
return result return result
} }
} }

View File

@@ -1,45 +1,45 @@
import { Dictionary } from '@freearhey/core' import { Dictionary } from '@freearhey/core'
import { SiteConfig, Channel } from 'epg-grabber' import { SiteConfig, Channel } from 'epg-grabber'
export type QueueItem = { export interface QueueItem {
channel: Channel channel: Channel
date: string date: string
config: SiteConfig config: SiteConfig
error: string | null error: string | null
} }
export class Queue { export class Queue {
_data: Dictionary _data: Dictionary
constructor() { constructor() {
this._data = new Dictionary() this._data = new Dictionary()
} }
missing(key: string): boolean { missing(key: string): boolean {
return this._data.missing(key) return this._data.missing(key)
} }
add( add(
key: string, key: string,
{ channel, config, date }: { channel: Channel; date: string | null; config: SiteConfig } { channel, config, date }: { channel: Channel; date: string | null; config: SiteConfig }
) { ) {
this._data.set(key, { this._data.set(key, {
channel, channel,
date, date,
config, config,
error: null error: null
}) })
} }
size(): number { size(): number {
return Object.values(this._data.data()).length return Object.values(this._data.data()).length
} }
items(): QueueItem[] { items(): QueueItem[] {
return Object.values(this._data.data()) as QueueItem[] return Object.values(this._data.data()) as QueueItem[]
} }
isEmpty(): boolean { isEmpty(): boolean {
return this.size() === 0 return this.size() === 0
} }
} }

View File

@@ -1,63 +1,63 @@
import { Storage, Collection, DateTime, Logger } from '@freearhey/core' import { Storage, Collection, DateTime, Logger } from '@freearhey/core'
import { SITES_DIR, DATA_DIR } from '../constants' import { SITES_DIR, DATA_DIR } from '../constants'
import { GrabOptions } from '../commands/epg/grab' import { GrabOptions } from '../commands/epg/grab'
import { ConfigLoader, Queue } from './' import { ConfigLoader, Queue } from './'
import { SiteConfig } from 'epg-grabber' import { SiteConfig } from 'epg-grabber'
import path from 'path' import path from 'path'
type QueueCreatorProps = { interface QueueCreatorProps {
logger: Logger logger: Logger
options: GrabOptions options: GrabOptions
channels: Collection channels: Collection
} }
export class QueueCreator { export class QueueCreator {
configLoader: ConfigLoader configLoader: ConfigLoader
logger: Logger logger: Logger
sitesStorage: Storage sitesStorage: Storage
dataStorage: Storage dataStorage: Storage
channels: Collection channels: Collection
options: GrabOptions options: GrabOptions
constructor({ channels, logger, options }: QueueCreatorProps) { constructor({ channels, logger, options }: QueueCreatorProps) {
this.channels = channels this.channels = channels
this.logger = logger this.logger = logger
this.sitesStorage = new Storage() this.sitesStorage = new Storage()
this.dataStorage = new Storage(DATA_DIR) this.dataStorage = new Storage(DATA_DIR)
this.options = options this.options = options
this.configLoader = new ConfigLoader() this.configLoader = new ConfigLoader()
} }
async create(): Promise<Queue> { async create(): Promise<Queue> {
let index = 0 let index = 0
const queue = new Queue() const queue = new Queue()
for (const channel of this.channels.all()) { for (const channel of this.channels.all()) {
channel.index = index++ channel.index = index++
if (!channel.site || !channel.site_id || !channel.name) continue if (!channel.site || !channel.site_id || !channel.name) continue
const configPath = path.resolve(SITES_DIR, `${channel.site}/${channel.site}.config.js`) const configPath = path.resolve(SITES_DIR, `${channel.site}/${channel.site}.config.js`)
const config: SiteConfig = await this.configLoader.load(configPath) const config: SiteConfig = await this.configLoader.load(configPath)
if (!channel.xmltv_id) { if (!channel.xmltv_id) {
channel.xmltv_id = channel.site_id channel.xmltv_id = channel.site_id
} }
const days = this.options.days || config.days || 1 const days = this.options.days || config.days || 1
const currDate = new DateTime(process.env.CURR_DATE || new Date().toISOString()) const currDate = new DateTime(process.env.CURR_DATE || new Date().toISOString())
const dates = Array.from({ length: days }, (_, day) => currDate.add(day, 'd')) const dates = Array.from({ length: days }, (_, day) => currDate.add(day, 'd'))
dates.forEach((date: DateTime) => { dates.forEach((date: DateTime) => {
const dateString = date.toJSON() const dateString = date.toJSON()
const key = `${channel.site}:${channel.lang}:${channel.xmltv_id}:${dateString}` const key = `${channel.site}:${channel.lang}:${channel.xmltv_id}:${dateString}`
if (queue.missing(key)) { if (queue.missing(key)) {
queue.add(key, { queue.add(key, {
channel, channel,
date: dateString, date: dateString,
config config
}) })
} }
}) })
} }
return queue return queue
} }
} }

View File

@@ -1,164 +1,164 @@
import { ChannelData, ChannelSearchableData } from '../types/channel' import { ChannelData, ChannelSearchableData } from '../types/channel'
import { Collection, Dictionary } from '@freearhey/core' import { Collection, Dictionary } from '@freearhey/core'
import { Stream, Feed, Logo, GuideChannel } from './' import { Stream, Feed, Logo, GuideChannel } from './'
export class Channel { export class Channel {
id?: string id?: string
name?: string name?: string
altNames?: Collection altNames?: Collection
network?: string network?: string
owners?: Collection owners?: Collection
countryCode?: string countryCode?: string
subdivisionCode?: string subdivisionCode?: string
cityName?: string cityName?: string
categoryIds?: Collection categoryIds?: Collection
isNSFW: boolean = false isNSFW = false
launched?: string launched?: string
closed?: string closed?: string
replacedBy?: string replacedBy?: string
website?: string website?: string
feeds?: Collection feeds?: Collection
logos: Collection = new Collection() logos: Collection = new Collection()
constructor(data?: ChannelData) { constructor(data?: ChannelData) {
if (!data) return if (!data) return
this.id = data.id this.id = data.id
this.name = data.name this.name = data.name
this.altNames = new Collection(data.alt_names) this.altNames = new Collection(data.alt_names)
this.network = data.network || undefined this.network = data.network || undefined
this.owners = new Collection(data.owners) this.owners = new Collection(data.owners)
this.countryCode = data.country this.countryCode = data.country
this.subdivisionCode = data.subdivision || undefined this.subdivisionCode = data.subdivision || undefined
this.cityName = data.city || undefined this.cityName = data.city || undefined
this.categoryIds = new Collection(data.categories) this.categoryIds = new Collection(data.categories)
this.isNSFW = data.is_nsfw this.isNSFW = data.is_nsfw
this.launched = data.launched || undefined this.launched = data.launched || undefined
this.closed = data.closed || undefined this.closed = data.closed || undefined
this.replacedBy = data.replaced_by || undefined this.replacedBy = data.replaced_by || undefined
this.website = data.website || undefined this.website = data.website || undefined
} }
withFeeds(feedsGroupedByChannelId: Dictionary): this { withFeeds(feedsGroupedByChannelId: Dictionary): this {
if (this.id) this.feeds = new Collection(feedsGroupedByChannelId.get(this.id)) if (this.id) this.feeds = new Collection(feedsGroupedByChannelId.get(this.id))
return this return this
} }
withLogos(logosGroupedByChannelId: Dictionary): this { withLogos(logosGroupedByChannelId: Dictionary): this {
if (this.id) this.logos = new Collection(logosGroupedByChannelId.get(this.id)) if (this.id) this.logos = new Collection(logosGroupedByChannelId.get(this.id))
return this return this
} }
getFeeds(): Collection { getFeeds(): Collection {
if (!this.feeds) return new Collection() if (!this.feeds) return new Collection()
return this.feeds return this.feeds
} }
getGuideChannels(): Collection { getGuideChannels(): Collection {
let channels = new Collection() let channels = new Collection()
this.getFeeds().forEach((feed: Feed) => { this.getFeeds().forEach((feed: Feed) => {
channels = channels.concat(feed.getGuideChannels()) channels = channels.concat(feed.getGuideChannels())
}) })
return channels return channels
} }
getGuideChannelNames(): Collection { getGuideChannelNames(): Collection {
return this.getGuideChannels() return this.getGuideChannels()
.map((channel: GuideChannel) => channel.siteName) .map((channel: GuideChannel) => channel.siteName)
.uniq() .uniq()
} }
getStreams(): Collection { getStreams(): Collection {
let streams = new Collection() let streams = new Collection()
this.getFeeds().forEach((feed: Feed) => { this.getFeeds().forEach((feed: Feed) => {
streams = streams.concat(feed.getStreams()) streams = streams.concat(feed.getStreams())
}) })
return streams return streams
} }
getStreamNames(): Collection { getStreamNames(): Collection {
return this.getStreams() return this.getStreams()
.map((stream: Stream) => stream.getName()) .map((stream: Stream) => stream.getName())
.uniq() .uniq()
} }
getFeedFullNames(): Collection { getFeedFullNames(): Collection {
return this.getFeeds() return this.getFeeds()
.map((feed: Feed) => feed.getFullName()) .map((feed: Feed) => feed.getFullName())
.uniq() .uniq()
} }
getName(): string { getName(): string {
return this.name || '' return this.name || ''
} }
getId(): string { getId(): string {
return this.id || '' return this.id || ''
} }
getAltNames(): Collection { getAltNames(): Collection {
return this.altNames || new Collection() return this.altNames || new Collection()
} }
getLogos(): Collection { getLogos(): Collection {
function feed(logo: Logo): number { function feed(logo: Logo): number {
if (!logo.feed) return 1 if (!logo.feed) return 1
if (logo.feed.isMain) return 1 if (logo.feed.isMain) return 1
return 0 return 0
} }
function format(logo: Logo): number { function format(logo: Logo): number {
const levelByFormat: { [key: string]: number } = { const levelByFormat: Record<string, number> = {
SVG: 0, SVG: 0,
PNG: 3, PNG: 3,
APNG: 1, APNG: 1,
WebP: 1, WebP: 1,
AVIF: 1, AVIF: 1,
JPEG: 2, JPEG: 2,
GIF: 1 GIF: 1
} }
return logo.format ? levelByFormat[logo.format] : 0 return logo.format ? levelByFormat[logo.format] : 0
} }
function size(logo: Logo): number { function size(logo: Logo): number {
return Math.abs(512 - logo.width) + Math.abs(512 - logo.height) return Math.abs(512 - logo.width) + Math.abs(512 - logo.height)
} }
return this.logos.orderBy([feed, format, size], ['desc', 'desc', 'asc'], false) return this.logos.orderBy([feed, format, size], ['desc', 'desc', 'asc'], false)
} }
getLogo(): Logo | undefined { getLogo(): Logo | undefined {
return this.getLogos().first() return this.getLogos().first()
} }
hasLogo(): boolean { hasLogo(): boolean {
return this.getLogos().notEmpty() return this.getLogos().notEmpty()
} }
getLogoUrl(): string { getLogoUrl(): string {
const logo = this.getLogo() const logo = this.getLogo()
if (!logo) return '' if (!logo) return ''
return logo.url || '' return logo.url || ''
} }
getSearchable(): ChannelSearchableData { getSearchable(): ChannelSearchableData {
return { return {
id: this.getId(), id: this.getId(),
name: this.getName(), name: this.getName(),
altNames: this.getAltNames().all(), altNames: this.getAltNames().all(),
guideNames: this.getGuideChannelNames().all(), guideNames: this.getGuideChannelNames().all(),
streamNames: this.getStreamNames().all(), streamNames: this.getStreamNames().all(),
feedFullNames: this.getFeedFullNames().all() feedFullNames: this.getFeedFullNames().all()
} }
} }
} }

View File

@@ -1,77 +1,77 @@
import { Collection } from '@freearhey/core' import { Collection } from '@freearhey/core'
import epgGrabber from 'epg-grabber' import epgGrabber from 'epg-grabber'
export class ChannelList { export class ChannelList {
channels: Collection = new Collection() channels: Collection = new Collection()
constructor(data: { channels: epgGrabber.Channel[] }) { constructor(data: { channels: epgGrabber.Channel[] }) {
this.channels = new Collection(data.channels) this.channels = new Collection(data.channels)
} }
add(channel: epgGrabber.Channel): this { add(channel: epgGrabber.Channel): this {
this.channels.add(channel) this.channels.add(channel)
return this return this
} }
get(siteId: string): epgGrabber.Channel | undefined { get(siteId: string): epgGrabber.Channel | undefined {
return this.channels.find((channel: epgGrabber.Channel) => channel.site_id == siteId) return this.channels.find((channel: epgGrabber.Channel) => channel.site_id == siteId)
} }
sort(): this { sort(): this {
this.channels = this.channels.orderBy([ this.channels = this.channels.orderBy([
(channel: epgGrabber.Channel) => channel.lang || '_', (channel: epgGrabber.Channel) => channel.lang || '_',
(channel: epgGrabber.Channel) => (channel.xmltv_id ? channel.xmltv_id.toLowerCase() : '0'), (channel: epgGrabber.Channel) => (channel.xmltv_id ? channel.xmltv_id.toLowerCase() : '0'),
(channel: epgGrabber.Channel) => channel.site_id (channel: epgGrabber.Channel) => channel.site_id
]) ])
return this return this
} }
toString() { toString() {
function escapeString(value: string, defaultValue: string = '') { function escapeString(value: string, defaultValue = '') {
if (!value) return defaultValue if (!value) return defaultValue
const regex = new RegExp( const regex = new RegExp(
'((?:[\0-\x08\x0B\f\x0E-\x1F\uFFFD\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]))|([\\x7F-\\x84]|[\\x86-\\x9F]|[\\uFDD0-\\uFDEF]|(?:\\uD83F[\\uDFFE\\uDFFF])|(?:\\uD87F[\\uDF' + '((?:[\0-\x08\x0B\f\x0E-\x1F\uFFFD\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]))|([\\x7F-\\x84]|[\\x86-\\x9F]|[\\uFDD0-\\uFDEF]|(?:\\uD83F[\\uDFFE\\uDFFF])|(?:\\uD87F[\\uDF' +
'FE\\uDFFF])|(?:\\uD8BF[\\uDFFE\\uDFFF])|(?:\\uD8FF[\\uDFFE\\uDFFF])|(?:\\uD93F[\\uDFFE\\uD' + 'FE\\uDFFF])|(?:\\uD8BF[\\uDFFE\\uDFFF])|(?:\\uD8FF[\\uDFFE\\uDFFF])|(?:\\uD93F[\\uDFFE\\uD' +
'FFF])|(?:\\uD97F[\\uDFFE\\uDFFF])|(?:\\uD9BF[\\uDFFE\\uDFFF])|(?:\\uD9FF[\\uDFFE\\uDFFF])' + 'FFF])|(?:\\uD97F[\\uDFFE\\uDFFF])|(?:\\uD9BF[\\uDFFE\\uDFFF])|(?:\\uD9FF[\\uDFFE\\uDFFF])' +
'|(?:\\uDA3F[\\uDFFE\\uDFFF])|(?:\\uDA7F[\\uDFFE\\uDFFF])|(?:\\uDABF[\\uDFFE\\uDFFF])|(?:\\' + '|(?:\\uDA3F[\\uDFFE\\uDFFF])|(?:\\uDA7F[\\uDFFE\\uDFFF])|(?:\\uDABF[\\uDFFE\\uDFFF])|(?:\\' +
'uDAFF[\\uDFFE\\uDFFF])|(?:\\uDB3F[\\uDFFE\\uDFFF])|(?:\\uDB7F[\\uDFFE\\uDFFF])|(?:\\uDBBF' + 'uDAFF[\\uDFFE\\uDFFF])|(?:\\uDB3F[\\uDFFE\\uDFFF])|(?:\\uDB7F[\\uDFFE\\uDFFF])|(?:\\uDBBF' +
'[\\uDFFE\\uDFFF])|(?:\\uDBFF[\\uDFFE\\uDFFF])(?:[\\0-\\t\\x0B\\f\\x0E-\\u2027\\u202A-\\uD7FF\\' + '[\\uDFFE\\uDFFF])|(?:\\uDBFF[\\uDFFE\\uDFFF])(?:[\\0-\\t\\x0B\\f\\x0E-\\u2027\\u202A-\\uD7FF\\' +
'uE000-\\uFFFF]|[\\uD800-\\uDBFF][\\uDC00-\\uDFFF]|[\\uD800-\\uDBFF](?![\\uDC00-\\uDFFF])|' + 'uE000-\\uFFFF]|[\\uD800-\\uDBFF][\\uDC00-\\uDFFF]|[\\uD800-\\uDBFF](?![\\uDC00-\\uDFFF])|' +
'(?:[^\\uD800-\\uDBFF]|^)[\\uDC00-\\uDFFF]))', '(?:[^\\uD800-\\uDBFF]|^)[\\uDC00-\\uDFFF]))',
'g' 'g'
) )
value = String(value || '').replace(regex, '') value = String(value || '').replace(regex, '')
return value return value
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
.replace(/</g, '&lt;') .replace(/</g, '&lt;')
.replace(/>/g, '&gt;') .replace(/>/g, '&gt;')
.replace(/"/g, '&quot;') .replace(/"/g, '&quot;')
.replace(/'/g, '&apos;') .replace(/'/g, '&apos;')
.replace(/\n|\r/g, ' ') .replace(/\n|\r/g, ' ')
.replace(/ +/g, ' ') .replace(/ +/g, ' ')
.trim() .trim()
} }
let output = '<?xml version="1.0" encoding="UTF-8"?>\r\n<channels>\r\n' let output = '<?xml version="1.0" encoding="UTF-8"?>\r\n<channels>\r\n'
this.channels.forEach((channel: epgGrabber.Channel) => { this.channels.forEach((channel: epgGrabber.Channel) => {
const logo = channel.logo ? ` logo="${channel.logo}"` : '' const logo = channel.logo ? ` logo="${channel.logo}"` : ''
const xmltv_id = channel.xmltv_id ? escapeString(channel.xmltv_id) : '' const xmltv_id = channel.xmltv_id ? escapeString(channel.xmltv_id) : ''
const lang = channel.lang || '' const lang = channel.lang || ''
const site_id = channel.site_id || '' const site_id = channel.site_id || ''
const site = channel.site || '' const site = channel.site || ''
const displayName = channel.name ? escapeString(channel.name) : '' const displayName = channel.name ? escapeString(channel.name) : ''
output += ` <channel site="${site}" lang="${lang}" xmltv_id="${xmltv_id}" site_id="${site_id}"${logo}>${displayName}</channel>\r\n` output += ` <channel site="${site}" lang="${lang}" xmltv_id="${xmltv_id}" site_id="${site_id}"${logo}>${displayName}</channel>\r\n`
}) })
output += '</channels>\r\n' output += '</channels>\r\n'
return output return output
} }
} }

View File

@@ -1,124 +1,124 @@
import { Collection, Dictionary } from '@freearhey/core' import { Collection, Dictionary } from '@freearhey/core'
import { FeedData } from '../types/feed' import { FeedData } from '../types/feed'
import { Logo, Channel } from '.' import { Logo, Channel } from '.'
export class Feed { export class Feed {
channelId: string channelId: string
channel?: Channel channel?: Channel
id: string id: string
name: string name: string
isMain: boolean isMain: boolean
broadcastAreaCodes: Collection broadcastAreaCodes: Collection
languageCodes: Collection languageCodes: Collection
timezoneIds: Collection timezoneIds: Collection
videoFormat: string videoFormat: string
guideChannels?: Collection guideChannels?: Collection
streams?: Collection streams?: Collection
logos: Collection = new Collection() logos: Collection = new Collection()
constructor(data: FeedData) { constructor(data: FeedData) {
this.channelId = data.channel this.channelId = data.channel
this.id = data.id this.id = data.id
this.name = data.name this.name = data.name
this.isMain = data.is_main this.isMain = data.is_main
this.broadcastAreaCodes = new Collection(data.broadcast_area) this.broadcastAreaCodes = new Collection(data.broadcast_area)
this.languageCodes = new Collection(data.languages) this.languageCodes = new Collection(data.languages)
this.timezoneIds = new Collection(data.timezones) this.timezoneIds = new Collection(data.timezones)
this.videoFormat = data.video_format this.videoFormat = data.video_format
} }
withChannel(channelsKeyById: Dictionary): this { withChannel(channelsKeyById: Dictionary): this {
this.channel = channelsKeyById.get(this.channelId) this.channel = channelsKeyById.get(this.channelId)
return this return this
} }
withStreams(streamsGroupedById: Dictionary): this { withStreams(streamsGroupedById: Dictionary): this {
this.streams = new Collection(streamsGroupedById.get(`${this.channelId}@${this.id}`)) this.streams = new Collection(streamsGroupedById.get(`${this.channelId}@${this.id}`))
if (this.isMain) { if (this.isMain) {
this.streams = this.streams.concat(new Collection(streamsGroupedById.get(this.channelId))) this.streams = this.streams.concat(new Collection(streamsGroupedById.get(this.channelId)))
} }
return this return this
} }
withGuideChannels(guideChannelsGroupedByStreamId: Dictionary): this { withGuideChannels(guideChannelsGroupedByStreamId: Dictionary): this {
this.guideChannels = new Collection( this.guideChannels = new Collection(
guideChannelsGroupedByStreamId.get(`${this.channelId}@${this.id}`) guideChannelsGroupedByStreamId.get(`${this.channelId}@${this.id}`)
) )
if (this.isMain) { if (this.isMain) {
this.guideChannels = this.guideChannels.concat( this.guideChannels = this.guideChannels.concat(
new Collection(guideChannelsGroupedByStreamId.get(this.channelId)) new Collection(guideChannelsGroupedByStreamId.get(this.channelId))
) )
} }
return this return this
} }
withLogos(logosGroupedByStreamId: Dictionary): this { withLogos(logosGroupedByStreamId: Dictionary): this {
this.logos = new Collection(logosGroupedByStreamId.get(this.getStreamId())) this.logos = new Collection(logosGroupedByStreamId.get(this.getStreamId()))
return this return this
} }
getGuideChannels(): Collection { getGuideChannels(): Collection {
if (!this.guideChannels) return new Collection() if (!this.guideChannels) return new Collection()
return this.guideChannels return this.guideChannels
} }
getStreams(): Collection { getStreams(): Collection {
if (!this.streams) return new Collection() if (!this.streams) return new Collection()
return this.streams return this.streams
} }
getFullName(): string { getFullName(): string {
if (!this.channel) return '' if (!this.channel) return ''
return `${this.channel.name} ${this.name}` return `${this.channel.name} ${this.name}`
} }
getStreamId(): string { getStreamId(): string {
return `${this.channelId}@${this.id}` return `${this.channelId}@${this.id}`
} }
getLogos(): Collection { getLogos(): Collection {
function format(logo: Logo): number { function format(logo: Logo): number {
const levelByFormat: { [key: string]: number } = { const levelByFormat: Record<string, number> = {
SVG: 0, SVG: 0,
PNG: 3, PNG: 3,
APNG: 1, APNG: 1,
WebP: 1, WebP: 1,
AVIF: 1, AVIF: 1,
JPEG: 2, JPEG: 2,
GIF: 1 GIF: 1
} }
return logo.format ? levelByFormat[logo.format] : 0 return logo.format ? levelByFormat[logo.format] : 0
} }
function size(logo: Logo): number { function size(logo: Logo): number {
return Math.abs(512 - logo.width) + Math.abs(512 - logo.height) return Math.abs(512 - logo.width) + Math.abs(512 - logo.height)
} }
return this.logos.orderBy([format, size], ['desc', 'asc'], false) return this.logos.orderBy([format, size], ['desc', 'asc'], false)
} }
getLogo(): Logo | undefined { getLogo(): Logo | undefined {
return this.getLogos().first() return this.getLogos().first()
} }
hasLogo(): boolean { hasLogo(): boolean {
return this.getLogos().notEmpty() return this.getLogos().notEmpty()
} }
getLogoUrl(): string { getLogoUrl(): string {
const logo = this.getLogo() const logo = this.getLogo()
if (!logo) return '' if (!logo) return ''
return logo.url || '' return logo.url || ''
} }
} }

View File

@@ -1,35 +1,35 @@
import { Collection, DateTime } from '@freearhey/core' import { Collection, DateTime } from '@freearhey/core'
import { generateXMLTV } from 'epg-grabber' import { generateXMLTV } from 'epg-grabber'
type GuideData = { interface GuideData {
channels: Collection channels: Collection
programs: Collection programs: Collection
filepath: string filepath: string
gzip: boolean gzip: boolean
} }
export class Guide { export class Guide {
channels: Collection channels: Collection
programs: Collection programs: Collection
filepath: string filepath: string
gzip: boolean gzip: boolean
constructor({ channels, programs, filepath, gzip }: GuideData) { constructor({ channels, programs, filepath, gzip }: GuideData) {
this.channels = channels this.channels = channels
this.programs = programs this.programs = programs
this.filepath = filepath this.filepath = filepath
this.gzip = gzip || false this.gzip = gzip || false
} }
toString() { toString() {
const currDate = new DateTime(process.env.CURR_DATE || new Date().toISOString(), { const currDate = new DateTime(process.env.CURR_DATE || new Date().toISOString(), {
timezone: 'UTC' timezone: 'UTC'
}) })
return generateXMLTV({ return generateXMLTV({
channels: this.channels.all(), channels: this.channels.all(),
programs: this.programs.all(), programs: this.programs.all(),
date: currDate.toJSON() date: currDate.toJSON()
}) })
} }
} }

View File

@@ -1,59 +1,59 @@
import { Dictionary } from '@freearhey/core' import { Dictionary } from '@freearhey/core'
import epgGrabber from 'epg-grabber' import epgGrabber from 'epg-grabber'
import { Feed, Channel } from '.' import { Feed, Channel } from '.'
export class GuideChannel { export class GuideChannel {
channelId?: string channelId?: string
channel?: Channel channel?: Channel
feedId?: string feedId?: string
feed?: Feed feed?: Feed
xmltvId?: string xmltvId?: string
languageCode?: string languageCode?: string
siteId?: string siteId?: string
logoUrl?: string logoUrl?: string
siteDomain?: string siteDomain?: string
siteName?: string siteName?: string
constructor(data: epgGrabber.Channel) { constructor(data: epgGrabber.Channel) {
const [channelId, feedId] = data.xmltv_id ? data.xmltv_id.split('@') : [undefined, undefined] const [channelId, feedId] = data.xmltv_id ? data.xmltv_id.split('@') : [undefined, undefined]
this.channelId = channelId this.channelId = channelId
this.feedId = feedId this.feedId = feedId
this.xmltvId = data.xmltv_id this.xmltvId = data.xmltv_id
this.languageCode = data.lang this.languageCode = data.lang
this.siteId = data.site_id this.siteId = data.site_id
this.logoUrl = data.logo this.logoUrl = data.logo
this.siteDomain = data.site this.siteDomain = data.site
this.siteName = data.name this.siteName = data.name
} }
withChannel(channelsKeyById: Dictionary): this { withChannel(channelsKeyById: Dictionary): this {
if (this.channelId) this.channel = channelsKeyById.get(this.channelId) if (this.channelId) this.channel = channelsKeyById.get(this.channelId)
return this return this
} }
withFeed(feedsKeyByStreamId: Dictionary): this { withFeed(feedsKeyByStreamId: Dictionary): this {
if (this.feedId) this.feed = feedsKeyByStreamId.get(this.getStreamId()) if (this.feedId) this.feed = feedsKeyByStreamId.get(this.getStreamId())
return this return this
} }
getStreamId(): string { getStreamId(): string {
if (!this.channelId) return '' if (!this.channelId) return ''
if (!this.feedId) return this.channelId if (!this.feedId) return this.channelId
return `${this.channelId}@${this.feedId}` return `${this.channelId}@${this.feedId}`
} }
toJSON() { toJSON() {
return { return {
channel: this.channelId || null, channel: this.channelId || null,
feed: this.feedId || null, feed: this.feedId || null,
site: this.siteDomain || '', site: this.siteDomain || '',
site_id: this.siteId || '', site_id: this.siteId || '',
site_name: this.siteName || '', site_name: this.siteName || '',
lang: this.languageCode || '' lang: this.languageCode || ''
} }
} }
} }

View File

@@ -1,9 +1,9 @@
export * from './channel' export * from './channel'
export * from './feed' export * from './feed'
export * from './guide' export * from './guide'
export * from './guideChannel' export * from './guideChannel'
export * from './issue' export * from './issue'
export * from './logo' export * from './logo'
export * from './site' export * from './site'
export * from './stream' export * from './stream'
export * from './channelList' export * from './channelList'

View File

@@ -1,24 +1,24 @@
import { Dictionary } from '@freearhey/core' import { Dictionary } from '@freearhey/core'
import { OWNER, REPO } from '../constants' import { OWNER, REPO } from '../constants'
type IssueProps = { interface IssueProps {
number: number number: number
labels: string[] labels: string[]
data: Dictionary data: Dictionary
} }
export class Issue { export class Issue {
number: number number: number
labels: string[] labels: string[]
data: Dictionary data: Dictionary
constructor({ number, labels, data }: IssueProps) { constructor({ number, labels, data }: IssueProps) {
this.number = number this.number = number
this.labels = labels this.labels = labels
this.data = data this.data = data
} }
getURL() { getURL() {
return `https://github.com/${OWNER}/${REPO}/issues/${this.number}` return `https://github.com/${OWNER}/${REPO}/issues/${this.number}`
} }
} }

View File

@@ -1,41 +1,41 @@
import { Collection, type Dictionary } from '@freearhey/core' import { Collection, type Dictionary } from '@freearhey/core'
import type { LogoData } from '../types/logo' import type { LogoData } from '../types/logo'
import { type Feed } from './feed' import { type Feed } from './feed'
export class Logo { export class Logo {
channelId?: string channelId?: string
feedId?: string feedId?: string
feed?: Feed feed?: Feed
tags: Collection = new Collection() tags: Collection = new Collection()
width: number = 0 width = 0
height: number = 0 height = 0
format?: string format?: string
url?: string url?: string
constructor(data?: LogoData) { constructor(data?: LogoData) {
if (!data) return if (!data) return
this.channelId = data.channel this.channelId = data.channel
this.feedId = data.feed || undefined this.feedId = data.feed || undefined
this.tags = new Collection(data.tags) this.tags = new Collection(data.tags)
this.width = data.width this.width = data.width
this.height = data.height this.height = data.height
this.format = data.format || undefined this.format = data.format || undefined
this.url = data.url this.url = data.url
} }
withFeed(feedsKeyByStreamId: Dictionary): this { withFeed(feedsKeyByStreamId: Dictionary): this {
if (!this.feedId) return this if (!this.feedId) return this
this.feed = feedsKeyByStreamId.get(this.getStreamId()) this.feed = feedsKeyByStreamId.get(this.getStreamId())
return this return this
} }
getStreamId(): string { getStreamId(): string {
if (!this.channelId) return '' if (!this.channelId) return ''
if (!this.feedId) return this.channelId if (!this.feedId) return this.channelId
return `${this.channelId}@${this.feedId}` return `${this.channelId}@${this.feedId}`
} }
} }

View File

@@ -1,63 +1,63 @@
import { Collection } from '@freearhey/core' import { Collection } from '@freearhey/core'
import { Issue } from './' import { Issue } from './'
enum StatusCode { enum StatusCode {
DOWN = 'down', DOWN = 'down',
WARNING = 'warning', WARNING = 'warning',
OK = 'ok' OK = 'ok'
} }
type Status = { interface Status {
code: StatusCode code: StatusCode
emoji: string emoji: string
} }
type SiteProps = { interface SiteProps {
domain: string domain: string
totalChannels?: number totalChannels?: number
markedChannels?: number markedChannels?: number
issues: Collection issues: Collection
} }
export class Site { export class Site {
domain: string domain: string
totalChannels: number totalChannels: number
markedChannels: number markedChannels: number
issues: Collection issues: Collection
constructor({ domain, totalChannels = 0, markedChannels = 0, issues }: SiteProps) { constructor({ domain, totalChannels = 0, markedChannels = 0, issues }: SiteProps) {
this.domain = domain this.domain = domain
this.totalChannels = totalChannels this.totalChannels = totalChannels
this.markedChannels = markedChannels this.markedChannels = markedChannels
this.issues = issues this.issues = issues
} }
getStatus(): Status { getStatus(): Status {
const issuesWithStatusDown = this.issues.filter((issue: Issue) => const issuesWithStatusDown = this.issues.filter((issue: Issue) =>
issue.labels.find(label => label === 'status:down') issue.labels.find(label => label === 'status:down')
) )
if (issuesWithStatusDown.notEmpty()) if (issuesWithStatusDown.notEmpty())
return { return {
code: StatusCode.DOWN, code: StatusCode.DOWN,
emoji: '🔴' emoji: '🔴'
} }
const issuesWithStatusWarning = this.issues.filter((issue: Issue) => const issuesWithStatusWarning = this.issues.filter((issue: Issue) =>
issue.labels.find(label => label === 'status:warning') issue.labels.find(label => label === 'status:warning')
) )
if (issuesWithStatusWarning.notEmpty()) if (issuesWithStatusWarning.notEmpty())
return { return {
code: StatusCode.WARNING, code: StatusCode.WARNING,
emoji: '🟡' emoji: '🟡'
} }
return { return {
code: StatusCode.OK, code: StatusCode.OK,
emoji: '🟢' emoji: '🟢'
} }
} }
getIssues(): Collection { getIssues(): Collection {
return this.issues.map((issue: Issue) => issue.getURL()) return this.issues.map((issue: Issue) => issue.getURL())
} }
} }

View File

@@ -1,58 +1,58 @@
import type { StreamData } from '../types/stream' import type { StreamData } from '../types/stream'
import { Feed, Channel } from './index' import { Feed, Channel } from './index'
export class Stream { export class Stream {
name?: string name?: string
url: string url: string
id?: string id?: string
channelId?: string channelId?: string
channel?: Channel channel?: Channel
feedId?: string feedId?: string
feed?: Feed feed?: Feed
filepath?: string filepath?: string
line?: number line?: number
label?: string label?: string
verticalResolution?: number verticalResolution?: number
isInterlaced?: boolean isInterlaced?: boolean
referrer?: string referrer?: string
userAgent?: string userAgent?: string
groupTitle: string = 'Undefined' groupTitle = 'Undefined'
removed: boolean = false removed = false
constructor(data: StreamData) { constructor(data: StreamData) {
const id = data.channel && data.feed ? [data.channel, data.feed].join('@') : data.channel const id = data.channel && data.feed ? [data.channel, data.feed].join('@') : data.channel
const { verticalResolution, isInterlaced } = parseQuality(data.quality) const { verticalResolution, isInterlaced } = parseQuality(data.quality)
this.id = id || undefined this.id = id || undefined
this.channelId = data.channel || undefined this.channelId = data.channel || undefined
this.feedId = data.feed || undefined this.feedId = data.feed || undefined
this.name = data.name || undefined this.name = data.name || undefined
this.url = data.url this.url = data.url
this.referrer = data.referrer || undefined this.referrer = data.referrer || undefined
this.userAgent = data.user_agent || undefined this.userAgent = data.user_agent || undefined
this.verticalResolution = verticalResolution || undefined this.verticalResolution = verticalResolution || undefined
this.isInterlaced = isInterlaced || undefined this.isInterlaced = isInterlaced || undefined
this.label = data.label || undefined this.label = data.label || undefined
} }
getId(): string { getId(): string {
return this.id || '' return this.id || ''
} }
getName(): string { getName(): string {
return this.name || '' return this.name || ''
} }
} }
function parseQuality(quality: string | null): { function parseQuality(quality: string | null): {
verticalResolution: number | null verticalResolution: number | null
isInterlaced: boolean | null isInterlaced: boolean | null
} { } {
if (!quality) return { verticalResolution: null, isInterlaced: null } if (!quality) return { verticalResolution: null, isInterlaced: null }
const [, verticalResolutionString] = quality.match(/^(\d+)/) || [null, undefined] const [, verticalResolutionString] = quality.match(/^(\d+)/) || [null, undefined]
const isInterlaced = /i$/i.test(quality) const isInterlaced = /i$/i.test(quality)
let verticalResolution = 0 let verticalResolution = 0
if (verticalResolutionString) verticalResolution = parseInt(verticalResolutionString) if (verticalResolutionString) verticalResolution = parseInt(verticalResolutionString)
return { verticalResolution, isInterlaced } return { verticalResolution, isInterlaced }
} }

View File

@@ -1,16 +1,16 @@
module.exports = { module.exports = {
site: '<DOMAIN>', site: '<DOMAIN>',
url({ channel, date }) { url({ channel, date }) {
return `https://example.com/api/${channel.site_id}/${date.format('YYYY-MM-DD')}` return `https://example.com/api/${channel.site_id}/${date.format('YYYY-MM-DD')}`
}, },
parser({ content }) { parser({ content }) {
try { try {
return JSON.parse(content) return JSON.parse(content)
} catch { } catch {
return [] return []
} }
}, },
channels() { channels() {
return [] return []
} }
} }

View File

@@ -1,38 +1,38 @@
const { parser, url } = require('./<DOMAIN>.config.js') const { parser, url } = require('./<DOMAIN>.config.js')
const dayjs = require('dayjs') const dayjs = require('dayjs')
const utc = require('dayjs/plugin/utc') const utc = require('dayjs/plugin/utc')
const customParseFormat = require('dayjs/plugin/customParseFormat') const customParseFormat = require('dayjs/plugin/customParseFormat')
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
dayjs.extend(utc) dayjs.extend(utc)
const date = dayjs.utc('2025-01-12', 'YYYY-MM-DD').startOf('d') const date = dayjs.utc('2025-01-12', 'YYYY-MM-DD').startOf('d')
const channel = { site_id: 'bbc1' } const channel = { site_id: 'bbc1' }
it('can generate valid url', () => { it('can generate valid url', () => {
expect(url({ channel, date })).toBe('https://example.com/api/bbc1/2025-01-12') expect(url({ channel, date })).toBe('https://example.com/api/bbc1/2025-01-12')
}) })
it('can parse response', () => { it('can parse response', () => {
const content = const content =
'[{"title":"Program 1","start":"2025-01-12T00:00:00.000Z","stop":"2025-01-12T00:30:00.000Z"},{"title":"Program 2","start":"2025-01-12T00:30:00.000Z","stop":"2025-01-12T01:00:00.000Z"}]' '[{"title":"Program 1","start":"2025-01-12T00:00:00.000Z","stop":"2025-01-12T00:30:00.000Z"},{"title":"Program 2","start":"2025-01-12T00:30:00.000Z","stop":"2025-01-12T01:00:00.000Z"}]'
const results = parser({ content }) const results = parser({ content })
expect(results.length).toBe(2) expect(results.length).toBe(2)
expect(results[0]).toMatchObject({ expect(results[0]).toMatchObject({
title: 'Program 1', title: 'Program 1',
start: '2025-01-12T00:00:00.000Z', start: '2025-01-12T00:00:00.000Z',
stop: '2025-01-12T00:30:00.000Z' stop: '2025-01-12T00:30:00.000Z'
}) })
expect(results[1]).toMatchObject({ expect(results[1]).toMatchObject({
title: 'Program 2', title: 'Program 2',
start: '2025-01-12T00:30:00.000Z', start: '2025-01-12T00:30:00.000Z',
stop: '2025-01-12T01:00:00.000Z' stop: '2025-01-12T01:00:00.000Z'
}) })
}) })
it('can handle empty guide', () => { it('can handle empty guide', () => {
const results = parser({ content: '' }) const results = parser({ content: '' })
expect(results).toMatchObject([]) expect(results).toMatchObject([])
}) })

View File

@@ -1,27 +1,27 @@
import { Collection } from '@freearhey/core' import { Collection } from '@freearhey/core'
export type ChannelData = { export interface ChannelData {
id: string id: string
name: string name: string
alt_names: string[] alt_names: string[]
network: string network: string
owners: Collection owners: Collection
country: string country: string
subdivision: string subdivision: string
city: string city: string
categories: Collection categories: Collection
is_nsfw: boolean is_nsfw: boolean
launched: string launched: string
closed: string closed: string
replaced_by: string replaced_by: string
website: string website: string
} }
export type ChannelSearchableData = { export interface ChannelSearchableData {
id: string id: string
name: string name: string
altNames: string[] altNames: string[]
guideNames: string[] guideNames: string[]
streamNames: string[] streamNames: string[]
feedFullNames: string[] feedFullNames: string[]
} }

View File

@@ -1,20 +1,20 @@
import { Storage } from '@freearhey/core' import { Storage } from '@freearhey/core'
export type DataLoaderProps = { export interface DataLoaderProps {
storage: Storage storage: Storage
} }
export type DataLoaderData = { export interface DataLoaderData {
countries: object | object[] countries: object | object[]
regions: object | object[] regions: object | object[]
subdivisions: object | object[] subdivisions: object | object[]
languages: object | object[] languages: object | object[]
categories: object | object[] categories: object | object[]
blocklist: object | object[] blocklist: object | object[]
channels: object | object[] channels: object | object[]
feeds: object | object[] feeds: object | object[]
timezones: object | object[] timezones: object | object[]
guides: object | object[] guides: object | object[]
streams: object | object[] streams: object | object[]
logos: object | object[] logos: object | object[]
} }

View File

@@ -1,16 +1,16 @@
import { Collection, Dictionary } from '@freearhey/core' import { Collection, Dictionary } from '@freearhey/core'
export type DataProcessorData = { export interface DataProcessorData {
guideChannelsGroupedByStreamId: Dictionary guideChannelsGroupedByStreamId: Dictionary
feedsGroupedByChannelId: Dictionary feedsGroupedByChannelId: Dictionary
logosGroupedByChannelId: Dictionary logosGroupedByChannelId: Dictionary
logosGroupedByStreamId: Dictionary logosGroupedByStreamId: Dictionary
feedsKeyByStreamId: Dictionary feedsKeyByStreamId: Dictionary
streamsGroupedById: Dictionary streamsGroupedById: Dictionary
channelsKeyById: Dictionary channelsKeyById: Dictionary
guideChannels: Collection guideChannels: Collection
channels: Collection channels: Collection
streams: Collection streams: Collection
feeds: Collection feeds: Collection
logos: Collection logos: Collection
} }

View File

@@ -1,12 +1,12 @@
import { Collection } from '@freearhey/core' import { Collection } from '@freearhey/core'
export type FeedData = { export interface FeedData {
channel: string channel: string
id: string id: string
name: string name: string
is_main: boolean is_main: boolean
broadcast_area: Collection broadcast_area: Collection
languages: Collection languages: Collection
timezones: Collection timezones: Collection
video_format: string video_format: string
} }

View File

@@ -1,8 +1,8 @@
export type GuideData = { export interface GuideData {
channel: string channel: string
feed: string feed: string
site: string site: string
site_id: string site_id: string
site_name: string site_name: string
lang: string lang: string
} }

View File

@@ -1 +1 @@
declare module 'langs' declare module 'langs'

View File

@@ -1,9 +1,9 @@
export type LogoData = { export interface LogoData {
channel: string channel: string
feed: string | null feed: string | null
tags: string[] tags: string[]
width: number width: number
height: number height: number
format: string | null format: string | null
url: string url: string
} }

View File

@@ -1,10 +1,10 @@
export type StreamData = { export interface StreamData {
channel: string | null channel: string | null
feed: string | null feed: string | null
name?: string name?: string
url: string url: string
referrer: string | null referrer: string | null
user_agent: string | null user_agent: string | null
quality: string | null quality: string | null
label: string | null label: string | null
} }

View File

@@ -1,69 +1,69 @@
const cheerio = require('cheerio') const cheerio = require('cheerio')
const dayjs = require('dayjs') const dayjs = require('dayjs')
const utc = require('dayjs/plugin/utc') const utc = require('dayjs/plugin/utc')
const timezone = require('dayjs/plugin/timezone') const timezone = require('dayjs/plugin/timezone')
const customParseFormat = require('dayjs/plugin/customParseFormat') const customParseFormat = require('dayjs/plugin/customParseFormat')
dayjs.extend(utc) dayjs.extend(utc)
dayjs.extend(timezone) dayjs.extend(timezone)
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
module.exports = { module.exports = {
site: '9tv.co.il', site: '9tv.co.il',
days: 2, days: 2,
url: function ({ date }) { url: function ({ date }) {
return `https://www.9tv.co.il/BroadcastSchedule/getBrodcastSchedule?date=${date.format( return `https://www.9tv.co.il/BroadcastSchedule/getBrodcastSchedule?date=${date.format(
'DD/MM/YYYY 00:00:00' 'DD/MM/YYYY 00:00:00'
)}` )}`
}, },
parser: function ({ content, date }) { parser: function ({ content, date }) {
const programs = [] const programs = []
const items = parseItems(content) const items = parseItems(content)
items.forEach(item => { items.forEach(item => {
const prev = programs[programs.length - 1] const prev = programs[programs.length - 1]
const $item = cheerio.load(item) const $item = cheerio.load(item)
const start = parseStart($item, date) const start = parseStart($item, date)
if (prev) prev.stop = start if (prev) prev.stop = start
const stop = start.add(1, 'h') const stop = start.add(1, 'h')
programs.push({ programs.push({
title: parseTitle($item), title: parseTitle($item),
image: parseImage($item), image: parseImage($item),
description: parseDescription($item), description: parseDescription($item),
start, start,
stop stop
}) })
}) })
return programs return programs
} }
} }
function parseStart($item, date) { function parseStart($item, date) {
let time = $item('a > div.guide_list_time').text().trim() let time = $item('a > div.guide_list_time').text().trim()
return dayjs.tz(`${date.format('YYYY-MM-DD')} ${time}`, 'YYYY-MM-DD HH:mm', 'Asia/Jerusalem') return dayjs.tz(`${date.format('YYYY-MM-DD')} ${time}`, 'YYYY-MM-DD HH:mm', 'Asia/Jerusalem')
} }
function parseImage($item) { function parseImage($item) {
const backgroundImage = $item('a > div.guide_info_group > div.guide_info_pict').css( const backgroundImage = $item('a > div.guide_info_group > div.guide_info_pict').css(
'background-image' 'background-image'
) )
if (!backgroundImage) return null if (!backgroundImage) return null
const [, relativePath] = backgroundImage.match(/url\((.*)\)/) || [null, null] const [, relativePath] = backgroundImage.match(/url\((.*)\)/) || [null, null]
return relativePath ? `https://www.9tv.co.il${relativePath}` : null return relativePath ? `https://www.9tv.co.il${relativePath}` : null
} }
function parseDescription($item) { function parseDescription($item) {
return $item('a > div.guide_info_group > div.guide_txt_group > div').text().trim() return $item('a > div.guide_info_group > div.guide_txt_group > div').text().trim()
} }
function parseTitle($item) { function parseTitle($item) {
return $item('a > div.guide_info_group > div.guide_txt_group > h3').text().trim() return $item('a > div.guide_info_group > div.guide_txt_group > h3').text().trim()
} }
function parseItems(content) { function parseItems(content) {
const $ = cheerio.load(content) const $ = cheerio.load(content)
return $('li').toArray() return $('li').toArray()
} }

View File

@@ -1,55 +1,56 @@
const { parser, url } = require('./9tv.co.il.config.js') const { parser, url } = require('./9tv.co.il.config.js')
const dayjs = require('dayjs') const fs = require('fs')
const utc = require('dayjs/plugin/utc') const path = require('path')
const customParseFormat = require('dayjs/plugin/customParseFormat') const dayjs = require('dayjs')
dayjs.extend(customParseFormat) const utc = require('dayjs/plugin/utc')
dayjs.extend(utc) const customParseFormat = require('dayjs/plugin/customParseFormat')
dayjs.extend(customParseFormat)
const date = dayjs.utc('2022-03-06', 'YYYY-MM-DD').startOf('d') dayjs.extend(utc)
const channel = {
site_id: '#', const date = dayjs.utc('2022-03-06', 'YYYY-MM-DD').startOf('d')
xmltv_id: 'Channel9.il' const channel = {
} site_id: '#',
xmltv_id: 'Channel9.il'
it('can generate valid url', () => { }
expect(url({ date })).toBe(
'https://www.9tv.co.il/BroadcastSchedule/getBrodcastSchedule?date=06/03/2022 00:00:00' it('can generate valid url', () => {
) expect(url({ date })).toBe(
}) 'https://www.9tv.co.il/BroadcastSchedule/getBrodcastSchedule?date=06/03/2022 00:00:00'
)
it('can parse response', () => { })
const content =
'<li> <a href="#" class="guide_list_link w-inline-block"> <div class="guide_list_time">06:30</div><div class="guide_info_group"> <div class="guide_info_pict" style="background-image: url(/download/pictures/img_id=8484.jpg);"></div><div class="guide_txt_group"> <h3 class="guide_info_title">Слепая</h3> <div>Она не очень любит говорить о себе или о том, кто и зачем к ней обращается. Живет уединенно, в глуши. Но тех, кто приходит -принимает. Она видит судьбы.&#160;</div></div></div></a></li><li> <a href="#" class="guide_list_link even w-inline-block"> <div class="guide_list_time">09:10</div><div class="guide_info_group"> <div class="guide_info_pict" style="background-image: url(/download/pictures/img_id=23694.jpg);"></div><div class="guide_txt_group"> <h3 class="guide_info_title">Орел и решка. Морской сезон</h3> <div>Орел и решка. Морской сезон. Ведущие -Алина Астровская и Коля Серга.</div></div></div></a></li>' it('can parse response', () => {
const result = parser({ content, date }).map(p => { const content = fs.readFileSync(path.resolve(__dirname, '__data__/content.html'), 'utf8')
p.start = p.start.toJSON() const result = parser({ content, date }).map(p => {
p.stop = p.stop.toJSON() p.start = p.start.toJSON()
return p p.stop = p.stop.toJSON()
}) return p
})
expect(result).toMatchObject([
{ expect(result).toMatchObject([
start: '2022-03-06T04:30:00.000Z', {
stop: '2022-03-06T07:10:00.000Z', start: '2022-03-06T04:30:00.000Z',
title: 'Слепая', stop: '2022-03-06T07:10:00.000Z',
image: 'https://www.9tv.co.il/download/pictures/img_id=8484.jpg', title: 'Слепая',
description: image: 'https://www.9tv.co.il/download/pictures/img_id=8484.jpg',
'Она не очень любит говорить о себе или о том, кто и зачем к ней обращается. Живет уединенно, в глуши. Но тех, кто приходит -принимает. Она видит судьбы.' description:
}, 'Она не очень любит говорить о себе или о том, кто и зачем к ней обращается. Живет уединенно, в глуши. Но тех, кто приходит -принимает. Она видит судьбы.'
{ },
start: '2022-03-06T07:10:00.000Z', {
stop: '2022-03-06T08:10:00.000Z', start: '2022-03-06T07:10:00.000Z',
image: 'https://www.9tv.co.il/download/pictures/img_id=23694.jpg', stop: '2022-03-06T08:10:00.000Z',
title: 'Орел и решка. Морской сезон', image: 'https://www.9tv.co.il/download/pictures/img_id=23694.jpg',
description: 'Орел и решка. Морской сезон. Ведущие -Алина Астровская и Коля Серга.' title: 'Орел и решка. Морской сезон',
} description: 'Орел и решка. Морской сезон. Ведущие -Алина Астровская и Коля Серга.'
]) }
}) ])
})
it('can handle empty guide', () => {
const result = parser({ it('can handle empty guide', () => {
date, const result = parser({
channel, date,
content: '<!DOCTYPE html><html><head></head><body></body></html>' channel,
}) content: fs.readFileSync(path.resolve(__dirname, '__data__/no_content.html'), 'utf8')
expect(result).toMatchObject([]) })
}) expect(result).toMatchObject([])
})

View File

@@ -0,0 +1 @@
<li> <a href="#" class="guide_list_link w-inline-block"> <div class="guide_list_time">06:30</div><div class="guide_info_group"> <div class="guide_info_pict" style="background-image: url(/download/pictures/img_id=8484.jpg);"></div><div class="guide_txt_group"> <h3 class="guide_info_title">Слепая</h3> <div>Она не очень любит говорить о себе или о том, кто и зачем к ней обращается. Живет уединенно, в глуши. Но тех, кто приходит -принимает. Она видит судьбы.&#160;</div></div></div></a></li><li> <a href="#" class="guide_list_link even w-inline-block"> <div class="guide_list_time">09:10</div><div class="guide_info_group"> <div class="guide_info_pict" style="background-image: url(/download/pictures/img_id=23694.jpg);"></div><div class="guide_txt_group"> <h3 class="guide_info_title">Орел и решка. Морской сезон</h3> <div>Орел и решка. Морской сезон. Ведущие -Алина Астровская и Коля Серга.</div></div></div></a></li>

View File

@@ -0,0 +1 @@
<!DOCTYPE html><html><head></head><body></body></html>

View File

@@ -1,122 +1,122 @@
const axios = require('axios') const axios = require('axios')
const dayjs = require('dayjs') const dayjs = require('dayjs')
const utc = require('dayjs/plugin/utc') const utc = require('dayjs/plugin/utc')
const timezone = require('dayjs/plugin/timezone') const timezone = require('dayjs/plugin/timezone')
const customParseFormat = require('dayjs/plugin/customParseFormat') const customParseFormat = require('dayjs/plugin/customParseFormat')
dayjs.extend(utc) dayjs.extend(utc)
dayjs.extend(timezone) dayjs.extend(timezone)
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
module.exports = { module.exports = {
site: 'abc.net.au', site: 'abc.net.au',
days: 3, days: 3,
request: { request: {
cache: { cache: {
ttl: 60 * 60 * 1000 // 1 hour ttl: 60 * 60 * 1000 // 1 hour
} }
}, },
url({ date, channel }) { url({ date, channel }) {
const [region] = channel.site_id.split('#') const [region] = channel.site_id.split('#')
return `https://cdn.iview.abc.net.au/epg/processed/${region}_${date.format('YYYY-MM-DD')}.json` return `https://cdn.iview.abc.net.au/epg/processed/${region}_${date.format('YYYY-MM-DD')}.json`
}, },
parser({ content, channel }) { parser({ content, channel }) {
let programs = [] let programs = []
const items = parseItems(content, channel) const items = parseItems(content, channel)
items.forEach(item => { items.forEach(item => {
programs.push({ programs.push({
title: item.title, title: item.title,
sub_title: item.episode_title, sub_title: item.episode_title,
category: item.genres, category: item.genres,
description: item.description, description: item.description,
season: parseSeason(item), season: parseSeason(item),
episode: parseEpisode(item), episode: parseEpisode(item),
rating: parseRating(item), rating: parseRating(item),
image: parseImage(item), image: parseImage(item),
start: parseTime(item.start_time), start: parseTime(item.start_time),
stop: parseTime(item.end_time) stop: parseTime(item.end_time)
}) })
}) })
return programs return programs
}, },
async channels({ region = 'syd' }) { async channels({ region = 'syd' }) {
const now = dayjs() const now = dayjs()
const regions = { const regions = {
syd: 'Sydney', syd: 'Sydney',
mel: 'Melbourne', mel: 'Melbourne',
bri: 'Brisbane', bri: 'Brisbane',
gc: 'GoldCoast', gc: 'GoldCoast',
per: 'Perth', per: 'Perth',
adl: 'Adelaide', adl: 'Adelaide',
hbr: 'Hobart', hbr: 'Hobart',
drw: 'Darwin', drw: 'Darwin',
cbr: 'Canberra', cbr: 'Canberra',
nsw: 'New South Wales', nsw: 'New South Wales',
vic: 'Victoria', vic: 'Victoria',
tsv: 'Townsville', tsv: 'Townsville',
qld: 'Queensland', qld: 'Queensland',
wa: 'Western Australia', wa: 'Western Australia',
sa: 'South Australia', sa: 'South Australia',
tas: 'Tasmania', tas: 'Tasmania',
nt: 'Northern Territory' nt: 'Northern Territory'
} }
let channels = [] let channels = []
const regionName = regions[region] const regionName = regions[region]
const data = await axios const data = await axios
.get( .get(
`https://cdn.iview.abc.net.au/epg/processed/${regionName}_${now.format('YYYY-MM-DD')}.json` `https://cdn.iview.abc.net.au/epg/processed/${regionName}_${now.format('YYYY-MM-DD')}.json`
) )
.then(r => r.data) .then(r => r.data)
.catch(console.log) .catch(console.log)
for (let item of data.schedule) { for (let item of data.schedule) {
channels.push({ channels.push({
lang: 'en', lang: 'en',
site_id: `${regionName}#${item.channel}`, site_id: `${regionName}#${item.channel}`,
name: item.channel name: item.channel
}) })
} }
return channels return channels
} }
} }
function parseItems(content, channel) { function parseItems(content, channel) {
try { try {
const data = JSON.parse(content) const data = JSON.parse(content)
if (!data) return [] if (!data) return []
if (!Array.isArray(data.schedule)) return [] if (!Array.isArray(data.schedule)) return []
const [, channelId] = channel.site_id.split('#') const [, channelId] = channel.site_id.split('#')
const channelData = data.schedule.find(i => i.channel == channelId) const channelData = data.schedule.find(i => i.channel == channelId)
return channelData.listing && Array.isArray(channelData.listing) ? channelData.listing : [] return channelData.listing && Array.isArray(channelData.listing) ? channelData.listing : []
} catch { } catch {
return [] return []
} }
} }
function parseSeason(item) { function parseSeason(item) {
return item.series_num || null return item.series_num || null
} }
function parseEpisode(item) { function parseEpisode(item) {
return item.episode_num || null return item.episode_num || null
} }
function parseTime(time) { function parseTime(time) {
return dayjs.tz(time, 'YYYY-MM-DD HH:mm', 'Australia/Sydney') return dayjs.tz(time, 'YYYY-MM-DD HH:mm', 'Australia/Sydney')
} }
function parseImage(item) { function parseImage(item) {
return item.image_file return item.image_file
? `https://www.abc.net.au/tv/common/images/publicity/${item.image_file}` ? `https://www.abc.net.au/tv/common/images/publicity/${item.image_file}`
: null : null
} }
function parseRating(item) { function parseRating(item) {
return item.rating return item.rating
? { ? {
system: 'ACB', system: 'ACB',
value: item.rating value: item.rating
} }
: null : null
} }

View File

@@ -1,51 +1,51 @@
const { parser, url } = require('./abc.net.au.config.js') const { parser, url } = require('./abc.net.au.config.js')
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
const dayjs = require('dayjs') const dayjs = require('dayjs')
const utc = require('dayjs/plugin/utc') const utc = require('dayjs/plugin/utc')
dayjs.extend(utc) dayjs.extend(utc)
const date = dayjs.utc('2025-02-04', 'YYYY-MM-DD').startOf('d') const date = dayjs.utc('2025-02-04', 'YYYY-MM-DD').startOf('d')
const channel = { site_id: 'Sydney#ABC1' } const channel = { site_id: 'Sydney#ABC1' }
it('can generate valid url', () => { it('can generate valid url', () => {
expect(url({ date, channel })).toBe( expect(url({ date, channel })).toBe(
'https://cdn.iview.abc.net.au/epg/processed/Sydney_2025-02-04.json' 'https://cdn.iview.abc.net.au/epg/processed/Sydney_2025-02-04.json'
) )
}) })
it('can parse response', () => { it('can parse response', () => {
const content = fs.readFileSync(path.resolve(__dirname, '__data__/content.json')) const content = fs.readFileSync(path.resolve(__dirname, '__data__/content.json'))
const results = parser({ content, channel }).map(p => { const results = parser({ content, channel }).map(p => {
p.start = p.start.toJSON() p.start = p.start.toJSON()
p.stop = p.stop.toJSON() p.stop = p.stop.toJSON()
return p return p
}) })
expect(results.length).toBe(30) expect(results.length).toBe(30)
expect(results[0]).toMatchObject({ expect(results[0]).toMatchObject({
title: "Julia Zemiro's Home Delivery", title: "Julia Zemiro's Home Delivery",
sub_title: 'Maggie Beer', sub_title: 'Maggie Beer',
description: description:
"The kitchen Maggie Beer made famous in The Cook and the Chef may be in the heart of the Barossa Valley, but our most beloved foodie meets up with Julia where she grew up in Sydney's Lakemba.", "The kitchen Maggie Beer made famous in The Cook and the Chef may be in the heart of the Barossa Valley, but our most beloved foodie meets up with Julia where she grew up in Sydney's Lakemba.",
category: ['Entertainment', 'Factual'], category: ['Entertainment', 'Factual'],
rating: { rating: {
system: 'ACB', system: 'ACB',
value: 'G' value: 'G'
}, },
season: null, season: null,
episode: null, episode: null,
image: 'https://www.abc.net.au/tv/common/images/publicity/LE1761H002S00_460.jpg', image: 'https://www.abc.net.au/tv/common/images/publicity/LE1761H002S00_460.jpg',
start: '2025-02-03T12:40:00.000Z', start: '2025-02-03T12:40:00.000Z',
stop: '2025-02-03T13:09:00.000Z' stop: '2025-02-03T13:09:00.000Z'
}) })
}) })
it('can handle empty guide', () => { it('can handle empty guide', () => {
const results = parser({ const results = parser({
content: fs.readFileSync(path.resolve(__dirname, '__data__/no_content.html')), content: fs.readFileSync(path.resolve(__dirname, '__data__/no_content.html')),
channel channel
}) })
expect(results).toMatchObject([]) expect(results).toMatchObject([])
}) })

View File

@@ -0,0 +1 @@
{"channels":[{"id":"0148","icon":"//images.ctfassets.net/989y85n5kcxs/5uT9g9pdQWRZeDPQXVI9g6/9cc44da567f591822ed645c99ecdcb64/SVT_1_black_new__2_.png","name":"SVT1 HD (T)","events":[{"id":"0086202208220710","live":false,"time":"2022-08-22T07:10:00Z","title":"Hemmagympa med Sofia","details":{"title":"Hemmagympa med Sofia","image":"https://viasatps.api.comspace.se/PS/channeldate/image/viasat.ps/21/2022-08-22/se.cs.svt1.event.A_41214031600.jpg?size=2560x1440","description":"Svenskt träningsprogram från 2021. Styrka. Sofia Åhman leder SVT:s hemmagympapass. Denna gång fokuserar vi på styrka.","season":4,"episode":1,"categories":["other"],"duration":"20"}}]}]}

View File

@@ -0,0 +1 @@
{"date":"2001-11-17","categories":[],"channels":[]}

View File

@@ -1,65 +1,65 @@
const dayjs = require('dayjs') const dayjs = require('dayjs')
module.exports = { module.exports = {
site: 'allente.dk', site: 'allente.dk',
days: 2, days: 2,
request: { request: {
cache: { cache: {
ttl: 60 * 60 * 1000 // 1 hour ttl: 60 * 60 * 1000 // 1 hour
} }
}, },
url({ date }) { url({ date }) {
return `https://cs-vcb.allente.dk/epg/events?date=${date.format('YYYY-MM-DD')}` return `https://cs-vcb.allente.dk/epg/events?date=${date.format('YYYY-MM-DD')}`
}, },
parser({ content, channel }) { parser({ content, channel }) {
let programs = [] let programs = []
const items = parseItems(content, channel) const items = parseItems(content, channel)
items.forEach(item => { items.forEach(item => {
if (!item.details) return if (!item.details) return
const start = dayjs(item.time) const start = dayjs(item.time)
const stop = start.add(item.details.duration, 'm') const stop = start.add(item.details.duration, 'm')
programs.push({ programs.push({
title: item.title, title: item.title,
category: item.details.categories, category: item.details.categories,
description: item.details.description, description: item.details.description,
image: item.details.image, image: item.details.image,
season: parseSeason(item), season: parseSeason(item),
episode: parseEpisode(item), episode: parseEpisode(item),
start, start,
stop stop
}) })
}) })
return programs return programs
}, },
async channels() { async channels() {
const axios = require('axios') const axios = require('axios')
const data = await axios const data = await axios
.get(`https://cs-vcb.allente.dk/epg/events?date=${dayjs().format('YYYY-MM-DD')}`) .get(`https://cs-vcb.allente.dk/epg/events?date=${dayjs().format('YYYY-MM-DD')}`)
.then(r => r.data) .then(r => r.data)
.catch(console.log) .catch(console.log)
return data.channels.map(item => { return data.channels.map(item => {
return { return {
lang: 'da', lang: 'da',
site_id: item.id, site_id: item.id,
name: item.name name: item.name
} }
}) })
} }
} }
function parseItems(content, channel) { function parseItems(content, channel) {
const data = JSON.parse(content) const data = JSON.parse(content)
if (!data || !Array.isArray(data.channels)) return [] if (!data || !Array.isArray(data.channels)) return []
const channelData = data.channels.find(i => i.id === channel.site_id) const channelData = data.channels.find(i => i.id === channel.site_id)
return channelData && Array.isArray(channelData.events) ? channelData.events : [] return channelData && Array.isArray(channelData.events) ? channelData.events : []
} }
function parseSeason(item) { function parseSeason(item) {
return item.details.season || null return item.details.season || null
} }
function parseEpisode(item) { function parseEpisode(item) {
return item.details.episode || null return item.details.episode || null
} }

View File

@@ -1,50 +1,51 @@
const { parser, url } = require('./allente.dk.config.js') const { parser, url } = require('./allente.dk.config.js')
const dayjs = require('dayjs') const dayjs = require('dayjs')
const utc = require('dayjs/plugin/utc') const utc = require('dayjs/plugin/utc')
const customParseFormat = require('dayjs/plugin/customParseFormat') const customParseFormat = require('dayjs/plugin/customParseFormat')
dayjs.extend(customParseFormat) const { readFileSync } = require('fs')
dayjs.extend(utc) const { resolve } = require('path')
dayjs.extend(customParseFormat)
const date = dayjs.utc('2021-11-17', 'YYYY-MM-DD').startOf('d') dayjs.extend(utc)
const channel = {
site_id: '0148', const date = dayjs.utc('2021-11-17', 'YYYY-MM-DD').startOf('d')
xmltv_id: 'SVT1.se' const channel = {
} site_id: '0148',
xmltv_id: 'SVT1.se'
it('can generate valid url', () => { }
expect(url({ date, channel })).toBe('https://cs-vcb.allente.dk/epg/events?date=2021-11-17')
}) it('can generate valid url', () => {
expect(url({ date, channel })).toBe('https://cs-vcb.allente.dk/epg/events?date=2021-11-17')
it('can parse response', () => { })
const content =
'{"channels":[{"id":"0148","icon":"//images.ctfassets.net/989y85n5kcxs/5uT9g9pdQWRZeDPQXVI9g6/9cc44da567f591822ed645c99ecdcb64/SVT_1_black_new__2_.png","name":"SVT1 HD (T)","events":[{"id":"0086202208220710","live":false,"time":"2022-08-22T07:10:00Z","title":"Hemmagympa med Sofia","details":{"title":"Hemmagympa med Sofia","image":"https://viasatps.api.comspace.se/PS/channeldate/image/viasat.ps/21/2022-08-22/se.cs.svt1.event.A_41214031600.jpg?size=2560x1440","description":"Svenskt träningsprogram från 2021. Styrka. Sofia Åhman leder SVT:s hemmagympapass. Denna gång fokuserar vi på styrka.","season":4,"episode":1,"categories":["other"],"duration":"20"}}]}]}' it('can parse response', () => {
const result = parser({ content, channel }).map(p => { const content = readFileSync(resolve(__dirname, '__data__/content.json'))
p.start = p.start.toJSON() const result = parser({ content, channel }).map(p => {
p.stop = p.stop.toJSON() p.start = p.start.toJSON()
return p p.stop = p.stop.toJSON()
}) return p
})
expect(result).toMatchObject([
{ expect(result).toMatchObject([
start: '2022-08-22T07:10:00.000Z', {
stop: '2022-08-22T07:30:00.000Z', start: '2022-08-22T07:10:00.000Z',
title: 'Hemmagympa med Sofia', stop: '2022-08-22T07:30:00.000Z',
category: ['other'], title: 'Hemmagympa med Sofia',
description: category: ['other'],
'Svenskt träningsprogram från 2021. Styrka. Sofia Åhman leder SVT:s hemmagympapass. Denna gång fokuserar vi på styrka.', description:
image: 'Svenskt träningsprogram från 2021. Styrka. Sofia Åhman leder SVT:s hemmagympapass. Denna gång fokuserar vi på styrka.',
'https://viasatps.api.comspace.se/PS/channeldate/image/viasat.ps/21/2022-08-22/se.cs.svt1.event.A_41214031600.jpg?size=2560x1440', image:
season: 4, 'https://viasatps.api.comspace.se/PS/channeldate/image/viasat.ps/21/2022-08-22/se.cs.svt1.event.A_41214031600.jpg?size=2560x1440',
episode: 1 season: 4,
} episode: 1
]) }
}) ])
})
it('can handle empty guide', () => {
const result = parser({ it('can handle empty guide', () => {
date, const result = parser({
channel, date,
content: '{"date":"2001-11-17","categories":[],"channels":[]}' channel,
}) content: '{"date":"2001-11-17","categories":[],"channels":[]}'
expect(result).toMatchObject([]) })
}) expect(result).toMatchObject([])
})

View File

@@ -0,0 +1 @@
{"channels":[{"id":"0148","icon":"//images.ctfassets.net/989y85n5kcxs/5uT9g9pdQWRZeDPQXVI9g6/9cc44da567f591822ed645c99ecdcb64/SVT_1_black_new__2_.png","name":"SVT1 HD (T)","events":[{"id":"0086202208220710","live":false,"time":"2022-08-22T07:10:00Z","title":"Hemmagympa med Sofia","details":{"title":"Hemmagympa med Sofia","image":"https://viasatps.api.comspace.se/PS/channeldate/image/viasat.ps/21/2022-08-22/se.cs.svt1.event.A_41214031600.jpg?size=2560x1440","description":"Svenskt träningsprogram från 2021. Styrka. Sofia Åhman leder SVT:s hemmagympapass. Denna gång fokuserar vi på styrka.","season":4,"episode":1,"categories":["other"],"duration":"20"}}]}]}

View File

@@ -0,0 +1 @@
{"date":"2001-11-17","categories":[],"channels":[]}

View File

@@ -1,65 +1,65 @@
const dayjs = require('dayjs') const dayjs = require('dayjs')
module.exports = { module.exports = {
site: 'allente.fi', site: 'allente.fi',
days: 2, days: 2,
request: { request: {
cache: { cache: {
ttl: 60 * 60 * 1000 // 1 hour ttl: 60 * 60 * 1000 // 1 hour
} }
}, },
url({ date }) { url({ date }) {
return `https://cs-vcb.allente.fi/epg/events?date=${date.format('YYYY-MM-DD')}` return `https://cs-vcb.allente.fi/epg/events?date=${date.format('YYYY-MM-DD')}`
}, },
parser({ content, channel }) { parser({ content, channel }) {
let programs = [] let programs = []
const items = parseItems(content, channel) const items = parseItems(content, channel)
items.forEach(item => { items.forEach(item => {
if (!item.details) return if (!item.details) return
const start = dayjs(item.time) const start = dayjs(item.time)
const stop = start.add(item.details.duration, 'm') const stop = start.add(item.details.duration, 'm')
programs.push({ programs.push({
title: item.title, title: item.title,
category: item.details.categories, category: item.details.categories,
description: item.details.description, description: item.details.description,
image: item.details.image, image: item.details.image,
season: parseSeason(item), season: parseSeason(item),
episode: parseEpisode(item), episode: parseEpisode(item),
start, start,
stop stop
}) })
}) })
return programs return programs
}, },
async channels() { async channels() {
const axios = require('axios') const axios = require('axios')
const data = await axios const data = await axios
.get(`https://cs-vcb.allente.fi/epg/events?date=${dayjs().format('YYYY-MM-DD')}`) .get(`https://cs-vcb.allente.fi/epg/events?date=${dayjs().format('YYYY-MM-DD')}`)
.then(r => r.data) .then(r => r.data)
.catch(console.log) .catch(console.log)
return data.channels.map(item => { return data.channels.map(item => {
return { return {
lang: 'fi', lang: 'fi',
site_id: item.id, site_id: item.id,
name: item.name name: item.name
} }
}) })
} }
} }
function parseItems(content, channel) { function parseItems(content, channel) {
const data = JSON.parse(content) const data = JSON.parse(content)
if (!data || !Array.isArray(data.channels)) return [] if (!data || !Array.isArray(data.channels)) return []
const channelData = data.channels.find(i => i.id === channel.site_id) const channelData = data.channels.find(i => i.id === channel.site_id)
return channelData && Array.isArray(channelData.events) ? channelData.events : [] return channelData && Array.isArray(channelData.events) ? channelData.events : []
} }
function parseSeason(item) { function parseSeason(item) {
return item.details.season || null return item.details.season || null
} }
function parseEpisode(item) { function parseEpisode(item) {
return item.details.episode || null return item.details.episode || null
} }

View File

@@ -1,50 +1,51 @@
const { parser, url } = require('./allente.fi.config.js') const { parser, url } = require('./allente.fi.config.js')
const dayjs = require('dayjs') const fs = require('fs')
const utc = require('dayjs/plugin/utc') const path = require('path')
const customParseFormat = require('dayjs/plugin/customParseFormat') const dayjs = require('dayjs')
dayjs.extend(customParseFormat) const utc = require('dayjs/plugin/utc')
dayjs.extend(utc) const customParseFormat = require('dayjs/plugin/customParseFormat')
dayjs.extend(customParseFormat)
const date = dayjs.utc('2021-11-17', 'YYYY-MM-DD').startOf('d') dayjs.extend(utc)
const channel = {
site_id: '0148', const date = dayjs.utc('2021-11-17', 'YYYY-MM-DD').startOf('d')
xmltv_id: 'SVT1.se' const channel = {
} site_id: '0148',
xmltv_id: 'SVT1.se'
it('can generate valid url', () => { }
expect(url({ date, channel })).toBe('https://cs-vcb.allente.fi/epg/events?date=2021-11-17')
}) it('can generate valid url', () => {
expect(url({ date, channel })).toBe('https://cs-vcb.allente.fi/epg/events?date=2021-11-17')
it('can parse response', () => { })
const content =
'{"channels":[{"id":"0148","icon":"//images.ctfassets.net/989y85n5kcxs/5uT9g9pdQWRZeDPQXVI9g6/9cc44da567f591822ed645c99ecdcb64/SVT_1_black_new__2_.png","name":"SVT1 HD (T)","events":[{"id":"0086202208220710","live":false,"time":"2022-08-22T07:10:00Z","title":"Hemmagympa med Sofia","details":{"title":"Hemmagympa med Sofia","image":"https://viasatps.api.comspace.se/PS/channeldate/image/viasat.ps/21/2022-08-22/se.cs.svt1.event.A_41214031600.jpg?size=2560x1440","description":"Svenskt träningsprogram från 2021. Styrka. Sofia Åhman leder SVT:s hemmagympapass. Denna gång fokuserar vi på styrka.","season":4,"episode":1,"categories":["other"],"duration":"20"}}]}]}' it('can parse response', () => {
const result = parser({ content, channel }).map(p => { const content = fs.readFileSync(path.resolve(__dirname, '__data__/content.json'))
p.start = p.start.toJSON() const result = parser({ content, channel }).map(p => {
p.stop = p.stop.toJSON() p.start = p.start.toJSON()
return p p.stop = p.stop.toJSON()
}) return p
})
expect(result).toMatchObject([
{ expect(result).toMatchObject([
start: '2022-08-22T07:10:00.000Z', {
stop: '2022-08-22T07:30:00.000Z', start: '2022-08-22T07:10:00.000Z',
title: 'Hemmagympa med Sofia', stop: '2022-08-22T07:30:00.000Z',
category: ['other'], title: 'Hemmagympa med Sofia',
description: category: ['other'],
'Svenskt träningsprogram från 2021. Styrka. Sofia Åhman leder SVT:s hemmagympapass. Denna gång fokuserar vi på styrka.', description:
image: 'Svenskt träningsprogram från 2021. Styrka. Sofia Åhman leder SVT:s hemmagympapass. Denna gång fokuserar vi på styrka.',
'https://viasatps.api.comspace.se/PS/channeldate/image/viasat.ps/21/2022-08-22/se.cs.svt1.event.A_41214031600.jpg?size=2560x1440', image:
season: 4, 'https://viasatps.api.comspace.se/PS/channeldate/image/viasat.ps/21/2022-08-22/se.cs.svt1.event.A_41214031600.jpg?size=2560x1440',
episode: 1 season: 4,
} episode: 1
]) }
}) ])
})
it('can handle empty guide', () => {
const result = parser({ it('can handle empty guide', () => {
date, const result = parser({
channel, date,
content: '{"date":"2001-11-17","categories":[],"channels":[]}' channel,
}) content: fs.readFileSync(path.resolve(__dirname, '__data__/no_content.json'))
expect(result).toMatchObject([]) })
}) expect(result).toMatchObject([])
})

View File

@@ -0,0 +1 @@
{"channels":[{"id":"0148","icon":"//images.ctfassets.net/989y85n5kcxs/5uT9g9pdQWRZeDPQXVI9g6/9cc44da567f591822ed645c99ecdcb64/SVT_1_black_new__2_.png","name":"SVT1 HD (T)","events":[{"id":"0086202208220710","live":false,"time":"2022-08-22T07:10:00Z","title":"Hemmagympa med Sofia","details":{"title":"Hemmagympa med Sofia","image":"https://viasatps.api.comspace.se/PS/channeldate/image/viasat.ps/21/2022-08-22/se.cs.svt1.event.A_41214031600.jpg?size=2560x1440","description":"Svenskt träningsprogram från 2021. Styrka. Sofia Åhman leder SVT:s hemmagympapass. Denna gång fokuserar vi på styrka.","season":4,"episode":1,"categories":["other"],"duration":"20"}}]}]}

View File

@@ -0,0 +1 @@
{"date":"2001-11-17","categories":[],"channels":[]}

View File

@@ -1,65 +1,65 @@
const dayjs = require('dayjs') const dayjs = require('dayjs')
module.exports = { module.exports = {
site: 'allente.no', site: 'allente.no',
days: 2, days: 2,
request: { request: {
cache: { cache: {
ttl: 60 * 60 * 1000 // 1 hour ttl: 60 * 60 * 1000 // 1 hour
} }
}, },
url({ date }) { url({ date }) {
return `https://cs-vcb.allente.no/epg/events?date=${date.format('YYYY-MM-DD')}` return `https://cs-vcb.allente.no/epg/events?date=${date.format('YYYY-MM-DD')}`
}, },
parser({ content, channel }) { parser({ content, channel }) {
let programs = [] let programs = []
const items = parseItems(content, channel) const items = parseItems(content, channel)
items.forEach(item => { items.forEach(item => {
if (!item.details) return if (!item.details) return
const start = dayjs(item.time) const start = dayjs(item.time)
const stop = start.add(item.details.duration, 'm') const stop = start.add(item.details.duration, 'm')
programs.push({ programs.push({
title: item.title, title: item.title,
category: item.details.categories, category: item.details.categories,
description: item.details.description, description: item.details.description,
image: item.details.image, image: item.details.image,
season: parseSeason(item), season: parseSeason(item),
episode: parseEpisode(item), episode: parseEpisode(item),
start, start,
stop stop
}) })
}) })
return programs return programs
}, },
async channels() { async channels() {
const axios = require('axios') const axios = require('axios')
const data = await axios const data = await axios
.get(`https://cs-vcb.allente.no/epg/events?date=${dayjs().format('YYYY-MM-DD')}`) .get(`https://cs-vcb.allente.no/epg/events?date=${dayjs().format('YYYY-MM-DD')}`)
.then(r => r.data) .then(r => r.data)
.catch(console.log) .catch(console.log)
return data.channels.map(item => { return data.channels.map(item => {
return { return {
lang: 'no', lang: 'no',
site_id: item.id, site_id: item.id,
name: item.name name: item.name
} }
}) })
} }
} }
function parseItems(content, channel) { function parseItems(content, channel) {
const data = JSON.parse(content) const data = JSON.parse(content)
if (!data || !Array.isArray(data.channels)) return [] if (!data || !Array.isArray(data.channels)) return []
const channelData = data.channels.find(i => i.id === channel.site_id) const channelData = data.channels.find(i => i.id === channel.site_id)
return channelData && Array.isArray(channelData.events) ? channelData.events : [] return channelData && Array.isArray(channelData.events) ? channelData.events : []
} }
function parseSeason(item) { function parseSeason(item) {
return item.details.season || null return item.details.season || null
} }
function parseEpisode(item) { function parseEpisode(item) {
return item.details.episode || null return item.details.episode || null
} }

View File

@@ -1,50 +1,51 @@
const { parser, url } = require('./allente.no.config.js') const { parser, url } = require('./allente.no.config.js')
const dayjs = require('dayjs') const fs = require('fs')
const utc = require('dayjs/plugin/utc') const path = require('path')
const customParseFormat = require('dayjs/plugin/customParseFormat') const dayjs = require('dayjs')
dayjs.extend(customParseFormat) const utc = require('dayjs/plugin/utc')
dayjs.extend(utc) const customParseFormat = require('dayjs/plugin/customParseFormat')
dayjs.extend(customParseFormat)
const date = dayjs.utc('2021-11-17', 'YYYY-MM-DD').startOf('d') dayjs.extend(utc)
const channel = {
site_id: '0148', const date = dayjs.utc('2021-11-17', 'YYYY-MM-DD').startOf('d')
xmltv_id: 'SVT1.se' const channel = {
} site_id: '0148',
xmltv_id: 'SVT1.se'
it('can generate valid url', () => { }
expect(url({ date, channel })).toBe('https://cs-vcb.allente.no/epg/events?date=2021-11-17')
}) it('can generate valid url', () => {
expect(url({ date, channel })).toBe('https://cs-vcb.allente.no/epg/events?date=2021-11-17')
it('can parse response', () => { })
const content =
'{"channels":[{"id":"0148","icon":"//images.ctfassets.net/989y85n5kcxs/5uT9g9pdQWRZeDPQXVI9g6/9cc44da567f591822ed645c99ecdcb64/SVT_1_black_new__2_.png","name":"SVT1 HD (T)","events":[{"id":"0086202208220710","live":false,"time":"2022-08-22T07:10:00Z","title":"Hemmagympa med Sofia","details":{"title":"Hemmagympa med Sofia","image":"https://viasatps.api.comspace.se/PS/channeldate/image/viasat.ps/21/2022-08-22/se.cs.svt1.event.A_41214031600.jpg?size=2560x1440","description":"Svenskt träningsprogram från 2021. Styrka. Sofia Åhman leder SVT:s hemmagympapass. Denna gång fokuserar vi på styrka.","season":4,"episode":1,"categories":["other"],"duration":"20"}}]}]}' it('can parse response', () => {
const result = parser({ content, channel }).map(p => { const content = fs.readFileSync(path.resolve(__dirname, '__data__/content.json'))
p.start = p.start.toJSON() const result = parser({ content, channel }).map(p => {
p.stop = p.stop.toJSON() p.start = p.start.toJSON()
return p p.stop = p.stop.toJSON()
}) return p
})
expect(result).toMatchObject([
{ expect(result).toMatchObject([
start: '2022-08-22T07:10:00.000Z', {
stop: '2022-08-22T07:30:00.000Z', start: '2022-08-22T07:10:00.000Z',
title: 'Hemmagympa med Sofia', stop: '2022-08-22T07:30:00.000Z',
category: ['other'], title: 'Hemmagympa med Sofia',
description: category: ['other'],
'Svenskt träningsprogram från 2021. Styrka. Sofia Åhman leder SVT:s hemmagympapass. Denna gång fokuserar vi på styrka.', description:
image: 'Svenskt träningsprogram från 2021. Styrka. Sofia Åhman leder SVT:s hemmagympapass. Denna gång fokuserar vi på styrka.',
'https://viasatps.api.comspace.se/PS/channeldate/image/viasat.ps/21/2022-08-22/se.cs.svt1.event.A_41214031600.jpg?size=2560x1440', image:
season: 4, 'https://viasatps.api.comspace.se/PS/channeldate/image/viasat.ps/21/2022-08-22/se.cs.svt1.event.A_41214031600.jpg?size=2560x1440',
episode: 1 season: 4,
} episode: 1
]) }
}) ])
})
it('can handle empty guide', () => {
const result = parser({ it('can handle empty guide', () => {
date, const result = parser({
channel, date,
content: '{"date":"2001-11-17","categories":[],"channels":[]}' channel,
}) content: fs.readFileSync(path.resolve(__dirname, '__data__/no_content.json'))
expect(result).toMatchObject([]) })
}) expect(result).toMatchObject([])
})

View File

@@ -0,0 +1 @@
{"channels":[{"id":"0148","icon":"//images.ctfassets.net/989y85n5kcxs/5uT9g9pdQWRZeDPQXVI9g6/9cc44da567f591822ed645c99ecdcb64/SVT_1_black_new__2_.png","name":"SVT1 HD (T)","events":[{"id":"0086202208220710","live":false,"time":"2022-08-22T07:10:00Z","title":"Hemmagympa med Sofia","details":{"title":"Hemmagympa med Sofia","image":"https://viasatps.api.comspace.se/PS/channeldate/image/viasat.ps/21/2022-08-22/se.cs.svt1.event.A_41214031600.jpg?size=2560x1440","description":"Svenskt träningsprogram från 2021. Styrka. Sofia Åhman leder SVT:s hemmagympapass. Denna gång fokuserar vi på styrka.","season":4,"episode":1,"categories":["other"],"duration":"20"}}]}]}

View File

@@ -0,0 +1 @@
{"date":"2001-11-17","categories":[],"channels":[]}

View File

@@ -1,65 +1,65 @@
const dayjs = require('dayjs') const dayjs = require('dayjs')
module.exports = { module.exports = {
site: 'allente.se', site: 'allente.se',
days: 2, days: 2,
request: { request: {
cache: { cache: {
ttl: 60 * 60 * 1000 // 1 hour ttl: 60 * 60 * 1000 // 1 hour
} }
}, },
url({ date }) { url({ date }) {
return `https://cs-vcb.allente.se/epg/events?date=${date.format('YYYY-MM-DD')}` return `https://cs-vcb.allente.se/epg/events?date=${date.format('YYYY-MM-DD')}`
}, },
parser({ content, channel }) { parser({ content, channel }) {
let programs = [] let programs = []
const items = parseItems(content, channel) const items = parseItems(content, channel)
items.forEach(item => { items.forEach(item => {
if (!item.details) return if (!item.details) return
const start = dayjs(item.time) const start = dayjs(item.time)
const stop = start.add(item.details.duration, 'm') const stop = start.add(item.details.duration, 'm')
programs.push({ programs.push({
title: item.title, title: item.title,
category: item.details.categories, category: item.details.categories,
description: item.details.description, description: item.details.description,
image: item.details.image, image: item.details.image,
season: parseSeason(item), season: parseSeason(item),
episode: parseEpisode(item), episode: parseEpisode(item),
start, start,
stop stop
}) })
}) })
return programs return programs
}, },
async channels() { async channels() {
const axios = require('axios') const axios = require('axios')
const data = await axios const data = await axios
.get(`https://cs-vcb.allente.se/epg/events?date=${dayjs().format('YYYY-MM-DD')}`) .get(`https://cs-vcb.allente.se/epg/events?date=${dayjs().format('YYYY-MM-DD')}`)
.then(r => r.data) .then(r => r.data)
.catch(console.log) .catch(console.log)
return data.channels.map(item => { return data.channels.map(item => {
return { return {
lang: 'sv', lang: 'sv',
site_id: item.id, site_id: item.id,
name: item.name name: item.name
} }
}) })
} }
} }
function parseItems(content, channel) { function parseItems(content, channel) {
const data = JSON.parse(content) const data = JSON.parse(content)
if (!data || !Array.isArray(data.channels)) return [] if (!data || !Array.isArray(data.channels)) return []
const channelData = data.channels.find(i => i.id === channel.site_id) const channelData = data.channels.find(i => i.id === channel.site_id)
return channelData && Array.isArray(channelData.events) ? channelData.events : [] return channelData && Array.isArray(channelData.events) ? channelData.events : []
} }
function parseSeason(item) { function parseSeason(item) {
return item.details.season || null return item.details.season || null
} }
function parseEpisode(item) { function parseEpisode(item) {
return item.details.episode || null return item.details.episode || null
} }

View File

@@ -1,50 +1,51 @@
const { parser, url } = require('./allente.se.config.js') const { parser, url } = require('./allente.se.config.js')
const dayjs = require('dayjs') const fs = require('fs')
const utc = require('dayjs/plugin/utc') const path = require('path')
const customParseFormat = require('dayjs/plugin/customParseFormat') const dayjs = require('dayjs')
dayjs.extend(customParseFormat) const utc = require('dayjs/plugin/utc')
dayjs.extend(utc) const customParseFormat = require('dayjs/plugin/customParseFormat')
dayjs.extend(customParseFormat)
const date = dayjs.utc('2021-11-17', 'YYYY-MM-DD').startOf('d') dayjs.extend(utc)
const channel = {
site_id: '0148', const date = dayjs.utc('2021-11-17', 'YYYY-MM-DD').startOf('d')
xmltv_id: 'SVT1.se' const channel = {
} site_id: '0148',
xmltv_id: 'SVT1.se'
it('can generate valid url', () => { }
expect(url({ date, channel })).toBe('https://cs-vcb.allente.se/epg/events?date=2021-11-17')
}) it('can generate valid url', () => {
expect(url({ date, channel })).toBe('https://cs-vcb.allente.se/epg/events?date=2021-11-17')
it('can parse response', () => { })
const content =
'{"channels":[{"id":"0148","icon":"//images.ctfassets.net/989y85n5kcxs/5uT9g9pdQWRZeDPQXVI9g6/9cc44da567f591822ed645c99ecdcb64/SVT_1_black_new__2_.png","name":"SVT1 HD (T)","events":[{"id":"0086202208220710","live":false,"time":"2022-08-22T07:10:00Z","title":"Hemmagympa med Sofia","details":{"title":"Hemmagympa med Sofia","image":"https://viasatps.api.comspace.se/PS/channeldate/image/viasat.ps/21/2022-08-22/se.cs.svt1.event.A_41214031600.jpg?size=2560x1440","description":"Svenskt träningsprogram från 2021. Styrka. Sofia Åhman leder SVT:s hemmagympapass. Denna gång fokuserar vi på styrka.","season":4,"episode":1,"categories":["other"],"duration":"20"}}]}]}' it('can parse response', () => {
const result = parser({ content, channel }).map(p => { const content = fs.readFileSync(path.resolve(__dirname, '__data__/content.json'))
p.start = p.start.toJSON() const result = parser({ content, channel }).map(p => {
p.stop = p.stop.toJSON() p.start = p.start.toJSON()
return p p.stop = p.stop.toJSON()
}) return p
})
expect(result).toMatchObject([
{ expect(result).toMatchObject([
start: '2022-08-22T07:10:00.000Z', {
stop: '2022-08-22T07:30:00.000Z', start: '2022-08-22T07:10:00.000Z',
title: 'Hemmagympa med Sofia', stop: '2022-08-22T07:30:00.000Z',
category: ['other'], title: 'Hemmagympa med Sofia',
description: category: ['other'],
'Svenskt träningsprogram från 2021. Styrka. Sofia Åhman leder SVT:s hemmagympapass. Denna gång fokuserar vi på styrka.', description:
image: 'Svenskt träningsprogram från 2021. Styrka. Sofia Åhman leder SVT:s hemmagympapass. Denna gång fokuserar vi på styrka.',
'https://viasatps.api.comspace.se/PS/channeldate/image/viasat.ps/21/2022-08-22/se.cs.svt1.event.A_41214031600.jpg?size=2560x1440', image:
season: 4, 'https://viasatps.api.comspace.se/PS/channeldate/image/viasat.ps/21/2022-08-22/se.cs.svt1.event.A_41214031600.jpg?size=2560x1440',
episode: 1 season: 4,
} episode: 1
]) }
}) ])
})
it('can handle empty guide', () => {
const result = parser({ it('can handle empty guide', () => {
date, const result = parser({
channel, date,
content: '{"date":"2001-11-17","categories":[],"channels":[]}' channel,
}) content: fs.readFileSync(path.resolve(__dirname, '__data__/no_content.json'))
expect(result).toMatchObject([]) })
}) expect(result).toMatchObject([])
})

View File

@@ -1,59 +1,59 @@
const cheerio = require('cheerio') const cheerio = require('cheerio')
const { DateTime } = require('luxon') const { DateTime } = require('luxon')
module.exports = { module.exports = {
site: 'andorradifusio.ad', site: 'andorradifusio.ad',
days: 2, days: 2,
url({ channel }) { url({ channel }) {
return `https://www.andorradifusio.ad/programacio/${channel.site_id}` return `https://www.andorradifusio.ad/programacio/${channel.site_id}`
}, },
parser({ content, date }) { parser({ content, date }) {
const programs = [] const programs = []
const items = parseItems(content, date) const items = parseItems(content, date)
items.forEach(item => { items.forEach(item => {
const prev = programs[programs.length - 1] const prev = programs[programs.length - 1]
let start = parseStart(item, date) let start = parseStart(item, date)
if (prev) { if (prev) {
if (start < prev.start) { if (start < prev.start) {
start = start.plus({ days: 1 }) start = start.plus({ days: 1 })
date = date.add(1, 'd') date = date.add(1, 'd')
} }
prev.stop = start prev.stop = start
} }
const stop = start.plus({ hours: 1 }) const stop = start.plus({ hours: 1 })
programs.push({ programs.push({
title: item.title, title: item.title,
start, start,
stop stop
}) })
}) })
return programs return programs
} }
} }
function parseStart(item, date) { function parseStart(item, date) {
const dateString = `${date.format('MM/DD/YYYY')} ${item.time}` const dateString = `${date.format('MM/DD/YYYY')} ${item.time}`
return DateTime.fromFormat(dateString, 'MM/dd/yyyy HH:mm', { zone: 'Europe/Madrid' }).toUTC() return DateTime.fromFormat(dateString, 'MM/dd/yyyy HH:mm', { zone: 'Europe/Madrid' }).toUTC()
} }
function parseItems(content, date) { function parseItems(content, date) {
const $ = cheerio.load(content) const $ = cheerio.load(content)
const day = DateTime.fromMillis(date.valueOf()).setLocale('ca').toFormat('dd LLLL').toLowerCase() const day = DateTime.fromMillis(date.valueOf()).setLocale('ca').toFormat('dd LLLL').toLowerCase()
const column = $('.programacio-dia > h3 > .dia') const column = $('.programacio-dia > h3 > .dia')
.filter((i, el) => $(el).text() === day.slice(0, 6) + '.') .filter((i, el) => $(el).text() === day.slice(0, 6) + '.')
.first() .first()
.parent() .parent()
.parent() .parent()
const items = [] const items = []
const titles = column.find('p').toArray() const titles = column.find('p').toArray()
column.find('h4').each((i, time) => { column.find('h4').each((i, time) => {
items.push({ items.push({
time: $(time).text(), time: $(time).text(),
title: $(titles[i]).text() title: $(titles[i]).text()
}) })
}) })
return items return items
} }

View File

@@ -1,47 +1,47 @@
const { parser, url } = require('./andorradifusio.ad.config.js') const { parser, url } = require('./andorradifusio.ad.config.js')
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
const dayjs = require('dayjs') const dayjs = require('dayjs')
const utc = require('dayjs/plugin/utc') const utc = require('dayjs/plugin/utc')
const customParseFormat = require('dayjs/plugin/customParseFormat') const customParseFormat = require('dayjs/plugin/customParseFormat')
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
dayjs.extend(utc) dayjs.extend(utc)
const date = dayjs.utc('2023-06-07', 'YYYY-MM-DD').startOf('d') const date = dayjs.utc('2023-06-07', 'YYYY-MM-DD').startOf('d')
const channel = { const channel = {
site_id: 'atv', site_id: 'atv',
xmltv_id: 'AndorraTV.ad' xmltv_id: 'AndorraTV.ad'
} }
it('can generate valid url', () => { it('can generate valid url', () => {
expect(url({ channel })).toBe('https://www.andorradifusio.ad/programacio/atv') expect(url({ channel })).toBe('https://www.andorradifusio.ad/programacio/atv')
}) })
it('can parse response', () => { it('can parse response', () => {
const content = fs.readFileSync(path.resolve(__dirname, '__data__/content.html')) const content = fs.readFileSync(path.resolve(__dirname, '__data__/content.html'))
const results = parser({ content, date }).map(p => { const results = parser({ content, date }).map(p => {
p.start = p.start.toJSON() p.start = p.start.toJSON()
p.stop = p.stop.toJSON() p.stop = p.stop.toJSON()
return p return p
}) })
expect(results[0]).toMatchObject({ expect(results[0]).toMatchObject({
start: '2023-06-07T05:00:00.000Z', start: '2023-06-07T05:00:00.000Z',
stop: '2023-06-07T06:00:00.000Z', stop: '2023-06-07T06:00:00.000Z',
title: 'Club Piolet' title: 'Club Piolet'
}) })
expect(results[20]).toMatchObject({ expect(results[20]).toMatchObject({
start: '2023-06-07T23:00:00.000Z', start: '2023-06-07T23:00:00.000Z',
stop: '2023-06-08T00:00:00.000Z', stop: '2023-06-08T00:00:00.000Z',
title: 'Àrea Andorra Difusió' title: 'Àrea Andorra Difusió'
}) })
}) })
it('can handle empty guide', () => { it('can handle empty guide', () => {
const result = parser({ const result = parser({
date, date,
content: '<!DOCTYPE html><html><head></head><body></body></html>' content: '<!DOCTYPE html><html><head></head><body></body></html>'
}) })
expect(result).toMatchObject([]) expect(result).toMatchObject([])
}) })

View File

@@ -1,108 +1,108 @@
const axios = require('axios') const axios = require('axios')
const dayjs = require('dayjs') const dayjs = require('dayjs')
const utc = require('dayjs/plugin/utc') const utc = require('dayjs/plugin/utc')
const timezone = require('dayjs/plugin/timezone') const timezone = require('dayjs/plugin/timezone')
const customParseFormat = require('dayjs/plugin/customParseFormat') const customParseFormat = require('dayjs/plugin/customParseFormat')
dayjs.extend(utc) dayjs.extend(utc)
dayjs.extend(timezone) dayjs.extend(timezone)
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
const API_ENDPOINT = 'https://cds-frontend.vera.com.uy/api-contenidos' const API_ENDPOINT = 'https://cds-frontend.vera.com.uy/api-contenidos'
module.exports = { module.exports = {
site: 'anteltv.com.uy', site: 'anteltv.com.uy',
days: 2, days: 2,
async url({ date, channel }) { async url({ date, channel }) {
const session = await loadSessionDetails() const session = await loadSessionDetails()
if (!session || !session.token) return null if (!session || !session.token) return null
return `${API_ENDPOINT}/canales/epg/${ return `${API_ENDPOINT}/canales/epg/${
channel.site_id channel.site_id
}?limit=500&dias_siguientes=0&fecha=${date.format('YYYY-MM-DD')}&token=${session.token}` }?limit=500&dias_siguientes=0&fecha=${date.format('YYYY-MM-DD')}&token=${session.token}`
}, },
request: { request: {
async headers() { async headers() {
const session = await loadSessionDetails() const session = await loadSessionDetails()
if (!session || !session.jwt) return null if (!session || !session.jwt) return null
return { return {
authorization: `Bearer ${session.jwt}`, authorization: `Bearer ${session.jwt}`,
'x-frontend-id': 1196, 'x-frontend-id': 1196,
'x-service-id': 3, 'x-service-id': 3,
'x-system-id': 1 'x-system-id': 1
} }
} }
}, },
parser({ content }) { parser({ content }) {
let programs = [] let programs = []
let items = parseItems(content) let items = parseItems(content)
items.forEach(item => { items.forEach(item => {
programs.push({ programs.push({
title: item.nombre_programa, title: item.nombre_programa,
sub_title: item.subtitle, sub_title: item.subtitle,
description: item.descripcion_programa, description: item.descripcion_programa,
start: parseStart(item), start: parseStart(item),
stop: parseStop(item) stop: parseStop(item)
}) })
}) })
return programs return programs
}, },
async channels() { async channels() {
const session = await loadSessionDetails() const session = await loadSessionDetails()
if (!session || !session.jwt || !session.token) return null if (!session || !session.jwt || !session.token) return null
const data = await axios const data = await axios
.get(`${API_ENDPOINT}/listas/68?token=${session.token}`, { .get(`${API_ENDPOINT}/listas/68?token=${session.token}`, {
headers: { headers: {
authorization: `Bearer ${session.jwt}`, authorization: `Bearer ${session.jwt}`,
'x-frontend-id': 1196, 'x-frontend-id': 1196,
'x-service-id': 3, 'x-service-id': 3,
'x-system-id': 1 'x-system-id': 1
} }
}) })
.then(r => r.data) .then(r => r.data)
.catch(console.error) .catch(console.error)
return data.contenidos.map(c => { return data.contenidos.map(c => {
return { return {
lang: 'es', lang: 'es',
site_id: c.public_id, site_id: c.public_id,
name: c.nombre name: c.nombre
} }
}) })
} }
} }
function parseStart(item) { function parseStart(item) {
return dayjs.tz(item.fecha_hora_inicio, 'YYYY-MM-DD HH:mm:ss', 'America/Montevideo') return dayjs.tz(item.fecha_hora_inicio, 'YYYY-MM-DD HH:mm:ss', 'America/Montevideo')
} }
function parseStop(item) { function parseStop(item) {
return dayjs.tz(item.fecha_hora_fin, 'YYYY-MM-DD HH:mm:ss', 'America/Montevideo') return dayjs.tz(item.fecha_hora_fin, 'YYYY-MM-DD HH:mm:ss', 'America/Montevideo')
} }
function parseItems(content) { function parseItems(content) {
const data = JSON.parse(content) const data = JSON.parse(content)
if (!data || !Array.isArray(data.data)) return [] if (!data || !Array.isArray(data.data)) return []
return data.data return data.data
} }
function loadSessionDetails() { function loadSessionDetails() {
return axios return axios
.post( .post(
'https://veratv-be.vera.com.uy/api/sesiones', 'https://veratv-be.vera.com.uy/api/sesiones',
{ {
tipo: 'anonima' tipo: 'anonima'
}, },
{ {
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
} }
) )
.then(r => r.data) .then(r => r.data)
.catch(console.log) .catch(console.log)
} }

View File

@@ -1,85 +1,85 @@
const { parser, url, request } = require('./anteltv.com.uy.config.js') const { parser, url, request } = require('./anteltv.com.uy.config.js')
const fs = require('fs') const fs = require('fs')
const axios = require('axios') const axios = require('axios')
const path = require('path') const path = require('path')
const dayjs = require('dayjs') const dayjs = require('dayjs')
const utc = require('dayjs/plugin/utc') const utc = require('dayjs/plugin/utc')
const customParseFormat = require('dayjs/plugin/customParseFormat') const customParseFormat = require('dayjs/plugin/customParseFormat')
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
dayjs.extend(utc) dayjs.extend(utc)
jest.mock('axios') jest.mock('axios')
axios.post.mockImplementation((url, data, opts) => { axios.post.mockImplementation((url, data, opts) => {
if ( if (
url === 'https://veratv-be.vera.com.uy/api/sesiones' && url === 'https://veratv-be.vera.com.uy/api/sesiones' &&
JSON.stringify(opts.headers) === JSON.stringify(opts.headers) ===
JSON.stringify({ JSON.stringify({
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}) && }) &&
JSON.stringify(data) === JSON.stringify(data) ===
JSON.stringify({ JSON.stringify({
tipo: 'anonima' tipo: 'anonima'
}) })
) { ) {
return Promise.resolve({ return Promise.resolve({
data: JSON.parse(fs.readFileSync(path.resolve(__dirname, '__data__/session.json'))) data: JSON.parse(fs.readFileSync(path.resolve(__dirname, '__data__/session.json')))
}) })
} else { } else {
return Promise.resolve({ return Promise.resolve({
data: JSON.parse(fs.readFileSync(path.resolve(__dirname, '__data__/no_session.json'))) data: JSON.parse(fs.readFileSync(path.resolve(__dirname, '__data__/no_session.json')))
}) })
} }
}) })
const date = dayjs.utc('2023-02-11', 'YYYY-MM-DD').startOf('d') const date = dayjs.utc('2023-02-11', 'YYYY-MM-DD').startOf('d')
const channel = { const channel = {
site_id: '2s6nd', site_id: '2s6nd',
xmltv_id: 'Canal5.uy' xmltv_id: 'Canal5.uy'
} }
it('can generate valid url', async () => { it('can generate valid url', async () => {
const result = await url({ date, channel }) const result = await url({ date, channel })
expect(result).toBe( expect(result).toBe(
'https://cds-frontend.vera.com.uy/api-contenidos/canales/epg/2s6nd?limit=500&dias_siguientes=0&fecha=2023-02-11&token=MpDY52p1V6g511VSABp1015B' 'https://cds-frontend.vera.com.uy/api-contenidos/canales/epg/2s6nd?limit=500&dias_siguientes=0&fecha=2023-02-11&token=MpDY52p1V6g511VSABp1015B'
) )
}) })
it('can generate valid request headers', async () => { it('can generate valid request headers', async () => {
const result = await request.headers() const result = await request.headers()
expect(result).toMatchObject({ expect(result).toMatchObject({
authorization: authorization:
'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOnsidGlwbyI6ImFub25pbWEifSwic3ViIjoiTXBEWTUycDFWNmc1MTFWU0FCcDEwMTVCIiwicHJuIjp7ImlkX3NlcnZpY2lvIjozLCJpZF9mcm9udGVuZCI6MTE5NiwiaXAiOiIxNzkuMjcuMTU0LjI0MiIsImlwX3JlZmVyZW5jaWFkYSI6IjE4OC4yNDIuNDguOTMiLCJpZF9kaXNwb3NpdGl2byI6MH0sImF1ZCI6IkFwcHNcL1dlYnMgRnJvbnRlbmRzIiwiaWF0IjoxNjc1ODI3NDU2LCJleHAiOjE2NzU4NDkwNTZ9.8bAQciQl5DOIZF7GgCl6ad-KJUSpqQREetozGv_IH5s', 'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOnsidGlwbyI6ImFub25pbWEifSwic3ViIjoiTXBEWTUycDFWNmc1MTFWU0FCcDEwMTVCIiwicHJuIjp7ImlkX3NlcnZpY2lvIjozLCJpZF9mcm9udGVuZCI6MTE5NiwiaXAiOiIxNzkuMjcuMTU0LjI0MiIsImlwX3JlZmVyZW5jaWFkYSI6IjE4OC4yNDIuNDguOTMiLCJpZF9kaXNwb3NpdGl2byI6MH0sImF1ZCI6IkFwcHNcL1dlYnMgRnJvbnRlbmRzIiwiaWF0IjoxNjc1ODI3NDU2LCJleHAiOjE2NzU4NDkwNTZ9.8bAQciQl5DOIZF7GgCl6ad-KJUSpqQREetozGv_IH5s',
'x-frontend-id': 1196, 'x-frontend-id': 1196,
'x-service-id': 3, 'x-service-id': 3,
'x-system-id': 1 'x-system-id': 1
}) })
}) })
it('can parse response', () => { it('can parse response', () => {
const content = fs.readFileSync(path.resolve(__dirname, '__data__/content.json'), 'utf8') const content = fs.readFileSync(path.resolve(__dirname, '__data__/content.json'), 'utf8')
let results = parser({ content }) let results = parser({ content })
results = results.map(p => { results = results.map(p => {
p.start = p.start.toJSON() p.start = p.start.toJSON()
p.stop = p.stop.toJSON() p.stop = p.stop.toJSON()
return p return p
}) })
expect(results[0]).toMatchObject({ expect(results[0]).toMatchObject({
start: '2023-02-11T02:30:00.000Z', start: '2023-02-11T02:30:00.000Z',
stop: '2023-02-11T04:00:00.000Z', stop: '2023-02-11T04:00:00.000Z',
title: 'Canal 5 Noticias rep.', title: 'Canal 5 Noticias rep.',
sub_title: '', sub_title: '',
description: '' description: ''
}) })
}) })
it('can handle empty guide', () => { it('can handle empty guide', () => {
const results = parser({ const results = parser({
content: fs.readFileSync(path.resolve(__dirname, '__data__/no_content.json'), 'utf8') content: fs.readFileSync(path.resolve(__dirname, '__data__/no_content.json'), 'utf8')
}) })
expect(results).toMatchObject([]) expect(results).toMatchObject([])
}) })

View File

@@ -1,59 +1,59 @@
const cheerio = require('cheerio') const cheerio = require('cheerio')
const dayjs = require('dayjs') const dayjs = require('dayjs')
const utc = require('dayjs/plugin/utc') const utc = require('dayjs/plugin/utc')
const timezone = require('dayjs/plugin/timezone') const timezone = require('dayjs/plugin/timezone')
const customParseFormat = require('dayjs/plugin/customParseFormat') const customParseFormat = require('dayjs/plugin/customParseFormat')
dayjs.extend(utc) dayjs.extend(utc)
dayjs.extend(timezone) dayjs.extend(timezone)
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
module.exports = { module.exports = {
site: 'antennaeurope.gr', site: 'antennaeurope.gr',
days: 2, days: 2,
url({ date }) { url({ date }) {
return `https://www.antennaeurope.gr/el/tvguide.html?date=${date.format('YYYY-MM-DD')}` return `https://www.antennaeurope.gr/el/tvguide.html?date=${date.format('YYYY-MM-DD')}`
}, },
parser({ content, date }) { parser({ content, date }) {
const programs = [] const programs = []
const items = parseItems(content, date) const items = parseItems(content, date)
items.forEach(item => { items.forEach(item => {
const $item = cheerio.load(item) const $item = cheerio.load(item)
const prev = programs[programs.length - 1] const prev = programs[programs.length - 1]
let start = parseStart($item, date) let start = parseStart($item, date)
if (prev) { if (prev) {
if (start.isBefore(prev.start)) { if (start.isBefore(prev.start)) {
start = start.add(1, 'd') start = start.add(1, 'd')
date = date.add(1, 'd') date = date.add(1, 'd')
} }
prev.stop = start prev.stop = start
} }
const stop = start.add(30, 'm') const stop = start.add(30, 'm')
programs.push({ programs.push({
title: parseTitle($item), title: parseTitle($item),
start, start,
stop stop
}) })
}) })
return programs return programs
} }
} }
function parseTitle($item) { function parseTitle($item) {
return $item('.title').text().trim() return $item('.title').text().trim()
} }
function parseStart($item, date) { function parseStart($item, date) {
const time = $item('dt.col-time').clone().children().remove().end().text().trim() const time = $item('dt.col-time').clone().children().remove().end().text().trim()
return time return time
? dayjs.tz(`${date.format('YYYY-MM-DD')} ${time}`, 'YYYY-MM-DD HH:mm', 'Europe/Athens') ? dayjs.tz(`${date.format('YYYY-MM-DD')} ${time}`, 'YYYY-MM-DD HH:mm', 'Europe/Athens')
: null : null
} }
function parseItems(content) { function parseItems(content) {
const $ = cheerio.load(content) const $ = cheerio.load(content)
return $('dl.show').toArray() return $('dl.show').toArray()
} }

View File

@@ -1,46 +1,46 @@
const { parser, url } = require('./antennaeurope.gr.config.js') const { parser, url } = require('./antennaeurope.gr.config.js')
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
const dayjs = require('dayjs') const dayjs = require('dayjs')
const utc = require('dayjs/plugin/utc') const utc = require('dayjs/plugin/utc')
const customParseFormat = require('dayjs/plugin/customParseFormat') const customParseFormat = require('dayjs/plugin/customParseFormat')
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
dayjs.extend(utc) dayjs.extend(utc)
const date = dayjs.utc('2025-01-21', 'YYYY-MM-DD').startOf('d') const date = dayjs.utc('2025-01-21', 'YYYY-MM-DD').startOf('d')
it('can generate valid url', () => { it('can generate valid url', () => {
expect(url({ date })).toBe('https://www.antennaeurope.gr/el/tvguide.html?date=2025-01-21') expect(url({ date })).toBe('https://www.antennaeurope.gr/el/tvguide.html?date=2025-01-21')
}) })
it('can parse response', () => { it('can parse response', () => {
const content = fs.readFileSync(path.resolve(__dirname, '__data__/content.html'), 'utf8') const content = fs.readFileSync(path.resolve(__dirname, '__data__/content.html'), 'utf8')
let results = parser({ content, date }) let results = parser({ content, date })
results = results.map(p => { results = results.map(p => {
p.start = p.start.toJSON() p.start = p.start.toJSON()
p.stop = p.stop.toJSON() p.stop = p.stop.toJSON()
return p return p
}) })
expect(results.length).toBe(16) expect(results.length).toBe(16)
expect(results[0]).toMatchObject({ expect(results[0]).toMatchObject({
start: '2025-01-21T03:45:00.000Z', start: '2025-01-21T03:45:00.000Z',
stop: '2025-01-21T07:50:00.000Z', stop: '2025-01-21T07:50:00.000Z',
title: 'ΚΑΛΗΜΕΡΑ ΕΛΛΑΔΑ' title: 'ΚΑΛΗΜΕΡΑ ΕΛΛΑΔΑ'
}) })
expect(results[15]).toMatchObject({ expect(results[15]).toMatchObject({
start: '2025-01-22T01:30:00.000Z', start: '2025-01-22T01:30:00.000Z',
stop: '2025-01-22T02:00:00.000Z', stop: '2025-01-22T02:00:00.000Z',
title: 'ΤΟ ΠΡΩΙΝΟ' title: 'ΤΟ ΠΡΩΙΝΟ'
}) })
}) })
it('can handle empty guide', () => { it('can handle empty guide', () => {
const results = parser({ const results = parser({
date, date,
content: fs.readFileSync(path.resolve(__dirname, '__data__/no_content.html'), 'utf8') content: fs.readFileSync(path.resolve(__dirname, '__data__/no_content.html'), 'utf8')
}) })
expect(results).toMatchObject([]) expect(results).toMatchObject([])
}) })

View File

@@ -1,59 +1,59 @@
const cheerio = require('cheerio') const cheerio = require('cheerio')
const dayjs = require('dayjs') const dayjs = require('dayjs')
const utc = require('dayjs/plugin/utc') const utc = require('dayjs/plugin/utc')
const timezone = require('dayjs/plugin/timezone') const timezone = require('dayjs/plugin/timezone')
const customParseFormat = require('dayjs/plugin/customParseFormat') const customParseFormat = require('dayjs/plugin/customParseFormat')
dayjs.extend(utc) dayjs.extend(utc)
dayjs.extend(timezone) dayjs.extend(timezone)
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
module.exports = { module.exports = {
site: 'antennapacific.gr', site: 'antennapacific.gr',
days: 2, days: 2,
url({ date }) { url({ date }) {
return `https://www.antennapacific.gr/el/tvguide.html?date=${date.format('YYYY-MM-DD')}` return `https://www.antennapacific.gr/el/tvguide.html?date=${date.format('YYYY-MM-DD')}`
}, },
parser({ content, date }) { parser({ content, date }) {
const programs = [] const programs = []
const items = parseItems(content, date) const items = parseItems(content, date)
items.forEach(item => { items.forEach(item => {
const $item = cheerio.load(item) const $item = cheerio.load(item)
const prev = programs[programs.length - 1] const prev = programs[programs.length - 1]
let start = parseStart($item, date) let start = parseStart($item, date)
if (prev) { if (prev) {
if (start.isBefore(prev.start)) { if (start.isBefore(prev.start)) {
start = start.add(1, 'd') start = start.add(1, 'd')
date = date.add(1, 'd') date = date.add(1, 'd')
} }
prev.stop = start prev.stop = start
} }
const stop = start.add(30, 'm') const stop = start.add(30, 'm')
programs.push({ programs.push({
title: parseTitle($item), title: parseTitle($item),
start, start,
stop stop
}) })
}) })
return programs return programs
} }
} }
function parseTitle($item) { function parseTitle($item) {
return $item('.title').text().trim() return $item('.title').text().trim()
} }
function parseStart($item, date) { function parseStart($item, date) {
const time = $item('dt.col-time').clone().children().remove().end().text().trim() const time = $item('dt.col-time').clone().children().remove().end().text().trim()
return time return time
? dayjs.tz(`${date.format('YYYY-MM-DD')} ${time}`, 'YYYY-MM-DD HH:mm', 'Europe/Athens') ? dayjs.tz(`${date.format('YYYY-MM-DD')} ${time}`, 'YYYY-MM-DD HH:mm', 'Europe/Athens')
: null : null
} }
function parseItems(content) { function parseItems(content) {
const $ = cheerio.load(content) const $ = cheerio.load(content)
return $('dl.show').toArray() return $('dl.show').toArray()
} }

View File

@@ -1,46 +1,46 @@
const { parser, url } = require('./antennapacific.gr.config.js') const { parser, url } = require('./antennapacific.gr.config.js')
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
const dayjs = require('dayjs') const dayjs = require('dayjs')
const utc = require('dayjs/plugin/utc') const utc = require('dayjs/plugin/utc')
const customParseFormat = require('dayjs/plugin/customParseFormat') const customParseFormat = require('dayjs/plugin/customParseFormat')
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
dayjs.extend(utc) dayjs.extend(utc)
const date = dayjs.utc('2025-01-21', 'YYYY-MM-DD').startOf('d') const date = dayjs.utc('2025-01-21', 'YYYY-MM-DD').startOf('d')
it('can generate valid url', () => { it('can generate valid url', () => {
expect(url({ date })).toBe('https://www.antennapacific.gr/el/tvguide.html?date=2025-01-21') expect(url({ date })).toBe('https://www.antennapacific.gr/el/tvguide.html?date=2025-01-21')
}) })
it('can parse response', () => { it('can parse response', () => {
const content = fs.readFileSync(path.resolve(__dirname, '__data__/content.html'), 'utf8') const content = fs.readFileSync(path.resolve(__dirname, '__data__/content.html'), 'utf8')
let results = parser({ content, date }) let results = parser({ content, date })
results = results.map(p => { results = results.map(p => {
p.start = p.start.toJSON() p.start = p.start.toJSON()
p.stop = p.stop.toJSON() p.stop = p.stop.toJSON()
return p return p
}) })
expect(results.length).toBe(17) expect(results.length).toBe(17)
expect(results[0]).toMatchObject({ expect(results[0]).toMatchObject({
start: '2025-01-21T05:00:00.000Z', start: '2025-01-21T05:00:00.000Z',
stop: '2025-01-21T06:00:00.000Z', stop: '2025-01-21T06:00:00.000Z',
title: 'ANT1 NEWS - ΚΕΝΤΡΙΚΟ ΔΕΛΤΙΟ' title: 'ANT1 NEWS - ΚΕΝΤΡΙΚΟ ΔΕΛΤΙΟ'
}) })
expect(results[16]).toMatchObject({ expect(results[16]).toMatchObject({
start: '2025-01-22T02:45:00.000Z', start: '2025-01-22T02:45:00.000Z',
stop: '2025-01-22T03:15:00.000Z', stop: '2025-01-22T03:15:00.000Z',
title: 'ΚΑΛΗΜΕΡΑ ΕΛΛΑΔΑ' title: 'ΚΑΛΗΜΕΡΑ ΕΛΛΑΔΑ'
}) })
}) })
it('can handle empty guide', () => { it('can handle empty guide', () => {
const results = parser({ const results = parser({
date, date,
content: fs.readFileSync(path.resolve(__dirname, '__data__/no_content.html'), 'utf8') content: fs.readFileSync(path.resolve(__dirname, '__data__/no_content.html'), 'utf8')
}) })
expect(results).toMatchObject([]) expect(results).toMatchObject([])
}) })

View File

@@ -1,59 +1,59 @@
const cheerio = require('cheerio') const cheerio = require('cheerio')
const dayjs = require('dayjs') const dayjs = require('dayjs')
const utc = require('dayjs/plugin/utc') const utc = require('dayjs/plugin/utc')
const timezone = require('dayjs/plugin/timezone') const timezone = require('dayjs/plugin/timezone')
const customParseFormat = require('dayjs/plugin/customParseFormat') const customParseFormat = require('dayjs/plugin/customParseFormat')
dayjs.extend(utc) dayjs.extend(utc)
dayjs.extend(timezone) dayjs.extend(timezone)
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
module.exports = { module.exports = {
site: 'antennasatellite.gr', site: 'antennasatellite.gr',
days: 2, days: 2,
url({ date }) { url({ date }) {
return `https://www.antennasatellite.gr/el/tvguide.html?date=${date.format('YYYY-MM-DD')}` return `https://www.antennasatellite.gr/el/tvguide.html?date=${date.format('YYYY-MM-DD')}`
}, },
parser({ content, date }) { parser({ content, date }) {
const programs = [] const programs = []
const items = parseItems(content, date) const items = parseItems(content, date)
items.forEach(item => { items.forEach(item => {
const $item = cheerio.load(item) const $item = cheerio.load(item)
const prev = programs[programs.length - 1] const prev = programs[programs.length - 1]
let start = parseStart($item, date) let start = parseStart($item, date)
if (prev) { if (prev) {
if (start.isBefore(prev.start)) { if (start.isBefore(prev.start)) {
start = start.add(1, 'd') start = start.add(1, 'd')
date = date.add(1, 'd') date = date.add(1, 'd')
} }
prev.stop = start prev.stop = start
} }
const stop = start.add(30, 'm') const stop = start.add(30, 'm')
programs.push({ programs.push({
title: parseTitle($item), title: parseTitle($item),
start, start,
stop stop
}) })
}) })
return programs return programs
} }
} }
function parseTitle($item) { function parseTitle($item) {
return $item('.title').text().trim() return $item('.title').text().trim()
} }
function parseStart($item, date) { function parseStart($item, date) {
const time = $item('dt.col-time').clone().children().remove().end().text().trim() const time = $item('dt.col-time').clone().children().remove().end().text().trim()
return time return time
? dayjs.tz(`${date.format('YYYY-MM-DD')} ${time}`, 'YYYY-MM-DD HH:mm', 'Europe/Athens') ? dayjs.tz(`${date.format('YYYY-MM-DD')} ${time}`, 'YYYY-MM-DD HH:mm', 'Europe/Athens')
: null : null
} }
function parseItems(content) { function parseItems(content) {
const $ = cheerio.load(content) const $ = cheerio.load(content)
return $('dl.show').toArray() return $('dl.show').toArray()
} }

View File

@@ -1,46 +1,46 @@
const { parser, url } = require('./antennasatellite.gr.config.js') const { parser, url } = require('./antennasatellite.gr.config.js')
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
const dayjs = require('dayjs') const dayjs = require('dayjs')
const utc = require('dayjs/plugin/utc') const utc = require('dayjs/plugin/utc')
const customParseFormat = require('dayjs/plugin/customParseFormat') const customParseFormat = require('dayjs/plugin/customParseFormat')
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
dayjs.extend(utc) dayjs.extend(utc)
const date = dayjs.utc('2025-01-21', 'YYYY-MM-DD').startOf('d') const date = dayjs.utc('2025-01-21', 'YYYY-MM-DD').startOf('d')
it('can generate valid url', () => { it('can generate valid url', () => {
expect(url({ date })).toBe('https://www.antennasatellite.gr/el/tvguide.html?date=2025-01-21') expect(url({ date })).toBe('https://www.antennasatellite.gr/el/tvguide.html?date=2025-01-21')
}) })
it('can parse response', () => { it('can parse response', () => {
const content = fs.readFileSync(path.resolve(__dirname, '__data__/content.html'), 'utf8') const content = fs.readFileSync(path.resolve(__dirname, '__data__/content.html'), 'utf8')
let results = parser({ content, date }) let results = parser({ content, date })
results = results.map(p => { results = results.map(p => {
p.start = p.start.toJSON() p.start = p.start.toJSON()
p.stop = p.stop.toJSON() p.stop = p.stop.toJSON()
return p return p
}) })
expect(results.length).toBe(16) expect(results.length).toBe(16)
expect(results[0]).toMatchObject({ expect(results[0]).toMatchObject({
start: '2025-01-21T04:00:00.000Z', start: '2025-01-21T04:00:00.000Z',
stop: '2025-01-21T04:40:00.000Z', stop: '2025-01-21T04:40:00.000Z',
title: 'ANT1 NEWS' title: 'ANT1 NEWS'
}) })
expect(results[15]).toMatchObject({ expect(results[15]).toMatchObject({
start: '2025-01-22T00:50:00.000Z', start: '2025-01-22T00:50:00.000Z',
stop: '2025-01-22T01:20:00.000Z', stop: '2025-01-22T01:20:00.000Z',
title: 'ΤΟ ΠΡΩΙΝΟ' title: 'ΤΟ ΠΡΩΙΝΟ'
}) })
}) })
it('can handle empty guide', () => { it('can handle empty guide', () => {
const results = parser({ const results = parser({
date, date,
content: fs.readFileSync(path.resolve(__dirname, '__data__/no_content.html'), 'utf8') content: fs.readFileSync(path.resolve(__dirname, '__data__/no_content.html'), 'utf8')
}) })
expect(results).toMatchObject([]) expect(results).toMatchObject([])
}) })

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<channels>
<channel site="arianaafgtv.com" lang="en" xmltv_id="ArianaAfghanistanInternationalTV.us" site_id="#">Ariana Afghanistan Television</channel>
</channels>

View File

@@ -1,82 +0,0 @@
const cheerio = require('cheerio')
const dayjs = require('dayjs')
const utc = require('dayjs/plugin/utc')
const timezone = require('dayjs/plugin/timezone')
const customParseFormat = require('dayjs/plugin/customParseFormat')
dayjs.extend(utc)
dayjs.extend(timezone)
dayjs.extend(customParseFormat)
module.exports = {
site: 'arianaafgtv.com',
days: 2,
url: 'https://www.arianaafgtv.com/index.html',
parser({ content, date }) {
const programs = []
const items = parseItems(content, date)
items.forEach(item => {
const title = item.title
const start = parseStart(item, date)
const stop = parseStop(item, date)
programs.push({
title,
start,
stop
})
})
return programs
}
}
function parseStop(item, date) {
const time = `${date.format('MM/DD/YYYY')} ${item.end.toUpperCase()}`
return dayjs.tz(time, 'MM/DD/YYYY hh:mm A', 'Asia/Kabul')
}
function parseStart(item, date) {
const time = `${date.format('MM/DD/YYYY')} ${item.start.toUpperCase()}`
return dayjs.tz(time, 'MM/DD/YYYY hh:mm A', 'Asia/Kabul')
}
function parseItems(content, date) {
const $ = cheerio.load(content)
const dayOfWeek = date.format('dddd')
const column = $('.H4')
.filter((i, el) => {
return $(el).text() === dayOfWeek
})
.first()
.parent()
const rows = column
.find('.Paragraph')
.map((i, el) => {
return $(el).html()
})
.toArray()
.map(r => (r === '&nbsp;' ? '|' : r))
.join(' ')
.split('|')
const items = []
rows.forEach(row => {
row = row.trim()
if (row) {
const found = row.match(/(\d+(|:\d+)(a|p)m-\d+(|:\d+)(a|p)m)/gi)
if (!found) return
const time = found[0]
let start = time.match(/(\d+(|:\d+)(a|p)m)-/i)[1]
start = dayjs(start.toUpperCase(), ['hh:mmA', 'h:mmA', 'hA']).format('hh:mm A')
let end = time.match(/-(\d+(|:\d+)(a|p)m)/i)[1]
end = dayjs(end.toUpperCase(), ['hh:mmA', 'h:mmA', 'hA']).format('hh:mm A')
const title = row.replace(time, '').replace('&nbsp;', '').trim()
items.push({ start, end, title })
}
})
return items
}

View File

@@ -1,15 +0,0 @@
# arianaafgtv.com
https://arianaafgtv.com/#ariana-afghanistan-television-tv-guide
### Download the guide
```sh
npm run grab --- --site=arianaafgtv.com
```
### Test
```sh
npm test --- arianaafgtv.com
```

View File

@@ -0,0 +1 @@
<!DOCTYPE html><html><head></head><body><textarea data-jtrt-table-id="508" id="jtrt_table_settings_508" cols="30" rows="10">[[["Start","Saturday","Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","",""],["7:00","City Report","ICC T20 Highlights","ICC T20 Highlights","ICC T20 Highlights","ICC T20 Highlights","ICC T20 Highlights","ICC T20 Highlights","",""],["7:30","ICC T20 Highlights","Sport ","Sport ","Sport ","Sport ","Sport ","Sport ","",""],["15:00","ICC T20 World Cup","ICC T20 World Cup","ICC T20 World Cup","ICC T20 World Cup","ICC T20 World Cup","ICC T20 World Cup","ICC T20 World Cup","",""],["6:30","Quran and Hadis ","Falah","Falah","Falah","Falah","Falah","Falah","",""],["","\\n","","","","","","","",""]]]</textarea></body></html>

View File

@@ -0,0 +1 @@
<!DOCTYPE html><html><head></head><body><textarea data-jtrt-table-id="508" id="jtrt_table_settings_508" cols="30" rows="10"></textarea></body></html>

View File

@@ -1,60 +1,60 @@
const cheerio = require('cheerio') const cheerio = require('cheerio')
const { DateTime } = require('luxon') const { DateTime } = require('luxon')
module.exports = { module.exports = {
site: 'arianatelevision.com', site: 'arianatelevision.com',
days: 2, days: 2,
url: 'https://www.arianatelevision.com/program-schedule/', url: 'https://www.arianatelevision.com/program-schedule/',
parser({ content, date }) { parser({ content, date }) {
const programs = [] const programs = []
const items = parseItems(content, date) const items = parseItems(content, date)
items.forEach(item => { items.forEach(item => {
const prev = programs[programs.length - 1] const prev = programs[programs.length - 1]
let start = parseStart(item, date) let start = parseStart(item, date)
if (prev) { if (prev) {
if (start < prev.start) { if (start < prev.start) {
start = start.plus({ days: 1 }) start = start.plus({ days: 1 })
date = date.add(1, 'd') date = date.add(1, 'd')
} }
prev.stop = start prev.stop = start
} }
const stop = start.plus({ minutes: 30 }) const stop = start.plus({ minutes: 30 })
programs.push({ programs.push({
title: item.title, title: item.title,
start, start,
stop stop
}) })
}) })
return programs return programs
} }
} }
function parseStart(item, date) { function parseStart(item, date) {
const time = `${date.format('YYYY-MM-DD')} ${item.start}` const time = `${date.format('YYYY-MM-DD')} ${item.start}`
return DateTime.fromFormat(time, 'yyyy-MM-dd H:mm', { zone: 'Asia/Kabul' }).toUTC() return DateTime.fromFormat(time, 'yyyy-MM-dd H:mm', { zone: 'Asia/Kabul' }).toUTC()
} }
function parseItems(content, date) { function parseItems(content, date) {
const $ = cheerio.load(content) const $ = cheerio.load(content)
const settings = $('#jtrt_table_settings_508').text() const settings = $('#jtrt_table_settings_508').text()
if (!settings) return [] if (!settings) return []
const data = JSON.parse(settings) const data = JSON.parse(settings)
if (!data || !Array.isArray(data)) return [] if (!data || !Array.isArray(data)) return []
let rows = data[0] let rows = data[0]
rows.shift() rows.shift()
const output = [] const output = []
rows.forEach(row => { rows.forEach(row => {
let day = date.day() + 2 let day = date.day() + 2
if (day > 7) day = 1 if (day > 7) day = 1
if (!row[0] || !row[day]) return if (!row[0] || !row[day]) return
output.push({ output.push({
start: row[0].trim(), start: row[0].trim(),
title: row[day].trim() title: row[day].trim()
}) })
}) })
return output return output
} }

View File

@@ -1,59 +1,59 @@
const { parser, url } = require('./arianatelevision.com.config.js') const { parser, url } = require('./arianatelevision.com.config.js')
const dayjs = require('dayjs') const fs = require('fs')
const utc = require('dayjs/plugin/utc') const path = require('path')
const customParseFormat = require('dayjs/plugin/customParseFormat') const dayjs = require('dayjs')
dayjs.extend(customParseFormat) const utc = require('dayjs/plugin/utc')
dayjs.extend(utc) const customParseFormat = require('dayjs/plugin/customParseFormat')
dayjs.extend(customParseFormat)
const date = dayjs.utc('2021-11-27', 'YYYY-MM-DD').startOf('d') dayjs.extend(utc)
const channel = {
site_id: '#', const date = dayjs.utc('2021-11-27', 'YYYY-MM-DD').startOf('d')
xmltv_id: 'ArianaTVNational.af' const channel = {
} site_id: '#',
xmltv_id: 'ArianaTVNational.af'
it('can generate valid url', () => { }
expect(url).toBe('https://www.arianatelevision.com/program-schedule/')
}) it('can generate valid url', () => {
expect(url).toBe('https://www.arianatelevision.com/program-schedule/')
it('can parse response', () => { })
const content =
'<!DOCTYPE html><html><head></head><body><textarea data-jtrt-table-id="508" id="jtrt_table_settings_508" cols="30" rows="10">[[["Start","Saturday","Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","",""],["7:00","City Report","ICC T20 Highlights","ICC T20 Highlights","ICC T20 Highlights","ICC T20 Highlights","ICC T20 Highlights","ICC T20 Highlights","",""],["7:30","ICC T20 Highlights","Sport ","Sport ","Sport ","Sport ","Sport ","Sport ","",""],["15:00","ICC T20 World Cup","ICC T20 World Cup","ICC T20 World Cup","ICC T20 World Cup","ICC T20 World Cup","ICC T20 World Cup","ICC T20 World Cup","",""],["6:30","Quran and Hadis ","Falah","Falah","Falah","Falah","Falah","Falah","",""],["","\\n","","","","","","","",""]]]</textarea></body></html>' it('can parse response', () => {
const result = parser({ content, date }).map(p => { const content = fs.readFileSync(path.resolve(__dirname, '__data__/content.html'), 'utf8')
p.start = p.start.toJSON() const result = parser({ content, date }).map(p => {
p.stop = p.stop.toJSON() p.start = p.start.toJSON()
return p p.stop = p.stop.toJSON()
}) return p
})
expect(result).toMatchObject([
{ expect(result).toMatchObject([
start: '2021-11-27T02:30:00.000Z', {
stop: '2021-11-27T03:00:00.000Z', start: '2021-11-27T02:30:00.000Z',
title: 'City Report' stop: '2021-11-27T03:00:00.000Z',
}, title: 'City Report'
{ },
start: '2021-11-27T03:00:00.000Z', {
stop: '2021-11-27T10:30:00.000Z', start: '2021-11-27T03:00:00.000Z',
title: 'ICC T20 Highlights' stop: '2021-11-27T10:30:00.000Z',
}, title: 'ICC T20 Highlights'
{ },
start: '2021-11-27T10:30:00.000Z', {
stop: '2021-11-28T02:00:00.000Z', start: '2021-11-27T10:30:00.000Z',
title: 'ICC T20 World Cup' stop: '2021-11-28T02:00:00.000Z',
}, title: 'ICC T20 World Cup'
{ },
start: '2021-11-28T02:00:00.000Z', {
stop: '2021-11-28T02:30:00.000Z', start: '2021-11-28T02:00:00.000Z',
title: 'Quran and Hadis' stop: '2021-11-28T02:30:00.000Z',
} title: 'Quran and Hadis'
]) }
}) ])
})
it('can handle empty guide', () => {
const result = parser({ it('can handle empty guide', () => {
date, const result = parser({
channel, date,
content: channel,
'<!DOCTYPE html><html><head></head><body><textarea data-jtrt-table-id="508" id="jtrt_table_settings_508" cols="30" rows="10"></textarea></body></html>' content: fs.readFileSync(path.resolve(__dirname, '__data__/no_content.html'), 'utf8')
}) })
expect(result).toMatchObject([]) expect(result).toMatchObject([])
}) })

View File

@@ -1,163 +1,163 @@
const axios = require('axios') const axios = require('axios')
const dayjs = require('dayjs') const dayjs = require('dayjs')
const utc = require('dayjs/plugin/utc') const utc = require('dayjs/plugin/utc')
const timezone = require('dayjs/plugin/timezone') const timezone = require('dayjs/plugin/timezone')
const customParseFormat = require('dayjs/plugin/customParseFormat') const customParseFormat = require('dayjs/plugin/customParseFormat')
dayjs.extend(utc) dayjs.extend(utc)
dayjs.extend(timezone) dayjs.extend(timezone)
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
module.exports = { module.exports = {
site: 'arirang.com', site: 'arirang.com',
output: 'arirang.com.guide.xml', output: 'arirang.com.guide.xml',
channels: 'arirang.com.channels.xml', channels: 'arirang.com.channels.xml',
lang: 'en', lang: 'en',
days: 7, days: 7,
delay: 5000, delay: 5000,
url: 'https://www.arirang.com/v1.0/open/external/proxy', url: 'https://www.arirang.com/v1.0/open/external/proxy',
request: { request: {
method: 'POST', method: 'POST',
timeout: 5000, timeout: 5000,
cache: { ttl: 60 * 60 * 1000 }, cache: { ttl: 60 * 60 * 1000 },
headers: { headers: {
Accept: 'application/json, text/plain, */*', Accept: 'application/json, text/plain, */*',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Origin: 'https://www.arirang.com', Origin: 'https://www.arirang.com',
Referer: 'https://www.arirang.com/schedule', Referer: 'https://www.arirang.com/schedule',
'User-Agent': 'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36' 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36'
}, },
data: function (context) { data: function (context) {
const { channel, date } = context const { channel, date } = context
return { return {
address: 'https://script.arirang.com/api/v1/bis/listScheduleV3.do', address: 'https://script.arirang.com/api/v1/bis/listScheduleV3.do',
method: 'POST', method: 'POST',
headers: {}, headers: {},
body: { body: {
data: { data: {
dmParam: { dmParam: {
chanId: channel.site_id, chanId: channel.site_id,
broadYmd: dayjs.tz(date, 'Asia/Seoul').format('YYYYMMDD'), broadYmd: dayjs.tz(date, 'Asia/Seoul').format('YYYYMMDD'),
planNo: '1' planNo: '1'
} }
} }
} }
} }
} }
}, },
logo: function (context) { logo: function (context) {
return context.channel.logo return context.channel.logo
}, },
async parser(context) { async parser(context) {
const programs = [] const programs = []
const items = parseItems(context.content) const items = parseItems(context.content)
for (let item of items) { for (let item of items) {
const programDetail = await parseProgramDetail(item) const programDetail = await parseProgramDetail(item)
programs.push({ programs.push({
title: parseTitle(programDetail), title: parseTitle(programDetail),
start: parseStart(item), start: parseStart(item),
stop: parseStop(item), stop: parseStop(item),
image: parseImage(programDetail), image: parseImage(programDetail),
category: parseCategory(programDetail), category: parseCategory(programDetail),
description: parseDescription(programDetail) description: parseDescription(programDetail)
}) })
} }
return programs return programs
} }
} }
function parseItems(content) { function parseItems(content) {
if (content != '') { if (content != '') {
const data = JSON.parse(content) const data = JSON.parse(content)
return !data || !data.responseBody || !Array.isArray(data.responseBody.dsSchWeek) return !data || !data.responseBody || !Array.isArray(data.responseBody.dsSchWeek)
? [] ? []
: data.responseBody.dsSchWeek : data.responseBody.dsSchWeek
} else { } else {
return [] return []
} }
} }
function parseStart(item) { function parseStart(item) {
return dayjs.tz(item.broadYmd + ' ' + item.broadHm, 'YYYYMMDD HHmm', 'Asia/Seoul') return dayjs.tz(item.broadYmd + ' ' + item.broadHm, 'YYYYMMDD HHmm', 'Asia/Seoul')
} }
function parseStop(item) { function parseStop(item) {
return dayjs return dayjs
.tz(item.broadYmd + ' ' + item.broadHm, 'YYYYMMDD HHmm', 'Asia/Seoul') .tz(item.broadYmd + ' ' + item.broadHm, 'YYYYMMDD HHmm', 'Asia/Seoul')
.add(item.broadRun, 'minute') .add(item.broadRun, 'minute')
} }
async function parseProgramDetail(item) { async function parseProgramDetail(item) {
return axios return axios
.post( .post(
'https://www.arirang.com/v1.0/open/program/detail', 'https://www.arirang.com/v1.0/open/program/detail',
{ {
bis_program_code: item.pgmCd bis_program_code: item.pgmCd
}, },
{ {
headers: { headers: {
Accept: 'application/json, text/plain, */*', Accept: 'application/json, text/plain, */*',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Origin: 'https://www.arirang.com', Origin: 'https://www.arirang.com',
Referer: 'https://www.arirang.com/schedule', Referer: 'https://www.arirang.com/schedule',
'User-Agent': 'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36' 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36'
}, },
timeout: 5000, timeout: 5000,
cache: { ttl: 60 * 1000 } cache: { ttl: 60 * 1000 }
} }
) )
.then(response => { .then(response => {
// console.log('Retrieved program detail: bis_program_code ' + item.pgmCd) // console.log('Retrieved program detail: bis_program_code ' + item.pgmCd)
return response.data return response.data
}) })
.catch(function () { .catch(function () {
// The provider/server may not have details on every single programs. // The provider/server may not have details on every single programs.
// console.log('Unavailable program detail: bis_program_code ' + item.pgmCd) // console.log('Unavailable program detail: bis_program_code ' + item.pgmCd)
}) })
} }
function parseTitle(programDetail) { function parseTitle(programDetail) {
if (programDetail && programDetail.title && programDetail.title[0] && programDetail.title[0].text) { if (programDetail && programDetail.title && programDetail.title[0] && programDetail.title[0].text) {
return programDetail.title[0].text return programDetail.title[0].text
} else { } else {
return '' return ''
} }
} }
function parseImage(programDetail) { function parseImage(programDetail) {
if (programDetail && programDetail.image && programDetail.image[0].url) { if (programDetail && programDetail.image && programDetail.image[0].url) {
return programDetail.image[0].url return programDetail.image[0].url
} else { } else {
return '' return ''
} }
} }
function parseCategory(programDetail) { function parseCategory(programDetail) {
if (programDetail && programDetail.category_Info && programDetail.category_Info[0].title) { if (programDetail && programDetail.category_Info && programDetail.category_Info[0].title) {
return programDetail.category_Info[0].title return programDetail.category_Info[0].title
} else { } else {
return '' return ''
} }
} }
function parseDescription(programDetail) { function parseDescription(programDetail) {
if ( if (
programDetail && programDetail &&
programDetail.content && programDetail.content &&
programDetail.content[0] && programDetail.content[0] &&
programDetail.content[0].text programDetail.content[0].text
) { ) {
let description = programDetail.content[0].text let description = programDetail.content[0].text
let regex = /(<([^>]+)>)/gi let regex = /(<([^>]+)>)/gi
return description.replace(regex, '') return description.replace(regex, '')
} else { } else {
return '' return ''
} }
} }

View File

@@ -1,72 +1,72 @@
const { url, parser } = require('./arirang.com.config.js') const { url, parser } = require('./arirang.com.config.js')
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
const axios = require('axios') const axios = require('axios')
const dayjs = require('dayjs') const dayjs = require('dayjs')
const utc = require('dayjs/plugin/utc') const utc = require('dayjs/plugin/utc')
dayjs.extend(utc) dayjs.extend(utc)
jest.mock('axios') jest.mock('axios')
const date = dayjs.tz('2025-04-20', 'Asia/Seoul').startOf('d') const date = dayjs.tz('2025-04-20', 'Asia/Seoul').startOf('d')
const channel = { const channel = {
xmltv_id: 'ArirangWorld.kr', xmltv_id: 'ArirangWorld.kr',
site_id: 'CH_W', site_id: 'CH_W',
name: 'Arirang World', name: 'Arirang World',
lang: 'en', lang: 'en',
logo: 'https://i.imgur.com/5Aoithj.png' logo: 'https://i.imgur.com/5Aoithj.png'
} }
const content = fs.readFileSync(path.resolve(__dirname, '__data__/schedule.json'), 'utf8') const content = fs.readFileSync(path.resolve(__dirname, '__data__/schedule.json'), 'utf8')
const programDetail = fs.readFileSync(path.resolve(__dirname, '__data__/detail.json'), 'utf8') const programDetail = fs.readFileSync(path.resolve(__dirname, '__data__/detail.json'), 'utf8')
const context = { channel: channel, content: content, date: date } const context = { channel: channel, content: content, date: date }
it('can generate valid url', () => { it('can generate valid url', () => {
expect(url).toBe('https://www.arirang.com/v1.0/open/external/proxy') expect(url).toBe('https://www.arirang.com/v1.0/open/external/proxy')
}) })
it('can handle empty guide', async () => { it('can handle empty guide', async () => {
const results = await parser({ channel: channel, content: '', date: date }) const results = await parser({ channel: channel, content: '', date: date })
expect(results).toMatchObject([]) expect(results).toMatchObject([])
}) })
it('can parse response', async () => { it('can parse response', async () => {
axios.post.mockImplementation((url, data) => { axios.post.mockImplementation((url, data) => {
if ( if (
url === 'https://www.arirang.com/v1.0/open/external/proxy' && url === 'https://www.arirang.com/v1.0/open/external/proxy' &&
JSON.stringify(data) === JSON.stringify(data) ===
JSON.stringify({ JSON.stringify({
address: 'https://script.arirang.com/api/v1/bis/listScheduleV3.do', address: 'https://script.arirang.com/api/v1/bis/listScheduleV3.do',
method: 'POST', method: 'POST',
headers: {}, headers: {},
body: { data: { dmParam: { chanId: 'CH_W', broadYmd: '20250420', planNo: '1' } } } body: { data: { dmParam: { chanId: 'CH_W', broadYmd: '20250420', planNo: '1' } } }
}) })
) { ) {
return Promise.resolve({ return Promise.resolve({
data: JSON.parse(content) data: JSON.parse(content)
}) })
} else if ( } else if (
url === 'https://www.arirang.com/v1.0/open/program/detail' && url === 'https://www.arirang.com/v1.0/open/program/detail' &&
JSON.stringify(data) === JSON.stringify({ bis_program_code: '2025006T' }) JSON.stringify(data) === JSON.stringify({ bis_program_code: '2025006T' })
) { ) {
return Promise.resolve({ return Promise.resolve({
data: JSON.parse(programDetail) data: JSON.parse(programDetail)
}) })
} else { } else {
return Promise.resolve({ return Promise.resolve({
data: '' data: ''
}) })
} }
}) })
const results = await parser(context) const results = await parser(context)
expect(results[0]).toMatchObject({ expect(results[0]).toMatchObject({
title: 'Diplomat Archives: Hidden Stories', title: 'Diplomat Archives: Hidden Stories',
start: dayjs.tz(date, 'Asia/Seoul'), start: dayjs.tz(date, 'Asia/Seoul'),
stop: dayjs.tz(date, 'Asia/Seoul').add(30, 'minute'), stop: dayjs.tz(date, 'Asia/Seoul').add(30, 'minute'),
image: image:
'https://img.arirang.com/v1/AUTH_d52449c16d3b4bbca17d4fffd9fc44af/public/images/202504/2985531324875408146.jpg', 'https://img.arirang.com/v1/AUTH_d52449c16d3b4bbca17d4fffd9fc44af/public/images/202504/2985531324875408146.jpg',
description: 'As of April 2025, S. Korea has established diplomatic relations with a total of 194 countries.\nAmong them are countries that have had ties and exchanges with Korea for hundreds of years.\nWith such long-standing relationships with so many nations,\nmight there be fascinating hidden stories between Korea and the rest of the world that we dont know yet? \n\n"Diplomats Archives: Hidden Stories" begins with this very question.\nTogether with foreign embassies in Korea, the series uncovers and sheds light on meaningful yet lesser-known stories between Korea and other countries.\nThrough this, we aim to reaffirm the deep friendships that have been built over time, highlight how countries are interconnected—bilaterally and multilaterally—\nand emphasize the importance of cooperation on the global stage today.', description: 'As of April 2025, S. Korea has established diplomatic relations with a total of 194 countries.\nAmong them are countries that have had ties and exchanges with Korea for hundreds of years.\nWith such long-standing relationships with so many nations,\nmight there be fascinating hidden stories between Korea and the rest of the world that we dont know yet? \n\n"Diplomats Archives: Hidden Stories" begins with this very question.\nTogether with foreign embassies in Korea, the series uncovers and sheds light on meaningful yet lesser-known stories between Korea and other countries.\nThrough this, we aim to reaffirm the deep friendships that have been built over time, highlight how countries are interconnected—bilaterally and multilaterally—\nand emphasize the importance of cooperation on the global stage today.',
category: 'Current Affairs' category: 'Current Affairs'
}) })
}) })

View File

@@ -0,0 +1 @@
[{"id":158963,"eventid":null,"duration":"01:34:00","lang":"Arabic","title":"الراقصه و السياسي","description":"تقرر الراقصه سونيا انشاء دار حضانه للأطفال اليتامى و عندما تتقدم بمشورعها للمسئول يرفض فتتحداه ، تلجأ للوزير عبد الحميد رأفت تربطه بها علاقة قديمة ، يخشى على مركزه و يرفض مساعدتها فتقرر كتابة مذكراتها بمساعدة أحد الصحفيين ، يتخوف عبد الحميد و المسئولين ثم يفاجأ عبد الحميد بحصول سونيا على الموافقه للمشورع و البدء في تنفيذه و ذلك لعلاقتها بأحد كبار المسئولين .","thumbnail":"/UploadImages/Channel/ARTAFLAM1/03/AlRaqesaWaAlSeyasi.jpg","image":"0","start_Time":"00:30","adddate":"3/4/2022 12:00:00 AM","repeat1":null,"iD_genre":0,"iD_Show_Type":0,"iD_Channel":77,"iD_country":0,"iD_rating":0,"end_time":"02:04","season_Number":0,"epoisode_Number":0,"hasCatchup":0,"cmsid":0,"containerID":0,"imagePath":"../../UploadImages/Channel/ARTAFLAM1/3/","youtube":"0","published_at":"0","directed_by":"0","composition":"0","cast":"0","timeShow":null,"short_description":"تقرر الراقصه سونيا انشاء دار حضانه للأطفال اليتامى و عندما تتقدم بمشورعها للمسئول يرفض فتتحداه ، تلجأ للوزير عبد الحميد رأفت تربطه بها علاقة قديمة ، يخشى على مركزه و يرفض مساعدتها فتقرر كتابة مذكراتها بمساعدة أحد الصحفيين ، يتخوف عبد الحميد و المسئولين ثم يفاجأ عبد الحميد بحصول سونيا على الموافقه للمشورع و البدء في تنفيذه و ذلك لعلاقتها بأحد كبار المسئولين .","seOdescription":null,"tagseo":null,"channel_name":null,"pathimage":null,"pathThumbnail":null}]

View File

@@ -1,70 +1,70 @@
process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = 0 process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = 0
const customParseFormat = require('dayjs/plugin/customParseFormat') const customParseFormat = require('dayjs/plugin/customParseFormat')
const timezone = require('dayjs/plugin/timezone') const timezone = require('dayjs/plugin/timezone')
const utc = require('dayjs/plugin/utc') const utc = require('dayjs/plugin/utc')
const dayjs = require('dayjs') const dayjs = require('dayjs')
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
dayjs.extend(timezone) dayjs.extend(timezone)
dayjs.extend(utc) dayjs.extend(utc)
module.exports = { module.exports = {
site: 'artonline.tv', site: 'artonline.tv',
days: 2, days: 2,
url: function ({ channel }) { url: function ({ channel }) {
const [, site_id] = channel.site_id.split('#') const [, site_id] = channel.site_id.split('#')
return `https://www.artonline.tv/Home/Tvlist${site_id}` return `https://www.artonline.tv/Home/Tvlist${site_id}`
}, },
request: { request: {
method: 'POST', method: 'POST',
headers: { headers: {
'content-type': 'application/x-www-form-urlencoded' 'content-type': 'application/x-www-form-urlencoded'
}, },
data: function ({ date }) { data: function ({ date }) {
const diff = date.diff(dayjs.utc().startOf('d'), 'd') const diff = date.diff(dayjs.utc().startOf('d'), 'd')
const params = new URLSearchParams() const params = new URLSearchParams()
params.append('objId', diff) params.append('objId', diff)
return params return params
} }
}, },
parser: function ({ content }) { parser: function ({ content }) {
const programs = [] const programs = []
if (!content) return programs if (!content) return programs
const items = JSON.parse(content) const items = JSON.parse(content)
items.forEach(item => { items.forEach(item => {
const image = parseImage(item) const image = parseImage(item)
const start = parseStart(item) const start = parseStart(item)
const duration = parseDuration(item) const duration = parseDuration(item)
const stop = start.add(duration, 's') const stop = start.add(duration, 's')
programs.push({ programs.push({
title: item.title, title: item.title,
description: item.description, description: item.description,
image, image,
start, start,
stop stop
}) })
}) })
return programs return programs
} }
} }
function parseStart(item) { function parseStart(item) {
const [, M, D, YYYY] = item.adddate.match(/(\d+)\/(\d+)\/(\d+) /) const [, M, D, YYYY] = item.adddate.match(/(\d+)\/(\d+)\/(\d+) /)
const [HH, mm] = item.start_Time.split(':') const [HH, mm] = item.start_Time.split(':')
return dayjs.tz(`${YYYY}-${M}-${D}T${HH}:${mm}:00`, 'YYYY-M-DTHH:mm:ss', 'Asia/Riyadh') return dayjs.tz(`${YYYY}-${M}-${D}T${HH}:${mm}:00`, 'YYYY-M-DTHH:mm:ss', 'Asia/Riyadh')
} }
function parseDuration(item) { function parseDuration(item) {
const [, HH, mm, ss] = item.duration.match(/(\d+):(\d+):(\d+)/) const [, HH, mm, ss] = item.duration.match(/(\d+):(\d+):(\d+)/)
return parseInt(HH) * 3600 + parseInt(mm) * 60 + parseInt(ss) return parseInt(HH) * 3600 + parseInt(mm) * 60 + parseInt(ss)
} }
function parseImage(item) { function parseImage(item) {
return item.thumbnail ? `https://www.artonline.tv${item.thumbnail}` : null return item.thumbnail ? `https://www.artonline.tv${item.thumbnail}` : null
} }

View File

@@ -1,65 +1,66 @@
const { parser, url, request } = require('./artonline.tv.config.js') const { parser, url, request } = require('./artonline.tv.config.js')
const dayjs = require('dayjs') const fs = require('fs')
const utc = require('dayjs/plugin/utc') const path = require('path')
const customParseFormat = require('dayjs/plugin/customParseFormat') const dayjs = require('dayjs')
dayjs.extend(customParseFormat) const utc = require('dayjs/plugin/utc')
dayjs.extend(utc) const customParseFormat = require('dayjs/plugin/customParseFormat')
dayjs.extend(customParseFormat)
const channel = { dayjs.extend(utc)
site_id: '#Aflam2',
xmltv_id: 'ARTAflam2.sa' const channel = {
} site_id: '#Aflam2',
xmltv_id: 'ARTAflam2.sa'
it('can generate valid url', () => { }
expect(url({ channel })).toBe('https://www.artonline.tv/Home/TvlistAflam2')
}) it('can generate valid url', () => {
expect(url({ channel })).toBe('https://www.artonline.tv/Home/TvlistAflam2')
it('can generate valid request method', () => { })
expect(request.method).toBe('POST')
}) it('can generate valid request method', () => {
expect(request.method).toBe('POST')
it('can generate valid request headers', () => { })
expect(request.headers).toMatchObject({
'content-type': 'application/x-www-form-urlencoded' it('can generate valid request headers', () => {
}) expect(request.headers).toMatchObject({
}) 'content-type': 'application/x-www-form-urlencoded'
})
it('can generate valid request data for today', () => { })
const date = dayjs.utc().startOf('d')
const data = request.data({ date }) it('can generate valid request data for today', () => {
expect(data.get('objId')).toBe('0') const date = dayjs.utc().startOf('d')
}) const data = request.data({ date })
expect(data.get('objId')).toBe('0')
it('can generate valid request data for tomorrow', () => { })
const date = dayjs.utc().startOf('d').add(1, 'd')
const data = request.data({ date }) it('can generate valid request data for tomorrow', () => {
expect(data.get('objId')).toBe('1') const date = dayjs.utc().startOf('d').add(1, 'd')
}) const data = request.data({ date })
expect(data.get('objId')).toBe('1')
it('can parse response', () => { })
const content =
'[{"id":158963,"eventid":null,"duration":"01:34:00","lang":"Arabic","title":"الراقصه و السياسي","description":"تقرر الراقصه سونيا انشاء دار حضانه للأطفال اليتامى و عندما تتقدم بمشورعها للمسئول يرفض فتتحداه ، تلجأ للوزير عبد الحميد رأفت تربطه بها علاقة قديمة ، يخشى على مركزه و يرفض مساعدتها فتقرر كتابة مذكراتها بمساعدة أحد الصحفيين ، يتخوف عبد الحميد و المسئولين ثم يفاجأ عبد الحميد بحصول سونيا على الموافقه للمشورع و البدء في تنفيذه و ذلك لعلاقتها بأحد كبار المسئولين .","thumbnail":"/UploadImages/Channel/ARTAFLAM1/03/AlRaqesaWaAlSeyasi.jpg","image":"0","start_Time":"00:30","adddate":"3/4/2022 12:00:00 AM","repeat1":null,"iD_genre":0,"iD_Show_Type":0,"iD_Channel":77,"iD_country":0,"iD_rating":0,"end_time":"02:04","season_Number":0,"epoisode_Number":0,"hasCatchup":0,"cmsid":0,"containerID":0,"imagePath":"../../UploadImages/Channel/ARTAFLAM1/3/","youtube":"0","published_at":"0","directed_by":"0","composition":"0","cast":"0","timeShow":null,"short_description":"تقرر الراقصه سونيا انشاء دار حضانه للأطفال اليتامى و عندما تتقدم بمشورعها للمسئول يرفض فتتحداه ، تلجأ للوزير عبد الحميد رأفت تربطه بها علاقة قديمة ، يخشى على مركزه و يرفض مساعدتها فتقرر كتابة مذكراتها بمساعدة أحد الصحفيين ، يتخوف عبد الحميد و المسئولين ثم يفاجأ عبد الحميد بحصول سونيا على الموافقه للمشورع و البدء في تنفيذه و ذلك لعلاقتها بأحد كبار المسئولين .","seOdescription":null,"tagseo":null,"channel_name":null,"pathimage":null,"pathThumbnail":null}]' it('can parse response', () => {
const result = parser({ content }).map(p => { const content = fs.readFileSync(path.resolve(__dirname, '__data__/content.json'))
p.start = p.start.toJSON() const result = parser({ content }).map(p => {
p.stop = p.stop.toJSON() p.start = p.start.toJSON()
return p p.stop = p.stop.toJSON()
}) return p
})
expect(result).toMatchObject([
{ expect(result).toMatchObject([
start: '2022-03-03T21:30:00.000Z', {
stop: '2022-03-03T23:04:00.000Z', start: '2022-03-03T21:30:00.000Z',
title: 'الراقصه و السياسي', stop: '2022-03-03T23:04:00.000Z',
description: title: 'الراقصه و السياسي',
'تقرر الراقصه سونيا انشاء دار حضانه للأطفال اليتامى و عندما تتقدم بمشورعها للمسئول يرفض فتتحداه ، تلجأ للوزير عبد الحميد رأفت تربطه بها علاقة قديمة ، يخشى على مركزه و يرفض مساعدتها فتقرر كتابة مذكراتها بمساعدة أحد الصحفيين ، يتخوف عبد الحميد و المسئولين ثم يفاجأ عبد الحميد بحصول سونيا على الموافقه للمشورع و البدء في تنفيذه و ذلك لعلاقتها بأحد كبار المسئولين .', description:
image: 'https://www.artonline.tv/UploadImages/Channel/ARTAFLAM1/03/AlRaqesaWaAlSeyasi.jpg' 'تقرر الراقصه سونيا انشاء دار حضانه للأطفال اليتامى و عندما تتقدم بمشورعها للمسئول يرفض فتتحداه ، تلجأ للوزير عبد الحميد رأفت تربطه بها علاقة قديمة ، يخشى على مركزه و يرفض مساعدتها فتقرر كتابة مذكراتها بمساعدة أحد الصحفيين ، يتخوف عبد الحميد و المسئولين ثم يفاجأ عبد الحميد بحصول سونيا على الموافقه للمشورع و البدء في تنفيذه و ذلك لعلاقتها بأحد كبار المسئولين .',
} image: 'https://www.artonline.tv/UploadImages/Channel/ARTAFLAM1/03/AlRaqesaWaAlSeyasi.jpg'
]) }
}) ])
})
it('can handle empty guide', () => {
const result = parser({ it('can handle empty guide', () => {
content: '' const result = parser({
}) content: ''
expect(result).toMatchObject([]) })
}) expect(result).toMatchObject([])
})

View File

@@ -1,86 +1,86 @@
const cheerio = require('cheerio') const cheerio = require('cheerio')
const axios = require('axios') const axios = require('axios')
const { DateTime } = require('luxon') const { DateTime } = require('luxon')
module.exports = { module.exports = {
site: 'awilime.com', site: 'awilime.com',
days: 2, days: 2,
url({ channel, date }) { url({ channel, date }) {
return `https://www.awilime.com/tv/napi_musor/${channel.site_id}/${date.format('YYYY_MM_DD')}` return `https://www.awilime.com/tv/napi_musor/${channel.site_id}/${date.format('YYYY_MM_DD')}`
}, },
parser({ content, date }) { parser({ content, date }) {
const programs = [] const programs = []
const items = parseItems(content) const items = parseItems(content)
items.forEach(item => { items.forEach(item => {
const prev = programs[programs.length - 1] const prev = programs[programs.length - 1]
const $item = cheerio.load(item) const $item = cheerio.load(item)
let start = parseStart($item, date) let start = parseStart($item, date)
if (!start) return if (!start) return
if (prev) { if (prev) {
prev.stop = start prev.stop = start
} }
const stop = start.plus({ minute: 30 }) const stop = start.plus({ minute: 30 })
programs.push({ programs.push({
title: parseTitle($item), title: parseTitle($item),
sub_title: parseSubTitle($item), sub_title: parseSubTitle($item),
description: parseDescription($item), description: parseDescription($item),
start, start,
stop stop
}) })
}) })
return programs return programs
}, },
async channels() { async channels() {
const html = await axios const html = await axios
.get('https://www.awilime.com/tv/napi_musor') .get('https://www.awilime.com/tv/napi_musor')
.then(r => r.data) .then(r => r.data)
.catch(console.log) .catch(console.log)
const $ = cheerio.load(html) const $ = cheerio.load(html)
const items = $('#body > div.tk > div > div').toArray() const items = $('#body > div.tk > div > div').toArray()
const channels = [] const channels = []
items.forEach(item => { items.forEach(item => {
const name = $(item).find('a').text().trim() const name = $(item).find('a').text().trim()
const url = $(item).find('a').attr('href') const url = $(item).find('a').attr('href')
const [, site_id] = url.match(/\/tv\/napi_musor\/(.*)/) || [null, null] const [, site_id] = url.match(/\/tv\/napi_musor\/(.*)/) || [null, null]
if (!site_id) return if (!site_id) return
if (channels.find(channel => channel.site_id === site_id)) return if (channels.find(channel => channel.site_id === site_id)) return
channels.push({ channels.push({
lang: 'hu', lang: 'hu',
site_id, site_id,
name name
}) })
}) })
return channels return channels
} }
} }
function parseTitle($item) { function parseTitle($item) {
return $item('b > a').text().trim() return $item('b > a').text().trim()
} }
function parseSubTitle($item) { function parseSubTitle($item) {
return $item('i').clone().children().remove('s').end().text().trim() return $item('i').clone().children().remove('s').end().text().trim()
} }
function parseDescription($item) { function parseDescription($item) {
return $item('p').text().trim() return $item('p').text().trim()
} }
function parseStart($item, date) { function parseStart($item, date) {
let time = $item('b').clone().children().remove().end().text().trim() let time = $item('b').clone().children().remove().end().text().trim()
if (!time || !/^\d/.test(time)) return null if (!time || !/^\d/.test(time)) return null
time = `${date.format('YYYY-MM-DD')} ${time}` time = `${date.format('YYYY-MM-DD')} ${time}`
return DateTime.fromFormat(time, 'yyyy-MM-dd HH:mm', { zone: 'Europe/Budapest' }).toUTC() return DateTime.fromFormat(time, 'yyyy-MM-dd HH:mm', { zone: 'Europe/Budapest' }).toUTC()
} }
function parseItems(content) { function parseItems(content) {
const $ = cheerio.load(content) const $ = cheerio.load(content)
return $('#body > div.tdc > div.td2 > div').toArray() return $('#body > div.tdc > div.td2 > div').toArray()
} }

View File

@@ -1,49 +1,49 @@
const { parser, url } = require('./awilime.com.config.js') const { parser, url } = require('./awilime.com.config.js')
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
const dayjs = require('dayjs') const dayjs = require('dayjs')
const utc = require('dayjs/plugin/utc') const utc = require('dayjs/plugin/utc')
const customParseFormat = require('dayjs/plugin/customParseFormat') const customParseFormat = require('dayjs/plugin/customParseFormat')
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
dayjs.extend(utc) dayjs.extend(utc)
const date = dayjs.utc('2024-06-26', 'YYYY-MM-DD').startOf('d') const date = dayjs.utc('2024-06-26', 'YYYY-MM-DD').startOf('d')
const channel = { const channel = {
site_id: 'budapest_europa_tv', site_id: 'budapest_europa_tv',
xmltv_id: 'BudapestEuropaTelevizio.hu' xmltv_id: 'BudapestEuropaTelevizio.hu'
} }
it('can generate valid url', () => { it('can generate valid url', () => {
expect(url({ channel, date })).toBe( expect(url({ channel, date })).toBe(
'https://www.awilime.com/tv/napi_musor/budapest_europa_tv/2024_06_26' 'https://www.awilime.com/tv/napi_musor/budapest_europa_tv/2024_06_26'
) )
}) })
it('can parse response', () => { it('can parse response', () => {
const content = fs.readFileSync(path.resolve(__dirname, '__data__/content.html')) const content = fs.readFileSync(path.resolve(__dirname, '__data__/content.html'))
const results = parser({ content, date }).map(p => { const results = parser({ content, date }).map(p => {
p.start = p.start.toJSON() p.start = p.start.toJSON()
p.stop = p.stop.toJSON() p.stop = p.stop.toJSON()
return p return p
}) })
expect(results.length).toBe(15) expect(results.length).toBe(15)
expect(results[3]).toMatchObject({ expect(results[3]).toMatchObject({
start: '2024-06-26T07:00:00.000Z', start: '2024-06-26T07:00:00.000Z',
stop: '2024-06-26T08:00:00.000Z', stop: '2024-06-26T08:00:00.000Z',
title: 'Ébredés', title: 'Ébredés',
sub_title: 'Amerikai dokumentumfilm (2018)', sub_title: 'Amerikai dokumentumfilm (2018)',
description: 'Balla Tibor misszionárius' description: 'Balla Tibor misszionárius'
}) })
}) })
it('can handle empty guide', () => { it('can handle empty guide', () => {
const result = parser({ const result = parser({
date, date,
channel, channel,
content: content:
'<html><head><title>Object moved</title></head><body><h2>Object moved to <a href="/tv/napi_musor/budapest_europa_tv/2024_06_24">here</a>.</h2></body></html>' '<html><head><title>Object moved</title></head><body><h2>Object moved to <a href="/tv/napi_musor/budapest_europa_tv/2024_06_24">here</a>.</h2></body></html>'
}) })
expect(result).toMatchObject([]) expect(result).toMatchObject([])
}) })

View File

@@ -1,110 +1,110 @@
const axios = require('axios') const axios = require('axios')
const dayjs = require('dayjs') const dayjs = require('dayjs')
const cheerio = require('cheerio') const cheerio = require('cheerio')
const { DateTime } = require('luxon') const { DateTime } = require('luxon')
module.exports = { module.exports = {
site: 'bein.com', site: 'bein.com',
days: 2, days: 2,
request: { request: {
cache: { cache: {
ttl: 60 * 60 * 1000 // 1 hour ttl: 60 * 60 * 1000 // 1 hour
} }
}, },
url: function ({ date, channel }) { url: function ({ date, channel }) {
const [category] = channel.site_id.split('#') const [category] = channel.site_id.split('#')
const postid = channel.lang === 'ar' ? '25344' : '25356' const postid = channel.lang === 'ar' ? '25344' : '25356'
return `https://www.bein.com/${ return `https://www.bein.com/${
channel.lang channel.lang
}/epg-ajax-template/?action=epg_fetch&category=${category}&cdate=${date.format( }/epg-ajax-template/?action=epg_fetch&category=${category}&cdate=${date.format(
'YYYY-MM-DD' 'YYYY-MM-DD'
)}&language=${channel.lang.toUpperCase()}&loadindex=0&mins=00&offset=0&postid=${postid}&serviceidentity=bein.net` )}&language=${channel.lang.toUpperCase()}&loadindex=0&mins=00&offset=0&postid=${postid}&serviceidentity=bein.net`
}, },
parser: function ({ content, channel, date }) { parser: function ({ content, channel, date }) {
let programs = [] let programs = []
const items = parseItems(content, channel) const items = parseItems(content, channel)
date = DateTime.fromMillis(date.valueOf()).minus({ days: 1 }) date = DateTime.fromMillis(date.valueOf()).minus({ days: 1 })
items.forEach(item => { items.forEach(item => {
const $item = cheerio.load(item) const $item = cheerio.load(item)
const title = parseTitle($item) const title = parseTitle($item)
if (!title) return if (!title) return
const category = parseCategory($item) const category = parseCategory($item)
const prev = programs[programs.length - 1] const prev = programs[programs.length - 1]
let start = parseTime($item, date) let start = parseTime($item, date)
if (prev) { if (prev) {
if (start < prev.start) { if (start < prev.start) {
start = start.plus({ days: 1 }) start = start.plus({ days: 1 })
date = date.plus({ days: 1 }) date = date.plus({ days: 1 })
} }
prev.stop = start prev.stop = start
} }
let stop = parseTime($item, start) let stop = parseTime($item, start)
if (stop < start) { if (stop < start) {
stop = stop.plus({ days: 1 }) stop = stop.plus({ days: 1 })
} }
programs.push({ programs.push({
title, title,
category, category,
start, start,
stop stop
}) })
}) })
return programs return programs
}, },
async channels({ lang }) { async channels({ lang }) {
const categories = ['entertainment', 'sports'] const categories = ['entertainment', 'sports']
let channels = [] let channels = []
for (let category of categories) { for (let category of categories) {
const url = `https://www.bein.com/en/epg-ajax-template/?action=epg_fetch&offset=0&category=${category}&serviceidentity=bein.net&mins=00&cdate=${dayjs().format( const url = `https://www.bein.com/en/epg-ajax-template/?action=epg_fetch&offset=0&category=${category}&serviceidentity=bein.net&mins=00&cdate=${dayjs().format(
'YYYY-MM-DD' 'YYYY-MM-DD'
)}&language=${lang.toUpperCase()}&postid=25356&loadindex=0` )}&language=${lang.toUpperCase()}&postid=25356&loadindex=0`
const data = await axios const data = await axios
.get(url) .get(url)
.then(r => r.data) .then(r => r.data)
.catch(console.log) .catch(console.log)
const $ = cheerio.load(data) const $ = cheerio.load(data)
$('.container-tvguide > div').each((i, el) => { $('.container-tvguide > div').each((i, el) => {
const id = $(el).attr('id') const id = $(el).attr('id')
if (!id || !/^channels_\d+/.test(id)) return if (!id || !/^channels_\d+/.test(id)) return
const [, channelId] = id.split('_') const [, channelId] = id.split('_')
channels.push({ channels.push({
lang, lang,
site_id: `${category}#${channelId}`, site_id: `${category}#${channelId}`,
name: channelId name: channelId
}) })
}) })
} }
return channels return channels
} }
} }
function parseTitle($item) { function parseTitle($item) {
return $item('.title').text() return $item('.title').text()
} }
function parseCategory($item) { function parseCategory($item) {
return $item('.format').text() return $item('.format').text()
} }
function parseTime($item, date) { function parseTime($item, date) {
let [, time] = $item('.time') let [, time] = $item('.time')
.text() .text()
.match(/^(\d{2}:\d{2})/) || [null, null] .match(/^(\d{2}:\d{2})/) || [null, null]
if (!time) return null if (!time) return null
time = `${date.toFormat('yyyy-MM-dd')} ${time}` time = `${date.toFormat('yyyy-MM-dd')} ${time}`
return DateTime.fromFormat(time, 'yyyy-MM-dd HH:mm', { zone: 'Asia/Qatar' }).toUTC() return DateTime.fromFormat(time, 'yyyy-MM-dd HH:mm', { zone: 'Asia/Qatar' }).toUTC()
} }
function parseItems(content, channel) { function parseItems(content, channel) {
const [, channelId] = channel.site_id.split('#') const [, channelId] = channel.site_id.split('#')
const $ = cheerio.load(content) const $ = cheerio.load(content)
return $(`#channels_${channelId} .slider > ul:first-child > li`).toArray() return $(`#channels_${channelId} .slider > ul:first-child > li`).toArray()
} }

Some files were not shown because too many files have changed in this diff Show More