Merge branch 'master' into zap2it.com

This commit is contained in:
Newbiect
2025-08-05 14:28:02 -04:00
committed by GitHub
1309 changed files with 263503 additions and 106197 deletions
-39
View File
@@ -1,39 +0,0 @@
{
"env": {
"node": true,
"es2021": true,
"jest": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"@typescript-eslint"
],
"rules": {
"@typescript-eslint/no-var-requires": "off",
"no-case-declarations": "off",
"linebreak-style": [
"error",
"windows"
],
"quotes": [
"error",
"single",
{
"avoidEscape": true
}
],
"semi": [
"error",
"never"
]
}
}
+2
View File
@@ -0,0 +1,2 @@
# Enforce the usage of CRLF in GitHub Actions per ESLint configuration.
* text eol=crlf
@@ -0,0 +1,30 @@
name: ✍️ Channel Request
description: Request to add a channel to the guide
labels: ['channel request']
body:
- type: markdown
attributes:
value: |
Please fill out the request as much as possible so we can efficiently process your request.
- type: input
attributes:
label: Site
description: The name of the site
placeholder: 'guidatv.sky.it'
validations:
required: true
- type: textarea
attributes:
label: Channels
description: List of channels to be added
placeholder: 'BBC One'
validations:
required: true
- type: textarea
attributes:
label: Notes
description: Anything else we should know?
@@ -0,0 +1,24 @@
name: ✨ Feature request
description: Ask for a feature to be added to the guide
labels: ['feature request']
body:
- type: markdown
attributes:
value: |
Please fill out the issue template as much as you can so we could efficiently process your request.
- type: input
attributes:
label: Site
description: The name of the site
placeholder: 'guidatv.sky.it'
validations:
required: true
- type: textarea
attributes:
label: Description
description: Detailed description of the feature.
validations:
required: true
-3
View File
@@ -1,8 +1,5 @@
blank_issues_enabled: true blank_issues_enabled: true
contact_links: contact_links:
- name: 💡 Feature request
url: https://github.com/orgs/iptv-org/discussions/categories/ideas
about: For any ideas or feature requests
- name: ❓ Ask a question - name: ❓ Ask a question
url: https://github.com/orgs/iptv-org/discussions/categories/q-a url: https://github.com/orgs/iptv-org/discussions/categories/q-a
about: Ask questions about this project about: Ask questions about this project
+53
View File
@@ -0,0 +1,53 @@
name: check
on:
workflow_dispatch:
pull_request:
types: [opened, synchronize, reopened, edited]
branches:
- master
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: changed files
id: files
run: |
git fetch origin master:master
JS_ANY_CHANGED=false
JS_ALL_CHANGED_FILES=$(git diff --name-only master -- tests/**/*.js tests/**/*.ts scripts/**/*.js scripts/**/*.mts scripts/**/*.ts sites/**/*.js sites/**/*.ts | tr '\n' ' ')
if [ -n "${JS_ALL_CHANGED_FILES}" ]; then
JS_ANY_CHANGED=true
fi
echo "js_all_changed_files=$JS_ALL_CHANGED_FILES" >> "$GITHUB_OUTPUT"
echo "js_any_changed=$JS_ANY_CHANGED" >> "$GITHUB_OUTPUT"
CHANNELS_ANY_CHANGED=false
CHANNELS_ALL_CHANGED_FILES=$(git diff --name-only master -- sites/**/*.channels.xml | tr '\n' ' ')
if [ -n "${CHANNELS_ALL_CHANGED_FILES}" ]; then
CHANNELS_ANY_CHANGED=true
fi
echo "channels_all_changed_files=$CHANNELS_ALL_CHANGED_FILES" >> "$GITHUB_OUTPUT"
echo "channels_any_changed=$CHANNELS_ANY_CHANGED" >> "$GITHUB_OUTPUT"
- uses: actions/setup-node@v4
if: ${{ steps.files.outputs.js_any_changed == 'true' || steps.files.outputs.channels_any_changed == 'true' }}
with:
node-version: 22
cache: 'npm'
- name: install dependencies
if: steps.files.outputs.js_any_changed == 'true' || steps.files.outputs.channels_any_changed == 'true'
run: SKIP_POSTINSTALL=1 npm install
- name: check changed js-files
if: steps.files.outputs.js_any_changed == 'true'
run: |
npx eslint ${{ steps.files.outputs.js_all_changed_files }}
- name: check changed *.channels.xml
if: steps.files.outputs.channels_any_changed == 'true'
run: |
npm run channels:lint -- ${{ steps.files.outputs.channels_all_changed_files }}
npm run postinstall
npm run channels:validate -- ${{ steps.files.outputs.channels_all_changed_files }}
+17 -5
View File
@@ -7,14 +7,14 @@ jobs:
main: main:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: tibdex/github-app-token@v1.8.2 - uses: tibdex/github-app-token@v1.8.2
if: ${{ !env.ACT }} if: ${{ !env.ACT }}
id: create-app-token id: create-app-token
with: with:
app_id: ${{ secrets.APP_ID }} app_id: ${{ secrets.APP_ID }}
private_key: ${{ secrets.APP_PRIVATE_KEY }} private_key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/checkout@v3 - uses: actions/checkout@v4
if: ${{ !env.ACT }} if: ${{ !env.ACT }}
with: with:
token: ${{ steps.create-app-token.outputs.token }} token: ${{ steps.create-app-token.outputs.token }}
@@ -22,16 +22,28 @@ jobs:
run: | run: |
git config user.name "iptv-bot[bot]" git config user.name "iptv-bot[bot]"
git config user.email "84861620+iptv-bot[bot]@users.noreply.github.com" git config user.email "84861620+iptv-bot[bot]@users.noreply.github.com"
- uses: actions/setup-node@v3 - uses: actions/setup-node@v4
if: ${{ !env.ACT }}
with: with:
node-version: 18 node-version: 22
cache: 'npm' cache: 'npm'
- name: install dependencies - name: install dependencies
run: npm install run: npm install
- name: check *.channels.xml - name: check *.channels.xml
run: | run: |
npm run channels:lint npm run channels:lint
- name: update sites.md
run: npm run sites:update
- run: git status
- name: commit changes to sites.md
if: ${{ !env.ACT && github.ref == 'refs/heads/master' }}
run: |
SITE=SITES.md
CHANGED=$(git diff ${SITE})
if [ -n "${CHANGED}" ]; then
git add ${SITE}
git commit -m "[Bot] Update ${SITE}" -m "Committed by [iptv-bot](https://github.com/apps/iptv-bot) via [update](https://github.com/iptv-org/epg/actions/runs/${{ github.run_id }}) workflow." --no-verify
git push
fi
- name: generate .api/guides.json - name: generate .api/guides.json
run: npm run api:generate run: npm run api:generate
- run: git status - run: git status
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
channels_changed="$(git diff --staged --name-only --diff-filter=ACMR -- 'sites/**/*.channels.xml' | sed 's| |\\ |g')"
if [ ! -z "$channels_changed" ]; then
npm run channels:lint -- $channels_changed
fi
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
channels_changed="$(git diff --staged --name-only --diff-filter=ACMR -- 'sites/**/*.channels.xml' | sed 's| |\\ |g')"
if [ ! -z "$channels_changed" ]; then
npm run channels:validate -- $channels_changed
fi
+3
View File
@@ -0,0 +1,3 @@
.husky/scripts_lint.sh
.husky/channels_lint.sh
.husky/channels_validate.sh
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
scripts_changed="$(git diff --staged --name-only --diff-filter=ACMR -- 'tests/**/*.ts' 'tests/**/*.js' 'scripts/**/*.ts' 'scripts/**/*.mts' 'scripts/**/*.js' 'sites/**/*.js' 'sites/**/*.ts' | sed 's| |\\ |g')"
if [ ! -z "$scripts_changed" ]; then
npx eslint $scripts_changed
fi
+1 -1
View File
@@ -1,7 +1,7 @@
module.exports = { module.exports = {
tabWidth: 2, tabWidth: 2,
useTabs: false, useTabs: false,
endOfLine: 'lf', endOfLine: 'crlf',
semi: false, semi: false,
singleQuote: true, singleQuote: true,
printWidth: 100, printWidth: 100,
+319 -42
View File
@@ -8,47 +8,247 @@
### How to add a channel to the guide? ### How to add a channel to the guide?
Open the [/sites](/sites) folder and select the source that you know has the guide for the channel you want. To ask for help with adding a channel simply fill out this [form](https://github.com/iptv-org/epg/issues/new?assignees=&labels=channel+request&projects=&template=2_channel-request.yml).
Then in the selected folder open the file `*.channels.xml` and add to it: If you want to add a channel to the list yourself, here are the instructions on how to do it.
First select the site from [SITES.md](SITES.md) that you know has a guide for the channel you need. Then go to the folder with its config and open the file `*.channels.xml`.
Make sure that the desired channel is not already in the list. If it is not, simply add its description to the end of the list as shown here:
```xml ```xml
<channel site="SITE" lang="LANGUAGE_CODE" xmltv_id="CHANNEL_ID" site_id="SITE_ID">CHANNEL_NAME</channel> <?xml version="1.0" encoding="UTF-8"?>
<channels>
...
<channel site="SITE" lang="LANGUAGE_CODE" xmltv_id="CHANNEL_ID" site_id="SITE_ID">CHANNEL_NAME</channel>
</channels>
``` ```
| Attribute | Description | Example | | Attribute | Description | Example |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------- | | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
| SITE | Site domain name. | `example.com` | | SITE | Site domain name. | `example.com` |
| LANGUAGE_CODE | Language of the guide ([ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) code). | `en` | | LANGUAGE_CODE | Language of the guide ([ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) code). | `en` |
| CHANNEL_ID | Channel ID from [iptv-org/database](https://github.com/iptv-org/database). A complete list of supported channels can also be found at https://iptv-org.github.io/. | `BBCOne.uk` | | CHANNEL_ID | ID of the channel. Full list of supported channels with corresponding ID could be found on [iptv-org.github.io](https://iptv-org.github.io/). | `HBO.us@East` |
| SITE_ID | Unique ID of the channel used in the source. | `bbc1` | | SITE_ID | Unique ID of the channel used in the source. | `hbo` |
| CHANNEL_NAME | Name of the channel used in the source. | `BBC 1` | | CHANNEL_NAME | Name of the channel used in the source. | `HBO East` |
After that just commit all changes and send a pull request. After that just [commit](https://docs.github.com/en/pull-requests/committing-changes-to-your-project/creating-and-editing-commits/about-commits) all changes and send a [pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests).
### How to report broken guide?
If you start to get errors when downloading the guide or if nothing loads at all, please let us know via this [form](https://github.com/iptv-org/epg/issues/new?assignees=&labels=broken+guide&projects=&template=3_broken-guide.yml).
### How to add a new source to the repository? ### How to add a new source to the repository?
To do this, you must create a new folder in the [/sites](/sites) with at least 3 files: If you are not familiar with javascript programming, you can ask for help from other community members through this [form](https://github.com/iptv-org/epg/issues/new?assignees=&labels=source+request&projects=&template=1_source-request.yml). Otherwise, below are the instructions for you.
To start with, you need to create a new folder in the [/sites](/sites) folder and put at least 4 files in it:
<details> <details>
<summary>example.com.config.js</summary> <summary>example.com.config.js</summary>
<br> <br>
This file describes what kind of request we need to send to get the guide for a particular channel on a certain date. It also describes how to parse the response. This file describes what kind of request we need to send to get the guide for a particular channel on a certain date and how to parse the response.
```js ```js
module.exports = { module.exports = {
site: 'example.com', site: 'example.com',
url: function ({ 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: function ({ content }) { parser(context) {
return JSON.parse(content) try {
return JSON.parse(context.content)
} catch {
return []
}
} }
} }
``` ```
More detailed instructions for this file can be found here: https://github.com/freearhey/epg-grabber#site-config ### Context Object
From each function in `config.js` you can access a `context` object containing the following data:
- `channel`: The object describing the current channel (xmltv_id, site_id, name, lang)
- `date`: The 'dayjs' instance with the requested date
- `content`: The response data as a String
- `buffer`: The response data as an ArrayBuffer
- `headers`: The response headers
- `request`: The request config
- `cached`: A boolean to check whether this request was cached or not
### Program Properties
List of properties that can be assigned to each program during parsing.
| Property | Aliases | Type | Required |
| --------------- | -------------------------------- | ------------------------------------------ | -------- |
| start | | `String \| Number \| Date()` | true |
| stop | | `String \| Number \| Date()` | true |
| title | titles | `String \| Object \| String[] \| Object[]` | true |
| subTitle | subTitles, sub_title, sub_titles | `String \| Object \| String[] \| Object[]` | false |
| description | desc, descriptions | `String \| Object \| String[] \| Object[]` | false |
| date | | `String \| Number \| Date()` | false |
| category | categories | `String \| Object \| String[] \| Object[]` | false |
| keyword | keywords | `String \| Object \| String[] \| Object[]` | false |
| language | languages | `String \| Object \| String[] \| Object[]` | false |
| origLanguage | origLanguages | `String \| Object \| String[] \| Object[]` | false |
| length | | `String \| Object \| String[] \| Object[]` | false |
| url | urls | `String \| Object \| String[] \| Object[]` | false |
| country | countries | `String \| Object \| String[] \| Object[]` | false |
| video | | `Object` | false |
| audio | | `Object` | false |
| season | | `String \| Number` | false |
| episode | | `String \| Number` | false |
| episodeNumber | episodeNum, episodeNumbers | `Object` | false |
| previouslyShown | | `String \| Object \| String[] \| Object[]` | false |
| premiere | | `String \| Object \| String[] \| Object[]` | false |
| lastChance | | `String \| Object \| String[] \| Object[]` | false |
| new | | `Boolean` | false |
| subtitles | | `Object \| Object[]` | false |
| rating | ratings | `String \| Object \| String[] \| Object[]` | false |
| starRating | starRatings | `String \| Object \| String[] \| Object[]` | false |
| review | reviews | `String \| Object \| String[] \| Object[]` | false |
| director | directors | `String \| Object \| String[] \| Object[]` | false |
| actor | actors | `String \| Object \| String[] \| Object[]` | false |
| writer | writers | `String \| Object \| String[] \| Object[]` | false |
| adapter | adapters | `String \| Object \| String[] \| Object[]` | false |
| producer | producers | `String \| Object \| String[] \| Object[]` | false |
| presenter | presenters | `String \| Object \| String[] \| Object[]` | false |
| composer | composers | `String \| Object \| String[] \| Object[]` | false |
| editor | editors | `String \| Object \| String[] \| Object[]` | false |
| commentator | commentators | `String \| Object \| String[] \| Object[]` | false |
| guest | guests | `String \| Object \| String[] \| Object[]` | false |
| image | images | `String \| Object \| String[] \| Object[]` | false |
| icon | icons | `String \| Object \| String[] \| Object[]` | false |
Example:
```js
{
start: '2021-03-19T06:00:00.000Z',
stop: '2021-03-19T06:30:00.000Z',
title: 'Program 1',
subTitle: 'Sub-title & 1',
description: 'Description for Program 1',
date: '2022-05-06',
categories: ['Comedy', 'Drama'],
keywords: [
{ lang: 'en', value: 'physical-comedy' },
{ lang: 'en', value: 'romantic' }
],
language: 'English',
origLanguage: { lang: 'en', value: 'French' },
length: { units: 'minutes', value: '60' },
url: 'http://example.com/title.html',
country: 'US',
video: {
present: 'yes',
colour: 'no',
aspect: '16:9',
quality: 'HDTV'
},
audio: {
present: 'yes',
stereo: 'Dolby Digital'
},
season: 9,
episode: 239,
previouslyShown: [{ start: '20080711000000', channel: 'channel-two.tv' }],
premiere: 'First time on British TV',
lastChance: [{ lang: 'en', value: 'Last time on this channel' }],
new: true,
subtitles: [
{ type: 'teletext', language: 'English' },
{ type: 'onscreen', language: [{ lang: 'en', value: 'Spanish' }] }
],
rating: {
system: 'MPAA',
value: 'P&G',
icon: 'http://example.com/pg_symbol.png'
},
starRatings: [
{
system: 'TV Guide',
value: '4/5',
icon: [{ src: 'stars.png', width: 100, height: 100 }]
},
{
system: 'IMDB',
value: '8/10'
}
],
reviews: [
{
type: 'text',
source: 'Rotten Tomatoes',
reviewer: 'Joe Bloggs',
lang: 'en',
value: 'This is a fantastic show!'
},
{
type: 'text',
source: 'IDMB',
reviewer: 'Jane Doe',
lang: 'en',
value: 'I love this show!'
},
{
type: 'url',
source: 'Rotten Tomatoes',
reviewer: 'Joe Bloggs',
lang: 'en',
value: 'https://example.com/programme_one_review'
}
],
directors: [
{
value: 'Director 1',
url: { value: 'http://example.com/director1.html', system: 'TestSystem' },
image: [
'https://example.com/image1.jpg',
{
value: 'https://example.com/image2.jpg',
type: 'person',
size: '2',
system: 'TestSystem',
orient: 'P'
}
]
},
'Director 2'
],
actors: ['Actor 1', 'Actor 2'],
writer: 'Writer 1',
producers: 'Roger Dobkowitz',
presenters: 'Drew Carey',
images: [
{
type: 'poster',
size: '1',
orient: 'P',
system: 'tvdb',
value: 'https://tvdb.com/programme_one_poster_1.jpg'
},
{
type: 'poster',
size: '2',
orient: 'P',
system: 'tmdb',
value: 'https://tmdb.com/programme_one_poster_2.jpg'
},
{
type: 'backdrop',
size: '3',
orient: 'L',
system: 'tvdb',
value: 'https://tvdb.com/programme_one_backdrop_3.jpg'
}
],
icon: 'https://example.com/images/Program1.png?x=шеллы&sid=777'
}
```
</details> </details>
@@ -59,44 +259,50 @@ More detailed instructions for this file can be found here: https://github.com/f
With this file we can test the previously created config and make sure it works as you expect. With this file we can test the previously created config and make sure it works as you expect.
```js ```js
const { url, parser } = require('./example.com.config.js') const { parser, url } = require('./example.com.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')
dayjs.extend(customParseFormat)
dayjs.extend(utc) dayjs.extend(utc)
const date = dayjs.utc('2022-11-18', 'YYYY-MM-DD').startOf('d') const date = dayjs.utc('2025-01-12', 'YYYY-MM-DD').startOf('d')
const channel = { site_id: 'bbc1', xmltv_id: 'BBCOne.uk', lang: 'en' } const channel = { site_id: 'bbc1', xmltv_id: 'BBCOne.uk' }
it('can generate valid url', () => { it('can generate valid url', () => {
expect(url({ channel, date })).toBe('https://example.com/api/bbc1/2022-11-18') expect(url({ channel, date })).toBe('https://example.com/api/bbc1/2025-01-12')
}) })
it('can parse response', () => { it('can parse response', () => {
const content = `[{"start":"2022-11-18T01:30:00.000Z","stop":"2022-11-18T02:00:00.000Z","title":"Program 1"}]` 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"}]'
const results = parser({ content }) const results = parser({ content })
expect(results).toMatchObject([ expect(results.length).toBe(2)
{ expect(results[0]).toMatchObject({
start: '2022-11-18T01:30:00.000Z', title: 'Program 1',
stop: '2022-11-18T02:00:00.000Z', start: '2025-01-12T00:00:00.000Z',
title: 'Program 1' stop: '2025-01-12T00:30:00.000Z'
} })
]) expect(results[1]).toMatchObject({
title: 'Program 2',
start: '2025-01-12T00:30:00.000Z',
stop: '2025-01-12T01:00:00.000Z'
})
}) })
it('can handle empty guide', () => { it('can handle empty guide', () => {
const results = parser({ content: '' }) const result = parser({
date,
channel,
content: ''
})
expect(results).toMatchObject([]) expect(result).toMatchObject([])
}) })
``` ```
To run the tests you can use the following command:
```sh
npm test --- example.com
```
Detailed documentation for the tests can be found here: https://jestjs.io/docs/using-matchers Detailed documentation for the tests can be found here: https://jestjs.io/docs/using-matchers
</details> </details>
@@ -110,21 +316,89 @@ This file contains a list of channels available at the source.
```xml ```xml
<?xml version="1.0" encoding="UTF-8" ?> <?xml version="1.0" encoding="UTF-8" ?>
<channels> <channels>
<channel site="example.com" lang="en" xmltv_id="BBCOne.uk" site_id="bbc1">BBC 1</channel> <channel site="example.com" lang="en" xmltv_id="BBCOne.uk" site_id="bbc1">BBC One</channel>
</channels> </channels>
``` ```
</details> </details>
After creating all the files we can make sure that the guide loads correctly and has no errors using the command: <details>
<summary>readme.md</summary>
<br>
This file contains instructions on how to use this config.
````
# example.com
https://example.com
### Download the guide
```sh ```sh
npm run grab --- --site=example.com npm run grab --- --site=example.com
``` ```
If the download is successful, the `guide.xml` file with the ready to use program should appear in the root directory. ### Test
After that, all that remains is to commit all the changes and send a pull request. ```sh
npm test --- example.com
```
````
</details>
The fastest way to create all these files is to use the command:
```sh
npm run sites:init --- example.com
```
After you finish working on the files you can make sure that everything works by running the config test:
```
npm test --- example.com
```
Then check that all channels have the correct `xmltv-id`:
```
npm run channels:validate sites/example.com/example.com.channels.xml
```
And then try downloading the guide itself:
```
npm run grab --- example.com
```
If everything goes well just [commit](https://docs.github.com/en/pull-requests/committing-changes-to-your-project/creating-and-editing-commits/about-commits) all changes and send us a [pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests).
### How to map the channels?
In order for the guides to be linked with playlists from [iptv-org/iptv](https://github.com/iptv-org/iptv) and also with our other projects, each channel must have the same ID in the description as in our [iptv-org/database](https://github.com/iptv-org/database).
To check this, select one of the sites in the [SITES.md](SITES.md), open its `*.channels.xml` file and check that all channels have a valid `xmltv_id`. A list of all channels with corresponding IDs can be found at [iptv-org.github.io](https://iptv-org.github.io/).
If the channel is not in our database yet, you can add it to it through this [form](https://github.com/iptv-org/database/issues/new?assignees=&labels=channels%3Aadd&projects=&template=1_channels_add.yml&title=Add%3A+).
If the `*.channels.xml` file contains many channels without `xmltv_id`, you can speed up the process by running the command in the [Console](https://en.wikipedia.org/wiki/Windows_Console) (or [Terminal](<https://en.wikipedia.org/wiki/Terminal_(macOS)>) if you have macOS):
```sh
npm run channels:edit path/to/channels.xml
```
This way, you can map channels by simply selecting the proper ID from the list:
```sh
? Select channel ID for "BBC One" (bbc1): (Use arrow keys)
BBCOne.uk (BBC One, BBC1, BBC Television, BBC Television Service)
BBCOneHD.uk (BBC One HD)
Type...
Skip
```
Once complete, [commit](https://docs.github.com/en/pull-requests/committing-changes-to-your-project/creating-and-editing-commits/about-commits) all changes and send a [pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests).
## Project Structure ## Project Structure
@@ -147,13 +421,16 @@ For scripts to work, you must have [Node.js](https://nodejs.org/en) installed on
To run scripts use the `npm run <script-name>` command. To run scripts use the `npm run <script-name>` command.
- `act:check`: allows to test the [check](https://github.com/iptv-org/iptv/blob/master/.github/workflows/check.yml) workflow locally. Depends on [nektos/act](https://github.com/nektos/act).
- `act:update`: allows to test the [update](https://github.com/iptv-org/iptv/blob/master/.github/workflows/update.yml) workflow locally. Depends on [nektos/act](https://github.com/nektos/act). - `act:update`: allows to test the [update](https://github.com/iptv-org/iptv/blob/master/.github/workflows/update.yml) workflow locally. Depends on [nektos/act](https://github.com/nektos/act).
- `api:load`: downloads the latest channels data from the [iptv-org/api](https://github.com/iptv-org/api). - `api:load`: downloads the latest channels data from the [iptv-org/api](https://github.com/iptv-org/api).
- `api:generate`: generates a JSON file with all channels for the [iptv-org/api](https://github.com/iptv-org/api) repository. - `api:generate`: generates a JSON file with all channels for the [iptv-org/api](https://github.com/iptv-org/api) repository.
- `channels:lint`: сhecks the channel lists for syntax errors. - `channels:lint`: сhecks the channel lists for syntax errors.
- `channels:parse`: generates a list of channels based on the site configuration. - `channels:parse`: generates a list of channels based on the site configuration.
- `channels:editor`: utility for quick channels markup. - `channels:edit`: utility for quick channels mapping.
- `channels:validate`: checks the description of channels for errors. - `channels:validate`: checks the description of channels for errors.
- `sites:init`: creates a new site config from the template.
- `sites:update`: updates the list of sites and their status in [SITES.md](SITES.md).
- `grab`: downloads a program from a specified source. - `grab`: downloads a program from a specified source.
- `serve`: starts the [web server](https://github.com/vercel/serve). - `serve`: starts the [web server](https://github.com/vercel/serve).
- `lint`: сhecks the scripts for syntax errors. - `lint`: сhecks the scripts for syntax errors.
+24
View File
@@ -0,0 +1,24 @@
FROM node:22-alpine
ARG GIT_REPO=https://github.com/iptv-org/epg.git
ARG GIT_BRANCH=master
ARG WORKDIR=/epg
ENV CRON_SCHEDULE="0 0 * * *"
ENV GZIP=false
ENV MAX_CONNECTIONS=1
ENV DAYS=
RUN apk update \
&& apk upgrade --available \
&& apk add curl git tzdata bash \
&& npm install -g npm@latest \
&& npm install pm2 -g \
&& mkdir $(echo "${WORKDIR}") -p \
&& cd $WORKDIR \
&& git clone --depth 1 -b $(echo "${GIT_BRANCH} ${GIT_REPO}") . \
&& npm install \
&& mkdir /public
RUN apk del git curl \
&& rm -rf /var/cache/apk/*
COPY pm2.config.js $WORKDIR
WORKDIR $WORKDIR
EXPOSE 3000
CMD [ "pm2-runtime", "pm2.config.js" ]
+237 -174
View File
@@ -1,174 +1,237 @@
# EPG # 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)
- 📺 [Playlists](#playlists) - 🐋 [Docker](#docker)
- 🗄 [Database](#database) - 📺 [Playlists](#playlists)
- 👨‍💻 [API](#api) - 🗄 [Database](#database)
- 📚 [Resources](#resources) - 👨‍💻 [API](#api)
- 💬 [Discussions](#discussions) - 📚 [Resources](#resources)
- 🛠 [Contribution](#contribution) - 💬 [Discussions](#discussions)
- 📄 [License](#license) - 🛠 [Contribution](#contribution)
- 📄 [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
git clone --depth 1 -b master https://github.com/iptv-org/epg.git ```sh
``` 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
cd epg ```sh
``` cd epg
```
And install all the dependencies:
And install all the dependencies:
```sh
npm install ```sh
``` npm install
```
## Usage
## Usage
To start the download of the guide, select one of the [supported sites](SITES.md) 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
npm run grab --- --site=example.com ```sh
``` npm run grab --- --site=example.com
```
And once the download is complete, the guide will be saved to the `guide.xml` file.
Then run it and wait for the guide to finish downloading. When finished, a new `guide.xml` file will appear in the current directory.
```sh
Usage: npm run grab --- [options] You can also customize the behavior of the script using this options:
Options: ```sh
-s, --site <name> Name of the site to parse Usage: npm run grab --- [options]
-c, --channels <path> Path to *.channels.xml file (required if the "--site" attribute is
not specified) Options:
-o, --output <path> Path to output file (default: "guide.xml") -s, --site <name> Name of the site to parse
-l, --lang <code> Filter channels by language (ISO 639-2 code) -c, --channels <path> Path to *.channels.xml file (required if the "--site" attribute is
-t, --timeout <milliseconds> Override the default timeout for each request not specified)
-d, --delay <milliseconds> Override the default delay between request -o, --output <path> Path to output file (default: "guide.xml")
--days <days> Override the number of days for which the program will be loaded -l, --lang <codes> Allows you to restrict downloading to channels in specified languages only (example: "en,id")
(defaults to the value from the site config) -t, --timeout <milliseconds> Timeout for each request in milliseconds (default: 0)
--maxConnections <number> Limit on the number of concurrent requests (default: 1) -d, --delay <milliseconds> Delay between request in milliseconds (default: 0)
--cron <expression> Schedule a script run (example: "0 0 * * *") -x, --proxy <url> Use the specified proxy (example: "socks5://username:password@127.0.0.1:1234")
--gzip Create a compressed version of the guide as well (default: false) --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)
--gzip Specifies whether or not to create a compressed version of the guide (default: false)
### Access the guide by URL --curl Display each request as CURL (default: false)
```
You can make the guide available via URL by running your own server:
### Parallel downloading
```sh
npm run serve 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
After that, the guide will be available at the link: npm run grab --- --site=example.com --maxConnections=10
```
```
http://localhost:3000/guide.xml But be aware that under heavy load, some sites may start return an error or completely block your access.
```
### Use custom channel list
In addition it will be available to other devices on the same local network at the address:
Create an XML file and copy the descriptions of all the channels you need from the [/sites](sites) into it:
```
http://<your_local_ip_address>:3000/guide.xml ```xml
``` <?xml version="1.0" encoding="UTF-8"?>
<channels>
### Parallel downloading <channel site="arirang.com" lang="en" xmltv_id="ArirangTV.kr" site_id="CH_K">Arirang TV</channel>
...
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: </channels>
```
```sh
npm run grab --- --site=example.com --maxConnections=10 And then specify the path to that file via the `--channels` attribute:
```
```sh
But be aware that under heavy load, some sites may start return an error or completely block your access. npm run grab --- --channels=path/to/custom.channels.xml
```
### Use custom channel list
### Run on schedule
Create an XML file and copy the descriptions of all the channels you need from the [/sites](sites) into it:
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.
```xml
<?xml version="1.0" encoding="UTF-8"?> To start it, you only need to specify the necessary `grab` command and [cron expression](https://crontab.guru/):
<channels>
<channel site="arirang.com" lang="en" xmltv_id="ArirangTV.kr" site_id="CH_K">Arirang TV</channel> ```sh
... npx chronos --execute="npm run grab --- --site=example.com" --pattern="0 0,12 * * *" --log
</channels> ```
```
For more info go to [chronos](https://github.com/freearhey/chronos) documentation.
And then specify the path to that file via the `--channels` attribute:
### Access the guide by URL
```sh
npm run grab --- --channels=path/to/custom.channels.xml 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
### Run on schedule npx serve
```
If you want to download the guide automatically on a schedule, you need to pass a valid [cron expression](https://crontab.guru/) to the script using the `--cron` attribute:
After that, the guide will be available at the link:
```sh
npm run grab --- --site=example.com --cron="0 0 * * *" ```
``` http://localhost:3000/guide.xml
```
## Update
In addition it will be available to other devices on the same local network at the address:
If you have downloaded the repository code according to the instructions above, then to update it will be enough to run the command:
```
```sh http://<your_local_ip_address>:3000/guide.xml
git pull ```
```
For more info go to [serve](https://github.com/vercel/serve) documentation.
And then update all the dependencies:
## Update
```sh
npm install If you have downloaded the repository code according to the instructions above, then to update it will be enough to run the command:
```
```sh
## Playlists git pull
```
Playlists with already linked guides can be found in the [iptv-org/iptv](https://github.com/iptv-org/iptv) repository.
And then update all the dependencies:
## Database
```sh
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. npm install
```
## API
## Docker
The API documentation can be found in the [iptv-org/api](https://github.com/iptv-org/api) repository.
### Build an image
## Resources
```sh
Links to other useful IPTV-related resources can be found in the [iptv-org/awesome-iptv](https://github.com/iptv-org/awesome-iptv) repository. docker build -t iptv-org/epg --no-cache .
```
## Discussions
### Create and run container
If you have a question or an idea, you can post it in the [Discussions](https://github.com/orgs/iptv-org/discussions) tab.
```sh
## Contribution docker run -p 3000:3000 -v /path/to/channels.xml:/epg/channels.xml iptv-org/epg
```
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).
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.
And thank you to everyone who has already contributed!
From the outside, it will be available at this link:
### Backers
```
<a href="https://opencollective.com/iptv-org"><img src="https://opencollective.com/iptv-org/backers.svg?width=890" /></a> http://localhost:3000/guide.xml
```
### Contributors
or
<a href="https://github.com/iptv-org/epg/graphs/contributors"><img src="https://opencollective.com/iptv-org/contributors.svg?width=890" /></a>
```
## License http://<your_local_ip_address>:3000/guide.xml
```
[![CC0](http://mirrors.creativecommons.org/presskit/buttons/88x31/svg/cc-zero.svg)](LICENSE)
### Environment Variables
To fine-tune the execution, you can pass environment variables to the container as follows:
```sh
docker run \
-p 5000:3000 \
-v /path/to/channels.xml:/epg/channels.xml \
-e CRON_SCHEDULE="0 0,12 * * *" \
-e MAX_CONNECTIONS=10 \
-e GZIP=true \
-e CURL=true \
-e PROXY="socks5://127.0.0.1:1234" \
-e DAYS=14 \
-e TIMEOUT=5 \
-e DELAY=2 \
iptv-org/epg
```
| Variable | Description |
| --------------- | ------------------------------------------------------------------------------------------------------------------ |
| 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) |
| GZIP | Boolean value indicating whether to create a compressed version of the guide (default: false) |
| CURL | Display each request as CURL (default: false) |
| PROXY | Use the specified proxy |
| 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) |
| DELAY | Delay between request in milliseconds (default: 0) |
## 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.
## API
The API documentation can be found in the [iptv-org/api](https://github.com/iptv-org/api) repository.
## 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.
## Discussions
If you have a question or an idea, you can post it in the [Discussions](https://github.com/orgs/iptv-org/discussions) tab.
## 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).
And thank you to everyone who has already contributed!
### Backers
<a href="https://opencollective.com/iptv-org"><img src="https://opencollective.com/iptv-org/backers.svg?width=890" /></a>
### Contributors
<a href="https://github.com/iptv-org/epg/graphs/contributors"><img src="https://opencollective.com/iptv-org/contributors.svg?width=890" /></a>
## License
[![CC0](http://mirrors.creativecommons.org/presskit/buttons/88x31/svg/cc-zero.svg)](LICENSE)
+240 -201
View File
@@ -1,203 +1,242 @@
# Sites # Sites
| Site | Status | Notes | <table>
| ------------------------------------------------------------------ | ------ | ---------------------------------------------------------------------------------------- | <thead>
| [9tv.co.il](sites/9tv.co.il) | 🟢 | | <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>
| [abc.net.au](sites/abc.net.au) | 🟢 | | </thead>
| [allente.dk](sites/allente.dk) | 🟢 | | <tbody>
| [allente.fi](sites/allente.fi) | 🟢 | | <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>
| [allente.no](sites/allente.no) | 🟢 | | <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>
| [allente.se](sites/allente.se) | 🟢 | | <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>
| [andorradifusio.ad](sites/andorradifusio.ad) | 🟢 | | <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>
| [anteltv.com.uy](sites/anteltv.com.uy) | 🟢 | | <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>
| [arianaafgtv.com](sites/arianaafgtv.com) | 🟢 | | <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>
| [arianatelevision.com](sites/arianatelevision.com) | 🟢 | | <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>
| [arirang.com](sites/arirang.com) | 🟢 | | <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>
| [artonline.tv](sites/artonline.tv) | 🟢 | | <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>
| [awilime.com](sites/awilime.com) | 🟢 | | <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>
| [bein.com](sites/bein.com) | 🟢 | | <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>
| [beinsports.com](sites/beinsports.com) | 🟢 | | <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>
| [berrymedia.co.kr](sites/berrymedia.co.kr) | 🟢 | | <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>
| [cablego.com.pe](sites/cablego.com.pe) | 🟢 | | <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>
| [cableplus.com.uy](sites/cableplus.com.uy) | 🟢 | | <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>
| [canalplus-haiti.com](sites/canalplus-haiti.com) | 🔴 | https://github.com/iptv-org/epg/issues/2237 | <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>
| [canalplus.com](sites/canalplus.com) | 🟢 | | <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>
| [cgates.lt](sites/cgates.lt) | 🟢 | | <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>
| [chada.ma](sites/chada.ma) | 🟢 | | <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>
| [chaines-tv.orange.fr](sites/chaines-tv.orange.fr) | 🟡 | https://github.com/iptv-org/epg/issues/2395 | <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>
| [clickthecity.com](sites/clickthecity.com) | 🟢 | | <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>
| [comteco.com.bo](sites/comteco.com.bo) | 🔴 | https://github.com/iptv-org/epg/issues/2239 | <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>
| [content.astro.com.my](sites/content.astro.com.my) | 🟢 | | <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>
| [cosmote.gr](sites/cosmote.gr) | 🟢 | | <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>
| [cubmu.com](sites/cubmu.com) | 🟢 | | <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>
| [dens.tv](sites/dens.tv) | 🟢 | | <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>
| [digiturk.com.tr](sites/digiturk.com.tr) | 🔴 | https://github.com/iptv-org/epg/issues/2304, https://github.com/iptv-org/epg/issues/2547 | <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>
| [directv.com](sites/directv.com) | 🔴 | https://github.com/iptv-org/epg/issues/2284 | <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>
| [directv.com.ar](sites/directv.com.ar) | 🔴 | https://github.com/iptv-org/epg/issues/2248 | <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>
| [directv.com.uy](sites/directv.com.uy) | 🟢 | | <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>
| [dishtv.in](sites/dishtv.in) | 🔴 | https://github.com/iptv-org/epg/issues/2445 | <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>
| [dsmart.com.tr](sites/dsmart.com.tr) | 🟡 | https://github.com/iptv-org/epg/issues/2303 | <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>
| [dstv.com](sites/dstv.com) | 🟢 | | <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>
| [elcinema.com](sites/elcinema.com) | 🔴 | https://github.com/iptv-org/epg/issues/2541 | <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>
| [ena.skylifetv.co.kr](sites/ena.skylifetv.co.kr) | 🟢 | | <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>
| [energeek.cl](sites/energeek.cl) | 🟢 | | <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>
| [entertainment.ie](sites/entertainment.ie) | 🟢 | | <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>
| [firstmedia.com](sites/firstmedia.com) | 🟢 | | <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>
| [flixed.io](sites/flixed.io) | 🟡 | https://github.com/iptv-org/epg/issues/2543 | <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>
| [foxsports.com.au](sites/foxsports.com.au) | 🟢 | | <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>
| [foxtel.com.au](sites/foxtel.com.au) | 🟢 | | <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>
| [frikanalen.no](sites/frikanalen.no) | 🟢 | | <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>
| [gatotv.com](sites/gatotv.com) | 🟢 | | <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>
| [getafteritmedia.com](sites/getafteritmedia.com) | 🟢 | | <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>
| [guida.tv](sites/guida.tv) | 🟢 | | <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>
| [guidatv.sky.it](sites/guidatv.sky.it) | 🟢 | | <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>
| [hd-plus.de](sites/hd-plus.de) | 🔴 | https://github.com/iptv-org/epg/issues/2173 | <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>
| [horizon.tv](sites/horizon.tv) | 🟢 | | <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>
| [hoy.tv](sites/hoy.tv) | 🟢 | | <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>
| [i.mjh.nz](sites/i.mjh.nz) | 🟢 | | <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>
| [i24news.tv](sites/i24news.tv) | 🟢 | | <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>
| [iltalehti.fi](sites/iltalehti.fi) | 🟡 | https://github.com/iptv-org/epg/issues/2396 | <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>
| [indihometv.com](sites/indihometv.com) | 🟢 | | <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>
| [ionplustv.com](sites/ionplustv.com) | 🟢 | | <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>
| [ipko.tv](sites/ipko.tv) | 🟢 | | <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>
| [kan.org.il](sites/kan.org.il) | 🔴 | https://github.com/iptv-org/epg/issues/2273 | <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>
| [knr.gl](sites/knr.gl) | 🟢 | | <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>
| [kplus.vn](sites/kplus.vn) | 🔴 | https://github.com/iptv-org/epg/issues/2240 | <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>
| [kvf.fo](sites/kvf.fo) | 🟢 | | <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>
| [m.tv.sms.cz](sites/m.tv.sms.cz) | 🔴 | https://github.com/iptv-org/epg/issues/2241 | <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>
| [m.tving.com](sites/m.tving.com) | 🟢 | | <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>
| [magticom.ge](sites/magticom.ge) | 🟢 | | <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>
| [mako.co.il](sites/mako.co.il) | 🟢 | | <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>
| [maxtv.hrvatskitelekom.hr](sites/maxtv.hrvatskitelekom.hr) | 🔴 | https://github.com/iptv-org/epg/issues/2509 | <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>
| [maxtvgo.mk](sites/maxtvgo.mk) | 🟢 | | <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>
| [mediagenie.co.kr](sites/mediagenie.co.kr) | 🟢 | | <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>
| [mediaklikk.hu](sites/mediaklikk.hu) | 🟢 | | <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>
| [mediasetinfinity.mediaset.it](sites/mediasetinfinity.mediaset.it) | 🟢 | | <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>
| [melita.com](sites/melita.com) | 🟢 | | <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>
| [meo.pt](sites/meo.pt) | 🟡 | https://github.com/iptv-org/epg/issues/2446 | <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>
| [meuguia.tv](sites/meuguia.tv) | 🟢 | | <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>
| [mewatch.sg](sites/mewatch.sg) | 🟢 | | <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>
| [mi.tv](sites/mi.tv) | 🟢 | | <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>
| [mncvision.id](sites/mncvision.id) | 🟢 | | <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>
| [moji.id](sites/moji.id) | 🟢 | | <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>
| [mon-programme-tv.be](sites/mon-programme-tv.be) | 🟢 | | <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>
| [movistarplus.es](sites/movistarplus.es) | 🔴 | https://github.com/iptv-org/epg/issues/2498 | <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>
| [mtel.ba](sites/mtel.ba) | 🟢 | | <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>
| [mts.rs](sites/mts.rs) | 🟢 | | <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>
| [mujtvprogram.cz](sites/mujtvprogram.cz) | 🟢 | | <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>
| [musor.tv](sites/musor.tv) | 🟢 | | <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>
| [mysky.com.ph](sites/mysky.com.ph) | 🟢 | | <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>
| [mytelly.co.uk](sites/mytelly.co.uk) | 🟢 | | <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>
| [mytvsuper.com](sites/mytvsuper.com) | 🟢 | | <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>
| [neo.io](sites/neo.io) | 🟢 | | <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>
| [nhkworldpremium.com](sites/nhkworldpremium.com) | 🟢 | | <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>
| [nhl.com](sites/nhl.com) | 🟢 | | <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>
| [nostv.pt](sites/nostv.pt) | 🟢 | | <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>
| [novacyprus.com](sites/novacyprus.com) | 🟢 | | <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>
| [novasports.gr](sites/novasports.gr) | 🟢 | | <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>
| [nowplayer.now.com](sites/nowplayer.now.com) | 🟢 | | <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>
| [nuevosiglo.com.uy](sites/nuevosiglo.com.uy) | 🟢 | | <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>
| [nzxmltv.com](sites/nzxmltv.com) | 🟢 | | <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>
| [ontvtonight.com](sites/ontvtonight.com) | 🟢 | | <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>
| [osn.com](sites/osn.com) | 🟢 | | <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>
| [pbsguam.org](sites/pbsguam.org) | 🟢 | | <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>
| [pickx.be](sites/pickx.be) | 🟢 | | <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>
| [player.ee.co.uk](sites/player.ee.co.uk) | 🟢 | | <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>
| [playtv.unifi.com.my](sites/playtv.unifi.com.my) | 🟢 | | <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>
| [plex.tv](sites/plex.tv) | 🟢 | | <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>
| [programacion-tv.elpais.com](sites/programacion-tv.elpais.com) | 🟢 | | <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>
| [programacion.tcc.com.uy](sites/programacion.tcc.com.uy) | 🟢 | | <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>
| [programetv.ro](sites/programetv.ro) | 🟢 | | <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>
| [programme-tv.net](sites/programme-tv.net) | 🟢 | | <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>
| [programme-tv.vini.pf](sites/programme-tv.vini.pf) | 🟢 | | <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>
| [programme.tvb.com](sites/programme.tvb.com) | 🟢 | | <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>
| [programtv.onet.pl](sites/programtv.onet.pl) | 🟢 | | <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>
| [raiplay.it](sites/raiplay.it) | 🟢 | | <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>
| [reportv.com.ar](sites/reportv.com.ar) | 🟢 | | <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>
| [rev.bs](sites/rev.bs) | 🔴 | https://github.com/iptv-org/epg/issues/2255 | <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>
| [rotana.net](sites/rotana.net) | 🟢 | | <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>
| [rtb.gov.bn](sites/rtb.gov.bn) | 🔴 | https://github.com/iptv-org/epg/issues/2257 | <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>
| [rthk.hk](sites/rthk.hk) | 🟢 | | <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>
| [rtmklik.rtm.gov.my](sites/rtmklik.rtm.gov.my) | 🟢 | | <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>
| [rtp.pt](sites/rtp.pt) | 🟢 | | <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>
| [ruv.is](sites/ruv.is) | 🟢 | | <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>
| [s.mxtv.jp](sites/s.mxtv.jp) | 🟢 | | <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>
| [sat.tv](sites/sat.tv) | 🟢 | | <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>
| [shahid.mbc.net](sites/shahid.mbc.net) | 🟢 | | <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>
| [siba.com.co](sites/siba.com.co) | 🟢 | | <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>
| [singtel.com](sites/singtel.com) | 🟢 | | <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>
| [sjonvarp.is](sites/sjonvarp.is) | 🟢 | | <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>
| [sky.co.nz](sites/sky.co.nz) | 🟢 | | <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>
| [sky.com](sites/sky.com) | 🟡 | https://github.com/iptv-org/epg/issues/2501, https://github.com/iptv-org/epg/issues/2516 | <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>
| [sky.de](sites/sky.de) | 🟢 | | <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>
| [skylife.co.kr](sites/skylife.co.kr) | 🟢 | | <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>
| [skyperfecttv.co.jp](sites/skyperfecttv.co.jp) | 🟢 | | <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>
| [sporttv.pt](sites/sporttv.pt) | 🟢 | | <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>
| [starhubtvplus.com](sites/starhubtvplus.com) | 🟢 | | <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>
| [startimestv.com](sites/startimestv.com) | 🟢 | | <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>
| [streamingtvguides.com](sites/streamingtvguides.com) | 🟢 | | <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>
| [superguidatv.it](sites/superguidatv.it) | 🟢 | | <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>
| [taiwanplus.com](sites/taiwanplus.com) | 🟢 | | <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>
| [tapdmv.com](sites/tapdmv.com) | 🟢 | | <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>
| [telenet.tv](sites/telenet.tv) | 🟢 | | <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>
| [teliatv.ee](sites/teliatv.ee) | 🟢 | | <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>
| [telkussa.fi](sites/telkussa.fi) | 🟢 | | <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>
| [telsu.fi](sites/telsu.fi) | 🟢 | | <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>
| [tivu.tv](sites/tivu.tv) | 🟢 | | <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>
| [toonamiaftermath.com](sites/toonamiaftermath.com) | 🟢 | | <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>
| [turksatkablo.com.tr](sites/turksatkablo.com.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>
| [tv-programme.telecablesat.fr](sites/tv-programme.telecablesat.fr) | 🟢 | | <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>
| [tv.blue.ch](sites/tv.blue.ch) | 🟢 | | <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>
| [tv.cctv.com](sites/tv.cctv.com) | 🟢 | | <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>
| [tv.dir.bg](sites/tv.dir.bg) | 🟢 | | <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>
| [tv.lv](sites/tv.lv) | 🟢 | | <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>
| [tv.magenta.at](sites/tv.magenta.at) | 🟢 | | <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>
| [tv.mail.ru](sites/tv.mail.ru) | 🟢 | | <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>
| [tv.movistar.com.pe](sites/tv.movistar.com.pe) | 🟢 | | <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>
| [tv.nu](sites/tv.nu) | 🟢 | | <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>
| [tv.post.lu](sites/tv.post.lu) | 🟢 | | <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>
| [tv.trueid.net](sites/tv.trueid.net) | 🟢 | | <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>
| [tv.yandex.ru](sites/tv.yandex.ru) | 🟢 | | <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>
| [tv.yettel.hu](sites/tv.yettel.hu) | 🔴 | https://github.com/iptv-org/epg/issues/2263 | <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>
| [tv24.co.uk](sites/tv24.co.uk) | 🟢 | | <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>
| [tv24.se](sites/tv24.se) | 🟢 | | <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>
| [tv2go.t-2.net](sites/tv2go.t-2.net) | 🟢 | | <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>
| [tva.tv](sites/tva.tv) | 🔴 | https://github.com/iptv-org/epg/issues/2264 | <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>
| [tvarenasport.com](sites/tvarenasport.com) | 🟢 | | <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>
| [tvarenasport.hr](sites/tvarenasport.hr) | 🟢 | | <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>
| [tvcesoir.fr](sites/tvcesoir.fr) | 🟢 | | <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>
| [tvcubana.icrt.cu](sites/tvcubana.icrt.cu) | 🟢 | | <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>
| [tvgids.nl](sites/tvgids.nl) | 🟡 | https://github.com/iptv-org/epg/issues/2400 | <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>
| [tvguide.com](sites/tvguide.com) | 🔴 | https://github.com/iptv-org/epg/issues/2542 | <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>
| [tvguide.myjcom.jp](sites/tvguide.myjcom.jp) | 🟢 | | <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>
| [tvhebdo.com](sites/tvhebdo.com) | 🟢 | | <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>
| [tvheute.at](sites/tvheute.at) | 🟢 | | <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>
| [tvim.tv](sites/tvim.tv) | 🟢 | | <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>
| [tvireland.ie](sites/tvireland.ie) | 🟢 | | <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>
| [tvmi.mt](sites/tvmi.mt) | 🟢 | | <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>
| [tvmusor.hu](sites/tvmusor.hu) | 🟢 | | <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>
| [tvpassport.com](sites/tvpassport.com) | 🟢 | | <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>
| [tvplus.com.tr](sites/tvplus.com.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>
| [tvprofil.com](sites/tvprofil.com) | 🟡 | https://github.com/iptv-org/epg/issues/2399 | <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>
| [tvtv.us](sites/tvtv.us) | 🟢 | | <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>
| [v3.myafn.dodmedia.osd.mil](sites/v3.myafn.dodmedia.osd.mil) | 🟢 | | <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>
| [vidio.com](sites/vidio.com) | 🟢 | | <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>
| [virginmediatelevision.ie](sites/virginmediatelevision.ie) | 🟢 | | <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>
| [virgintvgo.virginmedia.com](sites/virgintvgo.virginmedia.com) | 🟢 | | <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>
| [visionplus.id](sites/visionplus.id) | 🟢 | | <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>
| [vivacom.bg](sites/vivacom.bg) | 🔴 | https://github.com/iptv-org/epg/issues/2270 | <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>
| [vtm.be](sites/vtm.be) | 🟢 | | <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>
| [walesi.com.fj](sites/walesi.com.fj) | 🟢 | | <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>
| [watch.sportsnet.ca](sites/watch.sportsnet.ca) | 🟢 | | <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>
| [watchyour.tv](sites/watchyour.tv) | 🟢 | | <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>
| [wavve.com](sites/wavve.com) | 🟢 | | <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>
| [web.magentatv.de](sites/web.magentatv.de) | 🟢 | | <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>
| [webtv.delta.nl](sites/webtv.delta.nl) | 🟢 | | <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>
| [winplay.co](sites/winplay.co) | 🟢 | | <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>
| [worldfishingnetwork.com](sites/worldfishingnetwork.com) | 🟢 | | <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>
| [www3.nhk.or.jp](sites/www3.nhk.or.jp) | 🟢 | | <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>
| [xumo.tv](sites/xumo.tv) | 🟢 | | <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>
| [zap.co.ao](sites/zap.co.ao) | 🟢 | | <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>
| [ziggogo.tv](sites/ziggogo.tv) | 🟡 | https://github.com/iptv-org/epg/issues/2393 | <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>
| [znbc.co.zm](sites/znbc.co.zm) | 🟢 | | <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>
| [zuragt.mn](sites/zuragt.mn) | 🟢 | | <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/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/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/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/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/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">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/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/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/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/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/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/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/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/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/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/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/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/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/zuragt.mn">zuragt.mn</a></td><td align="right">36</td><td align="right">25</td><td align="center">🟢</td><td></td></tr>
</tbody>
</table>
+57
View File
@@ -0,0 +1,57 @@
import typescriptEslint from '@typescript-eslint/eslint-plugin'
import stylistic from '@stylistic/eslint-plugin'
import globals from 'globals'
import tsParser from '@typescript-eslint/parser'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import js from '@eslint/js'
import { FlatCompat } from '@eslint/eslintrc'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all
})
export default [
...compat.extends('eslint:recommended', 'plugin:@typescript-eslint/strict', 'plugin:@typescript-eslint/stylistic', 'prettier'),
{
plugins: {
'@typescript-eslint': typescriptEslint,
'@stylistic': stylistic
},
languageOptions: {
globals: {
...globals.node,
...globals.jest
},
parser: tsParser,
ecmaVersion: 'latest',
sourceType: 'module'
},
rules: {
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-var-requires': 'off',
'no-case-declarations': 'off',
'@stylistic/linebreak-style': ['error', 'windows'],
quotes: [
'error',
'single',
{
avoidEscape: true
}
],
semi: ['error', 'never']
}
},
{
ignores: ['tests/__data__/']
}
]
+10069 -6660
View File
File diff suppressed because it is too large Load Diff
+96 -64
View File
@@ -1,91 +1,123 @@
{ {
"name": "epg", "name": "epg",
"scripts": { "scripts": {
"act:check": "act pull_request -W .github/workflows/check.yml",
"act:update": "act workflow_dispatch -W .github/workflows/update.yml", "act:update": "act workflow_dispatch -W .github/workflows/update.yml",
"api:load": "npx tsx scripts/commands/api/load.ts", "api:load": "tsx scripts/commands/api/load.ts",
"api:generate": "npx tsx scripts/commands/api/generate.ts", "api:generate": "tsx scripts/commands/api/generate.ts",
"channels:lint": "npx tsx scripts/commands/channels/lint.ts", "channels:lint": "tsx scripts/commands/channels/lint.mts",
"channels:parse": "npx tsx scripts/commands/channels/parse.ts", "channels:parse": "tsx scripts/commands/channels/parse.ts",
"channels:editor": "npx tsx scripts/commands/channels/editor.ts", "channels:edit": "tsx scripts/commands/channels/edit.ts",
"channels:validate": "npx tsx scripts/commands/channels/validate.ts", "channels:validate": "tsx scripts/commands/channels/validate.ts",
"grab": "npx tsx scripts/commands/epg/grab.ts", "sites:init": "tsx scripts/commands/sites/init.ts",
"serve": "npx serve", "sites:update": "tsx scripts/commands/sites/update.ts",
"lint": "npx eslint \"{scripts,tests}/**/*.{ts,js}\"", "grab": "tsx scripts/commands/epg/grab.ts",
"test": "run-script-os", "lint": "npx eslint \"{scripts,tests,sites}/**/*.{ts,mts,js}\"",
"test:win32": "SET \"TZ=Pacific/Nauru\" && npx jest --runInBand", "test": "cross-env TZ=Pacific/Nauru npx jest --runInBand",
"test:default": "TZ=Pacific/Nauru npx jest --runInBand", "postinstall": "skip-postinstall || npm run api:load",
"postinstall": "npm run api:load" "prepare": "husky"
}, },
"private": true, "private": true,
"author": "Arhey",
"license": "UNLICENSED", "license": "UNLICENSED",
"jest": { "jest": {
"setupFiles": [
"<rootDir>/node_modules/jest-offline"
],
"transform": { "transform": {
"^.+\\.(ts|js)$": "ts-jest" "^.+\\.(ts|js)$": "@swc/jest"
}, },
"testRegex": "(tests|sites)/(.*?/)?.*test.(js|ts)$", "testMatch": [
"testTimeout": 10000 "<rootDir>/sites/**/*.test.(js|ts)",
"<rootDir>/tests/commands/**/*.test.(js|ts)"
],
"testTimeout": 10000,
"transformIgnorePatterns": [
"<rootDir>/node_modules/(?!parse-duration/.*|@freearhey/core/.*|glob/.*|srcset/.*|balanced-match/.*|minimatch/.*)"
]
}, },
"dependencies": { "dependencies": {
"@alex_neo/jest-expect-message": "^1.0.5", "@alex_neo/jest-expect-message": "^1.0.5",
"@freearhey/core": "^0.3.1", "@eslint/eslintrc": "^3.3.1",
"@freearhey/search-js": "^0.1.1", "@eslint/js": "^9.32.0",
"@ntlab/sfetch": "^1.0.0", "@freearhey/chronos": "^0.0.1",
"@octokit/core": "^4.1.0", "@freearhey/core": "^0.10.2",
"@types/cli-progress": "^3.11.3", "@freearhey/search-js": "^0.1.2",
"@types/fs-extra": "^11.0.2", "@ntlab/sfetch": "^1.2.0",
"@types/inquirer": "^9.0.3", "@octokit/core": "^7.0.3",
"@types/jest": "^29.5.5", "@octokit/plugin-paginate-rest": "^13.1.1",
"@types/lodash": "^4.14.199", "@octokit/plugin-rest-endpoint-methods": "^16.0.0",
"@types/node-cleanup": "^2.1.2", "@stylistic/eslint-plugin": "^5.2.2",
"@types/numeral": "^2.0.3", "@swc/core": "^1.13.2",
"@typescript-eslint/eslint-plugin": "^8.18.0", "@swc/jest": "^0.2.39",
"axios": "^1.5.1", "@types/cli-progress": "^3.11.6",
"axios-cookiejar-support": "^5.0.4", "@types/fs-extra": "^11.0.4",
"chalk": "^4.1.2", "@types/inquirer": "^9.0.8",
"cheerio": "^1.0.0-rc.10", "@types/jest": "^30.0.0",
"@types/langs": "^2.0.5",
"@types/lodash.orderby": "^4.6.9",
"@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/numeral": "^2.0.5",
"@typescript-eslint/eslint-plugin": "^8.38.0",
"@typescript-eslint/parser": "^8.38.0",
"axios": "^1.11.0",
"axios-cookiejar-support": "^6.0.4",
"chalk": "^5.4.1",
"cheerio": "^1.1.2",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",
"commander": "^8.2.0", "commander": "^14.0.0",
"consola": "^3.2.3", "consola": "^3.4.2",
"cron": "^2.4.3", "cross-env": "^10.0.0",
"csv-parser": "^3.0.0", "csv-parser": "^3.2.0",
"cwait": "^1.1.2", "cwait": "^1.1.2",
"dayjs": "^1.11.10", "dayjs": "^1.11.13",
"epg-grabber": "^0.37.4", "epg-grabber": "^0.41.0",
"epg-parser": "^0.3.1", "epg-parser": "^0.3.1",
"eslint": "^9.17.0", "eslint": "^9.32.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^10.1.8",
"form-data": "^4.0.0", "form-data": "^4.0.4",
"fs-extra": "^10.0.1", "fs-extra": "^11.3.0",
"glob": "^7.2.0", "glob": "^11.0.3",
"iconv-lite": "^0.4.24", "globals": "^16.3.0",
"inquirer": "^8.2.6", "husky": "^9.1.7",
"jest": "^29.7.0", "iconv-lite": "^0.6.3",
"inquirer": "^12.8.2",
"jest": "^30.0.5",
"jest-offline": "^1.0.1",
"langs": "^2.0.0", "langs": "^2.0.0",
"libxmljs2": "^0.35.0", "libxml2-wasm": "^0.5.0",
"lodash": "^4.17.21", "lodash.orderby": "^4.6.0",
"luxon": "^3.3.0", "lodash.sortby": "^4.7.0",
"markdown-include": "^0.4.3", "lodash.startcase": "^4.4.0",
"lodash.uniqby": "^4.7.0",
"luxon": "^3.7.1",
"mockdate": "^3.0.5", "mockdate": "^3.0.5",
"nedb-promises": "^6.0.3", "nedb-promises": "^6.2.3",
"node-cleanup": "^2.1.2", "node-cleanup": "^2.1.2",
"node-gzip": "^1.1.2", "node-gzip": "^1.1.2",
"numeral": "^2.0.6", "numeral": "^2.0.6",
"parse-duration": "^1.0.0", "pako": "^2.1.0",
"parse-duration": "^2.1.4",
"pdf-parse": "^1.1.1", "pdf-parse": "^1.1.1",
"pm2": "^6.0.8",
"readline": "^1.3.0",
"run-script-os": "^1.1.6",
"serve": "^14.2.4", "serve": "^14.2.4",
"signale": "^1.4.0", "signale": "^1.4.0",
"srcset": "^4.0.0", "skip-postinstall": "^1.0.0",
"socks-proxy-agent": "^8.0.5",
"srcset": "^5.0.1",
"table2array": "^0.0.2", "table2array": "^0.0.2",
"tabletojson": "^2.0.7", "tabletojson": "^4.1.6",
"tough-cookie": "^5.0.0", "tough-cookie": "^5.1.2",
"transliteration": "^2.2.0", "transliteration": "^2.3.5",
"ts-jest": "^29.1.1", "tsx": "^4.20.3",
"tsx": "^4.19.2", "typescript": "^5.8.3",
"unzipit": "^1.4.0", "unzipit": "^1.4.3",
"wildcard-match": "^5.1.2" "uuid": "^11.1.0",
}, "wildcard-match": "^5.1.4"
"devDependencies": {
"run-script-os": "^1.1.6"
} }
} }
+24
View File
@@ -0,0 +1,24 @@
const grab = process.env.SITE
? `npm run grab -- --site=${process.env.SITE} ${
process.env.CLANG ? `--lang=${process.env.CLANG}` : ''
} --output=public/guide.xml`
: 'npm run grab -- --channels=channels.xml --output=public/guide.xml'
module.exports = {
apps: [
{
name: 'serve',
script: 'npx serve -- public',
instances: 1,
watch: false,
autorestart: true
},
{
name: 'grab',
script: `npx chronos -e "${grab}" -p "${process.env.CRON_SCHEDULE}" -l`,
instances: 1,
watch: false,
autorestart: true
}
]
}
+17 -27
View File
@@ -1,16 +1,9 @@
import { Logger, Storage, Collection } from '@freearhey/core' import { Logger, Collection, Storage } from '@freearhey/core'
import { ChannelsParser } from '../../core'
import path from 'path'
import { SITES_DIR, API_DIR } from '../../constants' import { SITES_DIR, API_DIR } from '../../constants'
import { Channel } from 'epg-grabber' import { GuideChannel } from '../../models'
import { ChannelsParser } from '../../core'
type OutputItem = { import epgGrabber from 'epg-grabber'
channel: string | null import path from 'path'
site: string
site_id: string
site_name: string
lang: string
}
async function main() { async function main() {
const logger = new Logger() const logger = new Logger()
@@ -19,27 +12,24 @@ async function main() {
logger.info('loading channels...') logger.info('loading channels...')
const sitesStorage = new Storage(SITES_DIR) const sitesStorage = new Storage(SITES_DIR)
const parser = new ChannelsParser({ storage: sitesStorage }) const parser = new ChannelsParser({
storage: sitesStorage
})
let files: string[] = [] const files: string[] = await sitesStorage.list('**/*.channels.xml')
files = await sitesStorage.list('**/*.channels.xml')
let parsedChannels = new Collection() const channels = new Collection()
for (const filepath of files) { for (const filepath of files) {
parsedChannels = parsedChannels.concat(await parser.parse(filepath)) const channelList = await parser.parse(filepath)
channelList.channels.forEach((data: epgGrabber.Channel) => {
channels.add(new GuideChannel(data))
})
} }
logger.info(` found ${parsedChannels.count()} channel(s)`) logger.info(`found ${channels.count()} channel(s)`)
const output = parsedChannels.map((channel: Channel): OutputItem => { const output = channels.map((channel: GuideChannel) => channel.toJSON())
return {
channel: channel.xmltv_id || null,
site: channel.site || '',
site_id: channel.site_id || '',
site_name: channel.name,
lang: channel.lang || ''
}
})
const apiStorage = new Storage(API_DIR) const apiStorage = new Storage(API_DIR)
const outputFilename = 'guides.json' const outputFilename = 'guides.json'
+25 -18
View File
@@ -1,18 +1,25 @@
import { Logger } from '@freearhey/core' import { DATA_DIR } from '../../constants'
import { ApiClient } from '../../core' import { Storage } from '@freearhey/core'
import { DataLoader } from '../../core'
async function main() {
const logger = new Logger() async function main() {
const client = new ApiClient({ logger }) const storage = new Storage(DATA_DIR)
const loader = new DataLoader({ storage })
const requests = [
client.download('channels.json'), await Promise.all([
client.download('countries.json'), loader.download('blocklist.json'),
client.download('regions.json'), loader.download('categories.json'),
client.download('subdivisions.json') loader.download('channels.json'),
] loader.download('countries.json'),
loader.download('languages.json'),
await Promise.all(requests) loader.download('regions.json'),
} loader.download('subdivisions.json'),
loader.download('feeds.json'),
main() loader.download('timezones.json'),
loader.download('guides.json'),
loader.download('streams.json'),
loader.download('logos.json')
])
}
main()
-1
View File
@@ -1 +0,0 @@
/replace.ts
+216
View File
@@ -0,0 +1,216 @@
import { Storage, Collection, Logger, Dictionary } from '@freearhey/core'
import type { DataProcessorData } from '../../types/dataProcessor'
import type { DataLoaderData } from '../../types/dataLoader'
import { ChannelSearchableData } from '../../types/channel'
import { Channel, ChannelList, Feed } from '../../models'
import { DataProcessor, DataLoader } from '../../core'
import { select, input } from '@inquirer/prompts'
import { ChannelsParser } from '../../core'
import { DATA_DIR } from '../../constants'
import nodeCleanup from 'node-cleanup'
import sjs from '@freearhey/search-js'
import epgGrabber from 'epg-grabber'
import { Command } from 'commander'
import readline from 'readline'
interface ChoiceValue { type: string; value?: Feed | Channel }
interface Choice { name: string; short?: string; value: ChoiceValue; default?: boolean }
if (process.platform === 'win32') {
readline
.createInterface({
input: process.stdin,
output: process.stdout
})
.on('SIGINT', function () {
process.emit('SIGINT')
})
}
const program = new Command()
program.argument('<filepath>', 'Path to *.channels.xml file to edit').parse(process.argv)
const filepath = program.args[0]
const logger = new Logger()
const storage = new Storage()
let channelList = new ChannelList({ channels: [] })
main(filepath)
nodeCleanup(() => {
save(filepath, channelList)
})
export default async function main(filepath: string) {
if (!(await storage.exists(filepath))) {
throw new Error(`File "${filepath}" does not exists`)
}
logger.info('loading data from api...')
const processor = new DataProcessor()
const dataStorage = new Storage(DATA_DIR)
const loader = new DataLoader({ storage: dataStorage })
const data: DataLoaderData = await loader.load()
const { channels, channelsKeyById, feedsGroupedByChannelId }: DataProcessorData =
processor.process(data)
logger.info('loading channels...')
const parser = new ChannelsParser({ storage })
channelList = await parser.parse(filepath)
const parsedChannelsWithoutId = channelList.channels.filter(
(channel: epgGrabber.Channel) => !channel.xmltv_id
)
logger.info(
`found ${channelList.channels.count()} channels (including ${parsedChannelsWithoutId.count()} without ID)`
)
logger.info('creating search index...')
const items = channels.map((channel: Channel) => channel.getSearchable()).all()
const searchIndex = sjs.createIndex(items, {
searchable: ['name', 'altNames', 'guideNames', 'streamNames', 'feedFullNames']
})
logger.info('starting...\n')
for (const channel of parsedChannelsWithoutId.all()) {
try {
channel.xmltv_id = await selectChannel(
channel,
searchIndex,
feedsGroupedByChannelId,
channelsKeyById
)
} catch (err) {
logger.info(err.message)
break
}
}
parsedChannelsWithoutId.forEach((channel: epgGrabber.Channel) => {
if (channel.xmltv_id === '-') {
channel.xmltv_id = ''
}
})
}
async function selectChannel(
channel: epgGrabber.Channel,
searchIndex,
feedsGroupedByChannelId: Dictionary,
channelsKeyById: Dictionary
): Promise<string> {
const query = escapeRegex(channel.name)
const similarChannels = searchIndex
.search(query)
.map((item: ChannelSearchableData) => channelsKeyById.get(item.id))
const selected: ChoiceValue = await select({
message: `Select channel ID for "${channel.name}" (${channel.site_id}):`,
choices: getChannelChoises(new Collection(similarChannels)),
pageSize: 10
})
switch (selected.type) {
case 'skip':
return '-'
case 'type': {
const typedChannelId = await input({ message: ' Channel ID:' })
if (!typedChannelId) return ''
const selectedFeedId = await selectFeed(typedChannelId, feedsGroupedByChannelId)
if (selectedFeedId === '-') return typedChannelId
return [typedChannelId, selectedFeedId].join('@')
}
case 'channel': {
const selectedChannel = selected.value
if (!selectedChannel) return ''
const selectedFeedId = await selectFeed(selectedChannel.id || '', feedsGroupedByChannelId)
if (selectedFeedId === '-') return selectedChannel.id || ''
return [selectedChannel.id, selectedFeedId].join('@')
}
}
return ''
}
async function selectFeed(channelId: string, feedsGroupedByChannelId: Dictionary): Promise<string> {
const channelFeeds = feedsGroupedByChannelId.has(channelId)
? new Collection(feedsGroupedByChannelId.get(channelId))
: new Collection()
const choices = getFeedChoises(channelFeeds)
const selected: ChoiceValue = await select({
message: `Select feed ID for "${channelId}":`,
choices,
pageSize: 10
})
switch (selected.type) {
case 'skip':
return '-'
case 'type':
return await input({ message: ' Feed ID:', default: 'SD' })
case 'feed':
const selectedFeed = selected.value
if (!selectedFeed) return ''
return selectedFeed.id || ''
}
return ''
}
function getChannelChoises(channels: Collection): Choice[] {
const choises: Choice[] = []
channels.forEach((channel: Channel) => {
const names = new Collection([channel.name, ...channel.getAltNames().all()]).uniq().join(', ')
choises.push({
value: {
type: 'channel',
value: channel
},
name: `${channel.id} (${names})`,
short: `${channel.id}`
})
})
choises.push({ name: 'Type...', value: { type: 'type' } })
choises.push({ name: 'Skip', value: { type: 'skip' } })
return choises
}
function getFeedChoises(feeds: Collection): Choice[] {
const choises: Choice[] = []
feeds.forEach((feed: Feed) => {
let name = `${feed.id} (${feed.name})`
if (feed.isMain) name += ' [main]'
choises.push({
value: {
type: 'feed',
value: feed
},
default: feed.isMain,
name,
short: feed.id
})
})
choises.push({ name: 'Type...', value: { type: 'type' } })
choises.push({ name: 'Skip', value: { type: 'skip' } })
return choises
}
function save(filepath: string, channelList: ChannelList) {
if (!storage.existsSync(filepath)) return
storage.saveSync(filepath, channelList.toString())
logger.info(`\nFile '${filepath}' successfully saved`)
}
function escapeRegex(string: string) {
return string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&')
}
-179
View File
@@ -1,179 +0,0 @@
import { DATA_DIR } from '../../constants'
import { Storage, Collection, Dictionary, Logger } from '@freearhey/core'
import { ChannelsParser, XML, ApiChannel } from '../../core'
import { Channel } from 'epg-grabber'
import { transliterate } from 'transliteration'
import nodeCleanup from 'node-cleanup'
import { program } from 'commander'
import inquirer, { QuestionCollection } from 'inquirer'
import sj from '@freearhey/search-js'
program
.argument('<filepath>', 'Path to *.channels.xml file to edit')
.option('-c, --country <name>', 'Default country (ISO 3166 code)', 'US')
.parse(process.argv)
const filepath = program.args[0]
const programOptions = program.opts()
const defaultCountry = programOptions.country.toLowerCase()
const newLabel = ' [new]'
let options = new Collection()
async function main() {
const storage = new Storage()
if (!(await storage.exists(filepath))) {
throw new Error(`File "${filepath}" does not exists`)
}
const parser = new ChannelsParser({ storage })
const parsedChannels = await parser.parse(filepath)
options = parsedChannels.map((channel: Channel): { channel: Channel; delete: boolean } => {
return {
channel,
delete: false
}
})
const dataStorage = new Storage(DATA_DIR)
const channelsContent = await dataStorage.json('channels.json')
const channelsIndex = sj.createIndex(channelsContent)
const buffer = new Dictionary()
for (let option of options.all()) {
const channel: Channel = option.channel
if (channel.xmltv_id) {
if (channel.xmltv_id !== '-') {
buffer.set(`${channel.xmltv_id}/${channel.lang}`, true)
}
continue
}
const choices = getOptions(channelsIndex, channel)
const question: QuestionCollection = {
name: 'option',
message: `Choose xmltv_id for "${channel.name}" (${channel.site_id}):`,
type: 'list',
choices,
pageSize: 10
}
await inquirer.prompt(question).then(async selected => {
switch (selected.option) {
case 'Overwrite':
const input = await getInput(channel)
channel.xmltv_id = input.xmltv_id
break
case 'Skip':
channel.xmltv_id = '-'
break
default:
const [, xmltv_id] = selected.option
.replace(/ \[.*\]/, '')
.split('|')
.map((i: string) => i.trim().replace(newLabel, ''))
channel.xmltv_id = xmltv_id
break
}
const found = buffer.has(`${channel.xmltv_id}/${channel.lang}`)
if (found) {
const question: QuestionCollection = {
name: 'option',
message: `"${channel.xmltv_id}" already on the list. Choose an option:`,
type: 'list',
choices: ['Skip', 'Add', 'Delete'],
pageSize: 5
}
await inquirer.prompt(question).then(async selected => {
switch (selected.option) {
case 'Skip':
channel.xmltv_id = '-'
break
case 'Delete':
option.delete = true
break
default:
break
}
})
} else {
if (channel.xmltv_id !== '-') {
buffer.set(`${channel.xmltv_id}/${channel.lang}`, true)
}
}
})
}
}
main()
function save() {
const logger = new Logger()
const storage = new Storage()
if (!storage.existsSync(filepath)) return
const channels = options
.filter((option: { channel: Channel; delete: boolean }) => !option.delete)
.map((option: { channel: Channel; delete: boolean }) => option.channel)
const xml = new XML(channels)
storage.saveSync(filepath, xml.toString())
logger.info(`\nFile '${filepath}' successfully saved`)
}
nodeCleanup(() => {
save()
})
async function getInput(channel: Channel) {
const name = channel.name.trim()
const input = await inquirer.prompt([
{
name: 'xmltv_id',
message: ' ID:',
type: 'input',
default: generateCode(name, defaultCountry)
}
])
return { name, xmltv_id: input['xmltv_id'] }
}
function getOptions(channelsIndex, channel: Channel) {
const channelId = generateCode(channel.name, defaultCountry)
const query = channel.name
.replace(/\s(SD|TV|HD|SD\/HD|HDTV)$/i, '')
.replace(/(\(|\)|,)/gi, '')
.replace(/\-/gi, ' ')
.replace(/\+/gi, '')
const similar = channelsIndex.search(query).map(item => new ApiChannel(item))
const variants = new Collection()
variants.add(`${channel.name.trim()} | ${channelId}${newLabel}`)
similar.forEach((_channel: ApiChannel) => {
const altNames = _channel.altNames.notEmpty() ? ` (${_channel.altNames.join(',')})` : ''
const closed = _channel.closed ? ` [closed:${_channel.closed}]` : ''
const replacedBy = _channel.replacedBy ? `[replaced_by:${_channel.replacedBy}]` : ''
variants.add(`${_channel.name}${altNames} | ${_channel.id}${closed}${replacedBy}`)
})
variants.add('Overwrite')
variants.add('Skip')
return variants.all()
}
function generateCode(name: string, country: string) {
const channelId: string = transliterate(name)
.replace(/\+/gi, 'Plus')
.replace(/^&/gi, 'And')
.replace(/[^a-z\d]+/gi, '')
return `${channelId}.${country}`
}
+109
View File
@@ -0,0 +1,109 @@
import chalk from 'chalk'
import { program } from 'commander'
import { Storage, File } from '@freearhey/core'
import { XmlDocument, XsdValidator, XmlValidateError, ErrorDetail } from 'libxml2-wasm'
const xsd = `<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified">
<xs:element name="channels">
<xs:complexType>
<xs:sequence>
<xs:element minOccurs="0" maxOccurs="unbounded" ref="channel"/>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="channel">
<xs:complexType mixed="true">
<xs:attribute use="required" ref="site"/>
<xs:attribute use="required" ref="lang"/>
<xs:attribute use="required" ref="site_id"/>
<xs:attribute name="xmltv_id" use="required" type="xs:string"/>
<xs:attribute name="logo" type="xs:string"/>
<xs:attribute name="lcn" type="xs:string"/>
</xs:complexType>
</xs:element>
<xs:attribute name="site">
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:minLength value="1"/>
</xs:restriction>
</xs:simpleType>
</xs:attribute>
<xs:attribute name="site_id">
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:minLength value="1"/>
</xs:restriction>
</xs:simpleType>
</xs:attribute>
<xs:attribute name="lang">
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:minLength value="1"/>
</xs:restriction>
</xs:simpleType>
</xs:attribute>
</xs:schema>`
program.argument('[filepath...]', 'Path to *.channels.xml files to check').parse(process.argv)
async function main() {
const storage = new Storage()
let errors: ErrorDetail[] = []
const files = program.args.length ? program.args : await storage.list('sites/**/*.channels.xml')
for (const filepath of files) {
const file = new File(filepath)
if (file.extension() !== 'xml') continue
const xml = await storage.load(filepath)
let localErrors: ErrorDetail[] = []
try {
const schema = XmlDocument.fromString(xsd)
const validator = XsdValidator.fromDoc(schema)
const doc = XmlDocument.fromString(xml)
validator.validate(doc)
schema.dispose()
validator.dispose()
doc.dispose()
} catch (_error) {
const error = _error as XmlValidateError
localErrors = localErrors.concat(error.details)
}
xml.split('\n').forEach((line: string, lineIndex: number) => {
const found = line.match(/='/)
if (found) {
const colIndex = found.index || 0
localErrors.push({
line: lineIndex + 1,
col: colIndex + 1,
message: 'Single quotes cannot be used in attributes'
})
}
})
if (localErrors.length) {
console.log(`\n${chalk.underline(filepath)}`)
localErrors.forEach((error: ErrorDetail) => {
const position = `${error.line}:${error.col}`
console.log(` ${chalk.gray(position.padEnd(4, ' '))} ${error.message.trim()}`)
})
errors = errors.concat(localErrors)
}
}
if (errors.length) {
console.log(chalk.red(`\n${errors.length} error(s)`))
process.exit(1)
}
}
main()
-78
View File
@@ -1,78 +0,0 @@
import chalk from 'chalk'
import libxml, { ValidationError } from 'libxmljs2'
import { program } from 'commander'
import { Logger, Storage, File } from '@freearhey/core'
const xsd = `<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified">
<xs:element name="channels">
<xs:complexType>
<xs:sequence>
<xs:element minOccurs="0" maxOccurs="unbounded" ref="channel"/>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="channel">
<xs:complexType mixed="true">
<xs:attribute name="site" use="required" type="xs:string"/>
<xs:attribute name="lang" use="required" type="xs:string"/>
<xs:attribute name="site_id" use="required" type="xs:string"/>
<xs:attribute name="xmltv_id" use="required" type="xs:string"/>
<xs:attribute name="logo" type="xs:string"/>
</xs:complexType>
</xs:element>
</xs:schema>`
program
.option(
'-c, --channels <path>',
'Path to channels.xml file to validate',
'sites/**/*.channels.xml'
)
.parse(process.argv)
const options = program.opts()
async function main() {
const logger = new Logger()
const storage = new Storage()
logger.info('options:')
logger.tree(options)
let errors: ValidationError[] = []
const files: string[] = await storage.list(options.channels)
for (const filepath of files) {
const file = new File(filepath)
if (file.extension() !== 'xml') continue
const xml = await storage.load(filepath)
let localErrors: ValidationError[] = []
const xsdDoc = libxml.parseXml(xsd)
const doc = libxml.parseXml(xml)
if (!doc.validate(xsdDoc)) {
localErrors = doc.validationErrors
}
if (localErrors.length) {
console.log(`\n${chalk.underline(filepath)}`)
localErrors.forEach((error: ValidationError) => {
const position = `${error.line}:${error.column}`
console.log(` ${chalk.gray(position.padEnd(4, ' '))} ${error.message.trim()}`)
})
errors = errors.concat(localErrors)
}
}
if (errors.length) {
console.log(chalk.red(`\n${errors.length} error(s)`))
process.exit(1)
}
}
main()
+86 -93
View File
@@ -1,93 +1,86 @@
import { Logger, File, Collection, Storage } from '@freearhey/core' import { Logger, File, Storage } from '@freearhey/core'
import { ChannelsParser, XML } from '../../core' import { ChannelsParser } from '../../core'
import { Channel } from 'epg-grabber' import { ChannelList } from '../../models'
import { Command } from 'commander' import { pathToFileURL } from 'node:url'
import path from 'path' import epgGrabber from 'epg-grabber'
import { Command } from 'commander'
const program = new Command()
program const program = new Command()
.requiredOption('-c, --config <config>', 'Config file') program
.option('-s, --set [args...]', 'Set custom arguments') .requiredOption('-c, --config <config>', 'Config file')
.option('-o, --output <output>', 'Output file') .option('-s, --set [args...]', 'Set custom arguments')
.parse(process.argv) .option('-o, --output <output>', 'Output file')
.parse(process.argv)
type ParseOptions = {
config: string interface ParseOptions {
set?: string config: string
output?: string set?: string
clean?: boolean output?: string
} clean?: boolean
}
const options: ParseOptions = program.opts()
const options: ParseOptions = program.opts()
async function main() {
const storage = new Storage() async function main() {
const parser = new ChannelsParser({ storage }) function isPromise(promise: object[] | Promise<object[]>) {
const logger = new Logger() return (
const file = new File(options.config) !!promise &&
const dir = file.dirname() typeof promise === 'object' &&
const config = require(path.resolve(options.config)) typeof (promise as Promise<object[]>).then === 'function'
const outputFilepath = options.output || `${dir}/${config.site}.channels.xml` )
}
let channels = new Collection()
if (await storage.exists(outputFilepath)) { const storage = new Storage()
channels = await parser.parse(outputFilepath) const logger = new Logger()
} const parser = new ChannelsParser({ storage })
const file = new File(options.config)
const args: { const dir = file.dirname()
[key: string]: string const config = (await import(pathToFileURL(options.config).toString())).default
} = {} const outputFilepath = options.output || `${dir}/${config.site}.channels.xml`
if (Array.isArray(options.set)) { let channelList = new ChannelList({ channels: [] })
options.set.forEach((arg: string) => { if (await storage.exists(outputFilepath)) {
const [key, value] = arg.split(':') channelList = await parser.parse(outputFilepath)
args[key] = value }
})
} const args: Record<string, string> = {}
let parsedChannels = config.channels(args) if (Array.isArray(options.set)) {
if (isPromise(parsedChannels)) { options.set.forEach((arg: string) => {
parsedChannels = await parsedChannels const [key, value] = arg.split(':')
} args[key] = value
parsedChannels = parsedChannels.map((channel: Channel) => { })
channel.site = config.site }
return channel let parsedChannels = config.channels(args)
}) if (isPromise(parsedChannels)) {
parsedChannels = await parsedChannels
let output = new Collection() }
parsedChannels.forEach((channel: Channel) => { parsedChannels = parsedChannels.map((channel: epgGrabber.Channel) => {
const found: Channel | undefined = channels.first( channel.site = config.site
(_channel: Channel) => _channel.site_id == channel.site_id
) return channel
})
if (found) {
channel.xmltv_id = found.xmltv_id const newChannelList = new ChannelList({ channels: [] })
channel.lang = found.lang parsedChannels.forEach((channel: epgGrabber.Channel) => {
} if (!channel.site_id) return
output.add(channel) const found: epgGrabber.Channel | undefined = channelList.get(channel.site_id)
})
if (found) {
output = output.orderBy([ channel.xmltv_id = found.xmltv_id
(channel: Channel) => channel.lang || '_', channel.lang = found.lang
(channel: Channel) => (channel.xmltv_id ? channel.xmltv_id.toLowerCase() : '0'), }
(channel: Channel) => channel.site_id
]) newChannelList.add(channel)
})
const xml = new XML(output)
newChannelList.sort()
await storage.save(outputFilepath, xml.toString())
await storage.save(outputFilepath, newChannelList.toString())
logger.info(`File '${outputFilepath}' successfully saved`)
} logger.info(`File '${outputFilepath}' successfully saved`)
}
main()
main()
function isPromise(promise: object[] | Promise<object[]>) {
return (
!!promise &&
typeof promise === 'object' &&
typeof (promise as Promise<object[]>).then === 'function'
)
}
+100 -102
View File
@@ -1,102 +1,100 @@
import { Storage, Collection, Dictionary, File, Logger } from '@freearhey/core' import { ChannelsParser, DataLoader, DataProcessor } from '../../core'
import { ChannelsParser, ApiChannel } from '../../core' import { DataProcessorData } from '../../types/dataProcessor'
import { program } from 'commander' import { Storage, Dictionary, File } from '@freearhey/core'
import chalk from 'chalk' import { DataLoaderData } from '../../types/dataLoader'
import langs from 'langs' import { ChannelList } from '../../models'
import { DATA_DIR } from '../../constants' import { DATA_DIR } from '../../constants'
import { Channel } from 'epg-grabber' import epgGrabber from 'epg-grabber'
import { program } from 'commander'
program import chalk from 'chalk'
.option( import langs from 'langs'
'-c, --channels <path>',
'Path to channels.xml file to validate', program.argument('[filepath...]', 'Path to *.channels.xml files to validate').parse(process.argv)
'sites/**/*.channels.xml'
) interface ValidationError {
.parse(process.argv) type: 'duplicate' | 'wrong_channel_id' | 'wrong_feed_id' | 'wrong_lang'
name: string
const options = program.opts() lang?: string
xmltv_id?: string
type ValidationError = { site_id?: string
type: 'duplicate' | 'wrong_xmltv_id' | 'wrong_lang' logo?: string
name: string }
lang?: string
xmltv_id?: string async function main() {
site_id?: string const processor = new DataProcessor()
logo?: string const dataStorage = new Storage(DATA_DIR)
} const loader = new DataLoader({ storage: dataStorage })
const data: DataLoaderData = await loader.load()
async function main() { const { channelsKeyById, feedsKeyByStreamId }: DataProcessorData = processor.process(data)
const logger = new Logger() const parser = new ChannelsParser({
storage: new Storage()
logger.info('options:') })
logger.tree(options)
let totalFiles = 0
const parser = new ChannelsParser({ storage: new Storage() }) let totalErrors = 0
let totalWarnings = 0
const dataStorage = new Storage(DATA_DIR)
const channelsContent = await dataStorage.json('channels.json') const storage = new Storage()
const channels = new Collection(channelsContent).map(data => new ApiChannel(data)) const files = program.args.length ? program.args : await storage.list('sites/**/*.channels.xml')
for (const filepath of files) {
let totalFiles = 0 const file = new File(filepath)
let totalErrors = 0 if (file.extension() !== 'xml') continue
const storage = new Storage()
const files: string[] = await storage.list(options.channels) const channelList: ChannelList = await parser.parse(filepath)
for (const filepath of files) {
const file = new File(filepath) const bufferBySiteId = new Dictionary()
if (file.extension() !== 'xml') continue const errors: ValidationError[] = []
channelList.channels.forEach((channel: epgGrabber.Channel) => {
const parsedChannels = await parser.parse(filepath) const bufferId: string = channel.site_id
if (bufferBySiteId.missing(bufferId)) {
const bufferById = new Dictionary() bufferBySiteId.set(bufferId, true)
const bufferBySiteId = new Dictionary() } else {
const errors: ValidationError[] = [] errors.push({ type: 'duplicate', ...channel })
parsedChannels.forEach((channel: Channel) => { totalErrors++
const bufferSiteId: string = `${channel.site_id}:${channel.lang}` }
if (bufferBySiteId.missing(bufferSiteId)) {
bufferBySiteId.set(bufferSiteId, true) if (!langs.where('1', channel.lang ?? '')) {
} else { errors.push({ type: 'wrong_lang', ...channel })
errors.push({ type: 'duplicate', ...channel }) totalErrors++
totalErrors++ }
}
if (!channel.xmltv_id) return
if (!langs.where('1', channel.lang)) { const [channelId, feedId] = channel.xmltv_id.split('@')
errors.push({ type: 'wrong_lang', ...channel })
totalErrors++ const foundChannel = channelsKeyById.get(channelId)
} if (!foundChannel) {
errors.push({ type: 'wrong_channel_id', ...channel })
if (!channel.xmltv_id) return totalWarnings++
}
const foundChannel = channels.first(
(_channel: ApiChannel) => _channel.id === channel.xmltv_id if (feedId) {
) const foundFeed = feedsKeyByStreamId.get(channel.xmltv_id)
if (!foundChannel) { if (!foundFeed) {
errors.push({ type: 'wrong_xmltv_id', ...channel }) errors.push({ type: 'wrong_feed_id', ...channel })
totalErrors++ totalWarnings++
} }
}
// if (foundChannel && foundChannel.replacedBy) { })
// errors.push({ type: 'replaced', ...channel })
// totalErrors++ if (errors.length) {
// } console.log(chalk.underline(filepath))
console.table(errors, ['type', 'lang', 'xmltv_id', 'site_id', 'name'])
// if (foundChannel && foundChannel.closed && !foundChannel.replacedBy) { console.log()
// errors.push({ type: 'closed', ...channel }) totalFiles++
// totalErrors++ }
// } }
})
const totalProblems = totalWarnings + totalErrors
if (errors.length) { if (totalProblems > 0) {
console.log(chalk.underline(filepath)) console.log(
console.table(errors, ['type', 'lang', 'xmltv_id', 'site_id', 'name']) chalk.red(
console.log() `${totalProblems} problems (${totalErrors} errors, ${totalWarnings} warnings) in ${totalFiles} file(s)`
totalFiles++ )
} )
} if (totalErrors > 0) {
process.exit(1)
if (totalErrors > 0) { }
console.log(chalk.red(`${totalErrors} error(s) in ${totalFiles} file(s)`)) }
process.exit(1) }
}
} main()
main()
+133 -117
View File
@@ -1,117 +1,133 @@
import { Logger, Timer, Storage, Collection } from '@freearhey/core' import { Logger, Timer, Storage, Collection } from '@freearhey/core'
import { program } from 'commander' import { QueueCreator, Job, ChannelsParser } from '../../core'
import { CronJob } from 'cron' import { Option, program } from 'commander'
import { QueueCreator, Job, ChannelsParser } from '../../core' import { SITES_DIR } from '../../constants'
import { Channel } from 'epg-grabber' import { Channel } from 'epg-grabber'
import path from 'path' import path from 'path'
import { SITES_DIR } from '../../constants' import { ChannelList } from '../../models'
program program
.option('-s, --site <name>', 'Name of the site to parse') .addOption(new Option('-s, --site <name>', 'Name of the site to parse'))
.option( .addOption(
'-c, --channels <path>', new Option(
'Path to *.channels.xml file (required if the "--site" attribute is not specified)' '-c, --channels <path>',
) 'Path to *.channels.xml file (required if the "--site" attribute is not specified)'
.option('-o, --output <path>', 'Path to output file', 'guide.xml') )
.option('-l, --lang <code>', 'Filter channels by language (ISO 639-2 code)') )
.option('-t, --timeout <milliseconds>', 'Override the default timeout for each request') .addOption(new Option('-o, --output <path>', 'Path to output file').default('guide.xml'))
.option('-d, --delay <milliseconds>', 'Override the default delay between request') .addOption(new Option('-l, --lang <codes>', 'Filter channels by languages (ISO 639-1 codes)'))
.option( .addOption(
'--days <days>', new Option('-t, --timeout <milliseconds>', 'Override the default timeout for each request').env(
'Override the number of days for which the program will be loaded (defaults to the value from the site config)', 'TIMEOUT'
value => parseInt(value) )
) )
.option( .addOption(
'--maxConnections <number>', new Option('-d, --delay <milliseconds>', 'Override the default delay between request').env(
'Limit on the number of concurrent requests', 'DELAY'
value => parseInt(value), )
1 )
) .addOption(new Option('-x, --proxy <url>', 'Use the specified proxy').env('PROXY'))
.option('--cron <expression>', 'Schedule a script run (example: "0 0 * * *")') .addOption(
.option('--gzip', 'Create a compressed version of the guide as well', false) new Option(
.parse(process.argv) '--days <days>',
'Override the number of days for which the program will be loaded (defaults to the value from the site config)'
export type GrabOptions = { )
site?: string .argParser(value => parseInt(value))
channels?: string .env('DAYS')
output: string )
gzip: boolean .addOption(
maxConnections: number new Option('--maxConnections <number>', 'Limit on the number of concurrent requests')
timeout?: string .default(1)
delay?: string .env('MAX_CONNECTIONS')
lang?: string )
days?: number .addOption(
cron?: string new Option('--gzip', 'Create a compressed version of the guide as well')
} .default(false)
.env('GZIP')
const options: GrabOptions = program.opts() )
.addOption(new Option('--curl', 'Display each request as CURL').default(false).env('CURL'))
async function main() { .parse()
if (!options.site && !options.channels)
throw new Error('One of the arguments must be presented: `--site` or `--channels`') export interface GrabOptions {
site?: string
const logger = new Logger() channels?: string
output: string
logger.start('starting...') gzip: boolean
curl: boolean
logger.info('config:') maxConnections: number
logger.tree(options) timeout?: string
delay?: string
logger.info('loading channels...') lang?: string
const storage = new Storage() days?: number
const parser = new ChannelsParser({ storage }) proxy?: string
}
let files: string[] = []
if (options.site) { const options: GrabOptions = program.opts()
let pattern = path.join(SITES_DIR, options.site, '*.channels.xml')
pattern = pattern.replace(/\\/g, '/') async function main() {
files = await storage.list(pattern) if (!options.site && !options.channels)
} else if (options.channels) { throw new Error('One of the arguments must be presented: `--site` or `--channels`')
files = await storage.list(options.channels)
} const logger = new Logger()
let parsedChannels = new Collection() logger.start('starting...')
for (const filepath of files) {
parsedChannels = parsedChannels.concat(await parser.parse(filepath)) logger.info('config:')
} logger.tree(options)
if (options.lang) {
parsedChannels = parsedChannels.filter((channel: Channel) => channel.lang === options.lang) logger.info('loading channels...')
} const storage = new Storage()
logger.info(` found ${parsedChannels.count()} channel(s)`) const parser = new ChannelsParser({ storage })
let runIndex = 1 let files: string[] = []
if (options.cron) { if (options.site) {
const cronJob = new CronJob(options.cron, async () => { let pattern = path.join(SITES_DIR, options.site, '*.channels.xml')
logger.info(`run #${runIndex}:`) pattern = pattern.replace(/\\/g, '/')
await runJob({ logger, parsedChannels }) files = await storage.list(pattern)
runIndex++ } else if (options.channels) {
}) files = await storage.list(options.channels)
cronJob.start() }
} else {
logger.info(`run #${runIndex}:`) let channels = new Collection()
runJob({ logger, parsedChannels }) for (const filepath of files) {
} const channelList: ChannelList = await parser.parse(filepath)
}
channels = channels.concat(channelList.channels)
main() }
async function runJob({ logger, parsedChannels }: { logger: Logger; parsedChannels: Collection }) { if (options.lang) {
const timer = new Timer() channels = channels.filter((channel: Channel) => {
timer.start() if (!options.lang || !channel.lang) return true
const queueCreator = new QueueCreator({ return options.lang.includes(channel.lang)
parsedChannels, })
logger, }
options
}) logger.info(` found ${channels.count()} channel(s)`)
const queue = await queueCreator.create()
const job = new Job({ logger.info('run:')
queue, runJob({ logger, channels })
logger, }
options
}) main()
await job.run() async function runJob({ logger, channels }: { logger: Logger; channels: Collection }) {
const timer = new Timer()
logger.success(` done in ${timer.format('HH[h] mm[m] ss[s]')}`) timer.start()
}
const queueCreator = new QueueCreator({
channels,
logger,
options
})
const queue = await queueCreator.create()
const job = new Job({
queue,
logger,
options
})
await job.run()
logger.success(` done in ${timer.format('HH[h] mm[m] ss[s]')}`)
}
+45
View File
@@ -0,0 +1,45 @@
import { Logger, Storage } from '@freearhey/core'
import { SITES_DIR } from '../../constants'
import { pathToFileURL } from 'node:url'
import { program } from 'commander'
import fs from 'fs-extra'
program.argument('<site>', 'Domain name of the site').parse(process.argv)
const domain = program.args[0]
async function main() {
const storage = new Storage(SITES_DIR)
const logger = new Logger()
logger.info(`Initializing "${domain}"...\r\n`)
const dir = domain
if (await storage.exists(dir)) {
throw new Error(`Folder "${dir}" already exists`)
}
await storage.createDir(dir)
logger.info(`Creating "${dir}/${domain}.test.js"...`)
const testTemplate = fs.readFileSync(pathToFileURL('scripts/templates/_test.js'), {
encoding: 'utf8'
})
await storage.save(`${dir}/${domain}.test.js`, testTemplate.replace(/<DOMAIN>/g, domain))
logger.info(`Creating "${dir}/${domain}.config.js"...`)
const configTemplate = fs.readFileSync(pathToFileURL('scripts/templates/_config.js'), {
encoding: 'utf8'
})
await storage.save(`${dir}/${domain}.config.js`, configTemplate.replace(/<DOMAIN>/g, domain))
logger.info(`Creating "${dir}/readme.md"...`)
const readmeTemplate = fs.readFileSync(pathToFileURL('scripts/templates/_readme.md'), {
encoding: 'utf8'
})
await storage.save(`${dir}/readme.md`, readmeTemplate.replace(/<DOMAIN>/g, domain))
logger.info('\r\nDone')
}
main()
+76
View File
@@ -0,0 +1,76 @@
import { IssueLoader, HTMLTable, ChannelsParser } from '../../core'
import { Logger, Storage, Collection } from '@freearhey/core'
import { ChannelList, Issue, Site } from '../../models'
import { SITES_DIR, ROOT_DIR } from '../../constants'
import { Channel } from 'epg-grabber'
async function main() {
const logger = new Logger({ level: -999 })
const issueLoader = new IssueLoader()
const sitesStorage = new Storage(SITES_DIR)
const sites = new Collection()
logger.info('loading channels...')
const channelsParser = new ChannelsParser({
storage: sitesStorage
})
logger.info('loading list of sites')
const folders = await sitesStorage.list('*/')
logger.info('loading issues...')
const issues = await issueLoader.load()
logger.info('putting the data together...')
const brokenGuideReports = issues.filter(issue =>
issue.labels.find((label: string) => label === 'broken guide')
)
for (const domain of folders) {
const filteredIssues = brokenGuideReports.filter(
(issue: Issue) => domain === issue.data.get('site')
)
const site = new Site({
domain,
issues: filteredIssues
})
const files = await sitesStorage.list(`${domain}/*.channels.xml`)
for (const filepath of files) {
const channelList: ChannelList = await channelsParser.parse(filepath)
site.totalChannels += channelList.channels.count()
site.markedChannels += channelList.channels
.filter((channel: Channel) => channel.xmltv_id)
.count()
}
sites.add(site)
}
logger.info('creating sites table...')
const tableData = new Collection()
sites.forEach((site: Site) => {
tableData.add([
{ value: `<a href="sites/${site.domain}">${site.domain}</a>` },
{ value: site.totalChannels, align: 'right' },
{ value: site.markedChannels, align: 'right' },
{ value: site.getStatus().emoji, align: 'center' },
{ value: site.getIssues().all().join(', ') }
])
})
logger.info('updating sites.md...')
const table = new HTMLTable(tableData.all(), [
{ name: 'Site', align: 'left' },
{ name: 'Channels<br>(total / with xmltv-id)', colspan: 2, align: 'left' },
{ name: 'Status', align: 'left' },
{ name: 'Notes', align: 'left' }
])
const rootStorage = new Storage(ROOT_DIR)
const sitesTemplate = await new Storage().load('scripts/templates/_sites.md')
const sitesContent = sitesTemplate.replace('_TABLE_', table.toString())
await rootStorage.save('SITES.md', sitesContent)
}
main()
+9 -4
View File
@@ -1,4 +1,9 @@
export const SITES_DIR = process.env.SITES_DIR || './sites' export const ROOT_DIR = process.env.ROOT_DIR || '.'
export const GUIDES_DIR = process.env.GUIDES_DIR || './guides' export const SITES_DIR = process.env.SITES_DIR || './sites'
export const DATA_DIR = process.env.DATA_DIR || './temp/data' export const GUIDES_DIR = process.env.GUIDES_DIR || './guides'
export const API_DIR = process.env.API_DIR || '.api' export const DATA_DIR = process.env.DATA_DIR || './temp/data'
export const API_DIR = process.env.API_DIR || '.api'
export const DOT_SITES_DIR = process.env.DOT_SITES_DIR || './.sites'
export const TESTING = process.env.NODE_ENV === 'test' ? true : false
export const OWNER = 'iptv-org'
export const REPO = 'epg'
-79
View File
@@ -1,79 +0,0 @@
import { Collection } from '@freearhey/core'
type ApiChannelProps = {
id: string
name: string
alt_names: string[]
network: string
owners: string[]
country: string
subdivision: string
city: string
broadcast_area: string[]
languages: string[]
categories: string[]
is_nsfw: boolean
launched: string
closed: string
replaced_by: string
website: string
logo: string
}
export class ApiChannel {
id: string
name: string
altNames: Collection
network: string
owners: Collection
country: string
subdivision: string
city: string
broadcastArea: Collection
languages: Collection
categories: Collection
isNSFW: boolean
launched: string
closed: string
replacedBy: string
website: string
logo: string
constructor({
id,
name,
alt_names,
network,
owners,
country,
subdivision,
city,
broadcast_area,
languages,
categories,
is_nsfw,
launched,
closed,
replaced_by,
website,
logo
}: ApiChannelProps) {
this.id = id
this.name = name
this.altNames = new Collection(alt_names)
this.network = network
this.owners = new Collection(owners)
this.country = country
this.subdivision = subdivision
this.city = city
this.broadcastArea = new Collection(broadcast_area)
this.languages = new Collection(languages)
this.categories = new Collection(categories)
this.isNSFW = is_nsfw
this.launched = launched
this.closed = closed
this.replacedBy = replaced_by
this.website = website
this.logo = logo
}
}
+16 -59
View File
@@ -1,59 +1,16 @@
import { Logger, Storage } from '@freearhey/core' import axios, { AxiosInstance, AxiosResponse, AxiosRequestConfig } from 'axios'
import axios, { AxiosInstance, AxiosResponse, AxiosProgressEvent } from 'axios'
import cliProgress, { MultiBar } from 'cli-progress' export class ApiClient {
import numeral from 'numeral' instance: AxiosInstance
export class ApiClient { constructor() {
progressBar: MultiBar this.instance = axios.create({
client: AxiosInstance baseURL: 'https://iptv-org.github.io/api',
storage: Storage responseType: 'stream'
logger: Logger })
}
constructor({ logger }: { logger: Logger }) {
this.logger = logger get(url: string, options: AxiosRequestConfig): Promise<AxiosResponse> {
this.client = axios.create({ return this.instance.get(url, options)
responseType: 'stream' }
}) }
this.storage = new Storage()
this.progressBar = new cliProgress.MultiBar({
stopOnComplete: true,
hideCursor: true,
forceRedraw: true,
barsize: 36,
format(options, params, payload) {
const filename = payload.filename.padEnd(18, ' ')
const barsize = options.barsize || 40
const percent = (params.progress * 100).toFixed(2)
const speed = payload.speed ? numeral(payload.speed).format('0.0 b') + '/s' : 'N/A'
const total = numeral(params.total).format('0.0 b')
const completeSize = Math.round(params.progress * barsize)
const incompleteSize = barsize - completeSize
const bar =
options.barCompleteString && options.barIncompleteString
? options.barCompleteString.substr(0, completeSize) +
options.barGlue +
options.barIncompleteString.substr(0, incompleteSize)
: '-'.repeat(barsize)
return `${filename} [${bar}] ${percent}% | ETA: ${params.eta}s | ${total} | ${speed}`
}
})
}
async download(filename: string) {
const stream = await this.storage.createStream(`/temp/data/${filename}`)
const bar = this.progressBar.create(0, 0, { filename })
this.client
.get(`https://iptv-org.github.io/api/${filename}`, {
onDownloadProgress({ total, loaded, rate }: AxiosProgressEvent) {
if (total) bar.setTotal(total)
bar.update(loaded, { speed: rate })
}
})
.then((response: AxiosResponse) => {
response.data.pipe(stream)
})
}
}
+22 -24
View File
@@ -1,24 +1,22 @@
import { parseChannels } from 'epg-grabber' import { parseChannels } from 'epg-grabber'
import { Storage, Collection } from '@freearhey/core' import { Storage } from '@freearhey/core'
import { ChannelList } from '../models'
type ChannelsParserProps = {
storage: Storage interface ChannelsParserProps {
} storage: Storage
}
export class ChannelsParser {
storage: Storage export class ChannelsParser {
storage: Storage
constructor({ storage }: ChannelsParserProps) {
this.storage = storage constructor({ storage }: ChannelsParserProps) {
} this.storage = storage
}
async parse(filepath: string) {
let parsedChannels = new Collection() 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 channels = parseChannels(content)
parsedChannels = parsedChannels.concat(new Collection(channels)) return new ChannelList({ channels: parsed })
}
return parsedChannels }
}
}
+32 -33
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: 2, 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) }
} }
}
+103
View File
@@ -0,0 +1,103 @@
import type { DataLoaderProps, DataLoaderData } from '../types/dataLoader'
import cliProgress, { MultiBar } from 'cli-progress'
import { Storage } from '@freearhey/core'
import { ApiClient } from './apiClient'
import numeral from 'numeral'
export class DataLoader {
client: ApiClient
storage: Storage
progressBar: MultiBar
constructor(props: DataLoaderProps) {
this.client = new ApiClient()
this.storage = props.storage
this.progressBar = new cliProgress.MultiBar({
stopOnComplete: true,
hideCursor: true,
forceRedraw: true,
barsize: 36,
format(options, params, payload) {
const filename = payload.filename.padEnd(18, ' ')
const barsize = options.barsize || 40
const percent = (params.progress * 100).toFixed(2)
const speed = payload.speed ? numeral(payload.speed).format('0.0 b') + '/s' : 'N/A'
const total = numeral(params.total).format('0.0 b')
const completeSize = Math.round(params.progress * barsize)
const incompleteSize = barsize - completeSize
const bar =
options.barCompleteString && options.barIncompleteString
? options.barCompleteString.substr(0, completeSize) +
options.barGlue +
options.barIncompleteString.substr(0, incompleteSize)
: '-'.repeat(barsize)
return `${filename} [${bar}] ${percent}% | ETA: ${params.eta}s | ${total} | ${speed}`
}
})
}
async load(): Promise<DataLoaderData> {
const [
countries,
regions,
subdivisions,
languages,
categories,
blocklist,
channels,
feeds,
timezones,
guides,
streams,
logos
] = await Promise.all([
this.storage.json('countries.json'),
this.storage.json('regions.json'),
this.storage.json('subdivisions.json'),
this.storage.json('languages.json'),
this.storage.json('categories.json'),
this.storage.json('blocklist.json'),
this.storage.json('channels.json'),
this.storage.json('feeds.json'),
this.storage.json('timezones.json'),
this.storage.json('guides.json'),
this.storage.json('streams.json'),
this.storage.json('logos.json')
])
return {
countries,
regions,
subdivisions,
languages,
categories,
blocklist,
channels,
feeds,
timezones,
guides,
streams,
logos
}
}
async download(filename: string) {
if (!this.storage || !this.progressBar) return
const stream = await this.storage.createStream(filename)
const progressBar = this.progressBar.create(0, 0, { filename })
this.client
.get(filename, {
responseType: 'stream',
onDownloadProgress({ total, loaded, rate }) {
if (total) progressBar.setTotal(total)
progressBar.update(loaded, { speed: rate })
}
})
.then(response => {
response.data.pipe(stream)
})
}
}
+55
View File
@@ -0,0 +1,55 @@
import { Channel, Feed, GuideChannel, Logo, Stream } from '../models'
import { DataLoaderData } from '../types/dataLoader'
import { Collection } from '@freearhey/core'
export class DataProcessor {
process(data: DataLoaderData) {
let channels = new Collection(data.channels).map(data => new Channel(data))
const channelsKeyById = channels.keyBy((channel: Channel) => channel.id)
const guideChannels = new Collection(data.guides).map(data => new GuideChannel(data))
const guideChannelsGroupedByStreamId = guideChannels.groupBy((channel: GuideChannel) =>
channel.getStreamId()
)
const streams = new Collection(data.streams).map(data => new Stream(data))
const streamsGroupedById = streams.groupBy((stream: Stream) => stream.getId())
let feeds = new Collection(data.feeds).map(data =>
new Feed(data)
.withGuideChannels(guideChannelsGroupedByStreamId)
.withStreams(streamsGroupedById)
.withChannel(channelsKeyById)
)
const feedsKeyByStreamId = feeds.keyBy((feed: Feed) => feed.getStreamId())
const logos = new Collection(data.logos).map(data =>
new Logo(data).withFeed(feedsKeyByStreamId)
)
const logosGroupedByChannelId = logos.groupBy((logo: Logo) => logo.channelId)
const logosGroupedByStreamId = logos.groupBy((logo: Logo) => logo.getStreamId())
feeds = feeds.map((feed: Feed) => feed.withLogos(logosGroupedByStreamId))
const feedsGroupedByChannelId = feeds.groupBy((feed: Feed) => feed.channelId)
channels = channels.map((channel: Channel) =>
channel.withFeeds(feedsGroupedByChannelId).withLogos(logosGroupedByChannelId)
)
return {
guideChannelsGroupedByStreamId,
feedsGroupedByChannelId,
logosGroupedByChannelId,
logosGroupedByStreamId,
streamsGroupedById,
feedsKeyByStreamId,
channelsKeyById,
guideChannels,
channels,
streams,
feeds,
logos
}
}
}
+14 -13
View File
@@ -1,13 +1,14 @@
const dayjs = require('dayjs') import dayjs from 'dayjs'
const utc = require('dayjs/plugin/utc') import utc from 'dayjs/plugin/utc'
dayjs.extend(utc)
dayjs.extend(utc)
const date = {}
const date = {}
date.getUTC = function (d = null) {
if (typeof d === 'string') return dayjs.utc(d).startOf('d') date.getUTC = function (d = null) {
if (typeof d === 'string') return dayjs.utc(d).startOf('d')
return dayjs.utc().startOf('d')
} return dayjs.utc().startOf('d')
}
module.exports = date
export default date
+105 -80
View File
@@ -1,80 +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 } 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'
type GrabberProps = {
logger: Logger interface GrabberProps {
queue: Queue logger: Logger
options: GrabOptions queue: Queue
} options: GrabOptions
}
export class Grabber {
logger: Logger export class Grabber {
queue: Queue logger: Logger
options: GrabOptions queue: Queue
options: GrabOptions
constructor({ logger, queue, options }: GrabberProps) { grabber: EPGGrabber | EPGGrabberMock
this.logger = logger
this.queue = queue constructor({ logger, queue, options }: GrabberProps) {
this.options = options this.logger = logger
this.grabber = process.env.NODE_ENV === 'test' ? new EPGGrabberMock() : new EPGGrabber() this.queue = queue
} this.options = options
this.grabber = process.env.NODE_ENV === 'test' ? new EPGGrabberMock() : new EPGGrabber()
async grab(): Promise<{ channels: Collection; programs: Collection }> { }
const taskQueue = new TaskQueue(Promise as PromisyClass, this.options.maxConnections)
async grab(): Promise<{ channels: Collection; programs: Collection }> {
const total = this.queue.size() const proxyParser = new ProxyParser()
const taskQueue = new TaskQueue(Promise as PromisyClass, this.options.maxConnections)
const channels = new Collection()
let programs = new Collection() const total = this.queue.size()
let i = 1
const channels = new Collection()
await Promise.all( let programs = new Collection()
this.queue.items().map( let i = 1
taskQueue.wrap(
async (queueItem: { channel: Channel; config: SiteConfig; date: string }) => { await Promise.all(
const { channel, config, date } = queueItem this.queue.items().map(
taskQueue.wrap(
channels.add(channel) async (queueItem: { channel: Channel; config: SiteConfig; date: string }) => {
const { channel, config, date } = queueItem
if (this.options.timeout !== undefined) {
const timeout = parseInt(this.options.timeout) channels.add(channel)
config.request = { ...config.request, ...{ timeout } }
} if (this.options.timeout !== undefined) {
const timeout = parseInt(this.options.timeout)
if (this.options.delay !== undefined) { config.request = { ...config.request, ...{ timeout } }
const delay = parseInt(this.options.delay) }
config.delay = delay
} if (this.options.delay !== undefined) {
const delay = parseInt(this.options.delay)
const _programs = await this.grabber.grab( config.delay = delay
channel, }
date,
config, if (this.options.proxy !== undefined) {
(data: GrabCallbackData, error: Error | null) => { const proxy = proxyParser.parse(this.options.proxy)
const { programs, date } = data
if (
this.logger.info( proxy.protocol &&
` [${i}/${total}] ${channel.site} (${channel.lang}) - ${ ['socks', 'socks5', 'socks5h', 'socks4', 'socks4a'].includes(String(proxy.protocol))
channel.xmltv_id ) {
} - ${date.format('MMM D, YYYY')} (${programs.length} programs)` const socksProxyAgent = new SocksProxyAgent(this.options.proxy)
)
if (i < total) i++ config.request = {
...config.request,
if (error) { ...{ httpAgent: socksProxyAgent, httpsAgent: socksProxyAgent }
this.logger.info(` ERR: ${error.message}`) }
} } else {
} config.request = { ...config.request, ...{ proxy } }
) }
}
programs = programs.concat(new Collection(_programs))
} if (this.options.curl === true) {
) config.curl = true
) }
)
const _programs = await this.grabber.grab(
return { channels, programs } channel,
} date,
} config,
(data: GrabCallbackData, error: Error | null) => {
const { programs, date } = data
this.logger.info(
` [${i}/${total}] ${channel.site} (${channel.lang}) - ${
channel.xmltv_id
} - ${date.format('MMM D, YYYY')} (${programs.length} programs)`
)
if (i < total) i++
if (error) {
this.logger.info(` ERR: ${error.message}`)
}
}
)
programs = programs.concat(new Collection(_programs))
}
)
)
)
return { channels, programs }
}
}
-60
View File
@@ -1,60 +0,0 @@
import { Collection, Logger, DateTime, Storage, Zip } from '@freearhey/core'
import { Channel } from 'epg-grabber'
import { XMLTV } from '../core'
import path from 'path'
type GuideProps = {
channels: Collection
programs: Collection
logger: Logger
filepath: string
gzip: boolean
}
export class Guide {
channels: Collection
programs: Collection
logger: Logger
storage: Storage
filepath: string
gzip: boolean
constructor({ channels, programs, logger, filepath, gzip }: GuideProps) {
this.channels = channels
this.programs = programs
this.logger = logger
this.storage = new Storage(path.dirname(filepath))
this.filepath = filepath
this.gzip = gzip || false
}
async save() {
const channels = this.channels.uniqBy(
(channel: Channel) => `${channel.xmltv_id}:${channel.site}`
)
const programs = this.programs
const currDate = new DateTime(process.env.CURR_DATE || new Date().toISOString(), {
zone: 'UTC'
})
const xmltv = new XMLTV({
channels,
programs,
date: currDate
})
const xmlFilepath = this.filepath
const xmlFilename = path.basename(xmlFilepath)
this.logger.info(` saving to "${xmlFilepath}"...`)
await this.storage.save(xmlFilename, xmltv.toString())
if (this.gzip) {
const zip = new Zip()
const compressed = await zip.compress(xmltv.toString())
const gzFilepath = `${this.filepath}.gz`
const gzFilename = path.basename(gzFilepath)
this.logger.info(` saving to "${gzFilepath}"...`)
await this.storage.save(gzFilename, compressed)
}
}
}
+111 -61
View File
@@ -1,61 +1,111 @@
import { Collection, Logger, Storage, StringTemplate } from '@freearhey/core' import { Collection, Logger, Zip, Storage, StringTemplate } from '@freearhey/core'
import { OptionValues } from 'commander' import epgGrabber from 'epg-grabber'
import { Channel, Program } from 'epg-grabber' import { OptionValues } from 'commander'
import { Guide } from '.' import { Channel, Feed, Guide } from '../models'
import path from 'path'
type GuideManagerProps = { import { DataLoader, DataProcessor } from '.'
options: OptionValues import { DataLoaderData } from '../types/dataLoader'
logger: Logger import { DataProcessorData } from '../types/dataProcessor'
channels: Collection import { DATA_DIR } from '../constants'
programs: Collection
} interface GuideManagerProps {
options: OptionValues
export class GuideManager { logger: Logger
options: OptionValues channels: Collection
storage: Storage programs: Collection
logger: Logger }
channels: Collection
programs: Collection export class GuideManager {
options: OptionValues
constructor({ channels, programs, logger, options }: GuideManagerProps) { logger: Logger
this.options = options channels: Collection
this.logger = logger programs: Collection
this.channels = channels
this.programs = programs constructor({ channels, programs, logger, options }: GuideManagerProps) {
this.storage = new Storage() this.options = options
} this.logger = logger
this.channels = channels
async createGuides() { this.programs = programs
const pathTemplate = new StringTemplate(this.options.output) }
const groupedChannels = this.channels async createGuides() {
.orderBy([(channel: Channel) => channel.xmltv_id]) const pathTemplate = new StringTemplate(this.options.output)
.uniqBy((channel: Channel) => `${channel.xmltv_id}:${channel.site}:${channel.lang}`)
.groupBy((channel: Channel) => { const processor = new DataProcessor()
return pathTemplate.format({ lang: channel.lang || 'en', site: channel.site || '' }) const dataStorage = new Storage(DATA_DIR)
}) const loader = new DataLoader({ storage: dataStorage })
const data: DataLoaderData = await loader.load()
const groupedPrograms = this.programs const { feedsKeyByStreamId, channelsKeyById }: DataProcessorData = processor.process(data)
.orderBy([(program: Program) => program.channel, (program: Program) => program.start])
.groupBy((program: Program) => { const groupedChannels = this.channels
const lang = .map((channel: epgGrabber.Channel) => {
program.titles && program.titles.length && program.titles[0].lang if (channel.xmltv_id && !channel.icon) {
? program.titles[0].lang const foundFeed: Feed = feedsKeyByStreamId.get(channel.xmltv_id)
: 'en' if (foundFeed && foundFeed.hasLogo()) {
channel.icon = foundFeed.getLogoUrl()
return pathTemplate.format({ lang, site: program.site || '' }) } else {
}) const [channelId] = channel.xmltv_id.split('@')
const foundChannel: Channel = channelsKeyById.get(channelId)
for (const groupKey of groupedPrograms.keys()) { if (foundChannel && foundChannel.hasLogo()) {
const guide = new Guide({ channel.icon = foundChannel.getLogoUrl()
filepath: groupKey, }
gzip: this.options.gzip, }
channels: new Collection(groupedChannels.get(groupKey)), }
programs: new Collection(groupedPrograms.get(groupKey)),
logger: this.logger return channel
}) })
.orderBy([
await guide.save() (channel: epgGrabber.Channel) => channel.index,
} (channel: epgGrabber.Channel) => channel.xmltv_id
} ])
} .uniqBy(
(channel: epgGrabber.Channel) => `${channel.xmltv_id}:${channel.site}:${channel.lang}`
)
.groupBy((channel: epgGrabber.Channel) => {
return pathTemplate.format({ lang: channel.lang || 'en', site: channel.site || '' })
})
const groupedPrograms = this.programs
.orderBy([
(program: epgGrabber.Program) => program.channel,
(program: epgGrabber.Program) => program.start
])
.groupBy((program: epgGrabber.Program) => {
const lang =
program.titles && program.titles.length && program.titles[0].lang
? program.titles[0].lang
: 'en'
return pathTemplate.format({ lang, site: program.site || '' })
})
for (const groupKey of groupedPrograms.keys()) {
const guide = new Guide({
filepath: groupKey,
gzip: this.options.gzip,
channels: new Collection(groupedChannels.get(groupKey)),
programs: new Collection(groupedPrograms.get(groupKey))
})
await this.save(guide)
}
}
async save(guide: Guide) {
const storage = new Storage(path.dirname(guide.filepath))
const xmlFilepath = guide.filepath
const xmlFilename = path.basename(xmlFilepath)
this.logger.info(` saving to "${xmlFilepath}"...`)
const xmltv = guide.toString()
await storage.save(xmlFilename, xmltv)
if (guide.gzip) {
const zip = new Zip()
const compressed = zip.compress(xmltv)
const gzFilepath = `${guide.filepath}.gz`
const gzFilename = path.basename(gzFilepath)
this.logger.info(` saving to "${gzFilepath}"...`)
await storage.save(gzFilename, compressed)
}
}
}
+55
View File
@@ -0,0 +1,55 @@
interface Column {
name: string
nowrap?: boolean
align?: string
colspan?: number
}
type DataItem = {
value: string
nowrap?: boolean
align?: string
colspan?: number
}[]
export class HTMLTable {
data: DataItem[]
columns: Column[]
constructor(data: DataItem[], columns: Column[]) {
this.data = data
this.columns = columns
}
toString() {
let output = '<table>\r\n'
output += ' <thead>\r\n <tr>'
for (const column of this.columns) {
const nowrap = column.nowrap ? ' nowrap' : ''
const align = column.align ? ` align="${column.align}"` : ''
const colspan = column.colspan ? ` colspan="${column.colspan}"` : ''
output += `<th${align}${nowrap}${colspan}>${column.name}</th>`
}
output += '</tr>\r\n </thead>\r\n'
output += ' <tbody>\r\n'
for (const row of this.data) {
output += ' <tr>'
for (const item of row) {
const nowrap = item.nowrap ? ' nowrap' : ''
const align = item.align ? ` align="${item.align}"` : ''
const colspan = item.colspan ? ` colspan="${item.colspan}"` : ''
output += `<td${align}${nowrap}${colspan}>${item.value}</td>`
}
output += '</tr>\r\n'
}
output += ' </tbody>\r\n'
output += '</table>'
return output
}
}
+14 -12
View File
@@ -1,12 +1,14 @@
export * from './xml' export * from './apiClient'
export * from './channelsParser' export * from './channelsParser'
export * from './xmltv' export * from './configLoader'
export * from './configLoader' export * from './dataLoader'
export * from './grabber' export * from './dataProcessor'
export * from './job' export * from './grabber'
export * from './queue' export * from './guideManager'
export * from './guideManager' export * from './htmlTable'
export * from './guide' export * from './issueLoader'
export * from './apiChannel' export * from './issueParser'
export * from './apiClient' export * from './job'
export * from './queueCreator' export * from './proxyParser'
export * from './queue'
export * from './queueCreator'
+37
View File
@@ -0,0 +1,37 @@
import { restEndpointMethods } from '@octokit/plugin-rest-endpoint-methods'
import { paginateRest } from '@octokit/plugin-paginate-rest'
import { TESTING, OWNER, REPO } from '../constants'
import { Collection } from '@freearhey/core'
import { Octokit } from '@octokit/core'
import { IssueParser } from './'
const CustomOctokit = Octokit.plugin(paginateRest, restEndpointMethods)
const octokit = new CustomOctokit()
export class IssueLoader {
async load(props?: { labels: string[] | string }) {
let labels = ''
if (props && props.labels) {
labels = Array.isArray(props.labels) ? props.labels.join(',') : props.labels
}
let issues: object[] = []
if (TESTING) {
issues = (await import('../../tests/__data__/input/sites_update/issues.mjs')).default
} else {
issues = await octokit.paginate(octokit.rest.issues.listForRepo, {
owner: OWNER,
repo: REPO,
per_page: 100,
labels,
state: 'open',
headers: {
'X-GitHub-Api-Version': '2022-11-28'
}
})
}
const parser = new IssueParser()
return new Collection(issues).map(parser.parse)
}
}
+34
View File
@@ -0,0 +1,34 @@
import { Dictionary } from '@freearhey/core'
import { Issue } from '../models'
const FIELDS = new Dictionary({
Site: 'site'
})
export class IssueParser {
parse(issue: { number: number; body: string; labels: { name: string }[] }): Issue {
const fields = issue.body.split('###')
const data = new Dictionary()
fields.forEach((field: string) => {
const parsed = field.split(/\r?\n/).filter(Boolean)
let _label = parsed.shift()
_label = _label ? _label.trim() : ''
let _value = parsed.join('\r\n')
_value = _value ? _value.trim() : ''
if (!_label || !_value) return data
const id: string = FIELDS.get(_label)
const value: string = _value === '_No response_' || _value === 'None' ? '' : _value
if (!id) return
data.set(id, value)
})
const labels = issue.labels.map(label => label.name)
return new Issue({ number: issue.number, labels, data })
}
}
+34 -34
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()
} }
} }
+31
View File
@@ -0,0 +1,31 @@
import { URL } from 'node:url'
interface ProxyParserResult {
protocol: string | null
auth?: {
username?: string
password?: string
}
host: string
port: number | null
}
export class ProxyParser {
parse(_url: string): ProxyParserResult {
const parsed = new URL(_url)
const result: ProxyParserResult = {
protocol: parsed.protocol.replace(':', '') || null,
host: parsed.hostname,
port: parsed.port ? parseInt(parsed.port) : null
}
if (parsed.username || parsed.password) {
result.auth = {}
if (parsed.username) result.auth.username = parsed.username
if (parsed.password) result.auth.password = parsed.password
}
return result
}
}
+45 -45
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
} }
} }
+63 -75
View File
@@ -1,75 +1,63 @@
import { Storage, Collection, DateTime, Logger } from '@freearhey/core' import { Storage, Collection, DateTime, Logger } from '@freearhey/core'
import { ChannelsParser, ConfigLoader, ApiChannel, Queue } from './' import { SITES_DIR, DATA_DIR } from '../constants'
import { SITES_DIR, DATA_DIR } from '../constants' import { GrabOptions } from '../commands/epg/grab'
import { Channel, SiteConfig } from 'epg-grabber' import { ConfigLoader, Queue } from './'
import path from 'path' import { SiteConfig } from 'epg-grabber'
import { GrabOptions } from '../commands/epg/grab' import path from 'path'
type QueueCreatorProps = { interface QueueCreatorProps {
logger: Logger logger: Logger
options: GrabOptions options: GrabOptions
parsedChannels: 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
parser: ChannelsParser channels: Collection
parsedChannels: Collection options: GrabOptions
options: GrabOptions
constructor({ channels, logger, options }: QueueCreatorProps) {
constructor({ parsedChannels, logger, options }: QueueCreatorProps) { this.channels = channels
this.parsedChannels = parsedChannels 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.parser = new ChannelsParser({ storage: new Storage() }) this.configLoader = new ConfigLoader()
this.options = options }
this.configLoader = new ConfigLoader()
} async create(): Promise<Queue> {
let index = 0
async create(): Promise<Queue> { const queue = new Queue()
const channelsContent = await this.dataStorage.json('channels.json') for (const channel of this.channels.all()) {
const channels = new Collection(channelsContent).map(data => new ApiChannel(data)) channel.index = index++
if (!channel.site || !channel.site_id || !channel.name) continue
const queue = new Queue()
for (const channel of this.parsedChannels.all()) { const configPath = path.resolve(SITES_DIR, `${channel.site}/${channel.site}.config.js`)
if (!channel.site || !channel.site_id || !channel.name) continue const config: SiteConfig = await this.configLoader.load(configPath)
if (this.options.lang && channel.lang !== this.options.lang) continue
if (!channel.xmltv_id) {
const configPath = path.resolve(SITES_DIR, `${channel.site}/${channel.site}.config.js`) channel.xmltv_id = channel.site_id
const config: SiteConfig = await this.configLoader.load(configPath) }
if (channel.xmltv_id) { const days = this.options.days || config.days || 1
const found: ApiChannel = channels.first( const currDate = new DateTime(process.env.CURR_DATE || new Date().toISOString())
(_channel: ApiChannel) => _channel.id === channel.xmltv_id const dates = Array.from({ length: days }, (_, day) => currDate.add(day, 'd'))
) dates.forEach((date: DateTime) => {
if (found) { const dateString = date.toJSON()
channel.icon = found.logo const key = `${channel.site}:${channel.lang}:${channel.xmltv_id}:${dateString}`
channel.name = found.name if (queue.missing(key)) {
} queue.add(key, {
} else { channel,
channel.xmltv_id = channel.site_id date: dateString,
} config
})
const days = this.options.days || config.days || 1 }
const currDate = new DateTime(process.env.CURR_DATE || new Date().toISOString()) })
const dates = Array.from({ length: days }, (_, day) => currDate.add(day, 'd')) }
dates.forEach((date: DateTime) => {
const dateString = date.toJSON() return queue
const key = `${channel.site}:${channel.lang}:${channel.xmltv_id}:${dateString}` }
if (queue.missing(key)) { }
queue.add(key, {
channel,
date: dateString,
config
})
}
})
}
return queue
}
}
-56
View File
@@ -1,56 +0,0 @@
import { Collection } from '@freearhey/core'
import { Channel } from 'epg-grabber'
export class XML {
items: Collection
constructor(items: Collection) {
this.items = items
}
toString() {
let output = '<?xml version="1.0" encoding="UTF-8"?>\r\n<channels>\r\n'
this.items.forEach((channel: Channel) => {
const logo = channel.logo ? ` logo="${channel.logo}"` : ''
const xmltv_id = channel.xmltv_id || ''
const lang = channel.lang || ''
const site_id = channel.site_id || ''
output += ` <channel site="${channel.site}" lang="${lang}" xmltv_id="${escapeString(
xmltv_id
)}" site_id="${site_id}"${logo}>${escapeString(channel.name)}</channel>\r\n`
})
output += '</channels>\r\n'
return output
}
}
function escapeString(value: string, defaultValue: string = '') {
if (!value) return defaultValue
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' +
'FE\\uDFFF])|(?:\\uD8BF[\\uDFFE\\uDFFF])|(?:\\uD8FF[\\uDFFE\\uDFFF])|(?:\\uD93F[\\uDFFE\\uD' +
'FFF])|(?:\\uD97F[\\uDFFE\\uDFFF])|(?:\\uD9BF[\\uDFFE\\uDFFF])|(?:\\uD9FF[\\uDFFE\\uDFFF])' +
'|(?:\\uDA3F[\\uDFFE\\uDFFF])|(?:\\uDA7F[\\uDFFE\\uDFFF])|(?:\\uDABF[\\uDFFE\\uDFFF])|(?:\\' +
'uDAFF[\\uDFFE\\uDFFF])|(?:\\uDB3F[\\uDFFE\\uDFFF])|(?:\\uDB7F[\\uDFFE\\uDFFF])|(?:\\uDBBF' +
'[\\uDFFE\\uDFFF])|(?:\\uDBFF[\\uDFFE\\uDFFF])(?:[\\0-\\t\\x0B\\f\\x0E-\\u2027\\u202A-\\uD7FF\\' +
'uE000-\\uFFFF]|[\\uD800-\\uDBFF][\\uDC00-\\uDFFF]|[\\uD800-\\uDBFF](?![\\uDC00-\\uDFFF])|' +
'(?:[^\\uD800-\\uDBFF]|^)[\\uDC00-\\uDFFF]))',
'g'
)
value = String(value || '').replace(regex, '')
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
.replace(/\n|\r/g, ' ')
.replace(/ +/g, ' ')
.trim()
}
-28
View File
@@ -1,28 +0,0 @@
import { DateTime, Collection } from '@freearhey/core'
import { generateXMLTV } from 'epg-grabber'
type XMLTVProps = {
channels: Collection
programs: Collection
date: DateTime
}
export class XMLTV {
channels: Collection
programs: Collection
date: DateTime
constructor({ channels, programs, date }: XMLTVProps) {
this.channels = channels
this.programs = programs
this.date = date
}
toString() {
return generateXMLTV({
channels: this.channels.all(),
programs: this.programs.all(),
date: this.date.toJSON()
})
}
}
+164
View File
@@ -0,0 +1,164 @@
import { ChannelData, ChannelSearchableData } from '../types/channel'
import { Collection, Dictionary } from '@freearhey/core'
import { Stream, Feed, Logo, GuideChannel } from './'
export class Channel {
id?: string
name?: string
altNames?: Collection
network?: string
owners?: Collection
countryCode?: string
subdivisionCode?: string
cityName?: string
categoryIds?: Collection
isNSFW = false
launched?: string
closed?: string
replacedBy?: string
website?: string
feeds?: Collection
logos: Collection = new Collection()
constructor(data?: ChannelData) {
if (!data) return
this.id = data.id
this.name = data.name
this.altNames = new Collection(data.alt_names)
this.network = data.network || undefined
this.owners = new Collection(data.owners)
this.countryCode = data.country
this.subdivisionCode = data.subdivision || undefined
this.cityName = data.city || undefined
this.categoryIds = new Collection(data.categories)
this.isNSFW = data.is_nsfw
this.launched = data.launched || undefined
this.closed = data.closed || undefined
this.replacedBy = data.replaced_by || undefined
this.website = data.website || undefined
}
withFeeds(feedsGroupedByChannelId: Dictionary): this {
if (this.id) this.feeds = new Collection(feedsGroupedByChannelId.get(this.id))
return this
}
withLogos(logosGroupedByChannelId: Dictionary): this {
if (this.id) this.logos = new Collection(logosGroupedByChannelId.get(this.id))
return this
}
getFeeds(): Collection {
if (!this.feeds) return new Collection()
return this.feeds
}
getGuideChannels(): Collection {
let channels = new Collection()
this.getFeeds().forEach((feed: Feed) => {
channels = channels.concat(feed.getGuideChannels())
})
return channels
}
getGuideChannelNames(): Collection {
return this.getGuideChannels()
.map((channel: GuideChannel) => channel.siteName)
.uniq()
}
getStreams(): Collection {
let streams = new Collection()
this.getFeeds().forEach((feed: Feed) => {
streams = streams.concat(feed.getStreams())
})
return streams
}
getStreamNames(): Collection {
return this.getStreams()
.map((stream: Stream) => stream.getName())
.uniq()
}
getFeedFullNames(): Collection {
return this.getFeeds()
.map((feed: Feed) => feed.getFullName())
.uniq()
}
getName(): string {
return this.name || ''
}
getId(): string {
return this.id || ''
}
getAltNames(): Collection {
return this.altNames || new Collection()
}
getLogos(): Collection {
function feed(logo: Logo): number {
if (!logo.feed) return 1
if (logo.feed.isMain) return 1
return 0
}
function format(logo: Logo): number {
const levelByFormat: Record<string, number> = {
SVG: 0,
PNG: 3,
APNG: 1,
WebP: 1,
AVIF: 1,
JPEG: 2,
GIF: 1
}
return logo.format ? levelByFormat[logo.format] : 0
}
function size(logo: Logo): number {
return Math.abs(512 - logo.width) + Math.abs(512 - logo.height)
}
return this.logos.orderBy([feed, format, size], ['desc', 'desc', 'asc'], false)
}
getLogo(): Logo | undefined {
return this.getLogos().first()
}
hasLogo(): boolean {
return this.getLogos().notEmpty()
}
getLogoUrl(): string {
const logo = this.getLogo()
if (!logo) return ''
return logo.url || ''
}
getSearchable(): ChannelSearchableData {
return {
id: this.getId(),
name: this.getName(),
altNames: this.getAltNames().all(),
guideNames: this.getGuideChannelNames().all(),
streamNames: this.getStreamNames().all(),
feedFullNames: this.getFeedFullNames().all()
}
}
}
+77
View File
@@ -0,0 +1,77 @@
import { Collection } from '@freearhey/core'
import epgGrabber from 'epg-grabber'
export class ChannelList {
channels: Collection = new Collection()
constructor(data: { channels: epgGrabber.Channel[] }) {
this.channels = new Collection(data.channels)
}
add(channel: epgGrabber.Channel): this {
this.channels.add(channel)
return this
}
get(siteId: string): epgGrabber.Channel | undefined {
return this.channels.find((channel: epgGrabber.Channel) => channel.site_id == siteId)
}
sort(): this {
this.channels = this.channels.orderBy([
(channel: epgGrabber.Channel) => channel.lang || '_',
(channel: epgGrabber.Channel) => (channel.xmltv_id ? channel.xmltv_id.toLowerCase() : '0'),
(channel: epgGrabber.Channel) => channel.site_id
])
return this
}
toString() {
function escapeString(value: string, defaultValue = '') {
if (!value) return defaultValue
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' +
'FE\\uDFFF])|(?:\\uD8BF[\\uDFFE\\uDFFF])|(?:\\uD8FF[\\uDFFE\\uDFFF])|(?:\\uD93F[\\uDFFE\\uD' +
'FFF])|(?:\\uD97F[\\uDFFE\\uDFFF])|(?:\\uD9BF[\\uDFFE\\uDFFF])|(?:\\uD9FF[\\uDFFE\\uDFFF])' +
'|(?:\\uDA3F[\\uDFFE\\uDFFF])|(?:\\uDA7F[\\uDFFE\\uDFFF])|(?:\\uDABF[\\uDFFE\\uDFFF])|(?:\\' +
'uDAFF[\\uDFFE\\uDFFF])|(?:\\uDB3F[\\uDFFE\\uDFFF])|(?:\\uDB7F[\\uDFFE\\uDFFF])|(?:\\uDBBF' +
'[\\uDFFE\\uDFFF])|(?:\\uDBFF[\\uDFFE\\uDFFF])(?:[\\0-\\t\\x0B\\f\\x0E-\\u2027\\u202A-\\uD7FF\\' +
'uE000-\\uFFFF]|[\\uD800-\\uDBFF][\\uDC00-\\uDFFF]|[\\uD800-\\uDBFF](?![\\uDC00-\\uDFFF])|' +
'(?:[^\\uD800-\\uDBFF]|^)[\\uDC00-\\uDFFF]))',
'g'
)
value = String(value || '').replace(regex, '')
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
.replace(/\n|\r/g, ' ')
.replace(/ +/g, ' ')
.trim()
}
let output = '<?xml version="1.0" encoding="UTF-8"?>\r\n<channels>\r\n'
this.channels.forEach((channel: epgGrabber.Channel) => {
const logo = channel.logo ? ` logo="${channel.logo}"` : ''
const xmltv_id = channel.xmltv_id ? escapeString(channel.xmltv_id) : ''
const lang = channel.lang || ''
const site_id = channel.site_id || ''
const site = channel.site || ''
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 += '</channels>\r\n'
return output
}
}
+124
View File
@@ -0,0 +1,124 @@
import { Collection, Dictionary } from '@freearhey/core'
import { FeedData } from '../types/feed'
import { Logo, Channel } from '.'
export class Feed {
channelId: string
channel?: Channel
id: string
name: string
isMain: boolean
broadcastAreaCodes: Collection
languageCodes: Collection
timezoneIds: Collection
videoFormat: string
guideChannels?: Collection
streams?: Collection
logos: Collection = new Collection()
constructor(data: FeedData) {
this.channelId = data.channel
this.id = data.id
this.name = data.name
this.isMain = data.is_main
this.broadcastAreaCodes = new Collection(data.broadcast_area)
this.languageCodes = new Collection(data.languages)
this.timezoneIds = new Collection(data.timezones)
this.videoFormat = data.video_format
}
withChannel(channelsKeyById: Dictionary): this {
this.channel = channelsKeyById.get(this.channelId)
return this
}
withStreams(streamsGroupedById: Dictionary): this {
this.streams = new Collection(streamsGroupedById.get(`${this.channelId}@${this.id}`))
if (this.isMain) {
this.streams = this.streams.concat(new Collection(streamsGroupedById.get(this.channelId)))
}
return this
}
withGuideChannels(guideChannelsGroupedByStreamId: Dictionary): this {
this.guideChannels = new Collection(
guideChannelsGroupedByStreamId.get(`${this.channelId}@${this.id}`)
)
if (this.isMain) {
this.guideChannels = this.guideChannels.concat(
new Collection(guideChannelsGroupedByStreamId.get(this.channelId))
)
}
return this
}
withLogos(logosGroupedByStreamId: Dictionary): this {
this.logos = new Collection(logosGroupedByStreamId.get(this.getStreamId()))
return this
}
getGuideChannels(): Collection {
if (!this.guideChannels) return new Collection()
return this.guideChannels
}
getStreams(): Collection {
if (!this.streams) return new Collection()
return this.streams
}
getFullName(): string {
if (!this.channel) return ''
return `${this.channel.name} ${this.name}`
}
getStreamId(): string {
return `${this.channelId}@${this.id}`
}
getLogos(): Collection {
function format(logo: Logo): number {
const levelByFormat: Record<string, number> = {
SVG: 0,
PNG: 3,
APNG: 1,
WebP: 1,
AVIF: 1,
JPEG: 2,
GIF: 1
}
return logo.format ? levelByFormat[logo.format] : 0
}
function size(logo: Logo): number {
return Math.abs(512 - logo.width) + Math.abs(512 - logo.height)
}
return this.logos.orderBy([format, size], ['desc', 'asc'], false)
}
getLogo(): Logo | undefined {
return this.getLogos().first()
}
hasLogo(): boolean {
return this.getLogos().notEmpty()
}
getLogoUrl(): string {
const logo = this.getLogo()
if (!logo) return ''
return logo.url || ''
}
}
+35
View File
@@ -0,0 +1,35 @@
import { Collection, DateTime } from '@freearhey/core'
import { generateXMLTV } from 'epg-grabber'
interface GuideData {
channels: Collection
programs: Collection
filepath: string
gzip: boolean
}
export class Guide {
channels: Collection
programs: Collection
filepath: string
gzip: boolean
constructor({ channels, programs, filepath, gzip }: GuideData) {
this.channels = channels
this.programs = programs
this.filepath = filepath
this.gzip = gzip || false
}
toString() {
const currDate = new DateTime(process.env.CURR_DATE || new Date().toISOString(), {
timezone: 'UTC'
})
return generateXMLTV({
channels: this.channels.all(),
programs: this.programs.all(),
date: currDate.toJSON()
})
}
}
+59
View File
@@ -0,0 +1,59 @@
import { Dictionary } from '@freearhey/core'
import epgGrabber from 'epg-grabber'
import { Feed, Channel } from '.'
export class GuideChannel {
channelId?: string
channel?: Channel
feedId?: string
feed?: Feed
xmltvId?: string
languageCode?: string
siteId?: string
logoUrl?: string
siteDomain?: string
siteName?: string
constructor(data: epgGrabber.Channel) {
const [channelId, feedId] = data.xmltv_id ? data.xmltv_id.split('@') : [undefined, undefined]
this.channelId = channelId
this.feedId = feedId
this.xmltvId = data.xmltv_id
this.languageCode = data.lang
this.siteId = data.site_id
this.logoUrl = data.logo
this.siteDomain = data.site
this.siteName = data.name
}
withChannel(channelsKeyById: Dictionary): this {
if (this.channelId) this.channel = channelsKeyById.get(this.channelId)
return this
}
withFeed(feedsKeyByStreamId: Dictionary): this {
if (this.feedId) this.feed = feedsKeyByStreamId.get(this.getStreamId())
return this
}
getStreamId(): string {
if (!this.channelId) return ''
if (!this.feedId) return this.channelId
return `${this.channelId}@${this.feedId}`
}
toJSON() {
return {
channel: this.channelId || null,
feed: this.feedId || null,
site: this.siteDomain || '',
site_id: this.siteId || '',
site_name: this.siteName || '',
lang: this.languageCode || ''
}
}
}
+9
View File
@@ -0,0 +1,9 @@
export * from './channel'
export * from './feed'
export * from './guide'
export * from './guideChannel'
export * from './issue'
export * from './logo'
export * from './site'
export * from './stream'
export * from './channelList'
+24
View File
@@ -0,0 +1,24 @@
import { Dictionary } from '@freearhey/core'
import { OWNER, REPO } from '../constants'
interface IssueProps {
number: number
labels: string[]
data: Dictionary
}
export class Issue {
number: number
labels: string[]
data: Dictionary
constructor({ number, labels, data }: IssueProps) {
this.number = number
this.labels = labels
this.data = data
}
getURL() {
return `https://github.com/${OWNER}/${REPO}/issues/${this.number}`
}
}
+41
View File
@@ -0,0 +1,41 @@
import { Collection, type Dictionary } from '@freearhey/core'
import type { LogoData } from '../types/logo'
import { type Feed } from './feed'
export class Logo {
channelId?: string
feedId?: string
feed?: Feed
tags: Collection = new Collection()
width = 0
height = 0
format?: string
url?: string
constructor(data?: LogoData) {
if (!data) return
this.channelId = data.channel
this.feedId = data.feed || undefined
this.tags = new Collection(data.tags)
this.width = data.width
this.height = data.height
this.format = data.format || undefined
this.url = data.url
}
withFeed(feedsKeyByStreamId: Dictionary): this {
if (!this.feedId) return this
this.feed = feedsKeyByStreamId.get(this.getStreamId())
return this
}
getStreamId(): string {
if (!this.channelId) return ''
if (!this.feedId) return this.channelId
return `${this.channelId}@${this.feedId}`
}
}
+63
View File
@@ -0,0 +1,63 @@
import { Collection } from '@freearhey/core'
import { Issue } from './'
enum StatusCode {
DOWN = 'down',
WARNING = 'warning',
OK = 'ok'
}
interface Status {
code: StatusCode
emoji: string
}
interface SiteProps {
domain: string
totalChannels?: number
markedChannels?: number
issues: Collection
}
export class Site {
domain: string
totalChannels: number
markedChannels: number
issues: Collection
constructor({ domain, totalChannels = 0, markedChannels = 0, issues }: SiteProps) {
this.domain = domain
this.totalChannels = totalChannels
this.markedChannels = markedChannels
this.issues = issues
}
getStatus(): Status {
const issuesWithStatusDown = this.issues.filter((issue: Issue) =>
issue.labels.find(label => label === 'status:down')
)
if (issuesWithStatusDown.notEmpty())
return {
code: StatusCode.DOWN,
emoji: '🔴'
}
const issuesWithStatusWarning = this.issues.filter((issue: Issue) =>
issue.labels.find(label => label === 'status:warning')
)
if (issuesWithStatusWarning.notEmpty())
return {
code: StatusCode.WARNING,
emoji: '🟡'
}
return {
code: StatusCode.OK,
emoji: '🟢'
}
}
getIssues(): Collection {
return this.issues.map((issue: Issue) => issue.getURL())
}
}
+58
View File
@@ -0,0 +1,58 @@
import type { StreamData } from '../types/stream'
import { Feed, Channel } from './index'
export class Stream {
name?: string
url: string
id?: string
channelId?: string
channel?: Channel
feedId?: string
feed?: Feed
filepath?: string
line?: number
label?: string
verticalResolution?: number
isInterlaced?: boolean
referrer?: string
userAgent?: string
groupTitle = 'Undefined'
removed = false
constructor(data: StreamData) {
const id = data.channel && data.feed ? [data.channel, data.feed].join('@') : data.channel
const { verticalResolution, isInterlaced } = parseQuality(data.quality)
this.id = id || undefined
this.channelId = data.channel || undefined
this.feedId = data.feed || undefined
this.name = data.name || undefined
this.url = data.url
this.referrer = data.referrer || undefined
this.userAgent = data.user_agent || undefined
this.verticalResolution = verticalResolution || undefined
this.isInterlaced = isInterlaced || undefined
this.label = data.label || undefined
}
getId(): string {
return this.id || ''
}
getName(): string {
return this.name || ''
}
}
function parseQuality(quality: string | null): {
verticalResolution: number | null
isInterlaced: boolean | null
} {
if (!quality) return { verticalResolution: null, isInterlaced: null }
const [, verticalResolutionString] = quality.match(/^(\d+)/) || [null, undefined]
const isInterlaced = /i$/i.test(quality)
let verticalResolution = 0
if (verticalResolutionString) verticalResolution = parseInt(verticalResolutionString)
return { verticalResolution, isInterlaced }
}
+16
View File
@@ -0,0 +1,16 @@
module.exports = {
site: '<DOMAIN>',
url({ channel, date }) {
return `https://example.com/api/${channel.site_id}/${date.format('YYYY-MM-DD')}`
},
parser({ content }) {
try {
return JSON.parse(content)
} catch {
return []
}
},
channels() {
return []
}
}
+21
View File
@@ -0,0 +1,21 @@
# <DOMAIN>
https://<DOMAIN>
### Download the guide
```sh
npm run grab --- --site=<DOMAIN>
```
### Update channel list
```sh
npm run channels:parse --- --config=./sites/<DOMAIN>/<DOMAIN>.config.js --output=./sites/<DOMAIN>/<DOMAIN>.channels.xml
```
### Test
```sh
npm test --- <DOMAIN>
```
+3
View File
@@ -0,0 +1,3 @@
# Sites
_TABLE_
+38
View File
@@ -0,0 +1,38 @@
const { parser, url } = require('./<DOMAIN>.config.js')
const dayjs = require('dayjs')
const utc = require('dayjs/plugin/utc')
const customParseFormat = require('dayjs/plugin/customParseFormat')
dayjs.extend(customParseFormat)
dayjs.extend(utc)
const date = dayjs.utc('2025-01-12', 'YYYY-MM-DD').startOf('d')
const channel = { site_id: 'bbc1' }
it('can generate valid url', () => {
expect(url({ channel, date })).toBe('https://example.com/api/bbc1/2025-01-12')
})
it('can parse response', () => {
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"}]'
const results = parser({ content })
expect(results.length).toBe(2)
expect(results[0]).toMatchObject({
title: 'Program 1',
start: '2025-01-12T00:00:00.000Z',
stop: '2025-01-12T00:30:00.000Z'
})
expect(results[1]).toMatchObject({
title: 'Program 2',
start: '2025-01-12T00:30:00.000Z',
stop: '2025-01-12T01:00:00.000Z'
})
})
it('can handle empty guide', () => {
const results = parser({ content: '' })
expect(results).toMatchObject([])
})
+27
View File
@@ -0,0 +1,27 @@
import { Collection } from '@freearhey/core'
export interface ChannelData {
id: string
name: string
alt_names: string[]
network: string
owners: Collection
country: string
subdivision: string
city: string
categories: Collection
is_nsfw: boolean
launched: string
closed: string
replaced_by: string
website: string
}
export interface ChannelSearchableData {
id: string
name: string
altNames: string[]
guideNames: string[]
streamNames: string[]
feedFullNames: string[]
}
+20
View File
@@ -0,0 +1,20 @@
import { Storage } from '@freearhey/core'
export interface DataLoaderProps {
storage: Storage
}
export interface DataLoaderData {
countries: object | object[]
regions: object | object[]
subdivisions: object | object[]
languages: object | object[]
categories: object | object[]
blocklist: object | object[]
channels: object | object[]
feeds: object | object[]
timezones: object | object[]
guides: object | object[]
streams: object | object[]
logos: object | object[]
}
+16
View File
@@ -0,0 +1,16 @@
import { Collection, Dictionary } from '@freearhey/core'
export interface DataProcessorData {
guideChannelsGroupedByStreamId: Dictionary
feedsGroupedByChannelId: Dictionary
logosGroupedByChannelId: Dictionary
logosGroupedByStreamId: Dictionary
feedsKeyByStreamId: Dictionary
streamsGroupedById: Dictionary
channelsKeyById: Dictionary
guideChannels: Collection
channels: Collection
streams: Collection
feeds: Collection
logos: Collection
}
+12
View File
@@ -0,0 +1,12 @@
import { Collection } from '@freearhey/core'
export interface FeedData {
channel: string
id: string
name: string
is_main: boolean
broadcast_area: Collection
languages: Collection
timezones: Collection
video_format: string
}
+8
View File
@@ -0,0 +1,8 @@
export interface GuideData {
channel: string
feed: string
site: string
site_id: string
site_name: string
lang: string
}
+1 -1
View File
@@ -1 +1 @@
declare module 'langs' declare module 'langs'
+9
View File
@@ -0,0 +1,9 @@
export interface LogoData {
channel: string
feed: string | null
tags: string[]
width: number
height: number
format: string | null
url: string
}
+10
View File
@@ -0,0 +1,10 @@
export interface StreamData {
channel: string | null
feed: string | null
name?: string
url: string
referrer: string | null
user_agent: string | null
quality: string | null
label: string | null
}
+69 -69
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()
} }
+56 -55
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([])
})
+1
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>
+1
View File
@@ -0,0 +1 @@
<!DOCTYPE html><html><head></head><body></body></html>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error
><code>NoSuchKey</code><Message>The specified key does not exist.</Message
><Key>processed/Sydney_2026-02-04.json</Key><RequestId>A8BGG30W2N9P97Z1</RequestId
><HostId
>4NlYzMSR9t2HPBctGhbCKPEBuFdVxtbuyLIOA7uTL28R6/ooqneS1TTwVa7PkTt7gr9yyopqCKk=</HostId
></Error
>
-543
View File
@@ -1,543 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<channels>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#RTV">RTV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#SHAKE">SHAKE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#SPREE">SPREE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#TENHD">TENHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#TVSN">TVSN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#VICHD">VICHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#7">7</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#7bravo">7bravo</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#7flix">7flix</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#7HD">7HD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#9">9</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#9HD">9HD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#9Life">9Life</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#9Rush">9Rush</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#10">10</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#RTV">RTV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#SHAKE">SHAKE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#SPREE">SPREE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#TENHD">TENHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#TVSN">TVSN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#VICHD">VICHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#7bravo">7bravo</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#7flix">7flix</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#9Life">9Life</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#ASPIR">ASPIR</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#PrimS">PrimS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#RTV">RTV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#SCA9">SCA9</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#SCHD">SCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#SHAKE">SHAKE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#SNR">SNR</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#TVSN">TVSN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#VICHD">VICHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#WIN">WIN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#WINHD">WINHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#9">9</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#9HD">9HD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#9Life">9Life</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#ASPIR">ASPIR</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#DDT">DDT</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#RTV">RTV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#SC">SC</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#VICHD">VICHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#7">7</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#7bravo">7bravo</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#7flix">7flix</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#7HD">7HD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#9">9</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#9HD">9HD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#9Life">9Life</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#10">10</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#10Nth">10Nth</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#NBN">NBN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#Prime">Prime</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#RTV">RTV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#SHAKE">SHAKE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#SNR">SNR</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#TVSN">TVSN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#VICHD">VICHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#9Life">9Life</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#RTV">RTV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#SC">SC</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#SCA9">SCA9</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#SCHD">SCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#SHAKE">SHAKE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#SHD7">SHD7</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#TDHD">TDHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#TDT">TDT</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#TVSN">TVSN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#VICHD">VICHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#WIN">WIN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#WINHD">WINHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#7">7</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#7bravo">7bravo</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#7flix">7flix</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#7HD">7HD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#9">9</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#9HD">9HD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#9Life">9Life</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#9Rush">9Rush</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#10">10</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#RTV">RTV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#SHAKE">SHAKE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#SPREE">SPREE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#TENHD">TENHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#TVSN">TVSN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#VICHD">VICHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#7">7</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#7bravo">7bravo</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#7flix">7flix</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#7GTS">7GTS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#9Life">9Life</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#10Nth">10Nth</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#ASPIR">ASPIR</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#Imp">Imp</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#NBN">NBN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#PrimN">PrimN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#PrimS">PrimS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#RTV">RTV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#SCA9">SCA9</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#SCHD">SCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#SNR">SNR</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#TVSN">TVSN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#VICHD">VICHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#WIN">WIN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#WINHD">WINHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#7C">7C</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#10">10</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#Imp">Imp</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#VICHD">VICHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#7">7</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#7bravo">7bravo</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#7flix">7flix</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#7HD">7HD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#9">9</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#9HD">9HD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#9Life">9Life</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#9Rush">9Rush</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#10">10</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#RTV">RTV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#SHAKE">SHAKE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#SPREE">SPREE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#TENHD">TENHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#TVSN">TVSN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#VICHD">VICHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#7bravo">7bravo</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#7C">7C</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#7flix">7flix</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#7QLD">7QLD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#7TWOQ">7TWOQ</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#9Life">9Life</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#10">10</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#ASPIR">ASPIR</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#Imp">Imp</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#RTV">RTV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#SCA9">SCA9</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#SCHD">SCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#SHAKE">SHAKE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#SNR">SNR</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#TVSN">TVSN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#VICHD">VICHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#WIN">WIN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#WINHD">WINHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#7C">7C</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#7GTS">7GTS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#9Life">9Life</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#10">10</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#ASPIR">ASPIR</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#Imp">Imp</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#RTV">RTV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#SC9">SC9</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#SNR">SNR</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#VICHD">VICHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#WIN">WIN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#WIN9">WIN9</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#WIN10">WIN10</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#WINHD">WINHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#9Life">9Life</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#ASPIR">ASPIR</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#RTV">RTV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#SC">SC</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#SCA9">SCA9</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#SCHD">SCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#SHAKE">SHAKE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#SHD7">SHD7</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#TDHD">TDHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#TDT">TDT</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#TVSN">TVSN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#VICHD">VICHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#WIN">WIN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Tasmania#WINHD">WINHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#7">7</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#7bravo">7bravo</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#7flix">7flix</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#9Life">9Life</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#10">10</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#RTV">RTV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#SCA9">SCA9</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#SCHD">SCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#SHAKE">SHAKE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#TVSN">TVSN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#VICHD">VICHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#WIN">WIN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Townsville#WINHD">WINHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#7bravo">7bravo</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#7flix">7flix</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#9Life">9Life</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#10">10</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#ASPIR">ASPIR</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#Prime">Prime</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#RTV">RTV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#SCA9">SCA9</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#SCHD">SCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#SHAKE">SHAKE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#SNR">SNR</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#TVSN">TVSN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#VICHD">VICHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#WIN">WIN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#WINHD">WINHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Victoria#WinSA">WinSA</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Western Australia#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Western Australia#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Western Australia#9Life">9Life</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Western Australia#10">10</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Western Australia#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Western Australia#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Western Australia#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Western Australia#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Western Australia#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Western Australia#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Western Australia#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Western Australia#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Western Australia#GWN">GWN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Western Australia#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Western Australia#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Western Australia#RTV">RTV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Western Australia#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Western Australia#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Western Australia#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Western Australia#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Western Australia#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Western Australia#VICHD">VICHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Western Australia#WDT">WDT</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Western Australia#WIN">WIN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Western Australia#WINHD">WINHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="7bravoAdelaide.au" site_id="Adelaide#7bravo">7bravo</channel>
<channel site="abc.net.au" lang="en" xmltv_id="7bravoSydney.au" site_id="Sydney#7bravo">7bravo</channel>
<channel site="abc.net.au" lang="en" xmltv_id="7flixAdelaide.au" site_id="Adelaide#7flix">7flix</channel>
<channel site="abc.net.au" lang="en" xmltv_id="7flixSydney.au" site_id="Sydney#7flix">7flix</channel>
<channel site="abc.net.au" lang="en" xmltv_id="7HDAdelaide.au" site_id="Adelaide#7HD">7HD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="7mateAdelaide.au" site_id="Adelaide#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="7mateSydney.au" site_id="Sydney#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="7twoAdelaide.au" site_id="Adelaide#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="7twoSydney.au" site_id="Sydney#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="9GemSydney.au" site_id="Sydney#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="9GoSydney.au" site_id="Sydney#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="9HDAdelaide.au" site_id="Adelaide#9HD">9HD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="9LifeAdelaide.au" site_id="Adelaide#9Life">9Life</channel>
<channel site="abc.net.au" lang="en" xmltv_id="9LifeSydney.au" site_id="Sydney#9Life">9Life</channel>
<channel site="abc.net.au" lang="en" xmltv_id="9RushAdelaide.au" site_id="Adelaide#9Rush">9Rush</channel>
<channel site="abc.net.au" lang="en" xmltv_id="9RushSydney.au" site_id="Sydney#9Rush">9Rush</channel>
<channel site="abc.net.au" lang="en" xmltv_id="10BoldSydney.au" site_id="Sydney#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="10PeachSydney.au" site_id="Sydney#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="10ShakeSydney.au" site_id="Sydney#SHAKE">SHAKE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="ABCAustralia.au" site_id="Sydney#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="ABCKidsSydney.au" site_id="Sydney#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="ABCMESydney.au" site_id="Sydney#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="ABCTVNSW.au" site_id="New South Wales#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="ABCTVNT.au" site_id="Northern Territory#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="ABCTVPlusSydney.au" site_id="Sydney#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="ABCTVQLD.au" site_id="Queensland#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="ABCTVSA.au" site_id="South Australia#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="ABCTVSydney.au" site_id="Sydney#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="ABCTVSydney.au" site_id="Sydney#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="ABCTVTAS.au" site_id="Tasmania#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="ABCTVVIC.au" site_id="Victoria#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="ABCTVWA.au" site_id="Western Australia#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="C31Melbourne.au" site_id="Melbourne#31">31</channel>
<channel site="abc.net.au" lang="en" xmltv_id="Channel7Adelaide.au" site_id="Adelaide#7">7</channel>
<channel site="abc.net.au" lang="en" xmltv_id="Channel7Sydney.au" site_id="Sydney#7">7</channel>
<channel site="abc.net.au" lang="en" xmltv_id="Channel7Sydney.au" site_id="Sydney#7HD">7HD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="Channel9Adelaide.au" site_id="Adelaide#9">9</channel>
<channel site="abc.net.au" lang="en" xmltv_id="Channel9Sydney.au" site_id="Sydney#9">9</channel>
<channel site="abc.net.au" lang="en" xmltv_id="Channel9Sydney.au" site_id="Sydney#9HD">9HD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="10Adelaide.au" site_id="Adelaide#10">10</channel>
<channel site="abc.net.au" lang="en" xmltv_id="10Sydney.au" site_id="Sydney#10">10</channel>
<channel site="abc.net.au" lang="en" xmltv_id="10Sydney.au" site_id="Sydney#TENHD">TENHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="Channel44.au" site_id="Adelaide#44">44</channel>
<channel site="abc.net.au" lang="en" xmltv_id="NITVAdelaide.au" site_id="Adelaide#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="NITVSydney.au" site_id="Sydney#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="Racingcom.au" site_id="Sydney#RTV">RTV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="SBSAdelaide.au" site_id="Adelaide#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="SBSFoodSydney.au" site_id="Sydney#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="SBSSydney.au" site_id="Sydney#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="SBSSydney.au" site_id="Sydney#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="SBSVicelandSydney.au" site_id="Sydney#VICHD">VICHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="SBSWorldMoviesSydney.au" site_id="Sydney#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="SBSWorldWatchSydney.au" site_id="Sydney#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="SpreeTV.au" site_id="Sydney#SPREE">SPREE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="TVSN.au" site_id="Sydney#TVSN">TVSN</channel>
</channels>
+122 -121
View File
@@ -1,121 +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://epg.abctv.net.au/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() { async channels({ region = 'syd' }) {
const now = dayjs() const now = dayjs()
const regions = [ const regions = {
'Sydney', syd: 'Sydney',
'Melbourne', mel: 'Melbourne',
'Brisbane', bri: 'Brisbane',
'GoldCoast', gc: 'GoldCoast',
'Perth', per: 'Perth',
'Adelaide', adl: 'Adelaide',
'Hobart', hbr: 'Hobart',
'Darwin', drw: 'Darwin',
'Canberra', cbr: 'Canberra',
'New South Wales', nsw: 'New South Wales',
'Victoria', vic: 'Victoria',
'Townsville', tsv: 'Townsville',
'Queensland', qld: 'Queensland',
'Western Australia', wa: 'Western Australia',
'South Australia', sa: 'South Australia',
'Tasmania', tas: 'Tasmania',
'Northern Territory' nt: 'Northern Territory'
] }
let channels = [] let channels = []
for (let region of regions) { const regionName = regions[region]
const data = await axios const data = await axios
.get(`https://epg.abctv.net.au/processed/${region}_${now.format('YYYY-MM-DD')}.json`) .get(
.then(r => r.data) `https://cdn.iview.abc.net.au/epg/processed/${regionName}_${now.format('YYYY-MM-DD')}.json`
.catch(console.log) )
.then(r => r.data)
for (let item of data.schedule) { .catch(console.log)
channels.push({
lang: 'en', for (let item of data.schedule) {
site_id: `${region}#${item.channel}`, channels.push({
name: item.channel lang: 'en',
}) site_id: `${regionName}#${item.channel}`,
} name: item.channel
} })
}
return channels
} return channels
} }
}
function parseItems(content, channel) {
try { function parseItems(content, channel) {
const data = JSON.parse(content) try {
if (!data) return [] const data = JSON.parse(content)
if (!Array.isArray(data.schedule)) return [] if (!data) return []
if (!Array.isArray(data.schedule)) return []
const [, channelId] = channel.site_id.split('#')
const channelData = data.schedule.find(i => i.channel == channelId) const [, channelId] = channel.site_id.split('#')
return channelData.listing && Array.isArray(channelData.listing) ? channelData.listing : [] const channelData = data.schedule.find(i => i.channel == channelId)
} catch (err) { return channelData.listing && Array.isArray(channelData.listing) ? channelData.listing : []
return [] } catch {
} return []
} }
}
function parseSeason(item) {
return item.series_num || null function parseSeason(item) {
} return item.series_num || null
function parseEpisode(item) { }
return item.episode_num || null function parseEpisode(item) {
} return item.episode_num || null
function parseTime(time) { }
return dayjs.tz(time, 'YYYY-MM-DD HH:mm', 'Australia/Sydney') function parseTime(time) {
} return dayjs.tz(time, 'YYYY-MM-DD HH:mm', 'Australia/Sydney')
function parseImage(item) { }
return item.image_file function parseImage(item) {
? `https://www.abc.net.au/tv/common/images/publicity/${item.image_file}` return item.image_file
: null ? `https://www.abc.net.au/tv/common/images/publicity/${item.image_file}`
} : null
function parseRating(item) { }
return item.rating function parseRating(item) {
? { return item.rating
system: 'ACB', ? {
value: item.rating system: 'ACB',
} value: item.rating
: null }
} : null
}
+51 -54
View File
@@ -1,54 +1,51 @@
const { parser, url } = require('./abc.net.au.config.js') const { parser, url } = require('./abc.net.au.config.js')
const dayjs = require('dayjs') const fs = require('fs')
const utc = require('dayjs/plugin/utc') const path = require('path')
dayjs.extend(utc) const dayjs = require('dayjs')
const utc = require('dayjs/plugin/utc')
const date = dayjs.utc('2022-12-22', 'YYYY-MM-DD').startOf('d') dayjs.extend(utc)
const channel = {
site_id: 'Sydney#ABC1', const date = dayjs.utc('2025-02-04', 'YYYY-MM-DD').startOf('d')
xmltv_id: 'ABCTV.au' const channel = { site_id: 'Sydney#ABC1' }
}
it('can generate valid url', () => { it('can generate valid url', () => {
expect(url({ date, channel })).toBe('https://epg.abctv.net.au/processed/Sydney_2022-12-22.json') expect(url({ date, channel })).toBe(
}) 'https://cdn.iview.abc.net.au/epg/processed/Sydney_2025-02-04.json'
)
it('can parse response', () => { })
const content =
'{"date":"2022-12-22","region":"Sydney","schedule":[{"channel":"ABC1","listing":[{"consumer_advice":"Adult Themes, Drug Use, Violence","rating":"M","show_id":912747,"repeat":true,"description":"When tragedy strikes close to home, it puts head teacher Noah Taylor on a collision course with the criminals responsible. Can the Lyell team help him stop the cycle of violence?","title":"Silent Witness","crid":"ZW2178A004S00","start_time":"2022-12-22T00:46:00","series-crid":"ZW2178A","live":false,"captioning":true,"show_type":"Episode","series_num":22,"episode_title":"Lift Up Your Hearts (part Two)","length":58,"onair_title":"Silent Witness","end_time":"2022-12-22T01:44:00","genres":["Entertainment"],"image_file":"ZW2178A004S00_460.jpg","prog_slug":"silent-witness","episode_num":4}]}]}' it('can parse response', () => {
const content = fs.readFileSync(path.resolve(__dirname, '__data__/content.json'))
const result = 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(result).toMatchObject([ expect(results.length).toBe(30)
{ expect(results[0]).toMatchObject({
title: 'Silent Witness', title: "Julia Zemiro's Home Delivery",
sub_title: 'Lift Up Your Hearts (part Two)', sub_title: 'Maggie Beer',
description: description:
'When tragedy strikes close to home, it puts head teacher Noah Taylor on a collision course with the criminals responsible. Can the Lyell team help him stop the cycle of violence?', "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'], category: ['Entertainment', 'Factual'],
rating: { rating: {
system: 'ACB', system: 'ACB',
value: 'M' value: 'G'
}, },
season: 22, season: null,
episode: 4, episode: null,
image: 'https://www.abc.net.au/tv/common/images/publicity/ZW2178A004S00_460.jpg', image: 'https://www.abc.net.au/tv/common/images/publicity/LE1761H002S00_460.jpg',
start: '2022-12-21T13:46:00.000Z', start: '2025-02-03T12:40:00.000Z',
stop: '2022-12-21T14:44:00.000Z' stop: '2025-02-03T13:09:00.000Z'
} })
]) })
})
it('can handle empty guide', () => {
it('can handle empty guide', () => { const results = parser({
const result = parser( content: fs.readFileSync(path.resolve(__dirname, '__data__/no_content.html')),
{ channel
content: })
'<Error><Code>NoSuchKey</Code><Message>The specified key does not exist.</Message><Key>processed/Sydney_2023-01-17.json</Key><RequestId>6MRHX5TJ12X39B3Y</RequestId><HostId>59rH6XRMrmkFywg8Kv58iqpI6O1fuOCuEbKa1HRRYa4buByXMBTvAhz8zuAK7X5D+ZN9ZuWxyGs=</HostId></Error>'
}, expect(results).toMatchObject([])
channel })
)
expect(result).toMatchObject([])
})
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<channels>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#7">7</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#7bravo">7bravo</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#7flix">7flix</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#7HD">7HD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#9">9</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#9HD">9HD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#9Life">9Life</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#9Rush">9Rush</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#10">10</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#44">44</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#RTV">RTV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#SHAKE">SHAKE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#SPREE">SPREE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#TENHD">TENHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#TVSN">TVSN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#VICHD">VICHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Adelaide#YOUTV">YOUTV</channel>
</channels>
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<channels>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#7">7</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#7bravo">7bravo</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#7flix">7flix</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#7HD">7HD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#9">9</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#9HD">9HD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#9Life">9Life</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#9Rush">9Rush</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#10">10</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#RTV">RTV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#SHAKE">SHAKE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#SPREE">SPREE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#TENHD">TENHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#TVSN">TVSN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#VICHD">VICHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Brisbane#YOUTV">YOUTV</channel>
</channels>
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<channels>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#7bravo">7bravo</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#7flix">7flix</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#9Life">9Life</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#ASPIR">ASPIR</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#PrimS">PrimS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#RTV">RTV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#SCA9">SCA9</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#SCHD">SCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#SHAKE">SHAKE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#SNR">SNR</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#TVSN">TVSN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#VICHD">VICHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#WIN">WIN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Canberra#WINHD">WINHD</channel>
</channels>
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<channels>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#9">9</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#9HD">9HD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#9Life">9Life</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#ASPIR">ASPIR</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#DDT">DDT</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#RTV">RTV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#SC">SC</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Darwin#VICHD">VICHD</channel>
</channels>
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<channels>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#7bravo">7bravo</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#7flix">7flix</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#9">9</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#9HD">9HD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#9Life">9Life</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#10">10</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#10Nth">10Nth</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#NBN">NBN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#Prime">Prime</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#RTV">RTV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#SHAKE">SHAKE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#SNR">SNR</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#TVSN">TVSN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#VICHD">VICHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="GoldCoast#YOUTV">YOUTV</channel>
</channels>
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<channels>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#9Life">9Life</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#RTV">RTV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#SC">SC</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#SCA9">SCA9</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#SCHD">SCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#SHAKE">SHAKE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#SHD7">SHD7</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#TDHD">TDHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#TDT">TDT</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#TVSN">TVSN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#VICHD">VICHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#WIN">WIN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#WINHD">WINHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Hobart#YOUTV">YOUTV</channel>
</channels>
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<channels>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#7">7</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#7bravo">7bravo</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#7flix">7flix</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#7HD">7HD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#9">9</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#9HD">9HD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#9Life">9Life</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#9Rush">9Rush</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#10">10</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#31">31</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#RTV">RTV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#SHAKE">SHAKE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#SPREE">SPREE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#TENHD">TENHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#TVSN">TVSN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#VICHD">VICHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Melbourne#YOUTV">YOUTV</channel>
</channels>
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<channels>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#7">7</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#7bravo">7bravo</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#7flix">7flix</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#7GTS">7GTS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#9Life">9Life</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#10Nth">10Nth</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#ASPIR">ASPIR</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#Imp">Imp</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#NBN">NBN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#PrimN">PrimN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#PrimS">PrimS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#RTV">RTV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#SCA9">SCA9</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#SCHD">SCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#SNR">SNR</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#TVSN">TVSN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#VICHD">VICHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#WIN">WIN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#WINHD">WINHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="New South Wales#YOUTV">YOUTV</channel>
</channels>
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<channels>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#7C">7C</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#10">10</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#Imp">Imp</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Northern Territory#VICHD">VICHD</channel>
</channels>
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<channels>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#7">7</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#7bravo">7bravo</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#7flix">7flix</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#7HD">7HD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#9">9</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#9HD">9HD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#9Life">9Life</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#9Rush">9Rush</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#10">10</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#RTV">RTV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#SHAKE">SHAKE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#SPREE">SPREE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#TENHD">TENHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#TVSN">TVSN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#VICHD">VICHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Perth#YOUTV">YOUTV</channel>
</channels>
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<channels>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#7bravo">7bravo</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#7C">7C</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#7flix">7flix</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#7QLD">7QLD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#7TWOQ">7TWOQ</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#9Life">9Life</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#10">10</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#ASPIR">ASPIR</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#Imp">Imp</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#RTV">RTV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#SCA9">SCA9</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#SCHD">SCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#SHAKE">SHAKE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#SNR">SNR</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#TVSN">TVSN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#VICHD">VICHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#WIN">WIN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Queensland#WINHD">WINHD</channel>
</channels>
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<channels>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#7C">7C</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#7GTS">7GTS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#9Life">9Life</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#10">10</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#ASPIR">ASPIR</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#Imp">Imp</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#RTV">RTV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#SC9">SC9</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#SNR">SNR</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#VICHD">VICHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#WIN">WIN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#WIN9">WIN9</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#WIN10">WIN10</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="South Australia#WINHD">WINHD</channel>
</channels>
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<channels>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#7">7</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#7bravo">7bravo</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#7flix">7flix</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#7HD">7HD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#7MATE">7MATE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#7TWO">7TWO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#9">9</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#9HD">9HD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#9Life">9Life</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#9Rush">9Rush</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#10">10</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#11">11</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#ABC1">ABC1</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#ABC2">ABC2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#ABC3">ABC3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#ABC4KIDS">ABC4KIDS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#ABCHD">ABCHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#ABCN">ABCN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#GEM">GEM</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#GO">GO</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#NITV">NITV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#ONE">ONE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#RTV">RTV</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#SBS">SBS</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#SBS2">SBS2</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#SBS3">SBS3</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#SBSHD">SBSHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#SBSWW">SBSWW</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#SHAKE">SHAKE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#SPREE">SPREE</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#TENHD">TENHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#TVSN">TVSN</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#VICHD">VICHD</channel>
<channel site="abc.net.au" lang="en" xmltv_id="" site_id="Sydney#YOUTV">YOUTV</channel>
</channels>

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