├── tests ├── __data__ │ ├── .gitignore │ ├── input │ │ └── db │ │ │ ├── validate │ │ │ ├── invalid_line_ending │ │ │ │ ├── .gitattributes │ │ │ │ └── categories.csv │ │ │ ├── wrong_num_cols │ │ │ │ └── categories.csv │ │ │ ├── duplicate │ │ │ │ ├── languages.csv │ │ │ │ ├── subdivisions.csv │ │ │ │ ├── timezones.csv │ │ │ │ ├── countries.csv │ │ │ │ ├── categories.csv │ │ │ │ ├── blocklist.csv │ │ │ │ ├── channels.csv │ │ │ │ ├── logos.csv │ │ │ │ └── feeds.csv │ │ │ ├── invalid_value │ │ │ │ ├── languages.csv │ │ │ │ ├── timezones.csv │ │ │ │ ├── blocklist.csv │ │ │ │ ├── countries.csv │ │ │ │ ├── subdivisions.csv │ │ │ │ ├── logos.csv │ │ │ │ ├── cities.csv │ │ │ │ ├── channels.csv │ │ │ │ └── feeds.csv │ │ │ ├── valid_data │ │ │ │ ├── subdivisions.csv │ │ │ │ ├── timezones.csv │ │ │ │ ├── languages.csv │ │ │ │ ├── cities.csv │ │ │ │ ├── categories.csv │ │ │ │ ├── countries.csv │ │ │ │ ├── channels.csv │ │ │ │ ├── logos.csv │ │ │ │ └── feeds.csv │ │ │ └── empty_line │ │ │ │ └── channels.csv │ │ │ ├── export │ │ │ └── data │ │ │ │ ├── cities.csv │ │ │ │ ├── subdivisions.csv │ │ │ │ ├── timezones.csv │ │ │ │ ├── categories.csv │ │ │ │ ├── blocklist.csv │ │ │ │ ├── feeds.csv │ │ │ │ ├── logos.csv │ │ │ │ └── channels.csv │ │ │ └── update │ │ │ └── data │ │ │ ├── subdivisions.csv │ │ │ ├── cities.csv │ │ │ ├── blocklist.csv │ │ │ ├── timezones.csv │ │ │ ├── countries.csv │ │ │ ├── feeds.csv │ │ │ ├── channels.csv │ │ │ ├── logos.csv │ │ │ └── categories.csv │ └── expected │ │ └── db │ │ ├── export │ │ └── api │ │ │ ├── cities.json │ │ │ ├── categories.json │ │ │ ├── timezones.json │ │ │ ├── subdivisions.json │ │ │ ├── blocklist.json │ │ │ ├── feeds.json │ │ │ ├── logos.json │ │ │ └── channels.json │ │ └── update │ │ ├── data │ │ ├── cities.csv │ │ ├── blocklist.csv │ │ ├── logos.csv │ │ ├── channels.csv │ │ └── feeds.csv │ │ └── update.log └── commands │ └── db │ ├── export.test.ts │ ├── update.test.ts │ └── validate.test.ts ├── .gitattributes ├── .gitignore ├── scripts ├── core │ ├── index.ts │ ├── utils.ts │ └── db.ts ├── types │ ├── utils.d.ts │ ├── validator.d.ts │ └── db.d.ts ├── constants.ts ├── models │ ├── index.ts │ ├── issue.ts │ ├── issueData.ts │ ├── language.ts │ ├── category.ts │ ├── blocklistRecord.ts │ ├── timezone.ts │ ├── region.ts │ ├── country.ts │ ├── subdivision.ts │ ├── logo.ts │ ├── city.ts │ ├── feed.ts │ └── channel.ts └── commands │ └── db │ ├── export.ts │ └── validate.ts ├── .readme └── preview.png ├── .husky └── pre-commit ├── .prettierrc.js ├── tsconfig.json ├── .github ├── ISSUE_TEMPLATE │ ├── 91_blocklist_remove.yml │ ├── 12_cities_remove.yml │ ├── 06_feeds_remove.yml │ ├── 03_channels_remove.yml │ ├── 90_blocklist_add.yml │ ├── 09_logos_remove.yml │ ├── 08_logos_edit.yml │ ├── 07_logos_add.yml │ ├── 11_cities_edit.yml │ ├── 10_cities_add.yml │ ├── 05_feeds_edit.yml │ ├── 04_feeds_add.yml │ ├── 02_channels_edit.yml │ └── 01_channels_add.yml ├── FUNDING.yml ├── workflows │ ├── deploy.yml │ ├── check.yml │ └── update.yml └── CODE_OF_CONDUCT.md ├── LICENSE ├── eslint.config.mjs ├── README.md ├── data ├── categories.csv ├── regions.csv ├── countries.csv └── timezones.csv ├── package.json └── CONTRIBUTING.md /tests/__data__/.gitignore: -------------------------------------------------------------------------------- 1 | output/ -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=crlf 2 | *.png -text 3 | .husky/** -text -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /.artifacts/ 3 | .DS_Store 4 | /temp/ -------------------------------------------------------------------------------- /scripts/core/index.ts: -------------------------------------------------------------------------------- 1 | export * from './db' 2 | export * from './utils' 3 | -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/invalid_line_ending/.gitattributes: -------------------------------------------------------------------------------- 1 | *.csv binary -------------------------------------------------------------------------------- /.readme/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iptv-org/database/HEAD/.readme/preview.png -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/wrong_num_cols/categories.csv: -------------------------------------------------------------------------------- 1 | id,name,description 2 | auto -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/duplicate/languages.csv: -------------------------------------------------------------------------------- 1 | code,name 2 | cat,Catalan 3 | spa,Spanish -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/invalid_value/languages.csv: -------------------------------------------------------------------------------- 1 | code,name 2 | cat,Catalan 3 | spa,Spanish -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/duplicate/subdivisions.csv: -------------------------------------------------------------------------------- 1 | country,name,code 2 | AD,Andorra la Vella,AD-07 -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/invalid_value/timezones.csv: -------------------------------------------------------------------------------- 1 | id,utc_offset,countries 2 | Africa/Accra,+00:00,GH -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/duplicate/timezones.csv: -------------------------------------------------------------------------------- 1 | id,utc_offset,countries 2 | America/Santo_Domingo,-04:00,DO -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/valid_data/subdivisions.csv: -------------------------------------------------------------------------------- 1 | country,code,name,parent 2 | DO,DO-01,Distrito Nacional, -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/valid_data/timezones.csv: -------------------------------------------------------------------------------- 1 | id,utc_offset,countries 2 | America/Santo_Domingo,-04:00,DO -------------------------------------------------------------------------------- /tests/__data__/input/db/export/data/cities.csv: -------------------------------------------------------------------------------- 1 | country,subdivision,code,name,wikidata_id 2 | AD,AD-02,ADCAN,Canillo,Q386802 -------------------------------------------------------------------------------- /tests/__data__/input/db/update/data/subdivisions.csv: -------------------------------------------------------------------------------- 1 | country,code,name,parent 2 | AF,AF-HER,Herat, 3 | CN,CN-SD,Shandong, -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/valid_data/languages.csv: -------------------------------------------------------------------------------- 1 | code,name 2 | cat,Catalan 3 | spa,Spanish 4 | ukr,Ukrainian -------------------------------------------------------------------------------- /tests/__data__/input/db/export/data/subdivisions.csv: -------------------------------------------------------------------------------- 1 | country,code,name,parent 2 | AZ,AZ-KAN,Kǝngǝrli,AZ-NX 3 | AZ,AZ-NX,Naxçıvan, -------------------------------------------------------------------------------- /tests/__data__/input/db/export/data/timezones.csv: -------------------------------------------------------------------------------- 1 | id,utc_offset,countries 2 | Africa/Abidjan,+00:00,CI;BF;GH;GM;GN;IS;ML;MR;SH;SL;SN;TG -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/valid_data/cities.csv: -------------------------------------------------------------------------------- 1 | country,subdivision,code,name,wikidata_id 2 | DO,DO-01,DOSCL,San Carlos,Q6118516 -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/duplicate/countries.csv: -------------------------------------------------------------------------------- 1 | name,code,languages,flag 2 | Andorra,AD,cat,🇦🇩 3 | Dominican Republic,DO,spa,🇩🇴 -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/invalid_value/blocklist.csv: -------------------------------------------------------------------------------- 1 | channel,reason,ref 2 | aaa.us,dmca,https://github.com/iptv-org/iptv/issues/1831 -------------------------------------------------------------------------------- /tests/__data__/expected/db/export/api/cities.json: -------------------------------------------------------------------------------- 1 | [{"country":"AD","subdivision":"AD-02","code":"ADCAN","name":"Canillo","wikidata_id":"Q386802"}] -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/invalid_value/countries.csv: -------------------------------------------------------------------------------- 1 | name,code,languages,flag 2 | Andorra,AD,cat,🇦🇩 3 | Dominican Republic,DO,spa,🇩🇴 -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/invalid_value/subdivisions.csv: -------------------------------------------------------------------------------- 1 | country,code,name,parent 2 | AD,AD-07,Andorra la Vella, 3 | AD,AD-02,Canillo,AD-05 -------------------------------------------------------------------------------- /tests/__data__/input/db/export/data/categories.csv: -------------------------------------------------------------------------------- 1 | id,name,description 2 | auto,Auto,"Programming related to cars, motorcycles, and other automobiles" -------------------------------------------------------------------------------- /tests/__data__/input/db/update/data/cities.csv: -------------------------------------------------------------------------------- 1 | country,subdivision,code,name,wikidata_id 2 | AE,AE-AJ,AEAJM,Ajman,Q530171 3 | AL,AL-08,ALMIL,Milot,Q18749 -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/empty_line/channels.csv: -------------------------------------------------------------------------------- 1 | id,name,alt_names,network,owners,country,categories,is_nsfw,launched,closed,replaced_by,website 2 | -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/invalid_line_ending/categories.csv: -------------------------------------------------------------------------------- 1 | id,name,description 2 | aaa,AAA,Lorem ipsum dolor sit amet consectetur adipisicing elit -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/invalid_value/logos.csv: -------------------------------------------------------------------------------- 1 | channel,feed,tags,width,height,format,url 2 | 1NOMO.vu,DD,Dark,512,512,JPG,i.imgur.com/rNffU8H.jpeg -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/valid_data/categories.csv: -------------------------------------------------------------------------------- 1 | id,name,description 2 | auto,Auto,"Programming related to cars, motorcycles, and other automobiles" -------------------------------------------------------------------------------- /tests/__data__/expected/db/export/api/categories.json: -------------------------------------------------------------------------------- 1 | [{"id":"auto","name":"Auto","description":"Programming related to cars, motorcycles, and other automobiles"}] -------------------------------------------------------------------------------- /tests/__data__/expected/db/update/data/cities.csv: -------------------------------------------------------------------------------- 1 | country,subdivision,code,name,wikidata_id 2 | AF,AF-HER,AEAJM,Herāt,Q45313 3 | CN,CN-SD,CNYAT,Yantai,Q210493 -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/valid_data/countries.csv: -------------------------------------------------------------------------------- 1 | name,code,languages,flag 2 | Andorra,AD,cat,🇦🇩 3 | Dominican Republic,DO,spa,🇩🇴 4 | Ukraine,UA,ukr,🇺🇦 -------------------------------------------------------------------------------- /tests/__data__/expected/db/export/api/timezones.json: -------------------------------------------------------------------------------- 1 | [{"id":"Africa/Abidjan","utc_offset":"+00:00","countries":["CI","BF","GH","GM","GN","IS","ML","MR","SH","SL","SN","TG"]}] -------------------------------------------------------------------------------- /tests/__data__/expected/db/export/api/subdivisions.json: -------------------------------------------------------------------------------- 1 | [{"country":"AZ","code":"AZ-KAN","name":"Kǝngǝrli","parent":"AZ-NX"},{"country":"AZ","code":"AZ-NX","name":"Naxçıvan","parent":null}] -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/invalid_value/cities.csv: -------------------------------------------------------------------------------- 1 | country,subdivision,code,name,wikidata_id 2 | AD,AD-02,ADCAN,Canillo,Q386802 3 | AD,AD-02,ADCAN,Canillo,Q386802 4 | BD,BD-03,ADENC,Encamp,Q989558 -------------------------------------------------------------------------------- /tests/__data__/expected/db/update/data/blocklist.csv: -------------------------------------------------------------------------------- 1 | channel,reason,ref 2 | beINMoviesTurk.tr,dmca,https://github.com/iptv-org/iptv/issues/1831 3 | HGTVHungary.hu,nsfw,https://github.com/iptv-org/iptv/issues/1831 -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/duplicate/categories.csv: -------------------------------------------------------------------------------- 1 | id,name,description 2 | aaa,AAA,Lorem ipsum dolor sit amet consectetur adipisicing elit 3 | aaa,BBB,Lorem ipsum dolor sit amet consectetur adipisicing elit -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | data_changed="$(git diff --staged --name-only --diff-filter=ACMR -- 'data/*.csv' | sed 's| |\\ |g')" 4 | 5 | if [ ! -z "$data_changed" ]; then 6 | npm run db:validate 7 | fi -------------------------------------------------------------------------------- /tests/__data__/input/db/export/data/blocklist.csv: -------------------------------------------------------------------------------- 1 | channel,reason,ref 2 | AnimalPlanetAfrica.za,dmca,https://github.com/iptv-org/iptv/issues/1831 3 | BeijingSatelliteTV.cn,dmca,https://github.com/iptv-org/iptv/issues/1831 -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/duplicate/blocklist.csv: -------------------------------------------------------------------------------- 1 | channel,reason,ref 2 | 002RadioTV.do,dmca,https://en.wikipedia.org/wiki/Lemurs_of_Madagascar_(book) 3 | 002RadioTV.do,dmca,https://en.wikipedia.org/wiki/Lemurs_of_Madagascar_(book) -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/duplicate/channels.csv: -------------------------------------------------------------------------------- 1 | id,name,alt_names,network,owners,country,categories,is_nsfw,launched,closed,replaced_by,website 2 | 002RadioTV.do,002 Radio TV,,,,DO,,FALSE,,,, 3 | 10Channel.do,10 Channel,,,,DO,,FALSE,,,, -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | tabWidth: 2, 3 | useTabs: false, 4 | endOfLine: 'lf', 5 | semi: false, 6 | singleQuote: true, 7 | printWidth: 100, 8 | trailingComma: 'none', 9 | arrowParens: 'avoid' 10 | } 11 | -------------------------------------------------------------------------------- /tests/__data__/expected/db/export/api/blocklist.json: -------------------------------------------------------------------------------- 1 | [{"channel":"AnimalPlanetAfrica.za","reason":"dmca","ref":"https://github.com/iptv-org/iptv/issues/1831"},{"channel":"BeijingSatelliteTV.cn","reason":"dmca","ref":"https://github.com/iptv-org/iptv/issues/1831"}] -------------------------------------------------------------------------------- /tests/__data__/input/db/export/data/feeds.csv: -------------------------------------------------------------------------------- 1 | channel,id,name,alt_names,is_main,broadcast_area,timezones,languages,format 2 | 002RadioTV.do,SD,SD,,TRUE,c/DO,America/Santo_Domingo,spa,480i 3 | M5.hu,SD,SD,Méditerranée;Sud,FALSE,c/DO,America/Santo_Domingo,spa,480i -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/valid_data/channels.csv: -------------------------------------------------------------------------------- 1 | id,name,alt_names,network,owners,country,categories,is_nsfw,launched,closed,replaced_by,website 2 | PeoplesWeather.do,People°s Weather,,,,DO,,FALSE,,,, 3 | KSTVKids.ua,¡KS TV | Kids,,,,UA,,FALSE,,,, 4 | -------------------------------------------------------------------------------- /scripts/types/utils.d.ts: -------------------------------------------------------------------------------- 1 | export type CSVRow = { 2 | line: number 3 | data: { [key: string]: string | string[] | boolean | number | null } 4 | } 5 | export type ImageProbeResult = { 6 | width: number 7 | height: number 8 | format: string 9 | } 10 | -------------------------------------------------------------------------------- /scripts/types/validator.d.ts: -------------------------------------------------------------------------------- 1 | import { Collection } from '@freearhey/core' 2 | 3 | export type ValidatorError = { 4 | line: number 5 | message: string 6 | } 7 | 8 | export interface Validator { 9 | validate(): Collection 10 | } 11 | -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/duplicate/logos.csv: -------------------------------------------------------------------------------- 1 | channel,feed,tags,width,height,format,url 2 | 002RadioTV.do,,,334,210,PNG,https://i.imgur.com/7oNe8xj.png 3 | 002RadioTV.do,,,91,81,PNG,https://i.imgur.com/RMucFq8.png 4 | 002RadioTV.do,,,64,64,PNG,https://i.imgur.com/7oNe8xj.png -------------------------------------------------------------------------------- /tests/__data__/input/db/update/data/blocklist.csv: -------------------------------------------------------------------------------- 1 | channel,reason,ref 2 | AnimalPlanetAfrica.za,dmca,https://github.com/iptv-org/iptv/issues/1831 3 | BeijingSatelliteTV.cn,dmca,https://github.com/iptv-org/iptv/issues/1831 4 | 002RadioTV.do,dmca,https://github.com/iptv-org/iptv/issues/1831 -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/invalid_value/channels.csv: -------------------------------------------------------------------------------- 1 | id,name,alt_names,network,owners,country,categories,is_nsfw,launched,closed,replaced_by,website 2 | 002RadioTV.do,002 Radio TV,,,,DO,,FALSE,,,002RadioTV.do@4K,ttps://www.002radio.com/ 3 | 10Channel.do,10 Channel,,,,DO,,FALSE,,,, 4 | 24B.do,24B,,,,DO,,FALSE,,,, -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/valid_data/logos.csv: -------------------------------------------------------------------------------- 1 | channel,feed,tags,width,height,format,url 2 | PeoplesWeather.do,SD,,0,0,,https://i.imgur.com/7oNe8xj.png 3 | KSTVKids.ua,,black,512,506,PNG,https://upload.wikimedia.org/wikipedia/commons/thumb/0/0c/13th_street_logo_uk_master_rgb_black.png/512px-13th_street_logo_uk_master_rgb_black.png -------------------------------------------------------------------------------- /scripts/constants.ts: -------------------------------------------------------------------------------- 1 | export const OWNER = 'iptv-org' 2 | export const REPO = 'database' 3 | export const DATA_DIR = process.env.DATA_DIR || './data' 4 | export const API_DIR = process.env.API_DIR || './.api' 5 | export const TESTING = process.env.NODE_ENV === 'test' ? true : false 6 | export const LOGS_DIR = process.env.LOGS_DIR || './temp/logs' 7 | -------------------------------------------------------------------------------- /tests/__data__/input/db/update/data/timezones.csv: -------------------------------------------------------------------------------- 1 | id,utc_offset,countries 2 | Africa/Abidjan,+00:00,CI;BF;GH;GM;GN;IS;ML;MR;SH;SL;SN;TG 3 | Africa/Dakar,+00:00,SN 4 | Africa/El_Aaiun,+00:00,EH 5 | Atlantic/Bermuda,-04:00,BM 6 | Europe/London,+00:00,UK;GG;IM;JE 7 | Africa/Johannesburg,+02:00,ZA;LS;SZ 8 | Africa/Kigali,+02:00,RW 9 | America/Sao_Paulo,-03:00,BR -------------------------------------------------------------------------------- /tests/__data__/input/db/update/data/countries.csv: -------------------------------------------------------------------------------- 1 | name,code,languages,flag 2 | Argentina,AR,spa,🇦🇷 3 | Brazil,BR,por,🇧🇷 4 | Bermuda,BM,eng,🇧🇲 5 | United Kingdom,UK,eng,🇬🇧 6 | Ireland,IE,eng;gle,🇮🇪 7 | China,CN,zho,🇨🇳 8 | Hungary,HU,hun,🇭🇺 9 | Uruguay,UY,spa,🇺🇾 10 | Turkiye,TR,tur,🇹🇷 11 | Afghanistan,AF,pus;prd;tuk,🇦🇫 12 | United States,US,eng;spa,🇺🇸 -------------------------------------------------------------------------------- /tests/__data__/input/db/export/data/logos.csv: -------------------------------------------------------------------------------- 1 | channel,feed,tags,width,height,format,url 2 | 002RadioTV.do,SD,,0,0,,https://i.imgur.com/7oNe8xj.png 3 | 114TV.az,,,480,480,JPEG,https://i.imgur.com/LWT52nh.jpeg 4 | 13thStreet.nl,,black;stacked,512,506,PNG,https://upload.wikimedia.org/wikipedia/commons/thumb/0/0c/13th_street_logo_uk_master_rgb_black.png/512px-13th_street_logo_uk_master_rgb_black.png -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/duplicate/feeds.csv: -------------------------------------------------------------------------------- 1 | channel,id,name,alt_names,is_main,broadcast_area,timezones,languages,format 2 | 002RadioTV.do,SD,SD,,TRUE,c/DO,America/Santo_Domingo,eng,480i 3 | 002RadioTV.do,SD,SD,,FALSE,c/DO,America/Santo_Domingo,spa,1080i 4 | 10Channel.do,SD,SD,,TRUE,c/DO,America/Santo_Domingo,spa,1080i 5 | 10Channel.do,HD,HD,,FALSE,c/DO,America/Santo_Domingo,spa,1080i 6 | -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/invalid_value/feeds.csv: -------------------------------------------------------------------------------- 1 | channel,id,name,alt_names,is_main,broadcast_area,timezones,languages,format 2 | 0TV.dk,SD,SD,,TRUE,c/BE,Europe/Copenhagen,dan,576I 3 | 002RadioTV.do,SD,SD,,TRUE,c/DO,Africa/Accra,dan,1080i 4 | 002RadioTV.do,HD,HD,,TRUE,c/DO,Africa/Accra,dan,576i 5 | 24B.do,SD,SD,,FALSE,c/DO,Africa/Accra,dan,576i 6 | 10Channel.do,SD,SD,,TRUE,c/DO,Africa/Accra,eng,576i -------------------------------------------------------------------------------- /tests/__data__/expected/db/export/api/feeds.json: -------------------------------------------------------------------------------- 1 | [{"channel":"002RadioTV.do","id":"SD","name":"SD","alt_names":[],"is_main":true,"broadcast_area":["c/DO"],"timezones":["America/Santo_Domingo"],"languages":["spa"],"format":"480i"},{"channel":"M5.hu","id":"SD","name":"SD","alt_names":["Méditerranée","Sud"],"is_main":false,"broadcast_area":["c/DO"],"timezones":["America/Santo_Domingo"],"languages":["spa"],"format":"480i"}] -------------------------------------------------------------------------------- /tests/__data__/expected/db/update/update.log: -------------------------------------------------------------------------------- 1 | closes #5897, closes #5891, closes #5900, closes #5899, closes #5898, closes #5901, closes #5902, closes #5903, closes #5701, closes #5871, closes #8900, closes #7901, closes #6871, closes #17612, closes #9900, closes #9901, closes #9902, closes #9903, closes #9871, closes #9904, closes #9905, closes #9906, closes #9907, closes #9910, closes #9911, closes #9912, closes #9922 -------------------------------------------------------------------------------- /tests/__data__/input/db/validate/valid_data/feeds.csv: -------------------------------------------------------------------------------- 1 | channel,id,name,alt_names,is_main,broadcast_area,timezones,languages,format 2 | KSTVKids.ua,HD,HD,,TRUE,c/UA,America/Santo_Domingo,ukr,480i 3 | PeoplesWeather.do,SD,SD,,TRUE,c/DO,America/Santo_Domingo,spa,480i 4 | PeoplesWeather.do,HD,HD,,FALSE,c/DO,America/Santo_Domingo,spa,1080i 5 | PeoplesWeather.do,West,West,,FALSE,c/DO;ct/DOSCL,America/Santo_Domingo,spa,1080i -------------------------------------------------------------------------------- /scripts/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './blocklistRecord' 2 | export * from './category' 3 | export * from './channel' 4 | export * from './city' 5 | export * from './country' 6 | export * from './feed' 7 | export * from './issue' 8 | export * from './issueData' 9 | export * from './language' 10 | export * from './logo' 11 | export * from './region' 12 | export * from './subdivision' 13 | export * from './timezone' 14 | -------------------------------------------------------------------------------- /tests/__data__/input/db/export/data/channels.csv: -------------------------------------------------------------------------------- 1 | id,name,alt_names,network,owners,country,categories,is_nsfw,launched,closed,replaced_by,website 2 | 002RadioTV.do,002 Radio TV,,,,DO,general,FALSE,,,,https://www.002radio.com/ 3 | BeijingSatelliteTV.cn,Beijing Satellite TV,北京卫视,,,CN,general,FALSE,1979-05-16,,002RadioTV.do@SD,https://www.brtn.cn/btv/ 4 | M5.hu,M5,,,,HU,auto,TRUE,,2001-01-01,BeijingSatelliteTV.cn,https://www.mediaklikk.hu/m5/ -------------------------------------------------------------------------------- /scripts/models/issue.ts: -------------------------------------------------------------------------------- 1 | import { IssueData } from './' 2 | 3 | type IssueProps = { 4 | number: number 5 | labels: string[] 6 | data: IssueData 7 | } 8 | 9 | export class Issue { 10 | number: number 11 | labels: string[] 12 | data: IssueData 13 | 14 | constructor({ number, labels, data }: IssueProps) { 15 | this.number = number 16 | this.labels = labels 17 | this.data = data 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "module": "nodenext", 5 | "moduleResolution": "nodenext", 6 | "target": "es2022", 7 | "esModuleInterop": true, 8 | "declaration": true, 9 | "typeRoots": [ 10 | "./node_modules/@types", 11 | "./scripts/types" 12 | ], 13 | "allowJs": true 14 | }, 15 | "include": ["scripts/**/*"], 16 | "exclude": ["node_modules"] 17 | } -------------------------------------------------------------------------------- /tests/__data__/expected/db/export/api/logos.json: -------------------------------------------------------------------------------- 1 | [{"channel":"002RadioTV.do","feed":"SD","tags":[],"width":0,"height":0,"format":null,"url":"https://i.imgur.com/7oNe8xj.png"},{"channel":"114TV.az","feed":null,"tags":[],"width":480,"height":480,"format":"JPEG","url":"https://i.imgur.com/LWT52nh.jpeg"},{"channel":"13thStreet.nl","feed":null,"tags":["black","stacked"],"width":512,"height":506,"format":"PNG","url":"https://upload.wikimedia.org/wikipedia/commons/thumb/0/0c/13th_street_logo_uk_master_rgb_black.png/512px-13th_street_logo_uk_master_rgb_black.png"}] -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/91_blocklist_remove.yml: -------------------------------------------------------------------------------- 1 | name: '🗑️ blocklist/remove' 2 | description: Request to remove a channel from the blocklist 3 | title: 'Unblock: ' 4 | labels: ['blocklist:remove'] 5 | 6 | body: 7 | - type: input 8 | id: channel 9 | attributes: 10 | label: Channel ID 11 | description: The ID of the channel that should be removed 12 | placeholder: 'AnhuiTV.cn' 13 | validations: 14 | required: true 15 | 16 | - type: textarea 17 | attributes: 18 | label: Notes 19 | description: 'Describe in detail the reason for removing the channel from the blocklist' 20 | validations: 21 | required: true 22 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: iptv-org 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /tests/__data__/expected/db/export/api/channels.json: -------------------------------------------------------------------------------- 1 | [{"id":"002RadioTV.do","name":"002 Radio TV","alt_names":[],"network":null,"owners":[],"country":"DO","categories":["general"],"is_nsfw":false,"launched":null,"closed":null,"replaced_by":null,"website":"https://www.002radio.com/"},{"id":"BeijingSatelliteTV.cn","name":"Beijing Satellite TV","alt_names":["北京卫视"],"network":null,"owners":[],"country":"CN","categories":["general"],"is_nsfw":false,"launched":"1979-05-16","closed":null,"replaced_by":"002RadioTV.do@SD","website":"https://www.brtn.cn/btv/"},{"id":"M5.hu","name":"M5","alt_names":[],"network":null,"owners":[],"country":"HU","categories":["auto"],"is_nsfw":true,"launched":null,"closed":"2001-01-01","replaced_by":"BeijingSatelliteTV.cn","website":"https://www.mediaklikk.hu/m5/"}] -------------------------------------------------------------------------------- /scripts/commands/db/export.ts: -------------------------------------------------------------------------------- 1 | import { Storage, File } from '@freearhey/storage-js' 2 | import { DATA_DIR, API_DIR } from '../../constants' 3 | import { CSVRow } from '../../types/utils' 4 | import { parseCSV } from '../../core' 5 | 6 | async function main() { 7 | const dataStorage = new Storage(DATA_DIR) 8 | const apiStorage = new Storage(API_DIR) 9 | 10 | const files = await dataStorage.list('*.csv') 11 | for (const filepath of files) { 12 | const file = new File(filepath) 13 | const filename = file.name() 14 | const data = await dataStorage.load(file.basename()) 15 | const parsed = await parseCSV(data) 16 | const items = parsed.map((row: CSVRow) => row.data) 17 | 18 | await apiStorage.save(`${filename}.json`, items.toJSON()) 19 | } 20 | } 21 | 22 | main() 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/12_cities_remove.yml: -------------------------------------------------------------------------------- 1 | name: '🗑️ cities/remove' 2 | description: Request to remove a city from the database 3 | title: 'Remove: ' 4 | labels: ['cities:remove'] 5 | 6 | body: 7 | - type: input 8 | id: city_code 9 | attributes: 10 | label: City Code 11 | description: The code of the city that should be removed 12 | placeholder: 'CNYAT' 13 | validations: 14 | required: true 15 | 16 | - type: dropdown 17 | attributes: 18 | label: Reason 19 | description: Select the reason for removal from the list below 20 | options: 21 | - 'Not a city' 22 | - 'Other' 23 | validations: 24 | required: true 25 | 26 | - type: textarea 27 | attributes: 28 | label: Notes (optional) 29 | description: 'Anything else we should know?' 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/06_feeds_remove.yml: -------------------------------------------------------------------------------- 1 | name: '🗑️ feeds/remove' 2 | description: Request to remove a feed from the database 3 | title: 'Remove: ' 4 | labels: ['feeds:remove'] 5 | 6 | body: 7 | - type: input 8 | id: channel_id 9 | attributes: 10 | label: Channel ID 11 | description: ID of the channel to which this feed belongs 12 | placeholder: 'France3.fr' 13 | validations: 14 | required: true 15 | 16 | - type: input 17 | id: feed_id 18 | attributes: 19 | label: Feed ID 20 | description: The ID of the feed that should be removed 21 | placeholder: 'Alpes' 22 | validations: 23 | required: true 24 | 25 | - type: dropdown 26 | attributes: 27 | label: Reason 28 | description: Select the reason for removal from the list below 29 | options: 30 | - 'Duplicate' 31 | - 'Other' 32 | validations: 33 | required: true 34 | 35 | - type: textarea 36 | attributes: 37 | label: Notes (optional) 38 | description: 'Anything else we should know?' 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/03_channels_remove.yml: -------------------------------------------------------------------------------- 1 | name: '🗑️ channels/remove' 2 | description: Request to remove a channel from the database 3 | title: 'Remove: ' 4 | labels: ['channels:remove'] 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Deleting a channel will also delete all associated feeds, logos and records in the blocklist. 11 | 12 | - type: input 13 | id: id 14 | attributes: 15 | label: Channel ID 16 | description: The ID of the channel that should be removed 17 | placeholder: 'AnhuiTV.cn' 18 | validations: 19 | required: true 20 | 21 | - type: dropdown 22 | id: reason 23 | attributes: 24 | label: Reason 25 | description: Select the reason for removal from the list below 26 | options: 27 | - 'Duplicate' 28 | - 'Not a TV channel' 29 | - 'Other' 30 | validations: 31 | required: true 32 | 33 | - type: textarea 34 | id: notes 35 | attributes: 36 | label: Notes (optional) 37 | description: 'Anything else we should know?' 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/90_blocklist_add.yml: -------------------------------------------------------------------------------- 1 | name: '➕ blocklist/add' 2 | description: Request to add a channel to the blocklist 3 | title: 'Block: ' 4 | labels: ['blocklist:add'] 5 | 6 | body: 7 | - type: input 8 | id: channel 9 | attributes: 10 | label: Channel ID 11 | description: The ID of the channel that should be blocked 12 | placeholder: 'AnhuiTV.cn' 13 | validations: 14 | required: true 15 | 16 | - type: dropdown 17 | id: reason 18 | attributes: 19 | label: Reason 20 | description: Reason for blocking the channel 21 | options: 22 | - 'DMCA' 23 | - 'NSFW' 24 | validations: 25 | required: true 26 | 27 | - type: input 28 | id: ref 29 | attributes: 30 | label: Reference 31 | description: Link to DMCA notice or approved channel removal request 32 | placeholder: 'https://github.com/iptv-org/iptv/issues/1831' 33 | validations: 34 | required: true 35 | 36 | - type: textarea 37 | attributes: 38 | label: Notes (optional) 39 | description: 'Anything else we should know?' 40 | -------------------------------------------------------------------------------- /scripts/types/db.d.ts: -------------------------------------------------------------------------------- 1 | import { Dictionary, Collection } from '@freearhey/core' 2 | import { 3 | BlocklistRecord, 4 | Category, 5 | Channel, 6 | City, 7 | Country, 8 | Feed, 9 | Language, 10 | Logo, 11 | Region, 12 | Subdivision, 13 | Timezone 14 | } from '../models' 15 | 16 | export type DatabaseData = { 17 | feeds: Collection 18 | feedsGroupedByChannelId: Dictionary 19 | feedsKeyByStreamId: Dictionary 20 | channels: Collection 21 | categories: Collection 22 | countries: Collection 23 | languages: Collection 24 | blocklistRecords: Collection 25 | timezones: Collection 26 | regions: Collection 27 | subdivisions: Collection 28 | cities: Collection 29 | citiesKeyByCode: Dictionary 30 | channelsKeyById: Dictionary 31 | countriesKeyByCode: Dictionary 32 | subdivisionsKeyByCode: Dictionary 33 | categoriesKeyById: Dictionary 34 | regionsKeyByCode: Dictionary 35 | timezonesKeyById: Dictionary 36 | languagesKeyByCode: Dictionary 37 | logos: Collection 38 | } 39 | -------------------------------------------------------------------------------- /tests/__data__/input/db/update/data/feeds.csv: -------------------------------------------------------------------------------- 1 | channel,id,name,alt_names,is_main,broadcast_area,timezones,languages,format 2 | 002RadioTV.do,SD,SD,,TRUE,c/DO,America/Santo_Domingo,spa,480i 3 | 01TV.fr,SD,SD,,TRUE,c/FR,Europe/Paris,fra,576i 4 | 0TV.dk,SD,SD,,TRUE,c/DK,Europe/Copenhagen,dan,576i 5 | 1000xHoraTV.uy,SD,SD,,TRUE,c/UY,America/Montevideo,spa,576i 6 | 24HorasInternacional.es,SD,SD,,TRUE,c/ES,Europe/Madrid,spa,576i 7 | 7alaTV.ps,SD,SD,,TRUE,c/PS,Europe/Paris,fra,576i 8 | 7alaTV.ps,HD,HD,,FALSE,c/PS,Europe/Paris,fra,576i 9 | Baladna.ps,SD,SD,,FALSE,c/PS,Europe/Paris,fra,576i 10 | Baladna.ps,HD,HD,,TRUE,c/PS,Europe/Paris,fra,576i 11 | ZonaDAZN3.it,HD,HD,,TRUE,c/IT,Europe/Rome,ita,576i 12 | ZonaDAZN3.it,SD,SD,,FALSE,c/IT,Europe/Rome,ita,576i 13 | BeijingSatelliteTV.cn,SD,SD,,TRUE,c/DO,America/Santo_Domingo,spa,480i 14 | M5.hu,SD,SD,Sud,FALSE,c/DO,America/Santo_Domingo,spa,480i 15 | M5.hu,West,West,,TRUE,c/DO,America/Santo_Domingo,spa,480i 16 | Channel82.bm,SD,SD,,FALSE,c/BM,America/Santo_Domingo,eng,480i 17 | TrueCrime.uk,SD,SD,,TRUE,c/UK;c/IE,Europe/London,eng,576i 18 | ZutTV.ve,SD,SD,,TRUE,c/UK;c/IE;ct/ALMIL,Europe/London,eng,576i 19 | ZTVWorld.hu,SD,SD,,TRUE,c/HU,Europe/London,eng,576i 20 | K04JRD1.us,SD,SD,,TRUE,c/US,Europe/London,eng,576i -------------------------------------------------------------------------------- /tests/__data__/input/db/update/data/channels.csv: -------------------------------------------------------------------------------- 1 | id,name,alt_names,network,owners,country,categories,is_nsfw,launched,closed,replaced_by,website 2 | 002RadioTV.do,002 Radio TV,,,,DO,general,FALSE,,,,https://www.002radio.com/ 3 | 0TV.dk,0-TV,,,,DK,general,FALSE,,,01TV.fr@SD,https://0-tv.dk/ 4 | 1000xHoraTV.uy,1000xHora TV,,,,UY,auto,FALSE,,,M5.hu@SD,https://www.1000xhoratv.com/ 5 | 24HorasInternacional.es,24 Horas Internacional,,,,ES,,FALSE,,,, 6 | 7alaTV.ps,7ala TV,,,,PS,,FALSE,,,, 7 | Baladna.ps,Baladna,,,,PS,,FALSE,,,, 8 | K04GWD1.us,K04GW-D1,,,,US,,FALSE,,,, 9 | K04JRD1.us,K04JR-D1,,,,US,,FALSE,,,, 10 | K05FWD1.us,K05FW-D1,,,,US,,FALSE,,,, 11 | K04JRD2.us,K04JR-D2,,,,US,,FALSE,,,, 12 | Premiere10.br,Premiere 10,,,,BR,,FALSE,,,, 13 | HGTVHungary.hu,HGTV Hungary,,,,HU,,FALSE,,,, 14 | TrueCrime.uk,True Crime,,,,UK,,FALSE,,,, 15 | ZonaDAZN3.it,Zona DAZN3,,,,IT,,FALSE,,,, 16 | BeijingSatelliteTV.cn,Beijing Satellite TV,北京卫视,,,CN,general,FALSE,,,002RadioTV.do@SD,https://www.brtn.cn/btv/ 17 | M5.hu,M5,Lista TV Mundo,Lirquén 8 región,,HU,auto,TRUE,,2021-01-01,002RadioTV.do@SD,https://www.mediaklikk.hu/m5/ 18 | Channel82.bm,Channel 82,,,,BM,,FALSE,,,, 19 | ZugloTV.hu,Zuglo TV,,,,HU,entertainment,FALSE,,,, 20 | ZutTV.ve,Zut TV,,,,VE,music,FALSE,,,,https://www.zut.tv/ -------------------------------------------------------------------------------- /tests/__data__/input/db/update/data/logos.csv: -------------------------------------------------------------------------------- 1 | channel,feed,tags,width,height,format,url 2 | 002RadioTV.do,SD,,512,512,PNG,https://i.imgur.com/7oNe8xj.png 3 | 01TV.fr,SD,,512,512,PNG,https://i.imgur.com/RMucFq8.png 4 | 01TV.fr,HD,,512,512,PNG,https://i.imgur.com/aR5q6mA.png 5 | 7alaTV.ps,,,400,400,PNG,https://upload.wikimedia.org/wikipedia/commons/7/71/Nothing_whitespace_blank.png 6 | Baladna.ps,SD,,400,400,PNG,https://upload.wikimedia.org/wikipedia/commons/7/71/Nothing_whitespace_blank.png 7 | BeijingSatelliteTV.cn,,,512,512,PNG,https://i.imgur.com/vsktAez.png 8 | M5.hu,SD,,512,512,PNG,https://i.imgur.com/y21wFd0.png 9 | ZonaDAZN2.it,SD,,512,512,PNG,https://i.imgur.com/vMdyLVv.png 10 | ZonaDAZN3.it,SD,,512,512,PNG,https://i.imgur.com/Jop3Vip.png 11 | ZonaDAZN3.it,HD,,512,512,PNG,https://i.imgur.com/Jop3Vip.png 12 | K04GWD1.us,,,0,0,PNG,https://upload.wikimedia.org/wikipedia/en/thumb/3/33/PBS_logo.svg/512px-PBS_logo.svg.png 13 | K04JRD1.us,,,0,0,PNG,https://upload.wikimedia.org/wikipedia/en/thumb/3/33/PBS_logo.svg/512px-PBS_logo.svg.png 14 | K05FWD1.us,,,0,0,PNG,https://upload.wikimedia.org/wikipedia/en/thumb/3/33/PBS_logo.svg/512px-PBS_logo.svg.png 15 | ZugloTV.hu,,,0,0,PNG,https://i.imgur.com/wPqCtwR.png 16 | ZutTV.ve,SD,,0,0,PNG,https://i.imgur.com/wPqCtwK.png -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: deploy 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - master 7 | jobs: 8 | main: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-node@v4 13 | with: 14 | node-version: '22.12.0' 15 | cache: 'npm' 16 | - name: install dependencies 17 | run: npm install 18 | - name: export data to ./api 19 | run: npm run db:export 20 | - uses: tibdex/github-app-token@v1.8.2 21 | if: ${{ !env.ACT }} 22 | id: create-app-token 23 | with: 24 | app_id: ${{ secrets.APP_ID }} 25 | private_key: ${{ secrets.APP_PRIVATE_KEY }} 26 | - uses: JamesIves/github-pages-deploy-action@4.1.1 27 | if: ${{ !env.ACT && github.ref == 'refs/heads/master' }} 28 | with: 29 | repository-name: iptv-org/api 30 | branch: gh-pages 31 | folder: .api 32 | token: ${{ steps.create-app-token.outputs.token }} 33 | git-config-name: iptv-bot[bot] 34 | git-config-email: 84861620+iptv-bot[bot]@users.noreply.github.com 35 | commit-message: '[Bot] Deploy to iptv-org/api' 36 | clean: false 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/09_logos_remove.yml: -------------------------------------------------------------------------------- 1 | name: '🗑️ logos/remove' 2 | description: Request to remove a logo from the database 3 | title: 'Remove: ' 4 | labels: ['logos:remove'] 5 | 6 | body: 7 | - type: input 8 | id: logo_url 9 | attributes: 10 | label: Logo URL (required) 11 | description: List of logo URLs to be deleted. One per line. 12 | placeholder: 'https://example.com/logo.png' 13 | validations: 14 | required: true 15 | 16 | - type: input 17 | id: channel_id 18 | attributes: 19 | label: Channel ID 20 | description: Allows to refine the request 21 | placeholder: 'France3.fr' 22 | 23 | - type: input 24 | id: feed_id 25 | attributes: 26 | label: Feed ID 27 | description: Allows to refine the request 28 | placeholder: 'Alpes' 29 | 30 | - type: dropdown 31 | id: reason 32 | attributes: 33 | label: Reason (required) 34 | description: Select the reason for removal from the list below 35 | options: 36 | - 'Not loading' 37 | - 'Geo-blocked' 38 | - 'Wrong channel ID' 39 | - 'Outdated' 40 | - 'Other' 41 | validations: 42 | required: true 43 | 44 | - type: textarea 45 | id: notes 46 | attributes: 47 | label: Notes 48 | description: 'Anything else we should know?' 49 | -------------------------------------------------------------------------------- /tests/__data__/expected/db/update/data/logos.csv: -------------------------------------------------------------------------------- 1 | channel,feed,tags,width,height,format,url 2 | 01TV.fr,HD,,512,512,PNG,https://i.imgur.com/aR5q6mA.png 3 | 24HorasInternacional.es,,light;2006;on-screen,80,80,JPEG,https://i.imgur.com/IUVRm5L.png 4 | 7alaTV.ps,,deprecated,400,400,PNG,https://upload.wikimedia.org/wikipedia/commons/7/71/Nothing_whitespace_blank.png 5 | Baladna.ps,SD,deprecated,400,400,PNG,https://upload.wikimedia.org/wikipedia/commons/7/71/Nothing_whitespace_blank.png 6 | beINMoviesTurk.tr,,,512,512,PNG,https://i.imgur.com/vsktAez.png 7 | K04GWD1.us,,,0,0,PNG,https://upload.wikimedia.org/wikipedia/en/thumb/3/33/PBS_logo.svg/512px-PBS_logo.svg.png 8 | K04JRD1.us,,,0,0,PNG,https://upload.wikimedia.org/wikipedia/en/thumb/3/33/PBS_logo.svg/512px-PBS_logo.svg.png 9 | K05FWD1.us,,,0,0,PNG,https://upload.wikimedia.org/wikipedia/en/thumb/3/33/PBS_logo.svg/512px-PBS_logo.svg.png 10 | M5.hu,HD,,512,512,PNG,https://i.imgur.com/y21wFd0.png 11 | WenzhouEconomicandEducation.cn,,,80,80,JPEG,https://www.tvchinese.net/uploads/tv/wzjjkj.jpg 12 | YiwuBusinessChannel.cn,,,80,80,JPEG,https://www.tvchinese.net/uploads/tv/yiwutv.jpg 13 | YiwuNewsIntegratedChannel.cn,,,80,80,JPEG,https://www.tvchinese.net/uploads/tv/yiwutv.jpg 14 | ZonaDAZN3.it,HD,,400,400,PNG,https://i.imgur.com/Jop3Vip.png 15 | ZTVWorld.hu,,,0,0,PNG,https://i.imgur.com/wPqCtwR.png 16 | ZutTV.ve,HD,,0,0,PNG,https://i.imgur.com/wPqCtwK.png -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: check 2 | on: 3 | workflow_dispatch: 4 | pull_request: 5 | types: [opened, synchronize, reopened, edited] 6 | jobs: 7 | main: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: actions/setup-node@v4 12 | with: 13 | node-version: '22.12.0' 14 | cache: 'npm' 15 | - name: changed files 16 | id: files 17 | run: | 18 | git fetch origin master:master 19 | ANY_CHANGED=false 20 | ALL_CHANGED_FILES=$(git diff --name-only master -- data/ | tr '\n' ' ') 21 | if [ -n "${ALL_CHANGED_FILES}" ]; then 22 | ANY_CHANGED=true 23 | fi 24 | echo "all_changed_files=$ALL_CHANGED_FILES" >> "$GITHUB_OUTPUT" 25 | echo "any_changed=$ANY_CHANGED" >> "$GITHUB_OUTPUT" 26 | - name: install dependencies 27 | if: steps.files.outputs.any_changed == 'true' 28 | run: npm install 29 | - name: check if files are crlf 30 | uses: kforeverisback/check-crlf-extended@v2 31 | continue-on-error: false 32 | with: 33 | directory: ./data 34 | line_ending_type: 'CRLF' 35 | include_pattern: '*.csv' 36 | pattern_type: 'shell_glob' 37 | - name: validate 38 | if: steps.files.outputs.any_changed == 'true' 39 | run: npm run db:validate 40 | -------------------------------------------------------------------------------- /tests/commands/db/export.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, it, expect } from 'vitest' 2 | import { pathToFileURL } from 'node:url' 3 | import { execSync } from 'child_process' 4 | import * as fs from 'fs-extra' 5 | import { glob } from 'glob' 6 | 7 | beforeEach(() => { 8 | fs.emptyDirSync('tests/__data__/output') 9 | }) 10 | 11 | describe('db:export', () => { 12 | const ENV_VAR = 13 | 'cross-env DATA_DIR=tests/__data__/input/db/export/data API_DIR=tests/__data__/output/api' 14 | 15 | it('can export data as json', () => { 16 | const cmd = `${ENV_VAR} npm run db:export` 17 | const stdout = execSync(cmd, { encoding: 'utf8' }) 18 | if (process.env.DEBUG === 'true') console.log(cmd, stdout) 19 | 20 | const files = glob.sync('tests/__data__/expected/db/export/api/*.json').map(filepath => { 21 | const fileUrl = pathToFileURL(filepath).toString() 22 | const pathToRemove = pathToFileURL('tests/__data__/expected/db/export/api/').toString() 23 | 24 | return fileUrl.replace(pathToRemove, '') 25 | }) 26 | 27 | files.forEach(filepath => { 28 | expect(content(`tests/__data__/output/api/${filepath}`), filepath).toBe( 29 | content(`tests/__data__/expected/db/export/api/${filepath}`) 30 | ) 31 | }) 32 | }) 33 | }) 34 | 35 | function content(filepath: string) { 36 | return fs.readFileSync(pathToFileURL(filepath), { 37 | encoding: 'utf8' 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | 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. 4 | 5 | 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. 6 | 7 | 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. 8 | 9 | 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. 10 | 11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 12 | 13 | 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 14 | -------------------------------------------------------------------------------- /tests/__data__/expected/db/update/data/channels.csv: -------------------------------------------------------------------------------- 1 | id,name,alt_names,network,owners,country,categories,is_nsfw,launched,closed,replaced_by,website 2 | 0TV.dk,0-TV,,,,DK,general,FALSE,,,,https://0-tv.dk/ 3 | 1000xHoraTV.uy,1000xHora TV,,,,UY,auto,FALSE,2020-01-01,2021-01-01,M5.hu@HD,https://www.1000xhoratv.com/ 4 | 24HorasInternacional.es,24 Horas Internacional,,,,ES,,FALSE,,,, 5 | 7alaTV.ps,7ala TV,,,,PS,,FALSE,,,, 6 | Baladna.ps,Baladna,,,,PS,,FALSE,,,, 7 | beINMoviesTurk.tr,beIN Movies Turk,beIN Movies Türk,BBC,Gazprom Media,TR,movies,TRUE,1979-05-16,1980-05-16,M5.hu,http://www.digiturk.com.tr/ 8 | Channel82.bm,Channel 82,,,,BM,,FALSE,,,, 9 | HGTVHungary.hu,HGTV Hungary,,,,HU,,FALSE,,,, 10 | K04GWD1.us,K04GW-D1,,,,US,,FALSE,,,, 11 | K04JRD1.us,K04JR-D1,,,,US,,FALSE,,,K04JRD2.us, 12 | K04JRD2.us,K04JR-D2,,,,US,,FALSE,,,, 13 | K05FWD1.us,K05FW-D1,,,,US,,FALSE,,,, 14 | M5.hu,M5,,,Duna Médiaszolgáltató Nonprofit Zrt.,HU,,TRUE,2020-01-01,,0TV.dk@SD,https://www.mediaklikk.hu/m5/ 15 | Premiere10.br,Premiere 10,,,,BR,,FALSE,,,, 16 | TrueCrime.uk,True Crime,,,,UK,,FALSE,,,, 17 | WenzhouEconomicandEducation.cn,Wenzhou Economic and Education,,,,CN,science,FALSE,,,, 18 | YiwuBusinessChannel.cn,Yiwu Business Channel,,,,CN,business,FALSE,,,, 19 | YiwuNewsIntegratedChannel.cn,Yiwu News Integrated Channel,,,,CN,news,FALSE,,,, 20 | ZonaDAZN3.it,Zona DAZN3,,,,IT,,FALSE,,,, 21 | ZTVWorld.hu,ZTV World,,,,HU,entertainment,FALSE,,,, 22 | ZutTV.ve,Zut TV,,,,VE,music,FALSE,,,,https://www.zut.tv/ -------------------------------------------------------------------------------- /tests/__data__/expected/db/update/data/feeds.csv: -------------------------------------------------------------------------------- 1 | channel,id,name,alt_names,is_main,broadcast_area,timezones,languages,format 2 | 0TV.dk,SD,SD,,TRUE,c/DK,Europe/Copenhagen,dan,576i 3 | 1000xHoraTV.uy,HD,HD,Oeste;Este,TRUE,c/CN,Africa/Johannesburg;Africa/Kigali,zho,576i 4 | 1000xHoraTV.uy,SD,SD,,FALSE,c/UY,America/Montevideo,spa,576i 5 | 24HorasInternacional.es,SD,SD,,TRUE,c/ES,Europe/Madrid,spa,576i 6 | 7alaTV.ps,HD,HD,,FALSE,c/PS,Europe/Paris,fra,576i 7 | 7alaTV.ps,SD,SD,,TRUE,c/PS,Europe/Paris,fra,576i 8 | Baladna.ps,HD,HD,,TRUE,c/PS,Europe/Paris,fra,576i 9 | Baladna.ps,SD,SD,,FALSE,c/PS,Europe/Paris,fra,576i 10 | beINMoviesTurk.tr,SD,SD,,TRUE,c/DO,America/Santo_Domingo,spa,480i 11 | Channel82.bm,SD,SD,,FALSE,c/BM,Atlantic/Bermuda,eng,480i 12 | K04JRD1.us,SD,SD,,TRUE,c/US,Europe/London,eng,576i 13 | M5.hu,HD,HD,Méditerranée;Sud,TRUE,c/BR,Africa/Dakar;Africa/El_Aaiun,por;spa,1080i 14 | M5.hu,West,West,,FALSE,c/DO,America/Santo_Domingo,spa,480i 15 | TrueCrime.uk,Plus1,+1,,FALSE,c/UK,Europe/London,eng,576i 16 | TrueCrime.uk,SD,SD,,TRUE,c/UK;c/IE,Europe/London,eng,576i 17 | WenzhouEconomicandEducation.cn,SD,SD,,TRUE,c/CN,Africa/Johannesburg;Africa/Kigali,zho,576i 18 | YiwuBusinessChannel.cn,SD,SD,,TRUE,c/CN,Africa/Johannesburg;Africa/Kigali,zho,576i 19 | YiwuNewsIntegratedChannel.cn,SD,SD,,TRUE,c/CN,Africa/Johannesburg;Africa/Kigali,zho,576i 20 | ZonaDAZN3.it,HD,HD,,TRUE,c/IT,Europe/Rome,ita,576i 21 | ZonaDAZN3.it,SD,SD,,FALSE,c/IT,Europe/Rome,ita,576i 22 | ZTVWorld.hu,SD,SD,,TRUE,c/HU,Europe/London,eng,576i 23 | ZutTV.ve,HD,HD,,TRUE,c/UK;c/IE,Europe/London,eng,576i -------------------------------------------------------------------------------- /scripts/models/issueData.ts: -------------------------------------------------------------------------------- 1 | import { Dictionary } from '@freearhey/core' 2 | 3 | export class IssueData { 4 | #data: Dictionary 5 | 6 | constructor(data: Dictionary) { 7 | this.#data = data 8 | } 9 | 10 | has(key: string): boolean { 11 | return this.#data.has(key) 12 | } 13 | 14 | missing(key: string): boolean { 15 | return this.#data.missing(key) || this.#data.get(key) === undefined 16 | } 17 | 18 | getBoolean(key: string): boolean | undefined { 19 | if (this.missing(key)) return undefined 20 | 21 | return this.#data.get(key) === 'TRUE' ? true : false 22 | } 23 | 24 | getString(key: string): string | undefined { 25 | const deleteSymbol = '~' 26 | 27 | return this.missing(key) 28 | ? undefined 29 | : this.#data.get(key) === deleteSymbol 30 | ? '' 31 | : this.#data.get(key) 32 | } 33 | 34 | getNumber(key: string): number | undefined { 35 | const string = this.getString(key) 36 | 37 | return string ? Number(string) : undefined 38 | } 39 | 40 | getArray(key: string): string[] | undefined { 41 | const deleteSymbol = '~' 42 | 43 | if (this.#data.missing(key)) return undefined 44 | 45 | const value = this.#data.get(key) 46 | 47 | if (typeof value !== 'string') return undefined 48 | 49 | return value === deleteSymbol ? [] : value.split(/[\r\n;]/) 50 | } 51 | 52 | getChanged(): string[] { 53 | const keys = Object.keys(this.#data) 54 | 55 | return keys.filter((key: string) => this.#data.has(key)) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import typescriptEslint from '@typescript-eslint/eslint-plugin' 2 | import stylistic from '@stylistic/eslint-plugin' 3 | import globals from 'globals' 4 | import tsParser from '@typescript-eslint/parser' 5 | import path from 'node:path' 6 | import { fileURLToPath } from 'node:url' 7 | import js from '@eslint/js' 8 | import { FlatCompat } from '@eslint/eslintrc' 9 | 10 | const __filename = fileURLToPath(import.meta.url) 11 | const __dirname = path.dirname(__filename) 12 | const compat = new FlatCompat({ 13 | baseDirectory: __dirname, 14 | recommendedConfig: js.configs.recommended, 15 | allConfig: js.configs.all 16 | }) 17 | 18 | export default [ 19 | ...compat.extends('eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'), 20 | { 21 | plugins: { 22 | '@typescript-eslint': typescriptEslint, 23 | '@stylistic': stylistic 24 | }, 25 | 26 | languageOptions: { 27 | globals: { 28 | ...globals.node, 29 | ...globals.jest 30 | }, 31 | 32 | parser: tsParser, 33 | ecmaVersion: 'latest', 34 | sourceType: 'module' 35 | }, 36 | 37 | rules: { 38 | '@typescript-eslint/no-require-imports': 'off', 39 | '@typescript-eslint/no-var-requires': 'off', 40 | 'no-case-declarations': 'off', 41 | '@stylistic/linebreak-style': ['error', 'windows'], 42 | 43 | quotes: [ 44 | 'error', 45 | 'single', 46 | { 47 | avoidEscape: true 48 | } 49 | ], 50 | 51 | semi: ['error', 'never'] 52 | } 53 | } 54 | ] 55 | -------------------------------------------------------------------------------- /scripts/models/language.ts: -------------------------------------------------------------------------------- 1 | import { Validator, ValidatorError } from '../types/validator' 2 | import { Collection } from '@freearhey/core' 3 | import { CSVRow } from '../types/utils' 4 | import * as sdk from '@iptv-org/sdk' 5 | import Joi from 'joi' 6 | 7 | export class Language extends sdk.Models.Language implements Validator { 8 | line: number = -1 9 | 10 | static fromRow(row: CSVRow): Language { 11 | if (!row.data.code) throw new Error('Language: "code" not specified') 12 | if (!row.data.name) throw new Error('Language: "name" not specified') 13 | 14 | const language = new Language({ 15 | code: row.data.code.toString(), 16 | name: row.data.name.toString() 17 | }) 18 | 19 | language.line = row.line 20 | 21 | return language 22 | } 23 | 24 | getSchema() { 25 | return Joi.object({ 26 | code: Joi.string() 27 | .regex(/^[a-z]{3}$/) 28 | .required(), 29 | name: Joi.string().required() 30 | }) 31 | } 32 | 33 | toCSVRecord(): Record { 34 | return this.toObject() as Record 35 | } 36 | 37 | validate(): Collection { 38 | const errors = new Collection() 39 | 40 | const joiResults = this.getSchema().validate(this.toObject(), { abortEarly: false }) 41 | if (joiResults.error) { 42 | joiResults.error.details.forEach((detail: { message: string }) => { 43 | errors.add({ line: this.line, message: `${this.code}: ${detail.message}` }) 44 | }) 45 | } 46 | 47 | return errors 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/08_logos_edit.yml: -------------------------------------------------------------------------------- 1 | name: '✏️ logos/edit' 2 | description: Request to edit logo description 3 | title: 'Edit: ' 4 | labels: ['logos:edit'] 5 | 6 | body: 7 | - type: input 8 | id: logo_url 9 | attributes: 10 | label: Logo URL (required) 11 | description: URL of the logo to be edited 12 | placeholder: 'https://example.com/logo.png' 13 | validations: 14 | required: true 15 | 16 | - type: markdown 17 | attributes: 18 | value: | 19 | Please specify exactly what should be changed. To delete an existing value without replacement use the `~` symbol. 20 | 21 | - type: input 22 | id: tags 23 | attributes: 24 | label: Tags 25 | description: "List of keywords describing this version of the logo separated by `;`. May include: `a-z`, `0-9` and `-`" 26 | placeholder: 'horizontal;white' 27 | 28 | - type: input 29 | id: width 30 | attributes: 31 | label: Width 32 | description: The width of the image in pixels 33 | placeholder: '512' 34 | 35 | - type: input 36 | id: height 37 | attributes: 38 | label: Height 39 | description: The height of the image in pixels 40 | placeholder: '512' 41 | 42 | - type: dropdown 43 | id: format 44 | attributes: 45 | label: Format 46 | description: Image format 47 | options: 48 | - 'PNG' 49 | - 'JPEG' 50 | - 'SVG' 51 | - 'GIF' 52 | - 'WebP' 53 | - 'AVIF' 54 | - 'APNG' 55 | 56 | - type: textarea 57 | attributes: 58 | label: Notes 59 | description: 'Anything else we should know?' 60 | -------------------------------------------------------------------------------- /tests/commands/db/update.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, it, expect } from 'vitest' 2 | import { pathToFileURL } from 'node:url' 3 | import { execSync } from 'child_process' 4 | import * as fs from 'fs-extra' 5 | import { glob } from 'glob' 6 | 7 | beforeEach(() => { 8 | fs.emptyDirSync('tests/__data__/output') 9 | fs.copySync('tests/__data__/input/db/update/data', 'tests/__data__/output/data') 10 | }) 11 | 12 | describe('db:update', () => { 13 | const ENV_VAR = 14 | 'cross-env DATA_DIR=tests/__data__/output/data LOGS_DIR=tests/__data__/output/logs' 15 | 16 | it('can update db with data from issues', () => { 17 | const cmd = `${ENV_VAR} npm run db:update` 18 | const stdout = execSync(cmd, { encoding: 'utf8' }) 19 | if (process.env.DEBUG === 'true') console.log(cmd, stdout) 20 | 21 | const files = glob.sync('tests/__data__/expected/db/update/data/*.csv').map(filepath => { 22 | const fileUrl = pathToFileURL(filepath).toString() 23 | const pathToRemove = pathToFileURL('tests/__data__/expected/db/update/data/').toString() 24 | 25 | return fileUrl.replace(pathToRemove, '') 26 | }) 27 | 28 | files.forEach(filepath => { 29 | expect(content(`tests/__data__/output/data/${filepath}`), filepath).toBe( 30 | content(`tests/__data__/expected/db/update/data/${filepath}`) 31 | ) 32 | }) 33 | 34 | expect(content('tests/__data__/output/logs/update.log')).toBe( 35 | content('tests/__data__/expected/db/update/update.log') 36 | ) 37 | }) 38 | }) 39 | 40 | function content(filepath: string) { 41 | return fs.readFileSync(pathToFileURL(filepath), { 42 | encoding: 'utf8' 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/07_logos_add.yml: -------------------------------------------------------------------------------- 1 | name: '➕ logos/add' 2 | description: Request to add a channel logo into the database 3 | title: 'Add: ' 4 | labels: ['logos:add'] 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Please fill out the form as much as you can so we could efficiently process your request. 11 | 12 | - type: input 13 | id: channel_id 14 | attributes: 15 | label: Channel ID (required) 16 | description: ID of the channel to which this logo belongs 17 | placeholder: 'France3.fr' 18 | validations: 19 | required: true 20 | 21 | - type: input 22 | id: feed_id 23 | attributes: 24 | label: Feed ID 25 | description: ID of the feed to which this logo belongs 26 | placeholder: 'Alpes' 27 | 28 | - type: input 29 | id: logo_url 30 | attributes: 31 | label: Logo URL (required) 32 | description: "Logo URL. Supported formats: `PNG`, `JPEG`, `SVG`, `GIF`, `WebP`, `AVIF`, `APNG`. Only URLs with [HTTPS](https://ru.wikipedia.org/wiki/HTTPS) protocol are supported. The link should not be [geo-blocked](https://en.wikipedia.org/wiki/Geo-blocking)." 33 | placeholder: 'https://example.com/logo.png' 34 | validations: 35 | required: true 36 | 37 | - type: input 38 | id: tags 39 | attributes: 40 | label: Tags 41 | description: "List of keywords describing this version of the logo separated by `;`. May include: `a-z`, `0-9` and `-`." 42 | placeholder: 'horizontal;white' 43 | 44 | - type: markdown 45 | attributes: 46 | value: | 47 | `width`, `height` and `format` will be calculated automatically 48 | 49 | - type: textarea 50 | attributes: 51 | label: Notes 52 | description: 'Anything else we should know?' 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Database [![update](https://github.com/iptv-org/database/actions/workflows/update.yml/badge.svg)](https://github.com/iptv-org/database/actions/workflows/update.yml) 2 | 3 | User editable database for TV channels. 4 | 5 | ![channels.csv](.readme/preview.png) 6 | 7 | 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/), ...). 8 | 9 | ## API 10 | 11 | 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. 12 | 13 | ## Resources 14 | 15 | Links to other useful IPTV-related resources can be found in the [iptv-org/awesome-iptv](https://github.com/iptv-org/awesome-iptv) repository. 16 | 17 | ## Discussions 18 | 19 | If you have a question or an idea, you can post it in the [Discussions](https://github.com/orgs/iptv-org/discussions) tab. 20 | 21 | ## Contribution 22 | 23 | Please make sure to read the [Contributing Guide](CONTRIBUTING.md) before sending [issue](https://github.com/iptv-org/epg/issues) or a [pull request](https://github.com/iptv-org/epg/pulls). 24 | 25 | And thank you to everyone who has already contributed! 26 | 27 | ### Backers 28 | 29 | 30 | 31 | ### Contributors 32 | 33 | 34 | 35 | ## License 36 | 37 | [![CC0](http://mirrors.creativecommons.org/presskit/buttons/88x31/svg/cc-zero.svg)](LICENSE) 38 | -------------------------------------------------------------------------------- /scripts/models/category.ts: -------------------------------------------------------------------------------- 1 | import { Validator, ValidatorError } from '../types/validator' 2 | import { Collection } from '@freearhey/core' 3 | import { CSVRow } from '../types/utils' 4 | import * as sdk from '@iptv-org/sdk' 5 | import Joi from 'joi' 6 | 7 | export class Category extends sdk.Models.Category implements Validator { 8 | line: number = -1 9 | 10 | static fromRow(row: CSVRow): Category { 11 | if (!row.data.id) throw new Error('Category: "id" not specified') 12 | if (!row.data.name) throw new Error('Category: "name" not specified') 13 | if (!row.data.description) throw new Error('Category: "description" not specified') 14 | 15 | const category = new Category({ 16 | id: row.data.id.toString(), 17 | name: row.data.name.toString(), 18 | description: row.data.description.toString() 19 | }) 20 | 21 | category.line = row.line 22 | 23 | return category 24 | } 25 | 26 | getSchema() { 27 | return Joi.object({ 28 | id: Joi.string() 29 | .regex(/^[a-z]+$/) 30 | .required(), 31 | name: Joi.string() 32 | .regex(/^[A-Z]+$/i) 33 | .required(), 34 | description: Joi.string().required() 35 | }) 36 | } 37 | 38 | toCSVRecord(): Record { 39 | return this.toObject() as Record 40 | } 41 | 42 | validate(): Collection { 43 | const errors = new Collection() 44 | 45 | const joiResults = this.getSchema().validate(this.toObject(), { abortEarly: false }) 46 | if (joiResults.error) { 47 | joiResults.error.details.forEach((detail: { message: string }) => { 48 | errors.add({ line: this.line, message: `${this.id}: ${detail.message}` }) 49 | }) 50 | } 51 | 52 | return errors 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/11_cities_edit.yml: -------------------------------------------------------------------------------- 1 | name: '✏️ cities/edit' 2 | description: Request to edit city description 3 | title: 'Edit: ' 4 | labels: ['cities:edit'] 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Please specify exactly what should be changed. To delete an existing value without replacement use the `~` symbol. 11 | 12 | - type: input 13 | id: city_code 14 | attributes: 15 | label: City Code (required) 16 | description: The code of the city that should be updated 17 | placeholder: 'CNYAT' 18 | validations: 19 | required: true 20 | 21 | - type: input 22 | id: city_name 23 | attributes: 24 | label: City Name 25 | description: Official name of the city 26 | placeholder: 'Yantai' 27 | 28 | - type: input 29 | id: country 30 | attributes: 31 | label: Country 32 | description: Country code where the city is located. A list of all supported countries and their codes can be found in [data/countries.csv](https://github.com/iptv-org/database/blob/master/data/countries.csv) 33 | placeholder: 'CN' 34 | 35 | - type: input 36 | id: subdivision 37 | attributes: 38 | label: Subdivision 39 | description: Code of the subdivision in which the city is located. A list of all supported subdivisions and their codes can be found in [data/subdivisions.csv](https://github.com/iptv-org/database/blob/master/data/subdivisions.csv) 40 | placeholder: 'CN-SD' 41 | 42 | - type: input 43 | id: wikidata_id 44 | attributes: 45 | label: Wikidata ID 46 | description: ID of this city in [Wikidata](https://www.wikidata.org/wiki/Wikidata:Main_Page) 47 | placeholder: 'Q210493' 48 | 49 | - type: textarea 50 | attributes: 51 | label: Notes 52 | description: 'Anything else we should know?' 53 | -------------------------------------------------------------------------------- /data/categories.csv: -------------------------------------------------------------------------------- 1 | id,name,description 2 | auto,Auto,"Programming related to cars, motorcycles, and other automobiles" 3 | animation,Animation,Programming is mostly 2D or 3D animation 4 | business,Business,Programming related to business 5 | classic,Classic,Programming is mostly from earlier decades 6 | comedy,Comedy,Programming is mostly comedy 7 | cooking,Cooking,Programs related to cooking or food in general 8 | culture,Culture,Programming is mostly about art and culture 9 | documentary,Documentary,Programming that depicts a person or real-world event 10 | education,Education,Programming is intended to be educational 11 | entertainment,Entertainment,Channels with a variety of entertainment programs 12 | family,Family,Programming that is be suitable for all members of a family 13 | general,General,Provides a variety of different programming 14 | interactive,Interactive,Programming that allows viewers to actively engage with the content 15 | kids,Kids,Programming targeted to children 16 | legislative,Legislative,Programming specific to the operation of government 17 | lifestyle,Lifestyle,"Programs related to health, fitness, leisure, fashion, decor, etc." 18 | movies,Movies,Channels that only show movies 19 | music,Music,Programming is music or music related 20 | news,News,Programming is mostly news 21 | outdoor,Outdoor,"Programming related to outdoor activities like fishing, hunting, etc." 22 | public,Public,Non-commercial channels that prioritize public service 23 | relax,Relax,Programming is calm sounding and beautiful views 24 | religious,Religious,Religious programming 25 | series,Series,Science and Technology 26 | science,Science,Channels that only show series 27 | shop,Shop,Programming is for shopping 28 | sports,Sports,Programming is sports 29 | travel,Travel,Programming is travel related 30 | weather,Weather,Programming is focused on weather 31 | xxx,XXX,Programming is adult oriented and x-rated -------------------------------------------------------------------------------- /tests/__data__/input/db/update/data/categories.csv: -------------------------------------------------------------------------------- 1 | id,name,description 2 | auto,Auto,"Programming related to cars, motorcycles, and other automobiles" 3 | animation,Animation,Programming is mostly 2D or 3D animation 4 | business,Business,Programming related to business 5 | classic,Classic,Programming is mostly from earlier decades 6 | comedy,Comedy,Programming is mostly comedy 7 | cooking,Cooking,Programs related to cooking or food in general 8 | culture,Culture,Programming is mostly about art and culture 9 | documentary,Documentary,Programming that depicts a person or real-world event 10 | education,Education,Programming is intended to be educational 11 | entertainment,Entertainment,Channels with a variety of entertainment programs 12 | family,Family,Programming that is be suitable for all members of a family 13 | general,General,Provides a variety of different programming 14 | interactive,Interactive,Programming that allows viewers to actively engage with the content 15 | kids,Kids,Programming targeted to children 16 | legislative,Legislative,Programming specific to the operation of government 17 | lifestyle,Lifestyle,"Programs related to health, fitness, leisure, fashion, decor, etc." 18 | movies,Movies,Channels that only show movies 19 | music,Music,Programming is music or music related 20 | news,News,Programming is mostly news 21 | outdoor,Outdoor,"Programming related to outdoor activities like fishing, hunting, etc." 22 | public,Public,Non-commercial channels that prioritize public service 23 | relax,Relax,Programming is calm sounding and beautiful views 24 | religious,Religious,Religious programming 25 | series,Series,Science and Technology 26 | science,Science,Channels that only show series 27 | shop,Shop,Programming is for shopping 28 | sports,Sports,Programming is sports 29 | travel,Travel,Programming is travel related 30 | weather,Weather,Programming is focused on weather 31 | xxx,XXX,Programming is adult oriented and x-rated -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@iptv-org/database", 3 | "scripts": { 4 | "act:check": "act pull_request -W .github/workflows/check.yml", 5 | "act:update": "act workflow_dispatch -W .github/workflows/update.yml", 6 | "act:deploy": "act push -W .github/workflows/deploy.yml", 7 | "db:validate": "tsx scripts/commands/db/validate.ts", 8 | "db:export": "tsx scripts/commands/db/export.ts", 9 | "db:update": "tsx scripts/commands/db/update.ts", 10 | "lint": "npx eslint \"{scripts,tests}/**/*.{ts,js}\"", 11 | "test": "npx vitest run", 12 | "prepare": "husky" 13 | }, 14 | "private": true, 15 | "license": "UNLICENSED", 16 | "jest": { 17 | "testMatch": [ 18 | "/tests/commands/**/*.test.(js|ts)" 19 | ] 20 | }, 21 | "dependencies": { 22 | "@eslint/js": "^9.39.1", 23 | "@freearhey/core": "^0.15.0", 24 | "@freearhey/storage-js": "^0.2.0", 25 | "@iptv-org/sdk": "^1.1.0", 26 | "@joi/date": "^2.1.1", 27 | "@json2csv/formatters": "^7.0.6", 28 | "@json2csv/node": "^7.0.6", 29 | "@json2csv/transforms": "^7.0.6", 30 | "@octokit/core": "^7.0.3", 31 | "@octokit/plugin-paginate-rest": "^13.1.1", 32 | "@octokit/plugin-rest-endpoint-methods": "^16.0.0", 33 | "@stylistic/eslint-plugin": "^5.5.0", 34 | "@swc/core": "^1.15.2", 35 | "@types/fs-extra": "^11.0.4", 36 | "@types/probe-image-size": "^7.2.5", 37 | "@typescript-eslint/eslint-plugin": "^8.46.4", 38 | "async-es": "^3.2.6", 39 | "chalk": "^5.4.1", 40 | "commander": "^14.0.0", 41 | "cross-env": "^10.0.0", 42 | "csvtojson": "^2.0.10", 43 | "eslint": "^9.39.1", 44 | "eslint-config-prettier": "^10.1.8", 45 | "fs-extra": "^11.2.0", 46 | "glob": "^11.0.3", 47 | "globals": "^16.3.0", 48 | "husky": "^9.1.7", 49 | "joi": "^17.13.3", 50 | "probe-image-size": "^7.2.3", 51 | "tsx": "^4.20.3", 52 | "typescript": "^5.8.3", 53 | "vitest": "^4.0.9" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/10_cities_add.yml: -------------------------------------------------------------------------------- 1 | name: '➕ cities/add' 2 | description: Request to add a city into the database 3 | title: 'Add: ' 4 | labels: ['cities:add'] 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Please fill out the form as much as you can so we could efficiently process your request. 11 | 12 | - type: input 13 | id: country 14 | attributes: 15 | label: Country 16 | description: Country code where the city is located. A list of all supported countries and their codes can be found in [data/countries.csv](https://github.com/iptv-org/database/blob/master/data/countries.csv) 17 | placeholder: 'CN' 18 | validations: 19 | required: true 20 | 21 | - type: input 22 | id: subdivision 23 | attributes: 24 | label: Subdivision (optional) 25 | description: Code of the subdivision in which the city is located. A list of all supported subdivisions and their codes can be found in [data/subdivisions.csv](https://github.com/iptv-org/database/blob/master/data/subdivisions.csv) 26 | placeholder: 'CN-SD' 27 | 28 | - type: input 29 | id: city_name 30 | attributes: 31 | label: City Name 32 | description: Official name of the city 33 | placeholder: 'Yantai' 34 | validations: 35 | required: true 36 | 37 | - type: input 38 | id: city_code 39 | attributes: 40 | label: City Code 41 | description: [UN/LOCODE](https://en.wikipedia.org/wiki/UN/LOCODE) of the city. The complete list of codes can be found at [unlocode.info](https://unlocode.info/) 42 | placeholder: 'CNYAT' 43 | validations: 44 | required: true 45 | 46 | - type: input 47 | id: wikidata_id 48 | attributes: 49 | label: Wikidata ID 50 | description: ID of this city in [Wikidata](https://www.wikidata.org/wiki/Wikidata:Main_Page) 51 | placeholder: 'Q210493' 52 | validations: 53 | required: true 54 | 55 | - type: textarea 56 | attributes: 57 | label: Notes (optional) 58 | description: 'Anything else we should know?' 59 | -------------------------------------------------------------------------------- /.github/workflows/update.yml: -------------------------------------------------------------------------------- 1 | name: update 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | jobs: 7 | main: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: tibdex/github-app-token@v1.8.2 12 | if: ${{ !env.ACT }} 13 | id: create-app-token 14 | with: 15 | app_id: ${{ secrets.APP_ID }} 16 | private_key: ${{ secrets.APP_PRIVATE_KEY }} 17 | - uses: actions/checkout@v4 18 | if: ${{ !env.ACT }} 19 | with: 20 | token: ${{ steps.create-app-token.outputs.token }} 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: '22.12.0' 24 | cache: 'npm' 25 | - name: install dependencies 26 | run: npm install 27 | - name: update data 28 | id: db-update 29 | run: | 30 | npm run db:update 31 | echo "processed_issues=$(cat temp/logs/update.log)" >> $GITHUB_OUTPUT 32 | - name: validate changes 33 | run: npm run db:validate 34 | - name: check if files are crlf 35 | uses: kforeverisback/check-crlf-extended@v2 36 | continue-on-error: false 37 | with: 38 | directory: ./data 39 | line_ending_type: 'CRLF' 40 | include_pattern: '*.csv' 41 | pattern_type: 'shell_glob' 42 | - name: setup git 43 | run: | 44 | git config user.name "iptv-bot[bot]" 45 | git config user.email "84861620+iptv-bot[bot]@users.noreply.github.com" 46 | - run: git status 47 | - name: commit changes 48 | if: steps.db-update.outputs.processed_issues != 0 49 | run: | 50 | git add data/*.csv 51 | git status 52 | 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.processed_issues }}" --no-verify 53 | - name: push all changes to the repository 54 | if: ${{ !env.ACT && github.ref == 'refs/heads/master' }} 55 | run: git push 56 | -------------------------------------------------------------------------------- /scripts/models/blocklistRecord.ts: -------------------------------------------------------------------------------- 1 | import { Validator, ValidatorError } from '../types/validator' 2 | import { Collection, Dictionary } from '@freearhey/core' 3 | import { CSVRow } from '../types/utils' 4 | import * as sdk from '@iptv-org/sdk' 5 | import { Channel } from './channel' 6 | import { data } from '../core/db' 7 | import Joi from 'joi' 8 | 9 | export class BlocklistRecord extends sdk.Models.BlocklistRecord implements Validator { 10 | line: number = -1 11 | 12 | static fromRow(row: CSVRow): BlocklistRecord { 13 | if (!row.data.channel) throw new Error('BlocklistRecord: "channel" not specified') 14 | if (!row.data.reason) throw new Error('BlocklistRecord: "reason" not specified') 15 | if (!row.data.ref) throw new Error('BlocklistRecord: "ref" not specified') 16 | 17 | const record = new BlocklistRecord({ 18 | channel: row.data.channel.toString(), 19 | reason: row.data.reason.toString(), 20 | ref: row.data.ref.toString() 21 | }) 22 | 23 | record.line = row.line 24 | 25 | return record 26 | } 27 | 28 | hasValidChannelId(channelsKeyById: Dictionary): boolean { 29 | return channelsKeyById.has(this.channel) 30 | } 31 | 32 | getSchema() { 33 | return Joi.object({ 34 | channel: Joi.string() 35 | .regex(/^[A-Za-z0-9]+\.[a-z]{2}$/) 36 | .required(), 37 | reason: Joi.string() 38 | .valid(...['dmca', 'nsfw']) 39 | .required(), 40 | ref: Joi.string().uri().required() 41 | }) 42 | } 43 | 44 | toCSVRecord(): Record { 45 | return this.toObject() as Record 46 | } 47 | 48 | validate(): Collection { 49 | const { channelsKeyById } = data 50 | 51 | const errors = new Collection() 52 | 53 | const joiResults = this.getSchema().validate(this.toObject(), { abortEarly: false }) 54 | if (joiResults.error) { 55 | joiResults.error.details.forEach((detail: { message: string }) => { 56 | errors.add({ 57 | line: this.line, 58 | message: `${this.channel}: ${detail.message}` 59 | }) 60 | }) 61 | } 62 | 63 | if (!this.hasValidChannelId(channelsKeyById)) { 64 | errors.add({ 65 | line: this.line, 66 | message: `"${this.channel}" is missing from the channels.csv` 67 | }) 68 | } 69 | 70 | return errors 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /scripts/models/timezone.ts: -------------------------------------------------------------------------------- 1 | import { Validator, ValidatorError } from '../types/validator' 2 | import { Collection, Dictionary } from '@freearhey/core' 3 | import { CSVRow } from '../types/utils' 4 | import * as sdk from '@iptv-org/sdk' 5 | import { Country } from './country' 6 | import { data } from '../core/db' 7 | import Joi from 'joi' 8 | 9 | export class Timezone extends sdk.Models.Timezone implements Validator { 10 | line: number = -1 11 | 12 | static fromRow(row: CSVRow): Timezone { 13 | if (!row.data.id) throw new Error('Timezone: "id" not specified') 14 | if (!row.data.utc_offset) throw new Error('Timezone: "utc_offset" not specified') 15 | if (!row.data.countries) throw new Error('Timezone: "countries" not specified') 16 | 17 | const timezone = new Timezone({ 18 | id: row.data.id.toString(), 19 | utc_offset: row.data.utc_offset.toString(), 20 | countries: Array.isArray(row.data.countries) ? row.data.countries : [] 21 | }) 22 | 23 | timezone.line = row.line 24 | 25 | return timezone 26 | } 27 | 28 | hasValidCountryCodes(countriesKeyByCode: Dictionary): boolean { 29 | const hasInvalid = this.countries.find((code: string) => countriesKeyByCode.missing(code)) 30 | 31 | return !hasInvalid 32 | } 33 | 34 | getSchema() { 35 | return Joi.object({ 36 | id: Joi.string() 37 | .regex(/^[a-z-_/]+$/i) 38 | .required(), 39 | utc_offset: Joi.string() 40 | .regex(/^(\+|-)\d{2}:\d{2}$/) 41 | .required(), 42 | countries: Joi.array().items(Joi.string().regex(/^[A-Z]{2}$/)) 43 | }) 44 | } 45 | 46 | toCSVRecord(): Record { 47 | return this.toObject() as Record 48 | } 49 | 50 | validate(): Collection { 51 | const { countriesKeyByCode } = data 52 | 53 | const errors = new Collection() 54 | 55 | const joiResults = this.getSchema().validate(this.toObject(), { abortEarly: false }) 56 | if (joiResults.error) { 57 | joiResults.error.details.forEach((detail: { message: string }) => { 58 | errors.add({ line: this.line, message: `${this.id}: ${detail.message}` }) 59 | }) 60 | } 61 | 62 | if (!this.hasValidCountryCodes(countriesKeyByCode)) { 63 | errors.add({ 64 | line: this.line, 65 | message: `"${this.id}" has the wrong countries "${this.countries.join(';')}"` 66 | }) 67 | } 68 | 69 | return errors 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /scripts/models/region.ts: -------------------------------------------------------------------------------- 1 | import { Validator, ValidatorError } from '../types/validator' 2 | import { Collection, Dictionary } from '@freearhey/core' 3 | import { CSVRow } from '../types/utils' 4 | import * as sdk from '@iptv-org/sdk' 5 | import { Country } from './country' 6 | import { data } from '../core/db' 7 | import Joi from 'joi' 8 | 9 | export class Region extends sdk.Models.Region implements Validator { 10 | line: number = -1 11 | 12 | static fromRow(row: CSVRow): Region { 13 | if (!row.data.code) throw new Error('Region: "code" not specified') 14 | if (!row.data.name) throw new Error('Region: "name" not specified') 15 | if (!row.data.countries) throw new Error('Region: "countries" not specified') 16 | 17 | const region = new Region({ 18 | code: row.data.code.toString(), 19 | name: row.data.name.toString(), 20 | countries: Array.isArray(row.data.countries) ? row.data.countries : [] 21 | }) 22 | 23 | region.line = row.line 24 | 25 | return region 26 | } 27 | 28 | hasValidCountries(countriesKeyByCode: Dictionary): boolean { 29 | const hasInvalid = this.countries.find((code: string) => countriesKeyByCode.missing(code)) 30 | 31 | return !hasInvalid 32 | } 33 | 34 | getSchema() { 35 | return Joi.object({ 36 | name: Joi.string() 37 | .regex(/^[\sA-Z\u00C0-\u00FF().,-]+$/i) 38 | .required(), 39 | code: Joi.string() 40 | .regex(/^[A-Z]{2,7}$/) 41 | .required(), 42 | countries: Joi.array().items( 43 | Joi.string() 44 | .regex(/^[A-Z]{2}$/) 45 | .required() 46 | ) 47 | }) 48 | } 49 | 50 | toCSVRecord(): Record { 51 | return this.toObject() as Record 52 | } 53 | 54 | validate(): Collection { 55 | const { countriesKeyByCode } = data 56 | 57 | const errors = new Collection() 58 | 59 | const joiResults = this.getSchema().validate(this.toObject(), { abortEarly: false }) 60 | if (joiResults.error) { 61 | joiResults.error.details.forEach((detail: { message: string }) => { 62 | errors.add({ line: this.line, message: `${this.code}: ${detail.message}` }) 63 | }) 64 | } 65 | 66 | if (!this.hasValidCountries(countriesKeyByCode)) { 67 | errors.add({ 68 | line: this.line, 69 | message: `"${this.code}" has the wrong countries "${this.countries.join(';')}"` 70 | }) 71 | } 72 | 73 | return errors 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /scripts/models/country.ts: -------------------------------------------------------------------------------- 1 | import { Validator, ValidatorError } from '../types/validator' 2 | import { Collection, Dictionary } from '@freearhey/core' 3 | import { CSVRow } from '../types/utils' 4 | import { Language } from './language' 5 | import * as sdk from '@iptv-org/sdk' 6 | import Joi from 'joi' 7 | import { data } from '../core/db' 8 | 9 | export class Country extends sdk.Models.Country implements Validator { 10 | line: number = -1 11 | 12 | static fromRow(row: CSVRow): Country { 13 | if (!row.data.name) throw new Error('Country: "name" not specified') 14 | if (!row.data.code) throw new Error('Country: "code" not specified') 15 | if (!row.data.languages) throw new Error('Country: "languages" not specified') 16 | if (!row.data.flag) throw new Error('Country: "flag" not specified') 17 | 18 | const country = new Country({ 19 | name: row.data.name.toString(), 20 | code: row.data.code.toString(), 21 | languages: Array.isArray(row.data.languages) ? row.data.languages : [], 22 | flag: row.data.flag.toString() 23 | }) 24 | 25 | country.line = row.line 26 | 27 | return country 28 | } 29 | 30 | hasValidLanguageCodes(languagesKeyByCode: Dictionary): boolean { 31 | const hasInvalid = this.languages.find((code: string) => languagesKeyByCode.missing(code)) 32 | 33 | return !hasInvalid 34 | } 35 | 36 | getSchema() { 37 | return Joi.object({ 38 | name: Joi.string() 39 | .regex(/^[\sA-Z\u00C0-\u00FF().-]+$/i) 40 | .required(), 41 | code: Joi.string() 42 | .regex(/^[A-Z]{2}$/) 43 | .required(), 44 | languages: Joi.array().items( 45 | Joi.string() 46 | .regex(/^[a-z]{3}$/) 47 | .required() 48 | ), 49 | flag: Joi.string() 50 | .regex(/^[\uD83C][\uDDE6-\uDDFF][\uD83C][\uDDE6-\uDDFF]$/) 51 | .required() 52 | }) 53 | } 54 | 55 | toCSVRecord(): Record { 56 | return this.toObject() as Record 57 | } 58 | 59 | validate(): Collection { 60 | const { languagesKeyByCode } = data 61 | 62 | const errors = new Collection() 63 | 64 | const joiResults = this.getSchema().validate(this.toObject(), { abortEarly: false }) 65 | if (joiResults.error) { 66 | joiResults.error.details.forEach((detail: { message: string }) => { 67 | errors.add({ line: this.line, message: `${this.code}: ${detail.message}` }) 68 | }) 69 | } 70 | 71 | if (!this.hasValidLanguageCodes(languagesKeyByCode)) { 72 | errors.add({ 73 | line: this.line, 74 | message: `"${this.code}" has an invalid languages "${this.languages.join(';')}"` 75 | }) 76 | } 77 | 78 | return errors 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /scripts/models/subdivision.ts: -------------------------------------------------------------------------------- 1 | import { Validator, ValidatorError } from '../types/validator' 2 | import { Collection, Dictionary } from '@freearhey/core' 3 | import { CSVRow } from '../types/utils' 4 | import * as sdk from '@iptv-org/sdk' 5 | import { Country } from './country' 6 | import { data } from '../core/db' 7 | import Joi from 'joi' 8 | 9 | export class Subdivision extends sdk.Models.Subdivision implements Validator { 10 | line: number = -1 11 | 12 | static fromRow(row: CSVRow): Subdivision { 13 | if (!row.data.country) throw new Error('Subdivision: "country" not specified') 14 | if (!row.data.code) throw new Error('Subdivision: "code" not specified') 15 | if (!row.data.name) throw new Error('Subdivision: "name" not specified') 16 | 17 | const subdivision = new Subdivision({ 18 | country: row.data.country.toString(), 19 | code: row.data.code.toString(), 20 | name: row.data.name.toString(), 21 | parent: row.data.parent ? row.data.parent.toString() : null 22 | }) 23 | 24 | subdivision.line = row.line 25 | 26 | return subdivision 27 | } 28 | 29 | hasValidCountryCode(countriesKeyByCode: Dictionary): boolean { 30 | return countriesKeyByCode.has(this.country) 31 | } 32 | 33 | hasValidParent(subdivisionsKeyByCode: Dictionary): boolean { 34 | if (!this.parent) return true 35 | 36 | return subdivisionsKeyByCode.has(this.parent) 37 | } 38 | 39 | getSchema() { 40 | return Joi.object({ 41 | country: Joi.string() 42 | .regex(/^[A-Z]{2}$/) 43 | .required(), 44 | name: Joi.string().required(), 45 | code: Joi.string() 46 | .regex(/^[A-Z]{2}-[A-Z0-9]{1,3}$/) 47 | .required(), 48 | parent: Joi.string() 49 | .regex(/^[A-Z]{2}-[A-Z0-9]{1,3}$/) 50 | .allow(null) 51 | }) 52 | } 53 | 54 | toCSVRecord(): Record { 55 | return this.toObject() as Record 56 | } 57 | 58 | validate(): Collection { 59 | const { countriesKeyByCode, subdivisionsKeyByCode } = data 60 | 61 | const errors = new Collection() 62 | 63 | const joiResults = this.getSchema().validate(this.toObject(), { abortEarly: false }) 64 | if (joiResults.error) { 65 | joiResults.error.details.forEach((detail: { message: string }) => { 66 | errors.add({ 67 | line: this.line, 68 | message: `${this.code}: ${detail.message}` 69 | }) 70 | }) 71 | } 72 | 73 | if (!this.hasValidCountryCode(countriesKeyByCode)) { 74 | errors.add({ 75 | line: this.line, 76 | message: `"${this.code}" has an invalid country "${this.country}"` 77 | }) 78 | } 79 | 80 | if (!this.hasValidParent(subdivisionsKeyByCode)) { 81 | errors.add({ 82 | line: this.line, 83 | message: `"${this.code}" has an invalid parent "${this.parent}"` 84 | }) 85 | } 86 | 87 | return errors 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/05_feeds_edit.yml: -------------------------------------------------------------------------------- 1 | name: '✏️ feeds/edit' 2 | description: Request to edit feed description 3 | title: 'Edit: ' 4 | labels: ['feeds:edit'] 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Please specify exactly what should be changed. To delete an existing value without replacement use the `~` symbol. 11 | 12 | - type: input 13 | id: channel_id 14 | attributes: 15 | label: Channel ID (required) 16 | description: ID of the channel to which this feed belongs 17 | placeholder: 'France3.fr' 18 | validations: 19 | required: true 20 | 21 | - type: input 22 | id: feed_id 23 | attributes: 24 | label: Feed ID (required) 25 | description: The ID of the feed that should be updated 26 | placeholder: 'Mediterranee' 27 | validations: 28 | required: true 29 | 30 | - type: input 31 | id: feed_name 32 | attributes: 33 | label: Feed Name 34 | description: "Unique for this channel feed name in English. For example: `HD`, `East`, `French`, `+1`, etc. May include: `a-z`, `0-9`, `space`, `-`, `!`, `:`, `&`, `.`, `+`, `'`, `/`, `»`, `#`, `%`, `°`, `$`, `@`, `?`, `|`, `¡`" 35 | placeholder: 'Mediterranee HD' 36 | 37 | - type: input 38 | id: alt_names 39 | attributes: 40 | label: Alternative Names 41 | description: List of alternative feed names separated by `;`. May contain any characters except `,` and `"` 42 | placeholder: 'Méditerranée HD' 43 | 44 | - type: dropdown 45 | id: is_main 46 | attributes: 47 | label: Main Feed 48 | description: Indicates if this feed is the main for the channel 49 | options: 50 | - 'FALSE' 51 | - 'TRUE' 52 | 53 | - type: input 54 | id: broadcast_area 55 | attributes: 56 | label: Broadcast Area 57 | description: "List of codes describing the broadcasting area of the feed separated by `;`. Any combination of `r/`, `c/`, `s/`, `ct/`. A full list of supported codes can be found here: [data/regions.csv](https://github.com/iptv-org/database/blob/master/data/regions.csv), [data/countries.csv](https://github.com/iptv-org/database/blob/master/data/countries.csv), [data/subdivisions.csv](https://github.com/iptv-org/database/blob/master/data/subdivisions.csv), [data/cities.csv](https://github.com/iptv-org/database/blob/master/data/cities.csv)" 58 | placeholder: 's/FR-IDF' 59 | 60 | - type: input 61 | id: timezones 62 | attributes: 63 | label: Timezones 64 | description: List of broadcast time zones separated by `;`. A list of all supported timezones and their codes can be found in [data/timezones.csv](https://github.com/iptv-org/database/blob/master/data/timezones.csv) 65 | placeholder: 'Europe/Paris' 66 | 67 | - type: input 68 | id: languages 69 | attributes: 70 | label: Languages 71 | description: List of languages in which the feed is broadcast separated by `;`. A list of all supported languages and their codes can be found in [data/languages.csv](https://github.com/iptv-org/database/blob/master/data/languages.csv) 72 | placeholder: 'fra;eng' 73 | 74 | - type: dropdown 75 | id: format 76 | attributes: 77 | label: Format 78 | description: Video format of the broadcast 79 | options: 80 | - '4320p' 81 | - '2160p' 82 | - '1080p' 83 | - '1080i' 84 | - '720p' 85 | - '576p' 86 | - '576i' 87 | - '480p' 88 | - '480i' 89 | - '360p' 90 | - '240p' 91 | 92 | - type: textarea 93 | attributes: 94 | label: Notes 95 | description: 'Anything else we should know?' 96 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/04_feeds_add.yml: -------------------------------------------------------------------------------- 1 | name: '➕ feeds/add' 2 | description: Request to add a channel feed into the database 3 | title: 'Add: ' 4 | labels: ['feeds:add'] 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Please fill out the issue form as much as you can so we could efficiently process your request. 11 | 12 | - type: input 13 | id: channel_id 14 | attributes: 15 | label: Channel ID (required) 16 | description: ID of the channel to which this feed belongs 17 | placeholder: 'France3.fr' 18 | validations: 19 | required: true 20 | 21 | - type: input 22 | id: feed_name 23 | attributes: 24 | label: Feed Name 25 | description: "Unique for this channel feed name in English. For example: `HD`, `East`, `French`, `+1`, etc. May include: `a-z`, `0-9`, `space`, `-`, `!`, `:`, `&`, `.`, `+`, `'`, `/`, `»`, `#`, `%`, `°`, `$`, `@`, `?`, `|`, `¡`" 26 | placeholder: 'Mediterranee' 27 | validations: 28 | required: true 29 | 30 | - type: input 31 | id: alt_name 32 | attributes: 33 | label: Alternative Names 34 | description: List of alternative feed names separated by `;`. May contain any characters except `,` and `"` 35 | placeholder: 'Méditerranée;Mediterranean' 36 | 37 | - type: dropdown 38 | id: is_main 39 | attributes: 40 | label: Main Feed 41 | description: Indicates if this feed is the main for the channel 42 | options: 43 | - 'FALSE' 44 | - 'TRUE' 45 | validations: 46 | required: true 47 | 48 | - type: input 49 | id: broadcast_area 50 | attributes: 51 | label: Broadcast Area 52 | description: "List of codes describing the broadcasting area of the feed separated by `;`. Any combination of `r/`, `c/`, `s/`, `ct/`. A full list of supported codes can be found here: [data/regions.csv](https://github.com/iptv-org/database/blob/master/data/regions.csv), [data/countries.csv](https://github.com/iptv-org/database/blob/master/data/countries.csv), [data/subdivisions.csv](https://github.com/iptv-org/database/blob/master/data/subdivisions.csv), [data/cities.csv](https://github.com/iptv-org/database/blob/master/data/cities.csv)" 53 | placeholder: 's/FR-IDF' 54 | validations: 55 | required: true 56 | 57 | - type: input 58 | id: timezones 59 | attributes: 60 | label: Timezones 61 | description: List of broadcast time zones separated by `;`. A list of all supported timezones and their codes can be found in [data/timezones.csv](https://github.com/iptv-org/database/blob/master/data/timezones.csv) 62 | placeholder: 'Europe/Paris' 63 | validations: 64 | required: true 65 | 66 | - type: input 67 | id: languages 68 | attributes: 69 | label: Languages 70 | description: List of languages in which the feed is broadcast separated by `;`. A list of all supported languages and their codes can be found in [data/languages.csv](https://github.com/iptv-org/database/blob/master/data/languages.csv) 71 | placeholder: 'fra;eng' 72 | validations: 73 | required: true 74 | 75 | - type: dropdown 76 | id: format 77 | attributes: 78 | label: Format 79 | description: Video format of the broadcast 80 | default: 6 81 | options: 82 | - '4320p' 83 | - '2160p' 84 | - '1080p' 85 | - '1080i' 86 | - '720p' 87 | - '576p' 88 | - '576i' 89 | - '480p' 90 | - '480i' 91 | - '360p' 92 | - '240p' 93 | validations: 94 | required: true 95 | 96 | - type: textarea 97 | attributes: 98 | label: Notes 99 | description: 'Anything else we should know?' 100 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/02_channels_edit.yml: -------------------------------------------------------------------------------- 1 | name: '✏️ channels/edit' 2 | description: Request to edit channel description 3 | title: 'Edit: ' 4 | labels: ['channels:edit'] 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Please specify exactly what should be changed. To delete an existing value without replacement use the `~` symbol. To edit `broadcast_area`, `languages`, `timezones` and `formats` use this [form](https://github.com/iptv-org/database/issues/new?assignees=&labels=feeds%3Aedit&projects=&template=05_feeds_edit.yml&title=Edit%3A+). To edit `logo` use this [form](https://github.com/iptv-org/database/issues/new?assignees=&labels=logos%3Aedit&projects=&template=08_logos_edit.yml&title=Edit%3A+). 11 | 12 | - type: input 13 | id: id 14 | attributes: 15 | label: Channel ID (required) 16 | description: The ID of the channel that should be updated 17 | placeholder: 'AnhuiTV.cn' 18 | validations: 19 | required: true 20 | 21 | - type: input 22 | id: name 23 | attributes: 24 | label: Channel Name 25 | description: "Official channel name in English or call sign. May include: `a-z`, `0-9`, `space`, `-`, `!`, `:`, `&`, `.`, `+`, `'`, `/`, `»`, `#`, `%`, `°`, `$`, `@`, `?`, `|`, `¡`" 26 | placeholder: 'Anhui TV' 27 | 28 | - type: input 29 | id: alt_names 30 | attributes: 31 | label: Alternative Names 32 | description: List of alternative channel names separated by `;`. May contain any characters except `,` and `"` 33 | placeholder: '安徽卫视' 34 | 35 | - type: input 36 | id: network 37 | attributes: 38 | label: Network 39 | description: Network of which this channel is a part. May contain any characters except `,` and `"` 40 | placeholder: 'Anhui' 41 | 42 | - type: input 43 | id: owners 44 | attributes: 45 | label: Owners 46 | description: List of channel owners separated by `;`. May contain any characters except `,` and `"` 47 | placeholder: 'China Central Television' 48 | 49 | - type: input 50 | id: country 51 | attributes: 52 | label: Country 53 | description: Country code from which the channel is transmitted. A list of all supported countries and their codes can be found in [data/countries.csv](https://github.com/iptv-org/database/blob/master/data/countries.csv) 54 | placeholder: 'CN' 55 | 56 | - type: input 57 | id: categories 58 | attributes: 59 | label: Categories 60 | description: List of categories to which this channel belongs separated by `;`. A list of all supported categories can be found in [data/categories.csv](https://github.com/iptv-org/database/blob/master/data/categories.csv) 61 | placeholder: 'animation;kids' 62 | 63 | - type: dropdown 64 | id: is_nsfw 65 | attributes: 66 | label: NSFW 67 | description: Indicates whether the channel broadcasts adult content 68 | options: 69 | - 'FALSE' 70 | - 'TRUE' 71 | 72 | - type: input 73 | id: launched 74 | attributes: 75 | label: Launched (optional) 76 | description: Launch date of the channel (`YYYY-MM-DD`) 77 | placeholder: '2016-07-28' 78 | 79 | - type: input 80 | id: closed 81 | attributes: 82 | label: Closed (optional) 83 | description: Date on which the channel closed (`YYYY-MM-DD`) 84 | placeholder: '2020-05-31' 85 | 86 | - type: input 87 | id: replaced_by 88 | attributes: 89 | label: Replaced By (optional) 90 | description: The ID of the channel that this channel was replaced by 91 | placeholder: 'CCTV1.cn' 92 | 93 | - type: input 94 | id: website 95 | attributes: 96 | label: Website 97 | description: Official website URL 98 | placeholder: 'http://www.ahtv.cn/' 99 | 100 | - type: textarea 101 | id: notes 102 | attributes: 103 | label: Notes 104 | description: 'Anything else we should know?' 105 | -------------------------------------------------------------------------------- /scripts/models/logo.ts: -------------------------------------------------------------------------------- 1 | import { Validator, ValidatorError } from '../types/validator' 2 | import { IssueData } from '../models/issueData' 3 | import { Collection } from '@freearhey/core' 4 | import { CSVRow } from '../types/utils' 5 | import * as sdk from '@iptv-org/sdk' 6 | import { data } from '../core/db' 7 | import Joi from 'joi' 8 | 9 | export class Logo extends sdk.Models.Logo implements Validator { 10 | line: number = -1 11 | 12 | static fromRow(row: CSVRow): Logo { 13 | if (!row.data.channel) throw new Error('Logo: "channel" not specified') 14 | if (!row.data.url) throw new Error('Logo: "url" not specified') 15 | 16 | const logo = new Logo({ 17 | channel: row.data.channel.toString(), 18 | feed: row.data.feed ? row.data.feed.toString() : null, 19 | url: row.data.url.toString(), 20 | tags: Array.isArray(row.data.tags) ? row.data.tags : [], 21 | width: typeof row.data.width === 'number' ? row.data.width : 0, 22 | height: typeof row.data.height === 'number' ? row.data.height : 0, 23 | format: row.data.format ? row.data.format.toString() : null 24 | }) 25 | 26 | logo.line = row.line 27 | 28 | return logo 29 | } 30 | 31 | update(issueData: IssueData): this { 32 | const data = { 33 | tags: issueData.getArray('tags'), 34 | width: issueData.getNumber('width'), 35 | height: issueData.getNumber('height'), 36 | format: issueData.getString('format') 37 | } 38 | 39 | if (data.tags !== undefined) this.tags = data.tags 40 | if (data.width !== undefined) this.width = data.width 41 | if (data.height !== undefined) this.height = data.height 42 | if (data.format !== undefined) this.format = data.format 43 | 44 | return this 45 | } 46 | 47 | hasValidChannelId(): boolean { 48 | return data.channelsKeyById.has(this.channel) 49 | } 50 | 51 | hasValidFeedId(): boolean { 52 | return this.feed ? data.feedsKeyByStreamId.has(this.getStreamId()) : true 53 | } 54 | 55 | getSchema() { 56 | return Joi.object({ 57 | channel: Joi.string() 58 | .regex(/^[A-Za-z0-9]+\.[a-z]{2}$/) 59 | .required(), 60 | feed: Joi.string() 61 | .regex(/^[A-Za-z0-9]+$/) 62 | .allow(null), 63 | tags: Joi.array().items(Joi.string().regex(/^[a-z0-9-]+$/i)), 64 | width: Joi.number().required(), 65 | height: Joi.number().required(), 66 | format: Joi.string().valid('SVG', 'PNG', 'JPEG', 'GIF', 'WebP', 'AVIF', 'APNG').allow(null), 67 | url: Joi.string() 68 | .regex(/,/, { invert: true }) 69 | .uri({ 70 | scheme: ['https'] 71 | }) 72 | .required() 73 | }) 74 | } 75 | 76 | toCSVRecord(): Record { 77 | return { 78 | channel: this.channel, 79 | feed: this.feed || '', 80 | tags: this.tags, 81 | width: typeof this.width === 'number' ? this.width.toString() : '', 82 | height: typeof this.height === 'number' ? this.height.toString() : '', 83 | format: this.format || '', 84 | url: this.url 85 | } 86 | } 87 | 88 | validate(): Collection { 89 | const errors = new Collection() 90 | 91 | const joiResults = this.getSchema().validate(this.toObject(), { abortEarly: false }) 92 | if (joiResults.error) { 93 | joiResults.error.details.forEach((detail: { message: string }) => { 94 | errors.add({ line: this.line, message: `${this.url}: ${detail.message}` }) 95 | }) 96 | } 97 | 98 | if (!this.hasValidChannelId()) { 99 | errors.add({ 100 | line: this.line, 101 | message: `Channel with id "${this.channel}" is missing from the channels.csv` 102 | }) 103 | } 104 | 105 | if (!this.hasValidFeedId()) { 106 | errors.add({ 107 | line: this.line, 108 | message: `Feed with channel "${this.channel}" and id "${this.feed}" is missing from the feeds.csv` 109 | }) 110 | } 111 | 112 | return errors 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /scripts/models/city.ts: -------------------------------------------------------------------------------- 1 | import { Validator, ValidatorError } from '../types/validator' 2 | import { Collection, Dictionary } from '@freearhey/core' 3 | import { Subdivision } from './subdivision' 4 | import { CSVRow } from '../types/utils' 5 | import { IssueData } from './issueData' 6 | import * as sdk from '@iptv-org/sdk' 7 | import { Country } from './country' 8 | import { data } from '../core/db' 9 | import Joi from 'joi' 10 | 11 | export class City extends sdk.Models.City implements Validator { 12 | line: number = -1 13 | 14 | static fromRow(row: CSVRow): City { 15 | if (!row.data.code) throw new Error('City: "code" not specified') 16 | if (!row.data.name) throw new Error('City: "name" not specified') 17 | if (!row.data.country) throw new Error('City: "country" not specified') 18 | if (!row.data.wikidata_id) throw new Error('City: "wikidata_id" not specified') 19 | 20 | const city = new City({ 21 | code: row.data.code.toString(), 22 | name: row.data.name.toString(), 23 | country: row.data.country.toString(), 24 | subdivision: row.data.subdivision ? row.data.subdivision.toString() : null, 25 | wikidata_id: row.data.wikidata_id.toString() 26 | }) 27 | 28 | city.line = row.line 29 | 30 | return city 31 | } 32 | 33 | update(issueData: IssueData): this { 34 | const data = { 35 | name: issueData.getString('city_name'), 36 | country: issueData.getString('country'), 37 | subdivision: issueData.getString('subdivision'), 38 | wikidata_id: issueData.getString('wikidata_id') 39 | } 40 | 41 | if (data.name !== undefined) this.name = data.name 42 | if (data.country !== undefined) this.country = data.country 43 | if (data.subdivision !== undefined) this.subdivision = data.subdivision 44 | if (data.wikidata_id !== undefined) this.wikidata_id = data.wikidata_id 45 | 46 | return this 47 | } 48 | 49 | hasValidCountryCode(countriesKeyByCode: Dictionary) { 50 | return countriesKeyByCode.has(this.country) 51 | } 52 | 53 | hasValidSubdivisionCode(subdivisionsKeyByCode: Dictionary) { 54 | if (!this.subdivision) return true 55 | 56 | return subdivisionsKeyByCode.has(this.subdivision) 57 | } 58 | 59 | getSchema() { 60 | return Joi.object({ 61 | country: Joi.string() 62 | .regex(/^[A-Z]{2}$/) 63 | .required(), 64 | subdivision: Joi.string() 65 | .regex(/^[A-Z]{2}-[A-Z0-9]{1,3}$/) 66 | .allow(null), 67 | name: Joi.string().required(), 68 | code: Joi.string() 69 | .regex( 70 | /(A[D-GILMOQ-UWXZ]|B[ABIE-HDJLM-OQR-TWYZ]|C[ACDF-IK-ORU-Z]|D[EJKMOZ]|E[CEGHRST]|F[I-KMOR]|JE|G[ABD-IL-UWY]|H[KMNRTU]|I[DELMNOQRST]|J[MOP]|K[EGHIMNPRWY]|K[YZ]|L[ABCIKR-VY]|M[AC-HKL-Z]|N[ACE-ILO-PRUZ]|OM|[P-Z][A-Z]+)[A-Z0-9]{3}/ 71 | ) 72 | .required(), 73 | wikidata_id: Joi.string() 74 | .regex(/^Q\d+$/) 75 | .required() 76 | }) 77 | } 78 | 79 | toCSVRecord(): Record { 80 | return { 81 | country: this.country, 82 | subdivision: this.subdivision || '', 83 | code: this.code, 84 | name: this.name, 85 | wikidata_id: this.wikidata_id 86 | } 87 | } 88 | 89 | validate(): Collection { 90 | const { countriesKeyByCode, subdivisionsKeyByCode } = data 91 | 92 | const errors = new Collection() 93 | 94 | const joiResults = this.getSchema().validate(this.toObject(), { abortEarly: false }) 95 | if (joiResults.error) { 96 | joiResults.error.details.forEach((detail: { message: string }) => { 97 | errors.add({ 98 | line: this.line, 99 | message: `${this.code}: ${detail.message}` 100 | }) 101 | }) 102 | } 103 | 104 | if (!this.hasValidCountryCode(countriesKeyByCode)) { 105 | errors.add({ 106 | line: this.line, 107 | message: `"${this.code}" has an invalid country "${this.country}"` 108 | }) 109 | } 110 | 111 | if (!this.hasValidSubdivisionCode(subdivisionsKeyByCode)) { 112 | errors.add({ 113 | line: this.line, 114 | message: `"${this.code}" has an invalid subdivision "${this.subdivision}"` 115 | }) 116 | } 117 | 118 | return errors 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /data/regions.csv: -------------------------------------------------------------------------------- 1 | code,name,countries 2 | AFR,Africa,AO;BF;BI;BJ;BW;CD;CF;CG;CI;CM;CV;DJ;DZ;EG;EH;ER;ET;GA;GH;GM;GN;GQ;GW;KE;KM;LR;LS;LY;MA;MG;ML;MR;MU;MW;MZ;NA;NE;NG;RE;RW;SC;SD;SH;SL;SN;SO;SS;ST;SZ;TD;TF;TG;TN;TZ;UG;YT;ZA;ZM;ZW 3 | AMER,Americas,AG;AI;AR;AW;BB;BL;BM;BO;BR;BS;BV;BZ;CA;CL;CO;CR;CU;CW;DM;DO;EC;FK;GD;GF;GL;GP;GS;GT;GY;HN;HT;JM;KN;KY;LC;MF;MQ;MS;MX;NI;PA;PE;PM;PR;PY;SR;SV;SX;TC;TT;US;UY;VC;VE;VG;VI 4 | APAC,Asia-Pacific,AF;AS;AU;BD;BN;BT;CK;CN;FJ;FM;GU;ID;IN;JP;KH;KI;KP;KR;LA;LK;MH;MM;MN;MP;MV;MY;NC;NF;NP;NR;NU;NZ;PF;PG;PH;PK;PN;PW;SB;SG;TH;TK;TL;TO;TV;TW;VN;VU;WF;WS 5 | ARAB,Arab world,AE;BH;DJ;DZ;EG;IQ;JO;KM;KW;LB;LY;MA;MR;OM;PS;QA;SA;SD;SO;SY;TN;YE 6 | ASEAN,Association of Southeast Asian Nations,BN;KH;ID;LA;MY;MM;PH;SG;TH;VN 7 | ASIA,Asia,AE;AF;AM;AZ;BD;BH;BN;BT;CN;CY;GE;ID;IL;IN;IQ;IR;JO;JP;KG;KH;KP;KR;KW;KZ;LA;LB;LK;MM;MN;MV;MY;NP;OM;PH;PK;PS;QA;RU;SA;SG;SY;TH;TJ;TL;TM;TR;TW;UZ;VN;YE 8 | BALKAN,Balkan,AL;BA;BG;HR;GR;XK;ME;MK;RO;RS;SI;TR 9 | BENELUX,Benelux,BE;LU;NL 10 | CARIB,Caribbean,AG;AI;AW;BB;BL;BS;CU;CW;DM;DO;GD;GP;HT;JM;KN;KY;LC;MF;MQ;MS;PR;SX;TC;TT;VC;VG;VI 11 | CAS,Central Asia,KG;KZ;TJ;TM;UZ 12 | CEE,Central and Eastern Europe,AL;AM;AZ;BY;GE;MD;RU;UA;BA;BG;HR;CZ;HU;XK;ME;MK;PL;RO;RS;SK;SI 13 | CENAMER,Central America,BZ;CR;SV;GT;HN;NI;PA 14 | CEU,Central Europe,AT;HR;CZ;DE;HU;IT;PL;SK;SI 15 | CIS,Commonwealth of Independent States,AM;AZ;BY;KG;KZ;MD;RU;TJ;UZ 16 | EAF,East Africa,BI;KE;TZ;UG;DJ;ET;SO;KM;TF;MG;MU;IO;YT;RE;SC;SS;MW;MZ;ZM;ZW 17 | EAS,East Asia,CN;HK;MO;JP;MN;KP;KR;TW 18 | EMEA,"Europe, the Middle East and Africa",AD;AE;AL;AM;AO;AT;AZ;BA;BE;BF;BG;BH;BI;BJ;BW;BY;CD;CF;CG;CH;CI;CM;CV;CY;CZ;DE;DJ;DK;DZ;EE;EG;EH;ER;ES;ET;FI;FR;GA;GE;GH;GM;GN;GQ;GR;GW;HR;HU;IE;IQ;IR;IS;IT;JO;KE;KM;KW;KZ;LB;LI;LR;LS;LT;LU;LV;LY;MA;MC;MD;ME;MG;MK;ML;MR;MT;MU;MW;MZ;NA;NE;NG;NL;NO;OM;PL;PS;PT;QA;RE;RO;RS;RU;RW;SA;SC;SD;SE;SH;SI;SK;SL;SM;SN;SO;SS;ST;SY;SZ;TD;TF;TG;TN;TR;TZ;UA;UG;UK;VA;YE;YT;ZA;ZM;ZW 19 | EU,European Union,AT;BE;BG;CY;CZ;DE;DK;EE;ES;FI;FR;GR;HR;HU;IE;IT;LT;LU;LV;MT;NL;PL;PT;RO;SE;SI;SK 20 | EUR,Europe,AD;AL;AM;AT;AZ;BA;BE;BG;BY;CH;CY;CZ;DE;DK;EE;ES;FI;FR;GE;GR;HR;HU;IE;IS;IT;KZ;LI;LT;LU;LV;MC;MD;ME;MK;MT;NL;NO;PL;PT;RO;RS;RU;SE;SI;SK;SM;TR;UA;UK;VA 21 | GCC,Gulf Cooperation Council,AE;BH;KW;OM;QA;SA 22 | HISPAM,Hispanic America,AR;BO;CL;CO;CR;CU;DO;EC;GT;HN;MX;NI;PA;PE;PR;PY;SV;UY;VE 23 | LAC,Latin America and the Caribbean,AG;AI;AR;AW;BB;BL;BO;BR;BS;CL;CO;CR;CU;CW;DM;DO;EC;GD;GF;GP;GT;HN;HT;JM;KN;KY;LC;MF;MQ;MS;MX;NI;PA;PE;PR;PY;SV;SX;TC;TT;UY;VC;VE;VG;VI 24 | LATAM,Latin America,AR;BL;BO;BR;CL;CO;CR;CU;DO;EC;GF;GP;GT;HN;HT;MF;MQ;MX;NI;PA;PE;PR;PY;SV;UY;VE 25 | MAGHREB,Maghreb,DZ;LY;MA;MR;TN 26 | MENA,Middle East and North Africa,AE;BH;CY;DJ;DZ;EG;EH;IL;IQ;IR;JO;KW;LB;LY;MA;OM;PS;QA;SA;SD;SY;TN;TR;YE 27 | MIDEAST,Middle East,AE;BH;CY;EG;IL;IQ;IR;JO;KW;LB;OM;PS;QA;SA;SY;TR;YE 28 | NAM,Northern America,BM;CA;GL;PM;US 29 | NEU,Northern Europe,DK;EE;LV;LT;FI;IS;NO;SE 30 | NORAM,North America,AG;AI;AW;BB;BL;BM;BS;BZ;CA;CR;CU;CW;DM;DO;GD;GL;GP;GT;HN;HT;JM;KN;KY;LC;MF;MQ;MS;MX;NI;PA;PM;PR;SV;SX;TC;TT;US;VC;VG;VI 31 | NORD,Nordics,AX;DK;FO;FI;IS;NO;SE 32 | OCE,Oceania,AS;AU;CK;FJ;FM;GU;KI;MH;MP;NC;NF;NR;NU;NZ;PF;PG;PN;PW;SB;TK;TO;TV;VU;WF;WS 33 | SAF,Southern Africa,BW;SZ;LS;NA;ZA 34 | SAS,South Asia,AF;BD;BT;IN;LK;MV;NP;PK 35 | SEA,Southeast Asia,BN;KH;TL;ID;LA;MY;MM;PH;SG;TH;VN 36 | SER,Southern Europe,CY;GR;IT;VA;IT;MT;PT;SM;ES;TR 37 | SOUTHAM,South America,AR;BO;BR;CL;CO;EC;PY;PE;UY;VE;BV;FK;GF;GY;GS;SR 38 | SSA,Sub-Saharan Africa,AO;BF;BI;BJ;BW;CD;CF;CG;CI;CM;CV;DJ;ER;ET;GA;GH;GM;GN;GQ;GW;KE;KM;LR;LS;MG;ML;MR;MU;MW;MZ;NA;NE;NG;RW;SC;SD;SL;SN;SO;SS;ST;SZ;TD;TG;TZ;UG;ZA;ZM;ZW 39 | UN,United Nations,AF;AL;DZ;AD;AO;AG;AR;AM;AU;AT;AZ;BH;BD;BB;BY;BE;BZ;BJ;BT;BO;BA;BW;BR;BN;BG;BF;BI;KH;CM;CA;CV;CF;TD;CL;CO;KM;CR;HR;CU;CY;CZ;CD;DJ;DM;DO;EC;EG;SV;GQ;ER;EE;SZ;ET;FM;FJ;FI;FR;GA;GE;DE;GH;GR;GD;GT;GN;GW;GY;HT;HN;HU;IS;IN;ID;IR;IQ;IE;IL;IT;CI;JM;JP;JO;KZ;KE;NL;KI;KW;KG;LA;LV;LB;LS;LR;LY;LI;LT;LU;MG;MW;MY;MV;ML;MT;MH;MR;MU;MX;MD;MC;MN;ME;MA;MZ;MM;NA;NR;NP;NZ;NI;NE;NG;KP;MK;NO;OM;PK;PW;PA;PG;PY;CN;PE;PH;PL;PT;QA;CG;RO;RU;RW;KN;LC;VC;WS;SM;SA;SN;RS;SC;SL;SG;SK;SI;SB;SO;ZA;KR;SS;ES;LK;SD;SR;SE;CH;SY;ST;TW;TJ;TZ;TH;BS;GM;TL;TG;TO;TT;TN;TR;TM;TV;UG;UA;AE;UK;US;UY;UZ;VU;VE;VN;YE;ZM;ZW 40 | WAF,West Africa,BF;BJ;CI;CV;GH;GM;GN;GW;LR;ML;MR;NE;NG;SH;SL;SN;TG 41 | WAS,West Asia,AM;AZ;BH;CY;EG;GE;IR;IQ;IL;JO;KW;LB;OM;PS;QA;SA;SY;TR;AE;YE 42 | WER,Western Europe,AD;AT;BE;FR;DE;IE;LI;LU;MC;NL;CH;UK 43 | WW,Worldwide,AD;AE;AF;AG;AI;AL;AM;AO;AQ;AR;AS;AT;AU;AW;AX;AZ;BA;BB;BD;BE;BF;BG;BH;BI;BJ;BL;BM;BN;BO;BQ;BR;BS;BT;BV;BW;BY;BZ;CA;CC;CD;CF;CG;CH;CI;CK;CL;CM;CN;CO;CR;CU;CV;CW;CX;CY;CZ;DE;DJ;DK;DM;DO;DZ;EC;EE;EG;EH;ER;ES;ET;FI;FJ;FK;FM;FO;FR;GA;UK;GD;GE;GF;GG;GH;GI;GL;GM;GN;GP;GQ;GR;GS;GT;GU;GW;GY;HK;HM;HN;HR;HT;HU;ID;IE;IL;IM;IN;IO;IQ;IR;IS;IT;JE;JM;JO;JP;KE;KG;KH;KI;KM;KN;KP;KR;KW;KY;KZ;LA;LB;LC;LI;LK;LR;LS;LT;LU;LV;LY;MA;MC;MD;ME;MF;MG;MH;MK;ML;MM;MN;MO;MP;MQ;MR;MS;MT;MU;MV;MW;MX;MY;MZ;NA;NC;NE;NF;NG;NI;NL;NO;NP;NR;NU;NZ;OM;PA;PE;PF;PG;PH;PK;PL;PM;PN;PR;PS;PT;PW;PY;QA;RE;RO;RS;RU;RW;SA;SB;SC;SD;SE;SG;SH;SI;SJ;SK;SL;SM;SN;SO;SR;SS;ST;SV;SX;SY;SZ;TC;TD;TF;TG;TH;TJ;TK;TL;TM;TN;TO;TR;TT;TV;TW;TZ;UA;UG;UM;US;UY;UZ;VA;VC;VE;VG;VI;VN;VU;WF;WS;XK;YE;YT;ZA;ZM;ZW 44 | -------------------------------------------------------------------------------- /tests/commands/db/validate.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { execSync } from 'child_process' 3 | 4 | type ExecError = { 5 | status: number 6 | stdout: string 7 | } 8 | 9 | describe('db:validate', () => { 10 | it('shows an error if the number of columns in a row is incorrect', () => { 11 | const ENV_VAR = 'cross-env DATA_DIR=tests/__data__/input/db/validate/wrong_num_cols' 12 | const cmd = `${ENV_VAR} npm run db:validate` 13 | try { 14 | const stdout = execSync(cmd, { encoding: 'utf8' }) 15 | if (process.env.DEBUG === 'true') console.log(cmd, stdout) 16 | process.exit(1) 17 | } catch (error) { 18 | if (process.env.DEBUG === 'true') console.log(cmd, (error as ExecError).stdout) 19 | expect((error as ExecError).status).toBe(1) 20 | expect((error as ExecError).stdout).toContain('row has the wrong number of columns') 21 | } 22 | }) 23 | 24 | it('shows an error if one of the lines ends with an invalid character', () => { 25 | const ENV_VAR = 'cross-env DATA_DIR=tests/__data__/input/db/validate/invalid_line_ending' 26 | const cmd = `${ENV_VAR} npm run db:validate` 27 | try { 28 | const stdout = execSync(cmd, { encoding: 'utf8' }) 29 | if (process.env.DEBUG === 'true') console.log(cmd, stdout) 30 | process.exit(1) 31 | } catch (error) { 32 | if (process.env.DEBUG === 'true') console.log(cmd, (error as ExecError).stdout) 33 | expect((error as ExecError).status).toBe(1) 34 | expect((error as ExecError).stdout).toContain( 35 | 'row has the wrong line ending character, should be CRLF' 36 | ) 37 | } 38 | }) 39 | 40 | it('shows an error if there are duplicates in the file', () => { 41 | const ENV_VAR = 'cross-env DATA_DIR=tests/__data__/input/db/validate/duplicate' 42 | const cmd = `${ENV_VAR} npm run db:validate` 43 | try { 44 | const stdout = execSync(cmd, { encoding: 'utf8' }) 45 | if (process.env.DEBUG === 'true') console.log(cmd, stdout) 46 | process.exit(1) 47 | } catch (error) { 48 | if (process.env.DEBUG === 'true') console.log(cmd, (error as ExecError).stdout) 49 | expect((error as ExecError).status).toBe(1) 50 | expect((error as ExecError).stdout).toContain('category with id "aaa" already exists') 51 | expect((error as ExecError).stdout).toContain( 52 | 'blocklist record with channel "002RadioTV.do" and ref "https://en.wikipedia.org/wiki/Lemurs_of_Madagascar_(book)" already exists' 53 | ) 54 | expect((error as ExecError).stdout).toContain( 55 | 'feed with channel "002RadioTV.do" and id "SD" already exists' 56 | ) 57 | expect((error as ExecError).stdout).toContain( 58 | 'logo with channel "002RadioTV.do", feed "" and url "https://i.imgur.com/7oNe8xj.png" already exists' 59 | ) 60 | expect((error as ExecError).stdout).toContain('4 error(s)') 61 | } 62 | }) 63 | 64 | it('shows an error if the data contains an error', () => { 65 | const ENV_VAR = 'cross-env DATA_DIR=tests/__data__/input/db/validate/invalid_value' 66 | const cmd = `${ENV_VAR} npm run db:validate` 67 | try { 68 | const stdout = execSync(cmd, { encoding: 'utf8' }) 69 | if (process.env.DEBUG === 'true') console.log(cmd, stdout) 70 | process.exit(1) 71 | } catch (error) { 72 | if (process.env.DEBUG === 'true') console.log(cmd, (error as ExecError).stdout) 73 | expect((error as ExecError).status).toBe(1) 74 | expect((error as ExecError).stdout).toContain(`channels.csv 75 | 2 002RadioTV.do: "website" must be a valid uri with a scheme matching the http|https pattern 76 | 2 "002RadioTV.do" has an more than one main feed 77 | 2 "002RadioTV.do" has an invalid replaced_by "002RadioTV.do@4K" 78 | 4 "24B.do" does not have a main feed`) 79 | expect((error as ExecError).stdout).toContain(`feeds.csv 80 | 2 0TV.dk@SD: "format" with value "576I" fails to match the required pattern: /^\\d+(i|p)$/ 81 | 2 "0TV.dk@SD" has the wrong channel "0TV.dk" 82 | 2 "0TV.dk@SD" has the wrong broadcast_area "c/BE" 83 | 2 "0TV.dk@SD" has the wrong timezones "Europe/Copenhagen"`) 84 | expect((error as ExecError).stdout).toContain(`logos.csv 85 | 2 i.imgur.com/rNffU8H.jpeg: "format" must be one of [SVG, PNG, JPEG, GIF, WebP, AVIF, APNG, null] 86 | 2 i.imgur.com/rNffU8H.jpeg: "url" must be a valid uri with a scheme matching the https pattern 87 | 2 Channel with id "1NOMO.vu" is missing from the channels.csv 88 | 2 Feed with channel "1NOMO.vu" and id "DD" is missing from the feeds.csv`) 89 | expect((error as ExecError).stdout).toContain(`blocklist.csv 90 | 2 "aaa.us" is missing from the channels.csv`) 91 | expect((error as ExecError).stdout).toContain(`cities.csv 92 | 3 city with code "ADCAN" already exists 93 | 3 city with wikidata_id "Q386802" already exists 94 | 4 "ADENC" has an invalid country "BD" 95 | 4 "ADENC" has an invalid subdivision "BD-03"`) 96 | expect((error as ExecError).stdout).toContain(`subdivisions.csv 97 | 3 "AD-02" has an invalid parent "AD-05"`) 98 | expect((error as ExecError).stdout).toContain(`timezones.csv 99 | 2 "Africa/Accra" has the wrong countries "GH"`) 100 | expect((error as ExecError).stdout).toContain('19 error(s)') 101 | } 102 | }) 103 | 104 | it('does not show an error if all data are correct', () => { 105 | const ENV_VAR = 'cross-env DATA_DIR=tests/__data__/input/db/validate/valid_data' 106 | const cmd = `${ENV_VAR} npm run db:validate` 107 | try { 108 | const stdout = execSync(cmd, { encoding: 'utf8' }) 109 | if (process.env.DEBUG === 'true') console.log(cmd, stdout) 110 | } catch (error) { 111 | if (process.env.DEBUG === 'true') console.log(cmd, (error as ExecError).stdout) 112 | process.exit(1) 113 | } 114 | }) 115 | }) 116 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/01_channels_add.yml: -------------------------------------------------------------------------------- 1 | name: '➕ channels/add' 2 | description: Request to add a channel into the database 3 | title: 'Add: ' 4 | labels: ['channels:add'] 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Please fill out the issue form as much as you can so we could efficiently process your request 11 | 12 | - type: input 13 | id: name 14 | attributes: 15 | label: Channel Name 16 | description: "Official channel name in English or call sign. May include: `a-z`, `0-9`, `space`, `-`, `!`, `:`, `&`, `.`, `+`, `'`, `/`, `»`, `#`, `%`, `°`, `$`, `@`, `?`, `|`, `¡`" 17 | placeholder: 'Anhui TV' 18 | validations: 19 | required: true 20 | 21 | - type: input 22 | id: alt_names 23 | attributes: 24 | label: Alternative Names (optional) 25 | description: List of alternative channel names separated by `;`. May contain any characters except `,` and `"` 26 | placeholder: '安徽卫视' 27 | 28 | - type: input 29 | id: network 30 | attributes: 31 | label: Network (optional) 32 | description: Network of which this channel is a part. May contain any characters except `,` and `"` 33 | placeholder: 'Anhui' 34 | 35 | - type: input 36 | id: owners 37 | attributes: 38 | label: Owners (optional) 39 | description: List of channel owners separated by `;`. May contain any characters except `,` and `"` 40 | placeholder: 'China Central Television' 41 | 42 | - type: input 43 | id: country 44 | attributes: 45 | label: Country 46 | description: Country code from which the channel is transmitted. A list of all supported countries and their codes can be found in [data/countries.csv](https://github.com/iptv-org/database/blob/master/data/countries.csv) 47 | placeholder: 'CN' 48 | validations: 49 | required: true 50 | 51 | - type: input 52 | id: categories 53 | attributes: 54 | label: Categories (optional) 55 | description: List of categories to which this channel belongs separated by `;`. A list of all supported categories can be found in [data/categories.csv](https://github.com/iptv-org/database/blob/master/data/categories.csv) 56 | placeholder: 'animation;kids' 57 | 58 | - type: dropdown 59 | id: is_nsfw 60 | attributes: 61 | label: NSFW 62 | description: Indicates whether the channel broadcasts adult content 63 | options: 64 | - 'FALSE' 65 | - 'TRUE' 66 | validations: 67 | required: true 68 | 69 | - type: input 70 | id: launched 71 | attributes: 72 | label: Launched (optional) 73 | description: Launch date of the channel (`YYYY-MM-DD`) 74 | placeholder: '2016-07-28' 75 | 76 | - type: input 77 | id: closed 78 | attributes: 79 | label: Closed (optional) 80 | description: Date on which the channel closed (`YYYY-MM-DD`) 81 | placeholder: '2020-05-31' 82 | 83 | - type: input 84 | id: replaced_by 85 | attributes: 86 | label: Replaced By (optional) 87 | description: The ID of the channel that this channel was replaced by 88 | placeholder: 'CCTV1.cn' 89 | 90 | - type: input 91 | id: website 92 | attributes: 93 | label: Website (optional) 94 | description: Official website URL 95 | placeholder: 'http://www.ahtv.cn/' 96 | 97 | - type: markdown 98 | attributes: 99 | value: | 100 | ## Logo 101 | Description of the main logo of the channel 102 | 103 | - type: input 104 | id: logo_url 105 | attributes: 106 | label: Logo URL 107 | description: "Logo URL. Supported formats: `PNG`, `JPEG`, `SVG`, `GIF`, `WebP`, `AVIF`, `APNG`. Only URLs with [HTTPS](https://ru.wikipedia.org/wiki/HTTPS) protocol are supported. The link should not be [geo-blocked](https://en.wikipedia.org/wiki/Geo-blocking)" 108 | placeholder: 'https://example.com/logo.png' 109 | validations: 110 | required: true 111 | 112 | - type: markdown 113 | attributes: 114 | value: | 115 | `width`, `height` and `format` of the logo will be calculated automatically 116 | 117 | - type: markdown 118 | attributes: 119 | value: | 120 | ## Main Feed 121 | Description of the main feed of the channel 122 | 123 | - type: input 124 | id: feed_name 125 | attributes: 126 | label: Feed Name 127 | description: "Unique for this channel feed name in English. For example: `HD`, `East`, `French`, `+1`, etc. May include: `a-z`, `0-9`, `space`, `-`, `!`, `:`, `&`, `.`, `+`, `'`, `/`, `»`, `#`, `%`, `°`, `$`, `@`, `?`, `|`, `¡`" 128 | placeholder: 'SD' 129 | value: 'SD' 130 | validations: 131 | required: true 132 | 133 | - type: input 134 | id: broadcast_area 135 | attributes: 136 | label: Broadcast Area 137 | description: "List of codes describing the broadcasting area of the feed separated by `;`. Any combination of `r/`, `c/`, `s/`, `ct/`. A full list of supported codes can be found here: [data/regions.csv](https://github.com/iptv-org/database/blob/master/data/regions.csv), [data/countries.csv](https://github.com/iptv-org/database/blob/master/data/countries.csv), [data/subdivisions.csv](https://github.com/iptv-org/database/blob/master/data/subdivisions.csv), [data/cities.csv](https://github.com/iptv-org/database/blob/master/data/cities.csv)" 138 | placeholder: 'c/CN' 139 | validations: 140 | required: true 141 | 142 | - type: input 143 | id: timezones 144 | attributes: 145 | label: Timezones 146 | description: List of broadcast time zones separated by `;`. A list of all supported timezones and their codes can be found in [data/timezones.csv](https://github.com/iptv-org/database/blob/master/data/timezones.csv) 147 | placeholder: 'Asia/Shanghai' 148 | validations: 149 | required: true 150 | 151 | - type: input 152 | id: languages 153 | attributes: 154 | label: Languages 155 | description: List of languages in which the feed is broadcast separated by `;`. A list of all supported languages and their codes can be found in [data/languages.csv](https://github.com/iptv-org/database/blob/master/data/languages.csv) 156 | placeholder: 'zho;eng' 157 | validations: 158 | required: true 159 | 160 | - type: dropdown 161 | id: format 162 | attributes: 163 | label: Format 164 | description: Video format of the broadcast 165 | default: 6 166 | options: 167 | - '4320p' 168 | - '2160p' 169 | - '1080p' 170 | - '1080i' 171 | - '720p' 172 | - '576p' 173 | - '576i' 174 | - '480p' 175 | - '480i' 176 | - '360p' 177 | - '240p' 178 | validations: 179 | required: true 180 | 181 | - type: textarea 182 | attributes: 183 | label: Notes 184 | description: 'Anything else we should know?' 185 | -------------------------------------------------------------------------------- /data/countries.csv: -------------------------------------------------------------------------------- 1 | name,code,languages,flag 2 | Afghanistan,AF,pus;prd;tuk,🇦🇫 3 | Aland,AX,swe,🇦🇽 4 | Albania,AL,sqi,🇦🇱 5 | Algeria,DZ,ara,🇩🇿 6 | American Samoa,AS,eng;smo,🇦🇸 7 | Andorra,AD,cat,🇦🇩 8 | Angola,AO,por,🇦🇴 9 | Anguilla,AI,eng,🇦🇮 10 | Antarctica,AQ,eng,🇦🇶 11 | Antigua and Barbuda,AG,eng,🇦🇬 12 | Argentina,AR,grn;spa,🇦🇷 13 | Armenia,AM,hye,🇦🇲 14 | Aruba,AW,nld;pap,🇦🇼 15 | Australia,AU,eng,🇦🇺 16 | Austria,AT,deu,🇦🇹 17 | Azerbaijan,AZ,aze;rus,🇦🇿 18 | Bahamas,BS,eng,🇧🇸 19 | Bahrain,BH,ara,🇧🇭 20 | Bangladesh,BD,ben,🇧🇩 21 | Barbados,BB,eng,🇧🇧 22 | Belarus,BY,bel;rus,🇧🇾 23 | Belgium,BE,deu;fra;nld,🇧🇪 24 | Belize,BZ,bjz;eng;spa,🇧🇿 25 | Benin,BJ,fra,🇧🇯 26 | Bermuda,BM,eng,🇧🇲 27 | Bhutan,BT,dzo,🇧🇹 28 | Bolivia,BO,aym;grn;que;spa,🇧🇴 29 | Bonaire,BQ,eng;nld;pap,🇧🇶 30 | Bosnia and Herzegovina,BA,bos;hrv;srp,🇧🇦 31 | Botswana,BW,eng;tsn,🇧🇼 32 | Bouvet Island,BV,nor,🇧🇻 33 | Brazil,BR,por,🇧🇷 34 | British Indian Ocean Territory,IO,eng,🇮🇴 35 | British Virgin Islands,VG,eng,🇻🇬 36 | Brunei,BN,msa,🇧🇳 37 | Bulgaria,BG,bul,🇧🇬 38 | Burkina Faso,BF,fra,🇧🇫 39 | Burundi,BI,fra;run,🇧🇮 40 | Cambodia,KH,khm,🇰🇭 41 | Cameroon,CM,eng;fra,🇨🇲 42 | Canada,CA,eng;fra,🇨🇦 43 | Cape Verde,CV,por,🇨🇻 44 | Cayman Islands,KY,eng,🇰🇾 45 | Central African Republic,CF,fra;sag,🇨🇫 46 | Chad,TD,ara;fra,🇹🇩 47 | Chile,CL,spa,🇨🇱 48 | China,CN,zho,🇨🇳 49 | Christmas Island,CX,eng,🇨🇽 50 | Cocos (Keeling) Islands,CC,eng,🇨🇨 51 | Colombia,CO,spa,🇨🇴 52 | Comoros,KM,ara;fra;zdj,🇰🇲 53 | Cook Islands,CK,eng;rar,🇨🇰 54 | Costa Rica,CR,spa,🇨🇷 55 | Croatia,HR,hrv,🇭🇷 56 | Cuba,CU,spa,🇨🇺 57 | Curacao,CW,eng;nld;pap,🇨🇼 58 | Cyprus,CY,ell;tur,🇨🇾 59 | Czech Republic,CZ,ces;slk,🇨🇿 60 | Democratic Republic of the Congo,CD,fra;kon;lin;lua;swa,🇨🇩 61 | Denmark,DK,dan,🇩🇰 62 | Djibouti,DJ,ara;fra,🇩🇯 63 | Dominica,DM,eng,🇩🇲 64 | Dominican Republic,DO,spa,🇩🇴 65 | East Timor,TL,por;tet,🇹🇱 66 | Ecuador,EC,spa,🇪🇨 67 | Egypt,EG,ara,🇪🇬 68 | El Salvador,SV,spa,🇸🇻 69 | Equatorial Guinea,GQ,fra;por;spa,🇬🇶 70 | Eritrea,ER,ara;eng;tir,🇪🇷 71 | Estonia,EE,est,🇪🇪 72 | Ethiopia,ET,amh,🇪🇹 73 | Falkland Islands,FK,eng,🇫🇰 74 | Faroe Islands,FO,dan;fao,🇫🇴 75 | Fiji,FJ,eng;fij;hif,🇫🇯 76 | Finland,FI,fin;swe,🇫🇮 77 | France,FR,fra,🇫🇷 78 | French Guiana,GF,fra,🇬🇫 79 | French Polynesia,PF,fra,🇵🇫 80 | French Southern Territories,TF,fra,🇹🇫 81 | Gabon,GA,fra,🇬🇦 82 | Gambia,GM,eng,🇬🇲 83 | Georgia,GE,kat,🇬🇪 84 | Germany,DE,deu,🇩🇪 85 | Ghana,GH,eng,🇬🇭 86 | Gibraltar,GI,eng,🇬🇮 87 | Greece,GR,ell,🇬🇷 88 | Greenland,GL,kal,🇬🇱 89 | Grenada,GD,eng,🇬🇩 90 | Guadeloupe,GP,fra,🇬🇵 91 | Guam,GU,cha;eng;spa,🇬🇺 92 | Guatemala,GT,spa,🇬🇹 93 | Guernsey,GG,eng;fra;nfr,🇬🇬 94 | Guinea,GN,fra,🇬🇳 95 | Guinea-Bissau,GW,por;pov,🇬🇼 96 | Guyana,GY,eng,🇬🇾 97 | Haiti,HT,fra;hat,🇭🇹 98 | Heard Island and McDonald Islands,HM,eng,🇭🇲 99 | Honduras,HN,spa,🇭🇳 100 | Hong Kong,HK,eng;zho,🇭🇰 101 | Hungary,HU,hun,🇭🇺 102 | Iceland,IS,isl,🇮🇸 103 | India,IN,eng;hin;tam,🇮🇳 104 | Indonesia,ID,ind,🇮🇩 105 | Iran,IR,fas,🇮🇷 106 | Iraq,IQ,ara;arc;ckb,🇮🇶 107 | Ireland,IE,eng;gle,🇮🇪 108 | Isle of Man,IM,eng;glv,🇮🇲 109 | Israel,IL,ara;heb,🇮🇱 110 | Italy,IT,ita,🇮🇹 111 | Ivory Coast,CI,fra,🇨🇮 112 | Jamaica,JM,eng;jam,🇯🇲 113 | Japan,JP,jpn,🇯🇵 114 | Jersey,JE,eng;fra;nrf,🇯🇪 115 | Jordan,JO,ara,🇯🇴 116 | Kazakhstan,KZ,kaz;rus,🇰🇿 117 | Kenya,KE,eng;swa,🇰🇪 118 | Kiribati,KI,eng;gil,🇰🇮 119 | Kosovo,XK,sqi;srp,🇽🇰 120 | Kuwait,KW,ara,🇰🇼 121 | Kyrgyzstan,KG,kir;rus,🇰🇬 122 | Laos,LA,lao,🇱🇦 123 | Latvia,LV,lav,🇱🇻 124 | Lebanon,LB,ara;fra,🇱🇧 125 | Lesotho,LS,eng;sot,🇱🇸 126 | Liberia,LR,eng,🇱🇷 127 | Libya,LY,ara,🇱🇾 128 | Liechtenstein,LI,deu,🇱🇮 129 | Lithuania,LT,lit,🇱🇹 130 | Luxembourg,LU,deu;fra;ltz,🇱🇺 131 | Macao,MO,por;zho,🇲🇴 132 | Madagascar,MG,fra;mlg,🇲🇬 133 | Malawi,MW,eng;nya,🇲🇼 134 | Malaysia,MY,eng;msa,🇲🇾 135 | Maldives,MV,div,🇲🇻 136 | Mali,ML,fra,🇲🇱 137 | Malta,MT,eng;mlt,🇲🇹 138 | Marshall Islands,MH,eng;mah,🇲🇭 139 | Martinique,MQ,fra,🇲🇶 140 | Mauritania,MR,ara,🇲🇷 141 | Mauritius,MU,eng;fra;mfe,🇲🇺 142 | Mayotte,YT,fra,🇾🇹 143 | Mexico,MX,spa,🇲🇽 144 | Micronesia,FM,eng,🇫🇲 145 | Moldova,MD,ron,🇲🇩 146 | Monaco,MC,fra,🇲🇨 147 | Mongolia,MN,mon,🇲🇳 148 | Montenegro,ME,cnr;srp;bos;sqi;hrv,🇲🇪 149 | Montserrat,MS,eng,🇲🇸 150 | Morocco,MA,ara;zgh,🇲🇦 151 | Mozambique,MZ,por,🇲🇿 152 | Myanmar,MM,mya,🇲🇲 153 | Namibia,NA,afr;deu;eng;her;hgm;kwn;loz;ndo;tsn,🇳🇦 154 | Nauru,NR,eng;nau,🇳🇷 155 | Nepal,NP,nep,🇳🇵 156 | Netherlands,NL,nld,🇳🇱 157 | New Caledonia,NC,fra,🇳🇨 158 | New Zealand,NZ,eng;mri;nzs,🇳🇿 159 | Nicaragua,NI,spa,🇳🇮 160 | Niger,NE,fra,🇳🇪 161 | Nigeria,NG,eng,🇳🇬 162 | Niue,NU,eng;niu,🇳🇺 163 | Norfolk Island,NF,eng;pih,🇳🇫 164 | North Korea,KP,kor,🇰🇵 165 | North Macedonia,MK,mkd,🇲🇰 166 | Northern Mariana Islands,MP,cal;cha;eng,🇲🇵 167 | Norway,NO,nor,🇳🇴 168 | Oman,OM,ara,🇴🇲 169 | Pakistan,PK,eng;urd,🇵🇰 170 | Palau,PW,eng;pau,🇵🇼 171 | Palestine,PS,ara,🇵🇸 172 | Panama,PA,spa,🇵🇦 173 | Papua New Guinea,PG,eng;hmo;tpi,🇵🇬 174 | Paraguay,PY,grn;spa,🇵🇾 175 | Peru,PE,aym;que;spa,🇵🇪 176 | Philippines,PH,eng;fil,🇵🇭 177 | Pitcairn Islands,PN,eng,🇵🇳 178 | Poland,PL,pol,🇵🇱 179 | Portugal,PT,por,🇵🇹 180 | Puerto Rico,PR,eng;spa,🇵🇷 181 | Qatar,QA,ara,🇶🇦 182 | Republic of the Congo,CG,fra;kon;lin,🇨🇬 183 | Romania,RO,ron,🇷🇴 184 | Russia,RU,rus,🇷🇺 185 | Rwanda,RW,eng;fra;kin,🇷🇼 186 | Reunion,RE,fra,🇷🇪 187 | Saint Barthélemy,BL,fra,🇧🇱 188 | Saint Helena,SH,eng,🇸🇭 189 | Saint Kitts and Nevis,KN,eng,🇰🇳 190 | Saint Lucia,LC,eng,🇱🇨 191 | Saint Martin,MF,fra,🇲🇫 192 | Saint Pierre and Miquelon,PM,fra,🇵🇲 193 | Saint Vincent and the Grenadines,VC,eng,🇻🇨 194 | Samoa,WS,eng;smo,🇼🇸 195 | San Marino,SM,ita,🇸🇲 196 | Saudi Arabia,SA,ara,🇸🇦 197 | Senegal,SN,fra,🇸🇳 198 | Serbia,RS,srp,🇷🇸 199 | Seychelles,SC,crs;eng;fra,🇸🇨 200 | Sierra Leone,SL,eng,🇸🇱 201 | Singapore,SG,eng;msa;zho;tam,🇸🇬 202 | Sint Maarten,SX,eng;fra;nld,🇸🇽 203 | Slovakia,SK,slk;ces,🇸🇰 204 | Slovenia,SI,slv,🇸🇮 205 | Solomon Islands,SB,eng,🇸🇧 206 | Somalia,SO,ara;som,🇸🇴 207 | South Africa,ZA,afr;eng;nbl;nso;sot;ssw;tsn;tso;ven;xho;zul,🇿🇦 208 | South Georgia and the South Sandwich Islands,GS,eng,🇬🇸 209 | South Korea,KR,kor,🇰🇷 210 | South Sudan,SS,eng,🇸🇸 211 | Spain,ES,spa;cat;eus;glg,🇪🇸 212 | Sri Lanka,LK,sin;tam,🇱🇰 213 | Sudan,SD,ara;eng,🇸🇩 214 | Suriname,SR,nld,🇸🇷 215 | Svalbard and Jan Mayen,SJ,nor,🇸🇯 216 | Swaziland,SZ,eng;ssw,🇸🇿 217 | Sweden,SE,swe,🇸🇪 218 | Switzerland,CH,deu;fra;ita,🇨🇭 219 | Syria,SY,ara,🇸🇾 220 | Sao Tome and Principe,ST,por,🇸🇹 221 | Taiwan,TW,zho,🇹🇼 222 | Tajikistan,TJ,rus;tgk,🇹🇯 223 | Tanzania,TZ,eng;swa,🇹🇿 224 | Thailand,TH,tha,🇹🇭 225 | Togo,TG,fra,🇹🇬 226 | Tokelau,TK,eng;smo;tkl,🇹🇰 227 | Tonga,TO,eng;ton,🇹🇴 228 | Trinidad and Tobago,TT,eng,🇹🇹 229 | Tunisia,TN,ara,🇹🇳 230 | Turkiye,TR,tur,🇹🇷 231 | Turkmenistan,TM,rus;tuk,🇹🇲 232 | Turks and Caicos Islands,TC,eng,🇹🇨 233 | Tuvalu,TV,eng;tvl,🇹🇻 234 | U.S. Minor Outlying Islands,UM,eng,🇺🇲 235 | U.S. Virgin Islands,VI,eng,🇻🇮 236 | Uganda,UG,eng;swa,🇺🇬 237 | Ukraine,UA,ukr,🇺🇦 238 | United Arab Emirates,AE,ara,🇦🇪 239 | United Kingdom,UK,eng,🇬🇧 240 | United States,US,eng;spa,🇺🇸 241 | Uruguay,UY,spa,🇺🇾 242 | Uzbekistan,UZ,rus;uzb,🇺🇿 243 | Vanuatu,VU,bis;eng;fra,🇻🇺 244 | Vatican City,VA,ita;lat,🇻🇦 245 | Venezuela,VE,spa,🇻🇪 246 | Vietnam,VN,vie,🇻🇳 247 | Wallis and Futuna,WF,fra,🇼🇫 248 | Western Sahara,EH,zgh;mey;spa,🇪🇭 249 | Yemen,YE,ara,🇾🇪 250 | Zambia,ZM,eng,🇿🇲 251 | Zimbabwe,ZW,bwg;eng;kck;hio;ndc;nde;nya;sna;sot;toi;tsn;tso;ven;xho;zib,🇿🇼 252 | -------------------------------------------------------------------------------- /scripts/models/feed.ts: -------------------------------------------------------------------------------- 1 | import { Validator, ValidatorError } from '../types/validator' 2 | import { Collection, Dictionary } from '@freearhey/core' 3 | import { createFeedId, data } from '../core' 4 | import { Subdivision } from './subdivision' 5 | import { IssueData } from './issueData' 6 | import { CSVRow } from '../types/utils' 7 | import { Timezone } from './timezone' 8 | import * as sdk from '@iptv-org/sdk' 9 | import { Channel } from './channel' 10 | import { Country } from './country' 11 | import { Region } from './region' 12 | import JoiDate from '@joi/date' 13 | import { City } from './city' 14 | import BaseJoi from 'joi' 15 | 16 | const Joi = BaseJoi.extend(JoiDate) 17 | 18 | export class Feed extends sdk.Models.Feed implements Validator { 19 | line: number = -1 20 | 21 | static fromRow(row: CSVRow): Feed { 22 | if (!row.data.channel) throw new Error('Feed: "channel" not specified') 23 | if (!row.data.id) throw new Error('Feed: "id" not specified') 24 | if (!row.data.name) throw new Error('Feed: "name" not specified') 25 | if (!row.data.format) throw new Error('Feed: "format" not specified') 26 | 27 | const feed = new Feed({ 28 | channel: row.data.channel.toString(), 29 | id: row.data.id.toString(), 30 | name: row.data.name.toString(), 31 | alt_names: Array.isArray(row.data.alt_names) ? row.data.alt_names : [], 32 | is_main: !!row.data.is_main, 33 | broadcast_area: Array.isArray(row.data.broadcast_area) ? row.data.broadcast_area : [], 34 | timezones: Array.isArray(row.data.timezones) ? row.data.timezones : [], 35 | languages: Array.isArray(row.data.languages) ? row.data.languages : [], 36 | format: row.data.format.toString() 37 | }) 38 | 39 | feed.line = row.line 40 | 41 | return feed 42 | } 43 | 44 | update(issueData: IssueData): this { 45 | const data = { 46 | feed_name: issueData.getString('feed_name'), 47 | alt_names: issueData.getArray('alt_names'), 48 | is_main: issueData.getBoolean('is_main'), 49 | broadcast_area: issueData.getArray('broadcast_area'), 50 | timezones: issueData.getArray('timezones'), 51 | languages: issueData.getArray('languages'), 52 | format: issueData.getString('format') 53 | } 54 | 55 | if (data.feed_name !== undefined) this.name = data.feed_name 56 | if (data.alt_names !== undefined) this.alt_names = data.alt_names 57 | if (data.is_main !== undefined) this.is_main = data.is_main 58 | if (data.broadcast_area !== undefined) this.broadcast_area = data.broadcast_area 59 | if (data.timezones !== undefined) this.timezones = data.timezones 60 | if (data.languages !== undefined) this.languages = data.languages 61 | if (data.format !== undefined) this.format = data.format 62 | 63 | const newFeedName = issueData.getString('feed_name') 64 | if (newFeedName) { 65 | this.id = createFeedId(newFeedName) 66 | } 67 | 68 | return this 69 | } 70 | 71 | hasValidId(): boolean { 72 | const expectedId = createFeedId(this.name) 73 | 74 | return expectedId === this.id 75 | } 76 | 77 | hasValidChannelId(channelsKeyById: Dictionary): boolean { 78 | return channelsKeyById.has(this.channel) 79 | } 80 | 81 | hasValidTimezones(timezonesKeyById: Dictionary): boolean { 82 | const hasInvalid = this.timezones.find((id: string) => timezonesKeyById.missing(id)) 83 | 84 | return !hasInvalid 85 | } 86 | 87 | hasValidBroadcastAreaCodes( 88 | countriesKeyByCode: Dictionary, 89 | subdivisionsKeyByCode: Dictionary, 90 | regionsKeyByCode: Dictionary, 91 | citiesKeyByCode: Dictionary 92 | ): boolean { 93 | const hasInvalid = this.broadcast_area.find((areaCode: string) => { 94 | const [type, code] = areaCode.split('/') 95 | switch (type) { 96 | case 'c': 97 | return countriesKeyByCode.missing(code) 98 | case 's': 99 | return subdivisionsKeyByCode.missing(code) 100 | case 'r': 101 | return regionsKeyByCode.missing(code) 102 | case 'ct': 103 | return citiesKeyByCode.missing(code) 104 | } 105 | }) 106 | 107 | return !hasInvalid 108 | } 109 | 110 | getSchema() { 111 | return Joi.object({ 112 | channel: Joi.string() 113 | .regex(/^[A-Za-z0-9]+\.[a-z]{2}$/) 114 | .required(), 115 | id: Joi.string() 116 | .regex(/^[A-Za-z0-9]+$/) 117 | .required(), 118 | name: Joi.string() 119 | .regex(/^[a-z0-9-!:&.+'/»#%°$@?|¡–\s_—]+$/i) 120 | .regex(/^((?!\s-\s).)*$/) 121 | .required(), 122 | alt_names: Joi.array().items( 123 | Joi.string() 124 | .regex(/^[^",]+$/) 125 | .invalid(Joi.ref('name')) 126 | ), 127 | is_main: Joi.boolean().strict().required(), 128 | broadcast_area: Joi.array().items( 129 | Joi.string() 130 | .regex(/^(s\/[A-Z]{2}-[A-Z0-9]{1,3}|c\/[A-Z]{2}|r\/[A-Z0-9]{2,7}|ct\/[A-Z0-9]{5})$/) 131 | .required() 132 | ), 133 | timezones: Joi.array().items( 134 | Joi.string() 135 | .regex(/^[a-z-_/]+$/i) 136 | .required() 137 | ), 138 | languages: Joi.array().items( 139 | Joi.string() 140 | .regex(/^[a-z]{3}$/) 141 | .required() 142 | ), 143 | format: Joi.string() 144 | .regex(/^\d+(i|p)$/) 145 | .allow(null) 146 | }) 147 | } 148 | 149 | toCSVRecord(): Record { 150 | return { 151 | channel: this.channel, 152 | id: this.id, 153 | name: this.name, 154 | alt_names: this.alt_names, 155 | is_main: this.is_main, 156 | broadcast_area: this.broadcast_area, 157 | timezones: this.timezones, 158 | languages: this.languages, 159 | format: this.format 160 | } 161 | } 162 | 163 | validate(): Collection { 164 | const { 165 | channelsKeyById, 166 | countriesKeyByCode, 167 | subdivisionsKeyByCode, 168 | regionsKeyByCode, 169 | timezonesKeyById, 170 | citiesKeyByCode 171 | } = data 172 | 173 | const errors = new Collection() 174 | 175 | const joiResults = this.getSchema().validate(this.toObject(), { abortEarly: false }) 176 | if (joiResults.error) { 177 | joiResults.error.details.forEach((detail: { message: string }) => { 178 | errors.add({ line: this.line, message: `${this.getStreamId()}: ${detail.message}` }) 179 | }) 180 | } 181 | 182 | if (!this.hasValidId()) { 183 | errors.add({ 184 | line: this.line, 185 | message: `"${this.getStreamId()}" id "${this.id}" must be derived from the name "${ 186 | this.name 187 | }"` 188 | }) 189 | } 190 | 191 | if (!this.hasValidChannelId(channelsKeyById)) { 192 | errors.add({ 193 | line: this.line, 194 | message: `"${this.getStreamId()}" has the wrong channel "${this.channel}"` 195 | }) 196 | } 197 | 198 | if ( 199 | !this.hasValidBroadcastAreaCodes( 200 | countriesKeyByCode, 201 | subdivisionsKeyByCode, 202 | regionsKeyByCode, 203 | citiesKeyByCode 204 | ) 205 | ) { 206 | errors.add({ 207 | line: this.line, 208 | message: `"${this.getStreamId()}" has the wrong broadcast_area "${this.broadcast_area.join( 209 | ';' 210 | )}"` 211 | }) 212 | } 213 | 214 | if (!this.hasValidTimezones(timezonesKeyById)) { 215 | errors.add({ 216 | line: this.line, 217 | message: `"${this.getStreamId()}" has the wrong timezones "${this.timezones.join(';')}"` 218 | }) 219 | } 220 | 221 | return errors 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /scripts/commands/db/validate.ts: -------------------------------------------------------------------------------- 1 | import { displayErrors, findDuplicatesBy } from '../../core/utils' 2 | import { ValidatorError } from '../../types/validator' 3 | import { loadData, data } from '../../core/db' 4 | import { Collection } from '@freearhey/core' 5 | import chalk from 'chalk' 6 | import { 7 | BlocklistRecord, 8 | Subdivision, 9 | Category, 10 | Language, 11 | Timezone, 12 | Channel, 13 | Country, 14 | Region, 15 | City, 16 | Feed, 17 | Logo 18 | } from '../../models' 19 | 20 | let totalErrors = 0 21 | 22 | async function main() { 23 | await loadData() 24 | 25 | validateChannels() 26 | validateFeeds() 27 | validateLogo() 28 | validateRegions() 29 | validateBlocklist() 30 | validateCategories() 31 | validateCities() 32 | validateCountries() 33 | validateSubdivisions() 34 | validateLanguages() 35 | validateTimezones() 36 | 37 | if (totalErrors > 0) { 38 | console.log(chalk.red(`\r\n${totalErrors} error(s)`)) 39 | process.exit(1) 40 | } 41 | } 42 | 43 | main() 44 | 45 | function validateChannels() { 46 | let errors = new Collection() 47 | 48 | findDuplicatesBy(data.channels, (channel: Channel) => channel.id).forEach( 49 | (channel: Channel) => { 50 | errors.add({ 51 | line: channel.line, 52 | message: `channel with id "${channel.id}" already exists` 53 | }) 54 | } 55 | ) 56 | 57 | data.channels.forEach((channel: Channel) => { 58 | errors = errors.concat(channel.validate()) 59 | }) 60 | 61 | if (errors.count()) displayErrors('channels.csv', errors) 62 | 63 | totalErrors += errors.count() 64 | } 65 | 66 | function validateFeeds() { 67 | let errors = new Collection() 68 | 69 | findDuplicatesBy(data.feeds, (feed: Feed) => 70 | `$${feed.channel}${feed.id}`.toLowerCase() 71 | ).forEach((feed: Feed) => { 72 | errors.add({ 73 | line: feed.line, 74 | message: `feed with channel "${feed.channel}" and id "${feed.id}" already exists` 75 | }) 76 | }) 77 | 78 | data.feeds.forEach((feed: Feed) => { 79 | errors = errors.concat(feed.validate()) 80 | }) 81 | 82 | if (errors.count()) displayErrors('feeds.csv', errors) 83 | 84 | totalErrors += errors.count() 85 | } 86 | 87 | function validateLogo() { 88 | let errors = new Collection() 89 | 90 | findDuplicatesBy(data.logos, (logo: Logo) => 91 | `${logo.channel}${logo.feed}${logo.url}`.toLowerCase() 92 | ).forEach((logo: Logo) => { 93 | errors.add({ 94 | line: logo.line, 95 | message: `logo with channel "${logo.channel}", feed "${logo.feed ?? ''}" and url "${ 96 | logo.url 97 | }" already exists` 98 | }) 99 | }) 100 | 101 | data.logos.forEach((logo: Logo) => { 102 | errors = errors.concat(logo.validate()) 103 | }) 104 | 105 | if (errors.count()) displayErrors('logos.csv', errors) 106 | 107 | totalErrors += errors.count() 108 | } 109 | 110 | function validateRegions() { 111 | let errors = new Collection() 112 | 113 | findDuplicatesBy(data.regions, (region: Region) => region.code.toLowerCase()).forEach( 114 | (region: Region) => { 115 | errors.add({ 116 | line: region.line, 117 | message: `region with code "${region.code}" already exists` 118 | }) 119 | } 120 | ) 121 | 122 | data.regions.forEach((region: Region) => { 123 | errors = errors.concat(region.validate()) 124 | }) 125 | 126 | if (errors.count()) displayErrors('regions.csv', errors) 127 | 128 | totalErrors += errors.count() 129 | } 130 | 131 | function validateBlocklist() { 132 | let errors = new Collection() 133 | 134 | findDuplicatesBy(data.blocklistRecords, (record: BlocklistRecord) => 135 | `${record.channel}${record.ref}`.toLowerCase() 136 | ).forEach((record: BlocklistRecord) => { 137 | errors.add({ 138 | line: record.line, 139 | message: `blocklist record with channel "${record.channel}" and ref "${record.ref}" already exists` 140 | }) 141 | }) 142 | 143 | data.blocklistRecords.forEach((record: BlocklistRecord) => { 144 | errors = errors.concat(record.validate()) 145 | }) 146 | 147 | if (errors.count()) displayErrors('blocklist.csv', errors) 148 | 149 | totalErrors += errors.count() 150 | } 151 | 152 | function validateCategories() { 153 | let errors = new Collection() 154 | 155 | findDuplicatesBy(data.categories, (category: Category) => 156 | category.id.toLowerCase() 157 | ).forEach((category: Category) => { 158 | errors.add({ 159 | line: category.line, 160 | message: `category with id "${category.id}" already exists` 161 | }) 162 | }) 163 | 164 | data.categories.forEach((category: Category) => { 165 | errors = errors.concat(category.validate()) 166 | }) 167 | 168 | if (errors.count()) displayErrors('categories.csv', errors) 169 | 170 | totalErrors += errors.count() 171 | } 172 | 173 | function validateCountries() { 174 | let errors = new Collection() 175 | 176 | findDuplicatesBy(data.countries, (country: Country) => 177 | country.code.toLowerCase() 178 | ).forEach((country: Country) => { 179 | errors.add({ 180 | line: country.line, 181 | message: `country with code "${country.code}" already exists` 182 | }) 183 | }) 184 | 185 | data.countries.forEach((country: Country) => { 186 | errors = errors.concat(country.validate()) 187 | }) 188 | 189 | if (errors.count()) displayErrors('countries.csv', errors) 190 | 191 | totalErrors += errors.count() 192 | } 193 | 194 | function validateSubdivisions() { 195 | let errors = new Collection() 196 | 197 | findDuplicatesBy(data.subdivisions, (subdivision: Subdivision) => 198 | subdivision.code.toLowerCase() 199 | ).forEach((subdivision: Subdivision) => { 200 | errors.add({ 201 | line: subdivision.line, 202 | message: `subdivision with code "${subdivision.code}" already exists` 203 | }) 204 | }) 205 | 206 | data.subdivisions.forEach((subdivision: Subdivision) => { 207 | errors = errors.concat(subdivision.validate()) 208 | }) 209 | 210 | if (errors.count()) displayErrors('subdivisions.csv', errors) 211 | 212 | totalErrors += errors.count() 213 | } 214 | 215 | function validateCities() { 216 | let errors = new Collection() 217 | 218 | findDuplicatesBy(data.cities, (city: City) => city.code.toLowerCase()).forEach( 219 | (city: City) => { 220 | errors.add({ 221 | line: city.line, 222 | message: `city with code "${city.code}" already exists` 223 | }) 224 | } 225 | ) 226 | 227 | findDuplicatesBy(data.cities, (city: City) => city.wikidata_id.toLowerCase()).forEach( 228 | (city: City) => { 229 | errors.add({ 230 | line: city.line, 231 | message: `city with wikidata_id "${city.wikidata_id}" already exists` 232 | }) 233 | } 234 | ) 235 | 236 | data.cities.forEach((city: City) => { 237 | errors = errors.concat(city.validate()) 238 | }) 239 | 240 | if (errors.count()) displayErrors('cities.csv', errors) 241 | 242 | totalErrors += errors.count() 243 | } 244 | 245 | function validateLanguages() { 246 | let errors = new Collection() 247 | 248 | findDuplicatesBy(data.languages, (language: Language) => 249 | language.code.toLowerCase() 250 | ).forEach((language: Language) => { 251 | errors.add({ 252 | line: language.line, 253 | message: `language with code "${language.code}" already exists` 254 | }) 255 | }) 256 | 257 | data.languages.forEach((language: Language) => { 258 | errors = errors.concat(language.validate()) 259 | }) 260 | 261 | if (errors.count()) displayErrors('languages.csv', errors) 262 | 263 | totalErrors += errors.count() 264 | } 265 | 266 | function validateTimezones() { 267 | let errors = new Collection() 268 | 269 | findDuplicatesBy(data.timezones, (timezone: Timezone) => 270 | timezone.id.toLowerCase() 271 | ).forEach((timezone: Timezone) => { 272 | errors.add({ 273 | line: timezone.line, 274 | message: `timezone with id "${timezone.id}" already exists` 275 | }) 276 | }) 277 | 278 | data.timezones.forEach((timezone: Timezone) => { 279 | errors = errors.concat(timezone.validate()) 280 | }) 281 | 282 | if (errors.count()) displayErrors('timezones.csv', errors) 283 | 284 | totalErrors += errors.count() 285 | } 286 | -------------------------------------------------------------------------------- /scripts/models/channel.ts: -------------------------------------------------------------------------------- 1 | import { Validator, ValidatorError } from '../types/validator' 2 | import { Collection, Dictionary } from '@freearhey/core' 3 | import { createChannelId, data } from '../core' 4 | import { IssueData } from './issueData' 5 | import { CSVRow } from '../types/utils' 6 | import { Category } from './category' 7 | import * as sdk from '@iptv-org/sdk' 8 | import { Country } from './country' 9 | import JoiDate from '@joi/date' 10 | import { Feed } from './feed' 11 | import BaseJoi from 'joi' 12 | 13 | const Joi = BaseJoi.extend(JoiDate) 14 | 15 | export class Channel extends sdk.Models.Channel implements Validator { 16 | line: number = -1 17 | 18 | static fromRow(row: CSVRow): Channel { 19 | if (!row.data.id) throw new Error('Channel: "id" not specified') 20 | if (!row.data.name) throw new Error('Channel: "name" not specified') 21 | if (!row.data.country) throw new Error('Channel: "country" not specified') 22 | 23 | const channel = new Channel({ 24 | id: row.data.id.toString(), 25 | name: row.data.name.toString(), 26 | alt_names: Array.isArray(row.data.alt_names) ? row.data.alt_names : [], 27 | network: row.data.network ? row.data.network.toString() : null, 28 | owners: Array.isArray(row.data.owners) ? row.data.owners : [], 29 | country: row.data.country.toString(), 30 | categories: Array.isArray(row.data.categories) ? row.data.categories : [], 31 | is_nsfw: !!row.data.is_nsfw, 32 | launched: row.data.launched ? row.data.launched.toString() : null, 33 | closed: row.data.closed ? row.data.closed.toString() : null, 34 | replaced_by: row.data.replaced_by ? row.data.replaced_by.toString() : null, 35 | website: row.data.website ? row.data.website.toString() : null 36 | }) 37 | 38 | channel.line = row.line 39 | 40 | return channel 41 | } 42 | 43 | update(issueData: IssueData): this { 44 | const data = { 45 | channel_name: issueData.getString('channel_name'), 46 | alt_names: issueData.getArray('alt_names'), 47 | network: issueData.getString('network'), 48 | owners: issueData.getArray('owners'), 49 | country: issueData.getString('country'), 50 | categories: issueData.getArray('categories'), 51 | is_nsfw: issueData.getBoolean('is_nsfw'), 52 | launched: issueData.getString('launched'), 53 | closed: issueData.getString('closed'), 54 | replaced_by: issueData.getString('replaced_by'), 55 | website: issueData.getString('website') 56 | } 57 | 58 | if (data.channel_name !== undefined) this.name = data.channel_name 59 | if (data.alt_names !== undefined) this.alt_names = data.alt_names 60 | if (data.network !== undefined) this.network = data.network || null 61 | if (data.owners !== undefined) this.owners = data.owners 62 | if (data.country !== undefined) this.country = data.country 63 | if (data.categories !== undefined) this.categories = data.categories 64 | if (data.is_nsfw !== undefined) this.is_nsfw = data.is_nsfw 65 | if (data.launched !== undefined) this.launched = data.launched || null 66 | if (data.closed !== undefined) this.closed = data.closed || null 67 | if (data.replaced_by !== undefined) this.replaced_by = data.replaced_by || null 68 | if (data.website !== undefined) this.website = data.website || null 69 | 70 | let channelName = issueData.getString('channel_name') 71 | let country = issueData.getString('country') 72 | if (channelName || country) { 73 | channelName = channelName || this.name 74 | country = country || this.country 75 | if (channelName && country) { 76 | const newChannelId = createChannelId(channelName, country) 77 | if (newChannelId) this.id = newChannelId 78 | } 79 | } 80 | 81 | return this 82 | } 83 | 84 | hasValidId(): boolean { 85 | const expectedId = createChannelId(this.name, this.country) 86 | 87 | return expectedId === this.id 88 | } 89 | 90 | getFeeds(): Collection { 91 | return new Collection(data.feedsGroupedByChannelId.get(this.id)) 92 | } 93 | 94 | hasMainFeed(): boolean { 95 | const feeds = this.getFeeds() 96 | 97 | if (feeds.isEmpty()) return false 98 | 99 | const mainFeed = feeds.find((feed: Feed) => feed.is_main) 100 | 101 | return !!mainFeed 102 | } 103 | 104 | hasMoreThanOneMainFeed(): boolean { 105 | const mainFeeds = this.getFeeds().filter((feed: Feed) => feed.is_main) 106 | 107 | return mainFeeds.count() > 1 108 | } 109 | 110 | hasValidReplacedBy( 111 | channelsKeyById: Dictionary, 112 | feedsKeyByStreamId: Dictionary 113 | ): boolean { 114 | if (!this.replaced_by) return true 115 | 116 | const [channelId, feedId] = this.replaced_by.split('@') 117 | 118 | if (channelsKeyById.missing(channelId)) return false 119 | if (feedId && feedsKeyByStreamId.missing(this.replaced_by)) return false 120 | 121 | return true 122 | } 123 | 124 | hasValidCountryCode(countriesKeyByCode: Dictionary): boolean { 125 | return countriesKeyByCode.has(this.country) 126 | } 127 | 128 | hasValidCategoryIds(categoriesKeyById: Dictionary): boolean { 129 | const hasInvalid = this.categories.find((id: string) => categoriesKeyById.missing(id)) 130 | 131 | return !hasInvalid 132 | } 133 | 134 | getSchema() { 135 | return Joi.object({ 136 | id: Joi.string() 137 | .regex(/^[A-Za-z0-9]+\.[a-z]{2}$/) 138 | .required(), 139 | name: Joi.string() 140 | .regex(/^[a-z0-9-!:&.+'/»#%°$@?|¡–\s_—]+$/i) 141 | .regex(/^((?!\s-\s).)*$/) 142 | .required(), 143 | alt_names: Joi.array().items( 144 | Joi.string() 145 | .regex(/^[^",]+$/) 146 | .invalid(Joi.ref('name')) 147 | ), 148 | network: Joi.string() 149 | .regex(/^[^",]+$/) 150 | .allow(null), 151 | owners: Joi.array().items(Joi.string().regex(/^[^",]+$/)), 152 | country: Joi.string() 153 | .regex(/^[A-Z]{2}$/) 154 | .required(), 155 | categories: Joi.array().items(Joi.string().regex(/^[a-z]+$/)), 156 | is_nsfw: Joi.boolean().strict().required(), 157 | launched: Joi.date().format('YYYY-MM-DD').raw().allow(null), 158 | closed: Joi.date().format('YYYY-MM-DD').raw().allow(null).greater(Joi.ref('launched')), 159 | replaced_by: Joi.string() 160 | .regex(/^[A-Za-z0-9]+\.[a-z]{2}($|@[A-Za-z0-9]+$)/) 161 | .allow(null), 162 | website: Joi.string() 163 | .regex(/,/, { invert: true }) 164 | .uri({ 165 | scheme: ['http', 'https'] 166 | }) 167 | .allow(null) 168 | }) 169 | } 170 | 171 | toCSVRecord(): Record { 172 | return this.toObject() as Record 173 | } 174 | 175 | validate(): Collection { 176 | const { channelsKeyById, feedsKeyByStreamId, countriesKeyByCode, categoriesKeyById } = data 177 | 178 | const errors = new Collection() 179 | 180 | const joiResults = this.getSchema().validate(this.toObject(), { abortEarly: false }) 181 | if (joiResults.error) { 182 | joiResults.error.details.forEach((detail: { message: string }) => { 183 | errors.add({ line: this.line, message: `${this.id}: ${detail.message}` }) 184 | }) 185 | } 186 | 187 | if (!this.hasValidId()) { 188 | errors.add({ 189 | line: this.line, 190 | message: `"${this.id}" must be derived from the channel name "${this.name}" and the country code "${this.country}"` 191 | }) 192 | } 193 | 194 | if (!this.hasMainFeed()) { 195 | errors.add({ 196 | line: this.line, 197 | message: `"${this.id}" does not have a main feed` 198 | }) 199 | } 200 | 201 | if (this.hasMoreThanOneMainFeed()) { 202 | errors.add({ 203 | line: this.line, 204 | message: `"${this.id}" has an more than one main feed` 205 | }) 206 | } 207 | 208 | if (!this.hasValidReplacedBy(channelsKeyById, feedsKeyByStreamId)) { 209 | errors.add({ 210 | line: this.line, 211 | message: `"${this.id}" has an invalid replaced_by "${this.replaced_by}"` 212 | }) 213 | } 214 | 215 | if (!this.hasValidCountryCode(countriesKeyByCode)) { 216 | errors.add({ 217 | line: this.line, 218 | message: `"${this.id}" has an invalid country "${this.country}"` 219 | }) 220 | } 221 | 222 | if (!this.hasValidCategoryIds(categoriesKeyById)) { 223 | errors.add({ 224 | line: this.line, 225 | message: `"${this.id}" has an invalid categories "${this.categories.join(';')}"` 226 | }) 227 | } 228 | 229 | return errors 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /scripts/core/utils.ts: -------------------------------------------------------------------------------- 1 | import { restEndpointMethods } from '@octokit/plugin-rest-endpoint-methods' 2 | import { stringQuoteOnlyIfNecessary } from '@json2csv/formatters' 3 | import { paginateRest } from '@octokit/plugin-paginate-rest' 4 | import { ImageProbeResult, CSVRow } from '../types/utils' 5 | import { Collection, Dictionary } from '@freearhey/core' 6 | import { TESTING, OWNER, REPO } from '../constants' 7 | import { ValidatorError } from '../types/validator' 8 | import { IssueData } from '../models/issueData' 9 | import { Parser } from '@json2csv/plainjs' 10 | import { Octokit } from '@octokit/core' 11 | import { Issue } from '../models/issue' 12 | import probe from 'probe-image-size' 13 | import csv2json from 'csvtojson' 14 | import path from 'node:path' 15 | import chalk from 'chalk' 16 | 17 | export function createChannelId( 18 | name: string | undefined, 19 | country: string | undefined 20 | ): string | undefined { 21 | if (!name || !country) return undefined 22 | 23 | const slug = normalize(name) 24 | const code = country.toLowerCase() 25 | 26 | return `${slug}.${code}` 27 | } 28 | 29 | export function createFeedId(name: string): string { 30 | return normalize(name) 31 | } 32 | 33 | function normalize(string: string): string { 34 | return string 35 | .replace(/^@/gi, 'At') 36 | .replace(/^&/i, 'And') 37 | .replace(/\+/gi, 'Plus') 38 | .replace(/\s-(\d)/gi, ' Minus$1') 39 | .replace(/^-(\d)/gi, 'Minus$1') 40 | .replace(/[^a-z\d]+/gi, '') 41 | } 42 | 43 | export function getFileExtension(url: string): string { 44 | const filename = path.basename(url) 45 | const extension = path.extname(filename) 46 | 47 | return extension.replace(/^\./, '').toLowerCase() 48 | } 49 | 50 | export function convertToCSV(items: Record[]): string { 51 | function flattenArray(item: { [key: string]: string[] | string | boolean }) { 52 | for (const prop in item) { 53 | const value = item[prop] 54 | item[prop] = Array.isArray(value) ? value.join(';') : value 55 | } 56 | 57 | return item 58 | } 59 | 60 | function formatBool(item: { [key: string]: string[] | string | boolean }) { 61 | for (const prop in item) { 62 | if (item[prop] === false) { 63 | item[prop] = 'FALSE' 64 | } else if (item[prop] === true) { 65 | item[prop] = 'TRUE' 66 | } 67 | } 68 | 69 | return item 70 | } 71 | 72 | const parser = new Parser({ 73 | transforms: [flattenArray, formatBool], 74 | formatters: { 75 | string: stringQuoteOnlyIfNecessary() 76 | }, 77 | eol: '\r\n' 78 | }) 79 | 80 | return parser.parse(items) 81 | } 82 | 83 | export async function loadIssues(props?: { 84 | labels: string[] | string 85 | }): Promise> { 86 | function parseIssue(issue: { number: number; body: string; labels: { name: string }[] }): Issue { 87 | const FIELDS = new Dictionary({ 88 | 'Channel ID': 'channel_id', 89 | 'Channel Name': 'channel_name', 90 | 'Feed Name': 'feed_name', 91 | 'Feed ID': 'feed_id', 92 | 'Main Feed': 'is_main', 93 | 'Alternative Names': 'alt_names', 94 | Network: 'network', 95 | Owners: 'owners', 96 | Country: 'country', 97 | Subdivision: 'subdivision', 98 | 'Broadcast Area': 'broadcast_area', 99 | Timezones: 'timezones', 100 | Format: 'format', 101 | Languages: 'languages', 102 | Categories: 'categories', 103 | NSFW: 'is_nsfw', 104 | Launched: 'launched', 105 | Closed: 'closed', 106 | 'Replaced By': 'replaced_by', 107 | Website: 'website', 108 | Reason: 'reason', 109 | Notes: 'notes', 110 | Reference: 'ref', 111 | 'Logo URL': 'logo_url', 112 | Tags: 'tags', 113 | Width: 'width', 114 | Height: 'height', 115 | 'New Channel ID': 'new_channel_id', 116 | 'New Feed ID': 'new_feed_id', 117 | 'New Logo URL': 'new_logo_url', 118 | 'City Name': 'city_name', 119 | 'City Code': 'city_code', 120 | 'Wikidata ID': 'wikidata_id' 121 | }) 122 | 123 | const fields = typeof issue.body === 'string' ? issue.body.split('###') : [] 124 | 125 | const data = new Dictionary() 126 | fields.forEach((field: string) => { 127 | const parsed = typeof field === 'string' ? field.split(/\r?\n/).filter(Boolean) : [] 128 | let _label = parsed.shift() 129 | _label = _label ? _label.replace(/ \(optional\)| \(required\)/, '').trim() : '' 130 | let _value = parsed.join('\r\n') 131 | _value = _value ? _value.trim() : '' 132 | 133 | if (!_label || !_value) return data 134 | 135 | const id: string | undefined = FIELDS.get(_label) 136 | const value: string = 137 | _value.toLowerCase() === '_no response_' || _value.toLowerCase() === 'none' ? '' : _value 138 | 139 | if (!id) return 140 | 141 | data.set(id, value) 142 | }) 143 | 144 | const labels = issue.labels.map(label => label.name) 145 | 146 | return new Issue({ number: issue.number, labels, data: new IssueData(data) }) 147 | } 148 | 149 | const CustomOctokit = Octokit.plugin(paginateRest, restEndpointMethods) 150 | const octokit = new CustomOctokit() 151 | 152 | let labels = '' 153 | if (props && props.labels) { 154 | labels = Array.isArray(props.labels) ? props.labels.join(',') : props.labels 155 | } 156 | 157 | let issues: object[] = [] 158 | if (TESTING) { 159 | issues = (await import('../../tests/__data__/input/db/update/issues.js')).default 160 | } else { 161 | issues = await octokit.paginate(octokit.rest.issues.listForRepo, { 162 | owner: OWNER, 163 | repo: REPO, 164 | per_page: 100, 165 | labels, 166 | status: 'open', 167 | sort: 'created', 168 | direction: 'asc', 169 | headers: { 170 | 'X-GitHub-Api-Version': '2022-11-28' 171 | } 172 | }) 173 | } 174 | 175 | return new Collection(issues).map(parseIssue) 176 | } 177 | 178 | export async function probeImage(url: string): Promise { 179 | const formatsByMimeType: { [key: string]: string } = { 180 | 'image/svg+xml': 'SVG', 181 | 'image/png': 'PNG', 182 | 'image/jpeg': 'JPEG', 183 | 'image/gif': 'GIF', 184 | 'image/webp': 'WebP', 185 | 'image/avif': 'AVIF', 186 | 'image/apng': 'APNG' 187 | } 188 | const formatsByExtension: { [key: string]: string } = { 189 | svg: 'SVG', 190 | png: 'PNG', 191 | jpeg: 'JPEG', 192 | jpg: 'JPEG', 193 | gif: 'GIF', 194 | webp: 'WebP', 195 | avif: 'AVIF', 196 | apng: 'APNG' 197 | } 198 | 199 | let width = 0 200 | let height = 0 201 | let format = '' 202 | 203 | if (TESTING) { 204 | return { 205 | width: 80, 206 | height: 80, 207 | format: 'JPEG' 208 | } 209 | } else { 210 | const imageInfo = await probe(url).catch(() => {}) 211 | 212 | if (imageInfo) { 213 | width = Math.round(imageInfo.width) 214 | height = Math.round(imageInfo.height) 215 | format = formatsByMimeType[imageInfo.mime] 216 | } 217 | 218 | if (!format) { 219 | const extension = getFileExtension(url) 220 | format = formatsByExtension[extension] 221 | } 222 | 223 | return { 224 | width, 225 | height, 226 | format 227 | } 228 | } 229 | } 230 | 231 | export async function parseCSV(data: string): Promise> { 232 | function listParser(value: string) { 233 | return value.split(';').filter(i => i) 234 | } 235 | 236 | function boolParser(value: string) { 237 | switch (value) { 238 | case 'TRUE': 239 | return true 240 | case 'FALSE': 241 | return false 242 | default: 243 | return value 244 | } 245 | } 246 | 247 | function numberParser(value: string) { 248 | return Number(value) 249 | } 250 | 251 | function stringParser(value: string) { 252 | return value === '' ? null : value 253 | } 254 | 255 | const opts = { 256 | checkColumn: true, 257 | trim: true, 258 | delimiter: ',', 259 | eol: '\r\n', 260 | colParser: { 261 | alt_names: listParser, 262 | network: stringParser, 263 | owners: listParser, 264 | subdivision: stringParser, 265 | broadcast_area: listParser, 266 | languages: listParser, 267 | categories: listParser, 268 | is_nsfw: boolParser, 269 | launched: stringParser, 270 | closed: stringParser, 271 | replaced_by: stringParser, 272 | website: stringParser, 273 | logo: stringParser, 274 | countries: listParser, 275 | timezones: listParser, 276 | is_main: boolParser, 277 | format: stringParser, 278 | feed: stringParser, 279 | tags: listParser, 280 | width: numberParser, 281 | height: numberParser, 282 | parent: stringParser 283 | } 284 | } 285 | 286 | const parsed = await csv2json(opts).fromString(data) 287 | const rows = parsed.map((data, i) => { 288 | return { line: i + 2, data } 289 | }) 290 | 291 | return new Collection(rows) 292 | } 293 | 294 | export function displayErrors(filepath: string, errors: Collection) { 295 | console.log(`\r\n${chalk.underline(filepath)}`) 296 | 297 | errors.forEach((error: ValidatorError) => { 298 | const position = error.line.toString().padEnd(6, ' ') 299 | console.log(` ${chalk.gray(position) + error.message}`) 300 | }) 301 | } 302 | 303 | export function findDuplicatesBy( 304 | items: Collection, 305 | iterator: (item: Type) => string 306 | ): Collection { 307 | const duplicates = new Collection() 308 | const buffer = new Dictionary() 309 | 310 | items.forEach((item: Type) => { 311 | const uid = iterator(item) 312 | if (buffer.has(uid)) { 313 | duplicates.add(item) 314 | } 315 | 316 | buffer.set(uid, true) 317 | }) 318 | 319 | return duplicates 320 | } 321 | -------------------------------------------------------------------------------- /scripts/core/db.ts: -------------------------------------------------------------------------------- 1 | import { parseCSV, displayErrors, convertToCSV } from '../core/utils' 2 | import { Dictionary, Collection } from '@freearhey/core' 3 | import { Storage, File } from '@freearhey/storage-js' 4 | import { ValidatorError } from '../types/validator' 5 | import { DatabaseData } from '../types/db' 6 | import { DATA_DIR } from '../constants' 7 | import { CSVRow } from '../types/utils' 8 | import chalk from 'chalk' 9 | import { 10 | Feed, 11 | Channel, 12 | BlocklistRecord, 13 | Language, 14 | Country, 15 | Subdivision, 16 | Region, 17 | Timezone, 18 | Category, 19 | City, 20 | Logo 21 | } from '../models' 22 | 23 | let data: DatabaseData = { 24 | channels: new Collection(), 25 | feeds: new Collection(), 26 | categories: new Collection(), 27 | languages: new Collection(), 28 | blocklistRecords: new Collection(), 29 | timezones: new Collection(), 30 | regions: new Collection(), 31 | subdivisions: new Collection(), 32 | cities: new Collection(), 33 | countries: new Collection(), 34 | logos: new Collection(), 35 | 36 | citiesKeyByCode: new Dictionary(), 37 | feedsGroupedByChannelId: new Dictionary(), 38 | feedsKeyByStreamId: new Dictionary(), 39 | channelsKeyById: new Dictionary(), 40 | countriesKeyByCode: new Dictionary(), 41 | subdivisionsKeyByCode: new Dictionary(), 42 | categoriesKeyById: new Dictionary(), 43 | regionsKeyByCode: new Dictionary(), 44 | timezonesKeyById: new Dictionary(), 45 | languagesKeyByCode: new Dictionary() 46 | } 47 | 48 | let cache: DatabaseData = { 49 | channels: new Collection(), 50 | feeds: new Collection(), 51 | categories: new Collection(), 52 | languages: new Collection(), 53 | blocklistRecords: new Collection(), 54 | timezones: new Collection(), 55 | regions: new Collection(), 56 | subdivisions: new Collection(), 57 | cities: new Collection(), 58 | countries: new Collection(), 59 | logos: new Collection(), 60 | 61 | citiesKeyByCode: new Dictionary(), 62 | feedsGroupedByChannelId: new Dictionary(), 63 | feedsKeyByStreamId: new Dictionary(), 64 | channelsKeyById: new Dictionary(), 65 | countriesKeyByCode: new Dictionary(), 66 | subdivisionsKeyByCode: new Dictionary(), 67 | categoriesKeyById: new Dictionary(), 68 | regionsKeyByCode: new Dictionary(), 69 | timezonesKeyById: new Dictionary(), 70 | languagesKeyByCode: new Dictionary() 71 | } 72 | 73 | const dataStorage = new Storage(DATA_DIR) 74 | 75 | async function loadData(): Promise { 76 | const files = await dataStorage.list('*.csv') 77 | 78 | for (const filepath of files) { 79 | const file = new File(filepath) 80 | if (file.extension() !== 'csv') continue 81 | 82 | const csv = await dataStorage.load(file.basename()) 83 | const rows = csv.split(/\r\n/) 84 | const headers = rows[0].split(',') 85 | const errors = new Collection() 86 | for (const [i, line] of rows.entries()) { 87 | if (!line.trim()) continue 88 | if (line.indexOf('\n') > -1) { 89 | errors.add({ 90 | line: i + 1, 91 | message: 'row has the wrong line ending character, should be CRLF' 92 | }) 93 | } 94 | if (line.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/).length !== headers.length) { 95 | errors.add({ 96 | line: i + 1, 97 | message: 'row has the wrong number of columns' 98 | }) 99 | } 100 | } 101 | 102 | if (errors.isNotEmpty()) { 103 | displayErrors(filepath, errors) 104 | console.log(chalk.red(`\r\n${errors.count()} error(s)`)) 105 | process.exit(1) 106 | } 107 | 108 | const parsed = await parseCSV(csv) 109 | const filename = file.name() 110 | 111 | switch (filename) { 112 | case 'channels': { 113 | data.channels = parsed.map((row: CSVRow) => Channel.fromRow(row)) 114 | break 115 | } 116 | case 'feeds': { 117 | data.feeds = parsed.map((row: CSVRow) => Feed.fromRow(row)) 118 | break 119 | } 120 | case 'logos': { 121 | data.logos = parsed.map((row: CSVRow) => Logo.fromRow(row)) 122 | break 123 | } 124 | case 'blocklist': { 125 | data.blocklistRecords = parsed.map((row: CSVRow) => BlocklistRecord.fromRow(row)) 126 | break 127 | } 128 | case 'categories': { 129 | data.categories = parsed.map((row: CSVRow) => Category.fromRow(row)) 130 | break 131 | } 132 | case 'timezones': { 133 | data.timezones = parsed.map((row: CSVRow) => Timezone.fromRow(row)) 134 | break 135 | } 136 | case 'regions': { 137 | data.regions = parsed.map((row: CSVRow) => Region.fromRow(row)) 138 | break 139 | } 140 | case 'languages': { 141 | data.languages = parsed.map((row: CSVRow) => Language.fromRow(row)) 142 | break 143 | } 144 | case 'countries': { 145 | data.countries = parsed.map((row: CSVRow) => Country.fromRow(row)) 146 | break 147 | } 148 | case 'subdivisions': { 149 | data.subdivisions = parsed.map((row: CSVRow) => Subdivision.fromRow(row)) 150 | break 151 | } 152 | case 'cities': { 153 | data.cities = parsed.map((row: CSVRow) => City.fromRow(row)) 154 | break 155 | } 156 | } 157 | } 158 | 159 | data.channelsKeyById = data.channels.keyBy((channel: Channel) => channel.id) 160 | data.feedsGroupedByChannelId = data.feeds.groupBy((feed: Feed) => feed.channel) 161 | data.feedsKeyByStreamId = data.feeds.keyBy((feed: Feed) => feed.getStreamId()) 162 | data.categoriesKeyById = data.categories.keyBy((category: Category) => category.id) 163 | data.timezonesKeyById = data.timezones.keyBy((timezone: Timezone) => timezone.id) 164 | data.regionsKeyByCode = data.regions.keyBy((region: Region) => region.code) 165 | data.languagesKeyByCode = data.languages.keyBy((language: Language) => language.code) 166 | data.countriesKeyByCode = data.countries.keyBy((country: Country) => country.code) 167 | data.subdivisionsKeyByCode = data.subdivisions.keyBy( 168 | (subdivision: Subdivision) => subdivision.code 169 | ) 170 | data.citiesKeyByCode = data.cities.keyBy((city: City) => city.code) 171 | 172 | return data 173 | } 174 | 175 | function cacheData() { 176 | cache = { 177 | channels: data.channels.clone(), 178 | feeds: data.feeds.clone(), 179 | categories: data.categories.clone(), 180 | languages: data.languages.clone(), 181 | blocklistRecords: data.blocklistRecords.clone(), 182 | timezones: data.timezones.clone(), 183 | regions: data.regions.clone(), 184 | subdivisions: data.subdivisions.clone(), 185 | cities: data.cities.clone(), 186 | countries: data.countries.clone(), 187 | logos: data.logos.clone(), 188 | citiesKeyByCode: data.citiesKeyByCode, 189 | feedsGroupedByChannelId: data.feedsGroupedByChannelId, 190 | feedsKeyByStreamId: data.feedsKeyByStreamId, 191 | channelsKeyById: data.channelsKeyById, 192 | countriesKeyByCode: data.countriesKeyByCode, 193 | subdivisionsKeyByCode: data.subdivisionsKeyByCode, 194 | categoriesKeyById: data.categoriesKeyById, 195 | regionsKeyByCode: data.regionsKeyByCode, 196 | timezonesKeyById: data.timezonesKeyById, 197 | languagesKeyByCode: data.languagesKeyByCode 198 | } 199 | } 200 | 201 | function resetData() { 202 | data = { 203 | channels: cache.channels.clone(), 204 | feeds: cache.feeds.clone(), 205 | categories: cache.categories.clone(), 206 | languages: cache.languages.clone(), 207 | blocklistRecords: cache.blocklistRecords.clone(), 208 | timezones: cache.timezones.clone(), 209 | regions: cache.regions.clone(), 210 | subdivisions: cache.subdivisions.clone(), 211 | cities: cache.cities.clone(), 212 | countries: cache.countries.clone(), 213 | logos: cache.logos.clone(), 214 | citiesKeyByCode: cache.citiesKeyByCode, 215 | feedsGroupedByChannelId: cache.feedsGroupedByChannelId, 216 | feedsKeyByStreamId: cache.feedsKeyByStreamId, 217 | channelsKeyById: cache.channelsKeyById, 218 | countriesKeyByCode: cache.countriesKeyByCode, 219 | subdivisionsKeyByCode: cache.subdivisionsKeyByCode, 220 | categoriesKeyById: cache.categoriesKeyById, 221 | regionsKeyByCode: cache.regionsKeyByCode, 222 | timezonesKeyById: cache.timezonesKeyById, 223 | languagesKeyByCode: cache.languagesKeyByCode 224 | } 225 | } 226 | 227 | async function saveData() { 228 | const channels = data.channels 229 | .sortBy((channel: Channel) => channel.id.toLowerCase(), 'asc', false) 230 | .map((channel: Channel) => channel.toCSVRecord()) 231 | .all() 232 | await dataStorage.save('channels.csv', convertToCSV(channels)) 233 | 234 | const feeds = data.feeds 235 | .sortBy((feed: Feed) => `${feed.getStreamId()}`.toLowerCase(), 'asc', false) 236 | .map((feed: Feed) => feed.toCSVRecord()) 237 | .all() 238 | await dataStorage.save('feeds.csv', convertToCSV(feeds)) 239 | 240 | const blocklistRecords = data.blocklistRecords 241 | .sortBy( 242 | (blocklistRecord: BlocklistRecord) => blocklistRecord.channel.toLowerCase(), 243 | 'asc', 244 | false 245 | ) 246 | .map((blocklistRecord: BlocklistRecord) => blocklistRecord.toCSVRecord()) 247 | .all() 248 | await dataStorage.save('blocklist.csv', convertToCSV(blocklistRecords)) 249 | 250 | const logos = data.logos 251 | .sortBy((logo: Logo) => `${logo.channel}${logo.feed}${logo.url}`.toLowerCase(), 'asc', false) 252 | .map((logo: Logo) => logo.toCSVRecord()) 253 | .all() 254 | await dataStorage.save('logos.csv', convertToCSV(logos)) 255 | 256 | const cities = data.cities 257 | .sortBy( 258 | (city: City) => `${city.country}_${city.subdivision || ''}_${city.code}`.toLowerCase(), 259 | 'asc', 260 | true 261 | ) 262 | .map((city: City) => city.toCSVRecord()) 263 | .all() 264 | await dataStorage.save('cities.csv', convertToCSV(cities)) 265 | } 266 | 267 | export { loadData, data, saveData, cacheData, resetData } 268 | -------------------------------------------------------------------------------- /data/timezones.csv: -------------------------------------------------------------------------------- 1 | id,utc_offset,countries 2 | Africa/Abidjan,+00:00,CI;BF;GH;GM;GN;IS;ML;MR;SH;SL;SN;TG 3 | Africa/Accra,+00:00,GH 4 | Africa/Addis_Ababa,+03:00,ET 5 | Africa/Algiers,+01:00,DZ 6 | Africa/Asmara,+03:00,ER 7 | Africa/Bamako,+00:00,ML 8 | Africa/Bangui,+01:00,CF 9 | Africa/Banjul,+00:00,GM 10 | Africa/Bissau,+00:00,GW 11 | Africa/Blantyre,+02:00,MW 12 | Africa/Brazzaville,+01:00,CG 13 | Africa/Bujumbura,+02:00,BI 14 | Africa/Cairo,+02:00,EG 15 | Africa/Casablanca,+00:00,MA 16 | Africa/Ceuta,+01:00,ES 17 | Africa/Conakry,+00:00,GN 18 | Africa/Dakar,+00:00,SN 19 | Africa/Dar_es_Salaam,+03:00,TZ 20 | Africa/Djibouti,+03:00,DJ 21 | Africa/Douala,+01:00,CM 22 | Africa/El_Aaiun,+00:00,EH 23 | Africa/Freetown,+00:00,SL 24 | Africa/Gaborone,+02:00,BW 25 | Africa/Harare,+02:00,ZW 26 | Africa/Johannesburg,+02:00,ZA;LS;SZ 27 | Africa/Juba,+02:00,SS 28 | Africa/Kampala,+03:00,UG 29 | Africa/Khartoum,+02:00,SD 30 | Africa/Kigali,+02:00,RW 31 | Africa/Kinshasa,+01:00,CD 32 | Africa/Lagos,+01:00,NG;AO;BJ;CD;CF;CG;CM;GA;GQ;NE 33 | Africa/Libreville,+01:00,GA 34 | Africa/Lome,+00:00,TG 35 | Africa/Luanda,+01:00,AO 36 | Africa/Lubumbashi,+02:00,CD 37 | Africa/Lusaka,+02:00,ZM 38 | Africa/Malabo,+01:00,GQ 39 | Africa/Maputo,+02:00,MZ;BI;BW;CD;MW;RW;ZM;ZW 40 | Africa/Maseru,+02:00,LS 41 | Africa/Mbabane,+02:00,SZ 42 | Africa/Mogadishu,+03:00,SO 43 | Africa/Monrovia,+00:00,LR 44 | Africa/Nairobi,+03:00,KE;DJ;ER;ET;KM;MG;SO;TZ;UG;YT 45 | Africa/Ndjamena,+01:00,TD 46 | Africa/Niamey,+01:00,NE 47 | Africa/Nouakchott,+00:00,MR 48 | Africa/Ouagadougou,+00:00,BF 49 | Africa/Porto-Novo,+01:00,BJ 50 | Africa/Sao_Tome,+00:00,ST 51 | Africa/Tripoli,+02:00,LY 52 | Africa/Tunis,+01:00,TN 53 | Africa/Windhoek,+01:00,NA 54 | America/Adak,-10:00,US 55 | America/Anchorage,-09:00,US 56 | America/Anguilla,-04:00,AI 57 | America/Antigua,-04:00,AG 58 | America/Araguaina,-03:00,BR 59 | America/Argentina/Buenos_Aires,-03:00,AR 60 | America/Argentina/Catamarca,-03:00,AR 61 | America/Argentina/Cordoba,-03:00,AR 62 | America/Argentina/Jujuy,-03:00,AR 63 | America/Argentina/La_Rioja,-03:00,AR 64 | America/Argentina/Mendoza,-03:00,AR 65 | America/Argentina/Rio_Gallegos,-03:00,AR 66 | America/Argentina/Salta,-03:00,AR 67 | America/Argentina/San_Juan,-03:00,AR 68 | America/Argentina/San_Luis,-03:00,AR 69 | America/Argentina/Tucuman,-03:00,AR 70 | America/Argentina/Ushuaia,-03:00,AR 71 | America/Aruba,-04:00,AW 72 | America/Asuncion,-04:00,PY 73 | America/Atikokan,-05:00,CA 74 | America/Bahia,-03:00,BR 75 | America/Bahia_Banderas,-06:00,MX 76 | America/Barbados,-04:00,BB 77 | America/Belem,-03:00,BR 78 | America/Belize,-06:00,BZ 79 | America/Blanc-Sablon,-04:00,CA 80 | America/Boa_Vista,-04:00,BR 81 | America/Bogota,-05:00,CO 82 | America/Boise,-07:00,US 83 | America/Cambridge_Bay,-07:00,CA 84 | America/Campo_Grande,-04:00,BR 85 | America/Cancun,-05:00,MX 86 | America/Caracas,-04:00,VE 87 | America/Cayenne,-03:00,GF 88 | America/Cayman,-05:00,KY 89 | America/Chicago,-06:00,US 90 | America/Chihuahua,-07:00,MX 91 | America/Costa_Rica,-06:00,CR 92 | America/Creston,-07:00,CA 93 | America/Cuiaba,-04:00,BR 94 | America/Curacao,-04:00,CW 95 | America/Danmarkshavn,+00:00,GL 96 | America/Dawson,-07:00,CA 97 | America/Dawson_Creek,-07:00,CA 98 | America/Denver,-07:00,US 99 | America/Detroit,-05:00,US 100 | America/Dominica,-04:00,DM 101 | America/Edmonton,-07:00,CA 102 | America/Eirunepe,-05:00,BR 103 | America/El_Salvador,-06:00,SV 104 | America/Fort_Nelson,-07:00,CA 105 | America/Fortaleza,-03:00,BR 106 | America/Glace_Bay,-04:00,CA 107 | America/Goose_Bay,-04:00,CA 108 | America/Grand_Turk,-05:00,TC 109 | America/Grenada,-04:00,GD 110 | America/Guadeloupe,-04:00,GP 111 | America/Guatemala,-06:00,GT 112 | America/Guayaquil,-05:00,EC 113 | America/Guyana,-04:00,GY 114 | America/Halifax,-04:00,CA 115 | America/Havana,-05:00,CU 116 | America/Hermosillo,-07:00,MX 117 | America/Indiana/Indianapolis,-05:00,US 118 | America/Indiana/Knox,-06:00,US 119 | America/Indiana/Marengo,-05:00,US 120 | America/Indiana/Petersburg,-05:00,US 121 | America/Indiana/Tell_City,-06:00,US 122 | America/Indiana/Vevay,-05:00,US 123 | America/Indiana/Vincennes,-05:00,US 124 | America/Indiana/Winamac,-05:00,US 125 | America/Inuvik,-07:00,CA 126 | America/Iqaluit,-05:00,CA 127 | America/Jamaica,-05:00,JM 128 | America/Juneau,-09:00,US 129 | America/Kentucky/Louisville,-05:00,US 130 | America/Kentucky/Monticello,-05:00,US 131 | America/Kralendijk,-04:00,BQ 132 | America/La_Paz,-04:00,BO 133 | America/Lima,-05:00,PE 134 | America/Los_Angeles,-08:00,US 135 | America/Lower_Princes,-04:00,SX 136 | America/Maceio,-03:00,BR 137 | America/Managua,-06:00,NI 138 | America/Manaus,-04:00,BR 139 | America/Marigot,-04:00,MF 140 | America/Martinique,-04:00,MQ 141 | America/Matamoros,-06:00,MX 142 | America/Mazatlan,-07:00,MX 143 | America/Menominee,-06:00,US 144 | America/Merida,-06:00,MX 145 | America/Metlakatla,-09:00,US 146 | America/Mexico_City,-06:00,MX 147 | America/Miquelon,-03:00,PM 148 | America/Moncton,-04:00,CA 149 | America/Monterrey,-06:00,MX 150 | America/Montevideo,-03:00,UY 151 | America/Montserrat,-04:00,MS 152 | America/Nassau,-05:00,BS 153 | America/New_York,-05:00,US 154 | America/Nipigon,-05:00,CA 155 | America/Nome,-09:00,US 156 | America/Noronha,-02:00,BR 157 | America/North_Dakota/Beulah,-06:00,US 158 | America/North_Dakota/Center,-06:00,US 159 | America/North_Dakota/New_Salem,-06:00,US 160 | America/Nuuk,-02:00,GL 161 | America/Ojinaga,-07:00,MX 162 | America/Panama,-05:00,PA;CA;KY 163 | America/Paramaribo,-03:00,SR 164 | America/Phoenix,-07:00,US;CA 165 | America/Port-au-Prince,-05:00,HT 166 | America/Port_of_Spain,-04:00,TT 167 | America/Porto_Velho,-04:00,BR 168 | America/Puerto_Rico,-04:00,PR;AG;CA;AI;AW;BL;BQ;CW;DM;GD;GP;KN;LC;MF;MS;SX;TT;VC;VG;VI 169 | America/Punta_Arenas,-03:00,CL 170 | America/Rankin_Inlet,-06:00,CA 171 | America/Recife,-03:00,BR 172 | America/Regina,-06:00,CA 173 | America/Resolute,-06:00,CA 174 | America/Rio_Branco,-05:00,BR 175 | America/Santarem,-03:00,BR 176 | America/Santiago,-04:00,CL 177 | America/Santo_Domingo,-04:00,DO 178 | America/Sao_Paulo,-03:00,BR 179 | America/Scoresbysund,-01:00,GL 180 | America/Sitka,-09:00,US 181 | America/St_Barthelemy,-04:00,BL 182 | America/St_Johns,-03:30,CA 183 | America/St_Kitts,-04:00,KN 184 | America/St_Lucia,-04:00,LC 185 | America/St_Thomas,-04:00,VI 186 | America/St_Vincent,-04:00,VC 187 | America/Swift_Current,-06:00,CA 188 | America/Tegucigalpa,-06:00,HN 189 | America/Thule,-04:00,GL 190 | America/Thunder_Bay,-05:00,CA 191 | America/Tijuana,-08:00,MX 192 | America/Toronto,-05:00,CA;BS 193 | America/Tortola,-04:00,VG 194 | America/Vancouver,-08:00,CA 195 | America/Whitehorse,-07:00,CA 196 | America/Winnipeg,-06:00,CA 197 | America/Yakutat,-09:00,US 198 | Antarctica/Casey,+08:00,AQ 199 | Antarctica/Davis,+07:00,AQ 200 | Antarctica/DumontDUrville,+10:00,AQ 201 | Antarctica/Macquarie,+10:00,AU 202 | Antarctica/Mawson,+05:00,AQ 203 | Antarctica/McMurdo,+12:00,AQ 204 | Antarctica/Palmer,-03:00,AQ 205 | Antarctica/Rothera,-03:00,AQ 206 | Antarctica/Syowa,+03:00,AQ 207 | Antarctica/Troll,+00:00,AQ 208 | Antarctica/Vostok,+06:00,AQ 209 | Arctic/Longyearbyen,+01:00,SJ 210 | Asia/Aden,+03:00,YE 211 | Asia/Almaty,+05:00,KZ 212 | Asia/Amman,+02:00,JO 213 | Asia/Anadyr,+12:00,RU 214 | Asia/Aqtau,+05:00,KZ 215 | Asia/Aqtobe,+05:00,KZ 216 | Asia/Ashgabat,+05:00,TM 217 | Asia/Atyrau,+05:00,KZ 218 | Asia/Baghdad,+03:00,IQ 219 | Asia/Bahrain,+03:00,BH 220 | Asia/Baku,+04:00,AZ 221 | Asia/Bangkok,+07:00,TH;CX;KH;LA;VN 222 | Asia/Barnaul,+07:00,RU 223 | Asia/Beirut,+02:00,LB 224 | Asia/Bishkek,+06:00,KG 225 | Asia/Brunei,+08:00,BN 226 | Asia/Chita,+09:00,RU 227 | Asia/Colombo,+05:30,LK 228 | Asia/Damascus,+02:00,SY 229 | Asia/Dhaka,+06:00,BD 230 | Asia/Dili,+09:00,TL 231 | Asia/Dubai,+04:00,AE;OM;RE;SC;TF 232 | Asia/Dushanbe,+05:00,TJ 233 | Asia/Famagusta,+02:00,CY 234 | Asia/Gaza,+02:00,PS 235 | Asia/Hebron,+02:00,PS 236 | Asia/Ho_Chi_Minh,+07:00,VN 237 | Asia/Hong_Kong,+08:00,HK 238 | Asia/Hovd,+07:00,MN 239 | Asia/Irkutsk,+08:00,RU 240 | Asia/Jakarta,+07:00,ID 241 | Asia/Jayapura,+09:00,ID 242 | Asia/Jerusalem,+02:00,IL 243 | Asia/Kabul,+04:30,AF 244 | Asia/Kamchatka,+12:00,RU 245 | Asia/Karachi,+05:00,PK 246 | Asia/Kathmandu,+05:45,NP 247 | Asia/Khandyga,+09:00,RU 248 | Asia/Kolkata,+05:30,IN 249 | Asia/Krasnoyarsk,+07:00,RU 250 | Asia/Kuala_Lumpur,+08:00,MY 251 | Asia/Kuching,+08:00,MY;BN 252 | Asia/Kuwait,+03:00,KW 253 | Asia/Macau,+08:00,MO 254 | Asia/Magadan,+11:00,RU 255 | Asia/Makassar,+08:00,ID 256 | Asia/Manila,+08:00,PH 257 | Asia/Muscat,+04:00,OM 258 | Asia/Nicosia,+02:00,CY 259 | Asia/Novokuznetsk,+07:00,RU 260 | Asia/Novosibirsk,+07:00,RU 261 | Asia/Omsk,+06:00,RU 262 | Asia/Oral,+05:00,KZ 263 | Asia/Phnom_Penh,+07:00,KH 264 | Asia/Pontianak,+07:00,ID 265 | Asia/Pyongyang,+09:00,KP 266 | Asia/Qatar,+03:00,QA;BH 267 | Asia/Qostanay,+06:00,KZ 268 | Asia/Qyzylorda,+05:00,KZ 269 | Asia/Riyadh,+03:00,SA;AQ;KW;YE 270 | Asia/Sakhalin,+11:00,RU 271 | Asia/Samarkand,+05:00,UZ 272 | Asia/Seoul,+09:00,KR 273 | Asia/Shanghai,+08:00,CN 274 | Asia/Singapore,+08:00,SG;MY 275 | Asia/Srednekolymsk,+11:00,RU 276 | Asia/Taipei,+08:00,TW 277 | Asia/Tashkent,+05:00,UZ 278 | Asia/Tbilisi,+04:00,GE 279 | Asia/Tehran,+03:30,IR 280 | Asia/Thimphu,+06:00,BT 281 | Asia/Tokyo,+09:00,JP 282 | Asia/Tomsk,+07:00,RU 283 | Asia/Ulaanbaatar,+08:00,MN 284 | Asia/Urumqi,+06:00,CN 285 | Asia/Ust-Nera,+10:00,RU 286 | Asia/Vientiane,+07:00,LA 287 | Asia/Vladivostok,+10:00,RU 288 | Asia/Yakutsk,+09:00,RU 289 | Asia/Yangon,+06:30,MM;CC 290 | Asia/Yekaterinburg,+05:00,RU 291 | Asia/Yerevan,+04:00,AM 292 | Atlantic/Azores,-01:00,PT 293 | Atlantic/Bermuda,-04:00,BM 294 | Atlantic/Canary,+00:00,ES 295 | Atlantic/Cape_Verde,-01:00,CV 296 | Atlantic/Faroe,+00:00,FO 297 | Atlantic/Madeira,+00:00,PT 298 | Atlantic/Reykjavik,+00:00,IS 299 | Atlantic/South_Georgia,-02:00,GS 300 | Atlantic/St_Helena,+00:00,SH 301 | Atlantic/Stanley,-03:00,FK 302 | Australia/Adelaide,+09:30,AU 303 | Australia/Brisbane,+10:00,AU 304 | Australia/Broken_Hill,+09:30,AU 305 | Australia/Darwin,+09:30,AU 306 | Australia/Eucla,+08:45,AU 307 | Australia/Hobart,+10:00,AU 308 | Australia/Lindeman,+10:00,AU 309 | Australia/Lord_Howe,+10:30,AU 310 | Australia/Melbourne,+10:00,AU 311 | Australia/Perth,+08:00,AU 312 | Australia/Sydney,+10:00,AU 313 | Europe/Amsterdam,+01:00,NL 314 | Europe/Andorra,+01:00,AD 315 | Europe/Astrakhan,+04:00,RU 316 | Europe/Athens,+02:00,GR 317 | Europe/Belgrade,+01:00,RS;BA;HR;ME;MK;SI 318 | Europe/Berlin,+01:00,DE;DK;NO;SE;SJ 319 | Europe/Bratislava,+01:00,SK 320 | Europe/Brussels,+01:00,BE;LU;NL 321 | Europe/Bucharest,+02:00,RO 322 | Europe/Budapest,+01:00,HU 323 | Europe/Chisinau,+02:00,MD 324 | Europe/Copenhagen,+01:00,DK 325 | Europe/Dublin,+00:00,IE 326 | Europe/Gibraltar,+01:00,GI 327 | Europe/Guernsey,+00:00,GG 328 | Europe/Helsinki,+02:00,FI;AX 329 | Europe/Isle_of_Man,+00:00,IM 330 | Europe/Istanbul,+03:00,TR 331 | Europe/Jersey,+00:00,JE 332 | Europe/Kaliningrad,+02:00,RU 333 | Europe/Kirov,+03:00,RU 334 | Europe/Kyiv,+02:00,UA 335 | Europe/Lisbon,+00:00,PT 336 | Europe/Ljubljana,+01:00,SI 337 | Europe/London,+00:00,UK;GG;IM;JE 338 | Europe/Luxembourg,+01:00,LU 339 | Europe/Madrid,+01:00,ES 340 | Europe/Malta,+01:00,MT 341 | Europe/Mariehamn,+02:00,AX 342 | Europe/Minsk,+03:00,BY 343 | Europe/Monaco,+01:00,MC 344 | Europe/Moscow,+03:00,RU 345 | Europe/Oslo,+01:00,NO 346 | Europe/Paris,+01:00,FR;MC 347 | Europe/Podgorica,+01:00,ME 348 | Europe/Prague,+01:00,CZ;SK 349 | Europe/Riga,+02:00,LV 350 | Europe/Rome,+01:00,IT;SM;VA 351 | Europe/Samara,+04:00,RU 352 | Europe/San_Marino,+01:00,SM 353 | Europe/Sarajevo,+01:00,BA 354 | Europe/Saratov,+04:00,RU 355 | Europe/Simferopol,+03:00,RU;UA 356 | Europe/Skopje,+01:00,MK 357 | Europe/Sofia,+02:00,BG 358 | Europe/Stockholm,+01:00,SE 359 | Europe/Tallinn,+02:00,EE 360 | Europe/Tirane,+01:00,AL 361 | Europe/Ulyanovsk,+04:00,RU 362 | Europe/Vaduz,+01:00,LI 363 | Europe/Vatican,+01:00,VA 364 | Europe/Vienna,+01:00,AT 365 | Europe/Vilnius,+02:00,LT 366 | Europe/Volgograd,+03:00,RU 367 | Europe/Warsaw,+01:00,PL 368 | Europe/Zagreb,+01:00,HR 369 | Europe/Zurich,+01:00,CH;DE;LI 370 | Indian/Antananarivo,+03:00,MG 371 | Indian/Chagos,+06:00,IO 372 | Indian/Christmas,+07:00,CX 373 | Indian/Cocos,+06:30,CC 374 | Indian/Comoro,+03:00,KM 375 | Indian/Kerguelen,+05:00,TF 376 | Indian/Mahe,+04:00,SC 377 | Indian/Maldives,+05:00,MV;TF 378 | Indian/Mauritius,+04:00,MU 379 | Indian/Mayotte,+03:00,YT 380 | Indian/Reunion,+04:00,RE 381 | Pacific/Apia,+13:00,WS 382 | Pacific/Auckland,+12:00,NZ;AQ 383 | Pacific/Bougainville,+11:00,PG 384 | Pacific/Chatham,+12:45,NZ 385 | Pacific/Chuuk,+10:00,FM 386 | Pacific/Easter,-06:00,CL 387 | Pacific/Efate,+11:00,VU 388 | Pacific/Fakaofo,+13:00,TK 389 | Pacific/Fiji,+12:00,FJ 390 | Pacific/Funafuti,+12:00,TV 391 | Pacific/Galapagos,-06:00,EC 392 | Pacific/Gambier,-09:00,PF 393 | Pacific/Guadalcanal,+11:00,SB;FM 394 | Pacific/Guam,+10:00,GU;MP 395 | Pacific/Honolulu,-10:00,US 396 | Pacific/Kiritimati,+14:00,KI 397 | Pacific/Kosrae,+11:00,FM 398 | Pacific/Kwajalein,+12:00,MH 399 | Pacific/Majuro,+12:00,MH 400 | Pacific/Marquesas,-09:30,PF 401 | Pacific/Midway,-11:00,UM 402 | Pacific/Nauru,+12:00,NR 403 | Pacific/Niue,-11:00,NU 404 | Pacific/Norfolk,+11:00,NF 405 | Pacific/Noumea,+11:00,NC 406 | Pacific/Pago_Pago,-11:00,AS;UM 407 | Pacific/Palau,+09:00,PW 408 | Pacific/Pitcairn,-08:00,PN 409 | Pacific/Pohnpei,+11:00,FM 410 | Pacific/Port_Moresby,+10:00,PG;AQ;FM 411 | Pacific/Rarotonga,-10:00,CK 412 | Pacific/Saipan,+10:00,MP 413 | Pacific/Tahiti,-10:00,PF 414 | Pacific/Tarawa,+12:00,KI;MH;TV;UM;WF 415 | Pacific/Tongatapu,+13:00,TO 416 | Pacific/Wake,+12:00,UM 417 | Pacific/Wallis,+12:00,WF -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guide 2 | 3 | - [How to?](#how-to) 4 | - [Data Scheme](#data-scheme) 5 | - [Project Structure](#project-structure) 6 | - [Scripts](#scripts) 7 | - [Workflows](#workflows) 8 | 9 | ## How to? 10 | 11 | ### How to add a new entry to the database? 12 | 13 | The easiest way is to submit a request using one of the available [forms](https://github.com/iptv-org/database/issues/new/choose). Simply enter all the information you know and click "Submit". Once your request is approved, the entry will be automatically added to the database. 14 | 15 | If you want to add more than one entry, you can do so directly by editing the file in the [data/](data/) folder using any text editor. After that, just [commit](https://docs.github.com/en/pull-requests/committing-changes-to-your-project/creating-and-editing-commits/about-commits) all changes and send us 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). 16 | 17 | **IMPORTANT:** Before sending the request, make sure that the number of columns in the file has not changed and that all rows end with [CRLF](https://developer.mozilla.org/en-US/docs/Glossary/CRLF). Otherwise we will not be able to review this request. 18 | 19 | ### How to edit a database entry? 20 | 21 | The first option is to send a request through one of the available [forms](https://github.com/iptv-org/database/issues/new/choose). Simply enter the new data in the form and click "Submit". To delete a value, insert `~` in the desired field. Once your request has been approved, the entry will be automatically updated. 22 | 23 | The second option is to edit the file in the [data/](data/) folder using any text editor and then send us 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). 24 | 25 | **IMPORTANT:** Before sending the request, make sure that the number of columns in the file has not changed and that all rows end with [CRLF](https://developer.mozilla.org/en-US/docs/Glossary/CRLF). Otherwise we will not be able to review this request. 26 | 27 | ### How to delete an entry from the database? 28 | 29 | To do this, you need to fill out one of the [forms](https://github.com/iptv-org/database/issues/new/choose), and once your request has been approved, the entry will be automatically deleted. 30 | 31 | ## Data Scheme 32 | 33 | ### channels 34 | 35 | | Field | Description | Required | Example | 36 | | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | -------------------------- | 37 | | id | Unique channel ID derived from the `name` and `country` separated by dot. May only contain Latin letters, numbers and dot. | Required | `AnhuiTV.cn` | 38 | | name | Official channel name in English or call sign. May include: `a-z`, `0-9`, `space`, `-`, `!`, `:`, `&`, `.`, `+`, `'`, `/`, `»`, `#`, `%`, `°`, `$`, `@`, `?`, \|, `¡`. | Required | `Anhui TV` | 39 | | alt_names | List of alternative channel names separated by `;`. May contain any characters except `,` and `"`. | Optional | `安徽卫视;AHTV` | 40 | | network | Network of which this channel is a part. May contain any characters except `,` and `"`. | Optional | `Anhui` | 41 | | owners | List of channel owners separated by `;`. May contain any characters except `,` and `"`. | Optional | `China Central Television` | 42 | | 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` | 43 | | 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` | 44 | | is_nsfw | Indicates whether the channel broadcasts adult content (`TRUE` or `FALSE`). | Required | `FALSE` | 45 | | launched | Launch date of the channel (`YYYY-MM-DD`). | Optional | `2016-07-28` | 46 | | closed | Date on which the channel closed (`YYYY-MM-DD`). | Optional | `2020-05-31` | 47 | | replaced_by | The ID of the channel that this channel was replaced by. | Optional | `CCTV1.cn` | 48 | | website | Official website URL. | Optional | `http://www.ahtv.cn/` | 49 | 50 | ### feeds 51 | 52 | | Field | Description | Required | Example | 53 | | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ---------------------------- | 54 | | channel | ID of the channel to which this feed belongs. | Required | `France3.fr` | 55 | | id | Unique feed ID derived from the `name`. May only contain Latin letters and numbers. | Required | `Mediterranee` | 56 | | name | Name of the feed in English. May include: `a-z`, `0-9`, `space`, `-`, `!`, `:`, `&`, `.`, `+`, `'`, `/`, `»`, `#`, `%`, `°`, `$`, `@`, `?`, \|, `¡`. | Required | `Mediterranee` | 57 | | alt_names | List of alternative feed names separated by `;`. May contain any characters except `,` and `"`. | Optional | `Méditerranée;Mediterranean` | 58 | | is_main | Indicates if this feed is the main for the channel (`TRUE` or `FALSE`). | Required | `FALSE` | 59 | | broadcast_area | List of codes describing the broadcasting area of the feed separated by `;`. Any combination of `r/`, `c/`, `s/`, `ct/` is allowed. A full list of supported codes can be found here: [data/regions.csv](https://github.com/iptv-org/database/blob/master/data/regions.csv), [data/countries.csv](https://github.com/iptv-org/database/blob/master/data/countries.csv), [data/subdivisions.csv](https://github.com/iptv-org/database/blob/master/data/subdivisions.csv), [data/cities.csv](https://github.com/iptv-org/database/blob/master/data/cities.csv). | Required | `s/FR-IDF;s/FR-NOR` | 60 | | timezones | List of timezones in which the feed is broadcast separated by `;`. A list of all supported timezones and their codes can be found in [data/timezones.csv](data/timezones.csv). | Required | `Europe/Paris` | 61 | | languages | List of languages in which the feed is broadcast separated by `;`. A list of all supported languages and their codes can be found in [data/languages.csv](data/languages.csv). | Required | `fra;eng` | 62 | | format | Video format of the feed. | Required | `1080i` | 63 | 64 | ### logos 65 | 66 | | Field | Description | Required | Example | 67 | | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------------------------ | 68 | | channel | Channel ID. | Required | `France3.fr` | 69 | | feed | Feed ID. | Optional | `Alpes` | 70 | | tags | List of keywords describing this version of the logo separated by `;`. May include: `a-z`, `0-9` and `-`. | Optional | `horizontal;white` | 71 | | width | The width of the image in pixels. | Required | `1000` | 72 | | height | The height of the image in pixels. | Required | `468` | 73 | | format | Image format. One of: `PNG`, `JPEG`, `SVG`, `GIF`, `WebP`, `AVIF`, `APNG`. | Optional | `SVG` | 74 | | url | Logo URL. Only URLs with [HTTPS](https://ru.wikipedia.org/wiki/HTTPS) protocol are supported. Also the link should not be [geo-blocked](https://en.wikipedia.org/wiki/Geo-blocking). May contain any characters except `,` and `"`. | Required | `https://example.com/logo.svg` | 75 | 76 | ### categories 77 | 78 | | Field | Description | Required | Example | 79 | | ----------- | --------------------------------- | -------- | ---------------------------- | 80 | | id | Category ID | Required | `news` | 81 | | name | Category name | Required | `News` | 82 | | description | Short description of the category | Required | `Programming is mostly news` | 83 | 84 | ### languages 85 | 86 | | Field | Description | Required | Example | 87 | | ----- | ------------------------------------------------------------------------- | -------- | ---------- | 88 | | name | Official language name | Required | `Croatian` | 89 | | code | [ISO 639-3](https://en.wikipedia.org/wiki/ISO_639-3) code of the language | Required | `hrv` | 90 | 91 | ### countries 92 | 93 | | Field | Description | Required | Example | 94 | | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | --------- | 95 | | name | Official name of the country | Required | `Canada` | 96 | | code | [ISO 3166-1 alpha-2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) code of the country | Required | `CA` | 97 | | languages | List of official languages of the country separated by `;`. A list of all supported languages can be found in [data/languages.csv](data/languages.csv). | Required | `eng;fra` | 98 | | flag | Country flag emoji | Required | `🇨🇦` | 99 | 100 | ### subdivisions 101 | 102 | | Field | Description | Required | Example | 103 | | ------- | ------------------------------------------------------------------------------------------ | -------- | ----------- | 104 | | country | [ISO 3166-1 alpha-2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) code of the country | Required | `BD` | 105 | | name | Official subdivision name | Required | `Bandarban` | 106 | | code | [ISO 3166-2](https://en.wikipedia.org/wiki/ISO_3166-2) code of the subdivision | Required | `BD-01` | 107 | | parent | [ISO 3166-2](https://en.wikipedia.org/wiki/ISO_3166-2) code of the parent subdivision | Optional | `BD-B` | 108 | 109 | ### cities 110 | 111 | | Field | Description | Required | Example | 112 | | ----------- | -------------------------------------------------------------------------------------------------------------------- | -------- | --------- | 113 | | country | [ISO 3166-1 alpha-2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) code of the country where the city is located | Required | `CN` | 114 | | subdivision | [ISO 3166-2](https://en.wikipedia.org/wiki/ISO_3166-2) code of the subdivision where the city is located | Optional | `CN-SD` | 115 | | name | Official city name | Required | `Yantai` | 116 | | code | [UN/LOCODE](https://en.wikipedia.org/wiki/UN/LOCODE) of the city | Required | `CNYAT` | 117 | | wikidata_id | ID of this city in [Wikidata](https://www.wikidata.org/wiki/Wikidata:Main_Page) | Required | `Q210493` | 118 | 119 | ### regions 120 | 121 | | Field | Description | Required | Example | 122 | | --------- | ---------------------------------------------------------------------------------------------------------------------- | -------- | ---------------- | 123 | | name | Official name of the region | Required | `Central Asia` | 124 | | code | Abbreviated designation for the region. May only contain Latin letters in upper case. The minimum length is 3 letters. | Required | `CAS` | 125 | | countries | List of country codes in the region | Required | `KG;KZ;TJ;TM;UZ` | 126 | 127 | ### timezones 128 | 129 | | Field | Description | Required | Example | 130 | | ---------- | ------------------------------------------------------------------------- | -------- | --------------------- | 131 | | id | Timezone ID from [tz database](https://en.wikipedia.org/wiki/Tz_database) | Required | `Africa/Johannesburg` | 132 | | utc_offset | [UTC offset](https://en.wikipedia.org/wiki/UTC_offset) for this time zone | Required | `+02:00` | 133 | | countries | List of countries included in this time zone | Required | `ZA;LS;SZ` | 134 | 135 | ### blocklist 136 | 137 | List of channels blocked at the request of copyright holders. 138 | 139 | | Field | Description | Required | Example | 140 | | ------- | ----------------------------------------------- | -------- | --------------------------------- | 141 | | channel | Channel ID | Required | `AnimalPlanetAfrica.us` | 142 | | reason | Reason for blocking | Required | `dmca` | 143 | | ref | Link to removal request or DMCA takedown notice | Required | `https://example.com/issues/0000` | 144 | 145 | ## Project Structure 146 | 147 | ``` 148 | database/ 149 | ├── .github/ 150 | | ├── ISSUE_TEMPLATE # issue templates for the repository 151 | | ├── workflows # contains GitHub actions workflows 152 | | ├── CODE_OF_CONDUCT.md # rules you shouldn't break if you don't want to get banned 153 | ├── .husky/ 154 | | ├── pre-commit # commands to run before each commit 155 | ├── .readme/ 156 | | ├── preview.png # image displayed in the README.md 157 | ├── data/ # contains all data 158 | ├── scripts/ # contains all scripts used in the repository 159 | ├── tests/ # contains tests to check the scripts 160 | ├── .prettierrc.js # configuration file for Prettier 161 | ├── eslint.config.mjs # configuration file for ESLint 162 | ├── package.json # project manifest file 163 | ├── tsconfig.json # configuration file for TypeScript 164 | ├── LICENSE # license text 165 | ├── CONTRIBUTING.md # file you are currently reading 166 | ├── README.md # project description displayed on the home page 167 | ``` 168 | 169 | ## Scripts 170 | 171 | These scripts are created to automate routine processes in the repository and make it a bit easier to maintain. 172 | 173 | For scripts to work, you must have [Node.js](https://nodejs.org/en) installed on your computer. 174 | 175 | To run scripts use the `npm run ` command. 176 | 177 | - `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). 178 | - `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). 179 | - `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). 180 | - `db:validate`: checks the integrity of data. 181 | - `db:export`: saves all data in JSON format to the `/.api` folder. 182 | - `db:update`: triggers a data update using approved requests from issues. 183 | - `lint`: сhecks the scripts for syntax errors. 184 | - `test`: runs a test of all the scripts described above. 185 | 186 | ## Workflows 187 | 188 | To automate the run of the scripts described above, we use the [GitHub Actions workflows](https://docs.github.com/en/actions/using-workflows). 189 | 190 | Each workflow includes its own set of scripts that can be run either manually or in response to an event. 191 | 192 | - `check`: runs the `db:validate` script when a new pull request appears, and blocks the merge if it detects an error in it. 193 | - `update`: sequentially runs `db:update` and `db:validate` scripts and commits all the changes if successful. 194 | - `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. 195 | --------------------------------------------------------------------------------