stricter ESLint configuration, linebreak on stylistic per deprecation by ESLint, fixed changes. add attibutes to prevent blockade.

This commit is contained in:
theofficialomega
2025-07-28 22:28:48 +02:00
parent 88652ab1ae
commit e3c7a372f2
41 changed files with 8471 additions and 8424 deletions

2
.gitattributes vendored Normal file
View File

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

View File

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

45
package-lock.json generated
View File

@@ -18,6 +18,7 @@
"@octokit/core": "^7.0.3", "@octokit/core": "^7.0.3",
"@octokit/plugin-paginate-rest": "^13.1.1", "@octokit/plugin-paginate-rest": "^13.1.1",
"@octokit/plugin-rest-endpoint-methods": "^16.0.0", "@octokit/plugin-rest-endpoint-methods": "^16.0.0",
"@stylistic/eslint-plugin": "^5.2.2",
"@swc/core": "^1.13.2", "@swc/core": "^1.13.2",
"@swc/jest": "^0.2.39", "@swc/jest": "^0.2.39",
"@types/cli-progress": "^3.11.6", "@types/cli-progress": "^3.11.6",
@@ -3146,6 +3147,50 @@
"@sinonjs/commons": "^3.0.1" "@sinonjs/commons": "^3.0.1"
} }
}, },
"node_modules/@stylistic/eslint-plugin": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.2.2.tgz",
"integrity": "sha512-bE2DUjruqXlHYP3Q2Gpqiuj2bHq7/88FnuaS0FjeGGLCy+X6a07bGVuwtiOYnPSLHR6jmx5Bwdv+j7l8H+G97A==",
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/types": "^8.37.0",
"eslint-visitor-keys": "^4.2.1",
"espree": "^10.4.0",
"estraverse": "^5.3.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"peerDependencies": {
"eslint": ">=9.0.0"
}
},
"node_modules/@stylistic/eslint-plugin/node_modules/eslint-visitor-keys": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@stylistic/eslint-plugin/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@swc/core": { "node_modules/@swc/core": {
"version": "1.13.2", "version": "1.13.2",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.2.tgz", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.2.tgz",

View File

@@ -46,6 +46,7 @@
"@octokit/core": "^7.0.3", "@octokit/core": "^7.0.3",
"@octokit/plugin-paginate-rest": "^13.1.1", "@octokit/plugin-paginate-rest": "^13.1.1",
"@octokit/plugin-rest-endpoint-methods": "^16.0.0", "@octokit/plugin-rest-endpoint-methods": "^16.0.0",
"@stylistic/eslint-plugin": "^5.2.2",
"@swc/core": "^1.13.2", "@swc/core": "^1.13.2",
"@swc/jest": "^0.2.39", "@swc/jest": "^0.2.39",
"@types/cli-progress": "^3.11.6", "@types/cli-progress": "^3.11.6",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,77 +1,77 @@
/** /**
* Sorts an array by the result of running each element through an iteratee function. * Sorts an array by the result of running each element through an iteratee function.
* Creates a shallow copy of the array before sorting to avoid mutating the original. * Creates a shallow copy of the array before sorting to avoid mutating the original.
* *
* @param {Array} arr - The array to sort * @param {Array} arr - The array to sort
* @param {Function} fn - The iteratee function to compute sort values * @param {Function} fn - The iteratee function to compute sort values
* @returns {Array} A new sorted array * @returns {Array} A new sorted array
* *
* @example * @example
* const users = [{name: 'john', age: 30}, {name: 'jane', age: 25}]; * const users = [{name: 'john', age: 30}, {name: 'jane', age: 25}];
* sortBy(users, x => x.age); // [{name: 'jane', age: 25}, {name: 'john', age: 30}] * sortBy(users, x => x.age); // [{name: 'jane', age: 25}, {name: 'john', age: 30}]
*/ */
export const sortBy = <T>(arr: T[], fn: (item: T) => number | string): T[] => export const sortBy = <T>(arr: T[], fn: (item: T) => number | string): T[] =>
[...arr].sort((a, b) => (fn(a) > fn(b) ? 1 : -1)) [...arr].sort((a, b) => (fn(a) > fn(b) ? 1 : -1))
/** /**
* Sorts an array by multiple criteria with customizable sort orders. * Sorts an array by multiple criteria with customizable sort orders.
* Supports ascending (default) and descending order for each criterion. * Supports ascending (default) and descending order for each criterion.
* *
* @param {Array} arr - The array to sort * @param {Array} arr - The array to sort
* @param {Array<Function>} fns - Array of iteratee functions to compute sort values * @param {Array<Function>} fns - Array of iteratee functions to compute sort values
* @param {Array<string>} orders - Array of sort orders ('asc' or 'desc'), defaults to all 'asc' * @param {Array<string>} orders - Array of sort orders ('asc' or 'desc'), defaults to all 'asc'
* @returns {Array} A new sorted array * @returns {Array} A new sorted array
* *
* @example * @example
* const users = [{name: 'john', age: 30}, {name: 'jane', age: 25}, {name: 'bob', age: 30}]; * const users = [{name: 'john', age: 30}, {name: 'jane', age: 25}, {name: 'bob', age: 30}];
* orderBy(users, [x => x.age, x => x.name], ['desc', 'asc']); * orderBy(users, [x => x.age, x => x.name], ['desc', 'asc']);
* // [{name: 'bob', age: 30}, {name: 'john', age: 30}, {name: 'jane', age: 25}] * // [{name: 'bob', age: 30}, {name: 'john', age: 30}, {name: 'jane', age: 25}]
*/ */
export const orderBy = ( export const orderBy = (
arr: Array<unknown>, arr: unknown[],
fns: Array<(item: unknown) => string | number>, fns: ((item: unknown) => string | number)[],
orders: Array<string> = [] orders: string[] = []
): Array<unknown> => ): unknown[] =>
[...arr].sort((a, b) => [...arr].sort((a, b) =>
fns.reduce( fns.reduce(
(acc, fn, i) => (acc, fn, i) =>
acc || acc ||
((orders[i] === 'desc' ? fn(b) > fn(a) : fn(a) > fn(b)) ? 1 : fn(a) === fn(b) ? 0 : -1), ((orders[i] === 'desc' ? fn(b) > fn(a) : fn(a) > fn(b)) ? 1 : fn(a) === fn(b) ? 0 : -1),
0 0
) )
) )
/** /**
* Creates a duplicate-free version of an array using an iteratee function to generate * Creates a duplicate-free version of an array using an iteratee function to generate
* the criterion by which uniqueness is computed. Only the first occurrence of each * the criterion by which uniqueness is computed. Only the first occurrence of each
* element is kept. * element is kept.
* *
* @param {Array} arr - The array to inspect * @param {Array} arr - The array to inspect
* @param {Function} fn - The iteratee function to compute uniqueness criterion * @param {Function} fn - The iteratee function to compute uniqueness criterion
* @returns {Array} A new duplicate-free array * @returns {Array} A new duplicate-free array
* *
* @example * @example
* const users = [{id: 1, name: 'john'}, {id: 2, name: 'jane'}, {id: 1, name: 'john'}]; * const users = [{id: 1, name: 'john'}, {id: 2, name: 'jane'}, {id: 1, name: 'john'}];
* uniqBy(users, x => x.id); // [{id: 1, name: 'john'}, {id: 2, name: 'jane'}] * uniqBy(users, x => x.id); // [{id: 1, name: 'john'}, {id: 2, name: 'jane'}]
*/ */
export const uniqBy = <T>(arr: T[], fn: (item: T) => unknown): T[] => export const uniqBy = <T>(arr: T[], fn: (item: T) => unknown): T[] =>
arr.filter((item, index) => arr.findIndex(x => fn(x) === fn(item)) === index) arr.filter((item, index) => arr.findIndex(x => fn(x) === fn(item)) === index)
/** /**
* Converts a string to start case (capitalizes the first letter of each word). * Converts a string to start case (capitalizes the first letter of each word).
* Handles camelCase, snake_case, kebab-case, and regular spaces. * Handles camelCase, snake_case, kebab-case, and regular spaces.
* *
* @param {string} str - The string to convert * @param {string} str - The string to convert
* @returns {string} The start case string * @returns {string} The start case string
* *
* @example * @example
* startCase('hello_world'); // "Hello World" * startCase('hello_world'); // "Hello World"
* startCase('helloWorld'); // "Hello World" * startCase('helloWorld'); // "Hello World"
* startCase('hello-world'); // "Hello World" * startCase('hello-world'); // "Hello World"
* startCase('hello world'); // "Hello World" * startCase('hello world'); // "Hello World"
*/ */
export const startCase = (str: string): string => export const startCase = (str: string): string =>
str str
.replace(/([a-z])([A-Z])/g, '$1 $2') // Split camelCase .replace(/([a-z])([A-Z])/g, '$1 $2') // Split camelCase
.replace(/[_-]/g, ' ') // Replace underscores and hyphens with spaces .replace(/[_-]/g, ' ') // Replace underscores and hyphens with spaces
.replace(/\b\w/g, c => c.toUpperCase()) // Capitalize first letter of each word .replace(/\b\w/g, c => c.toUpperCase()) // Capitalize first letter of each word

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,41 +1,41 @@
const { parser, url } = require('./tvim.tv.config.js') const { parser, url } = require('./tvim.tv.config.js')
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
const dayjs = require('dayjs') const dayjs = require('dayjs')
const utc = require('dayjs/plugin/utc') const utc = require('dayjs/plugin/utc')
const customParseFormat = require('dayjs/plugin/customParseFormat') const customParseFormat = require('dayjs/plugin/customParseFormat')
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
dayjs.extend(utc) dayjs.extend(utc)
const date = dayjs.utc('2021-10-24', 'YYYY-MM-DD').startOf('d') const date = dayjs.utc('2021-10-24', 'YYYY-MM-DD').startOf('d')
const channel = { site_id: 'T7', xmltv_id: 'T7.rs' } const channel = { site_id: 'T7', xmltv_id: 'T7.rs' }
const content = fs.readFileSync(path.resolve(__dirname, '__data__/content.json')) const content = fs.readFileSync(path.resolve(__dirname, '__data__/content.json'))
it('can generate valid url', () => { it('can generate valid url', () => {
const result = url({ date, channel }) const result = url({ date, channel })
expect(result).toBe( expect(result).toBe(
'https://www.tvim.tv/script/program_epg?date=24.10.2021&prog=T7&server_time=true' 'https://www.tvim.tv/script/program_epg?date=24.10.2021&prog=T7&server_time=true'
) )
}) })
it('can parse response', () => { it('can parse response', () => {
const result = parser({ date, channel, content }) const result = parser({ date, channel, content })
expect(result).toMatchObject([ expect(result).toMatchObject([
{ {
start: 'Sat, 23 Oct 2021 22:00:00 GMT', start: 'Sat, 23 Oct 2021 22:00:00 GMT',
stop: 'Sun, 24 Oct 2021 02:00:00 GMT', stop: 'Sun, 24 Oct 2021 02:00:00 GMT',
title: 'Programi i T7', title: 'Programi i T7',
description: 'Programi i T7', description: 'Programi i T7',
category: 'test' category: 'test'
} }
]) ])
}) })
it('can handle empty guide', () => { it('can handle empty guide', () => {
const result = parser({ const result = parser({
date, date,
channel, channel,
content: fs.readFileSync(path.resolve(__dirname, '__data__/no_content.json')) content: fs.readFileSync(path.resolve(__dirname, '__data__/no_content.json'))
}) })
expect(result).toMatchObject([]) expect(result).toMatchObject([])
}) })

View File

@@ -1,127 +1,127 @@
const cheerio = require('cheerio') const cheerio = require('cheerio')
const axios = require('axios') const axios = require('axios')
const { DateTime } = require('luxon') const { DateTime } = require('luxon')
module.exports = { module.exports = {
site: 'tvinsider.com', site: 'tvinsider.com',
days: 2, days: 2,
url({ channel }) { url({ channel }) {
return `https://www.tvinsider.com/network/${channel.site_id}/schedule/` return `https://www.tvinsider.com/network/${channel.site_id}/schedule/`
}, },
parser({ content, date }) { parser({ content, date }) {
const programs = [] const programs = []
const items = parseItems(content, date) const items = parseItems(content, date)
items.forEach(item => { items.forEach(item => {
const prev = programs[programs.length - 1] const prev = programs[programs.length - 1]
const $item = cheerio.load(item) const $item = cheerio.load(item)
const episodeInfo = parseEP($item) const episodeInfo = parseEP($item)
let start = parseStart($item, date) let start = parseStart($item, date)
if (!start) return if (!start) return
if (prev) { if (prev) {
prev.stop = start prev.stop = start
} }
const stop = start.plus({ minute: 30 }) const stop = start.plus({ minute: 30 })
programs.push({ programs.push({
title: parseTitle($item), title: parseTitle($item),
description: parseDescription($item), description: parseDescription($item),
category: parseCategory($item), category: parseCategory($item),
date: parseDate($item), date: parseDate($item),
...episodeInfo, ...episodeInfo,
subTitles: parseSubtitle($item), subTitles: parseSubtitle($item),
previouslyShown: parsePreviously($item), previouslyShown: parsePreviously($item),
start, start,
stop stop
}) })
}) })
return programs return programs
}, },
async channels() { async channels() {
const html = await axios const html = await axios
.get('https://www.tvinsider.com/network/5-star-max/') .get('https://www.tvinsider.com/network/5-star-max/')
.then(r => r.data) .then(r => r.data)
.catch(console.log) .catch(console.log)
const $ = cheerio.load(html) const $ = cheerio.load(html)
const items = $('body > main > section > select > option').toArray() const items = $('body > main > section > select > option').toArray()
const channels = [] const channels = []
items.forEach(item => { items.forEach(item => {
const name = $(item).text().trim() const name = $(item).text().trim()
const path = $(item).attr('value') const path = $(item).attr('value')
if (!path) return if (!path) return
const [, , site_id] = path.split('/') || [null, null, null] const [, , site_id] = path.split('/') || [null, null, null]
if (!site_id) return if (!site_id) return
channels.push({ channels.push({
lang: 'en', lang: 'en',
site_id, site_id,
name name
}) })
}) })
return channels return channels
} }
} }
function parseTitle($item) { function parseTitle($item) {
return $item('h3').text().trim() return $item('h3').text().trim()
} }
function parseEP($item){ function parseEP($item){
const text = $item('h6').text().trim() const text = $item('h6').text().trim()
const match = text.match(/Season\s+(\d+)\s*•\s*Episode\s+(\d+)/i) const match = text.match(/Season\s+(\d+)\s*•\s*Episode\s+(\d+)/i)
if (!match) return {} // Return an empty object if no match, so properties are undefined later if (!match) return {} // Return an empty object if no match, so properties are undefined later
const season = parseInt(match[1], 10) const season = parseInt(match[1], 10)
const episode = parseInt(match[2], 10) const episode = parseInt(match[2], 10)
return { season, episode } // Return an object with season and episode return { season, episode } // Return an object with season and episode
} }
function parseSubtitle($item) { function parseSubtitle($item) {
return $item('h5').text().trim() return $item('h5').text().trim()
} }
function parsePreviously($item){ function parsePreviously($item){
const h3Text = $item('h3').text().trim() const h3Text = $item('h3').text().trim()
const isNewShow = /New$/.test(h3Text) const isNewShow = /New$/.test(h3Text)
if (isNewShow) { if (isNewShow) {
return null return null
} else { } else {
return {} return {}
} }
} }
function parseDescription($item) { function parseDescription($item) {
return $item('p').text().trim() return $item('p').text().trim()
} }
function parseCategory($item) { function parseCategory($item) {
const [category] = $item('h4').text().trim().split(' • ') const [category] = $item('h4').text().trim().split(' • ')
return category return category
} }
function parseDate($item) { function parseDate($item) {
const [, date] = $item('h4').text().trim().split(' • ') const [, date] = $item('h4').text().trim().split(' • ')
return date return date
} }
function parseStart($item, date) { function parseStart($item, date) {
let time = $item('time').text().trim() let time = $item('time').text().trim()
time = `${date.format('YYYY-MM-DD')} ${time}` time = `${date.format('YYYY-MM-DD')} ${time}`
return DateTime.fromFormat(time, 'yyyy-MM-dd t', { zone: 'America/New_York' }).toUTC() return DateTime.fromFormat(time, 'yyyy-MM-dd t', { zone: 'America/New_York' }).toUTC()
} }
function parseItems(content, date) { function parseItems(content, date) {
const $ = cheerio.load(content) const $ = cheerio.load(content)
return $(`#${date.format('MM-DD-YYYY')}`) return $(`#${date.format('MM-DD-YYYY')}`)
.next() .next()
.find('a') .find('a')
.toArray() .toArray()
} }

View File

@@ -1,99 +1,99 @@
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')
const { uniqBy } = require('../../scripts/functions') const { uniqBy } = require('../../scripts/functions')
dayjs.extend(utc) dayjs.extend(utc)
dayjs.extend(timezone) dayjs.extend(timezone)
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
module.exports = { module.exports = {
site: 'tvireland.ie', site: 'tvireland.ie',
days: 2, days: 2,
url: function ({ date, channel }) { url: function ({ date, channel }) {
return `https://www.tvireland.ie/tv/listings/channel/${channel.site_id}?dt=${date.format( return `https://www.tvireland.ie/tv/listings/channel/${channel.site_id}?dt=${date.format(
'YYYY-MM-DD' 'YYYY-MM-DD'
)}` )}`
}, },
parser: function ({ content, date, channel }) { parser: function ({ content, date, channel }) {
const programs = [] const programs = []
const items = parseItems(content) const items = parseItems(content)
items.forEach(item => { items.forEach(item => {
const prev = programs[programs.length - 1] const prev = programs[programs.length - 1]
const $item = cheerio.load(item) const $item = cheerio.load(item)
let start = parseStart($item, date, channel) let start = parseStart($item, date, channel)
if (prev) { if (prev) {
if (start.isBefore(prev.start)) { if (start.isBefore(prev.start)) {
start = start.add(1, 'd') start = start.add(1, 'd')
date = date.add(1, 'd') date = date.add(1, 'd')
} }
prev.stop = start prev.stop = start
} }
const stop = start.add(30, 'm') const stop = start.add(30, 'm')
programs.push({ programs.push({
title: parseTitle($item), title: parseTitle($item),
start, start,
stop stop
}) })
}) })
return programs return programs
}, },
async channels() { async channels() {
const axios = require('axios') const axios = require('axios')
const providers = ['-9000019', '-8000019', '-1000019', '-2000019', '-7000019'] const providers = ['-9000019', '-8000019', '-1000019', '-2000019', '-7000019']
const channels = [] const channels = []
for (let provider of providers) { for (let provider of providers) {
const data = await axios const data = await axios
.post('https://www.tvireland.ie/tv/schedule', null, { .post('https://www.tvireland.ie/tv/schedule', null, {
params: { params: {
provider, provider,
region: 'Ireland', region: 'Ireland',
TVperiod: 'Night', TVperiod: 'Night',
date: dayjs().format('YYYY-MM-DD'), date: dayjs().format('YYYY-MM-DD'),
st: 0, st: 0,
u_time: 2027, u_time: 2027,
is_mobile: 1 is_mobile: 1
} }
}) })
.then(r => r.data) .then(r => r.data)
.catch(console.log) .catch(console.log)
const $ = cheerio.load(data) const $ = cheerio.load(data)
$('.channelname').each((i, el) => { $('.channelname').each((i, el) => {
const name = $(el).find('center > a:eq(1)').text() const name = $(el).find('center > a:eq(1)').text()
const url = $(el).find('center > a:eq(1)').attr('href') const url = $(el).find('center > a:eq(1)').attr('href')
const [, number, slug] = url.match(/\/(\d+)\/(.*)\.html$/) const [, number, slug] = url.match(/\/(\d+)\/(.*)\.html$/)
channels.push({ channels.push({
lang: 'en', lang: 'en',
name, name,
site_id: `${number}/${slug}` site_id: `${number}/${slug}`
}) })
}) })
} }
return uniqBy(channels, x => x.site_id) return uniqBy(channels, x => x.site_id)
} }
} }
function parseStart($item, date) { function parseStart($item, date) {
const timeString = $item('td:eq(0)').text().trim() const timeString = $item('td:eq(0)').text().trim()
const dateString = `${date.format('YYYY-MM-DD')} ${timeString}` const dateString = `${date.format('YYYY-MM-DD')} ${timeString}`
return dayjs.tz(dateString, 'YYYY-MM-DD H:mm a', 'Europe/Dublin') return dayjs.tz(dateString, 'YYYY-MM-DD H:mm a', 'Europe/Dublin')
} }
function parseTitle($item) { function parseTitle($item) {
return $item('td:eq(1)').text().trim() return $item('td:eq(1)').text().trim()
} }
function parseItems(content) { function parseItems(content) {
const $ = cheerio.load(content) const $ = cheerio.load(content)
return $('table.table > tbody > tr').toArray() return $('table.table > tbody > tr').toArray()
} }

View File

@@ -1,81 +1,81 @@
const axios = require('axios') const axios = require('axios')
const dayjs = require('dayjs') const dayjs = require('dayjs')
const { uniqBy } = require('../../scripts/functions') const { uniqBy } = require('../../scripts/functions')
module.exports = { module.exports = {
site: 'tvmusor.hu', site: 'tvmusor.hu',
days: 2, days: 2,
url: 'https://tvmusor.borsonline.hu/a/get-events/', url: 'https://tvmusor.borsonline.hu/a/get-events/',
request: { request: {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
}, },
data({ channel, date }) { data({ channel, date }) {
const params = new URLSearchParams() const params = new URLSearchParams()
params.append( params.append(
'data', 'data',
JSON.stringify({ JSON.stringify({
blocks: [`${channel.site_id}|${date.format('YYYY-MM-DD')}`] blocks: [`${channel.site_id}|${date.format('YYYY-MM-DD')}`]
}) })
) )
return params return params
} }
}, },
parser({ content, channel, date }) { parser({ content, channel, date }) {
let programs = [] let programs = []
const items = parseItems(content, channel, date) const items = parseItems(content, channel, date)
items.forEach(item => { items.forEach(item => {
const prev = programs[programs.length - 1] const prev = programs[programs.length - 1]
let start = dayjs(item.e) let start = dayjs(item.e)
let stop = dayjs(item.f) let stop = dayjs(item.f)
if (prev) { if (prev) {
start = prev.stop start = prev.stop
} }
programs.push({ programs.push({
title: item.j, title: item.j,
category: item.h, category: item.h,
description: item.c, description: item.c,
image: parseImage(item), image: parseImage(item),
start, start,
stop stop
}) })
}) })
return programs return programs
}, },
async channels() { async channels() {
const data = await axios const data = await axios
.get('https://tvmusor.borsonline.hu/most/') .get('https://tvmusor.borsonline.hu/most/')
.then(r => r.data) .then(r => r.data)
.catch(console.log) .catch(console.log)
const [, channelData] = data.match(/const CHANNEL_DATA = (.*);/) const [, channelData] = data.match(/const CHANNEL_DATA = (.*);/)
const json = channelData.replace('},}', '}}').replace(/(\d+):/g, '"$1":') const json = channelData.replace('},}', '}}').replace(/(\d+):/g, '"$1":')
const channels = JSON.parse(json) const channels = JSON.parse(json)
return Object.values(channels).map(item => { return Object.values(channels).map(item => {
return { return {
lang: 'hu', lang: 'hu',
site_id: item.id, site_id: item.id,
name: item.name name: item.name
} }
}) })
} }
} }
function parseImage(item) { function parseImage(item) {
return item.z ? `https://tvmusor.borsonline.hu/images/events/408/${item.z}` : null return item.z ? `https://tvmusor.borsonline.hu/images/events/408/${item.z}` : null
} }
function parseItems(content, channel, date) { function parseItems(content, channel, date) {
const data = JSON.parse(content) const data = JSON.parse(content)
if (!data || !data.data || !data.data.loadedBlocks) return [] if (!data || !data.data || !data.data.loadedBlocks) return []
const blocks = data.data.loadedBlocks const blocks = data.data.loadedBlocks
const blockId = `${channel.site_id}_${date.format('YYYY-MM-DD')}` const blockId = `${channel.site_id}_${date.format('YYYY-MM-DD')}`
if (!Array.isArray(blocks[blockId])) return [] if (!Array.isArray(blocks[blockId])) return []
return uniqBy(uniqBy(blocks[blockId], a => a.e), b => b.b) return uniqBy(uniqBy(blocks[blockId], a => a.e), b => b.b)
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,213 +1,213 @@
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 customParseFormat = require('dayjs/plugin/customParseFormat') const customParseFormat = require('dayjs/plugin/customParseFormat')
let X_CSRFTOKEN let X_CSRFTOKEN
let Cookie let Cookie
const cookiesToExtract = ['JSESSIONID', 'CSESSIONID', 'CSRFSESSION'] const cookiesToExtract = ['JSESSIONID', 'CSESSIONID', 'CSRFSESSION']
dayjs.extend(utc) dayjs.extend(utc)
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
module.exports = { module.exports = {
site: 'web.magentatv.de', site: 'web.magentatv.de',
days: 2, days: 2,
url: 'https://api.prod.sngtv.magentatv.de/EPG/JSON/PlayBillList', url: 'https://api.prod.sngtv.magentatv.de/EPG/JSON/PlayBillList',
request: { request: {
method: 'POST', method: 'POST',
async headers() { async headers() {
return await setHeaders() return await setHeaders()
}, },
data({ channel, date }) { data({ channel, date }) {
return { return {
count: -1, count: -1,
isFillProgram: 1, isFillProgram: 1,
offset: 0, offset: 0,
properties: [ properties: [
{ {
include: include:
'endtime,genres,id,name,starttime,channelid,pictures,introduce,subName,seasonNum,subNum,cast,country,producedate,externalIds', 'endtime,genres,id,name,starttime,channelid,pictures,introduce,subName,seasonNum,subNum,cast,country,producedate,externalIds',
name: 'playbill' name: 'playbill'
} }
], ],
type: 2, type: 2,
begintime: date.format('YYYYMMDD000000'), begintime: date.format('YYYYMMDD000000'),
channelid: channel.site_id, channelid: channel.site_id,
endtime: date.add(1, 'd').format('YYYYMMDD000000') endtime: date.add(1, 'd').format('YYYYMMDD000000')
} }
} }
}, },
parser({ content }) { parser({ content }) {
const programs = [] const programs = []
const items = parseItems(content) const items = parseItems(content)
items.forEach(item => { items.forEach(item => {
programs.push({ programs.push({
title: item.name, title: item.name,
description: item.introduce, description: item.introduce,
image: parseImage(item), image: parseImage(item),
category: parseCategory(item), category: parseCategory(item),
start: parseStart(item), start: parseStart(item),
stop: parseStop(item), stop: parseStop(item),
sub_title: item.subName, sub_title: item.subName,
season: item.seasonNum, season: item.seasonNum,
episode: item.subNum, episode: item.subNum,
directors: parseDirectors(item), directors: parseDirectors(item),
producers: parseProducers(item), producers: parseProducers(item),
adapters: parseAdapters(item), adapters: parseAdapters(item),
country: item.country?.toUpperCase(), country: item.country?.toUpperCase(),
date: item.producedate, date: item.producedate,
urls: parseUrls(item) urls: parseUrls(item)
}) })
}) })
return programs return programs
}, },
async channels() { async channels() {
const url = 'https://api.prod.sngtv.magentatv.de/EPG/JSON/AllChannel' const url = 'https://api.prod.sngtv.magentatv.de/EPG/JSON/AllChannel'
const body = { const body = {
channelNamespace: 2, channelNamespace: 2,
filterlist: [ filterlist: [
{ {
key: 'IsHide', key: 'IsHide',
value: '-1' value: '-1'
} }
], ],
metaDataVer: 'Channel/1.1', metaDataVer: 'Channel/1.1',
properties: [ properties: [
{ {
include: '/channellist/logicalChannel/contentId,/channellist/logicalChannel/name', include: '/channellist/logicalChannel/contentId,/channellist/logicalChannel/name',
name: 'logicalChannel' name: 'logicalChannel'
} }
], ],
returnSatChannel: 0 returnSatChannel: 0
} }
const params = { const params = {
headers: await setHeaders() headers: await setHeaders()
} }
const data = await axios const data = await axios
.post(url, body, params) .post(url, body, params)
.then(r => r.data) .then(r => r.data)
.catch(console.log) .catch(console.log)
return data.channellist.map(item => { return data.channellist.map(item => {
return { return {
lang: 'de', lang: 'de',
site_id: item.contentId, site_id: item.contentId,
name: item.name name: item.name
} }
}) })
} }
} }
function parseCategory(item) { function parseCategory(item) {
return item.genres return item.genres
? item.genres ? item.genres
.replace('und', ',') .replace('und', ',')
.split(',') .split(',')
.map(i => i.trim()) .map(i => i.trim())
: [] : []
} }
function parseDirectors(item) { function parseDirectors(item) {
if (!item.cast || !item.cast.director) return [] if (!item.cast || !item.cast.director) return []
return item.cast.director return item.cast.director
.replace('und', ',') .replace('und', ',')
.split(',') .split(',')
.map(i => i.trim()) .map(i => i.trim())
} }
function parseProducers(item) { function parseProducers(item) {
if (!item.cast || !item.cast.producer) return [] if (!item.cast || !item.cast.producer) return []
return item.cast.producer return item.cast.producer
.replace('und', ',') .replace('und', ',')
.split(',') .split(',')
.map(i => i.trim()) .map(i => i.trim())
} }
function parseAdapters(item) { function parseAdapters(item) {
if (!item.cast || !item.cast.adaptor) return [] if (!item.cast || !item.cast.adaptor) return []
return item.cast.adaptor return item.cast.adaptor
.replace('und', ',') .replace('und', ',')
.split(',') .split(',')
.map(i => i.trim()) .map(i => i.trim())
} }
function parseUrls(item) { function parseUrls(item) {
// currently only a imdb id is returned by the api, thus we can construct the url here // currently only a imdb id is returned by the api, thus we can construct the url here
if (!item.externalIds) return [] if (!item.externalIds) return []
return JSON.parse(item.externalIds) return JSON.parse(item.externalIds)
.filter(externalId => externalId.type === 'imdb' && externalId.id) .filter(externalId => externalId.type === 'imdb' && externalId.id)
.map(externalId => ({ system: 'imdb', value: `https://www.imdb.com/title/${externalId.id}` })) .map(externalId => ({ system: 'imdb', value: `https://www.imdb.com/title/${externalId.id}` }))
} }
function parseImage(item) { function parseImage(item) {
if (!Array.isArray(item.pictures) || !item.pictures.length) return null if (!Array.isArray(item.pictures) || !item.pictures.length) return null
return item.pictures[0].href return item.pictures[0].href
} }
function parseStart(item) { function parseStart(item) {
return dayjs.utc(item.starttime, 'YYYY-MM-DD HH:mm:ss') return dayjs.utc(item.starttime, 'YYYY-MM-DD HH:mm:ss')
} }
function parseStop(item) { function parseStop(item) {
return dayjs.utc(item.endtime, 'YYYY-MM-DD HH:mm:ss') return dayjs.utc(item.endtime, 'YYYY-MM-DD HH:mm:ss')
} }
function parseItems(content) { function parseItems(content) {
const data = JSON.parse(content) const data = JSON.parse(content)
if (!data || !Array.isArray(data.playbilllist)) return [] if (!data || !Array.isArray(data.playbilllist)) return []
return data.playbilllist return data.playbilllist
} }
async function fetchCookieAndToken() { async function fetchCookieAndToken() {
// Only fetch the cookies and csrfToken if they are not already set // Only fetch the cookies and csrfToken if they are not already set
if (X_CSRFTOKEN && Cookie) { if (X_CSRFTOKEN && Cookie) {
return return
} }
try { try {
const response = await axios.request({ const response = await axios.request({
url: 'https://api.prod.sngtv.magentatv.de/EPG/JSON/Authenticate', url: 'https://api.prod.sngtv.magentatv.de/EPG/JSON/Authenticate',
params: { params: {
SID: 'firstup', SID: 'firstup',
T: 'Windows_chrome_118' T: 'Windows_chrome_118'
}, },
method: 'POST', method: 'POST',
data: '{"terminalid":"00:00:00:00:00:00","mac":"00:00:00:00:00:00","terminaltype":"WEBTV","utcEnable":1,"timezone":"Etc/GMT0","userType":3,"terminalvendor":"Unknown"}', data: '{"terminalid":"00:00:00:00:00:00","mac":"00:00:00:00:00:00","terminaltype":"WEBTV","utcEnable":1,"timezone":"Etc/GMT0","userType":3,"terminalvendor":"Unknown"}',
}) })
// Extract the cookies specified in cookiesToExtract // Extract the cookies specified in cookiesToExtract
const setCookieHeader = response.headers['set-cookie'] || [] const setCookieHeader = response.headers['set-cookie'] || []
const extractedCookies = [] const extractedCookies = []
cookiesToExtract.forEach(cookieName => { cookiesToExtract.forEach(cookieName => {
const regex = new RegExp(`${cookieName}=(.+?)(;|$)`) const regex = new RegExp(`${cookieName}=(.+?)(;|$)`)
const match = setCookieHeader.find(header => regex.test(header)) const match = setCookieHeader.find(header => regex.test(header))
if (match) { if (match) {
const cookieString = regex.exec(match)[0] const cookieString = regex.exec(match)[0]
extractedCookies.push(cookieString) extractedCookies.push(cookieString)
} }
}) })
// check if we recieved a csrfToken only then store the values // check if we recieved a csrfToken only then store the values
if (!response.data.csrfToken) { if (!response.data.csrfToken) {
console.log('csrfToken not found in the response.') console.log('csrfToken not found in the response.')
return return
} }
X_CSRFTOKEN = response.data.csrfToken X_CSRFTOKEN = response.data.csrfToken
Cookie = extractedCookies.join(' ') Cookie = extractedCookies.join(' ')
} catch(error) { } catch(error) {
console.error(error) console.error(error)
} }
} }
async function setHeaders() { async function setHeaders() {
await fetchCookieAndToken() await fetchCookieAndToken()
return { X_CSRFTOKEN, Cookie } return { X_CSRFTOKEN, Cookie }
} }

View File

@@ -1,83 +1,83 @@
import { execSync } from 'child_process' import { execSync } from 'child_process'
type ExecError = { interface ExecError {
status: number status: number
stdout: string stdout: string
} }
describe('channels:lint', () => { describe('channels:lint', () => {
it('will show a message if the file contains a syntax error', () => { it('will show a message if the file contains a syntax error', () => {
try { try {
const cmd = 'npm run channels:lint --- tests/__data__/input/channels_lint/error.channels.xml' const cmd = 'npm run channels:lint --- tests/__data__/input/channels_lint/error.channels.xml'
const stdout = execSync(cmd, { encoding: 'utf8' }) const stdout = execSync(cmd, { encoding: 'utf8' })
if (process.env.DEBUG === 'true') console.log(cmd, stdout) if (process.env.DEBUG === 'true') console.log(cmd, stdout)
process.exit(1) process.exit(1)
} catch (error) { } catch (error) {
expect((error as ExecError).status).toBe(1) expect((error as ExecError).status).toBe(1)
expect((error as ExecError).stdout).toContain( expect((error as ExecError).stdout).toContain(
"error.channels.xml\n 3:0 Element 'channel': The attribute 'lang' is required but missing.\n\n1 error(s)\n" "error.channels.xml\n 3:0 Element 'channel': The attribute 'lang' is required but missing.\n\n1 error(s)\n"
) )
} }
}) })
it('will show a message if an error occurred while parsing an xml file', () => { it('will show a message if an error occurred while parsing an xml file', () => {
try { try {
const cmd = const cmd =
'npm run channels:lint --- tests/__data__/input/channels_lint/invalid.channels.xml' 'npm run channels:lint --- tests/__data__/input/channels_lint/invalid.channels.xml'
const stdout = execSync(cmd, { encoding: 'utf8' }) const stdout = execSync(cmd, { encoding: 'utf8' })
if (process.env.DEBUG === 'true') console.log(cmd, stdout) if (process.env.DEBUG === 'true') console.log(cmd, stdout)
process.exit(1) process.exit(1)
} catch (error) { } catch (error) {
expect((error as ExecError).status).toBe(1) expect((error as ExecError).status).toBe(1)
expect((error as ExecError).stdout).toContain( expect((error as ExecError).stdout).toContain(
'invalid.channels.xml\n 2:6 XML declaration allowed only at the start of the document\n' 'invalid.channels.xml\n 2:6 XML declaration allowed only at the start of the document\n'
) )
} }
}) })
it('can test multiple files at ones', () => { it('can test multiple files at ones', () => {
try { try {
const cmd = const cmd =
'npm run channels:lint --- tests/__data__/input/channels_lint/error.channels.xml tests/__data__/input/channels_lint/invalid.channels.xml' 'npm run channels:lint --- tests/__data__/input/channels_lint/error.channels.xml tests/__data__/input/channels_lint/invalid.channels.xml'
const stdout = execSync(cmd, { encoding: 'utf8' }) const stdout = execSync(cmd, { encoding: 'utf8' })
if (process.env.DEBUG === 'true') console.log(cmd, stdout) if (process.env.DEBUG === 'true') console.log(cmd, stdout)
process.exit(1) process.exit(1)
} catch (error) { } catch (error) {
expect((error as ExecError).status).toBe(1) expect((error as ExecError).status).toBe(1)
expect((error as ExecError).stdout).toContain( expect((error as ExecError).stdout).toContain(
"error.channels.xml\n 3:0 Element 'channel': The attribute 'lang' is required but missing.\n" "error.channels.xml\n 3:0 Element 'channel': The attribute 'lang' is required but missing.\n"
) )
expect((error as ExecError).stdout).toContain( expect((error as ExecError).stdout).toContain(
'invalid.channels.xml\n 2:6 XML declaration allowed only at the start of the document\n' 'invalid.channels.xml\n 2:6 XML declaration allowed only at the start of the document\n'
) )
expect((error as ExecError).stdout).toContain('2 error(s)') expect((error as ExecError).stdout).toContain('2 error(s)')
} }
}) })
it('will show a message if the file contains single quotes', () => { it('will show a message if the file contains single quotes', () => {
try { try {
const cmd = const cmd =
'npm run channels:lint --- tests/__data__/input/channels_lint/single_quotes.channels.xml' 'npm run channels:lint --- tests/__data__/input/channels_lint/single_quotes.channels.xml'
const stdout = execSync(cmd, { encoding: 'utf8' }) const stdout = execSync(cmd, { encoding: 'utf8' })
if (process.env.DEBUG === 'true') console.log(cmd, stdout) if (process.env.DEBUG === 'true') console.log(cmd, stdout)
process.exit(1) process.exit(1)
} catch (error) { } catch (error) {
expect((error as ExecError).status).toBe(1) expect((error as ExecError).status).toBe(1)
expect((error as ExecError).stdout).toContain('single_quotes.channels.xml') expect((error as ExecError).stdout).toContain('single_quotes.channels.xml')
expect((error as ExecError).stdout).toContain( expect((error as ExecError).stdout).toContain(
'1:14 Single quotes cannot be used in attributes' '1:14 Single quotes cannot be used in attributes'
) )
} }
}) })
it('does not display errors if there are none', () => { it('does not display errors if there are none', () => {
try { try {
const cmd = 'npm run channels:lint --- tests/__data__/input/channels_lint/valid.channels.xml' const cmd = 'npm run channels:lint --- tests/__data__/input/channels_lint/valid.channels.xml'
const stdout = execSync(cmd, { encoding: 'utf8' }) const stdout = execSync(cmd, { encoding: 'utf8' })
if (process.env.DEBUG === 'true') console.log(cmd, stdout) if (process.env.DEBUG === 'true') console.log(cmd, stdout)
} catch (error) { } catch (error) {
if (process.env.DEBUG === 'true') console.log((error as ExecError).stdout) if (process.env.DEBUG === 'true') console.log((error as ExecError).stdout)
process.exit(1) process.exit(1)
} }
}) })
}) })

View File

@@ -1,70 +1,70 @@
import { execSync } from 'child_process' import { execSync } from 'child_process'
type ExecError = { interface ExecError {
status: number status: number
stdout: string stdout: string
} }
const ENV_VAR = 'cross-env DATA_DIR=tests/__data__/input/__data__' const ENV_VAR = 'cross-env DATA_DIR=tests/__data__/input/__data__'
describe('channels:validate', () => { describe('channels:validate', () => {
it('will show a message if the file contains a duplicate', () => { it('will show a message if the file contains a duplicate', () => {
try { try {
const cmd = `${ENV_VAR} npm run channels:validate --- tests/__data__/input/channels_validate/duplicate.channels.xml` const cmd = `${ENV_VAR} npm run channels:validate --- tests/__data__/input/channels_validate/duplicate.channels.xml`
const stdout = execSync(cmd, { encoding: 'utf8' }) const stdout = execSync(cmd, { encoding: 'utf8' })
if (process.env.DEBUG === 'true') console.log(cmd, stdout) if (process.env.DEBUG === 'true') console.log(cmd, stdout)
process.exit(1) process.exit(1)
} catch (error) { } catch (error) {
expect((error as ExecError).status).toBe(1) expect((error as ExecError).status).toBe(1)
expect((error as ExecError).stdout).toContain(` expect((error as ExecError).stdout).toContain(`
┌─────────┬─────────────┬──────┬─────────────────┬─────────┬─────────┐ ┌─────────┬─────────────┬──────┬─────────────────┬─────────┬─────────┐
│ (index) │ type │ lang │ xmltv_id │ site_id │ name │ │ (index) │ type │ lang │ xmltv_id │ site_id │ name │
├─────────┼─────────────┼──────┼─────────────────┼─────────┼─────────┤ ├─────────┼─────────────┼──────┼─────────────────┼─────────┼─────────┤
│ 0 │ 'duplicate' │ 'en' │ 'Bravo.us@East' │ '140' │ 'Bravo' │ │ 0 │ 'duplicate' │ 'en' │ 'Bravo.us@East' │ '140' │ 'Bravo' │
└─────────┴─────────────┴──────┴─────────────────┴─────────┴─────────┘ └─────────┴─────────────┴──────┴─────────────────┴─────────┴─────────┘
1 error(s) in 1 file(s) 1 error(s) in 1 file(s)
`) `)
} }
}) })
it('will show a message if the file contains a channel with wrong channel id', () => { it('will show a message if the file contains a channel with wrong channel id', () => {
try { try {
const cmd = `${ENV_VAR} npm run channels:validate --- tests/__data__/input/channels_validate/wrong_channel_id.channels.xml` const cmd = `${ENV_VAR} npm run channels:validate --- tests/__data__/input/channels_validate/wrong_channel_id.channels.xml`
const stdout = execSync(cmd, { encoding: 'utf8' }) const stdout = execSync(cmd, { encoding: 'utf8' })
if (process.env.DEBUG === 'true') console.log(cmd, stdout) if (process.env.DEBUG === 'true') console.log(cmd, stdout)
process.exit(1) process.exit(1)
} catch (error) { } catch (error) {
expect((error as ExecError).status).toBe(1) expect((error as ExecError).status).toBe(1)
expect((error as ExecError).stdout).toContain(` expect((error as ExecError).stdout).toContain(`
┌─────────┬────────────────────┬──────┬────────────────────┬─────────┬─────────────────────┐ ┌─────────┬────────────────────┬──────┬────────────────────┬─────────┬─────────────────────┐
│ (index) │ type │ lang │ xmltv_id │ site_id │ name │ │ (index) │ type │ lang │ xmltv_id │ site_id │ name │
├─────────┼────────────────────┼──────┼────────────────────┼─────────┼─────────────────────┤ ├─────────┼────────────────────┼──────┼────────────────────┼─────────┼─────────────────────┤
│ 0 │ 'wrong_channel_id' │ 'en' │ 'CNNInternational' │ '140' │ 'CNN International' │ │ 0 │ 'wrong_channel_id' │ 'en' │ 'CNNInternational' │ '140' │ 'CNN International' │
└─────────┴────────────────────┴──────┴────────────────────┴─────────┴─────────────────────┘ └─────────┴────────────────────┴──────┴────────────────────┴─────────┴─────────────────────┘
1 error(s) in 1 file(s) 1 error(s) in 1 file(s)
`) `)
} }
}) })
it('will show a message if the file contains a channel with wrong feed id', () => { it('will show a message if the file contains a channel with wrong feed id', () => {
try { try {
const cmd = `${ENV_VAR} npm run channels:validate --- tests/__data__/input/channels_validate/wrong_feed_id.channels.xml` const cmd = `${ENV_VAR} npm run channels:validate --- tests/__data__/input/channels_validate/wrong_feed_id.channels.xml`
const stdout = execSync(cmd, { encoding: 'utf8' }) const stdout = execSync(cmd, { encoding: 'utf8' })
if (process.env.DEBUG === 'true') console.log(cmd, stdout) if (process.env.DEBUG === 'true') console.log(cmd, stdout)
process.exit(1) process.exit(1)
} catch (error) { } catch (error) {
expect((error as ExecError).status).toBe(1) expect((error as ExecError).status).toBe(1)
expect((error as ExecError).stdout).toContain(` expect((error as ExecError).stdout).toContain(`
┌─────────┬─────────────────┬──────┬─────────────────┬─────────┬─────────┐ ┌─────────┬─────────────────┬──────┬─────────────────┬─────────┬─────────┐
│ (index) │ type │ lang │ xmltv_id │ site_id │ name │ │ (index) │ type │ lang │ xmltv_id │ site_id │ name │
├─────────┼─────────────────┼──────┼─────────────────┼─────────┼─────────┤ ├─────────┼─────────────────┼──────┼─────────────────┼─────────┼─────────┤
│ 0 │ 'wrong_feed_id' │ 'en' │ 'Bravo.us@West' │ '150' │ 'Bravo' │ │ 0 │ 'wrong_feed_id' │ 'en' │ 'Bravo.us@West' │ '150' │ 'Bravo' │
└─────────┴─────────────────┴──────┴─────────────────┴─────────┴─────────┘ └─────────┴─────────────────┴──────┴─────────────────┴─────────┴─────────┘
1 error(s) in 1 file(s) 1 error(s) in 1 file(s)
`) `)
} }
}) })
}) })