Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
379 changes: 194 additions & 185 deletions scripts/commands/playlist/edit.ts
Original file line number Diff line number Diff line change
@@ -1,185 +1,194 @@
import { loadData, data, searchChannels } from '../../api'
import { Collection, Logger } from '@freearhey/core'
import { select, input } from '@inquirer/prompts'
import { Playlist, Stream } from '../../models'
import { Storage } from '@freearhey/storage-js'
import { PlaylistParser } from '../../core'
import nodeCleanup from 'node-cleanup'
import * as sdk from '@iptv-org/sdk'
import { truncate } from '../../utils'
import { Command } from 'commander'
import readline from 'readline'

type ChoiceValue = { type: string; value?: sdk.Models.Feed | sdk.Models.Channel }
type Choice = { name: string; short?: string; value: ChoiceValue; default?: boolean }

if (process.platform === 'win32') {
readline
.createInterface({
input: process.stdin,
output: process.stdout
})
.on('SIGINT', function () {
process.emit('SIGINT')
})
}

const program = new Command()

program.argument('<filepath>', 'Path to *.channels.xml file to edit').parse(process.argv)

const filepath = program.args[0]
const logger = new Logger()
const storage = new Storage()
let parsedStreams = new Collection<Stream>()

main(filepath)
nodeCleanup(() => {
save(filepath)
})

export default async function main(filepath: string) {
if (!(await storage.exists(filepath))) {
throw new Error(`File "${filepath}" does not exists`)
}

logger.info('loading data from api...')
await loadData()

logger.info('loading streams...')
const parser = new PlaylistParser({
storage
})
parsedStreams = await parser.parseFile(filepath)
const streamsWithoutId = parsedStreams.filter((stream: Stream) => !stream.tvgId)

logger.info(
`found ${parsedStreams.count()} streams (including ${streamsWithoutId.count()} without ID)`
)

logger.info('starting...\n')

for (const stream of streamsWithoutId.all()) {
try {
stream.tvgId = await selectChannel(stream)
} catch (err) {
logger.info(err.message)
break
}
}

streamsWithoutId.forEach((stream: Stream) => {
if (stream.tvgId === '-') {
stream.tvgId = ''
}
})
}

async function selectChannel(stream: Stream): Promise<string> {
const similarChannels = searchChannels(stream.title)
const url = truncate(stream.url, 50)

const selected: ChoiceValue = await select({
message: `Select channel ID for "${stream.title}" (${url}):`,
choices: getChannelChoises(similarChannels),
pageSize: 10
})

switch (selected.type) {
case 'skip':
return '-'
case 'type': {
const typedChannelId = await input({ message: ' Channel ID:' })
if (!typedChannelId) return ''
const selectedFeedId = await selectFeed(typedChannelId)
if (selectedFeedId === '-') return typedChannelId
return [typedChannelId, selectedFeedId].join('@')
}
case 'channel': {
const selectedChannel = selected.value
if (!selectedChannel) return ''
const selectedFeedId = await selectFeed(selectedChannel.id)
if (selectedFeedId === '-') return selectedChannel.id
return [selectedChannel.id, selectedFeedId].join('@')
}
}

return ''
}

async function selectFeed(channelId: string): Promise<string> {
const channelFeeds = new Collection(data.feedsGroupedByChannel.get(channelId))
const choices = getFeedChoises(channelFeeds)

const selected: ChoiceValue = await select({
message: `Select feed ID for "${channelId}":`,
choices,
pageSize: 10
})

switch (selected.type) {
case 'skip':
return '-'
case 'type':
return await input({ message: ' Feed ID:', default: 'SD' })
case 'feed':
const selectedFeed = selected.value
if (!selectedFeed) return ''
return selectedFeed.id
}

return ''
}

function getChannelChoises(channels: Collection<sdk.Models.Channel>): Choice[] {
const choises: Choice[] = []

channels.forEach((channel: sdk.Models.Channel) => {
const names = new Collection([channel.name, ...channel.alt_names]).uniq().join(', ')

choises.push({
value: {
type: 'channel',
value: channel
},
name: `${channel.id} (${names})`,
short: `${channel.id}`
})
})

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

return choises
}

function getFeedChoises(feeds: Collection<sdk.Models.Feed>): Choice[] {
const choises: Choice[] = []

feeds.forEach((feed: sdk.Models.Feed) => {
let name = `${feed.id} (${feed.name})`
if (feed.is_main) name += ' [main]'

choises.push({
value: {
type: 'feed',
value: feed
},
default: feed.is_main,
name,
short: feed.id
})
})

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

return choises
}

function save(filepath: string) {
if (!storage.existsSync(filepath)) return
const playlist = new Playlist(parsedStreams)
storage.saveSync(filepath, playlist.toString())
logger.info(`\nFile '${filepath}' successfully saved`)
}
import { loadData, data, searchChannels } from '../../api'
import { Collection, Logger } from '@freearhey/core'
import { select, input } from '@inquirer/prompts'
import { Playlist, Stream } from '../../models'
import { Storage } from '@freearhey/storage-js'
import { PlaylistParser } from '../../core'
import nodeCleanup from 'node-cleanup'
import * as sdk from '@iptv-org/sdk'
import { truncate } from '../../utils'
import { Command } from 'commander'
import readline from 'readline'
import path from 'path'

type ChoiceValue = { type: string; value?: sdk.Models.Feed | sdk.Models.Channel }
type Choice = { name: string; short?: string; value: ChoiceValue; default?: boolean }

if (process.platform === 'win32') {
readline
.createInterface({
input: process.stdin,
output: process.stdout
})
.on('SIGINT', function () {
process.emit('SIGINT')
})
}

const program = new Command()

program.argument('<filepath>', 'Path to *.channels.xml file to edit').parse(process.argv)

const filepath = program.args[0]
const logger = new Logger()

const resolvedPath = path.resolve(filepath)
const relative = path.relative(process.cwd(), resolvedPath)
if (relative.startsWith('..') || path.isAbsolute(relative)) {
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The directory-traversal guard uses relative.startsWith('..'), which can incorrectly reject legitimate in-tree paths whose first path segment begins with .. (e.g. ..cache/file.channels.xml). Safer is to treat only the parent-dir segment as disallowed (e.g. relative === '..' || relative.startsWith('..' + path.sep)).

Suggested change
if (relative.startsWith('..') || path.isAbsolute(relative)) {
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {

Copilot uses AI. Check for mistakes.
console.error(`Error: filepath "${filepath}" is outside the working directory`)
process.exit(1)
}
Comment on lines +35 to +40
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

path.resolve() does not account for symlinks. A filepath inside the working directory can still escape via a symlink (e.g. ./safe/link-to-/etc/passwd), and the subsequent Storage read/write will follow the link. Consider validating against fs.realpathSync()/fs.promises.realpath() (after existence check) and ensure the real path is within the allowed base directory.

Copilot uses AI. Check for mistakes.

const storage = new Storage()
let parsedStreams = new Collection<Stream>()

main(filepath)
nodeCleanup(() => {
save(filepath)
Comment on lines +45 to +47
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After computing and validating resolvedPath, the script continues to use the original filepath for storage.exists(), parsing, and saving. To ensure the validated path is the one actually used (and to avoid absolute-path handling differences in Storage), pass the validated path (resolvedPath / realpath) through to main() and save() instead of the raw argument string.

Suggested change
main(filepath)
nodeCleanup(() => {
save(filepath)
main(resolvedPath)
nodeCleanup(() => {
save(resolvedPath)

Copilot uses AI. Check for mistakes.
})

export default async function main(filepath: string) {
if (!(await storage.exists(filepath))) {
throw new Error(`File "${filepath}" does not exists`)
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Grammar in the error message: does not exists -> does not exist. This improves clarity in CLI output.

Suggested change
throw new Error(`File "${filepath}" does not exists`)
throw new Error(`File "${filepath}" does not exist`)

Copilot uses AI. Check for mistakes.
}

logger.info('loading data from api...')
await loadData()

logger.info('loading streams...')
const parser = new PlaylistParser({
storage
})
parsedStreams = await parser.parseFile(filepath)
const streamsWithoutId = parsedStreams.filter((stream: Stream) => !stream.tvgId)

logger.info(
`found ${parsedStreams.count()} streams (including ${streamsWithoutId.count()} without ID)`
)

logger.info('starting...\n')

for (const stream of streamsWithoutId.all()) {
try {
stream.tvgId = await selectChannel(stream)
} catch (err) {
logger.info(err.message)
break
}
}

streamsWithoutId.forEach((stream: Stream) => {
if (stream.tvgId === '-') {
stream.tvgId = ''
}
})
}

async function selectChannel(stream: Stream): Promise<string> {
const similarChannels = searchChannels(stream.title)
const url = truncate(stream.url, 50)

const selected: ChoiceValue = await select({
message: `Select channel ID for "${stream.title}" (${url}):`,
choices: getChannelChoises(similarChannels),
pageSize: 10
})

switch (selected.type) {
case 'skip':
return '-'
case 'type': {
const typedChannelId = await input({ message: ' Channel ID:' })
if (!typedChannelId) return ''
const selectedFeedId = await selectFeed(typedChannelId)
if (selectedFeedId === '-') return typedChannelId
return [typedChannelId, selectedFeedId].join('@')
}
case 'channel': {
const selectedChannel = selected.value
if (!selectedChannel) return ''
const selectedFeedId = await selectFeed(selectedChannel.id)
if (selectedFeedId === '-') return selectedChannel.id
return [selectedChannel.id, selectedFeedId].join('@')
}
}

return ''
}

async function selectFeed(channelId: string): Promise<string> {
const channelFeeds = new Collection(data.feedsGroupedByChannel.get(channelId))
const choices = getFeedChoises(channelFeeds)

const selected: ChoiceValue = await select({
message: `Select feed ID for "${channelId}":`,
choices,
pageSize: 10
})

switch (selected.type) {
case 'skip':
return '-'
case 'type':
return await input({ message: ' Feed ID:', default: 'SD' })
case 'feed':
const selectedFeed = selected.value
if (!selectedFeed) return ''
return selectedFeed.id
}

return ''
}

function getChannelChoises(channels: Collection<sdk.Models.Channel>): Choice[] {
const choises: Choice[] = []

channels.forEach((channel: sdk.Models.Channel) => {
const names = new Collection([channel.name, ...channel.alt_names]).uniq().join(', ')

choises.push({
value: {
type: 'channel',
value: channel
},
name: `${channel.id} (${names})`,
short: `${channel.id}`
})
})

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

return choises
}

function getFeedChoises(feeds: Collection<sdk.Models.Feed>): Choice[] {
const choises: Choice[] = []

feeds.forEach((feed: sdk.Models.Feed) => {
let name = `${feed.id} (${feed.name})`
if (feed.is_main) name += ' [main]'

choises.push({
value: {
type: 'feed',
value: feed
},
default: feed.is_main,
name,
short: feed.id
})
})

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

return choises
}

function save(filepath: string) {
if (!storage.existsSync(filepath)) return
const playlist = new Playlist(parsedStreams)
storage.saveSync(filepath, playlist.toString())
logger.info(`\nFile '${filepath}' successfully saved`)
}