mirror of https://github.com/iptv-org/iptv
commit
bf1e19b306
@ -1 +1,6 @@
|
||||
node_modules
|
||||
node_modules
|
||||
database
|
||||
.artifacts
|
||||
.secrets
|
||||
.actrc
|
||||
.DS_Store
|
||||
@ -1,3 +1,4 @@
|
||||
_categories.md
|
||||
_countries.md
|
||||
_languages.md
|
||||
_languages.md
|
||||
_regions.md
|
||||
@ -0,0 +1,8 @@
|
||||
## Supported Statuses
|
||||
|
||||
| Label | Description |
|
||||
| ----------- | ------------------------------------------------- |
|
||||
| Geo-blocked | Channel is only available in selected countries. |
|
||||
| Not 24/7 | Broadcast is not available 24 hours a day. |
|
||||
| Timeout | Server does not respond for more than 60 seconds. |
|
||||
| Offline | The broadcast does not work for any other reason. |
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,25 +1,31 @@
|
||||
{
|
||||
"name": "iptv",
|
||||
"scripts": {
|
||||
"lint": "npx m3u-linter -c m3u-linter.json"
|
||||
"validate": "node scripts/commands/validate.js",
|
||||
"lint": "npx m3u-linter -c m3u-linter.json",
|
||||
"test": "jest --runInBand"
|
||||
},
|
||||
"jest": {
|
||||
"testRegex": "tests/(.*?/)?.*test.js$"
|
||||
},
|
||||
"pre-push": [
|
||||
"lint"
|
||||
],
|
||||
"author": "Arhey",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^0.21.4",
|
||||
"chunk": "^0.0.3",
|
||||
"commander": "^7.0.0",
|
||||
"iptv-checker": "^0.21.0",
|
||||
"iptv-playlist-parser": "^0.9.0",
|
||||
"crypto": "^1.0.1",
|
||||
"dayjs": "^1.10.7",
|
||||
"iptv-checker": "^0.22.0",
|
||||
"iptv-playlist-parser": "^0.10.2",
|
||||
"jest": "^27.4.3",
|
||||
"lodash": "^4.17.21",
|
||||
"m3u-linter": "^0.2.2",
|
||||
"markdown-include": "^0.4.3",
|
||||
"natural-orderby": "^2.0.3",
|
||||
"mz": "^2.7.0",
|
||||
"nedb-promises": "^5.0.2",
|
||||
"normalize-url": "^6.1.0",
|
||||
"pre-push": "^0.1.1",
|
||||
"progress": "^2.0.3",
|
||||
"transliteration": "^2.2.0"
|
||||
"transliteration": "^2.2.0",
|
||||
"winston": "^3.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
logs/
|
||||
channels.db
|
||||
@ -0,0 +1,50 @@
|
||||
const { program } = require('commander')
|
||||
const { db, logger, timer, checker, store, file, parser } = require('../core')
|
||||
|
||||
const options = program
|
||||
.requiredOption('-c, --cluster-id <cluster-id>', 'The ID of cluster to load', parser.parseNumber)
|
||||
.option('-t, --timeout <timeout>', 'Set timeout for each request', parser.parseNumber, 60000)
|
||||
.option('-d, --delay <delay>', 'Set delay for each request', parser.parseNumber, 0)
|
||||
.option('--debug', 'Enable debug mode')
|
||||
.parse(process.argv)
|
||||
.opts()
|
||||
|
||||
const config = {
|
||||
timeout: options.timeout,
|
||||
delay: options.delay,
|
||||
debug: options.debug
|
||||
}
|
||||
|
||||
const LOGS_PATH = process.env.LOGS_PATH || 'scripts/logs'
|
||||
|
||||
async function main() {
|
||||
logger.info('Starting...')
|
||||
logger.info(`Timeout: ${options.timeout}ms`)
|
||||
logger.info(`Delay: ${options.delay}ms`)
|
||||
timer.start()
|
||||
|
||||
const clusterLog = `${LOGS_PATH}/check-streams/cluster_${options.clusterId}.log`
|
||||
logger.info(`Loading cluster: ${options.clusterId}`)
|
||||
logger.info(`Creating '${clusterLog}'...`)
|
||||
await file.create(clusterLog)
|
||||
const items = await db.find({ cluster_id: options.clusterId })
|
||||
const total = items.length
|
||||
logger.info(`Found ${total} links`)
|
||||
|
||||
logger.info('Checking...')
|
||||
const results = {}
|
||||
for (const [i, item] of items.entries()) {
|
||||
const message = `[${i + 1}/${total}] ${item.filepath}: ${item.url}`
|
||||
const result = await checker.check(item, config)
|
||||
if (!result.error) {
|
||||
logger.info(message)
|
||||
} else {
|
||||
logger.info(`${message} (${result.error})`)
|
||||
}
|
||||
await file.append(clusterLog, JSON.stringify(result) + '\n')
|
||||
}
|
||||
|
||||
logger.info(`Done in ${timer.format('HH[h] mm[m] ss[s]')}`)
|
||||
}
|
||||
|
||||
main()
|
||||
@ -0,0 +1,24 @@
|
||||
const { db, logger } = require('../core')
|
||||
|
||||
async function main() {
|
||||
logger.info(`Loading database...`)
|
||||
let streams = await db.find({})
|
||||
|
||||
logger.info(`Removing broken links...`)
|
||||
let removed = 0
|
||||
const buffer = []
|
||||
for (const stream of streams) {
|
||||
const duplicate = buffer.find(i => i.id === stream.id)
|
||||
if (duplicate && ['offline', 'timeout'].includes(stream.status.code)) {
|
||||
await db.remove({ _id: stream._id })
|
||||
removed++
|
||||
} else {
|
||||
buffer.push(stream)
|
||||
}
|
||||
}
|
||||
db.compact()
|
||||
|
||||
logger.info(`Removed ${removed} links`)
|
||||
}
|
||||
|
||||
main()
|
||||
@ -0,0 +1,104 @@
|
||||
const { db, file, parser, store, logger } = require('../core')
|
||||
const transliteration = require('transliteration')
|
||||
const { program } = require('commander')
|
||||
const _ = require('lodash')
|
||||
|
||||
const options = program
|
||||
.option(
|
||||
'--max-clusters <max-clusters>',
|
||||
'Set maximum number of clusters',
|
||||
parser.parseNumber,
|
||||
200
|
||||
)
|
||||
.option('--input-dir <input-dir>', 'Set path to input directory', 'channels')
|
||||
.parse(process.argv)
|
||||
.opts()
|
||||
|
||||
const links = []
|
||||
|
||||
async function main() {
|
||||
logger.info('Starting...')
|
||||
logger.info(`Number of clusters: ${options.maxClusters}`)
|
||||
|
||||
await loadChannels()
|
||||
await saveToDatabase()
|
||||
|
||||
logger.info('Done')
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
async function loadChannels() {
|
||||
logger.info(`Loading links...`)
|
||||
|
||||
const files = await file.list(`${options.inputDir}/**/*.m3u`)
|
||||
for (const filepath of files) {
|
||||
const items = await parser.parsePlaylist(filepath)
|
||||
for (const item of items) {
|
||||
item.filepath = filepath
|
||||
links.push(item)
|
||||
}
|
||||
}
|
||||
logger.info(`Found ${links.length} links`)
|
||||
}
|
||||
|
||||
async function saveToDatabase() {
|
||||
logger.info('Saving to the database...')
|
||||
|
||||
await db.reset()
|
||||
const chunks = split(_.shuffle(links), options.maxClusters)
|
||||
for (const [i, chunk] of chunks.entries()) {
|
||||
for (const item of chunk) {
|
||||
const stream = store.create()
|
||||
stream.set('name', { title: item.name })
|
||||
stream.set('id', { id: item.tvg.id })
|
||||
stream.set('filepath', { filepath: item.filepath })
|
||||
stream.set('src_country', { filepath: item.filepath })
|
||||
stream.set('tvg_country', { tvg_country: item.tvg.country })
|
||||
stream.set('countries', { tvg_country: item.tvg.country })
|
||||
stream.set('regions', { countries: stream.get('countries') })
|
||||
stream.set('languages', { tvg_language: item.tvg.language })
|
||||
stream.set('categories', { group_title: item.group.title })
|
||||
stream.set('tvg_url', { tvg_url: item.tvg.url })
|
||||
stream.set('guides', { tvg_url: item.tvg.url })
|
||||
stream.set('logo', { logo: item.tvg.logo })
|
||||
stream.set('resolution', { title: item.name })
|
||||
stream.set('status', { title: item.name })
|
||||
stream.set('url', { url: item.url })
|
||||
stream.set('http', { http: item.http })
|
||||
stream.set('is_nsfw', { categories: stream.get('categories') })
|
||||
stream.set('is_broken', { status: stream.get('status') })
|
||||
stream.set('updated', { updated: false })
|
||||
stream.set('cluster_id', { cluster_id: i + 1 })
|
||||
|
||||
if (!stream.get('id')) {
|
||||
const id = generateChannelId(stream.get('name'), stream.get('src_country'))
|
||||
stream.set('id', { id })
|
||||
}
|
||||
|
||||
await db.insert(stream.data())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function split(arr, n) {
|
||||
let result = []
|
||||
for (let i = n; i > 0; i--) {
|
||||
result.push(arr.splice(0, Math.ceil(arr.length / i)))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function generateChannelId(name, src_country) {
|
||||
if (name && src_country) {
|
||||
const slug = transliteration
|
||||
.transliterate(name)
|
||||
.replace(/\+/gi, 'Plus')
|
||||
.replace(/[^a-z\d]+/gi, '')
|
||||
const code = src_country.code.toLowerCase()
|
||||
|
||||
return `${slug}.${code}`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
const { logger, db } = require('../core')
|
||||
|
||||
async function main() {
|
||||
const docs = await db.find({}).sort({ cluster_id: 1 })
|
||||
const cluster_id = docs.reduce((acc, curr) => {
|
||||
if (!acc.includes(curr.cluster_id)) acc.push(curr.cluster_id)
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
const matrix = { cluster_id }
|
||||
const output = `::set-output name=matrix::${JSON.stringify(matrix)}`
|
||||
logger.info(output)
|
||||
}
|
||||
|
||||
main()
|
||||
@ -0,0 +1,321 @@
|
||||
const { db, logger, generator, file } = require('../core')
|
||||
const _ = require('lodash')
|
||||
|
||||
let languages = []
|
||||
let countries = []
|
||||
let categories = []
|
||||
let regions = []
|
||||
|
||||
const LOGS_PATH = process.env.LOGS_PATH || 'scripts/logs'
|
||||
const PUBLIC_PATH = process.env.PUBLIC_PATH || '.gh-pages'
|
||||
|
||||
async function main() {
|
||||
await setUp()
|
||||
|
||||
await generateCategories()
|
||||
await generateCountries()
|
||||
await generateLanguages()
|
||||
await generateRegions()
|
||||
await generateIndex()
|
||||
await generateIndexNSFW()
|
||||
await generateIndexCategory()
|
||||
await generateIndexCountry()
|
||||
await generateIndexLanguage()
|
||||
await generateIndexRegion()
|
||||
|
||||
await generateChannelsJson()
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
async function generateCategories() {
|
||||
logger.info(`Generating categories/...`)
|
||||
|
||||
for (const category of categories) {
|
||||
const { count } = await generator.generate(
|
||||
`${PUBLIC_PATH}/categories/${category.slug}.m3u`,
|
||||
{ categories: { $elemMatch: category } },
|
||||
{ saveEmpty: true, includeNSFW: true }
|
||||
)
|
||||
|
||||
await log('categories', {
|
||||
name: category.name,
|
||||
slug: category.slug,
|
||||
count
|
||||
})
|
||||
}
|
||||
|
||||
const { count: otherCount } = await generator.generate(
|
||||
`${PUBLIC_PATH}/categories/other.m3u`,
|
||||
{ categories: { $size: 0 } },
|
||||
{ saveEmpty: true }
|
||||
)
|
||||
|
||||
await log('categories', {
|
||||
name: 'Other',
|
||||
slug: 'other',
|
||||
count: otherCount
|
||||
})
|
||||
}
|
||||
|
||||
async function generateCountries() {
|
||||
logger.info(`Generating countries/...`)
|
||||
|
||||
for (const country of countries) {
|
||||
const { count } = await generator.generate(
|
||||
`${PUBLIC_PATH}/countries/${country.code.toLowerCase()}.m3u`,
|
||||
{
|
||||
countries: { $elemMatch: country }
|
||||
}
|
||||
)
|
||||
|
||||
await log('countries', {
|
||||
name: country.name,
|
||||
code: country.code,
|
||||
count
|
||||
})
|
||||
}
|
||||
|
||||
const { count: intCount } = await generator.generate(`${PUBLIC_PATH}/countries/int.m3u`, {
|
||||
tvg_country: 'INT'
|
||||
})
|
||||
|
||||
await log('countries', {
|
||||
name: 'International',
|
||||
code: 'INT',
|
||||
count: intCount
|
||||
})
|
||||
|
||||
const { count: undefinedCount } = await generator.generate(
|
||||
`${PUBLIC_PATH}/countries/undefined.m3u`,
|
||||
{
|
||||
countries: { $size: 0 }
|
||||
}
|
||||
)
|
||||
|
||||
await log('countries', {
|
||||
name: 'Undefined',
|
||||
code: 'UNDEFINED',
|
||||
count: undefinedCount
|
||||
})
|
||||
}
|
||||
|
||||
async function generateLanguages() {
|
||||
logger.info(`Generating languages/...`)
|
||||
|
||||
for (const language of _.uniqBy(languages, 'code')) {
|
||||
const { count } = await generator.generate(`${PUBLIC_PATH}/languages/${language.code}.m3u`, {
|
||||
languages: { $elemMatch: language }
|
||||
})
|
||||
|
||||
await log('languages', {
|
||||
name: language.name,
|
||||
code: language.code,
|
||||
count
|
||||
})
|
||||
}
|
||||
|
||||
const { count: undefinedCount } = await generator.generate(
|
||||
`${PUBLIC_PATH}/languages/undefined.m3u`,
|
||||
{
|
||||
languages: { $size: 0 }
|
||||
}
|
||||
)
|
||||
|
||||
await log('languages', {
|
||||
name: 'Undefined',
|
||||
code: 'undefined',
|
||||
count: undefinedCount
|
||||
})
|
||||
}
|
||||
|
||||
async function generateRegions() {
|
||||
logger.info(`Generating regions/...`)
|
||||
|
||||
for (const region of regions) {
|
||||
const { count } = await generator.generate(
|
||||
`${PUBLIC_PATH}/regions/${region.code.toLowerCase()}.m3u`,
|
||||
{
|
||||
regions: { $elemMatch: region }
|
||||
}
|
||||
)
|
||||
|
||||
await log('regions', {
|
||||
name: region.name,
|
||||
code: region.code,
|
||||
count
|
||||
})
|
||||
}
|
||||
|
||||
const { count: undefinedCount } = await generator.generate(
|
||||
`${PUBLIC_PATH}/regions/undefined.m3u`,
|
||||
{ regions: { $size: 0 } },
|
||||
{ saveEmpty: true }
|
||||
)
|
||||
|
||||
await log('regions', {
|
||||
name: 'Undefined',
|
||||
code: 'UNDEFINED',
|
||||
count: undefinedCount
|
||||
})
|
||||
}
|
||||
|
||||
async function generateIndexNSFW() {
|
||||
logger.info(`Generating index.nsfw.m3u...`)
|
||||
|
||||
await generator.generate(`${PUBLIC_PATH}/index.nsfw.m3u`, {}, { includeNSFW: true })
|
||||
}
|
||||
|
||||
async function generateIndex() {
|
||||
logger.info(`Generating index.m3u...`)
|
||||
|
||||
await generator.generate(`${PUBLIC_PATH}/index.m3u`, {})
|
||||
}
|
||||
|
||||
async function generateIndexCategory() {
|
||||
logger.info(`Generating index.category.m3u...`)
|
||||
|
||||
await generator.generate(
|
||||
`${PUBLIC_PATH}/index.category.m3u`,
|
||||
{},
|
||||
{ sortBy: item => item.group_title }
|
||||
)
|
||||
}
|
||||
|
||||
async function generateIndexCountry() {
|
||||
logger.info(`Generating index.country.m3u...`)
|
||||
|
||||
await generator.generate(
|
||||
`${PUBLIC_PATH}/index.country.m3u`,
|
||||
{},
|
||||
{
|
||||
onLoad: function (items) {
|
||||
let results = items
|
||||
.filter(item => !item.countries.length)
|
||||
.map(item => {
|
||||
const newItem = _.cloneDeep(item)
|
||||
newItem.group_title = ''
|
||||
return newItem
|
||||
})
|
||||
for (const country of _.sortBy(Object.values(countries), ['name'])) {
|
||||
let filtered = items
|
||||
.filter(item => {
|
||||
return item.countries.map(c => c.code).includes(country.code)
|
||||
})
|
||||
.map(item => {
|
||||
const newItem = _.cloneDeep(item)
|
||||
newItem.group_title = country.name
|
||||
return newItem
|
||||
})
|
||||
results = results.concat(filtered)
|
||||
}
|
||||
|
||||
return results
|
||||
},
|
||||
sortBy: item => item.group_title
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async function generateIndexLanguage() {
|
||||
logger.info(`Generating index.language.m3u...`)
|
||||
|
||||
await generator.generate(
|
||||
`${PUBLIC_PATH}/index.language.m3u`,
|
||||
{},
|
||||
{
|
||||
onLoad: function (items) {
|
||||
let results = items
|
||||
.filter(item => !item.languages.length)
|
||||
.map(item => {
|
||||
const newItem = _.cloneDeep(item)
|
||||
newItem.group_title = ''
|
||||
return newItem
|
||||
})
|
||||
for (const language of languages) {
|
||||
let filtered = items
|
||||
.filter(item => {
|
||||
return item.languages.map(c => c.code).includes(language.code)
|
||||
})
|
||||
.map(item => {
|
||||
const newItem = _.cloneDeep(item)
|
||||
newItem.group_title = language.name
|
||||
return newItem
|
||||
})
|
||||
results = results.concat(filtered)
|
||||
}
|
||||
|
||||
return results
|
||||
},
|
||||
sortBy: item => item.group_title
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async function generateIndexRegion() {
|
||||
logger.info(`Generating index.region.m3u...`)
|
||||
|
||||
await generator.generate(
|
||||
`${PUBLIC_PATH}/index.region.m3u`,
|
||||
{},
|
||||
{
|
||||
onLoad: function (items) {
|
||||
let results = items
|
||||
.filter(item => !item.regions.length)
|
||||
.map(item => {
|
||||
const newItem = _.cloneDeep(item)
|
||||
newItem.group_title = ''
|
||||
return newItem
|
||||
})
|
||||
for (const region of regions) {
|
||||
let filtered = items
|
||||
.filter(item => {
|
||||
return item.regions.map(c => c.code).includes(region.code)
|
||||
})
|
||||
.map(item => {
|
||||
const newItem = _.cloneDeep(item)
|
||||
newItem.group_title = region.name
|
||||
return newItem
|
||||
})
|
||||
results = results.concat(filtered)
|
||||
}
|
||||
|
||||
return results
|
||||
},
|
||||
sortBy: item => item.group_title
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async function generateChannelsJson() {
|
||||
logger.info('Generating channels.json...')
|
||||
|
||||
await generator.generate(`${PUBLIC_PATH}/channels.json`, {}, { format: 'json' })
|
||||
}
|
||||
|
||||
async function setUp() {
|
||||
logger.info(`Loading database...`)
|
||||
const items = await db.find({})
|
||||
categories = _.sortBy(_.uniqBy(_.flatten(items.map(i => i.categories)), 'slug'), ['name'])
|
||||
countries = _.sortBy(_.uniqBy(_.flatten(items.map(i => i.countries)), 'code'), ['name'])
|
||||
languages = _.sortBy(_.uniqBy(_.flatten(items.map(i => i.languages)), 'code'), ['name'])
|
||||
regions = _.sortBy(_.uniqBy(_.flatten(items.map(i => i.regions)), 'code'), ['name'])
|
||||
|
||||
const categoriesLog = `${LOGS_PATH}/generate-playlists/categories.log`
|
||||
const countriesLog = `${LOGS_PATH}/generate-playlists/countries.log`
|
||||
const languagesLog = `${LOGS_PATH}/generate-playlists/languages.log`
|
||||
const regionsLog = `${LOGS_PATH}/generate-playlists/regions.log`
|
||||
|
||||
logger.info(`Creating '${categoriesLog}'...`)
|
||||
await file.create(categoriesLog)
|
||||
logger.info(`Creating '${countriesLog}'...`)
|
||||
await file.create(countriesLog)
|
||||
logger.info(`Creating '${languagesLog}'...`)
|
||||
await file.create(languagesLog)
|
||||
logger.info(`Creating '${regionsLog}'...`)
|
||||
await file.create(regionsLog)
|
||||
}
|
||||
|
||||
async function log(type, data) {
|
||||
await file.append(`${LOGS_PATH}/generate-playlists/${type}.log`, JSON.stringify(data) + '\n')
|
||||
}
|
||||
@ -0,0 +1,232 @@
|
||||
const _ = require('lodash')
|
||||
const statuses = require('../data/statuses')
|
||||
const languages = require('../data/languages')
|
||||
const { db, store, parser, file, logger } = require('../core')
|
||||
|
||||
let epgCodes = []
|
||||
let streams = []
|
||||
let checkResults = {}
|
||||
const origins = {}
|
||||
const items = []
|
||||
|
||||
const LOGS_PATH = process.env.LOGS_PATH || 'scripts/logs'
|
||||
const EPG_CODES_FILEPATH = process.env.EPG_CODES_FILEPATH || 'scripts/data/codes.json'
|
||||
|
||||
async function main() {
|
||||
await setUp()
|
||||
await loadDatabase()
|
||||
await removeDuplicates()
|
||||
await loadCheckResults()
|
||||
await findStreamOrigins()
|
||||
await updateStreams()
|
||||
await updateDatabase()
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
async function loadDatabase() {
|
||||
logger.info('Loading database...')
|
||||
|
||||
streams = await db.find({})
|
||||
|
||||
logger.info(`Found ${streams.length} streams`)
|
||||
}
|
||||
|
||||
async function removeDuplicates() {
|
||||
logger.info('Removing duplicates...')
|
||||
|
||||
const before = streams.length
|
||||
streams = _.uniqBy(streams, 'id')
|
||||
const after = streams.length
|
||||
|
||||
logger.info(`Removed ${before - after} links`)
|
||||
}
|
||||
|
||||
async function loadCheckResults() {
|
||||
logger.info('Loading check results from logs/...')
|
||||
|
||||
const files = await file.list(`${LOGS_PATH}/check-streams/cluster_*.log`)
|
||||
for (const filepath of files) {
|
||||
const results = await parser.parseLogs(filepath)
|
||||
for (const result of results) {
|
||||
checkResults[result._id] = result
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Found ${Object.values(checkResults).length} results`)
|
||||
}
|
||||
|
||||
async function findStreamOrigins() {
|
||||
logger.info('Searching for stream origins...')
|
||||
|
||||
for (const { error, requests } of Object.values(checkResults)) {
|
||||
if (error || !Array.isArray(requests) || !requests.length) continue
|
||||
|
||||
let origin = requests.shift()
|
||||
origin = new URL(origin.url)
|
||||
for (const request of requests) {
|
||||
const curr = new URL(request.url)
|
||||
const key = curr.href.replace(/(^\w+:|^)/, '')
|
||||
if (!origins[key] && curr.host === origin.host) {
|
||||
origins[key] = origin.href
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Found ${_.uniq(Object.values(origins)).length} origins`)
|
||||
}
|
||||
|
||||
async function updateStreams() {
|
||||
logger.info('Updating streams...')
|
||||
|
||||
let updated = 0
|
||||
for (const item of streams) {
|
||||
const stream = store.create(item)
|
||||
const result = checkResults[item._id]
|
||||
|
||||
if (result) {
|
||||
const { error, streams, requests } = result
|
||||
const status = parseStatus(error)
|
||||
const resolution = parseResolution(streams)
|
||||
const origin = findOrigin(requests)
|
||||
|
||||
if (status) {
|
||||
stream.set('status', { status })
|
||||
stream.set('is_broken', { status: stream.get('status') })
|
||||
}
|
||||
|
||||
if (resolution) {
|
||||
stream.set('resolution', { resolution })
|
||||
}
|
||||
|
||||
if (origin) {
|
||||
stream.set('url', { url: origin })
|
||||
}
|
||||
}
|
||||
|
||||
if (!stream.has('logo')) {
|
||||
const logo = findLogo(stream.get('id'))
|
||||
stream.set('logo', { logo })
|
||||
}
|
||||
|
||||
if (!stream.has('guides')) {
|
||||
const guides = findGuides(stream.get('id'))
|
||||
stream.set('guides', { guides })
|
||||
}
|
||||
|
||||
if (!stream.has('countries') && stream.get('src_country')) {
|
||||
const countries = [stream.get('src_country')]
|
||||
stream.set('countries', { countries })
|
||||
}
|
||||
|
||||
if (!stream.has('languages')) {
|
||||
const languages = findLanguages(stream.get('countries'), stream.get('src_country'))
|
||||
stream.set('languages', { languages })
|
||||
}
|
||||
|
||||
if (stream.changed) {
|
||||
stream.set('updated', true)
|
||||
items.push(stream.data())
|
||||
updated++
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Updated ${updated} items`)
|
||||
}
|
||||
|
||||
async function updateDatabase() {
|
||||
logger.info('Updating database...')
|
||||
|
||||
for (const item of items) {
|
||||
await db.update({ _id: item._id }, item)
|
||||
}
|
||||
db.compact()
|
||||
|
||||
logger.info('Done')
|
||||
}
|
||||
|
||||
async function setUp() {
|
||||
try {
|
||||
const codes = await file.read(EPG_CODES_FILEPATH)
|
||||
epgCodes = JSON.parse(codes)
|
||||
} catch (err) {
|
||||
logger.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
function findLanguages(countries, src_country) {
|
||||
if (countries && Array.isArray(countries)) {
|
||||
let codes = countries.map(country => country.lang)
|
||||
codes = _.uniq(codes)
|
||||
|
||||
return codes.map(code => languages.find(l => l.code === code)).filter(l => l)
|
||||
}
|
||||
|
||||
if (src_country) {
|
||||
const code = src_country.lang
|
||||
const lang = languages.find(l => l.code === code)
|
||||
|
||||
return lang ? [lang] : []
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function findOrigin(requests) {
|
||||
if (origins && Array.isArray(requests)) {
|
||||
requests = requests.map(r => r.url.replace(/(^\w+:|^)/, ''))
|
||||
for (const url of requests) {
|
||||
if (origins[url]) {
|
||||
return origins[url]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function parseResolution(streams) {
|
||||
const resolution = streams
|
||||
.filter(s => s.codec_type === 'video')
|
||||
.reduce(
|
||||
(acc, curr) => {
|
||||
if (curr.height > acc.height) return { width: curr.width, height: curr.height }
|
||||
return acc
|
||||
},
|
||||
{ width: 0, height: 0 }
|
||||
)
|
||||
|
||||
if (resolution.width > 0 && resolution.height > 0) return resolution
|
||||
return null
|
||||
}
|
||||
|
||||
function parseStatus(error) {
|
||||
if (error) {
|
||||
if (error.includes('timed out')) {
|
||||
return statuses['timeout']
|
||||
} else if (error.includes('403')) {
|
||||
return statuses['geo_blocked']
|
||||
}
|
||||
return statuses['offline']
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function findLogo(id) {
|
||||
const item = epgCodes.find(i => i.tvg_id === id)
|
||||
if (item && item.logo) {
|
||||
return item.logo
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function findGuides(id) {
|
||||
const item = epgCodes.find(i => i.tvg_id === id)
|
||||
if (item && Array.isArray(item.guides)) {
|
||||
return item.guides
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
const _ = require('lodash')
|
||||
const { generator, db, logger } = require('../core')
|
||||
|
||||
async function main() {
|
||||
let items = await db
|
||||
.find({})
|
||||
.sort({ name: 1, 'status.level': 1, 'resolution.height': -1, url: 1 })
|
||||
const files = _.groupBy(items, 'filepath')
|
||||
|
||||
for (const filepath in files) {
|
||||
const items = files[filepath]
|
||||
await generator.saveAsM3U(filepath, items, { includeGuides: false })
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
@ -0,0 +1,142 @@
|
||||
const { file, markdown, parser, logger } = require('../core')
|
||||
const { program } = require('commander')
|
||||
|
||||
let categories = []
|
||||
let countries = []
|
||||
let languages = []
|
||||
let regions = []
|
||||
|
||||
const LOGS_PATH = process.env.LOGS_PATH || 'scripts/logs'
|
||||
|
||||
const options = program
|
||||
.option('-c, --config <config>', 'Set path to config file', '.readme/config.json')
|
||||
.parse(process.argv)
|
||||
.opts()
|
||||
|
||||
async function main() {
|
||||
await setUp()
|
||||
|
||||
await generateCategoryTable()
|
||||
await generateLanguageTable()
|
||||
await generateRegionTable()
|
||||
await generateCountryTable()
|
||||
|
||||
await updateReadme()
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
async function generateCategoryTable() {
|
||||
logger.info('Generating category table...')
|
||||
|
||||
const rows = []
|
||||
for (const category of categories) {
|
||||
rows.push({
|
||||
category: category.name,
|
||||
channels: category.count,
|
||||
playlist: `<code>https://iptv-org.github.io/iptv/categories/${category.slug}.m3u</code>`
|
||||
})
|
||||
}
|
||||
|
||||
const table = markdown.createTable(rows, [
|
||||
{ name: 'Category', align: 'left' },
|
||||
{ name: 'Channels', align: 'right' },
|
||||
{ name: 'Playlist', align: 'left' }
|
||||
])
|
||||
|
||||
await file.create('./.readme/_categories.md', table)
|
||||
}
|
||||
|
||||
async function generateCountryTable() {
|
||||
logger.info('Generating country table...')
|
||||
|
||||
const rows = []
|
||||
for (const country of countries) {
|
||||
const flag = getCountryFlag(country.code)
|
||||
const prefix = flag ? `${flag} ` : ''
|
||||
|
||||
rows.push({
|
||||
country: prefix + country.name,
|
||||
channels: country.count,
|
||||
playlist: `<code>https://iptv-org.github.io/iptv/countries/${country.code.toLowerCase()}.m3u</code>`
|
||||
})
|
||||
}
|
||||
|
||||
const table = markdown.createTable(rows, [
|
||||
{ name: 'Country', align: 'left' },
|
||||
{ name: 'Channels', align: 'right' },
|
||||
{ name: 'Playlist', align: 'left' }
|
||||
])
|
||||
|
||||
await file.create('./.readme/_countries.md', table)
|
||||
}
|
||||
|
||||
async function generateRegionTable() {
|
||||
logger.info('Generating region table...')
|
||||
|
||||
const rows = []
|
||||
for (const region of regions) {
|
||||
rows.push({
|
||||
region: region.name,
|
||||
channels: region.count,
|
||||
playlist: `<code>https://iptv-org.github.io/iptv/regions/${region.code.toLowerCase()}.m3u</code>`
|
||||
})
|
||||
}
|
||||
|
||||
const table = markdown.createTable(rows, [
|
||||
{ name: 'Region', align: 'left' },
|
||||
{ name: 'Channels', align: 'right' },
|
||||
{ name: 'Playlist', align: 'left' }
|
||||
])
|
||||
|
||||
await file.create('./.readme/_regions.md', table)
|
||||
}
|
||||
|
||||
async function generateLanguageTable() {
|
||||
logger.info('Generating language table...')
|
||||
|
||||
const rows = []
|
||||
for (const language of languages) {
|
||||
rows.push({
|
||||
language: language.name,
|
||||
channels: language.count,
|
||||
playlist: `<code>https://iptv-org.github.io/iptv/languages/${language.code}.m3u</code>`
|
||||
})
|
||||
}
|
||||
|
||||
const table = markdown.createTable(rows, [
|
||||
{ name: 'Language', align: 'left' },
|
||||
{ name: 'Channels', align: 'right' },
|
||||
{ name: 'Playlist', align: 'left' }
|
||||
])
|
||||
|
||||
await file.create('./.readme/_languages.md', table)
|
||||
}
|
||||
|
||||
async function updateReadme() {
|
||||
logger.info('Updating README.md...')
|
||||
|
||||
const config = require(file.resolve(options.config))
|
||||
await file.createDir(file.dirname(config.build))
|
||||
await markdown.compile(options.config)
|
||||
}
|
||||
|
||||
async function setUp() {
|
||||
categories = await parser.parseLogs(`${LOGS_PATH}/generate-playlists/categories.log`)
|
||||
countries = await parser.parseLogs(`${LOGS_PATH}/generate-playlists/countries.log`)
|
||||
languages = await parser.parseLogs(`${LOGS_PATH}/generate-playlists/languages.log`)
|
||||
regions = await parser.parseLogs(`${LOGS_PATH}/generate-playlists/regions.log`)
|
||||
}
|
||||
|
||||
function getCountryFlag(code) {
|
||||
switch (code) {
|
||||
case 'UK':
|
||||
return '🇬🇧'
|
||||
case 'INT':
|
||||
return '🌍'
|
||||
case 'UNDEFINED':
|
||||
return ''
|
||||
default:
|
||||
return code.replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
const blocklist = require('../data/blocklist')
|
||||
const parser = require('iptv-playlist-parser')
|
||||
const { file, logger } = require('../core')
|
||||
const { program } = require('commander')
|
||||
|
||||
const options = program
|
||||
.option('--input-dir <input-dir>', 'Set path to input directory', 'channels')
|
||||
.parse(process.argv)
|
||||
.opts()
|
||||
|
||||
async function main() {
|
||||
const files = await file.list(`${options.inputDir}/**/*.m3u`)
|
||||
const errors = []
|
||||
for (const filepath of files) {
|
||||
const content = await file.read(filepath)
|
||||
const playlist = parser.parse(content)
|
||||
const basename = file.basename(filepath)
|
||||
const [_, country] = basename.match(/([a-z]{2})(|_.*)\.m3u/i) || [null, null]
|
||||
|
||||
const items = playlist.items
|
||||
.map(item => {
|
||||
const details = check(item, country)
|
||||
|
||||
return details ? { ...item, details } : null
|
||||
})
|
||||
.filter(i => i)
|
||||
|
||||
items.forEach(item => {
|
||||
errors.push(
|
||||
`${filepath}:${item.line} '${item.details.name}' is on the blocklist due to claims of copyright holders (${item.details.reference})`
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
errors.forEach(error => {
|
||||
logger.error(error)
|
||||
})
|
||||
|
||||
if (errors.length) {
|
||||
logger.info('')
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
function check(channel, country) {
|
||||
return blocklist.find(item => {
|
||||
const regexp = new RegExp(item.regex, 'i')
|
||||
const hasSameName = regexp.test(channel.name)
|
||||
const fromSameCountry = country === item.country.toLowerCase()
|
||||
|
||||
return hasSameName && fromSameCountry
|
||||
})
|
||||
}
|
||||
|
||||
main()
|
||||
@ -0,0 +1,19 @@
|
||||
const IPTVChecker = require('iptv-checker')
|
||||
|
||||
const checker = {}
|
||||
|
||||
checker.check = async function (item, config) {
|
||||
const ic = new IPTVChecker(config)
|
||||
const result = await ic.checkStream({ url: item.url, http: item.http })
|
||||
|
||||
return {
|
||||
_id: item._id,
|
||||
url: item.url,
|
||||
http: item.http,
|
||||
error: !result.status.ok ? result.status.reason : null,
|
||||
streams: result.status.ok ? result.status.metadata.streams : [],
|
||||
requests: result.status.ok ? result.status.metadata.requests : []
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = checker
|
||||
@ -0,0 +1,61 @@
|
||||
const Database = require('nedb-promises')
|
||||
const file = require('./file')
|
||||
|
||||
const DB_FILEPATH = process.env.DB_FILEPATH || './scripts/channels.db'
|
||||
|
||||
const nedb = Database.create({
|
||||
filename: file.resolve(DB_FILEPATH),
|
||||
autoload: true,
|
||||
onload(err) {
|
||||
if (err) console.error(err)
|
||||
},
|
||||
compareStrings: (a, b) => {
|
||||
a = a.replace(/\s/g, '_')
|
||||
b = b.replace(/\s/g, '_')
|
||||
|
||||
return a.localeCompare(b, undefined, {
|
||||
sensitivity: 'accent',
|
||||
numeric: true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const db = {}
|
||||
|
||||
db.removeIndex = function (field) {
|
||||
return nedb.removeIndex(field)
|
||||
}
|
||||
|
||||
db.addIndex = function (options) {
|
||||
return nedb.ensureIndex(options)
|
||||
}
|
||||
|
||||
db.compact = function () {
|
||||
return nedb.persistence.compactDatafile()
|
||||
}
|
||||
|
||||
db.reset = function () {
|
||||
return file.clear(DB_FILEPATH)
|
||||
}
|
||||
|
||||
db.count = function (query) {
|
||||
return nedb.count(query)
|
||||
}
|
||||
|
||||
db.insert = function (doc) {
|
||||
return nedb.insert(doc)
|
||||
}
|
||||
|
||||
db.update = function (query, update) {
|
||||
return nedb.update(query, update)
|
||||
}
|
||||
|
||||
db.find = function (query) {
|
||||
return nedb.find(query)
|
||||
}
|
||||
|
||||
db.remove = function (query, options) {
|
||||
return nedb.remove(query, options)
|
||||
}
|
||||
|
||||
module.exports = db
|
||||
@ -0,0 +1,67 @@
|
||||
const path = require('path')
|
||||
const glob = require('glob')
|
||||
const fs = require('mz/fs')
|
||||
|
||||
const file = {}
|
||||
|
||||
file.list = function (pattern) {
|
||||
return new Promise(resolve => {
|
||||
glob(pattern, function (err, files) {
|
||||
resolve(files)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
file.getFilename = function (filepath) {
|
||||
return path.parse(filepath).name
|
||||
}
|
||||
|
||||
file.createDir = async function (dir) {
|
||||
if (await file.exists(dir)) return
|
||||
|
||||
return fs.mkdir(dir, { recursive: true }).catch(console.error)
|
||||
}
|
||||
|
||||
file.exists = function (filepath) {
|
||||
return fs.exists(path.resolve(filepath))
|
||||
}
|
||||
|
||||
file.read = function (filepath) {
|
||||
return fs.readFile(path.resolve(filepath), { encoding: 'utf8' }).catch(console.error)
|
||||
}
|
||||
|
||||
file.append = function (filepath, data) {
|
||||
return fs.appendFile(path.resolve(filepath), data).catch(console.error)
|
||||
}
|
||||
|
||||
file.create = function (filepath, data = '') {
|
||||
filepath = path.resolve(filepath)
|
||||
const dir = path.dirname(filepath)
|
||||
|
||||
return file
|
||||
.createDir(dir)
|
||||
.then(() => fs.writeFile(filepath, data, { encoding: 'utf8', flag: 'w' }))
|
||||
.catch(console.error)
|
||||
}
|
||||
|
||||
file.write = function (filepath, data = '') {
|
||||
return fs.writeFile(path.resolve(filepath), data).catch(console.error)
|
||||
}
|
||||
|
||||
file.clear = function (filepath) {
|
||||
return file.write(filepath, '')
|
||||
}
|
||||
|
||||
file.resolve = function (filepath) {
|
||||
return path.resolve(filepath)
|
||||
}
|
||||
|
||||
file.dirname = function (filepath) {
|
||||
return path.dirname(filepath)
|
||||
}
|
||||
|
||||
file.basename = function (filepath) {
|
||||
return path.basename(filepath)
|
||||
}
|
||||
|
||||
module.exports = file
|
||||
@ -0,0 +1,114 @@
|
||||
const { create: createPlaylist } = require('./playlist')
|
||||
const store = require('./store')
|
||||
const file = require('./file')
|
||||
const logger = require('./logger')
|
||||
const db = require('./db')
|
||||
const _ = require('lodash')
|
||||
|
||||
const generator = {}
|
||||
|
||||
generator.generate = async function (filepath, query = {}, options = {}) {
|
||||
options = {
|
||||
...{
|
||||
format: 'm3u',
|
||||
saveEmpty: false,
|
||||
includeNSFW: false,
|
||||
includeGuides: true,
|
||||
includeBroken: false,
|
||||
onLoad: r => r,
|
||||
uniqBy: item => item.id || _.uniqueId(),
|
||||
sortBy: null
|
||||
},
|
||||
...options
|
||||
}
|
||||
|
||||
query['is_nsfw'] = options.includeNSFW ? { $in: [true, false] } : false
|
||||
query['is_broken'] = options.includeBroken ? { $in: [true, false] } : false
|
||||
|
||||
let items = await db
|
||||
.find(query)
|
||||
.sort({ name: 1, 'status.level': 1, 'resolution.height': -1, url: 1 })
|
||||
|
||||
items = _.uniqBy(items, 'url')
|
||||
if (!options.saveEmpty && !items.length) return { filepath, query, options, count: 0 }
|
||||
if (options.uniqBy) items = _.uniqBy(items, options.uniqBy)
|
||||
|
||||
items = options.onLoad(items)
|
||||
|
||||
if (options.sortBy) items = _.sortBy(items, options.sortBy)
|
||||
|
||||
switch (options.format) {
|
||||
case 'json':
|
||||
await saveAsJSON(filepath, items, options)
|
||||
break
|
||||
case 'm3u':
|
||||
default:
|
||||
await saveAsM3U(filepath, items, options)
|
||||
break
|
||||
}
|
||||
|
||||
return { filepath, query, options, count: items.length }
|
||||
}
|
||||
|
||||
async function saveAsM3U(filepath, items, options) {
|
||||
const playlist = await createPlaylist(filepath)
|
||||
|
||||
const header = {}
|
||||
if (options.includeGuides) {
|
||||
let guides = items.map(item => item.guides)
|
||||
guides = _.uniq(_.flatten(guides)).sort().join(',')
|
||||
|
||||
header['x-tvg-url'] = guides
|
||||
}
|
||||
|
||||
await playlist.header(header)
|
||||
for (const item of items) {
|
||||
const stream = store.create(item)
|
||||
await playlist.link(
|
||||
stream.get('url'),
|
||||
stream.get('title'),
|
||||
{
|
||||
'tvg-id': stream.get('tvg_id'),
|
||||
'tvg-country': stream.get('tvg_country'),
|
||||
'tvg-language': stream.get('tvg_language'),
|
||||
'tvg-logo': stream.get('tvg_logo'),
|
||||
// 'tvg-url': stream.get('tvg_url') || undefined,
|
||||
'user-agent': stream.get('http.user-agent') || undefined,
|
||||
'group-title': stream.get('group_title')
|
||||
},
|
||||
{
|
||||
'http-referrer': stream.get('http.referrer') || undefined,
|
||||
'http-user-agent': stream.get('http.user-agent') || undefined
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAsJSON(filepath, items, options) {
|
||||
const output = items.map(item => {
|
||||
const stream = store.create(item)
|
||||
const categories = stream.get('categories').map(c => ({ name: c.name, slug: c.slug }))
|
||||
const countries = stream.get('countries').map(c => ({ name: c.name, code: c.code }))
|
||||
|
||||
return {
|
||||
name: stream.get('name'),
|
||||
logo: stream.get('logo'),
|
||||
url: stream.get('url'),
|
||||
categories,
|
||||
countries,
|
||||
languages: stream.get('languages'),
|
||||
tvg: {
|
||||
id: stream.get('tvg_id'),
|
||||
name: stream.get('name'),
|
||||
url: stream.get('tvg_url')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await file.create(filepath, JSON.stringify(output))
|
||||
}
|
||||
|
||||
generator.saveAsM3U = saveAsM3U
|
||||
generator.saveAsJSON = saveAsJSON
|
||||
|
||||
module.exports = generator
|
||||
@ -0,0 +1,10 @@
|
||||
exports.db = require('./db')
|
||||
exports.logger = require('./logger')
|
||||
exports.file = require('./file')
|
||||
exports.timer = require('./timer')
|
||||
exports.parser = require('./parser')
|
||||
exports.checker = require('./checker')
|
||||
exports.generator = require('./generator')
|
||||
exports.playlist = require('./playlist')
|
||||
exports.store = require('./store')
|
||||
exports.markdown = require('./markdown')
|
||||
@ -0,0 +1,42 @@
|
||||
const { createLogger, format, transports, addColors } = require('winston')
|
||||
const { combine, timestamp, printf } = format
|
||||
|
||||
const consoleFormat = ({ level, message, timestamp }) => {
|
||||
if (typeof message === 'object') return JSON.stringify(message)
|
||||
return message
|
||||
}
|
||||
|
||||
const config = {
|
||||
levels: {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
failed: 3,
|
||||
success: 4,
|
||||
http: 5,
|
||||
verbose: 6,
|
||||
debug: 7,
|
||||
silly: 8
|
||||
},
|
||||
colors: {
|
||||
info: 'white',
|
||||
success: 'green',
|
||||
failed: 'red'
|
||||
}
|
||||
}
|
||||
|
||||
const t = [
|
||||
new transports.Console({
|
||||
format: format.combine(format.printf(consoleFormat))
|
||||
})
|
||||
]
|
||||
|
||||
const logger = createLogger({
|
||||
transports: t,
|
||||
levels: config.levels,
|
||||
level: 'verbose'
|
||||
})
|
||||
|
||||
addColors(config.colors)
|
||||
|
||||
module.exports = logger
|
||||
@ -0,0 +1,39 @@
|
||||
const markdownInclude = require('markdown-include')
|
||||
const file = require('./file')
|
||||
|
||||
const markdown = {}
|
||||
|
||||
markdown.createTable = function (data, cols) {
|
||||
let output = '<table>\n'
|
||||
|
||||
output += ' <thead>\n <tr>'
|
||||
for (let column of cols) {
|
||||
output += `<th align="${column.align}">${column.name}</th>`
|
||||
}
|
||||
output += '</tr>\n </thead>\n'
|
||||
|
||||
output += ' <tbody>\n'
|
||||
for (let item of data) {
|
||||
output += ' <tr>'
|
||||
let i = 0
|
||||
for (let prop in item) {
|
||||
const column = cols[i]
|
||||
let nowrap = column.nowrap
|
||||
let align = column.align
|
||||
output += `<td align="${align}"${nowrap ? ' nowrap' : ''}>${item[prop]}</td>`
|
||||
i++
|
||||
}
|
||||
output += '</tr>\n'
|
||||
}
|
||||
output += ' </tbody>\n'
|
||||
|
||||
output += '</table>'
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
markdown.compile = function (filepath) {
|
||||
markdownInclude.compileFiles(file.resolve(filepath))
|
||||
}
|
||||
|
||||
module.exports = markdown
|
||||
@ -0,0 +1,31 @@
|
||||
const ipp = require('iptv-playlist-parser')
|
||||
const logger = require('./logger')
|
||||
const file = require('./file')
|
||||
|
||||
const parser = {}
|
||||
|
||||
parser.parsePlaylist = async function (filepath) {
|
||||
const content = await file.read(filepath)
|
||||
const playlist = ipp.parse(content)
|
||||
|
||||
return playlist.items
|
||||
}
|
||||
|
||||
parser.parseLogs = async function (filepath) {
|
||||
const content = await file.read(filepath)
|
||||
if (!content) return []
|
||||
const lines = content.split('\n')
|
||||
|
||||
return lines.map(line => (line ? JSON.parse(line) : null)).filter(l => l)
|
||||
}
|
||||
|
||||
parser.parseNumber = function (string) {
|
||||
const parsed = parseInt(string)
|
||||
if (isNaN(parsed)) {
|
||||
throw new Error('scripts/core/parser.js:parseNumber() Input value is not a number')
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
module.exports = parser
|
||||
@ -0,0 +1,49 @@
|
||||
const file = require('./file')
|
||||
|
||||
const playlist = {}
|
||||
|
||||
playlist.create = async function (filepath) {
|
||||
playlist.filepath = filepath
|
||||
const dir = file.dirname(filepath)
|
||||
file.createDir(dir)
|
||||
await file.create(filepath, '')
|
||||
|
||||
return playlist
|
||||
}
|
||||
|
||||
playlist.header = async function (attrs) {
|
||||
let header = `#EXTM3U`
|
||||
for (const name in attrs) {
|
||||
const value = attrs[name]
|
||||
header += ` ${name}="${value}"`
|
||||
}
|
||||
header += `\n`
|
||||
|
||||
await file.append(playlist.filepath, header)
|
||||
|
||||
return playlist
|
||||
}
|
||||
|
||||
playlist.link = async function (url, title, attrs, vlcOpts) {
|
||||
let link = `#EXTINF:-1`
|
||||
for (const name in attrs) {
|
||||
const value = attrs[name]
|
||||
if (value !== undefined) {
|
||||
link += ` ${name}="${value}"`
|
||||
}
|
||||
}
|
||||
link += `,${title}\n`
|
||||
for (const name in vlcOpts) {
|
||||
const value = vlcOpts[name]
|
||||
if (value !== undefined) {
|
||||
link += `#EXTVLCOPT:${name}=${value}\n`
|
||||
}
|
||||
}
|
||||
link += `${url}\n`
|
||||
|
||||
await file.append(playlist.filepath, link)
|
||||
|
||||
return playlist
|
||||
}
|
||||
|
||||
module.exports = playlist
|
||||
@ -0,0 +1,56 @@
|
||||
const _ = require('lodash')
|
||||
const logger = require('./logger')
|
||||
const setters = require('../store/setters')
|
||||
const getters = require('../store/getters')
|
||||
|
||||
module.exports = {
|
||||
create(state = {}) {
|
||||
return {
|
||||
state,
|
||||
changed: false,
|
||||
set: function (prop, value) {
|
||||
const prevState = JSON.stringify(this.state)
|
||||
|
||||
const setter = setters[prop]
|
||||
if (typeof setter === 'function') {
|
||||
try {
|
||||
this.state[prop] = setter.bind()(value)
|
||||
} catch (error) {
|
||||
logger.error(`store/setters/${prop}.js: ${error.message}`)
|
||||
}
|
||||
} else if (typeof value === 'object') {
|
||||
this.state[prop] = value[prop]
|
||||
} else {
|
||||
this.state[prop] = value
|
||||
}
|
||||
|
||||
const newState = JSON.stringify(this.state)
|
||||
if (prevState !== newState) {
|
||||
this.changed = true
|
||||
}
|
||||
|
||||
return this
|
||||
},
|
||||
get: function (prop) {
|
||||
const getter = getters[prop]
|
||||
if (typeof getter === 'function') {
|
||||
try {
|
||||
return getter.bind(this.state)()
|
||||
} catch (error) {
|
||||
logger.error(`store/getters/${prop}.js: ${error.message}`)
|
||||
}
|
||||
} else {
|
||||
return prop.split('.').reduce((o, i) => (o ? o[i] : undefined), this.state)
|
||||
}
|
||||
},
|
||||
has: function (prop) {
|
||||
const value = this.get(prop)
|
||||
|
||||
return !_.isEmpty(value)
|
||||
},
|
||||
data: function () {
|
||||
return this.state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
const { performance } = require('perf_hooks')
|
||||
const dayjs = require('dayjs')
|
||||
const duration = require('dayjs/plugin/duration')
|
||||
const relativeTime = require('dayjs/plugin/relativeTime')
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
dayjs.extend(duration)
|
||||
|
||||
const timer = {}
|
||||
|
||||
let t0 = 0
|
||||
|
||||
timer.start = function () {
|
||||
t0 = performance.now()
|
||||
}
|
||||
|
||||
timer.format = function (f) {
|
||||
let t1 = performance.now()
|
||||
|
||||
return dayjs.duration(t1 - t0).format(f)
|
||||
}
|
||||
|
||||
timer.humanize = function (suffix = true) {
|
||||
let t1 = performance.now()
|
||||
|
||||
return dayjs.duration(t1 - t0).humanize(suffix)
|
||||
}
|
||||
|
||||
module.exports = timer
|
||||
@ -1,9 +0,0 @@
|
||||
const file = require('./helpers/file')
|
||||
|
||||
file.list().then(files => {
|
||||
files = files.filter(file => file !== 'channels/unsorted.m3u')
|
||||
const country = files.map(file => file.replace(/channels\/|\.m3u/gi, ''))
|
||||
const matrix = { country }
|
||||
const output = `::set-output name=matrix::${JSON.stringify(matrix)}`
|
||||
console.log(output)
|
||||
})
|
||||
@ -0,0 +1 @@
|
||||
codes.json
|
||||
@ -1,147 +1,147 @@
|
||||
[
|
||||
{
|
||||
{
|
||||
"auto": {
|
||||
"name": "Auto",
|
||||
"id": "auto",
|
||||
"slug": "auto",
|
||||
"nsfw": false
|
||||
},
|
||||
{
|
||||
"animation": {
|
||||
"name": "Animation",
|
||||
"id": "animation",
|
||||
"slug": "animation",
|
||||
"nsfw": false
|
||||
},
|
||||
{
|
||||
"business": {
|
||||
"name": "Business",
|
||||
"id": "business",
|
||||
"slug": "business",
|
||||
"nsfw": false
|
||||
},
|
||||
{
|
||||
"classic": {
|
||||
"name": "Classic",
|
||||
"id": "classic",
|
||||
"slug": "classic",
|
||||
"nsfw": false
|
||||
},
|
||||
{
|
||||
"comedy": {
|
||||
"name": "Comedy",
|
||||
"id": "comedy",
|
||||
"slug": "comedy",
|
||||
"nsfw": false
|
||||
},
|
||||
{
|
||||
"cooking": {
|
||||
"name": "Cooking",
|
||||
"id": "cooking",
|
||||
"slug": "cooking",
|
||||
"nsfw": false
|
||||
},
|
||||
{
|
||||
"culture": {
|
||||
"name": "Culture",
|
||||
"id": "culture",
|
||||
"slug": "culture",
|
||||
"nsfw": false
|
||||
},
|
||||
{
|
||||
"documentary": {
|
||||
"name": "Documentary",
|
||||
"id": "documentary",
|
||||
"slug": "documentary",
|
||||
"nsfw": false
|
||||
},
|
||||
{
|
||||
"education": {
|
||||
"name": "Education",
|
||||
"id": "education",
|
||||
"slug": "education",
|
||||
"nsfw": false
|
||||
},
|
||||
{
|
||||
"entertainment": {
|
||||
"name": "Entertainment",
|
||||
"id": "entertainment",
|
||||
"slug": "entertainment",
|
||||
"nsfw": false
|
||||
},
|
||||
{
|
||||
"family": {
|
||||
"name": "Family",
|
||||
"id": "family",
|
||||
"slug": "family",
|
||||
"nsfw": false
|
||||
},
|
||||
{
|
||||
"general": {
|
||||
"name": "General",
|
||||
"id": "general",
|
||||
"slug": "general",
|
||||
"nsfw": false
|
||||
},
|
||||
{
|
||||
"kids": {
|
||||
"name": "Kids",
|
||||
"id": "kids",
|
||||
"slug": "kids",
|
||||
"nsfw": false
|
||||
},
|
||||
{
|
||||
"legislative": {
|
||||
"name": "Legislative",
|
||||
"id": "legislative",
|
||||
"slug": "legislative",
|
||||
"nsfw": false
|
||||
},
|
||||
{
|
||||
"lifestyle": {
|
||||
"name": "Lifestyle",
|
||||
"id": "lifestyle",
|
||||
"slug": "lifestyle",
|
||||
"nsfw": false
|
||||
},
|
||||
{
|
||||
"local": {
|
||||
"name": "Local",
|
||||
"id": "local",
|
||||
"slug": "local",
|
||||
"nsfw": false
|
||||
},
|
||||
{
|
||||
"movies": {
|
||||
"name": "Movies",
|
||||
"id": "movies",
|
||||
"slug": "movies",
|
||||
"nsfw": false
|
||||
},
|
||||
{
|
||||
"music": {
|
||||
"name": "Music",
|
||||
"id": "music",
|
||||
"slug": "music",
|
||||
"nsfw": false
|
||||
},
|
||||
{
|
||||
"news": {
|
||||
"name": "News",
|
||||
"id": "news",
|
||||
"slug": "news",
|
||||
"nsfw": false
|
||||
},
|
||||
{
|
||||
"outdoor": {
|
||||
"name": "Outdoor",
|
||||
"id": "outdoor",
|
||||
"slug": "outdoor",
|
||||
"nsfw": false
|
||||
},
|
||||
{
|
||||
"relax": {
|
||||
"name": "Relax",
|
||||
"id": "relax",
|
||||
"slug": "relax",
|
||||
"nsfw": false
|
||||
},
|
||||
{
|
||||
"religious": {
|
||||
"name": "Religious",
|
||||
"id": "religious",
|
||||
"slug": "religious",
|
||||
"nsfw": false
|
||||
},
|
||||
{
|
||||
"series": {
|
||||
"name": "Series",
|
||||
"id": "series",
|
||||
"slug": "series",
|
||||
"nsfw": false
|
||||
},
|
||||
{
|
||||
"science": {
|
||||
"name": "Science",
|
||||
"id": "science",
|
||||
"slug": "science",
|
||||
"nsfw": false
|
||||
},
|
||||
{
|
||||
"shop": {
|
||||
"name": "Shop",
|
||||
"id": "shop",
|
||||
"slug": "shop",
|
||||
"nsfw": false
|
||||
},
|
||||
{
|
||||
"sports": {
|
||||
"name": "Sports",
|
||||
"id": "sports",
|
||||
"slug": "sports",
|
||||
"nsfw": false
|
||||
},
|
||||
{
|
||||
"travel": {
|
||||
"name": "Travel",
|
||||
"id": "travel",
|
||||
"slug": "travel",
|
||||
"nsfw": false
|
||||
},
|
||||
{
|
||||
"weather": {
|
||||
"name": "Weather",
|
||||
"id": "weather",
|
||||
"slug": "weather",
|
||||
"nsfw": false
|
||||
},
|
||||
{
|
||||
"xxx": {
|
||||
"name": "XXX",
|
||||
"id": "xxx",
|
||||
"slug": "xxx",
|
||||
"nsfw": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,27 @@
|
||||
{
|
||||
"online": {
|
||||
"label": "",
|
||||
"code": "online",
|
||||
"level": 1
|
||||
},
|
||||
"geo_blocked": {
|
||||
"label": "Geo-blocked",
|
||||
"code": "geo_blocked",
|
||||
"level": 2
|
||||
},
|
||||
"not_247": {
|
||||
"label": "Not 24/7",
|
||||
"code": "not_247",
|
||||
"level": 3
|
||||
},
|
||||
"timeout": {
|
||||
"label": "Timeout",
|
||||
"code": "timeout",
|
||||
"level": 4
|
||||
},
|
||||
"offline": {
|
||||
"label": "Offline",
|
||||
"code": "offline",
|
||||
"level": 5
|
||||
}
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
const blacklist = require('./data/blacklist.json')
|
||||
const parser = require('./helpers/parser')
|
||||
const file = require('./helpers/file')
|
||||
const log = require('./helpers/log')
|
||||
|
||||
async function main() {
|
||||
log.start()
|
||||
|
||||
const files = await file.list()
|
||||
if (!files.length) log.print(`No files is selected\n`)
|
||||
for (const file of files) {
|
||||
log.print(`\nProcessing '${file}'...`)
|
||||
await parser
|
||||
.parsePlaylist(file)
|
||||
.then(removeBlacklisted)
|
||||
.then(p => p.save())
|
||||
}
|
||||
|
||||
log.print('\n')
|
||||
log.finish()
|
||||
}
|
||||
|
||||
function removeBlacklisted(playlist) {
|
||||
const channels = playlist.channels.filter(channel => {
|
||||
return !blacklist.find(item => {
|
||||
const regexp = new RegExp(item.regex, 'i')
|
||||
const hasSameName = regexp.test(channel.name)
|
||||
const fromSameCountry = playlist.country.code === item.country
|
||||
|
||||
return hasSameName && fromSameCountry
|
||||
})
|
||||
})
|
||||
|
||||
if (playlist.channels.length !== channels.length) {
|
||||
log.print(`updated`)
|
||||
playlist.channels = channels
|
||||
playlist.updated = true
|
||||
}
|
||||
|
||||
return playlist
|
||||
}
|
||||
|
||||
main()
|
||||
@ -1,307 +0,0 @@
|
||||
const axios = require('axios')
|
||||
const { program } = require('commander')
|
||||
const normalize = require('normalize-url')
|
||||
const IPTVChecker = require('iptv-checker')
|
||||
const parser = require('./helpers/parser')
|
||||
const utils = require('./helpers/utils')
|
||||
const file = require('./helpers/file')
|
||||
const log = require('./helpers/log')
|
||||
const epg = require('./helpers/epg')
|
||||
|
||||
const ignoreStatus = ['Geo-blocked']
|
||||
|
||||
program
|
||||
.usage('[OPTIONS]...')
|
||||
.option('--debug', 'Enable debug mode')
|
||||
.option('--offline', 'Enable offline mode')
|
||||
.option('-d, --delay <delay>', 'Set delay for each request', parseNumber, 0)
|
||||
.option('-t, --timeout <timeout>', 'Set timeout for each request', parseNumber, 5000)
|
||||
.option('-c, --country <country>', 'Comma-separated list of country codes', '')
|
||||
.option('-e, --exclude <exclude>', 'Comma-separated list of country codes to be excluded', '')
|
||||
.parse(process.argv)
|
||||
|
||||
const config = program.opts()
|
||||
const checker = new IPTVChecker({
|
||||
timeout: config.timeout
|
||||
})
|
||||
|
||||
let buffer, origins
|
||||
async function main() {
|
||||
log.start()
|
||||
|
||||
const include = config.country.split(',').filter(i => i)
|
||||
const exclude = config.exclude.split(',').filter(i => i)
|
||||
let files = await file.list(include, exclude)
|
||||
if (!files.length) log.print(`No files is selected\n`)
|
||||
for (const file of files) {
|
||||
await parser.parsePlaylist(file).then(updatePlaylist).then(savePlaylist)
|
||||
}
|
||||
|
||||
log.finish()
|
||||
}
|
||||
|
||||
function savePlaylist(playlist) {
|
||||
if (file.read(playlist.url) !== playlist.toString()) {
|
||||
log.print(`File '${playlist.url}' has been updated\n`)
|
||||
playlist.updated = true
|
||||
}
|
||||
|
||||
playlist.save()
|
||||
}
|
||||
|
||||
async function updatePlaylist(playlist) {
|
||||
const total = playlist.channels.length
|
||||
log.print(`Processing '${playlist.url}'...\n`)
|
||||
|
||||
let channels = {}
|
||||
let codes = {}
|
||||
if (!config.offline) {
|
||||
channels = await loadChannelsJson()
|
||||
codes = await loadCodes()
|
||||
}
|
||||
|
||||
buffer = {}
|
||||
origins = {}
|
||||
for (const [i, channel] of playlist.channels.entries()) {
|
||||
const curr = i + 1
|
||||
updateTvgName(channel)
|
||||
updateTvgId(channel, playlist)
|
||||
updateTvgCountry(channel)
|
||||
normalizeUrl(channel)
|
||||
|
||||
const data = channels[channel.tvg.id]
|
||||
const epgData = codes[channel.tvg.id]
|
||||
updateLogo(channel, data, epgData)
|
||||
updateGroupTitle(channel, data)
|
||||
updateTvgLanguage(channel, data)
|
||||
|
||||
if (config.offline || ignoreStatus.includes(channel.status)) {
|
||||
continue
|
||||
}
|
||||
|
||||
await checker
|
||||
.checkStream(channel.data)
|
||||
.then(parseResult)
|
||||
.then(result => {
|
||||
updateStatus(channel, result.status)
|
||||
if (result.status === 'online') {
|
||||
buffer[i] = result
|
||||
updateOrigins(channel, result.requests)
|
||||
updateResolution(channel, result.resolution)
|
||||
} else {
|
||||
buffer[i] = null
|
||||
if (config.debug) {
|
||||
log.print(` INFO: ${channel.url} (${result.error})\n`)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
buffer[i] = null
|
||||
if (config.debug) {
|
||||
log.print(` ERR: ${channel.data.url} (${err.message})\n`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (const [i, channel] of playlist.channels.entries()) {
|
||||
if (!buffer[i]) continue
|
||||
const { requests } = buffer[i]
|
||||
updateUrl(channel, requests)
|
||||
}
|
||||
|
||||
return playlist
|
||||
}
|
||||
|
||||
function updateOrigins(channel, requests) {
|
||||
if (!requests) return
|
||||
const origin = new URL(channel.url)
|
||||
const target = new URL(requests[0])
|
||||
const type = origin.host === target.host ? 'origin' : 'redirect'
|
||||
requests.forEach(url => {
|
||||
const key = utils.removeProtocol(url)
|
||||
if (!origins[key] && type === 'origin') {
|
||||
origins[key] = channel.url
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function updateStatus(channel, status) {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
if (channel.status !== 'Not 24/7')
|
||||
channel.status = channel.status === 'Offline' ? 'Not 24/7' : null
|
||||
break
|
||||
case 'error_403':
|
||||
if (!channel.status) channel.status = 'Geo-blocked'
|
||||
break
|
||||
case 'offline':
|
||||
if (channel.status !== 'Not 24/7') channel.status = 'Offline'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function updateResolution(channel, resolution) {
|
||||
if (!channel.resolution.height && resolution) {
|
||||
channel.resolution = resolution
|
||||
}
|
||||
}
|
||||
|
||||
function updateUrl(channel, requests) {
|
||||
for (const request of requests) {
|
||||
let key = utils.removeProtocol(channel.url)
|
||||
if (origins[key]) {
|
||||
channel.updateUrl(origins[key])
|
||||
break
|
||||
}
|
||||
|
||||
key = utils.removeProtocol(request)
|
||||
if (origins[key]) {
|
||||
channel.updateUrl(origins[key])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseResult(result) {
|
||||
return {
|
||||
status: parseStatus(result.status),
|
||||
resolution: result.status.ok ? parseResolution(result.status.metadata.streams) : null,
|
||||
requests: result.status.ok ? parseRequests(result.status.metadata.requests) : [],
|
||||
error: !result.status.ok ? result.status.reason : null
|
||||
}
|
||||
}
|
||||
|
||||
function parseStatus(status) {
|
||||
if (status.ok) {
|
||||
return 'online'
|
||||
} else if (status.reason.includes('timed out')) {
|
||||
return 'timeout'
|
||||
} else if (status.reason.includes('403')) {
|
||||
return 'error_403'
|
||||
} else if (status.reason.includes('not one of 40{0,1,3,4}')) {
|
||||
return 'error_40x' // 402, 451
|
||||
} else {
|
||||
return 'offline'
|
||||
}
|
||||
}
|
||||
|
||||
function parseResolution(streams) {
|
||||
const resolution = streams
|
||||
.filter(stream => stream.codec_type === 'video')
|
||||
.reduce(
|
||||
(acc, curr) => {
|
||||
if (curr.height > acc.height) return { width: curr.width, height: curr.height }
|
||||
return acc
|
||||
},
|
||||
{ width: 0, height: 0 }
|
||||
)
|
||||
|
||||
return resolution.width > 0 && resolution.height > 0 ? resolution : null
|
||||
}
|
||||
|
||||
function parseRequests(requests) {
|
||||
requests = requests.map(r => r.url)
|
||||
requests.shift()
|
||||
|
||||
return requests
|
||||
}
|
||||
|
||||
function updateTvgName(channel) {
|
||||
if (!channel.tvg.name) {
|
||||
channel.tvg.name = channel.name.replace(/\"/gi, '')
|
||||
}
|
||||
}
|
||||
|
||||
function updateTvgId(channel, playlist) {
|
||||
const code = playlist.country.code
|
||||
if (!channel.tvg.id && channel.tvg.name) {
|
||||
const id = utils.name2id(channel.tvg.name)
|
||||
channel.tvg.id = id ? `${id}.${code}` : ''
|
||||
}
|
||||
}
|
||||
|
||||
function updateTvgCountry(channel) {
|
||||
if (!channel.countries.length && channel.tvg.id) {
|
||||
const code = channel.tvg.id.split('.')[1] || null
|
||||
const name = utils.code2name(code)
|
||||
channel.countries = name ? [{ code, name }] : []
|
||||
channel.tvg.country = channel.countries.map(c => c.code.toUpperCase()).join(';')
|
||||
}
|
||||
}
|
||||
|
||||
function updateLogo(channel, data, epgData) {
|
||||
if (!channel.logo) {
|
||||
if (data && data.logo) {
|
||||
channel.logo = data.logo
|
||||
} else if (epgData && epgData.logo) {
|
||||
channel.logo = epgData.logo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateTvgLanguage(channel, data) {
|
||||
if (!channel.tvg.language) {
|
||||
if (data && data.languages.length) {
|
||||
channel.tvg.language = data.languages.map(l => l.name).join(';')
|
||||
} else if (channel.countries.length) {
|
||||
const countryCode = channel.countries[0].code
|
||||
channel.tvg.language = utils.country2language(countryCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateGroupTitle(channel, data) {
|
||||
if (!channel.group.title) {
|
||||
if (channel.category) {
|
||||
channel.group.title = channel.category
|
||||
} else if (data && data.category) {
|
||||
channel.group.title = data.category
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUrl(channel) {
|
||||
const normalized = normalize(channel.url, { stripWWW: false })
|
||||
const decoded = decodeURIComponent(normalized).replace(/\s/g, '+')
|
||||
channel.updateUrl(decoded)
|
||||
}
|
||||
|
||||
function parseNumber(str) {
|
||||
return parseInt(str)
|
||||
}
|
||||
|
||||
function loadCodes() {
|
||||
return epg.codes
|
||||
.load()
|
||||
.then(codes => {
|
||||
let output = {}
|
||||
codes.forEach(item => {
|
||||
output[item['tvg_id']] = item
|
||||
})
|
||||
return output
|
||||
})
|
||||
.catch(console.log)
|
||||
}
|
||||
|
||||
function loadChannelsJson() {
|
||||
return axios
|
||||
.get('https://iptv-org.github.io/iptv/channels.json')
|
||||
.then(r => r.data)
|
||||
.then(channels => {
|
||||
let output = {}
|
||||
channels.forEach(channel => {
|
||||
const item = output[channel.tvg.id]
|
||||
if (!item) {
|
||||
output[channel.tvg.id] = channel
|
||||
} else {
|
||||
item.logo = item.logo || channel.logo
|
||||
item.languages = item.languages.length ? item.languages : channel.languages
|
||||
item.category = item.category || channel.category
|
||||
}
|
||||
})
|
||||
return output
|
||||
})
|
||||
.catch(console.log)
|
||||
}
|
||||
|
||||
main()
|
||||
@ -1,232 +0,0 @@
|
||||
const file = require('./helpers/file')
|
||||
const log = require('./helpers/log')
|
||||
const db = require('./helpers/db')
|
||||
|
||||
const ROOT_DIR = './.gh-pages'
|
||||
|
||||
async function main() {
|
||||
await loadDatabase()
|
||||
createRootDirectory()
|
||||
createNoJekyllFile()
|
||||
generateIndex()
|
||||
generateCategoryIndex()
|
||||
generateCountryIndex()
|
||||
generateLanguageIndex()
|
||||
generateCategories()
|
||||
generateCountries()
|
||||
generateLanguages()
|
||||
generateChannelsJson()
|
||||
showResults()
|
||||
}
|
||||
|
||||
async function loadDatabase() {
|
||||
log.print('Loading database...\n')
|
||||
await db.load()
|
||||
}
|
||||
|
||||
function createRootDirectory() {
|
||||
log.print('Creating .gh-pages folder...\n')
|
||||
file.createDir(ROOT_DIR)
|
||||
}
|
||||
|
||||
function createNoJekyllFile() {
|
||||
log.print('Creating .nojekyll...\n')
|
||||
file.create(`${ROOT_DIR}/.nojekyll`)
|
||||
}
|
||||
|
||||
function generateIndex() {
|
||||
log.print('Generating index.m3u...\n')
|
||||
const channels = db.channels
|
||||
.sortBy(['name', 'status', 'resolution.height', 'url'], ['asc', 'asc', 'desc', 'asc'])
|
||||
.removeDuplicates()
|
||||
.removeOffline()
|
||||
.get()
|
||||
const guides = channels.map(channel => channel.tvg.url)
|
||||
|
||||
const filename = `${ROOT_DIR}/index.m3u`
|
||||
const urlTvg = generateUrlTvg(guides)
|
||||
file.create(filename, `#EXTM3U url-tvg="${urlTvg}"\n`)
|
||||
|
||||
const nsfwFilename = `${ROOT_DIR}/index.nsfw.m3u`
|
||||
file.create(nsfwFilename, `#EXTM3U url-tvg="${urlTvg}"\n`)
|
||||
|
||||
for (const channel of channels) {
|
||||
if (!channel.isNSFW()) {
|
||||
file.append(filename, channel.toString())
|
||||
}
|
||||
file.append(nsfwFilename, channel.toString())
|
||||
}
|
||||
}
|
||||
|
||||
function generateCategoryIndex() {
|
||||
log.print('Generating index.category.m3u...\n')
|
||||
const channels = db.channels
|
||||
.sortBy(
|
||||
['category', 'name', 'status', 'resolution.height', 'url'],
|
||||
['asc', 'asc', 'asc', 'desc', 'asc']
|
||||
)
|
||||
.removeDuplicates()
|
||||
.removeOffline()
|
||||
.get()
|
||||
const guides = channels.map(channel => channel.tvg.url)
|
||||
|
||||
const filename = `${ROOT_DIR}/index.category.m3u`
|
||||
const urlTvg = generateUrlTvg(guides)
|
||||
file.create(filename, `#EXTM3U url-tvg="${urlTvg}"\n`)
|
||||
|
||||
for (const channel of channels) {
|
||||
file.append(filename, channel.toString())
|
||||
}
|
||||
}
|
||||
|
||||
function generateCountryIndex() {
|
||||
log.print('Generating index.country.m3u...\n')
|
||||
|
||||
const guides = []
|
||||
const lines = []
|
||||
for (const country of [{ code: 'undefined' }, ...db.countries.sortBy(['name']).all()]) {
|
||||
const channels = db.channels
|
||||
.sortBy(['name', 'status', 'resolution.height', 'url'], ['asc', 'asc', 'desc', 'asc'])
|
||||
.forCountry(country)
|
||||
.removeDuplicates()
|
||||
.removeNSFW()
|
||||
.removeOffline()
|
||||
.get()
|
||||
for (const channel of channels) {
|
||||
const groupTitle = channel.group.title
|
||||
channel.group.title = country.name || ''
|
||||
lines.push(channel.toString())
|
||||
channel.group.title = groupTitle
|
||||
guides.push(channel.tvg.url)
|
||||
}
|
||||
}
|
||||
|
||||
const filename = `${ROOT_DIR}/index.country.m3u`
|
||||
const urlTvg = generateUrlTvg(guides)
|
||||
file.create(filename, `#EXTM3U url-tvg="${urlTvg}"\n${lines.join('')}`)
|
||||
}
|
||||
|
||||
function generateLanguageIndex() {
|
||||
log.print('Generating index.language.m3u...\n')
|
||||
|
||||
const guides = []
|
||||
const lines = []
|
||||
for (const language of [{ code: 'undefined' }, ...db.languages.sortBy(['name']).all()]) {
|
||||
const channels = db.channels
|
||||
.sortBy(['name', 'status', 'resolution.height', 'url'], ['asc', 'asc', 'desc', 'asc'])
|
||||
.forLanguage(language)
|
||||
.removeDuplicates()
|
||||
.removeNSFW()
|
||||
.removeOffline()
|
||||
.get()
|
||||
for (const channel of channels) {
|
||||
const groupTitle = channel.group.title
|
||||
channel.group.title = language.name || ''
|
||||
lines.push(channel.toString())
|
||||
channel.group.title = groupTitle
|
||||
guides.push(channel.tvg.url)
|
||||
}
|
||||
}
|
||||
|
||||
const filename = `${ROOT_DIR}/index.language.m3u`
|
||||
const urlTvg = generateUrlTvg(guides)
|
||||
file.create(filename, `#EXTM3U url-tvg="${urlTvg}"\n${lines.join('')}`)
|
||||
}
|
||||
|
||||
function generateCategories() {
|
||||
log.print(`Generating /categories...\n`)
|
||||
const outputDir = `${ROOT_DIR}/categories`
|
||||
file.createDir(outputDir)
|
||||
|
||||
for (const category of [...db.categories.all(), { id: 'other' }]) {
|
||||
const channels = db.channels
|
||||
.sortBy(['name', 'status', 'resolution.height', 'url'], ['asc', 'asc', 'desc', 'asc'])
|
||||
.forCategory(category)
|
||||
.removeDuplicates()
|
||||
.removeOffline()
|
||||
.get()
|
||||
const guides = channels.map(channel => channel.tvg.url)
|
||||
|
||||
const filename = `${outputDir}/${category.id}.m3u`
|
||||
const urlTvg = generateUrlTvg(guides)
|
||||
file.create(filename, `#EXTM3U url-tvg="${urlTvg}"\n`)
|
||||
for (const channel of channels) {
|
||||
file.append(filename, channel.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function generateCountries() {
|
||||
log.print(`Generating /countries...\n`)
|
||||
const outputDir = `${ROOT_DIR}/countries`
|
||||
file.createDir(outputDir)
|
||||
|
||||
for (const country of [...db.countries.all(), { code: 'undefined' }]) {
|
||||
const channels = db.channels
|
||||
.sortBy(['name', 'status', 'resolution.height', 'url'], ['asc', 'asc', 'desc', 'asc'])
|
||||
.forCountry(country)
|
||||
.removeDuplicates()
|
||||
.removeOffline()
|
||||
.removeNSFW()
|
||||
.get()
|
||||
const guides = channels.map(channel => channel.tvg.url)
|
||||
|
||||
const filename = `${outputDir}/${country.code}.m3u`
|
||||
const urlTvg = generateUrlTvg(guides)
|
||||
file.create(filename, `#EXTM3U url-tvg="${urlTvg}"\n`)
|
||||
for (const channel of channels) {
|
||||
file.append(filename, channel.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function generateLanguages() {
|
||||
log.print(`Generating /languages...\n`)
|
||||
const outputDir = `${ROOT_DIR}/languages`
|
||||
file.createDir(outputDir)
|
||||
|
||||
for (const language of [...db.languages.all(), { code: 'undefined' }]) {
|
||||
const channels = db.channels
|
||||
.sortBy(['name', 'status', 'resolution.height', 'url'], ['asc', 'asc', 'desc', 'asc'])
|
||||
.forLanguage(language)
|
||||
.removeDuplicates()
|
||||
.removeOffline()
|
||||
.removeNSFW()
|
||||
.get()
|
||||
const guides = channels.map(channel => channel.tvg.url)
|
||||
|
||||
const filename = `${outputDir}/${language.code}.m3u`
|
||||
const urlTvg = generateUrlTvg(guides)
|
||||
file.create(filename, `#EXTM3U url-tvg="${urlTvg}"\n`)
|
||||
for (const channel of channels) {
|
||||
file.append(filename, channel.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function generateChannelsJson() {
|
||||
log.print('Generating channels.json...\n')
|
||||
const filename = `${ROOT_DIR}/channels.json`
|
||||
const channels = db.channels
|
||||
.sortBy(['name', 'status', 'resolution.height', 'url'], ['asc', 'asc', 'desc', 'asc'])
|
||||
.get()
|
||||
.map(c => c.toObject())
|
||||
file.create(filename, JSON.stringify(channels))
|
||||
}
|
||||
|
||||
function showResults() {
|
||||
log.print(
|
||||
`Total: ${db.channels.count()} channels, ${db.countries.count()} countries, ${db.languages.count()} languages, ${db.categories.count()} categories.\n`
|
||||
)
|
||||
}
|
||||
|
||||
function generateUrlTvg(guides) {
|
||||
const output = guides.reduce((acc, curr) => {
|
||||
if (curr && !acc.includes(curr)) acc.push(curr)
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
return output.sort().join(',')
|
||||
}
|
||||
|
||||
main()
|
||||
@ -1,162 +0,0 @@
|
||||
const categories = require('../data/categories')
|
||||
const utils = require('./utils')
|
||||
const file = require('./file')
|
||||
|
||||
const sfwCategories = categories.filter(c => !c.nsfw).map(c => c.name)
|
||||
const nsfwCategories = categories.filter(c => c.nsfw).map(c => c.name)
|
||||
|
||||
module.exports = class Channel {
|
||||
constructor(data) {
|
||||
this.data = data
|
||||
this.raw = data.raw
|
||||
this.tvg = data.tvg
|
||||
this.http = data.http
|
||||
this.url = data.url
|
||||
this.logo = data.tvg.logo
|
||||
this.group = data.group
|
||||
this.name = this.parseName(data.name)
|
||||
this.status = this.parseStatus(data.name)
|
||||
this.resolution = this.parseResolution(data.name)
|
||||
this.category = this.parseCategory(data.group.title)
|
||||
this.countries = this.parseCountries(data.tvg.country)
|
||||
this.languages = this.parseLanguages(data.tvg.language)
|
||||
this.hash = this.generateHash()
|
||||
}
|
||||
|
||||
generateHash() {
|
||||
return `${this.tvg.id}:${this.tvg.country}:${this.tvg.language}:${this.logo}:${this.group.title}:${this.name}`.toLowerCase()
|
||||
}
|
||||
|
||||
updateUrl(url) {
|
||||
this.url = url
|
||||
this.data.url = url
|
||||
}
|
||||
|
||||
parseName(title) {
|
||||
return title
|
||||
.trim()
|
||||
.split(' ')
|
||||
.map(s => s.trim())
|
||||
.filter(s => {
|
||||
return !/\[|\]/i.test(s) && !/\((\d+)P\)/i.test(s)
|
||||
})
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
parseStatus(title) {
|
||||
const match = title.match(/\[(.*)\]/i)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
parseResolution(title) {
|
||||
const match = title.match(/\((\d+)P\)/i)
|
||||
const height = match ? parseInt(match[1]) : null
|
||||
|
||||
return { width: null, height }
|
||||
}
|
||||
|
||||
parseCategory(string) {
|
||||
const category = categories.find(c => c.id === string.toLowerCase())
|
||||
if (!category) return ''
|
||||
|
||||
return category.name
|
||||
}
|
||||
|
||||
parseCountries(string) {
|
||||
const list = string.split(';')
|
||||
return list
|
||||
.reduce((acc, curr) => {
|
||||
const codes = utils.region2codes(curr)
|
||||
if (codes.length) {
|
||||
for (let code of codes) {
|
||||
if (!acc.includes(code)) {
|
||||
acc.push(code)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
acc.push(curr)
|
||||
}
|
||||
|
||||
return acc
|
||||
}, [])
|
||||
.map(code => {
|
||||
const name = code ? utils.code2name(code) : null
|
||||
if (!name) return null
|
||||
|
||||
return { code: code.toLowerCase(), name }
|
||||
})
|
||||
.filter(c => c)
|
||||
}
|
||||
|
||||
parseLanguages(string) {
|
||||
const list = string.split(';')
|
||||
return list
|
||||
.map(name => {
|
||||
const code = name ? utils.language2code(name) : null
|
||||
if (!code) return null
|
||||
|
||||
return { code, name }
|
||||
})
|
||||
.filter(l => l)
|
||||
}
|
||||
|
||||
isSFW() {
|
||||
return sfwCategories.includes(this.category)
|
||||
}
|
||||
|
||||
isNSFW() {
|
||||
return nsfwCategories.includes(this.category)
|
||||
}
|
||||
|
||||
getInfo() {
|
||||
let info = `-1 tvg-id="${this.tvg.id}" tvg-country="${this.tvg.country || ''}" tvg-language="${
|
||||
this.tvg.language || ''
|
||||
}" tvg-logo="${this.logo || ''}"`
|
||||
|
||||
if (this.http['user-agent']) {
|
||||
info += ` user-agent="${this.http['user-agent']}"`
|
||||
}
|
||||
|
||||
info += ` group-title="${this.group.title || ''}",${this.name}`
|
||||
|
||||
if (this.resolution.height) {
|
||||
info += ` (${this.resolution.height}p)`
|
||||
}
|
||||
|
||||
if (this.status) {
|
||||
info += ` [${this.status}]`
|
||||
}
|
||||
|
||||
if (this.http['referrer']) {
|
||||
info += `\n#EXTVLCOPT:http-referrer=${this.http['referrer']}`
|
||||
}
|
||||
|
||||
if (this.http['user-agent']) {
|
||||
info += `\n#EXTVLCOPT:http-user-agent=${this.http['user-agent']}`
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
toString(raw = false) {
|
||||
if (raw) return this.raw + '\n'
|
||||
|
||||
return '#EXTINF:' + this.getInfo() + '\n' + this.url + '\n'
|
||||
}
|
||||
|
||||
toObject() {
|
||||
return {
|
||||
name: this.name,
|
||||
logo: this.logo || null,
|
||||
url: this.url,
|
||||
category: this.category || null,
|
||||
languages: this.languages,
|
||||
countries: this.countries,
|
||||
tvg: {
|
||||
id: this.tvg.id || null,
|
||||
name: this.tvg.name || this.name.replace(/\"/gi, ''),
|
||||
url: this.tvg.url || null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,41 +0,0 @@
|
||||
const Channel = require('./Channel')
|
||||
const file = require('./file')
|
||||
|
||||
module.exports = class Playlist {
|
||||
constructor({ header, items, url, name, country }) {
|
||||
this.url = url
|
||||
this.name = name
|
||||
this.country = country
|
||||
this.header = header
|
||||
this.channels = items.map(item => new Channel(item)).filter(channel => channel.url)
|
||||
this.updated = false
|
||||
}
|
||||
|
||||
getHeader() {
|
||||
let header = ['#EXTM3U']
|
||||
for (let key in this.header.attrs) {
|
||||
let value = this.header.attrs[key]
|
||||
if (value) {
|
||||
header.push(`${key}="${value}"`)
|
||||
}
|
||||
}
|
||||
|
||||
return header.join(' ')
|
||||
}
|
||||
|
||||
toString(options = {}) {
|
||||
const config = { raw: false, ...options }
|
||||
let output = `${this.getHeader()}\n`
|
||||
for (let channel of this.channels) {
|
||||
output += channel.toString(config.raw)
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
save() {
|
||||
if (this.updated) {
|
||||
file.create(this.url, this.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,237 +0,0 @@
|
||||
const categories = require('../data/categories')
|
||||
const parser = require('./parser')
|
||||
const utils = require('./utils')
|
||||
const file = require('./file')
|
||||
const epg = require('./epg')
|
||||
|
||||
const db = {}
|
||||
|
||||
db.load = async function () {
|
||||
const files = await file.list()
|
||||
const codes = await epg.codes.load()
|
||||
for (const file of files) {
|
||||
const playlist = await parser.parsePlaylist(file)
|
||||
for (const channel of playlist.channels) {
|
||||
const code = codes.find(ch => ch['tvg_id'] === channel.tvg.id)
|
||||
if (code && Array.isArray(code.guides) && code.guides.length) {
|
||||
channel.tvg.url = code.guides[0]
|
||||
}
|
||||
|
||||
db.channels.add(channel)
|
||||
|
||||
for (const country of channel.countries) {
|
||||
if (!db.countries.has(country)) {
|
||||
db.countries.add(country)
|
||||
}
|
||||
}
|
||||
|
||||
for (const language of channel.languages) {
|
||||
if (!db.languages.has(language)) {
|
||||
db.languages.add(language)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db.playlists.add(playlist)
|
||||
}
|
||||
}
|
||||
|
||||
db.channels = {
|
||||
list: [],
|
||||
filter: null,
|
||||
duplicates: true,
|
||||
offline: true,
|
||||
nsfw: true,
|
||||
add(channel) {
|
||||
this.list.push(channel)
|
||||
},
|
||||
get() {
|
||||
let output
|
||||
if (this.filter) {
|
||||
switch (this.filter.field) {
|
||||
case 'countries':
|
||||
if (this.filter.value === 'undefined') {
|
||||
output = this.list.filter(channel => !channel.countries.length)
|
||||
} else {
|
||||
output = this.list.filter(channel =>
|
||||
channel.countries.map(c => c.code).includes(this.filter.value)
|
||||
)
|
||||
}
|
||||
break
|
||||
case 'languages':
|
||||
if (this.filter.value === 'undefined') {
|
||||
output = this.list.filter(channel => !channel.languages.length)
|
||||
} else {
|
||||
output = this.list.filter(channel =>
|
||||
channel.languages.map(c => c.code).includes(this.filter.value)
|
||||
)
|
||||
}
|
||||
break
|
||||
case 'category':
|
||||
if (this.filter.value === 'other') {
|
||||
output = this.list.filter(channel => !channel.category)
|
||||
} else {
|
||||
output = this.list.filter(
|
||||
channel => channel.category.toLowerCase() === this.filter.value
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
} else {
|
||||
output = this.list
|
||||
}
|
||||
|
||||
if (!this.duplicates) {
|
||||
const buffer = []
|
||||
output = output.filter(channel => {
|
||||
if (buffer.includes(channel.hash)) return false
|
||||
buffer.push(channel.hash)
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
if (!this.nsfw) {
|
||||
output = output.filter(channel => !channel.isNSFW())
|
||||
}
|
||||
|
||||
if (!this.offline) {
|
||||
output = output.filter(channel => channel.status !== 'Offline')
|
||||
}
|
||||
|
||||
this.nsfw = true
|
||||
this.duplicates = true
|
||||
this.offline = true
|
||||
this.filter = null
|
||||
|
||||
return output
|
||||
},
|
||||
removeDuplicates() {
|
||||
this.duplicates = false
|
||||
|
||||
return this
|
||||
},
|
||||
removeNSFW() {
|
||||
this.nsfw = false
|
||||
|
||||
return this
|
||||
},
|
||||
removeOffline() {
|
||||
this.offline = false
|
||||
|
||||
return this
|
||||
},
|
||||
all() {
|
||||
return this.list
|
||||
},
|
||||
forCountry(country) {
|
||||
this.filter = {
|
||||
field: 'countries',
|
||||
value: country.code
|
||||
}
|
||||
|
||||
return this
|
||||
},
|
||||
forLanguage(language) {
|
||||
this.filter = {
|
||||
field: 'languages',
|
||||
value: language.code
|
||||
}
|
||||
|
||||
return this
|
||||
},
|
||||
forCategory(category) {
|
||||
this.filter = {
|
||||
field: 'category',
|
||||
value: category.id
|
||||
}
|
||||
|
||||
return this
|
||||
},
|
||||
count() {
|
||||
return this.get().length
|
||||
},
|
||||
sortBy(fields, order) {
|
||||
this.list = utils.sortBy(this.list, fields, order)
|
||||
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
db.countries = {
|
||||
list: [],
|
||||
has(country) {
|
||||
return this.list.map(c => c.code).includes(country.code)
|
||||
},
|
||||
add(country) {
|
||||
this.list.push(country)
|
||||
},
|
||||
all() {
|
||||
return this.list
|
||||
},
|
||||
count() {
|
||||
return this.list.length
|
||||
},
|
||||
sortBy(fields, order) {
|
||||
this.list = utils.sortBy(this.list, fields, order)
|
||||
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
db.languages = {
|
||||
list: [],
|
||||
has(language) {
|
||||
return this.list.map(c => c.code).includes(language.code)
|
||||
},
|
||||
add(language) {
|
||||
this.list.push(language)
|
||||
},
|
||||
all() {
|
||||
return this.list
|
||||
},
|
||||
count() {
|
||||
return this.list.length
|
||||
},
|
||||
sortBy(fields, order) {
|
||||
this.list = utils.sortBy(this.list, fields, order)
|
||||
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
db.categories = {
|
||||
list: categories,
|
||||
all() {
|
||||
return this.list
|
||||
},
|
||||
count() {
|
||||
return this.list.length
|
||||
}
|
||||
}
|
||||
|
||||
db.playlists = {
|
||||
list: [],
|
||||
add(playlist) {
|
||||
this.list.push(playlist)
|
||||
},
|
||||
all() {
|
||||
return this.list
|
||||
},
|
||||
only(list = []) {
|
||||
return this.list.filter(playlist => list.includes(playlist.filename))
|
||||
},
|
||||
except(list = []) {
|
||||
return this.list.filter(playlist => !list.includes(playlist.filename))
|
||||
},
|
||||
sortBy(fields, order) {
|
||||
this.list = utils.sortBy(this.list, fields, order)
|
||||
|
||||
return this
|
||||
},
|
||||
count() {
|
||||
return this.list.length
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = db
|
||||
@ -1,12 +0,0 @@
|
||||
const axios = require('axios')
|
||||
|
||||
module.exports = {
|
||||
codes: {
|
||||
async load() {
|
||||
return await axios
|
||||
.get('https://iptv-org.github.io/epg/codes.json')
|
||||
.then(r => r.data)
|
||||
.catch(console.log)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,53 +0,0 @@
|
||||
const markdownInclude = require('markdown-include')
|
||||
const path = require('path')
|
||||
const glob = require('glob')
|
||||
const fs = require('fs')
|
||||
|
||||
const rootPath = path.resolve(__dirname) + '/../../'
|
||||
const file = {}
|
||||
|
||||
file.list = function (include = [], exclude = []) {
|
||||
return new Promise(resolve => {
|
||||
glob('channels/**/*.m3u', function (err, files) {
|
||||
if (include.length) {
|
||||
include = include.map(filename => `channels/${filename}.m3u`)
|
||||
files = files.filter(filename => include.includes(filename))
|
||||
}
|
||||
|
||||
if (exclude.length) {
|
||||
exclude = exclude.map(filename => `channels/${filename}.m3u`)
|
||||
files = files.filter(filename => !exclude.includes(filename))
|
||||
}
|
||||
|
||||
resolve(files)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
file.getFilename = function (filename) {
|
||||
return path.parse(filename).name
|
||||
}
|
||||
|
||||
file.createDir = function (dir) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir)
|
||||
}
|
||||
}
|
||||
|
||||
file.read = function (filename) {
|
||||
return fs.readFileSync(rootPath + filename, { encoding: 'utf8' })
|
||||
}
|
||||
|
||||
file.append = function (filename, data) {
|
||||
fs.appendFileSync(rootPath + filename, data)
|
||||
}
|
||||
|
||||
file.create = function (filename, data = '') {
|
||||
fs.writeFileSync(rootPath + filename, data)
|
||||
}
|
||||
|
||||
file.compileMarkdown = function (filename) {
|
||||
markdownInclude.compileFiles(rootPath + filename)
|
||||
}
|
||||
|
||||
module.exports = file
|
||||
@ -1,17 +0,0 @@
|
||||
const log = {}
|
||||
|
||||
log.print = function (message) {
|
||||
if (typeof message === 'object') message = JSON.stringify(message, null, 2)
|
||||
process.stdout.write(message)
|
||||
}
|
||||
|
||||
log.start = function () {
|
||||
this.print('Starting...\n')
|
||||
console.time('Done in')
|
||||
}
|
||||
|
||||
log.finish = function () {
|
||||
console.timeEnd('Done in')
|
||||
}
|
||||
|
||||
module.exports = log
|
||||
@ -1,20 +0,0 @@
|
||||
const playlistParser = require('iptv-playlist-parser')
|
||||
const Playlist = require('./Playlist')
|
||||
const utils = require('./utils')
|
||||
const file = require('./file')
|
||||
|
||||
const parser = {}
|
||||
|
||||
parser.parsePlaylist = async function (url) {
|
||||
const content = file.read(url)
|
||||
const result = playlistParser.parse(content)
|
||||
const filename = file.getFilename(url)
|
||||
const country = {
|
||||
code: filename,
|
||||
name: utils.code2name(filename)
|
||||
}
|
||||
|
||||
return new Playlist({ header: result.header, items: result.items, url, filename, country })
|
||||
}
|
||||
|
||||
module.exports = parser
|
||||
@ -1,86 +0,0 @@
|
||||
const { orderBy } = require('natural-orderby')
|
||||
const transliteration = require('transliteration')
|
||||
const countries = require('../data/countries')
|
||||
const categories = require('../data/categories')
|
||||
const languages = require('../data/languages')
|
||||
const regions = require('../data/regions')
|
||||
|
||||
const utils = {}
|
||||
const intlDisplayNames = new Intl.DisplayNames(['en'], {
|
||||
style: 'narrow',
|
||||
type: 'region'
|
||||
})
|
||||
|
||||
utils.name2id = function (name) {
|
||||
return transliteration
|
||||
.transliterate(name)
|
||||
.replace(/\+/gi, 'Plus')
|
||||
.replace(/[^a-z\d]+/gi, '')
|
||||
}
|
||||
|
||||
utils.code2flag = function (code) {
|
||||
code = code.toUpperCase()
|
||||
switch (code) {
|
||||
case 'UK':
|
||||
return '🇬🇧'
|
||||
case 'INT':
|
||||
return '🌍'
|
||||
case 'UNDEFINED':
|
||||
return ''
|
||||
default:
|
||||
return code.replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397))
|
||||
}
|
||||
}
|
||||
|
||||
utils.region2codes = function (region) {
|
||||
region = region.toUpperCase()
|
||||
|
||||
return regions[region] ? regions[region].codes : []
|
||||
}
|
||||
|
||||
utils.code2name = function (code) {
|
||||
try {
|
||||
code = code.toUpperCase()
|
||||
if (regions[code]) return regions[code].name
|
||||
if (code === 'US') return 'United States'
|
||||
if (code === 'INT') return 'International'
|
||||
return intlDisplayNames.of(code)
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
utils.language2code = function (name) {
|
||||
const lang = languages.find(l => l.name === name)
|
||||
|
||||
return lang && lang.code ? lang.code : null
|
||||
}
|
||||
|
||||
utils.country2language = function (code) {
|
||||
const country = countries[code.toUpperCase()]
|
||||
if (!country.languages.length) return ''
|
||||
const language = languages.find(l => l.code === country.languages[0])
|
||||
|
||||
return language ? language.name : ''
|
||||
}
|
||||
|
||||
utils.sortBy = function (arr, fields, order = null) {
|
||||
fields = fields.map(field => {
|
||||
if (field === 'resolution.height') return channel => channel.resolution.height || 0
|
||||
if (field === 'status') return channel => channel.status || ''
|
||||
return channel => channel[field]
|
||||
})
|
||||
return orderBy(arr, fields, order)
|
||||
}
|
||||
|
||||
utils.removeProtocol = function (string) {
|
||||
return string.replace(/(^\w+:|^)\/\//, '')
|
||||
}
|
||||
|
||||
utils.sleep = function (ms) {
|
||||
return function (x) {
|
||||
return new Promise(resolve => setTimeout(() => resolve(x), ms))
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = utils
|
||||
@ -1,42 +0,0 @@
|
||||
const parser = require('./helpers/parser')
|
||||
const file = require('./helpers/file')
|
||||
const log = require('./helpers/log')
|
||||
|
||||
async function main() {
|
||||
log.start()
|
||||
|
||||
let files = await file.list()
|
||||
if (!files.length) log.print(`No files is selected\n`)
|
||||
files = files.filter(file => file !== 'channels/unsorted.m3u')
|
||||
for (const file of files) {
|
||||
log.print(`\nProcessing '${file}'...`)
|
||||
await parser
|
||||
.parsePlaylist(file)
|
||||
.then(removeBrokenLinks)
|
||||
.then(p => p.save())
|
||||
}
|
||||
|
||||
log.print('\n')
|
||||
log.finish()
|
||||
}
|
||||
|
||||
async function removeBrokenLinks(playlist) {
|
||||
const buffer = []
|
||||
const channels = playlist.channels.filter(channel => {
|
||||
const sameHash = buffer.find(item => item.hash === channel.hash)
|
||||
if (sameHash && channel.status === 'Offline') return false
|
||||
|
||||
buffer.push(channel)
|
||||
return true
|
||||
})
|
||||
|
||||
if (playlist.channels.length !== channels.length) {
|
||||
log.print('updated')
|
||||
playlist.channels = channels
|
||||
playlist.updated = true
|
||||
}
|
||||
|
||||
return playlist
|
||||
}
|
||||
|
||||
main()
|
||||
@ -1,81 +0,0 @@
|
||||
const parser = require('./helpers/parser')
|
||||
const utils = require('./helpers/utils')
|
||||
const file = require('./helpers/file')
|
||||
const log = require('./helpers/log')
|
||||
|
||||
let globalBuffer = []
|
||||
|
||||
async function main() {
|
||||
log.start()
|
||||
|
||||
let files = await file.list()
|
||||
if (!files.length) log.print(`No files is selected\n`)
|
||||
files = files.filter(file => file !== 'channels/unsorted.m3u')
|
||||
for (const file of files) {
|
||||
log.print(`\nProcessing '${file}'...`)
|
||||
await parser
|
||||
.parsePlaylist(file)
|
||||
.then(addToGlobalBuffer)
|
||||
.then(removeDuplicates)
|
||||
.then(p => p.save())
|
||||
}
|
||||
|
||||
if (files.length) {
|
||||
log.print(`\nProcessing 'channels/unsorted.m3u'...`)
|
||||
await parser
|
||||
.parsePlaylist('channels/unsorted.m3u')
|
||||
.then(removeDuplicates)
|
||||
.then(removeGlobalDuplicates)
|
||||
.then(p => p.save())
|
||||
}
|
||||
|
||||
log.print('\n')
|
||||
log.finish()
|
||||
}
|
||||
|
||||
async function addToGlobalBuffer(playlist) {
|
||||
playlist.channels.forEach(channel => {
|
||||
const url = utils.removeProtocol(channel.url)
|
||||
globalBuffer.push(url)
|
||||
})
|
||||
|
||||
return playlist
|
||||
}
|
||||
|
||||
async function removeDuplicates(playlist) {
|
||||
const buffer = []
|
||||
const channels = playlist.channels.filter(channel => {
|
||||
const sameUrl = buffer.find(item => {
|
||||
return utils.removeProtocol(item.url) === utils.removeProtocol(channel.url)
|
||||
})
|
||||
if (sameUrl) return false
|
||||
|
||||
buffer.push(channel)
|
||||
return true
|
||||
})
|
||||
|
||||
if (playlist.channels.length !== channels.length) {
|
||||
log.print('updated')
|
||||
playlist.channels = channels
|
||||
playlist.updated = true
|
||||
}
|
||||
|
||||
return playlist
|
||||
}
|
||||
|
||||
async function removeGlobalDuplicates(playlist) {
|
||||
const channels = playlist.channels.filter(channel => {
|
||||
const url = utils.removeProtocol(channel.url)
|
||||
return !globalBuffer.includes(url)
|
||||
})
|
||||
|
||||
if (channels.length !== playlist.channels.length) {
|
||||
log.print('updated')
|
||||
playlist.channels = channels
|
||||
playlist.updated = true
|
||||
}
|
||||
|
||||
return playlist
|
||||
}
|
||||
|
||||
main()
|
||||
@ -1,41 +0,0 @@
|
||||
const parser = require('./helpers/parser')
|
||||
const utils = require('./helpers/utils')
|
||||
const file = require('./helpers/file')
|
||||
const log = require('./helpers/log')
|
||||
|
||||
async function main() {
|
||||
log.start()
|
||||
|
||||
let files = await file.list()
|
||||
if (!files.length) log.print(`No files is selected\n`)
|
||||
files = files.filter(file => file !== 'channels/unsorted.m3u')
|
||||
for (const file of files) {
|
||||
log.print(`\nProcessing '${file}'...`)
|
||||
await parser
|
||||
.parsePlaylist(file)
|
||||
.then(sortChannels)
|
||||
.then(p => p.save())
|
||||
}
|
||||
|
||||
log.print('\n')
|
||||
log.finish()
|
||||
}
|
||||
|
||||
async function sortChannels(playlist) {
|
||||
let channels = [...playlist.channels]
|
||||
channels = utils.sortBy(
|
||||
channels,
|
||||
['name', 'status', 'resolution.height', 'url'],
|
||||
['asc', 'asc', 'desc', 'asc']
|
||||
)
|
||||
|
||||
if (JSON.stringify(channels) !== JSON.stringify(playlist.channels)) {
|
||||
log.print('updated')
|
||||
playlist.channels = channels
|
||||
playlist.updated = true
|
||||
}
|
||||
|
||||
return playlist
|
||||
}
|
||||
|
||||
main()
|
||||
@ -0,0 +1,12 @@
|
||||
module.exports = function () {
|
||||
if (this.group_title) return this.group_title
|
||||
|
||||
if (Array.isArray(this.categories)) {
|
||||
return this.categories
|
||||
.map(i => i.name)
|
||||
.sort()
|
||||
.join(';')
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
exports.group_title = require('./group_title')
|
||||
exports.title = require('./title')
|
||||
exports.tvg_country = require('./tvg_country')
|
||||
exports.tvg_id = require('./tvg_id')
|
||||
exports.tvg_language = require('./tvg_language')
|
||||
exports.tvg_logo = require('./tvg_logo')
|
||||
exports.tvg_url = require('./tvg_url')
|
||||
@ -0,0 +1,13 @@
|
||||
module.exports = function () {
|
||||
let title = this.name
|
||||
|
||||
if (this.resolution.height) {
|
||||
title += ` (${this.resolution.height}p)`
|
||||
}
|
||||
|
||||
if (this.status.label) {
|
||||
title += ` [${this.status.label}]`
|
||||
}
|
||||
|
||||
return title
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
module.exports = function () {
|
||||
if (this.tvg_country) return this.tvg_country
|
||||
|
||||
return Array.isArray(this.countries) ? this.countries.map(i => i.code).join(';') : ''
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
module.exports = function () {
|
||||
return this.id || ''
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
module.exports = function () {
|
||||
return Array.isArray(this.languages) ? this.languages.map(i => i.name).join(';') : ''
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
module.exports = function () {
|
||||
return this.logo || ''
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
module.exports = function () {
|
||||
return this.guides.length ? this.guides[0] : ''
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
const categories = require('../../data/categories')
|
||||
|
||||
module.exports = function ({ group_title }) {
|
||||
return group_title
|
||||
.split(';')
|
||||
.map(i => categories[i.toLowerCase()])
|
||||
.filter(i => i)
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
const dataRegions = require('../../data/regions')
|
||||
const dataCountries = require('../../data/countries')
|
||||
|
||||
module.exports = function ({ tvg_country, countries = [] }) {
|
||||
if (tvg_country) {
|
||||
return tvg_country
|
||||
.split(';')
|
||||
.reduce((acc, curr) => {
|
||||
const region = dataRegions[curr]
|
||||
if (region) {
|
||||
for (let code of region.country_codes) {
|
||||
if (!acc.includes(code)) acc.push(code)
|
||||
}
|
||||
} else {
|
||||
acc.push(curr)
|
||||
}
|
||||
|
||||
return acc
|
||||
}, [])
|
||||
.map(item => dataCountries[item])
|
||||
.filter(i => i)
|
||||
}
|
||||
|
||||
return countries
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
module.exports = function ({ tvg_url, guides = [] }) {
|
||||
return tvg_url ? [tvg_url] : guides
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
exports.categories = require('./categories')
|
||||
exports.countries = require('./countries')
|
||||
exports.guides = require('./guides')
|
||||
exports.is_broken = require('./is_broken')
|
||||
exports.is_nsfw = require('./is_nsfw')
|
||||
exports.languages = require('./languages')
|
||||
exports.name = require('./name')
|
||||
exports.regions = require('./regions')
|
||||
exports.resolution = require('./resolution')
|
||||
exports.src_country = require('./src_country')
|
||||
exports.status = require('./status')
|
||||
exports.url = require('./url')
|
||||
@ -0,0 +1,7 @@
|
||||
module.exports = function ({ is_broken = false, status }) {
|
||||
if (status) {
|
||||
return status.level > 3 ? true : false
|
||||
}
|
||||
|
||||
return is_broken
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
module.exports = function ({ categories }) {
|
||||
return Array.isArray(categories) ? categories.filter(c => c.nsfw).length > 0 : false
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
const langs = require('../../data/languages')
|
||||
|
||||
module.exports = function ({ tvg_language, languages = [] }) {
|
||||
if (tvg_language) {
|
||||
return tvg_language
|
||||
.split(';')
|
||||
.map(name => langs.find(l => l.name === name))
|
||||
.filter(i => i)
|
||||
}
|
||||
|
||||
return languages
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
module.exports = function ({ title }) {
|
||||
return title
|
||||
.trim()
|
||||
.split(' ')
|
||||
.map(s => s.trim())
|
||||
.filter(s => {
|
||||
return !/\[|\]/i.test(s) && !/\((\d+)P\)/i.test(s)
|
||||
})
|
||||
.join(' ')
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
const _ = require('lodash')
|
||||
|
||||
let regions = require('../../data/regions')
|
||||
|
||||
module.exports = function ({ countries }) {
|
||||
if (!countries.length) return []
|
||||
|
||||
const output = []
|
||||
regions = Object.values(regions)
|
||||
countries.forEach(country => {
|
||||
regions
|
||||
.filter(region => region.country_codes.includes(country.code))
|
||||
.forEach(found => {
|
||||
output.push({
|
||||
name: found.name,
|
||||
code: found.code
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return _.uniqBy(output, 'code')
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
module.exports = function ({ title, resolution = {} }) {
|
||||
if (title) {
|
||||
const [_, h] = title.match(/\((\d+)P\)/i) || [null, null]
|
||||
|
||||
return h ? { height: parseInt(h), width: null } : resolution
|
||||
}
|
||||
|
||||
return resolution
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
const { file } = require('../../core')
|
||||
const countries = require('../../data/countries')
|
||||
|
||||
module.exports = function ({ filepath }) {
|
||||
if (filepath) {
|
||||
const basename = file.basename(filepath)
|
||||
const [_, code] = basename.match(/([a-z]{2})(|_.*)\.m3u/i) || [null, null]
|
||||
|
||||
return code ? countries[code.toUpperCase()] : null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
const statuses = require('../../data/statuses')
|
||||
|
||||
module.exports = function ({ title, status = {} }) {
|
||||
if (title) {
|
||||
const [_, label] = title.match(/\[(.*)\]/i) || [null, null]
|
||||
|
||||
return Object.values(statuses).find(s => s.label === label) || statuses['online']
|
||||
}
|
||||
|
||||
if (status) {
|
||||
switch (status.code) {
|
||||
case 'not_247':
|
||||
case 'geo_blocked':
|
||||
return status
|
||||
case 'offline':
|
||||
return statuses['not_247']
|
||||
case 'timeout':
|
||||
return statuses['timeout']
|
||||
default:
|
||||
return statuses['online']
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
const normalize = require('normalize-url')
|
||||
|
||||
module.exports = function ({ url }) {
|
||||
const normalized = normalize(url, { stripWWW: false })
|
||||
|
||||
return decodeURIComponent(normalized).replace(/\s/g, '+')
|
||||
}
|
||||
@ -1,142 +0,0 @@
|
||||
const utils = require('./helpers/utils')
|
||||
const file = require('./helpers/file')
|
||||
const log = require('./helpers/log')
|
||||
const db = require('./helpers/db')
|
||||
|
||||
async function main() {
|
||||
log.start()
|
||||
await loadDatabase()
|
||||
generateCategoriesTable()
|
||||
generateCountriesTable()
|
||||
generateLanguagesTable()
|
||||
generateReadme()
|
||||
log.finish()
|
||||
}
|
||||
|
||||
async function loadDatabase() {
|
||||
log.print('Loading database...\n')
|
||||
await db.load()
|
||||
}
|
||||
|
||||
function generateCategoriesTable() {
|
||||
log.print('Generating categories table...\n')
|
||||
|
||||
const categories = []
|
||||
for (const category of [...db.categories.all(), { name: 'Other', id: 'other' }]) {
|
||||
categories.push({
|
||||
category: category.name,
|
||||
channels: db.channels.forCategory(category).removeOffline().removeDuplicates().count(),
|
||||
playlist: `<code>https://iptv-org.github.io/iptv/categories/${category.id}.m3u</code>`
|
||||
})
|
||||
}
|
||||
|
||||
const table = generateTable(categories, {
|
||||
columns: [
|
||||
{ name: 'Category', align: 'left' },
|
||||
{ name: 'Channels', align: 'right' },
|
||||
{ name: 'Playlist', align: 'left' }
|
||||
]
|
||||
})
|
||||
|
||||
file.create('./.readme/_categories.md', table)
|
||||
}
|
||||
|
||||
function generateCountriesTable() {
|
||||
log.print('Generating countries table...\n')
|
||||
|
||||
const countries = []
|
||||
for (const country of [
|
||||
...db.countries.sortBy(['name']).all(),
|
||||
{ name: 'Undefined', code: 'undefined' }
|
||||
]) {
|
||||
let flag = utils.code2flag(country.code)
|
||||
const prefix = flag ? `${flag} ` : ''
|
||||
countries.push({
|
||||
country: prefix + country.name,
|
||||
channels: db.channels
|
||||
.forCountry(country)
|
||||
.removeOffline()
|
||||
.removeDuplicates()
|
||||
.removeNSFW()
|
||||
.count(),
|
||||
playlist: `<code>https://iptv-org.github.io/iptv/countries/${country.code}.m3u</code>`
|
||||
})
|
||||
}
|
||||
|
||||
const table = generateTable(countries, {
|
||||
columns: [
|
||||
{ name: 'Country', align: 'left' },
|
||||
{ name: 'Channels', align: 'right' },
|
||||
{ name: 'Playlist', align: 'left', nowrap: true }
|
||||
]
|
||||
})
|
||||
|
||||
file.create('./.readme/_countries.md', table)
|
||||
}
|
||||
|
||||
function generateLanguagesTable() {
|
||||
log.print('Generating languages table...\n')
|
||||
const languages = []
|
||||
|
||||
for (const language of [
|
||||
...db.languages.sortBy(['name']).all(),
|
||||
{ name: 'Undefined', code: 'undefined' }
|
||||
]) {
|
||||
languages.push({
|
||||
language: language.name,
|
||||
channels: db.channels
|
||||
.forLanguage(language)
|
||||
.removeOffline()
|
||||
.removeDuplicates()
|
||||
.removeNSFW()
|
||||
.count(),
|
||||
playlist: `<code>https://iptv-org.github.io/iptv/languages/${language.code}.m3u</code>`
|
||||
})
|
||||
}
|
||||
|
||||
const table = generateTable(languages, {
|
||||
columns: [
|
||||
{ name: 'Language', align: 'left' },
|
||||
{ name: 'Channels', align: 'right' },
|
||||
{ name: 'Playlist', align: 'left' }
|
||||
]
|
||||
})
|
||||
|
||||
file.create('./.readme/_languages.md', table)
|
||||
}
|
||||
|
||||
function generateTable(data, options) {
|
||||
let output = '<table>\n'
|
||||
|
||||
output += '\t<thead>\n\t\t<tr>'
|
||||
for (let column of options.columns) {
|
||||
output += `<th align="${column.align}">${column.name}</th>`
|
||||
}
|
||||
output += '</tr>\n\t</thead>\n'
|
||||
|
||||
output += '\t<tbody>\n'
|
||||
for (let item of data) {
|
||||
output += '\t\t<tr>'
|
||||
let i = 0
|
||||
for (let prop in item) {
|
||||
const column = options.columns[i]
|
||||
let nowrap = column.nowrap
|
||||
let align = column.align
|
||||
output += `<td align="${align}"${nowrap ? ' nowrap' : ''}>${item[prop]}</td>`
|
||||
i++
|
||||
}
|
||||
output += '</tr>\n'
|
||||
}
|
||||
output += '\t</tbody>\n'
|
||||
|
||||
output += '</table>'
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
function generateReadme() {
|
||||
log.print('Generating README.md...\n')
|
||||
file.compileMarkdown('.readme/config.json')
|
||||
}
|
||||
|
||||
main()
|
||||
@ -0,0 +1,2 @@
|
||||
output/
|
||||
temp/
|
||||
@ -0,0 +1,3 @@
|
||||
#EXTM3U
|
||||
#EXTINF:-1 tvg-id="ATV.ad" tvg-country="AD" tvg-language="Catalan" tvg-logo="https://i.imgur.com/kJCjeQ4.png" group-title="General",ATV (720p) [Offline]
|
||||
https://iptv-all.lanesh4d0w.repl.co/andorra/atv
|
||||
@ -0,0 +1,3 @@
|
||||
#EXTM3U
|
||||
#EXTINF:-1 tvg-id="FoxSports2Asia.us" tvg-country="TH" tvg-language="Thai" tvg-logo="" group-title="Sports",Fox Sports 2 Asia (Thai) (720p)
|
||||
https://example.com/playlist.m3u8
|
||||
@ -0,0 +1 @@
|
||||
[{"tvg_id":"AndorraTV.ad","display_name":"Andorra TV","country":"ad","guides":["https://iptv-org.github.io/epg/guides/ad/andorradifusio.ad.epg.xml"],"logo":"https://www.andorradifusio.ad/images/logo/andorradifusio_logo_22122020091723.png"}]
|
||||
@ -0,0 +1,3 @@
|
||||
{"_id":"I6cjG2xCBRFFP4sz","url":"https://iptv-all.lanesh4d0w.repl.co/andorra/atv","http":{"referrer":"","user-agent":""},"error":"Operation timed out","streams":[],"requests":[]}
|
||||
{"_id":"3TbieV1ptnZVCIdn","url":"http://1111296894.rsc.cdn77.org/LS-ATL-54548-6/index.m3u8","http":{"referrer":"","user-agent":""},"error":"Server returned 404 Not Found","streams":[],"requests":[]}
|
||||
{"_id":"2ST8btby3mmsgPF0","url":"http://46.46.143.222:1935/live/mp4:ldpr.stream/playlist.m3u8","http":{"referrer":"","user-agent":""},"error":null,"streams":[{"index":0,"codec_name":"timed_id3","codec_long_name":"timed ID3 metadata","codec_type":"data","codec_tag_string":"ID3 ","codec_tag":"0x20334449","r_frame_rate":"0/0","avg_frame_rate":"0/0","time_base":"1/90000","disposition":{"default":0,"dub":0,"original":0,"comment":0,"lyrics":0,"karaoke":0,"forced":0,"hearing_impaired":0,"visual_impaired":0,"clean_effects":0,"attached_pic":0,"timed_thumbnails":0},"tags":{"variant_bitrate":"6527203"}},{"index":1,"codec_name":"h264","codec_long_name":"H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10","profile":"Main","codec_type":"video","codec_tag_string":"[27][0][0][0]","codec_tag":"0x001b","width":1920,"height":1080,"coded_width":1920,"coded_height":1080,"closed_captions":0,"has_b_frames":0,"sample_aspect_ratio":"1:1","display_aspect_ratio":"16:9","pix_fmt":"yuv420p","level":40,"chroma_location":"left","refs":1,"is_avc":"false","nal_length_size":"0","r_frame_rate":"50/1","avg_frame_rate":"50/1","time_base":"1/90000","start_pts":8171218184,"start_time":"90791.313156","bits_per_raw_sample":"8","disposition":{"default":0,"dub":0,"original":0,"comment":0,"lyrics":0,"karaoke":0,"forced":0,"hearing_impaired":0,"visual_impaired":0,"clean_effects":0,"attached_pic":0,"timed_thumbnails":0},"tags":{"variant_bitrate":"6527203"}},{"index":2,"codec_name":"aac","codec_long_name":"AAC (Advanced Audio Coding)","profile":"LC","codec_type":"audio","codec_tag_string":"[15][0][0][0]","codec_tag":"0x000f","sample_fmt":"fltp","sample_rate":"48000","channels":2,"channel_layout":"stereo","bits_per_sample":0,"r_frame_rate":"0/0","avg_frame_rate":"0/0","time_base":"1/90000","start_pts":8171229134,"start_time":"90791.434822","disposition":{"default":0,"dub":0,"original":0,"comment":0,"lyrics":0,"karaoke":0,"forced":0,"hearing_impaired":0,"visual_impaired":0,"clean_effects":0,"attached_pic":0,"timed_thumbnails":0},"tags":{"variant_bitrate":"6527203"}}],"requests":[{"method":"GET","url":"http://46.46.143.222:1935/live/mp4:ldpr.stream/playlist.m3u8","headers":{"User-Agent":"Lavf/58.76.100","Accept":"*/*","Range":"bytes=0-","Connection":"close","Host":"46.46.143.222:1935","Icy-MetaData":"1"}},{"method":"GET","url":"http://46.46.143.222:1935/live/mp4:ldpr.stream/chunklist_w1629502765.m3u8","headers":{"User-Agent":"Lavf/58.76.100","Accept":"*/*","Range":"bytes=0-","Connection":"keep-alive","Host":"46.46.143.222:1935","Icy-MetaData":"1"}},{"method":"GET","url":"http://46.46.143.222:1935/live/mp4:ldpr.stream/media_w1629502765_1085323.ts","headers":{"User-Agent":"Lavf/58.76.100","Accept":"*/*","Range":"bytes=0-","Connection":"keep-alive","Host":"46.46.143.222:1935","Icy-MetaData":"1"}},{"method":"GET","url":"http://46.46.143.222:1935/live/mp4:ldpr.stream/media_w1629502765_1085324.ts","headers":{"User-Agent":"Lavf/58.76.100","Accept":"*/*","Range":"bytes=0-","Connection":"keep-alive","Host":"46.46.143.222:1935","Icy-MetaData":"1"}}]}
|
||||
@ -0,0 +1,3 @@
|
||||
{"name":"General","slug":"general","count":1}
|
||||
{"name":"News","slug":"news","count":1}
|
||||
{"name":"Other","slug":"other","count":0}
|
||||
@ -0,0 +1,5 @@
|
||||
{"name":"Andorra","code":"AD","count":0}
|
||||
{"name":"Russia","code":"RU","count":1}
|
||||
{"name":"United Kingdom","code":"UK","count":1}
|
||||
{"name":"International","code":"INT","count":0}
|
||||
{"name":"Undefined","code":"UNDEFINED","count":0}
|
||||
@ -0,0 +1,4 @@
|
||||
{"name":"Catalan","code":"cat","count":0}
|
||||
{"name":"English","code":"eng","count":1}
|
||||
{"name":"Russian","code":"rus","count":1}
|
||||
{"name":"Undefined","code":"undefined","count":0}
|
||||
@ -0,0 +1,5 @@
|
||||
{"name":"Asia","code":"ASIA","count":1}
|
||||
{"name":"Commonwealth of Independent States","code":"CIS","count":1}
|
||||
{"name":"Europe","code":"EUR","count":2}
|
||||
{"name":"Europe, the Middle East and Africa","code":"EMEA","count":2}
|
||||
{"name":"Undefined","code":"UNDEFINED","count":0}
|
||||
@ -0,0 +1,4 @@
|
||||
{
|
||||
"build" : "tests/__data__/output/readme.md",
|
||||
"files" : ["./.readme/template.md"]
|
||||
}
|
||||
@ -0,0 +1,182 @@
|
||||
# IPTV
|
||||
|
||||
[](https://github.com/iptv-org/iptv/actions/workflows/auto-update.yml)
|
||||
|
||||
Collection of publicly available IPTV channels from all over the world.
|
||||
|
||||
Internet Protocol television (IPTV) is the delivery of television content over Internet Protocol (IP) networks.
|
||||
|
||||
## Usage
|
||||
|
||||
To watch IPTV you just need to paste this link `https://iptv-org.github.io/iptv/index.m3u` to any player which supports M3U-playlists.
|
||||
|
||||

|
||||
|
||||
Also you can instead use one of these playlists:
|
||||
|
||||
- `https://iptv-org.github.io/iptv/index.category.m3u` (grouped by category)
|
||||
- `https://iptv-org.github.io/iptv/index.language.m3u` (grouped by language)
|
||||
- `https://iptv-org.github.io/iptv/index.country.m3u` (grouped by country)
|
||||
- `https://iptv-org.github.io/iptv/index.region.m3u` (grouped by region)
|
||||
- `https://iptv-org.github.io/iptv/index.nsfw.m3u` (includes adult channels)
|
||||
|
||||
Or select one of the playlists from the list below.
|
||||
|
||||
### Playlists by category
|
||||
|
||||
<details>
|
||||
<summary>Expand</summary>
|
||||
<br>
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th align="left">Category</th><th align="right">Channels</th><th align="left">Playlist</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td align="left">General</td><td align="right">1</td><td align="left"><code>https://iptv-org.github.io/iptv/categories/general.m3u</code></td></tr>
|
||||
<tr><td align="left">News</td><td align="right">1</td><td align="left"><code>https://iptv-org.github.io/iptv/categories/news.m3u</code></td></tr>
|
||||
<tr><td align="left">Other</td><td align="right">0</td><td align="left"><code>https://iptv-org.github.io/iptv/categories/other.m3u</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</details>
|
||||
|
||||
### Playlists by language
|
||||
|
||||
<details>
|
||||
<summary>Expand</summary>
|
||||
<br>
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th align="left">Language</th><th align="right">Channels</th><th align="left">Playlist</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td align="left">Catalan</td><td align="right">0</td><td align="left"><code>https://iptv-org.github.io/iptv/languages/cat.m3u</code></td></tr>
|
||||
<tr><td align="left">English</td><td align="right">1</td><td align="left"><code>https://iptv-org.github.io/iptv/languages/eng.m3u</code></td></tr>
|
||||
<tr><td align="left">Russian</td><td align="right">1</td><td align="left"><code>https://iptv-org.github.io/iptv/languages/rus.m3u</code></td></tr>
|
||||
<tr><td align="left">Undefined</td><td align="right">0</td><td align="left"><code>https://iptv-org.github.io/iptv/languages/undefined.m3u</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</details>
|
||||
|
||||
### Playlists by region
|
||||
|
||||
<details>
|
||||
<summary>Expand</summary>
|
||||
<br>
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th align="left">Region</th><th align="right">Channels</th><th align="left">Playlist</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td align="left">Asia</td><td align="right">1</td><td align="left"><code>https://iptv-org.github.io/iptv/regions/asia.m3u</code></td></tr>
|
||||
<tr><td align="left">Commonwealth of Independent States</td><td align="right">1</td><td align="left"><code>https://iptv-org.github.io/iptv/regions/cis.m3u</code></td></tr>
|
||||
<tr><td align="left">Europe</td><td align="right">2</td><td align="left"><code>https://iptv-org.github.io/iptv/regions/eur.m3u</code></td></tr>
|
||||
<tr><td align="left">Europe, the Middle East and Africa</td><td align="right">2</td><td align="left"><code>https://iptv-org.github.io/iptv/regions/emea.m3u</code></td></tr>
|
||||
<tr><td align="left">Undefined</td><td align="right">0</td><td align="left"><code>https://iptv-org.github.io/iptv/regions/undefined.m3u</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</details>
|
||||
|
||||
### Playlists by country
|
||||
|
||||
<details>
|
||||
<summary>Expand</summary>
|
||||
<br>
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th align="left">Country</th><th align="right">Channels</th><th align="left">Playlist</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td align="left">🇦🇩 Andorra</td><td align="right">0</td><td align="left"><code>https://iptv-org.github.io/iptv/countries/ad.m3u</code></td></tr>
|
||||
<tr><td align="left">🇷🇺 Russia</td><td align="right">1</td><td align="left"><code>https://iptv-org.github.io/iptv/countries/ru.m3u</code></td></tr>
|
||||
<tr><td align="left">🇬🇧 United Kingdom</td><td align="right">1</td><td align="left"><code>https://iptv-org.github.io/iptv/countries/uk.m3u</code></td></tr>
|
||||
<tr><td align="left">🌍 International</td><td align="right">0</td><td align="left"><code>https://iptv-org.github.io/iptv/countries/int.m3u</code></td></tr>
|
||||
<tr><td align="left">Undefined</td><td align="right">0</td><td align="left"><code>https://iptv-org.github.io/iptv/countries/undefined.m3u</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</details>
|
||||
|
||||
## For Developers
|
||||
|
||||
In addition to the above methods, you can also get a list of all available channels in JSON format.
|
||||
|
||||
To do this, you just have to make a GET request to:
|
||||
|
||||
```
|
||||
https://iptv-org.github.io/iptv/channels.json
|
||||
```
|
||||
|
||||
If successful, you should get the following response:
|
||||
|
||||
<details>
|
||||
<summary>Expand</summary>
|
||||
<br>
|
||||
|
||||
```
|
||||
[
|
||||
...
|
||||
{
|
||||
"name": "CNN",
|
||||
"logo": "https://i.imgur.com/ilZJT5s.png",
|
||||
"url": "http://ott-cdn.ucom.am/s27/index.m3u8",
|
||||
"categories": [
|
||||
{
|
||||
"name": "News",
|
||||
"slug": "news"
|
||||
}
|
||||
],
|
||||
"countries": [
|
||||
{
|
||||
"code": "us",
|
||||
"name": "United States"
|
||||
},
|
||||
{
|
||||
"code": "ca",
|
||||
"name": "Canada"
|
||||
}
|
||||
],
|
||||
"languages": [
|
||||
{
|
||||
"code": "eng",
|
||||
"name": "English"
|
||||
}
|
||||
],
|
||||
"tvg": {
|
||||
"id": "cnn.us",
|
||||
"name": "CNN",
|
||||
"url": "http://epg.streamstv.me/epg/guide-usa.xml.gz"
|
||||
}
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
</details>
|
||||
|
||||
## EPG
|
||||
|
||||
Playlists already have a built-in list of EPG, so players that support the `url-tvg` tag should load it automatically. If not, you can find a list of available programs here:
|
||||
|
||||
https://github.com/iptv-org/epg
|
||||
|
||||
## Resources
|
||||
|
||||
You can find links to various IPTV related resources in this repository [iptv-org/awesome-iptv](https://github.com/iptv-org/awesome-iptv).
|
||||
|
||||
## Contribution
|
||||
|
||||
Please make sure to read the [Contributing Guide](CONTRIBUTING.md) before sending an issue or making a pull request.
|
||||
|
||||
## Legal
|
||||
|
||||
No video files are stored in this repository. The repository simply contains user-submitted links to publicly available video stream URLs, which to the best of our knowledge have been intentionally made publicly by the copyright holders. If any links in these playlists infringe on your rights as a copyright holder, they may be removed by sending a pull request or opening an issue. However, note that we have **no control** over the destination of the link, and just removing the link from the playlist will not remove its contents from the web. Note that linking does not directly infringe copyright because no copy is made on the site providing the link, and thus this is **not** a valid reason to send a DMCA notice to GitHub. To remove this content from the web, you should contact the web host that's actually hosting the content (**not** GitHub, nor the maintainers of this repository).
|
||||
@ -0,0 +1,44 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { execSync } = require('child_process')
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmdirSync('tests/__data__/output', { recursive: true })
|
||||
fs.mkdirSync('tests/__data__/output')
|
||||
fs.copyFileSync('tests/__data__/input/test.db', 'tests/__data__/temp/test.db')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmdirSync('tests/__data__/temp', { recursive: true })
|
||||
fs.mkdirSync('tests/__data__/temp')
|
||||
})
|
||||
|
||||
it('return results if stream with error', () => {
|
||||
const result = execSync(
|
||||
'DB_FILEPATH=tests/__data__/temp/test.db LOGS_PATH=tests/__data__/output/logs node scripts/commands/check-streams.js --cluster-id=1 --timeout=1',
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
const logs = fs.readFileSync(
|
||||
path.resolve('tests/__data__/output/logs/check-streams/cluster_1.log'),
|
||||
{
|
||||
encoding: 'utf8'
|
||||
}
|
||||
)
|
||||
const lines = logs.split('\n')
|
||||
expect(JSON.parse(lines[0])).toMatchObject({
|
||||
_id: '2ST8btby3mmsgPF0',
|
||||
url: 'http://46.46.143.222:1935/live/mp4:ldpr.stream/playlist.m3u8',
|
||||
http: { referrer: '', 'user-agent': '' },
|
||||
error: 'Operation timed out',
|
||||
streams: [],
|
||||
requests: []
|
||||
})
|
||||
expect(JSON.parse(lines[1])).toMatchObject({
|
||||
_id: 'I6cjG2xCBRFFP4sz',
|
||||
url: 'https://iptv-all.lanesh4d0w.repl.co/andorra/atv',
|
||||
http: { referrer: '', 'user-agent': '' },
|
||||
error: 'Operation timed out',
|
||||
streams: [],
|
||||
requests: []
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,45 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { execSync } = require('child_process')
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmdirSync('tests/__data__/output', { recursive: true })
|
||||
fs.mkdirSync('tests/__data__/output')
|
||||
})
|
||||
|
||||
it('can create database', () => {
|
||||
execSync(
|
||||
'DB_FILEPATH=tests/__data__/output/test.db node scripts/commands/create-database.js --input-dir=tests/__data__/input/channels --max-clusters=1',
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
|
||||
const database = fs.readFileSync(path.resolve('tests/__data__/output/test.db'), {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
const item = database.split('\n').find(i => i.includes('ATV.ad'))
|
||||
expect(JSON.parse(item)).toMatchObject({
|
||||
name: 'ATV',
|
||||
id: 'ATV.ad',
|
||||
filepath: 'tests/__data__/input/channels/ad_example.m3u',
|
||||
src_country: { name: 'Andorra', code: 'AD', lang: 'cat' },
|
||||
tvg_country: 'AD',
|
||||
countries: [{ name: 'Andorra', code: 'AD', lang: 'cat' }],
|
||||
regions: [
|
||||
{ name: 'Europe, the Middle East and Africa', code: 'EMEA' },
|
||||
{ name: 'Europe', code: 'EUR' }
|
||||
],
|
||||
languages: [{ name: 'Catalan', code: 'cat' }],
|
||||
categories: [{ name: 'General', slug: 'general', nsfw: false }],
|
||||
tvg_url: '',
|
||||
guides: [],
|
||||
logo: 'https://i.imgur.com/kJCjeQ4.png',
|
||||
resolution: { height: 720, width: null },
|
||||
status: { label: 'Offline', code: 'offline', level: 5 },
|
||||
url: 'https://iptv-all.lanesh4d0w.repl.co/andorra/atv',
|
||||
http: { referrer: '', 'user-agent': '' },
|
||||
is_nsfw: false,
|
||||
is_broken: true,
|
||||
updated: false,
|
||||
cluster_id: 1
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,20 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { execSync } = require('child_process')
|
||||
|
||||
beforeEach(() => {
|
||||
fs.copyFileSync('tests/__data__/input/test.db', 'tests/__data__/temp/test.db')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmdirSync('tests/__data__/temp', { recursive: true })
|
||||
fs.mkdirSync('tests/__data__/temp')
|
||||
})
|
||||
|
||||
it('can create valid matrix', () => {
|
||||
const result = execSync(
|
||||
'DB_FILEPATH=tests/__data__/temp/test.db node scripts/commands/create-matrix.js',
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
expect(result).toBe('::set-output name=matrix::{"cluster_id":[1,3]}\n')
|
||||
})
|
||||
@ -0,0 +1,23 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { execSync } = require('child_process')
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmdirSync(path.resolve('tests/__data__/output'), { recursive: true })
|
||||
})
|
||||
|
||||
it('can update readme.md', () => {
|
||||
const result = execSync(
|
||||
'LOGS_PATH=tests/__data__/input/logs node scripts/commands/update-readme.js --config=tests/__data__/input/readme.json',
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
|
||||
const readme = fs.readFileSync(path.resolve('tests/__data__/output/readme.md'), {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
const expected = fs.readFileSync(path.resolve('tests/__data__/input/readme.md'), {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
|
||||
expect(readme).toBe(expected)
|
||||
})
|
||||
@ -0,0 +1,16 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { execSync } = require('child_process')
|
||||
|
||||
it('can validate channels name', () => {
|
||||
try {
|
||||
execSync('node scripts/commands/validate.js --input-dir=tests/__data__/input/channels', {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
} catch (err) {
|
||||
expect(err.status).toBe(1)
|
||||
expect(err.stdout).toBe(
|
||||
`tests/__data__/input/channels/us_blocked.m3u:2 'Fox Sports' is on the blocklist due to claims of copyright holders (https://github.com/github/dmca/blob/master/2020/09/2020-09-16-dfl.md)\n\n`
|
||||
)
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue