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", "build": "NODE_OPTIONS=--max_old_space_size=4096 vite build",
"preview": "vite preview", "preview": "vite preview",
"postbuild": "npx svelte-sitemap -d https://iptv-org.github.io -o docs", "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": { "devDependencies": {
"@freearhey/core": "^0.5.1", "@freearhey/core": "^0.5.1",
"@freearhey/search-js": "^0.1.1", "@freearhey/search-js": "^0.1.2",
"@sveltejs/adapter-static": "^3.0.8", "@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.17.1", "@sveltejs/kit": "^2.17.1",
"@tailwindcss/line-clamp": "^0.4.2", "@tailwindcss/line-clamp": "^0.4.2",
@ -22,11 +30,13 @@
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",
"dayjs": "^1.11.1", "dayjs": "^1.11.1",
"iptv-playlist-generator": "^0.1.5", "iptv-playlist-generator": "^0.1.5",
"jest": "^29.7.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"numeral": "^2.0.6", "numeral": "^2.0.6",
"postcss": "^8.5.1", "postcss": "^8.5.1",
"prettier-plugin-svelte": "^3.3.3", "prettier-plugin-svelte": "^3.3.3",
"qs": "^6.11.2", "qs": "^6.11.2",
"svelte": "^5.22.6",
"svelte-simple-modal": "^2.0.0", "svelte-simple-modal": "^2.0.0",
"svelte-sitemap": "^2.6.0", "svelte-sitemap": "^2.6.0",
"sveltejs-tippy": "^3.0.0", "sveltejs-tippy": "^3.0.0",

View file

@ -3,13 +3,32 @@
export let channel export let channel
const blocklistRefs = channel.blocklist_records let reason
.map(record => { const messages = {
const parts = record.ref.split('/') dmca: 'The channel has been added to our blocklist due to the claims of the copyright holder',
const issueId = parts.pop() nsfw: 'The channel has been added to our blocklist due to NSFW content'
const prefix = record.ref.includes('/issues/') ? '#' : '' }
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(', ') .join(', ')
</script> </script>
@ -17,7 +36,7 @@
<div <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" 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={{ 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, allowHTML: true,
placement: 'right', placement: 'right',
interactive: true interactive: true

View file

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

View file

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

View file

@ -30,7 +30,7 @@
> >
<div class="w-2/3 overflow-hidden"> <div class="w-2/3 overflow-hidden">
<div class="flex items-center space-x-3"> <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"> <div class="flex space-x-2">
{#if channel.is_closed} {#if channel.is_closed}
<ClosedBadge {channel} /> <ClosedBadge {channel} />

View file

@ -6,7 +6,7 @@
export let channel export let channel
const endpoint = 'https://github.com/iptv-org/database/issues/new' 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 labels = 'channels:edit'
const template = '__channels_edit.yml' const template = '__channels_edit.yml'

View file

@ -1,7 +1,5 @@
<script> <script>
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { goto } from '$app/navigation'
import { query, hasQuery, setSearchParam } from '~/store'
export let data export let data
export let close = () => {} export let close = () => {}
@ -19,7 +17,7 @@
{ {
name: 'owners', name: 'owners',
type: 'link[]', 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', name: 'country',
@ -41,7 +39,7 @@
{ {
name: 'broadcast_area', name: 'broadcast_area',
type: 'link[]', type: 'link[]',
value: data._broadcast_area.map(v => ({ value: data._broadcastArea.map(v => ({
label: v.name, label: v.name,
query: `broadcast_area:${v.type}/${v.code}` query: `broadcast_area:${v.type}/${v.code}`
})) }))
@ -49,12 +47,12 @@
{ {
name: 'languages', name: 'languages',
type: 'link[]', 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', name: 'categories',
type: 'link[]', 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', name: 'is_nsfw',
@ -96,7 +94,7 @@
</div> </div>
</td> </td>
<td class="align-top w-full overflow-hidden"> <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'} {#if field.type === 'image'}
<img <img
src={field.value} src={field.value}

View file

@ -15,12 +15,12 @@
result: 'Find channels that have "Nat Geo" in the name.' result: 'Find channels that have "Nat Geo" in the name.'
}, },
{ {
query: 'alt_names:חינוכית', query: 'alt_name:חינוכית',
result: 'Finds channels whose alternative name contains "חינוכית".' result: 'Finds channels whose alternative name contains "חינוכית".'
}, },
{ query: 'network:ABC', result: 'Finds all channels operated by the ABC Network.' }, { query: 'network:ABC', result: 'Finds all channels operated by the ABC Network.' },
{ {
query: 'owners:^$', query: 'owner:^$',
result: 'Finds channels that have no owner listed.' result: 'Finds channels that have no owner listed.'
}, },
{ query: 'country:GY', result: 'Finds all channels that are broadcast from Guyana.' }, { 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: '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: '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: 'language:fra', result: 'Find channels that are broadcast in French.' },
{ query: 'categories:news', result: 'Finds all the news channels.' }, { query: 'category:news', result: 'Finds all the news channels.' },
{ query: 'website:.', result: 'Finds channels that have a link to the official website.' }, { query: 'website:.', result: 'Finds channels that have a link to the official website.' },
{ query: 'is_nsfw:true', result: 'Finds channels marked as NSFW.' }, { query: 'is_nsfw:true', result: 'Finds channels marked as NSFW.' },
{ {
@ -43,7 +43,8 @@
result: result:
'Finds channels that have been added to our blocklist due to the claim of the copyright holder.' '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> </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, countries,
filteredChannels, filteredChannels,
query, query,
setSearchParam,
setPageTitle, setPageTitle,
downloadMode, downloadMode,
search search
} from '~/store' } from '~/store'
import { onMount, onDestroy } from 'svelte' import { onMount } from 'svelte'
import CountryItem from '~/components/CountryItem.svelte' import CountryItem from '~/components/CountryItem.svelte'
import SearchField from '~/components/SearchField.svelte' import SearchField from '~/components/SearchField.svelte'
import _ from 'lodash' import _ from 'lodash'
@ -24,17 +23,7 @@
let _countries = [] let _countries = []
let isLoading = true let isLoading = true
$: groupedByCountry = _.groupBy($filteredChannels, 'country') $: groupedByCountry = _.groupBy($filteredChannels, channel => channel._country.code)
function loadMore({ detail }) {
let { loaded, complete } = detail
if (limit < _countries.length) {
limit++
loaded()
} else {
complete()
}
}
onMount(async () => { onMount(async () => {
if (!$channels.length) { if (!$channels.length) {
@ -92,7 +81,7 @@
loading... loading...
</div> </div>
{/if} {/if}
{#each _countries as country (country.code)} {#each _countries as country, idx (country)}
{#if groupedByCountry[country.code] && groupedByCountry[country.code].length > 0} {#if groupedByCountry[country.code] && groupedByCountry[country.code].length > 0}
<CountryItem <CountryItem
bind:country bind:country

View file

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

View file

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

View file

@ -3,6 +3,8 @@ import { Playlist, Link } from 'iptv-playlist-generator'
import sj from '@freearhey/search-js' import sj from '@freearhey/search-js'
import _ from 'lodash' import _ from 'lodash'
import { browser } from '$app/environment' import { browser } from '$app/environment'
import { Channel } from './models'
import { pushState } from '$app/navigation'
export const query = writable('') export const query = writable('')
export const hasQuery = writable(false) export const hasQuery = writable(false)
@ -14,7 +16,6 @@ export const downloadMode = writable(false)
let searchIndex = {} let searchIndex = {}
export function search(q) { export function search(q) {
console.log('.')
if (!q) { if (!q) {
filteredChannels.set(get(channels)) filteredChannels.set(get(channels))
hasQuery.set(false) hasQuery.set(false)
@ -33,7 +34,7 @@ export async function fetchChannels() {
countries.set(api.countries) 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) channels.set(_channels)
filteredChannels.set(_channels) filteredChannels.set(_channels)
@ -42,36 +43,41 @@ export async function fetchChannels() {
'id', 'id',
'name', 'name',
'alt_names', 'alt_names',
'alt_name',
'network', 'network',
'owner',
'owners', 'owners',
'country', 'country',
'subdivision', 'subdivision',
'city', 'city',
'broadcast_area', 'broadcast_area',
'language',
'languages', 'languages',
'category',
'categories', 'categories',
'launched', 'launched',
'closed', 'closed',
'replaced_by', 'replaced_by',
'website',
'streams', 'streams',
'guides', 'guides',
'is_nsfw', 'is_nsfw',
'is_closed', 'is_closed',
'is_blocked' 'is_blocked',
'_guideNames',
'_streamUrls'
] ]
}) })
} }
export function setSearchParam(key, value) { export function setSearchParam(key, value) {
if (window.history.pushState) { let query = key && value ? `?${key}=${value}` : ''
let query = key && value ? `?${key}=${value}` : '' query = query.replace(/\+/g, '%2B')
query = query.replace(/\+/g, '%2B') const url = `${window.location.protocol}//${window.location.host}${window.location.pathname}${query}`
const url = `${window.location.protocol}//${window.location.host}${window.location.pathname}${query}` const state = {}
const state = {} state[key] = value
state[key] = value pushState(url, state)
window.history.pushState(state, '', url) setPageTitle(value)
setPageTitle(value)
}
} }
export function 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() { async function loadAPI() {
const api = {} const api = {}
@ -155,40 +216,6 @@ async function loadAPI() {
return api 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() { function getStreams() {
let streams = [] let streams = []
get(selected).forEach(channel => { 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