Skip to content

Commit db56a4d

Browse files
authored
Merge pull request #2752 from iptv-org/patch-2025.04.3
Patch 2025.04.3
2 parents 113395c + 11cab21 commit db56a4d

35 files changed

+671
-165
lines changed

CONTRIBUTING.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -391,9 +391,9 @@ npm run channels:edit path/to/channels.xml
391391
This way, you can map channels by simply selecting the proper ID from the list:
392392

393393
```sh
394-
? Select xmltv_id for "BBC One" (bbc1): (Use arrow keys)
395-
❯ BBC One (BBC1, BBC Television, BBC Television Service) | BBCOne.uk
396-
BBC One HD | BBCOneHD.uk
394+
? Select channel ID for "BBC One" (bbc1): (Use arrow keys)
395+
BBCOne.uk (BBC One, BBC1, BBC Television, BBC Television Service)
396+
BBCOneHD.uk (BBC One HD)
397397
Type...
398398
Skip
399399
```

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ Options:
5757
-c, --channels <path> Path to *.channels.xml file (required if the "--site" attribute is
5858
not specified)
5959
-o, --output <path> Path to output file (default: "guide.xml")
60-
-l, --lang <code> Allows to limit the download to channels in the specified language only (ISO 639-1 code)
60+
-l, --lang <codes> Allows you to restrict downloading to channels in specified languages only (example: "en,id")
6161
-t, --timeout <milliseconds> Timeout for each request in milliseconds (default: 0)
6262
-d, --delay <milliseconds> Delay between request in milliseconds (default: 0)
6363
-x, --proxy <url> Use the specified proxy (example: "socks5://username:[email protected]:1234")

package-lock.json

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

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"@eslint/eslintrc": "^3.2.0",
4444
"@eslint/js": "^9.17.0",
4545
"@freearhey/core": "^0.7.0",
46+
"@freearhey/search-js": "^0.1.2",
4647
"@ntlab/sfetch": "^1.2.0",
4748
"@octokit/core": "^6.1.3",
4849
"@octokit/plugin-paginate-rest": "^11.3.6",
@@ -74,7 +75,6 @@
7475
"eslint-config-prettier": "^9.0.0",
7576
"form-data": "^4.0.0",
7677
"fs-extra": "^10.0.1",
77-
"fuse.js": "^7.0.0",
7878
"glob": "^7.2.0",
7979
"globals": "^15.14.0",
8080
"husky": "^9.1.7",

scripts/commands/api/load.ts

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,24 @@
1-
import { Logger } from '@freearhey/core'
2-
import { ApiClient } from '../../core'
1+
import { DATA_DIR } from '../../constants'
2+
import { Storage } from '@freearhey/core'
3+
import { DataLoader } from '../../core'
34

45
async function main() {
5-
const logger = new Logger()
6-
const client = new ApiClient({ logger })
6+
const storage = new Storage(DATA_DIR)
7+
const loader = new DataLoader({ storage })
78

8-
const requests = [
9-
client.download('channels.json'),
10-
client.download('feeds.json'),
11-
client.download('countries.json'),
12-
client.download('regions.json'),
13-
client.download('subdivisions.json')
14-
]
15-
16-
await Promise.all(requests)
9+
await Promise.all([
10+
loader.download('blocklist.json'),
11+
loader.download('categories.json'),
12+
loader.download('channels.json'),
13+
loader.download('countries.json'),
14+
loader.download('languages.json'),
15+
loader.download('regions.json'),
16+
loader.download('subdivisions.json'),
17+
loader.download('feeds.json'),
18+
loader.download('timezones.json'),
19+
loader.download('guides.json'),
20+
loader.download('streams.json')
21+
])
1722
}
1823

1924
main()

scripts/commands/channels/edit.ts

Lines changed: 66 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@ import { ChannelsParser, XML } from '../../core'
44
import { Channel, Feed } from '../../models'
55
import { DATA_DIR } from '../../constants'
66
import nodeCleanup from 'node-cleanup'
7-
import epgGrabber from 'epg-grabber'
87
import { Command } from 'commander'
98
import readline from 'readline'
10-
import Fuse from 'fuse.js'
9+
import sjs from '@freearhey/search-js'
10+
import { DataProcessor, DataLoader } from '../../core'
11+
import type { DataLoaderData } from '../../types/dataLoader'
12+
import type { DataProcessorData } from '../../types/dataProcessor'
13+
import epgGrabber from 'epg-grabber'
14+
import { ChannelSearchableData } from '../../types/channel'
1115

1216
type ChoiceValue = { type: string; value?: Feed | Channel }
13-
type Choice = { name: string; short?: string; value: ChoiceValue }
17+
type Choice = { name: string; short?: string; value: ChoiceValue; default?: boolean }
1418

1519
if (process.platform === 'win32') {
1620
readline
@@ -42,31 +46,48 @@ export default async function main(filepath: string) {
4246
throw new Error(`File "${filepath}" does not exists`)
4347
}
4448

49+
logger.info('loading data from api...')
50+
const processor = new DataProcessor()
51+
const dataStorage = new Storage(DATA_DIR)
52+
const loader = new DataLoader({ storage: dataStorage })
53+
const data: DataLoaderData = await loader.load()
54+
const { feedsGroupedByChannelId, channels, channelsKeyById }: DataProcessorData =
55+
processor.process(data)
56+
57+
logger.info('loading channels...')
4558
const parser = new ChannelsParser({ storage })
4659
parsedChannels = await parser.parse(filepath)
47-
48-
const dataStorage = new Storage(DATA_DIR)
49-
const channelsData = await dataStorage.json('channels.json')
50-
const channels = new Collection(channelsData).map(data => new Channel(data))
51-
const feedsData = await dataStorage.json('feeds.json')
52-
const feeds = new Collection(feedsData).map(data => new Feed(data))
53-
const feedsGroupedByChannelId = feeds.groupBy((feed: Feed) => feed.channelId)
54-
55-
const searchIndex: Fuse<Channel> = new Fuse(channels.all(), {
56-
keys: ['name', 'alt_names'],
57-
threshold: 0.4
60+
const parsedChannelsWithoutId = parsedChannels.filter(
61+
(channel: epgGrabber.Channel) => !channel.xmltv_id
62+
)
63+
64+
logger.info(
65+
`found ${parsedChannels.count()} channels (including ${parsedChannelsWithoutId.count()} without ID)`
66+
)
67+
68+
logger.info('creating search index...')
69+
const items = channels.map((channel: Channel) => channel.getSearchable()).all()
70+
const searchIndex = sjs.createIndex(items, {
71+
searchable: ['name', 'altNames', 'guideNames', 'streamNames', 'feedFullNames']
5872
})
5973

60-
for (const channel of parsedChannels.all()) {
61-
if (channel.xmltv_id) continue
74+
logger.info('starting...\n')
75+
76+
for (const parsedChannel of parsedChannelsWithoutId.all()) {
6277
try {
63-
channel.xmltv_id = await selectChannel(channel, searchIndex, feedsGroupedByChannelId)
64-
} catch {
78+
parsedChannel.xmltv_id = await selectChannel(
79+
parsedChannel,
80+
searchIndex,
81+
feedsGroupedByChannelId,
82+
channelsKeyById
83+
)
84+
} catch (err) {
85+
logger.info(err.message)
6586
break
6687
}
6788
}
6889

69-
parsedChannels.forEach((channel: epgGrabber.Channel) => {
90+
parsedChannelsWithoutId.forEach((channel: epgGrabber.Channel) => {
7091
if (channel.xmltv_id === '-') {
7192
channel.xmltv_id = ''
7293
}
@@ -75,12 +96,14 @@ export default async function main(filepath: string) {
7596

7697
async function selectChannel(
7798
channel: epgGrabber.Channel,
78-
searchIndex: Fuse<Channel>,
79-
feedsGroupedByChannelId: Dictionary
99+
searchIndex,
100+
feedsGroupedByChannelId: Dictionary,
101+
channelsKeyById: Dictionary
80102
): Promise<string> {
103+
const query = escapeRegex(channel.name)
81104
const similarChannels = searchIndex
82-
.search(channel.name)
83-
.map((result: { item: Channel }) => result.item)
105+
.search(query)
106+
.map((item: ChannelSearchableData) => channelsKeyById.get(item.id))
84107

85108
const selected: ChoiceValue = await select({
86109
message: `Select channel ID for "${channel.name}" (${channel.site_id}):`,
@@ -93,13 +116,16 @@ async function selectChannel(
93116
return '-'
94117
case 'type': {
95118
const typedChannelId = await input({ message: ' Channel ID:' })
96-
const typedFeedId = await input({ message: ' Feed ID:', default: 'SD' })
97-
return [typedChannelId, typedFeedId].join('@')
119+
if (!typedChannelId) return ''
120+
const selectedFeedId = await selectFeed(typedChannelId, feedsGroupedByChannelId)
121+
if (selectedFeedId === '-') return typedChannelId
122+
return [typedChannelId, selectedFeedId].join('@')
98123
}
99124
case 'channel': {
100125
const selectedChannel = selected.value
101126
if (!selectedChannel) return ''
102127
const selectedFeedId = await selectFeed(selectedChannel.id, feedsGroupedByChannelId)
128+
if (selectedFeedId === '-') return selectedChannel.id
103129
return [selectedChannel.id, selectedFeedId].join('@')
104130
}
105131
}
@@ -108,18 +134,22 @@ async function selectChannel(
108134
}
109135

110136
async function selectFeed(channelId: string, feedsGroupedByChannelId: Dictionary): Promise<string> {
111-
const channelFeeds = feedsGroupedByChannelId.get(channelId) || []
112-
if (channelFeeds.length <= 1) return ''
137+
const channelFeeds = feedsGroupedByChannelId.has(channelId)
138+
? new Collection(feedsGroupedByChannelId.get(channelId))
139+
: new Collection()
140+
const choices = getFeedChoises(channelFeeds)
113141

114142
const selected: ChoiceValue = await select({
115143
message: `Select feed ID for "${channelId}":`,
116-
choices: getFeedChoises(channelFeeds),
144+
choices,
117145
pageSize: 10
118146
})
119147

120148
switch (selected.type) {
149+
case 'skip':
150+
return '-'
121151
case 'type':
122-
return await input({ message: ' Feed ID:' })
152+
return await input({ message: ' Feed ID:', default: 'SD' })
123153
case 'feed':
124154
const selectedFeed = selected.value
125155
if (!selectedFeed) return ''
@@ -133,7 +163,7 @@ function getChannelChoises(channels: Collection): Choice[] {
133163
const choises: Choice[] = []
134164

135165
channels.forEach((channel: Channel) => {
136-
const names = [channel.name, ...channel.altNames.all()].join(', ')
166+
const names = new Collection([channel.name, ...channel.getAltNames().all()]).uniq().join(', ')
137167

138168
choises.push({
139169
value: {
@@ -163,12 +193,14 @@ function getFeedChoises(feeds: Collection): Choice[] {
163193
type: 'feed',
164194
value: feed
165195
},
196+
default: feed.isMain,
166197
name,
167198
short: feed.id
168199
})
169200
})
170201

171202
choises.push({ name: 'Type...', value: { type: 'type' } })
203+
choises.push({ name: 'Skip', value: { type: 'skip' } })
172204

173205
return choises
174206
}
@@ -179,3 +211,7 @@ function save(filepath: string) {
179211
storage.saveSync(filepath, xml.toString())
180212
logger.info(`\nFile '${filepath}' successfully saved`)
181213
}
214+
215+
function escapeRegex(string: string) {
216+
return string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&')
217+
}

scripts/commands/epg/grab.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ program
1414
)
1515
)
1616
.addOption(new Option('-o, --output <path>', 'Path to output file').default('guide.xml'))
17-
.addOption(new Option('-l, --lang <code>', 'Filter channels by language (ISO 639-1 code)'))
17+
.addOption(new Option('-l, --lang <codes>', 'Filter channels by languages (ISO 639-1 codes)'))
1818
.addOption(
1919
new Option('-t, --timeout <milliseconds>', 'Override the default timeout for each request').env(
2020
'TIMEOUT'
@@ -90,7 +90,11 @@ async function main() {
9090
parsedChannels = parsedChannels.concat(await parser.parse(filepath))
9191
}
9292
if (options.lang) {
93-
parsedChannels = parsedChannels.filter((channel: Channel) => channel.lang === options.lang)
93+
parsedChannels = parsedChannels.filter((channel: Channel) => {
94+
if (!options.lang || !channel.lang) return true
95+
96+
return options.lang.includes(channel.lang)
97+
})
9498
}
9599
logger.info(` found ${parsedChannels.count()} channel(s)`)
96100

0 commit comments

Comments
 (0)