Update scripts

This commit is contained in:
freearhey 2025-04-29 00:18:35 +03:00
parent 37b4197fb2
commit 6244ba7adb
54 changed files with 2020 additions and 1145 deletions

View file

@ -1,6 +1,7 @@
import { DATA_DIR, API_DIR } from '../../constants'
import { Storage, File } from '@freearhey/core'
import { DATA_DIR, API_DIR } from '../constants'
import { CSVParser } from '../core'
import { CSVParser } from '../../core'
import { CSVParserRow } from '../../types/csvParser'
async function main() {
const dataStorage = new Storage(DATA_DIR)
@ -12,7 +13,8 @@ async function main() {
const file = new File(filepath)
const filename = file.name()
const data = await dataStorage.load(file.basename())
const items = await parser.parse(data)
const parsed = await parser.parse(data)
const items = parsed.map((row: CSVParserRow) => row.data)
await apiStorage.save(`${filename}.json`, items.toJSON())
}

View file

@ -0,0 +1,411 @@
import { CSV, IssueLoader, CSVParser, Issue, IssueData } from '../../core'
import { createChannelId, createFeedId } from '../../utils'
import { Channel, Feed, BlocklistRecord } from '../../models'
import { Storage, Collection, Logger } from '@freearhey/core'
import { DATA_DIR } from '../../constants'
import { DataLoader } from '../../core/dataLoader'
import { DataLoaderData } from '../../types/dataLoader'
const processedIssues = new Collection()
const dataStorage = new Storage(DATA_DIR)
const logger = new Logger({ level: -999 })
async function main() {
const parser = new CSVParser()
const issueLoader = new IssueLoader()
const dataLoader = new DataLoader({ storage: dataStorage })
logger.info('loading issues...')
const issues = await issueLoader.load()
logger.info('loading data...')
const data = await dataLoader.load()
logger.info('processing issues...')
await removeFeeds(issues, data)
await removeChannels(issues, data)
await editFeeds(issues, data)
await editChannels(issues, data)
await addFeeds(issues, data)
await addChannels(issues, data)
await blockChannels(issues, data)
await unblockChannels(issues, data)
logger.info('saving data...')
await save(data)
const output = processedIssues.map((issue: Issue) => `closes #${issue.number}`).join(', ')
process.stdout.write(`OUTPUT=${output}`)
}
main()
async function save(data: DataLoaderData) {
const channels = data.channels
.sortBy((channel: Channel) => channel.id.toLowerCase())
.map((channel: Channel) => channel.data())
const channelsOutput = new CSV({ items: channels }).toString()
await dataStorage.save('channels.csv', channelsOutput)
const feeds = data.feeds
.sortBy((feed: Feed) => `${feed.getStreamId()}`.toLowerCase())
.map((feed: Feed) => feed.data())
const feedsOutput = new CSV({ items: feeds }).toString()
await dataStorage.save('feeds.csv', feedsOutput)
const blocklistRecords = data.blocklistRecords
.sortBy((blocklistRecord: BlocklistRecord) => blocklistRecord.channelId.toLowerCase())
.map((blocklistRecord: BlocklistRecord) => blocklistRecord.data())
const blocklistOutput = new CSV({ items: blocklistRecords }).toString()
await dataStorage.save('blocklist.csv', blocklistOutput)
}
async function removeFeeds(issues: Collection, data: DataLoaderData) {
const requests = issues.filter(
issue => issue.labels.includes('feeds:remove') && issue.labels.includes('approved')
)
requests.forEach((issue: Issue) => {
const issueData: IssueData = issue.data
if (issueData.missing('channel_id') || issueData.missing('feed_id')) return
const found: Feed = data.feeds.first(
(feed: Feed) =>
feed.channelId === issueData.getString('channel_id') &&
feed.id === issueData.getString('feed_id')
)
if (!found) return
data.feeds.remove((feed: Feed) => feed.channelId === found.channelId && feed.id === found.id)
onFeedRemoval(found.channelId, found.id, data)
processedIssues.push(issue)
})
}
async function editFeeds(issues: Collection, data: DataLoaderData) {
const requests = issues.filter(
issue => issue.labels.includes('feeds:edit') && issue.labels.includes('approved')
)
requests.forEach((issue: Issue) => {
const issueData: IssueData = issue.data
if (issueData.missing('channel_id') || issueData.missing('feed_id')) return
const found: Feed = data.feeds.first(
(feed: Feed) =>
feed.channelId === issueData.getString('channel_id') &&
feed.id === issueData.getString('feed_id')
)
if (!found) return
let channelId: string | undefined = found.channelId
let feedId: string | undefined = found.id
if (issueData.has('feed_name')) {
const name = issueData.getString('feed_name') || found.name
if (name) {
feedId = createFeedId(name)
if (feedId) onFeedIdChange(found.channelId, found.id, feedId, data)
}
}
if (issueData.has('is_main')) {
const isMain = issueData.getBoolean('is_main') || false
if (isMain) onFeedNewMain(channelId, feedId, data)
}
if (!feedId || !channelId) return
found.update(issueData).setId(feedId)
processedIssues.push(issue)
})
}
async function addFeeds(issues: Collection, data: DataLoaderData) {
const requests = issues.filter(
issue => issue.labels.includes('feeds:add') && issue.labels.includes('approved')
)
requests.forEach((issue: Issue) => {
const issueData: IssueData = issue.data
if (
issueData.missing('channel_id') ||
issueData.missing('feed_name') ||
issueData.missing('is_main') ||
issueData.missing('broadcast_area') ||
issueData.missing('timezones') ||
issueData.missing('languages') ||
issueData.missing('video_format')
)
return
const channelId = issueData.getString('channel_id')
const feedName = issueData.getString('feed_name') || 'SD'
const feedId = createFeedId(feedName)
if (!channelId || !feedId) return
const found: Feed = data.feeds.first(
(feed: Feed) => feed.channelId === channelId && feed.id === feedId
)
if (found) return
const isMain = issueData.getBoolean('is_main') || false
if (isMain) onFeedNewMain(channelId, feedId, data)
const newFeed = new Feed({
channel: channelId,
id: feedId,
name: feedName,
is_main: issueData.getBoolean('is_main') || false,
broadcast_area: issueData.getArray('broadcast_area') || [],
timezones: issueData.getArray('timezones') || [],
languages: issueData.getArray('languages') || [],
video_format: issueData.getString('video_format')
})
data.feeds.add(newFeed)
processedIssues.push(issue)
})
}
async function removeChannels(issues: Collection, data: DataLoaderData) {
const requests = issues.filter(
issue => issue.labels.includes('channels:remove') && issue.labels.includes('approved')
)
requests.forEach((issue: Issue) => {
const issueData: IssueData = issue.data
if (issueData.missing('channel_id')) return
const found = data.channels.first(
(channel: Channel) => channel.id === issueData.getString('channel_id')
)
if (!found) return
data.channels.remove((channel: Channel) => channel.id === found.id)
onChannelRemoval(found.id, data)
processedIssues.push(issue)
})
}
async function editChannels(issues: Collection, data: DataLoaderData) {
const requests = issues.filter(
issue => issue.labels.includes('channels:edit') && issue.labels.includes('approved')
)
requests.forEach((issue: Issue) => {
const issueData: IssueData = issue.data
if (issueData.missing('channel_id')) return
const found: Channel = data.channels.first(
(channel: Channel) => channel.id === issueData.getString('channel_id')
)
if (!found) return
let channelId: string | undefined = found.id
if (issueData.has('channel_name') || issueData.has('country')) {
const name = issueData.getString('channel_name') || found.name
const country = issueData.getString('country') || found.countryCode
if (name && country) {
channelId = createChannelId(name, country)
if (channelId) onChannelIdChange(found.id, channelId, data)
}
}
if (!channelId) return
found.update(issueData).setId(channelId)
processedIssues.push(issue)
})
}
async function addChannels(issues: Collection, data: DataLoaderData) {
const requests = issues.filter(
issue => issue.labels.includes('channels:add') && issue.labels.includes('approved')
)
requests.forEach((issue: Issue) => {
const issueData: IssueData = issue.data
if (
issueData.missing('channel_name') ||
issueData.missing('country') ||
issueData.missing('is_nsfw') ||
issueData.missing('logo') ||
issueData.missing('feed_name') ||
issueData.missing('broadcast_area') ||
issueData.missing('timezones') ||
issueData.missing('languages') ||
issueData.missing('video_format')
)
return
const channelId = createChannelId(
issueData.getString('channel_name'),
issueData.getString('country')
)
if (!channelId) return
const found: Channel = data.channels.first((channel: Channel) => channel.id === channelId)
if (found) return
const newChannel = new Channel({
id: channelId,
name: issueData.getString('channel_name') || '',
alt_names: issueData.getArray('alt_names'),
network: issueData.getString('network'),
owners: issueData.getArray('owners'),
country: issueData.getString('country') || '',
subdivision: issueData.getString('subdivision'),
city: issueData.getString('city'),
categories: issueData.getArray('categories'),
is_nsfw: issueData.getBoolean('is_nsfw') || false,
launched: issueData.getString('launched'),
closed: issueData.getString('closed'),
replaced_by: issueData.getString('replaced_by'),
website: issueData.getString('website'),
logo: issueData.getString('logo') || ''
})
data.channels.add(newChannel)
const feedName = issueData.getString('feed_name') || 'SD'
const newFeed = new Feed({
channel: channelId,
id: createFeedId(feedName),
name: feedName,
is_main: true,
broadcast_area: issueData.getArray('broadcast_area') || [],
timezones: issueData.getArray('timezones') || [],
languages: issueData.getArray('languages') || [],
video_format: issueData.getString('video_format')
})
data.feeds.add(newFeed)
processedIssues.push(issue)
})
}
async function unblockChannels(issues: Collection, data: DataLoaderData) {
const requests = issues.filter(
issue => issue.labels.includes('blocklist:remove') && issue.labels.includes('approved')
)
requests.forEach((issue: Issue) => {
const issueData: IssueData = issue.data
if (issueData.missing('channel_id')) return
const found: BlocklistRecord = data.blocklistRecords.first(
(blocklistRecord: BlocklistRecord) =>
blocklistRecord.channelId === issueData.getString('channel_id')
)
if (!found) return
data.blocklistRecords.remove(
(blocklistRecord: BlocklistRecord) => blocklistRecord.channelId === found.channelId
)
processedIssues.push(issue)
})
}
async function blockChannels(issues: Collection, data: DataLoaderData) {
const requests = issues.filter(
issue => issue.labels.includes('blocklist:add') && issue.labels.includes('approved')
)
requests.forEach((issue: Issue) => {
const issueData: IssueData = issue.data
if (issueData.missing('channel_id')) return
const found: BlocklistRecord = data.blocklistRecords.first(
(blocklistRecord: BlocklistRecord) =>
blocklistRecord.channelId === issueData.getString('channel_id')
)
if (found) return
const channel = issueData.getString('channel_id')
const reason = issueData.getString('reason')?.toLowerCase()
const ref = issueData.getString('ref')
if (!channel || !reason || !ref) return
const newBlocklistRecord = new BlocklistRecord({
channel,
reason,
ref
})
data.blocklistRecords.add(newBlocklistRecord)
processedIssues.push(issue)
})
}
function onFeedIdChange(
channelId: string,
feedId: string,
newFeedId: string,
data: DataLoaderData
) {
data.channels.forEach((channel: Channel) => {
if (channel.replacedBy && channel.replacedBy === `${channelId}@${feedId}`) {
channel.replacedBy = `${channelId}@${newFeedId}`
}
})
}
function onFeedNewMain(channelId: string, feedId: string, data: DataLoaderData) {
data.feeds.forEach((feed: Feed) => {
if (feed.channelId === channelId && feed.id !== feedId && feed.isMain === true) {
feed.isMain = false
}
})
}
function onFeedRemoval(channelId: string, feedId: string, data: DataLoaderData) {
data.channels.forEach((channel: Channel) => {
if (channel.replacedBy && channel.replacedBy === `${channelId}@${feedId}`) {
channel.replacedBy = ''
}
})
}
function onChannelIdChange(channelId: string, newChannelId: string, data: DataLoaderData) {
data.channels.forEach((channel: Channel) => {
if (channel.replacedBy && channel.replacedBy.includes(channelId)) {
channel.replacedBy = channel.replacedBy.replace(channelId, newChannelId)
}
})
data.feeds.forEach((feed: Feed) => {
if (feed.channelId === channelId) {
feed.channelId = newChannelId
}
})
data.blocklistRecords.forEach((blocklistRecord: BlocklistRecord) => {
if (blocklistRecord.channelId === channelId) {
blocklistRecord.channelId = newChannelId
}
})
}
function onChannelRemoval(channelId: string, data: DataLoaderData) {
data.channels.forEach((channel: Channel) => {
if (channel.replacedBy && channel.replacedBy.includes(channelId)) {
channel.replacedBy = ''
}
})
data.feeds.remove((feed: Feed) => feed.channelId === channelId)
}

View file

@ -0,0 +1,260 @@
import { Collection, Storage, Dictionary } from '@freearhey/core'
import { DataLoaderData } from '../../types/dataLoader'
import { ValidatorError } from '../../types/validator'
import { DataLoader } from '../../core/dataLoader'
import { DATA_DIR } from '../../constants'
import chalk from 'chalk'
import {
BlocklistRecord,
Subdivision,
Category,
Language,
Timezone,
Channel,
Country,
Region,
Feed
} from '../../models'
import {
BlocklistRecordValidator,
SubdivisionValidator,
CategoryValidator,
LanguageValidator,
TimezoneValidator,
ChannelValidator,
CountryValidator,
RegionValidator,
FeedValidator
} from '../../validators'
let totalErrors = 0
async function main() {
const dataStorage = new Storage(DATA_DIR)
const dataLoader = new DataLoader({ storage: dataStorage })
const data = await dataLoader.load()
validateChannels(data)
validateFeeds(data)
validateRegions(data)
validateBlocklist(data)
validateCategories(data)
validateCountries(data)
validateSubdivisions(data)
validateLanguages(data)
validateTimezones(data)
if (totalErrors > 0) {
console.log(chalk.red(`\r\n${totalErrors} error(s)`))
process.exit(1)
}
}
main()
function validateChannels(data: DataLoaderData) {
let errors = new Collection()
findDuplicatesBy(data.channels, ['id']).forEach((channel: Channel) => {
errors.add({
line: channel.getLine(),
message: `channel with id "${channel.id}" already exists`
})
})
const validator = new ChannelValidator({ data })
data.channels.forEach((channel: Channel) => {
errors = errors.concat(validator.validate(channel))
})
if (errors.count()) displayErrors('channels.csv', errors)
totalErrors += errors.count()
}
function validateFeeds(data: DataLoaderData) {
let errors = new Collection()
findDuplicatesBy(data.feeds, ['channelId', 'id']).forEach((feed: Feed) => {
errors.add({
line: feed.getLine(),
message: `feed with channel "${feed.channelId}" and id "${feed.id}" already exists`
})
})
const validator = new FeedValidator({ data })
data.feeds.forEach((feed: Feed) => {
errors = errors.concat(validator.validate(feed))
})
if (errors.count()) displayErrors('feeds.csv', errors)
totalErrors += errors.count()
}
function validateRegions(data: DataLoaderData) {
let errors = new Collection()
findDuplicatesBy(data.regions, ['code']).forEach((region: Region) => {
errors.add({
line: region.getLine(),
message: `region with code "${region.code}" already exists`
})
})
const validator = new RegionValidator({ data })
data.regions.forEach((region: Region) => {
errors = errors.concat(validator.validate(region))
})
if (errors.count()) displayErrors('regions.csv', errors)
totalErrors += errors.count()
}
function validateBlocklist(data: DataLoaderData) {
let errors = new Collection()
findDuplicatesBy(data.blocklistRecords, ['channelId', 'ref']).forEach(
(blocklistRecord: BlocklistRecord) => {
errors.add({
line: blocklistRecord.getLine(),
message: `blocklist record with channel "${blocklistRecord.channelId}" and ref "${blocklistRecord.ref}" already exists`
})
}
)
const validator = new BlocklistRecordValidator({ data })
data.blocklistRecords.forEach((blocklistRecord: BlocklistRecord) => {
errors = errors.concat(validator.validate(blocklistRecord))
})
if (errors.count()) displayErrors('blocklist.csv', errors)
totalErrors += errors.count()
}
function validateCategories(data: DataLoaderData) {
let errors = new Collection()
findDuplicatesBy(data.categories, ['id']).forEach((category: Category) => {
errors.add({
line: category.getLine(),
message: `category with id "${category.id}" already exists`
})
})
const validator = new CategoryValidator({ data })
data.categories.forEach((category: Category) => {
errors = errors.concat(validator.validate(category))
})
if (errors.count()) displayErrors('categories.csv', errors)
totalErrors += errors.count()
}
function validateCountries(data: DataLoaderData) {
let errors = new Collection()
findDuplicatesBy(data.countries, ['code']).forEach((country: Country) => {
errors.add({
line: country.getLine(),
message: `country with code "${country.code}" already exists`
})
})
const validator = new CountryValidator({ data })
data.countries.forEach((country: Country) => {
errors = errors.concat(validator.validate(country))
})
if (errors.count()) displayErrors('countries.csv', errors)
totalErrors += errors.count()
}
function validateSubdivisions(data: DataLoaderData) {
let errors = new Collection()
findDuplicatesBy(data.subdivisions, ['code']).forEach((subdivision: Subdivision) => {
errors.add({
line: subdivision.getLine(),
message: `subdivision with code "${subdivision.code}" already exists`
})
})
const validator = new SubdivisionValidator({ data })
data.subdivisions.forEach((subdivision: Subdivision) => {
errors = errors.concat(validator.validate(subdivision))
})
if (errors.count()) displayErrors('subdivisions.csv', errors)
totalErrors += errors.count()
}
function validateLanguages(data: DataLoaderData) {
let errors = new Collection()
findDuplicatesBy(data.languages, ['code']).forEach((language: Language) => {
errors.add({
line: language.getLine(),
message: `language with code "${language.code}" already exists`
})
})
const validator = new LanguageValidator({ data })
data.languages.forEach((language: Language) => {
errors = errors.concat(validator.validate(language))
})
if (errors.count()) displayErrors('languages.csv', errors)
totalErrors += errors.count()
}
function validateTimezones(data: DataLoaderData) {
let errors = new Collection()
findDuplicatesBy(data.timezones, ['id']).forEach((timezone: Timezone) => {
errors.add({
line: timezone.getLine(),
message: `timezone with id "${timezone.id}" already exists`
})
})
const validator = new TimezoneValidator({ data })
data.timezones.forEach((timezone: Timezone) => {
errors = errors.concat(validator.validate(timezone))
})
if (errors.count()) displayErrors('timezones.csv', errors)
totalErrors += errors.count()
}
function findDuplicatesBy(items: Collection, keys: string[]) {
const duplicates = new Collection()
const buffer = new Dictionary()
items.forEach((item, i = 0) => {
const normId = keys.map(key => item[key].toString().toLowerCase()).join()
if (buffer.has(normId)) {
duplicates.add(item)
}
buffer.set(normId, true)
})
return duplicates
}
function displayErrors(filepath: string, errors: Collection) {
console.log(`\r\n${chalk.underline(filepath)}`)
errors.forEach((error: ValidatorError) => {
const position = error.line.toString().padEnd(6, ' ')
console.log(` ${chalk.gray(position) + error.message}`)
})
}

View file

@ -30,9 +30,12 @@ const opts = {
export class CSVParser {
async parse(data: string): Promise<Collection> {
const items = await csv2json(opts).fromString(data)
const parsed = await csv2json(opts).fromString(data)
const rows = parsed.map((data, i) => {
return { line: i + 2, data }
})
return new Collection(items)
return new Collection(rows)
}
}

173
scripts/core/dataLoader.ts Normal file
View file

@ -0,0 +1,173 @@
import { Storage, File, Dictionary, Collection } from '@freearhey/core'
import { DataLoaderData, DataLoaderProps } from '../types/dataLoader'
import { CSVParserRow } from '../types/csvParser'
import { CSVParser } from './'
import chalk from 'chalk'
import {
Feed,
Channel,
BlocklistRecord,
Language,
Country,
Subdivision,
Region,
Timezone,
Category
} from '../models'
export class DataLoader {
storage: Storage
parser: CSVParser
constructor({ storage }: DataLoaderProps) {
this.storage = storage
this.parser = new CSVParser()
}
async load(): Promise<DataLoaderData> {
const files = await this.storage.list('*.csv')
let data: DataLoaderData = {
channels: new Collection(),
feeds: new Collection(),
categories: new Collection(),
languages: new Collection(),
blocklistRecords: new Collection(),
timezones: new Collection(),
regions: new Collection(),
subdivisions: new Collection(),
countries: new Collection(),
feedsGroupedByChannelId: new Dictionary(),
feedsKeyByStreamId: new Dictionary(),
channelsKeyById: new Dictionary(),
countriesKeyByCode: new Dictionary(),
subdivisionsKeyByCode: new Dictionary(),
categoriesKeyById: new Dictionary(),
regionsKeyByCode: new Dictionary(),
timezonesKeyById: new Dictionary(),
languagesKeyByCode: new Dictionary()
}
for (const filepath of files) {
const file = new File(filepath)
if (file.extension() !== 'csv') continue
const csv = await this.storage.load(file.basename())
const rows = csv.split(/\r\n/)
const headers = rows[0].split(',')
let errors = new Collection()
for (const [i, line] of rows.entries()) {
if (!line.trim()) continue
if (line.indexOf('\n') > -1) {
errors.add({
line: i + 1,
message: 'row has the wrong line ending character, should be CRLF'
})
}
if (line.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/).length !== headers.length) {
errors.add({
line: i + 1,
message: 'row has the wrong number of columns'
})
}
}
if (errors.notEmpty()) {
displayErrors(filepath, errors)
console.log(chalk.red(`\r\n${errors.count()} error(s)`))
process.exit(1)
}
const parsed = await this.parser.parse(csv)
const filename = file.name()
switch (filename) {
case 'channels': {
const channels = parsed.map((row: CSVParserRow) =>
new Channel(row.data).setLine(row.line)
)
data.channels = channels
data.channelsKeyById = channels.keyBy((channel: Channel) => channel.id)
break
}
case 'feeds': {
const feeds = parsed.map((row: CSVParserRow) => new Feed(row.data).setLine(row.line))
data.feeds = feeds
data.feedsGroupedByChannelId = feeds.groupBy((feed: Feed) => feed.channelId)
data.feedsKeyByStreamId = feeds.keyBy((feed: Feed) => feed.getStreamId())
break
}
case 'blocklist': {
const blocklistRecords = parsed.map((row: CSVParserRow) =>
new BlocklistRecord(row.data).setLine(row.line)
)
data.blocklistRecords = blocklistRecords
break
}
case 'categories': {
const categories = parsed.map((row: CSVParserRow) =>
new Category(row.data).setLine(row.line)
)
data.categories = categories
data.categoriesKeyById = categories.keyBy((category: Category) => category.id)
break
}
case 'timezones': {
const timezones = parsed.map((row: CSVParserRow) =>
new Timezone(row.data).setLine(row.line)
)
data.timezones = timezones
data.timezonesKeyById = timezones.keyBy((timezone: Timezone) => timezone.id)
break
}
case 'regions': {
const regions = parsed.map((row: CSVParserRow) => new Region(row.data).setLine(row.line))
data.regions = regions
data.regionsKeyByCode = regions.keyBy((region: Region) => region.code)
break
}
case 'languages': {
const languages = parsed.map((row: CSVParserRow) =>
new Language(row.data).setLine(row.line)
)
data.languages = languages
data.languagesKeyByCode = languages.keyBy((language: Language) => language.code)
break
}
case 'countries': {
const countries = parsed.map((row: CSVParserRow) =>
new Country(row.data).setLine(row.line)
)
data.countries = countries
data.countriesKeyByCode = countries.keyBy((country: Country) => country.code)
break
}
case 'subdivisions': {
const subdivisions = parsed.map((row: CSVParserRow) =>
new Subdivision(row.data).setLine(row.line)
)
data.subdivisions = subdivisions
data.subdivisionsKeyByCode = subdivisions.keyBy(
(subdivision: Subdivision) => subdivision.code
)
break
}
}
}
data.channels = data.channels.map((channel: Channel) =>
channel.withFeeds(data.feedsGroupedByChannelId)
)
return data
}
}
function displayErrors(filepath: string, errors: Collection) {
console.log(`\r\n${chalk.underline(filepath)}`)
errors.forEach((error: ValidatorError) => {
const position = error.line.toString().padEnd(6, ' ')
console.log(` ${chalk.gray(position) + error.message}`)
})
}

View file

@ -17,7 +17,7 @@ export class IssueLoader {
let issues: object[] = []
if (TESTING) {
issues = (await import('../../tests/__data__/input/update/issues.js')).default
issues = (await import('../../tests/__data__/input/db/update/issues.js')).default
} else {
issues = await octokit.paginate(octokit.rest.issues.listForRepo, {
owner: OWNER,

View file

@ -1,424 +0,0 @@
import { CSV, IssueLoader, CSVParser, Issue, IssueData } from '../core'
import { Channel, Blocked, Feed } from '../models'
import { DATA_DIR } from '../constants'
import { Storage, Collection } from '@freearhey/core'
import { createChannelId, createFeedId } from '../utils'
let blocklist = new Collection()
let channels = new Collection()
let feeds = new Collection()
let issues = new Collection()
const processedIssues = new Collection()
async function main() {
const dataStorage = new Storage(DATA_DIR)
const parser = new CSVParser()
const loader = new IssueLoader()
issues = await loader.load()
const channelsCSV = await dataStorage.load('channels.csv')
channels = (await parser.parse(channelsCSV)).map(data => new Channel(data))
const feedsCSV = await dataStorage.load('feeds.csv')
feeds = (await parser.parse(feedsCSV)).map(data => new Feed(data))
const blocklistCSV = await dataStorage.load('blocklist.csv')
blocklist = (await parser.parse(blocklistCSV)).map(data => new Blocked(data))
await removeFeeds()
await removeChannels()
await editFeeds()
await editChannels()
await addFeeds()
await addChannels()
await blockChannels()
await unblockChannels()
channels = channels.sortBy(channel => channel.id.toLowerCase())
const channelsOutput = new CSV({ items: channels }).toString()
await dataStorage.save('channels.csv', channelsOutput)
feeds = feeds.sortBy(feed => `${feed.channel}@${feed.id}`.toLowerCase())
const feedsOutput = new CSV({ items: feeds }).toString()
await dataStorage.save('feeds.csv', feedsOutput)
blocklist = blocklist.sortBy(blocked => blocked.channel.toLowerCase())
const blocklistOutput = new CSV({ items: blocklist }).toString()
await dataStorage.save('blocklist.csv', blocklistOutput)
const output = processedIssues.map((issue: Issue) => `closes #${issue.number}`).join(', ')
process.stdout.write(`OUTPUT=${output}`)
}
main()
async function removeFeeds() {
const requests = issues.filter(
issue => issue.labels.includes('feeds:remove') && issue.labels.includes('approved')
)
requests.forEach((issue: Issue) => {
if (issue.data.missing('channel_id') || issue.data.missing('feed_id')) return
const found = feeds.first(
(feed: Feed) =>
feed.channel === issue.data.getString('channel_id') &&
feed.id === issue.data.getString('feed_id')
)
if (!found) return
feeds.remove((feed: Feed) => feed.channel === found.channel && feed.id === found.id)
onFeedRemoval(found.channel, found.id)
processedIssues.push(issue)
})
}
async function editFeeds() {
const requests = issues.filter(
issue => issue.labels.includes('feeds:edit') && issue.labels.includes('approved')
)
requests.forEach((issue: Issue) => {
const data: IssueData = issue.data
if (data.missing('channel_id') || data.missing('feed_id')) return
const found: Feed = feeds.first(
(feed: Feed) =>
feed.channel === data.getString('channel_id') && feed.id === data.getString('feed_id')
)
if (!found) return
let channelId: string | undefined = found.channel
let feedId: string | undefined = found.id
if (data.has('feed_name')) {
const name = data.getString('feed_name') || found.name
if (name) {
feedId = createFeedId(name)
if (feedId) onFeedIdChange(found.channel, found.id, feedId)
}
}
if (data.has('is_main')) {
const isMain = data.getBoolean('is_main') || false
if (isMain) onFeedNewMain(channelId, feedId)
}
if (!feedId || !channelId) return
const updated = new Feed({
channel: channelId,
id: feedId,
name: data.getString('feed_name'),
is_main: data.getBoolean('is_main'),
broadcast_area: data.getArray('broadcast_area'),
timezones: data.getArray('timezones'),
languages: data.getArray('languages'),
video_format: data.getString('video_format')
})
found.merge(updated)
processedIssues.push(issue)
})
}
async function addFeeds() {
const requests = issues.filter(
issue => issue.labels.includes('feeds:add') && issue.labels.includes('approved')
)
requests.forEach((issue: Issue) => {
const data: IssueData = issue.data
if (
data.missing('channel_id') ||
data.missing('feed_name') ||
data.missing('is_main') ||
data.missing('broadcast_area') ||
data.missing('timezones') ||
data.missing('languages') ||
data.missing('video_format')
)
return
const channelId = data.getString('channel_id')
const feedName = data.getString('feed_name') || 'SD'
const feedId = createFeedId(feedName)
if (!channelId || !feedId) return
const found: Feed = feeds.first(
(feed: Feed) => feed.channel === channelId && feed.id === feedId
)
if (found) return
const isMain = data.getBoolean('is_main') || false
if (isMain) onFeedNewMain(channelId, feedId)
feeds.push(
new Feed({
channel: channelId,
id: feedId,
name: feedName,
is_main: data.getBoolean('is_main'),
broadcast_area: data.getArray('broadcast_area'),
timezones: data.getArray('timezones'),
languages: data.getArray('languages'),
video_format: data.getString('video_format')
})
)
processedIssues.push(issue)
})
}
async function removeChannels() {
const requests = issues.filter(
issue => issue.labels.includes('channels:remove') && issue.labels.includes('approved')
)
requests.forEach((issue: Issue) => {
if (issue.data.missing('channel_id')) return
const found = channels.first(
(channel: Channel) => channel.id === issue.data.getString('channel_id')
)
if (!found) return
channels.remove((channel: Channel) => channel.id === found.id)
onChannelRemoval(found.id)
processedIssues.push(issue)
})
}
async function editChannels() {
const requests = issues.filter(
issue => issue.labels.includes('channels:edit') && issue.labels.includes('approved')
)
requests.forEach((issue: Issue) => {
const data: IssueData = issue.data
if (data.missing('channel_id')) return
const found: Channel = channels.first(
(channel: Channel) => channel.id === data.getString('channel_id')
)
if (!found) return
let channelId: string | undefined = found.id
if (data.has('channel_name') || data.has('country')) {
const name = data.getString('channel_name') || found.name
const country = data.getString('country') || found.country
if (name && country) {
channelId = createChannelId(name, country)
if (channelId) onChannelIdChange(found.id, channelId)
}
}
if (!channelId) return
const updated = new Channel({
id: channelId,
name: data.getString('channel_name'),
alt_names: data.getArray('alt_names'),
network: data.getString('network'),
owners: data.getArray('owners'),
country: data.getString('country'),
subdivision: data.getString('subdivision'),
city: data.getString('city'),
broadcast_area: data.getArray('broadcast_area'),
languages: data.getArray('languages'),
categories: data.getArray('categories'),
is_nsfw: data.getBoolean('is_nsfw'),
launched: data.getString('launched'),
closed: data.getString('closed'),
replaced_by: data.getString('replaced_by'),
website: data.getString('website'),
logo: data.getString('logo')
})
found.merge(updated)
processedIssues.push(issue)
})
}
async function addChannels() {
const requests = issues.filter(
issue => issue.labels.includes('channels:add') && issue.labels.includes('approved')
)
requests.forEach((issue: Issue) => {
const data: IssueData = issue.data
if (
data.missing('channel_name') ||
data.missing('country') ||
data.missing('is_nsfw') ||
data.missing('logo') ||
data.missing('feed_name') ||
data.missing('broadcast_area') ||
data.missing('timezones') ||
data.missing('languages') ||
data.missing('video_format')
)
return
const channelId = createChannelId(data.getString('channel_name'), data.getString('country'))
if (!channelId) return
const found: Channel = channels.first((channel: Channel) => channel.id === channelId)
if (found) return
channels.push(
new Channel({
id: channelId,
name: data.getString('channel_name'),
alt_names: data.getArray('alt_names'),
network: data.getString('network'),
owners: data.getArray('owners'),
country: data.getString('country'),
subdivision: data.getString('subdivision'),
city: data.getString('city'),
broadcast_area: data.getArray('broadcast_area'),
languages: data.getArray('languages'),
categories: data.getArray('categories'),
is_nsfw: data.getBoolean('is_nsfw'),
launched: data.getString('launched'),
closed: data.getString('closed'),
replaced_by: data.getString('replaced_by'),
website: data.getString('website'),
logo: data.getString('logo')
})
)
const feedName = data.getString('feed_name') || 'SD'
feeds.push(
new Feed({
channel: channelId,
id: createFeedId(feedName),
name: feedName,
is_main: true,
broadcast_area: data.getArray('broadcast_area'),
timezones: data.getArray('timezones'),
languages: data.getArray('languages'),
video_format: data.getString('video_format'),
launched: data.getString('launched'),
closed: data.getString('closed'),
replaced_by: data.getString('replaced_by')
})
)
processedIssues.push(issue)
})
}
async function unblockChannels() {
const requests = issues.filter(
issue => issue.labels.includes('blocklist:remove') && issue.labels.includes('approved')
)
requests.forEach((issue: Issue) => {
const data = issue.data
if (data.missing('channel_id')) return
const found: Blocked = blocklist.first(
(blocked: Blocked) => blocked.channel === data.getString('channel_id')
)
if (!found) return
blocklist.remove((blocked: Blocked) => blocked.channel === found.channel)
processedIssues.push(issue)
})
}
async function blockChannels() {
const requests = issues.filter(
issue => issue.labels.includes('blocklist:add') && issue.labels.includes('approved')
)
requests.forEach((issue: Issue) => {
const data = issue.data
if (data.missing('channel_id')) return
const found: Blocked = blocklist.first(
(blocked: Blocked) => blocked.channel === data.getString('channel_id')
)
if (found) return
const channel = data.getString('channel_id')
const reason = data.getString('reason')?.toLowerCase()
const ref = data.getString('ref')
if (!channel || !reason || !ref) return
blocklist.push(
new Blocked({
channel,
reason,
ref
})
)
processedIssues.push(issue)
})
}
function onFeedIdChange(channelId: string, feedId: string, newFeedId: string) {
channels.forEach((channel: Channel) => {
if (channel.replaced_by && channel.replaced_by === `${channelId}@${feedId}`) {
channel.replaced_by = `${channelId}@${newFeedId}`
}
})
}
function onFeedNewMain(channelId: string, feedId: string) {
feeds.forEach((feed: Feed) => {
if (feed.channel === channelId && feed.id !== feedId && feed.is_main === true) {
feed.is_main = false
}
})
}
function onFeedRemoval(channelId: string, feedId: string) {
channels.forEach((channel: Channel) => {
if (channel.replaced_by && channel.replaced_by === `${channelId}@${feedId}`) {
channel.replaced_by = ''
}
})
}
function onChannelIdChange(channelId: string, newChannelId: string) {
channels.forEach((channel: Channel) => {
if (channel.replaced_by && channel.replaced_by.includes(channelId)) {
channel.replaced_by = channel.replaced_by.replace(channelId, newChannelId)
}
})
feeds.forEach((feed: Feed) => {
if (feed.channel === channelId) {
feed.channel = newChannelId
}
})
blocklist.forEach((blocked: Blocked) => {
if (blocked.channel === channelId) {
blocked.channel = newChannelId
}
})
}
function onChannelRemoval(channelId: string) {
channels.forEach((channel: Channel) => {
if (channel.replaced_by && channel.replaced_by.includes(channelId)) {
channel.replaced_by = ''
}
})
feeds.remove((feed: Feed) => feed.channel === channelId)
}

View file

@ -1,361 +0,0 @@
import { Collection, Storage, File, Dictionary, Logger } from '@freearhey/core'
import { DATA_DIR } from '../constants'
import schemesData from '../schemes'
import { program } from 'commander'
import Joi from 'joi'
import { CSVParser } from '../core'
import chalk from 'chalk'
import { createChannelId } from '../utils'
program.argument('[filepath]', 'Path to file to validate').parse(process.argv)
const logger = new Logger()
const buffer = new Dictionary()
const files = new Dictionary()
const schemes: { [key: string]: object } = schemesData
async function main() {
const dataStorage = new Storage(DATA_DIR)
const _files = await dataStorage.list('*.csv')
let globalErrors = new Collection()
const parser = new CSVParser()
for (const filepath of _files) {
const file = new File(filepath)
if (file.extension() !== 'csv') continue
const csv = await dataStorage.load(file.basename())
const rows = csv.split(/\r\n/)
const headers = rows[0].split(',')
for (const [i, line] of rows.entries()) {
if (!line.trim()) continue
if (line.indexOf('\n') > -1)
return handleError(
`Error: row ${i + 1} has the wrong line ending character, should be CRLF (${filepath})`
)
if (line.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/).length !== headers.length)
return handleError(`Error: row ${i + 1} has the wrong number of columns (${filepath})`)
}
const data = await parser.parse(csv)
const filename = file.name()
switch (filename) {
case 'feeds':
buffer.set(
'feeds',
data.keyBy(item => item.channel + item.id)
)
buffer.set(
'feedsByChannel',
data.filter(item => item.is_main).keyBy(item => item.channel)
)
break
case 'blocklist':
buffer.set(
'blocklist',
data.keyBy(item => item.channel + item.ref)
)
break
case 'categories':
case 'channels':
case 'timezones':
buffer.set(
filename,
data.keyBy(item => item.id)
)
break
default:
buffer.set(
filename,
data.keyBy(item => item.code)
)
break
}
files.set(filename, data)
}
const filesToCheck = program.args.length ? program.args : _files
for (const filepath of filesToCheck) {
const file = new File(filepath)
const filename = file.name()
if (!schemes[filename]) return handleError(`Error: "${filename}" scheme is missing`)
const rows: Collection = files.get(filename)
const rowsCopy = JSON.parse(JSON.stringify(rows.all()))
let fileErrors = new Collection()
switch (filename) {
case 'channels':
fileErrors = fileErrors.concat(findDuplicatesBy(rowsCopy, ['id']))
for (const [i, row] of rowsCopy.entries()) {
fileErrors = fileErrors.concat(validateChannelId(row, i))
fileErrors = fileErrors.concat(validateMainFeed(row, i))
fileErrors = fileErrors.concat(validateChannelBroadcastArea(row, i))
fileErrors = fileErrors.concat(validateReplacedBy(row, i))
fileErrors = fileErrors.concat(
checkValue(i, row, 'id', 'subdivision', buffer.get('subdivisions'))
)
fileErrors = fileErrors.concat(
checkValue(i, row, 'id', 'categories', buffer.get('categories'))
)
fileErrors = fileErrors.concat(
checkValue(i, row, 'id', 'languages', buffer.get('languages'))
)
fileErrors = fileErrors.concat(
checkValue(i, row, 'id', 'country', buffer.get('countries'))
)
}
break
case 'feeds':
fileErrors = fileErrors.concat(findDuplicatesBy(rowsCopy, ['channel', 'id']))
fileErrors = fileErrors.concat(findDuplicateMainFeeds(rowsCopy))
for (const [i, row] of rowsCopy.entries()) {
fileErrors = fileErrors.concat(validateChannel(row.channel, i))
fileErrors = fileErrors.concat(validateTimezones(row, i))
}
break
case 'blocklist':
fileErrors = fileErrors.concat(findDuplicatesBy(rowsCopy, ['channel', 'ref']))
for (const [i, row] of rowsCopy.entries()) {
fileErrors = fileErrors.concat(validateChannel(row.channel, i))
}
break
case 'countries':
fileErrors = fileErrors.concat(findDuplicatesBy(rowsCopy, ['code']))
for (const [i, row] of rowsCopy.entries()) {
fileErrors = fileErrors.concat(
checkValue(i, row, 'code', 'languages', buffer.get('languages'))
)
}
break
case 'subdivisions':
fileErrors = fileErrors.concat(findDuplicatesBy(rowsCopy, ['code']))
for (const [i, row] of rowsCopy.entries()) {
fileErrors = fileErrors.concat(
checkValue(i, row, 'code', 'country', buffer.get('countries'))
)
}
break
case 'regions':
fileErrors = fileErrors.concat(findDuplicatesBy(rowsCopy, ['code']))
for (const [i, row] of rowsCopy.entries()) {
fileErrors = fileErrors.concat(
checkValue(i, row, 'code', 'countries', buffer.get('countries'))
)
}
break
case 'categories':
fileErrors = fileErrors.concat(findDuplicatesBy(rowsCopy, ['id']))
break
case 'languages':
fileErrors = fileErrors.concat(findDuplicatesBy(rowsCopy, ['code']))
break
}
const schema = Joi.object(schemes[filename])
rows.all().forEach((row: { [key: string]: string }, i: number) => {
const { error } = schema.validate(row, { abortEarly: false })
if (error) {
error.details.forEach(detail => {
fileErrors.push({ line: i + 2, row, message: detail.message })
})
}
})
if (fileErrors.count()) {
logger.info(`\n${chalk.underline(filepath)}`)
fileErrors.forEach(err => {
const position = err.line.toString().padEnd(6, ' ')
const id = err.row && err.row.id ? ` ${err.row.id}:` : ''
logger.info(` ${chalk.gray(position)}${id} ${err.message}`)
})
globalErrors = globalErrors.concat(fileErrors)
}
}
if (globalErrors.count()) return handleError(`${globalErrors.count()} error(s)`)
}
main()
function checkValue(
i: number,
row: { [key: string]: string[] | string | boolean },
key: string,
field: string,
collection: Collection
) {
const errors = new Collection()
let values: string[] = []
if (Array.isArray(row[field])) {
values = row[field] as string[]
} else if (typeof row[field] === 'string') {
values = new Array(row[field]) as string[]
}
values.forEach((value: string) => {
if (collection.missing(value)) {
errors.push({
line: i + 2,
message: `"${row[key]}" has an invalid ${field} "${value}"`
})
}
})
return errors
}
function validateReplacedBy(row: { [key: string]: string }, i: number) {
const errors = new Collection()
if (!row.replaced_by) return errors
const channels = buffer.get('channels')
const feeds = buffer.get('feeds')
const [channelId, feedId] = row.replaced_by.split('@')
if (channels.missing(channelId)) {
errors.push({
line: i + 2,
message: `"${row.id}" has an invalid replaced_by "${row.replaced_by}"`
})
} else if (feedId && feeds.missing(channelId + feedId)) {
errors.push({
line: i + 2,
message: `"${row.id}" has an invalid replaced_by "${row.replaced_by}"`
})
}
return errors
}
function validateChannel(channelId: string, i: number) {
const errors = new Collection()
const channels = buffer.get('channels')
if (channels.missing(channelId)) {
errors.push({
line: i + 2,
message: `"${channelId}" is missing in the channels.csv`
})
}
return errors
}
function validateMainFeed(row: { [key: string]: string }, i: number) {
const errors = new Collection()
const feedsByChannel = buffer.get('feedsByChannel')
if (feedsByChannel.missing(row.id)) {
errors.push({
line: i + 2,
message: `"${row.id}" channel does not have a main feed`
})
}
return errors
}
function findDuplicatesBy(rows: { [key: string]: string }[], keys: string[]) {
const errors = new Collection()
const buffer = new Dictionary()
rows.forEach((row, i) => {
const normId = keys.map(key => row[key].toString().toLowerCase()).join()
if (buffer.has(normId)) {
const fieldsList = keys.map(key => `${key} "${row[key]}"`).join(' and ')
errors.push({
line: i + 2,
message: `entry with the ${fieldsList} already exists`
})
}
buffer.set(normId, true)
})
return errors
}
function findDuplicateMainFeeds(rows: { [key: string]: string }[]) {
const errors = new Collection()
const buffer = new Dictionary()
rows.forEach((row, i) => {
const normId = `${row.channel}${row.is_main}`
if (buffer.has(normId)) {
errors.push({
line: i + 2,
message: `entry with the channel "${row.channel}" and is_main "true" already exists`
})
}
if (row.is_main) {
buffer.set(normId, true)
}
})
return errors
}
function validateChannelId(row: { [key: string]: string }, i: number) {
const errors = new Collection()
const expectedId = createChannelId(row.name, row.country)
if (expectedId !== row.id) {
errors.push({
line: i + 2,
message: `"${row.id}" must be derived from the channel name "${row.name}" and the country code "${row.country}"`
})
}
return errors
}
function validateChannelBroadcastArea(row: { [key: string]: string[] }, i: number) {
const errors = new Collection()
const regions = buffer.get('regions')
const countries = buffer.get('countries')
const subdivisions = buffer.get('subdivisions')
row.broadcast_area.forEach((areaCode: string) => {
const [type, code] = areaCode.split('/')
if (
(type === 'r' && regions.missing(code)) ||
(type === 'c' && countries.missing(code)) ||
(type === 's' && subdivisions.missing(code))
) {
errors.push({
line: i + 2,
message: `"${row.id}" has the wrong broadcast_area "${areaCode}"`
})
}
})
return errors
}
function validateTimezones(row: { [key: string]: string[] }, i: number) {
const errors = new Collection()
const timezones = buffer.get('timezones')
row.timezones.forEach((timezone: string) => {
if (timezones.missing(timezone)) {
errors.push({
line: i + 2,
message: `"${row.channel}@${row.id}" has the wrong timezone "${timezone}"`
})
}
})
return errors
}
function handleError(message: string) {
logger.error(chalk.red(message))
process.exit(1)
}

View file

@ -1,17 +0,0 @@
type BlockedProps = {
channel: string
reason: string
ref: string
}
export class Blocked {
channel: string
reason: string
ref: string
constructor({ ref, reason, channel }: BlockedProps) {
this.channel = channel
this.reason = reason
this.ref = ref
}
}

View file

@ -0,0 +1,42 @@
import { BlocklistRecordData } from '../types/blocklistRecord'
import { Dictionary } from '@freearhey/core'
import { Model } from './model'
import Joi from 'joi'
export class BlocklistRecord extends Model {
channelId: string
reason: string
ref: string
constructor(data: BlocklistRecordData) {
super()
this.channelId = data.channel
this.reason = data.reason
this.ref = data.ref
}
hasValidChannelId(channelsKeyById: Dictionary): boolean {
return channelsKeyById.has(this.channelId)
}
data(): BlocklistRecordData {
return {
channel: this.channelId,
reason: this.reason,
ref: this.ref
}
}
getSchema() {
return Joi.object({
channel: Joi.string()
.regex(/^[A-Za-z0-9]+\.[a-z]{2}$/)
.required(),
reason: Joi.string()
.valid(...['dmca', 'nsfw'])
.required(),
ref: Joi.string().uri().required()
})
}
}

View file

@ -0,0 +1,33 @@
import { CategoryData } from '../types/category'
import { Model } from './model'
import Joi from 'joi'
export class Category extends Model {
id: string
name: string
constructor(data: CategoryData) {
super()
this.id = data.id
this.name = data.name
}
data(): CategoryData {
return {
id: this.id,
name: this.name
}
}
getSchema() {
return Joi.object({
id: Joi.string()
.regex(/^[a-z]+$/)
.required(),
name: Joi.string()
.regex(/^[A-Z]+$/i)
.required()
})
}
}

View file

@ -1,91 +1,237 @@
type ChannelProps = {
id: string
name?: string
alt_names?: string[]
network?: string
owners?: string[]
country?: string
subdivision?: string
city?: string
broadcast_area?: string[]
languages?: string[]
categories?: string[]
is_nsfw?: boolean
launched?: string
closed?: string
replaced_by?: string
website?: string
logo?: string
}
import { Dictionary, Collection } from '@freearhey/core'
import { ChannelData } from '../types/channel'
import { createChannelId } from '../utils'
import JoiDate from '@joi/date'
import { Model } from './model'
import { Feed } from './feed'
import BaseJoi from 'joi'
import { IssueData } from '../core'
export class Channel {
id: string
name?: string
alt_names?: string[]
network?: string
owners?: string[]
country?: string
subdivision?: string
city?: string
broadcast_area?: string[]
languages?: string[]
categories?: string[]
is_nsfw?: boolean
launched?: string
closed?: string
replaced_by?: string
website?: string
logo?: string
const Joi = BaseJoi.extend(JoiDate)
constructor({
id,
name,
alt_names,
network,
owners,
country,
subdivision,
city,
broadcast_area,
languages,
categories,
is_nsfw,
launched,
closed,
replaced_by,
website,
logo
}: ChannelProps) {
export class Channel extends Model {
id: string
name: string
altNames?: Collection
networkName?: string
ownerNames?: Collection
countryCode: string
subdivisionCode?: string
cityName?: string
categoryIds?: Collection
isNSFW: boolean
launchedDateString?: string
closedDateString?: string
replacedBy?: string
websiteUrl?: string
logoUrl: string
feeds?: Collection
constructor(data: ChannelData) {
super()
this.id = data.id
this.name = data.name
this.altNames = data.alt_names ? new Collection(data.alt_names) : undefined
this.networkName = data.network
this.ownerNames = data.owners ? new Collection(data.owners) : undefined
this.countryCode = data.country
this.subdivisionCode = data.subdivision
this.cityName = data.city
this.categoryIds = data.categories ? new Collection(data.categories) : undefined
this.isNSFW = data.is_nsfw
this.launchedDateString = data.launched
this.closedDateString = data.closed
this.replacedBy = data.replaced_by
this.websiteUrl = data.website
this.logoUrl = data.logo
}
setId(id: string): this {
this.id = id
this.name = name
this.alt_names = alt_names
this.network = network
this.owners = owners
this.country = country
this.subdivision = subdivision
this.city = city
this.broadcast_area = broadcast_area
this.languages = languages
this.categories = categories
this.is_nsfw = is_nsfw
this.launched = launched
this.closed = closed
this.replaced_by = replaced_by
this.website = website
this.logo = logo
return this
}
data() {
const { ...object } = this
update(issueData: IssueData): this {
const data = {
channel_name: issueData.getString('channel_name'),
alt_names: issueData.getArray('alt_names'),
network: issueData.getString('network'),
owners: issueData.getArray('owners'),
country: issueData.getString('country'),
subdivision: issueData.getString('subdivision'),
city: issueData.getString('city'),
categories: issueData.getArray('categories'),
is_nsfw: issueData.getBoolean('is_nsfw'),
launched: issueData.getString('launched'),
closed: issueData.getString('closed'),
replaced_by: issueData.getString('replaced_by'),
website: issueData.getString('website'),
logo: issueData.getString('logo')
}
return object
if (data.channel_name !== undefined) this.name = data.channel_name
if (data.alt_names !== undefined) this.altNames = new Collection(data.alt_names)
if (data.network !== undefined) this.networkName = data.network
if (data.owners !== undefined) this.ownerNames = new Collection(data.owners)
if (data.country !== undefined) this.countryCode = data.country
if (data.subdivision !== undefined) this.subdivisionCode = data.subdivision
if (data.city !== undefined) this.cityName = data.city
if (data.categories !== undefined) this.categoryIds = new Collection(data.categories)
if (data.is_nsfw !== undefined) this.isNSFW = data.is_nsfw
if (data.launched !== undefined) this.launchedDateString = data.launched
if (data.closed !== undefined) this.closedDateString = data.closed
if (data.replaced_by !== undefined) this.replacedBy = data.replaced_by
if (data.website !== undefined) this.websiteUrl = data.website
if (data.logo !== undefined) this.logoUrl = data.logo
return this
}
merge(channel: Channel) {
const data: { [key: string]: string | string[] | boolean | undefined } = channel.data()
for (const prop in data) {
if (data[prop] === undefined) continue
this[prop] = data[prop]
withFeeds(feedsGroupedByChannelId: Dictionary): this {
this.feeds = new Collection(feedsGroupedByChannelId.get(this.id))
return this
}
getFeeds(): Collection {
if (!this.feeds) return new Collection()
return this.feeds
}
hasValidId(): boolean {
const expectedId = createChannelId(this.name, this.countryCode)
return expectedId === this.id
}
hasMainFeed(): boolean {
const feeds = this.getFeeds()
if (feeds.isEmpty()) return false
const mainFeed = feeds.find((feed: Feed) => feed.isMain)
return !!mainFeed
}
hasMoreThanOneMainFeed(): boolean {
const mainFeeds = this.getFeeds().filter((feed: Feed) => feed.isMain)
return mainFeeds.count() > 1
}
hasValidReplacedBy(channelsKeyById: Dictionary, feedsKeyByStreamId: Dictionary): boolean {
if (!this.replacedBy) return true
const [channelId, feedId] = this.replacedBy.split('@')
if (channelsKeyById.missing(channelId)) return false
if (feedId && feedsKeyByStreamId.missing(this.replacedBy)) return false
return true
}
hasValidCountryCode(countriesKeyByCode: Dictionary): boolean {
return countriesKeyByCode.has(this.countryCode)
}
hasValidSubdivisionCode(subdivisionsKeyByCode: Dictionary): boolean {
return !this.subdivisionCode || subdivisionsKeyByCode.has(this.subdivisionCode)
}
hasValidCategoryIds(categoriesKeyById: Dictionary): boolean {
const hasInvalid = this.getCategoryIds().find((id: string) => categoriesKeyById.missing(id))
return !hasInvalid
}
getCategoryIds(): Collection {
if (!this.categoryIds) return new Collection()
return this.categoryIds
}
getAltNames(): Collection {
if (!this.altNames) return new Collection()
return this.altNames
}
getOwnerNames(): Collection {
if (!this.ownerNames) return new Collection()
return this.ownerNames
}
data(): ChannelData {
return {
id: this.id,
name: this.name,
alt_names: this.getAltNames().all(),
network: this.networkName,
owners: this.getOwnerNames().all(),
country: this.countryCode,
subdivision: this.subdivisionCode,
city: this.cityName,
categories: this.getCategoryIds().all(),
is_nsfw: this.isNSFW,
launched: this.launchedDateString,
closed: this.closedDateString,
replaced_by: this.replacedBy,
website: this.websiteUrl,
logo: this.logoUrl
}
}
getSchema() {
return Joi.object({
id: Joi.string()
.regex(/^[A-Za-z0-9]+\.[a-z]{2}$/)
.required(),
name: Joi.string()
.regex(/^[a-z0-9-!:&.+'/»#%°$@?|¡–\s_—]+$/i)
.regex(/^((?!\s-\s).)*$/)
.required(),
alt_names: Joi.array().items(
Joi.string()
.regex(/^[^",]+$/)
.invalid(Joi.ref('name'))
),
network: Joi.string()
.regex(/^[^",]+$/)
.allow(null),
owners: Joi.array().items(Joi.string().regex(/^[^",]+$/)),
country: Joi.string()
.regex(/^[A-Z]{2}$/)
.required(),
subdivision: Joi.string()
.regex(/^[A-Z]{2}-[A-Z0-9]{1,3}$/)
.allow(null),
city: Joi.string()
.regex(/^[^",]+$/)
.allow(null),
categories: Joi.array().items(Joi.string().regex(/^[a-z]+$/)),
is_nsfw: Joi.boolean().strict().required(),
launched: Joi.date().format('YYYY-MM-DD').raw().allow(null),
closed: Joi.date().format('YYYY-MM-DD').raw().allow(null).greater(Joi.ref('launched')),
replaced_by: Joi.string()
.regex(/^[A-Za-z0-9]+\.[a-z]{2}($|@[A-Za-z0-9]+$)/)
.allow(null),
website: Joi.string()
.regex(/,/, { invert: true })
.uri({
scheme: ['http', 'https']
})
.allow(null),
logo: Joi.string()
.regex(/,/, { invert: true })
.uri({
scheme: ['https']
})
.required()
})
}
}

54
scripts/models/country.ts Normal file
View file

@ -0,0 +1,54 @@
import { Collection, Dictionary } from '@freearhey/core'
import { CountryData } from '../types/country'
import { Model } from './model'
import Joi from 'joi'
export class Country extends Model {
code: string
name: string
flagEmoji: string
languageCodes: Collection
constructor(data: CountryData) {
super()
this.code = data.code
this.name = data.name
this.flagEmoji = data.flag
this.languageCodes = new Collection(data.languages)
}
hasValidLanguageCodes(languagesKeyByCode: Dictionary): boolean {
const hasInvalid = this.languageCodes.find((code: string) => languagesKeyByCode.missing(code))
return !hasInvalid
}
data(): CountryData {
return {
code: this.code,
name: this.name,
flag: this.flagEmoji,
languages: this.languageCodes.all()
}
}
getSchema() {
return Joi.object({
name: Joi.string()
.regex(/^[\sA-Z\u00C0-\u00FF().-]+$/i)
.required(),
code: Joi.string()
.regex(/^[A-Z]{2}$/)
.required(),
languages: Joi.array().items(
Joi.string()
.regex(/^[a-z]{3}$/)
.required()
),
flag: Joi.string()
.regex(/^[\uD83C][\uDDE6-\uDDFF][\uD83C][\uDDE6-\uDDFF]$/)
.required()
})
}
}

View file

@ -1,55 +1,147 @@
type FeedProps = {
channel: string
id: string
name?: string
is_main?: boolean
broadcast_area?: string[]
timezones?: string[]
languages?: string[]
video_format?: string
}
import { Collection, Dictionary } from '@freearhey/core'
import { FeedData } from '../types/feed'
import { createFeedId } from '../utils'
import { Model } from './model'
import JoiDate from '@joi/date'
import BaseJoi from 'joi'
import { IssueData } from '../core'
export class Feed {
channel: string
id: string
name?: string
is_main?: boolean
broadcast_area: string[]
timezones: string[]
languages: string[]
video_format?: string
const Joi = BaseJoi.extend(JoiDate)
constructor({
channel,
id,
name,
is_main,
broadcast_area,
timezones,
languages,
video_format
}: FeedProps) {
this.channel = channel
export class Feed extends Model {
channelId: string
id: string
name: string
isMain: boolean
broadcastAreaCodes: Collection
timezoneIds: Collection
languageCodes: Collection
videoFormat?: string
constructor(data: FeedData) {
super()
this.channelId = data.channel
this.id = data.id
this.name = data.name
this.isMain = data.is_main
this.broadcastAreaCodes = new Collection(data.broadcast_area)
this.timezoneIds = new Collection(data.timezones)
this.languageCodes = new Collection(data.languages)
this.videoFormat = data.video_format
}
setId(id: string): this {
this.id = id
this.name = name
this.is_main = is_main
this.broadcast_area = broadcast_area || []
this.timezones = timezones || []
this.languages = languages || []
this.video_format = video_format
return this
}
data() {
const { ...object } = this
update(issueData: IssueData): this {
const data = {
feed_name: issueData.getString('feed_name'),
is_main: issueData.getBoolean('is_main'),
broadcast_area: issueData.getArray('broadcast_area'),
timezones: issueData.getArray('timezones'),
languages: issueData.getArray('languages'),
video_format: issueData.getString('video_format')
}
return object
if (data.feed_name !== undefined) this.name = data.feed_name
if (data.is_main !== undefined) this.isMain = data.is_main
if (data.broadcast_area !== undefined)
this.broadcastAreaCodes = new Collection(data.broadcast_area)
if (data.timezones !== undefined) this.timezoneIds = new Collection(data.timezones)
if (data.languages !== undefined) this.languageCodes = new Collection(data.languages)
if (data.video_format !== undefined) this.videoFormat = data.video_format
return this
}
merge(feed: Feed) {
const data: { [key: string]: string | string[] | boolean | undefined } = feed.data()
for (const prop in data) {
if (data[prop] === undefined) continue
this[prop] = data[prop]
hasValidId(): boolean {
const expectedId = createFeedId(this.name)
return expectedId === this.id
}
hasValidChannelId(channelsKeyById: Dictionary): boolean {
return channelsKeyById.has(this.channelId)
}
hasValidTimezones(timezonesKeyById: Dictionary): boolean {
const hasInvalid = this.timezoneIds.find((id: string) => timezonesKeyById.missing(id))
return !hasInvalid
}
hasValidBroadcastAreaCodes(
countriesKeyByCode: Dictionary,
subdivisionsKeyByCode: Dictionary,
regionsKeyByCode: Dictionary
): boolean {
const hasInvalid = this.broadcastAreaCodes.find((areaCode: string) => {
const [type, code] = areaCode.split('/')
switch (type) {
case 'c':
return countriesKeyByCode.missing(code)
case 's':
return subdivisionsKeyByCode.missing(code)
case 'r':
return regionsKeyByCode.missing(code)
}
})
return !hasInvalid
}
getStreamId(): string {
return `${this.channelId}@${this.id}`
}
data(): FeedData {
return {
channel: this.channelId,
id: this.id,
name: this.name,
is_main: this.isMain,
broadcast_area: this.broadcastAreaCodes.all(),
timezones: this.timezoneIds.all(),
languages: this.languageCodes.all(),
video_format: this.videoFormat
}
}
getSchema() {
return Joi.object({
channel: Joi.string()
.regex(/^[A-Za-z0-9]+\.[a-z]{2}$/)
.required(),
id: Joi.string()
.regex(/^[A-Za-z0-9]+$/)
.required(),
name: Joi.string()
.regex(/^[a-z0-9-!:&.+'/»#%°$@?|¡–\s_—]+$/i)
.regex(/^((?!\s-\s).)*$/)
.required(),
is_main: Joi.boolean().strict().required(),
broadcast_area: Joi.array().items(
Joi.string()
.regex(/^(s\/[A-Z]{2}-[A-Z0-9]{1,3}|c\/[A-Z]{2}|r\/[A-Z0-9]{2,7})$/)
.required()
),
timezones: Joi.array().items(
Joi.string()
.regex(/^[a-z-_/]+$/i)
.required()
),
languages: Joi.array().items(
Joi.string()
.regex(/^[a-z]{3}$/)
.required()
),
video_format: Joi.string()
.regex(/^\d+(i|p)$/)
.allow(null)
})
}
}

View file

@ -1,3 +1,9 @@
export * from './channel'
export * from './blocked'
export * from './blocklistRecord'
export * from './feed'
export * from './region'
export * from './subdivision'
export * from './category'
export * from './country'
export * from './language'
export * from './timezone'

View file

@ -0,0 +1,31 @@
import { LanguageData } from '../types/language'
import { Model } from './model'
import Joi from 'joi'
export class Language extends Model {
code: string
name: string
constructor(data: LanguageData) {
super()
this.code = data.code
this.name = data.name
}
data(): LanguageData {
return {
code: this.code,
name: this.name
}
}
getSchema() {
return Joi.object({
code: Joi.string()
.regex(/^[a-z]{3}$/)
.required(),
name: Joi.string().required()
})
}
}

15
scripts/models/model.ts Normal file
View file

@ -0,0 +1,15 @@
export class Model {
line?: number
constructor() {}
setLine(line: number): this {
this.line = line
return this
}
getLine(): number {
return this.line || 0
}
}

48
scripts/models/region.ts Normal file
View file

@ -0,0 +1,48 @@
import { Collection, Dictionary } from '@freearhey/core'
import { RegionData } from '../types/region'
import { Model } from './model'
import Joi from 'joi'
export class Region extends Model {
code: string
name: string
countryCodes: Collection
constructor(data: RegionData) {
super()
this.code = data.code
this.name = data.name
this.countryCodes = new Collection(data.countries)
}
hasValidCountryCodes(countriesKeyByCode: Dictionary): boolean {
const hasInvalid = this.countryCodes.find((code: string) => countriesKeyByCode.missing(code))
return !hasInvalid
}
data(): RegionData {
return {
code: this.code,
name: this.name,
countries: this.countryCodes.all()
}
}
getSchema() {
return Joi.object({
name: Joi.string()
.regex(/^[\sA-Z\u00C0-\u00FF().,-]+$/i)
.required(),
code: Joi.string()
.regex(/^[A-Z]{2,7}$/)
.required(),
countries: Joi.array().items(
Joi.string()
.regex(/^[A-Z]{2}$/)
.required()
)
})
}
}

View file

@ -0,0 +1,42 @@
import { SubdivisionData } from '../types/subdivision'
import { Dictionary } from '@freearhey/core'
import { Model } from './model'
import Joi from 'joi'
export class Subdivision extends Model {
code: string
name: string
countryCode: string
constructor(data: SubdivisionData) {
super()
this.code = data.code
this.name = data.name
this.countryCode = data.country
}
hasValidCountryCode(countriesKeyByCode: Dictionary): boolean {
return countriesKeyByCode.has(this.countryCode)
}
data(): SubdivisionData {
return {
code: this.code,
name: this.name,
country: this.countryCode
}
}
getSchema() {
return Joi.object({
country: Joi.string()
.regex(/^[A-Z]{2}$/)
.required(),
name: Joi.string().required(),
code: Joi.string()
.regex(/^[A-Z]{2}-[A-Z0-9]{1,3}$/)
.required()
})
}
}

View file

@ -0,0 +1,44 @@
import { Collection, Dictionary } from '@freearhey/core'
import { TimezoneData } from '../types/timezone'
import { Model } from './model'
import Joi from 'joi'
export class Timezone extends Model {
id: string
utcOffset: string
countryCodes: Collection
constructor(data: TimezoneData) {
super()
this.id = data.id
this.utcOffset = data.utc_offset
this.countryCodes = new Collection(data.countries)
}
hasValidCountryCodes(countriesKeyByCode: Dictionary): boolean {
const hasInvalid = this.countryCodes.find((code: string) => countriesKeyByCode.missing(code))
return !hasInvalid
}
data(): TimezoneData {
return {
id: this.id,
utc_offset: this.utcOffset,
countries: this.countryCodes.all()
}
}
getSchema() {
return Joi.object({
id: Joi.string()
.regex(/^[a-z-_/]+$/i)
.required(),
utc_offset: Joi.string()
.regex(/^(\+|-)\d{2}:\d{2}$/)
.required(),
countries: Joi.array().items(Joi.string().regex(/^[A-Z]{2}$/))
})
}
}

View file

@ -1,11 +0,0 @@
import Joi from 'joi'
export default {
channel: Joi.string()
.regex(/^[A-Za-z0-9]+\.[a-z]{2}$/)
.required(),
reason: Joi.string()
.valid(...['dmca', 'nsfw'])
.required(),
ref: Joi.string().uri().required()
}

View file

@ -1,10 +0,0 @@
import Joi from 'joi'
export default {
id: Joi.string()
.regex(/^[a-z]+$/)
.required(),
name: Joi.string()
.regex(/^[A-Z]+$/i)
.required()
}

View file

@ -1,71 +0,0 @@
import BaseJoi from 'joi'
import JoiDate from '@joi/date'
import path from 'path'
import url from 'url'
const Joi = BaseJoi.extend(JoiDate)
export default {
id: Joi.string()
.regex(/^[A-Za-z0-9]+\.[a-z]{2}$/)
.required(),
name: Joi.string()
.regex(/^[a-z0-9-!:&.+'/»#%°$@?|¡–\s_—]+$/i)
.regex(/^((?!\s-\s).)*$/)
.required(),
alt_names: Joi.array().items(
Joi.string()
.regex(/^[^",]+$/)
.invalid(Joi.ref('name'))
),
network: Joi.string()
.regex(/^[^",]+$/)
.allow(null),
owners: Joi.array().items(Joi.string().regex(/^[^",]+$/)),
country: Joi.string()
.regex(/^[A-Z]{2}$/)
.required(),
subdivision: Joi.string()
.regex(/^[A-Z]{2}-[A-Z0-9]{1,3}$/)
.allow(null),
city: Joi.string()
.regex(/^[^",]+$/)
.allow(null),
broadcast_area: Joi.array().items(
Joi.string()
.regex(/^(s\/[A-Z]{2}-[A-Z0-9]{1,3}|c\/[A-Z]{2}|r\/[A-Z0-9]{2,7})$/)
.required()
),
languages: Joi.array().items(
Joi.string()
.regex(/^[a-z]{3}$/)
.required()
),
categories: Joi.array().items(Joi.string().regex(/^[a-z]+$/)),
is_nsfw: Joi.boolean().strict().required(),
launched: Joi.date().format('YYYY-MM-DD').raw().allow(null),
closed: Joi.date().format('YYYY-MM-DD').raw().allow(null).greater(Joi.ref('launched')),
replaced_by: Joi.string()
.regex(/^[A-Za-z0-9]+\.[a-z]{2}($|@[A-Za-z0-9]+$)/)
.allow(null),
website: Joi.string()
.regex(/,/, { invert: true })
.uri({
scheme: ['http', 'https']
})
.allow(null),
logo: Joi.string()
.regex(/,/, { invert: true })
.uri({
scheme: ['https']
})
.custom((value, helper) => {
const ext = path.extname(url.parse(value).pathname)
if (!ext || /(\.png|\.jpeg|\.jpg)/i.test(ext)) {
return true
} else {
return helper.message(`"logo" has an invalid file extension "${ext}"`)
}
})
.required()
}

View file

@ -1,18 +0,0 @@
import Joi from 'joi'
export default {
name: Joi.string()
.regex(/^[\sA-Z\u00C0-\u00FF().-]+$/i)
.required(),
code: Joi.string()
.regex(/^[A-Z]{2}$/)
.required(),
languages: Joi.array().items(
Joi.string()
.regex(/^[a-z]{3}$/)
.required()
),
flag: Joi.string()
.regex(/^[\uD83C][\uDDE6-\uDDFF][\uD83C][\uDDE6-\uDDFF]$/)
.required()
}

View file

@ -1,36 +0,0 @@
import BaseJoi from 'joi'
import JoiDate from '@joi/date'
const Joi = BaseJoi.extend(JoiDate)
export default {
channel: Joi.string()
.regex(/^[A-Za-z0-9]+\.[a-z]{2}$/)
.required(),
id: Joi.string()
.regex(/^[A-Za-z0-9]+$/)
.required(),
name: Joi.string()
.regex(/^[a-z0-9-!:&.+'/»#%°$@?|¡–\s_—]+$/i)
.regex(/^((?!\s-\s).)*$/)
.required(),
is_main: Joi.boolean().strict().required(),
broadcast_area: Joi.array().items(
Joi.string()
.regex(/^(s\/[A-Z]{2}-[A-Z0-9]{1,3}|c\/[A-Z]{2}|r\/[A-Z0-9]{2,7})$/)
.required()
),
timezones: Joi.array().items(
Joi.string()
.regex(/^[a-z-_/]+$/i)
.required()
),
languages: Joi.array().items(
Joi.string()
.regex(/^[a-z]{3}$/)
.required()
),
video_format: Joi.string()
.regex(/^\d+(i|p)$/)
.allow(null)
}

View file

@ -1,21 +0,0 @@
import { default as channels } from './channels'
import { default as categories } from './categories'
import { default as countries } from './countries'
import { default as languages } from './languages'
import { default as regions } from './regions'
import { default as subdivisions } from './subdivisions'
import { default as blocklist } from './blocklist'
import { default as feeds } from './feeds'
import { default as timezones } from './timezones'
export default {
channels,
categories,
countries,
languages,
regions,
subdivisions,
blocklist,
feeds,
timezones
}

View file

@ -1,8 +0,0 @@
import Joi from 'joi'
export default {
code: Joi.string()
.regex(/^[a-z]{3}$/)
.required(),
name: Joi.string().required()
}

View file

@ -1,15 +0,0 @@
import Joi from 'joi'
export default {
name: Joi.string()
.regex(/^[\sA-Z\u00C0-\u00FF().,-]+$/i)
.required(),
code: Joi.string()
.regex(/^[A-Z]{2,7}$/)
.required(),
countries: Joi.array().items(
Joi.string()
.regex(/^[A-Z]{2}$/)
.required()
)
}

View file

@ -1,11 +0,0 @@
import Joi from 'joi'
export default {
country: Joi.string()
.regex(/^[A-Z]{2}$/)
.required(),
name: Joi.string().required(),
code: Joi.string()
.regex(/^[A-Z]{2}-[A-Z0-9]{1,3}$/)
.required()
}

View file

@ -1,11 +0,0 @@
import Joi from 'joi'
export default {
id: Joi.string()
.regex(/^[a-z-_/]+$/i)
.required(),
utc_offset: Joi.string()
.regex(/^(\+|-)\d{2}:\d{2}$/)
.required(),
countries: Joi.array().items(Joi.string().regex(/^[A-Z]{2}$/))
}

5
scripts/types/blocklistRecord.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
export type BlocklistRecordData = {
channel: string
reason: string
ref: string
}

4
scripts/types/category.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
export type CategoryData = {
id: string
name: string
}

17
scripts/types/channel.d.ts vendored Normal file
View file

@ -0,0 +1,17 @@
export type ChannelData = {
id: string
name: string
alt_names?: string[]
network?: string
owners?: string[]
country: string
subdivision?: string
city?: string
categories?: string[]
is_nsfw: boolean
launched?: string
closed?: string
replaced_by?: string
website?: string
logo: string
}

6
scripts/types/country.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
export type CountryData = {
code: string
name: string
flag: string
languages: string[]
}

4
scripts/types/csvParser.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
export type CSVParserRow = {
line: number
data: any
}

26
scripts/types/dataLoader.d.ts vendored Normal file
View file

@ -0,0 +1,26 @@
import { Dictionary, Collection, Storage } from '@freearhey/core'
export type DataLoaderData = {
feeds: Collection
feedsGroupedByChannelId: Dictionary
feedsKeyByStreamId: Dictionary
channels: Collection
categories: Collection
countries: Collection
languages: Collection
blocklistRecords: Collection
timezones: Collection
regions: Collection
subdivisions: Collection
channelsKeyById: Dictionary
countriesKeyByCode: Dictionary
subdivisionsKeyByCode: Dictionary
categoriesKeyById: Dictionary
regionsKeyByCode: Dictionary
timezonesKeyById: Dictionary
languagesKeyByCode: Dictionary
}
export type DataLoaderProps = {
storage: Storage
}

10
scripts/types/feed.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
export type FeedData = {
channel: string
id: string
name: string
is_main: boolean
broadcast_area: string[]
timezones: string[]
languages: string[]
video_format?: string
}

4
scripts/types/language.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
export type LanguageData = {
code: string
name: string
}

5
scripts/types/region.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
export type RegionData = {
code: string
name: string
countries: string[]
}

5
scripts/types/subdivision.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
export type SubdivisionData = {
country: string
name: string
code: string
}

5
scripts/types/timezone.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
export type TimezoneData = {
id: string
utc_offset: string
countries: string[]
}

10
scripts/types/validator.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
import { DataLoaderData } from './dataLoader'
export type ValidatorError = {
line: number
message: string
}
export type ValidatorProps = {
data: DataLoaderData
}

View file

@ -20,5 +20,6 @@ function normalize(string: string) {
.replace(/^&/i, 'And')
.replace(/\+/gi, 'Plus')
.replace(/\s-(\d)/gi, ' Minus$1')
.replace(/^-(\d)/gi, 'Minus$1')
.replace(/[^a-z\d]+/gi, '')
}

View file

@ -0,0 +1,38 @@
import { DataLoaderData } from '../types/dataLoader'
import { ValidatorProps } from '../types/validator'
import { Collection } from '@freearhey/core'
import { Validator } from './validator'
import { BlocklistRecord } from '../models'
export class BlocklistRecordValidator extends Validator {
constructor(props: ValidatorProps) {
super(props)
}
validate(blocklistRecord: BlocklistRecord): Collection {
const { channelsKeyById }: DataLoaderData = this.data
let errors = new Collection()
const joiResults = blocklistRecord
.getSchema()
.validate(blocklistRecord.data(), { abortEarly: false })
if (joiResults.error) {
joiResults.error.details.forEach((detail: { message: string }) => {
errors.add({
line: blocklistRecord.getLine(),
message: `${blocklistRecord.channelId}: ${detail.message}`
})
})
}
if (!blocklistRecord.hasValidChannelId(channelsKeyById)) {
errors.add({
line: blocklistRecord.getLine(),
message: `"${blocklistRecord.channelId}" is missing from the channels.csv`
})
}
return errors
}
}

View file

@ -0,0 +1,23 @@
import { ValidatorProps } from '../types/validator'
import { Collection } from '@freearhey/core'
import { Validator } from './validator'
import { Category } from '../models'
export class CategoryValidator extends Validator {
constructor(props: ValidatorProps) {
super(props)
}
validate(category: Category): Collection {
let errors = new Collection()
const joiResults = category.getSchema().validate(category.data(), { abortEarly: false })
if (joiResults.error) {
joiResults.error.details.forEach((detail: { message: string }) => {
errors.add({ line: category.getLine(), message: `${category.id}: ${detail.message}` })
})
}
return errors
}
}

View file

@ -0,0 +1,81 @@
import { ValidatorProps } from '../types/validator'
import { DataLoaderData } from '../types/dataLoader'
import { Collection } from '@freearhey/core'
import { Validator } from './validator'
import { Channel } from '../models'
export class ChannelValidator extends Validator {
constructor(props: ValidatorProps) {
super(props)
}
validate(channel: Channel): Collection {
const {
channelsKeyById,
feedsKeyByStreamId,
countriesKeyByCode,
subdivisionsKeyByCode,
categoriesKeyById
}: DataLoaderData = this.data
let errors = new Collection()
const joiResults = channel.getSchema().validate(channel.data(), { abortEarly: false })
if (joiResults.error) {
joiResults.error.details.forEach((detail: { message: string }) => {
errors.add({ line: channel.getLine(), message: `${channel.id}: ${detail.message}` })
})
}
if (!channel.hasValidId()) {
errors.add({
line: channel.getLine(),
message: `"${channel.id}" must be derived from the channel name "${channel.name}" and the country code "${channel.countryCode}"`
})
}
if (!channel.hasMainFeed()) {
errors.add({
line: channel.getLine(),
message: `"${channel.id}" does not have a main feed`
})
}
if (channel.hasMoreThanOneMainFeed()) {
errors.add({
line: channel.getLine(),
message: `"${channel.id}" has an more than one main feed`
})
}
if (!channel.hasValidReplacedBy(channelsKeyById, feedsKeyByStreamId)) {
errors.add({
line: channel.getLine(),
message: `"${channel.id}" has an invalid replaced_by "${channel.replacedBy}"`
})
}
if (!channel.hasValidCountryCode(countriesKeyByCode)) {
errors.add({
line: channel.getLine(),
message: `"${channel.id}" has an invalid country "${channel.countryCode}"`
})
}
if (!channel.hasValidSubdivisionCode(subdivisionsKeyByCode)) {
errors.add({
line: channel.getLine(),
message: `"${channel.id}" has an invalid subdivision "${channel.subdivisionCode}"`
})
}
if (!channel.hasValidCategoryIds(categoriesKeyById)) {
errors.add({
line: channel.getLine(),
message: `"${channel.id}" has an invalid categories "${channel.getCategoryIds().join(';')}"`
})
}
return errors
}
}

View file

@ -0,0 +1,33 @@
import { ValidatorProps } from '../types/validator'
import { Collection } from '@freearhey/core'
import { Validator } from './validator'
import { Country } from '../models'
import { DataLoaderData } from '../types/dataLoader'
export class CountryValidator extends Validator {
constructor(props: ValidatorProps) {
super(props)
}
validate(country: Country): Collection {
const { languagesKeyByCode }: DataLoaderData = this.data
let errors = new Collection()
const joiResults = country.getSchema().validate(country.data(), { abortEarly: false })
if (joiResults.error) {
joiResults.error.details.forEach((detail: { message: string }) => {
errors.add({ line: country.getLine(), message: `${country.code}: ${detail.message}` })
})
}
if (!country.hasValidLanguageCodes(languagesKeyByCode)) {
errors.add({
line: country.getLine(),
message: `"${country.code}" has an invalid languages "${country.languageCodes.join(';')}"`
})
}
return errors
}
}

View file

@ -0,0 +1,66 @@
import { ValidatorProps } from '../types/validator'
import { Collection } from '@freearhey/core'
import { Validator } from './validator'
import { Feed } from '../models'
import { DataLoaderData } from '../types/dataLoader'
export class FeedValidator extends Validator {
constructor(props: ValidatorProps) {
super(props)
}
validate(feed: Feed): Collection {
const {
channelsKeyById,
countriesKeyByCode,
subdivisionsKeyByCode,
regionsKeyByCode,
timezonesKeyById
}: DataLoaderData = this.data
let errors = new Collection()
const joiResults = feed.getSchema().validate(feed.data(), { abortEarly: false })
if (joiResults.error) {
joiResults.error.details.forEach((detail: { message: string }) => {
errors.add({ line: feed.getLine(), message: `${feed.getStreamId()}: ${detail.message}` })
})
}
if (!feed.hasValidId()) {
errors.add({
line: feed.getLine(),
message: `"${feed.getStreamId()}" id "${feed.id}" must be derived from the name "${
feed.name
}"`
})
}
if (!feed.hasValidChannelId(channelsKeyById)) {
errors.add({
line: feed.getLine(),
message: `"${feed.getStreamId()}" has the wrong channel "${feed.channelId}"`
})
}
if (
!feed.hasValidBroadcastAreaCodes(countriesKeyByCode, subdivisionsKeyByCode, regionsKeyByCode)
) {
errors.add({
line: feed.getLine(),
message: `"${feed.getStreamId()}" has the wrong broadcast_area "${feed.broadcastAreaCodes.join(
';'
)}"`
})
}
if (!feed.hasValidTimezones(timezonesKeyById)) {
errors.add({
line: feed.getLine(),
message: `"${feed.getStreamId()}" has the wrong timezones "${feed.timezoneIds.join(';')}"`
})
}
return errors
}
}

View file

@ -0,0 +1,9 @@
export * from './blocklistRecordValidator'
export * from './categoryValidator'
export * from './channelValidator'
export * from './countryValidator'
export * from './feedValidator'
export * from './languageValidator'
export * from './regionValidator'
export * from './subdivisionValidator'
export * from './timezoneValidator'

View file

@ -0,0 +1,23 @@
import { ValidatorProps } from '../types/validator'
import { Collection } from '@freearhey/core'
import { Validator } from './validator'
import { Language } from '../models'
export class LanguageValidator extends Validator {
constructor(props: ValidatorProps) {
super(props)
}
validate(language: Language): Collection {
let errors = new Collection()
const joiResults = language.getSchema().validate(language.data(), { abortEarly: false })
if (joiResults.error) {
joiResults.error.details.forEach((detail: { message: string }) => {
errors.add({ line: language.getLine(), message: `${language.code}: ${detail.message}` })
})
}
return errors
}
}

View file

@ -0,0 +1,33 @@
import { DataLoaderData } from '../types/dataLoader'
import { ValidatorProps } from '../types/validator'
import { Collection } from '@freearhey/core'
import { Validator } from './validator'
import { Region } from '../models'
export class RegionValidator extends Validator {
constructor(props: ValidatorProps) {
super(props)
}
validate(region: Region): Collection {
const { countriesKeyByCode }: DataLoaderData = this.data
let errors = new Collection()
const joiResults = region.getSchema().validate(region.data(), { abortEarly: false })
if (joiResults.error) {
joiResults.error.details.forEach((detail: { message: string }) => {
errors.add({ line: region.getLine(), message: `${region.code}: ${detail.message}` })
})
}
if (!region.hasValidCountryCodes(countriesKeyByCode)) {
errors.add({
line: region.getLine(),
message: `"${region.code}" has the wrong countries "${region.countryCodes.join(';')}"`
})
}
return errors
}
}

View file

@ -0,0 +1,36 @@
import { ValidatorProps } from '../types/validator'
import { Collection } from '@freearhey/core'
import { Validator } from './validator'
import { Subdivision } from '../models'
import { DataLoaderData } from '../types/dataLoader'
export class SubdivisionValidator extends Validator {
constructor(props: ValidatorProps) {
super(props)
}
validate(subdivision: Subdivision): Collection {
const { countriesKeyByCode }: DataLoaderData = this.data
let errors = new Collection()
const joiResults = subdivision.getSchema().validate(subdivision.data(), { abortEarly: false })
if (joiResults.error) {
joiResults.error.details.forEach((detail: { message: string }) => {
errors.add({
line: subdivision.getLine(),
message: `${subdivision.code}: ${detail.message}`
})
})
}
if (!subdivision.hasValidCountryCode(countriesKeyByCode)) {
errors.add({
line: subdivision.getLine(),
message: `"${subdivision.code}" has an invalid country "${subdivision.countryCode}"`
})
}
return errors
}
}

View file

@ -0,0 +1,33 @@
import { DataLoaderData } from '../types/dataLoader'
import { ValidatorProps } from '../types/validator'
import { Collection } from '@freearhey/core'
import { Validator } from './validator'
import { Timezone } from '../models'
export class TimezoneValidator extends Validator {
constructor(props: ValidatorProps) {
super(props)
}
validate(timezone: Timezone): Collection {
const { countriesKeyByCode }: DataLoaderData = this.data
let errors = new Collection()
const joiResults = timezone.getSchema().validate(timezone.data(), { abortEarly: false })
if (joiResults.error) {
joiResults.error.details.forEach((detail: { message: string }) => {
errors.add({ line: timezone.getLine(), message: `${timezone.id}: ${detail.message}` })
})
}
if (!timezone.hasValidCountryCodes(countriesKeyByCode)) {
errors.add({
line: timezone.getLine(),
message: `"${timezone.id}" has the wrong countries "${timezone.countryCodes.join(';')}"`
})
}
return errors
}
}

View file

@ -0,0 +1,10 @@
import { ValidatorProps } from '../types/validator'
import { DataLoaderData } from '../types/dataLoader'
export class Validator {
data: DataLoaderData
constructor({ data }: ValidatorProps) {
this.data = data
}
}