Merge pull request #55984 from iptv-org/patch-2026.10.1

Patch 2026.10.1
This commit is contained in:
brokenstream authored and GitHub committed 2026-10-22 07:10:41 -05:00
commit 069f82d503
14 files changed
+808 -467

No files matched your search

@@ -0,0 +1,9 @@
{
"discussion": {
"number": 19956,
"body": "### Channel ID\n\nCNBCe.tr@SD",
"category": {
"name": "Channel Search"
}
}
}
+66
View File
@@ -0,0 +1,66 @@
name: Validate Discussions
on:
discussion:
types: [created, edited]
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
if: github.event.discussion.category.name == 'Channel Search'
steps:
- name: Checkout repository code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: tibdex/github-app-token@0d49dd721133f900ebd5e0dff2810704e8defbc6 # v1.8.2
if: ${{ !env.ACT }}
id: create-app-token
with:
app_id: ${{ secrets.APP_ID }}
private_key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run discussion:validate
id: validator
continue-on-error: true
env:
RAW_BODY: ${{ github.event.discussion.body }}
DISC_NUMBER: ${{ github.event.discussion.number }}
GITHUB_TOKEN: ${{ steps.create-app-token.outputs.token || secrets.GITHUB_TOKEN }}
run: |
npm run discussion:validate -- \
--number "$DISC_NUMBER" \
--body "$RAW_BODY"
- name: Handle Discussion Result
if: always() && hashFiles('temp/logs/result.json') != ''
env:
GH_TOKEN: ${{ steps.create-app-token.outputs.token }}
DISC_NUMBER: ${{ github.event.discussion.number }}
run: |
COMMENT=$(jq -r '.comment // empty' temp/logs/result.json)
LABEL=$(jq -r '.label // empty' temp/logs/result.json)
if [ "${{ env.ACT }}" = "true" ]; then
echo "[MOCK] Comment: $COMMENT"
echo "[MOCK] Label: $LABEL"
else
if [ -n "$COMMENT" ] && [ "$COMMENT" != "null" ]; then
gh discussion comment $DISC_NUMBER --body "$COMMENT"
fi
if [ -n "$LABEL" ] && [ "$LABEL" != "null" ]; then
gh discussion edit $DISC_NUMBER --add-label "$LABEL"
fi
fi
+18
View File
@@ -7,9 +7,11 @@ For the scripts to work, you must have [Node.js](https://nodejs.org/en) installe
- [act:check](#actcheck)
- [act:format](#actformat)
- [act:update](#actupdate)
- [act:validate_discussion](#actvalidate_discussion)
- [act:validate_issue](#actvalidate_issue)
- [act:validate_label](#actvalidate_label)
- [api:load](#apiload)
- [discussion:validate](#discussionvalidate)
- [issue:validate](#issuevalidate)
- [playlist:format](#playlistformat)
- [playlist:update](#playlistupdate)
@@ -48,6 +50,14 @@ Runs the [update](./workflows.md#update) workflow locally. Depends on [nektos/gh
npm run act:update
```
## act:validate_discussion
Runs the [validate_discussion](./workflows.md#validate_discussion) workflow locally. Depends on [nektos/gh-act](https://github.com/nektos/gh-act).
```sh
npm run act:validate_discussion -- -e .github/mocks/events/discussion_opened.json -s GITHUB_TOKEN=github_pat_***
```
## act:validate_issue
Runs the [validate_issue](./workflows.md#validate_issue) workflow locally. Depends on [nektos/gh-act](https://github.com/nektos/gh-act).
@@ -72,6 +82,14 @@ Downloads the latest channel and stream data from the [iptv-org/api](https://git
npm run api:load
```
## discussion:validate
Checks the request for errors and saves the result to the file `temp/logs/result.json`
```sh
npm run discussion:validate --number=123 --body="### Stream URL..."
```
## issue:validate
Checks the request for errors and, if any are found, saves a list of them to the file `temp/logs/errors.txt`
+4
View File
@@ -16,6 +16,10 @@ Sequentially runs the `api:load`, `playlist:format`, `playlist:lint`, and `playl
Runs every day at 0:00 UTC. It sequentially executes the `api:load`, `playlist:update`, `playlist:lint`, `playlist:validate`, `playlist:generate`, `playlist:export`, and `readme:update` scripts, then automatically deploys the updated files if successful.
## validate_discussion
Runs when an discussion is opened or edited. It checks the requests for errors using `discussion:validate` script and, if any are found, the bot posts them in the comments.
## validate_issue
Runs when an issue is opened or edited. It checks the requests for errors using `issue:validate` script and, if any are found, the bot posts them in the comments.
+423 -459
View File
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -4,10 +4,12 @@
"act:check": "gh act pull_request -W .github/workflows/check.yml",
"act:format": "gh act workflow_dispatch -W .github/workflows/format.yml",
"act:update": "gh act workflow_dispatch -W .github/workflows/update.yml",
"act:validate_discussion": "gh act discussion -W .github/workflows/validate_discussion.yml",
"act:validate_issue": "gh act issues -W .github/workflows/validate_issue.yml",
"act:validate_label": "gh act issues -W .github/workflows/validate_label.yml",
"api:load": "tsx scripts/commands/api/load.ts",
"issue:validate": "tsx scripts/commands/issue/validate.ts",
"discussion:validate": "tsx scripts/commands/discussion/validate.ts",
"playlist:format": "tsx scripts/commands/playlist/format.ts",
"playlist:update": "tsx scripts/commands/playlist/update.ts",
"playlist:generate": "tsx scripts/commands/playlist/generate.ts",
@@ -72,4 +74,4 @@
"esbuild": "0.23.1",
"@xmldom/xmldom": ">=0.8.13 <1.0.0"
}
}
}
+130
View File
@@ -0,0 +1,130 @@
import { LOGS_DIR, OWNER, REPO, STREAMS_DIR } from '../../constants'
import { loadData, data as apiData } from '../../api'
import { Logger } from '@freearhey/core'
import { Storage } from '@freearhey/storage-js'
import { PlaylistParser } from '../../core'
import { Discussion, Stream } from '../../models'
import { loadDiscussions, parseIssueBody } from '../../utils'
import { program } from 'commander'
program
.requiredOption('--number <number>', 'The discussion number')
.requiredOption('--body <body>', 'The full markdown body text of the discussion')
.parse(process.argv)
const { number: discussionNumberStr, body } = program.opts()
const targetDiscussionNumber = parseInt(discussionNumberStr, 10)
const logsStorage = new Storage(LOGS_DIR)
const status = {
OK: 'ok',
FULFILLED: 'fulfilled',
INVALID_CHANNEL_ID: 'invalid_channel_id',
MISSING_FEED_ID: 'missing_feed_id',
DUPLICATE_REQUEST: 'duplicate_request'
}
async function main() {
const logger = new Logger()
logger.info('loading discussions...')
const discussions = await loadDiscussions()
logger.info('loading data from api...')
await loadData()
logger.info('loading streams...')
const streamsStorage = new Storage(STREAMS_DIR)
const parser = new PlaylistParser({ storage: streamsStorage })
const files = await streamsStorage.list('**/*.m3u')
const streams = await parser.parse(files)
const streamsGroupedById = streams.groupBy((stream: Stream) => stream.getId())
const discussionData = parseIssueBody(body)
const streamId =
discussionData.getString('stream_id') ||
discussionData.getString('channel_id') ||
''
const [channelId, feedId] = streamId.split('@')
const priorDiscussions = discussions.filter(
(d: Discussion) => d.number < targetDiscussionNumber && d.category === 'Channel Search'
)
const seenStreamIds = new Set<string>()
let originalDiscussionNumber: number | null = null
priorDiscussions.forEach((d: Discussion) => {
const existingId = d.data.getString('stream_id') || d.data.getString('channel_id') || ''
if (existingId) {
seenStreamIds.add(existingId)
if (existingId === streamId && !originalDiscussionNumber) {
originalDiscussionNumber = d.number
}
}
})
let finalStatus = status.OK
if (!channelId || apiData.channelsKeyById.missing(channelId)) {
finalStatus = status.INVALID_CHANNEL_ID
} else if (!feedId) {
finalStatus = status.MISSING_FEED_ID
} else if (streamId && seenStreamIds.has(streamId)) {
finalStatus = status.DUPLICATE_REQUEST
} else if (streamsGroupedById.has(streamId)) {
finalStatus = status.FULFILLED
}
const repo = process.env.GITHUB_REPOSITORY || OWNER + '/' + REPO
const sha = process.env.GITHUB_SHA || 'master'
let comment = ''
let label = 'check:passed'
let close = 'false'
if (finalStatus === status.FULFILLED) {
for (const filePath of files) {
const content = await streamsStorage.load(filePath)
const lines = content.split(/\r?\n/)
const lineIndex = lines.findIndex(line => line.includes(streamId))
if (lineIndex !== -1) {
const permalink = `https://github.com/${repo}/blob/${sha}/streams/${filePath}#L${lineIndex + 1}-L${lineIndex + 2}`
comment = `A link to the "${streamId}" stream is already in the playlist: ${permalink}`
label = 'check:failed'
close = 'true'
break
}
}
} else if (finalStatus === status.DUPLICATE_REQUEST) {
comment = `Duplicate of #${originalDiscussionNumber}.`
label = 'check:failed'
close = 'true'
} else if (finalStatus === status.INVALID_CHANNEL_ID) {
comment = `There is no channel with the ID "${channelId}" in the database.`
label = 'check:failed'
close = 'true'
} else if (finalStatus === status.MISSING_FEED_ID) {
comment = 'The request is missing the feed ID.'
label = 'check:failed'
close = 'true'
}
const result = {
number: targetDiscussionNumber,
streamId: streamId,
status: finalStatus,
label: label,
comment: comment,
close: close
}
logsStorage.saveSync('result.json', JSON.stringify(result, null, 2))
if (finalStatus !== status.FULFILLED) {
process.exit(1)
}
process.exit(0)
}
main()
+8
View File
@@ -604,5 +604,13 @@
"country": "BR",
"categories": [],
"is_nsfw": false
},
{
"id": "CNBCe.tr",
"name": "CNBCe",
"network": null,
"country": "TR",
"categories": [],
"is_nsfw": false
}
]
+16
View File
@@ -889,5 +889,21 @@
"America/New_York"
],
"video_format": "576i"
},
{
"channel": "CNBCe.tr",
"id": "SD",
"name": "SD",
"is_main": true,
"broadcast_area": [
"c/TR"
],
"languages": [
"eng"
],
"timezones": [
"America/New_York"
],
"video_format": "576i"
}
]
@@ -0,0 +1,3 @@
#EXTM3U
#EXTINF:-1 tvg-id="CNBCe.tr@SD",CNBCe
https://ythls.onrender.com/channel/UC40TUSUx490U5uR1lZt3Ajg.m3u8
+16 -1
View File
@@ -1,7 +1,7 @@
module.exports = [
{
number: 19956,
body: '### Stream ID (required)\r\n\r\nCNBCe.tr\r\n\r\n### Notes\r\n\r\n_No response_',
body: '### Stream ID (required)\r\n\r\nCNBCe.tr@SD\r\n\r\n### Notes\r\n\r\n_No response_',
category: { name: 'Channel Search' }
},
{
@@ -9,6 +9,21 @@ module.exports = [
body: '### Stream ID (required)\r\n\r\n13thStreet.au\r\n\r\n### Notes\r\n\r\nI need Disney Channel East Stream m3u Url please\r\n\r\n### Contributing Guide\r\n\r\n- [x] I have read [Contributing Guide](https://github.com/iptv-org/iptv/blob/master/CONTRIBUTING.md)',
category: { name: 'Channel Search' }
},
{
number: 19958,
body: '### Stream ID (required)\r\n\r\nCNBCe.tr@SD\r\n\r\n### Notes\r\n\r\n_No response_',
category: { name: 'Channel Search' }
},
{
number: 19959,
body: '### Stream ID (required)\r\n\r\nCNBCe.com\r\n\r\n### Notes\r\n\r\n_No response_',
category: { name: 'Channel Search' }
},
{
number: 19960,
body: '### Stream ID (required)\r\n\r\nCNBCe.tr\r\n\r\n### Notes\r\n\r\n_No response_',
category: { name: 'Channel Search' }
},
{
number: 20956,
body: '### Stream ID (required)\r\n\r\nIONTV.us\r\n\r\n### Notes\r\n\r\nI need it fast please thank you\r\n\r\n### Contributing Guide\r\n\r\n- [x] I have read [Contributing Guide](https://github.com/iptv-org/iptv/blob/master/CONTRIBUTING.md)',
+104
View File
@@ -0,0 +1,104 @@
import { describe, beforeEach, it, expect } from 'vitest'
import { pathToFileURL } from 'node:url'
import { execFileSync } from 'child_process'
import * as fs from 'fs-extra'
const ENV = {
...process.env,
DATA_DIR: 'tests/__data__/input/data',
STREAMS_DIR: 'tests/__data__/output/streams',
LOGS_DIR: 'tests/__data__/output/logs',
GITHUB_REPOSITORY: 'test-owner/test-repo',
GITHUB_SHA: 'main'
}
beforeEach(() => {
fs.emptyDirSync('tests/__data__/output')
fs.copySync('tests/__data__/input/discussion_validate/streams', 'tests/__data__/output/streams')
})
describe('discussion:validate', () => {
it('can handle fulfilled channel search request', () => {
const discussionNumber = 19956
const body = '### Channel ID\n\nCNBCe.tr@SD'
const stdout = runDiscussionValidate(discussionNumber, body)
if (process.env.DEBUG === 'true') console.log(stdout)
const result = readResultJson()
expect(result.status).toBe('fulfilled')
expect(result.label).toBe('check:failed')
expect(result.comment).toContain('https://github.com/test-owner/test-repo/blob/main/')
expect(result.close).toBe('true')
})
it('can handle duplicate channel search request', () => {
const discussionNumber = 19958
const body = '### Channel ID\n\nCNBCe.tr@SD'
try {
runDiscussionValidate(discussionNumber, body)
throw new Error('Expected process to fail for duplicate request')
} catch {
const result = readResultJson()
expect(result.status).toBe('duplicate_request')
expect(result.label).toBe('check:failed')
expect(result.comment).toContain('Duplicate of #19956.')
expect(result.close).toBe('true')
}
})
it('can handle invalid channel id request', () => {
const discussionNumber = 19959
const body = '### Channel ID\n\nCNBCe.com@SD'
try {
runDiscussionValidate(discussionNumber, body)
throw new Error('Expected process to fail for invalid channel ID')
} catch {
const result = readResultJson()
expect(result.status).toBe('invalid_channel_id')
expect(result.label).toBe('check:failed')
expect(result.comment).toContain('There is no channel with the ID "CNBCe.com" in the database.')
expect(result.close).toBe('true')
}
})
it('can handle missing feed id request', () => {
const discussionNumber = 19960
const body = '### Channel ID\n\nCNBCe.tr'
try {
runDiscussionValidate(discussionNumber, body)
throw new Error('Expected process to fail for missing feed ID')
} catch {
const result = readResultJson()
expect(result.status).toBe('missing_feed_id')
expect(result.label).toBe('check:failed')
expect(result.comment).toContain('The request is missing the feed ID.')
expect(result.close).toBe('true')
}
})
})
function runDiscussionValidate(number: number, body: string) {
return execFileSync(
process.execPath,
[
'--import',
'tsx',
'scripts/commands/discussion/validate.ts',
'--number',
`${number}`,
'--body',
body
],
{ encoding: 'utf8', env: ENV }
)
}
function readResultJson() {
const filepath = 'tests/__data__/output/logs/result.json'
const raw = fs.readFileSync(pathToFileURL(filepath), { encoding: 'utf8' })
return JSON.parse(raw)
}
+1 -1
View File
@@ -134,7 +134,7 @@ describe('playlist:validate', () => {
} catch (error) {
if (process.env.DEBUG === 'true') console.log(cmd, error)
expect((error as ExecError).stdout).toContain(
'log_level.m3u\n 2 error \"new: https://streamer2.nexgen.bz/07-CHANNEL7/index.m3u8\" is not a valid URL\n\n1 problems (1 errors, 0 warnings)\n'
'log_level.m3u\n 2 error "new: https://streamer2.nexgen.bz/07-CHANNEL7/index.m3u8" is not a valid URL\n\n1 problems (1 errors, 0 warnings)\n'
)
}
})
+7 -5
View File
@@ -23,11 +23,13 @@ describe('report:create', () => {
│ 4 │ 14179 │ 'streams:add' │ 'ManoramaNews.in' │ '(https://mitelefe.com/Api/Videos/GetSourceUrl/694564/0/HLS / https://ssl.cloud.telefe.com/Api/Vid...' │ 'invalid_stream_url' │
│ 5 │ 15175 │ 'streams:add' │ 'Channel7.bz@SD' │ 'new: https://streamer2.nexgen.bz/07-CHANNEL7/index.m3u8' │ 'invalid_stream_url' │
│ 6 │ 16120 │ 'streams:remove' │ undefined │ 'http://190.61.102.67:2000/play/a038/index.m3u8' │ 'nonexistent_link' │
│ 7 │ 19956 │ 'channel search' │ 'CNBCe.tr' │ undefined │ 'invalid_channel_id' │
│ 8 │ 19957 │ 'channel search' │ '13thStreet.au' │ undefined │ 'channel_closed' │
│ 9 │ 20956 │ 'channel search' │ 'IONTV.us' │ undefined │ 'fulfilled' │
│ 10 │ 25157 │ 'streams:add' │ 'OnTimeSports.eg@SD' │ 'OnTime Sports SD.mu38' │ 'invalid_stream_url' │
│ 11 │ 39097 │ 'streams:edit' │ '~' │ 'https://jmp2.uk/plu-5a74b8e1e22a61737979c6bf.m3u8' │ 'nonexistent_link' │
│ 7 │ 19957 │ 'channel search' │ '13thStreet.au' │ undefined │ 'channel_closed' │
│ 8 │ 19958 │ 'channel search' │ 'CNBCe.tr@SD' │ undefined │ 'duplicate_request' │
│ 9 │ 19959 │ 'channel search' │ 'CNBCe.com' │ undefined │ 'invalid_channel_id' │
│ 10 │ 19960 │ 'channel search' │ 'CNBCe.tr' │ undefined │ 'duplicate_request' │
│ 11 │ 20956 │ 'channel search' │ 'IONTV.us' │ undefined │ 'fulfilled' │
│ 12 │ 25157 │ 'streams:add' │ 'OnTimeSports.eg@SD' │ 'OnTime Sports SD.mu38' │ 'invalid_stream_url' │
│ 13 │ 39097 │ 'streams:edit' │ '~' │ 'https://jmp2.uk/plu-5a74b8e1e22a61737979c6bf.m3u8' │ 'nonexistent_link' │
└─────────┴─────────────┴──────────────────┴─────────────────────────────┴────────────────────────────────────────────────────────────────────────────────────────────────────────┴──────────────────────┘`).trim()
)
).toBe(true)