-
-
Notifications
You must be signed in to change notification settings - Fork 5.9k
fix: cli commands accept file path arguments directl... in edit.ts #35311
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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)) { | ||||||||||||||
| console.error(`Error: filepath "${filepath}" is outside the working directory`) | ||||||||||||||
| process.exit(1) | ||||||||||||||
| } | ||||||||||||||
|
Comment on lines
+35
to
+40
|
||||||||||||||
|
|
||||||||||||||
| const storage = new Storage() | ||||||||||||||
| let parsedStreams = new Collection<Stream>() | ||||||||||||||
|
|
||||||||||||||
| main(filepath) | ||||||||||||||
| nodeCleanup(() => { | ||||||||||||||
| save(filepath) | ||||||||||||||
|
Comment on lines
+45
to
+47
|
||||||||||||||
| main(filepath) | |
| nodeCleanup(() => { | |
| save(filepath) | |
| main(resolvedPath) | |
| nodeCleanup(() => { | |
| save(resolvedPath) |
Copilot
AI
Apr 2, 2026
There was a problem hiding this comment.
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.
| throw new Error(`File "${filepath}" does not exists`) | |
| throw new Error(`File "${filepath}" does not exist`) |
There was a problem hiding this comment.
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)).