Merge branch 'master' into be-cn-changes

This commit is contained in:
freearhey
2025-10-11 05:34:34 +03:00
294 changed files with 17468 additions and 18401 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ body:
id: directives
attributes:
label: Directives
description: 'List of directives telling players how to play the stream. Supported `#KODIPROP` and `#VLCOPT`.'
description: 'List of directives telling players how to play the stream. Supported `#KODIPROP` and `#EXTVLCOPT`.'
placeholder: '#KODIPROP:inputstream=inputstream.adaptive'
- type: textarea
+1 -1
View File
@@ -76,7 +76,7 @@ body:
id: directives
attributes:
label: Directives
description: 'List of directives telling players how to play the stream. Supported `#KODIPROP` and `#VLCOPT`.'
description: 'List of directives telling players how to play the stream. Supported `#KODIPROP` and `#EXTVLCOPT`.'
placeholder: '#KODIPROP:inputstream=inputstream.adaptive'
- type: textarea
+3 -3
View File
@@ -7,9 +7,9 @@ body:
- type: input
id: stream_id
attributes:
label: Channel ID (required)
description: Unique channel ID from [iptv-org.github.io](https://iptv-org.github.io/). If you can't find the channel you want in the list, please let us know through this [form](https://github.com/iptv-org/database/issues/new?assignees=&labels=channels%3Aadd&projects=&template=channels_add.yml&title=Add%3A+) before posting your request.
placeholder: 'BBCAmericaEast.us'
label: Stream ID (required)
description: Unique ID of the channel and feed from [iptv-org.github.io](https://iptv-org.github.io/). If you cannot find the channel or feed you are looking for in the list, please let us know via one of the [forms](https://github.com/iptv-org/database/issues/new/choose) before posting your request.
placeholder: 'BBCAmerica.us@East'
validations:
required: true
+9 -4
View File
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: changed files
- name: Get list of changed files
id: files
run: |
git fetch origin master:master
@@ -23,14 +23,19 @@ jobs:
echo "all_changed_files=$ALL_CHANGED_FILES" >> "$GITHUB_OUTPUT"
echo "any_changed=$ANY_CHANGED" >> "$GITHUB_OUTPUT"
- uses: actions/setup-node@v4
if: ${{ !env.ACT && steps.files.outputs.any_changed == 'true' }}
if: steps.files.outputs.any_changed == 'true'
with:
node-version: 22
cache: 'npm'
- name: install dependencies
- name: Setup .npmrc for GitHub Packages
run: |
echo "//npm.pkg.github.com/:_authToken=$GITHUB_TOKEN" >> .npmrc
echo "@iptv-org:registry=https://npm.pkg.github.com/" >> .npmrc
echo "always-auth=true" >> .npmrc
- name: Install dependencies
if: steps.files.outputs.any_changed == 'true'
run: npm install
- name: validate
- name: Validate changed files
if: steps.files.outputs.any_changed == 'true'
run: |
npm run playlist:lint -- ${{ steps.files.outputs.all_changed_files }}
+11 -80
View File
@@ -1,10 +1,6 @@
name: format
on:
workflow_dispatch:
# pull_request:
# types: [closed]
# branches:
# - master
# schedule:
# - cron: "0 12 * * *"
jobs:
@@ -28,19 +24,20 @@ jobs:
with:
node-version: 22
cache: 'npm'
- name: setup git
- name: Setup .npmrc for GitHub Packages
run: |
git config user.name "iptv-bot[bot]"
git config user.email "84861620+iptv-bot[bot]@users.noreply.github.com"
- name: install dependencies
echo "//npm.pkg.github.com/:_authToken=$GITHUB_TOKEN" >> .npmrc
echo "@iptv-org:registry=https://npm.pkg.github.com/" >> .npmrc
echo "always-auth=true" >> .npmrc
- name: Install dependencies
run: npm install
- name: format internal playlists
- name: Format internal playlists
run: npm run playlist:format
- name: check internal playlists
- name: Check internal playlists
run: |
npm run playlist:lint
npm run playlist:validate
- name: changed files
- name: Get list of changed files
id: files_after
run: |
ANY_CHANGED=false
@@ -50,82 +47,16 @@ jobs:
fi
echo "all_changed_files=$ALL_CHANGED_FILES" >> "$GITHUB_OUTPUT"
echo "any_changed=$ANY_CHANGED" >> "$GITHUB_OUTPUT"
- name: git status
run: git status
- name: commit changes
if: steps.files_after.outputs.any_changed == 'true'
run: |
git add streams
git status
git commit -m "[Bot] Format /streams" -m "Committed by [iptv-bot](https://github.com/apps/iptv-bot) via [format](https://github.com/iptv-org/iptv/actions/runs/${{ github.run_id }}) workflow." --no-verify
- name: push all changes to the repository
if: ${{ !env.ACT && github.ref == 'refs/heads/master' && steps.files_after.outputs.any_changed == 'true' }}
run: git push
on_merge:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: tibdex/github-app-token@v1.8.2
if: ${{ !env.ACT }}
id: create-app-token
with:
app_id: ${{ secrets.APP_ID }}
private_key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
if: ${{ !env.ACT }}
with:
token: ${{ steps.create-app-token.outputs.token }}
- name: changed files
id: files
run: |
ANY_CHANGED=false
ALL_CHANGED_FILES=$(git diff --name-only master -- streams/ | tr '\n' ' ')
if [ -n "${ALL_CHANGED_FILES}" ]; then
ANY_CHANGED=true
fi
echo "all_changed_files=$ALL_CHANGED_FILES" >> "$GITHUB_OUTPUT"
echo "any_changed=$ANY_CHANGED" >> "$GITHUB_OUTPUT"
- uses: actions/setup-node@v4
if: ${{ steps.files.outputs.any_changed == 'true' }}
with:
node-version: 22
cache: 'npm'
- name: setup git
if: steps.files.outputs.any_changed == 'true'
- name: Setup git
run: |
git config user.name "iptv-bot[bot]"
git config user.email "84861620+iptv-bot[bot]@users.noreply.github.com"
- name: install dependencies
if: steps.files.outputs.any_changed == 'true'
run: npm install
- name: format internal playlists
if: steps.files.outputs.any_changed == 'true'
run: npm run playlist:format -- ${{ steps.files.outputs.all_changed_files }}
- name: check internal playlists
if: steps.files.outputs.any_changed == 'true'
run: |
npm run playlist:lint -- ${{ steps.files.outputs.all_changed_files }}
npm run playlist:validate -- ${{ steps.files.outputs.all_changed_files }}
- name: git status
if: steps.files.outputs.any_changed == 'true'
run: git status
- name: changed files
id: files_after
run: |
ANY_CHANGED=false
ALL_CHANGED_FILES=$(git diff --name-only master -- streams/ | tr '\n' ' ')
if [ -n "${ALL_CHANGED_FILES}" ]; then
ANY_CHANGED=true
fi
echo "all_changed_files=$ALL_CHANGED_FILES" >> "$GITHUB_OUTPUT"
echo "any_changed=$ANY_CHANGED" >> "$GITHUB_OUTPUT"
- name: commit changes
- name: Commit changes to /streams
if: steps.files_after.outputs.any_changed == 'true'
run: |
git add streams
git status
git commit -m "[Bot] Format /streams" -m "Committed by [iptv-bot](https://github.com/apps/iptv-bot) via [format](https://github.com/iptv-org/iptv/actions/runs/${{ github.run_id }}) workflow." --no-verify
- name: push all changes to the repository
- name: Push all changes to the repository
if: ${{ !env.ACT && github.ref == 'refs/heads/master' && steps.files_after.outputs.any_changed == 'true' }}
run: git push
+1
View File
@@ -4,6 +4,7 @@ on:
schedule:
- cron: '0 0 * * *'
permissions:
actions: write
issues: write
jobs:
stale:
+24 -21
View File
@@ -7,56 +7,59 @@ jobs:
main:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: tibdex/github-app-token@v1.8.2
if: ${{ !env.ACT }}
id: create-app-token
with:
app_id: ${{ secrets.APP_ID }}
private_key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/checkout@v3
- uses: actions/checkout@v4
if: ${{ !env.ACT }}
with:
token: ${{ steps.create-app-token.outputs.token }}
- name: setup git
run: |
git config user.name "iptv-bot[bot]"
git config user.email "84861620+iptv-bot[bot]@users.noreply.github.com"
- uses: actions/setup-node@v3
if: ${{ !env.ACT }}
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: 'npm'
- name: install dependencies
- name: Setup .npmrc for GitHub Packages
run: |
echo "//npm.pkg.github.com/:_authToken=$GITHUB_TOKEN" >> .npmrc
echo "@iptv-org:registry=https://npm.pkg.github.com/" >> .npmrc
echo "always-auth=true" >> .npmrc
- name: Install dependencies
run: npm install
- name: update internal playlists
- name: Update internal playlists
run: npm run playlist:update --silent >> $GITHUB_OUTPUT
id: playlist-update
- name: check internal playlists
- name: Check internal playlists
run: |
npm run playlist:lint
npm run playlist:validate
- name: generate public playlists
- name: Generate public playlists
run: npm run playlist:generate
- name: generate .api/streams.json
- name: Generate .api/streams.json
run: npm run api:generate
- name: update readme
- name: Update readme
run: npm run readme:update
- run: git status
- name: commit changes to /streams
- name: Setup git
run: |
git config user.name "iptv-bot[bot]"
git config user.email "84861620+iptv-bot[bot]@users.noreply.github.com"
- name: Commit changes to /streams
run: |
git add streams
git status
git commit --allow-empty -m "[Bot] Update /streams" -m "Committed by [iptv-bot](https://github.com/apps/iptv-bot) via [update](https://github.com/iptv-org/iptv/actions/runs/${{ github.run_id }}) workflow." -m "${{ steps.playlist-update.outputs.OUTPUT }}" --no-verify
- name: commit changes to playlists.md
- name: Commit changes to PLAYLIST.md
run: |
git add PLAYLISTS.md
git status
git commit --allow-empty -m "[Bot] Update PLAYLISTS.md" -m "Committed by [iptv-bot](https://github.com/apps/iptv-bot) via [update](https://github.com/iptv-org/iptv/actions/runs/${{ github.run_id }}) workflow." --no-verify
- name: push all changes to the repository
- name: Push all changes to the repository
if: ${{ !env.ACT && github.ref == 'refs/heads/master' }}
run: git push
- name: deploy public playlists to github pages
- name: Deploy public playlists to GitHub Pages
uses: JamesIves/github-pages-deploy-action@4.1.1
if: ${{ !env.ACT && github.ref == 'refs/heads/master' }}
with:
@@ -68,7 +71,7 @@ jobs:
git-config-email: 84861620+iptv-bot[bot]@users.noreply.github.com
commit-message: '[Bot] Deploy to GitHub Pages'
clean: true
- name: move .api/streams.json to iptv-org/api
- name: Move .api/streams.json to iptv-org/api
uses: JamesIves/github-pages-deploy-action@4.1.1
if: ${{ !env.ACT && github.ref == 'refs/heads/master' }}
with:
+85 -58
View File
@@ -22,37 +22,37 @@ Same thing, but split up into separate files:
<tr><th align="left">Category</th><th align="left">Channels</th><th align="left">Playlist</th></tr>
</thead>
<tbody>
<tr><td>Animation</td><td align="right">56</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/animation.m3u</code></td></tr>
<tr><td>Auto</td><td align="right">18</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/auto.m3u</code></td></tr>
<tr><td>Business</td><td align="right">68</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/business.m3u</code></td></tr>
<tr><td>Animation</td><td align="right">60</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/animation.m3u</code></td></tr>
<tr><td>Auto</td><td align="right">19</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/auto.m3u</code></td></tr>
<tr><td>Business</td><td align="right">67</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/business.m3u</code></td></tr>
<tr><td>Classic</td><td align="right">45</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/classic.m3u</code></td></tr>
<tr><td>Comedy</td><td align="right">84</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/comedy.m3u</code></td></tr>
<tr><td>Cooking</td><td align="right">35</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/cooking.m3u</code></td></tr>
<tr><td>Culture</td><td align="right">165</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/culture.m3u</code></td></tr>
<tr><td>Documentary</td><td align="right">123</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/documentary.m3u</code></td></tr>
<tr><td>Education</td><td align="right">176</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/education.m3u</code></td></tr>
<tr><td>Entertainment</td><td align="right">620</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/entertainment.m3u</code></td></tr>
<tr><td>Family</td><td align="right">53</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/family.m3u</code></td></tr>
<tr><td>General</td><td align="right">2375</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/general.m3u</code></td></tr>
<tr><td>Cooking</td><td align="right">37</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/cooking.m3u</code></td></tr>
<tr><td>Culture</td><td align="right">170</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/culture.m3u</code></td></tr>
<tr><td>Documentary</td><td align="right">127</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/documentary.m3u</code></td></tr>
<tr><td>Education</td><td align="right">175</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/education.m3u</code></td></tr>
<tr><td>Entertainment</td><td align="right">644</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/entertainment.m3u</code></td></tr>
<tr><td>Family</td><td align="right">54</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/family.m3u</code></td></tr>
<tr><td>General</td><td align="right">2461</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/general.m3u</code></td></tr>
<tr><td>Interactive</td><td align="right">1</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/interactive.m3u</code></td></tr>
<tr><td>Kids</td><td align="right">267</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/kids.m3u</code></td></tr>
<tr><td>Legislative</td><td align="right">187</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/legislative.m3u</code></td></tr>
<tr><td>Lifestyle</td><td align="right">104</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/lifestyle.m3u</code></td></tr>
<tr><td>Movies</td><td align="right">372</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/movies.m3u</code></td></tr>
<tr><td>Music</td><td align="right">632</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/music.m3u</code></td></tr>
<tr><td>News</td><td align="right">813</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/news.m3u</code></td></tr>
<tr><td>Outdoor</td><td align="right">45</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/outdoor.m3u</code></td></tr>
<tr><td>Public</td><td align="right">26</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/public.m3u</code></td></tr>
<tr><td>Relax</td><td align="right">4</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/relax.m3u</code></td></tr>
<tr><td>Religious</td><td align="right">684</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/religious.m3u</code></td></tr>
<tr><td>Kids</td><td align="right">272</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/kids.m3u</code></td></tr>
<tr><td>Legislative</td><td align="right">189</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/legislative.m3u</code></td></tr>
<tr><td>Lifestyle</td><td align="right">109</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/lifestyle.m3u</code></td></tr>
<tr><td>Movies</td><td align="right">379</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/movies.m3u</code></td></tr>
<tr><td>Music</td><td align="right">639</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/music.m3u</code></td></tr>
<tr><td>News</td><td align="right">848</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/news.m3u</code></td></tr>
<tr><td>Outdoor</td><td align="right">43</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/outdoor.m3u</code></td></tr>
<tr><td>Public</td><td align="right">30</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/public.m3u</code></td></tr>
<tr><td>Relax</td><td align="right">3</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/relax.m3u</code></td></tr>
<tr><td>Religious</td><td align="right">725</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/religious.m3u</code></td></tr>
<tr><td>Science</td><td align="right">25</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/science.m3u</code></td></tr>
<tr><td>Series</td><td align="right">256</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/series.m3u</code></td></tr>
<tr><td>Shop</td><td align="right">85</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/shop.m3u</code></td></tr>
<tr><td>Sports</td><td align="right">294</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/sports.m3u</code></td></tr>
<tr><td>Travel</td><td align="right">41</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/travel.m3u</code></td></tr>
<tr><td>Series</td><td align="right">266</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/series.m3u</code></td></tr>
<tr><td>Shop</td><td align="right">89</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/shop.m3u</code></td></tr>
<tr><td>Sports</td><td align="right">305</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/sports.m3u</code></td></tr>
<tr><td>Travel</td><td align="right">42</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/travel.m3u</code></td></tr>
<tr><td>Weather</td><td align="right">14</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/weather.m3u</code></td></tr>
<tr><td>XXX</td><td align="right">0</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/xxx.m3u</code></td></tr>
<tr><td>Undefined</td><td align="right">3692</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/undefined.m3u</code></td></tr>
<tr><td>Undefined</td><td align="right">3696</td><td nowrap><code>https://iptv-org.github.io/iptv/categories/undefined.m3u</code></td></tr>
</tbody>
</table>
@@ -81,13 +81,13 @@ Same thing, but split up into separate files:
<tr><td align="left">Acoli</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/ach.m3u</code></td></tr>
<tr><td align="left">Adhola</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/adh.m3u</code></td></tr>
<tr><td align="left">Afar</td><td align="right">2</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/aar.m3u</code></td></tr>
<tr><td align="left">Afrikaans</td><td align="right">3</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/afr.m3u</code></td></tr>
<tr><td align="left">Afrikaans</td><td align="right">5</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/afr.m3u</code></td></tr>
<tr><td align="left">Albanian</td><td align="right">42</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/sqi.m3u</code></td></tr>
<tr><td align="left">Algerian Sign Language</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/asp.m3u</code></td></tr>
<tr><td align="left">Alur</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/alz.m3u</code></td></tr>
<tr><td align="left">Amharic</td><td align="right">6</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/amh.m3u</code></td></tr>
<tr><td align="left">Arabic</td><td align="right">400</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/ara.m3u</code></td></tr>
<tr><td align="left">Armenian</td><td align="right">8</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/hye.m3u</code></td></tr>
<tr><td align="left">Amharic</td><td align="right">8</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/amh.m3u</code></td></tr>
<tr><td align="left">Arabic</td><td align="right">454</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/ara.m3u</code></td></tr>
<tr><td align="left">Armenian</td><td align="right">7</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/hye.m3u</code></td></tr>
<tr><td align="left">Assamese</td><td align="right">9</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/asm.m3u</code></td></tr>
<tr><td align="left">Assyrian Neo-Aramaic</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/aii.m3u</code></td></tr>
<tr><td align="left">Ayizo Gbe</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/ayb.m3u</code></td></tr>
@@ -103,15 +103,16 @@ Same thing, but split up into separate files:
<tr><td align="left">Bisa</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/bib.m3u</code></td></tr>
<tr><td align="left">Bosnian</td><td align="right">14</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/bos.m3u</code></td></tr>
<tr><td align="left">Buamu</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/box.m3u</code></td></tr>
<tr><td align="left">Bulgarian</td><td align="right">23</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/bul.m3u</code></td></tr>
<tr><td align="left">Bulgarian</td><td align="right">24</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/bul.m3u</code></td></tr>
<tr><td align="left">Burmese</td><td align="right">6</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/mya.m3u</code></td></tr>
<tr><td align="left">Catalan</td><td align="right">55</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/cat.m3u</code></td></tr>
<tr><td align="left">Central Atlas Tamazight</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/tzm.m3u</code></td></tr>
<tr><td align="left">Central Kurdish</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/ckb.m3u</code></td></tr>
<tr><td align="left">Chenoua</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/cnu.m3u</code></td></tr>
<tr><td align="left">Chewa</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/nya.m3u</code></td></tr>
<tr><td align="left">Chhattisgarhi</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/hne.m3u</code></td></tr>
<tr><td align="left">Chiga</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/cgg.m3u</code></td></tr>
<tr><td align="left">Chinese</td><td align="right">214</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/zho.m3u</code></td></tr>
<tr><td align="left">Chinese</td><td align="right">215</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/zho.m3u</code></td></tr>
<tr><td align="left">Croatian</td><td align="right">27</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/hrv.m3u</code></td></tr>
<tr><td align="left">Czech</td><td align="right">38</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/ces.m3u</code></td></tr>
<tr><td align="left">Danish</td><td align="right">12</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/dan.m3u</code></td></tr>
@@ -124,21 +125,22 @@ Same thing, but split up into separate files:
<tr><td align="left">Dutch</td><td align="right">181</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/nld.m3u</code></td></tr>
<tr><td align="left">Dyula</td><td align="right">2</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/dyu.m3u</code></td></tr>
<tr><td align="left">Egyptian Arabic</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/arz.m3u</code></td></tr>
<tr><td align="left">English</td><td align="right">2311</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/eng.m3u</code></td></tr>
<tr><td align="left">Estonian</td><td align="right">8</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/est.m3u</code></td></tr>
<tr><td align="left">English</td><td align="right">2414</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/eng.m3u</code></td></tr>
<tr><td align="left">Estonian</td><td align="right">9</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/est.m3u</code></td></tr>
<tr><td align="left">Ewe</td><td align="right">2</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/ewe.m3u</code></td></tr>
<tr><td align="left">Faroese</td><td align="right">3</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/fao.m3u</code></td></tr>
<tr><td align="left">Fataleka</td><td align="right">2</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/far.m3u</code></td></tr>
<tr><td align="left">Filipino</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/fil.m3u</code></td></tr>
<tr><td align="left">Finnish</td><td align="right">16</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/fin.m3u</code></td></tr>
<tr><td align="left">Fon</td><td align="right">3</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/fon.m3u</code></td></tr>
<tr><td align="left">French</td><td align="right">476</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/fra.m3u</code></td></tr>
<tr><td align="left">French</td><td align="right">532</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/fra.m3u</code></td></tr>
<tr><td align="left">Fulah</td><td align="right">3</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/ful.m3u</code></td></tr>
<tr><td align="left">Gaelic</td><td align="right">2</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/gla.m3u</code></td></tr>
<tr><td align="left">Ganda</td><td align="right">5</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/lug.m3u</code></td></tr>
<tr><td align="left">Galician</td><td align="right">2</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/glg.m3u</code></td></tr>
<tr><td align="left">Ganda</td><td align="right">12</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/lug.m3u</code></td></tr>
<tr><td align="left">Gen</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/gej.m3u</code></td></tr>
<tr><td align="left">Georgian</td><td align="right">22</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/kat.m3u</code></td></tr>
<tr><td align="left">German</td><td align="right">331</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/deu.m3u</code></td></tr>
<tr><td align="left">German</td><td align="right">333</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/deu.m3u</code></td></tr>
<tr><td align="left">Gikuyu</td><td align="right">3</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/kik.m3u</code></td></tr>
<tr><td align="left">Goan Konkani</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/gom.m3u</code></td></tr>
<tr><td align="left">Gourmanchéma</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/gux.m3u</code></td></tr>
@@ -147,17 +149,17 @@ Same thing, but split up into separate files:
<tr><td align="left">Gujarati</td><td align="right">10</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/guj.m3u</code></td></tr>
<tr><td align="left">Gun</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/guw.m3u</code></td></tr>
<tr><td align="left">Haitian</td><td align="right">13</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/hat.m3u</code></td></tr>
<tr><td align="left">Hausa</td><td align="right">2</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/hau.m3u</code></td></tr>
<tr><td align="left">Hausa</td><td align="right">8</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/hau.m3u</code></td></tr>
<tr><td align="left">Hebrew</td><td align="right">15</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/heb.m3u</code></td></tr>
<tr><td align="left">Hindi</td><td align="right">214</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/hin.m3u</code></td></tr>
<tr><td align="left">Hmong</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/hmn.m3u</code></td></tr>
<tr><td align="left">Hungarian</td><td align="right">103</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/hun.m3u</code></td></tr>
<tr><td align="left">Icelandic</td><td align="right">5</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/isl.m3u</code></td></tr>
<tr><td align="left">Indonesian</td><td align="right">136</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/ind.m3u</code></td></tr>
<tr><td align="left">Indonesian</td><td align="right">141</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/ind.m3u</code></td></tr>
<tr><td align="left">Inuktitut</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/iku.m3u</code></td></tr>
<tr><td align="left">Irish</td><td align="right">3</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/gle.m3u</code></td></tr>
<tr><td align="left">Isekiri</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/its.m3u</code></td></tr>
<tr><td align="left">Italian</td><td align="right">345</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/ita.m3u</code></td></tr>
<tr><td align="left">Italian</td><td align="right">357</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/ita.m3u</code></td></tr>
<tr><td align="left">Japanese</td><td align="right">22</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/jpn.m3u</code></td></tr>
<tr><td align="left">Javanese</td><td align="right">3</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/jav.m3u</code></td></tr>
<tr><td align="left">Kabiyè</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/kbp.m3u</code></td></tr>
@@ -169,6 +171,7 @@ Same thing, but split up into separate files:
<tr><td align="left">Khorasani Turkish</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/kmz.m3u</code></td></tr>
<tr><td align="left">Kinyarwanda</td><td align="right">9</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/kin.m3u</code></td></tr>
<tr><td align="left">Kirghiz</td><td align="right">13</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/kir.m3u</code></td></tr>
<tr><td align="left">Kiswahili</td><td align="right">2</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/swh.m3u</code></td></tr>
<tr><td align="left">Kituba (Congo)</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/mkw.m3u</code></td></tr>
<tr><td align="left">Konabéré</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/bbo.m3u</code></td></tr>
<tr><td align="left">Kongo</td><td align="right">2</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/kon.m3u</code></td></tr>
@@ -181,18 +184,21 @@ Same thing, but split up into separate files:
<tr><td align="left">Latin</td><td align="right">4</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/lat.m3u</code></td></tr>
<tr><td align="left">Latvian</td><td align="right">7</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/lav.m3u</code></td></tr>
<tr><td align="left">Letzeburgesch</td><td align="right">12</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/ltz.m3u</code></td></tr>
<tr><td align="left">Lingala</td><td align="right">5</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/lin.m3u</code></td></tr>
<tr><td align="left">Lingala</td><td align="right">6</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/lin.m3u</code></td></tr>
<tr><td align="left">Lithuanian</td><td align="right">5</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/lit.m3u</code></td></tr>
<tr><td align="left">Lobi</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/lob.m3u</code></td></tr>
<tr><td align="left">Luba-Lulua</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/lua.m3u</code></td></tr>
<tr><td align="left">Lusengo</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/lse.m3u</code></td></tr>
<tr><td align="left">Lushai</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/lus.m3u</code></td></tr>
<tr><td align="left">Lyélé</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/lee.m3u</code></td></tr>
<tr><td align="left">Macedonian</td><td align="right">33</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/mkd.m3u</code></td></tr>
<tr><td align="left">Maithili</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/mai.m3u</code></td></tr>
<tr><td align="left">Malagasy</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/mlg.m3u</code></td></tr>
<tr><td align="left">Malay</td><td align="right">17</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/msa.m3u</code></td></tr>
<tr><td align="left">Malayalam</td><td align="right">83</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/mal.m3u</code></td></tr>
<tr><td align="left">Maltese</td><td align="right">5</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/mlt.m3u</code></td></tr>
<tr><td align="left">Mandarin Chinese</td><td align="right">6</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/cmn.m3u</code></td></tr>
<tr><td align="left">Manding</td><td align="right">2</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/man.m3u</code></td></tr>
<tr><td align="left">Mandinka</td><td align="right">3</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/mnk.m3u</code></td></tr>
<tr><td align="left">Maori</td><td align="right">2</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/mri.m3u</code></td></tr>
<tr><td align="left">Marathi</td><td align="right">21</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/mar.m3u</code></td></tr>
@@ -204,39 +210,46 @@ Same thing, but split up into separate files:
<tr><td align="left">Montenegrin</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/cnr.m3u</code></td></tr>
<tr><td align="left">Morisyen</td><td align="right">3</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/mfe.m3u</code></td></tr>
<tr><td align="left">Moroccan Sign Language</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/xms.m3u</code></td></tr>
<tr><td align="left">Mossi</td><td align="right">2</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/mos.m3u</code></td></tr>
<tr><td align="left">Mossi</td><td align="right">3</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/mos.m3u</code></td></tr>
<tr><td align="left">Mycenaean Greek</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/gmy.m3u</code></td></tr>
<tr><td align="left">Nepali</td><td align="right">7</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/nep.m3u</code></td></tr>
<tr><td align="left">North Ndebele</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/nde.m3u</code></td></tr>
<tr><td align="left">Northern Dagara</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/dgi.m3u</code></td></tr>
<tr><td align="left">Norwegian</td><td align="right">9</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/nor.m3u</code></td></tr>
<tr><td align="left">Nyankole</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/nyn.m3u</code></td></tr>
<tr><td align="left">Nyoro</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/nyo.m3u</code></td></tr>
<tr><td align="left">Oriya (macrolanguage)</td><td align="right">14</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/ori.m3u</code></td></tr>
<tr><td align="left">Oromo</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/orm.m3u</code></td></tr>
<tr><td align="left">Panjabi</td><td align="right">30</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/pan.m3u</code></td></tr>
<tr><td align="left">Papiamento</td><td align="right">17</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/pap.m3u</code></td></tr>
<tr><td align="left">Pashto</td><td align="right">28</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/pus.m3u</code></td></tr>
<tr><td align="left">Persian</td><td align="right">226</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/fas.m3u</code></td></tr>
<tr><td align="left">Polish</td><td align="right">67</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/pol.m3u</code></td></tr>
<tr><td align="left">Portuguese</td><td align="right">252</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/por.m3u</code></td></tr>
<tr><td align="left">Pulaar</td><td align="right">4</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/fuc.m3u</code></td></tr>
<tr><td align="left">Persian</td><td align="right">225</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/fas.m3u</code></td></tr>
<tr><td align="left">Polish</td><td align="right">70</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/pol.m3u</code></td></tr>
<tr><td align="left">Portuguese</td><td align="right">255</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/por.m3u</code></td></tr>
<tr><td align="left">Pulaar</td><td align="right">6</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/fuc.m3u</code></td></tr>
<tr><td align="left">Pular</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/fuf.m3u</code></td></tr>
<tr><td align="left">Quechua</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/que.m3u</code></td></tr>
<tr><td align="left">Romanian</td><td align="right">105</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/ron.m3u</code></td></tr>
<tr><td align="left">Russian</td><td align="right">370</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/rus.m3u</code></td></tr>
<tr><td align="left">Romanian</td><td align="right">98</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/ron.m3u</code></td></tr>
<tr><td align="left">Rundi</td><td align="right">2</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/run.m3u</code></td></tr>
<tr><td align="left">Russian</td><td align="right">354</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/rus.m3u</code></td></tr>
<tr><td align="left">Saint Lucian Creole French</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/acf.m3u</code></td></tr>
<tr><td align="left">Samoan</td><td align="right">2</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/smo.m3u</code></td></tr>
<tr><td align="left">Sango</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/sag.m3u</code></td></tr>
<tr><td align="left">Santali</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/sat.m3u</code></td></tr>
<tr><td align="left">Serbian</td><td align="right">52</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/srp.m3u</code></td></tr>
<tr><td align="left">Serbo-Croatian</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/hbs.m3u</code></td></tr>
<tr><td align="left">Shona</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/sna.m3u</code></td></tr>
<tr><td align="left">Sinhala</td><td align="right">13</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/sin.m3u</code></td></tr>
<tr><td align="left">Slovak</td><td align="right">47</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/slk.m3u</code></td></tr>
<tr><td align="left">Slovenian</td><td align="right">13</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/slv.m3u</code></td></tr>
<tr><td align="left">Somali</td><td align="right">15</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/som.m3u</code></td></tr>
<tr><td align="left">Somali</td><td align="right">17</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/som.m3u</code></td></tr>
<tr><td align="left">Soninke</td><td align="right">2</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/snk.m3u</code></td></tr>
<tr><td align="left">South African Sign Language</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/sfs.m3u</code></td></tr>
<tr><td align="left">South Ndebele</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/nbl.m3u</code></td></tr>
<tr><td align="left">Southern Samo</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/sbd.m3u</code></td></tr>
<tr><td align="left">Spanish</td><td align="right">1709</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/spa.m3u</code></td></tr>
<tr><td align="left">Spanish</td><td align="right">1752</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/spa.m3u</code></td></tr>
<tr><td align="left">Standard Arabic</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/arb.m3u</code></td></tr>
<tr><td align="left">Swahili</td><td align="right">19</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/swa.m3u</code></td></tr>
<tr><td align="left">Swahili</td><td align="right">38</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/swa.m3u</code></td></tr>
<tr><td align="left">Swati</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/ssw.m3u</code></td></tr>
<tr><td align="left">Swedish</td><td align="right">12</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/swe.m3u</code></td></tr>
<tr><td align="left">Tachawit</td><td align="right">2</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/shy.m3u</code></td></tr>
@@ -246,7 +259,7 @@ Same thing, but split up into separate files:
<tr><td align="left">Tajik</td><td align="right">15</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/tgk.m3u</code></td></tr>
<tr><td align="left">Tamashek</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/tmh.m3u</code></td></tr>
<tr><td align="left">Tamasheq</td><td align="right">2</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/taq.m3u</code></td></tr>
<tr><td align="left">Tamil</td><td align="right">93</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/tam.m3u</code></td></tr>
<tr><td align="left">Tamil</td><td align="right">94</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/tam.m3u</code></td></tr>
<tr><td align="left">Tarifit</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/rif.m3u</code></td></tr>
<tr><td align="left">Tatar</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/tat.m3u</code></td></tr>
<tr><td align="left">Telugu</td><td align="right">54</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/tel.m3u</code></td></tr>
@@ -256,26 +269,28 @@ Same thing, but split up into separate files:
<tr><td align="left">Tigrinya</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/tir.m3u</code></td></tr>
<tr><td align="left">Tooro</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/ttj.m3u</code></td></tr>
<tr><td align="left">Tsonga</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/tso.m3u</code></td></tr>
<tr><td align="left">Tswana</td><td align="right">4</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/tsn.m3u</code></td></tr>
<tr><td align="left">Tumzabt</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/mzb.m3u</code></td></tr>
<tr><td align="left">Turkish</td><td align="right">263</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/tur.m3u</code></td></tr>
<tr><td align="left">Turkish</td><td align="right">233</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/tur.m3u</code></td></tr>
<tr><td align="left">Turkmen</td><td align="right">8</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/tuk.m3u</code></td></tr>
<tr><td align="left">Uighur</td><td align="right">3</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/uig.m3u</code></td></tr>
<tr><td align="left">Ukrainian</td><td align="right">58</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/ukr.m3u</code></td></tr>
<tr><td align="left">Urdu</td><td align="right">53</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/urd.m3u</code></td></tr>
<tr><td align="left">Ukrainian</td><td align="right">72</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/ukr.m3u</code></td></tr>
<tr><td align="left">Urdu</td><td align="right">55</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/urd.m3u</code></td></tr>
<tr><td align="left">Uzbek</td><td align="right">24</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/uzb.m3u</code></td></tr>
<tr><td align="left">Venda</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/ven.m3u</code></td></tr>
<tr><td align="left">Vietnamese</td><td align="right">82</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/vie.m3u</code></td></tr>
<tr><td align="left">Welsh</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/cym.m3u</code></td></tr>
<tr><td align="left">Western Frisian</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/fry.m3u</code></td></tr>
<tr><td align="left">Wolof</td><td align="right">11</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/wol.m3u</code></td></tr>
<tr><td align="left">Western Niger Fulfulde</td><td align="right">4</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/fuh.m3u</code></td></tr>
<tr><td align="left">Wolof</td><td align="right">16</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/wol.m3u</code></td></tr>
<tr><td align="left">Xhosa</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/xho.m3u</code></td></tr>
<tr><td align="left">Yakut</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/sah.m3u</code></td></tr>
<tr><td align="left">Yoruba</td><td align="right">2</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/yor.m3u</code></td></tr>
<tr><td align="left">Yucatec Maya</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/yua.m3u</code></td></tr>
<tr><td align="left">Yue Chinese</td><td align="right">5</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/yue.m3u</code></td></tr>
<tr><td align="left">Zarma</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/dje.m3u</code></td></tr>
<tr><td align="left">Zarma</td><td align="right">4</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/dje.m3u</code></td></tr>
<tr><td align="left">Zulu</td><td align="right">1</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/zul.m3u</code></td></tr>
<tr><td align="left">Undefined</td><td align="right">2181</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/undefined.m3u</code></td></tr>
<tr><td align="left">Undefined</td><td align="right">2176</td><td align="left" nowrap><code>https://iptv-org.github.io/iptv/languages/undefined.m3u</code></td></tr>
</tbody>
</table>
@@ -386,6 +401,7 @@ Same thing, but split up into separate files:
- Santa Cruz de la Sierra <code>https://iptv-org.github.io/iptv/cities/boscs.m3u</code>
- 🇧🇶 Bonaire <code>https://iptv-org.github.io/iptv/countries/bq.m3u</code>
- 🇧🇦 Bosnia and Herzegovina <code>https://iptv-org.github.io/iptv/countries/ba.m3u</code>
- 🇧🇼 Botswana <code>https://iptv-org.github.io/iptv/countries/bw.m3u</code>
- 🇧🇷 Brazil <code>https://iptv-org.github.io/iptv/countries/br.m3u</code>
- Alagoas <code>https://iptv-org.github.io/iptv/subdivisions/br-al.m3u</code>
- Maceió <code>https://iptv-org.github.io/iptv/cities/brmcz.m3u</code>
@@ -444,6 +460,7 @@ Same thing, but split up into separate files:
- 🇧🇳 Brunei <code>https://iptv-org.github.io/iptv/countries/bn.m3u</code>
- 🇧🇬 Bulgaria <code>https://iptv-org.github.io/iptv/countries/bg.m3u</code>
- 🇧🇫 Burkina Faso <code>https://iptv-org.github.io/iptv/countries/bf.m3u</code>
- 🇧🇮 Burundi <code>https://iptv-org.github.io/iptv/countries/bi.m3u</code>
- 🇰🇭 Cambodia <code>https://iptv-org.github.io/iptv/countries/kh.m3u</code>
- 🇨🇲 Cameroon <code>https://iptv-org.github.io/iptv/countries/cm.m3u</code>
- 🇨🇦 Canada <code>https://iptv-org.github.io/iptv/countries/ca.m3u</code>
@@ -485,8 +502,11 @@ Same thing, but split up into separate files:
- Saskatchewan <code>https://iptv-org.github.io/iptv/subdivisions/ca-sk.m3u</code>
- Regina <code>https://iptv-org.github.io/iptv/cities/careg.m3u</code>
- 🇨🇻 Cape Verde <code>https://iptv-org.github.io/iptv/countries/cv.m3u</code>
- 🇨🇫 Central African Republic <code>https://iptv-org.github.io/iptv/countries/cf.m3u</code>
- 🇹🇩 Chad <code>https://iptv-org.github.io/iptv/countries/td.m3u</code>
- 🇨🇱 Chile <code>https://iptv-org.github.io/iptv/countries/cl.m3u</code>
- Biobio <code>https://iptv-org.github.io/iptv/subdivisions/cl-bi.m3u</code>
- Yumbel <code>https://iptv-org.github.io/iptv/cities/clyum.m3u</code>
- La Araucania <code>https://iptv-org.github.io/iptv/subdivisions/cl-ar.m3u</code>
- Libertador General Bernardo O'Higgins <code>https://iptv-org.github.io/iptv/subdivisions/cl-li.m3u</code>
- Pichilemu <code>https://iptv-org.github.io/iptv/cities/clplm.m3u</code>
@@ -510,6 +530,7 @@ Same thing, but split up into separate files:
- San Andrés <code>https://iptv-org.github.io/iptv/cities/cosac.m3u</code>
- Valle del Cauca <code>https://iptv-org.github.io/iptv/subdivisions/co-vac.m3u</code>
- Cali <code>https://iptv-org.github.io/iptv/cities/coclo.m3u</code>
- 🇰🇲 Comoros <code>https://iptv-org.github.io/iptv/countries/km.m3u</code>
- 🇨🇷 Costa Rica <code>https://iptv-org.github.io/iptv/countries/cr.m3u</code>
- Cartago <code>https://iptv-org.github.io/iptv/subdivisions/cr-c.m3u</code>
- Puntarenas <code>https://iptv-org.github.io/iptv/subdivisions/cr-p.m3u</code>
@@ -707,12 +728,15 @@ Same thing, but split up into separate files:
- 🇱🇹 Lithuania <code>https://iptv-org.github.io/iptv/countries/lt.m3u</code>
- 🇱🇺 Luxembourg <code>https://iptv-org.github.io/iptv/countries/lu.m3u</code>
- 🇲🇴 Macao <code>https://iptv-org.github.io/iptv/countries/mo.m3u</code>
- 🇲🇬 Madagascar <code>https://iptv-org.github.io/iptv/countries/mg.m3u</code>
- 🇲🇼 Malawi <code>https://iptv-org.github.io/iptv/countries/mw.m3u</code>
- 🇲🇾 Malaysia <code>https://iptv-org.github.io/iptv/countries/my.m3u</code>
- 🇲🇻 Maldives <code>https://iptv-org.github.io/iptv/countries/mv.m3u</code>
- 🇲🇱 Mali <code>https://iptv-org.github.io/iptv/countries/ml.m3u</code>
- 🇲🇹 Malta <code>https://iptv-org.github.io/iptv/countries/mt.m3u</code>
- 🇲🇶 Martinique <code>https://iptv-org.github.io/iptv/countries/mq.m3u</code>
- 🇲🇷 Mauritania <code>https://iptv-org.github.io/iptv/countries/mr.m3u</code>
- West Nouakchott <code>https://iptv-org.github.io/iptv/subdivisions/mr-13.m3u</code>
- 🇲🇺 Mauritius <code>https://iptv-org.github.io/iptv/countries/mu.m3u</code>
- 🇲🇽 Mexico <code>https://iptv-org.github.io/iptv/countries/mx.m3u</code>
- Baja California <code>https://iptv-org.github.io/iptv/subdivisions/mx-bcn.m3u</code>
@@ -761,7 +785,6 @@ Same thing, but split up into separate files:
- Itapua <code>https://iptv-org.github.io/iptv/subdivisions/py-7.m3u</code>
- Encarnacion <code>https://iptv-org.github.io/iptv/cities/pyeno.m3u</code>
- 🇵🇪 Peru <code>https://iptv-org.github.io/iptv/countries/pe.m3u</code>
- Apurimac <code>https://iptv-org.github.io/iptv/subdivisions/pe-apu.m3u</code>
- Arequipa <code>https://iptv-org.github.io/iptv/subdivisions/pe-are.m3u</code>
- Arequipa <code>https://iptv-org.github.io/iptv/cities/peaqp.m3u</code>
- Junin <code>https://iptv-org.github.io/iptv/subdivisions/pe-jun.m3u</code>
@@ -844,6 +867,7 @@ Same thing, but split up into separate files:
- 🇸🇦 Saudi Arabia <code>https://iptv-org.github.io/iptv/countries/sa.m3u</code>
- 🇸🇳 Senegal <code>https://iptv-org.github.io/iptv/countries/sn.m3u</code>
- 🇷🇸 Serbia <code>https://iptv-org.github.io/iptv/countries/rs.m3u</code>
- 🇸🇱 Sierra Leone <code>https://iptv-org.github.io/iptv/countries/sl.m3u</code>
- 🇸🇬 Singapore <code>https://iptv-org.github.io/iptv/countries/sg.m3u</code>
- 🇸🇽 Sint Maarten <code>https://iptv-org.github.io/iptv/countries/sx.m3u</code>
- 🇸🇰 Slovakia <code>https://iptv-org.github.io/iptv/countries/sk.m3u</code>
@@ -877,6 +901,8 @@ Same thing, but split up into separate files:
- Ulsan-gwangyeoksi <code>https://iptv-org.github.io/iptv/subdivisions/kr-31.m3u</code>
- Ulsan <code>https://iptv-org.github.io/iptv/cities/krusn.m3u</code>
- 🇪🇸 Spain <code>https://iptv-org.github.io/iptv/countries/es.m3u</code>
- A Coruña <code>https://iptv-org.github.io/iptv/subdivisions/es-c.m3u</code>
- Santiago de Compostela <code>https://iptv-org.github.io/iptv/cities/esscq.m3u</code>
- Andalucía <code>https://iptv-org.github.io/iptv/subdivisions/es-an.m3u</code>
- Aragón <code>https://iptv-org.github.io/iptv/subdivisions/es-ar.m3u</code>
- Barcelona <code>https://iptv-org.github.io/iptv/subdivisions/es-b.m3u</code>
@@ -1264,6 +1290,7 @@ Same thing, but split up into separate files:
- 🇻🇳 Vietnam <code>https://iptv-org.github.io/iptv/countries/vn.m3u</code>
- 🇪🇭 Western Sahara <code>https://iptv-org.github.io/iptv/countries/eh.m3u</code>
- 🇾🇪 Yemen <code>https://iptv-org.github.io/iptv/countries/ye.m3u</code>
- 🇿🇲 Zambia <code>https://iptv-org.github.io/iptv/countries/zm.m3u</code>
- 🇿🇼 Zimbabwe <code>https://iptv-org.github.io/iptv/countries/zw.m3u</code>
- 🌐 International <code>https://iptv-org.github.io/iptv/countries/int.m3u</code>
- Undefined <code>https://iptv-org.github.io/iptv/countries/undefined.m3u</code>
+5 -5
View File
@@ -1,11 +1,11 @@
import typescriptEslint from '@typescript-eslint/eslint-plugin'
import globals from 'globals'
import tsParser from '@typescript-eslint/parser'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import js from '@eslint/js'
import stylistic from '@stylistic/eslint-plugin'
import tsParser from '@typescript-eslint/parser'
import { FlatCompat } from '@eslint/eslintrc'
import { fileURLToPath } from 'node:url'
import globals from 'globals'
import path from 'node:path'
import js from '@eslint/js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
+86 -32
View File
@@ -11,9 +11,11 @@
"@alex_neo/jest-expect-message": "^1.0.5",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.32.0",
"@freearhey/core": "^0.10.2",
"@freearhey/core": "^0.14.3",
"@freearhey/search-js": "^0.1.2",
"@freearhey/storage-js": "^0.1.0",
"@inquirer/prompts": "^7.8.0",
"@iptv-org/sdk": "^1.0.2",
"@octokit/core": "^7.0.3",
"@octokit/plugin-paginate-rest": "^13.1.1",
"@octokit/plugin-rest-endpoint-methods": "^16.0.0",
@@ -25,9 +27,10 @@
"@types/fs-extra": "^11.0.4",
"@types/jest": "^30.0.0",
"@types/lodash.uniqueid": "^4.0.9",
"@types/node-cleanup": "^2.1.5",
"@typescript-eslint/eslint-plugin": "^8.38.0",
"@typescript-eslint/parser": "^8.38.0",
"async-es": "^3.2.6",
"async": "^3.2.6",
"axios": "^1.11.0",
"chalk": "^5.4.1",
"cli-progress": "^3.12.0",
@@ -37,13 +40,14 @@
"eslint": "^9.32.0",
"glob": "^11.0.3",
"globals": "^16.3.0",
"iptv-playlist-parser": "^0.15.0",
"iptv-playlist-parser": "^0.15.1",
"jest": "^30.0.5",
"jest-expect-message": "^1.1.3",
"lodash.uniqueid": "^4.0.1",
"m3u-linter": "^0.4.2",
"mediainfo.js": "^0.3.6",
"node-cleanup": "^2.1.2",
"normalize-url": "^8.1.0",
"socks-proxy-agent": "^8.0.5",
"tsx": "^4.20.3"
}
@@ -1080,18 +1084,18 @@
}
},
"node_modules/@freearhey/core": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@freearhey/core/-/core-0.10.2.tgz",
"integrity": "sha512-crIE1oVYnhmCvISuNvJ4eP70tmoPzCTg6emlPxDIimu8LAtC8FVyckC04nBQCMYaXjLuju7tI80vHmKRBXniVg==",
"license": "MIT",
"version": "0.14.3",
"resolved": "https://registry.npmjs.org/@freearhey/core/-/core-0.14.3.tgz",
"integrity": "sha512-w/kaoUdZlbqvOSOmid0nrBI9iGZkUZ+KLZUfkWpfZRPXOEu/FxlEP5gPANGgovwijrgogAbVf/qnf9VV+429ag==",
"dependencies": {
"@types/lodash": "^4.14.198",
"@types/pako": "^2.0.3",
"consola": "^3.4.2",
"dayjs": "^1.11.13",
"fs-extra": "^11.3.0",
"glob": "^11.0.1",
"lodash": "^4.17.21",
"natural-orderby": "^5.0.0",
"normalize-url": "^6.1.0",
"normalize-url": "^8.1.0",
"object-treeify": "^2.1.1",
"pako": "^2.1.0",
"timer-node": "^5.0.9"
@@ -1108,6 +1112,15 @@
"node": ">=16.0.0"
}
},
"node_modules/@freearhey/storage-js": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@freearhey/storage-js/-/storage-js-0.1.0.tgz",
"integrity": "sha512-1TR5vPPkVewwNVelM4atDMxgjUaBE1kz/sD9z82zOVSMoyWahachhXW2/pxSHSElENg7SBY75Wki99kS+Kouxw==",
"dependencies": {
"fs-extra": "^11.3.1",
"glob": "^11.0.3"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1487,6 +1500,30 @@
}
}
},
"node_modules/@iptv-org/sdk": {
"version": "1.0.2",
"resolved": "https://npm.pkg.github.com/download/@iptv-org/sdk/1.0.2/131e5145ff68ffb5e213eb02d26d43db91a8a5a3",
"integrity": "sha512-Lpq+5vko9HkqOMDaXpeLBzKVgJikuKax7dJgBjr+XIgxRgMB1gucCjbvTNCR5AHhnORWvX+LFaodzy06jKukOA==",
"license": "UNLICENSED",
"dependencies": {
"@freearhey/core": "^0.14.3",
"@freearhey/search-js": "^0.2.0",
"@ntlab/sfetch": "^1.2.0",
"axios": "^1.11.0",
"dayjs": "^1.11.18"
}
},
"node_modules/@iptv-org/sdk/node_modules/@freearhey/search-js": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@freearhey/search-js/-/search-js-0.2.0.tgz",
"integrity": "sha512-1sxfCRbxM12Js3nM/S51cVKLYEjoksERidz539bleMAXes44eTC2m0TEQTJzJyE7l1pw2qUwsIhjd2l2l88fSw==",
"dependencies": {
"lodash": "^4.17.21"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
@@ -2286,6 +2323,14 @@
"node": ">= 8"
}
},
"node_modules/@ntlab/sfetch": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@ntlab/sfetch/-/sfetch-1.2.0.tgz",
"integrity": "sha512-9SE4NnqWo8l6mG0rnAkgng6ozSamIpF3EC+GOTQGGa6eAC0tNJvzrylMz6YRjjEGH6mOfn7ZBAuKj5WIZUul6A==",
"dependencies": {
"axios": "^1.7.9"
}
},
"node_modules/@octokit/auth-token": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz",
@@ -2880,6 +2925,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/node-cleanup": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@types/node-cleanup/-/node-cleanup-2.1.5.tgz",
"integrity": "sha512-+82RAk5uYiqiMoEv2fPeh03AL4pB5d3TL+Pf+hz31Mme6ECFI1kRlgmxYjdSlHzDbJ9yLorTnKi4Op5FA54kQQ=="
},
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="
},
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
@@ -3514,10 +3569,10 @@
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
},
"node_modules/async-es": {
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async-es/-/async-es-3.2.6.tgz",
"integrity": "sha512-9C2+oOPd7/EzIeneF4k24o75oY7OcHU/Isl7xIot12EBRwXonyuqKsmxwLuAbFWL6B/FucTQip09xTbiu1CA8A=="
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="
},
"node_modules/asynckit": {
"version": "0.4.0",
@@ -3525,10 +3580,9 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/axios": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
"license": "MIT",
"version": "1.12.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.1.tgz",
"integrity": "sha512-Kn4kbSXpkFHCGE6rBFNwIv0GQs4AvDT80jlveJDKFxjbTYMUeB4QtsdPCv6H8Cm19Je7IU6VFtRl2zWZI0rudQ==",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
@@ -3998,9 +4052,9 @@
}
},
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="
"version": "1.11.18",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz",
"integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA=="
},
"node_modules/debug": {
"version": "4.4.0",
@@ -4640,9 +4694,9 @@
}
},
"node_modules/fs-extra": {
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz",
"integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==",
"version": "11.3.1",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz",
"integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -5003,9 +5057,9 @@
}
},
"node_modules/iptv-playlist-parser": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/iptv-playlist-parser/-/iptv-playlist-parser-0.15.0.tgz",
"integrity": "sha512-9EGiQ5y95UZsV24HBSHuSyOuqzFR4YCAm7X8MqJNHlcm9jP38DHH7inYU0YJuXVq9fAttij3Yh8MJZkV1Bu/SA==",
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/iptv-playlist-parser/-/iptv-playlist-parser-0.15.1.tgz",
"integrity": "sha512-Fsm0uGiKsxZxOFvrpO0YY8eZtZLA9yqre7ne+T5AN2xvMsxFF2yY84CbFF6CTMYm7S+/AUQ+hPVst+uo9dGEuA==",
"dependencies": {
"is-valid-path": "^0.1.1",
"validator": "^13.7.0"
@@ -6225,9 +6279,9 @@
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="
},
"node_modules/jsonfile": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
"integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"dependencies": {
"universalify": "^2.0.0"
},
@@ -6625,11 +6679,11 @@
}
},
"node_modules/normalize-url": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
"integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==",
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.0.tgz",
"integrity": "sha512-X06Mfd/5aKsRHc0O0J5CUedwnPmnDtLF2+nq+KN9KSDlJHkPuh0JUviWjEWMe0SW/9TDdSLVPuk7L5gGTIA1/w==",
"engines": {
"node": ">=10"
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
+10 -6
View File
@@ -1,9 +1,9 @@
{
"name": "iptv",
"scripts": {
"act:check": "act pull_request -W .github/workflows/check.yml",
"act:format": "act workflow_dispatch -W .github/workflows/format.yml",
"act:update": "act workflow_dispatch -W .github/workflows/update.yml",
"act:check": "act pull_request -W .github/workflows/check.yml -s GITHUB_TOKEN=\"$(gh auth token)\"",
"act:format": "act workflow_dispatch -W .github/workflows/format.yml -s GITHUB_TOKEN=\"$(gh auth token)\"",
"act:update": "act workflow_dispatch -W .github/workflows/update.yml -s GITHUB_TOKEN=\"$(gh auth token)\"",
"api:load": "tsx scripts/commands/api/load.ts",
"api:generate": "tsx scripts/commands/api/generate.ts",
"api:deploy": "npx gh-pages-clean && npx gh-pages -a -m \"Deploy to iptv-org/api\" -d .api -r https://$GITHUB_TOKEN@github.com/iptv-org/api.git",
@@ -41,9 +41,11 @@
"@alex_neo/jest-expect-message": "^1.0.5",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.32.0",
"@freearhey/core": "^0.10.2",
"@freearhey/core": "^0.14.3",
"@freearhey/search-js": "^0.1.2",
"@freearhey/storage-js": "^0.1.0",
"@inquirer/prompts": "^7.8.0",
"@iptv-org/sdk": "^1.0.2",
"@octokit/core": "^7.0.3",
"@octokit/plugin-paginate-rest": "^13.1.1",
"@octokit/plugin-rest-endpoint-methods": "^16.0.0",
@@ -55,9 +57,10 @@
"@types/fs-extra": "^11.0.4",
"@types/jest": "^30.0.0",
"@types/lodash.uniqueid": "^4.0.9",
"@types/node-cleanup": "^2.1.5",
"@typescript-eslint/eslint-plugin": "^8.38.0",
"@typescript-eslint/parser": "^8.38.0",
"async-es": "^3.2.6",
"async": "^3.2.6",
"axios": "^1.11.0",
"chalk": "^5.4.1",
"cli-progress": "^3.12.0",
@@ -67,13 +70,14 @@
"eslint": "^9.32.0",
"glob": "^11.0.3",
"globals": "^16.3.0",
"iptv-playlist-parser": "^0.15.0",
"iptv-playlist-parser": "^0.15.1",
"jest": "^30.0.5",
"jest-expect-message": "^1.1.3",
"lodash.uniqueid": "^4.0.1",
"m3u-linter": "^0.4.2",
"mediainfo.js": "^0.3.6",
"node-cleanup": "^2.1.2",
"normalize-url": "^8.1.0",
"socks-proxy-agent": "^8.0.5",
"tsx": "^4.20.3"
}
+151
View File
@@ -0,0 +1,151 @@
import { Collection, Dictionary } from '@freearhey/core'
import { DATA_DIR } from './constants'
import cliProgress from 'cli-progress'
import * as sdk from '@iptv-org/sdk'
const data = {
categoriesKeyById: new Dictionary<sdk.Models.Category>(),
countriesKeyByCode: new Dictionary<sdk.Models.Country>(),
subdivisionsKeyByCode: new Dictionary<sdk.Models.Subdivision>(),
citiesKeyByCode: new Dictionary<sdk.Models.City>(),
regionsKeyByCode: new Dictionary<sdk.Models.Region>(),
languagesKeyByCode: new Dictionary<sdk.Models.Language>(),
channelsKeyById: new Dictionary<sdk.Models.Channel>(),
feedsKeyByStreamId: new Dictionary<sdk.Models.Feed>(),
feedsGroupedByChannel: new Dictionary<sdk.Models.Feed[]>(),
blocklistRecordsGroupedByChannel: new Dictionary<sdk.Models.BlocklistRecord[]>(),
categories: new Collection<sdk.Models.Category>(),
countries: new Collection<sdk.Models.Country>(),
subdivisions: new Collection<sdk.Models.Subdivision>(),
cities: new Collection<sdk.Models.City>(),
regions: new Collection<sdk.Models.Region>()
}
let searchIndex
async function loadData() {
const dataManager = new sdk.DataManager({ dataDir: DATA_DIR })
await dataManager.loadFromDisk()
dataManager.processData()
const {
channels,
feeds,
categories,
languages,
countries,
subdivisions,
cities,
regions,
blocklist
} = dataManager.getProcessedData()
searchIndex = sdk.SearchEngine.createIndex<sdk.Models.Channel>(channels)
data.categoriesKeyById = categories.keyBy((category: sdk.Models.Category) => category.id)
data.countriesKeyByCode = countries.keyBy((country: sdk.Models.Country) => country.code)
data.subdivisionsKeyByCode = subdivisions.keyBy(
(subdivision: sdk.Models.Subdivision) => subdivision.code
)
data.citiesKeyByCode = cities.keyBy((city: sdk.Models.City) => city.code)
data.regionsKeyByCode = regions.keyBy((region: sdk.Models.Region) => region.code)
data.languagesKeyByCode = languages.keyBy((language: sdk.Models.Language) => language.code)
data.channelsKeyById = channels.keyBy((channel: sdk.Models.Channel) => channel.id)
data.feedsKeyByStreamId = feeds.keyBy((feed: sdk.Models.Feed) => feed.getStreamId())
data.feedsGroupedByChannel = feeds.groupBy((feed: sdk.Models.Feed) => feed.channel)
data.blocklistRecordsGroupedByChannel = blocklist.groupBy(
(blocklistRecord: sdk.Models.BlocklistRecord) => blocklistRecord.channel
)
data.categories = categories
data.countries = countries
data.subdivisions = subdivisions
data.cities = cities
data.regions = regions
}
async function downloadData() {
function formatBytes(bytes: number) {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
const files = [
'blocklist',
'categories',
'channels',
'cities',
'countries',
'feeds',
'guides',
'languages',
'logos',
'regions',
'streams',
'subdivisions',
'timezones'
]
const multiBar = new cliProgress.MultiBar({
stopOnComplete: true,
hideCursor: true,
forceRedraw: true,
barsize: 36,
format(options, params, payload) {
const filename = payload.filename.padEnd(18, ' ')
const barsize = options.barsize || 40
const percent = (params.progress * 100).toFixed(2)
const speed = payload.speed ? formatBytes(payload.speed) + '/s' : 'N/A'
const total = formatBytes(params.total)
const completeSize = Math.round(params.progress * barsize)
const incompleteSize = barsize - completeSize
const bar =
options.barCompleteString && options.barIncompleteString
? options.barCompleteString.substr(0, completeSize) +
options.barGlue +
options.barIncompleteString.substr(0, incompleteSize)
: '-'.repeat(barsize)
return `${filename} [${bar}] ${percent}% | ETA: ${params.eta}s | ${total} | ${speed}`
}
})
const dataManager = new sdk.DataManager({ dataDir: DATA_DIR })
const requests: Promise<unknown>[] = []
for (const basename of files) {
const filename = `${basename}.json`
const progressBar = multiBar.create(0, 0, { filename })
const request = dataManager.downloadFileToDisk(basename, {
onDownloadProgress({ total, loaded, rate }) {
if (total) progressBar.setTotal(total)
progressBar.update(loaded, { speed: rate })
}
})
requests.push(request)
}
await Promise.allSettled(requests).catch(console.error)
}
function searchChannels(query: string): Collection<sdk.Models.Channel> {
if (!searchIndex) return new Collection<sdk.Models.Channel>()
const results = searchIndex.search(query)
const channels = new Collection<sdk.Models.Channel>()
new Collection<sdk.Types.ChannelSearchableData>(results).forEach(
(item: sdk.Types.ChannelSearchableData) => {
const channel = data.channelsKeyById.get(item.id)
if (channel) channels.add(channel)
}
)
return channels
}
export { data, loadData, downloadData, searchChannels }
+13 -21
View File
@@ -1,39 +1,31 @@
import { DataLoader, DataProcessor, PlaylistParser } from '../../core'
import type { DataProcessorData } from '../../types/dataProcessor'
import { API_DIR, STREAMS_DIR, DATA_DIR } from '../../constants'
import type { DataLoaderData } from '../../types/dataLoader'
import { Logger, Storage } from '@freearhey/core'
import { API_DIR, STREAMS_DIR } from '../../constants'
import { Storage } from '@freearhey/storage-js'
import { PlaylistParser } from '../../core'
import { Logger } from '@freearhey/core'
import { Stream } from '../../models'
import { loadData } from '../../api'
async function main() {
const logger = new Logger()
logger.info('loading data from api...')
const processor = new DataProcessor()
const dataStorage = new Storage(DATA_DIR)
const dataLoader = new DataLoader({ storage: dataStorage })
const data: DataLoaderData = await dataLoader.load()
const { channelsKeyById, feedsGroupedByChannelId, logosGroupedByStreamId }: DataProcessorData =
processor.process(data)
await loadData()
logger.info('loading streams...')
const streamsStorage = new Storage(STREAMS_DIR)
const parser = new PlaylistParser({
storage: streamsStorage,
channelsKeyById,
logosGroupedByStreamId,
feedsGroupedByChannelId
storage: streamsStorage
})
const files = await streamsStorage.list('**/*.m3u')
let streams = await parser.parse(files)
streams = streams
.orderBy((stream: Stream) => stream.getId())
.map((stream: Stream) => stream.toJSON())
logger.info(`found ${streams.count()} streams`)
const parsed = await parser.parse(files)
const _streams = parsed
.sortBy((stream: Stream) => stream.getId())
.map((stream: Stream) => stream.toObject())
logger.info(`found ${_streams.count()} streams`)
logger.info('saving to .api/streams.json...')
const apiStorage = new Storage(API_DIR)
await apiStorage.save('streams.json', streams.toJSON())
await apiStorage.save('streams.json', _streams.toJSON())
}
main()
+2 -21
View File
@@ -1,26 +1,7 @@
import { DATA_DIR } from '../../constants'
import { Storage } from '@freearhey/core'
import { DataLoader } from '../../core'
import { downloadData } from '../../api'
async function main() {
const storage = new Storage(DATA_DIR)
const loader = new DataLoader({ storage })
await Promise.all([
loader.download('blocklist.json'),
loader.download('categories.json'),
loader.download('channels.json'),
loader.download('countries.json'),
loader.download('languages.json'),
loader.download('regions.json'),
loader.download('subdivisions.json'),
loader.download('feeds.json'),
loader.download('logos.json'),
loader.download('timezones.json'),
loader.download('guides.json'),
loader.download('streams.json'),
loader.download('cities.json')
])
await downloadData()
}
main()
+31 -58
View File
@@ -1,17 +1,16 @@
import { Storage, Collection, Logger, Dictionary } from '@freearhey/core'
import { DataLoader, DataProcessor, PlaylistParser } from '../../core'
import type { ChannelSearchableData } from '../../types/channel'
import { Channel, Feed, Playlist, Stream } from '../../models'
import { DataProcessorData } from '../../types/dataProcessor'
import { DataLoaderData } from '../../types/dataLoader'
import { loadData, data, searchChannels } from '../../api'
import { Collection, Logger } from '@freearhey/core'
import { select, input } from '@inquirer/prompts'
import { DATA_DIR } from '../../constants'
import { Playlist, Stream } from '../../models'
import { Storage } from '@freearhey/storage-js'
import { PlaylistParser } from '../../core'
import nodeCleanup from 'node-cleanup'
import sjs from '@freearhey/search-js'
import * as sdk from '@iptv-org/sdk'
import { truncate } from '../../utils'
import { Command } from 'commander'
import readline from 'readline'
type ChoiceValue = { type: string; value?: Feed | Channel }
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') {
@@ -32,7 +31,7 @@ program.argument('<filepath>', 'Path to *.channels.xml file to edit').parse(proc
const filepath = program.args[0]
const logger = new Logger()
const storage = new Storage()
let parsedStreams = new Collection()
let parsedStreams = new Collection<Stream>()
main(filepath)
nodeCleanup(() => {
@@ -45,42 +44,24 @@ export default async function main(filepath: string) {
}
logger.info('loading data from api...')
const processor = new DataProcessor()
const dataStorage = new Storage(DATA_DIR)
const loader = new DataLoader({ storage: dataStorage })
const data: DataLoaderData = await loader.load()
const {
channels,
channelsKeyById,
feedsGroupedByChannelId,
logosGroupedByStreamId
}: DataProcessorData = processor.process(data)
await loadData()
logger.info('loading streams...')
const parser = new PlaylistParser({
storage,
feedsGroupedByChannelId,
logosGroupedByStreamId,
channelsKeyById
storage
})
parsedStreams = await parser.parseFile(filepath)
const streamsWithoutId = parsedStreams.filter((stream: Stream) => !stream.id)
const streamsWithoutId = parsedStreams.filter((stream: Stream) => !stream.tvgId)
logger.info(
`found ${parsedStreams.count()} streams (including ${streamsWithoutId.count()} without ID)`
)
logger.info('creating search index...')
const items = channels.map((channel: Channel) => channel.getSearchable()).all()
const searchIndex = sjs.createIndex(items, {
searchable: ['name', 'altNames', 'guideNames', 'streamTitles', 'feedFullNames']
})
logger.info('starting...\n')
for (const stream of streamsWithoutId.all()) {
try {
stream.id = await selectChannel(stream, searchIndex, feedsGroupedByChannelId, channelsKeyById)
stream.tvgId = await selectChannel(stream)
} catch (err) {
logger.info(err.message)
break
@@ -88,28 +69,20 @@ export default async function main(filepath: string) {
}
streamsWithoutId.forEach((stream: Stream) => {
if (stream.id === '-') {
stream.id = ''
if (stream.channel === '-') {
stream.channel = ''
}
})
}
async function selectChannel(
stream: Stream,
searchIndex,
feedsGroupedByChannelId: Dictionary,
channelsKeyById: Dictionary
): Promise<string> {
const query = escapeRegex(stream.getTitle())
const similarChannels = searchIndex
.search(query)
.map((item: ChannelSearchableData) => channelsKeyById.get(item.id))
const url = stream.url.length > 50 ? stream.url.slice(0, 50) + '...' : stream.url
async function selectChannel(stream: Stream): Promise<string> {
const query = escapeRegex(stream.title)
const similarChannels = searchChannels(query)
const url = truncate(stream.url, 50)
const selected: ChoiceValue = await select({
message: `Select channel ID for "${stream.title}" (${url}):`,
choices: getChannelChoises(new Collection(similarChannels)),
choices: getChannelChoises(similarChannels),
pageSize: 10
})
@@ -119,14 +92,14 @@ async function selectChannel(
case 'type': {
const typedChannelId = await input({ message: ' Channel ID:' })
if (!typedChannelId) return ''
const selectedFeedId = await selectFeed(typedChannelId, feedsGroupedByChannelId)
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, feedsGroupedByChannelId)
const selectedFeedId = await selectFeed(selectedChannel.id)
if (selectedFeedId === '-') return selectedChannel.id
return [selectedChannel.id, selectedFeedId].join('@')
}
@@ -135,8 +108,8 @@ async function selectChannel(
return ''
}
async function selectFeed(channelId: string, feedsGroupedByChannelId: Dictionary): Promise<string> {
const channelFeeds = new Collection(feedsGroupedByChannelId.get(channelId))
async function selectFeed(channelId: string): Promise<string> {
const channelFeeds = new Collection(data.feedsGroupedByChannel.get(channelId))
const choices = getFeedChoises(channelFeeds)
const selected: ChoiceValue = await select({
@@ -159,11 +132,11 @@ async function selectFeed(channelId: string, feedsGroupedByChannelId: Dictionary
return ''
}
function getChannelChoises(channels: Collection): Choice[] {
function getChannelChoises(channels: Collection<sdk.Models.Channel>): Choice[] {
const choises: Choice[] = []
channels.forEach((channel: Channel) => {
const names = new Collection([channel.name, ...channel.altNames.all()]).uniq().join(', ')
channels.forEach((channel: sdk.Models.Channel) => {
const names = new Collection([channel.name, ...channel.alt_names]).uniq().join(', ')
choises.push({
value: {
@@ -181,19 +154,19 @@ function getChannelChoises(channels: Collection): Choice[] {
return choises
}
function getFeedChoises(feeds: Collection): Choice[] {
function getFeedChoises(feeds: Collection<sdk.Models.Feed>): Choice[] {
const choises: Choice[] = []
feeds.forEach((feed: Feed) => {
feeds.forEach((feed: sdk.Models.Feed) => {
let name = `${feed.id} (${feed.name})`
if (feed.isMain) name += ' [main]'
if (feed.is_main) name += ' [main]'
choises.push({
value: {
type: 'feed',
value: feed
},
default: feed.isMain,
default: feed.is_main,
name,
short: feed.id
})
+27 -21
View File
@@ -1,10 +1,10 @@
import { Logger, Storage } from '@freearhey/core'
import { STREAMS_DIR, DATA_DIR } from '../../constants'
import { DataLoader, DataProcessor, PlaylistParser } from '../../core'
import { Collection, Logger } from '@freearhey/core'
import { Stream, Playlist } from '../../models'
import { Storage } from '@freearhey/storage-js'
import { STREAMS_DIR } from '../../constants'
import { PlaylistParser } from '../../core'
import { loadData } from '../../api'
import { program } from 'commander'
import { DataLoaderData } from '../../types/dataLoader'
import { DataProcessorData } from '../../types/dataProcessor'
import path from 'node:path'
program.argument('[filepath...]', 'Path to file to format').parse(process.argv)
@@ -13,20 +13,12 @@ async function main() {
const logger = new Logger()
logger.info('loading data from api...')
const processor = new DataProcessor()
const dataStorage = new Storage(DATA_DIR)
const loader = new DataLoader({ storage: dataStorage })
const data: DataLoaderData = await loader.load()
const { channelsKeyById, feedsGroupedByChannelId, logosGroupedByStreamId }: DataProcessorData =
processor.process(data)
await loadData()
logger.info('loading streams...')
const streamsStorage = new Storage(STREAMS_DIR)
const parser = new PlaylistParser({
storage: streamsStorage,
channelsKeyById,
feedsGroupedByChannelId,
logosGroupedByStreamId
storage: streamsStorage
})
let files = program.args.length ? program.args : await streamsStorage.list('**/*.m3u')
files = files.map((filepath: string) => path.basename(filepath))
@@ -45,19 +37,33 @@ async function main() {
logger.info('removing wrong id...')
streams = streams.map((stream: Stream) => {
if (!stream.channel || channelsKeyById.missing(stream.channel.id)) {
stream.id = ''
const channel = stream.getChannel()
if (channel) return stream
stream.tvgId = ''
stream.channel = ''
stream.feed = ''
return stream
})
logger.info('adding the missing feed id...')
streams = streams.map((stream: Stream) => {
const feed = stream.getFeed()
if (feed) {
stream.feed = feed.id
stream.tvgId = stream.getId()
}
return stream
})
logger.info('sorting links...')
streams = streams.orderBy(
streams = streams.sortBy(
[
(stream: Stream) => stream.title,
(stream: Stream) => stream.getVerticalResolution(),
(stream: Stream) => stream.getLabel(),
(stream: Stream) => stream.label,
(stream: Stream) => stream.url
],
['asc', 'desc', 'asc', 'asc']
@@ -66,9 +72,9 @@ async function main() {
logger.info('saving...')
const groupedStreams = streams.groupBy((stream: Stream) => stream.getFilepath())
for (const filepath of groupedStreams.keys()) {
const streams = groupedStreams.get(filepath) || []
const streams = new Collection(groupedStreams.get(filepath))
if (!streams.length) return
if (streams.isEmpty()) return
const playlist = new Playlist(streams, { public: false })
await streamsStorage.save(filepath, playlist.toString())
+13 -29
View File
@@ -1,10 +1,10 @@
import { PlaylistParser, DataProcessor, DataLoader } from '../../core'
import type { DataProcessorData } from '../../types/dataProcessor'
import { DATA_DIR, LOGS_DIR, STREAMS_DIR } from '../../constants'
import type { DataLoaderData } from '../../types/dataLoader'
import { Logger, Storage, File } from '@freearhey/core'
import { Stream } from '../../models'
import { LOGS_DIR, STREAMS_DIR } from '../../constants'
import { Storage, File } from '@freearhey/storage-js'
import { PlaylistParser } from '../../core'
import { loadData, data } from '../../api'
import { Logger } from '@freearhey/core'
import uniqueId from 'lodash.uniqueid'
import { Stream } from '../../models'
import {
IndexCategoryGenerator,
IndexLanguageGenerator,
@@ -25,28 +25,12 @@ async function main() {
const logFile = new File('generators.log')
logger.info('loading data from api...')
const processor = new DataProcessor()
const dataStorage = new Storage(DATA_DIR)
const loader = new DataLoader({ storage: dataStorage })
const data: DataLoaderData = await loader.load()
const {
feedsGroupedByChannelId,
logosGroupedByStreamId,
channelsKeyById,
subdivisions,
categories,
countries,
regions,
cities
}: DataProcessorData = processor.process(data)
await loadData()
logger.info('loading streams...')
const streamsStorage = new Storage(STREAMS_DIR)
const parser = new PlaylistParser({
storage: streamsStorage,
feedsGroupedByChannelId,
logosGroupedByStreamId,
channelsKeyById
storage: streamsStorage
})
const files = await streamsStorage.list('**/*.m3u')
let streams = await parser.parse(files)
@@ -57,20 +41,20 @@ async function main() {
await new RawGenerator({ streams, logFile }).generate()
logger.info('filtering streams...')
streams = streams.uniqBy((stream: Stream) =>
stream.hasId() ? stream.getChannelId() + stream.getFeedId() : uniqueId()
)
streams = streams.uniqBy((stream: Stream) => stream.getId() || uniqueId())
logger.info('sorting streams...')
streams = streams.orderBy(
streams = streams.sortBy(
[
(stream: Stream) => stream.getId(),
(stream: Stream) => stream.getVerticalResolution(),
(stream: Stream) => stream.getLabel()
(stream: Stream) => stream.label
],
['asc', 'asc', 'desc']
)
const { categories, countries, subdivisions, cities, regions } = data
logger.info('generating categories/...')
await new CategoriesGenerator({ categories, streams, logFile }).generate()
+21 -26
View File
@@ -1,15 +1,16 @@
import { Logger, Storage, Collection } from '@freearhey/core'
import { ROOT_DIR, STREAMS_DIR, DATA_DIR } from '../../constants'
import { PlaylistParser, StreamTester, CliTable, DataProcessor, DataLoader } from '../../core'
import { PlaylistParser, StreamTester, CliTable } from '../../core'
import type { TestResult } from '../../core/streamTester'
import { Stream } from '../../models'
import { ROOT_DIR, STREAMS_DIR } from '../../constants'
import { Logger, Collection } from '@freearhey/core'
import { program, OptionValues } from 'commander'
import { eachLimit } from 'async-es'
import { Storage } from '@freearhey/storage-js'
import { Stream } from '../../models'
import { loadData } from '../../api'
import { eachLimit } from 'async'
import dns from 'node:dns'
import chalk from 'chalk'
import os from 'node:os'
import dns from 'node:dns'
import type { DataLoaderData } from '../../types/dataLoader'
import type { DataProcessorData } from '../../types/dataProcessor'
import { truncate } from '../../utils'
const LIVE_UPDATE_INTERVAL = 5000
const LIVE_UPDATE_MAX_STREAMS = 100
@@ -18,7 +19,7 @@ let errors = 0
let warnings = 0
const results: { [key: string]: string } = {}
let interval: string | number | NodeJS.Timeout | undefined
let streams = new Collection()
let streams = new Collection<Stream>()
let isLiveUpdateEnabled = true
program
@@ -50,20 +51,12 @@ async function main() {
}
logger.info('loading data from api...')
const processor = new DataProcessor()
const dataStorage = new Storage(DATA_DIR)
const loader = new DataLoader({ storage: dataStorage })
const data: DataLoaderData = await loader.load()
const { channelsKeyById, feedsGroupedByChannelId, logosGroupedByStreamId }: DataProcessorData =
processor.process(data)
await loadData()
logger.info('loading streams...')
const rootStorage = new Storage(ROOT_DIR)
const parser = new PlaylistParser({
storage: rootStorage,
channelsKeyById,
feedsGroupedByChannelId,
logosGroupedByStreamId
storage: rootStorage
})
const files = program.args.length ? program.args : await rootStorage.list(`${STREAMS_DIR}/*.m3u`)
streams = await parser.parse(files)
@@ -79,7 +72,7 @@ async function main() {
}, LIVE_UPDATE_INTERVAL)
}
await eachLimit(
eachLimit(
streams.all(),
options.parallel,
async (stream: Stream) => {
@@ -96,7 +89,7 @@ async function main() {
main()
async function runTest(stream: Stream) {
const key = stream.filepath + stream.getId() + stream.url
const key = stream.getUniqKey()
results[key] = chalk.white('LOADING...')
const result: TestResult = await tester.test(stream)
@@ -121,7 +114,7 @@ function drawTable() {
const streamsGrouped = streams.groupBy((stream: Stream) => stream.filepath)
for (const filepath of streamsGrouped.keys()) {
const streams: Stream[] = streamsGrouped.get(filepath)
const streams: Stream[] = streamsGrouped.get(filepath) || []
const table = new CliTable({
columns: [
@@ -132,12 +125,14 @@ function drawTable() {
]
})
streams.forEach((stream: Stream, index: number) => {
const status = results[stream.filepath + stream.getId() + stream.url] || chalk.gray('PENDING')
const key = stream.getUniqKey()
const status = results[key] || chalk.gray('PENDING')
const tvgId = stream.getTvgId()
const row = {
'': index,
'tvg-id': stream.getId().length > 25 ? stream.getId().slice(0, 22) + '...' : stream.getId(),
url: stream.url.length > 100 ? stream.url.slice(0, 97) + '...' : stream.url,
'tvg-id': truncate(tvgId, 25),
url: truncate(stream.url, 100),
status
}
table.append(row)
@@ -149,7 +144,7 @@ function drawTable() {
}
}
function onFinish(error: any) {
function onFinish(error: Error) {
clearInterval(interval)
if (error) {
+39 -59
View File
@@ -1,9 +1,10 @@
import { DataLoader, DataProcessor, IssueLoader, PlaylistParser } from '../../core'
import { Logger, Storage, Collection, Dictionary } from '@freearhey/core'
import type { DataProcessorData } from '../../types/dataProcessor'
import { Stream, Playlist, Channel, Issue } from '../../models'
import type { DataLoaderData } from '../../types/dataLoader'
import { DATA_DIR, STREAMS_DIR } from '../../constants'
import { IssueLoader, PlaylistParser } from '../../core'
import { Playlist, Issue, Stream } from '../../models'
import { loadData, data as apiData } from '../../api'
import { Logger, Collection } from '@freearhey/core'
import { Storage } from '@freearhey/storage-js'
import { STREAMS_DIR } from '../../constants'
import * as sdk from '@iptv-org/sdk'
import { isURI } from '../../utils'
const processedIssues = new Collection()
@@ -16,20 +17,12 @@ async function main() {
const issues = await issueLoader.load()
logger.info('loading data from api...')
const processor = new DataProcessor()
const dataStorage = new Storage(DATA_DIR)
const dataLoader = new DataLoader({ storage: dataStorage })
const data: DataLoaderData = await dataLoader.load()
const { channelsKeyById, feedsGroupedByChannelId, logosGroupedByStreamId }: DataProcessorData =
processor.process(data)
await loadData()
logger.info('loading streams...')
const streamsStorage = new Storage(STREAMS_DIR)
const parser = new PlaylistParser({
storage: streamsStorage,
feedsGroupedByChannelId,
logosGroupedByStreamId,
channelsKeyById
storage: streamsStorage
})
const files = await streamsStorage.list('**/*.m3u')
const streams = await parser.parse(files)
@@ -40,23 +33,19 @@ async function main() {
logger.info('edit stream description...')
await editStreams({
streams,
issues,
channelsKeyById,
feedsGroupedByChannelId
issues
})
logger.info('add new streams...')
await addStreams({
streams,
issues,
channelsKeyById,
feedsGroupedByChannelId
issues
})
logger.info('saving...')
const groupedStreams = streams.groupBy((stream: Stream) => stream.getFilepath())
for (const filepath of groupedStreams.keys()) {
let streams = groupedStreams.get(filepath) || []
let streams = new Collection(groupedStreams.get(filepath))
streams = streams.filter((stream: Stream) => stream.removed === false)
const playlist = new Playlist(streams, { public: false })
@@ -69,10 +58,17 @@ async function main() {
main()
async function removeStreams({ streams, issues }: { streams: Collection; issues: Collection }) {
async function removeStreams({
streams,
issues
}: {
streams: Collection<Stream>
issues: Collection<Issue>
}) {
const requests = issues.filter(
issue => issue.labels.includes('streams:remove') && issue.labels.includes('approved')
)
requests.forEach((issue: Issue) => {
const data = issue.data
if (data.missing('streamUrl')) return
@@ -97,14 +93,10 @@ async function removeStreams({ streams, issues }: { streams: Collection; issues:
async function editStreams({
streams,
issues,
channelsKeyById,
feedsGroupedByChannelId
issues
}: {
streams: Collection
issues: Collection
channelsKeyById: Dictionary
feedsGroupedByChannelId: Dictionary
streams: Collection<Stream>
issues: Collection<Issue>
}) {
const requests = issues.filter(
issue => issue.labels.includes('streams:edit') && issue.labels.includes('approved')
@@ -123,17 +115,12 @@ async function editStreams({
const [channelId, feedId] = streamId.split('@')
if (channelId) {
stream
.setChannelId(channelId)
.setFeedId(feedId)
.withChannel(channelsKeyById)
.withFeed(feedsGroupedByChannelId)
.updateId()
.updateTitle()
.updateFilepath()
stream.channel = channelId
stream.feed = feedId
stream.updateTvgId().updateTitle().updateFilepath()
}
stream.update(data)
stream.updateWithIssue(data)
processedIssues.add(issue.number)
})
@@ -141,14 +128,10 @@ async function editStreams({
async function addStreams({
streams,
issues,
channelsKeyById,
feedsGroupedByChannelId
issues
}: {
streams: Collection
issues: Collection
channelsKeyById: Dictionary
feedsGroupedByChannelId: Dictionary
streams: Collection<Stream>
issues: Collection<Issue>
}) {
const requests = issues.filter(
issue => issue.labels.includes('streams:add') && issue.labels.includes('approved')
@@ -163,30 +146,27 @@ async function addStreams({
const streamId = data.getString('streamId') || ''
const [channelId, feedId] = streamId.split('@')
const channel: Channel = channelsKeyById.get(channelId)
const channel: sdk.Models.Channel | undefined = apiData.channelsKeyById.get(channelId)
if (!channel) return
const label = data.getString('label') || null
const label = data.getString('label') || ''
const quality = data.getString('quality') || null
const httpUserAgent = data.getString('httpUserAgent') || null
const httpReferrer = data.getString('httpReferrer') || null
const directives = data.getArray('directives') || []
const stream = new Stream({
channelId,
feedId,
channel: channelId,
feed: feedId,
title: channel.name,
url: streamUrl,
userAgent: httpUserAgent,
user_agent: httpUserAgent,
referrer: httpReferrer,
directives,
quality,
label
quality
})
.withChannel(channelsKeyById)
.withFeed(feedsGroupedByChannelId)
.updateTitle()
.updateFilepath()
stream.label = label
stream.setDirectives(directives).updateTitle().updateFilepath()
streams.add(stream)
processedIssues.add(issue.number)
+35 -44
View File
@@ -1,9 +1,10 @@
import { Logger, Storage, Collection, Dictionary } from '@freearhey/core'
import { DataLoader, DataProcessor, PlaylistParser } from '../../core'
import { DataProcessorData } from '../../types/dataProcessor'
import { DATA_DIR, ROOT_DIR } from '../../constants'
import { DataLoaderData } from '../../types/dataLoader'
import { BlocklistRecord, Stream } from '../../models'
import { Logger, Collection, Dictionary } from '@freearhey/core'
import { Storage } from '@freearhey/storage-js'
import { PlaylistParser } from '../../core'
import { data, loadData } from '../../api'
import { ROOT_DIR } from '../../constants'
import { Stream } from '../../models'
import * as sdk from '@iptv-org/sdk'
import { program } from 'commander'
import chalk from 'chalk'
@@ -19,24 +20,12 @@ async function main() {
const logger = new Logger()
logger.info('loading data from api...')
const processor = new DataProcessor()
const dataStorage = new Storage(DATA_DIR)
const loader = new DataLoader({ storage: dataStorage })
const data: DataLoaderData = await loader.load()
const {
channelsKeyById,
feedsGroupedByChannelId,
logosGroupedByStreamId,
blocklistRecordsGroupedByChannelId
}: DataProcessorData = processor.process(data)
await loadData()
logger.info('loading streams...')
const rootStorage = new Storage(ROOT_DIR)
const parser = new PlaylistParser({
storage: rootStorage,
channelsKeyById,
feedsGroupedByChannelId,
logosGroupedByStreamId
storage: rootStorage
})
const files = program.args.length ? program.args : await rootStorage.list('streams/**/*.m3u')
const streams = await parser.parse(files)
@@ -49,16 +38,16 @@ async function main() {
const streams = streamsGroupedByFilepath.get(filepath)
if (!streams) continue
const log = new Collection()
const buffer = new Dictionary()
const log = new Collection<LogItem>()
const buffer = new Dictionary<boolean>()
streams.forEach((stream: Stream) => {
if (stream.channelId) {
const channel = channelsKeyById.get(stream.channelId)
if (stream.channel) {
const channel = data.channelsKeyById.get(stream.channel)
if (!channel) {
log.add({
type: 'warning',
line: stream.getLine(),
message: `"${stream.id}" is not in the database`
message: `"${stream.tvgId}" is not in the database`
})
}
}
@@ -74,28 +63,30 @@ async function main() {
buffer.set(stream.url, true)
}
const blocklistRecords = stream.channel
? new Collection(blocklistRecordsGroupedByChannelId.get(stream.channel.id))
: new Collection()
if (stream.channel) {
const blocklistRecords = new Collection(
data.blocklistRecordsGroupedByChannel.get(stream.channel)
)
blocklistRecords.forEach((blocklistRecord: BlocklistRecord) => {
if (blocklistRecord.reason === 'dmca') {
log.add({
type: 'error',
line: stream.getLine(),
message: `"${blocklistRecord.channelId}" is on the blocklist due to claims of copyright holders (${blocklistRecord.ref})`
})
} else if (blocklistRecord.reason === 'nsfw') {
log.add({
type: 'error',
line: stream.getLine(),
message: `"${blocklistRecord.channelId}" is on the blocklist due to NSFW content (${blocklistRecord.ref})`
})
}
})
blocklistRecords.forEach((blocklistRecord: sdk.Models.BlocklistRecord) => {
if (blocklistRecord.reason === 'dmca') {
log.add({
type: 'error',
line: stream.getLine(),
message: `"${blocklistRecord.channel}" is on the blocklist due to claims of copyright holders (${blocklistRecord.ref})`
})
} else if (blocklistRecord.reason === 'nsfw') {
log.add({
type: 'error',
line: stream.getLine(),
message: `"${blocklistRecord.channel}" is on the blocklist due to NSFW content (${blocklistRecord.ref})`
})
}
})
}
})
if (log.notEmpty()) {
if (log.isNotEmpty()) {
console.log(`\n${chalk.underline(filepath)}`)
log.forEach((logItem: LogItem) => {
+11 -29
View File
@@ -1,41 +1,23 @@
import { CategoriesTable, CountriesTable, LanguagesTable, RegionsTable } from '../../tables'
import { DataLoader, DataProcessor, Markdown } from '../../core'
import { DataProcessorData } from '../../types/dataProcessor'
import { DataLoaderData } from '../../types/dataLoader'
import { README_DIR, DATA_DIR, ROOT_DIR } from '../../constants'
import { Logger, Storage } from '@freearhey/core'
import { README_DIR, ROOT_DIR } from '../../constants'
import { Logger } from '@freearhey/core'
import { Markdown } from '../../core'
import { loadData } from '../../api'
async function main() {
const logger = new Logger()
const dataStorage = new Storage(DATA_DIR)
const processor = new DataProcessor()
const loader = new DataLoader({ storage: dataStorage })
const data: DataLoaderData = await loader.load()
const {
subdivisionsKeyByCode,
languagesKeyByCode,
countriesKeyByCode,
categoriesKeyById,
subdivisions,
countries,
regions,
cities
}: DataProcessorData = processor.process(data)
logger.info('loading data from api...')
await loadData()
logger.info('creating category table...')
await new CategoriesTable({ categoriesKeyById }).make()
await new CategoriesTable().create()
logger.info('creating language table...')
await new LanguagesTable({ languagesKeyByCode }).make()
await new LanguagesTable().create()
logger.info('creating countires table...')
await new CountriesTable({
countriesKeyByCode,
subdivisionsKeyByCode,
subdivisions,
countries,
cities
}).make()
await new CountriesTable().create()
logger.info('creating region table...')
await new RegionsTable({ regions }).make()
await new RegionsTable().create()
logger.info('updating playlists.md...')
const playlists = new Markdown({
+53 -55
View File
@@ -1,10 +1,24 @@
import { DataLoader, DataProcessor, IssueLoader, PlaylistParser } from '../../core'
import { Logger, Storage, Collection, Dictionary } from '@freearhey/core'
import { DataProcessorData } from '../../types/dataProcessor'
import { DATA_DIR, STREAMS_DIR } from '../../constants'
import { DataLoaderData } from '../../types/dataLoader'
import { Logger, Collection, Dictionary } from '@freearhey/core'
import { IssueLoader, PlaylistParser } from '../../core'
import { Storage } from '@freearhey/storage-js'
import { isURI, truncate } from '../../utils'
import { STREAMS_DIR } from '../../constants'
import { Issue, Stream } from '../../models'
import { isURI } from '../../utils'
import { data, loadData } from '../../api'
const status = {
PENDING: 'pending',
FULFILLED: 'fulfilled',
MISSING_CHANNEL_ID: 'missing_channel_id',
INVALID_CHANNEL_ID: 'invalid_channel_id',
MISSING_STREAM_URL: 'missing_stream_url',
INVALID_STREAM_URL: 'invalid_stream_url',
NONEXISTENT_LINK: 'nonexistent_link',
CHANNEL_BLOCKED: 'channel_blocked',
CHANNEL_CLOSED: 'channel_closed',
DUPLICATE_LINK: 'duplicate_link',
DUPLICATE_REQUEST: 'duplicate_request'
}
async function main() {
const logger = new Logger()
@@ -15,29 +29,17 @@ async function main() {
const issues = await issueLoader.load()
logger.info('loading data from api...')
const processor = new DataProcessor()
const dataStorage = new Storage(DATA_DIR)
const dataLoader = new DataLoader({ storage: dataStorage })
const data: DataLoaderData = await dataLoader.load()
const {
channelsKeyById,
feedsGroupedByChannelId,
logosGroupedByStreamId,
blocklistRecordsGroupedByChannelId
}: DataProcessorData = processor.process(data)
await loadData()
logger.info('loading streams...')
const streamsStorage = new Storage(STREAMS_DIR)
const parser = new PlaylistParser({
storage: streamsStorage,
channelsKeyById,
feedsGroupedByChannelId,
logosGroupedByStreamId
storage: streamsStorage
})
const files = await streamsStorage.list('**/*.m3u')
const streams = await parser.parse(files)
const streamsGroupedByUrl = streams.groupBy((stream: Stream) => stream.url)
const streamsGroupedByChannelId = streams.groupBy((stream: Stream) => stream.channelId)
const streamsGroupedByChannel = streams.groupBy((stream: Stream) => stream.channel)
const streamsGroupedById = streams.groupBy((stream: Stream) => stream.getId())
logger.info('checking streams:remove requests...')
@@ -53,7 +55,7 @@ async function main() {
type: 'streams:remove',
streamId: undefined,
streamUrl: undefined,
status: 'missing_link'
status: status.NONEXISTENT_LINK
}
report.add(result)
@@ -64,11 +66,11 @@ async function main() {
type: 'streams:remove',
streamId: undefined,
streamUrl: truncate(streamUrl),
status: 'pending'
status: status.PENDING
}
if (streamsGroupedByUrl.missing(streamUrl)) {
result.status = 'wrong_link'
result.status = status.NONEXISTENT_LINK
}
report.add(result)
@@ -89,17 +91,18 @@ async function main() {
type: 'streams:add',
streamId: streamId || undefined,
streamUrl: truncate(streamUrl),
status: 'pending'
status: status.PENDING
}
if (!channelId) result.status = 'missing_id'
else if (!streamUrl) result.status = 'missing_link'
else if (!isURI(streamUrl)) result.status = 'invalid_link'
else if (blocklistRecordsGroupedByChannelId.has(channelId)) result.status = 'blocked'
else if (channelsKeyById.missing(channelId)) result.status = 'wrong_id'
else if (streamsGroupedByUrl.has(streamUrl)) result.status = 'on_playlist'
else if (addRequestsBuffer.has(streamUrl)) result.status = 'duplicate'
else result.status = 'pending'
if (!channelId) result.status = status.MISSING_CHANNEL_ID
else if (!streamUrl) result.status = status.MISSING_STREAM_URL
else if (!isURI(streamUrl)) result.status = status.INVALID_STREAM_URL
else if (data.blocklistRecordsGroupedByChannel.has(channelId))
result.status = status.CHANNEL_BLOCKED
else if (data.channelsKeyById.missing(channelId)) result.status = status.INVALID_CHANNEL_ID
else if (streamsGroupedByUrl.has(streamUrl)) result.status = status.DUPLICATE_LINK
else if (addRequestsBuffer.has(streamUrl)) result.status = status.DUPLICATE_REQUEST
else result.status = status.PENDING
addRequestsBuffer.set(streamUrl, true)
@@ -120,12 +123,13 @@ async function main() {
type: 'streams:edit',
streamId: streamId || undefined,
streamUrl: truncate(streamUrl),
status: 'pending'
status: status.PENDING
}
if (!streamUrl) result.status = 'missing_link'
else if (streamsGroupedByUrl.missing(streamUrl)) result.status = 'invalid_link'
else if (channelId && channelsKeyById.missing(channelId)) result.status = 'invalid_id'
if (!streamUrl) result.status = status.MISSING_STREAM_URL
else if (streamsGroupedByUrl.missing(streamUrl)) result.status = status.NONEXISTENT_LINK
else if (channelId && data.channelsKeyById.missing(channelId))
result.status = status.INVALID_CHANNEL_ID
report.add(result)
})
@@ -136,7 +140,7 @@ async function main() {
)
const channelSearchRequestsBuffer = new Dictionary()
channelSearchRequests.forEach((issue: Issue) => {
const streamId = issue.data.getString('channelId') || ''
const streamId = issue.data.getString('streamId') || issue.data.getString('channelId') || ''
const [channelId, feedId] = streamId.split('@')
const result = {
@@ -144,18 +148,19 @@ async function main() {
type: 'channel search',
streamId: streamId || undefined,
streamUrl: undefined,
status: 'pending'
status: status.PENDING
}
if (!channelId) result.status = 'missing_id'
else if (channelsKeyById.missing(channelId)) result.status = 'invalid_id'
else if (channelSearchRequestsBuffer.has(streamId)) result.status = 'duplicate'
else if (blocklistRecordsGroupedByChannelId.has(channelId)) result.status = 'blocked'
else if (streamsGroupedById.has(streamId)) result.status = 'fulfilled'
else if (!feedId && streamsGroupedByChannelId.has(channelId)) result.status = 'fulfilled'
if (!channelId) result.status = status.MISSING_CHANNEL_ID
else if (data.channelsKeyById.missing(channelId)) result.status = status.INVALID_CHANNEL_ID
else if (channelSearchRequestsBuffer.has(streamId)) result.status = status.DUPLICATE_REQUEST
else if (data.blocklistRecordsGroupedByChannel.has(channelId))
result.status = status.CHANNEL_BLOCKED
else if (streamsGroupedById.has(streamId)) result.status = status.FULFILLED
else if (!feedId && streamsGroupedByChannel.has(channelId)) result.status = status.FULFILLED
else {
const channelData = channelsKeyById.get(channelId)
if (channelData && channelData.isClosed) result.status = 'closed'
const channelData = data.channelsKeyById.get(channelId)
if (channelData && channelData.isClosed()) result.status = status.CHANNEL_CLOSED
}
channelSearchRequestsBuffer.set(streamId, true)
@@ -163,16 +168,9 @@ async function main() {
report.add(result)
})
report = report.orderBy(item => item.issueNumber).filter(item => item.status !== 'pending')
report = report.sortBy(item => item.issueNumber).filter(item => item.status !== status.PENDING)
console.table(report.all())
}
main()
function truncate(string: string, limit: number = 100) {
if (!string) return string
if (string.length < limit) return string
return string.slice(0, limit) + '...'
}
-16
View File
@@ -1,16 +0,0 @@
import axios, { AxiosInstance, AxiosResponse, AxiosRequestConfig } from 'axios'
export class ApiClient {
instance: AxiosInstance
constructor() {
this.instance = axios.create({
baseURL: 'https://iptv-org.github.io/api',
responseType: 'stream'
})
}
get(url: string, options: AxiosRequestConfig): Promise<AxiosResponse> {
return this.instance.get(url, options)
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { Table } from 'console-table-printer'
import { ComplexOptions } from 'console-table-printer/dist/src/models/external-table'
import { Table } from 'console-table-printer'
export class CliTable {
table: Table
-113
View File
@@ -1,113 +0,0 @@
import { ApiClient } from './apiClient'
import { Storage } from '@freearhey/core'
import cliProgress, { MultiBar } from 'cli-progress'
import type { DataLoaderProps, DataLoaderData } from '../types/dataLoader'
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
export class DataLoader {
client: ApiClient
storage: Storage
progressBar: MultiBar
constructor(props: DataLoaderProps) {
this.client = new ApiClient()
this.storage = props.storage
this.progressBar = new cliProgress.MultiBar({
stopOnComplete: true,
hideCursor: true,
forceRedraw: true,
barsize: 36,
format(options, params, payload) {
const filename = payload.filename.padEnd(18, ' ')
const barsize = options.barsize || 40
const percent = (params.progress * 100).toFixed(2)
const speed = payload.speed ? formatBytes(payload.speed) + '/s' : 'N/A'
const total = formatBytes(params.total)
const completeSize = Math.round(params.progress * barsize)
const incompleteSize = barsize - completeSize
const bar =
options.barCompleteString && options.barIncompleteString
? options.barCompleteString.substr(0, completeSize) +
options.barGlue +
options.barIncompleteString.substr(0, incompleteSize)
: '-'.repeat(barsize)
return `${filename} [${bar}] ${percent}% | ETA: ${params.eta}s | ${total} | ${speed}`
}
})
}
async load(): Promise<DataLoaderData> {
const [
countries,
regions,
subdivisions,
languages,
categories,
blocklist,
channels,
feeds,
logos,
timezones,
guides,
streams,
cities
] = await Promise.all([
this.storage.json('countries.json'),
this.storage.json('regions.json'),
this.storage.json('subdivisions.json'),
this.storage.json('languages.json'),
this.storage.json('categories.json'),
this.storage.json('blocklist.json'),
this.storage.json('channels.json'),
this.storage.json('feeds.json'),
this.storage.json('logos.json'),
this.storage.json('timezones.json'),
this.storage.json('guides.json'),
this.storage.json('streams.json'),
this.storage.json('cities.json')
])
return {
countries,
regions,
subdivisions,
languages,
categories,
blocklist,
channels,
feeds,
logos,
timezones,
guides,
streams,
cities
}
}
async download(filename: string) {
if (!this.storage || !this.progressBar) return
const stream = await this.storage.createStream(filename)
const progressBar = this.progressBar.create(0, 0, { filename })
this.client
.get(filename, {
responseType: 'stream',
onDownloadProgress({ total, loaded, rate }) {
if (total) progressBar.setTotal(total)
progressBar.update(loaded, { speed: rate })
}
})
.then(response => {
response.data.pipe(stream)
})
}
}
-165
View File
@@ -1,165 +0,0 @@
import { DataProcessorData } from '../types/dataProcessor'
import { DataLoaderData } from '../types/dataLoader'
import { Collection } from '@freearhey/core'
import {
BlocklistRecord,
Subdivision,
Category,
Language,
Timezone,
Channel,
Country,
Region,
Stream,
Guide,
City,
Feed,
Logo
} from '../models'
export class DataProcessor {
process(data: DataLoaderData): DataProcessorData {
let regions = new Collection(data.regions).map(data => new Region(data))
let regionsKeyByCode = regions.keyBy((region: Region) => region.code)
const categories = new Collection(data.categories).map(data => new Category(data))
const categoriesKeyById = categories.keyBy((category: Category) => category.id)
const languages = new Collection(data.languages).map(data => new Language(data))
const languagesKeyByCode = languages.keyBy((language: Language) => language.code)
let subdivisions = new Collection(data.subdivisions).map(data => new Subdivision(data))
let subdivisionsKeyByCode = subdivisions.keyBy((subdivision: Subdivision) => subdivision.code)
let subdivisionsGroupedByCountryCode = subdivisions.groupBy(
(subdivision: Subdivision) => subdivision.countryCode
)
let countries = new Collection(data.countries).map(data => new Country(data))
let countriesKeyByCode = countries.keyBy((country: Country) => country.code)
const cities = new Collection(data.cities).map(data =>
new City(data)
.withRegions(regions)
.withCountry(countriesKeyByCode)
.withSubdivision(subdivisionsKeyByCode)
)
const citiesKeyByCode = cities.keyBy((city: City) => city.code)
const citiesGroupedByCountryCode = cities.groupBy((city: City) => city.countryCode)
const citiesGroupedBySubdivisionCode = cities.groupBy((city: City) => city.subdivisionCode)
const timezones = new Collection(data.timezones).map(data =>
new Timezone(data).withCountries(countriesKeyByCode)
)
const timezonesKeyById = timezones.keyBy((timezone: Timezone) => timezone.id)
const blocklistRecords = new Collection(data.blocklist).map(data => new BlocklistRecord(data))
const blocklistRecordsGroupedByChannelId = blocklistRecords.groupBy(
(blocklistRecord: BlocklistRecord) => blocklistRecord.channelId
)
let channels = new Collection(data.channels).map(data => new Channel(data))
let channelsKeyById = channels.keyBy((channel: Channel) => channel.id)
let feeds = new Collection(data.feeds).map(data => new Feed(data))
let feedsGroupedByChannelId = feeds.groupBy((feed: Feed) => feed.channelId)
let feedsGroupedById = feeds.groupBy((feed: Feed) => feed.id)
const logos = new Collection(data.logos).map(data => new Logo(data).withFeed(feedsGroupedById))
const logosGroupedByChannelId = logos.groupBy((logo: Logo) => logo.channelId)
const logosGroupedByStreamId = logos.groupBy((logo: Logo) => logo.getStreamId())
const streams = new Collection(data.streams).map(data =>
new Stream(data).withLogos(logosGroupedByStreamId)
)
const streamsGroupedById = streams.groupBy((stream: Stream) => stream.getId())
const guides = new Collection(data.guides).map(data => new Guide(data))
const guidesGroupedByStreamId = guides.groupBy((guide: Guide) => guide.getStreamId())
regions = regions.map((region: Region) =>
region
.withCountries(countriesKeyByCode)
.withRegions(regions)
.withSubdivisions(subdivisions)
.withCities(cities)
)
regionsKeyByCode = regions.keyBy((region: Region) => region.code)
countries = countries.map((country: Country) =>
country
.withCities(citiesGroupedByCountryCode)
.withSubdivisions(subdivisionsGroupedByCountryCode)
.withRegions(regions)
.withLanguage(languagesKeyByCode)
)
countriesKeyByCode = countries.keyBy((country: Country) => country.code)
subdivisions = subdivisions.map((subdivision: Subdivision) =>
subdivision
.withCities(citiesGroupedBySubdivisionCode)
.withCountry(countriesKeyByCode)
.withRegions(regions)
.withParent(subdivisionsKeyByCode)
)
subdivisionsKeyByCode = subdivisions.keyBy((subdivision: Subdivision) => subdivision.code)
subdivisionsGroupedByCountryCode = subdivisions.groupBy(
(subdivision: Subdivision) => subdivision.countryCode
)
channels = channels.map((channel: Channel) =>
channel
.withFeeds(feedsGroupedByChannelId)
.withLogos(logosGroupedByChannelId)
.withCategories(categoriesKeyById)
.withCountry(countriesKeyByCode)
.withSubdivision(subdivisionsKeyByCode)
.withCategories(categoriesKeyById)
)
channelsKeyById = channels.keyBy((channel: Channel) => channel.id)
feeds = feeds.map((feed: Feed) =>
feed
.withChannel(channelsKeyById)
.withLanguages(languagesKeyByCode)
.withTimezones(timezonesKeyById)
.withBroadcastArea(
citiesKeyByCode,
subdivisionsKeyByCode,
countriesKeyByCode,
regionsKeyByCode
)
)
feedsGroupedByChannelId = feeds.groupBy((feed: Feed) => feed.channelId)
feedsGroupedById = feeds.groupBy((feed: Feed) => feed.id)
return {
blocklistRecordsGroupedByChannelId,
subdivisionsGroupedByCountryCode,
feedsGroupedByChannelId,
guidesGroupedByStreamId,
logosGroupedByStreamId,
subdivisionsKeyByCode,
countriesKeyByCode,
languagesKeyByCode,
streamsGroupedById,
categoriesKeyById,
timezonesKeyById,
regionsKeyByCode,
blocklistRecords,
channelsKeyById,
citiesKeyByCode,
subdivisions,
categories,
countries,
languages,
timezones,
channels,
regions,
streams,
cities,
guides,
feeds,
logos
}
}
}
+14 -10
View File
@@ -1,16 +1,18 @@
type Column = {
import { Collection } from '@freearhey/core'
export type HTMLTableColumn = {
name: string
nowrap?: boolean
align?: string
}
type DataItem = string[]
export type HTMLTableItem = string[]
export class HTMLTable {
data: DataItem[]
columns: Column[]
data: Collection<HTMLTableItem>
columns: Collection<HTMLTableColumn>
constructor(data: DataItem[], columns: Column[]) {
constructor(data: Collection<HTMLTableItem>, columns: Collection<HTMLTableColumn>) {
this.data = data
this.columns = columns
}
@@ -19,24 +21,26 @@ export class HTMLTable {
let output = '<table>\r\n'
output += ' <thead>\r\n <tr>'
for (const column of this.columns) {
this.columns.forEach((column: HTMLTableColumn) => {
output += `<th align="left">${column.name}</th>`
}
})
output += '</tr>\r\n </thead>\r\n'
output += ' <tbody>\r\n'
for (const item of this.data) {
this.data.forEach((item: HTMLTableItem) => {
output += ' <tr>'
let i = 0
for (const prop in item) {
const column = this.columns[i]
const column = this.columns.all()[i]
const nowrap = column.nowrap ? ' nowrap' : ''
const align = column.align ? ` align="${column.align}"` : ''
output += `<td${align}${nowrap}>${item[prop]}</td>`
i++
}
output += '</tr>\r\n'
}
})
output += ' </tbody>\r\n'
output += '</table>'
-3
View File
@@ -1,7 +1,4 @@
export * from './apiClient'
export * from './cliTable'
export * from './dataProcessor'
export * from './dataLoader'
export * from './htmlTable'
export * from './issueData'
export * from './issueLoader'
+5 -3
View File
@@ -1,8 +1,8 @@
import { Dictionary } from '@freearhey/core'
export class IssueData {
_data: Dictionary
constructor(data: Dictionary) {
_data: Dictionary<string>
constructor(data: Dictionary<string>) {
this._data = data
}
@@ -29,6 +29,8 @@ export class IssueData {
if (this._data.missing(key)) return undefined
return this._data.get(key) === deleteSymbol ? [] : this._data.get(key).split('\r\n')
const value = this._data.get(key)
return !value || value === deleteSymbol ? [] : value.split('\r\n')
}
}
+2 -2
View File
@@ -1,9 +1,9 @@
import { Collection } from '@freearhey/core'
import { restEndpointMethods } from '@octokit/plugin-rest-endpoint-methods'
import { paginateRest } from '@octokit/plugin-paginate-rest'
import { TESTING, OWNER, REPO } from '../constants'
import { Collection } from '@freearhey/core'
import { Octokit } from '@octokit/core'
import { IssueParser } from './'
import { TESTING, OWNER, REPO } from '../constants'
const CustomOctokit = Octokit.plugin(paginateRest, restEndpointMethods)
const octokit = new CustomOctokit()
+3 -3
View File
@@ -1,6 +1,6 @@
import { Dictionary } from '@freearhey/core'
import { Issue } from '../models'
import { IssueData } from './issueData'
import { Issue } from '../models'
const FIELDS = new Dictionary({
'Stream ID': 'streamId',
@@ -23,7 +23,7 @@ export class IssueParser {
parse(issue: { number: number; body: string; labels: { name: string }[] }): Issue {
const fields = typeof issue.body === 'string' ? issue.body.split('###') : []
const data = new Dictionary()
const data = new Dictionary<string>()
fields.forEach((field: string) => {
const parsed = typeof field === 'string' ? field.split(/\r?\n/).filter(Boolean) : []
let _label = parsed.shift()
@@ -33,7 +33,7 @@ export class IssueParser {
if (!_label || !_value) return data
const id: string = FIELDS.get(_label)
const id = FIELDS.get(_label)
const value: string = _value === '_No response_' || _value === 'None' ? '' : _value
if (!id) return
+1 -1
View File
@@ -1,5 +1,5 @@
import fs from 'fs'
import path from 'path'
import fs from 'fs'
type MarkdownConfig = {
build: string
+14 -31
View File
@@ -1,58 +1,41 @@
import { Collection, Storage, Dictionary } from '@freearhey/core'
import { Storage } from '@freearhey/storage-js'
import { Collection } from '@freearhey/core'
import parser from 'iptv-playlist-parser'
import { Stream } from '../models'
type PlaylistPareserProps = {
storage: Storage
feedsGroupedByChannelId: Dictionary
logosGroupedByStreamId: Dictionary
channelsKeyById: Dictionary
}
export class PlaylistParser {
storage: Storage
feedsGroupedByChannelId: Dictionary
logosGroupedByStreamId: Dictionary
channelsKeyById: Dictionary
constructor({
storage,
feedsGroupedByChannelId,
logosGroupedByStreamId,
channelsKeyById
}: PlaylistPareserProps) {
constructor({ storage }: PlaylistPareserProps) {
this.storage = storage
this.feedsGroupedByChannelId = feedsGroupedByChannelId
this.logosGroupedByStreamId = logosGroupedByStreamId
this.channelsKeyById = channelsKeyById
}
async parse(files: string[]): Promise<Collection> {
let streams = new Collection()
async parse(files: string[]): Promise<Collection<Stream>> {
const parsed = new Collection<Stream>()
for (const filepath of files) {
if (!this.storage.existsSync(filepath)) continue
const _streams: Collection = await this.parseFile(filepath)
streams = streams.concat(_streams)
const _parsed: Collection<Stream> = await this.parseFile(filepath)
parsed.concat(_parsed)
}
return streams
return parsed
}
async parseFile(filepath: string): Promise<Collection> {
async parseFile(filepath: string): Promise<Collection<Stream>> {
const content = await this.storage.load(filepath)
const parsed: parser.Playlist = parser.parse(content)
const streams = new Collection(parsed.items).map((data: parser.PlaylistItem) => {
const stream = new Stream()
.fromPlaylistItem(data)
.withFeed(this.feedsGroupedByChannelId)
.withChannel(this.channelsKeyById)
.withLogos(this.logosGroupedByStreamId)
.setFilepath(filepath)
const streams = new Collection<Stream>()
parsed.items.forEach((data: parser.PlaylistItem) => {
const stream = Stream.fromPlaylistItem(data)
stream.filepath = filepath
return stream
streams.add(stream)
})
return streams
+22 -14
View File
@@ -1,18 +1,25 @@
import { Stream } from '../models'
import { TESTING } from '../constants'
import mediaInfoFactory from 'mediainfo.js'
import axios, { AxiosInstance, AxiosProxyConfig, AxiosRequestConfig } from 'axios'
import { ProxyParser } from './proxyParser.js'
import { OptionValues } from 'commander'
import axios, { AxiosInstance, AxiosProxyConfig, AxiosRequestConfig, AxiosResponse } from 'axios'
import { SocksProxyAgent } from 'socks-proxy-agent'
import { ProxyParser } from './proxyParser.js'
import mediaInfoFactory from 'mediainfo.js'
import { OptionValues } from 'commander'
import { TESTING } from '../constants'
import { Stream } from '../models'
export type TestResult = {
export type StreamTesterResult = {
status: {
ok: boolean
code: string
}
}
export type StreamTesterError = {
name: string
code?: string
cause?: Error & { code?: string }
response?: AxiosResponse
}
export type StreamTesterProps = {
options: OptionValues
}
@@ -46,7 +53,7 @@ export class StreamTester {
this.options = options
}
async test(stream: Stream): Promise<TestResult> {
async test(stream: Stream): Promise<StreamTesterResult> {
if (TESTING) {
const results = (await import('../../tests/__data__/input/playlist_test/results.js')).default
@@ -56,8 +63,8 @@ export class StreamTester {
const res = await this.client(stream.url, {
signal: AbortSignal.timeout(this.options.timeout),
headers: {
'User-Agent': stream.getUserAgent() || 'Mozilla/5.0',
Referer: stream.getReferrer()
'User-Agent': stream.user_agent || 'Mozilla/5.0',
Referer: stream.referrer
}
})
@@ -65,8 +72,7 @@ export class StreamTester {
const buffer = await res.data
const result = await mediainfo.analyzeData(
() => buffer.byteLength,
(size: any, offset: number | undefined) =>
Buffer.from(buffer).subarray(offset, offset + size)
(size: number, offset: number) => Buffer.from(buffer).subarray(offset, offset + size)
)
if (result && result.media && result.media.track.length > 0) {
@@ -84,7 +90,9 @@ export class StreamTester {
}
}
}
} catch (error: any) {
} catch (err: unknown) {
const error = err as StreamTesterError
let code = 'UNKNOWN_ERROR'
if (error.name === 'CanceledError') {
code = 'TIMEOUT'
@@ -97,7 +105,7 @@ export class StreamTester {
code = `AXIOS_${error.code}`
}
} else if (error.cause) {
const cause = error.cause as Error & { code?: string }
const cause = error.cause
if (cause.code) {
code = cause.code
} else {
+16 -10
View File
@@ -1,17 +1,19 @@
import { Collection, Storage, File } from '@freearhey/core'
import { Stream, Category, Playlist } from '../models'
import { Storage, File } from '@freearhey/storage-js'
import { PUBLIC_DIR, EOL } from '../constants'
import { Collection } from '@freearhey/core'
import { Stream, Playlist } from '../models'
import { Generator } from './generator'
import * as sdk from '@iptv-org/sdk'
type CategoriesGeneratorProps = {
streams: Collection
categories: Collection
streams: Collection<Stream>
categories: Collection<sdk.Models.Category>
logFile: File
}
export class CategoriesGenerator implements Generator {
streams: Collection
categories: Collection
streams: Collection<Stream>
categories: Collection<sdk.Models.Category>
storage: Storage
logFile: File
@@ -23,13 +25,17 @@ export class CategoriesGenerator implements Generator {
}
async generate() {
const streams = this.streams.orderBy([(stream: Stream) => stream.getTitle()])
const streams = this.streams.sortBy([(stream: Stream) => stream.title])
this.categories.forEach(async (category: Category) => {
this.categories.forEach(async (category: sdk.Models.Category) => {
const categoryStreams = streams
.filter((stream: Stream) => stream.hasCategory(category))
.map((stream: Stream) => {
const groupTitle = stream.getCategoryNames().join(';')
const groupTitle = stream
.getCategories()
.map(category => category.name)
.sort()
.join(';')
if (groupTitle) stream.groupTitle = groupTitle
return stream
@@ -43,7 +49,7 @@ export class CategoriesGenerator implements Generator {
)
})
const undefinedStreams = streams.filter((stream: Stream) => !stream.hasCategories())
const undefinedStreams = streams.filter((stream: Stream) => stream.getCategories().isEmpty())
const playlist = new Playlist(undefinedStreams, { public: true })
const filepath = 'categories/undefined.m3u'
await this.storage.save(filepath, playlist.toString())
+23 -12
View File
@@ -1,17 +1,19 @@
import { City, Stream, Playlist } from '../models'
import { Collection, Storage, File } from '@freearhey/core'
import { Storage, File } from '@freearhey/storage-js'
import { PUBLIC_DIR, EOL } from '../constants'
import { Stream, Playlist } from '../models'
import { Collection } from '@freearhey/core'
import { Generator } from './generator'
import * as sdk from '@iptv-org/sdk'
type CitiesGeneratorProps = {
streams: Collection
cities: Collection
streams: Collection<Stream>
cities: Collection<sdk.Models.City>
logFile: File
}
export class CitiesGenerator implements Generator {
streams: Collection
cities: Collection
streams: Collection<Stream>
cities: Collection<sdk.Models.City>
storage: Storage
logFile: File
@@ -24,20 +26,29 @@ export class CitiesGenerator implements Generator {
async generate(): Promise<void> {
const streams = this.streams
.orderBy((stream: Stream) => stream.getTitle())
.sortBy((stream: Stream) => stream.title)
.filter((stream: Stream) => stream.isSFW())
this.cities.forEach(async (city: City) => {
const cityStreams = streams.filter((stream: Stream) => stream.isBroadcastInCity(city))
const streamsGroupedByCityCode = {}
streams.forEach((stream: Stream) => {
stream.getBroadcastCities().forEach((city: sdk.Models.City) => {
if (streamsGroupedByCityCode[city.code]) {
streamsGroupedByCityCode[city.code].add(stream)
} else {
streamsGroupedByCityCode[city.code] = new Collection<Stream>([stream])
}
})
})
if (cityStreams.isEmpty()) return
for (const cityCode in streamsGroupedByCityCode) {
const cityStreams = streamsGroupedByCityCode[cityCode]
const playlist = new Playlist(cityStreams, { public: true })
const filepath = `cities/${city.code.toLowerCase()}.m3u`
const filepath = `cities/${cityCode.toLowerCase()}.m3u`
await this.storage.save(filepath, playlist.toString())
this.logFile.append(
JSON.stringify({ type: 'city', filepath, count: playlist.streams.count() }) + EOL
)
})
}
}
}
+27 -15
View File
@@ -1,17 +1,19 @@
import { Country, Stream, Playlist } from '../models'
import { Collection, Storage, File } from '@freearhey/core'
import { Storage, File } from '@freearhey/storage-js'
import { PUBLIC_DIR, EOL } from '../constants'
import { Stream, Playlist } from '../models'
import { Collection } from '@freearhey/core'
import { Generator } from './generator'
import * as sdk from '@iptv-org/sdk'
type CountriesGeneratorProps = {
streams: Collection
countries: Collection
streams: Collection<Stream>
countries: Collection<sdk.Models.Country>
logFile: File
}
export class CountriesGenerator implements Generator {
streams: Collection
countries: Collection
streams: Collection<Stream>
countries: Collection<sdk.Models.Country>
storage: Storage
logFile: File
@@ -24,22 +26,30 @@ export class CountriesGenerator implements Generator {
async generate(): Promise<void> {
const streams = this.streams
.orderBy((stream: Stream) => stream.getTitle())
.sortBy((stream: Stream) => stream.title)
.filter((stream: Stream) => stream.isSFW())
this.countries.forEach(async (country: Country) => {
const countryStreams = streams.filter((stream: Stream) =>
stream.isBroadcastInCountry(country)
)
if (countryStreams.isEmpty()) return
const streamsGroupedByCountryCode = {}
streams.forEach((stream: Stream) => {
stream.getBroadcastCountries().forEach((country: sdk.Models.Country) => {
if (streamsGroupedByCountryCode[country.code]) {
streamsGroupedByCountryCode[country.code].add(stream)
} else {
streamsGroupedByCountryCode[country.code] = new Collection<Stream>([stream])
}
})
})
for (const countryCode in streamsGroupedByCountryCode) {
const countryStreams = streamsGroupedByCountryCode[countryCode]
const playlist = new Playlist(countryStreams, { public: true })
const filepath = `countries/${country.code.toLowerCase()}.m3u`
const filepath = `countries/${countryCode.toLowerCase()}.m3u`
await this.storage.save(filepath, playlist.toString())
this.logFile.append(
JSON.stringify({ type: 'country', filepath, count: playlist.streams.count() }) + EOL
)
})
}
const internationalStreams = streams.filter((stream: Stream) => stream.isInternational())
const internationalPlaylist = new Playlist(internationalStreams, { public: true })
@@ -53,7 +63,9 @@ export class CountriesGenerator implements Generator {
}) + EOL
)
const undefinedStreams = streams.filter((stream: Stream) => !stream.hasBroadcastArea())
const undefinedStreams = streams.filter((stream: Stream) =>
stream.getBroadcastAreaCodes().isEmpty()
)
const undefinedPlaylist = new Playlist(undefinedStreams, { public: true })
const undefinedFilepath = 'countries/undefined.m3u'
await this.storage.save(undefinedFilepath, undefinedPlaylist.toString())
-1
View File
@@ -5,7 +5,6 @@ export * from './indexCategoryGenerator'
export * from './indexCountryGenerator'
export * from './indexGenerator'
export * from './indexLanguageGenerator'
export * from './indexNsfwGenerator'
export * from './languagesGenerator'
export * from './rawGenerator'
export * from './regionsGenerator'
+13 -12
View File
@@ -1,15 +1,17 @@
import { Collection, Storage, File } from '@freearhey/core'
import { Stream, Playlist, Category } from '../models'
import { Storage, File } from '@freearhey/storage-js'
import { PUBLIC_DIR, EOL } from '../constants'
import { Stream, Playlist } from '../models'
import { Collection } from '@freearhey/core'
import { Generator } from './generator'
import * as sdk from '@iptv-org/sdk'
type IndexCategoryGeneratorProps = {
streams: Collection
streams: Collection<Stream>
logFile: File
}
export class IndexCategoryGenerator implements Generator {
streams: Collection
streams: Collection<Stream>
storage: Storage
logFile: File
@@ -20,27 +22,26 @@ export class IndexCategoryGenerator implements Generator {
}
async generate(): Promise<void> {
const streams = this.streams
.orderBy(stream => stream.getTitle())
.filter(stream => stream.isSFW())
const streams = this.streams.sortBy(stream => stream.title).filter(stream => stream.isSFW())
let groupedStreams = new Collection()
let groupedStreams = new Collection<Stream>()
streams.forEach((stream: Stream) => {
if (!stream.hasCategories()) {
const streamCategories = stream.getCategories()
if (streamCategories.isEmpty()) {
const streamClone = stream.clone()
streamClone.groupTitle = 'Undefined'
groupedStreams.add(streamClone)
return
}
stream.getCategories().forEach((category: Category) => {
streamCategories.forEach((category: sdk.Models.Category) => {
const streamClone = stream.clone()
streamClone.groupTitle = category.name
groupedStreams.push(streamClone)
groupedStreams.add(streamClone)
})
})
groupedStreams = groupedStreams.orderBy(stream => {
groupedStreams = groupedStreams.sortBy(stream => {
if (stream.groupTitle === 'Undefined') return 'ZZ'
return stream.groupTitle
})
+13 -9
View File
@@ -1,15 +1,17 @@
import { Collection, Storage, File } from '@freearhey/core'
import { Stream, Playlist, Country } from '../models'
import { Storage, File } from '@freearhey/storage-js'
import { PUBLIC_DIR, EOL } from '../constants'
import { Stream, Playlist } from '../models'
import { Collection } from '@freearhey/core'
import { Generator } from './generator'
import * as sdk from '@iptv-org/sdk'
type IndexCountryGeneratorProps = {
streams: Collection
streams: Collection<Stream>
logFile: File
}
export class IndexCountryGenerator implements Generator {
streams: Collection
streams: Collection<Stream>
storage: Storage
logFile: File
@@ -20,20 +22,22 @@ export class IndexCountryGenerator implements Generator {
}
async generate(): Promise<void> {
let groupedStreams = new Collection()
let groupedStreams = new Collection<Stream>()
this.streams
.orderBy((stream: Stream) => stream.getTitle())
.sortBy((stream: Stream) => stream.title)
.filter((stream: Stream) => stream.isSFW())
.forEach((stream: Stream) => {
if (!stream.hasBroadcastArea()) {
const broadcastAreaCountries = stream.getBroadcastCountries()
if (stream.getBroadcastAreaCodes().isEmpty()) {
const streamClone = stream.clone()
streamClone.groupTitle = 'Undefined'
groupedStreams.add(streamClone)
return
}
stream.getBroadcastCountries().forEach((country: Country) => {
broadcastAreaCountries.forEach((country: sdk.Models.Country) => {
const streamClone = stream.clone()
streamClone.groupTitle = country.name
groupedStreams.add(streamClone)
@@ -46,7 +50,7 @@ export class IndexCountryGenerator implements Generator {
}
})
groupedStreams = groupedStreams.orderBy((stream: Stream) => {
groupedStreams = groupedStreams.sortBy((stream: Stream) => {
if (stream.groupTitle === 'International') return 'ZZ'
if (stream.groupTitle === 'Undefined') return 'ZZZ'
+11 -6
View File
@@ -1,15 +1,16 @@
import { Collection, File, Storage } from '@freearhey/core'
import { Stream, Playlist } from '../models'
import { Storage, File } from '@freearhey/storage-js'
import { PUBLIC_DIR, EOL } from '../constants'
import { Stream, Playlist } from '../models'
import { Collection } from '@freearhey/core'
import { Generator } from './generator'
type IndexGeneratorProps = {
streams: Collection
streams: Collection<Stream>
logFile: File
}
export class IndexGenerator implements Generator {
streams: Collection
streams: Collection<Stream>
storage: Storage
logFile: File
@@ -21,10 +22,14 @@ export class IndexGenerator implements Generator {
async generate(): Promise<void> {
const sfwStreams = this.streams
.orderBy(stream => stream.getTitle())
.sortBy(stream => stream.title)
.filter((stream: Stream) => stream.isSFW())
.map((stream: Stream) => {
const groupTitle = stream.getCategoryNames().join(';')
const groupTitle = stream
.getCategories()
.map(category => category.name)
.sort()
.join(';')
if (groupTitle) stream.groupTitle = groupTitle
return stream
+12 -9
View File
@@ -1,15 +1,17 @@
import { Collection, Storage, File } from '@freearhey/core'
import { Stream, Playlist, Language } from '../models'
import { Storage, File } from '@freearhey/storage-js'
import { PUBLIC_DIR, EOL } from '../constants'
import { Stream, Playlist } from '../models'
import { Collection } from '@freearhey/core'
import { Generator } from './generator'
import * as sdk from '@iptv-org/sdk'
type IndexLanguageGeneratorProps = {
streams: Collection
streams: Collection<Stream>
logFile: File
}
export class IndexLanguageGenerator implements Generator {
streams: Collection
streams: Collection<Stream>
storage: Storage
logFile: File
@@ -20,26 +22,27 @@ export class IndexLanguageGenerator implements Generator {
}
async generate(): Promise<void> {
let groupedStreams = new Collection()
let groupedStreams = new Collection<Stream>()
this.streams
.orderBy((stream: Stream) => stream.getTitle())
.sortBy((stream: Stream) => stream.title)
.filter((stream: Stream) => stream.isSFW())
.forEach((stream: Stream) => {
if (!stream.hasLanguages()) {
const streamLanguages = stream.getLanguages()
if (streamLanguages.isEmpty()) {
const streamClone = stream.clone()
streamClone.groupTitle = 'Undefined'
groupedStreams.add(streamClone)
return
}
stream.getLanguages().forEach((language: Language) => {
streamLanguages.forEach((language: sdk.Models.Language) => {
const streamClone = stream.clone()
streamClone.groupTitle = language.name
groupedStreams.add(streamClone)
})
})
groupedStreams = groupedStreams.orderBy((stream: Stream) => {
groupedStreams = groupedStreams.sortBy((stream: Stream) => {
if (stream.groupTitle === 'Undefined') return 'ZZ'
return stream.groupTitle
})
-32
View File
@@ -1,32 +0,0 @@
import { Collection, File, Storage } from '@freearhey/core'
import { Stream, Playlist } from '../models'
import { PUBLIC_DIR, EOL } from '../constants'
import { Generator } from './generator'
type IndexNsfwGeneratorProps = {
streams: Collection
logFile: File
}
export class IndexNsfwGenerator implements Generator {
streams: Collection
storage: Storage
logFile: File
constructor({ streams, logFile }: IndexNsfwGeneratorProps) {
this.streams = streams.clone()
this.storage = new Storage(PUBLIC_DIR)
this.logFile = logFile
}
async generate(): Promise<void> {
const allStreams = this.streams.orderBy((stream: Stream) => stream.getTitle())
const playlist = new Playlist(allStreams, { public: true })
const filepath = 'index.nsfw.m3u'
await this.storage.save(filepath, playlist.toString())
this.logFile.append(
JSON.stringify({ type: 'index', filepath, count: playlist.streams.count() }) + EOL
)
}
}
+14 -13
View File
@@ -1,12 +1,14 @@
import { Collection, Storage, File } from '@freearhey/core'
import { Playlist, Language, Stream } from '../models'
import { Storage, File } from '@freearhey/storage-js'
import { PUBLIC_DIR, EOL } from '../constants'
import { Playlist, Stream } from '../models'
import { Collection } from '@freearhey/core'
import { Generator } from './generator'
import * as sdk from '@iptv-org/sdk'
type LanguagesGeneratorProps = { streams: Collection; logFile: File }
type LanguagesGeneratorProps = { streams: Collection<Stream>; logFile: File }
export class LanguagesGenerator implements Generator {
streams: Collection
streams: Collection<Stream>
storage: Storage
logFile: File
@@ -17,20 +19,20 @@ export class LanguagesGenerator implements Generator {
}
async generate(): Promise<void> {
const streams = this.streams
.orderBy((stream: Stream) => stream.getTitle())
const streams: Collection<Stream> = this.streams
.sortBy((stream: Stream) => stream.title)
.filter((stream: Stream) => stream.isSFW())
let languages = new Collection()
const languages = new Collection<sdk.Models.Language>()
streams.forEach((stream: Stream) => {
languages = languages.concat(stream.getLanguages())
languages.concat(stream.getLanguages())
})
languages
.filter(Boolean)
.uniqBy((language: Language) => language.code)
.orderBy((language: Language) => language.name)
.forEach(async (language: Language) => {
.uniqBy((language: sdk.Models.Language) => language.code)
.sortBy((language: sdk.Models.Language) => language.name)
.forEach(async (language: sdk.Models.Language) => {
const languageStreams = streams.filter((stream: Stream) => stream.hasLanguage(language))
if (languageStreams.isEmpty()) return
@@ -43,8 +45,7 @@ export class LanguagesGenerator implements Generator {
)
})
const undefinedStreams = streams.filter((stream: Stream) => !stream.hasLanguages())
const undefinedStreams = streams.filter((stream: Stream) => stream.getLanguages().isEmpty())
if (undefinedStreams.isEmpty()) return
const playlist = new Playlist(undefinedStreams, { public: true })
+10 -5
View File
@@ -1,15 +1,16 @@
import { Collection, Storage, File } from '@freearhey/core'
import { Stream, Playlist } from '../models'
import { Storage, File } from '@freearhey/storage-js'
import { PUBLIC_DIR, EOL } from '../constants'
import { Stream, Playlist } from '../models'
import { Collection } from '@freearhey/core'
import { Generator } from './generator'
type RawGeneratorProps = {
streams: Collection
streams: Collection<Stream>
logFile: File
}
export class RawGenerator implements Generator {
streams: Collection
streams: Collection<Stream>
storage: Storage
logFile: File
@@ -24,7 +25,11 @@ export class RawGenerator implements Generator {
for (const filename of files.keys()) {
const streams = new Collection(files.get(filename)).map((stream: Stream) => {
const groupTitle = stream.getCategoryNames().join(';')
const groupTitle = stream
.getCategories()
.map(category => category.name)
.sort()
.join(';')
if (groupTitle) stream.groupTitle = groupTitle
return stream
+24 -11
View File
@@ -1,17 +1,19 @@
import { Collection, Storage, File } from '@freearhey/core'
import { Playlist, Region, Stream } from '../models'
import { Storage, File } from '@freearhey/storage-js'
import { PUBLIC_DIR, EOL } from '../constants'
import { Playlist, Stream } from '../models'
import { Collection } from '@freearhey/core'
import { Generator } from './generator'
import * as sdk from '@iptv-org/sdk'
type RegionsGeneratorProps = {
streams: Collection
regions: Collection
streams: Collection<Stream>
regions: Collection<sdk.Models.Region>
logFile: File
}
export class RegionsGenerator implements Generator {
streams: Collection
regions: Collection
streams: Collection<Stream>
regions: Collection<sdk.Models.Region>
storage: Storage
logFile: File
@@ -24,18 +26,29 @@ export class RegionsGenerator implements Generator {
async generate(): Promise<void> {
const streams = this.streams
.orderBy((stream: Stream) => stream.getTitle())
.sortBy((stream: Stream) => stream.title)
.filter((stream: Stream) => stream.isSFW())
this.regions.forEach(async (region: Region) => {
const regionStreams = streams.filter((stream: Stream) => stream.isBroadcastInRegion(region))
const streamsGroupedByRegionCode = {}
streams.forEach((stream: Stream) => {
stream.getBroadcastRegions().forEach((region: sdk.Models.Region) => {
if (streamsGroupedByRegionCode[region.code]) {
streamsGroupedByRegionCode[region.code].add(stream)
} else {
streamsGroupedByRegionCode[region.code] = new Collection<Stream>([stream])
}
})
})
for (const regionCode in streamsGroupedByRegionCode) {
const regionStreams = streamsGroupedByRegionCode[regionCode]
const playlist = new Playlist(regionStreams, { public: true })
const filepath = `regions/${region.code.toLowerCase()}.m3u`
const filepath = `regions/${regionCode.toLowerCase()}.m3u`
await this.storage.save(filepath, playlist.toString())
this.logFile.append(
JSON.stringify({ type: 'region', filepath, count: playlist.streams.count() }) + EOL
)
})
}
}
}
+14 -8
View File
@@ -1,15 +1,16 @@
import { Collection, Storage, File, type Dictionary } from '@freearhey/core'
import { Stream, Playlist } from '../models'
import { Collection, Dictionary } from '@freearhey/core'
import { Storage, File } from '@freearhey/storage-js'
import { PUBLIC_DIR, EOL } from '../constants'
import { Stream, Playlist } from '../models'
import { Generator } from './generator'
type SourcesGeneratorProps = {
streams: Collection
streams: Collection<Stream>
logFile: File
}
export class SourcesGenerator implements Generator {
streams: Collection
streams: Collection<Stream>
storage: Storage
logFile: File
@@ -20,14 +21,19 @@ export class SourcesGenerator implements Generator {
}
async generate() {
const files: Dictionary = this.streams.groupBy((stream: Stream) => stream.getFilename())
const files: Dictionary<Stream[]> = this.streams.groupBy((stream: Stream) =>
stream.getFilename()
)
for (const filename of files.keys()) {
if (!filename) continue
let streams = new Collection(files.get(filename))
streams = streams.map((stream: Stream) => {
const groupTitle = stream.getCategoryNames().join(';')
const streams = new Collection<Stream>(files.get(filename)).map((stream: Stream) => {
const groupTitle = stream
.getCategories()
.map(category => category.name)
.sort()
.join(';')
if (groupTitle) stream.groupTitle = groupTitle
return stream
+23 -14
View File
@@ -1,17 +1,19 @@
import { Subdivision, Stream, Playlist } from '../models'
import { Collection, Storage, File } from '@freearhey/core'
import { Storage, File } from '@freearhey/storage-js'
import { PUBLIC_DIR, EOL } from '../constants'
import { Stream, Playlist } from '../models'
import { Collection } from '@freearhey/core'
import { Generator } from './generator'
import * as sdk from '@iptv-org/sdk'
type SubdivisionsGeneratorProps = {
streams: Collection
subdivisions: Collection
streams: Collection<Stream>
subdivisions: Collection<sdk.Models.Subdivision>
logFile: File
}
export class SubdivisionsGenerator implements Generator {
streams: Collection
subdivisions: Collection
streams: Collection<Stream>
subdivisions: Collection<sdk.Models.Subdivision>
storage: Storage
logFile: File
@@ -24,22 +26,29 @@ export class SubdivisionsGenerator implements Generator {
async generate(): Promise<void> {
const streams = this.streams
.orderBy((stream: Stream) => stream.getTitle())
.sortBy((stream: Stream) => stream.title)
.filter((stream: Stream) => stream.isSFW())
this.subdivisions.forEach(async (subdivision: Subdivision) => {
const subdivisionStreams = streams.filter((stream: Stream) =>
stream.isBroadcastInSubdivision(subdivision)
)
const streamsGroupedBySubdivisionCode = {}
streams.forEach((stream: Stream) => {
stream.getBroadcastSubdivisions().forEach((subdivision: sdk.Models.Subdivision) => {
if (streamsGroupedBySubdivisionCode[subdivision.code]) {
streamsGroupedBySubdivisionCode[subdivision.code].add(stream)
} else {
streamsGroupedBySubdivisionCode[subdivision.code] = new Collection<Stream>([stream])
}
})
})
if (subdivisionStreams.isEmpty()) return
for (const subdivisionCode in streamsGroupedBySubdivisionCode) {
const subdivisionStreams = streamsGroupedBySubdivisionCode[subdivisionCode]
const playlist = new Playlist(subdivisionStreams, { public: true })
const filepath = `subdivisions/${subdivision.code.toLowerCase()}.m3u`
const filepath = `subdivisions/${subdivisionCode.toLowerCase()}.m3u`
await this.storage.save(filepath, playlist.toString())
this.logFile.append(
JSON.stringify({ type: 'subdivision', filepath, count: playlist.streams.count() }) + EOL
)
})
}
}
}
-15
View File
@@ -1,15 +0,0 @@
import type { BlocklistRecordData } from '../types/blocklistRecord'
export class BlocklistRecord {
channelId: string
reason: string
ref: string
constructor(data?: BlocklistRecordData) {
if (!data) return
this.channelId = data.channel
this.reason = data.reason
this.ref = data.ref
}
}
-108
View File
@@ -1,108 +0,0 @@
import { Collection, Dictionary } from '@freearhey/core'
import { City, Subdivision, Region, Country } from './'
export class BroadcastArea {
codes: Collection
citiesIncluded: Collection
subdivisionsIncluded: Collection
countriesIncluded: Collection
regionsIncluded: Collection
constructor(codes: Collection) {
this.codes = codes
}
withLocations(
citiesKeyByCode: Dictionary,
subdivisionsKeyByCode: Dictionary,
countriesKeyByCode: Dictionary,
regionsKeyByCode: Dictionary
): this {
const citiesIncluded = new Collection()
const subdivisionsIncluded = new Collection()
const countriesIncluded = new Collection()
let regionsIncluded = new Collection()
this.codes.forEach((value: string) => {
const [type, code] = value.split('/')
switch (type) {
case 'ct': {
const city: City = citiesKeyByCode.get(code)
if (!city) return
citiesIncluded.add(city)
if (city.subdivision) subdivisionsIncluded.add(city.subdivision)
if (city.subdivision && city.subdivision.parent)
subdivisionsIncluded.add(city.subdivision.parent)
if (city.country) countriesIncluded.add(city.country)
regionsIncluded = regionsIncluded.concat(city.getRegions())
break
}
case 's': {
const subdivision: Subdivision = subdivisionsKeyByCode.get(code)
if (!subdivision) return
subdivisionsIncluded.add(subdivision)
if (subdivision.country) countriesIncluded.add(subdivision.country)
regionsIncluded = regionsIncluded.concat(subdivision.getRegions())
break
}
case 'c': {
const country: Country = countriesKeyByCode.get(code)
if (!country) return
countriesIncluded.add(country)
regionsIncluded = regionsIncluded.concat(country.getRegions())
break
}
case 'r': {
const region: Region = regionsKeyByCode.get(code)
if (!region) return
regionsIncluded = regionsIncluded.concat(region.getRegions())
break
}
}
})
this.citiesIncluded = citiesIncluded.uniqBy((city: City) => city.code)
this.subdivisionsIncluded = subdivisionsIncluded.uniqBy(
(subdivision: Subdivision) => subdivision.code
)
this.countriesIncluded = countriesIncluded.uniqBy((country: Country) => country.code)
this.regionsIncluded = regionsIncluded.uniqBy((region: Region) => region.code)
return this
}
getCountries(): Collection {
return this.countriesIncluded || new Collection()
}
getSubdivisions(): Collection {
return this.subdivisionsIncluded || new Collection()
}
getCities(): Collection {
return this.citiesIncluded || new Collection()
}
getRegions(): Collection {
return this.regionsIncluded || new Collection()
}
includesCountry(country: Country): boolean {
return this.getCountries().includes((_country: Country) => _country.code === country.code)
}
includesSubdivision(subdivision: Subdivision): boolean {
return this.getSubdivisions().includes(
(_subdivision: Subdivision) => _subdivision.code === subdivision.code
)
}
includesRegion(region: Region): boolean {
return this.getRegions().includes((_region: Region) => _region.code === region.code)
}
includesCity(city: City): boolean {
return this.getCities().includes((_city: City) => _city.code === city.code)
}
}
-18
View File
@@ -1,18 +0,0 @@
import type { CategoryData, CategorySerializedData } from '../types/category'
export class Category {
id: string
name: string
constructor(data: CategoryData) {
this.id = data.id
this.name = data.name
}
serialize(): CategorySerializedData {
return {
id: this.id,
name: this.name
}
}
}
-233
View File
@@ -1,233 +0,0 @@
import { Collection, Dictionary } from '@freearhey/core'
import { Category, Country, Feed, Guide, Logo, Stream, Subdivision } from './index'
import type { ChannelData, ChannelSearchableData, ChannelSerializedData } from '../types/channel'
export class Channel {
id: string
name: string
altNames: Collection
network?: string
owners: Collection
countryCode: string
country?: Country
subdivisionCode?: string
subdivision?: Subdivision
cityName?: string
categoryIds: Collection
categories: Collection = new Collection()
isNSFW: boolean
launched?: string
closed?: string
replacedBy?: string
isClosed: boolean
website?: string
feeds?: Collection
logos: Collection = new Collection()
constructor(data?: ChannelData) {
if (!data) return
this.id = data.id
this.name = data.name
this.altNames = new Collection(data.alt_names)
this.network = data.network || undefined
this.owners = new Collection(data.owners)
this.countryCode = data.country
this.subdivisionCode = data.subdivision || undefined
this.cityName = data.city || undefined
this.categoryIds = new Collection(data.categories)
this.isNSFW = data.is_nsfw
this.launched = data.launched || undefined
this.closed = data.closed || undefined
this.replacedBy = data.replaced_by || undefined
this.website = data.website || undefined
this.isClosed = !!data.closed || !!data.replaced_by
}
withSubdivision(subdivisionsKeyByCode: Dictionary): this {
if (!this.subdivisionCode) return this
this.subdivision = subdivisionsKeyByCode.get(this.subdivisionCode)
return this
}
withCountry(countriesKeyByCode: Dictionary): this {
this.country = countriesKeyByCode.get(this.countryCode)
return this
}
withCategories(categoriesKeyById: Dictionary): this {
this.categories = this.categoryIds
.map((id: string) => categoriesKeyById.get(id))
.filter(Boolean)
return this
}
withFeeds(feedsGroupedByChannelId: Dictionary): this {
this.feeds = new Collection(feedsGroupedByChannelId.get(this.id))
return this
}
withLogos(logosGroupedByChannelId: Dictionary): this {
if (this.id) this.logos = new Collection(logosGroupedByChannelId.get(this.id))
return this
}
getCountry(): Country | undefined {
return this.country
}
getSubdivision(): Subdivision | undefined {
return this.subdivision
}
getCategories(): Collection {
return this.categories || new Collection()
}
hasCategories(): boolean {
return !!this.categories && this.categories.notEmpty()
}
hasCategory(category: Category): boolean {
return (
!!this.categories &&
this.categories.includes((_category: Category) => _category.id === category.id)
)
}
getFeeds(): Collection {
if (!this.feeds) return new Collection()
return this.feeds
}
getGuides(): Collection {
let guides = new Collection()
this.getFeeds().forEach((feed: Feed) => {
guides = guides.concat(feed.getGuides())
})
return guides
}
getGuideNames(): Collection {
return this.getGuides()
.map((guide: Guide) => guide.siteName)
.uniq()
}
getStreams(): Collection {
let streams = new Collection()
this.getFeeds().forEach((feed: Feed) => {
streams = streams.concat(feed.getStreams())
})
return streams
}
getStreamTitles(): Collection {
return this.getStreams()
.map((stream: Stream) => stream.getTitle())
.uniq()
}
getFeedFullNames(): Collection {
return this.getFeeds()
.map((feed: Feed) => feed.getFullName())
.uniq()
}
isSFW(): boolean {
return this.isNSFW === false
}
getLogos(): Collection {
function feed(logo: Logo): number {
if (!logo.feed) return 1
if (logo.feed.isMain) return 1
return 0
}
function format(logo: Logo): number {
const levelByFormat = { SVG: 0, PNG: 3, APNG: 1, WebP: 1, AVIF: 1, JPEG: 2, GIF: 1 }
return logo.format ? levelByFormat[logo.format] : 0
}
function size(logo: Logo): number {
return Math.abs(512 - logo.width) + Math.abs(512 - logo.height)
}
return this.logos.orderBy([feed, format, size], ['desc', 'desc', 'asc'], false)
}
getLogo(): Logo | undefined {
return this.getLogos().first()
}
hasLogo(): boolean {
return this.getLogos().notEmpty()
}
getSearchable(): ChannelSearchableData {
return {
id: this.id,
name: this.name,
altNames: this.altNames.all(),
guideNames: this.getGuideNames().all(),
streamTitles: this.getStreamTitles().all(),
feedFullNames: this.getFeedFullNames().all()
}
}
serialize(): ChannelSerializedData {
return {
id: this.id,
name: this.name,
altNames: this.altNames.all(),
network: this.network,
owners: this.owners.all(),
countryCode: this.countryCode,
country: this.country ? this.country.serialize() : undefined,
subdivisionCode: this.subdivisionCode,
subdivision: this.subdivision ? this.subdivision.serialize() : undefined,
cityName: this.cityName,
categoryIds: this.categoryIds.all(),
categories: this.categories.map((category: Category) => category.serialize()).all(),
isNSFW: this.isNSFW,
launched: this.launched,
closed: this.closed,
replacedBy: this.replacedBy,
website: this.website
}
}
deserialize(data: ChannelSerializedData): this {
this.id = data.id
this.name = data.name
this.altNames = new Collection(data.altNames)
this.network = data.network
this.owners = new Collection(data.owners)
this.countryCode = data.countryCode
this.country = data.country ? new Country().deserialize(data.country) : undefined
this.subdivisionCode = data.subdivisionCode
this.cityName = data.cityName
this.categoryIds = new Collection(data.categoryIds)
this.isNSFW = data.isNSFW
this.launched = data.launched
this.closed = data.closed
this.replacedBy = data.replacedBy
this.website = data.website
return this
}
}
-78
View File
@@ -1,78 +0,0 @@
import { Collection, Dictionary } from '@freearhey/core'
import { Country, Region, Subdivision } from '.'
import type { CityData, CitySerializedData } from '../types/city'
export class City {
code: string
name: string
countryCode: string
country?: Country
subdivisionCode?: string
subdivision?: Subdivision
wikidataId: string
regions?: Collection
constructor(data?: CityData) {
if (!data) return
this.code = data.code
this.name = data.name
this.countryCode = data.country
this.subdivisionCode = data.subdivision || undefined
this.wikidataId = data.wikidata_id
}
withCountry(countriesKeyByCode: Dictionary): this {
this.country = countriesKeyByCode.get(this.countryCode)
return this
}
withSubdivision(subdivisionsKeyByCode: Dictionary): this {
if (!this.subdivisionCode) return this
this.subdivision = subdivisionsKeyByCode.get(this.subdivisionCode)
return this
}
withRegions(regions: Collection): this {
this.regions = regions.filter((region: Region) =>
region.countryCodes.includes(this.countryCode)
)
return this
}
getRegions(): Collection {
if (!this.regions) return new Collection()
return this.regions
}
serialize(): CitySerializedData {
return {
code: this.code,
name: this.name,
countryCode: this.countryCode,
country: this.country ? this.country.serialize() : undefined,
subdivisionCode: this.subdivisionCode || null,
subdivision: this.subdivision ? this.subdivision.serialize() : undefined,
wikidataId: this.wikidataId
}
}
deserialize(data: CitySerializedData): this {
this.code = data.code
this.name = data.name
this.countryCode = data.countryCode
this.country = data.country ? new Country().deserialize(data.country) : undefined
this.subdivisionCode = data.subdivisionCode || undefined
this.subdivision = data.subdivision
? new Subdivision().deserialize(data.subdivision)
: undefined
this.wikidataId = data.wikidataId
return this
}
}
-95
View File
@@ -1,95 +0,0 @@
import { Collection, Dictionary } from '@freearhey/core'
import { Region, Language, Subdivision } from '.'
import type { CountryData, CountrySerializedData } from '../types/country'
import { SubdivisionSerializedData } from '../types/subdivision'
import { RegionSerializedData } from '../types/region'
export class Country {
code: string
name: string
flag: string
languageCode: string
language?: Language
subdivisions?: Collection
regions?: Collection
cities?: Collection
constructor(data?: CountryData) {
if (!data) return
this.code = data.code
this.name = data.name
this.flag = data.flag
this.languageCode = data.lang
}
withSubdivisions(subdivisionsGroupedByCountryCode: Dictionary): this {
this.subdivisions = new Collection(subdivisionsGroupedByCountryCode.get(this.code))
return this
}
withRegions(regions: Collection): this {
this.regions = regions.filter((region: Region) => region.includesCountryCode(this.code))
return this
}
withCities(citiesGroupedByCountryCode: Dictionary): this {
this.cities = new Collection(citiesGroupedByCountryCode.get(this.code))
return this
}
withLanguage(languagesKeyByCode: Dictionary): this {
this.language = languagesKeyByCode.get(this.languageCode)
return this
}
getLanguage(): Language | undefined {
return this.language
}
getRegions(): Collection {
return this.regions || new Collection()
}
getSubdivisions(): Collection {
return this.subdivisions || new Collection()
}
getCities(): Collection {
return this.cities || new Collection()
}
serialize(): CountrySerializedData {
return {
code: this.code,
name: this.name,
flag: this.flag,
languageCode: this.languageCode,
language: this.language ? this.language.serialize() : null,
subdivisions: this.subdivisions
? this.subdivisions.map((subdivision: Subdivision) => subdivision.serialize()).all()
: [],
regions: this.regions ? this.regions.map((region: Region) => region.serialize()).all() : []
}
}
deserialize(data: CountrySerializedData): this {
this.code = data.code
this.name = data.name
this.flag = data.flag
this.languageCode = data.languageCode
this.language = data.language ? new Language().deserialize(data.language) : undefined
this.subdivisions = new Collection(data.subdivisions).map((data: SubdivisionSerializedData) =>
new Subdivision().deserialize(data)
)
this.regions = new Collection(data.regions).map((data: RegionSerializedData) =>
new Region().deserialize(data)
)
return this
}
}
-170
View File
@@ -1,170 +0,0 @@
import { Country, Language, Region, Channel, Subdivision, BroadcastArea, City } from './index'
import { Collection, Dictionary } from '@freearhey/core'
import type { FeedData } from '../types/feed'
export class Feed {
channelId: string
channel?: Channel
id: string
name: string
isMain: boolean
broadcastAreaCodes: Collection
broadcastArea?: BroadcastArea
languageCodes: Collection
languages?: Collection
timezoneIds: Collection
timezones?: Collection
videoFormat: string
guides?: Collection
streams?: Collection
constructor(data: FeedData) {
this.channelId = data.channel
this.id = data.id
this.name = data.name
this.isMain = data.is_main
this.broadcastAreaCodes = new Collection(data.broadcast_area)
this.languageCodes = new Collection(data.languages)
this.timezoneIds = new Collection(data.timezones)
this.videoFormat = data.video_format
}
withChannel(channelsKeyById: Dictionary): this {
this.channel = channelsKeyById.get(this.channelId)
return this
}
withStreams(streamsGroupedById: Dictionary): this {
this.streams = new Collection(streamsGroupedById.get(`${this.channelId}@${this.id}`))
if (this.isMain) {
this.streams = this.streams.concat(new Collection(streamsGroupedById.get(this.channelId)))
}
return this
}
withGuides(guidesGroupedByStreamId: Dictionary): this {
this.guides = new Collection(guidesGroupedByStreamId.get(`${this.channelId}@${this.id}`))
if (this.isMain) {
this.guides = this.guides.concat(new Collection(guidesGroupedByStreamId.get(this.channelId)))
}
return this
}
withLanguages(languagesKeyByCode: Dictionary): this {
this.languages = this.languageCodes
.map((code: string) => languagesKeyByCode.get(code))
.filter(Boolean)
return this
}
withTimezones(timezonesKeyById: Dictionary): this {
this.timezones = this.timezoneIds.map((id: string) => timezonesKeyById.get(id)).filter(Boolean)
return this
}
withBroadcastArea(
citiesKeyByCode: Dictionary,
subdivisionsKeyByCode: Dictionary,
countriesKeyByCode: Dictionary,
regionsKeyByCode: Dictionary
): this {
this.broadcastArea = new BroadcastArea(this.broadcastAreaCodes).withLocations(
citiesKeyByCode,
subdivisionsKeyByCode,
countriesKeyByCode,
regionsKeyByCode
)
return this
}
hasBroadcastArea(): boolean {
return !!this.broadcastArea
}
getBroadcastCountries(): Collection {
if (!this.broadcastArea) return new Collection()
return this.broadcastArea.getCountries()
}
getBroadcastRegions(): Collection {
if (!this.broadcastArea) return new Collection()
return this.broadcastArea.getRegions()
}
getTimezones(): Collection {
return this.timezones || new Collection()
}
getLanguages(): Collection {
return this.languages || new Collection()
}
hasLanguages(): boolean {
return !!this.languages && this.languages.notEmpty()
}
hasLanguage(language: Language): boolean {
return (
!!this.languages &&
this.languages.includes((_language: Language) => _language.code === language.code)
)
}
isBroadcastInCity(city: City): boolean {
if (!this.broadcastArea) return false
return this.broadcastArea.includesCity(city)
}
isBroadcastInSubdivision(subdivision: Subdivision): boolean {
if (!this.broadcastArea) return false
return this.broadcastArea.includesSubdivision(subdivision)
}
isBroadcastInCountry(country: Country): boolean {
if (!this.broadcastArea) return false
return this.broadcastArea.includesCountry(country)
}
isBroadcastInRegion(region: Region): boolean {
if (!this.broadcastArea) return false
return this.broadcastArea.includesRegion(region)
}
isInternational(): boolean {
if (!this.broadcastArea) return false
return this.broadcastArea.codes.join(',').includes('r/')
}
getGuides(): Collection {
if (!this.guides) return new Collection()
return this.guides
}
getStreams(): Collection {
if (!this.streams) return new Collection()
return this.streams
}
getFullName(): string {
if (!this.channel) return ''
return `${this.channel.name} ${this.name}`
}
}
-54
View File
@@ -1,54 +0,0 @@
import type { GuideData, GuideSerializedData } from '../types/guide'
export class Guide {
channelId?: string
feedId?: string
siteDomain: string
siteId: string
siteName: string
languageCode: string
constructor(data?: GuideData) {
if (!data) return
this.channelId = data.channel
this.feedId = data.feed
this.siteDomain = data.site
this.siteId = data.site_id
this.siteName = data.site_name
this.languageCode = data.lang
}
getUUID(): string {
return this.getStreamId() + this.siteId
}
getStreamId(): string | undefined {
if (!this.channelId) return undefined
if (!this.feedId) return this.channelId
return `${this.channelId}@${this.feedId}`
}
serialize(): GuideSerializedData {
return {
channelId: this.channelId,
feedId: this.feedId,
siteDomain: this.siteDomain,
siteId: this.siteId,
siteName: this.siteName,
languageCode: this.languageCode
}
}
deserialize(data: GuideSerializedData): this {
this.channelId = data.channelId
this.feedId = data.feedId
this.siteDomain = data.siteDomain
this.siteId = data.siteId
this.siteName = data.siteName
this.languageCode = data.languageCode
return this
}
}
-13
View File
@@ -1,16 +1,3 @@
export * from './blocklistRecord'
export * from './broadcastArea'
export * from './category'
export * from './channel'
export * from './city'
export * from './country'
export * from './feed'
export * from './guide'
export * from './issue'
export * from './language'
export * from './logo'
export * from './playlist'
export * from './region'
export * from './stream'
export * from './subdivision'
export * from './timezone'
-27
View File
@@ -1,27 +0,0 @@
import type { LanguageData, LanguageSerializedData } from '../types/language'
export class Language {
code: string
name: string
constructor(data?: LanguageData) {
if (!data) return
this.code = data.code
this.name = data.name
}
serialize(): LanguageSerializedData {
return {
code: this.code,
name: this.name
}
}
deserialize(data: LanguageSerializedData): this {
this.code = data.code
this.name = data.name
return this
}
}
-40
View File
@@ -1,40 +0,0 @@
import { Collection, type Dictionary } from '@freearhey/core'
import type { LogoData } from '../types/logo'
import { type Feed } from './feed'
export class Logo {
channelId: string
feedId?: string
feed: Feed
tags: Collection
width: number
height: number
format?: string
url: string
constructor(data?: LogoData) {
if (!data) return
this.channelId = data.channel
this.feedId = data.feed || undefined
this.tags = new Collection(data.tags)
this.width = data.width
this.height = data.height
this.format = data.format || undefined
this.url = data.url
}
withFeed(feedsKeyById: Dictionary): this {
if (!this.feedId) return this
this.feed = feedsKeyById.get(this.feedId)
return this
}
getStreamId(): string {
if (!this.feedId) return this.channelId
return `${this.channelId}@${this.feedId}`
}
}
+2 -2
View File
@@ -6,12 +6,12 @@ type PlaylistOptions = {
}
export class Playlist {
streams: Collection
streams: Collection<Stream>
options: {
public: boolean
}
constructor(streams: Collection, options?: PlaylistOptions) {
constructor(streams: Collection<Stream>, options?: PlaylistOptions) {
this.streams = streams
this.options = options || { public: false }
}
-118
View File
@@ -1,118 +0,0 @@
import { Collection, Dictionary } from '@freearhey/core'
import { City, Country, Subdivision } from '.'
import type { RegionData, RegionSerializedData } from '../types/region'
import { CountrySerializedData } from '../types/country'
import { SubdivisionSerializedData } from '../types/subdivision'
import { CitySerializedData } from '../types/city'
export class Region {
code: string
name: string
countryCodes: Collection
countries?: Collection
subdivisions?: Collection
cities?: Collection
regions?: Collection
constructor(data?: RegionData) {
if (!data) return
this.code = data.code
this.name = data.name
this.countryCodes = new Collection(data.countries)
}
withCountries(countriesKeyByCode: Dictionary): this {
this.countries = this.countryCodes.map((code: string) => countriesKeyByCode.get(code))
return this
}
withSubdivisions(subdivisions: Collection): this {
this.subdivisions = subdivisions.filter(
(subdivision: Subdivision) => this.countryCodes.indexOf(subdivision.countryCode) > -1
)
return this
}
withCities(cities: Collection): this {
this.cities = cities.filter((city: City) => this.countryCodes.indexOf(city.countryCode) > -1)
return this
}
withRegions(regions: Collection): this {
this.regions = regions.filter(
(region: Region) => !region.countryCodes.intersects(this.countryCodes).isEmpty()
)
return this
}
getSubdivisions(): Collection {
if (!this.subdivisions) return new Collection()
return this.subdivisions
}
getCountries(): Collection {
if (!this.countries) return new Collection()
return this.countries
}
getCities(): Collection {
if (!this.cities) return new Collection()
return this.cities
}
getRegions(): Collection {
if (!this.regions) return new Collection()
return this.regions
}
includesCountryCode(code: string): boolean {
return this.countryCodes.includes((countryCode: string) => countryCode === code)
}
isWorldwide(): boolean {
return ['INT', 'WW'].includes(this.code)
}
serialize(): RegionSerializedData {
return {
code: this.code,
name: this.name,
countryCodes: this.countryCodes.all(),
countries: this.getCountries()
.map((country: Country) => country.serialize())
.all(),
subdivisions: this.getSubdivisions()
.map((subdivision: Subdivision) => subdivision.serialize())
.all(),
cities: this.getCities()
.map((city: City) => city.serialize())
.all()
}
}
deserialize(data: RegionSerializedData): this {
this.code = data.code
this.name = data.name
this.countryCodes = new Collection(data.countryCodes)
this.countries = new Collection(data.countries).map((data: CountrySerializedData) =>
new Country().deserialize(data)
)
this.subdivisions = new Collection(data.subdivisions).map((data: SubdivisionSerializedData) =>
new Subdivision().deserialize(data)
)
this.cities = new Collection(data.cities).map((data: CitySerializedData) =>
new City().deserialize(data)
)
return this
}
}
+316 -329
View File
@@ -1,61 +1,21 @@
import {
Feed,
Channel,
Category,
Region,
Subdivision,
Country,
Language,
Logo,
City
} from './index'
import { URL, Collection, Dictionary } from '@freearhey/core'
import type { StreamData } from '../types/stream'
import { Collection } from '@freearhey/core'
import parser from 'iptv-playlist-parser'
import { normalizeURL } from '../utils'
import * as sdk from '@iptv-org/sdk'
import { IssueData } from '../core'
import { data } from '../api'
import path from 'node:path'
export class Stream {
title: string
url: string
id?: string
channelId?: string
channel?: Channel
feedId?: string
feed?: Feed
logos: Collection = new Collection()
export class Stream extends sdk.Models.Stream {
directives: Collection<string>
filepath?: string
line?: number
label?: string
verticalResolution?: number
isInterlaced?: boolean
referrer?: string
userAgent?: string
groupTitle: string = 'Undefined'
removed: boolean = false
directives: Collection = new Collection()
tvgId?: string
label: string | null
constructor(data?: StreamData) {
if (!data) return
const id =
data.channelId && data.feedId ? [data.channelId, data.feedId].join('@') : data.channelId
const { verticalResolution, isInterlaced } = parseQuality(data.quality)
this.id = id || undefined
this.channelId = data.channelId || undefined
this.feedId = data.feedId || undefined
this.title = data.title || ''
this.url = data.url
this.referrer = data.referrer || undefined
this.userAgent = data.userAgent || undefined
this.verticalResolution = verticalResolution || undefined
this.isInterlaced = isInterlaced || undefined
this.label = data.label || undefined
this.directives = new Collection(data.directives)
}
update(issueData: IssueData): this {
updateWithIssue(issueData: IssueData): this {
const data = {
label: issueData.getString('label'),
quality: issueData.getString('quality'),
@@ -66,16 +26,20 @@ export class Stream {
}
if (data.label !== undefined) this.label = data.label
if (data.quality !== undefined) this.setQuality(data.quality)
if (data.httpUserAgent !== undefined) this.userAgent = data.httpUserAgent
if (data.quality !== undefined) this.quality = data.quality
if (data.httpUserAgent !== undefined) this.user_agent = data.httpUserAgent
if (data.httpReferrer !== undefined) this.referrer = data.httpReferrer
if (data.newStreamUrl !== undefined) this.url = data.newStreamUrl
if (data.directives !== undefined) this.directives = new Collection(data.directives)
if (data.directives !== undefined) this.setDirectives(data.directives)
return this
}
fromPlaylistItem(data: parser.PlaylistItem): this {
static fromPlaylistItem(data: parser.PlaylistItem): Stream {
function escapeRegExp(text) {
return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')
}
function parseName(name: string): {
title: string
label: string
@@ -84,14 +48,14 @@ export class Stream {
let title = name
const [, label] = title.match(/ \[(.*)\]$/) || [null, '']
title = title.replace(new RegExp(` \\[${escapeRegExp(label)}\\]$`), '')
const [, quality] = title.match(/ \(([0-9]+p)\)$/) || [null, '']
const [, quality] = title.match(/ \(([0-9]+[p|i])\)$/) || [null, '']
title = title.replace(new RegExp(` \\(${quality}\\)$`), '')
return { title, label, quality }
}
function parseDirectives(string: string) {
const directives = new Collection()
function parseDirectives(string: string): Collection<string> {
const directives = new Collection<string>()
if (!string) return directives
@@ -113,293 +77,329 @@ export class Stream {
const [channelId, feedId] = data.tvg.id.split('@')
const { title, label, quality } = parseName(data.name)
const { verticalResolution, isInterlaced } = parseQuality(quality)
this.id = data.tvg.id || undefined
this.feedId = feedId || undefined
this.channelId = channelId || undefined
this.line = data.line
this.label = label || undefined
this.title = title
this.verticalResolution = verticalResolution || undefined
this.isInterlaced = isInterlaced || undefined
this.url = data.url
this.referrer = data.http.referrer || undefined
this.userAgent = data.http['user-agent'] || undefined
this.directives = parseDirectives(data.raw)
const stream = new Stream({
channel: channelId || null,
feed: feedId || null,
title: title,
quality: quality || null,
url: data.url,
referrer: data.http.referrer || null,
user_agent: data.http['user-agent'] || null
})
return this
stream.tvgId = data.tvg.id
stream.line = data.line
stream.label = label || null
stream.directives = parseDirectives(data.raw)
return stream
}
withChannel(channelsKeyById: Dictionary): this {
if (!this.channelId) return this
isSFW(): boolean {
const channel = this.getChannel()
this.channel = channelsKeyById.get(this.channelId)
if (!channel) return true
return this
return !channel.is_nsfw
}
withFeed(feedsGroupedByChannelId: Dictionary): this {
if (!this.channelId) return this
getUniqKey(): string {
const filepath = this.getFilepath()
const tvgId = this.getTvgId()
const channelFeeds = feedsGroupedByChannelId.get(this.channelId) || []
if (this.feedId) this.feed = channelFeeds.find((feed: Feed) => feed.id === this.feedId)
if (!this.feedId && !this.feed) this.feed = channelFeeds.find((feed: Feed) => feed.isMain)
return this
}
withLogos(logosGroupedByStreamId: Dictionary): this {
if (this.id) this.logos = new Collection(logosGroupedByStreamId.get(this.id))
return this
}
setId(id: string): this {
this.id = id
return this
}
setChannelId(channelId: string): this {
this.channelId = channelId
return this
}
setFeedId(feedId: string | undefined): this {
this.feedId = feedId
return this
}
setQuality(quality: string): this {
const { verticalResolution, isInterlaced } = parseQuality(quality)
this.verticalResolution = verticalResolution || undefined
this.isInterlaced = isInterlaced || undefined
return this
}
getLine(): number {
return this.line || -1
}
getFilename(): string {
if (!this.filepath) return ''
return path.basename(this.filepath)
}
setFilepath(filepath: string): this {
this.filepath = filepath
return this
}
updateFilepath(): this {
if (!this.channel) return this
this.filepath = `${this.channel.countryCode.toLowerCase()}.m3u`
return this
}
getChannelId(): string {
return this.channelId || ''
}
getFeedId(): string {
if (this.feedId) return this.feedId
if (this.feed) return this.feed.id
return ''
}
getFilepath(): string {
return this.filepath || ''
}
getReferrer(): string {
return this.referrer || ''
}
getUserAgent(): string {
return this.userAgent || ''
}
getQuality(): string {
if (!this.verticalResolution) return ''
let quality = this.verticalResolution.toString()
if (this.isInterlaced) quality += 'i'
else quality += 'p'
return quality
}
hasId(): boolean {
return !!this.id
}
hasQuality(): boolean {
return !!this.verticalResolution
return filepath + tvgId + this.url
}
getVerticalResolution(): number {
if (!this.hasQuality()) return 0
if (!this.quality) return 0
return parseInt(this.getQuality().replace(/p|i/, ''))
const [, verticalResolutionString] = this.quality.match(/^(\d+)/) || ['', '0']
return parseInt(verticalResolutionString)
}
updateTitle(): this {
if (!this.channel) return this
getBroadcastCountries(): Collection<sdk.Models.Country> {
const countries = new Collection<sdk.Models.Country>()
this.title = this.channel.name
if (this.feed && !this.feed.isMain) {
this.title += ` ${this.feed.name}`
const feed = this.getFeed()
if (!feed) return countries
feed
.getBroadcastArea()
.getLocations()
.forEach((location: sdk.Models.BroadcastAreaLocation) => {
let country: sdk.Models.Country | undefined
switch (location.type) {
case 'country': {
country = data.countriesKeyByCode.get(location.code)
break
}
case 'subdivision': {
const subdivision = data.subdivisionsKeyByCode.get(location.code)
if (!subdivision) break
country = data.countriesKeyByCode.get(subdivision.country)
break
}
case 'city': {
const city = data.citiesKeyByCode.get(location.code)
if (!city) break
country = data.countriesKeyByCode.get(city.country)
break
}
}
if (country) countries.add(country)
})
return countries.uniqBy((country: sdk.Models.Country) => country.code)
}
getBroadcastSubdivisions(): Collection<sdk.Models.Subdivision> {
const subdivisions = new Collection<sdk.Models.Subdivision>()
const feed = this.getFeed()
if (!feed) return subdivisions
feed
.getBroadcastArea()
.getLocations()
.forEach((location: sdk.Models.BroadcastAreaLocation) => {
switch (location.type) {
case 'subdivision': {
const subdivision = data.subdivisionsKeyByCode.get(location.code)
if (!subdivision) break
subdivisions.add(subdivision)
if (!subdivision.parent) break
const parentSubdivision = data.subdivisionsKeyByCode.get(subdivision.parent)
if (!parentSubdivision) break
subdivisions.add(parentSubdivision)
break
}
case 'city': {
const city = data.citiesKeyByCode.get(location.code)
if (!city || !city.subdivision) break
const subdivision = data.subdivisionsKeyByCode.get(city.subdivision)
if (!subdivision) break
subdivisions.add(subdivision)
if (!subdivision.parent) break
const parentSubdivision = data.subdivisionsKeyByCode.get(subdivision.parent)
if (!parentSubdivision) break
subdivisions.add(parentSubdivision)
break
}
}
})
return subdivisions.uniqBy((subdivision: sdk.Models.Subdivision) => subdivision.code)
}
getBroadcastCities(): Collection<sdk.Models.City> {
const cities = new Collection<sdk.Models.City>()
const feed = this.getFeed()
if (!feed) return cities
feed
.getBroadcastArea()
.getLocations()
.forEach((location: sdk.Models.BroadcastAreaLocation) => {
if (location.type !== 'city') return
const city = data.citiesKeyByCode.get(location.code)
if (city) cities.add(city)
})
return cities.uniqBy((city: sdk.Models.City) => city.code)
}
getBroadcastRegions(): Collection<sdk.Models.Region> {
const regions = new Collection<sdk.Models.Region>()
const feed = this.getFeed()
if (!feed) return regions
feed
.getBroadcastArea()
.getLocations()
.forEach((location: sdk.Models.BroadcastAreaLocation) => {
switch (location.type) {
case 'region': {
const region = data.regionsKeyByCode.get(location.code)
if (!region) break
regions.add(region)
const relatedRegions = data.regions.filter((_region: sdk.Models.Region) =>
new Collection<string>(_region.countries)
.intersects(new Collection<string>(region.countries))
.isNotEmpty()
)
regions.concat(relatedRegions)
break
}
case 'country': {
const country = data.countriesKeyByCode.get(location.code)
if (!country) break
const countryRegions = data.regions.filter((_region: sdk.Models.Region) =>
new Collection<string>(_region.countries).includes(
(code: string) => code === country.code
)
)
regions.concat(countryRegions)
break
}
case 'subdivision': {
const subdivision = data.subdivisionsKeyByCode.get(location.code)
if (!subdivision) break
const subdivisionRegions = data.regions.filter((_region: sdk.Models.Region) =>
new Collection<string>(_region.countries).includes(
(code: string) => code === subdivision.country
)
)
regions.concat(subdivisionRegions)
break
}
case 'city': {
const city = data.citiesKeyByCode.get(location.code)
if (!city) break
const cityRegions = data.regions.filter((_region: sdk.Models.Region) =>
new Collection<string>(_region.countries).includes(
(code: string) => code === city.country
)
)
regions.concat(cityRegions)
break
}
}
})
return regions.uniqBy((region: sdk.Models.Region) => region.code)
}
isInternational(): boolean {
const feed = this.getFeed()
if (!feed) return false
const broadcastAreaCodes = feed.getBroadcastArea().codes
if (broadcastAreaCodes.join(';').includes('r/')) return true
if (broadcastAreaCodes.filter(code => code.includes('c/')).length > 1) return true
return false
}
hasCategory(category: sdk.Models.Category): boolean {
const channel = this.getChannel()
if (!channel) return false
const found = channel.categories.find((id: string) => id === category.id)
return !!found
}
hasLanguage(language: sdk.Models.Language): boolean {
const found = this.getLanguages().find(
(_language: sdk.Models.Language) => _language.code === language.code
)
return !!found
}
setDirectives(directives: string[]): this {
this.directives = new Collection(directives).filter((directive: string) =>
/^(#KODIPROP|#EXTVLCOPT)/.test(directive)
)
return this
}
updateTvgId(): this {
if (!this.channel) return this
if (this.feed) {
this.tvgId = `${this.channel}@${this.feed}`
} else {
this.tvgId = this.channel
}
return this
}
updateId(): this {
if (!this.channel) return this
if (this.feed) {
this.id = `${this.channel.id}@${this.feed.id}`
} else {
this.id = this.channel.id
updateFilepath(): this {
const channel = this.getChannel()
if (!channel) return this
this.filepath = `${channel.country.toLowerCase()}.m3u`
return this
}
updateTitle(): this {
const channel = this.getChannel()
if (!channel) return this
const feed = this.getFeed()
this.title = channel.name
if (feed && !feed.is_main) {
this.title += ` ${feed.name}`
}
return this
}
normalizeURL() {
const url = new URL(this.url)
this.url = url.normalize().toString()
this.url = normalizeURL(this.url)
}
clone(): Stream {
return Object.assign(Object.create(Object.getPrototypeOf(this)), this)
}
getLogos(): Collection<sdk.Models.Logo> {
const logos = super.getLogos()
hasChannel() {
return !!this.channel
}
if (logos.isEmpty()) return new Collection()
getBroadcastRegions(): Collection {
return this.feed ? this.feed.getBroadcastRegions() : new Collection()
}
getBroadcastCountries(): Collection {
return this.feed ? this.feed.getBroadcastCountries() : new Collection()
}
hasBroadcastArea(): boolean {
return this.feed ? this.feed.hasBroadcastArea() : false
}
isSFW(): boolean {
return this.channel ? this.channel.isSFW() : true
}
hasCategories(): boolean {
return this.channel ? this.channel.hasCategories() : false
}
hasCategory(category: Category): boolean {
return this.channel ? this.channel.hasCategory(category) : false
}
getCategoryNames(): string[] {
return this.getCategories()
.map((category: Category) => category.name)
.sort()
.all()
}
getCategories(): Collection {
return this.channel ? this.channel.getCategories() : new Collection()
}
getLanguages(): Collection {
return this.feed ? this.feed.getLanguages() : new Collection()
}
hasLanguages() {
return this.feed ? this.feed.hasLanguages() : false
}
hasLanguage(language: Language) {
return this.feed ? this.feed.hasLanguage(language) : false
}
getBroadcastAreaCodes(): Collection {
return this.feed ? this.feed.broadcastAreaCodes : new Collection()
}
isBroadcastInCity(city: City): boolean {
return this.feed ? this.feed.isBroadcastInCity(city) : false
}
isBroadcastInSubdivision(subdivision: Subdivision): boolean {
return this.feed ? this.feed.isBroadcastInSubdivision(subdivision) : false
}
isBroadcastInCountry(country: Country): boolean {
return this.feed ? this.feed.isBroadcastInCountry(country) : false
}
isBroadcastInRegion(region: Region): boolean {
return this.feed ? this.feed.isBroadcastInRegion(region) : false
}
isInternational(): boolean {
return this.feed ? this.feed.isInternational() : false
}
getLogos(): Collection {
function format(logo: Logo): number {
function format(logo: sdk.Models.Logo): number {
const levelByFormat = { SVG: 0, PNG: 3, APNG: 1, WebP: 1, AVIF: 1, JPEG: 2, GIF: 1 }
return logo.format ? levelByFormat[logo.format] : 0
}
function size(logo: Logo): number {
function size(logo: sdk.Models.Logo): number {
return Math.abs(512 - logo.width) + Math.abs(512 - logo.height)
}
return this.logos.orderBy([format, size], ['desc', 'asc'], false)
return logos.sortBy([format, size], ['desc', 'asc'], false)
}
getLogo(): Logo | undefined {
return this.getLogos().first()
getFilepath(): string {
return this.filepath || ''
}
hasLogo(): boolean {
return this.getLogos().notEmpty()
getFilename(): string {
return path.basename(this.getFilepath())
}
getLogoUrl(): string {
let logo: Logo | undefined
getLine(): number {
return this.line || -1
}
if (this.hasLogo()) logo = this.getLogo()
else logo = this?.channel?.getLogo()
getTvgId(): string {
if (this.tvgId) return this.tvgId
return this.getId()
}
getTvgLogo(): string {
const logo = this.getLogos().first()
return logo ? logo.url : ''
}
getTitle(): string {
return this.title || ''
}
getFullTitle(): string {
let title = `${this.getTitle()}`
let title = `${this.title}`
if (this.getQuality()) {
title += ` (${this.getQuality()})`
if (this.quality) {
title += ` (${this.quality})`
}
if (this.label) {
@@ -409,39 +409,21 @@ export class Stream {
return title
}
getLabel(): string {
return this.label || ''
}
toString(options: { public?: boolean } = {}) {
options = { ...{ public: false }, ...options }
getId(): string {
return this.id || ''
}
toJSON() {
return {
channel: this.channelId || null,
feed: this.feedId || null,
title: this.title,
url: this.url,
referrer: this.referrer || null,
user_agent: this.userAgent || null,
quality: this.getQuality() || null
}
}
toString(options: { public: boolean }) {
let output = `#EXTINF:-1 tvg-id="${this.getId()}"`
let output = `#EXTINF:-1 tvg-id="${this.getTvgId()}"`
if (options.public) {
output += ` tvg-logo="${this.getLogoUrl()}" group-title="${this.groupTitle}"`
output += ` tvg-logo="${this.getTvgLogo()}" group-title="${this.groupTitle}"`
}
if (this.referrer) {
output += ` http-referrer="${this.referrer}"`
}
if (this.userAgent) {
output += ` http-user-agent="${this.userAgent}"`
if (this.user_agent) {
output += ` http-user-agent="${this.user_agent}"`
}
output += `,${this.getFullTitle()}`
@@ -454,21 +436,26 @@ export class Stream {
return output
}
}
function escapeRegExp(text) {
return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')
}
toObject(): sdk.Types.StreamData {
let feedId = this.feed
if (!feedId) {
const feed = this.getFeed()
if (feed) feedId = feed.id
}
function parseQuality(quality: string | null): {
verticalResolution: number | null
isInterlaced: boolean | null
} {
if (!quality) return { verticalResolution: null, isInterlaced: null }
const [, verticalResolutionString] = quality.match(/^(\d+)/) || [null, undefined]
const isInterlaced = /i$/i.test(quality)
let verticalResolution = 0
if (verticalResolutionString) verticalResolution = parseInt(verticalResolutionString)
return {
channel: this.channel,
feed: feedId,
title: this.title,
url: this.url,
quality: this.quality,
user_agent: this.user_agent,
referrer: this.referrer
}
}
return { verticalResolution, isInterlaced }
clone(): Stream {
return Object.assign(Object.create(Object.getPrototypeOf(this)), this)
}
}
-83
View File
@@ -1,83 +0,0 @@
import { SubdivisionData, SubdivisionSerializedData } from '../types/subdivision'
import { Dictionary, Collection } from '@freearhey/core'
import { Country, Region } from '.'
export class Subdivision {
code: string
name: string
countryCode: string
country?: Country
parentCode?: string
parent?: Subdivision
regions?: Collection
cities?: Collection
constructor(data?: SubdivisionData) {
if (!data) return
this.code = data.code
this.name = data.name
this.countryCode = data.country
this.parentCode = data.parent || undefined
}
withCountry(countriesKeyByCode: Dictionary): this {
this.country = countriesKeyByCode.get(this.countryCode)
return this
}
withRegions(regions: Collection): this {
this.regions = regions.filter((region: Region) =>
region.countryCodes.includes(this.countryCode)
)
return this
}
withCities(citiesGroupedBySubdivisionCode: Dictionary): this {
this.cities = new Collection(citiesGroupedBySubdivisionCode.get(this.code))
return this
}
withParent(subdivisionsKeyByCode: Dictionary): this {
if (!this.parentCode) return this
this.parent = subdivisionsKeyByCode.get(this.parentCode)
return this
}
getRegions(): Collection {
if (!this.regions) return new Collection()
return this.regions
}
getCities(): Collection {
if (!this.cities) return new Collection()
return this.cities
}
serialize(): SubdivisionSerializedData {
return {
code: this.code,
name: this.name,
countryCode: this.countryCode,
country: this.country ? this.country.serialize() : undefined,
parentCode: this.parentCode || null
}
}
deserialize(data: SubdivisionSerializedData): this {
this.code = data.code
this.name = data.name
this.countryCode = data.countryCode
this.country = data.country ? new Country().deserialize(data.country) : undefined
this.parentCode = data.parentCode || undefined
return this
}
}
-30
View File
@@ -1,30 +0,0 @@
import { Collection, Dictionary } from '@freearhey/core'
type TimezoneData = {
id: string
utc_offset: string
countries: string[]
}
export class Timezone {
id: string
utcOffset: string
countryCodes: Collection
countries?: Collection
constructor(data: TimezoneData) {
this.id = data.id
this.utcOffset = data.utc_offset
this.countryCodes = new Collection(data.countries)
}
withCountries(countriesKeyByCode: Dictionary): this {
this.countries = this.countryCodes.map((code: string) => countriesKeyByCode.get(code))
return this
}
getCountries(): Collection {
return this.countries || new Collection()
}
}
+28 -21
View File
@@ -1,55 +1,62 @@
import { Storage, Collection, File, Dictionary } from '@freearhey/core'
import { HTMLTable, LogParser, LogItem } from '../core'
import { HTMLTable, HTMLTableItem, LogParser, LogItem, HTMLTableColumn } from '../core'
import { Storage, File } from '@freearhey/storage-js'
import { LOGS_DIR, README_DIR } from '../constants'
import { Category } from '../models'
import { Collection } from '@freearhey/core'
import * as sdk from '@iptv-org/sdk'
import { Table } from './table'
type CategoriesTableProps = {
categoriesKeyById: Dictionary
}
import { data } from '../api'
export class CategoriesTable implements Table {
categoriesKeyById: Dictionary
constructor({ categoriesKeyById }: CategoriesTableProps) {
this.categoriesKeyById = categoriesKeyById
}
async make() {
async create() {
const parser = new LogParser()
const logsStorage = new Storage(LOGS_DIR)
const generatorsLog = await logsStorage.load('generators.log')
let items = new Collection()
let items = new Collection<HTMLTableItem>()
parser
.parse(generatorsLog)
.filter((logItem: LogItem) => logItem.type === 'category')
.forEach((logItem: LogItem) => {
if (logItem.filepath.includes('undefined')) {
items.add([
'ZZ',
'Undefined',
logItem.count.toString(),
`<code>https://iptv-org.github.io/iptv/${logItem.filepath}</code>`
])
return
}
const file = new File(logItem.filepath)
const categoryId = file.name()
const category: Category = this.categoriesKeyById.get(categoryId)
const category: sdk.Models.Category | undefined = data.categoriesKeyById.get(categoryId)
if (!category) return
items.add([
category ? category.name : 'ZZ',
category ? category.name : 'Undefined',
logItem.count,
category.name,
category.name,
logItem.count.toString(),
`<code>https://iptv-org.github.io/iptv/${logItem.filepath}</code>`
])
})
items = items
.orderBy(item => item[0])
.sortBy(item => item[0])
.map(item => {
item.shift()
return item
})
const table = new HTMLTable(items.all(), [
const columns = new Collection<HTMLTableColumn>([
{ name: 'Category' },
{ name: 'Channels', align: 'right' },
{ name: 'Playlist', nowrap: true }
])
const table = new HTMLTable(items, columns)
const readmeStorage = new Storage(README_DIR)
await readmeStorage.save('_categories.md', table.toString())
}
+49 -62
View File
@@ -1,39 +1,21 @@
import { Storage, Collection, Dictionary } from '@freearhey/core'
import { City, Country, Subdivision } from '../models'
import { LOGS_DIR, README_DIR } from '../constants'
import { Storage } from '@freearhey/storage-js'
import { Collection } from '@freearhey/core'
import { LogParser, LogItem } from '../core'
import * as sdk from '@iptv-org/sdk'
import { Table } from './table'
import { data } from '../api'
type CountriesTableProps = {
countriesKeyByCode: Dictionary
subdivisionsKeyByCode: Dictionary
countries: Collection
subdivisions: Collection
cities: Collection
type ListItem = {
index: string
count: number
link: string
name: string
children: Collection<ListItem>
}
export class CountriesTable implements Table {
countriesKeyByCode: Dictionary
subdivisionsKeyByCode: Dictionary
countries: Collection
subdivisions: Collection
cities: Collection
constructor({
countriesKeyByCode,
subdivisionsKeyByCode,
countries,
subdivisions,
cities
}: CountriesTableProps) {
this.countriesKeyByCode = countriesKeyByCode
this.subdivisionsKeyByCode = subdivisionsKeyByCode
this.countries = countries
this.subdivisions = subdivisions
this.cities = cities
}
async make() {
async create() {
const parser = new LogParser()
const logsStorage = new Storage(LOGS_DIR)
const generatorsLog = await logsStorage.load('generators.log')
@@ -43,15 +25,16 @@ export class CountriesTable implements Table {
const logCities = parsed.filter((logItem: LogItem) => logItem.type === 'city')
let items = new Collection()
this.countries.forEach((country: Country) => {
data.countries.forEach((country: sdk.Models.Country) => {
const countryCode = country.code
const countriesLogItem = logCountries.find(
(logItem: LogItem) => logItem.filepath === `countries/${country.code.toLowerCase()}.m3u`
(logItem: LogItem) => logItem.filepath === `countries/${countryCode.toLowerCase()}.m3u`
)
const countryItem = {
const countryItem: ListItem = {
index: country.name,
count: 0,
link: `https://iptv-org.github.io/iptv/countries/${country.code.toLowerCase()}.m3u`,
link: `https://iptv-org.github.io/iptv/countries/${countryCode.toLowerCase()}.m3u`,
name: `${country.flag} ${country.name}`,
children: new Collection()
}
@@ -60,38 +43,41 @@ export class CountriesTable implements Table {
countryItem.count = countriesLogItem.count
}
const countrySubdivisions = this.subdivisions.filter(
(subdivision: Subdivision) => subdivision.countryCode === country.code
const countrySubdivisions = data.subdivisions.filter(
(subdivision: sdk.Models.Subdivision) => subdivision.country === countryCode
)
const countryCities = this.cities.filter((city: City) => city.countryCode === country.code)
if (countrySubdivisions.notEmpty()) {
this.subdivisions.forEach((subdivision: Subdivision) => {
if (subdivision.countryCode !== country.code) return
const countryCities = data.cities.filter(
(city: sdk.Models.City) => city.country === countryCode
)
if (countrySubdivisions.isNotEmpty()) {
data.subdivisions.forEach((subdivision: sdk.Models.Subdivision) => {
if (subdivision.country !== countryCode) return
const subdivisionCode = subdivision.code
const subdivisionCities = countryCities.filter(
(city: City) =>
(city.subdivisionCode && city.subdivisionCode === subdivision.code) ||
city.countryCode === subdivision.countryCode
(city: sdk.Models.City) =>
(city.subdivision && city.subdivision === subdivisionCode) ||
city.country === subdivision.country
)
const subdivisionsLogItem = logSubdivisions.find(
(logItem: LogItem) =>
logItem.filepath === `subdivisions/${subdivision.code.toLowerCase()}.m3u`
logItem.filepath === `subdivisions/${subdivisionCode.toLowerCase()}.m3u`
)
const subdivisionItem = {
const subdivisionItem: ListItem = {
index: subdivision.name,
name: subdivision.name,
count: 0,
link: `https://iptv-org.github.io/iptv/subdivisions/${subdivision.code.toLowerCase()}.m3u`,
children: new Collection()
link: `https://iptv-org.github.io/iptv/subdivisions/${subdivisionCode.toLowerCase()}.m3u`,
children: new Collection<ListItem>()
}
if (subdivisionsLogItem) {
subdivisionItem.count = subdivisionsLogItem.count
}
subdivisionCities.forEach((city: City) => {
if (city.countryCode !== country.code || city.subdivisionCode !== subdivision.code)
return
subdivisionCities.forEach((city: sdk.Models.City) => {
if (city.country !== countryCode || city.subdivision !== subdivisionCode) return
const citiesLogItem = logCities.find(
(logItem: LogItem) => logItem.filepath === `cities/${city.code.toLowerCase()}.m3u`
)
@@ -102,16 +88,17 @@ export class CountriesTable implements Table {
index: city.name,
name: city.name,
count: citiesLogItem.count,
link: `https://iptv-org.github.io/iptv/${citiesLogItem.filepath}`
link: `https://iptv-org.github.io/iptv/${citiesLogItem.filepath}`,
children: new Collection<ListItem>()
})
})
if (subdivisionItem.count > 0 || subdivisionItem.children.notEmpty()) {
if (subdivisionItem.count > 0 || subdivisionItem.children.isNotEmpty()) {
countryItem.children.add(subdivisionItem)
}
})
} else if (countryCities.notEmpty()) {
countryCities.forEach((city: City) => {
} else if (countryCities.isNotEmpty()) {
countryCities.forEach((city: sdk.Models.City) => {
const citiesLogItem = logCities.find(
(logItem: LogItem) => logItem.filepath === `cities/${city.code.toLowerCase()}.m3u`
)
@@ -128,7 +115,7 @@ export class CountriesTable implements Table {
})
}
if (countryItem.count > 0 || countryItem.children.notEmpty()) {
if (countryItem.count > 0 || countryItem.children.isNotEmpty()) {
items.add(countryItem)
}
})
@@ -138,7 +125,7 @@ export class CountriesTable implements Table {
)
if (internationalLogItem) {
items.push({
items.add({
index: 'ZZ',
name: '🌐 International',
count: internationalLogItem.count,
@@ -152,7 +139,7 @@ export class CountriesTable implements Table {
)
if (undefinedLogItem) {
items.push({
items.add({
index: 'ZZZ',
name: 'Undefined',
count: undefinedLogItem.count,
@@ -161,20 +148,20 @@ export class CountriesTable implements Table {
})
}
items = items.orderBy(item => item.index)
items = items.sortBy(item => item.index)
const output = items
.map(item => {
.map((item: ListItem) => {
let row = `- ${item.name} <code>${item.link}</code>`
item.children
.orderBy(item => item.index)
.forEach(item => {
.sortBy((item: ListItem) => item.index)
.forEach((item: ListItem) => {
row += `\r\n - ${item.name} <code>${item.link}</code>`
item.children
.orderBy(item => item.index)
.forEach(item => {
.sortBy((item: ListItem) => item.index)
.forEach((item: ListItem) => {
row += `\r\n - ${item.name} <code>${item.link}</code>`
})
})
+30 -23
View File
@@ -1,55 +1,62 @@
import { Storage, Collection, File, Dictionary } from '@freearhey/core'
import { HTMLTable, LogParser, LogItem } from '../core'
import { HTMLTable, LogParser, LogItem, HTMLTableColumn, HTMLTableItem } from '../core'
import { Storage, File } from '@freearhey/storage-js'
import { LOGS_DIR, README_DIR } from '../constants'
import { Language } from '../models'
import { Collection } from '@freearhey/core'
import * as sdk from '@iptv-org/sdk'
import { Table } from './table'
type LanguagesTableProps = {
languagesKeyByCode: Dictionary
}
import { data } from '../api'
export class LanguagesTable implements Table {
languagesKeyByCode: Dictionary
constructor({ languagesKeyByCode }: LanguagesTableProps) {
this.languagesKeyByCode = languagesKeyByCode
}
async make() {
async create() {
const parser = new LogParser()
const logsStorage = new Storage(LOGS_DIR)
const generatorsLog = await logsStorage.load('generators.log')
let data = new Collection()
let items = new Collection<HTMLTableItem>()
parser
.parse(generatorsLog)
.filter((logItem: LogItem) => logItem.type === 'language')
.forEach((logItem: LogItem) => {
if (logItem.filepath.includes('undefined')) {
items.add([
'ZZ',
'Undefined',
logItem.count.toString(),
`<code>https://iptv-org.github.io/iptv/${logItem.filepath}</code>`
])
return
}
const file = new File(logItem.filepath)
const languageCode = file.name()
const language: Language = this.languagesKeyByCode.get(languageCode)
const language: sdk.Models.Language | undefined = data.languagesKeyByCode.get(languageCode)
data.add([
language ? language.name : 'ZZ',
language ? language.name : 'Undefined',
logItem.count,
if (!language) return
items.add([
language.name,
language.name,
logItem.count.toString(),
`<code>https://iptv-org.github.io/iptv/${logItem.filepath}</code>`
])
})
data = data
.orderBy(item => item[0])
items = items
.sortBy(item => item[0])
.map(item => {
item.shift()
return item
})
const table = new HTMLTable(data.all(), [
const columns = new Collection<HTMLTableColumn>([
{ name: 'Language', align: 'left' },
{ name: 'Channels', align: 'right' },
{ name: 'Playlist', align: 'left', nowrap: true }
])
const table = new HTMLTable(items, columns)
const readmeStorage = new Storage(README_DIR)
await readmeStorage.save('_languages.md', table.toString())
}
+13 -16
View File
@@ -1,29 +1,27 @@
import { Storage, Collection } from '@freearhey/core'
import { LogParser, LogItem } from '../core'
import { LOGS_DIR, README_DIR } from '../constants'
import { Region } from '../models'
import { Storage } from '@freearhey/storage-js'
import { LogParser, LogItem } from '../core'
import { Collection } from '@freearhey/core'
import * as sdk from '@iptv-org/sdk'
import { Table } from './table'
import { data } from '../api'
type RegionsTableProps = {
regions: Collection
type ListItem = {
name: string
count: number
link: string
}
export class RegionsTable implements Table {
regions: Collection
constructor({ regions }: RegionsTableProps) {
this.regions = regions
}
async make() {
async create() {
const parser = new LogParser()
const logsStorage = new Storage(LOGS_DIR)
const generatorsLog = await logsStorage.load('generators.log')
const parsed = parser.parse(generatorsLog)
const logRegions = parsed.filter((logItem: LogItem) => logItem.type === 'region')
let items = new Collection()
this.regions.forEach((region: Region) => {
let items = new Collection<ListItem>()
data.regions.forEach((region: sdk.Models.Region) => {
const logItem = logRegions.find(
(logItem: LogItem) => logItem.filepath === `regions/${region.code.toLowerCase()}.m3u`
)
@@ -31,14 +29,13 @@ export class RegionsTable implements Table {
if (!logItem) return
items.add({
index: region.name,
name: region.name,
count: logItem.count,
link: `https://iptv-org.github.io/iptv/${logItem.filepath}`
})
})
items = items.orderBy(item => item.index)
items = items.sortBy(item => item.name)
const output = items
.map(item => {
+1 -1
View File
@@ -1,3 +1,3 @@
export interface Table {
make(): void
create(): void
}
-5
View File
@@ -1,5 +0,0 @@
export type BlocklistRecordData = {
channel: string
reason: string
ref: string
}
-9
View File
@@ -1,9 +0,0 @@
export type CategorySerializedData = {
id: string
name: string
}
export type CategoryData = {
id: string
name: string
}
-50
View File
@@ -1,50 +0,0 @@
import { Collection } from '@freearhey/core'
import type { CountrySerializedData } from './country'
import type { SubdivisionSerializedData } from './subdivision'
import type { CategorySerializedData } from './category'
export type ChannelSerializedData = {
id: string
name: string
altNames: string[]
network?: string
owners: string[]
countryCode: string
country?: CountrySerializedData
subdivisionCode?: string
subdivision?: SubdivisionSerializedData
cityName?: string
categoryIds: string[]
categories?: CategorySerializedData[]
isNSFW: boolean
launched?: string
closed?: string
replacedBy?: string
website?: string
}
export type ChannelData = {
id: string
name: string
alt_names: string[]
network: string
owners: Collection
country: string
subdivision: string
city: string
categories: Collection
is_nsfw: boolean
launched: string
closed: string
replaced_by: string
website: string
}
export type ChannelSearchableData = {
id: string
name: string
altNames: string[]
guideNames: string[]
streamTitles: string[]
feedFullNames: string[]
}
-20
View File
@@ -1,20 +0,0 @@
import { CountrySerializedData } from './country'
import { SubdivisionSerializedData } from './subdivision'
export type CitySerializedData = {
code: string
name: string
countryCode: string
country?: CountrySerializedData
subdivisionCode: string | null
subdivision?: SubdivisionSerializedData
wikidataId: string
}
export type CityData = {
code: string
name: string
country: string
subdivision: string | null
wikidata_id: string
}
-20
View File
@@ -1,20 +0,0 @@
import type { LanguageSerializedData } from './language'
import type { SubdivisionSerializedData } from './subdivision'
import type { RegionSerializedData } from './region'
export type CountrySerializedData = {
code: string
name: string
flag: string
languageCode: string
language: LanguageSerializedData | null
subdivisions: SubdivisionSerializedData[]
regions: RegionSerializedData[]
}
export type CountryData = {
code: string
name: string
lang: string
flag: string
}
-21
View File
@@ -1,21 +0,0 @@
import { Storage } from '@freearhey/core'
export type DataLoaderProps = {
storage: Storage
}
export type DataLoaderData = {
countries: object | object[]
regions: object | object[]
subdivisions: object | object[]
languages: object | object[]
categories: object | object[]
blocklist: object | object[]
channels: object | object[]
feeds: object | object[]
logos: object | object[]
timezones: object | object[]
guides: object | object[]
streams: object | object[]
cities: object | object[]
}
-31
View File
@@ -1,31 +0,0 @@
import { Collection, Dictionary } from '@freearhey/core'
export type DataProcessorData = {
blocklistRecordsGroupedByChannelId: Dictionary
subdivisionsGroupedByCountryCode: Dictionary
feedsGroupedByChannelId: Dictionary
guidesGroupedByStreamId: Dictionary
logosGroupedByStreamId: Dictionary
subdivisionsKeyByCode: Dictionary
countriesKeyByCode: Dictionary
languagesKeyByCode: Dictionary
streamsGroupedById: Dictionary
categoriesKeyById: Dictionary
timezonesKeyById: Dictionary
regionsKeyByCode: Dictionary
blocklistRecords: Collection
channelsKeyById: Dictionary
citiesKeyByCode: Dictionary
subdivisions: Collection
categories: Collection
countries: Collection
languages: Collection
timezones: Collection
channels: Collection
regions: Collection
streams: Collection
cities: Collection
guides: Collection
feeds: Collection
logos: Collection
}
-10
View File
@@ -1,10 +0,0 @@
export type FeedData = {
channel: string
id: string
name: string
is_main: boolean
broadcast_area: string[]
languages: string[]
timezones: string[]
video_format: string
}
-17
View File
@@ -1,17 +0,0 @@
export type GuideSerializedData = {
channelId?: string
feedId?: string
siteDomain: string
siteId: string
siteName: string
languageCode: string
}
export type GuideData = {
channel: string
feed: string
site: string
site_id: string
site_name: string
lang: string
}
-9
View File
@@ -1,9 +0,0 @@
export type LanguageSerializedData = {
code: string
name: string
}
export type LanguageData = {
code: string
name: string
}
-9
View File
@@ -1,9 +0,0 @@
export type LogoData = {
channel: string
feed: string | null
tags: string[]
width: number
height: number
format: string | null
url: string
}
-18
View File
@@ -1,18 +0,0 @@
import { CitySerializedData } from './city'
import { CountrySerializedData } from './country'
import { SubdivisionSerializedData } from './subdivision'
export type RegionSerializedData = {
code: string
name: string
countryCodes: string[]
countries?: CountrySerializedData[]
subdivisions?: SubdivisionSerializedData[]
cities?: CitySerializedData[]
}
export type RegionData = {
code: string
name: string
countries: string[]
}
-11
View File
@@ -1,11 +0,0 @@
export type StreamData = {
channelId: string | null
feedId: string | null
title: string | null
url: string
referrer: string | null
userAgent: string | null
quality: string | null
label: string | null
directives: string[]
}
-16
View File
@@ -1,16 +0,0 @@
import { CountrySerializedData } from './country'
export type SubdivisionSerializedData = {
code: string
name: string
countryCode: string
country?: CountrySerializedData
parentCode: string | null
}
export type SubdivisionData = {
code: string
name: string
country: string
parent: string | null
}
+15
View File
@@ -1,3 +1,5 @@
import normalizeUrl from 'normalize-url'
export function isURI(string: string): boolean {
try {
new URL(string)
@@ -6,3 +8,16 @@ export function isURI(string: string): boolean {
return false
}
}
export function normalizeURL(url: string): string {
const normalized = normalizeUrl(url, { stripWWW: false })
return decodeURIComponent(normalized).replace(/\s/g, '+').toString()
}
export function truncate(string: string, limit: number = 100) {
if (!string) return string
if (string.length < limit) return string
return string.slice(0, limit - 3) + '...'
}
+22 -4
View File
@@ -1,14 +1,16 @@
#EXTM3U
#EXTINF:-1 tvg-id="AbuDhabiAloula.ae@SD",Abu Dhabi Aloula (1080p)
https://vo-live-media.cdb.cdn.orange.com/Content/Channel/AbuDhabiChannel/HLS/index.m3u8
#EXTINF:-1 tvg-id="AbuDhabiEmirates.ae@SD",Abu Dhabi Emirates (1080p)
https://vo-live-media.cdb.cdn.orange.com/Content/Channel/EmiratesChannel/HLS/index.m3u8
#EXTINF:-1 tvg-id="AbuDhabiSports1.ae",Abu Dhabi Sports 1 (1080p)
http://213.57.91.138:8000/play/a057
#EXTINF:-1 tvg-id="AbuDhabiSports1.ae",Abu Dhabi Sports 1 (1080p) [Geo-blocked]
https://vo-live-media.cdb.cdn.orange.com/Content/Channel/AbuDhabiSportsChannel1/HLS/index.m3u8
#EXTINF:-1 tvg-id="AbuDhabiSports2.ae",Abu Dhabi Sports 2 (1080p)
http://213.57.91.138:8000/play/a058
#EXTINF:-1 tvg-id="AbuDhabiSports2.ae",Abu Dhabi Sports 2 (1080p) [Geo-blocked]
https://vo-live-media.cdb.cdn.orange.com/Content/Channel/AbuDhabiSportsChannel2/HLS/index.m3u8
#EXTINF:-1 tvg-id="AbuDhabiTV.ae@SD",Abu Dhabi TV
http://dvrfl05.bozztv.com/gin-abudhabitv/index.m3u8
#EXTINF:-1 tvg-id="AbuDhabiTV.ae@SD",Abu Dhabi TV (1080p)
https://vo-live-media.cdb.cdn.orange.com/Content/Channel/AbuDhabiChannel/HLS/index.m3u8
#EXTINF:-1 tvg-id="AjmanTV.ae",Ajman TV (1080p)
https://cdn1.logichost.in/ajmantv/live/playlist.m3u8
#EXTINF:-1 tvg-id="Alarabiya.ae",Al Arabiya (1080p)
@@ -39,6 +41,8 @@ https://viamotionhsi.netplus.ch/live/eds/alarabiya/browser-dash/alarabiya.mpd
https://viamotionhsi.netplus.ch/live/eds/alarabiya/browser-HLS8/alarabiya.m3u8
#EXTINF:-1 tvg-id="BaynounahTV.ae@SD",Baynounah TV (1080p)
https://vo-live-media.cdb.cdn.orange.com/Content/Channel/Baynounah/HLS/index.m3u8
#EXTINF:-1 tvg-id="ChinaArabTV.ae",China Arab TV (576p)
http://213.57.91.138:8000/play/a061
#EXTINF:-1 tvg-id="CNBCArabiya.ae",CNBC Arabiya (1080p)
https://cnbc-live.akamaized.net/cnbc/master.m3u8
#EXTINF:-1 tvg-id="DubaiOne.ae",Dubai One (1080p)
@@ -134,4 +138,18 @@ https://shd-gcp-live.edgenextcdn.net/live/bitmovin-wanasah/13e82ea6232fa647c43b2
#EXTINF:-1 tvg-id="WatarRadio.ae",Watar Radio (1080p)
https://svs.itworkscdn.net/smcwatarlive/smcwatar/playlist.m3u8
#EXTINF:-1 tvg-id="YasTV.ae@SD",Yas TV (1080p)
http://213.57.91.138:8000/play/a059
#EXTINF:-1 tvg-id="YasTV.ae@SD",Yas TV (1080p) [Geo-blocked]
https://vo-live-media.cdb.cdn.orange.com/Content/Channel/YASSportsChannel/HLS/index.m3u8
#EXTINF:-1 tvg-id="ZeeAflam.ae",Zee Aflam (576p)
http://213.57.91.138:8000/play/a05s
#EXTINF:-1 tvg-id="ZeeAlwan.ae",Zee Alwan (576p)
http://213.57.91.138:8000/play/a05t
#EXTINF:-1 tvg-id="ZeeAlwan.ae@Egypt",Zee Alwan Egypt (576p)
http://213.57.91.138:8000/play/a05u
#EXTINF:-1 tvg-id="DubaiOne.ae@SD",Dubai One
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119197/dubaione/playlist.m3u8
#EXTINF:-1 tvg-id="SkyNewsArabia.ae@SD",Sky News Arabia
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119687/skynewsarabic/playlist.m3u8
#EXTINF:-1 tvg-id="Alarabiya.ae@SD",Alarabiya
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119196/alarabia/playlist.m3u8
+4 -2
View File
@@ -101,8 +101,6 @@ https://tv.mediacp.eu:19360/cosmos/cosmos.m3u8
https://stream.arcast.live/cpe/ngrp:cpe_all/playlist.m3u8
#EXTINF:-1 tvg-id="ElTrece.ar",El Trece (1080p)
https://live-01-02-eltrece.vodgc.net/eltrecetv/index.m3u8
#EXTINF:-1 tvg-id="ElTrece.ar",El Trece
http://190.123.90.146:8000/play/a00n/index.m3u8
#EXTINF:-1 tvg-id="GarageTVLatinAmerica.ar",Garage TV Latin America
https://stream1.sersat.com/hls/garagetv.m3u8
#EXTINF:-1 tvg-id="LapachoTVCanal11.ar",Lapacho TV Canal 11 (720p)
@@ -196,3 +194,7 @@ https://stmv6.voxtvhd.com.br/cineclasico/cineclasico/playlist.m3u8
#EXTINF:-1 tvg-id="XtremaTerror.ar" http-referrer="https://xtrematv.com/?p=1456",Xtrema Terror
#EXTVLCOPT:http-referrer=https://xtrematv.com/?p=1456
https://stmv6.voxtvhd.com.br/cineterror/cineterror/playlist.m3u8
#EXTINF:-1 tvg-id="DisneyChannelLatinAmerica.ar",Disney Channel Latin America (1080p)
http://201.230.121.186:8000/play/a0fb/index.m3u8
#EXTINF:-1 tvg-id="DisneyJrLatinAmerica.ar@South",Disney Jr. Latin America South (480p)
http://38.183.182.166:8000/play/a0j9/index.m3u8
+2
View File
@@ -99,3 +99,5 @@ https://live-tvsn.simplestreamcdn.com/live12/tvsnbeauty/bitrate1.isml/.m3u8
https://live-tvsn.simplestreamcdn.com/live13/tvsnjewellery/bitrate1.isml/.m3u8
#EXTINF:-1 tvg-id="ABCAustralia.au@SD",ABC Australia
https://live.dinesh29.com.np/stream/jiotvplus/abcaustralia/master.m3u8
#EXTINF:-1 tvg-id="10Shake.au",10 Shake (480p)
http://44.32.200.212:5004/auto/v13
+21 -1
View File
@@ -1,9 +1,29 @@
#EXTM3U
#EXTINF:-1 tvg-id="BF1.bf@SD",BF1
https://cdn-u.bozztv.com/usn1/cdntulix-astv33/bf1tv/playlist.m3u8
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119690/bf1/playlist.m3u8
#EXTINF:-1 tvg-id="BurkinaInfoTV.bf",Burkina Info TV (540p)
http://69.64.57.208/burkinainfo/index.m3u8
#EXTINF:-1 tvg-id="RTB.bf",RTB (360p) [Not 24/7]
https://edge12.vedge.infomaniak.com/livecast/ik:rtblive1_8/manifest.m3u8
#EXTINF:-1 tvg-id="RTB3.bf",RTB 3
https://edge13.vedge.infomaniak.com/livecast/ik:rtb3-1/manifest.m3u8
#EXTINF:-1 tvg-id="SavaneTV.bf@SD",Savane TV
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2120430/savanetv/playlist.m3u8
#EXTINF:-1 tvg-id="3TV.bf@SD",3TV
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2120430/3tv/playlist.m3u8
#EXTINF:-1 tvg-id="BurkinaInfoTV.bf@SD",Burkina Info TV
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2120430/burkinainfo/playlist.m3u8
#EXTINF:-1 tvg-id="RTB3.bf@SD",RTB 3
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119691/rtb3/playlist.m3u8
#EXTINF:-1 tvg-id="TVMaria.bf@SD",TV Maria
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119690/tv-maria/playlist.m3u8
#EXTINF:-1 tvg-id="ImpactTV.bf@SD",Impact TV
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119690/impacttv/playlist.m3u8
#EXTINF:-1 tvg-id="CVK.bf@SD",CVK
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119690/cvk/playlist.m3u8
#EXTINF:-1 tvg-id="Canal3.bf@SD",Canal 3
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119690/canal3/playlist.m3u8
#EXTINF:-1 tvg-id="RTBZenith.bf@SD",RTB Zenith
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119690/rtbzenith/playlist.m3u8
#EXTINF:-1 tvg-id="RTB.bf@SD",RTB
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119033/rtb/playlist.m3u8
+4
View File
@@ -38,3 +38,7 @@ https://streamer103.neterra.tv/travel/live.m3u8
http://zagoratv.ddns.net:8080/tvzagora.m3u8
#EXTINF:-1 tvg-id="TVNBulgaria.bg",TVN-Bulgaria (1080p)
https://obs.friendshipchurch.eu/tvn/mystream.m3u8
#EXTINF:-1 tvg-id="Nickelodeon.bg@SD",Nickelodeon (576p)
http://87.246.60.6:11004/?token=fas-tv.com
#EXTINF:-1 tvg-id="DisneyChannel.bg",Disney Channel (576p)
http://84.54.128.52:9090/play/3006/index.m3u8
+4
View File
@@ -1 +1,5 @@
#EXTM3U
#EXTINF:-1 tvg-id="RTNBTV.bi@SD",RTNB TV
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119691/rtnb2/playlist.m3u8
#EXTINF:-1 tvg-id="RemaTV.bi@SD",Rema TV
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119691/rematv/playlist.m3u8
+8
View File
@@ -17,3 +17,11 @@ https://strhls.streamakaci.tv/ortb/ortb1-multi/playlist.m3u8
https://stream.it-innov.com/powertv/index.fmp4.m3u8
#EXTINF:-1 tvg-id="TVCBenin.bj",TVC Bénin (720p)
https://live.tvcbenin.com/direct/729c37a1d3319ee21991227b1f84c687.sdp/playlist.m3u8
#EXTINF:-1 tvg-id="TVCBenin.bj@SD",TVC Benin
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2120684/tvcbenin/playlist.m3u8
#EXTINF:-1 tvg-id="EdenTV.bj@SD",Eden TV
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2120684/edentv/playlist.m3u8
#EXTINF:-1 tvg-id="Canal3Benin.bj@SD",Canal 3 Benin
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119195/canal3benin/playlist.m3u8
#EXTINF:-1 tvg-id="ORTBTV.bj@SD",ORTB TV
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119033/ortb/playlist.m3u8
+2
View File
@@ -408,3 +408,5 @@ http://unitvaovivo.ufrgs.br:8080/live.ogg
https://sistemavrt.vrtchannel.tv.br:3473/stream/play.m3u8
#EXTINF:-1 tvg-id="VV8TV.br",VV8 TV (720p)
https://video01.kshost.com.br/seap8272/seap8272/playlist.m3u8
#EXTINF:-1 tvg-id="TVGuarapari.br@SD",TV Guarapari (1080p)
https://ikki.hubcloud.tv.br/TVGuarapari/index.m3u8
+8
View File
@@ -1 +1,9 @@
#EXTM3U
#EXTINF:-1 tvg-id="BTV1.bw@SD",BTV 1
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119033/btv1/playlist.m3u8
#EXTINF:-1 tvg-id="BTVNews.bw@SD",BTV News
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119033/btvnews/playlist.m3u8
#EXTINF:-1 tvg-id="YTV.bw",YTV
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119033/ytvbotswana/playlist.m3u8
#EXTINF:-1 tvg-id="BTV2.bw",BTV 2
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119033/btv2/playlist.m3u8
+1 -1
View File
@@ -91,7 +91,7 @@ https://streamer12.vdn.dstreamone.net/saazoawaz/saazoawaz/playlist.m3u8
https://eu.streamjo.com/eetlive/eettv.m3u8
#EXTINF:-1 tvg-id="ErfanHalghehTV.ca",Erfan Halgheh TV (720p)
https://hls.erfanhalgheh.live/hls/stream.m3u8
#EXTINF:-1 tvg-id="EWTN.us@Canada",EWTN Canada (720p) [Not 24/7]
#EXTINF:-1 tvg-id="EWTN.us@Canada",EWTN Canada (720p)
https://cdn3.wowza.com/1/YW5wSWZiRGd2eFlU/bGV0aVBq/hls/live/playlist.m3u8
#EXTINF:-1 tvg-id="FightNetwork.ca",Fight Network (1080p)
https://d12a2vxqkkh1bo.cloudfront.net/hls/main.m3u8
+16
View File
@@ -49,3 +49,19 @@ http://69.64.57.208/rtnc/playlist.m3u8
http://51.254.199.122:8080/e-cableRTNC3/index.m3u8
#EXTINF:-1 tvg-id="RTVS1.cd@SD",RTVS1
http://51.254.199.122:8080/RTVS1/index.m3u8
#EXTINF:-1 tvg-id="BOneTV.cd@SD",B-One TV
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2120686/b-one/playlist.m3u8
#EXTINF:-1 tvg-id="Kin24.cd@SD",Kin24
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119692/kin24/playlist.m3u8
#EXTINF:-1 tvg-id="RTGAWorld.cd@SD",RTGA World
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119692/rtga/playlist.m3u8
#EXTINF:-1 tvg-id="CanalCVVInternational.cd@SD",Canal CVV International
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119033/cvvtv/playlist.m3u8
#EXTINF:-1 tvg-id="RTVS1.cd@SD",RTVS1
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119692/tvs1/playlist.m3u8
#EXTINF:-1 tvg-id="RTNC3.cd@SD",RTNC 3
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119692/rtnc3/playlist.m3u8
#EXTINF:-1 tvg-id="DigitalCongoTV.cd@SD",Digital Congo TV
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119692/dcc/playlist.m3u8
#EXTINF:-1 tvg-id="RTNC.cd@SD",RTNC
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119692/rtnc1/playlist.m3u8
+3
View File
@@ -0,0 +1,3 @@
#EXTM3U
#EXTINF:-1 tvg-id="TVCentrafricaine.cf",TV Centrafricaine
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2120428/tcf/playlist.m3u8
+2
View File
@@ -13,3 +13,5 @@ http://69.64.57.208/telecongo/playlist.m3u8
http://trn03.bozztv.com/gin-telecongo/index.m3u8
#EXTINF:-1 tvg-id="TeleCongo.cg",Télé Congo (720p)
http://51.254.199.122:8080/telecongo/index.m3u8
#EXTINF:-1 tvg-id="TeleCongo.cg@SD",Tele Congo
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119194/telecongo/playlist.m3u8
+6
View File
@@ -47,3 +47,9 @@ http://69.64.57.208/la3/index.m3u8
https://cdn140m.panaccess.com/HLS/RTVC/index.m3u8
#EXTINF:-1 tvg-id="TVAlBayane.ci",TV Al Bayane (360p)
http://69.64.57.208/albayanetv/index.m3u8
#EXTINF:-1 tvg-id="BenieTV.ci@SD",Benie TV
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2120977/benietv/playlist.m3u8
#EXTINF:-1 tvg-id="7Info.ci@SD",7 Info
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2120684/7info/playlist.m3u8
#EXTINF:-1 tvg-id="RTI1.ci@SD",RTI 1
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119033/rti/playlist.m3u8
+8
View File
@@ -187,3 +187,11 @@ https://5ff3d9babae13.streamlock.net/jwagpqxehu/jwagpqxehu/playlist.m3u8
https://unlimited1-us.dps.live/vtv/vtv.smil/playlist.m3u8
#EXTINF:-1 tvg-id="VTVValledeAconcagua.cl",VTV Valle de Aconcagua (720p) [Not 24/7]
https://unlimited6-cl.dps.live/vtv/vtv.smil/playlist.m3u8
#EXTINF:-1 tvg-id="Canal13.cl",Canal 13 (1080p)
http://38.250.127.17:9800/play/a067/index.m3u8
#EXTINF:-1 tvg-id="ChileVision.cl",ChileVision (1080p)
http://38.250.127.17:9800/play/a05h/index.m3u8
#EXTINF:-1 tvg-id="CanalClaro.cl",Canal Claro (480p)
http://181.188.216.5:18000/play/a09l/index.m3u8
#EXTINF:-1 tvg-id="JuntosTV.cl@SD",Juntos TV
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119960/juntos/playlist.m3u8
+24
View File
@@ -31,3 +31,27 @@ http://connectiktv.ddns.net:5000/plextv/@plextv/playlist.m3u8
https://stream.it-innov.com/tr24/index.m3u8
#EXTINF:-1 tvg-id="Vision4.cm",Vision 4 (360p)
https://cdn-globecast.akamaized.net/live/eds/vision4/hls_video/index.m3u8
#EXTINF:-1 tvg-id="RevivalChannel.cm@SD",Revival Channel
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2120683/revivalchannel/playlist.m3u8
#EXTINF:-1 tvg-id="Canal2Movies.cm@SD",Canal 2 Movies
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2120430/canal2movies/playlist.m3u8
#EXTINF:-1 tvg-id="CAM10TV.cm@SD",CAM 10 TV
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2120431/cam10tv/playlist.m3u8
#EXTINF:-1 tvg-id="BalafonTV.cm@SD",Balafon TV
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2120430/balafontv/playlist.m3u8
#EXTINF:-1 tvg-id="MyMovieFrench.cm@SD",My Movie French
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2120430/mymoviesfrench/playlist.m3u8
#EXTINF:-1 tvg-id="STV.cm@SD",STV
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2120429/stvcameroon/playlist.m3u8
#EXTINF:-1 tvg-id="Bnews1.cm@SD",Bnews1
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2120429/bnews1/playlist.m3u8
#EXTINF:-1 tvg-id="AfriqueMedia.cm@SD",Afrique Media
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2120428/afriquemedia/playlist.m3u8
#EXTINF:-1 tvg-id="Canal2International.cm@SD",Canal 2 International
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2120428/canal2international/playlist.m3u8
#EXTINF:-1 tvg-id="CRTVSportsandEntertainment.cm@SD",CRTV Sports and Entertainment
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119689/crtvsports/playlist.m3u8
#EXTINF:-1 tvg-id="CRTVNews.cm@SD",CRTV News
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119689/crtvnews/playlist.m3u8
#EXTINF:-1 tvg-id="CRTV.cm@SD",CRTV
https://uvotv-aniview.global.ssl.fastly.net/hls/live/2119689/crtv/playlist.m3u8

Some files were not shown because too many files have changed in this diff Show More