Skip to content

Commit d8a240c

Browse files
authored
Merge pull request #2138 from iptv-org/patch-2026.04.4
Patch 2026.04.4
2 parents f9d2ad7 + 23d1699 commit d8a240c

86 files changed

Lines changed: 3379 additions & 3009 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

package-lock.json

Lines changed: 2343 additions & 2689 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@
1717
"devDependencies": {
1818
"@eslint/js": "^9.39.1",
1919
"@freearhey/core": "^0.15.0",
20-
"@iptv-org/sdk": "^1.3.0",
20+
"@iptv-org/sdk": "^1.4.0",
2121
"@sveltejs/adapter-static": "^3.0.10",
22-
"@sveltejs/kit": "^2.49.1",
22+
"@sveltejs/kit": "^2.58.0",
23+
"@sveltejs/vite-plugin-svelte": "^7.0.0",
2324
"@tailwindcss/line-clamp": "^0.4.4",
24-
"@tailwindcss/vite": "^4.1.17",
25+
"@tailwindcss/vite": "^4.2.4",
2526
"@types/cli-progress": "^3.11.6",
2627
"@types/qs": "^6.14.0",
2728
"@zerodevx/svelte-toast": "^0.9.6",
@@ -43,9 +44,9 @@
4344
"tsx": "^4.20.3",
4445
"typescript": "^5.9.3",
4546
"typescript-eslint": "^8.48.0",
46-
"vite": "^7.2.6",
47+
"vite": "^8.0.10",
4748
"vite-plugin-mkcert": "^1.17.12",
48-
"vitest": "^4.0.14"
49+
"vitest": "^4.1.5"
4950
},
5051
"overrides": {
5152
"esbuild": "0.23.1"

src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export const SITE_ORIGIN = 'https://iptv-org.github.io'
22
export const DATA_DIR = './temp/data'
3+
export const DEFAULT_QUERY = 'is_closed:false'

src/lib/actions/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './clickOutside'
2+
export * from './scrollToSelected'
23
export * from './tippy'
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { Action } from 'svelte/action'
2+
3+
export const scrollToSelected: Action<HTMLElement, boolean> = (node, isSelected) => {
4+
const scroll = (active: boolean) => {
5+
const container = node.parentElement
6+
if (active && container) {
7+
const containerRect = container.getBoundingClientRect()
8+
const nodeRect = node.getBoundingClientRect()
9+
10+
const distanceFromContainerLeft = nodeRect.left - containerRect.left
11+
const halfContainerWidth = containerRect.width / 2
12+
const halfNodeWidth = nodeRect.width / 2
13+
const targetScrollLeft =
14+
container.scrollLeft + distanceFromContainerLeft - halfContainerWidth + halfNodeWidth
15+
16+
container.scrollTo({
17+
left: targetScrollLeft,
18+
behavior: 'auto'
19+
})
20+
}
21+
}
22+
23+
scroll(isSelected)
24+
25+
return {
26+
update: scroll
27+
}
28+
}

src/lib/api.ts

Lines changed: 194 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -16,63 +16,108 @@ interface Config {
1616
dataDir?: string
1717
}
1818

19-
export async function loadData(config: Config = {}) {
19+
type ProcessedData = {
20+
countries: Country[]
21+
channels: Channel[]
22+
feeds: Feed[]
23+
streams: Stream[]
24+
channelsKeyById: Map<string, Channel>
25+
}
26+
27+
export let processedData: ProcessedData | undefined
28+
29+
export async function loadData(config: Config = {}): Promise<ProcessedData> {
2030
const dataDir = config.dataDir || DATA_DIR
2131

2232
const dataManager = new sdk.DataManager({ dataDir })
2333
await dataManager.downloadToMemory()
2434
dataManager.loadFromMemory()
2535
const rawData = dataManager.getRawData()
36+
processedData = processData(rawData)
2637

27-
return processData(rawData)
38+
return processedData
2839
}
2940

30-
export async function loadDataFromDisk(config: Config = {}) {
41+
export async function loadDataFromDisk(config: Config = {}): Promise<ProcessedData> {
3142
const dataDir = config.dataDir || DATA_DIR
3243

3344
const dataManager = new sdk.DataManager({ dataDir })
3445
await dataManager.loadFromDisk()
3546
const rawData = dataManager.getRawData()
47+
processedData = processData(rawData)
3648

37-
return processData(rawData)
49+
return processedData
3850
}
3951

40-
function processData(rawData: sdk.Types.RawData) {
41-
const feedsKeyByStreamId = rawData.feeds.reduce((acc, data: sdk.Types.FeedData) => {
42-
const feed = new Feed(data)
43-
acc.set(feed.getStreamId(), feed)
44-
return acc
45-
}, new Map())
46-
const logosGroupedByChannel2 = Map.groupBy(
47-
rawData.logos.map((data: sdk.Types.LogoData) => new Logo(data)),
52+
function processData(rawData: sdk.Types.RawData): ProcessedData {
53+
const channelsGroupedByName = Map.groupBy(
54+
rawData.channels,
55+
(channel: sdk.Types.ChannelData) => channel.name
56+
)
57+
const feedsKeyByStreamId: Map<string, Feed> = rawData.feeds.reduce(
58+
(acc, data: sdk.Types.FeedData) => {
59+
const feed = new Feed(data)
60+
acc.set(feed.getStreamId(), feed)
61+
return acc
62+
},
63+
new Map()
64+
)
65+
const channelsKeyById2: Map<string, Channel> = rawData.channels.reduce(
66+
(acc, data: sdk.Types.ChannelData) => {
67+
const channel = new Channel(data)
68+
channel.hasUniqueName = channelsGroupedByName.get(channel.name).length === 1
69+
acc.set(channel.id, channel)
70+
return acc
71+
},
72+
new Map()
73+
)
74+
const logosGroupedByChannel2: Map<string, Logo[]> = Map.groupBy(
75+
rawData.logos.map((data: sdk.Types.LogoData) =>
76+
new Logo(data).withChannel(channelsKeyById2.get(data.channel))
77+
),
4878
(logo: Logo) => logo.channel
4979
)
50-
const categoriesKeyById = rawData.categories.reduce((acc, data: sdk.Types.CategoryData) => {
51-
const category = new sdk.Models.Category(data)
52-
acc.set(category.id, category)
53-
return acc
54-
}, new Map())
55-
const countriesKeyByCode = rawData.countries.reduce((acc, data: sdk.Types.CountryData) => {
56-
const country = new Country(data)
57-
acc.set(country.code, country)
58-
return acc
59-
}, new Map())
60-
const channelsKeyById = rawData.channels.reduce((acc, data: sdk.Types.ChannelData) => {
61-
const channel = new Channel(data)
62-
const logos = logosGroupedByChannel2.get(channel.id)
63-
const categories = channel.categories.map((id: string) => categoriesKeyById.get(id))
64-
const country = countriesKeyByCode.get(channel.country)
65-
channel.withLogos(logos).withCategories(categories).withCountry(country)
66-
acc.set(channel.id, channel)
67-
return acc
68-
}, new Map())
80+
const categoriesKeyById: Map<string, sdk.Models.Category> = rawData.categories.reduce(
81+
(acc, data: sdk.Types.CategoryData) => {
82+
const category = new sdk.Models.Category(data)
83+
acc.set(category.id, category)
84+
return acc
85+
},
86+
new Map()
87+
)
88+
const countriesKeyByCode: Map<string, Country> = rawData.countries.reduce(
89+
(acc, data: sdk.Types.CountryData) => {
90+
const country = new Country(data)
91+
acc.set(country.code, country)
92+
return acc
93+
},
94+
new Map()
95+
)
96+
const channelsKeyById: Map<string, Channel> = rawData.channels.reduce(
97+
(acc, data: sdk.Types.ChannelData) => {
98+
const channel = new Channel(data)
99+
const logos = logosGroupedByChannel2.get(channel.id)
100+
const categories = channel.categories.map((id: string) => categoriesKeyById.get(id))
101+
const country = countriesKeyByCode.get(channel.country)
102+
channel.withLogos(logos).withCategories(categories).withCountry(country)
103+
channel.hasUniqueName = channelsGroupedByName.get(channel.name).length === 1
104+
acc.set(channel.id, channel)
105+
return acc
106+
},
107+
new Map()
108+
)
69109

70110
const logos = rawData.logos.map((data: sdk.Types.LogoData) => {
71111
const logo = new Logo(data)
112+
const feed = feedsKeyByStreamId.get(logo.getStreamId())
113+
const channel = channelsKeyById.get(logo.channel)
114+
115+
if (feed && channel) feed.withChannel(channel)
116+
117+
if (feed) logo.withFeed(feed)
118+
if (channel) logo.withChannel(channel)
72119

73120
return logo
74-
.withFeed(feedsKeyByStreamId.get(logo.getStreamId()))
75-
.withChannel(channelsKeyById.get(logo.channel))
76121
})
77122

78123
const logosGroupedByStreamId = Map.groupBy(logos, (logo: Logo) => logo.getStreamId())
@@ -100,7 +145,7 @@ function processData(rawData: sdk.Types.RawData) {
100145
acc.set(city.code, city)
101146
return acc
102147
}, new Map())
103-
const subdivisionsKeyByCode = rawData.subdivisions.reduce(
148+
const subdivisionsKeyByCode: Map<string, sdk.Models.Subdivision> = rawData.subdivisions.reduce(
104149
(acc, data: sdk.Types.SubdivisionData) => {
105150
const subdivision = new sdk.Models.Subdivision(data)
106151
acc.set(subdivision.code, subdivision)
@@ -109,16 +154,22 @@ function processData(rawData: sdk.Types.RawData) {
109154
new Map()
110155
)
111156

112-
const regionsKeyByCode = rawData.regions.reduce((acc, data: sdk.Types.RegionData) => {
113-
const region = new sdk.Models.Region(data)
114-
acc.set(region.code, region)
115-
return acc
116-
}, new Map())
117-
const timezonesKeyById = rawData.timezones.reduce((acc, data: sdk.Types.TimezoneData) => {
118-
const timezone = new sdk.Models.Timezone(data)
119-
acc.set(timezone.id, timezone)
120-
return acc
121-
}, new Map())
157+
const regionsKeyByCode: Map<string, sdk.Models.Region> = rawData.regions.reduce(
158+
(acc, data: sdk.Types.RegionData) => {
159+
const region = new sdk.Models.Region(data)
160+
acc.set(region.code, region)
161+
return acc
162+
},
163+
new Map()
164+
)
165+
const timezonesKeyById: Map<string, sdk.Models.Timezone> = rawData.timezones.reduce(
166+
(acc, data: sdk.Types.TimezoneData) => {
167+
const timezone = new sdk.Models.Timezone(data)
168+
acc.set(timezone.id, timezone)
169+
return acc
170+
},
171+
new Map()
172+
)
122173
const feeds = rawData.feeds.map((data: sdk.Types.FeedData) => {
123174
const feed = new Feed(data)
124175

@@ -130,7 +181,7 @@ function processData(rawData: sdk.Types.RawData) {
130181
const streams = streamsGroupedById.get(feed.getStreamId())
131182
const guides = guidesGroupedByStreamId.get(feed.getStreamId())
132183
const locations = feed.broadcast_area.map((rawCode: string) => {
133-
let name
184+
let name: string | undefined
134185
const [type, code] = rawCode.split('/')
135186
switch (type) {
136187
case 'ct': {
@@ -170,6 +221,16 @@ function processData(rawData: sdk.Types.RawData) {
170221
.withChannel(channel)
171222
})
172223

224+
const graph = buildGraph(rawData.channels)
225+
226+
const edgesTo = new Map<string, string[]>()
227+
const edgesFrom = new Map<string, string>()
228+
for (const edge of graph.edges) {
229+
if (!edgesTo.has(edge.to)) edgesTo.set(edge.to, [])
230+
edgesTo.get(edge.to)!.push(edge.from)
231+
edgesFrom.set(edge.from, edge.to)
232+
}
233+
173234
const logosGroupedByChannel = Map.groupBy(logos, (logo: Logo) => logo.channel)
174235
const feedsGroupedByChannel = Map.groupBy(feeds, (feed: Feed) => feed.channel)
175236
const blocklistRecords = rawData.blocklist.map(
@@ -179,15 +240,28 @@ function processData(rawData: sdk.Types.RawData) {
179240
blocklistRecords,
180241
(record: BlocklistRecord) => record.channel
181242
)
182-
const channels = rawData.channels.map((data: sdk.Types.ChannelData) => {
183-
const channel = new Channel(data)
243+
244+
const finalChannelsKeyById = new Map<string, Channel>()
245+
const channels = rawData.channels.map((channelData: sdk.Types.ChannelData) => {
246+
const channel = new Channel(channelData)
184247
const categories = channel.categories.map((id: string) => categoriesKeyById.get(id))
185-
return channel
248+
const history = getChannelHistory(channel.id, edgesTo, edgesFrom, channelsKeyById)
249+
250+
channel
186251
.withLogos(logosGroupedByChannel.get(channel.id))
187252
.withFeeds(feedsGroupedByChannel.get(channel.id))
188253
.withCategories(categories)
189254
.withCountry(countriesKeyByCode.get(channel.country))
190255
.withBlocklistRecords(blocklistRecordsGroupedByChannel.get(channel.id))
256+
257+
if (history.length > 1) {
258+
channel.withHistory(history)
259+
}
260+
261+
channel.hasUniqueName = channelsGroupedByName.get(channel.name).length === 1
262+
263+
finalChannelsKeyById.set(channel.id, channel)
264+
return channel
191265
})
192266

193267
const channelsGroupedByCountry = Map.groupBy(channels, (channel: Channel) => channel.country)
@@ -200,6 +274,77 @@ function processData(rawData: sdk.Types.RawData) {
200274
countries,
201275
channels,
202276
feeds,
203-
streams
277+
streams,
278+
channelsKeyById: finalChannelsKeyById
279+
}
280+
}
281+
282+
function buildGraph(channels: sdk.Types.ChannelData[]) {
283+
const graph = {
284+
edges: [],
285+
nodes: []
204286
}
287+
288+
channels.forEach((channel: sdk.Types.ChannelData) => {
289+
graph.nodes.push({ id: channel.id, label: channel.name })
290+
291+
if (channel.replaced_by) {
292+
const target = channel.replaced_by.split('@')[0]
293+
294+
if (target !== channel.id) {
295+
graph.edges.push({ from: channel.id, to: target })
296+
}
297+
}
298+
})
299+
300+
return graph
301+
}
302+
303+
function getChannelHistory(
304+
targetId: string,
305+
edgesTo: Map<string, string[]>,
306+
edgesFrom: Map<string, string>,
307+
channelsKeyById: Map<string, Channel>
308+
): (Channel | Channel[])[] {
309+
const visited = new Set<string>()
310+
311+
const getAncestors = (id: string): (Channel | Channel[])[] => {
312+
if (visited.has(id)) return []
313+
visited.add(id)
314+
315+
const parentIds = edgesTo.get(id) || []
316+
if (parentIds.length === 0) return []
317+
318+
if (parentIds.length > 1) {
319+
const multiParents = parentIds
320+
.map(pId => channelsKeyById.get(pId))
321+
.filter((p): p is Channel => !!p)
322+
return multiParents.length > 0 ? [multiParents] : []
323+
}
324+
325+
const parentId = parentIds[0]
326+
const parentNode = channelsKeyById.get(parentId)
327+
const ancestors = getAncestors(parentId)
328+
329+
return parentNode ? [...ancestors, parentNode] : ancestors
330+
}
331+
332+
const successors: Channel[] = []
333+
const visitedSuccessors = new Set<string>([targetId])
334+
let current = targetId
335+
336+
while (true) {
337+
const nextId = edgesFrom.get(current)
338+
const nextNode = nextId ? channelsKeyById.get(nextId) : null
339+
if (!nextId || !nextNode || visitedSuccessors.has(nextId)) break
340+
341+
successors.push(nextNode)
342+
visitedSuccessors.add(nextId)
343+
current = nextId
344+
}
345+
346+
const targetNode = channelsKeyById.get(targetId)
347+
const ancestors = getAncestors(targetId)
348+
349+
return [...ancestors, targetNode, ...successors].filter((n): n is Channel | Channel[] => !!n)
205350
}

src/lib/components/BottomBar/DownloadButton.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
function _onClick() {
1616
const streams = new Collection(Array.from($selectedStreams)).sortBy(
1717
[
18-
(stream: Stream) => stream.channel.toLowerCase(),
18+
(stream: Stream) => stream.getFullTitle(),
1919
(stream: Stream) => stream.getVerticalResolution(),
2020
(stream: Stream) => stream.url
2121
],

0 commit comments

Comments
 (0)