From 4b9d92289cbcdc919b10882d692b0482ee3fed47 Mon Sep 17 00:00:00 2001
From: freearhey <7253922+freearhey@users.noreply.github.com>
Date: Sun, 3 May 2026 21:36:26 +0300
Subject: [PATCH 01/16] Update tsconfig.json
---
tsconfig.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tsconfig.json b/tsconfig.json
index 9e9476b15..4fc2a0295 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -13,6 +13,6 @@
"allowJs": true,
"outDir": "./dist"
},
- "include": ["scripts/**/*", "src/**/*"],
+ "include": ["scripts/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}
\ No newline at end of file
From 42426d6f71cacb0aeae7f9368da8c071fd15d0ec Mon Sep 17 00:00:00 2001
From: freearhey <7253922+freearhey@users.noreply.github.com>
Date: Sun, 3 May 2026 21:36:43 +0300
Subject: [PATCH 02/16] Update tests/__data__
---
.../epg_grab/{gzip.guide.xml => gzip/guide.xml} | 0
.../{gzip.guide.xml.gz => gzip/guide.xml.gz} | Bin
tests/__data__/expected/epg_grab/json/guide.json | 1 +
tests/__data__/expected/epg_grab/json/guide.xml | 8 ++++++++
tests/__data__/expected/guides_update/GUIDES.md | 4 ++--
tests/__data__/input/guides_update/worker.json | 6 +++++-
6 files changed, 16 insertions(+), 3 deletions(-)
rename tests/__data__/expected/epg_grab/{gzip.guide.xml => gzip/guide.xml} (100%)
rename tests/__data__/expected/epg_grab/{gzip.guide.xml.gz => gzip/guide.xml.gz} (100%)
create mode 100644 tests/__data__/expected/epg_grab/json/guide.json
create mode 100644 tests/__data__/expected/epg_grab/json/guide.xml
diff --git a/tests/__data__/expected/epg_grab/gzip.guide.xml b/tests/__data__/expected/epg_grab/gzip/guide.xml
similarity index 100%
rename from tests/__data__/expected/epg_grab/gzip.guide.xml
rename to tests/__data__/expected/epg_grab/gzip/guide.xml
diff --git a/tests/__data__/expected/epg_grab/gzip.guide.xml.gz b/tests/__data__/expected/epg_grab/gzip/guide.xml.gz
similarity index 100%
rename from tests/__data__/expected/epg_grab/gzip.guide.xml.gz
rename to tests/__data__/expected/epg_grab/gzip/guide.xml.gz
diff --git a/tests/__data__/expected/epg_grab/json/guide.json b/tests/__data__/expected/epg_grab/json/guide.json
new file mode 100644
index 000000000..3f632b054
--- /dev/null
+++ b/tests/__data__/expected/epg_grab/json/guide.json
@@ -0,0 +1 @@
+{"channels":[{"xmltv_id":"Channel3.us","name":"Channel 3","site":"example2.com","site_id":"150","lang":"en","logo":"https://upload.wikimedia.org/wikipedia/commons/6/64/6%27eren_2015.png","url":"https://example2.com","lcn":null,"index":0},{"xmltv_id":"Channel4.us@HD","name":"Channel 4","site":"example2.com","site_id":"152","lang":"en","logo":"https://i.imgur.com/BPzH88J.png","url":"https://example2.com","lcn":null,"index":1},{"xmltv_id":"Channel1.us","name":"Channel 1","site":"example2.com","site_id":"140","lang":"fr","logo":"https://i.imgur.com/GPzH88J.png","url":"https://example2.com","lcn":null,"index":2}],"programs":[{"site":"example2.com","start":1666240800000,"stop":1666249800000,"channel":"Channel1.us","titles":[{"value":"Programme1 (example2.com)","lang":"fr"}],"subTitles":[],"descriptions":[],"date":0,"categories":[],"keywords":[],"languages":[],"origLanguages":[],"length":[],"urls":[],"countries":[],"episodeNumbers":[],"video":{"present":"","colour":"","aspect":"","quality":""},"audio":{"present":"","stereo":""},"previouslyShown":[],"premiere":[],"lastChance":[],"new":false,"subtitles":[],"ratings":[],"starRatings":[],"reviews":[],"directors":[],"actors":[],"writers":[],"adapters":[],"producers":[],"composers":[],"editors":[],"presenters":[],"commentators":[],"guests":[],"images":[],"icons":[]},{"site":"example2.com","start":1666240260000,"stop":1666249800000,"channel":"Channel3.us","titles":[{"value":"Program1 (example2.com)","lang":"en"}],"subTitles":[],"descriptions":[],"date":0,"categories":[],"keywords":[],"languages":[],"origLanguages":[],"length":[],"urls":[],"countries":[],"episodeNumbers":[],"video":{"present":"","colour":"","aspect":"","quality":""},"audio":{"present":"","stereo":""},"previouslyShown":[],"premiere":[],"lastChance":[],"new":false,"subtitles":[],"ratings":[],"starRatings":[],"reviews":[],"directors":[],"actors":[],"writers":[],"adapters":[],"producers":[],"composers":[],"editors":[],"presenters":[],"commentators":[],"guests":[],"images":[],"icons":[]},{"site":"example2.com","start":1666240260000,"stop":1666249800000,"channel":"Channel4.us@HD","titles":[{"value":"Program1 (example2.com)","lang":"en"}],"subTitles":[],"descriptions":[],"date":0,"categories":[],"keywords":[],"languages":[],"origLanguages":[],"length":[],"urls":[],"countries":[],"episodeNumbers":[],"video":{"present":"","colour":"","aspect":"","quality":""},"audio":{"present":"","stereo":""},"previouslyShown":[],"premiere":[],"lastChance":[],"new":false,"subtitles":[],"ratings":[],"starRatings":[],"reviews":[],"directors":[],"actors":[],"writers":[],"adapters":[],"producers":[],"composers":[],"editors":[],"presenters":[],"commentators":[],"guests":[],"images":[],"icons":[]}]}
\ No newline at end of file
diff --git a/tests/__data__/expected/epg_grab/json/guide.xml b/tests/__data__/expected/epg_grab/json/guide.xml
new file mode 100644
index 000000000..67968c1bd
--- /dev/null
+++ b/tests/__data__/expected/epg_grab/json/guide.xml
@@ -0,0 +1,8 @@
+
+Channel 3https://example2.com
+Channel 4https://example2.com
+Channel 1https://example2.com
+Programme1 (example2.com)
+Program1 (example2.com)
+Program1 (example2.com)
+
\ No newline at end of file
diff --git a/tests/__data__/expected/guides_update/GUIDES.md b/tests/__data__/expected/guides_update/GUIDES.md
index f6e86cfc8..2abafcce9 100644
--- a/tests/__data__/expected/guides_update/GUIDES.md
+++ b/tests/__data__/expected/guides_update/GUIDES.md
@@ -5,8 +5,8 @@
| Host | Status | Channels | Last Updated | Links |
- | example.com | 🔴 | 0 | - | |
- | worker-9dd4.onrender.com | 🟢 | 1 | a day ago | channels.xml guide.xml |
+ | example.com | 🔴 | 0 | - | - |
+ | worker-9dd4.onrender.com | 🟢 | 1 | a day ago | XML | GZIP | JSON |
diff --git a/tests/__data__/input/guides_update/worker.json b/tests/__data__/input/guides_update/worker.json
index 371feec07..35ceb014e 100644
--- a/tests/__data__/input/guides_update/worker.json
+++ b/tests/__data__/input/guides_update/worker.json
@@ -1,4 +1,8 @@
{
"channels": "channels.xml",
- "guide": "guide.xml"
+ "guide": {
+ "xml": "guide.xml",
+ "gzip": "guide.xml.gz",
+ "json": "guide.json"
+ }
}
\ No newline at end of file
From 56ccf2ac1b53d88b110d153ac3aed5dd746c2231 Mon Sep 17 00:00:00 2001
From: freearhey <7253922+freearhey@users.noreply.github.com>
Date: Sun, 3 May 2026 21:37:06 +0300
Subject: [PATCH 03/16] Update grab.test.ts
---
tests/commands/epg/grab.test.ts | 77 +++++++++++++++++++++++++++++----
1 file changed, 68 insertions(+), 9 deletions(-)
diff --git a/tests/commands/epg/grab.test.ts b/tests/commands/epg/grab.test.ts
index b2a965c3b..743cb0fb7 100644
--- a/tests/commands/epg/grab.test.ts
+++ b/tests/commands/epg/grab.test.ts
@@ -90,21 +90,20 @@ describe('epg:grab', () => {
it('can grab epg with gzip option enabled', () => {
const cmd = `${ENV_VAR} npm run grab --- --channels=tests/__data__/input/epg_grab/sites/example2.com/example2.com.channels.xml --output="${path.resolve(
- 'tests/__data__/output/guides/gzip.guide.xml'
- )}" --gzip `
+ 'tests/__data__/output/guides/guide.xml'
+ )}" --gzip`
const stdout = execSync(cmd, { encoding: 'utf8' })
if (process.env.DEBUG === 'true') console.log(cmd, stdout)
- expect(content('tests/__data__/output/guides/gzip.guide.xml')).toEqual(
- content('tests/__data__/expected/epg_grab/gzip.guide.xml')
+ expect(content('tests/__data__/output/guides/guide.xml')).toEqual(
+ content('tests/__data__/expected/epg_grab/gzip/guide.xml')
)
- const outputString = pako.ungzip(
- fs.readFileSync('tests/__data__/output/guides/gzip.guide.xml.gz'),
- { to: 'string' }
- )
+ const outputString = pako.ungzip(fs.readFileSync('tests/__data__/output/guides/guide.xml.gz'), {
+ to: 'string'
+ })
const expectedString = pako.ungzip(
- fs.readFileSync('tests/__data__/expected/epg_grab/gzip.guide.xml.gz'),
+ fs.readFileSync('tests/__data__/expected/epg_grab/gzip/guide.xml.gz'),
{ to: 'string' }
)
@@ -113,6 +112,66 @@ describe('epg:grab', () => {
expect(output).toEqual(expected)
})
+
+ it('can grab epg with gzip option enabled', () => {
+ const cmd = `${ENV_VAR} npm run grab --- --channels=tests/__data__/input/epg_grab/sites/example2.com/example2.com.channels.xml --output="${path.resolve(
+ 'tests/__data__/output/guides/guide.xml'
+ )}" --gzip="${path.resolve('tests/__data__/output/guides/custom.xml.gz')}"`
+ const stdout = execSync(cmd, { encoding: 'utf8' })
+ if (process.env.DEBUG === 'true') console.log(cmd, stdout)
+
+ expect(content('tests/__data__/output/guides/guide.xml')).toEqual(
+ content('tests/__data__/expected/epg_grab/gzip/guide.xml')
+ )
+
+ const outputString = pako.ungzip(
+ fs.readFileSync('tests/__data__/output/guides/custom.xml.gz'),
+ {
+ to: 'string'
+ }
+ )
+ const expectedString = pako.ungzip(
+ fs.readFileSync('tests/__data__/expected/epg_grab/gzip/guide.xml.gz'),
+ { to: 'string' }
+ )
+
+ const output = new Set(outputString.split('\r\n'))
+ const expected = new Set(expectedString.split('\r\n'))
+
+ expect(output).toEqual(expected)
+ })
+
+ it('can grab epg with json option enabled', () => {
+ const cmd = `${ENV_VAR} npm run grab --- --channels=tests/__data__/input/epg_grab/sites/example2.com/example2.com.channels.xml --output="${path.resolve(
+ 'tests/__data__/output/guides/guide.xml'
+ )}" --json`
+ const stdout = execSync(cmd, { encoding: 'utf8' })
+ if (process.env.DEBUG === 'true') console.log(cmd, stdout)
+
+ expect(content('tests/__data__/output/guides/guide.xml')).toEqual(
+ content('tests/__data__/expected/epg_grab/json/guide.xml')
+ )
+
+ expect(content('tests/__data__/output/guides/guide.json')).toEqual(
+ content('tests/__data__/expected/epg_grab/json/guide.json')
+ )
+ })
+
+ it('can grab epg with json option enabled', () => {
+ const cmd = `${ENV_VAR} npm run grab --- --channels=tests/__data__/input/epg_grab/sites/example2.com/example2.com.channels.xml --output="${path.resolve(
+ 'tests/__data__/output/guides/guide.xml'
+ )}" --json="${path.resolve('tests/__data__/output/guides/custom.json')}"`
+ const stdout = execSync(cmd, { encoding: 'utf8' })
+ if (process.env.DEBUG === 'true') console.log(cmd, stdout)
+
+ expect(content('tests/__data__/output/guides/guide.xml')).toEqual(
+ content('tests/__data__/expected/epg_grab/json/guide.xml')
+ )
+
+ expect(content('tests/__data__/output/guides/custom.json')).toEqual(
+ content('tests/__data__/expected/epg_grab/json/guide.json')
+ )
+ })
})
function content(filepath: string) {
From 56a228e9cb97b23b03e7f60aced96c9417b47a5c Mon Sep 17 00:00:00 2001
From: freearhey <7253922+freearhey@users.noreply.github.com>
Date: Sun, 3 May 2026 21:37:49 +0300
Subject: [PATCH 04/16] Update scripts
---
scripts/commands/epg/grab.ts | 15 ++++++++--
scripts/commands/guides/update.ts | 19 +++++++------
scripts/default.config.js | 1 +
scripts/models/guide.ts | 20 +++++++++++--
scripts/models/worker.ts | 47 ++++++++++++++++++++++++++++---
5 files changed, 84 insertions(+), 18 deletions(-)
diff --git a/scripts/commands/epg/grab.ts b/scripts/commands/epg/grab.ts
index 2dabb6b53..eb9ee55e3 100644
--- a/scripts/commands/epg/grab.ts
+++ b/scripts/commands/epg/grab.ts
@@ -54,7 +54,10 @@ program
.argParser(parseNumber)
.env('MAX_CONNECTIONS')
)
- .addOption(new Option('--gzip', 'Create a compressed version of the guide as well').env('GZIP'))
+ .addOption(
+ new Option('--gzip [path]', 'Create a compressed version of the guide as well').env('GZIP')
+ )
+ .addOption(new Option('--json [path]', 'Create a JSON version of the guide as well').env('JSON'))
.addOption(new Option('--curl', 'Display each request as CURL').env('CURL'))
.addOption(new Option('--debug', 'Enable debug mode').env('DEBUG'))
.parse()
@@ -63,7 +66,8 @@ interface GrabOptions {
sites?: string[]
channels?: string
output?: string
- gzip?: boolean
+ gzip?: boolean | string
+ json?: boolean | string
curl?: boolean
debug?: boolean
maxConnections?: number
@@ -109,7 +113,10 @@ async function main() {
if (typeof options.maxConnections === 'number')
globalConfig.maxConnections = options.maxConnections
if (typeof options.curl === 'boolean') globalConfig.curl = options.curl
- if (typeof options.gzip === 'boolean') globalConfig.gzip = options.gzip
+ if (typeof options.gzip === 'boolean' || typeof options.gzip === 'string')
+ globalConfig.gzip = options.gzip
+ if (typeof options.json === 'boolean' || typeof options.json === 'string')
+ globalConfig.json = options.json
if (typeof options.debug === 'boolean') globalConfig.debug = options.debug
logger.debug(`config: ${JSON.stringify(globalConfig, null, 2)}`)
@@ -298,6 +305,7 @@ async function main() {
})
const gzip = globalConfig.gzip || defaultConfig.gzip
+ const json = globalConfig.json || defaultConfig.json
for (const groupKey of channelsGroupedByKey.keys()) {
const groupChannels = new Collection(channelsGroupedByKey.get(groupKey))
@@ -305,6 +313,7 @@ async function main() {
const guide = new Guide({
filepath: groupKey,
gzip,
+ json,
channels: groupChannels,
programs: groupPrograms
})
diff --git a/scripts/commands/guides/update.ts b/scripts/commands/guides/update.ts
index f43b96b6c..f1e830dde 100644
--- a/scripts/commands/guides/update.ts
+++ b/scripts/commands/guides/update.ts
@@ -61,7 +61,10 @@ async function main() {
}
worker.channelsPath = workerJson.channels
- worker.guidePath = workerJson.guide
+ worker.guideXmlPath =
+ typeof workerJson.guide === 'string' ? workerJson.guide : workerJson?.guide?.xml
+ worker.guideGzipPath = workerJson?.guide?.gzip
+ worker.guideJsonPath = workerJson?.guide?.json
if (!worker.channelsPath) {
worker.status = 'MISSING_CHANNELS_PATH'
@@ -69,8 +72,8 @@ async function main() {
continue
}
- if (!worker.guidePath) {
- worker.status = 'MISSING_GUIDE_PATH'
+ if (!worker.guideXmlPath) {
+ worker.status = 'MISSING_GUIDE_XML_PATH'
logger.error('The "guide" property is missing from the workers config')
continue
}
@@ -91,7 +94,7 @@ async function main() {
)
const guideXml = await client
- .get(worker.guidePath)
+ .get(worker.guideXmlPath)
.then(res => res.data)
.catch(err => {
worker.status = err.status
@@ -109,6 +112,7 @@ async function main() {
logger.info('creating guides table...')
const rows = new Collection()
workers.forEach((worker: Worker) => {
+ const links = worker.getLinks()
rows.add(
new Collection([
{ value: worker.host },
@@ -116,10 +120,9 @@ async function main() {
{ value: worker.getChannelsCount().toString(), align: 'right' },
{ value: worker.getLastUpdated(), align: 'left' },
{
- value:
- worker.status === 'OK'
- ? `${worker.channelsPath}
${worker.guidePath}`
- : ''
+ value: links.length
+ ? links.map(link => `${link.label}`).join(' | ')
+ : '-'
}
])
)
diff --git a/scripts/default.config.js b/scripts/default.config.js
index d5e49a641..056695ba7 100644
--- a/scripts/default.config.js
+++ b/scripts/default.config.js
@@ -5,6 +5,7 @@ export default {
maxConnections: 1,
curl: false,
gzip: false,
+ json: false,
debug: false,
request: {
maxContentLength: 5242880,
diff --git a/scripts/models/guide.ts b/scripts/models/guide.ts
index 8c367e6eb..51bc15f4d 100644
--- a/scripts/models/guide.ts
+++ b/scripts/models/guide.ts
@@ -13,20 +13,23 @@ interface GuideData {
channels: Collection
programs: Collection
filepath: string
- gzip: boolean
+ gzip: boolean | string
+ json: boolean | string
}
export class Guide {
channels: Collection
programs: Collection
filepath: string
- gzip: boolean
+ gzip: boolean | string
+ json: boolean | string
constructor(data: GuideData) {
this.channels = data.channels
this.programs = data.programs
this.filepath = data.filepath
this.gzip = data.gzip || false
+ this.json = data.json || false
}
addChannel(channel: Channel) {
@@ -50,10 +53,21 @@ export class Guide {
if (this.gzip) {
const compressed = pako.gzip(xmltv)
- const gzFilepath = `${this.filepath}.gz`
+ const gzFilepath = typeof this.gzip === 'string' ? this.gzip : `${this.filepath}.gz`
const gzFilename = path.basename(gzFilepath)
logger.info(` saving to "${gzFilepath}"...`)
await storage.save(gzFilename, compressed)
}
+
+ if (this.json) {
+ const filename = path.basename(this.filepath).split('.')[0]
+ const jsonFilepath =
+ typeof this.json === 'string' ? this.json : path.join(dir, `${filename}.json`)
+ const channels = this.channels.map((channel: Channel) => channel.toObject()).all()
+ const programs = this.programs.map((program: Program) => program.toObject()).all()
+ const json = JSON.stringify({ channels, programs })
+ logger.info(` saving to "${jsonFilepath}"...`)
+ await storage.save(jsonFilepath, json)
+ }
}
}
diff --git a/scripts/models/worker.ts b/scripts/models/worker.ts
index 94b94dfcb..71480e0bc 100644
--- a/scripts/models/worker.ts
+++ b/scripts/models/worker.ts
@@ -14,7 +14,9 @@ export interface WorkerData {
export class Worker {
host: string
channelsPath?: string
- guidePath?: string
+ guideXmlPath?: string
+ guideGzipPath?: string
+ guideJsonPath?: string
channels?: Collection
status?: string
lastUpdated?: string
@@ -41,10 +43,26 @@ export class Worker {
return url.href
}
- getGuideUrl(): string {
- if (!this.guidePath) return ''
+ getGuideXmlUrl(): string {
+ if (!this.guideXmlPath) return ''
- const url = new URL(this.guidePath, this.getBaseUrl())
+ const url = new URL(this.guideXmlPath, this.getBaseUrl())
+
+ return url.href
+ }
+
+ getGuideGzipUrl(): string {
+ if (!this.guideGzipPath) return ''
+
+ const url = new URL(this.guideGzipPath, this.getBaseUrl())
+
+ return url.href
+ }
+
+ getGuideJsonUrl(): string {
+ if (!this.guideJsonPath) return ''
+
+ const url = new URL(this.guideJsonPath, this.getBaseUrl())
return url.href
}
@@ -70,4 +88,25 @@ export class Worker {
return dayjs.utc(this.lastUpdated).from(now)
}
+
+ getLinks(): { url: string; label: string }[] {
+ const links = []
+
+ if (this.guideXmlPath) {
+ const url = new URL(this.guideXmlPath, this.getBaseUrl())
+ links.push({ url: url.href, label: 'XML' })
+ }
+
+ if (this.guideGzipPath) {
+ const url = new URL(this.guideGzipPath, this.getBaseUrl())
+ links.push({ url: url.href, label: 'GZIP' })
+ }
+
+ if (this.guideJsonPath) {
+ const url = new URL(this.guideJsonPath, this.getBaseUrl())
+ links.push({ url: url.href, label: 'JSON' })
+ }
+
+ return links
+ }
}
From 451b53e28e2f662e038c505c50c47a33b12964e3 Mon Sep 17 00:00:00 2001
From: freearhey <7253922+freearhey@users.noreply.github.com>
Date: Sun, 3 May 2026 21:37:59 +0300
Subject: [PATCH 05/16] Update README.md
---
README.md | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index cd59eb8c9..18ce76ab7 100644
--- a/README.md
+++ b/README.md
@@ -64,7 +64,8 @@ Options:
-x, --proxy Use the specified proxy (example: "socks5://username:password@127.0.0.1:1234")
--days Number of days for which the program will be loaded (defaults to the value from the site config)
--maxConnections Number of concurrent requests (default: 1)
- --gzip Specifies whether or not to create a compressed version of the guide (default: false)
+ --gzip [path] Specifies whether or not to create a compressed version of the guide (default: false)
+ --json [path] Specifies whether or not to create a JSON version of the guide (default: false)
--curl Display each request as CURL (default: false)
```
@@ -197,11 +198,13 @@ docker run \
-e CRON_SCHEDULE="0 0,12 * * *" \
-e MAX_CONNECTIONS=10 \
-e GZIP=true \
+-e JSON=true \
-e CURL=true \
-e PROXY="socks5://127.0.0.1:1234" \
-e DAYS=14 \
-e TIMEOUT=5 \
-e DELAY=2 \
+-e RUN_AT_STARTUP=true \
ghcr.io/iptv-org/epg:master
```
@@ -210,6 +213,7 @@ ghcr.io/iptv-org/epg:master
| 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) |
+| JSON | Boolean value indicating whether to create a JSON 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) |
From 53393797e84b191444b816f510ca5d717f57d20b Mon Sep 17 00:00:00 2001
From: freearhey <7253922+freearhey@users.noreply.github.com>
Date: Sun, 3 May 2026 21:38:36 +0300
Subject: [PATCH 06/16] Update CONTRIBUTING.md
---
CONTRIBUTING.md | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 2ee3bec41..8522a6145 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -422,6 +422,19 @@ To do this, you just need to place the `worker.json` file in a public directory
}
```
+If you generate a guide in multiple formats, you can specify the paths to all the files as follows:
+
+```json
+{
+ "channels": "path/to/channels.xml",
+ "guide": {
+ "xml": "path/to/guide.xml",
+ "gzip": "path/to/guide.xml.gz",
+ "json": "path/to/guide.json"
+ }
+}
+```
+
And then add server domain or IP address to the [workers.txt](workers.txt) file.
From e09daa51f1afefe481abaf81f01974d1c878e9e6 Mon Sep 17 00:00:00 2001
From: freearhey <7253922+freearhey@users.noreply.github.com>
Date: Mon, 4 May 2026 14:23:53 +0300
Subject: [PATCH 07/16] Update dependencies
---
package-lock.json | 14 +++++++-------
package.json | 2 +-
2 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index f66bf79ce..dcddf5890 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -44,7 +44,7 @@
"csv-parser": "^3.2.0",
"curl-generator": "^0.5.0",
"dayjs": "^1.11.20",
- "epg-grabber": "^0.46.2",
+ "epg-grabber": "^0.47.0",
"epg-parser": "^0.5.0",
"eslint": "^10.2.1",
"eslint-config-prettier": "^10.1.8",
@@ -5632,9 +5632,9 @@
}
},
"node_modules/epg-grabber": {
- "version": "0.46.2",
- "resolved": "https://registry.npmjs.org/epg-grabber/-/epg-grabber-0.46.2.tgz",
- "integrity": "sha512-pcZD9eo0d5QFqLr/YUXh4SapjrE3MfHvjyIokVpfCVI5wVXkdy3q9SxM8b34hPYPy2caeaCTpZM1yTAeoIOIMw==",
+ "version": "0.47.0",
+ "resolved": "https://registry.npmjs.org/epg-grabber/-/epg-grabber-0.47.0.tgz",
+ "integrity": "sha512-kl+erC90n1ipt2aThPF9o7bkxYsS/QIUQ56M/RWfOL9vyKZxSU9vvzMDp88HwCZ6NWMYPpeDTooA1XnsWT86Eg==",
"license": "MIT",
"dependencies": {
"@freearhey/core": "^0.14.0",
@@ -14952,9 +14952,9 @@
"integrity": "sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA=="
},
"epg-grabber": {
- "version": "0.46.2",
- "resolved": "https://registry.npmjs.org/epg-grabber/-/epg-grabber-0.46.2.tgz",
- "integrity": "sha512-pcZD9eo0d5QFqLr/YUXh4SapjrE3MfHvjyIokVpfCVI5wVXkdy3q9SxM8b34hPYPy2caeaCTpZM1yTAeoIOIMw==",
+ "version": "0.47.0",
+ "resolved": "https://registry.npmjs.org/epg-grabber/-/epg-grabber-0.47.0.tgz",
+ "integrity": "sha512-kl+erC90n1ipt2aThPF9o7bkxYsS/QIUQ56M/RWfOL9vyKZxSU9vvzMDp88HwCZ6NWMYPpeDTooA1XnsWT86Eg==",
"requires": {
"@freearhey/core": "^0.14.0",
"@types/bluebird": "^3.5.42",
diff --git a/package.json b/package.json
index 017530196..f57f300d3 100644
--- a/package.json
+++ b/package.json
@@ -72,7 +72,7 @@
"csv-parser": "^3.2.0",
"curl-generator": "^0.5.0",
"dayjs": "^1.11.20",
- "epg-grabber": "^0.46.2",
+ "epg-grabber": "^0.47.0",
"epg-parser": "^0.5.0",
"eslint": "^10.2.1",
"eslint-config-prettier": "^10.1.8",
From 970d046515fcbc19be89754f66ebae5e145ab267 Mon Sep 17 00:00:00 2001
From: freearhey <7253922+freearhey@users.noreply.github.com>
Date: Mon, 4 May 2026 14:24:11 +0300
Subject: [PATCH 08/16] Update tests/__data__
---
tests/__data__/expected/epg_grab/base.guide.xml | 8 ++++----
.../expected/epg_grab/custom_channels.guide.xml | 12 ++++++------
tests/__data__/expected/epg_grab/gzip/guide.xml | 6 +++---
.../expected/epg_grab/gzip/guide.xml.gz | Bin 342 -> 331 bytes
.../__data__/expected/epg_grab/json/guide.json | 2 +-
tests/__data__/expected/epg_grab/json/guide.xml | 6 +++---
.../expected/epg_grab/lang/fr/guide.xml | 2 +-
.../expected/epg_grab/multilang.guide.xml | 4 ++--
.../epg_grab/multiple_channels.guide.xml | 14 +++++++-------
.../epg_grab/wildcard/example.com/en/guide.xml | 4 ++--
.../epg_grab/wildcard/example.com/fr/guide.xml | 2 +-
.../epg_grab/wildcard/example.com/it/guide.xml | 2 +-
12 files changed, 31 insertions(+), 31 deletions(-)
diff --git a/tests/__data__/expected/epg_grab/base.guide.xml b/tests/__data__/expected/epg_grab/base.guide.xml
index a0ed3732f..16b14442b 100644
--- a/tests/__data__/expected/epg_grab/base.guide.xml
+++ b/tests/__data__/expected/epg_grab/base.guide.xml
@@ -1,8 +1,8 @@
-Channel 1https://example.com
-Channel 2https://example.com36
-Channel 1https://example.com
-Channel 3https://example.com
+Channel 1
+Channel 236
+Channel 1
+Channel 3
Programme1 (example.com)
Programme1 (example.com)
Program1 (example.com)
diff --git a/tests/__data__/expected/epg_grab/custom_channels.guide.xml b/tests/__data__/expected/epg_grab/custom_channels.guide.xml
index 94215f489..b097cf402 100644
--- a/tests/__data__/expected/epg_grab/custom_channels.guide.xml
+++ b/tests/__data__/expected/epg_grab/custom_channels.guide.xml
@@ -1,10 +1,10 @@
-Custom Channel 1https://example.com
-Channel 1https://example2.com
-Channel 4https://example2.com
-Channel 3https://example2.com
-Channel 1https://example.com
-Custom Channel 2https://example.com
+Custom Channel 1
+Channel 1
+Channel 4
+Channel 3
+Channel 1
+Custom Channel 2
Program1 (example.com)
Program1 (example.com)
Programme1 (example2.com)
diff --git a/tests/__data__/expected/epg_grab/gzip/guide.xml b/tests/__data__/expected/epg_grab/gzip/guide.xml
index 67968c1bd..bba9e9a18 100644
--- a/tests/__data__/expected/epg_grab/gzip/guide.xml
+++ b/tests/__data__/expected/epg_grab/gzip/guide.xml
@@ -1,7 +1,7 @@
-Channel 3https://example2.com
-Channel 4https://example2.com
-Channel 1https://example2.com
+Channel 3
+Channel 4
+Channel 1
Programme1 (example2.com)
Program1 (example2.com)
Program1 (example2.com)
diff --git a/tests/__data__/expected/epg_grab/gzip/guide.xml.gz b/tests/__data__/expected/epg_grab/gzip/guide.xml.gz
index ccce7906cb2b71c94a87333c643da178db852260..3f0eaf110a33d4d1e518dc300572f46da5896e8c 100644
GIT binary patch
literal 331
zcmV-R0kr-fiwFP!000001I5xyO9L?w0N}gee+U6Vk>;_xwneh3sA%!jgPtYq#%*x(
zAW3WW*So80Ta{WL2QML$37L<~u+c)-U@m=7*5r^P0)aGzEtRQqcyoQx8$vMREY3kG
zqRe4Jl7y0kK)$=fim5P0)Ps;~wGU*vEa
zqYK9v&s=Rqi5}Fw(y~+nSzqBca&3eQtF<)&_i;bN{k>!$y)?H8p-0G>3S!Q1XWGEa
z8s5qHe3LNSE>uYAYUWWx!Kag_@o;$c&D!*fuc1FHe7U)>^S1IrYY9RWKCU`OvWyS{
z4x6QEa){Qg002_ao4Wu2
literal 342
zcmV-c0jd5UiwFP!000001I3a{O9L?!h4+I0Ap`_PI!UHuTO^Z;iWXO0=vu-|oCfnE
z$<*qvcSieCsl_U|3As7Rz26Ck4rf|{sqjHslS7hV2!ttYDNU8byW6YI0D>W-aSBQv
zMGjM(rUa)Lvi&_;Ot>*Z0a@no;(gyu>HrxnWpIi=cMR8p%_Y#K=)*acg*71fB8QV0
zT{uOkcFOWHd6JJ(i&FB$`U=PP>9y8X??P$&AWZ}6=hTcDBi~I!9zA)Od{ZL9qNdp
ox2p};5@riom+VH?{YoaxlI;JuRw4Zz8j4f)3W2ki9smRY09$IGsQ>@~
diff --git a/tests/__data__/expected/epg_grab/json/guide.json b/tests/__data__/expected/epg_grab/json/guide.json
index 3f632b054..83d442b77 100644
--- a/tests/__data__/expected/epg_grab/json/guide.json
+++ b/tests/__data__/expected/epg_grab/json/guide.json
@@ -1 +1 @@
-{"channels":[{"xmltv_id":"Channel3.us","name":"Channel 3","site":"example2.com","site_id":"150","lang":"en","logo":"https://upload.wikimedia.org/wikipedia/commons/6/64/6%27eren_2015.png","url":"https://example2.com","lcn":null,"index":0},{"xmltv_id":"Channel4.us@HD","name":"Channel 4","site":"example2.com","site_id":"152","lang":"en","logo":"https://i.imgur.com/BPzH88J.png","url":"https://example2.com","lcn":null,"index":1},{"xmltv_id":"Channel1.us","name":"Channel 1","site":"example2.com","site_id":"140","lang":"fr","logo":"https://i.imgur.com/GPzH88J.png","url":"https://example2.com","lcn":null,"index":2}],"programs":[{"site":"example2.com","start":1666240800000,"stop":1666249800000,"channel":"Channel1.us","titles":[{"value":"Programme1 (example2.com)","lang":"fr"}],"subTitles":[],"descriptions":[],"date":0,"categories":[],"keywords":[],"languages":[],"origLanguages":[],"length":[],"urls":[],"countries":[],"episodeNumbers":[],"video":{"present":"","colour":"","aspect":"","quality":""},"audio":{"present":"","stereo":""},"previouslyShown":[],"premiere":[],"lastChance":[],"new":false,"subtitles":[],"ratings":[],"starRatings":[],"reviews":[],"directors":[],"actors":[],"writers":[],"adapters":[],"producers":[],"composers":[],"editors":[],"presenters":[],"commentators":[],"guests":[],"images":[],"icons":[]},{"site":"example2.com","start":1666240260000,"stop":1666249800000,"channel":"Channel3.us","titles":[{"value":"Program1 (example2.com)","lang":"en"}],"subTitles":[],"descriptions":[],"date":0,"categories":[],"keywords":[],"languages":[],"origLanguages":[],"length":[],"urls":[],"countries":[],"episodeNumbers":[],"video":{"present":"","colour":"","aspect":"","quality":""},"audio":{"present":"","stereo":""},"previouslyShown":[],"premiere":[],"lastChance":[],"new":false,"subtitles":[],"ratings":[],"starRatings":[],"reviews":[],"directors":[],"actors":[],"writers":[],"adapters":[],"producers":[],"composers":[],"editors":[],"presenters":[],"commentators":[],"guests":[],"images":[],"icons":[]},{"site":"example2.com","start":1666240260000,"stop":1666249800000,"channel":"Channel4.us@HD","titles":[{"value":"Program1 (example2.com)","lang":"en"}],"subTitles":[],"descriptions":[],"date":0,"categories":[],"keywords":[],"languages":[],"origLanguages":[],"length":[],"urls":[],"countries":[],"episodeNumbers":[],"video":{"present":"","colour":"","aspect":"","quality":""},"audio":{"present":"","stereo":""},"previouslyShown":[],"premiere":[],"lastChance":[],"new":false,"subtitles":[],"ratings":[],"starRatings":[],"reviews":[],"directors":[],"actors":[],"writers":[],"adapters":[],"producers":[],"composers":[],"editors":[],"presenters":[],"commentators":[],"guests":[],"images":[],"icons":[]}]}
\ No newline at end of file
+{"date":"20221020","channels":[{"xmltv_id":"Channel3.us","name":"Channel 3","site":"example2.com","site_id":"150","lang":"en","logo":"https://upload.wikimedia.org/wikipedia/commons/6/64/6%27eren_2015.png","url":null,"lcn":null,"index":0},{"xmltv_id":"Channel4.us@HD","name":"Channel 4","site":"example2.com","site_id":"152","lang":"en","logo":"https://i.imgur.com/BPzH88J.png","url":null,"lcn":null,"index":1},{"xmltv_id":"Channel1.us","name":"Channel 1","site":"example2.com","site_id":"140","lang":"fr","logo":"https://i.imgur.com/GPzH88J.png","url":null,"lcn":null,"index":2}],"programs":[{"site":"example2.com","start":1666240800000,"stop":1666249800000,"channel":"Channel1.us","titles":[{"value":"Programme1 (example2.com)","lang":"fr"}],"subTitles":[],"descriptions":[],"date":0,"categories":[],"keywords":[],"languages":[],"origLanguages":[],"length":[],"urls":[],"countries":[],"episodeNumbers":[],"video":{"present":"","colour":"","aspect":"","quality":""},"audio":{"present":"","stereo":""},"previouslyShown":[],"premiere":[],"lastChance":[],"new":false,"subtitles":[],"ratings":[],"starRatings":[],"reviews":[],"directors":[],"actors":[],"writers":[],"adapters":[],"producers":[],"composers":[],"editors":[],"presenters":[],"commentators":[],"guests":[],"images":[],"icons":[]},{"site":"example2.com","start":1666240260000,"stop":1666249800000,"channel":"Channel3.us","titles":[{"value":"Program1 (example2.com)","lang":"en"}],"subTitles":[],"descriptions":[],"date":0,"categories":[],"keywords":[],"languages":[],"origLanguages":[],"length":[],"urls":[],"countries":[],"episodeNumbers":[],"video":{"present":"","colour":"","aspect":"","quality":""},"audio":{"present":"","stereo":""},"previouslyShown":[],"premiere":[],"lastChance":[],"new":false,"subtitles":[],"ratings":[],"starRatings":[],"reviews":[],"directors":[],"actors":[],"writers":[],"adapters":[],"producers":[],"composers":[],"editors":[],"presenters":[],"commentators":[],"guests":[],"images":[],"icons":[]},{"site":"example2.com","start":1666240260000,"stop":1666249800000,"channel":"Channel4.us@HD","titles":[{"value":"Program1 (example2.com)","lang":"en"}],"subTitles":[],"descriptions":[],"date":0,"categories":[],"keywords":[],"languages":[],"origLanguages":[],"length":[],"urls":[],"countries":[],"episodeNumbers":[],"video":{"present":"","colour":"","aspect":"","quality":""},"audio":{"present":"","stereo":""},"previouslyShown":[],"premiere":[],"lastChance":[],"new":false,"subtitles":[],"ratings":[],"starRatings":[],"reviews":[],"directors":[],"actors":[],"writers":[],"adapters":[],"producers":[],"composers":[],"editors":[],"presenters":[],"commentators":[],"guests":[],"images":[],"icons":[]}]}
\ No newline at end of file
diff --git a/tests/__data__/expected/epg_grab/json/guide.xml b/tests/__data__/expected/epg_grab/json/guide.xml
index 67968c1bd..bba9e9a18 100644
--- a/tests/__data__/expected/epg_grab/json/guide.xml
+++ b/tests/__data__/expected/epg_grab/json/guide.xml
@@ -1,7 +1,7 @@
-Channel 3https://example2.com
-Channel 4https://example2.com
-Channel 1https://example2.com
+Channel 3
+Channel 4
+Channel 1
Programme1 (example2.com)
Program1 (example2.com)
Program1 (example2.com)
diff --git a/tests/__data__/expected/epg_grab/lang/fr/guide.xml b/tests/__data__/expected/epg_grab/lang/fr/guide.xml
index dde894e5f..15f36ee2f 100644
--- a/tests/__data__/expected/epg_grab/lang/fr/guide.xml
+++ b/tests/__data__/expected/epg_grab/lang/fr/guide.xml
@@ -1,5 +1,5 @@
-Channel 1https://example.com
+Channel 1
Programme1 (example.com)
Programme1 (example.com)
\ No newline at end of file
diff --git a/tests/__data__/expected/epg_grab/multilang.guide.xml b/tests/__data__/expected/epg_grab/multilang.guide.xml
index 40e5b5aa6..bd0209bd5 100644
--- a/tests/__data__/expected/epg_grab/multilang.guide.xml
+++ b/tests/__data__/expected/epg_grab/multilang.guide.xml
@@ -1,6 +1,6 @@
-Channel 1https://example.com
-Channel 3https://example.com
+Channel 1
+Channel 3
Programme1 (example.com)
Programme1 (example.com)
Program1 (example.com)
diff --git a/tests/__data__/expected/epg_grab/multiple_channels.guide.xml b/tests/__data__/expected/epg_grab/multiple_channels.guide.xml
index 1a2ececa4..b2b76d31a 100644
--- a/tests/__data__/expected/epg_grab/multiple_channels.guide.xml
+++ b/tests/__data__/expected/epg_grab/multiple_channels.guide.xml
@@ -1,11 +1,11 @@
-Channel 1https://example.com
-Channel 1https://example2.com
-Channel 4https://example2.com
-Channel 3https://example2.com
-Channel 3https://example.com
-Channel 1https://example.com
-Channel 2https://example.com36
+Channel 1
+Channel 1
+Channel 4
+Channel 3
+Channel 3
+Channel 1
+Channel 236
Program1 (example.com)
Program1 (example.com)
Programme1 (example2.com)
diff --git a/tests/__data__/expected/epg_grab/wildcard/example.com/en/guide.xml b/tests/__data__/expected/epg_grab/wildcard/example.com/en/guide.xml
index 6426626a0..ed1cb2d20 100644
--- a/tests/__data__/expected/epg_grab/wildcard/example.com/en/guide.xml
+++ b/tests/__data__/expected/epg_grab/wildcard/example.com/en/guide.xml
@@ -1,6 +1,6 @@
-Channel 1https://example.com
-Channel 2https://example.com36
+Channel 1
+Channel 236
Program1 (example.com)
Program1 (example.com)
\ No newline at end of file
diff --git a/tests/__data__/expected/epg_grab/wildcard/example.com/fr/guide.xml b/tests/__data__/expected/epg_grab/wildcard/example.com/fr/guide.xml
index dde894e5f..15f36ee2f 100644
--- a/tests/__data__/expected/epg_grab/wildcard/example.com/fr/guide.xml
+++ b/tests/__data__/expected/epg_grab/wildcard/example.com/fr/guide.xml
@@ -1,5 +1,5 @@
-Channel 1https://example.com
+Channel 1
Programme1 (example.com)
Programme1 (example.com)
\ No newline at end of file
diff --git a/tests/__data__/expected/epg_grab/wildcard/example.com/it/guide.xml b/tests/__data__/expected/epg_grab/wildcard/example.com/it/guide.xml
index 9d839e5e3..37cfd2ed3 100644
--- a/tests/__data__/expected/epg_grab/wildcard/example.com/it/guide.xml
+++ b/tests/__data__/expected/epg_grab/wildcard/example.com/it/guide.xml
@@ -1,5 +1,5 @@
-Channel 3https://example.com
+Channel 3
Program1 (example.com)
Program1 (example.com)
\ No newline at end of file
From e50f38240beef17edc94b97648d98d6466f1a03d Mon Sep 17 00:00:00 2001
From: freearhey <7253922+freearhey@users.noreply.github.com>
Date: Mon, 4 May 2026 14:24:17 +0300
Subject: [PATCH 09/16] Update guide.ts
---
scripts/models/guide.ts | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/scripts/models/guide.ts b/scripts/models/guide.ts
index 51bc15f4d..cb68ae1f1 100644
--- a/scripts/models/guide.ts
+++ b/scripts/models/guide.ts
@@ -38,8 +38,9 @@ export class Guide {
toString() {
const currDate = dayjs.utc(process.env.CURR_DATE || new Date().toISOString())
+ const headers = { date: currDate.format('YYYYMMDD') }
- return EPGGrabber.generateXMLTV(this.channels.all(), this.programs.all(), currDate)
+ return EPGGrabber.generateXMLTV(this.channels.all(), this.programs.all(), headers)
}
async save({ logger }: { logger: Logger }) {
@@ -63,9 +64,11 @@ export class Guide {
const filename = path.basename(this.filepath).split('.')[0]
const jsonFilepath =
typeof this.json === 'string' ? this.json : path.join(dir, `${filename}.json`)
+ const currDate = dayjs.utc(process.env.CURR_DATE || new Date().toISOString())
+ const headers = { date: currDate.format('YYYYMMDD') }
const channels = this.channels.map((channel: Channel) => channel.toObject()).all()
const programs = this.programs.map((program: Program) => program.toObject()).all()
- const json = JSON.stringify({ channels, programs })
+ const json = JSON.stringify({ ...headers, channels, programs })
logger.info(` saving to "${jsonFilepath}"...`)
await storage.save(jsonFilepath, json)
}
From ea954fec380244e16482c6073cb376a8b1c5ae5c Mon Sep 17 00:00:00 2001
From: freearhey <7253922+freearhey@users.noreply.github.com>
Date: Mon, 4 May 2026 18:01:25 +0300
Subject: [PATCH 10/16] Update tests/__data__
---
.../expected/api_generate/guides.json | 1 -
.../expected/guides_export/guides.json | 1 +
.../expected/workers_load/workers.json | 1 +
.../example.com/example.com.channels.xml | 10 +++----
.../example2.com/example2.com.channels.xml | 10 +++----
.../__data__/input/guides_update/workers.json | 29 +++++++++++++++++++
.../channels.xml | 0
.../{guides_update => workers_load}/guide.xml | 2 +-
.../worker.json | 0
.../workers.txt | 0
10 files changed, 42 insertions(+), 12 deletions(-)
delete mode 100644 tests/__data__/expected/api_generate/guides.json
create mode 100644 tests/__data__/expected/guides_export/guides.json
create mode 100644 tests/__data__/expected/workers_load/workers.json
rename tests/__data__/input/{api_generate => guides_export}/sites/example.com/example.com.channels.xml (98%)
rename tests/__data__/input/{api_generate => guides_export}/sites/example2.com/example2.com.channels.xml (98%)
create mode 100644 tests/__data__/input/guides_update/workers.json
rename tests/__data__/input/{guides_update => workers_load}/channels.xml (100%)
rename tests/__data__/input/{guides_update => workers_load}/guide.xml (97%)
rename tests/__data__/input/{guides_update => workers_load}/worker.json (100%)
rename tests/__data__/input/{guides_update => workers_load}/workers.txt (100%)
diff --git a/tests/__data__/expected/api_generate/guides.json b/tests/__data__/expected/api_generate/guides.json
deleted file mode 100644
index b658a7821..000000000
--- a/tests/__data__/expected/api_generate/guides.json
+++ /dev/null
@@ -1 +0,0 @@
-[{"channel":"Channel1.us","feed":"SD","site":"example.com","site_id":"140","site_name":"Channel 1","lang":"en"},{"channel":"Channel2.us","feed":null,"site":"example.com","site_id":"142","site_name":"Channel 2","lang":"en"},{"channel":"Channel1.us","feed":"HD","site":"example.com","site_id":"140","site_name":"Channel 1","lang":"fr"},{"channel":"Channel3.us","feed":"HD","site":"example2.com","site_id":"150","site_name":"Channel 3","lang":"en"},{"channel":"Channel4.us","feed":null,"site":"example2.com","site_id":"152","site_name":"Channel 4","lang":"en"},{"channel":"Channel1.us","feed":null,"site":"example2.com","site_id":"140","site_name":"Channel 1","lang":"fr"}]
\ No newline at end of file
diff --git a/tests/__data__/expected/guides_export/guides.json b/tests/__data__/expected/guides_export/guides.json
new file mode 100644
index 000000000..1239c7edf
--- /dev/null
+++ b/tests/__data__/expected/guides_export/guides.json
@@ -0,0 +1 @@
+[{"channel":"Channel1.us","feed":"SD","site":"example.com","site_id":"140","site_name":"Channel 1","lang":"en","sources":[]},{"channel":"Channel2.us","feed":null,"site":"example.com","site_id":"142","site_name":"Channel 2","lang":"en","sources":[]},{"channel":"Channel1.us","feed":"HD","site":"example.com","site_id":"140","site_name":"Channel 1","lang":"fr","sources":[]},{"channel":"Channel3.us","feed":"HD","site":"example2.com","site_id":"150","site_name":"Channel 3","lang":"en","sources":[]},{"channel":"Channel4.us","feed":null,"site":"example2.com","site_id":"152","site_name":"Channel 4","lang":"en","sources":[]},{"channel":"Channel1.us","feed":null,"site":"example2.com","site_id":"140","site_name":"Channel 1","lang":"fr","sources":[]}]
\ No newline at end of file
diff --git a/tests/__data__/expected/workers_load/workers.json b/tests/__data__/expected/workers_load/workers.json
new file mode 100644
index 000000000..5884dd7af
--- /dev/null
+++ b/tests/__data__/expected/workers_load/workers.json
@@ -0,0 +1 @@
+[{"host":"example.com","channels":[],"status":"MISSING_WORKER_CONFIG"},{"host":"worker-9dd4.onrender.com","channelsPath":"channels.xml","channels":[{"xmltv_id":"ANT1Europe.gr@SD","name":"ANT1 Europe","site":"antennaeurope.gr","site_id":"#","lang":"el","logo":null,"url":null,"lcn":null,"index":0}],"guideXmlPath":"guide.xml","guideGzipPath":"guide.xml.gz","guideJsonPath":"guide.json","status":"OK","lastUpdated":"2022-10-03T00:00:00.000Z"}]
\ No newline at end of file
diff --git a/tests/__data__/input/api_generate/sites/example.com/example.com.channels.xml b/tests/__data__/input/guides_export/sites/example.com/example.com.channels.xml
similarity index 98%
rename from tests/__data__/input/api_generate/sites/example.com/example.com.channels.xml
rename to tests/__data__/input/guides_export/sites/example.com/example.com.channels.xml
index 630e9225e..c0c4c2ffa 100644
--- a/tests/__data__/input/api_generate/sites/example.com/example.com.channels.xml
+++ b/tests/__data__/input/guides_export/sites/example.com/example.com.channels.xml
@@ -1,6 +1,6 @@
-
-
- Channel 1
- Channel 2
- Channel 1
+
+
+ Channel 1
+ Channel 2
+ Channel 1
\ No newline at end of file
diff --git a/tests/__data__/input/api_generate/sites/example2.com/example2.com.channels.xml b/tests/__data__/input/guides_export/sites/example2.com/example2.com.channels.xml
similarity index 98%
rename from tests/__data__/input/api_generate/sites/example2.com/example2.com.channels.xml
rename to tests/__data__/input/guides_export/sites/example2.com/example2.com.channels.xml
index f95bdb8dc..edb959bae 100644
--- a/tests/__data__/input/api_generate/sites/example2.com/example2.com.channels.xml
+++ b/tests/__data__/input/guides_export/sites/example2.com/example2.com.channels.xml
@@ -1,6 +1,6 @@
-
-
- Channel 3
- Channel 4
- Channel 1
+
+
+ Channel 3
+ Channel 4
+ Channel 1
\ No newline at end of file
diff --git a/tests/__data__/input/guides_update/workers.json b/tests/__data__/input/guides_update/workers.json
new file mode 100644
index 000000000..314483d59
--- /dev/null
+++ b/tests/__data__/input/guides_update/workers.json
@@ -0,0 +1,29 @@
+[
+ {
+ "host": "example.com",
+ "channels": [],
+ "status": "MISSING_WORKER_CONFIG"
+ },
+ {
+ "host": "worker-9dd4.onrender.com",
+ "channelsPath": "channels.xml",
+ "channels": [
+ {
+ "xmltv_id": "ANT1Europe.gr@SD",
+ "name": "ANT1 Europe",
+ "site": "antennaeurope.gr",
+ "site_id": "#",
+ "lang": "el",
+ "logo": null,
+ "url": null,
+ "lcn": null,
+ "index": 0
+ }
+ ],
+ "guideXmlPath": "guide.xml",
+ "guideGzipPath": "guide.xml.gz",
+ "guideJsonPath": "guide.json",
+ "status": "OK",
+ "lastUpdated": "2022-10-03T00:00:00.000Z"
+ }
+]
\ No newline at end of file
diff --git a/tests/__data__/input/guides_update/channels.xml b/tests/__data__/input/workers_load/channels.xml
similarity index 100%
rename from tests/__data__/input/guides_update/channels.xml
rename to tests/__data__/input/workers_load/channels.xml
diff --git a/tests/__data__/input/guides_update/guide.xml b/tests/__data__/input/workers_load/guide.xml
similarity index 97%
rename from tests/__data__/input/guides_update/guide.xml
rename to tests/__data__/input/workers_load/guide.xml
index ed17438ed..ddc29c7b0 100644
--- a/tests/__data__/input/guides_update/guide.xml
+++ b/tests/__data__/input/workers_load/guide.xml
@@ -1,4 +1,4 @@
-
+
ANT1 Europehttps://antennaeurope.gr
ΚΑΛΗΜΕΡΑ ΕΛΛΑΔΑ
ΤΟ ΠΡΩΙΝΟ
diff --git a/tests/__data__/input/guides_update/worker.json b/tests/__data__/input/workers_load/worker.json
similarity index 100%
rename from tests/__data__/input/guides_update/worker.json
rename to tests/__data__/input/workers_load/worker.json
diff --git a/tests/__data__/input/guides_update/workers.txt b/tests/__data__/input/workers_load/workers.txt
similarity index 100%
rename from tests/__data__/input/guides_update/workers.txt
rename to tests/__data__/input/workers_load/workers.txt
From 3f6b8267cce6af117d72cc19adb900c7b94d7c72 Mon Sep 17 00:00:00 2001
From: freearhey <7253922+freearhey@users.noreply.github.com>
Date: Mon, 4 May 2026 18:01:35 +0300
Subject: [PATCH 11/16] Update tests
---
.../export.test.ts} | 55 ++++++++++---------
tests/commands/guides/update.test.ts | 20 ++++---
tests/commands/workers/load.test.ts | 32 +++++++++++
3 files changed, 71 insertions(+), 36 deletions(-)
rename tests/commands/{api/generate.test.ts => guides/export.test.ts} (64%)
create mode 100644 tests/commands/workers/load.test.ts
diff --git a/tests/commands/api/generate.test.ts b/tests/commands/guides/export.test.ts
similarity index 64%
rename from tests/commands/api/generate.test.ts
rename to tests/commands/guides/export.test.ts
index 2044f0d9c..ee787e8a2 100644
--- a/tests/commands/api/generate.test.ts
+++ b/tests/commands/guides/export.test.ts
@@ -1,27 +1,28 @@
-import { execSync } from 'child_process'
-import fs from 'fs-extra'
-import { pathToFileURL } from 'node:url'
-
-const ENV_VAR = 'cross-env SITES_DIR=tests/__data__/input/api_generate/sites API_DIR=tests/__data__/output'
-
-beforeEach(() => {
- fs.emptyDirSync('tests/__data__/output')
-})
-
-describe('api:generate', () => {
- it('can generate guides.json', () => {
- const cmd = `${ENV_VAR} npm run api:generate`
- const stdout = execSync(cmd, { encoding: 'utf8' })
- if (process.env.DEBUG === 'true') console.log(cmd, stdout)
-
- expect(content('tests/__data__/output/guides.json')).toEqual(
- content('tests/__data__/expected/api_generate/guides.json')
- )
- })
-})
-
-function content(filepath: string) {
- return fs.readFileSync(pathToFileURL(filepath), {
- encoding: 'utf8'
- })
-}
+import { execSync } from 'child_process'
+import fs from 'fs-extra'
+import { pathToFileURL } from 'node:url'
+
+const ENV_VAR =
+ 'cross-env SITES_DIR=tests/__data__/input/guides_export/sites API_DIR=tests/__data__/output'
+
+beforeEach(() => {
+ fs.emptyDirSync('tests/__data__/output')
+})
+
+describe('guides:export', () => {
+ it('can generate guides.json', () => {
+ const cmd = `${ENV_VAR} npm run guides:export`
+ const stdout = execSync(cmd, { encoding: 'utf8' })
+ if (process.env.DEBUG === 'true') console.log(cmd, stdout)
+
+ expect(content('tests/__data__/output/guides.json')).toEqual(
+ content('tests/__data__/expected/guides_export/guides.json')
+ )
+ })
+})
+
+function content(filepath: string) {
+ return fs.readFileSync(pathToFileURL(filepath), {
+ encoding: 'utf8'
+ })
+}
diff --git a/tests/commands/guides/update.test.ts b/tests/commands/guides/update.test.ts
index 4f3d0f6ef..0a4da2639 100644
--- a/tests/commands/guides/update.test.ts
+++ b/tests/commands/guides/update.test.ts
@@ -2,22 +2,24 @@ import { execSync } from 'child_process'
import { pathToFileURL } from 'node:url'
import fs from 'fs-extra'
-const ENV_VAR = 'cross-env ROOT_DIR=tests/__data__/output'
+const ENV_VAR =
+ 'cross-env CURR_DATE=2022-10-04 ROOT_DIR=tests/__data__/output DATA_DIR=tests/__data__/input/guides_update'
beforeEach(() => {
fs.emptyDirSync('tests/__data__/output')
- fs.copySync('tests/__data__/input/guides_update/workers.txt', 'tests/__data__/output/workers.txt')
})
-it('can update GUIDES.md', () => {
- const cmd = `${ENV_VAR} npm run guides:update`
+describe('guides:update', () => {
+ it('can update GUIDES.md', () => {
+ const cmd = `${ENV_VAR} npm run guides:update`
- const stdout = execSync(cmd, { encoding: 'utf8' })
- if (process.env.DEBUG === 'true') console.log(cmd, stdout)
+ const stdout = execSync(cmd, { encoding: 'utf8' })
+ if (process.env.DEBUG === 'true') console.log(cmd, stdout)
- expect(content('tests/__data__/output/GUIDES.md')).toEqual(
- content('tests/__data__/expected/guides_update/GUIDES.md')
- )
+ expect(content('tests/__data__/output/GUIDES.md')).toEqual(
+ content('tests/__data__/expected/guides_update/GUIDES.md')
+ )
+ })
})
function content(filepath: string) {
diff --git a/tests/commands/workers/load.test.ts b/tests/commands/workers/load.test.ts
new file mode 100644
index 000000000..724e0f50e
--- /dev/null
+++ b/tests/commands/workers/load.test.ts
@@ -0,0 +1,32 @@
+import { execSync } from 'child_process'
+import { pathToFileURL } from 'node:url'
+import fs from 'fs-extra'
+
+const ENV_VAR =
+ 'cross-env CURR_DATE=2022-10-04 ROOT_DIR=tests/__data__/output DATA_DIR=tests/__data__/output/'
+
+beforeEach(() => {
+ fs.emptyDirSync('tests/__data__/output')
+ fs.copySync('tests/__data__/input/workers_load/workers.txt', 'tests/__data__/output/workers.txt')
+})
+
+describe('workers:load', () => {
+ it('can create workers.json', () => {
+ const cmd = `${ENV_VAR} npm run workers:load`
+
+ const stdout = execSync(cmd, { encoding: 'utf8' })
+ if (process.env.DEBUG === 'true') console.log(cmd, stdout)
+
+ expect(content('tests/__data__/output/workers.json')).toEqual(
+ content('tests/__data__/expected/workers_load/workers.json')
+ )
+ })
+})
+
+function content(filepath: string) {
+ const data = fs.readFileSync(pathToFileURL(filepath), {
+ encoding: 'utf8'
+ })
+
+ return JSON.stringify(data)
+}
From e8e47b511ad4053c750587e301770309e344c4ca Mon Sep 17 00:00:00 2001
From: freearhey <7253922+freearhey@users.noreply.github.com>
Date: Mon, 4 May 2026 18:01:51 +0300
Subject: [PATCH 12/16] Update scripts
---
scripts/commands/api/generate.ts | 43 -----------
scripts/commands/guides/export.ts | 88 +++++++++++++++++++++
scripts/commands/guides/update.ts | 119 ++++-------------------------
scripts/commands/workers/load.ts | 122 ++++++++++++++++++++++++++++++
scripts/models/apiGuide.ts | 49 ++++++++++++
scripts/models/channel.ts | 14 ----
scripts/models/worker.ts | 88 ++++++++++++++++++---
scripts/types/channel.d.ts | 8 --
8 files changed, 353 insertions(+), 178 deletions(-)
delete mode 100644 scripts/commands/api/generate.ts
create mode 100644 scripts/commands/guides/export.ts
create mode 100644 scripts/commands/workers/load.ts
create mode 100644 scripts/models/apiGuide.ts
delete mode 100644 scripts/types/channel.d.ts
diff --git a/scripts/commands/api/generate.ts b/scripts/commands/api/generate.ts
deleted file mode 100644
index 083113c58..000000000
--- a/scripts/commands/api/generate.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { ChannelGuideObject } from '../../types/channel'
-import { SITES_DIR, API_DIR } from '../../constants'
-import { Logger, Collection } from '@freearhey/core'
-import epgGrabber, { EPGGrabber } from 'epg-grabber'
-import { Storage } from '@freearhey/storage-js'
-import { Channel } from '../../models'
-import path from 'path'
-
-async function main() {
- const logger = new Logger()
-
- logger.start('staring...')
-
- logger.info('loading channels...')
- const sitesStorage = new Storage(SITES_DIR)
-
- const files: string[] = await sitesStorage.list('**/*.channels.xml')
-
- const channels = new Collection()
- for (const filepath of files) {
- const xml = await sitesStorage.load(filepath)
- const parsedChannels = EPGGrabber.parseChannelsXML(xml)
- const channelsFromXML = new Collection(parsedChannels).map(
- (channel: epgGrabber.Channel) => new Channel(channel.toObject())
- )
-
- channelsFromXML.forEach((channel: Channel) => {
- channels.add(channel)
- })
- }
-
- logger.info(`found ${channels.count()} channel(s)`)
-
- const output = channels.map((channel: Channel) => channel.getGuideObject())
-
- const apiStorage = new Storage(API_DIR)
- const outputFilename = 'guides.json'
- await apiStorage.save('guides.json', output.toJSON())
-
- logger.info(`saved to "${path.join(API_DIR, outputFilename)}"`)
-}
-
-main()
diff --git a/scripts/commands/guides/export.ts b/scripts/commands/guides/export.ts
new file mode 100644
index 000000000..5f01f04f7
--- /dev/null
+++ b/scripts/commands/guides/export.ts
@@ -0,0 +1,88 @@
+import { SITES_DIR, API_DIR, DATA_DIR } from '../../constants'
+import { Logger, Collection } from '@freearhey/core'
+import epgGrabber, { EPGGrabber } from 'epg-grabber'
+import { Storage } from '@freearhey/storage-js'
+import { Channel, Worker, WorkerData } from '../../models'
+import path from 'path'
+import { ApiGuide } from '../../models/apiGuide'
+
+async function main() {
+ const logger = new Logger()
+
+ logger.start('staring...')
+
+ logger.info('loading channels...')
+ const channels = await loadChannels()
+ logger.info(`found ${channels.count()} channel(s)`)
+
+ logger.info('loading workers.json...')
+ const workers = await loadWorkers()
+ const guidesMap = workers.reduce((acc, data: WorkerData) => {
+ const worker = new Worker(data)
+ if (!worker.channels) return acc
+ worker.channels.forEach(channel => {
+ const [channelId, feedId] = channel.xmltv_id.split('@')
+ const key = [channelId, feedId, channel.site, channel.site_id, channel.lang].join('_')
+ acc[key] = worker.getGuideSources()
+ })
+ return acc
+ }, {})
+
+ logger.info('preparing output...')
+ const output = channels.map((channel: Channel) => {
+ const [channelId, feedId] = channel.xmltv_id.split('@')
+ const key = [channelId, feedId, channel.site, channel.site_id, channel.lang].join('_')
+
+ return new ApiGuide({
+ channel: channelId,
+ feed: feedId,
+ site: channel.site,
+ site_id: channel.site_id,
+ site_name: channel.name,
+ lang: channel.lang,
+ sources: guidesMap[key] || []
+ })
+ })
+
+ logger.info('saving output...')
+ const outputFilename = 'guides.json'
+ await saveOutput(outputFilename, output.toJSON())
+
+ logger.info(`saved to "${path.join(API_DIR, outputFilename)}"`)
+}
+
+main()
+
+async function saveOutput(filename: string, json: string) {
+ const apiStorage = new Storage(API_DIR)
+ await apiStorage.save(filename, json)
+}
+
+async function loadWorkers() {
+ const dataStorage = new Storage(DATA_DIR)
+
+ const workers = await dataStorage.json('workers.json')
+
+ return Array.isArray(workers) ? workers : []
+}
+
+async function loadChannels() {
+ const sitesStorage = new Storage(SITES_DIR)
+
+ const files: string[] = await sitesStorage.list('**/*.channels.xml')
+
+ const channels = new Collection()
+ for (const filepath of files) {
+ const xml = await sitesStorage.load(filepath)
+ const parsedChannels = EPGGrabber.parseChannelsXML(xml)
+ const channelsFromXML = new Collection(parsedChannels).map(
+ (channel: epgGrabber.Channel) => new Channel(channel.toObject())
+ )
+
+ channelsFromXML.forEach((channel: Channel) => {
+ channels.add(channel)
+ })
+ }
+
+ return channels
+}
diff --git a/scripts/commands/guides/update.ts b/scripts/commands/guides/update.ts
index f1e830dde..cd9e50b04 100644
--- a/scripts/commands/guides/update.ts
+++ b/scripts/commands/guides/update.ts
@@ -1,118 +1,26 @@
import { HTMLTableRow, HTMLTableDataItem, HTMLTableColumn } from '../../types/htmlTable'
-import epgGrabber, { EPGGrabber } from 'epg-grabber'
-import AxiosMockAdapter from 'axios-mock-adapter'
+import { Worker, WorkerData, WorkerGuideSource } from '../../models'
+import { DATA_DIR, ROOT_DIR } from '../../constants'
import { Storage } from '@freearhey/storage-js'
-import { Channel, Worker } from '../../models'
import { Collection } from '@freearhey/core'
-import { ROOT_DIR } from '../../constants'
import { Logger } from '@freearhey/core'
import { HTMLTable } from '../../core'
-import epgParser from 'epg-parser'
-import axios from 'axios'
async function main() {
const logger = new Logger({ level: process.env.NODE_ENV === 'test' ? -999 : 3 })
- const rootStorage = new Storage(ROOT_DIR)
- const workers = new Map()
+ const dataStorage = new Storage(DATA_DIR)
- logger.info('loading workers.txt...')
- const workersTxt = await rootStorage.load('workers.txt')
+ logger.info('loading workers.json...')
+ const workers = await dataStorage.json('workers.json')
- workersTxt.split('\r\n').forEach((host: string) => {
- if (!host) return
-
- const worker = new Worker({ host })
-
- workers.set(host, worker)
- })
-
- for (const worker of workers.values()) {
- logger.info(`processing "${worker.host}"...`)
-
- const client = axios.create({
- baseURL: worker.getBaseUrl(),
- timeout: 60000
- })
-
- if (process.env.NODE_ENV === 'test') {
- const mock = new AxiosMockAdapter(client)
- if (worker.host === 'example.com') {
- mock.onGet('worker.json').reply(404)
- } else {
- const testStorage = new Storage('tests/__data__/input/guides_update')
- mock.onGet('worker.json').reply(200, await testStorage.load('worker.json'))
- mock.onGet('channels.xml').reply(200, await testStorage.load('channels.xml'))
- mock.onGet('guide.xml').reply(200, await testStorage.load('guide.xml'))
- }
- }
-
- const workerJson = await client
- .get('worker.json')
- .then(res => res.data)
- .catch(err => {
- worker.status = err.status
- logger.error(err.message)
- })
-
- if (!workerJson) {
- worker.status = 'MISSING_WORKER_CONFIG'
- logger.error('Unable to load "workers.json"')
- continue
- }
-
- worker.channelsPath = workerJson.channels
- worker.guideXmlPath =
- typeof workerJson.guide === 'string' ? workerJson.guide : workerJson?.guide?.xml
- worker.guideGzipPath = workerJson?.guide?.gzip
- worker.guideJsonPath = workerJson?.guide?.json
-
- if (!worker.channelsPath) {
- worker.status = 'MISSING_CHANNELS_PATH'
- logger.error('The "channels" property is missing from the workers config')
- continue
- }
-
- if (!worker.guideXmlPath) {
- worker.status = 'MISSING_GUIDE_XML_PATH'
- logger.error('The "guide" property is missing from the workers config')
- continue
- }
-
- const channelsXml = await client
- .get(worker.channelsPath)
- .then(res => res.data)
- .catch(err => {
- worker.status = err.status
- logger.error(err.message)
- })
-
- if (!channelsXml) continue
-
- const parsedChannels = EPGGrabber.parseChannelsXML(channelsXml)
- worker.channels = new Collection(parsedChannels).map(
- (channel: epgGrabber.Channel) => new Channel(channel.toObject())
- )
-
- const guideXml = await client
- .get(worker.guideXmlPath)
- .then(res => res.data)
- .catch(err => {
- worker.status = err.status
- logger.error(err.message)
- })
-
- if (!guideXml) continue
-
- const parsedGuide = epgParser.parse(guideXml)
- worker.lastUpdated = parsedGuide.date
-
- worker.status = 'OK'
- }
+ if (!Array.isArray(workers)) return
logger.info('creating guides table...')
const rows = new Collection()
- workers.forEach((worker: Worker) => {
- const links = worker.getLinks()
+ workers.forEach((data: WorkerData) => {
+ const worker = new Worker(data)
+
+ const sources = worker.getGuideSources()
rows.add(
new Collection([
{ value: worker.host },
@@ -120,8 +28,10 @@ async function main() {
{ value: worker.getChannelsCount().toString(), align: 'right' },
{ value: worker.getLastUpdated(), align: 'left' },
{
- value: links.length
- ? links.map(link => `${link.label}`).join(' | ')
+ value: sources.length
+ ? sources
+ .map((source: WorkerGuideSource) => `${source.format}`)
+ .join(' | ')
: '-'
}
])
@@ -129,6 +39,7 @@ async function main() {
})
logger.info('updating guides.md...')
+ const rootStorage = new Storage(ROOT_DIR)
const table = new HTMLTable(
rows,
new Collection([
diff --git a/scripts/commands/workers/load.ts b/scripts/commands/workers/load.ts
new file mode 100644
index 000000000..7145c2c2c
--- /dev/null
+++ b/scripts/commands/workers/load.ts
@@ -0,0 +1,122 @@
+import epgGrabber, { EPGGrabber } from 'epg-grabber'
+import { DATA_DIR, ROOT_DIR } from '../../constants'
+import AxiosMockAdapter from 'axios-mock-adapter'
+import { Storage } from '@freearhey/storage-js'
+import { Channel, Worker } from '../../models'
+import { Collection } from '@freearhey/core'
+import { Logger } from '@freearhey/core'
+import epgParser from 'epg-parser'
+import axios from 'axios'
+import path from 'path'
+
+async function main() {
+ const logger = new Logger({ level: process.env.NODE_ENV === 'test' ? -999 : 3 })
+ const rootStorage = new Storage(ROOT_DIR)
+ const workers = new Map()
+
+ logger.info('loading workers.txt...')
+ const workersTxt = await rootStorage.load('workers.txt')
+ const hosts = workersTxt.split('\r\n')
+
+ hosts.forEach((host: string) => {
+ if (!host) return
+
+ const worker = new Worker({ host })
+
+ workers.set(host, worker)
+ })
+
+ for (const worker of workers.values()) {
+ logger.info(`processing "${worker.host}"...`)
+
+ const client = axios.create({
+ baseURL: worker.getBaseUrl(),
+ timeout: 60000
+ })
+
+ if (process.env.NODE_ENV === 'test') {
+ const mock = new AxiosMockAdapter(client)
+ if (worker.host === 'example.com') {
+ mock.onGet('worker.json').reply(404)
+ } else {
+ const testStorage = new Storage('tests/__data__/input/workers_load')
+ mock.onGet('worker.json').reply(200, await testStorage.load('worker.json'))
+ mock.onGet('channels.xml').reply(200, await testStorage.load('channels.xml'))
+ mock.onGet('guide.xml').reply(200, await testStorage.load('guide.xml'))
+ }
+ }
+
+ const workerConfig = await client
+ .get('worker.json')
+ .then(res => res.data)
+ .catch(err => {
+ worker.setStatus(err.status)
+ logger.error(err.message)
+ })
+
+ if (!workerConfig) {
+ worker.setStatus('MISSING_WORKER_CONFIG')
+ logger.error('Unable to load "workers.json"')
+ continue
+ }
+
+ worker
+ .setChannelsPath(workerConfig.channels)
+ .setGuideXmlPath(
+ typeof workerConfig.guide === 'string' ? workerConfig.guide : workerConfig?.guide?.xml
+ )
+ .setGuideGzipPath(workerConfig?.guide?.gzip)
+ .setGuideJsonPath(workerConfig?.guide?.json)
+
+ if (!worker.channelsPath) {
+ worker.setStatus('MISSING_CHANNELS_PATH')
+ logger.error('The "channels" property is missing from the workers config')
+ continue
+ }
+
+ if (!worker.guideXmlPath) {
+ worker.setStatus('MISSING_GUIDE_XML_PATH')
+ logger.error('The "guide" property is missing from the workers config')
+ continue
+ }
+
+ const channelsXml = await client
+ .get(worker.channelsPath)
+ .then(res => res.data)
+ .catch(err => {
+ worker.status = err.status
+ logger.error(err.message)
+ })
+
+ if (!channelsXml) continue
+
+ const parsedChannels = EPGGrabber.parseChannelsXML(channelsXml)
+ worker.channels = new Collection(parsedChannels).map(
+ (channel: epgGrabber.Channel) => new Channel(channel.toObject())
+ )
+
+ const guideXml = await client
+ .get(worker.guideXmlPath)
+ .then(res => res.data)
+ .catch(err => {
+ worker.setStatus(err.status)
+ logger.error(err.message)
+ })
+
+ if (!guideXml) continue
+
+ const parsedGuide = epgParser.parse(guideXml)
+ worker.lastUpdated = parsedGuide.date
+
+ worker.setStatus('OK')
+ }
+
+ const output = [...workers.values()]
+ const dataStorage = new Storage(DATA_DIR)
+ const outputFilename = 'workers.json'
+ await dataStorage.save(outputFilename, JSON.stringify(output))
+
+ logger.info(`saved to "${path.join(DATA_DIR, outputFilename)}"`)
+}
+
+main()
diff --git a/scripts/models/apiGuide.ts b/scripts/models/apiGuide.ts
new file mode 100644
index 000000000..bb558897a
--- /dev/null
+++ b/scripts/models/apiGuide.ts
@@ -0,0 +1,49 @@
+import { WorkerGuideSource } from './worker'
+
+export interface ApiGuideData {
+ channel: string | null
+ feed: string | null
+ site: string
+ site_id: string
+ site_name: string
+ lang: string | null
+ sources?: WorkerGuideSource[]
+}
+
+export class ApiGuide {
+ channel: string | null
+ feed: string | null
+ site: string
+ site_id: string
+ site_name: string
+ lang: string
+ sources: WorkerGuideSource[] = []
+
+ constructor(data: ApiGuideData) {
+ this.channel = data.channel || null
+ this.feed = data.feed || null
+ this.site = data.site
+ this.site_id = data.site_id
+ this.site_name = data.site_name
+ this.lang = data.lang || 'en'
+ this.sources = data.sources || []
+ }
+
+ addSource(source: ApiGuideSource): this {
+ this.sources.push(source)
+
+ return this
+ }
+
+ toJSON(): ApiGuideData {
+ return {
+ channel: this.channel,
+ feed: this.feed,
+ site: this.site,
+ site_id: this.site_id,
+ site_name: this.site_name,
+ lang: this.lang,
+ sources: this.sources
+ }
+ }
+}
diff --git a/scripts/models/channel.ts b/scripts/models/channel.ts
index 087a39778..0ceef155b 100644
--- a/scripts/models/channel.ts
+++ b/scripts/models/channel.ts
@@ -1,22 +1,8 @@
-import { ChannelGuideObject } from '../types/channel'
import * as epgGrabber from 'epg-grabber'
import { SITES_DIR } from '../constants'
import path from 'node:path'
export class Channel extends epgGrabber.Channel {
- getGuideObject(): ChannelGuideObject {
- const [channelId, feedId] = this.xmltv_id.split('@')
-
- return {
- channel: channelId || null,
- feed: feedId || null,
- site: this.site,
- site_id: this.site_id,
- site_name: this.name,
- lang: this.lang || 'en'
- }
- }
-
getConfigPath(): string {
return path.resolve(SITES_DIR, `${this.site}/${this.site}.config.js`)
}
diff --git a/scripts/models/worker.ts b/scripts/models/worker.ts
index 71480e0bc..f9e6f835b 100644
--- a/scripts/models/worker.ts
+++ b/scripts/models/worker.ts
@@ -1,5 +1,6 @@
import relativeTime from 'dayjs/plugin/relativeTime'
import { Collection } from '@freearhey/core'
+import * as epgGrabber from 'epg-grabber'
import { Channel } from './channel'
import utc from 'dayjs/plugin/utc'
import dayjs from 'dayjs'
@@ -7,22 +8,79 @@ import dayjs from 'dayjs'
dayjs.extend(relativeTime)
dayjs.extend(utc)
+export interface WorkerGuideSource {
+ host: string
+ format: string
+ url: string
+}
+
export interface WorkerData {
host: string
+ channelsPath?: string
+ channels?: epgGrabber.Types.ChannelData[]
+ guideXmlPath?: string
+ guideGzipPath?: string
+ guideJsonPath?: string
+ status?: string
+ lastUpdated?: string
}
export class Worker {
host: string
channelsPath?: string
+ channels?: Collection
guideXmlPath?: string
guideGzipPath?: string
guideJsonPath?: string
- channels?: Collection
status?: string
lastUpdated?: string
constructor(data: WorkerData) {
this.host = data.host
+
+ this.update(data)
+ }
+
+ update(data: Partial): this {
+ if (data.host) this.host = data.host
+ if (data.channelsPath) this.channelsPath = data.channelsPath
+ if (data.guideXmlPath) this.guideXmlPath = data.guideXmlPath
+ if (data.guideGzipPath) this.guideGzipPath = data.guideGzipPath
+ if (data.guideJsonPath) this.guideJsonPath = data.guideJsonPath
+ if (data.status) this.status = data.status
+ if (data.lastUpdated) this.lastUpdated = data.lastUpdated
+
+ if (data.channels) {
+ const channelInstances = data.channels.map(c => new Channel(c))
+ this.channels = new Collection(channelInstances)
+ }
+
+ return this
+ }
+
+ setChannelsPath(path: string): this {
+ this.channelsPath = path
+ return this
+ }
+
+ setGuideXmlPath(path: string): this {
+ this.guideXmlPath = path
+ return this
+ }
+
+ setGuideGzipPath(path: string): this {
+ this.guideGzipPath = path
+ return this
+ }
+
+ setGuideJsonPath(path: string): this {
+ this.guideJsonPath = path
+ return this
+ }
+
+ setStatus(status: string): this {
+ this.status = status
+ return this
}
getBaseUrl(): string {
@@ -83,30 +141,42 @@ export class Worker {
getLastUpdated(): string {
if (!this.lastUpdated) return '-'
- let now = dayjs()
- if (process.env.NODE_ENV === 'test') now = dayjs.utc('2026-02-13')
+ const now = dayjs.utc(process.env.CURR_DATE || new Date().toISOString())
return dayjs.utc(this.lastUpdated).from(now)
}
- getLinks(): { url: string; label: string }[] {
- const links = []
+ getGuideSources(): WorkerGuideSource[] {
+ const sources = []
if (this.guideXmlPath) {
const url = new URL(this.guideXmlPath, this.getBaseUrl())
- links.push({ url: url.href, label: 'XML' })
+ sources.push({ host: this.host, url: url.href, format: 'XML' })
}
if (this.guideGzipPath) {
const url = new URL(this.guideGzipPath, this.getBaseUrl())
- links.push({ url: url.href, label: 'GZIP' })
+ sources.push({ host: this.host, url: url.href, format: 'GZIP' })
}
if (this.guideJsonPath) {
const url = new URL(this.guideJsonPath, this.getBaseUrl())
- links.push({ url: url.href, label: 'JSON' })
+ sources.push({ host: this.host, url: url.href, format: 'JSON' })
}
- return links
+ return sources
+ }
+
+ toJSON(): WorkerData {
+ return {
+ host: this.host,
+ channelsPath: this.channelsPath,
+ channels: this.channels ? this.channels.map(c => c.toObject()).all() : [],
+ guideXmlPath: this.guideXmlPath,
+ guideGzipPath: this.guideGzipPath,
+ guideJsonPath: this.guideJsonPath,
+ status: this.status,
+ lastUpdated: this.lastUpdated
+ }
}
}
diff --git a/scripts/types/channel.d.ts b/scripts/types/channel.d.ts
deleted file mode 100644
index 3bd35d6fc..000000000
--- a/scripts/types/channel.d.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-export interface ChannelGuideObject {
- channel: string | null
- feed: string | null
- site: string
- site_id: string
- site_name: string
- lang: string
-}
From 6ee8500bf571d8a3fbf02bf8a91cc6da79ef9202 Mon Sep 17 00:00:00 2001
From: freearhey <7253922+freearhey@users.noreply.github.com>
Date: Mon, 4 May 2026 18:02:00 +0300
Subject: [PATCH 13/16] Update package.json
---
package.json | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/package.json b/package.json
index f57f300d3..088c52191 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,6 @@
"act:format": "act workflow_dispatch -W .github/workflows/format.yml",
"act:update": "act workflow_dispatch -W .github/workflows/update.yml",
"api:load": "tsx scripts/commands/api/load.ts",
- "api:generate": "tsx scripts/commands/api/generate.ts",
"channels:lint": "tsx scripts/commands/channels/lint.mts",
"channels:parse": "tsx scripts/commands/channels/parse.ts",
"channels:format": "tsx scripts/commands/channels/format.ts",
@@ -14,6 +13,8 @@
"sites:init": "tsx scripts/commands/sites/init.ts",
"sites:update": "tsx scripts/commands/sites/update.ts",
"guides:update": "tsx scripts/commands/guides/update.ts",
+ "guides:export": "tsx scripts/commands/guides/export.ts",
+ "workers:load": "tsx scripts/commands/workers/load.ts",
"grab": "tsx scripts/commands/epg/grab.ts",
"lint": "npx eslint \"{scripts,tests,sites}/**/*.{ts,mts,js}\"",
"test": "cross-env TZ=Pacific/Nauru npx jest --runInBand",
From 9b8a12940f1d7bf35cfd41c6cbaf0aabf3b839f3 Mon Sep 17 00:00:00 2001
From: freearhey <7253922+freearhey@users.noreply.github.com>
Date: Mon, 4 May 2026 18:02:03 +0300
Subject: [PATCH 14/16] Update update.yml
---
.github/workflows/update.yml | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/update.yml b/.github/workflows/update.yml
index 2f504e4ef..619075213 100644
--- a/.github/workflows/update.yml
+++ b/.github/workflows/update.yml
@@ -45,6 +45,8 @@ jobs:
git commit -m "[Bot] Update ${FILE}" -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: load workers
+ run: npm run workers:load
- name: update guides.md
run: npm run guides:update
- run: git status
@@ -59,7 +61,7 @@ jobs:
git push
fi
- name: generate .api/guides.json
- run: npm run api:generate
+ run: npm run guides:export
- run: git status
- name: move .api/guides.json to iptv-org/api
uses: JamesIves/github-pages-deploy-action@4.1.1
From 1270f767cbd476b6fa9a43390b8aa2fa69b445d0 Mon Sep 17 00:00:00 2001
From: freearhey <7253922+freearhey@users.noreply.github.com>
Date: Mon, 4 May 2026 18:02:06 +0300
Subject: [PATCH 15/16] Update CONTRIBUTING.md
---
CONTRIBUTING.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8522a6145..5920c61f2 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -467,7 +467,6 @@ To run scripts use the `npm run ` 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).
- `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.
- `channels:lint`: сhecks the channel lists for syntax errors.
- `channels:parse`: generates a list of channels based on the site configuration.
- `channels:format`: formats `*.channels.xml` files. The process involves removing invalid `xmltv_id`, adding missing Feed ID, and sorting the list.
@@ -476,6 +475,8 @@ To run scripts use the `npm run ` command.
- `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).
- `guides:update`: updates the list of guides and their status in [GUIDES.md](GUIDES.md).
+- `guides:export`: exports a list of all guides in JSON format for the [iptv-org/api](https://github.com/iptv-org/api) repository.
+- `workers:load`: loads a list of all workers and their configurations.
- `grab`: downloads a program from a specified source.
- `serve`: starts the [web server](https://github.com/vercel/serve).
- `lint`: сhecks the scripts for syntax errors.
From 435e23314c1ff013072cb6f5eea63eea4c43db90 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Isma=C3=ABl=20Moret?=
<30985701+BellezaEmporium@users.noreply.github.com>
Date: Fri, 8 May 2026 23:22:03 +0200
Subject: [PATCH 16/16] Update README.md
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 18ce76ab7..499bce01b 100644
--- a/README.md
+++ b/README.md
@@ -55,7 +55,7 @@ Usage: npm run grab --- [options]
Options:
-s, --sites A comma-separated list of the sites to parse
- -c, --channels Path to *.channels.xml file (required if the "--site" attribute is
+ -c, --channels Path to *.channels.xml file (required if the "--sites" attribute is
not specified)
-o, --output Path to output file (default: "guide.xml")
-l, --lang Allows you to restrict downloading to channels in specified languages only (example: "en,id")