Merge pull request #2059 from iptv-org/patch-2025.03.1

This commit is contained in:
Alstruit 2025-03-16 19:49:58 -05:00 committed by GitHub
commit 9e938795d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 9803 additions and 556 deletions

7234
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -8,11 +8,19 @@
"build": "NODE_OPTIONS=--max_old_space_size=4096 vite build",
"preview": "vite preview",
"postbuild": "npx svelte-sitemap -d https://iptv-org.github.io -o docs",
"postinstall": "node ./src/load.js"
"postinstall": "node ./src/load.js",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
},
"jest": {
"transform": {},
"moduleNameMapper": {
"^\\$app/environment$": "<rootDir>/tests/__mocks__/$app/environment.js",
"^\\$app/navigation$": "<rootDir>/tests/__mocks__/$app/navigation.js"
}
},
"devDependencies": {
"@freearhey/core": "^0.5.1",
"@freearhey/search-js": "^0.1.1",
"@freearhey/search-js": "^0.1.2",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.17.1",
"@tailwindcss/line-clamp": "^0.4.2",
@ -22,11 +30,13 @@
"cli-progress": "^3.12.0",
"dayjs": "^1.11.1",
"iptv-playlist-generator": "^0.1.5",
"jest": "^29.7.0",
"lodash": "^4.17.21",
"numeral": "^2.0.6",
"postcss": "^8.5.1",
"prettier-plugin-svelte": "^3.3.3",
"qs": "^6.11.2",
"svelte": "^5.22.6",
"svelte-simple-modal": "^2.0.0",
"svelte-sitemap": "^2.6.0",
"sveltejs-tippy": "^3.0.0",

View file

@ -3,13 +3,32 @@
export let channel
const blocklistRefs = channel.blocklist_records
.map(record => {
const parts = record.ref.split('/')
const issueId = parts.pop()
const prefix = record.ref.includes('/issues/') ? '#' : ''
let reason
const messages = {
dmca: 'The channel has been added to our blocklist due to the claims of the copyright holder',
nsfw: 'The channel has been added to our blocklist due to NSFW content'
}
return `<a class="underline" target="_blank" rel="noreferrer" href="${record.ref}">${prefix}${issueId}</a>`
const blocklistRefs = channel._blocklistRecords
.map(record => {
let refName
const isIssue = /issues|pull/.test(record.ref)
const isAttachment = /github\.zendesk\.com\/attachments\/token/.test(record.ref)
if (isIssue) {
const parts = record.ref.split('/')
const issueId = parts.pop()
refName = `#${issueId}`
} else if (isAttachment) {
const [, filename] = record.ref.match(/\?name=(.*)/) || [null, undefined]
refName = filename
} else {
refName = record.ref.split('/').pop()
}
reason = record.reason
return `<a class="underline" target="_blank" rel="noreferrer" href="${record.ref}">${refName}</a>`
})
.join(', ')
</script>
@ -17,7 +36,7 @@
<div
class="text-gray-500 border-[1px] border-gray-200 text-xs inline-flex items-center px-2.5 py-0.5 dark:text-gray-300 rounded-full"
use:tippy={{
content: `The channel has been added to our blocklist due to the claims of the copyright holder: ${blocklistRefs}`,
content: `${messages[reason]}: ${blocklistRefs}`,
allowHTML: true,
placement: 'right',
interactive: true

View file

@ -35,7 +35,7 @@
</div>
</div>
<div>
{#each channelsDisplay as channel (channel.id)}
{#each channelsDisplay as channel, idx (channel)}
<ChannelItem bind:channel />
{/each}
</div>

View file

@ -8,11 +8,13 @@
import ClosedBadge from './ClosedBadge.svelte'
import { downloadMode, selected } from '~/store'
import { fade } from 'svelte/transition'
import { pushState } from '$app/navigation'
export let channel
const guides = channel._guides
const streams = channel._streams
const displayName = channel._displayName
const [name, country] = channel.id.split('.')
@ -20,25 +22,21 @@
let prevUrl = '/'
const onOpened = () => {
prevUrl = window.location.href
window.history.pushState(
{},
`${channel.displayName} • iptv-org`,
`/channels/${country}/${name}`
)
pushState(`/channels/${country}/${name}`, {})
}
const onClose = () => {
window.history.pushState({}, `iptv-org`, prevUrl)
pushState(prevUrl, {})
}
const showGuides = () =>
open(
GuidesPopup,
{ guides, title: channel.displayName },
{ guides, title: displayName },
{ transitionBgProps: { duration: 0 }, transitionWindowProps: { duration: 0 } }
)
const showStreams = () =>
open(
StreamsPopup,
{ streams, title: channel.displayName },
{ streams, title: displayName },
{ transitionBgProps: { duration: 0 }, transitionWindowProps: { duration: 0 } }
)
const showChannelData = () => {
@ -89,7 +87,7 @@
loading="lazy"
referrerpolicy="no-referrer"
src={channel.logo}
alt={channel.displayName}
alt={displayName}
/>
{/if}
</div>
@ -103,9 +101,9 @@
href="/channels/{country}/{name}"
tabindex="0"
class="font-normal text-gray-600 dark:text-white hover:underline hover:text-blue-500 truncate whitespace-nowrap"
title={channel.displayName}
title={displayName}
>
{channel.displayName}
{displayName}
</a>
<div class="flex space-x-2">
{#if channel.is_closed}

View file

@ -30,7 +30,7 @@
>
<div class="w-2/3 overflow-hidden">
<div class="flex items-center space-x-3">
<h3 class="text-l font-medium text-gray-900 dark:text-white">{channel.displayName}</h3>
<h3 class="text-l font-medium text-gray-900 dark:text-white">{channel._displayName}</h3>
<div class="flex space-x-2">
{#if channel.is_closed}
<ClosedBadge {channel} />

View file

@ -6,7 +6,7 @@
export let channel
const endpoint = 'https://github.com/iptv-org/database/issues/new'
const title = `Edit: ${channel.displayName}`
const title = `Edit: ${channel._displayName}`
const labels = 'channels:edit'
const template = '__channels_edit.yml'

View file

@ -1,7 +1,5 @@
<script>
import dayjs from 'dayjs'
import { goto } from '$app/navigation'
import { query, hasQuery, setSearchParam } from '~/store'
export let data
export let close = () => {}
@ -19,7 +17,7 @@
{
name: 'owners',
type: 'link[]',
value: data.owners.map(value => ({ label: value, query: `owners:${norm(value)}` }))
value: data.owners.map(value => ({ label: value, query: `owner:${norm(value)}` }))
},
{
name: 'country',
@ -41,7 +39,7 @@
{
name: 'broadcast_area',
type: 'link[]',
value: data._broadcast_area.map(v => ({
value: data._broadcastArea.map(v => ({
label: v.name,
query: `broadcast_area:${v.type}/${v.code}`
}))
@ -49,12 +47,12 @@
{
name: 'languages',
type: 'link[]',
value: data._languages.map(v => ({ label: v.name, query: `languages:${v.code}` }))
value: data._languages.map(v => ({ label: v.name, query: `language:${v.code}` }))
},
{
name: 'categories',
type: 'link[]',
value: data._categories.map(v => ({ label: v.name, query: `categories:${v.id}` }))
value: data._categories.map(v => ({ label: v.name, query: `category:${v.id}` }))
},
{
name: 'is_nsfw',
@ -96,7 +94,7 @@
</div>
</td>
<td class="align-top w-full overflow-hidden">
<div class="flex pb-3 text-sm text-gray-800 dark:text-gray-100">
<div class="pb-3 text-sm text-gray-800 dark:text-gray-100">
{#if field.type === 'image'}
<img
src={field.value}

View file

@ -15,12 +15,12 @@
result: 'Find channels that have "Nat Geo" in the name.'
},
{
query: 'alt_names:חינוכית',
query: 'alt_name:חינוכית',
result: 'Finds channels whose alternative name contains "חינוכית".'
},
{ query: 'network:ABC', result: 'Finds all channels operated by the ABC Network.' },
{
query: 'owners:^$',
query: 'owner:^$',
result: 'Finds channels that have no owner listed.'
},
{ query: 'country:GY', result: 'Finds all channels that are broadcast from Guyana.' },
@ -30,8 +30,8 @@
},
{ query: 'city:"San Francisco"', result: 'Finds all channels broadcast from San Francisco.' },
{ query: 'broadcast_area:c/CV', result: 'Finds channels that are broadcast in Cape Verde.' },
{ query: 'languages:fra', result: 'Find channels that are broadcast in French.' },
{ query: 'categories:news', result: 'Finds all the news channels.' },
{ query: 'language:fra', result: 'Find channels that are broadcast in French.' },
{ query: 'category:news', result: 'Finds all the news channels.' },
{ query: 'website:.', result: 'Finds channels that have a link to the official website.' },
{ query: 'is_nsfw:true', result: 'Finds channels marked as NSFW.' },
{
@ -43,7 +43,8 @@
result:
'Finds channels that have been added to our blocklist due to the claim of the copyright holder.'
},
{ query: 'streams:<2', result: 'Finds channels with less than 2 streams.' }
{ query: 'streams:<2', result: 'Finds channels with less than 2 streams.' },
{ query: 'guides:>0', result: 'Finds channels that have guides.' }
]
</script>

56
src/models/channel.js Normal file
View file

@ -0,0 +1,56 @@
export class Channel {
constructor(data) {
const _streams = Array.isArray(data.streams) ? data.streams : []
const _guides = Array.isArray(data.guides) ? data.guides : []
const _blocklistRecords = Array.isArray(data.blocklistRecords) ? data.blocklistRecords : []
this.id = data.id
this.name = data.name
this.alt_names = this.alt_name = data.altNames
this.network = data.network
this.owners = this.owner = data.owners
this.city = data.city
this.country = [data.country?.code, data.country?.name].filter(Boolean)
this.subdivision = data.subdivision?.code || null
this.languages = this.language = [
...data.languages.map(language => language.code),
...data.languages.map(language => language.name)
]
this.categories = this.category = data.categories.map(category => category.name)
this.broadcast_area = [
...data.broadcastArea.map(area => `${area.type}/${area.code}`).filter(Boolean),
...data.broadcastArea.map(area => area.name).filter(Boolean),
...data.regionCountries.map(country => country.code).filter(Boolean),
...data.regionCountries.map(country => country.name).filter(Boolean)
]
this.is_nsfw = data.isNSFW
this.launched = data.launched
this.closed = data.closed
this.is_closed = !!data.closed || !!data.replacedBy
this.replaced_by = data.replacedBy
this.website = data.website
this.logo = data.logo
this.streams = _streams.length
this.guides = _guides.length
this.is_blocked = _blocklistRecords.length > 0
this._hasUniqueName = data.hasUniqueName
this._displayName = data.hasUniqueName ? data.name : `${data.name} (${data.country?.name})`
this._country = data.country
this._subdivision = data.subdivision || null
this._languages = data.languages
this._categories = data.categories
this._broadcastArea = data.broadcastArea
this._streams = _streams
this._guides = _guides
this._blocklistRecords = _blocklistRecords
this._guideNames = _guides.map(guide => guide.site_name).filter(Boolean)
this._streamUrls = _streams.map(stream => stream.url).filter(Boolean)
}
toObject() {
const { ...object } = this
return object
}
}

1
src/models/index.js Normal file
View file

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

View file

@ -10,12 +10,11 @@
countries,
filteredChannels,
query,
setSearchParam,
setPageTitle,
downloadMode,
search
} from '~/store'
import { onMount, onDestroy } from 'svelte'
import { onMount } from 'svelte'
import CountryItem from '~/components/CountryItem.svelte'
import SearchField from '~/components/SearchField.svelte'
import _ from 'lodash'
@ -24,17 +23,7 @@
let _countries = []
let isLoading = true
$: groupedByCountry = _.groupBy($filteredChannels, 'country')
function loadMore({ detail }) {
let { loaded, complete } = detail
if (limit < _countries.length) {
limit++
loaded()
} else {
complete()
}
}
$: groupedByCountry = _.groupBy($filteredChannels, channel => channel._country.code)
onMount(async () => {
if (!$channels.length) {
@ -92,7 +81,7 @@
loading...
</div>
{/if}
{#each _countries as country (country.code)}
{#each _countries as country, idx (country)}
{#if groupedByCountry[country.code] && groupedByCountry[country.code].length > 0}
<CountryItem
bind:country

View file

@ -1,4 +1,4 @@
import { transformChannel } from '~/store'
import { createChannel } from '~/store'
import _ from 'lodash'
import channels from '~/data/channels.json'
import countries from '~/data/countries.json'
@ -47,6 +47,6 @@ export function load({ params }) {
let channel = data.channels[id]
return {
channel: channel ? transformChannel(channel, data) : null
channel: channel ? createChannel(channel, data).toObject() : null
}
}

View file

@ -8,9 +8,10 @@
export let data
let isLoading = false
let channel = data.channel
let streams = channel ? channel._streams : []
let guides = channel ? channel._guides : []
const channel = data.channel
const streams = channel ? channel._streams : []
const guides = channel ? channel._guides : []
const displayName = channel._displayName
const structuredData = {
'@context': 'https://schema.org/',
@ -28,8 +29,8 @@
</script>
<svelte:head>
<title>{channel && channel.displayName ? `${channel.displayName} iptv-org` : 'iptv-org'}</title>
<meta name="description" content="Detailed description of {channel.displayName}." />
<title>{channel && displayName ? `${displayName} iptv-org` : 'iptv-org'}</title>
<meta name="description" content="Detailed description of {displayName}." />
{@html schema()}
</svelte:head>
@ -54,7 +55,7 @@
<div class="w-2/3 overflow-hidden">
<div class="flex space-x-3">
<h1 class="text-l font-medium text-gray-900 dark:text-white">
{channel.displayName}
{displayName}
</h1>
<div class="flex items-center space-x-2">
{#if channel.is_closed}

View file

@ -3,6 +3,8 @@ import { Playlist, Link } from 'iptv-playlist-generator'
import sj from '@freearhey/search-js'
import _ from 'lodash'
import { browser } from '$app/environment'
import { Channel } from './models'
import { pushState } from '$app/navigation'
export const query = writable('')
export const hasQuery = writable(false)
@ -14,7 +16,6 @@ export const downloadMode = writable(false)
let searchIndex = {}
export function search(q) {
console.log('.')
if (!q) {
filteredChannels.set(get(channels))
hasQuery.set(false)
@ -33,7 +34,7 @@ export async function fetchChannels() {
countries.set(api.countries)
let _channels = api.channels.map(c => transformChannel(c, api))
let _channels = api.channels.map(c => createChannel(c, api))
channels.set(_channels)
filteredChannels.set(_channels)
@ -42,36 +43,41 @@ export async function fetchChannels() {
'id',
'name',
'alt_names',
'alt_name',
'network',
'owner',
'owners',
'country',
'subdivision',
'city',
'broadcast_area',
'language',
'languages',
'category',
'categories',
'launched',
'closed',
'replaced_by',
'website',
'streams',
'guides',
'is_nsfw',
'is_closed',
'is_blocked'
'is_blocked',
'_guideNames',
'_streamUrls'
]
})
}
export function setSearchParam(key, value) {
if (window.history.pushState) {
let query = key && value ? `?${key}=${value}` : ''
query = query.replace(/\+/g, '%2B')
const url = `${window.location.protocol}//${window.location.host}${window.location.pathname}${query}`
const state = {}
state[key] = value
window.history.pushState(state, '', url)
setPageTitle(value)
}
let query = key && value ? `?${key}=${value}` : ''
query = query.replace(/\+/g, '%2B')
const url = `${window.location.protocol}//${window.location.host}${window.location.pathname}${query}`
const state = {}
state[key] = value
pushState(url, state)
setPageTitle(value)
}
export function setPageTitle(value) {
@ -81,6 +87,61 @@ export function setPageTitle(value) {
}
}
export function createChannel(data, api) {
let broadcastArea = []
let regionCountries = []
data.broadcast_area.forEach(areaCode => {
const [type, code] = areaCode.split('/')
switch (type) {
case 'c':
const country = api.countries[code]
if (country) broadcastArea.push({ type, code: country.code, name: country.name })
break
case 'r':
const region = api.regions[code]
if (region) {
broadcastArea.push({ type, code: region.code, name: region.name })
regionCountries = [
...regionCountries,
...region.countries.map(code => api.countries[code]).filter(Boolean)
]
}
break
case 's':
const subdivision = api.subdivisions[code]
if (subdivision)
broadcastArea.push({ type, code: subdivision.code, name: subdivision.name })
break
}
})
return new Channel({
id: data.id,
name: data.name,
altNames: data.alt_names,
network: data.network,
owners: data.owners,
city: data.city,
country: api.countries[data.country],
subdivision: api.subdivisions[data.subdivision],
languages: data.languages.map(code => api.languages[code]).filter(Boolean),
categories: data.categories.map(id => api.categories[id]).filter(Boolean),
isNSFW: data.is_nsfw,
launched: data.launched,
closed: data.closed,
replacedBy: data.replaced_by,
website: data.website,
logo: data.logo,
streams: api.streams[data.id],
guides: api.guides[data.id],
blocklistRecords: api.blocklist[data.id],
hasUniqueName: api.nameIndex[data.name.toLowerCase()].length === 1,
broadcastArea,
regionCountries
})
}
async function loadAPI() {
const api = {}
@ -155,40 +216,6 @@ async function loadAPI() {
return api
}
export function transformChannel(channel, data) {
channel._streams = data.streams[channel.id] || []
channel._guides = data.guides[channel.id] || []
channel._country = data.countries[channel.country]
channel._subdivision = data.subdivisions[channel.subdivision]
channel._languages = channel.languages.map(code => data.languages[code]).filter(i => i)
channel._categories = channel.categories.map(id => data.categories[id]).filter(i => i)
channel._broadcast_area = channel.broadcast_area.map(value => {
const [type, code] = value.split('/')
switch (type) {
case 'c':
return { type, ...data.countries[code] }
case 'r':
return { type, ...data.regions[code] }
case 's':
return { type, ...data.subdivisions[code] }
}
})
channel.is_closed = !!channel.closed || !!channel.replaced_by
channel.is_blocked = !!data.blocklist[channel.id]
channel.streams = channel._streams.length
channel.guides = channel._guides.length
channel.blocklist_records = Array.isArray(data.blocklist[channel.id])
? data.blocklist[channel.id]
: []
const isChannelNameRepeated = data.nameIndex[channel.name.toLowerCase()].length > 1
channel.displayName = isChannelNameRepeated
? `${channel.name} (${channel._country.name})`
: channel.name
return channel
}
function getStreams() {
let streams = []
get(selected).forEach(channel => {

View file

@ -0,0 +1,7 @@
[
{
"channel": "Bizarre.al",
"reason": "nsfw",
"ref": "https://github.com/iptv-org/iptv/issues/15723"
}
]

View file

@ -0,0 +1 @@
[{"id":"auto","name":"Auto"},{"id":"animation","name":"Animation"},{"id":"business","name":"Business"},{"id":"classic","name":"Classic"},{"id":"comedy","name":"Comedy"},{"id":"cooking","name":"Cooking"},{"id":"culture","name":"Culture"},{"id":"documentary","name":"Documentary"},{"id":"education","name":"Education"},{"id":"entertainment","name":"Entertainment"},{"id":"family","name":"Family"},{"id":"general","name":"General"},{"id":"kids","name":"Kids"},{"id":"legislative","name":"Legislative"},{"id":"lifestyle","name":"Lifestyle"},{"id":"movies","name":"Movies"},{"id":"music","name":"Music"},{"id":"news","name":"News"},{"id":"outdoor","name":"Outdoor"},{"id":"relax","name":"Relax"},{"id":"religious","name":"Religious"},{"id":"series","name":"Series"},{"id":"science","name":"Science"},{"id":"shop","name":"Shop"},{"id":"sports","name":"Sports"},{"id":"travel","name":"Travel"},{"id":"weather","name":"Weather"},{"id":"xxx","name":"XXX"}]

View file

@ -0,0 +1,406 @@
[
{
"id": "002RadioTV.do",
"name": "002 Radio TV",
"alt_names": [],
"network": null,
"owners": [],
"country": "DO",
"subdivision": null,
"city": "Santo Domingo",
"broadcast_area": [
"c/DO"
],
"languages": [
"spa"
],
"categories": [
"general"
],
"is_nsfw": false,
"launched": null,
"closed": null,
"replaced_by": null,
"website": "https://www.002radio.com/",
"logo": "https://i.imgur.com/7oNe8xj.png"
},
{
"id": "01TV.fr",
"name": "01 TV",
"alt_names": [],
"network": null,
"owners": [],
"country": "FR",
"subdivision": null,
"city": "Boulogne",
"broadcast_area": [
"c/FR"
],
"languages": [
"fra"
],
"categories": [
"education",
"news"
],
"is_nsfw": false,
"launched": null,
"closed": null,
"replaced_by": null,
"website": "https://www.01net.com/tag/01nettv/",
"logo": "https://i.imgur.com/RMucFq8.png"
},
{
"id": "FashionTVJohannesburg.fr",
"name": "FashionTV Johannesburg",
"alt_names": [],
"network": null,
"owners": [
"Michel Adam Lisowski"
],
"country": "FR",
"subdivision": null,
"city": "Paris",
"broadcast_area": [
"c/FR"
],
"languages": [
"eng"
],
"categories": [
"lifestyle"
],
"is_nsfw": false,
"launched": "2000-10-01",
"closed": null,
"replaced_by": null,
"website": "https://www.fashiontv.com/",
"logo": "https://i.imgur.com/6u4tZn6.png"
},
{
"id": "XtremaCartoons.ar",
"name": "Xtrema Cartoons",
"alt_names": [],
"network": null,
"owners": [],
"country": "AR",
"subdivision": null,
"city": null,
"broadcast_area": [
"c/AR"
],
"languages": [
"spa"
],
"categories": [
"animation",
"kids"
],
"is_nsfw": false,
"launched": null,
"closed": null,
"replaced_by": null,
"website": "https://xtrematv.com/",
"logo": "https://i.imgur.com/X2d8y4e.png"
},
{
"id": "XtremaRetroCartoons.ar",
"name": "Xtrema Retro Cartoons",
"alt_names": [],
"network": null,
"owners": [],
"country": "AR",
"subdivision": null,
"city": null,
"broadcast_area": [
"c/AR"
],
"languages": [
"spa"
],
"categories": [
"animation"
],
"is_nsfw": false,
"launched": null,
"closed": null,
"replaced_by": null,
"website": null,
"logo": "https://i.imgur.com/60ylWbQ.png"
},
{
"id": "TV1.dz",
"name": "TV1",
"alt_names": [
"الجزائرية الأولى",
"Algerian Television",
"التلفزيون الجزائري",
"The Terrestrial Channel",
"القناة الأرضية"
],
"network": null,
"owners": [
"EPTV"
],
"country": "DZ",
"subdivision": null,
"city": "Algiers",
"broadcast_area": [
"c/DZ"
],
"languages": [
"ara",
"fra"
],
"categories": [
"general"
],
"is_nsfw": false,
"launched": "1956-12-24",
"closed": null,
"replaced_by": null,
"website": "https://www.entv.dz/",
"logo": "https://i.imgur.com/F0DOrxX.png"
},
{
"id": "K11UUD1.as",
"name": "K11UU-D1",
"alt_names": [],
"network": "Hope Channel",
"owners": [
"American Samoa Adventist Media Ministry Inc"
],
"country": "AS",
"subdivision": null,
"city": "Pago Pago",
"broadcast_area": [
"c/AS"
],
"languages": [
"eng"
],
"categories": [
"religious"
],
"is_nsfw": false,
"launched": "2003-10-14",
"closed": null,
"replaced_by": null,
"website": "https://asamtv.org/",
"logo": "https://i.imgur.com/NQD8Zer.png"
},
{
"id": "13MaxTelevision.ar",
"name": "13Max Television",
"alt_names": [
"13Max Televisión"
],
"network": null,
"owners": [
"Río Paraná TV SRL"
],
"country": "AR",
"subdivision": "AR-W",
"city": "Corrientes",
"broadcast_area": [
"s/AR-W"
],
"languages": [
"spa"
],
"categories": [
"general"
],
"is_nsfw": false,
"launched": "1965-01-01",
"closed": null,
"replaced_by": null,
"website": "https://live-tv-channels.org/livetv/ar-13-max-tv.html",
"logo": "https://i.imgur.com/QvF4l2t.png"
},
{
"id": "Bizarre.al",
"name": "Bizarre",
"alt_names": [],
"network": null,
"owners": [],
"country": "AL",
"subdivision": null,
"city": null,
"broadcast_area": [
"c/AL"
],
"languages": [
"sqi"
],
"categories": [
"xxx"
],
"is_nsfw": true,
"launched": null,
"closed": null,
"replaced_by": null,
"website": "http://www.tring.al/",
"logo": "https://i.imgur.com/vpS477d.png"
},
{
"id": "AynaTV.af",
"name": "Ayna TV",
"alt_names": [],
"network": null,
"owners": [
"Abdul Rashid Dostum"
],
"country": "AF",
"subdivision": null,
"city": "Kabul",
"broadcast_area": [
"c/AF"
],
"languages": [
"pus"
],
"categories": [
"general"
],
"is_nsfw": false,
"launched": "2004-01-01",
"closed": "2022-02-16",
"replaced_by": null,
"website": "http://www.ayna.af/",
"logo": "https://i.imgur.com/2tHlT5Q.png"
},
{
"id": "LaLiganaZap.ao",
"name": "LaLiga na Zap",
"alt_names": [],
"network": null,
"owners": [
"ZAP Angola"
],
"country": "AO",
"subdivision": null,
"city": null,
"broadcast_area": [
"c/AO"
],
"languages": [
"por"
],
"categories": [
"sports"
],
"is_nsfw": false,
"launched": null,
"closed": null,
"replaced_by": null,
"website": "https://www.zap.co.ao/",
"logo": "https://i.imgur.com/NWFShcJ.png"
},
{
"id": "SEN502.us",
"name": "SEN 502",
"alt_names": [
"TV2 Sports"
],
"network": null,
"owners": [],
"country": "US",
"subdivision": null,
"city": null,
"broadcast_area": [
"c/US"
],
"languages": [
"eng"
],
"categories": [
"sports"
],
"is_nsfw": false,
"launched": null,
"closed": null,
"replaced_by": null,
"website": "https://www.senetwork.tv/",
"logo": "https://i.imgur.com/cEGW3pw.png"
},
{
"id": "SEN550.us",
"name": "SEN 550",
"alt_names": [],
"network": null,
"owners": [],
"country": "US",
"subdivision": null,
"city": null,
"broadcast_area": [
"c/US"
],
"languages": [
"eng"
],
"categories": [
"sports"
],
"is_nsfw": false,
"launched": null,
"closed": null,
"replaced_by": null,
"website": "https://www.senetwork.tv/",
"logo": "https://i.imgur.com/T4vLkeH.png"
},
{
"id": "CFCNTV2.ca",
"name": "CFCN-TV-2",
"alt_names": [],
"network": "CTV",
"owners": [
"Bell Media"
],
"country": "CA",
"subdivision": "CA-AB",
"city": "Banff",
"broadcast_area": [
"s/CA-AB"
],
"languages": [
"eng"
],
"categories": [
"general"
],
"is_nsfw": false,
"launched": null,
"closed": null,
"replaced_by": null,
"website": "https://calgary.ctvnews.ca/",
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/17/CTV_logo_2018.svg/512px-CTV_logo_2018.svg.png"
},
{
"id": "ORF2Europe.at",
"name": "ORF 2 Europe",
"alt_names": [],
"network": "ORF",
"owners": [
"ORF"
],
"country": "AT",
"subdivision": "AT-8",
"city": "Vienna",
"broadcast_area": [
"r/EUR"
],
"languages": [
"deu"
],
"categories": [
"culture"
],
"is_nsfw": false,
"launched": "2004-07-05",
"closed": null,
"replaced_by": null,
"website": "https://tv.orf.at/",
"logo": "https://i.imgur.com/Hmcl4qR.png"
}
]

View file

@ -0,0 +1,18 @@
[
{
"name": "Dominican Republic",
"code": "DO",
"languages": [
"spa"
],
"flag": "🇩🇴"
},
{
"name": "France",
"code": "FR",
"languages": [
"fra"
],
"flag": "🇫🇷"
}
]

View file

@ -0,0 +1,9 @@
[
{
"channel": "LaLiganaZap.ao",
"site": "zap.co.ao",
"site_id": "2386",
"site_name": "La Liga HD",
"lang": "pt"
}
]

View file

@ -0,0 +1,14 @@
[
{
"code": "eng",
"name": "English"
},
{
"code": "fra",
"name": "French"
},
{
"code": "spa",
"name": "Spanish"
}
]

View file

@ -0,0 +1,58 @@
[
{
"code": "EUR",
"name": "Europe",
"countries": [
"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"
]
}
]

View file

@ -0,0 +1,9 @@
[
{
"channel": "XtremaCartoons.ar",
"url": "https://stmv6.voxtvhd.com.br/xtremacartoons/xtremacartoons/playlist.m3u8",
"timeshift": null,
"http_referrer": "https://xtrematv.com/?p=1390",
"user_agent": null
}
]

View file

@ -0,0 +1,7 @@
[
{
"country": "AR",
"name": "Corrientes",
"code": "AR-W"
}
]

View file

@ -0,0 +1 @@
export const browser = true

View file

@ -0,0 +1 @@
export function pushState() {}

359
tests/store.test.js Normal file
View file

@ -0,0 +1,359 @@
import { search, fetchChannels, filteredChannels } from '../src/store'
import { get } from 'svelte/store'
import fs from 'fs'
import path from 'path'
import { jest } from '@jest/globals'
const API_ENDPOINT = 'https://iptv-org.github.io/api'
beforeEach(async () => {
global.fetch = mockFetch()
await fetchChannels()
})
describe('search', () => {
it('return all channels by default', () => {
const results = get(filteredChannels)
expect(results.length).toBe(15)
})
it('returns empty list if there is no such channel', () => {
search('lorem')
const results = get(filteredChannels)
expect(results.length).toBe(0)
})
it('can find channel by name', () => {
search('name:002')
const results = get(filteredChannels)
expect(results.length).toBe(1)
expect(results[0]).toMatchObject({
id: '002RadioTV.do'
})
})
it('can find channels by multiple words', () => {
search('Xtrema Cartoons')
const results = get(filteredChannels)
expect(results.length).toBe(2)
expect(results[0]).toMatchObject({
id: 'XtremaCartoons.ar'
})
expect(results[1]).toMatchObject({
id: 'XtremaRetroCartoons.ar'
})
})
it('can search for one of two words', () => {
search('Johannesburg,002')
const results = get(filteredChannels)
expect(results.length).toBe(2)
expect(results[0]).toMatchObject({
id: '002RadioTV.do'
})
expect(results[1]).toMatchObject({
id: 'FashionTVJohannesburg.fr'
})
})
it('can search for exact word matches', () => {
search('"Xtrema Cartoons"')
const results = get(filteredChannels)
expect(results.length).toBe(1)
expect(results[0]).toMatchObject({
id: 'XtremaCartoons.ar'
})
})
it('can find channels by id', () => {
search('id:002RadioTV.do')
const results = get(filteredChannels)
expect(results.length).toBe(1)
expect(results[0]).toMatchObject({
id: '002RadioTV.do'
})
})
it('can find channels by alternative names', () => {
search('alt_names:التلفزيون')
const results = get(filteredChannels)
expect(results.length).toBe(1)
expect(results[0]).toMatchObject({
id: 'TV1.dz'
})
})
it('can find channels by network', () => {
search('network:Hope')
const results = get(filteredChannels)
expect(results.length).toBe(1)
expect(results[0]).toMatchObject({
id: 'K11UUD1.as'
})
})
it('can find channels without the owner', () => {
search('owners:^$')
const results = get(filteredChannels)
expect(results.length).toBe(7)
expect(results[0]).toMatchObject({
id: '002RadioTV.do'
})
})
it('can find channels by country code', () => {
search('country:DO')
const results = get(filteredChannels)
expect(results.length).toBe(1)
expect(results[0]).toMatchObject({
id: '002RadioTV.do'
})
})
it('can find channels that are broadcast from the same region', () => {
search('subdivision:AR-W')
const results = get(filteredChannels)
expect(results.length).toBe(1)
expect(results[0]).toMatchObject({
id: '13MaxTelevision.ar'
})
})
it('can find channels that are broadcast from the same city', () => {
search('city:Corrientes')
const results = get(filteredChannels)
expect(results.length).toBe(1)
expect(results[0]).toMatchObject({
id: '13MaxTelevision.ar'
})
})
it('can find channels that are broadcast in the same region', () => {
search('broadcast_area:s/AR-W')
const results = get(filteredChannels)
expect(results.length).toBe(1)
expect(results[0]).toMatchObject({
id: '13MaxTelevision.ar'
})
})
it('can find channels that are broadcast in the same language', () => {
search('languages:spa')
const results = get(filteredChannels)
expect(results.length).toBe(4)
expect(results[0]).toMatchObject({
id: '002RadioTV.do'
})
})
it('can find channels that have the same category', () => {
search('categories:lifestyle')
const results = get(filteredChannels)
expect(results.length).toBe(1)
expect(results[0]).toMatchObject({
id: 'FashionTVJohannesburg.fr'
})
})
it('can find channels with website', () => {
search('website:.')
const results = get(filteredChannels)
expect(results.length).toBe(14)
expect(results[0]).toMatchObject({
id: '002RadioTV.do'
})
})
it('can find channels marked as NSFW', () => {
search('is_nsfw:true')
const results = get(filteredChannels)
expect(results.length).toBe(1)
expect(results[0]).toMatchObject({
id: 'Bizarre.al'
})
})
it('can find closed channels', () => {
search('is_closed:true')
const results = get(filteredChannels)
expect(results.length).toBe(1)
expect(results[0]).toMatchObject({
id: 'AynaTV.af'
})
})
it('can find blocked channels', () => {
search('is_blocked:true')
const results = get(filteredChannels)
expect(results.length).toBe(1)
expect(results[0]).toMatchObject({
id: 'Bizarre.al'
})
})
it('can find channels that have streams', () => {
search('streams:>0')
const results = get(filteredChannels)
expect(results.length).toBe(1)
expect(results[0]).toMatchObject({
id: 'XtremaCartoons.ar'
})
})
it('can find channels that have guides', () => {
search('guides:>0')
const results = get(filteredChannels)
expect(results.length).toBe(1)
expect(results[0]).toMatchObject({
id: 'LaLiganaZap.ao'
})
})
it('can find channels by query in lower case', () => {
search('tv2')
const results = get(filteredChannels)
expect(results.length).toBe(2)
expect(results[0]).toMatchObject({
id: 'SEN502.us'
})
expect(results[1]).toMatchObject({
id: 'CFCNTV2.ca'
})
})
it('can find channel by alternative name after another query', () => {
search('tv2')
search('alt_names:tv2')
const results = get(filteredChannels)
expect(results.length).toBe(1)
expect(results[0]).toMatchObject({
id: 'SEN502.us'
})
})
it('can find channel by broadcast area name', () => {
search('broadcast_area:"dominican republic"')
const results = get(filteredChannels)
expect(results.length).toBe(1)
expect(results[0]).toMatchObject({
id: '002RadioTV.do'
})
})
it('can find channel by country name', () => {
search('country:"dominican republic"')
const results = get(filteredChannels)
expect(results.length).toBe(1)
expect(results[0]).toMatchObject({
id: '002RadioTV.do'
})
})
it('can find channel by region code', () => {
search('broadcast_area:r/EUR')
const results = get(filteredChannels)
expect(results.length).toBe(1)
expect(results[0]).toMatchObject({
id: 'ORF2Europe.at'
})
})
it('can find channel by region name', () => {
search('broadcast_area:europe')
const results = get(filteredChannels)
expect(results.length).toBe(1)
expect(results[0]).toMatchObject({
id: 'ORF2Europe.at'
})
})
it('can find channel by country name from broadcast region', () => {
search('broadcast_area:france')
const results = get(filteredChannels)
expect(results.length).toBe(3)
expect(results[2]).toMatchObject({
id: 'ORF2Europe.at'
})
})
it('can find channel by display name from the guides', () => {
search('La Liga HD')
const results = get(filteredChannels)
expect(results.length).toBe(1)
expect(results[0]).toMatchObject({
id: 'LaLiganaZap.ao'
})
})
it('can find channel by stream url', () => {
search('https://stmv6.voxtvhd.com.br/xtremacartoons/xtremacartoons/playlist.m3u8')
const results = get(filteredChannels)
expect(results.length).toBe(1)
expect(results[0]).toMatchObject({
id: 'XtremaCartoons.ar'
})
})
})
function mockFetch() {
return jest.fn().mockImplementation(url =>
Promise.resolve({
ok: true,
json: () => {
if (url === `${API_ENDPOINT}/channels.json`)
return loadJson('tests/__data__/input/channels.json')
if (url === `${API_ENDPOINT}/countries.json`)
return loadJson('tests/__data__/input/countries.json')
if (url === `${API_ENDPOINT}/languages.json`)
return loadJson('tests/__data__/input/languages.json')
if (url === `${API_ENDPOINT}/guides.json`)
return loadJson('tests/__data__/input/guides.json')
if (url === `${API_ENDPOINT}/regions.json`)
return loadJson('tests/__data__/input/regions.json')
if (url === `${API_ENDPOINT}/blocklist.json`)
return loadJson('tests/__data__/input/blocklist.json')
if (url === `${API_ENDPOINT}/subdivisions.json`)
return loadJson('tests/__data__/input/subdivisions.json')
if (url === `${API_ENDPOINT}/categories.json`)
return loadJson('tests/__data__/input/categories.json')
if (url === `${API_ENDPOINT}/streams.json`)
return loadJson('tests/__data__/input/streams.json')
return []
}
})
)
}
function loadJson(filepath) {
return JSON.parse(fs.readFileSync(path.resolve(filepath), 'utf8'))
}

1929
yarn.lock

File diff suppressed because it is too large Load diff