Compare commits

..

78 Commits

Author SHA1 Message Date
renovate[bot]
ffbfad206e chore(deps): update actions/checkout action to v6 2026-04-21 21:54:00 +00:00
renovate[bot]
321a0e8540 chore(deps): lock file maintenance (#166)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 04:55:03 +00:00
renovate[bot]
cb8f769e34 chore(deps): lock file maintenance (#164)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-13 04:24:45 +00:00
renovate[bot]
38ff77a04e chore(deps): lock file maintenance (#162)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-06 02:01:35 +00:00
renovate[bot]
292cd8dd94 chore(deps): lock file maintenance (#161)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-30 04:52:58 +00:00
renovate[bot]
3878059314 chore(deps): lock file maintenance (#160)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-23 02:14:45 +00:00
renovate[bot]
7a7e50c7ba chore(deps): lock file maintenance (#158)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-16 01:49:18 +00:00
renovate[bot]
564dd536fc chore(deps): lock file maintenance (#157)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-09 05:28:15 +00:00
renovate[bot]
0e29805351 chore(deps): lock file maintenance (#152)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-02 09:38:43 +00:00
dc76267da3 Merge pull request #151 from TheBinaryNinja/m3u_xmltv_feature
PR 151: bump: version 1.5.9
2026-02-27 14:32:17 -05:00
147b11b22d bump: version 1.5.9 2026-02-27 14:30:03 -05:00
renovate[bot]
b992e4ff01 chore(deps): lock file maintenance (#149)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-23 05:25:33 +00:00
renovate[bot]
b46a922464 chore(deps): lock file maintenance (#147)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-16 04:56:43 +00:00
d4abc705a0 Merge pull request #145 from TheBinaryNinja/m3u_xmltv_feature
PR 145: Update XML and M3U file paths to version 2.0.0
2026-02-09 09:51:04 -05:00
454d13c608 Update XML and M3U file paths to version 2.0.0 2026-02-09 09:22:14 -05:00
renovate[bot]
6086dbbad2 chore(deps): lock file maintenance (#143)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-09 06:02:12 +00:00
renovate[bot]
b9607dddce chore(deps): lock file maintenance (#140)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-02 04:26:06 +00:00
renovate[bot]
1a7aeb4450 chore(deps): lock file maintenance (#137)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-26 05:43:30 +00:00
renovate[bot]
d973af6a8d chore(deps): lock file maintenance (#126)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-19 20:51:12 +00:00
2dae279f93 ci: switch runner in workflow 2026-01-08 12:34:23 -07:00
09d17717ab ci: update gitea workflow 2026-01-08 12:23:24 -07:00
bf4454f635 ci: update workflow network 2026-01-08 12:05:21 -07:00
9e531d823f ci: update release workflow 2026-01-08 11:57:25 -07:00
d17aa23e98 Merge pull request #133 from TheBinaryNinja/m3u-format
[FEATURE]: Add m3u playlist automated generation and validator
2026-01-07 23:44:37 -05:00
63f7c1d665 Change extM3U URL to XML EPG path
Updated extM3U URL to point to XML EPG location.
2026-01-07 23:20:47 -05:00
c5c2f741f0 ci: remove old index.js 2025-10-08 07:44:18 -07:00
ec24c51eea chore(lint): run linter 2025-10-08 07:41:19 -07:00
fa2c4073e3 ci: update gitea workflow 2025-10-08 06:54:56 -07:00
255d093269 ci: update gitea workflow 2025-10-08 06:51:24 -07:00
73a264b1c2 build: bump version to 1.5.8 2025-10-08 06:27:17 -07:00
iFlip721
c112230e05 update EPG endpoint with new pull location 2025-10-06 19:53:44 -04:00
renovate[bot]
02dd911e93 chore(deps): lock file maintenance (#123)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-06 05:45:30 +00:00
9c3ee3d146 refactor: update eslint corrections 2025-10-05 00:57:02 -07:00
4c8d5d03d9 chore(deps): update dependency uuid to v13 2025-10-05 00:07:47 -07:00
c729594864 build: populate channel urls from gitea repo 2025-10-05 00:06:12 -07:00
713626810b fix: update binaryninja gitea url 2025-10-04 23:37:05 -07:00
6e5c261065 ci: update docker flow 2025-10-01 03:29:38 -07:00
2f1027e068 ci: update docker build flow 2025-10-01 03:27:02 -07:00
739f547731 ci: update docker parameters 2025-10-01 03:22:19 -07:00
3f7ecdb84e ci: update docker buildx flags 2025-10-01 03:15:55 -07:00
e037764c3f ci: edit buildx installation parameters 2025-10-01 03:13:48 -07:00
c8aa866dfd ci: update docker build workflow for invalid / insecure ssl certs 2025-10-01 02:58:40 -07:00
84b1199878 ci: update docker restart command 2025-10-01 02:54:18 -07:00
04150d5320 ci: update bootstrap for docker buildx 2025-10-01 02:50:29 -07:00
11ccf2909f ci: update gitea workflow 2025-10-01 02:41:52 -07:00
631942ca75 docs(readme): add HDHR_PORT to env variables 2025-10-01 02:00:55 -07:00
4ee603d7a2 fix(hdhr): animated uptime now counting 2025-10-01 00:48:17 -07:00
7cfe22b72e feat: add HDHomeRun lineup.json to api 2025-10-01 00:47:55 -07:00
e6701cda95 feat: add silence health check to HDHomeRun server 2025-10-01 00:40:35 -07:00
865a2fd645 feat: add Hdhr.SlotsConnected and Hdhr:SlotsMax
add slot count to template and class
2025-10-01 00:34:07 -07:00
05f362153f docs(traefik): update traefik configs
add HDHomeRun
2025-10-01 00:11:04 -07:00
997eb72378 build(package): bump version v1.5.6 2025-09-30 23:54:39 -07:00
69805151c8 fix(hdhr): assign new var to tuner instance 2025-09-30 23:52:15 -07:00
47ec5267ec feat: add HDHomeRun website page when accessing port 6077 2025-09-30 23:50:27 -07:00
3a87b51f41 refactor(hdhr): add customizable HDHomeRun port 2025-09-30 23:07:43 -07:00
ffc8cfe68e build(dockerfile): add new env var HDHR_PORT 2025-09-30 23:04:29 -07:00
7f5fffa5e6 docs(license): update 2025-09-30 23:02:51 -07:00
b16f4a9fb3 refactor: remove docker.sock from examples/docker-compose.yml - fixes #105 2025-09-30 23:01:29 -07:00
ebf0b84a05 refactor: move classes migrated to dedicated class files 2025-09-30 22:58:37 -07:00
b724930c6a feat: add HDHomeRun core server functionality 2025-09-30 22:58:06 -07:00
603e444d35 refactor: migrate classes to dedicated class files; add new imports 2025-09-30 22:56:30 -07:00
f274b807f2 refactor: change the way domain checks validate a domain 2025-09-30 22:55:14 -07:00
d0c8920b98 fix(m3u): source 3 offline due to dns change 2025-09-30 22:52:56 -07:00
4c0d49508f feat: add git.binaryninja.net health check; stop overwriting m3u/epg when down
stops the app from wiping the m3u and xml file if the bit.binaryninja.net repo /website is down
2025-09-30 22:52:21 -07:00
2a09bc1ea3 feat: add new classes structure 2025-09-30 22:49:37 -07:00
259d27a2ce build: publish utils class 2025-09-30 22:47:57 -07:00
renovate[bot]
8aefbb39e0 chore(deps): lock file maintenance (#120)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-29 04:33:48 +00:00
renovate[bot]
e417b9f5d8 chore(deps): lock file maintenance (#118)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-22 05:30:37 +00:00
renovate[bot]
9458587d59 chore(deps): lock file maintenance (#114)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-15 23:27:53 +00:00
renovate[bot]
468c8c10fc chore(deps): lock file maintenance (#109)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-08 05:07:05 +00:00
renovate[bot]
6d90a88b60 chore(deps): lock file maintenance (#104)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-31 13:33:04 +00:00
renovate[bot]
7231199f9e chore(deps): lock file maintenance (#101)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-18 06:40:11 +00:00
renovate[bot]
41c0c9f685 chore(deps): lock file maintenance (#100)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-14 10:51:11 +00:00
renovate[bot]
79c5c648c9 chore(deps): lock file maintenance (#94)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-28 05:56:54 +00:00
renovate[bot]
0ba2e23171 chore(deps): lock file maintenance (#91)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-21 06:54:22 +00:00
renovate[bot]
b0f3869621 chore(deps): lock file maintenance (#89)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-14 06:08:51 +00:00
renovate[bot]
b709d53e40 chore(deps): lock file maintenance (#87)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-07 07:34:57 +00:00
renovate[bot]
b198168d75 chore(deps): lock file maintenance (#84)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 05:35:34 +00:00
34 changed files with 3033 additions and 1144 deletions

View File

@@ -114,7 +114,7 @@ jobs:
# #
- name: '☑️ Checkout'
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0

View File

@@ -152,8 +152,8 @@ jobs:
cleanup:
name: >-
🧹 Deployments Clean
# runs-on: ubuntu-latest
runs-on: apollo-x64
runs-on: ubuntu-latest
# runs-on: apollo-x64
timeout-minutes: 5
permissions: write-all
@@ -164,7 +164,7 @@ jobs:
# #
- name: '☑️ Checkout'
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0

View File

@@ -255,7 +255,7 @@ jobs:
- name: '✅ Checkout'
id: task_release_tags_checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -327,7 +327,7 @@ jobs:
- name: '✅ Checkout'
id: task_release_gh_checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -787,7 +787,7 @@ jobs:
- name: '✅ Checkout'
id: task_release_dh_checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -1232,7 +1232,7 @@ jobs:
- name: '✅ Checkout'
id: task_release_gi_checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0

View File

@@ -182,8 +182,8 @@ jobs:
job-docker-release-tags-create:
name: >-
📦 Release Create Tag
# runs-on: ubuntu-latest
runs-on: apollo-x64
runs-on: ubuntu-latest
# runs-on: apollo-x64
timeout-minutes: 4
outputs:
package_version: ${{ steps.task_initialize_package_getversion.outputs.PACKAGE_VERSION }}
@@ -199,7 +199,7 @@ jobs:
# #
- name: '☑️ Checkout'
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -374,8 +374,8 @@ jobs:
job-docker-release-dockerhub:
name: >-
📦 Release Dockerhub
# runs-on: ubuntu-latest
runs-on: apollo-x64
runs-on: ubuntu-latest
# runs-on: apollo-x64
timeout-minutes: 10
needs: [ job-docker-release-tags-create ]
permissions:
@@ -392,7 +392,7 @@ jobs:
# #
- name: '☑️ Checkout'
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0

View File

@@ -194,8 +194,8 @@ jobs:
job-docker-release-tags-create:
name: >-
📦 Release Create Tag
# runs-on: ubuntu-latest
runs-on: apollo-x64
runs-on: ubuntu-latest
# runs-on: apollo-x64
timeout-minutes: 4
outputs:
package_version: ${{ steps.task_initialize_package_getversion.outputs.PACKAGE_VERSION }}
@@ -211,7 +211,7 @@ jobs:
# #
- name: '☑️ Checkout'
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -386,8 +386,8 @@ jobs:
job-docker-release-gitea:
name: >-
📦 Release Gitea
# runs-on: ubuntu-latest
runs-on: apollo-x64
runs-on: ubuntu-latest
# runs-on: apollo-x64
timeout-minutes: 10
needs: [ job-docker-release-tags-create ]
permissions:
@@ -404,7 +404,7 @@ jobs:
# #
- name: '☑️ Checkout'
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -571,6 +571,32 @@ jobs:
id: task_release_gi_qemu
uses: docker/setup-qemu-action@v3
# #
# Required to fix insecure SSL error with docker buildx
# #
- name: '⚙️ Configure Docker daemon to allow insecure registry'
run: |
echo "Configuring daemon to treat ${REGISTRY_HOST} as insecure"
sudo mkdir -p /etc/docker
sudo tee /etc/docker/daemon.json > /dev/null <<'JSON'
{
"insecure-registries": ["git.binaryninja.net:443"]
}
JSON
# Restart Docker
sudo service docker restart
env:
REGISTRY_HOST: git.binaryninja.net
# #
# Make sure change in docker daemon config successful
# #
- name: '⚙️ Check Docker Daemon Configuration'
run: cat /etc/docker/daemon.json
# #
# Release Gitea Setup BuildX Amd64
# #
@@ -581,6 +607,10 @@ jobs:
with:
version: latest
driver-opts: 'image=moby/buildkit:latest'
driver: docker
buildkitd-flags: --allow-insecure-entitlement
install: true
use: true
# #
# Release Gitea Registry Login Amd64

View File

@@ -200,7 +200,7 @@ jobs:
- name: '✅ Checkout'
id: task_release_tags_checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -273,7 +273,7 @@ jobs:
- name: '✅ Checkout'
id: task_release_gh_checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
# #
# Release Gitea Get Timestamp
@@ -540,7 +540,7 @@ jobs:
- name: '✅ Checkout'
id: task_release_gh_checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
# #
# Release Gitea Get Timestamp

View File

@@ -181,8 +181,8 @@ jobs:
job-docker-release-tags-create:
name: >-
📦 Release Create Tag
# runs-on: ubuntu-latest
runs-on: apollo-x64
runs-on: ubuntu-latest
# runs-on: apollo-x64
timeout-minutes: 4
outputs:
package_version: ${{ steps.task_initialize_package_getversion.outputs.PACKAGE_VERSION }}
@@ -198,7 +198,7 @@ jobs:
# #
- name: '☑️ Checkout'
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -371,8 +371,8 @@ jobs:
job-docker-release-github:
name: >-
📦 Release Github
# runs-on: ubuntu-latest
runs-on: apollo-x64
runs-on: ubuntu-latest
# runs-on: apollo-x64
timeout-minutes: 10
needs: [ job-docker-release-tags-create ]
permissions:
@@ -389,7 +389,7 @@ jobs:
# #
- name: '☑️ Checkout'
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -954,8 +954,8 @@ jobs:
job-docker-release-cleanup:
name: >-
🧹 Release Cleanup
# runs-on: ubuntu-latest
runs-on: apollo-x64
runs-on: ubuntu-latest
# runs-on: apollo-x64
timeout-minutes: 5
needs: [ job-docker-release-tags-create, job-docker-release-github ]
permissions:
@@ -972,7 +972,7 @@ jobs:
# #
- name: '✅ Checkout'
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0

View File

@@ -170,8 +170,8 @@ env:
jobs:
build-docs:
# runs-on: ubuntu-latest
runs-on: apollo-x64
runs-on: ubuntu-latest
# runs-on: apollo-x64
timeout-minutes: 10
permissions:
contents: write
@@ -185,7 +185,7 @@ jobs:
# #
- name: '☑️ Checkout'
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0

View File

@@ -99,7 +99,7 @@ jobs:
# #
- name: '☑️ Checkout'
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0

View File

@@ -111,7 +111,7 @@ jobs:
# #
- name: '☑️ Checkout'
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0

View File

@@ -165,8 +165,8 @@ jobs:
job-labels-create:
name: >-
🎫 Labels Verify Existing
# runs-on: ubuntu-latest
runs-on: apollo-x64
runs-on: ubuntu-latest
# runs-on: apollo-x64
timeout-minutes: 5
steps:
@@ -175,7 +175,7 @@ jobs:
# #
- name: '☑️ Checkout'
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -335,8 +335,8 @@ jobs:
🏷️ Labels Assign
needs:
- job-labels-create
# runs-on: ubuntu-latest
runs-on: apollo-x64
runs-on: ubuntu-latest
# runs-on: apollo-x64
timeout-minutes: 5
permissions:
contents: 'read'
@@ -349,7 +349,7 @@ jobs:
# #
- name: '☑️ Checkout'
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -1191,8 +1191,8 @@ jobs:
🏷️ Labels Phrase Search
needs:
- job-labels-create
# runs-on: ubuntu-latest
runs-on: apollo-x64
runs-on: ubuntu-latest
# runs-on: apollo-x64
timeout-minutes: 5
permissions:
contents: 'read'
@@ -1207,7 +1207,7 @@ jobs:
- name: >-
☑️ Prepare
id: issues-labels-check-checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -1280,8 +1280,8 @@ jobs:
job-assign-assignees:
name: >-
✍️ Issue Assignees
# runs-on: ubuntu-latest
runs-on: apollo-x64
runs-on: ubuntu-latest
# runs-on: apollo-x64
timeout-minutes: 5
needs: [ job-assign-labels ]
# disable

View File

@@ -160,8 +160,8 @@ jobs:
job-pr-scan:
name: >-
🎫 Issues Autoscan
# runs-on: ubuntu-latest
runs-on: apollo-x64
runs-on: ubuntu-latest
# runs-on: apollo-x64
timeout-minutes: 5
permissions:
contents: read
@@ -175,7 +175,7 @@ jobs:
# #
- name: '☑️ Checkout'
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -385,7 +385,7 @@ jobs:
- name: >-
☑️ Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
if: |
( github.event_name == 'pull_request_target' ) || ( github.event_name == 'pull_request' ) || ( github.event_name == 'issue_comment' && contains( github.event.comment.html_url, '/pull/' ) && contains( github.event.comment.body, '/rescan' ) )
with:

View File

@@ -181,8 +181,8 @@ jobs:
job-labels-create:
name: >-
🎫 Labels Verify Existing
# runs-on: ubuntu-latest
runs-on: apollo-x64
runs-on: ubuntu-latest
# runs-on: apollo-x64
timeout-minutes: 5
steps:
@@ -191,7 +191,7 @@ jobs:
# #
- name: '☑️ Checkout'
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -360,8 +360,8 @@ jobs:
job-issues-nolabel:
name: >-
🎫 Labels Assign Missing
# runs-on: ubuntu-latest
runs-on: apollo-x64
runs-on: ubuntu-latest
# runs-on: apollo-x64
timeout-minutes: 4
needs: job-labels-create
steps:
@@ -371,7 +371,7 @@ jobs:
# #
- name: '☑️ Checkout'
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -961,8 +961,8 @@ jobs:
job-issues-stale:
name: >-
💤 Scan Check Stale
# runs-on: ubuntu-latest
runs-on: apollo-x64
runs-on: ubuntu-latest
# runs-on: apollo-x64
timeout-minutes: 5
needs:
- job-labels-create
@@ -1005,8 +1005,8 @@ jobs:
job-issues-lock:
name: >-
🔒 Scan Lock Inactive
# runs-on: ubuntu-latest
runs-on: apollo-x64
runs-on: ubuntu-latest
# runs-on: apollo-x64
timeout-minutes: 5
needs:
- job-labels-create

View File

@@ -146,8 +146,8 @@ jobs:
issues-labels-clean:
name: >-
🧹 Labels Clean
# runs-on: ubuntu-latest
runs-on: apollo-x64
runs-on: ubuntu-latest
# runs-on: apollo-x64
timeout-minutes: 3
permissions:
contents: 'read'
@@ -160,7 +160,7 @@ jobs:
# #
- name: '☑️ Checkout'
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0

View File

@@ -193,8 +193,8 @@ jobs:
issues-labels-create:
name: >-
🎫 Labels Create
# runs-on: ubuntu-latest
runs-on: apollo-x64
runs-on: ubuntu-latest
# runs-on: apollo-x64
timeout-minutes: 3
permissions:
contents: 'read'
@@ -207,7 +207,7 @@ jobs:
# #
- name: '☑️ Checkout'
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0

View File

@@ -79,7 +79,7 @@ jobs:
# #
- name: '☑️ Checkout'
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0

View File

@@ -166,7 +166,7 @@ jobs:
- name: '☑️ Checkout'
id: task_initialize_checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -229,7 +229,7 @@ jobs:
- name: '☑️ Checkout'
id: task_release_checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0

View File

@@ -99,6 +99,7 @@ ENV DIR_RUN=/usr/bin/app
ENV URL_REPO="https://git.binaryninja.net/binaryninja/"
ENV WEB_IP="0.0.0.0"
ENV WEB_PORT=4124
ENV HDHR_PORT=6077
ENV WEB_ENCODING="deflate, br"
ENV WEB_PROXY_HEADER="x-forwarded-for"
ENV STREAM_QUALITY="hd"

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2025 BinaryNinja
Copyright (c) 2025-2026 BinaryNinja
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -184,6 +184,7 @@ The following is a list of environment variables you can declare within your `do
| `WEB_FOLDER` | `www` | Internal container folder to keep TVApp2 web files in. <br /><br /> <sup>⚠️ This should not be used unless you know what you're doing</sup> |
| `WEB_ENCODING` | `deflate, br` | Defines the HTTP `Accept-Encoding` request and response header. This value specifies what content encoding the sender can understand<br /><br />Gzip compression can be enabled by specifying `'gzip, deflate, br'`, however, [it may break Jellyfin users](#build-error-err-27-jellyfinlivetvguideguidemanager-error-getting-programs-for-channel-xxxxxxxxxxxxxxx-source-2-systemxmlxmlexception--hexadecimal-value-0x1f-is-an-invalid-character-line-1-position-1). |
| `WEB_PROXY_HEADER` | `x-forwarded-for` | Defines the header to look for when finding a client's IP address. Used to get a client's IP when behind a reverse proxy or Cloudflare |
| `HDHR_PORT` | `6077` | HDHomeRun server default listening port |
| `URL_REPO` | `https://git.binaryninja.net/BinaryNinja/` | Determines where the data files will be downloaded from. Do not change this or you will be unable to get M3U and EPG data. |
| `FILE_URL` | `urls.txt` | Filename for `urls.txt` cache file |
| `FILE_M3U` | `playlist.m3u8` | Filename for M3U playlist file |
@@ -1318,6 +1319,7 @@ This docker container contains the following env variables:
| `WEB_FOLDER` | `www` | Internal container folder to keep TVApp2 web files in. <br /><br /> <sup>⚠️ This should not be used unless you know what you're doing</sup> |
| `WEB_ENCODING` | `deflate, br` | Defines the HTTP `Accept-Encoding` request and response header. This value specifies what content encoding the sender can understand<br /><br />Gzip compression can be enabled by specifying `'gzip, deflate, br'`, however, [it may break Jellyfin users](#build-error-err-27-jellyfinlivetvguideguidemanager-error-getting-programs-for-channel-xxxxxxxxxxxxxxx-source-2-systemxmlxmlexception--hexadecimal-value-0x1f-is-an-invalid-character-line-1-position-1). |
| `WEB_PROXY_HEADER` | `x-forwarded-for` | Defines the header to look for when finding a client's IP address. Used to get a client's IP when behind a reverse proxy or Cloudflare |
| `HDHR_PORT` | `6077` | HDHomeRun server default listening port |
| `URL_REPO` | `https://git.binaryninja.net/BinaryNinja/` | Determines where the data files will be downloaded from. Do not change this or you will be unable to get M3U and EPG data. |
| `FILE_URL` | `urls.txt` | Filename for `urls.txt` cache file |
| `FILE_M3U` | `playlist.m3u8` | Filename for M3U playlist file |

View File

@@ -25,7 +25,7 @@
services:
# #
# Service TVApp2 Traefik Labels
# Service TVApp2
# #
tvapp2:
@@ -40,7 +40,6 @@ services:
volumes:
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
- /var/run/docker.sock:/var/run/docker.sock
- ./config:/config
- ./app:/usr/bin/app
ulimits:
@@ -56,7 +55,7 @@ services:
- traefik.enable=true
# #
# Scope > http
# Routers Web Interface http
# #
- traefik.http.routers.tvapp2-http.rule=Host(`tvapp2.localhost`) || Host(`tvapp2.domain.lan`) || Host(`www.tvapp2.domain.lan`) || Host(`${SERVICE_IP}`)
@@ -66,7 +65,7 @@ services:
- traefik.http.routers.tvapp2-http.middlewares=https-redirect@file
# #
# Scope > https
# Routers Web Interface https
#
# remove the authentik@file line if you do not wish to use Authentik or middleware
# - traefik.http.routers.tvapp2-https.middlewares=authentik@file
@@ -83,8 +82,26 @@ services:
- traefik.http.routers.tvapp2-https.middlewares=authentik@file
# #
# Load Balancer
# Routers HDHomeRun
# #
- traefik.http.services.tvapp2.loadbalancer.server.port=http
- traefik.http.services.tvapp2.loadbalancer.server.scheme=4124
- traefik.http.routers.hdhr-https.rule=Host(`hdhr.domain.lan`)
- traefik.http.routers.hdhr-https.service=hdhr
- traefik.http.routers.hdhr-https.entrypoints=https
- traefik.http.routers.hdhr-https.priority=1
- traefik.http.routers.hdhr-https.tls=true
- traefik.http.routers.hdhr-https.tls.certresolver=cloudflare
# #
# Services Main Web Interface
# #
- traefik.http.services.tvapp2.loadbalancer.server.port=4124
- traefik.http.services.tvapp2.loadbalancer.server.scheme=http
# #
# Services HDHomeRun Server (optional)
# #
- traefik.http.services.hdhr.loadbalancer.server.port=6077
- traefik.http.services.hdhr.loadbalancer.server.scheme=http

View File

@@ -295,24 +295,24 @@ http:
- "*.domain.lan"
# #
# @container TVApp2
# @desc utomatic M3U playlist and XML guide updater for TheTvApp, TVPass, and MoveOnJoy utilized within your IPTV client.
# @container TVApp2 Main
# @desc automatic M3U playlist and XML guide updater for TheTvApp, TVPass, and MoveOnJoy utilized within your IPTV client.
# @url https://github.com/TheBinaryNinja/tvapp2
#
# remove / comment out the authentik line if you do not plan to use authentik:
# - authentik@file
# remove / comment out the authentik line if you do not plan to use authentik:
# - authentik@file
# #
tvapp2-http:
service: "tvapp2"
tvapp2-server-http:
service: "tvapp2-server"
rule: "Host(`tvapp2.localhost`) || Host(`tvapp2.domain.lan`)"
entryPoints:
- http
middlewares:
- https-redirect@file
tvapp2-https:
service: "tvapp2"
tvapp2-server-https:
service: "tvapp2-server"
rule: "Host(`tvapp2.localhost`) || Host(`tvapp2.domain.lan`)"
entryPoints:
- https
@@ -325,6 +325,37 @@ http:
- main: "domain.lan"
sans:
- "*.domain.lan"
# #
# @container TVApp2 HDHomeRun
# @desc automatic M3U playlist and XML guide updater for TheTvApp, TVPass, and MoveOnJoy utilized within your IPTV client.
# @url https://github.com/TheBinaryNinja/tvapp2
#
# remove / comment out the authentik line if you do not plan to use authentik:
# - authentik@file
# #
tvapp2-hdhr-http:
service: "tvapp2-hdhr"
rule: "Host(`hdhr.localhost`) || Host(`hdhr.domain.lan`)"
entryPoints:
- http
middlewares:
- https-redirect@file
tvapp2-hdhr-https:
service: "tvapp2-hdhr"
rule: "Host(`hdhr.localhost`) || Host(`hdhr.domain.lan`)"
entryPoints:
- https
middlewares:
- redirect-www@file
- authentik@file
tls:
certResolver: cloudflare
domains:
- main: "domain.lan"
sans:
- "*.domain.lan"
# #
# http Services
@@ -351,7 +382,12 @@ http:
servers:
- url: "http://plex:32400"
tvapp2:
tvapp2-server:
loadBalancer:
servers:
- url: "http://tvapp2:4124"
tvapp2-hdhr:
loadBalancer:
servers:
- url: "http://tvapp2:4124"

169
tvapp2/classes/CLib.js Normal file
View File

@@ -0,0 +1,169 @@
/*
Compress / Uncompress String with base64
these functions use a unique character table. moving the letters around will cause strings to not
be in the correct order once uncompressed.
@usage new CLib().compress( 'https://daddylive.mp/' )
new CLib().uncompress( 'burS7u6FvUHhZfrhkfJoYz8CswTD=' )
new CLib().translate( '=', plugin.defTrans, plugin.tvaTrans )
a custom character set can be specified with two additional parameters. however, anything prior
that was encoded will not be decoded by the new character set.
const strCompress = new CLib().compress( 'test.com' );
const strUncompress = new CLib().uncompress( strCompress );
new CLib().compress( 'test.com', 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/', 'rXzxP9ZdvehYlstwiTuV1c07j45Abo2Ama6k3gqpyf8n+/NMSEIUHBQRJDLFCGKO' )
new CLib().uncompress( 'oZcUozDkAQH=', 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/', 'rXzxP9ZdvehYlstwiTuV1c07j45Abo2Ama6k3gqpyf8n+/NMSEIUHBQRJDLFCGKO' )
*/
import chalk from 'chalk';
import Log from './Log.js';
/*
Class > CLib
*/
class CLib
{
constructor()
{
this.defTrans = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
this.tvaTrans = 'TVAPp29uqXiv6g5adr1j8nfwZ0bs7Ykm3xl4hczAtoey+/CDKJULSEMBQRFGIHNO';
}
compress( data, defTrans, tvaTrans )
{
if ( typeof data === 'string' )
data = Buffer.from( data, 'utf8' );
const transDef = defTrans || this.defTrans;
const transTva = tvaTrans || this.tvaTrans;
try
{
const dataCompress = this.translate( data.toString( 'base64' ), transDef, transTva );
Log.ok( `clib`, chalk.yellow( `[compress]` ), chalk.white( `⚙️` ),
chalk.blueBright( `<msg>` ), chalk.gray( `Compress string` ),
chalk.blueBright( `<strRaw>` ), chalk.gray( `${ data }` ),
chalk.blueBright( `<strCompress>` ), chalk.gray( `${ dataCompress }` ) );
return dataCompress;
}
catch ( err )
{
Log.error( `clib`, chalk.redBright( `[compress]` ), chalk.white( `` ),
chalk.redBright( `<msg>` ), chalk.gray( `Could not compress string; bad string ${ data }` ),
chalk.redBright( `<error>` ), chalk.gray( `${ err.message }` ),
chalk.redBright( `<strCompress>` ), chalk.gray( `${ data }` ) );
return null;
}
}
uncompress( data, defTrans, tvaTrans )
{
if ( Buffer.isBuffer( data ) )
data = data.toString();
const transDef = defTrans || this.defTrans;
const transTva = tvaTrans || this.tvaTrans;
try
{
const dataTranslated = this.translate( data, transTva, transDef );
const dataUncompress = Buffer.from( dataTranslated, 'base64' ).toString( 'utf8' );
Log.ok( `clib`, chalk.yellow( `[decompss]` ), chalk.white( `⚙️` ),
chalk.blueBright( `<msg>` ), chalk.gray( `Uncompress string` ),
chalk.blueBright( `<strCompress>` ), chalk.gray( `${ data }` ),
chalk.blueBright( `<strRaw>` ), chalk.gray( `${ dataUncompress }` ) );
return dataUncompress;
}
catch ( err )
{
Log.error( `clib`, chalk.redBright( `[decompss]` ), chalk.white( `` ),
chalk.redBright( `<msg>` ), chalk.gray( `Could not uncompress string; bad string ${ data }` ),
chalk.redBright( `<error>` ), chalk.gray( `${ err.message }` ),
chalk.redBright( `<strCompress>` ), chalk.gray( `${ data }` ) );
return null;
}
}
/*
Translate
compresses or decompresses encoded strings for the functions:
- compress
- uncompress
*/
translate( str, fromChars, toChars )
{
let res = '';
for ( let i = 0;i < str.length;i++ )
{
const char = str[i];
const index = fromChars.indexOf( char );
if ( index !== -1 )
res += toChars[index];
else
res += char;
}
return res;
}
/*
Encode: String > Hex > Base64
encodes a human-readable string into a hex value, and then to base64
@usage const clib = new CLib()
const encoded = clib.encodeToHexBase64('hello'); // Njg2NTZjNmM2Zg==
const decoded = clib.decodeFromHexBase64(`${ encoded }`); // hello
*/
encodeToHexBase64( str )
{
const hex = [...str].map( ( char ) =>
{
const code = char.charCodeAt( 0 ).toString( 16 );
return code.padStart( 2, '0' );
}).join( '' );
const base64 = btoa( hex );
return base64;
}
/*
Decode: Base64 > Hex > String
decodes a base64 value to hex, and then back into a human readable string
@usage const clib = new CLib()
const encoded = clib.encodeToHexBase64('hello'); // Njg2NTZjNmM2Zg==
const decoded = clib.decodeFromHexBase64(`${ encoded }`); // hello
*/
decodeFromHexBase64( base64Str )
{
const hex = atob( base64Str );
const chars = hex.match( /.{1,2}/g ); // every 2 hex chars = 1 byte
return chars.map( ( byte ) => String.fromCharCode( parseInt( byte, 16 ) ) ).join( '' );
}
}
/*
export class
@usage import CLib from './classes/CLib.js';
*/
// eslint-disable-next-line no-restricted-syntax
export default CLib;

121
tvapp2/classes/Log.js Normal file
View File

@@ -0,0 +1,121 @@
/*
Define > Logs
When assigning text colors, terminals and the windows command prompt can display any color; however apps
such as Portainer console cannot. If you use 16 million colors and are viewing console in Portainer, colors will
not be the same as the rgb value. It's best to just stick to Chalk's default colors.
Various levels of logs with the following usage:
Log.verbose(`This is verbose`)
Log.debug(`This is debug`)
Log.info(`This is info`)
Log.ok(`This is ok`)
Log.notice(`This is notice`)
Log.warn(`This is warn`)
Log.error(
`Error fetching sports data with error:`,
chalk.white(`→`),
chalk.grey(`This is the error message`)
);
Level Type
-----------------------------------
6 Trace
5 Debug
4 Info
3 Notice
2 Warn
1 Error
*/
import fs from 'fs';
import chalk from 'chalk';
/*
chalk.level
@ref https://npmjs.com/package/chalk
- 0 All colors disabled
- 1 Basic color support (16 colors)
- 2 256 color support
- 3 Truecolor support (16 million colors)
When assigning text colors, terminals and the windows command prompt can display any color; however apps
such as Portainer console cannot. If you use 16 million colors and are viewing console in Portainer, colors will
not be the same as the rgb value. It's best to just stick to Chalk's default colors.
*/
chalk.level = 3;
/*
Define
*/
const LOG_LEVEL = process.env.LOG_LEVEL || 4;
const { name } = JSON.parse( fs.readFileSync( './package.json' ) );
/*
Class > Log
*/
class Log
{
static now()
{
const now = new Date();
return chalk.gray( `[${ now.toLocaleTimeString() }]` );
}
static verbose( ...msg )
{
if ( LOG_LEVEL >= 6 )
console.debug( chalk.white.bgBlack.blackBright.bold( ` ${ name } ` ), chalk.white( `⚙️` ), this.now(), chalk.gray( msg.join( ' ' ) ) );
}
static debug( ...msg )
{
if ( LOG_LEVEL >= 7 )
console.trace( chalk.white.bgMagenta.bold( ` ${ name } ` ), chalk.white( `⚙️` ), this.now(), chalk.magentaBright( msg.join( ' ' ) ) );
else if ( LOG_LEVEL >= 5 )
console.debug( chalk.white.bgGray.bold( ` ${ name } ` ), chalk.white( `⚙️` ), this.now(), chalk.gray( msg.join( ' ' ) ) );
}
static info( ...msg )
{
if ( LOG_LEVEL >= 4 )
console.info( chalk.white.bgBlueBright.bold( ` ${ name } ` ), chalk.white( `` ), this.now(), chalk.blueBright( msg.join( ' ' ) ) );
}
static ok( ...msg )
{
if ( LOG_LEVEL >= 4 )
console.log( chalk.white.bgGreen.bold( ` ${ name } ` ), chalk.white( `` ), this.now(), chalk.greenBright( msg.join( ' ' ) ) );
}
static notice( ...msg )
{
if ( LOG_LEVEL >= 3 )
console.log( chalk.white.bgYellow.bold( ` ${ name } ` ), chalk.white( `📌` ), this.now(), chalk.yellowBright( msg.join( ' ' ) ) );
}
static warn( ...msg )
{
if ( LOG_LEVEL >= 2 )
console.warn( chalk.white.bgYellow.bold( ` ${ name } ` ), chalk.white( `⚠️` ), this.now(), chalk.yellowBright( msg.join( ' ' ) ) );
}
static error( ...msg )
{
if ( LOG_LEVEL >= 1 )
console.error( chalk.white.bgRedBright.bold( ` ${ name } ` ), chalk.white( `` ), this.now(), chalk.redBright( msg.join( ' ' ) ) );
}
}
/*
export class
@usage import Log from './classes/Log.js';
*/
// eslint-disable-next-line no-restricted-syntax
export default Log;

View File

@@ -0,0 +1,47 @@
/*
Semaphore > Declare
allows multiple threads to work with the same shared resources
*/
class Semaphore
{
constructor( max )
{
this.max = max;
this.queue = [];
this.active = 0;
}
async acquire()
{
if ( this.active < this.max )
{
this.active++;
return;
}
return new Promise( ( resolve ) => this.queue.push( resolve ) );
}
release()
{
this.active--;
if ( this.queue.length > 0 )
{
const resolve = this.queue.shift();
this.active++;
resolve();
}
}
}
/*
export class
@usage import Log from './classes/Log.js';
*/
// eslint-disable-next-line no-restricted-syntax
export default Semaphore;

520
tvapp2/classes/Storage.js Normal file
View File

@@ -0,0 +1,520 @@
/*
Class Storage
The storage classes allows you to save specific settings into a json file. These settings are better off being stored in
a local file, instead of using up the resources being saved in a database.
Class supports multiple storage files, but by default, it will save settings in `www/config.json`.
Settings include Tuner / HDHomeRun device information, etc.
@usage
const storage = new Storage( envWebFolder, FILE_CFG );
*/
import chalk from 'chalk';
import path from 'path';
import nconf from 'nconf';
import fs from 'fs';
import Log from './Log.js';
import Utils from './Utils.js';
import { fileURLToPath } from 'url';
/*
CJS ESM
*/
const __filename = fileURLToPath( import.meta.url ); // get resolved path to file
const __dirname = path.dirname( __filename ); // get name of directory
/*
Class Storage
constructor ( str:folder, str:file )
Initialize ( bool:bForceNew )
Setup ( bool:bForceNew )
Get ( str:key )
Set ( str:key, any:value )
Save ( )
GetConfig ( )
isJsonString ( json:str )
isJsonEmpty ( obj:json )
*/
class Storage
{
/*
Constructor Storage
Initializes a Storage instance for managing the config.json file.
Determines the full path to the config file based on folder and file arguments,
or uses the default static fileConfig if none are provided.
Handles Node.js packaged apps (process.pkg) by adjusting paths accordingly.
@args
folder (str) Optional folder where config.json will be stored. Defaults to 'www'.
file (str) Optional config file name. Defaults to static Storage.fileConfig.
@usage
const storage = new Storage(envWebFolder, FILE_CFG);
*/
static fileConfig = path.resolve( process.cwd( ), 'www', 'config.json' );
constructor( folder, file )
{
this.folderWeb = folder || 'www';
this.fileConfig = file ? path.resolve( folder, file ) : Storage.fileConfig;
if ( process.pkg )
this.fileConfig = path.join( path.dirname( process.execPath ), this.folderWeb, this.fileConfig );
else
this.fileConfig = path.resolve( process.cwd( ), this.folderWeb, this.fileConfig );
}
/*
Initialize Activate Config Setup with Logging
Activates the Storage.Setup( ) function while providing detailed logging.
Ensures the user's config.json file exists, is valid, and is initialized
with default values if missing or corrupt.
Steps:
- Logs the start of initialization.
- Calls Setup( ) with optional force flag to recreate config.
- Catches and logs any errors during setup.
@args
bForceNew (bool) Optional. If true, forces the config file to be removed
and regenerated from defaults.
@returns
(Promise) Resolves when initialization completes, or logs an error if setup fails.
@usage
const storage = new Storage(envWebFolder, FILE_CFG);
await storage.Initialize(false);
*/
async Initialize( bForceNew )
{
Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `<name>` ), chalk.gray( `${ Utils.getFuncName( ) }` ) );
const bForce = bForceNew || false;
try
{
Log.info( `conf`, chalk.yellow( `[initiate]` ), chalk.white( `` ),
chalk.blueBright( `<msg>` ), chalk.gray( `Initializing config file` ),
chalk.blueBright( `<file>` ), chalk.gray( `${ this.fileConfig }` ) );
await new Storage( ).Setup( bForce );
}
catch ( err )
{
console.log( 'Error writing Metadata.json:' + err.message );
}
}
/*
Initialize Setup User Config File
Sets up a user's config.json file, ensuring it exists and is valid JSON.
If the file is missing, empty, or invalid, it will be created or replaced.
Typically, you should call this via Storage( ).Initialize( ) rather than Setup( ) directly.
Steps:
- Creates parent directory if it doesn't exist.
- Removes existing config if bForceNew is true.
- Validates existing JSON; backs up invalid files.
- Creates default config if missing.
- Wires up nconf with argv, env, file, and default values.
@args
bForceNew (bool) Optional flag to force recreate the config file, wiping all existing data.
@returns
(Promise) Resolves true when initialization completes successfully.
@usage
const storage = new Storage(envWebFolder, FILE_CFG);
await storage.Initialize(false);
*/
async Setup( bForceNew )
{
Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `<name>` ), chalk.gray( `${ Utils.getFuncName( ) }` ) );
return new Promise( ( resolve, reject ) =>
{
try
{
Log.info( `conf`, chalk.yellow( `[generate]` ), chalk.white( `` ),
chalk.blueBright( `<msg>` ), chalk.gray( `Initializing storage setup` ),
chalk.blueBright( `<force>` ), chalk.gray( `${ bForceNew }` ),
chalk.blueBright( `<file>` ), chalk.gray( `${ this.fileConfig }` ) );
/*
ensure parent directory exists
*/
const dirPath = path.dirname( this.fileConfig );
if ( !fs.existsSync( dirPath ) )
{
fs.mkdirSync( dirPath, { recursive: true });
}
/*
if force flag is true, remove existing config file (force)
*/
if ( bForceNew === true && fs.existsSync( this.fileConfig ) )
{
Log.ok( `conf`, chalk.yellow( `[generate]` ), chalk.white( `` ),
chalk.greenBright( `<msg>` ), chalk.gray( `Remove original config; force new` ),
chalk.greenBright( `<file>` ), chalk.gray( `${ this.fileConfig }` ) );
try
{
fs.unlinkSync( this.fileConfig );
}
catch ( e )
{
Log.error( `conf`, chalk.redBright( `[generate]` ), chalk.white( `` ),
chalk.redBright( `<msg>` ), chalk.gray( `Failed to unlink existing config` ),
chalk.redBright( `<error>` ), chalk.gray( `${ e.message }` ),
chalk.redBright( `<file>` ), chalk.gray( `${ this.fileConfig }` ) );
}
}
/*
if config exists, validate JSON; if invalid, move to backup and recreate
*/
if ( fs.existsSync( this.fileConfig ) )
{
let raw = null;
let parsed = null;
try
{
raw = fs.readFileSync( this.fileConfig, { encoding: 'utf8' });
if ( typeof raw !== 'string' || raw.trim( ).length === 0 )
{
throw new Error( 'Empty config file' );
}
parsed = JSON.parse( raw );
}
catch ( e )
{
const backupPath = `${ this.fileConfig }.corrupt.${ Date.now( ) }`;
try
{
fs.renameSync( this.fileConfig, backupPath );
Log.error( `conf`, chalk.redBright( `[generate]` ), chalk.white( `` ),
chalk.redBright( `<msg>` ), chalk.gray( `Config file invalid; moved to backup` ),
chalk.redBright( `<backup>` ), chalk.gray( `${ backupPath }` ),
chalk.redBright( `<file>` ), chalk.gray( `${ this.fileConfig }` ) );
}
catch ( renameErr )
{
Log.error( `conf`, chalk.redBright( `[generate]` ), chalk.white( `` ),
chalk.redBright( `<msg>` ), chalk.gray( `Unable to backup invalid config file` ),
chalk.redBright( `<error>` ), chalk.gray( `${ renameErr.message }` ),
chalk.redBright( `<file>` ), chalk.gray( `${ this.fileConfig }` ) );
if ( this.rejected )
{
reject( renameErr );
return;
}
}
}
}
/*
if config does not exist (or was just moved because it was corrupt), create it atomically
*/
if ( !fs.existsSync( this.fileConfig ) )
{
const defaults =
{
deviceId: 'FFFFFFFF'
};
const tempPath = `${ this.fileConfig }.tmp`;
try
{
fs.writeFileSync( tempPath, JSON.stringify( defaults, null, 4 ), { encoding: 'utf8' });
fs.renameSync( tempPath, this.fileConfig );
Log.ok( `conf`, chalk.yellow( `[generate]` ), chalk.white( `` ),
chalk.greenBright( `<msg>` ), chalk.gray( `Created new config file with defaults` ),
chalk.greenBright( `<file>` ), chalk.gray( `${ this.fileConfig }` ) );
}
catch ( writeErr )
{
Log.error( `conf`, chalk.redBright( `[generate]` ), chalk.white( `` ),
chalk.redBright( `<msg>` ), chalk.gray( `Failed to create config file` ),
chalk.redBright( `<error>` ), chalk.gray( `${ writeErr.message }` ),
chalk.redBright( `<file>` ), chalk.gray( `${ this.fileConfig }` ) );
if ( this.rejected )
{
reject( writeErr );
return;
}
}
}
/*
now that file exists and is valid JSON, wire up nconf
*/
nconf.argv( ).env({ parseValues: true }).file({ file: this.fileConfig }).defaults(
{
deviceId: 'FFFFFFFF'
});
}
catch ( err )
{
Log.error( `conf`, chalk.redBright( `[generate]` ), chalk.white( `` ),
chalk.redBright( `<msg>` ), chalk.gray( `Could not generate and write to new config file` ),
chalk.redBright( `<error>` ), chalk.gray( `${ err.message }` ),
chalk.redBright( `<file>` ), chalk.gray( `${ this.fileConfig }` ) );
if ( this.rejected )
{
reject( err );
return;
}
}
resolve( true );
});
}
/*
Get Retrieve Configuration Value
Fetches a stored value from the application's persistent configuration
using the provided key via the nconf module.
This function is static, so it can be called without creating a Storage instance.
@args
key (str) The configuration key to retrieve.
@returns
(any) The value associated with the key, or undefined if the key does not exist.
@usage
const deviceId = Storage.Get('deviceId');
*/
static Get( key )
{
Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `<name>` ), chalk.gray( `${ Utils.getFuncName( ) }` ) );
return nconf.get( key );
}
/*
Set Store Configuration Value
Stores a value in the application's persistent configuration using
the provided key via the nconf module. Automatically saves the
updated configuration to disk by calling Storage.Save( ).
This function is static, so it can be called without creating a Storage instance.
@args
key (str) The configuration key to set.
value (any) The value to store under the specified key.
@returns
(void) No return value.
@usage
Storage.Set('deviceId', '105B35EF');
*/
static Set( key, value )
{
Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ),
chalk.blueBright( `<name>` ), chalk.gray( `${ Utils.getFuncName( ) }` ) );
nconf.set( key, value );
Storage.Save( );
}
/*
Save Persist Configuration to Disk
Saves the current configuration stored in nconf to disk.
After saving, the method reads back the file to verify it is valid JSON
and logs detailed status messages about success or errors.
@purpose
- Calls nconf.save() to write the current configuration.
- Reads back the saved file.
- Parses the file as JSON to confirm validity.
- Logs success or detailed error messages for failures.
@args
none
@returns
(void) Logs success or error; does not return a value.
@usage
Storage.Save();
*/
static Save( )
{
const filePath = this.fileConfig;
Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ),
chalk.blueBright( `<name>` ), chalk.gray( `${ Utils.getFuncName( ) }` ) );
nconf.save( ( err ) =>
{
if ( err )
{
Log.error( `conf`, chalk.redBright( `[snapshot]` ), chalk.white( `` ),
chalk.redBright( `<msg>` ), chalk.gray( `Could not save config` ),
chalk.redBright( `<error>` ), chalk.gray( `${ err }` ),
chalk.redBright( `<file>` ), chalk.gray( `${ filePath }` ) );
return;
}
fs.readFile( filePath, ( err, data ) =>
{
if ( err )
{
Log.error( `conf`, chalk.redBright( `[snapshot]` ), chalk.white( `` ),
chalk.redBright( `<msg>` ), chalk.gray( `Unable to read config file` ),
chalk.redBright( `<error>` ), chalk.gray( `${ err }` ),
chalk.redBright( `<file>` ), chalk.gray( `${ filePath }` ) );
return;
}
try
{
const parsed = JSON.parse( data.toString( ) );
Log.ok( `conf`, chalk.yellow( `[snapshot]` ), chalk.white( `` ),
chalk.greenBright( `<msg>` ), chalk.gray( `Save to config file successful` ),
chalk.greenBright( `<file>` ), chalk.gray( `${ filePath }` ) );
Log.debug( `conf`, chalk.yellow( `[snapshot]` ), chalk.white( `⚙️` ),
chalk.blueBright( `<msg>` ), chalk.gray( `Read values from saved config file` ),
chalk.blueBright( `<file>` ), chalk.gray( `${ filePath }` ),
chalk.blueBright( `<values>` ), chalk.gray( `${ JSON.stringify( parsed ) }` ) );
}
catch ( parseErr )
{
Log.error( `conf`, chalk.redBright( `[snapshot]` ), chalk.white( `` ),
chalk.redBright( `<msg>` ), chalk.gray( `Config file is not valid JSON` ),
chalk.redBright( `<error>` ), chalk.gray( `${ parseErr.message }` ),
chalk.redBright( `<file>` ), chalk.gray( `${ filePath }` ) );
}
});
});
}
/*
GetConfig Return Full Path to Config File
Returns the full path to the currently used config.json file for this Storage instance.
This is useful when you need to know the exact file location without reading its contents.
@args
none
@returns
(str) Absolute path to the config.json file.
@usage
const storage_config = Storage.GetConfig();
*/
static GetConfig( )
{
Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `<name>` ), chalk.gray( `${ Utils.getFuncName( ) }` ) );
return this.fileConfig;
}
/*
isJsonString Check if Input is Valid JSON
Determines whether a given string is valid JSON by attempting
to parse it. Returns true if parsing succeeds, false if it throws
an error.
@args
json (str) The string to test for valid JSON.
@returns
(bool) True if input is valid JSON, false otherwise.
@usage
const valid = Storage.isJsonString('{"key":"value"}'); // returns true
*/
static isJsonString( json )
{
Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `<name>` ), chalk.gray( `${ Utils.getFuncName( ) }` ) );
try
{
JSON.parse( json );
}
catch ( e )
{
return false;
}
return true;
}
/*
helper json object empty
*/
static isJsonEmpty( json )
{
Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `<name>` ), chalk.gray( `${ Utils.getFuncName( ) }` ) );
if ( Object.keys( json ).length === 0 )
return true;
if ( JSON.stringify( json ) === '\"{}\"' )
return true;
for ( const key in json )
{
if ( ! Object.prototype.hasOwnProperty.call( json, key ) )
return true;
}
return false;
}
}
/*
export class
@import
import Storage from './classes/Storage.js';
*/
// eslint-disable-next-line no-restricted-syntax
export default Storage;

455
tvapp2/classes/Tuner.js Normal file
View File

@@ -0,0 +1,455 @@
/*
Class Tuner
Handles HDHomeRun device management and deviceId lifecycle.
@purpose
- Generate / format HDHomeRun device IDs.
- Validate device IDs against HDHomeRun rules (length, hex chars, checksum).
- Persist device IDs using Storage class.
- Automatically generate new device ID if missing, invalid, or uninitialized (FFFFFFFF).
- Initialize tuner instances with validated device IDs.
@usage
await new Tuner( Storage.Get( 'deviceId' ) ).Initialize( );
const tuner = new Tuner( );
await tuner.Initialize( );
const validId = await tuner.VerifyDeviceId( );
@notes
- Device IDs are persisted via the Storage class (config.json).
- User's device id must be valid before HDHomeRun will initialize.
*/
import chalk from 'chalk';
import Storage from './Storage.js';
import Utils from './Utils.js';
import Log from './Log.js';
/*
Class Tuner
constructor ( str:deviceId )
Initialize ( )
Start ( )
_GenerateDeviceId ( int:len )
GenerateDeviceId ( )
GetDeviceId ( )
FormatDeviceId ( str:deviceid )
IsDeviceIdValid ( )
VerifyDeviceId ( )
*/
class Tuner
{
constructor( deviceId )
{
Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `<name>` ), chalk.gray( `${ Utils.getConstructorName( ) }` ) );
this.Name = `HDHomeRun`;
this.FriendlyName = `TVApp2`;
this.ModelNumber = `HDHR5-4US`;
this.FirmwareName = `hdhomerun5_atsc`;
this.FirmwareVersion = `0.9.15.00-RC04`;
this.SlotsConnected = 0;
this.SlotsMax = 10;
this.DeviceId = deviceId || Storage.Get( 'deviceId' );
}
/*
Initialize Setup and Start Tuner
Initializes the tuner by calling the Start( ) method.
Catches and logs any errors encountered during startup.
@args
none
@returns
(void) Logs status; does not return a value.
@usage
await tuner.Initialize( );
*/
async Initialize( )
{
Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `<name>` ), chalk.gray( `${ Utils.getFuncName( ) }` ) );
try
{
await this.Start( );
}
catch ( err )
{
Log.error( `hdhr`, chalk.redBright( `[initiate]` ), chalk.white( `` ),
chalk.redBright( `<msg>` ), chalk.gray( `Failure initializing tuner` ),
chalk.redBright( `<error>` ), chalk.gray( `${ err.message }` ) );
}
}
/*
Start Initialize and Verify Device ID
Starts the tuner by verifying the current deviceId.
If the deviceId is missing or invalid, it will be regenerated and validated.
Logs the status of the deviceId once verification completes.
@args
none
@returns
(bool) true if deviceId is valid after verification, false otherwise.
@usage
await tuner.Start( );
*/
async Start( )
{
Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `<name>` ), chalk.gray( `${ Utils.getFuncName( ) }` ) );
const verifiedId = await new Tuner( ).VerifyDeviceId( this.DeviceId );
if ( await this.IsDeviceIdValid( verifiedId ) )
{
Log.ok( `conf`, chalk.yellow( `[validate]` ), chalk.white( `` ),
chalk.greenBright( `<msg>` ), chalk.gray( `User has valid deviceId` ),
chalk.greenBright( `<deviceId>` ), chalk.gray( `${ verifiedId }` ) );
}
}
/*
_GenerateDeviceId Generate Raw Random Hexadecimal String
Generates a raw random hexadecimal string using Node.js crypto module.
This is typically used as the random portion of a deviceId.
@args
len (int) Optional number of bytes to generate. Defaults to 4 bytes.
@returns
(str) Uppercase hexadecimal string, length = len * 2 characters.
@usage
const randomHex = Tuner._GenerateDeviceId( 4 ); // 8-character hex string
*/
static _GenerateDeviceId( len )
{
Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `<name>` ), chalk.gray( `${ Utils.getFuncName( ) }` ) );
return crypto.randomBytes( len || 4 ).toString( 'hex' ).toUpperCase( );
}
/*
GenerateDeviceId Generate New HDHomeRun Device ID
Generates a new, properly formatted HDHomeRun deviceId.
Steps:
- Generates 4 random hexadecimal characters.
- Prepends '105' and appends '0' to form base deviceId.
- Passes baseId to Tuner.FormatDeviceId( ) to ensure correct checksum and 8-character format.
@args
None
@returns
(str) A valid, 8-character HDHomeRun deviceId in uppercase hexadecimal.
@usage
const newDeviceId = Tuner.GenerateDeviceId( );
*/
static GenerateDeviceId( )
{
Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `<name>` ), chalk.gray( `${ Utils.getFuncName( ) }` ) );
const chars = '0123456789ABCDEF';
let randomHex = '';
// generate 4 random hexadecimal chars
for ( let i = 0;i < 4;i++ )
{
randomHex += chars[Math.floor( Math.random( ) * chars.length )];
}
const baseId = '105' + randomHex + '0';
return this.FormatDeviceId( baseId );
}
/*
GetDeviceId Retrieve Stored HDHomeRun Device ID
Fetches the current deviceId from persistent storage (via Storage.Get).
@args
None
@returns
(str) The current deviceId stored in configuration.
@usage
const deviceId = await tuner.GetDeviceId( );
*/
GetDeviceId( )
{
Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `<name>` ), chalk.gray( `${ Utils.getFuncName( ) }` ) );
return Storage.Get( 'deviceId' );
}
/*
FormatDeviceId Validate and Format HDHomeRun Device ID
Fetches the provided deviceId (or instance default) and ensures it is valid
according to HDHomeRun rules, then returns a properly formatted ID.
Steps:
- Input must be exactly 8 hexadecimal characters.
- All characters must be 0-9 or A-F/a-f.
- Computes checksum using HDHomeRun-specific lookup table.
- Generates a new deviceId integer with checksum applied.
- Converts back to 8-character uppercase hexadecimal string.
Logs detailed errors if the input deviceId is invalid.
@args
deviceid (str) Optional deviceId to format. Defaults to instance deviceId.
@returns
(str|int) Formatted 8-character hex deviceId, or 0 if input invalid.
@usage
const formattedId = Tuner.FormatDeviceId( someDeviceId );
*/
static FormatDeviceId( deviceid )
{
Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `<name>` ), chalk.gray( `${ Utils.getFuncName( ) }` ) );
const deviceId = deviceid || this.DeviceId;
/*
Validate input length
*/
if ( !deviceId || deviceId.length !== 8 )
{
Log.error( `hdhr`, chalk.redBright( `[validate]` ), chalk.white( `` ),
chalk.redBright( `<msg>` ), chalk.gray( `HDHomeRun deviceId must be 8 hexadecimals` ),
chalk.redBright( `<deviceId>` ), chalk.gray( `${ deviceId }` ) );
return 0;
}
/*
All chars should be valid hexadecimal
*/
const hexPattern = /^[0-9A-Fa-f]+$/;
if ( !hexPattern.test( deviceId ) )
{
Log.error( `hdhr`, chalk.redBright( `[validate]` ), chalk.white( `` ),
chalk.redBright( `<msg>` ), chalk.gray( `HDHomeRun deviceId must contain all hex (0-9, A-F, a-f)` ),
chalk.redBright( `<deviceId>` ), chalk.gray( `${ deviceId }` ) );
return 0;
}
/*
Hex string to integer
*/
const deviceIdInt = parseInt( deviceId, 16 );
/*
Checksum lookup table
*/
const checksumLookup =
[
0xA, 0x5, 0xF, 0x6, 0x7, 0xC, 0x1, 0xB, 0x9, 0x2, 0x8, 0xD, 0x4, 0x3, 0xE, 0x0
];
/*
Calc checksum
*/
let checksum = 0;
checksum ^= checksumLookup[( deviceIdInt >> 28 ) & 0x0F];
checksum ^= ( deviceIdInt >> 24 ) & 0x0F;
checksum ^= checksumLookup[( deviceIdInt >> 20 ) & 0x0F];
checksum ^= ( deviceIdInt >> 16 ) & 0x0F;
checksum ^= checksumLookup[( deviceIdInt >> 12 ) & 0x0F];
checksum ^= ( deviceIdInt >> 8 ) & 0x0F;
checksum ^= checksumLookup[( deviceIdInt >> 4 ) & 0x0F];
/*
Calc new device ID
*/
const newDevId = ( deviceIdInt & 0xFFFFFFF0 ) + checksum;
/*
Convert back to hex string; ensure we get 8 characters with leading zeros; convert to uppercase
*/
return newDevId.toString( 16 ).toUpperCase( ).padStart( 8, '0' );
}
/*
IsDeviceIdValid Validate HDHomeRun Device ID
Checks if the current deviceId on this instance is valid according to HDHomeRun rules.
Validation steps:
- Must be exactly 8 characters long.
- All characters must be hexadecimal (0-9, A-F, a-f).
- Computes checksum using HDHomeRun-specific lookup table; must equal 0.
Logs detailed errors if the deviceId fails any validation step.
@returns
(bool) true if deviceId is valid, false otherwise.
@usage
const isValid = await tuner.IsDeviceIdValid( );
*/
async IsDeviceIdValid( )
{
Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `<name>` ), chalk.gray( `${ Utils.getFuncName( ) }` ) );
/*
Define Hexadecimal charset (0-9, A-F, a-f)
*/
const hexDigits = new Set( '0123456789ABCDEFabcdef' );
const deviceId = this.DeviceId;
/*
Check if device ID is exactly 8 characters
*/
if ( !deviceId || deviceId.length !== 8 )
{
Log.error( `hdhr`, chalk.redBright( `[validate]` ), chalk.white( `` ),
chalk.redBright( `<msg>` ), chalk.gray( `HDHomeRun deviceId must be 8 hexadecimals` ),
chalk.redBright( `<deviceId>` ), chalk.gray( `${ deviceId }` ) );
return false;
}
/*
Check if all characters are hexadecimal
*/
if ( !Array.from( deviceId ).every( ( c ) => hexDigits.has( c ) ) )
{
Log.error( `hdhr`, chalk.redBright( `[validate]` ), chalk.white( `` ),
chalk.redBright( `<msg>` ), chalk.gray( `HDHomeRun deviceId must contain all hex (0-A)` ),
chalk.redBright( `<deviceId>` ), chalk.gray( `${ deviceId }` ) );
return false;
}
/*
Convert hex string to integer (equivalent to int.from_bytes with big endian)
*/
const deviceIdInt = parseInt( deviceId, 16 );
/*
Checksum lookup table
*/
const checksumLookup =
[
0xA, 0x5, 0xF, 0x6, 0x7, 0xC, 0x1, 0xB, 0x9, 0x2, 0x8, 0xD, 0x4, 0x3, 0xE, 0x0
];
/*
Calc checksum
*/
let checksum = 0;
checksum ^= checksumLookup[( deviceIdInt >>> 28 ) & 0x0F];
checksum ^= ( deviceIdInt >>> 24 ) & 0x0F;
checksum ^= checksumLookup[( deviceIdInt >>> 20 ) & 0x0F];
checksum ^= ( deviceIdInt >>> 16 ) & 0x0F;
checksum ^= checksumLookup[( deviceIdInt >>> 12 ) & 0x0F];
checksum ^= ( deviceIdInt >>> 8 ) & 0x0F;
checksum ^= checksumLookup[( deviceIdInt >>> 4 ) & 0x0F];
checksum ^= ( deviceIdInt >>> 0 ) & 0x0F;
return checksum === 0;
}
/*
VerifyDeviceId Validate / Generate Device ID
Checks if the current deviceId on this instance is valid.
If missing, uninitialized ('FFFFFFFF'), or fails validation:
a new deviceId is generated via the static Tuner.GenerateDeviceId( ) method.
New deviceId is saved to persistent storage via Storage.Set( ) and
updated on the instance.
Function also recursively verifies until a valid deviceId is established.
@returns
(str) A valid deviceId for this tuner instance.
@usage
const validId = await tuner.VerifyDeviceId( );
*/
async VerifyDeviceId( )
{
Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `<name>` ), chalk.gray( `${ Utils.getFuncName( ) }` ) );
const deviceId = this.DeviceId;
if ( !deviceId || deviceId === 'FFFFFFFF' || !await this.IsDeviceIdValid( ) )
{
const deviceIdNew = Tuner.GenerateDeviceId( ); // static generates a properly formatted ID
if ( deviceId === 'FFFFFFFF' )
{
Log.info( `conf`, chalk.yellow( `[generate]` ), chalk.white( `📣` ),
chalk.yellow( `<msg>` ), chalk.gray( `Generating HDHomeRun deviceId for the first time` ),
chalk.yellow( `<deviceId>` ), chalk.gray( `${ deviceIdNew }` ) );
}
else
{
Log.error( `conf`, chalk.redBright( `[generate]` ), chalk.white( `` ),
chalk.redBright( `<msg>` ), chalk.gray( `Invalid deviceId; generating new` ),
chalk.redBright( `<oldDeviceId>` ), chalk.gray( `${ deviceId }` ),
chalk.redBright( `<deviceIdNew>` ), chalk.gray( `${ deviceIdNew }` ) );
}
Storage.Set( 'deviceId', deviceIdNew ); // save to JSON via nconf
this.DeviceId = deviceIdNew; // update the instance so validation works
// verify recursively until valid
const verifiedId = await this.VerifyDeviceId( );
return verifiedId;
}
return deviceId;
}
}
/*
export class
@image
import Tuner from './classes/Tuner.js';
*/
// eslint-disable-next-line no-restricted-syntax
export default Tuner;

47
tvapp2/classes/Utils.js Normal file
View File

@@ -0,0 +1,47 @@
class Utils
{
/*
Returns the name of the function that this function was called from.
used for Log.verbose
*/
static getFuncName()
{
return ( new Error() ).stack.match( /at (\S+)/g )[1].slice( 3 );
}
/*
Returns the name of the constructor that this function was called from.
used for Log.verbose
*/
static getConstructorName()
{
return ( new Error() ).stack.match( /new\s+(\w+)/g )[0];
}
/*
helper > str2bool
*/
static str2bool( str )
{
if ( typeof str === 'string' )
{
const lower = str.toLowerCase();
if ([
'1', 'true', 'yes', 'y', 't'
].includes( lower ) )
str = true;
if ([
'0', 'false', 'no', 'n', 'f'
].includes( lower ) )
str = false;
return str;
}
else return Boolean( str );
}
}
// eslint-disable-next-line no-restricted-syntax
export default Utils;

View File

@@ -223,7 +223,7 @@ export default
'@stylistic/no-whitespace-before-property': ['error'],
'@stylistic/object-curly-spacing': ['error', 'always'],
'@stylistic/quote-props': ['error', 'as-needed'],
'@stylistic/quotes': ['error', 'single', { allowTemplateLiterals: true }],
'@stylistic/quotes': ['error', 'single', { allowTemplateLiterals: 'always' }],
'@stylistic/semi': ['error', 'always'],
'@stylistic/space-infix-ops': ['error'],
'@stylistic/template-curly-spacing': ['error', 'always'],

View File

@@ -17,8 +17,12 @@ import ejs from 'ejs';
import moment from 'moment';
import TimeAgo from 'javascript-time-ago';
import en from 'javascript-time-ago/locale/en';
import nconf from 'nconf';
import crypto from 'node:crypto';
import Log from './classes/Log.js';
import Storage from './classes/Storage.js';
import Utils from './classes/Utils.js';
import CLib from './classes/CLib.js';
import Semaphore from './classes/Semaphore.js';
import Tuner from './classes/Tuner.js';
import cron, { schedule } from 'node-cron';
import * as child from 'child_process';
import * as crons from 'cron';
@@ -28,17 +32,27 @@ import * as crons from 'cron';
*/
import { fileURLToPath } from 'url';
/*
Initialize classes
*/
const cache = new Map();
const clib = new CLib();
const encoded = clib.encodeToHexBase64( 'tvapp2' );
const decoded = clib.decodeFromHexBase64( `${ encoded }` );
/*
Import package.json values
*/
const { name, author, version, repository, discord, docs } = JSON.parse( fs.readFileSync( './package.json' ) );
const __filename = fileURLToPath( import.meta.url ); // get resolved path to file
const __dirname = path.dirname( __filename ); // get name of directory
const __filename = fileURLToPath( import.meta.url ); // get resolved path to file
const __dirname = path.dirname( __filename ); // get name of directory
/*
const gitHash = child.execSync( 'git rev-parse HEAD' ).toString().trim();
const gitHash = child.execSync( 'git rev-parse HEAD' ).toString().trim();
*/
/*
@@ -58,19 +72,20 @@ const gitHash = child.execSync( 'git rev-parse HEAD' ).toString().trim();
chalk.level = 3;
/*
timeAgo
*/
TimeAgo.addDefaultLocale( en );
const timeAgo = new TimeAgo( );
/*
Define > General
Define General
@note if you change `envWebFolder`; ensure you re-name the folder where the
website assets are stored.
*/
let FILE_CFG;
let FILE_URL;
let FILE_M3U;
let FILE_XML;
@@ -83,11 +98,12 @@ let FILE_XML_MODIFIED = 0;
let FILE_GZP_MODIFIED = 0;
/*
Define > Environment Variables || Defaults
Define Environment Variables || Defaults
*/
const envAppRelease = process.env.RELEASE || 'stable';
const envUrlRepo = process.env.URL_REPO || 'https://git.binaryninja.net/binaryninja';
const envXmlEpg = process.env.URL_EPG || 'https://epg.binaryninja.net/XMLTV-EPG';
const envStreamQuality = process.env.STREAM_QUALITY || 'hd';
const envFileURL = process.env.FILE_URL || 'urls.txt';
const envFileM3U = process.env.FILE_M3U || 'playlist.m3u8';
@@ -97,6 +113,7 @@ const envApiKey = process.env.API_KEY || null;
const envWebIP = process.env.WEB_IP || '0.0.0.0';
const envWebPort = process.env.WEB_PORT || `4124`;
const envWebFolder = process.env.WEB_FOLDER || 'www';
const envHdhrPort = process.env.HDHR_PORT || `6077`;
const envWebEncoding = process.env.WEB_ENCODING || 'deflate, br';
const envProxyHeader = process.env.WEB_PROXY_HEADER || 'x-forwarded-for';
const envHealthTimer = process.env.HEALTH_TIMER || 600000;
@@ -112,15 +129,16 @@ let serverOs = 'Unknown';
let serverStartup = 0;
/*
Define > Externals
Define Externals
*/
const extURL = `${ envUrlRepo }/tvapp2-externals/raw/branch/main/urls.txt`;
const extXML = `${ envUrlRepo }/XMLTV-EPG/raw/branch/main/xmltv.1.xml`;
const extM3U = `${ envUrlRepo }/tvapp2-externals/raw/branch/main/formatted.dat`;
const extXML = `${ envXmlEpg }/xmltv_v2.0.0.xml`;
const extM3U = `${ envXmlEpg }/formatted_v2.0.0.dat`;
//const extM3U = `${ envUrlRepo }/tvapp2-externals/raw/branch/main/formatted.dat`;
/*
Define > Defaults
Define Defaults
*/
let urls = [];
@@ -132,12 +150,12 @@ const USERAGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/201
using any of the following subdomains / subpaths will trigger the download for that specific file
@example http://127.0.0.1:4124/gzip
http://127.0.0.1:4124/gz
http://127.0.0.1:4124/playlist
http://127.0.0.1:4124/key
http://127.0.0.1:4124/channel?url=https://thetvapp.to/tv/bbc-america-live-stream/
http://127.0.0.1:4124/api/health
@example http://127.0.0.1:4124/gzip
http://127.0.0.1:4124/gz
http://127.0.0.1:4124/playlist
http://127.0.0.1:4124/key
http://127.0.0.1:4124/channel?url=https://thetvapp.to/tv/bbc-america-live-stream/
http://127.0.0.1:4124/api/health
*/
const subdomainGZP = [ 'gzip', 'gz' ];
@@ -159,6 +177,19 @@ const fileIpContainer = '/var/run/s6/container_environment/IP_CONTAINER';
const envIpGateway = fs.existsSync( fileIpGateway ) ? fs.readFileSync( fileIpGateway, 'utf8' ) : `0.0.0.0`;
const envIpContainer = fs.existsSync( fileIpContainer ) ? fs.readFileSync( fileIpContainer, 'utf8' ) : `0.0.0.0`;
/*
Hosts
*/
const hosts =
[
{ name: 'TVPass.org', url: 'https://tvpass.org' },
{ name: 'TheTVApp.to', url: 'https://thetvapp.to' },
{ name: 'MoveOnJoy.com', url: 'http://moveonjoy.com' },
{ name: 'Daddylive.dad', url: 'https://daddylivestream.com' },
{ name: 'git.binaryninja.net', url: envUrlRepo }
];
/*
Get Server OS
@@ -188,111 +219,6 @@ getos( ( e, json ) =>
return serverOs;
});
/*
helper > str2bool
*/
function str2bool( str )
{
if ( typeof str === 'string' )
{
const lower = str.toLowerCase();
if ([
'1', 'true', 'yes', 'y', 't'
].includes( lower ) )
str = true;
if ([
'0', 'false', 'no', 'n', 'f'
].includes( lower ) )
str = false;
return str;
}
else return Boolean( str );
}
/*
Define > Logs
When assigning text colors, terminals and the windows command prompt can display any color; however apps
such as Portainer console cannot. If you use 16 million colors and are viewing console in Portainer, colors will
not be the same as the rgb value. It's best to just stick to Chalk's default colors.
Various levels of logs with the following usage:
Log.verbose(`This is verbose`)
Log.debug(`This is debug`)
Log.info(`This is info`)
Log.ok(`This is ok`)
Log.notice(`This is notice`)
Log.warn(`This is warn`)
Log.error(
`Error fetching sports data with error:`,
chalk.white(`→`),
chalk.grey(`This is the error message`)
);
Level Type
-----------------------------------
6 Trace
5 Debug
4 Info
3 Notice
2 Warn
1 Error
*/
class Log
{
static now()
{
const now = new Date();
return chalk.gray( `[${ now.toLocaleTimeString() }]` );
}
static verbose( ...msg )
{
if ( LOG_LEVEL >= 6 )
console.debug( chalk.white.bgBlack.blackBright.bold( ` ${ name } ` ), chalk.white( `⚙️` ), this.now(), chalk.gray( msg.join( ' ' ) ) );
}
static debug( ...msg )
{
if ( LOG_LEVEL >= 7 )
console.trace( chalk.white.bgMagenta.bold( ` ${ name } ` ), chalk.white( `⚙️` ), this.now(), chalk.magentaBright( msg.join( ' ' ) ) );
else if ( LOG_LEVEL >= 5 )
console.debug( chalk.white.bgGray.bold( ` ${ name } ` ), chalk.white( `⚙️` ), this.now(), chalk.gray( msg.join( ' ' ) ) );
}
static info( ...msg )
{
if ( LOG_LEVEL >= 4 )
console.info( chalk.white.bgBlueBright.bold( ` ${ name } ` ), chalk.white( `` ), this.now(), chalk.blueBright( msg.join( ' ' ) ) );
}
static ok( ...msg )
{
if ( LOG_LEVEL >= 4 )
console.log( chalk.white.bgGreen.bold( ` ${ name } ` ), chalk.white( `` ), this.now(), chalk.greenBright( msg.join( ' ' ) ) );
}
static notice( ...msg )
{
if ( LOG_LEVEL >= 3 )
console.log( chalk.white.bgYellow.bold( ` ${ name } ` ), chalk.white( `📌` ), this.now(), chalk.yellowBright( msg.join( ' ' ) ) );
}
static warn( ...msg )
{
if ( LOG_LEVEL >= 2 )
console.warn( chalk.white.bgYellow.bold( ` ${ name } ` ), chalk.white( `⚠️` ), this.now(), chalk.yellowBright( msg.join( ' ' ) ) );
}
static error( ...msg )
{
if ( LOG_LEVEL >= 1 )
console.error( chalk.white.bgRedBright.bold( ` ${ name } ` ), chalk.white( `` ), this.now(), chalk.redBright( msg.join( ' ' ) ) );
}
}
/*
Process
*/
@@ -303,6 +229,7 @@ if ( process.pkg )
chalk.blueBright( `<msg>` ), chalk.gray( `Starting server utilizing process.execPath` ) );
const basePath = path.dirname( process.execPath );
FILE_CFG = path.join( basePath, envWebFolder, `config.json` );
FILE_URL = path.join( basePath, envWebFolder, `${ envFileURL }` );
FILE_M3U = path.join( basePath, envWebFolder, `${ envFileM3U }` );
FILE_XML = path.join( basePath, envWebFolder, `${ envFileXML }` );
@@ -314,6 +241,7 @@ else
Log.info( `core`, chalk.yellow( `[initiate]` ), chalk.white( `` ),
chalk.blueBright( `<msg>` ), chalk.gray( `Starting server utilizing processed locals` ) );
FILE_CFG = path.resolve( __dirname, envWebFolder, `config.json` );
FILE_URL = path.resolve( __dirname, envWebFolder, `${ envFileURL }` );
FILE_M3U = path.resolve( __dirname, envWebFolder, `${ envFileM3U }` );
FILE_XML = path.resolve( __dirname, envWebFolder, `${ envFileXML }` );
@@ -321,7 +249,7 @@ else
}
/*
helper > sleep
helper sleep
*/
function sleep( ms )
@@ -333,45 +261,7 @@ function sleep( ms )
}
/*
Semaphore > Declare
allows multiple threads to work with the same shared resources
*/
class Semaphore
{
constructor( max )
{
this.max = max;
this.queue = [];
this.active = 0;
}
async acquire()
{
if ( this.active < this.max )
{
this.active++;
return;
}
return new Promise( ( resolve ) => this.queue.push( resolve ) );
}
release()
{
this.active--;
if ( this.queue.length > 0 )
{
const resolve = this.queue.shift();
this.active++;
resolve();
}
}
}
/*
Semaphore > Initialize
Semaphore Initialize
@arg int threads_max
*/
@@ -404,90 +294,94 @@ const clientIp = ( req ) =>
if try 2 fails with the opposite protocol; domain is considered down
*/
async function serviceCheck( service, uri )
async function hostCheck( service, uri )
{
/* try 1 */
try
{
const resp = await fetch( uri );
/* try 1 > domain down */
/* try 1 domain down */
if ( resp.status !== 200 )
{
Log.error( `ping`, chalk.redBright( `[response]` ), chalk.white( `` ), chalk.redBright( `<msg>` ), chalk.gray( `Service Offline; failed to communicate with service, possibly down` ), chalk.redBright( `<code>` ), chalk.gray( `${ resp.status }` ), chalk.redBright( `<service>` ), chalk.gray( `${ service }` ), chalk.redBright( `<address>` ), chalk.gray( `${ uri }` ) );
return;
Log.error( `ping`, chalk.redBright( `[response]` ), chalk.white( `` ), chalk.redBright( `<msg>` ), chalk.gray( `Try: Service Offline; failed to communicate with service, possibly down` ), chalk.redBright( `<code>` ), chalk.gray( `${ resp.status }` ), chalk.redBright( `<service>` ), chalk.gray( `${ service }` ), chalk.redBright( `<address>` ), chalk.gray( `${ uri }` ) );
return false;
}
/* try 1 > domain up */
Log.ok( `ping`, chalk.yellow( `[response]` ), chalk.white( `` ), chalk.greenBright( `<msg>` ), chalk.gray( `Service Online` ), chalk.greenBright( `<code>` ), chalk.gray( `${ resp.status }` ), chalk.greenBright( `<service>` ), chalk.gray( `${ service }` ), chalk.greenBright( `<address>` ), chalk.gray( `${ uri }` ) );
/* try 1 domain up */
Log.ok( `ping`, chalk.yellow( `[response]` ), chalk.white( `` ), chalk.greenBright( `<msg>` ), chalk.gray( `Domain Online` ), chalk.greenBright( `<code>` ), chalk.gray( `${ resp.status }` ), chalk.greenBright( `<service>` ), chalk.gray( `${ service }` ), chalk.greenBright( `<address>` ), chalk.gray( `${ uri }` ) );
return true;
}
catch ( err )
{
/*
try 2 > http
try 2 https
*/
if ( /^https:\/\//i.test( uri ) )
{
const uriRetry = uri.replace( /^https:\/\//ig, 'http://' );
Log.info( `ping`, chalk.yellow( `[response]` ), chalk.white( `⚠️` ), chalk.yellowBright( `<msg>` ), chalk.gray( `Service Offline; failed to communicate with service via SSL; trying http protocol` ), chalk.yellowBright( `<service>` ), chalk.gray( `${ service }` ), chalk.yellowBright( `<uriAttempt1>` ), chalk.gray( `${ uri }` ), chalk.redBright( `(failed)` ), chalk.yellowBright( `<uriAttempt2>` ), chalk.gray( `${ uriRetry }` ), chalk.blueBright( `(pending)` ) );
Log.info( `ping`, chalk.yellow( `[response]` ), chalk.white( `⚠️` ), chalk.yellowBright( `<msg>` ), chalk.gray( `Try: Failed via HTTPS; trying HTTP protocol` ), chalk.yellowBright( `<service>` ), chalk.gray( `${ service }` ), chalk.yellowBright( `<uriAttempt1>` ), chalk.gray( `${ uri }` ), chalk.redBright( `(failed)` ), chalk.yellowBright( `<uriAttempt2>` ), chalk.gray( `${ uriRetry }` ), chalk.blueBright( `(pending)` ) );
try
{
const resp = await fetch( uriRetry );
/* try 2 > http > domain down */
/* try 2 https domain down */
if ( resp.status !== 200 )
{
Log.error( `ping`, chalk.redBright( `[response]` ), chalk.white( `` ), chalk.redBright( `<msg>` ), chalk.gray( `Service Offline; failed to communicate with service, possibly down` ), chalk.redBright( `<code>` ), chalk.gray( `${ resp.status }` ), chalk.redBright( `<service>` ), chalk.gray( `${ service }` ), chalk.redBright( `<address>` ), chalk.gray( `${ uriRetry }` ) );
return;
Log.error( `ping`, chalk.redBright( `[response]` ), chalk.white( `` ), chalk.redBright( `<msg>` ), chalk.gray( `Try: Domain Offline; failed to communicate with domain, possibly down` ), chalk.redBright( `<code>` ), chalk.gray( `${ resp.status }` ), chalk.redBright( `<service>` ), chalk.gray( `${ service }` ), chalk.redBright( `<address>` ), chalk.gray( `${ uriRetry }` ) );
return false;
}
/* try 2 > http > domain up */
Log.ok( `ping`, chalk.yellow( `[response]` ), chalk.white( `` ), chalk.greenBright( `<msg>` ), chalk.gray( `Service Online` ), chalk.greenBright( `<code>` ), chalk.gray( `${ resp.status }` ), chalk.greenBright( `<service>` ), chalk.gray( `${ service }` ), chalk.greenBright( `<address>` ), chalk.gray( `${ uriRetry }` ) );
/* try 2 https domain up */
Log.ok( `ping`, chalk.yellow( `[response]` ), chalk.white( `` ), chalk.greenBright( `<msg>` ), chalk.gray( `Domain Online` ), chalk.greenBright( `<code>` ), chalk.gray( `${ resp.status }` ), chalk.greenBright( `<service>` ), chalk.gray( `${ service }` ), chalk.greenBright( `<address>` ), chalk.gray( `${ uriRetry }` ) );
return true;
}
catch ( err )
{
/* try 2 > http > domain not exist */
Log.error( `ping`, chalk.redBright( `[response]` ), chalk.white( `` ), chalk.redBright( `<msg>` ), chalk.gray( `Service Offline; failed to communicate with service, address does not exist` ), chalk.redBright( `<service>` ), chalk.gray( `${ service }` ), chalk.redBright( `<address>` ), chalk.gray( `${ uri }` ), chalk.redBright( `<message>` ), chalk.gray( `${ err }` ) );
/* try 2 https domain not exist */
Log.error( `ping`, chalk.redBright( `[response]` ), chalk.white( `` ), chalk.redBright( `<msg>` ), chalk.gray( `Try: Domain Offline; failed to communicate with domain, address does not exist` ), chalk.redBright( `<service>` ), chalk.gray( `${ service }` ), chalk.redBright( `<address>` ), chalk.gray( `${ uri }` ), chalk.redBright( `<message>` ), chalk.gray( `${ err }` ) );
return false;
}
}
/*
try 2 > https
try 2 http
*/
else if ( /^http:\/\//i.test( uri ) )
{
const uriRetry = uri.replace( /^http:\/\//ig, 'https://' );
Log.info( `ping`, chalk.yellow( `[response]` ), chalk.white( `⚠️` ), chalk.yellowBright( `<msg>` ), chalk.gray( `Service Offline; failed to communicate with service via SSL; trying https protocol` ), chalk.yellowBright( `<service>` ), chalk.gray( `${ service }` ), chalk.yellowBright( `<uriAttempt1>` ), chalk.gray( `${ uri }` ), chalk.redBright( `(failed)` ), chalk.yellowBright( `<uriAttempt2>` ), chalk.gray( `${ uriRetry }` ), chalk.blueBright( `(pending)` ) );
Log.info( `ping`, chalk.yellow( `[response]` ), chalk.white( `⚠️` ), chalk.yellowBright( `<msg>` ), chalk.gray( `Try: Failed via HTTP; trying HTTPS protocol` ), chalk.yellowBright( `<service>` ), chalk.gray( `${ service }` ), chalk.yellowBright( `<uriAttempt1>` ), chalk.gray( `${ uri }` ), chalk.redBright( `(failed)` ), chalk.yellowBright( `<uriAttempt2>` ), chalk.gray( `${ uriRetry }` ), chalk.blueBright( `(pending)` ) );
try
{
const resp = await fetch( uriRetry );
/* try 2 > https > domain down */
/* try 2 http domain down */
if ( resp.status !== 200 )
{
Log.error( `ping`, chalk.redBright( `[response]` ), chalk.white( `` ), chalk.redBright( `<msg>` ), chalk.gray( `Service Offline; failed to communicate with service, possibly down` ), chalk.redBright( `<code>` ), chalk.gray( `${ resp.status }` ), chalk.redBright( `<service>` ), chalk.gray( `${ service }` ), chalk.redBright( `<address>` ), chalk.gray( `${ uriRetry }` ) );
return;
Log.error( `ping`, chalk.redBright( `[response]` ), chalk.white( `` ), chalk.redBright( `<msg>` ), chalk.gray( `Domain Offline; failed to communicate with domain, possibly down` ), chalk.redBright( `<code>` ), chalk.gray( `${ resp.status }` ), chalk.redBright( `<service>` ), chalk.gray( `${ service }` ), chalk.redBright( `<address>` ), chalk.gray( `${ uriRetry }` ) );
return false;
}
/* try 2 > https > domain up */
Log.ok( `ping`, chalk.yellow( `[response]` ), chalk.white( `` ), chalk.greenBright( `<msg>` ), chalk.gray( `Service Online` ), chalk.greenBright( `<code>` ), chalk.gray( `${ resp.status }` ), chalk.greenBright( `<service>` ), chalk.gray( `${ service }` ), chalk.greenBright( `<address>` ), chalk.gray( `${ uriRetry }` ) );
/* try 2 http domain up */
Log.ok( `ping`, chalk.yellow( `[response]` ), chalk.white( `` ), chalk.greenBright( `<msg>` ), chalk.gray( `Domain Online` ), chalk.greenBright( `<code>` ), chalk.gray( `${ resp.status }` ), chalk.greenBright( `<service>` ), chalk.gray( `${ service }` ), chalk.greenBright( `<address>` ), chalk.gray( `${ uriRetry }` ) );
return true;
}
catch ( err )
{
/* try 2 > https > domain not exist */
Log.error( `ping`, chalk.redBright( `[response]` ), chalk.white( `` ), chalk.redBright( `<msg>` ), chalk.gray( `Service Offline; failed to communicate with service, address does not exist` ), chalk.redBright( `<service>` ), chalk.gray( `${ service }` ), chalk.redBright( `<address>` ), chalk.gray( `${ uri }` ), chalk.redBright( `<message>` ), chalk.gray( `${ err }` ) );
/* try 2 http domain not exist */
Log.error( `ping`, chalk.redBright( `[response]` ), chalk.white( `` ), chalk.redBright( `<msg>` ), chalk.gray( `Domain Offline; failed to communicate with domain, address does not exist` ), chalk.redBright( `<service>` ), chalk.gray( `${ service }` ), chalk.redBright( `<address>` ), chalk.gray( `${ uri }` ), chalk.redBright( `<message>` ), chalk.gray( `${ err }` ) );
return false;
}
}
}
}
/*
Func > Download File
Func Download File
@arg str url https://git.binaryninja.net/binaryninja/tvapp2-externals/raw/branch/main/urls.txt
@arg str filePath H:\Repos\github\BinaryNinja\tvapp2\tvapp2\urls.txt
@@ -568,10 +462,10 @@ function getFileModified( filename )
Takes the total number of bytes in a file's size and converts it into
a human readable format.
@arg str filename filename to get size in bytes for
@arg bool si divides the bytes of a file by 1000 instead of 2024
@arg int decimal specifies the decimal point
@ret str 111.9 KB
@arg str filename filename to get size in bytes for
@arg bool si divides the bytes of a file by 1000 instead of 2024
@arg int decimal specifies the decimal point
@ret str 111.9 KB
*/
@@ -630,7 +524,35 @@ async function getFile( url, filePath )
chalk.blueBright( `<src>` ), chalk.gray( `${ url }` ),
chalk.blueBright( `<dest>` ), chalk.gray( `${ filePath }` ) );
await downloadFile( url, filePath );
const ok = await hostCheck( 'git.binaryninja.com', `${ envUrlRepo }` );
if ( ok )
{
try
{
await downloadFile( url, filePath );
return true;
}
catch ( err )
{
Log.error( `file`, chalk.redBright( `[download]` ), chalk.white( `` ),
chalk.redBright( `<msg>` ), chalk.gray( `Download attempt failed after service check succeeded` ),
chalk.redBright( `<error>` ), chalk.gray( `${ err.message }` ),
chalk.redBright( `<src>` ), chalk.gray( `${ url }` ),
chalk.redBright( `<dest>` ), chalk.gray( `${ filePath }` ) );
return false;
}
}
else
{
Log.info( `file`, chalk.yellow( `[download]` ), chalk.white( `` ),
chalk.yellowBright( `<msg>` ), chalk.gray( `Skipping download because service is offline; using existing local file` ),
chalk.yellowBright( `<url>` ), chalk.gray( `${ url }` ),
chalk.yellowBright( `<dest>` ), chalk.gray( `${ filePath }` ) );
return false;
}
}
catch ( err )
{
@@ -1505,7 +1427,7 @@ async function serveHealthCheck( req, res )
if ( !paramUrl )
{
if ( str2bool( paramSilent ) !== true )
if ( Utils.str2bool( paramSilent ) !== true )
{
Log.debug( `/api`, chalk.yellow( `[health]` ), chalk.white( `⚙️` ),
chalk.blueBright( `<msg>` ), chalk.gray( `No api-key passed to health check` ) );
@@ -1532,7 +1454,7 @@ async function serveHealthCheck( req, res )
'Content-Type': 'application/json'
});
if ( str2bool( paramSilent ) !== true )
if ( Utils.str2bool( paramSilent ) !== true )
{
Log.ok( `/api`, chalk.yellow( `[health]` ), chalk.white( `` ),
chalk.greenBright( `<msg>` ), chalk.gray( `Response` ),
@@ -1650,18 +1572,20 @@ async function serveM3U( res, req )
chalk.blueBright( `<to>` ), chalk.gray( `${ baseUrl }/channel?url=${ encodeURIComponent( fullUrl ) }` ) );
return `${ baseUrl }/channel?url=${ encodeURIComponent( fullUrl ) }`;
})
.replace( /(https?:\/\/[^\s]*fl2.moveonjoy[^\s]*)/g, ( fullUrl ) =>
});
/*
.replace( /(https?:\/\/fl\d+\.moveonjoy\.com[^\s]*)/g, ( fullUrl ) =>
{
const urlRewrite = fullUrl.replace( 'fl2.moveonjoy', 'fl6.moveonjoy' );
const urlRewrite = fullUrl.replace( /fl\d+\.moveonjoy\.com/, 'fl25.moveonjoy.com' );
Log.debug( `.m3u`, chalk.yellow( `[rewriter]` ), chalk.white( `⚙️` ),
chalk.blueBright( `<msg>` ), chalk.gray( `Rewriting url for keyword` ),
chalk.blueBright( `<keyword>` ), chalk.gray( `*fl2.moveonjoy` ),
chalk.blueBright( `<keyword>` ), chalk.gray( `*fl1.moveonjoy` ),
chalk.blueBright( `<from>` ), chalk.gray( `${ fullUrl }` ),
chalk.blueBright( `<to>` ), chalk.gray( `${ urlRewrite }` ) );
return `${ urlRewrite }`;
});
*/
res.writeHead( 200, {
'Content-Type': 'application/x-mpegURL',
@@ -2086,10 +2010,10 @@ const server = http.createServer( ( req, resp ) =>
*/
const paramSilent = new URL( req.url, `http://${ req.headers.host }` ).searchParams.get( 'silent' );
if ( str2bool( paramSilent ) !== true )
if ( Utils.str2bool( paramSilent ) !== true )
{
Log.debug( `http`, chalk.yellow( `[requests]` ), chalk.white( `⚙️` ),
chalk.blueBright( `<msg>` ), chalk.gray( `Request started` ),
chalk.blueBright( `<msg>` ), chalk.gray( `New request` ),
chalk.blueBright( `<client>` ), chalk.gray( `${ clientIp( req ) }` ),
chalk.blueBright( `<request.url>` ), chalk.gray( `${ req.url }` ),
chalk.blueBright( `<reqUrl>` ), chalk.gray( `${ reqUrl }` ),
@@ -2294,7 +2218,7 @@ const server = http.createServer( ( req, resp ) =>
// do not show log if query is `uptime`, since uptime runs every 1 second.
// do not show logs if query has striggered `silent?=true` in url
if ( str2bool( paramSilent ) !== true )
if ( Utils.str2bool( paramSilent ) !== true )
{
Log.info( `http`, chalk.yellow( `[requests]` ), chalk.white( `` ),
chalk.blueBright( `<msg>` ), chalk.gray( `Requesting to access health api` ),
@@ -2423,10 +2347,353 @@ const server = http.createServer( ( req, resp ) =>
chalk.blueBright( `<file>` ), chalk.gray( `${ loadFile }` ),
chalk.blueBright( `<method>` ), chalk.gray( `${ method }` ) );
/*
Main Server Discovery.json
*/
if ( loadFile === 'discovery.json' )
{
Log.notice( `http`, chalk.yellowBright( `[notice]` ), chalk.white( `📌` ),
chalk.yellowBright( `<msg>` ), chalk.gray( `If you are attempting to load TVApp2 using an HDHomeRun tuner, please switch to the` ), chalk.yellowBright( `M3U Tuner` ) );
const tunerInstance = new Tuner(); // <-- use a different name
const hdHomeRun =
{
FriendlyName: tunerInstance.FriendlyName,
ModelNumber: tunerInstance.ModelNumber,
FirmwareName: tunerInstance.FirmwareName,
FirmwareVersion: tunerInstance.FirmwareVersion,
DeviceID: tunerInstance.GetDeviceId(),
TunerCount: tunerInstance.SlotsMax,
BaseURL: `${ envIpContainer }:${ envHdhrPort }`,
LineupURL: `${ envIpContainer }:${ envHdhrPort }/lineup.json`,
client: clientIp( req ),
message: 'Connected to HDHomeRun server',
status: 'healthy',
code: 200,
uptime: Math.round( process.uptime() ),
uptimeShort: timeAgo.format( Date.now() - process.uptime() * 1000, 'twitter' ),
uptimeLong: timeAgo.format( Date.now() - process.uptime() * 1000, 'round' ),
timestamp: Date.now()
};
resp.writeHead( hdHomeRun.code, {
'Content-Type': 'application/json'
});
resp.end( JSON.stringify( hdHomeRun ) );
return; // <- Prevent further code from executing
}
const statusCheck =
{
ip: envIpContainer,
gateway: envIpGateway,
client: clientIp( req ),
message: 'Page not found',
status: 'healthy',
ref: req.url,
method: method || 'GET',
code: 404,
uptime: Math.round( process.uptime() ),
uptimeShort: timeAgo.format( Date.now() - process.uptime() * 1000, 'twitter' ),
uptimeLong: timeAgo.format( Date.now() - process.uptime() * 1000, 'round' ),
timestamp: Date.now()
};
resp.writeHead( statusCheck.code, {
'Content-Type': 'application/json'
});
Log.error( `http`, chalk.redBright( `[requests]` ), chalk.white( `` ),
chalk.redBright( `<msg>` ), chalk.gray( `${ statusCheck.message }` ),
chalk.redBright( `<client>` ), chalk.gray( `${ clientIp( req ) }` ),
chalk.redBright( `<code>` ), chalk.gray( `${ statusCheck.code }` ),
chalk.redBright( `<error>` ), chalk.gray( `${ err }` ),
chalk.redBright( `<file>` ), chalk.gray( `${ loadFile }` ),
chalk.redBright( `<method>` ), chalk.gray( `${ method }` ) );
resp.end( JSON.stringify( statusCheck ) );
}
});
};
handleRequest().catch( ( err ) =>
{
resp.writeHead( 500, {
'Content-Type': 'text/plain'
});
Log.error( `http`, chalk.redBright( `[requests]` ), chalk.white( `` ),
chalk.redBright( `<msg>` ), chalk.gray( `Cannot handle request` ),
chalk.redBright( `<code>` ), chalk.gray( `500` ),
chalk.redBright( `<error>` ), chalk.gray( `${ err }` ) );
resp.end( 'Internal Server Error' );
});
});
/*
Server > HDHomeRun
this server will serve up the HDHomeRun lineup.json for people wishing to
see the IPTV streams using the HDHomeRun tuner.
*/
const serverHdHomeRun = http.createServer( ( req, resp ) =>
{
const method = req.method || 'GET';
let reqUrl = req.url;
if ( reqUrl === '/' )
reqUrl = 'hdhomerun.html';
/*
Remove leading forward slash
*/
const loadFile = reqUrl.replace( /^\/+/, '' );
const handleRequest = async() =>
{
/*
Define the different routes.
Place the template system last. Getting TVApp data should take priority.
subdomainM3U array []
loadFile channel?url=https%3A%2F%2Ftvpass.org%2Fchannel%2Fabc-wabc-new-york-ny%2F
*/
Log.debug( `hdjr`, chalk.yellow( `[requests]` ), chalk.white( `⚙️` ),
chalk.blueBright( `<msg>` ), chalk.gray( `Request sent to HDHomeRun` ),
chalk.blueBright( `<client>` ), chalk.gray( `${ clientIp( req ) }` ),
chalk.blueBright( `<request.url>` ), chalk.gray( `${ req.url }` ),
chalk.blueBright( `<reqUrl>` ), chalk.gray( `${ reqUrl }` ),
chalk.blueBright( `<file>` ), chalk.gray( `${ loadFile }` ),
chalk.blueBright( `<method>` ), chalk.gray( `${ method }` ) );
if ( subdomainHealth.some( ( urlKeyword ) => loadFile.startsWith( urlKeyword ) ) && method === 'GET' )
{
const paramSilent = new URL( req.url, `http://${ req.headers.host }` ).searchParams.get( 'silent' );
// do not show log if query is `uptime`, since uptime runs every 1 second.
// do not show logs if query has striggered `silent?=true` in url
if ( Utils.str2bool( paramSilent ) !== true )
{
Log.info( `http`, chalk.yellow( `[requests]` ), chalk.white( `` ),
chalk.blueBright( `<msg>` ), chalk.gray( `Requesting to access health api` ),
chalk.blueBright( `<type>` ), chalk.gray( `api/health` ),
chalk.blueBright( `<client>` ), chalk.gray( `${ clientIp( req ) }` ),
chalk.blueBright( `<file>` ), chalk.gray( `${ loadFile }` ),
chalk.blueBright( `<method>` ), chalk.gray( `${ method }` ) );
}
await serveHealthCheck( req, resp );
return;
}
/*
General Template & .html / .css / .js
read the loaded asset file
*/
const tunerInstance = new Tuner();
ejs.renderFile( `./${ envWebFolder }/${ loadFile }`,
{
friendlyName: tunerInstance.FriendlyName,
modelNumber: tunerInstance.ModelNumber,
firmwareName: tunerInstance.FirmwareName,
firmwareVersion: tunerInstance.FirmwareVersion,
slotsConnected: tunerInstance.SlotsConnected,
slotsMax: tunerInstance.SlotsMax,
deviceId: tunerInstance.GetDeviceId( ),
hdhrIp: `${ envIpContainer }`,
hdhrPort: `${ envHdhrPort }`,
healthTimer: envHealthTimer,
appRelease: envAppRelease,
appName: name,
appVersion: version,
appUrlGithub: repository.url.substr( 0, repository.url.lastIndexOf( '.' ) ),
appUrlDiscord: discord.url,
appUrlDocs: docs.url,
appGitHashShort: envGitSHA1.substring( 0, 9 ),
appGitHashLong: envGitSHA1,
appUptimeShort: timeAgo.format( Date.now() - Math.round( process.uptime() ) * 1000, 'twitter' ),
appUptimeLong: timeAgo.format( Date.now() - process.uptime() * 1000, 'twitter' ),
appUptimeFull: timeAgo.format( Date.now() - process.uptime() * 1000 ),
appStartup: Math.round( serverStartup ) / 1000,
serverOs: serverOs
}, ( err, data ) =>
{
if ( !err )
{
Log.debug( `http`, chalk.yellow( `[requests]` ), chalk.white( `⚙️` ),
chalk.blueBright( `<msg>` ), chalk.gray( `Request accepted by ejs` ),
chalk.blueBright( `<client>` ), chalk.gray( `${ clientIp( req ) }` ),
chalk.blueBright( `<file>` ), chalk.gray( `${ loadFile }` ),
chalk.blueBright( `<method>` ), chalk.gray( `${ method }` ) );
/*
This allows us to serve all files locally: css, js, etc.
the file loaded is dependent on what comes to the right of the period.
*/
const fileExt = loadFile.lastIndexOf( '.' );
const fileMime = fileExt === -1
? 'text/plain'
: {
'.html' : 'text/html',
'.htm' : 'text/html',
'.ico' : 'image/x-icon',
'.jpg' : 'image/jpeg',
'.png' : 'image/png',
'.gif' : 'image/gif',
'.css' : 'text/css',
'.scss' : 'text/x-sass',
'.gz' : 'application/gzip',
'.js' : 'text/javascript',
'.txt' : 'text/plain',
'.xml' : 'application/xml',
'.json' : 'application/json',
'.m3u' : 'text/plain',
'.m3u8' : 'text/plain'
}[loadFile.substring( fileExt )];
/*
ejs is only for templates; if we want to load an binary data (like images); we must use fs.readFile
*/
if ( fileMime !== 'text/html' )
data = fs.readFileSync( `./${ envWebFolder }/${ loadFile }` );
resp.setHeader( 'Content-type', fileMime );
resp.end( data );
/*
silence logs if loading css or js files; otherwise they'll spam console each time you load
a page by the client.
*/
if ( fileMime === 'text/html' || fileMime === 'application/xml' || fileMime === 'application/json' )
{
Log.ok( `http`, chalk.yellow( `[requests]` ), chalk.white( `` ),
chalk.greenBright( `<msg>` ), chalk.gray( `Request to load file` ),
chalk.greenBright( `<client>` ), chalk.gray( `${ clientIp( req ) }` ),
chalk.greenBright( `<file>` ), chalk.gray( `${ loadFile }` ),
chalk.greenBright( `<mime>` ), chalk.gray( `${ fileMime }` ),
chalk.greenBright( `<method>` ), chalk.gray( `${ method }` ) );
}
else
{
Log.debug( `http`, chalk.yellow( `[requests]` ), chalk.white( `⚙️` ),
chalk.blueBright( `<msg>` ), chalk.gray( `Request to load file` ),
chalk.blueBright( `<client>` ), chalk.gray( `${ clientIp( req ) }` ),
chalk.blueBright( `<file>` ), chalk.gray( `${ loadFile }` ),
chalk.blueBright( `<mime>` ), chalk.gray( `${ fileMime }` ),
chalk.blueBright( `<method>` ), chalk.gray( `${ method }` ) );
}
}
else
{
Log.debug( `http`, chalk.yellow( `[requests]` ), chalk.white( `⚙️` ),
chalk.blueBright( `<msg>` ), chalk.gray( `Request rejected by ejs` ),
chalk.blueBright( `<client>` ), chalk.gray( `${ clientIp( req ) }` ),
chalk.blueBright( `<error>` ), chalk.gray( `${ err }` ),
chalk.blueBright( `<file>` ), chalk.gray( `${ loadFile }` ),
chalk.blueBright( `<method>` ), chalk.gray( `${ method }` ) );
/*
HDHomeRun Discovery.json
*/
if ( loadFile === 'discovery.json' )
{
Log.notice( `http`, chalk.yellowBright( `[notice]` ), chalk.white( `📌` ),
chalk.yellowBright( `<msg>` ), chalk.gray( `If you are attempting to load TVApp2 using an HDHomeRun tuner, please switch to the` ), chalk.yellowBright( `M3U Tuner` ) );
const tunerInstance = new Tuner();
const hdHomeRun =
{
FriendlyName: tunerInstance.FriendlyName,
ModelNumber: tunerInstance.ModelNumber,
FirmwareName: tunerInstance.FirmwareName,
FirmwareVersion: tunerInstance.FirmwareVersion,
DeviceID: tunerInstance.GetDeviceId(),
TunerCount: tunerInstance.SlotsMax,
BaseURL: `${ envIpContainer }:${ envHdhrPort }`,
LineupURL: `${ envIpContainer }:${ envHdhrPort }/lineup.json`,
client: clientIp( req ),
message: 'Connected to HDHomeRun server',
status: 'healthy',
code: 200,
uptime: Math.round( process.uptime() ),
uptimeShort: timeAgo.format( Date.now() - process.uptime() * 1000, 'twitter' ),
uptimeLong: timeAgo.format( Date.now() - process.uptime() * 1000, 'round' ),
timestamp: Date.now()
};
resp.writeHead( hdHomeRun.code, {
'Content-Type': 'application/json'
});
Log.ok( `http`, chalk.yellow( `[requests]` ), chalk.white( `` ),
chalk.blueBright( `<msg>` ), chalk.gray( `Established connection to HDHomeRun` ),
chalk.blueBright( `<client>` ), chalk.gray( `${ clientIp( req ) }` ),
chalk.blueBright( `<friendlyName>` ), chalk.gray( `${ hdHomeRun.FriendlyName }` ),
chalk.blueBright( `<modelNumber>` ), chalk.gray( `${ hdHomeRun.ModelNumber }` ),
chalk.blueBright( `<deviceID>` ), chalk.gray( `${ hdHomeRun.DeviceID }` ),
chalk.blueBright( `<tunerCount>` ), chalk.gray( `${ hdHomeRun.TunerCount }` ),
chalk.blueBright( `<urlBase>` ), chalk.whiteBright.bgBlack( ` ${ hdHomeRun.BaseURL } ` ),
chalk.blueBright( `<urlLineup>` ), chalk.whiteBright.bgBlack( ` ${ hdHomeRun.LineupURL } ` ) );
resp.end( JSON.stringify( hdHomeRun ) );
return;
}
/*
HDHomeRun Lineup.json
*/
if ( loadFile === 'lineup.json' )
{
const tunerInstance = new Tuner();
const hdHomeRun =
{
FriendlyName: tunerInstance.FriendlyName,
ModelNumber: tunerInstance.ModelNumber,
FirmwareName: tunerInstance.FirmwareName,
FirmwareVersion: tunerInstance.FirmwareVersion,
DeviceID: tunerInstance.GetDeviceId(),
TunerCount: tunerInstance.SlotsMax,
BaseURL: `${ envIpContainer }:${ envHdhrPort }`,
LineupURL: `${ envIpContainer }:${ envHdhrPort }/lineup.json`,
client: clientIp( req ),
status: 'healthy',
code: 200,
uptime: Math.round( process.uptime() ),
uptimeShort: timeAgo.format( Date.now() - process.uptime() * 1000, 'twitter' ),
uptimeLong: timeAgo.format( Date.now() - process.uptime() * 1000, 'round' ),
timestamp: Date.now(),
channels: `[]`
};
resp.writeHead( hdHomeRun.code, {
'Content-Type': 'application/json'
});
Log.ok( `http`, chalk.yellow( `[requests]` ), chalk.white( `` ),
chalk.blueBright( `<msg>` ), chalk.gray( `Lineup requested` ),
chalk.blueBright( `<client>` ), chalk.gray( `${ clientIp( req ) }` ),
chalk.blueBright( `<friendlyName>` ), chalk.gray( `${ hdHomeRun.FriendlyName }` ),
chalk.blueBright( `<modelNumber>` ), chalk.gray( `${ hdHomeRun.ModelNumber }` ),
chalk.blueBright( `<deviceID>` ), chalk.gray( `${ hdHomeRun.DeviceID }` ),
chalk.blueBright( `<tunerCount>` ), chalk.gray( `${ hdHomeRun.TunerCount }` ),
chalk.blueBright( `<urlBase>` ), chalk.whiteBright.bgBlack( ` ${ hdHomeRun.BaseURL } ` ),
chalk.blueBright( `<urlLineup>` ), chalk.whiteBright.bgBlack( ` ${ hdHomeRun.LineupURL } ` ) );
resp.end( JSON.stringify( hdHomeRun ) );
return;
}
const statusCheck =
@@ -2497,17 +2764,15 @@ const server = http.createServer( ( req, resp ) =>
initialize
*/
await new Storage( envWebFolder, FILE_CFG ).Initialize();
await new Tuner( Storage.Get( 'deviceId' ) ).Initialize();
await initialize();
/*
check service status that we depend on
*/
serviceCheck( 'TVPass.org', 'https://tvpass.org' );
serviceCheck( 'TheTVApp.to', 'https://thetvapp.to' );
serviceCheck( 'MoveOnJoy.com', 'http://moveonjoy.com' );
serviceCheck( 'Daddylive.dad', 'https://daddylive.dad' );
serviceCheck( 'Newkso.ru', 'https://zekonew.newkso.ru/zeko' );
hosts.forEach( ( host ) => hostCheck( host.name, host.url ) );
/*
start web server
@@ -2525,6 +2790,14 @@ const server = http.createServer( ( req, resp ) =>
chalk.blueBright( `<version>` ), chalk.gray( ` ${ version } ` ),
chalk.blueBright( `<release>` ), chalk.gray( ` ${ envAppRelease } ` ) );
});
serverHdHomeRun.listen( `${ envHdhrPort }`, envWebIP, () =>
{
Log.ok( `core`, chalk.yellow( `[initiate]` ), chalk.white( `` ),
chalk.blueBright( `<msg>` ), chalk.gray( `Starting HDHomeRun server on` ),
chalk.blueBright( `<ipPublic>` ), chalk.whiteBright.bgBlack( ` ${ envWebIP }:${ envHdhrPort } ` ),
chalk.blueBright( `<ipDocker>` ), chalk.whiteBright.bgBlack( ` ${ envIpContainer }:${ envHdhrPort } ` ) );
});
})();
/*

1272
tvapp2/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "tvapp2",
"version": "1.5.5",
"version": "1.5.9",
"description": "This package allows you to generate M3U playlists and EPG guides from various online IPTV services.",
"author": "BinaryNinja",
"license": "MIT",
@@ -86,9 +86,9 @@
},
"devDependencies": {
"@aetherinox/noxenv": "^1.1.1",
"@types/uuid": "^10.0.0",
"@types/uuid": "^11.0.0",
"all-contributors-cli": "^6.26.1",
"uuid": "^11.1.0",
"uuid": "^13.0.0",
"env-cmd": "^10.1.0",
"eslint": "^9.28.0",
"eslint-plugin-chai-friendly": "^1.1.0",
@@ -111,6 +111,7 @@
"classes/Semaphore.js",
"classes/Storage.js",
"classes/Tuner.js",
"classes/Utils.js",
"www/index.html",
"www/hdhomerun.html",
"www/favicon.ico",

579
tvapp2/www/hdhomerun.html Normal file
View File

@@ -0,0 +1,579 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<title><%= appName %> | HDHomeRun Tuner | v<%= appVersion %></title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="robots" content="noindex, nofollow">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.5/dist/css/bootstrap.min.css" integrity="sha384-SgOJa3DmI69IUzQ2PVdRZhwQ+dy64/BUtbMJw1MZ8t5HZApcHrRKUc4W0kG879m7" crossorigin="anonymous">
<link rel="stylesheet" href="css/tvapp2.min.css">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<script src='https://cdn.jsdelivr.net/npm/bootstrap@5.3.5/dist/js/bootstrap.bundle.min.js' integrity='sha384-k6d4wzSIapyDyv1kpU366/PK5hCdSbCRGRCMv+eplOQJWyd1fbcAu9OCUj5zNLiq' crossorigin='anonymous'></script>
<script src=' https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js '></script>
<script src='js/tvapp2.min.js'></script>
</head>
<body>
<!-- Header -->
<div class="header">
<nav class="navbar sticky-top container">
<div class="brand">
<i data-bs-toggle="tooltip" title="v<%= appVersion %>" class="logo fa-sharp-duotone fa-regular fa-tv" style="--fa-primary-color: rgb(255, 255, 255); --fa-secondary-color: rgb(255, 255, 255);" aria-hidden="true"></i>
<a target="_blank" data-bs-toggle="tooltip" title="View Github Repository" class="header-name" href="<%= appUrlGithub %>">TVApp2 for Docker</a>
</div>
<div class="social">
<i id="action-health" data-bs-toggle="tooltip" title="Health" class="heart logo health fa-duotone fa-solid fa-heart" style="--fa-primary-color: rgb(255, 255, 255); --fa-secondary-color: rgb(255, 255, 255);" aria-hidden="true"></i>
<a href="javascript:runResync();"><i id="action-resync" data-bs-toggle="tooltip" title="Resync" class="restart fa-solid fa-rotate" style="--fa-primary-color: rgb(255, 255, 255); --fa-secondary-color: rgb(255, 255, 255);" aria-hidden="true"></i></a>
<a target="_blank" href="<%= appUrlDocs %>"><i data-bs-toggle="tooltip" title="Documentation" class="logo fa-duotone fa-solid fa-book-open-cover" style="--fa-primary-color: rgb(255, 255, 255); --fa-secondary-color: rgb(255, 255, 255);" aria-hidden="true"></i></a>
<a target="_blank" href="<%= appUrlGithub %>"><i data-bs-toggle="tooltip" title="Github" class="logo fa-logos fa-github" style="--fa-primary-color: rgb(255, 255, 255); --fa-secondary-color: rgb(255, 255, 255);" aria-hidden="true"></i></a>
<a target="_blank" href="<%= appUrlDiscord %>"><i data-bs-toggle="tooltip" title="Discord" class="logo fa-logos fa-discord" style="--fa-primary-color: rgb(255, 255, 255); --fa-secondary-color: rgb(255, 255, 255);" aria-hidden="true"></i></a>
</div>
</nav>
</div>
<!-- Header Notification: description -->
<div class="container">
<div class="introduction">
<div class="row">
<div class="col">
<div class="introduction-body">
<div class="desc">
<div class="about" style="font-size: 13px;"><code>HDHomeRun</code> is a network-attached digital television tuner box, produced by the company SiliconDust USA, Inc. Self-hosted multimedia applications such as Jellyfin allow for you to add IPTV channels either using a <code>M3U8 tuner</code>, or also with the option of specifying a <code>HDHomeRun</code> tuner.</div>
<div class="about" style="font-size: 13px;">The TVApp2 app allows you to host your own HDHomeRun tuner, and then utilize this tuner within apps like Jellyfin in order to stream IPTV using the integrated server. Your HDHomeRun tuner settings are provided below:</div>
</div>
</div>
</div>
</div>
</div>
<!-- Header Fontawesome Icons -->
<div class="container main-container">
<table id="list" class="table table-dark table-striped" style="width:60%; margin: 0 auto;">
<thead>
<tr class="d-none d-md-table-row">
<th class="file cell-file">
Property
</th>
<th class="link cell-link">
Value
</th>
<th class="desc cell-desc">
Description
</th>
</tr>
</thead>
<tbody>
<tr>
<td class="file cell-file" style="color: #919191;font-weight: 400;">
FriendlyName
</td>
<td class="link cell-link"><%= friendlyName %></td>
<td class="desc cell-desc">Name of tuner</td>
</tr>
<tr>
<td class="file cell-file" style="color: #919191;font-weight: 400;">
ModelNumber
</td>
<td class="link cell-link"><%= modelNumber %></td>
<td class="desc cell-desc">Virtual tuner model number</td>
</tr>
<tr>
<td class="file cell-file" style="color: #919191;font-weight: 400;">
FirmwareName
</td>
<td class="link cell-link"><%= firmwareName %></td>
<td class="desc cell-desc">Firmware name for tuner</td>
</tr>
<tr>
<td class="file cell-file" style="color: #919191;font-weight: 400;">
FirmwareVersion
</td>
<td class="link cell-link"><%= firmwareVersion %></td>
<td class="desc cell-desc">Firmware version running on tuner</td>
</tr>
<tr>
<td class="file cell-file" style="color: #919191;font-weight: 400;">
DeviceID
</td>
<td class="link cell-link"><%= deviceId %></td>
<td class="desc cell-desc">Tuner device id</td>
</tr>
<tr>
<td class="file cell-file" style="color: #919191;font-weight: 400;">
TunerCount
</td>
<td class="link cell-link"><%= slotsConnected %> / <%= slotsMax %></td>
<td class="desc cell-desc">Number of connection slots to view IPTV</td>
</tr>
<tr>
<td class="file cell-file" style="color: #919191;font-weight: 400;">
BaseURL
</td>
<td class="link cell-link"><a href="https://<%= hdhrIp %>:<%= hdhrPort %>" id="m3u-link" target="_blank"><%= hdhrIp %>:<%= hdhrPort %></a></td>
<td class="desc cell-desc">Base URL where HDHomeRun is hosted</td>
</tr>
<tr>
<td class="file cell-file" style="color: #919191;font-weight: 400;">
LineupURL
</td>
<td class="link cell-link"><a href="https://<%= hdhrIp %>:<%= hdhrPort %>/lineup.json" id="m3u-link" target="_blank"><%= hdhrIp %>:<%= hdhrPort %>/lineup.json</a></td>
<td class="desc cell-desc">URL to IPTV channel & guide lineups</td>
</tr>
<tr>
<td class="file cell-file" style="color: #919191;font-weight: 400;">
Uptime
</td>
<td class="link cell-link"><%= appUptimeFull %></td>
<td class="desc cell-desc">Duration that tuner has been online</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Footer -->
<footer class="footer">
<div class="container notifications" style="padding-bottom:20px;">
<div id="ntfy-restart" class="ntfy-parent indicator-success sticky-bottom"></div>
<div id="ntfy-firewall" class="ntfy-parent indicator-warning sticky-bottom"></div>
<div id="ntfy-localhost" class="ntfy-parent indicator-danger sticky-bottom"></div>
</div>
<div class="sub">
<div class="container">
<div class="col text-center text-muted text-small text-nowrap">
<small>Developed by BinaryNinja - <a data-bs-toggle="tooltip" title="v<%= appVersion %> <%= appRelease %> (<%= appGitHashShort %>)" href="<%= appUrlGithub %>"><%= appName %> (<%= appRelease %>)</a> v<%= appVersion %> <a target="_blank" data-bs-toggle="tooltip" title="View Github commit" href="<%= appUrlGithub %>/commit/<%= appGitHashLong %>"><%= appGitHashShort %></a></small><br />
<span class="footer-sub"><small>Uptime <a id="uptime" href="" data-bs-toggle="tooltip" title="<%= appUptimeLong %>"> <%= appUptimeShort %> </a> | Startup <a id="startup" data-bs-toggle="tooltip" title="Startup time" href=""><%= appStartup %>s</a> | OS <a id="os" href="" data-bs-toggle="tooltip" title="Server operating system" href=""><%= serverOs %></a></small></span>
</div>
</div>
</div>
</footer>
<!-- Toast Notifications -->
<!-- <button type="button" class="btn btn-primary" id="btnTestToasts">Show toast</button> -->
<div style="z-index: 9999;" class="toast position-fixed bottom-0 end-0 p-8 m-3" id="tvapp2Toast" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="true" data-bs-delay="10000">
<div class="toast-body">
<div class="d-flex gap-4">
<span><i class="fa-solid fa-circle-check fa-lg icon-success"></i></span>
<div class="d-flex flex-column flex-grow-1 gap-2">
<div class="d-flex align-items-center">
<span id="toast-title" class="fw-semibold">Toast Title</span>
<button type="button" class="btn-close btn-close-sm ms-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<span id="toast-message">Dismiss in 6 seconds</span>
</div>
</div>
</div>
</div>
<!-- Modal -->
<div class="modal fade" id="modalTvapp2" tabindex="-1" data-bs-backdrop="static" aria-labelledby="modalTvapp2Label" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="modalTvapp2Label">Modal title</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
...
</div>
<div class="modal-footer">
<button type="button" id="btn-secondary" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" id="btn-primary" class="btn btn-primary">Save changes</button>
</div>
</div>
</div>
</div>
<script>
/*
this is test code. enable the "Show Toast" button and then uncomment this code.
document.getElementById("btnTestToasts").onclick = function()
{
var toastElList = [].slice.call(document.querySelectorAll('.toast'))
var toastList = toastElList.map(function(toastEl)
{
return new bootstrap.Toast(toastEl)
});
toastList.forEach(toast => toast.show());
console.log(toastList);
};
*/
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
})
const urlBase = window.location.origin;
const urlM3U = urlBase + '/playlist';
const urlXML = urlBase + '/epg';
const urlGZP = urlBase + '/gzip';
</script>
<script>
/*
Document Ready
*/
$(function(){
$("[data-bs-toggle=tooltip]").tooltip({ placement: 'bottom'});
});
/*
Action > DOM Status
*/
document.addEventListener("DOMContentReady", function() {
$("#tvapp2Toast").toast();
});
/*
document.addEventListener("DOMContentLoaded", function() {
$('#tvapp2Toast').toast("show");
});
*/
/*
Notify > Localhost
*/
document.addEventListener( 'DOMContentLoaded', function()
{
const host = window.location.hostname;
const port = window.location.port || (window.location.protocol === 'https:' ? '443' : '80');
if (host === 'localhost' || host === '127.0.0.1')
{
const msg = "<div class='ntfy-child'><span class='danger'>Danger</span> \
<span class='msg'> \
If accessing this page via 127.0.0.1 / localhost, proxying will not work on other devices. Load this page using \
your computer's IP address (e.g., 192.168.x.x) and port to access the playlist from other devices on your network. \
<br> \
Learn how to locate your IP address on <a href='https://youtube.com/watch?v=UAhDHXN2c6E' target = '_blank' > Windows</a> \
or <a href='https://youtube.com/watch?v=gaIYP4TZfHI' target = '_blank' > Linux</a>.\
</span></div>";
document.getElementById( 'ntfy-localhost' ).innerHTML = msg;
document.getElementById( 'ntfy-localhost' ).style.display = 'block';
} else {
document.getElementById( 'ntfy-localhost' ).style.display = 'none';
}
});
/*
Notify > Firewall
*/
document.addEventListener( 'DOMContentLoaded', function()
{
const port = window.location.port || (window.location.protocol === 'https:' ? '443' : '80');
const msg = "<div class='ntfy-child'><span class='warning'>Warning</span> \
<span class='msg'> \
Port <strong> " + port + " </strong> must be open and allowed through your \
<a href='https://youtu.be/zOZWlTplrcA?si=nGXrHKU4sAQsy18e&t=18 target='_blank'>Windows</a> \
or \
<a href='https://youtu.be/7c_V_3nWWbA?si=Hkd_II9myn-AkNnS&t=12' target='_blank'>Linux</a> \
OS firewall settings. This action enables devices such as Firestick or Android to connect \
to the server and request the playlist through the proxy. \
</span></div>";
document.getElementById( 'ntfy-firewall' ).innerHTML = msg;
document.getElementById( 'ntfy-firewall' ).style.display = 'block';
});
/*
Notify > Restart / Resync
*/
document.addEventListener( 'DOMContentLoaded', function()
{
const port = window.location.port || (window.location.protocol === 'https:' ? '443' : '80');
const msg = "<div class='ntfy-child'><span class='success'>Success</span> \
<span class='msg'> \
Your IPTV m3u channels and xml guide data has been successfully re-synced. \
Please refresh this window to see new data \
</span></div>";
document.getElementById( 'ntfy-restart' ).innerHTML = msg;
document.getElementById( 'ntfy-restart' ).style.display = 'none';
});
/*
Set initial health check sync time
first health check runs after 10 seconds
all future health checks run after <%= healthTimer %>
*/
let timerDelayMS = 10000;
let timerStartMS = Date.now(); // returns milliseconds
const timerHealthRun = '<%= healthTimer %>'; // time in milliseconds until health check ran AFTER initial run
const timerUptime = 1000;
/*
Action > Healthcheck
*/
function runHealthCheck()
{
const toastTypeClass = [];
toastTypeClass[ 'DEFAULT' ] = 'text-bg-primary';
toastTypeClass[ 'UNHEALTHY' ] = 'text-bg-warning';
toastTypeClass[ 'HEALTHY' ] = 'text-bg-success';
toastTypeClass[ 'ERROR' ] = 'text-bg-danger';
$.ajax(
{
url: 'api/health',
type: 'GET',
data: {
query: 'healthcheck',
silent: false
},
beforeSend: function( data )
{
console.log( 'Sending health check ...' )
},
success: function( data )
{
const status = data.message;
const code = data.code;
if ( status )
{
const toastClass = toastTypeClass[status.toUpperCase()];
const toastElm = document.getElementById('tvapp2Toast');
toastElm.classList.add(toastClass);
$('.toast #toast-title').html(`<%= appName %> is ${ status }`);
$('.toast #toast-message').html(`Health check returned ${ status } (${ code })`);
$('#tvapp2Toast').toast('show');
const elementsList = document.querySelectorAll( '#ntfy-firewall, #ntfy-localhost, #ntfy-restart' );
const elementsArray = [...elementsList];
elementsArray.forEach(element =>
{
element.style.transition = '1s';
element.style.opacity = '0';
element.style.visibility = 'hidden';
});
}
},
error: function( data )
{
const toastClass = toastTypeClass['ERROR'];
const toastElm = document.getElementById('tvapp2Toast');
toastElm.classList.add(toastClass);
$('.toast #toast-title').html(`Could not connect to health check api`);
$('.toast #toast-message').html(`Failed to communicate with health check api. Try restarting the docker container to restore connection.`);
$('#tvapp2Toast').toast('show');
}
}).always(function()
{
timerDelayMS = parseInt(timerHealthRun);
timerStartMS = Date.now();
setTimeout(function()
{
runHealthCheck();
}, parseInt(timerHealthRun));
}).responseText;
}
function runUptime()
{
const toastTypeClass = [];
toastTypeClass[ 'DEFAULT' ] = 'text-bg-primary';
toastTypeClass[ 'UNHEALTHY' ] = 'text-bg-warning';
toastTypeClass[ 'HEALTHY' ] = 'text-bg-success';
toastTypeClass[ 'ERROR' ] = 'text-bg-danger';
$.ajax(
{
url: 'api/health',
type: 'GET',
data: {
query: 'uptime',
silent: true
},
success: function( data )
{
const status = data.message;
const code = data.code;
const uptimeShort = data.uptimeShort;
const uptimeLong = data.uptimeLong;
if ( status )
{
$('a#uptime').text(`${ uptimeShort }`);
const tooltip = bootstrap.Tooltip.getInstance('#uptime') // Returns a Bootstrap tooltip instance
tooltip.setContent( { '.tooltip-inner': `HDHomeRun server started ${ uptimeLong }` } )
}
},
error: function( data )
{
const toastClass = toastTypeClass['ERROR'];
const toastElm = document.getElementById('tvapp2Toast');
toastElm.classList.add(toastClass);
$('.toast #toast-title').html(`Could not get uptime from api`);
$('.toast #toast-message').html(`Failed to communicate with the api. Try restarting the docker container to restore connection.`);
$('#tvapp2Toast').toast('show');
}
}).always(function()
{
setTimeout(function()
{
runUptime();
}, parseInt(timerUptime));
}).responseText;
}
/*
Action > Do Resync
*/
function runResync()
{
$.ajax(
{
url: 'api/restart',
type: 'GET',
data: {
query: 'sync',
silent: false
},
beforeSend: function( data )
{
const dimmer = document.createElement('div');
dimmer.setAttribute('id', 'dimmer');
dimmer.style.visibility = 'visible';
dimmer.classList.add('dimmer-in');
document.getElementsByTagName('body')[0].appendChild(dimmer);
document.getElementById('ntfy-firewall').style.display = 'none';
document.getElementById('ntfy-localhost').style.display = 'none';
document.getElementById('ntfy-restart').style.display = 'none';
const iconResync = document.getElementsByClassName('fa-rotate');
iconResync[0].classList.remove('restart');
iconResync[0].classList.add('spin');
$('.modal-content .modal-body').html('<small>The M3U and EPG data will now be re-downloaded and synced with your TVApp2 container. Afterward, this page will be refreshed automatically.</small><br /><br /><small>Please wait...</small>')
$('.modal-content .modal-title').html('Resyncing Data')
$('#modalTvapp2').modal('show');
const modalBtnPrimary = document.querySelector('#btn-primary');
modalBtnPrimary.style.display = 'none';
modalBtnPrimary.style.visibility= 'hidden';
},
success: function( data )
{
/*
On successful restart, wait 1 second, remove dimmer, reload page in 5 seconds
*/
setTimeout( () =>
{
document.getElementById('ntfy-restart').style.display = 'block'
const dimmer = document.getElementById('dimmer');
dimmer.classList.remove('dimmer-in');
dimmer.classList.add('dimmer-out');
dimmer.remove();
setTimeout( function()
{
const iconResync = document.getElementsByClassName('fa-rotate'); // resync favicon
iconResync[0].classList.remove('spin'); // stop spinning
iconResync[0].classList.add('restart'); // normal spinner class
document.location.reload() // reload page
}, 5000 ); // how long until refresh page
}, 1000 ); // how long until dimmer is removed / reload page activated (also on delay)
}
});
}
/*
Health check > Show time remaining as tooltip
*/
function runTooltipCountdown( )
{
let timerHours, timerMins, timerRemainsLS;
function twoDigits( n )
{
return (n <= 9 ? "0" + n : n);
}
/*
Update Tooltip Countdown
MS = milliseconds
LS = long string (Wed Dec 31 1969 10:01:42 (Coordinated Universal Time))
*/
function updateTooltipCountdown()
{
const timerElapsedMS = Date.now() - timerStartMS; // ( 2091 )
const timerRemainsMS = timerDelayMS - timerElapsedMS; // ( 7909 ) divide by 1000 for seconds
timerRemainsLS = new Date( timerRemainsMS ); // (Wed Dec 31 1969 10:01:42 (Coordinated Universal Time))
timerHours = timerRemainsLS.getUTCHours(); // ( 0 )
timerMins = timerRemainsLS.getUTCMinutes(); // ( 9 )
const timeLeft = (timerHours ? timerHours + ':' + twoDigits( timerMins ) : timerMins) + ':' + twoDigits( timerRemainsLS.getUTCSeconds() );
jQuery(function($)
{
const tooltip = bootstrap.Tooltip.getInstance('#action-health') // Returns a Bootstrap tooltip instance
tooltip.setContent({ '.tooltip-inner': `Health check in ${ timeLeft }` })
});
const Heart = document.getElementsByClassName('fa-heart');
Heart[0].style.color = '#FFF';
setTimeout( function()
{
const Heart = document.getElementsByClassName('fa-heart');
Heart[0].style.color = '#FFF';
setTimeout( function()
{
Heart[0].style.color = '#FF6593';
}, timerRemainsLS.getUTCMilliseconds() + 100 );
}, timerRemainsLS.getUTCMilliseconds() + 500 );
setTimeout( function()
{
updateTooltipCountdown();
}, timerRemainsLS.getUTCMilliseconds() + 500 );
}
updateTooltipCountdown();
}
/*
Action > Healthcheck > Initialize
*/
setTimeout( function() { runHealthCheck(); }, timerDelayMS );
setTimeout( function() { runUptime(); }, 1000 );
/*
Action > Tooltip Resync Timers
*/
runTooltipCountdown( );
</script>
</body>
</html>

View File

@@ -318,10 +318,9 @@
const msg = "<div class='ntfy-child'><span class='danger'>Danger</span> \
<span class='msg'> \
If you are accessing this page via 127.0.0.1 or localhost, proxying will not work on other devices.Please load \
this page using your computer's IP address (e.g., 192.168.x.x) and port in order to access the playlist from other \
devices on your network. \
<br> <br> \
If accessing this page via 127.0.0.1 / localhost, proxying will not work on other devices. Load this page using \
your computer's IP address (e.g., 192.168.x.x) and port to access the playlist from other devices on your network. \
<br> \
Learn how to locate your IP address on <a href='https://youtube.com/watch?v=UAhDHXN2c6E' target = '_blank' > Windows</a> \
or <a href='https://youtube.com/watch?v=gaIYP4TZfHI' target = '_blank' > Linux</a>.\
</span></div>";