This commit is contained in:
AntiPontifex 2023-10-07 23:18:03 -04:00
commit 16817e09c9
70 changed files with 12913 additions and 5599 deletions

39
.eslintrc.json Normal file
View file

@ -0,0 +1,39 @@
{
"env": {
"node": true,
"es2021": true,
"jest": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"@typescript-eslint"
],
"rules": {
"@typescript-eslint/no-var-requires": "off",
"no-case-declarations": "off",
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"single",
{
"avoidEscape": true
}
],
"semi": [
"error",
"never"
]
}
}

13
.github/CODE_OF_CONDUCT.md vendored Normal file
View file

@ -0,0 +1,13 @@
# Contributor Code of Conduct
As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities.
We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, or religion.
Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team.
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers.
This Code of Conduct is adapted from the [Contributor Covenant](http:contributor-covenant.org), version 1.0.0, available at https://www.contributor-covenant.org/version/1/0/0/code-of-conduct.html

View file

@ -7,15 +7,20 @@ jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
with:
fetch-depth: 0
- id: files
uses: tj-actions/changed-files@v12.2
fetch-depth: 2
- uses: actions/setup-node@v3
if: ${{ !env.ACT }}
with:
files: 'data'
node-version: 18
cache: 'npm'
- uses: tj-actions/changed-files@v35
id: files
with:
files: data/*.csv
- name: install dependencies
run: npm install
- name: validate
if: steps.files.outputs.any_changed == 'true'
run: |
npm install
npm run db:validate -- ${{ steps.files.outputs.all_changed_files }}
run: npm run db:validate -- ${{ steps.files.outputs.all_changed_files }}

View file

@ -8,10 +8,17 @@ jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- run: npm install
- run: npm run db:export
- uses: tibdex/github-app-token@v1
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
if: ${{ !env.ACT }}
with:
node-version: 18
cache: 'npm'
- name: install dependencies
run: npm install
- name: export data to ./api
run: npm run db:export
- uses: tibdex/github-app-token@v1.8.2
if: ${{ !env.ACT }}
id: create-app-token
with:

View file

@ -7,25 +7,40 @@ jobs:
main:
runs-on: ubuntu-latest
steps:
- uses: tibdex/github-app-token@v1
- uses: actions/checkout@v3
- uses: tibdex/github-app-token@v1.8.2
if: ${{ !env.ACT }}
id: create-app-token
with:
app_id: ${{ secrets.APP_ID }}
private_key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/checkout@v3
if: ${{ !env.ACT }}
with:
token: ${{ steps.create-app-token.outputs.token }}
- run: npm install
- run: npm run db:update --silent >> $GITHUB_OUTPUT
id: issue-process
- name: Validate Changes
- uses: actions/setup-node@v3
if: ${{ !env.ACT }}
with:
node-version: 18
cache: 'npm'
- name: install dependencies
run: npm install
- name: update data
id: db-update
run: npm run db:update --silent >> $GITHUB_OUTPUT
- name: validate changes
run: npm run db:validate
- name: Commit Changes
if: steps.issue-process.outputs.OUTPUT != 0
- name: setup git
run: |
git config user.name "iptv-bot[bot]"
git config user.email "84861620+iptv-bot[bot]@users.noreply.github.com"
git add data/channels.csv
git commit -m "[Bot] Update channels.csv" -m "Committed by [iptv-bot](https://github.com/apps/iptv-bot) via [update](https://github.com/iptv-org/database/actions/runs/${{ github.run_id }}) workflow." -m "${{ steps.issue-process.outputs.OUTPUT }}" --no-verify
- run: git status
- name: commit changes
if: steps.db-update.outputs.OUTPUT != 0
run: |
git add data/*.csv
git status
git push
git commit -m "[Bot] Update data" -m "Committed by [iptv-bot](https://github.com/apps/iptv-bot) via [update](https://github.com/iptv-org/database/actions/runs/${{ github.run_id }}) workflow." -m "${{ steps.db-update.outputs.OUTPUT }}" --no-verify
- name: push all changes to the repository
if: ${{ !env.ACT && github.ref == 'refs/heads/master' }}
run: git push

10
.prettierrc.js Normal file
View file

@ -0,0 +1,10 @@
module.exports = {
tabWidth: 2,
useTabs: false,
endOfLine: 'lf',
semi: false,
singleQuote: true,
printWidth: 100,
trailingComma: 'none',
arrowParens: 'avoid'
}

View file

@ -1,41 +1,26 @@
# Contributing Guide
### How to add a channel to the database or edit its description?
1. Download the repository to your computer. The easiest way to do this is via [GitHub Desktop](https://desktop.github.com/).
2. Open [data/channels.csv](data/channels.csv) file in one of the spreadsheet editors (such as [Google Sheets](https://www.google.com/sheets/about/), [LibreOffice](https://www.libreoffice.org/discover/libreoffice/), ...).
3. Make the necessary changes and save the file.
4. Make a [pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests) with all changes. This can also be done via [GitHub Desktop](https://desktop.github.com/).
**IMPORTANT:** Since different programs process CSV files differently before publishing an edited file, please make sure that:
- no extra columns (commas) were added to the file
- only [CRLF](https://developer.mozilla.org/en-US/docs/Glossary/CRLF) is used to indicate the end of a line
- no empty lines at the end of the file
- [Data Scheme](#data-scheme)
- [Channel Logo Guidelines](#channel-logo-guidelines)
- [Project Structure](#project-structure)
- [Scripts](#scripts)
- [Workflows](#workflows)
## Data Scheme
- [channels](#channels)
- [categories](#categories)
- [countries](#countries)
- [languages](#languages)
- [regions](#regions)
- [subdivisions](#subdivisions)
- [blocklist](#blocklist)
### channels
| Field | Description | Required | Example |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------------------------ |
| id | Unique channel ID derived from the `name` and `country` separated by dot. May only contain Latin letters, numbers and dot. | Required | `AnhuiTV.cn` |
| name | Official channel name in English or call sign. May include: `a-z`, `0-9`, `space`, `-`, `!`, `:`, `&`, `.`, `+`, `'`, `/`, `»`, `#`, `%`, `°`, `$`, `@`, `?`. | Required | `Anhui TV` |
| alt_names | List of alternative channel names separated by `;`. May contain any characters except `,` and `"`. | Optional | `安徽卫视` |
| alt_names | List of alternative channel names separated by `;`. May contain any characters except `,` and `"`. | Optional | `安徽卫视;AHTV` |
| network | Network of which this channel is a part. May contain any characters except `,` and `"`. | Optional | `Anhui` |
| owners | List of channel owners separated by `;`. May contain any characters except `,` and `"`. | Optional | `China Central Television` |
| country | Country code from which the channel is transmitted. A list of all supported countries and their codes can be found in [data/countries.csv](data/countries.csv) | Required | `CN` |
| subdivision | Code of the subdivision (e.g., provinces or states) from which the broadcast is transmitted. A list of all supported subdivisions and their codes can be found in [data/subdivisions.csv](data/subdivisions.csv). | Optional | `CN-AH` |
| city | The name of the city in English from which the channel is broadcast. May contain any characters except `,` and `"`. | Optional | `Hefei` |
| broadcast_area | List of codes describing the broadcasting area of the channel separated by `;`. Any combination of `r/<region_code>`, `c/<country_code>`, `s/<subdivision_code>`. | Required | `c/CN;r/EUR` |
| broadcast_area | List of codes describing the broadcasting area of the channel separated by `;`. Any combination of `r/<region_code>`, `c/<country_code>`, `s/<subdivision_code>`. | Required | `c/CN;r/ASIA` |
| languages | List of languages in which the channel is broadcast separated by `;`. A list of all supported languages and their codes can be found in [data/languages.csv](data/languages.csv). | Required | `zho;eng` |
| categories | List of categories to which this channel belongs separated by `;`. A list of all supported categories can be found in [data/categories.csv](data/categories.csv). | Optional | `animation;kids` |
| is_nsfw | Indicates whether the channel broadcasts adult content (`TRUE` or `FALSE`). | Required | `FALSE` |
@ -118,3 +103,44 @@ Since finding a suitable logo for the channel is not always possible, this list
That way it's much easier to scale the logo later.
<img src="https://user-images.githubusercontent.com/7253922/235551815-b9925ac5-85ac-458a-bf0b-a9d57eb2547d.png" width="600"/>
## Project Structure
- `.github/`
- `ISSUE_TEMPLATE/`: issue templates for the repository.
- `workflows`: contains [GitHub actions](https://docs.github.com/en/actions/quickstart) workflows.
- `CODE_OF_CONDUCT.md`: rules you shouldn't break if you don't want to get banned.
- `.readme/`
- `preview.png`: image displayed in the `README.md`.
- `data/`: contains all data.
- `scripts/`: contains all scripts used in the repository.
- `tests/`: contains tests to check the scripts.
- `CONTRIBUTING.md`: file you are currently reading.
- `README.md`: project description displayed on the home page.
## Scripts
These scripts are created to automate routine processes in the repository and make it a bit easier to maintain.
For scripts to work, you must have [Node.js](https://nodejs.org/en) installed on your computer.
To run scripts use the `npm run <script-name>` command.
- `act:check`: allows to run the [check](https://github.com/iptv-org/iptv/blob/master/.github/workflows/check.yml) workflow locally. Depends on [nektos/act](https://github.com/nektos/act).
- `act:update`: allows to run the [update](https://github.com/iptv-org/iptv/blob/master/.github/workflows/update.yml) workflow locally. Depends on [nektos/act](https://github.com/nektos/act).
- `act:deploy`: allows to run the [deploy](https://github.com/iptv-org/iptv/blob/master/.github/workflows/deploy.yml) workflow locally. Depends on [nektos/act](https://github.com/nektos/act).
- `db:validate`: checks the integrity of data.
- `db:export`: saves all data in JSON format to the `/.api` folder.
- `db:update`: triggers a data update using approved requests from issues.
- `lint`: сhecks the scripts for syntax errors.
- `test`: runs a test of all the scripts described above.
## Workflows
To automate the run of the scripts described above, we use the [GitHub Actions workflows](https://docs.github.com/en/actions/using-workflows).
Each workflow includes its own set of scripts that can be run either manually or in response to an event.
- `check`: runs the `db:validate` script when a new pull request appears, and blocks the merge if it detects an error in it.
- `update`: sequentially runs `db:update` and `db:validate` scripts and commits all the changes if successful.
- `deploy`: after each update of the [master](https://github.com/iptv-org/database/branches) branch runs the script `db:export` and then publishes the resulting files to the [iptv-org/api](https://github.com/iptv-org/api) repository.

View file

@ -6,9 +6,31 @@ User editable database for TV channels.
All data is stored in the [/data](data) folder as [CSV](https://en.wikipedia.org/wiki/Comma-separated_values) (Comma-separated values) files and can be edited with any spreadsheet editor (such as [Google Sheets](https://www.google.com/sheets/about/), [LibreOffice](https://www.libreoffice.org/discover/libreoffice/), ...).
## API
All data is also available through the API, documentation for which can be found in the [iptv-org/api](https://github.com/iptv-org/api) repository.
## Resources
Links to other useful IPTV-related resources can be found in the [iptv-org/awesome-iptv](https://github.com/iptv-org/awesome-iptv) repository.
## Discussions
If you have a question or an idea, you can post it in the [Discussions](https://github.com/orgs/iptv-org/discussions) tab.
## Contribution
Please make sure to read the [Contributing Guide](CONTRIBUTING.md) before sending an issue or making a pull request.
Please make sure to read the [Contributing Guide](https://github.com/iptv-org/epg/blob/master/CONTRIBUTING.md) before sending [issue](https://github.com/iptv-org/epg/issues) or a [pull request](https://github.com/iptv-org/epg/pulls).
And thank you to everyone who has already contributed!
### Backers
<a href="https://opencollective.com/iptv-org"><img src="https://opencollective.com/iptv-org/backers.svg?width=890" /></a>
### Contributors
<a href="https://github.com/iptv-org/epg/graphs/contributors"><img src="https://opencollective.com/iptv-org/contributors.svg?width=890" /></a>
## License

View file

@ -333,6 +333,7 @@ HGTVBrazil.br,https://github.com/iptv-org/iptv/issues/1831
HGTVCanada.ca,https://github.com/iptv-org/iptv/issues/1831
HGTVEast.us,https://github.com/iptv-org/iptv/issues/1831
HGTVGermany.us,https://github.com/iptv-org/iptv/issues/1831
HGTVHungary.hu,https://github.com/iptv-org/iptv/issues/1831
HGTVItaly.it,https://github.com/iptv-org/iptv/issues/1831
HGTVNewZealand.nz,https://github.com/iptv-org/iptv/issues/1831
HGTVPanregional.us,https://github.com/iptv-org/iptv/issues/1831
@ -393,6 +394,7 @@ Motortrend.us,https://github.com/iptv-org/iptv/issues/1831
N1BosniaHerzegovina.ba,https://github.com/iptv-org/iptv/issues/6486
N1Croatia.hr,https://github.com/iptv-org/iptv/issues/6486
N1Serbia.rs,https://github.com/iptv-org/iptv/issues/6486
Nova.bg,https://github.com/iptv-org/iptv/issues/6486
Novacinema1.gr,https://github.com/iptv-org/iptv/issues/6486
Novacinema2.gr,https://github.com/iptv-org/iptv/issues/6486
Novacinema3.gr,https://github.com/iptv-org/iptv/issues/6486
@ -415,7 +417,6 @@ Novasports4.gr,https://github.com/github/dmca/blob/master/2020/09/2020-09-16-dfl
Novasports4.gr,https://github.com/iptv-org/iptv/issues/6486
Novasports5.gr,https://github.com/github/dmca/blob/master/2020/09/2020-09-16-dfl.md
Novasports6.gr,https://github.com/github/dmca/blob/master/2020/09/2020-09-16-dfl.md
Nova.bg,https://github.com/iptv-org/iptv/issues/6486
NovaTV.hr,https://github.com/iptv-org/iptv/issues/6486
Nove.it,https://github.com/iptv-org/iptv/issues/1831
One.il,https://github.com/github/dmca/blob/master/2020/09/2020-09-16-dfl.md

1 channel ref
333 HGTVCanada.ca https://github.com/iptv-org/iptv/issues/1831
334 HGTVEast.us https://github.com/iptv-org/iptv/issues/1831
335 HGTVGermany.us https://github.com/iptv-org/iptv/issues/1831
336 HGTVHungary.hu https://github.com/iptv-org/iptv/issues/1831
337 HGTVItaly.it https://github.com/iptv-org/iptv/issues/1831
338 HGTVNewZealand.nz https://github.com/iptv-org/iptv/issues/1831
339 HGTVPanregional.us https://github.com/iptv-org/iptv/issues/1831
394 N1BosniaHerzegovina.ba https://github.com/iptv-org/iptv/issues/6486
395 N1Croatia.hr https://github.com/iptv-org/iptv/issues/6486
396 N1Serbia.rs https://github.com/iptv-org/iptv/issues/6486
397 Nova.bg https://github.com/iptv-org/iptv/issues/6486
398 Novacinema1.gr https://github.com/iptv-org/iptv/issues/6486
399 Novacinema2.gr https://github.com/iptv-org/iptv/issues/6486
400 Novacinema3.gr https://github.com/iptv-org/iptv/issues/6486
417 Novasports4.gr https://github.com/iptv-org/iptv/issues/6486
418 Novasports5.gr https://github.com/github/dmca/blob/master/2020/09/2020-09-16-dfl.md
419 Novasports6.gr https://github.com/github/dmca/blob/master/2020/09/2020-09-16-dfl.md
Nova.bg https://github.com/iptv-org/iptv/issues/6486
420 NovaTV.hr https://github.com/iptv-org/iptv/issues/6486
421 Nove.it https://github.com/iptv-org/iptv/issues/1831
422 One.il https://github.com/github/dmca/blob/master/2020/09/2020-09-16-dfl.md

File diff suppressed because it is too large Load diff

11137
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -2,46 +2,45 @@
"name": "@iptv-org/database",
"scripts": {
"act:check": "act pull_request -W .github/workflows/check.yml",
"act:update": "act workflow_dispatch -W .github/workflows/update.yml",
"act:deploy": "act push -W .github/workflows/deploy.yml",
"db:validate": "node scripts/db/validate.js",
"db:export": "node scripts/db/export.js",
"db:update": "node scripts/db/update.js"
"db:validate": "ts-node scripts/db/validate.ts",
"db:export": "ts-node scripts/db/export.ts",
"db:update": "ts-node scripts/db/update.ts",
"lint": "npx eslint ./scripts/**/*.ts ./scripts/**/*.js ./tests/**/*.ts",
"test": "jest --runInBand"
},
"pre-commit": [
"db:validate"
],
"private": true,
"author": "Arhey",
"jest": {
"transform": {
"^.+\\.(ts|js)$": "ts-jest"
},
"testRegex": "tests/(.*?/)?.*test.(js|ts)$"
},
"dependencies": {
"@freearhey/core": "^0.2.1",
"@joi/date": "^2.1.0",
"@json2csv/formatters": "^7.0.3",
"@json2csv/node": "^7.0.3",
"@json2csv/transforms": "^7.0.3",
"@octokit/core": "^4.2.0",
"@octokit/plugin-paginate-rest": "^6.0.0",
"autocrop-js": "^0.2.0",
"axios": "^0.25.0",
"@octokit/plugin-rest-endpoint-methods": "^7.1.3",
"@types/jest": "^29.5.5",
"@typescript-eslint/eslint-plugin": "^6.7.4",
"chalk": "^4.1.2",
"cheerio": "^1.0.0-rc.10",
"cli-progress": "^3.11.2",
"commander": "^9.0.0",
"crlf": "^1.1.1",
"crypto": "^1.0.1",
"csvtojson": "^2.0.10",
"dayjs": "^1.11.0",
"form-data": "^4.0.0",
"glob": "^7.2.0",
"iso-639-2": "^3.0.1",
"eslint": "^8.50.0",
"eslint-config-prettier": "^9.0.0",
"jest": "^29.7.0",
"joi": "^17.6.0",
"json2csv": "^6.0.0-alpha.0",
"lodash": "^4.17.21",
"mz": "^2.7.0",
"node-cleanup": "^2.1.2",
"pre-commit": "^1.2.2",
"probe-image-size": "^7.2.3",
"sharp": "^0.31.1",
"signale": "^1.4.0",
"slugify": "^1.6.5",
"transliteration": "^2.2.0",
"wikijs": "^6.3.3",
"wtf_wikipedia": "^10.0.0",
"wtf-plugin-image": "^1.0.0"
"ts-jest": "^29.1.1",
"ts-node": "^10.9.1"
}
}

5
scripts/constants.ts Normal file
View file

@ -0,0 +1,5 @@
export const OWNER = 'iptv-org'
export const REPO = 'database'
export const DATA_DIR = process.env.DATA_DIR || './data'
export const API_DIR = process.env.API_DIR || './.api'
export const TESTING = process.env.NODE_ENV === 'test' ? true : false

View file

@ -1,105 +0,0 @@
const csv2json = require('csvtojson')
const chalk = require('chalk')
const logger = require('./logger')
const fs = require('mz/fs')
const {
Parser,
transforms: { flatten },
formatters: { stringQuoteOnlyIfNecessary }
} = require('json2csv')
const csv2jsonOptions = {
checkColumn: true,
trim: true,
delimiter: ',',
eol: '\r\n',
colParser: {
alt_names: listParser,
network: nullable,
owners: listParser,
subdivision: nullable,
city: nullable,
broadcast_area: listParser,
languages: listParser,
categories: listParser,
is_nsfw: boolParser,
launched: nullable,
closed: nullable,
replaced_by: nullable,
website: nullable,
logo: nullable,
countries: listParser
}
}
const json2csv = new Parser({
transforms: [flattenArray, formatBool],
formatters: {
string: stringQuoteOnlyIfNecessary()
},
eol: '\r\n'
})
const csv = {}
csv.fromFile = async function (filepath) {
return csv2json(csv2jsonOptions).fromFile(filepath)
}
csv.fromString = async function (filepath) {
return csv2json(csv2jsonOptions).fromString(filepath)
}
csv.save = async function (filepath, data) {
const string = json2csv.parse(data)
return fs.writeFile(filepath, string)
}
csv.saveSync = function (filepath, data) {
const string = json2csv.parse(data)
return fs.writeFileSync(filepath, string)
}
module.exports = csv
function flattenArray(item) {
for (let prop in item) {
const value = item[prop]
item[prop] = Array.isArray(value) ? value.join(';') : value
}
return item
}
function formatBool(item) {
for (let prop in item) {
if (item[prop] === false) {
item[prop] = 'FALSE'
} else if (item[prop] === true) {
item[prop] = 'TRUE'
}
}
return item
}
function listParser(value) {
return value.split(';').filter(i => i)
}
function boolParser(value) {
switch (value) {
case 'TRUE':
return true
case 'FALSE':
return false
default:
return value
}
}
function nullable(value) {
return value === '' ? null : value
}

44
scripts/core/csv.ts Normal file
View file

@ -0,0 +1,44 @@
import { Collection } from '@freearhey/core'
import { Parser } from '@json2csv/plainjs'
import { stringQuoteOnlyIfNecessary } from '@json2csv/formatters'
export class CSV {
items: Collection
constructor({ items }: { items: Collection }) {
this.items = items
}
toString(): string {
const parser = new Parser({
transforms: [flattenArray, formatBool],
formatters: {
string: stringQuoteOnlyIfNecessary()
},
eol: '\r\n'
})
return parser.parse(this.items.all())
}
}
function flattenArray(item: { [key: string]: string[] | string | boolean }) {
for (const prop in item) {
const value = item[prop]
item[prop] = Array.isArray(value) ? value.join(';') : value
}
return item
}
function formatBool(item: { [key: string]: string[] | string | boolean }) {
for (const prop in item) {
if (item[prop] === false) {
item[prop] = 'FALSE'
} else if (item[prop] === true) {
item[prop] = 'TRUE'
}
}
return item
}

53
scripts/core/csvParser.ts Normal file
View file

@ -0,0 +1,53 @@
import { Collection } from '@freearhey/core'
import csv2json from 'csvtojson'
const opts = {
checkColumn: true,
trim: true,
delimiter: ',',
eol: '\r\n',
colParser: {
alt_names: listParser,
network: nullable,
owners: listParser,
subdivision: nullable,
city: nullable,
broadcast_area: listParser,
languages: listParser,
categories: listParser,
is_nsfw: boolParser,
launched: nullable,
closed: nullable,
replaced_by: nullable,
website: nullable,
logo: nullable,
countries: listParser
}
}
export class CSVParser {
async parse(data: string): Promise<Collection> {
const items = await csv2json(opts).fromString(data)
return new Collection(items)
}
}
function listParser(value: string) {
return value.split(';').filter(i => i)
}
function boolParser(value: string) {
switch (value) {
case 'TRUE':
return true
case 'FALSE':
return false
default:
return value
}
}
function nullable(value: string) {
return value === '' ? null : value
}

View file

@ -1,78 +0,0 @@
const path = require('path')
const glob = require('glob')
const fs = require('mz/fs')
const crlf = require('crlf')
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(() => file.write(filepath, data))
.catch(console.error)
}
file.write = function (filepath, data = '') {
return fs.writeFile(path.resolve(filepath), data, { encoding: 'utf8' }).catch(console.error)
}
file.clear = async function (filepath) {
if (await file.exists(filepath)) return file.write(filepath, '')
return true
}
file.resolve = function (filepath) {
return path.resolve(filepath)
}
file.dirname = function (filepath) {
return path.dirname(filepath)
}
file.basename = function (filepath) {
return path.basename(filepath)
}
file.eol = function (filepath) {
return new Promise((resolve, reject) => {
crlf.get(filepath, null, function (err, endingType) {
if (err) reject(err)
resolve(endingType)
})
})
}
module.exports = file

17
scripts/core/idCreator.ts Normal file
View file

@ -0,0 +1,17 @@
export class IDCreator {
create(name: string, country: string): string {
const slug = normalize(name)
const code = country.toLowerCase()
return `${slug}.${code}`
}
}
function normalize(name: string) {
return name
.replace(/^@/gi, 'At')
.replace(/^&/i, 'And')
.replace(/\+/gi, 'Plus')
.replace(/\s-(\d)/gi, ' Minus$1')
.replace(/[^a-z\d]+/gi, '')
}

View file

@ -1,3 +0,0 @@
exports.csv = require('./csv')
exports.file = require('./file')
exports.logger = require('./logger')

5
scripts/core/index.ts Normal file
View file

@ -0,0 +1,5 @@
export * from './csv'
export * from './issueParser'
export * from './issueLoader'
export * from './csvParser'
export * from './idCreator'

View file

@ -0,0 +1,49 @@
import { Collection } from '@freearhey/core'
import { restEndpointMethods } from '@octokit/plugin-rest-endpoint-methods'
import { paginateRest } from '@octokit/plugin-paginate-rest'
import { Octokit } from '@octokit/core'
import { IssueParser } from './'
import { TESTING, OWNER, REPO } from '../constants'
const CustomOctokit = Octokit.plugin(paginateRest, restEndpointMethods)
const octokit = new CustomOctokit()
export class IssueLoader {
async load({ labels }: { labels: string[] | string }) {
labels = Array.isArray(labels) ? labels.join(',') : labels
let issues: object[] = []
if (TESTING) {
switch (labels) {
case 'channels:add,approved':
issues = require('../../tests/__data__/input/issues/channels_add_approved.js')
break
case 'channels:edit,approved':
issues = require('../../tests/__data__/input/issues/channels_edit_approved.js')
break
case 'channels:remove,approved':
issues = require('../../tests/__data__/input/issues/channels_remove_approved.js')
break
case 'blocklist:add,approved':
issues = require('../../tests/__data__/input/issues/blocklist_add_approved.js')
break
case 'blocklist:remove,approved':
issues = require('../../tests/__data__/input/issues/blocklist_remove_approved.js')
break
}
} else {
issues = await octokit.paginate(octokit.rest.issues.listForRepo, {
owner: OWNER,
repo: REPO,
per_page: 100,
labels,
headers: {
'X-GitHub-Api-Version': '2022-11-28'
}
})
}
const parser = new IssueParser()
return new Collection(issues).map(parser.parse)
}
}

View file

@ -0,0 +1,66 @@
import { Dictionary } from '@freearhey/core'
import { Issue } from '../models'
const FIELDS = new Dictionary({
'Channel ID': 'channel_id',
'Channel ID (required)': 'channel_id',
'Channel ID (optional)': 'channel_id',
'Channel Name': 'name',
'Alternative Names': 'alt_names',
'Alternative Names (optional)': 'alt_names',
Network: 'network',
'Network (optional)': 'network',
Owners: 'owners',
'Owners (optional)': 'owners',
Country: 'country',
Subdivision: 'subdivision',
'Subdivision (optional)': 'subdivision',
City: 'city',
'City (optional)': 'city',
'Broadcast Area': 'broadcast_area',
Languages: 'languages',
Categories: 'categories',
'Categories (optional)': 'categories',
NSFW: 'is_nsfw',
Launched: 'launched',
'Launched (optional)': 'launched',
Closed: 'closed',
'Closed (optional)': 'closed',
'Replaced By': 'replaced_by',
'Replaced By (optional)': 'replaced_by',
Website: 'website',
'Website (optional)': 'website',
Logo: 'logo',
Reason: 'reason',
Notes: 'notes',
'Notes (optional)': 'notes',
Reference: 'ref',
'Reference (optional)': 'ref',
'Reference (required)': 'ref'
})
export class IssueParser {
parse(issue: { number: number; body: string; labels: { name: string }[] }): Issue {
const fields = issue.body.split('###')
const data = new Dictionary()
fields.forEach((field: string) => {
let [_label, , _value] = field.split(/\r?\n/)
_label = _label ? _label.trim() : ''
_value = _value ? _value.trim() : ''
if (!_label || !_value) return data
const id: string = FIELDS.get(_label)
const value: string = _value === '_No response_' || _value === 'None' ? '' : _value
if (!id) return
data.set(id, value)
})
const labels = issue.labels.map(label => label.name)
return new Issue({ number: issue.number, labels, data })
}
}

View file

@ -1,13 +0,0 @@
const { Signale } = require('signale')
const options = {}
const logger = new Signale(options)
logger.config({
displayLabel: false,
displayScope: false,
displayBadge: false
})
module.exports = logger

View file

@ -1,19 +0,0 @@
const { csv, file, logger } = require('../core')
const chalk = require('chalk')
const DATA_DIR = process.env.DATA_DIR || './data'
const OUTPUT_DIR = process.env.OUTPUT_DIR || './.api'
async function main() {
const files = await file.list(`${DATA_DIR}/*.csv`)
for (const filepath of files) {
const filename = file.getFilename(filepath)
const json = await csv.fromFile(filepath).catch(err => {
logger.error(chalk.red(`\n${err.message} (${filepath})`))
process.exit(1)
})
await file.create(`${OUTPUT_DIR}/${filename}.json`, JSON.stringify(json))
}
}
main()

21
scripts/db/export.ts Normal file
View file

@ -0,0 +1,21 @@
import { Storage, File } from '@freearhey/core'
import { DATA_DIR, API_DIR } from '../constants'
import { CSVParser } from '../core'
async function main() {
const dataStorage = new Storage(DATA_DIR)
const apiStorage = new Storage(API_DIR)
const parser = new CSVParser()
const files = await dataStorage.list('*.csv')
for (const filepath of files) {
const file = new File(filepath)
const filename = file.name()
const data = await dataStorage.load(file.basename())
const items = await parser.parse(data)
await apiStorage.save(`${filename}.json`, items.toJSON())
}
}
main()

View file

@ -1,172 +0,0 @@
const { csv, file } = require('../core')
const channelScheme = require('../db/schemes/channels')
const { Octokit } = require('@octokit/core')
const { paginateRest } = require('@octokit/plugin-paginate-rest')
const CustomOctokit = Octokit.plugin(paginateRest)
const _ = require('lodash')
const octokit = new CustomOctokit()
const DATA_DIR = process.env.DATA_DIR || './data'
const OWNER = 'iptv-org'
const REPO = 'database'
let channels = []
let processedIssues = []
async function main() {
try {
const filepath = `${DATA_DIR}/channels.csv`
channels = await csv.fromFile(filepath)
await removeChannels()
await editChannels()
await addChannels()
channels = _.orderBy(channels, [channels => channels.id.toLowerCase()], ['asc'])
await csv.save(filepath, channels)
const output = processedIssues.map(issue => `closes #${issue.number}`).join(', ')
console.log(`OUTPUT=${output}`)
} catch (err) {
console.log(err.message)
}
}
main()
async function removeChannels() {
const issues = await fetchIssues('channels:remove,approved')
issues.map(parseIssue).forEach(({ issue, channel }) => {
if (!channel) return
const index = _.findIndex(channels, { id: channel.id })
if (index < 0) return
channels.splice(index, 1)
processedIssues.push(issue)
})
}
async function editChannels() {
const issues = await fetchIssues('channels:edit,approved')
issues.map(parseIssue).forEach(({ issue, channel }) => {
if (!channel) return
const index = _.findIndex(channels, { id: channel.id })
if (index < 0) return
const found = channels[index]
for (let prop in channel) {
if (channel[prop] !== undefined) {
found[prop] = channel[prop]
}
}
found.id = generateChannelId(found.name, found.country)
channels.splice(index, 1, found)
processedIssues.push(issue)
})
}
async function addChannels() {
const issues = await fetchIssues('channels:add,approved')
issues.map(parseIssue).forEach(({ issue, channel }) => {
if (!channel) return
const found = channels.find(c => c.id === channel.id)
if (found) return
channels.push(channel)
processedIssues.push(issue)
})
}
async function fetchIssues(labels) {
const issues = await octokit.paginate('GET /repos/{owner}/{repo}/issues', {
owner: OWNER,
repo: REPO,
per_page: 100,
labels,
headers: {
'X-GitHub-Api-Version': '2022-11-28'
}
})
return issues
}
function parseIssue(issue) {
const buffer = {}
const channel = {}
const fieldLabels = {
'Channel ID (required)': 'id',
'Channel Name': 'name',
'Alternative Names': 'alt_names',
'Alternative Names (optional)': 'alt_names',
Network: 'network',
'Network (optional)': 'network',
Owners: 'owners',
'Owners (optional)': 'owners',
Country: 'country',
Subdivision: 'subdivision',
'Subdivision (optional)': 'subdivision',
City: 'city',
'City (optional)': 'city',
'Broadcast Area': 'broadcast_area',
Languages: 'languages',
Categories: 'categories',
'Categories (optional)': 'categories',
NSFW: 'is_nsfw',
Launched: 'launched',
'Launched (optional)': 'launched',
Closed: 'closed',
'Closed (optional)': 'closed',
'Replaced By': 'replaced_by',
'Replaced By (optional)': 'replaced_by',
Website: 'website',
'Website (optional)': 'website',
Logo: 'logo'
}
const fields = issue.body.split('###')
if (!fields.length) return { issue, channel: null }
fields.forEach(item => {
const [fieldLabel, , value] = item.split(/\r?\n/)
const field = fieldLabel ? fieldLabels[fieldLabel.trim()] : null
if (!field) return
buffer[field] = value.includes('_No response_') ? undefined : value.trim()
})
for (let field of Object.keys(channelScheme)) {
channel[field] = buffer[field]
}
if (!channel.id) {
channel.id = generateChannelId(channel.name, channel.country)
}
return { issue, channel }
}
function generateChannelId(name, country) {
if (name && country) {
const slug = name
.replace(/\+/gi, 'Plus')
.replace(/^@/gi, 'At')
.replace(/[^a-z\d]+/gi, '')
country = country.toLowerCase()
return `${slug}.${country}`
}
return null
}

185
scripts/db/update.ts Normal file
View file

@ -0,0 +1,185 @@
import { CSV, IssueLoader, CSVParser, IDCreator } from '../core'
import { Channel, Blocked, Issue } from '../models'
import { DATA_DIR } from '../constants'
import { Storage, Collection } from '@freearhey/core'
let blocklist = new Collection()
let channels = new Collection()
const processedIssues = new Collection()
async function main() {
const idCreator = new IDCreator()
const dataStorage = new Storage(DATA_DIR)
const parser = new CSVParser()
const _channels = await dataStorage.load('channels.csv')
channels = (await parser.parse(_channels)).map(data => new Channel(data))
const _blocklist = await dataStorage.load('blocklist.csv')
blocklist = (await parser.parse(_blocklist)).map(data => new Blocked(data))
const loader = new IssueLoader()
await removeChannels({ loader })
await editChannels({ loader, idCreator })
await addChannels({ loader, idCreator })
await blockChannels({ loader })
await unblockChannels({ loader })
channels = sortBy(channels, 'id')
const channelsOutput = new CSV({ items: channels }).toString()
await dataStorage.save('channels.csv', channelsOutput)
blocklist = sortBy(blocklist, 'channel')
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()
function sortBy(channels: Collection, key: string) {
const items = channels.all().sort((a, b) => {
const normA = a[key].toLowerCase()
const normB = b[key].toLowerCase()
if (normA < normB) return -1
if (normA > normB) return 1
return 0
})
return new Collection(items)
}
async function removeChannels({ loader }: { loader: IssueLoader }) {
const issues = await loader.load({ labels: ['channels:remove,approved'] })
issues.forEach((issue: Issue) => {
if (issue.data.missing('channel_id')) return
const found = channels.first((channel: Channel) => channel.id === issue.data.get('channel_id'))
if (!found) return
channels.remove((channel: Channel) => channel.id === found.id)
processedIssues.push(issue)
})
}
async function editChannels({ loader, idCreator }: { loader: IssueLoader; idCreator: IDCreator }) {
const issues = await loader.load({ labels: ['channels:edit,approved'] })
issues.forEach((issue: Issue) => {
const data = issue.data
if (data.missing('channel_id')) return
const found: Channel = channels.first(
(channel: Channel) => channel.id === data.get('channel_id')
)
if (!found) return
let channelId = found.id
if (data.has('name') || data.has('country')) {
const name = data.get('name') || found.name
const country = data.get('country') || found.country
channelId = idCreator.create(name, country)
}
found.update({
id: channelId,
name: data.get('name'),
alt_names: data.get('alt_names'),
network: data.get('network'),
owners: data.get('owners'),
country: data.get('country'),
subdivision: data.get('subdivision'),
city: data.get('city'),
broadcast_area: data.get('broadcast_area'),
languages: data.get('languages'),
categories: data.get('categories'),
is_nsfw: data.get('is_nsfw'),
launched: data.get('launched'),
closed: data.get('closed'),
replaced_by: data.get('replaced_by'),
website: data.get('website'),
logo: data.get('logo')
})
processedIssues.push(issue)
})
}
async function addChannels({ loader, idCreator }: { loader: IssueLoader; idCreator: IDCreator }) {
const issues = await loader.load({ labels: ['channels:add,approved'] })
issues.forEach((issue: Issue) => {
const data = issue.data
if (data.missing('name') || data.missing('country')) return
const channelId = idCreator.create(data.get('name'), data.get('country'))
const found: Channel = channels.first((channel: Channel) => channel.id === channelId)
if (found) return
channels.push(
new Channel({
id: channelId,
name: data.get('name'),
alt_names: data.get('alt_names'),
network: data.get('network'),
owners: data.get('owners'),
country: data.get('country'),
subdivision: data.get('subdivision'),
city: data.get('city'),
broadcast_area: data.get('broadcast_area'),
languages: data.get('languages'),
categories: data.get('categories'),
is_nsfw: data.get('is_nsfw'),
launched: data.get('launched'),
closed: data.get('closed'),
replaced_by: data.get('replaced_by'),
website: data.get('website'),
logo: data.get('logo')
})
)
processedIssues.push(issue)
})
}
async function unblockChannels({ loader }: { loader: IssueLoader }) {
const issues = await loader.load({ labels: ['blocklist:remove,approved'] })
issues.forEach((issue: Issue) => {
const data = issue.data
if (data.missing('channel_id')) return
const found: Blocked = blocklist.first(
(blocked: Blocked) => blocked.channel === data.get('channel_id')
)
if (!found) return
blocklist.remove((blocked: Blocked) => blocked.channel === found.channel)
processedIssues.push(issue)
})
}
async function blockChannels({ loader }: { loader: IssueLoader }) {
const issues = await loader.load({ labels: ['blocklist:add,approved'] })
issues.forEach((issue: Issue) => {
const data = issue.data
if (data.missing('channel_id')) return
const found: Blocked = blocklist.first(
(blocked: Blocked) => blocked.channel === data.get('channel_id')
)
if (found) return
blocklist.push(
new Blocked({
channel: data.get('channel_id'),
ref: data.get('ref')
})
)
processedIssues.push(issue)
})
}

View file

@ -1,334 +0,0 @@
const { transliterate } = require('transliteration')
const { logger, file, csv } = require('../core')
const { program } = require('commander')
const schemes = require('./schemes')
const chalk = require('chalk')
const Joi = require('joi')
const _ = require('lodash')
program.argument('[filepath]', 'Path to file to validate').parse(process.argv)
const allFiles = [
'data/blocklist.csv',
'data/categories.csv',
'data/channels.csv',
'data/countries.csv',
'data/languages.csv',
'data/regions.csv',
'data/subdivisions.csv'
]
let db = {}
let files = {}
async function main() {
let globalErrors = []
for (let filepath of allFiles) {
if (!filepath.endsWith('.csv')) continue
const csvString = await file.read(filepath)
if (/\s+$/.test(csvString))
return handleError(`Error: empty lines at the end of file not allowed (${filepath})`)
const rows = csvString.split(/\r\n/)
const headers = rows[0].split(',')
for (let [i, line] of rows.entries()) {
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 filename = file.getFilename(filepath)
let data = await csv
.fromString(csvString)
.catch(err => handleError(`${err.message} (${filepath})`))
let grouped
switch (filename) {
case 'blocklist':
grouped = _.keyBy(data, 'channel')
break
case 'categories':
case 'channels':
grouped = _.keyBy(data, 'id')
break
default:
grouped = _.keyBy(data, 'code')
break
}
db[filename] = grouped
files[filename] = data
}
const toCheck = program.args.length ? program.args : allFiles
for (const filepath of toCheck) {
const filename = file.getFilename(filepath)
if (!schemes[filename]) return handleError(`Error: "${filename}" scheme is missing`)
const rows = files[filename]
const rowsCopy = JSON.parse(JSON.stringify(rows))
let fileErrors = []
if (filename === 'channels') {
fileErrors = fileErrors.concat(findDuplicatesById(rowsCopy))
// fileErrors = fileErrors.concat(findDuplicatesByName(rowsCopy))
for (const [i, row] of rowsCopy.entries()) {
fileErrors = fileErrors.concat(validateChannelId(row, i))
fileErrors = fileErrors.concat(validateChannelBroadcastArea(row, i))
fileErrors = fileErrors.concat(validateChannelSubdivision(row, i))
fileErrors = fileErrors.concat(validateChannelCategories(row, i))
fileErrors = fileErrors.concat(validateChannelReplacedBy(row, i))
fileErrors = fileErrors.concat(validateChannelLanguages(row, i))
fileErrors = fileErrors.concat(validateChannelCountry(row, i))
}
} else if (filename === 'blocklist') {
for (const [i, row] of rowsCopy.entries()) {
fileErrors = fileErrors.concat(validateChannel(row, i))
}
} else if (filename === 'countries') {
for (const [i, row] of rowsCopy.entries()) {
fileErrors = fileErrors.concat(validateCountryLanguages(row, i))
}
} else if (filename === 'subdivisions') {
for (const [i, row] of rowsCopy.entries()) {
fileErrors = fileErrors.concat(validateSubdivisionCountry(row, i))
}
} else if (filename === 'regions') {
for (const [i, row] of rowsCopy.entries()) {
fileErrors = fileErrors.concat(validateRegionCountries(row, i))
}
}
const schema = Joi.object(schemes[filename])
rows.forEach((row, i) => {
const { error } = schema.validate(row, { abortEarly: false })
if (error) {
error.details.forEach(detail => {
fileErrors.push({ line: i + 2, message: detail.message })
})
}
})
if (fileErrors.length) {
logger.info(`\n${chalk.underline(filepath)}`)
fileErrors.forEach(err => {
const position = err.line.toString().padEnd(6, ' ')
logger.info(` ${chalk.gray(position)} ${err.message}`)
})
globalErrors = globalErrors.concat(fileErrors)
}
}
if (globalErrors.length) return handleError(`${globalErrors.length} error(s)`)
}
main()
function findDuplicatesById(rows) {
const errors = []
const buffer = {}
rows.forEach((row, i) => {
const normId = row.id.toLowerCase()
if (buffer[normId]) {
errors.push({
line: i + 2,
message: `entry with the id "${row.id}" already exists`
})
}
buffer[normId] = true
})
return errors
}
function findDuplicatesByName(rows) {
const errors = []
const buffer = {}
rows.forEach((row, i) => {
const normName = row.name.toLowerCase()
if (buffer[normName]) {
errors.push({
line: i + 2,
message: `entry with the name "${row.name}" already exists`
})
}
buffer[normName] = true
})
return errors
}
function validateChannelId(row, i) {
const errors = []
let name = normalize(row.name)
let code = row.country.toLowerCase()
let expected = `${name}.${code}`
if (expected !== 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}"`
})
}
function normalize(name) {
let translit = transliterate(name)
return translit
.replace(/^@/i, 'At')
.replace(/^&/i, 'And')
.replace(/\+/gi, 'Plus')
.replace(/\s\-(\d)/gi, ' Minus$1')
.replace(/[^a-z\d]+/gi, '')
}
return errors
}
function validateChannelCategories(row, i) {
const errors = []
row.categories.forEach(category => {
if (!db.categories[category]) {
errors.push({
line: i + 2,
message: `"${row.id}" has the wrong category "${category}"`
})
}
})
return errors
}
function validateChannelCountry(row, i) {
const errors = []
if (!db.countries[row.country]) {
errors.push({
line: i + 2,
message: `"${row.id}" has the wrong country "${row.country}"`
})
}
return errors
}
function validateChannelReplacedBy(row, i) {
const errors = []
if (row.replaced_by && !db.channels[row.replaced_by]) {
errors.push({
line: i + 2,
message: `"${row.id}" has the wrong replaced_by "${row.replaced_by}"`
})
}
return errors
}
function validateChannelSubdivision(row, i) {
const errors = []
if (row.subdivision && !db.subdivisions[row.subdivision]) {
errors.push({
line: i + 2,
message: `"${row.id}" has the wrong subdivision "${row.subdivision}"`
})
}
return errors
}
function validateChannelBroadcastArea(row, i) {
const errors = []
row.broadcast_area.forEach(area => {
const [type, code] = area.split('/')
if (
(type === 'r' && !db.regions[code]) ||
(type === 'c' && !db.countries[code]) ||
(type === 's' && !db.subdivisions[code])
) {
errors.push({
line: i + 2,
message: `"${row.id}" has the wrong broadcast_area "${area}"`
})
}
})
return errors
}
function validateChannelLanguages(row, i) {
const errors = []
row.languages.forEach(language => {
if (!db.languages[language]) {
errors.push({
line: i + 2,
message: `"${row.id}" has the wrong language "${language}"`
})
}
})
return errors
}
function validateChannel(row, i) {
const errors = []
if (!db.channels[row.channel]) {
errors.push({
line: i + 2,
message: `"${row.channel}" is missing in the channels.csv`
})
}
return errors
}
function validateCountryLanguages(row, i) {
const errors = []
for (let lang of row.languages) {
if (!db.languages[lang]) {
errors.push({
line: i + 2,
message: `"${row.code}" has the wrong language "${lang}"`
})
}
}
return errors
}
function validateSubdivisionCountry(row, i) {
const errors = []
if (!db.countries[row.country]) {
errors.push({
line: i + 2,
message: `"${row.code}" has the wrong country "${row.country}"`
})
}
return errors
}
function validateRegionCountries(row, i) {
const errors = []
row.countries.forEach(country => {
if (!db.countries[country]) {
errors.push({
line: i + 2,
message: `"${row.code}" has the wrong country "${country}"`
})
}
})
return errors
}
function handleError(message) {
logger.error(chalk.red(`\n${message}`))
process.exit(1)
}

257
scripts/db/validate.ts Normal file
View file

@ -0,0 +1,257 @@
import { Collection, Storage, File, Dictionary, Logger } from '@freearhey/core'
import { DATA_DIR } from '../constants'
import { program } from 'commander'
import Joi from 'joi'
import { CSVParser, IDCreator } from '../core'
import chalk from 'chalk'
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 } = require('../schemes')
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())
if (/\s+$/.test(csv))
return handleError(`Error: empty lines at the end of file not allowed (${filepath})`)
const rows = csv.split(/\r\n/)
const headers = rows[0].split(',')
for (const [i, line] of rows.entries()) {
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()
let grouped
switch (filename) {
case 'blocklist':
grouped = data.keyBy(item => item.channel)
break
case 'categories':
case 'channels':
grouped = data.keyBy(item => item.id)
break
default:
grouped = data.keyBy(item => item.code)
break
}
buffer.set(filename, grouped)
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(validateChannelBroadcastArea(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', 'replaced_by', buffer.get('channels'))
)
fileErrors = fileErrors.concat(
checkValue(i, row, 'id', 'languages', buffer.get('languages'))
)
fileErrors = fileErrors.concat(
checkValue(i, row, 'id', 'country', buffer.get('countries'))
)
}
break
case 'blocklist':
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.forEach((row: string | string[] | boolean, i: number) => {
const { error } = schema.validate(row, { abortEarly: false })
if (error) {
error.details.forEach(detail => {
fileErrors.push({ line: i + 2, message: detail.message })
})
}
})
if (fileErrors.count()) {
logger.info(`\n${chalk.underline(filepath)}`)
fileErrors.forEach(err => {
const position = err.line.toString().padEnd(6, ' ')
logger.info(` ${chalk.gray(position)} ${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 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 findDuplicatesBy(rows: { [key: string]: string }[], key: string) {
const errors = new Collection()
const buffer = new Dictionary()
rows.forEach((row, i) => {
const normId = row[key].toLowerCase()
if (buffer.has(normId)) {
errors.push({
line: i + 2,
message: `entry with the ${key} "${row[key]}" already exists`
})
}
buffer.set(normId, true)
})
return errors
}
function validateChannelId(row: { [key: string]: string }, i: number) {
const errors = new Collection()
const expectedId = new IDCreator().create(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 handleError(message: string) {
logger.error(chalk.red(message))
process.exit(1)
}

14
scripts/models/blocked.ts Normal file
View file

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

85
scripts/models/channel.ts Normal file
View file

@ -0,0 +1,85 @@
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
}
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
constructor({
id,
name,
alt_names,
network,
owners,
country,
subdivision,
city,
broadcast_area,
languages,
categories,
is_nsfw,
launched,
closed,
replaced_by,
website,
logo
}: ChannelProps) {
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
}
update(data: { [key: string]: string }) {
for (const key in data) {
if (this[key] && data[key]) {
this[key] = data[key]
}
}
}
}

3
scripts/models/index.ts Normal file
View file

@ -0,0 +1,3 @@
export * from './channel'
export * from './issue'
export * from './blocked'

19
scripts/models/issue.ts Normal file
View file

@ -0,0 +1,19 @@
import { Dictionary } from '@freearhey/core'
type IssueProps = {
number: number
labels: string[]
data: Dictionary
}
export class Issue {
number: number
labels: string[]
data: Dictionary
constructor({ number, labels, data }: IssueProps) {
this.number = number
this.labels = labels
this.data = data
}
}

View file

@ -1,8 +1,8 @@
const Joi = require('joi')
module.exports = {
channel: Joi.string()
.regex(/^[A-Za-z0-9]+\.[a-z]{2}$/)
.required(),
ref: Joi.string().uri().required()
}
const Joi = require('joi')
module.exports = {
channel: Joi.string()
.regex(/^[A-Za-z0-9]+\.[a-z]{2}$/)
.required(),
ref: Joi.string().uri().required()
}

View file

@ -1,10 +1,10 @@
const Joi = require('joi')
module.exports = {
id: Joi.string()
.regex(/^[a-z]+$/)
.required(),
name: Joi.string()
.regex(/^[A-Z]+$/i)
.required()
}
const Joi = require('joi')
module.exports = {
id: Joi.string()
.regex(/^[a-z]+$/)
.required(),
name: Joi.string()
.regex(/^[A-Z]+$/i)
.required()
}

View file

@ -1,65 +1,65 @@
const Joi = require('joi').extend(require('@joi/date'))
const path = require('path')
const url = require('url')
module.exports = {
id: Joi.string()
.regex(/^[A-Za-z0-9]+\.[a-z]{2}$/)
.required(),
name: Joi.string()
.regex(/^[a-z0-9-!:&.+'/»#%°$@?\s]+$/i)
.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]{3,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}$/)
.allow(null),
website: Joi.string()
.uri({
scheme: ['http', 'https']
})
.allow(null),
logo: Joi.string()
.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()
}
const Joi = require('joi').extend(require('@joi/date'))
const path = require('path')
const url = require('url')
module.exports = {
id: Joi.string()
.regex(/^[A-Za-z0-9]+\.[a-z]{2}$/)
.required(),
name: Joi.string()
.regex(/^[a-z0-9-!:&.+'/»#%°$@?\s]+$/i)
.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]{3,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}$/)
.allow(null),
website: Joi.string()
.uri({
scheme: ['http', 'https']
})
.allow(null),
logo: Joi.string()
.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 +1,18 @@
const Joi = require('joi')
module.exports = {
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()
}
const Joi = require('joi')
module.exports = {
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,7 +1,7 @@
exports.channels = require('./channels')
exports.categories = require('./categories')
exports.countries = require('./countries')
exports.languages = require('./languages')
exports.regions = require('./regions')
exports.subdivisions = require('./subdivisions')
exports.blocklist = require('./blocklist')
exports.channels = require('./channels')
exports.categories = require('./categories')
exports.countries = require('./countries')
exports.languages = require('./languages')
exports.regions = require('./regions')
exports.subdivisions = require('./subdivisions')
exports.blocklist = require('./blocklist')

View file

@ -1,8 +1,8 @@
const Joi = require('joi')
module.exports = {
code: Joi.string()
.regex(/^[a-z]{3}$/)
.required(),
name: Joi.string().required()
}
const Joi = require('joi')
module.exports = {
code: Joi.string()
.regex(/^[a-z]{3}$/)
.required(),
name: Joi.string().required()
}

View file

@ -1,15 +1,15 @@
const Joi = require('joi')
module.exports = {
name: Joi.string()
.regex(/^[\sA-Z\u00C0-\u00FF().,-]+$/i)
.required(),
code: Joi.string()
.regex(/^[A-Z]{3,7}$/)
.required(),
countries: Joi.array().items(
Joi.string()
.regex(/^[A-Z]{2}$/)
.required()
)
}
const Joi = require('joi')
module.exports = {
name: Joi.string()
.regex(/^[\sA-Z\u00C0-\u00FF().,-]+$/i)
.required(),
code: Joi.string()
.regex(/^[A-Z]{3,7}$/)
.required(),
countries: Joi.array().items(
Joi.string()
.regex(/^[A-Z]{2}$/)
.required()
)
}

View file

@ -1,11 +1,11 @@
const Joi = require('joi')
module.exports = {
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()
}
const Joi = require('joi')
module.exports = {
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()
}

1
tests/__data__/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
output/

View file

@ -0,0 +1 @@
[{"channel":"AnimalPlanetAfrica.za","ref":"https://github.com/iptv-org/iptv/issues/1831"}]

View file

@ -0,0 +1 @@
[{"id":"002RadioTV.do","name":"002 Radio TV","alt_names":[],"network":null,"owners":[],"country":"DO","subdivision":null,"city":null,"broadcast_area":["c/DO"],"languages":["spa"],"categories":["general"],"is_nsfw":false,"launched":null,"closed":null,"replaced_by":null,"website":"https://www.002radio.com/","logo":"https://i.imgur.com/7oNe8xj.png"},{"id":"BeijingSatelliteTV.cn","name":"Beijing Satellite TV","alt_names":["北京卫视"],"network":null,"owners":[],"country":"CN","subdivision":null,"city":"Beijing","broadcast_area":["c/CN"],"languages":["zho"],"categories":["general"],"is_nsfw":false,"launched":"1979-05-16","closed":null,"replaced_by":null,"website":"https://www.brtn.cn/btv/","logo":"https://i.imgur.com/vsktAez.png"},{"id":"M5.hu","name":"M5","alt_names":[],"network":null,"owners":[],"country":"HU","subdivision":null,"city":null,"broadcast_area":["c/HU"],"languages":["hun"],"categories":[],"is_nsfw":false,"launched":null,"closed":null,"replaced_by":null,"website":"https://www.mediaklikk.hu/m5/","logo":"https://i.imgur.com/y21wFd0.png"}]

View file

@ -0,0 +1,2 @@
channel,ref
HGTVHungary.hu,https://github.com/iptv-org/iptv/issues/1831
1 channel ref
2 HGTVHungary.hu https://github.com/iptv-org/iptv/issues/1831

View file

@ -0,0 +1,6 @@
id,name,alt_names,network,owners,country,subdivision,city,broadcast_area,languages,categories,is_nsfw,launched,closed,replaced_by,website,logo
beINMoviesTurk.tr,beIN Movies Turk,beIN Movies Türk,,,TR,,Beijing,c/TR,tur,movies,FALSE,1979-05-16,,,http://www.digiturk.com.tr/,https://i.imgur.com/nw8Sa2z.png
M5.hu,M5,,,Duna Médiaszolgáltató Nonprofit Zrt.,HU,,,c/HU,hun,,FALSE,,,,https://www.mediaklikk.hu/m5/,https://i.imgur.com/y21wFd0.png
WenzhouEconomicandEducation.cn,Wenzhou Economic and Education,,,,CN,,Wenzhou,c/CN,zho,science,FALSE,,,,,https://www.tvchinese.net/uploads/tv/wzjjkj.jpg
YiwuBusinessChannel.cn,Yiwu Business Channel,,,,CN,,,c/CN,zho,business,FALSE,,,,,https://www.tvchinese.net/uploads/tv/yiwutv.jpg
YiwuNewsIntegratedChannel.cn,Yiwu News Integrated Channel,,,,CN,,,c/CN,zho,news,FALSE,,,,,https://www.tvchinese.net/uploads/tv/yiwutv.jpg
1 id name alt_names network owners country subdivision city broadcast_area languages categories is_nsfw launched closed replaced_by website logo
2 beINMoviesTurk.tr beIN Movies Turk beIN Movies Türk TR Beijing c/TR tur movies FALSE 1979-05-16 http://www.digiturk.com.tr/ https://i.imgur.com/nw8Sa2z.png
3 M5.hu M5 Duna Médiaszolgáltató Nonprofit Zrt. HU c/HU hun FALSE https://www.mediaklikk.hu/m5/ https://i.imgur.com/y21wFd0.png
4 WenzhouEconomicandEducation.cn Wenzhou Economic and Education CN Wenzhou c/CN zho science FALSE https://www.tvchinese.net/uploads/tv/wzjjkj.jpg
5 YiwuBusinessChannel.cn Yiwu Business Channel CN c/CN zho business FALSE https://www.tvchinese.net/uploads/tv/yiwutv.jpg
6 YiwuNewsIntegratedChannel.cn Yiwu News Integrated Channel CN c/CN zho news FALSE https://www.tvchinese.net/uploads/tv/yiwutv.jpg

View file

@ -0,0 +1,2 @@
channel,ref
AnimalPlanetAfrica.za,https://github.com/iptv-org/iptv/issues/1831
1 channel ref
2 AnimalPlanetAfrica.za https://github.com/iptv-org/iptv/issues/1831

View file

@ -0,0 +1,4 @@
id,name,alt_names,network,owners,country,subdivision,city,broadcast_area,languages,categories,is_nsfw,launched,closed,replaced_by,website,logo
002RadioTV.do,002 Radio TV,,,,DO,,,c/DO,spa,general,FALSE,,,,https://www.002radio.com/,https://i.imgur.com/7oNe8xj.png
BeijingSatelliteTV.cn,Beijing Satellite TV,北京卫视,,,CN,,Beijing,c/CN,zho,general,FALSE,1979-05-16,,,https://www.brtn.cn/btv/,https://i.imgur.com/vsktAez.png
M5.hu,M5,,,,HU,,,c/HU,hun,,FALSE,,,,https://www.mediaklikk.hu/m5/,https://i.imgur.com/y21wFd0.png
1 id name alt_names network owners country subdivision city broadcast_area languages categories is_nsfw launched closed replaced_by website logo
2 002RadioTV.do 002 Radio TV DO c/DO spa general FALSE https://www.002radio.com/ https://i.imgur.com/7oNe8xj.png
3 BeijingSatelliteTV.cn Beijing Satellite TV 北京卫视 CN Beijing c/CN zho general FALSE 1979-05-16 https://www.brtn.cn/btv/ https://i.imgur.com/vsktAez.png
4 M5.hu M5 HU c/HU hun FALSE https://www.mediaklikk.hu/m5/ https://i.imgur.com/y21wFd0.png

View file

@ -0,0 +1,81 @@
module.exports = [
{
url: 'https://api.github.com/repos/iptv-org/database/issues/5897',
repository_url: 'https://api.github.com/repos/iptv-org/database',
labels_url: 'https://api.github.com/repos/iptv-org/database/issues/5897/labels{/name}',
comments_url: 'https://api.github.com/repos/iptv-org/database/issues/5897/comments',
events_url: 'https://api.github.com/repos/iptv-org/database/issues/5897/events',
html_url: 'https://github.com/iptv-org/database/issues/5897',
id: 1929261634,
node_id: 'I_kwDOG1Kwp85y_jJC',
number: 5897,
title: 'Block: HGTV Hungary',
user: {
login: 'freearhey',
id: 7253922,
node_id: 'MDQ6VXNlcjcyNTM5MjI=',
avatar_url: 'https://avatars.githubusercontent.com/u/7253922?v=4',
gravatar_id: '',
url: 'https://api.github.com/users/freearhey',
html_url: 'https://github.com/freearhey',
followers_url: 'https://api.github.com/users/freearhey/followers',
following_url: 'https://api.github.com/users/freearhey/following{/other_user}',
gists_url: 'https://api.github.com/users/freearhey/gists{/gist_id}',
starred_url: 'https://api.github.com/users/freearhey/starred{/owner}{/repo}',
subscriptions_url: 'https://api.github.com/users/freearhey/subscriptions',
organizations_url: 'https://api.github.com/users/freearhey/orgs',
repos_url: 'https://api.github.com/users/freearhey/repos',
events_url: 'https://api.github.com/users/freearhey/events{/privacy}',
received_events_url: 'https://api.github.com/users/freearhey/received_events',
type: 'User',
site_admin: false
},
labels: [
{
id: 5366738347,
node_id: 'LA_kwDOG1Kwp88AAAABP-Htqw',
url: 'https://api.github.com/repos/iptv-org/database/labels/approved',
name: 'approved',
color: '85DDDE',
default: false,
description: ''
},
{
id: 6049155772,
node_id: 'LA_kwDOG1Kwp88AAAABaI7KvA',
url: 'https://api.github.com/repos/iptv-org/database/labels/blocklist:add',
name: 'blocklist:add',
color: 'e99695',
default: false,
description: 'Request to add a channel to the blocklist'
}
],
state: 'open',
locked: false,
assignee: null,
assignees: [],
milestone: null,
comments: 0,
created_at: '2023-10-06T00:35:32Z',
updated_at: '2023-10-06T00:35:32Z',
closed_at: null,
author_association: 'CONTRIBUTOR',
active_lock_reason: null,
body: '### Channel ID\n\nHGTVHungary.hu\n\n### Reference\n\nhttps://github.com/iptv-org/iptv/issues/1831\n\n### Notes (optional)\n\n_No response_',
reactions: {
url: 'https://api.github.com/repos/iptv-org/database/issues/5897/reactions',
total_count: 0,
'+1': 0,
'-1': 0,
laugh: 0,
hooray: 0,
confused: 0,
heart: 0,
rocket: 0,
eyes: 0
},
timeline_url: 'https://api.github.com/repos/iptv-org/database/issues/5897/timeline',
performed_via_github_app: null,
state_reason: null
}
]

View file

@ -0,0 +1,81 @@
module.exports = [
{
url: 'https://api.github.com/repos/iptv-org/database/issues/5891',
repository_url: 'https://api.github.com/repos/iptv-org/database',
labels_url: 'https://api.github.com/repos/iptv-org/database/issues/5891/labels{/name}',
comments_url: 'https://api.github.com/repos/iptv-org/database/issues/5891/comments',
events_url: 'https://api.github.com/repos/iptv-org/database/issues/5891/events',
html_url: 'https://github.com/iptv-org/database/issues/5891',
id: 1929261634,
node_id: 'I_kwDOG1Kwp85y_jJC',
number: 5891,
title: 'Unblock: Animal Planet Africa',
user: {
login: 'freearhey',
id: 7253922,
node_id: 'MDQ6VXNlcjcyNTM5MjI=',
avatar_url: 'https://avatars.githubusercontent.com/u/7253922?v=4',
gravatar_id: '',
url: 'https://api.github.com/users/freearhey',
html_url: 'https://github.com/freearhey',
followers_url: 'https://api.github.com/users/freearhey/followers',
following_url: 'https://api.github.com/users/freearhey/following{/other_user}',
gists_url: 'https://api.github.com/users/freearhey/gists{/gist_id}',
starred_url: 'https://api.github.com/users/freearhey/starred{/owner}{/repo}',
subscriptions_url: 'https://api.github.com/users/freearhey/subscriptions',
organizations_url: 'https://api.github.com/users/freearhey/orgs',
repos_url: 'https://api.github.com/users/freearhey/repos',
events_url: 'https://api.github.com/users/freearhey/events{/privacy}',
received_events_url: 'https://api.github.com/users/freearhey/received_events',
type: 'User',
site_admin: false
},
labels: [
{
id: 5366738347,
node_id: 'LA_kwDOG1Kwp88AAAABP-Htqw',
url: 'https://api.github.com/repos/iptv-org/database/labels/approved',
name: 'approved',
color: '85DDDE',
default: false,
description: ''
},
{
id: 6049155772,
node_id: 'LA_kwDOG1Kwp88AAAABaI7KvA',
url: 'https://api.github.com/repos/iptv-org/database/labels/blocklist:add',
name: 'blocklist:remove',
color: 'e99695',
default: false,
description: 'Request to remove a channel from the blocklist'
}
],
state: 'open',
locked: false,
assignee: null,
assignees: [],
milestone: null,
comments: 0,
created_at: '2023-10-06T00:35:32Z',
updated_at: '2023-10-06T00:35:32Z',
closed_at: null,
author_association: 'CONTRIBUTOR',
active_lock_reason: null,
body: '### Channel ID\n\nAnimalPlanetAfrica.za\n\n### Reason\n\nOther\n\n### Notes (optional)\n\n_No response_',
reactions: {
url: 'https://api.github.com/repos/iptv-org/database/issues/5891/reactions',
total_count: 0,
'+1': 0,
'-1': 0,
laugh: 0,
hooray: 0,
confused: 0,
heart: 0,
rocket: 0,
eyes: 0
},
timeline_url: 'https://api.github.com/repos/iptv-org/database/issues/5891/timeline',
performed_via_github_app: null,
state_reason: null
}
]

View file

@ -0,0 +1,239 @@
module.exports = [
{
url: 'https://api.github.com/repos/iptv-org/database/issues/5900',
repository_url: 'https://api.github.com/repos/iptv-org/database',
labels_url: 'https://api.github.com/repos/iptv-org/database/issues/5900/labels{/name}',
comments_url: 'https://api.github.com/repos/iptv-org/database/issues/5900/comments',
events_url: 'https://api.github.com/repos/iptv-org/database/issues/5900/events',
html_url: 'https://github.com/iptv-org/database/issues/5900',
id: 1929321995,
node_id: 'I_kwDOG1Kwp85y_x4L',
number: 5900,
title: 'Add: Yiwu News Integrated Channel',
user: {
login: 'AntiPontifex',
id: 81566772,
node_id: 'MDQ6VXNlcjgxNTY2Nzcy',
avatar_url: 'https://avatars.githubusercontent.com/u/81566772?v=4',
gravatar_id: '',
url: 'https://api.github.com/users/AntiPontifex',
html_url: 'https://github.com/AntiPontifex',
followers_url: 'https://api.github.com/users/AntiPontifex/followers',
following_url: 'https://api.github.com/users/AntiPontifex/following{/other_user}',
gists_url: 'https://api.github.com/users/AntiPontifex/gists{/gist_id}',
starred_url: 'https://api.github.com/users/AntiPontifex/starred{/owner}{/repo}',
subscriptions_url: 'https://api.github.com/users/AntiPontifex/subscriptions',
organizations_url: 'https://api.github.com/users/AntiPontifex/orgs',
repos_url: 'https://api.github.com/users/AntiPontifex/repos',
events_url: 'https://api.github.com/users/AntiPontifex/events{/privacy}',
received_events_url: 'https://api.github.com/users/AntiPontifex/received_events',
type: 'User',
site_admin: false
},
labels: [
{
id: 5303575699,
node_id: 'LA_kwDOG1Kwp88AAAABPB4kkw',
url: 'https://api.github.com/repos/iptv-org/database/labels/channels:add',
name: 'channels:add',
color: '017ff8',
default: false,
description: 'Request to add a channel into the database'
},
{
id: 5366738347,
node_id: 'LA_kwDOG1Kwp88AAAABP-Htqw',
url: 'https://api.github.com/repos/iptv-org/database/labels/approved',
name: 'approved',
color: '85DDDE',
default: false,
description: ''
}
],
state: 'open',
locked: false,
assignee: null,
assignees: [],
milestone: null,
comments: 0,
created_at: '2023-10-06T02:10:41Z',
updated_at: '2023-10-06T02:52:02Z',
closed_at: null,
author_association: 'CONTRIBUTOR',
active_lock_reason: null,
body: '### Channel Name\n\nYiwu News Integrated Channel\n\n### Alternative Names (optional)\n\n_No response_\n\n### Network (optional)\n\n_No response_\n\n### Owners (optional)\n\n_No response_\n\n### Country\n\nCN\n\n### Subdivision (optional)\n\n_No response_\n\n### City (optional)\n\n_No response_\n\n### Broadcast Area\n\nc/CN\n\n### Languages\n\nzho\n\n### Categories (optional)\n\nnews\n\n### NSFW\n\nFALSE\n\n### Launched (optional)\n\n_No response_\n\n### Closed (optional)\n\n_No response_\n\n### Replaced By (optional)\n\n_No response_\n\n### Website (optional)\n\n_No response_\n\n### Logo\n\nhttps://www.tvchinese.net/uploads/tv/yiwutv.jpg\n\n### Notes\n\n_No response_',
reactions: {
url: 'https://api.github.com/repos/iptv-org/database/issues/5900/reactions',
total_count: 0,
'+1': 0,
'-1': 0,
laugh: 0,
hooray: 0,
confused: 0,
heart: 0,
rocket: 0,
eyes: 0
},
timeline_url: 'https://api.github.com/repos/iptv-org/database/issues/5900/timeline',
performed_via_github_app: null,
state_reason: null
},
{
url: 'https://api.github.com/repos/iptv-org/database/issues/5899',
repository_url: 'https://api.github.com/repos/iptv-org/database',
labels_url: 'https://api.github.com/repos/iptv-org/database/issues/5899/labels{/name}',
comments_url: 'https://api.github.com/repos/iptv-org/database/issues/5899/comments',
events_url: 'https://api.github.com/repos/iptv-org/database/issues/5899/events',
html_url: 'https://github.com/iptv-org/database/issues/5899',
id: 1929318573,
node_id: 'I_kwDOG1Kwp85y_xCt',
number: 5899,
title: 'Add: Yiwu Business Channel',
user: {
login: 'AntiPontifex',
id: 81566772,
node_id: 'MDQ6VXNlcjgxNTY2Nzcy',
avatar_url: 'https://avatars.githubusercontent.com/u/81566772?v=4',
gravatar_id: '',
url: 'https://api.github.com/users/AntiPontifex',
html_url: 'https://github.com/AntiPontifex',
followers_url: 'https://api.github.com/users/AntiPontifex/followers',
following_url: 'https://api.github.com/users/AntiPontifex/following{/other_user}',
gists_url: 'https://api.github.com/users/AntiPontifex/gists{/gist_id}',
starred_url: 'https://api.github.com/users/AntiPontifex/starred{/owner}{/repo}',
subscriptions_url: 'https://api.github.com/users/AntiPontifex/subscriptions',
organizations_url: 'https://api.github.com/users/AntiPontifex/orgs',
repos_url: 'https://api.github.com/users/AntiPontifex/repos',
events_url: 'https://api.github.com/users/AntiPontifex/events{/privacy}',
received_events_url: 'https://api.github.com/users/AntiPontifex/received_events',
type: 'User',
site_admin: false
},
labels: [
{
id: 5303575699,
node_id: 'LA_kwDOG1Kwp88AAAABPB4kkw',
url: 'https://api.github.com/repos/iptv-org/database/labels/channels:add',
name: 'channels:add',
color: '017ff8',
default: false,
description: 'Request to add a channel into the database'
},
{
id: 5366738347,
node_id: 'LA_kwDOG1Kwp88AAAABP-Htqw',
url: 'https://api.github.com/repos/iptv-org/database/labels/approved',
name: 'approved',
color: '85DDDE',
default: false,
description: ''
}
],
state: 'open',
locked: false,
assignee: null,
assignees: [],
milestone: null,
comments: 0,
created_at: '2023-10-06T02:05:11Z',
updated_at: '2023-10-06T02:51:46Z',
closed_at: null,
author_association: 'CONTRIBUTOR',
active_lock_reason: null,
body: '### Channel Name\n\nYiwu Business Channel\n\n### Alternative Names (optional)\n\n_No response_\n\n### Network (optional)\n\n_No response_\n\n### Owners (optional)\n\n_No response_\n\n### Country\n\nCN\n\n### Subdivision (optional)\n\n_No response_\n\n### City (optional)\n\n_No response_\n\n### Broadcast Area\n\nc/CN\n\n### Languages\n\nzho\n\n### Categories (optional)\n\nbusiness\n\n### NSFW\n\nFALSE\n\n### Launched (optional)\n\n_No response_\n\n### Closed (optional)\n\n_No response_\n\n### Replaced By (optional)\n\n_No response_\n\n### Website (optional)\n\n_No response_\n\n### Logo\n\nhttps://www.tvchinese.net/uploads/tv/yiwutv.jpg\n\n### Notes\n\n_No response_',
reactions: {
url: 'https://api.github.com/repos/iptv-org/database/issues/5899/reactions',
total_count: 0,
'+1': 0,
'-1': 0,
laugh: 0,
hooray: 0,
confused: 0,
heart: 0,
rocket: 0,
eyes: 0
},
timeline_url: 'https://api.github.com/repos/iptv-org/database/issues/5899/timeline',
performed_via_github_app: null,
state_reason: null
},
{
url: 'https://api.github.com/repos/iptv-org/database/issues/5898',
repository_url: 'https://api.github.com/repos/iptv-org/database',
labels_url: 'https://api.github.com/repos/iptv-org/database/issues/5898/labels{/name}',
comments_url: 'https://api.github.com/repos/iptv-org/database/issues/5898/comments',
events_url: 'https://api.github.com/repos/iptv-org/database/issues/5898/events',
html_url: 'https://github.com/iptv-org/database/issues/5898',
id: 1929313117,
node_id: 'I_kwDOG1Kwp85y_vtd',
number: 5898,
title: 'Add: Wenzhou Economic and Education',
user: {
login: 'AntiPontifex',
id: 81566772,
node_id: 'MDQ6VXNlcjgxNTY2Nzcy',
avatar_url: 'https://avatars.githubusercontent.com/u/81566772?v=4',
gravatar_id: '',
url: 'https://api.github.com/users/AntiPontifex',
html_url: 'https://github.com/AntiPontifex',
followers_url: 'https://api.github.com/users/AntiPontifex/followers',
following_url: 'https://api.github.com/users/AntiPontifex/following{/other_user}',
gists_url: 'https://api.github.com/users/AntiPontifex/gists{/gist_id}',
starred_url: 'https://api.github.com/users/AntiPontifex/starred{/owner}{/repo}',
subscriptions_url: 'https://api.github.com/users/AntiPontifex/subscriptions',
organizations_url: 'https://api.github.com/users/AntiPontifex/orgs',
repos_url: 'https://api.github.com/users/AntiPontifex/repos',
events_url: 'https://api.github.com/users/AntiPontifex/events{/privacy}',
received_events_url: 'https://api.github.com/users/AntiPontifex/received_events',
type: 'User',
site_admin: false
},
labels: [
{
id: 5303575699,
node_id: 'LA_kwDOG1Kwp88AAAABPB4kkw',
url: 'https://api.github.com/repos/iptv-org/database/labels/channels:add',
name: 'channels:add',
color: '017ff8',
default: false,
description: 'Request to add a channel into the database'
},
{
id: 5366738347,
node_id: 'LA_kwDOG1Kwp88AAAABP-Htqw',
url: 'https://api.github.com/repos/iptv-org/database/labels/approved',
name: 'approved',
color: '85DDDE',
default: false,
description: ''
}
],
state: 'open',
locked: false,
assignee: null,
assignees: [],
milestone: null,
comments: 0,
created_at: '2023-10-06T01:56:32Z',
updated_at: '2023-10-06T02:51:22Z',
closed_at: null,
author_association: 'CONTRIBUTOR',
active_lock_reason: null,
body: '### Channel Name\n\nWenzhou Economic and Education\n\n### Alternative Names (optional)\n\n_No response_\n\n### Network (optional)\n\n_No response_\n\n### Owners (optional)\n\n_No response_\n\n### Country\n\nCN\n\n### Subdivision (optional)\n\n_No response_\n\n### City (optional)\n\nWenzhou\n\n### Broadcast Area\n\nc/CN\n\n### Languages\n\nzho\n\n### Categories (optional)\n\nscience\n\n### NSFW\n\nFALSE\n\n### Launched (optional)\n\n_No response_\n\n### Closed (optional)\n\n_No response_\n\n### Replaced By (optional)\n\n_No response_\n\n### Website (optional)\n\n_No response_\n\n### Logo\n\nhttps://www.tvchinese.net/uploads/tv/wzjjkj.jpg\n\n### Notes\n\n_No response_',
reactions: {
url: 'https://api.github.com/repos/iptv-org/database/issues/5898/reactions',
total_count: 0,
'+1': 0,
'-1': 0,
laugh: 0,
hooray: 0,
confused: 0,
heart: 0,
rocket: 0,
eyes: 0
},
timeline_url: 'https://api.github.com/repos/iptv-org/database/issues/5898/timeline',
performed_via_github_app: null,
state_reason: null
}
]

View file

@ -0,0 +1,160 @@
module.exports = [
{
url: 'https://api.github.com/repos/iptv-org/database/issues/5901',
repository_url: 'https://api.github.com/repos/iptv-org/database',
labels_url: 'https://api.github.com/repos/iptv-org/database/issues/5901/labels{/name}',
comments_url: 'https://api.github.com/repos/iptv-org/database/issues/5901/comments',
events_url: 'https://api.github.com/repos/iptv-org/database/issues/5901/events',
html_url: 'https://github.com/iptv-org/database/issues/5901',
id: 1929459171,
node_id: 'I_kwDOG1Kwp85zATXj',
number: 5901,
title: 'Edit: M5',
user: {
login: 'freearhey',
id: 7253922,
node_id: 'MDQ6VXNlcjcyNTM5MjI=',
avatar_url: 'https://avatars.githubusercontent.com/u/7253922?v=4',
gravatar_id: '',
url: 'https://api.github.com/users/freearhey',
html_url: 'https://github.com/freearhey',
followers_url: 'https://api.github.com/users/freearhey/followers',
following_url: 'https://api.github.com/users/freearhey/following{/other_user}',
gists_url: 'https://api.github.com/users/freearhey/gists{/gist_id}',
starred_url: 'https://api.github.com/users/freearhey/starred{/owner}{/repo}',
subscriptions_url: 'https://api.github.com/users/freearhey/subscriptions',
organizations_url: 'https://api.github.com/users/freearhey/orgs',
repos_url: 'https://api.github.com/users/freearhey/repos',
events_url: 'https://api.github.com/users/freearhey/events{/privacy}',
received_events_url: 'https://api.github.com/users/freearhey/received_events',
type: 'User',
site_admin: false
},
labels: [
{
id: 5303574335,
node_id: 'LA_kwDOG1Kwp88AAAABPB4fPw',
url: 'https://api.github.com/repos/iptv-org/database/labels/channels:edit',
name: 'channels:edit',
color: 'E12977',
default: false,
description: 'Request to edit channel description'
},
{
id: 5366738347,
node_id: 'LA_kwDOG1Kwp88AAAABP-Htqw',
url: 'https://api.github.com/repos/iptv-org/database/labels/approved',
name: 'approved',
color: '85DDDE',
default: false,
description: ''
}
],
state: 'open',
locked: false,
assignee: null,
assignees: [],
milestone: null,
comments: 0,
created_at: '2023-10-06T05:25:44Z',
updated_at: '2023-10-06T05:25:44Z',
closed_at: null,
author_association: 'CONTRIBUTOR',
active_lock_reason: null,
body: '### Channel ID (required)\n\nM5.hu\n\n### Channel Name\n\n_No response_\n\n### Alternative Names\n\n_No response_\n\n### Network\n\n_No response_\n\n### Owners\n\nDuna Médiaszolgáltató Nonprofit Zrt.\n\n### Country\n\n_No response_\n\n### Subdivision\n\n_No response_\n\n### City\n\n_No response_\n\n### Broadcast Area\n\n_No response_\n\n### Languages\n\n_No response_\n\n### Categories\n\n_No response_\n\n### NSFW\n\nFALSE\n\n### Launched\n\n_No response_\n\n### Closed\n\n_No response_\n\n### Replaced By\n\n_No response_\n\n### Website\n\n_No response_\n\n### Logo\n\n_No response_\n\n### Notes\n\n_No response_',
reactions: {
url: 'https://api.github.com/repos/iptv-org/database/issues/5901/reactions',
total_count: 0,
'+1': 0,
'-1': 0,
laugh: 0,
hooray: 0,
confused: 0,
heart: 0,
rocket: 0,
eyes: 0
},
timeline_url: 'https://api.github.com/repos/iptv-org/database/issues/5901/timeline',
performed_via_github_app: null,
state_reason: null
},
{
url: 'https://api.github.com/repos/iptv-org/database/issues/5701',
repository_url: 'https://api.github.com/repos/iptv-org/database',
labels_url: 'https://api.github.com/repos/iptv-org/database/issues/5701/labels{/name}',
comments_url: 'https://api.github.com/repos/iptv-org/database/issues/5701/comments',
events_url: 'https://api.github.com/repos/iptv-org/database/issues/5701/events',
html_url: 'https://github.com/iptv-org/database/issues/5701',
id: 1929459171,
node_id: 'I_kwDOG1Kwp85zATXj',
number: 5701,
title: 'Edit: M5',
user: {
login: 'freearhey',
id: 7253922,
node_id: 'MDQ6VXNlcjcyNTM5MjI=',
avatar_url: 'https://avatars.githubusercontent.com/u/7253922?v=4',
gravatar_id: '',
url: 'https://api.github.com/users/freearhey',
html_url: 'https://github.com/freearhey',
followers_url: 'https://api.github.com/users/freearhey/followers',
following_url: 'https://api.github.com/users/freearhey/following{/other_user}',
gists_url: 'https://api.github.com/users/freearhey/gists{/gist_id}',
starred_url: 'https://api.github.com/users/freearhey/starred{/owner}{/repo}',
subscriptions_url: 'https://api.github.com/users/freearhey/subscriptions',
organizations_url: 'https://api.github.com/users/freearhey/orgs',
repos_url: 'https://api.github.com/users/freearhey/repos',
events_url: 'https://api.github.com/users/freearhey/events{/privacy}',
received_events_url: 'https://api.github.com/users/freearhey/received_events',
type: 'User',
site_admin: false
},
labels: [
{
id: 5303574335,
node_id: 'LA_kwDOG1Kwp88AAAABPB4fPw',
url: 'https://api.github.com/repos/iptv-org/database/labels/channels:edit',
name: 'channels:edit',
color: 'E12977',
default: false,
description: 'Request to edit channel description'
},
{
id: 5366738347,
node_id: 'LA_kwDOG1Kwp88AAAABP-Htqw',
url: 'https://api.github.com/repos/iptv-org/database/labels/approved',
name: 'approved',
color: '85DDDE',
default: false,
description: ''
}
],
state: 'open',
locked: false,
assignee: null,
assignees: [],
milestone: null,
comments: 0,
created_at: '2023-10-06T05:25:44Z',
updated_at: '2023-10-06T05:25:44Z',
closed_at: null,
author_association: 'CONTRIBUTOR',
active_lock_reason: null,
body: '### Channel ID (required)\n\nBeijingSatelliteTV.cn\n\n### Channel Name\n\nbeIN Movies Turk\n\n### Alternative Names\n\nbeIN Movies Türk\n\n### Network\n\n_No response_\n\n### Owners\n\n_No response_\n\n### Country\n\nTR\n\n### Subdivision\n\n_No response_\n\n### City\n\n_No response_\n\n### Broadcast Area\n\nc/TR\n\n### Languages\n\ntur\n\n### Categories\n\nmovies\n\n### NSFW\n\nFALSE\n\n### Launched\n\n1979-05-16\n\n### Closed\n\n_No response_\n\n### Replaced By\n\n_No response_\n\n### Website\n\nhttp://www.digiturk.com.tr/\n\n### Logo\n\nhttps://i.imgur.com/nw8Sa2z.png\n\n### Notes\n\n_No response_',
reactions: {
url: 'https://api.github.com/repos/iptv-org/database/issues/5701/reactions',
total_count: 0,
'+1': 0,
'-1': 0,
laugh: 0,
hooray: 0,
confused: 0,
heart: 0,
rocket: 0,
eyes: 0
},
timeline_url: 'https://api.github.com/repos/iptv-org/database/issues/5701/timeline',
performed_via_github_app: null,
state_reason: null
}
]

View file

@ -0,0 +1,81 @@
module.exports = [
{
url: 'https://api.github.com/repos/iptv-org/database/issues/5871',
repository_url: 'https://api.github.com/repos/iptv-org/database',
labels_url: 'https://api.github.com/repos/iptv-org/database/issues/5871/labels{/name}',
comments_url: 'https://api.github.com/repos/iptv-org/database/issues/5871/comments',
events_url: 'https://api.github.com/repos/iptv-org/database/issues/5871/events',
html_url: 'https://github.com/iptv-org/database/issues/5871',
id: 1929261634,
node_id: 'I_kwDOG1Kwp85y_jJC',
number: 5871,
title: 'Remove: 002 Radio TV',
user: {
login: 'freearhey',
id: 7253922,
node_id: 'MDQ6VXNlcjcyNTM5MjI=',
avatar_url: 'https://avatars.githubusercontent.com/u/7253922?v=4',
gravatar_id: '',
url: 'https://api.github.com/users/freearhey',
html_url: 'https://github.com/freearhey',
followers_url: 'https://api.github.com/users/freearhey/followers',
following_url: 'https://api.github.com/users/freearhey/following{/other_user}',
gists_url: 'https://api.github.com/users/freearhey/gists{/gist_id}',
starred_url: 'https://api.github.com/users/freearhey/starred{/owner}{/repo}',
subscriptions_url: 'https://api.github.com/users/freearhey/subscriptions',
organizations_url: 'https://api.github.com/users/freearhey/orgs',
repos_url: 'https://api.github.com/users/freearhey/repos',
events_url: 'https://api.github.com/users/freearhey/events{/privacy}',
received_events_url: 'https://api.github.com/users/freearhey/received_events',
type: 'User',
site_admin: false
},
labels: [
{
id: 5366738347,
node_id: 'LA_kwDOG1Kwp88AAAABP-Htqw',
url: 'https://api.github.com/repos/iptv-org/database/labels/approved',
name: 'approved',
color: '85DDDE',
default: false,
description: ''
},
{
id: 6049155772,
node_id: 'LA_kwDOG1Kwp88AAAABaI7KvA',
url: 'https://api.github.com/repos/iptv-org/database/labels/blocklist:add',
name: 'channels:remove',
color: 'e99695',
default: false,
description: 'Request to remove a channel'
}
],
state: 'open',
locked: false,
assignee: null,
assignees: [],
milestone: null,
comments: 0,
created_at: '2023-10-06T00:35:32Z',
updated_at: '2023-10-06T00:35:32Z',
closed_at: null,
author_association: 'CONTRIBUTOR',
active_lock_reason: null,
body: '### Channel ID\n\n002RadioTV.do\n\n### Reason\n\nOther\n\n### Notes (optional)\n\n_No response_',
reactions: {
url: 'https://api.github.com/repos/iptv-org/database/issues/5871/reactions',
total_count: 0,
'+1': 0,
'-1': 0,
laugh: 0,
hooray: 0,
confused: 0,
heart: 0,
rocket: 0,
eyes: 0
},
timeline_url: 'https://api.github.com/repos/iptv-org/database/issues/5871/timeline',
performed_via_github_app: null,
state_reason: null
}
]

View file

@ -0,0 +1,3 @@
id,name
aaa,AAA
aaa,BBB
1 id name
2 aaa AAA
3 aaa BBB

View file

@ -0,0 +1 @@
id,name,alt_names,network,owners,country,subdivision,city,broadcast_area,languages,categories,is_nsfw,launched,closed,replaced_by,website,logo
1 id name alt_names network owners country subdivision city broadcast_area languages categories is_nsfw launched closed replaced_by website logo

View file

@ -0,0 +1,2 @@
id,name
aaa,AAA
1 id name
2 aaa AAA

View file

@ -0,0 +1,2 @@
channel,ref
aaa.us,https://github.com/iptv-org/iptv/issues/1831
1 channel ref
2 aaa.us https://github.com/iptv-org/iptv/issues/1831

View file

@ -0,0 +1,2 @@
id,name,alt_names,network,owners,country,subdivision,city,broadcast_area,languages,categories,is_nsfw,launched,closed,replaced_by,website,logo
002RadioTV.do,002 Radio TV,,,,DO,,,c/DO,spa,,FALSE,,,,https://www.002radio.com/,https://i.imgur.com/7oNe8xj.png
1 id name alt_names network owners country subdivision city broadcast_area languages categories is_nsfw launched closed replaced_by website logo
2 002RadioTV.do 002 Radio TV DO c/DO spa FALSE https://www.002radio.com/ https://i.imgur.com/7oNe8xj.png

View file

@ -0,0 +1,3 @@
name,code,languages,flag
Andorra,AD,cat,🇦🇩
Dominican Republic,DO,spa,🇩🇴
1 name code languages flag
2 Andorra AD cat 🇦🇩
3 Dominican Republic DO spa 🇩🇴

View file

@ -0,0 +1,3 @@
code,name
cat,Catalan
spa,Spanish
1 code name
2 cat Catalan
3 spa Spanish

View file

@ -0,0 +1,2 @@
country,name,code
AD,Andorra la Vella,AD-07
1 country name code
2 AD Andorra la Vella AD-07

View file

@ -0,0 +1,2 @@
id,name,alt_names,network,owners,country,subdivision,city,broadcast_area,languages,categories,is_nsfw,launched,closed,replaced_by,website,logo
PeoplesWeather.do,People°s Weather,,,,DO,,,c/DO,spa,,FALSE,,,,,https://i.imgur.com/7oNe8xj.png
1 id name alt_names network owners country subdivision city broadcast_area languages categories is_nsfw launched closed replaced_by website logo
2 PeoplesWeather.do People°s Weather DO c/DO spa FALSE https://i.imgur.com/7oNe8xj.png

View file

@ -0,0 +1,3 @@
name,code,languages,flag
Andorra,AD,cat,🇦🇩
Dominican Republic,DO,spa,🇩🇴
1 name code languages flag
2 Andorra AD cat 🇦🇩
3 Dominican Republic DO spa 🇩🇴

View file

@ -0,0 +1,3 @@
code,name
cat,Catalan
spa,Spanish
1 code name
2 cat Catalan
3 spa Spanish

View file

@ -0,0 +1,2 @@
id,name
auto
1 id,name
2 auto

24
tests/db/export.test.ts Normal file
View file

@ -0,0 +1,24 @@
import { execSync } from 'child_process'
import * as fs from 'fs-extra'
beforeEach(() => {
fs.emptyDirSync('tests/__data__/output')
})
it('can export data as json', () => {
execSync(
'DATA_DIR=tests/__data__/input/data API_DIR=tests/__data__/output/api npm run db:export',
{
encoding: 'utf8'
}
)
expect(content('output/api/blocklist.json')).toEqual(content('expected/api/blocklist.json'))
expect(content('output/api/channels.json')).toEqual(content('expected/api/channels.json'))
})
function content(filepath: string) {
return fs.readFileSync(`tests/__data__/${filepath}`, {
encoding: 'utf8'
})
}

25
tests/db/update.test.ts Normal file
View file

@ -0,0 +1,25 @@
import { execSync } from 'child_process'
import * as fs from 'fs-extra'
beforeEach(() => {
fs.emptyDirSync('tests/__data__/output')
fs.copySync('tests/__data__/input/data', 'tests/__data__/output/data')
})
it('can update db with data from issues', () => {
const stdout = execSync('DATA_DIR=tests/__data__/output/data npm run db:update --silent', {
encoding: 'utf8'
})
expect(content('output/data/blocklist.csv')).toEqual(content('expected/data/blocklist.csv'))
expect(content('output/data/channels.csv')).toEqual(content('expected/data/channels.csv'))
expect(stdout).toEqual(
'OUTPUT=closes #5871, closes #5901, closes #5701, closes #5900, closes #5899, closes #5898, closes #5897, closes #5891'
)
})
function content(filepath: string) {
return fs.readFileSync(`tests/__data__/${filepath}`, {
encoding: 'utf8'
})
}

81
tests/db/validate.test.ts Normal file
View file

@ -0,0 +1,81 @@
import { execSync } from 'child_process'
type ExecError = {
status: number
stdout: string
}
describe('db:validate', () => {
it('shows an error if there is an empty line at the end of the file', () => {
try {
execSync('DATA_DIR=tests/__data__/input/validate/empty_line npm run db:validate', {
encoding: 'utf8'
})
process.exit(1)
} catch (error) {
expect((error as ExecError).status).toBe(1)
expect((error as ExecError).stdout).toContain(
'Error: empty lines at the end of file not allowed (channels.csv)'
)
}
})
it('shows an error if the number of columns in a row is incorrect', () => {
try {
execSync('DATA_DIR=tests/__data__/input/validate/wrong_num_cols npm run db:validate', {
encoding: 'utf8'
})
process.exit(1)
} catch (error) {
expect((error as ExecError).status).toBe(1)
expect((error as ExecError).stdout).toContain(
'Error: row 2 has the wrong number of columns (categories.csv)'
)
}
})
it('shows an error if one of the lines ends with an invalid character', () => {
try {
execSync('DATA_DIR=tests/__data__/input/validate/invalid_line_ending npm run db:validate', {
encoding: 'utf8'
})
process.exit(1)
} catch (error) {
expect((error as ExecError).status).toBe(1)
expect((error as ExecError).stdout).toContain(
'Error: row 1 has the wrong line ending character, should be CRLF (categories.csv)'
)
}
})
it('shows an error if there are duplicates in the file', () => {
try {
execSync('DATA_DIR=tests/__data__/input/validate/duplicate npm run db:validate', {
encoding: 'utf8'
})
process.exit(1)
} catch (error) {
expect((error as ExecError).status).toBe(1)
expect((error as ExecError).stdout).toContain('entry with the id "aaa" already exists')
}
})
it('shows an error if an invalid value is specified', () => {
try {
execSync('DATA_DIR=tests/__data__/input/validate/invalid_value npm run db:validate', {
encoding: 'utf8'
})
process.exit(1)
} catch (error) {
expect((error as ExecError).status).toBe(1)
expect((error as ExecError).stdout).toContain('"aaa.us" is missing in the channels.csv')
expect((error as ExecError).stdout).toContain('1 error(s)')
}
})
it('does not show an error if all data are correct', () => {
execSync('DATA_DIR=tests/__data__/input/validate/valid_data npm run db:validate', {
encoding: 'utf8'
})
})
})

19
tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"strict": true,
"module": "commonjs",
"moduleResolution": "node",
"target": "es2020",
"esModuleInterop": true,
"declaration": true,
"typeRoots": [
"./node_modules/@types",
"./src/types"
],
"allowJs": true
},
"ts-node": {
"esm": true,
"transpileOnly": true
}
}

3995
yarn.lock

File diff suppressed because it is too large Load diff